Merge pull request #2302 from Wolfsblvt/world-override-failsafe

Implement failsafe for world creation with same name
This commit is contained in:
Cohee
2024-05-23 02:11:54 +03:00
committed by GitHub
5 changed files with 218 additions and 72 deletions

View File

@ -4,7 +4,6 @@ import {
characterGroupOverlay,
callPopup,
characters,
deleteCharacter,
event_types,
eventSource,
getCharacters,
@ -13,6 +12,7 @@ import {
buildAvatarList,
characterToEntity,
printCharactersDebounced,
deleteCharacter,
} from '../script.js';
import { favsToHotswap } from './RossAscends-mods.js';
@ -115,24 +115,7 @@ class CharacterContextMenu {
static delete = async (characterId, deleteChats = false) => {
const character = CharacterContextMenu.#getCharacter(characterId);
return fetch('/api/characters/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ avatar_url: character.avatar, delete_chats: deleteChats }),
cache: 'no-cache',
}).then(response => {
if (response.ok) {
eventSource.emit(event_types.CHARACTER_DELETED, { id: characterId, character: character });
return deleteCharacter(character.name, character.avatar, false).then(() => {
if (deleteChats) getPastCharacterChats(characterId).then(pastChats => {
for (const chat of pastChats) {
const name = chat.file_name.replace('.jsonl', '');
eventSource.emit(event_types.CHAT_DELETED, name);
}
});
});
}
});
await deleteCharacter(character.avatar, { deleteChats: deleteChats });
};
static #getCharacter = (characterId) => characters[characterId] ?? null;

View File

@ -1,5 +1,5 @@
import { getContext } from './extensions.js';
import { getRequestHeaders } from '../script.js';
import { callPopup, getRequestHeaders } from '../script.js';
import { isMobile } from './RossAscends-mods.js';
import { collapseNewlines } from './power-user.js';
import { debounce_timeout } from './constants.js';
@ -1021,6 +1021,36 @@ export function extractDataFromPng(data, identifier = 'chara') {
}
}
/**
* Sends a request to the server to sanitize a given filename
*
* @param {string} fileName - The name of the file to sanitize
* @returns {Promise<string>} A Promise that resolves to the sanitized filename if successful, or rejects with an error message if unsuccessful
*/
export async function getSanitizedFilename(fileName) {
try {
const result = await fetch('/api/files/sanitize-filename', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
fileName: fileName,
}),
});
if (!result.ok) {
const error = await result.text();
throw new Error(error);
}
const responseData = await result.json();
return responseData.fileName;
} catch (error) {
toastr.error(String(error), 'Could not sanitize fileName');
console.error('Could not sanitize fileName', error);
throw error;
}
}
/**
* Sends a base64 encoded image to the backend to be saved as a file.
*
@ -1469,23 +1499,47 @@ export function flashHighlight(element, timespan = 2000) {
setTimeout(() => element.removeClass('flash animated'), timespan);
}
/**
* A common base function for case-insensitive and accent-insensitive string comparisons.
*
* @param {string} a - The first string to compare.
* @param {string} b - The second string to compare.
* @param {(a:string,b:string)=>boolean} comparisonFunction - The function to use for the comparison.
* @returns {*} - The result of the comparison.
*/
export function compareIgnoreCaseAndAccents(a, b, comparisonFunction) {
if (!a || !b) return comparisonFunction(a, b); // Return the comparison result if either string is empty
// Normalize and remove diacritics, then convert to lower case
const normalizedA = a.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const normalizedB = b.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
// Check if the normalized strings are equal
return comparisonFunction(normalizedA, normalizedB);
}
/**
* Performs a case-insensitive and accent-insensitive substring search.
* This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents.
*
* @param {string} text - The text in which to search for the substring.
* @param {string} searchTerm - The substring to search for in the text.
* @returns {boolean} - Returns true if the searchTerm is found within the text, otherwise returns false.
* @param {string} text - The text in which to search for the substring
* @param {string} searchTerm - The substring to search for in the text
* @returns {boolean} true if the searchTerm is found within the text, otherwise returns false
*/
export function includesIgnoreCaseAndAccents(text, searchTerm) {
if (!text || !searchTerm) return false; // Return false if either string is empty
return compareIgnoreCaseAndAccents(text, searchTerm, (a, b) => a?.includes(b) === true);
}
// Normalize and remove diacritics, then convert to lower case
const normalizedText = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const normalizedSearchTerm = searchTerm.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
// Check if the normalized text includes the normalized search term
return normalizedText.includes(normalizedSearchTerm);
/**
* Performs a case-insensitive and accent-insensitive equality check.
* This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents.
*
* @param {string} a - The first string to compare
* @param {string} b - The second string to compare
* @returns {boolean} true if the strings are equal, otherwise returns false
*/
export function equalsIgnoreCaseAndAccents(a, b) {
return compareIgnoreCaseAndAccents(a, b, (a, b) => a === b);
}
/**
@ -1666,3 +1720,38 @@ export function highlightRegex(regexStr) {
return `<span class="regex-highlight">${regexStr}</span>`;
}
/**
* Confirms if the user wants to overwrite an existing data object (like character, world info, etc) if one exists.
* If no data with the name exists, this simply returns true.
*
* @param {string} type - The type of the check ("World Info", "Character", etc)
* @param {string[]} existingNames - The list of existing names to check against
* @param {string} name - The new name
* @param {object} options - Optional parameters
* @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when needing to overwrite an existing data object
* @param {string} [options.actionName='overwrite'] - The action name to display in the confirmation dialog
* @param {(existingName:string)=>void} [options.deleteAction=null] - Optional action to execute wen deleting an existing data object on overwrite
* @returns {Promise<boolean>} True if the user confirmed the overwrite or there is no overwrite needed, false otherwise
*/
export async function checkOverwriteExistingData(type, existingNames, name, { interactive = false, actionName = 'Overwrite', deleteAction = null } = {}) {
const existing = existingNames.find(x => equalsIgnoreCaseAndAccents(x, name));
if (!existing) {
return true;
}
const overwrite = interactive ? await callPopup(`<h3>${type} ${actionName}</h3><p>A ${type.toLowerCase()} with the same name already exists:<br />${existing}</p>Do you want to overwrite it?`, 'confirm') : false;
if (!overwrite) {
toastr.warning(`${type} ${actionName.toLowerCase()} cancelled. A ${type.toLowerCase()} with the same name already exists:<br />${existing}`, `${type} ${actionName}`, { escapeHtml: false });
return false;
}
toastr.info(`Overwriting Existing ${type}:<br />${existing}`, `${type} ${actionName}`, { escapeHtml: false });
// If there is an action to delete the existing data, do it, as the name might be slightly different so file name would not be the same
if (deleteAction) {
deleteAction(existing);
}
return true;
}

View File

@ -1,5 +1,5 @@
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 } from './utils.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';
@ -50,6 +50,7 @@ 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
@ -2544,9 +2545,15 @@ async function renameWorldInfo(name, data) {
}
}
/**
* 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;
return false;
}
const response = await fetch('/api/worldinfo/delete', {
@ -2555,24 +2562,28 @@ async function deleteWorldInfo(worldInfoName) {
body: JSON.stringify({ name: worldInfoName }),
});
if (response.ok) {
const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName);
if (existingWorldIndex !== -1) {
selected_world_info.splice(existingWorldIndex, 1);
saveSettingsDebounced();
}
if (!response.ok) {
return false;
}
await updateWorldInfoList();
$('#world_editor_select').trigger('change');
const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName);
if (existingWorldIndex !== -1) {
selected_world_info.splice(existingWorldIndex, 1);
saveSettingsDebounced();
}
if ($('#character_world').val() === worldInfoName) {
$('#character_world').val('').trigger('change');
setWorldInfoButtonClass(undefined, false);
if (menu_type != 'create') {
saveCharacterDebounced();
}
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) {
@ -2604,22 +2615,40 @@ function getFreeWorldName() {
return undefined;
}
async function createNewWorldInfo(worldInfoName) {
/**
* 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 (!worldInfoName) {
return;
if (!worldName) {
return false;
}
await saveWorldInfo(worldInfoName, worldInfoTemplate, true);
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(worldInfoName);
const selectedIndex = world_names.indexOf(worldName);
if (selectedIndex !== -1) {
$('#world_editor_select').val(selectedIndex).trigger('change');
} else {
hideWorldEditor();
}
return true;
}
async function getCharacterLore() {
@ -3552,6 +3581,13 @@ export async function importWorldInfo(file) {
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',
@ -3569,7 +3605,7 @@ export async function importWorldInfo(file) {
$('#world_editor_select').val(newIndex).trigger('change');
}
toastr.info(`World Info "${data.name}" imported successfully!`);
toastr.success(`World Info "${data.name}" imported successfully!`);
}
},
error: (_jqXHR, _exception) => { },
@ -3644,7 +3680,7 @@ jQuery(() => {
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);
await createNewWorldInfo(finalName, { interactive: true });
}
});