Allow create new world books via `/getpersonabook` and `/getcharbook` (#4597) * feat: allow create new world books via /getpersonabook and /getcharbook Refactor character world info management into dedicated functions Extract character world assignment logic from UI handlers into reusable functions Add support for automatic lorebook creation in slash commands Improve code maintainability by centralizing world info operations * refactor: update boolean enum provider from onOffToggle to trueFalse in world info commands * fix: improve world info name uniqueness check using getUniqueName utility * Unset current persona WI on file deletion --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

a65f708fd81817783a6a50e1ba2d9448ab4cc03d

Wolfsblvt <wolfsblvt@gmail.com>

Signed
4 files changed, +227 -79Showing whitespace changes
public/script.js+11 -54
@@ -46,6 +46,8 @@ import {
4646 wi_anchor_position,
4747 world_info_include_names,
4848 initWorldInfo,
49+ charUpdatePrimaryWorld,
50+ charSetAuxWorlds,
4951} from './scripts/world-info.js';
5052
5153import {
@@ -8151,61 +8153,16 @@ async function openCharacterWorldPopup() {
81518153 const selectedValue = $(this).val();
81528154 const worldIndex = selectedValue !== '' ? Number(selectedValue) : NaN;
81538155 const name = !isNaN(worldIndex) ? world_names[worldIndex] : '';
8154- const previousValue = $('#character_world').val();
8156+ await charUpdatePrimaryWorld(name);
8155- $('#character_world').val(name);
8156-
8157- console.debug('Character world selected:', name);
8158-
8159- if (menu_type == 'create') {
8160- create_save.world = name;
8161- } else {
8162- if (previousValue && !name) {
8163- try {
8164- // Dirty hack to remove embedded lorebook from character JSON data.
8165- const data = JSON.parse(String($('#character_json_data').val()));
8166-
8167- if (data?.data?.character_book) {
8168- data.data.character_book = undefined;
8169- }
8170-
8171- $('#character_json_data').val(JSON.stringify(data));
8172- toastr.info(t`Embedded lorebook will be removed from this character.`);
8173- } catch {
8174- console.error('Failed to parse character JSON data.');
8175- }
8176- }
8177-
8178- await createOrEditCharacter();
81798157 }
81808158
8181- setWorldInfoButtonClass(undefined, !!name);
8159+ function handleExtrasWorldSelect(evt) {
8182- }
8160+ const el = evt?.currentTarget ?? this;
8183-
8161+ const selectedValues = $(el).val();
8184- function handleExtrasWorldSelect() {
8162+ const selected = Array.isArray(selectedValues) ? selectedValues : [];
81858163 const selectedValuesfileName = $(this).valgetCharaFilename(null, {});
81868164 const selectedWorldsnextList = Arrayselected.isArraymap(selectedValues) ? selectedValuesi :=> world_names[i]).filter(Boolean);
8187- let charLore = world_info.charLore ?? [];
8165+ charSetAuxWorlds(fileName, nextList);
8188- const tempExtraBooks = selectedWorlds.map((index) => world_names[index]).filter(Boolean);
8189- const existingCharIndex = charLore.findIndex((e) => e.name === fileName);
8190-
8191- if (menu_type == 'create') {
8192- create_save.extra_books = tempExtraBooks;
8193- return;
8194- }
8195-
8196- if (existingCharIndex === -1) {
8197- // Add record only if at least 1 lorebook is selected.
8198- if (tempExtraBooks.length > 0) {
8199- charLore.push({ name: fileName, extraBooks: tempExtraBooks });
8200- }
8201- } else if (tempExtraBooks.length === 0) {
8202- charLore.splice(existingCharIndex, 1);
8203- } else {
8204- charLore[existingCharIndex].extraBooks = tempExtraBooks;
8205- }
8206-
8207- Object.assign(world_info, { charLore: charLore });
8208- saveSettingsDebounced();
82098166 }
82108167
82118168 // --- Populate Dropdowns ---
@@ -8328,7 +8285,7 @@ function addAlternateGreeting(template, greeting, index, getArray, popup) {
83288285 * Creates or edits a character based on the form data.
83298286 * @param {Event} [e] Event that triggered the function call.
83308287 */
83318288export async function createOrEditCharacter(e) {
83328289 $('#rm_info_avatar').html('');
83338290 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
83348291 formData.set('fav', String(fav_ch_checked));
public/scripts/personas.js+1 -1
@@ -1219,7 +1219,7 @@ function onPersonaDescriptionPositionInput() {
12191219 $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
12201220}
12211221
12221222export function getOrCreatePersonaDescriptor() {
12231223 let object = power_user.persona_descriptions[user_avatar];
12241224
12251225 if (!object) {
public/scripts/utils.js+10 -1
@@ -289,6 +289,15 @@ export function removeFromArray(array, item) {
289289}
290290
291291/**
292+ * Normalizes an array by removing duplicates, trimming strings, and filtering out empty values.
293+ * @param {any[]} arr - The array to normalize.
294+ * @returns {any[]} The normalized array.
295+ */
296+export function normalizeArray(arr) {
297+ return [...new Set((arr ?? []).map(s => typeof s === 'string' ? s.trim() : s).filter(Boolean))];
298+}
299+
300+/**
292301 * Checks if a string only contains digits.
293302 * @param {string} str The string to check.
294303 * @returns {boolean} True if the string only contains digits, false otherwise.
@@ -620,7 +629,7 @@ export function isElementInViewport(el) {
620629/**
621630 * Returns a name that is unique among the names that exist.
622631 * @param {string} name The name to check.
623632 * @param {{ (yname: anystring): boolean; }} exists Function to check if name exists.
624633 * @returns {string} A unique name.
625634 */
626635export function getUniqueName(name, exists) {
public/scripts/world-info.js+205 -23
@@ -1,7 +1,7 @@
11import { Fuse } from '../lib.js';
22
33import { saveSettings, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles, create_save, createOrEditCharacter, name1 } from '../script.js';
44import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce, findChar, onlyUnique, equalsIgnoreCaseAndAccents, uuidv4, normalizeArray, getUniqueName } from './utils.js';
55import { extension_settings, getContext } from './extensions.js';
66import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
77import { isMobile } from './RossAscends-mods.js';
@@ -22,6 +22,7 @@ import { StructuredCloneMap } from './util/StructuredCloneMap.js';
2222import { renderTemplateAsync } from './templates.js';
2323import { t } from './i18n.js';
2424import { accountStorage } from './util/AccountStorage.js';
25+import { getOrCreatePersonaDescriptor, setPersonaDescription, user_avatar } from './personas.js';
2526
2627export const world_info_insertion_strategy = {
2728 evenly: 0,
@@ -1041,39 +1042,69 @@ function registerWorldInfoSlashCommands() {
10411042
10421043 /**
10431044 * Gets the name of the persona-bound lorebook.
1044- * @returns {string} The name of the persona-bound lorebook
1045+ * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments
1046+ * @param {string} _unnamedArg not used
1047+ * @returns {Promise<string>} The name of the persona-bound lorebook
10451048 */
10461049 async function getPersonaBookCallback({ name, create }, _unnamedArg) {
10471050 returnlet bookName = power_user.persona_description_lorebook || '';
1051+ if (bookName) {
1052+ return bookName;
1053+ }
1054+
1055+ if (isTrueBoolean(String(create))) {
1056+ const newName = await createWorldWithName(name, `Persona Book ${name1}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64));
1057+ power_user.persona_description_lorebook = newName;
1058+ setPersonaDescription();
1059+ saveSettingsDebounced();
1060+ return newName;
1061+ }
1062+
1063+ return '';
10481064 }
10491065
10501066 /**
10511067 * Gets the name of the character-bound lorebook.
10521068 * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments
10531069 * @param {string} namecharacterIdentifier Character name
10541070 * @returns {Promise<string>} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string
10551071 */
10561072 async function getCharBookCallback({ type, name, create }, namecharacterIdentifier) {
10571073 const context = getContext();
10581074 if (context.groupId && !namecharacterIdentifier) throw new Error('This command is not available in groups without providing a character name');
10591075 type = String(type ?? '').trim().toLowerCase() || 'primary';
10601076 namecharacterIdentifier = String(namecharacterIdentifier ?? '') || context.characters[context.characterId]?.avatar || null;
10611077 const character = findChar({ name: characterIdentifier });
10621078 if (!character) {
10631079 toastr.error(t`Character not found.`);
10641080 return '';
10651081 }
10661082 const books = [];
10671083 if (type === 'all' || type === 'primary' && character.data?.extensions?.world) {
10681084 books.push(character.data?.extensions?.world);
10691085 }
10701086 if (type === 'all' || type === 'additional') {
10711087 const fileName = getCharaFilename(context.characters.indexOf(character));
10721088 const extraCharLore = world_info.charLore?.find((e) => e.name === fileName);
10731089 if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) {
10741090 books.push(...extraCharLore.extraBooks.filter(onlyUnique).filter(Boolean));
1091+ }
1092+ }
1093+
1094+ if (isTrueBoolean(String(create)) && books.length === 0) {
1095+ const newName = await createWorldWithName(name, `Character Book ${character.name}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64));
1096+ // Also assign the book now - additional if requested, otherwise as primary
1097+ if (type === 'additional') {
1098+ await charUpdateAddAuxWorld(character.avatar, newName);
1099+ }
1100+ else {
1101+ await charUpdatePrimaryWorld(newName);
10751102 }
1103+ // Refresh UI, if needed
1104+ setWorldInfoButtonClass(this_chid);
1105+ books.push(newName);
10761106 }
1107+
10771108 return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean));
10781109 }
10791110
@@ -1094,10 +1125,23 @@ function registerWorldInfoSlashCommands() {
10941125 return chat_metadata[METADATA_KEY];
10951126 }
10961127
1097- const name = (() => {
1128+ if (isFalseBoolean(String(args.create))) {
1129+ return '';
1130+ }
1131+
1132+ const name = await createWorldWithName(args.name, `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9 -]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64));
1133+
1134+ chat_metadata[METADATA_KEY] = name;
1135+ await saveMetadata();
1136+ $('.chat_lorebook_button').addClass('world_set');
1137+ return name;
1138+ }
1139+
1140+ async function createWorldWithName(possibleName = undefined, fallbackName = undefined) {
1141+ let newName = (() => {
10981142 // Use the provided name if it's not in use
10991143 if (typeof args.namepossibleName === 'string') {
11001144 const name = String(args.namepossibleName);
11011145 if (world_names.includes(name)) {
11021146 throw new Error('This World Info file name is already in use');
11031147 }
@@ -1105,14 +1149,14 @@ function registerWorldInfoSlashCommands() {
11051149 }
11061150
11071151 // Replace non-alphanumeric characters with underscores, cut to 64 characters
1108- return `Chat Book ${getCurrentChatId()}`.replace(/[^a-z0-9]/gi, '_').replace(/_{2,}/g, '_').substring(0, 64);
1152+ return fallbackName ?? `Lorebook (${uuidv4()})`;
11091153 })();
1110- await createNewWorldInfo(name);
11111154
1112- chat_metadata[METADATA_KEY] = name;
1155+ // Make sure the name is unique
1113- await saveMetadata();
1156+ newName = getUniqueName(newName, world_names.includes.bind(world_names));
1114- $('.chat_lorebook_button').addClass('world_set');
1157+
1115- return name;
1158+ await createNewWorldInfo(newName);
1159+ return newName;
11161160 }
11171161
11181162 async function findBookEntryCallback(args, value) {
@@ -1549,6 +1593,15 @@ function registerWorldInfoSlashCommands() {
15491593 isRequired: false,
15501594 acceptsMultiple: false,
15511595 }),
1596+ SlashCommandNamedArgument.fromProps({
1597+ name: 'create',
1598+ description: 'create a new lorebook if it doesn\'t exist',
1599+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1600+ isRequired: false,
1601+ acceptsMultiple: false,
1602+ enumList: commonEnumProviders.boolean('trueFalse')(),
1603+ defaultValue: 'true',
1604+ }),
15521605 ],
15531606 aliases: ['getchatlore', 'getchatwi'],
15541607 }));
@@ -1563,6 +1616,25 @@ function registerWorldInfoSlashCommands() {
15631616 name: 'getpersonabook',
15641617 callback: getPersonaBookCallback,
15651618 returns: 'lorebook name',
1619+
1620+ namedArgumentList: [
1621+ SlashCommandNamedArgument.fromProps({
1622+ name: 'name',
1623+ description: 'lorebook name if creating a new one, will be auto-generated otherwise',
1624+ typeList: [ARGUMENT_TYPE.STRING],
1625+ isRequired: false,
1626+ acceptsMultiple: false,
1627+ }),
1628+ SlashCommandNamedArgument.fromProps({
1629+ name: 'create',
1630+ description: 'create a new lorebook if it doesn\'t exist',
1631+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1632+ isRequired: false,
1633+ acceptsMultiple: false,
1634+ enumList: commonEnumProviders.boolean('trueFalse')(),
1635+ defaultValue: 'false',
1636+ }),
1637+ ],
15661638 helpString: 'Get a name of the current persona-bound lorebook and pass it down the pipe. Returns empty string if persona lorebook is not set.',
15671639 aliases: ['getpersonalore', 'getpersonawi'],
15681640 }));
@@ -1578,6 +1650,22 @@ function registerWorldInfoSlashCommands() {
15781650 enumList: ['primary', 'additional', 'all'],
15791651 defaultValue: 'primary',
15801652 }),
1653+ SlashCommandNamedArgument.fromProps({
1654+ name: 'name',
1655+ description: 'lorebook name if creating a new one, will be auto-generated otherwise',
1656+ typeList: [ARGUMENT_TYPE.STRING],
1657+ isRequired: false,
1658+ acceptsMultiple: false,
1659+ }),
1660+ SlashCommandNamedArgument.fromProps({
1661+ name: 'create',
1662+ description: 'create a new lorebook if it doesn\'t exist',
1663+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1664+ isRequired: false,
1665+ acceptsMultiple: false,
1666+ enumList: commonEnumProviders.boolean('trueFalse')(),
1667+ defaultValue: 'false',
1668+ }),
15811669 ],
15821670 unnamedArgumentList: [
15831671 SlashCommandArgument.fromProps({
@@ -3995,6 +4083,16 @@ export async function deleteWorldInfo(worldInfoName) {
39954083 }
39964084 }
39974085
4086+ if (power_user.persona_description_lorebook === worldInfoName) {
4087+ power_user.persona_description_lorebook = '';
4088+ if (power_user.personas[user_avatar]) {
4089+ const object = getOrCreatePersonaDescriptor();
4090+ object.lorebook = '';
4091+ }
4092+ $('#persona_lore_button').toggleClass('world_set', false);
4093+ saveSettingsDebounced();
4094+ }
4095+
39984096 return true;
39994097}
40004098
@@ -5636,6 +5734,90 @@ export async function moveWorldInfoEntry(sourceName, targetName, uid, { deleteOr
56365734 }
56375735}
56385736
5737+
5738+/**
5739+ * Updates the primary world info linked to a character.
5740+ * Can also unset it to null.
5741+ * @param {string} name - The name of the world info to link to the character.
5742+ */
5743+export async function charUpdatePrimaryWorld(name) {
5744+ const previousValue = $('#character_world').val();
5745+ $('#character_world').val(name);
5746+
5747+ console.debug('Character world selected:', name);
5748+
5749+ if (menu_type == 'create') {
5750+ create_save.world = name;
5751+ return;
5752+ }
5753+
5754+ if (previousValue && !name) {
5755+ try {
5756+ // Dirty hack to remove embedded lorebook from character JSON data.
5757+ const data = JSON.parse(String($('#character_json_data').val()));
5758+
5759+ if (data?.data?.character_book) {
5760+ data.data.character_book = undefined;
5761+ }
5762+
5763+ $('#character_json_data').val(JSON.stringify(data));
5764+ toastr.info(t`Embedded lorebook will be removed from this character.`);
5765+ } catch {
5766+ console.error('Failed to parse character JSON data.');
5767+ }
5768+ }
5769+
5770+ await createOrEditCharacter();
5771+
5772+ setWorldInfoButtonClass(undefined, !!name);
5773+}
5774+
5775+/**
5776+ * Adds one or more auxiliary world books to a character.
5777+ * @param {string} characterKey - The key of the character to add auxiliary world books to
5778+ * @param {string|string[]} nameOrNames - The name or names of the auxiliary world books to add
5779+ */
5780+export async function charUpdateAddAuxWorld(characterKey, nameOrNames) {
5781+ const fileName = getCharaFilename(null, { manualAvatarKey: characterKey });
5782+ const toAdd = Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames];
5783+ updateAuxBooks(fileName, curr => [...curr, ...toAdd]);
5784+}
5785+
5786+/**
5787+ * Replaces the entire list of auxiliary world books for a character.
5788+ * @param {string} fileName - The filename of the character to update
5789+ * @param {string[]} books - The new list of auxiliary world books to replace the existing list with
5790+ */
5791+export function charSetAuxWorlds(fileName, books) {
5792+ updateAuxBooks(fileName, _ => Array.isArray(books) ? books : []);
5793+}
5794+
5795+function updateAuxBooks(fileName, computeNext) {
5796+ if (!fileName) return;
5797+
5798+ if (menu_type === 'create') {
5799+ const current = create_save.extra_books ?? [];
5800+ create_save.extra_books = normalizeArray(computeNext(current));
5801+ return; // no debounced save in create flow
5802+ }
5803+
5804+ const charLore = world_info.charLore ?? [];
5805+ const idx = charLore.findIndex(e => e.name === fileName);
5806+ const current = idx !== -1 ? (charLore[idx].extraBooks ?? []) : [];
5807+ const next = normalizeArray(computeNext(current));
5808+
5809+ if (next.length === 0) {
5810+ if (idx !== -1) charLore.splice(idx, 1);
5811+ } else if (idx === -1) {
5812+ charLore.push({ name: fileName, extraBooks: next });
5813+ } else {
5814+ charLore[idx] = { ...charLore[idx], extraBooks: next };
5815+ }
5816+
5817+ Object.assign(world_info, { charLore });
5818+ saveSettingsDebounced();
5819+}
5820+
56395821export function initWorldInfo() {
56405822 $('#world_info').on('mousedown change', async function (e) {
56415823 // If there's no world names, don't do anything