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>
Signed| @@ -46,6 +46,8 @@ import { | |||
| 46 | wi_anchor_position, | 46 | wi_anchor_position, |
| 47 | world_info_include_names, | 47 | world_info_include_names, |
| 48 | initWorldInfo, | 48 | initWorldInfo, |
| 49 | charUpdatePrimaryWorld, | ||
| 50 | charSetAuxWorlds, | ||
| 49 | } from './scripts/world-info.js'; | 51 | } from './scripts/world-info.js'; |
| 50 | 52 | ||
| 51 | import { | 53 | import { |
| @@ -8151,61 +8153,16 @@ async function openCharacterWorldPopup() { | |||
| 8151 | const selectedValue = $(this).val(); | 8153 | const selectedValue = $(this).val(); |
| 8152 | const worldIndex = selectedValue !== '' ? Number(selectedValue) : NaN; | 8154 | const worldIndex = selectedValue !== '' ? Number(selectedValue) : NaN; |
| 8153 | const name = !isNaN(worldIndex) ? world_names[worldIndex] : ''; | 8155 | 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(); | ||
| 8179 | } | ||
| 8180 | |||
| 8181 | setWorldInfoButtonClass(undefined, !!name); | ||
| 8182 | } | 8157 | } |
| 8183 | 8158 | ||
| 8184 | function handleExtrasWorldSelect() { | 8159 | function handleExtrasWorldSelect(evt) { |
| 8185 | const selectedValues = $(this).val(); | 8160 | const el = evt?.currentTarget ?? this; |
| 8186 | const selectedWorlds = Array.isArray(selectedValues) ? selectedValues : []; | 8161 | const selectedValues = $(el).val(); |
| 8187 | let charLore = world_info.charLore ?? []; | 8162 | const selected = Array.isArray(selectedValues) ? selectedValues : []; |
| 8188 | const tempExtraBooks = selectedWorlds.map((index) => world_names[index]).filter(Boolean); | 8163 | const fileName = getCharaFilename(null, {}); |
| 8189 | const existingCharIndex = charLore.findIndex((e) => e.name === fileName); | 8164 | const nextList = selected.map(i => world_names[i]).filter(Boolean); |
| 8190 | 8165 | charSetAuxWorlds(fileName, nextList); | |
| 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(); | ||
| 8209 | } | 8166 | } |
| 8210 | 8167 | ||
| 8211 | // --- Populate Dropdowns --- | 8168 | // --- Populate Dropdowns --- |
| @@ -8328,7 +8285,7 @@ function addAlternateGreeting(template, greeting, index, getArray, popup) { | |||
| 8328 | * Creates or edits a character based on the form data. | 8285 | * Creates or edits a character based on the form data. |
| 8329 | * @param {Event} [e] Event that triggered the function call. | 8286 | * @param {Event} [e] Event that triggered the function call. |
| 8330 | */ | 8287 | */ |
| 8331 | async function createOrEditCharacter(e) { | 8288 | export async function createOrEditCharacter(e) { |
| 8332 | $('#rm_info_avatar').html(''); | 8289 | $('#rm_info_avatar').html(''); |
| 8333 | const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0))); | 8290 | const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0))); |
| 8334 | formData.set('fav', String(fav_ch_checked)); | 8291 | formData.set('fav', String(fav_ch_checked)); |
| @@ -1219,7 +1219,7 @@ function onPersonaDescriptionPositionInput() { | |||
| 1219 | $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH); | 1219 | $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH); |
| 1220 | } | 1220 | } |
| 1221 | 1221 | ||
| 1222 | function getOrCreatePersonaDescriptor() { | 1222 | export function getOrCreatePersonaDescriptor() { |
| 1223 | let object = power_user.persona_descriptions[user_avatar]; | 1223 | let object = power_user.persona_descriptions[user_avatar]; |
| 1224 | 1224 | ||
| 1225 | if (!object) { | 1225 | if (!object) { |
| @@ -289,6 +289,15 @@ export function removeFromArray(array, item) { | |||
| 289 | } | 289 | } |
| 290 | 290 | ||
| 291 | /** | 291 | /** |
| 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 | /** | ||
| 292 | * Checks if a string only contains digits. | 301 | * Checks if a string only contains digits. |
| 293 | * @param {string} str The string to check. | 302 | * @param {string} str The string to check. |
| 294 | * @returns {boolean} True if the string only contains digits, false otherwise. | 303 | * @returns {boolean} True if the string only contains digits, false otherwise. |
| @@ -620,7 +629,7 @@ export function isElementInViewport(el) { | |||
| 620 | /** | 629 | /** |
| 621 | * Returns a name that is unique among the names that exist. | 630 | * Returns a name that is unique among the names that exist. |
| 622 | * @param {string} name The name to check. | 631 | * @param {string} name The name to check. |
| 623 | * @param {{ (y: any): boolean; }} exists Function to check if name exists. | 632 | * @param {{ (name: string): boolean; }} exists Function to check if name exists. |
| 624 | * @returns {string} A unique name. | 633 | * @returns {string} A unique name. |
| 625 | */ | 634 | */ |
| 626 | export function getUniqueName(name, exists) { | 635 | export function getUniqueName(name, exists) { |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Fuse } from '../lib.js'; | 1 | import { Fuse } from '../lib.js'; |
| 2 | 2 | ||
| 3 | import { saveSettings, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js'; | 3 | import { 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'; |
| 4 | 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, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce, findChar, onlyUnique, equalsIgnoreCaseAndAccents } from './utils.js'; | 4 | 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, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray, cancelDebounce, findChar, onlyUnique, equalsIgnoreCaseAndAccents, uuidv4, normalizeArray, getUniqueName } from './utils.js'; |
| 5 | import { extension_settings, getContext } from './extensions.js'; | 5 | import { extension_settings, getContext } from './extensions.js'; |
| 6 | import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js'; | 6 | import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js'; |
| 7 | import { isMobile } from './RossAscends-mods.js'; | 7 | import { isMobile } from './RossAscends-mods.js'; |
| @@ -22,6 +22,7 @@ import { StructuredCloneMap } from './util/StructuredCloneMap.js'; | |||
| 22 | import { renderTemplateAsync } from './templates.js'; | 22 | import { renderTemplateAsync } from './templates.js'; |
| 23 | import { t } from './i18n.js'; | 23 | import { t } from './i18n.js'; |
| 24 | import { accountStorage } from './util/AccountStorage.js'; | 24 | import { accountStorage } from './util/AccountStorage.js'; |
| 25 | import { getOrCreatePersonaDescriptor, setPersonaDescription, user_avatar } from './personas.js'; | ||
| 25 | 26 | ||
| 26 | export const world_info_insertion_strategy = { | 27 | export const world_info_insertion_strategy = { |
| 27 | evenly: 0, | 28 | evenly: 0, |
| @@ -1041,39 +1042,69 @@ function registerWorldInfoSlashCommands() { | |||
| 1041 | 1042 | ||
| 1042 | /** | 1043 | /** |
| 1043 | * Gets the name of the persona-bound lorebook. | 1044 | * 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 | ||
| 1045 | */ | 1048 | */ |
| 1046 | function getPersonaBookCallback() { | 1049 | async function getPersonaBookCallback({ name, create }, _unnamedArg) { |
| 1047 | return power_user.persona_description_lorebook || ''; | 1050 | let 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 ''; | ||
| 1048 | } | 1064 | } |
| 1049 | 1065 | ||
| 1050 | /** | 1066 | /** |
| 1051 | * Gets the name of the character-bound lorebook. | 1067 | * Gets the name of the character-bound lorebook. |
| 1052 | * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments | 1068 | * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args Named arguments |
| 1053 | * @param {string} name Character name | 1069 | * @param {string} characterIdentifier Character name |
| 1054 | * @returns {string} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string | 1070 | * @returns {Promise<string>} The name of the character-bound lorebook, a JSON string of the character's lorebooks, or an empty string |
| 1055 | */ | 1071 | */ |
| 1056 | function getCharBookCallback({ type }, name) { | 1072 | async function getCharBookCallback({ type, name, create }, characterIdentifier) { |
| 1057 | const context = getContext(); | 1073 | const context = getContext(); |
| 1058 | if (context.groupId && !name) throw new Error('This command is not available in groups without providing a character name'); | 1074 | if (context.groupId && !characterIdentifier) throw new Error('This command is not available in groups without providing a character name'); |
| 1059 | type = String(type ?? '').trim().toLowerCase() || 'primary'; | 1075 | type = String(type ?? '').trim().toLowerCase() || 'primary'; |
| 1060 | name = String(name ?? '') || context.characters[context.characterId]?.avatar || null; | 1076 | characterIdentifier = String(characterIdentifier ?? '') || context.characters[context.characterId]?.avatar || null; |
| 1061 | const character = findChar({ name }); | 1077 | const character = findChar({ name: characterIdentifier }); |
| 1062 | if (!character) { | 1078 | if (!character) { |
| 1063 | toastr.error(t`Character not found.`); | 1079 | toastr.error(t`Character not found.`); |
| 1064 | return ''; | 1080 | return ''; |
| 1065 | } | 1081 | } |
| 1066 | const books = []; | 1082 | const books = []; |
| 1067 | if (type === 'all' || type === 'primary') { | 1083 | if (type === 'all' || type === 'primary' && character.data?.extensions?.world) { |
| 1068 | books.push(character.data?.extensions?.world); | 1084 | books.push(character.data.extensions.world); |
| 1069 | } | 1085 | } |
| 1070 | if (type === 'all' || type === 'additional') { | 1086 | if (type === 'all' || type === 'additional') { |
| 1071 | const fileName = getCharaFilename(context.characters.indexOf(character)); | 1087 | const fileName = getCharaFilename(context.characters.indexOf(character)); |
| 1072 | const extraCharLore = world_info.charLore?.find((e) => e.name === fileName); | 1088 | const extraCharLore = world_info.charLore?.find((e) => e.name === fileName); |
| 1073 | if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) { | 1089 | if (extraCharLore && Array.isArray(extraCharLore.extraBooks)) { |
| 1074 | books.push(...extraCharLore.extraBooks); | 1090 | books.push(...extraCharLore.extraBooks.filter(onlyUnique).filter(Boolean)); |
| 1075 | } | 1091 | } |
| 1076 | } | 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); | ||
| 1102 | } | ||
| 1103 | // Refresh UI, if needed | ||
| 1104 | setWorldInfoButtonClass(this_chid); | ||
| 1105 | books.push(newName); | ||
| 1106 | } | ||
| 1107 | |||
| 1077 | return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean)); | 1108 | return type === 'primary' ? (books[0] ?? '') : JSON.stringify(books.filter(onlyUnique).filter(Boolean)); |
| 1078 | } | 1109 | } |
| 1079 | 1110 | ||
| @@ -1094,10 +1125,23 @@ function registerWorldInfoSlashCommands() { | |||
| 1094 | return chat_metadata[METADATA_KEY]; | 1125 | return chat_metadata[METADATA_KEY]; |
| 1095 | } | 1126 | } |
| 1096 | 1127 | ||
| 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 = (() => { | ||
| 1098 | // Use the provided name if it's not in use | 1142 | // Use the provided name if it's not in use |
| 1099 | if (typeof args.name === 'string') { | 1143 | if (typeof possibleName === 'string') { |
| 1100 | const name = String(args.name); | 1144 | const name = String(possibleName); |
| 1101 | if (world_names.includes(name)) { | 1145 | if (world_names.includes(name)) { |
| 1102 | throw new Error('This World Info file name is already in use'); | 1146 | throw new Error('This World Info file name is already in use'); |
| 1103 | } | 1147 | } |
| @@ -1105,14 +1149,14 @@ function registerWorldInfoSlashCommands() { | |||
| 1105 | } | 1149 | } |
| 1106 | 1150 | ||
| 1107 | // Replace non-alphanumeric characters with underscores, cut to 64 characters | 1151 | // 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()})`; |
| 1109 | })(); | 1153 | })(); |
| 1110 | await createNewWorldInfo(name); | ||
| 1111 | 1154 | ||
| 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; | ||
| 1116 | } | 1160 | } |
| 1117 | 1161 | ||
| 1118 | async function findBookEntryCallback(args, value) { | 1162 | async function findBookEntryCallback(args, value) { |
| @@ -1549,6 +1593,15 @@ function registerWorldInfoSlashCommands() { | |||
| 1549 | isRequired: false, | 1593 | isRequired: false, |
| 1550 | acceptsMultiple: false, | 1594 | acceptsMultiple: false, |
| 1551 | }), | 1595 | }), |
| 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 | }), | ||
| 1552 | ], | 1605 | ], |
| 1553 | aliases: ['getchatlore', 'getchatwi'], | 1606 | aliases: ['getchatlore', 'getchatwi'], |
| 1554 | })); | 1607 | })); |
| @@ -1563,6 +1616,25 @@ function registerWorldInfoSlashCommands() { | |||
| 1563 | name: 'getpersonabook', | 1616 | name: 'getpersonabook', |
| 1564 | callback: getPersonaBookCallback, | 1617 | callback: getPersonaBookCallback, |
| 1565 | returns: 'lorebook name', | 1618 | 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 | ], | ||
| 1566 | 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.', | 1638 | 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.', |
| 1567 | aliases: ['getpersonalore', 'getpersonawi'], | 1639 | aliases: ['getpersonalore', 'getpersonawi'], |
| 1568 | })); | 1640 | })); |
| @@ -1578,6 +1650,22 @@ function registerWorldInfoSlashCommands() { | |||
| 1578 | enumList: ['primary', 'additional', 'all'], | 1650 | enumList: ['primary', 'additional', 'all'], |
| 1579 | defaultValue: 'primary', | 1651 | defaultValue: 'primary', |
| 1580 | }), | 1652 | }), |
| 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 | }), | ||
| 1581 | ], | 1669 | ], |
| 1582 | unnamedArgumentList: [ | 1670 | unnamedArgumentList: [ |
| 1583 | SlashCommandArgument.fromProps({ | 1671 | SlashCommandArgument.fromProps({ |
| @@ -3995,6 +4083,16 @@ export async function deleteWorldInfo(worldInfoName) { | |||
| 3995 | } | 4083 | } |
| 3996 | } | 4084 | } |
| 3997 | 4085 | ||
| 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 | |||
| 3998 | return true; | 4096 | return true; |
| 3999 | } | 4097 | } |
| 4000 | 4098 | ||
| @@ -5636,6 +5734,90 @@ export async function moveWorldInfoEntry(sourceName, targetName, uid, { deleteOr | |||
| 5636 | } | 5734 | } |
| 5637 | } | 5735 | } |
| 5638 | 5736 | ||
| 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 | |||
| 5639 | export function initWorldInfo() { | 5821 | export function initWorldInfo() { |
| 5640 | $('#world_info').on('mousedown change', async function (e) { | 5822 | $('#world_info').on('mousedown change', async function (e) { |
| 5641 | // If there's no world names, don't do anything | 5823 | // If there's no world names, don't do anything |