diff --git a/public/script.js b/public/script.js
index c12dc9c95..75aaad7d1 100644
--- a/public/script.js
+++ b/public/script.js
@@ -187,6 +187,7 @@ import { getFriendlyTokenizerName, getTokenCount, getTokenizerModel, initTokeniz
import { initPersonas, selectCurrentPersona, setPersonaDescription } from "./scripts/personas.js";
import { getBackgrounds, initBackgrounds } from "./scripts/backgrounds.js";
import { hideLoader, showLoader } from "./scripts/loader.js";
+import {CharacterContextMenu, BulkEditOverlay} from "./scripts/BulkEditOverlay.js";
//exporting functions and vars for mods
export {
@@ -299,6 +300,9 @@ export const event_types = {
OAI_PRESET_CHANGED_AFTER: 'oai_preset_changed_after',
WORLDINFO_SETTINGS_UPDATED: 'worldinfo_settings_updated',
CHARACTER_EDITED: 'character_edited',
+ CHARACTER_PAGE_LOADED: 'character_page_loaded',
+ CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE: 'character_group_overlay_state_change_before',
+ CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER: 'character_group_overlay_state_change_after',
USER_MESSAGE_RENDERED: 'user_message_rendered',
CHARACTER_MESSAGE_RENDERED: 'character_message_rendered',
FORCE_SET_BACKGROUND: 'force_set_background',
@@ -316,6 +320,10 @@ eventSource.on(event_types.CHAT_CHANGED, displayOverrideWarnings);
eventSource.on(event_types.MESSAGE_RECEIVED, processExtensionHelpers);
eventSource.on(event_types.MESSAGE_SENT, processExtensionHelpers);
+const characterGroupOverlay = new BulkEditOverlay();
+const characterContextMenu = new CharacterContextMenu(characterGroupOverlay);
+eventSource.on(event_types.CHARACTER_PAGE_LOADED, characterGroupOverlay.onPageLoad);
+
hljs.addPlugin({ "before:highlightElement": ({ el }) => { el.textContent = el.innerText } });
// Markdown converter
@@ -1048,6 +1056,7 @@ async function printCharacters(fullRefresh = false) {
$("#rm_print_characters_block").append(getGroupBlock(i.item));
}
}
+ eventSource.emit(event_types.CHARACTER_PAGE_LOADED);
},
afterSizeSelectorChange: function (e) {
localStorage.setItem(storageKey, e.target.value);
@@ -1076,7 +1085,7 @@ export function getEntitiesList({ doFilter } = {}) {
return entities;
}
-async function getOneCharacter(avatarUrl) {
+export async function getOneCharacter(avatarUrl) {
const response = await fetch("/getonecharacter", {
method: "POST",
headers: getRequestHeaders(),
@@ -5523,6 +5532,8 @@ export async function getChatsFromFiles(data, isGroupChat) {
* in descending order by file name. Returns `undefined` if the fetch request is unsuccessful.
*/
async function getPastCharacterChats() {
+ if (!characters.includes(this_chid)) return;
+
const response = await fetch("/getallchatsofcharacter", {
method: 'POST',
body: JSON.stringify({ avatar_url: characters[this_chid].avatar }),
@@ -7204,7 +7215,8 @@ function doCloseChat() {
* @param {boolean} delete_chats - Whether to delete chats or not.
*/
export async function handleDeleteCharacter(popup_type, this_chid, delete_chats) {
- if (popup_type !== "del_ch") {
+ if (popup_type !== "del_ch" ||
+ !characters[this_chid]) {
return;
}
diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js
new file mode 100644
index 000000000..7ce0cffaa
--- /dev/null
+++ b/public/scripts/BulkEditOverlay.js
@@ -0,0 +1,571 @@
+"use strict";
+
+import {
+ callPopup,
+ characters,
+ deleteCharacter,
+ event_types,
+ eventSource,
+ getCharacters,
+ getRequestHeaders,
+ printCharacters,
+ this_chid
+} from "../script.js";
+
+import {favsToHotswap} from "./RossAscends-mods.js";
+import {convertCharacterToPersona} from "./personas.js";
+import {createTagInput, getTagKeyForCharacter, tag_map} from "./tags.js";
+
+// Utility object for popup messages.
+const popupMessage = {
+ deleteChat(characterCount) {
+ return `
Delete ${characterCount} characters?
+
THIS IS PERMANENT!
+
`;
+ },
+}
+
+/**
+ * Static object representing the actions of the
+ * character context menu override.
+ */
+class CharacterContextMenu {
+ /**
+ * Tag one or more characters,
+ * opens a popup.
+ *
+ * @param selectedCharacters
+ */
+ static tag = (selectedCharacters) => {
+ BulkTagPopupHandler.show(selectedCharacters);
+ }
+
+ /**
+ * Duplicate one or more characters
+ *
+ * @param characterId
+ * @returns {Promise
}
+ */
+ static duplicate = async (characterId) => {
+ const character = CharacterContextMenu.#getCharacter(characterId);
+
+ return fetch('/dupecharacter', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({ avatar_url: character.avatar }),
+ });
+ }
+
+ /**
+ * Favorite a character
+ * and highlight it.
+ *
+ * @param characterId
+ * @returns {Promise}
+ */
+ static favorite = async (characterId) => {
+ const character = CharacterContextMenu.#getCharacter(characterId);
+
+ // Only set fav for V2 spec
+ const data = {
+ name: character.name,
+ avatar: character.avatar,
+ data: {
+ extensions: {
+ fav: !character.data.extensions.fav
+ }
+ }
+ };
+
+ return fetch('/v2/editcharacterattribute', {
+ method: "POST",
+ headers: getRequestHeaders(),
+ body: JSON.stringify(data),
+ }).then((response) => {
+ if (response.ok) {
+ const element = document.getElementById(`CharID${characterId}`);
+ element.classList.toggle('is_fav');
+ } else {
+ response.json().then(json => toastr.error('Character not saved. Error: ' + json.message + '. Field: ' + json.error));
+ }
+ });
+ }
+
+ /**
+ * Convert one or more characters to persona,
+ * may open a popup for one or more characters.
+ *
+ * @param characterId
+ * @returns {Promise}
+ */
+ static persona = async (characterId) => await convertCharacterToPersona(characterId);
+
+ /**
+ * Delete one or more characters,
+ * opens a popup.
+ *
+ * @param characterId
+ * @param deleteChats
+ * @returns {Promise}
+ */
+ static delete = async (characterId, deleteChats = false) => {
+ const character = CharacterContextMenu.#getCharacter(characterId);
+
+ return fetch('/deletecharacter', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({ avatar_url: character.avatar , delete_chats: deleteChats }),
+ cache: 'no-cache',
+ }).then(response => {
+ if (response.ok) {
+ deleteCharacter(character.name, character.avatar).then(() => {
+ if (deleteChats) {
+ fetch("/getallchatsofcharacter", {
+ method: 'POST',
+ body: JSON.stringify({ avatar_url: character.avatar }),
+ headers: getRequestHeaders(),
+ }).then( (response) => {
+ let data = response.json();
+ data = Object.values(data);
+ const pastChats = data.sort((a, b) => a["file_name"].localeCompare(b["file_name"])).reverse();
+
+ for (const chat of pastChats) {
+ const name = chat.file_name.replace('.jsonl', '');
+ eventSource.emit(event_types.CHAT_DELETED, name);
+ }
+ });
+ }
+ })
+ }
+
+ eventSource.emit('characterDeleted', { id: this_chid, character: characters[this_chid] });
+ });
+ }
+
+ static #getCharacter = (characterId) => characters[characterId] ?? null;
+
+ /**
+ * Show the context menu at the given position
+ *
+ * @param positionX
+ * @param positionY
+ */
+ static show = (positionX, positionY) => {
+ let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
+ contextMenu.style.left = `${positionX}px`;
+ contextMenu.style.top = `${positionY}px`;
+
+ document.getElementById(BulkEditOverlay.contextMenuId).classList.remove('hidden');
+ }
+
+ /**
+ * Hide the context menu
+ */
+ static hide = () => document.getElementById(BulkEditOverlay.contextMenuId).classList.add('hidden');
+
+ /**
+ * Sets up the context menu for the given overlay
+ *
+ * @param characterGroupOverlay
+ */
+ constructor(characterGroupOverlay) {
+ const contextMenuItems = [
+ {id: 'character_context_menu_favorite', callback: characterGroupOverlay.handleContextMenuFavorite},
+ {id: 'character_context_menu_duplicate', callback: characterGroupOverlay.handleContextMenuDuplicate},
+ {id: 'character_context_menu_delete', callback: characterGroupOverlay.handleContextMenuDelete},
+ {id: 'character_context_menu_persona', callback: characterGroupOverlay.handleContextMenuPersona},
+ {id: 'character_context_menu_tag', callback: characterGroupOverlay.handleContextMenuTag}
+ ];
+
+ contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback))
+ }
+}
+
+/**
+ * Represents a tag control not bound to a single character
+ */
+class BulkTagPopupHandler {
+ static #getHtml = (characterIds) => {
+ const characterData = JSON.stringify({characterIds: characterIds});
+ return `
+
+
+ `
+ };
+
+ /**
+ * Append and show the tag control
+ *
+ * @param characters - The characters assigned to this control
+ */
+ static show(characters) {
+ document.body.insertAdjacentHTML('beforeend', this.#getHtml(characters));
+ createTagInput('#bulkTagInput', '#bulkTagList');
+ document.querySelector('#bulk_tag_popup_cancel').addEventListener('click', this.hide.bind(this));
+ document.querySelector('#bulk_tag_popup_reset').addEventListener('click', this.resetTags.bind(this, characters));
+ }
+
+ /**
+ * Hide and remove the tag control
+ */
+ static hide() {
+ let popupElement = document.querySelector('#bulk_tag_shadow_popup');
+ if (popupElement) {
+ document.body.removeChild(popupElement);
+ }
+
+ printCharacters(true);
+ }
+
+ /**
+ * Empty the tag map for the given characters
+ *
+ * @param characterIds
+ */
+ static resetTags(characterIds) {
+ characterIds.forEach((characterId) => {
+ const key = getTagKeyForCharacter(characterId);
+ if (key) tag_map[key] = [];
+ });
+
+ printCharacters(true);
+ }
+}
+
+class BulkEditOverlayState {
+ /**
+ *
+ * @type {number}
+ */
+ static browse = 0;
+
+ /**
+ *
+ * @type {number}
+ */
+ static select = 1;
+}
+
+/**
+ * Implement a SingletonPattern, allowing access to the group overlay instance
+ * from everywhere via (new CharacterGroupOverlay())
+ *
+ * @type BulkEditOverlay
+ */
+let bulkEditOverlayInstance = null;
+
+class BulkEditOverlay {
+ static containerId = 'rm_print_characters_block';
+ static contextMenuId = 'character_context_menu';
+ static characterClass = 'character_select';
+ static selectModeClass = 'group_overlay_mode_select';
+ static selectedClass = 'character_selected';
+ static legacySelectedClass = 'bulk_select_checkbox';
+
+ static longPressDelay = 1500;
+
+ #state = BulkEditOverlayState.browse;
+ #longPress = false;
+ #stateChangeCallbacks = [];
+ #selectedCharacters = [];
+
+ /**
+ * @type HTMLElement
+ */
+ container = null;
+
+ get state() {
+ return this.#state;
+ }
+
+ set state(newState) {
+ if (this.#state === newState) return;
+
+ eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_BEFORE, newState)
+ .then(() => {
+ this.#state = newState;
+ eventSource.emit(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.state)
+ });
+ }
+
+ get isLongPress() {
+ return this.#longPress;
+ }
+
+ set isLongPress(longPress) {
+ this.#longPress = longPress;
+ }
+
+ get stateChangeCallbacks() {
+ return this.#stateChangeCallbacks;
+ }
+
+ /**
+ *
+ * @returns {*[]}
+ */
+ get selectedCharacters() {
+ return this.#selectedCharacters;
+ }
+
+ constructor() {
+ if (bulkEditOverlayInstance instanceof BulkEditOverlay)
+ return bulkEditOverlayInstance
+
+ this.container = document.getElementById(BulkEditOverlay.containerId);
+ this.container.addEventListener('click', this.handleCancelClick);
+
+ eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange);
+ bulkEditOverlayInstance = Object.freeze(this);
+ }
+
+ /**
+ * Set the overlay to browse mode
+ */
+ browseState = () => this.state = BulkEditOverlayState.browse;
+
+ /**
+ * Set the overlay to select mode
+ */
+ selectState = () => this.state = BulkEditOverlayState.select;
+
+ /**
+ * Set up a Sortable grid for the loaded page
+ */
+ onPageLoad = () => {
+ this.browseState();
+
+ const elements = this.#getEnabledElements();
+ elements.forEach(element => element.addEventListener('touchstart', this.handleHold));
+ elements.forEach(element => element.addEventListener('mousedown', this.handleHold));
+
+ elements.forEach(element => element.addEventListener('touchend', this.handleLongPressEnd));
+ elements.forEach(element => element.addEventListener('mouseup', this.handleLongPressEnd));
+ elements.forEach(element => element.addEventListener('dragend', this.handleLongPressEnd));
+
+ const grid = document.getElementById(BulkEditOverlay.containerId);
+ grid.addEventListener('click', this.handleCancelClick);
+ }
+
+ /**
+ * Handle state changes
+ *
+ *
+ */
+ handleStateChange = () => {
+ switch (this.state) {
+ case BulkEditOverlayState.browse:
+ this.container.classList.remove(BulkEditOverlay.selectModeClass);
+ this.#enableClickEventsForCharacters();
+ this.clearSelectedCharacters();
+ this.disableContextMenu();
+ this.#disableBulkEditButtonHighlight();
+ CharacterContextMenu.hide();
+ break;
+ case BulkEditOverlayState.select:
+ this.container.classList.add(BulkEditOverlay.selectModeClass);
+ this.#disableClickEventsForCharacters();
+ this.enableContextMenu();
+ this.#enableBulkEditButtonHighlight();
+ break;
+ }
+
+ this.stateChangeCallbacks.forEach(callback => callback(this.state));
+ }
+
+ /**
+ * Block the browsers native context menu and
+ * set a click event to hide the custom context menu.
+ */
+ enableContextMenu = () => {
+ document.addEventListener('contextmenu', this.handleContextMenuShow);
+ document.addEventListener('click', this.handleContextMenuHide);
+ }
+
+ /**
+ * Remove event listeners, allowing the native browser context
+ * menu to be opened.
+ */
+ disableContextMenu = () => {
+ document.removeEventListener('contextmenu', this.handleContextMenuShow);
+ document.removeEventListener('click', this.handleContextMenuHide);
+ }
+
+ handleHold = (event) => {
+ if (0 !== event.button && event.type !== 'touchstart') return;
+
+ // Prevent call for mobile browser context menu on long-press.
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.isLongPress = true;
+ setTimeout(() => {
+ if (this.isLongPress) {
+ if (this.state === BulkEditOverlayState.browse)
+ this.selectState();
+ else if (this.state === BulkEditOverlayState.select)
+ CharacterContextMenu.show(...this.#getContextMenuPosition(event));
+ }
+ }, BulkEditOverlay.longPressDelay);
+ }
+
+ handleLongPressEnd = () => {
+ this.isLongPress = false;
+ }
+
+ handleCancelClick = () => {
+ this.state = BulkEditOverlayState.browse;
+ }
+
+ /**
+ * Returns the position of the mouse/touch location
+ *
+ * @param event
+ * @returns {(boolean|number|*)[]}
+ */
+ #getContextMenuPosition = (event) => [
+ event.clientX || event.touches[0].clientX,
+ event.clientY || event.touches[0].clientY,
+ ];
+
+ #enableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.removeEventListener('click', this.toggleCharacterSelected));
+
+ #disableClickEventsForCharacters = () => this.#getEnabledElements().forEach(element => element.addEventListener('click', this.toggleCharacterSelected));
+
+ #enableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.add('bulk_edit_overlay_active');
+
+ #disableBulkEditButtonHighlight = () => document.getElementById('bulkEditButton').classList.remove('bulk_edit_overlay_active');
+
+ #getEnabledElements = () => [...this.container.getElementsByClassName(BulkEditOverlay.characterClass)];
+
+ toggleCharacterSelected = event => {
+ event.stopPropagation();
+
+ const character = event.currentTarget;
+ const characterId = character.getAttribute('chid');
+
+ const alreadySelected = this.selectedCharacters.includes(characterId)
+
+ const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass);
+
+ if (alreadySelected) {
+ character.classList.remove(BulkEditOverlay.selectedClass);
+ if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
+ this.dismissCharacter(characterId);
+ } else {
+ character.classList.add(BulkEditOverlay.selectedClass)
+ if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;
+ this.selectCharacter(characterId);
+ }
+ }
+
+ handleContextMenuShow = (event) => {
+ event.preventDefault();
+ document.getElementById(BulkEditOverlay.containerId).style.pointerEvents = 'none';
+ CharacterContextMenu.show(...this.#getContextMenuPosition(event));
+ }
+
+ handleContextMenuHide = (event) => {
+ document.getElementById(BulkEditOverlay.containerId).style.pointerEvents = '';
+ let contextMenu = document.getElementById(BulkEditOverlay.contextMenuId);
+ if (false === contextMenu.contains(event.target)) {
+ CharacterContextMenu.hide();
+ }
+ }
+
+ /**
+ * Concurrently handle character favorite requests.
+ *
+ * @returns {Promise}
+ */
+ handleContextMenuFavorite = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.favorite(characterId)))
+ .then(() => getCharacters())
+ .then(() => favsToHotswap())
+ .then(() => this.browseState())
+
+ /**
+ * Concurrently handle character duplicate requests.
+ *
+ * @returns {Promise}
+ */
+ handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId)))
+ .then(() => getCharacters())
+ .then(() => this.browseState())
+
+ /**
+ * Sequentially handle all character-to-persona conversions.
+ *
+ * @returns {Promise}
+ */
+ handleContextMenuPersona = async () => {
+ for (const characterId of this.selectedCharacters) {
+ await CharacterContextMenu.persona(characterId)
+ }
+
+ this.browseState();
+ }
+
+ /**
+ * Request user input before concurrently handle deletion
+ * requests.
+ *
+ * @returns {Promise}
+ */
+ handleContextMenuDelete = () => {
+ callPopup(
+ popupMessage.deleteChat(this.selectedCharacters.length), null)
+ .then((accept) => {
+ if (true !== accept) return;
+
+ const deleteChats = document.getElementById('del_char_checkbox').checked ?? false;
+
+ Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.delete(characterId, deleteChats)))
+ .then(() => getCharacters())
+ .then(() => this.browseState())}
+ );
+ }
+
+ /**
+ * Attaches and opens the tag menu
+ */
+ handleContextMenuTag = () => {
+ CharacterContextMenu.tag(this.selectedCharacters);
+ }
+
+ addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);
+
+ selectCharacter = characterId => this.selectedCharacters.push(String(characterId));
+
+ dismissCharacter = characterId => this.#selectedCharacters = this.selectedCharacters.filter(item => String(characterId) !== item);
+
+ /**
+ * Clears internal character storage and
+ * removes visual highlight.
+ */
+ clearSelectedCharacters = () => {
+ document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.selectedClass)
+ .forEach( element => element.classList.remove(BulkEditOverlay.selectedClass));
+ this.selectedCharacters.length = 0;
+ }
+}
+
+export {BulkEditOverlayState, CharacterContextMenu, BulkEditOverlay};
diff --git a/public/scripts/bulk-edit.js b/public/scripts/bulk-edit.js
index 19694262b..82c2794ee 100644
--- a/public/scripts/bulk-edit.js
+++ b/public/scripts/bulk-edit.js
@@ -1,24 +1,44 @@
import { characters, getCharacters, handleDeleteCharacter, callPopup } from "../script.js";
+import {BulkEditOverlay, BulkEditOverlayState} from "./BulkEditOverlay.js";
+
let is_bulk_edit = false;
+const enableBulkEdit = () => {
+ enableBulkSelect();
+ (new BulkEditOverlay()).selectState();
+ // show the delete button
+ $("#bulkDeleteButton").show();
+ is_bulk_edit = true;
+}
+
+const disableBulkEdit = () => {
+ disableBulkSelect();
+ (new BulkEditOverlay()).browseState();
+ // hide the delete button
+ $("#bulkDeleteButton").hide();
+ is_bulk_edit = false;
+}
+
+const toggleBulkEditMode = (isBulkEdit) => {
+ if (isBulkEdit) {
+ disableBulkEdit();
+ } else {
+ enableBulkEdit();
+ }
+}
+
+(new BulkEditOverlay()).addStateChangeCallback((state) => {
+ if (state === BulkEditOverlayState.select) enableBulkEdit();
+ if (state === BulkEditOverlayState.browse) disableBulkEdit();
+});
+
/**
* Toggles bulk edit mode on/off when the edit button is clicked.
*/
function onEditButtonClick() {
console.log("Edit button clicked");
- // toggle bulk edit mode
- if (is_bulk_edit) {
- disableBulkSelect();
- // hide the delete button
- $("#bulkDeleteButton").hide();
- is_bulk_edit = false;
- } else {
- enableBulkSelect();
- // show the delete button
- $("#bulkDeleteButton").show();
- is_bulk_edit = true;
- }
+ toggleBulkEditMode(is_bulk_edit);
}
/**
diff --git a/public/scripts/personas.js b/public/scripts/personas.js
index c3113b3db..fc91e8008 100644
--- a/public/scripts/personas.js
+++ b/public/scripts/personas.js
@@ -42,16 +42,18 @@ async function createDummyPersona() {
await uploadUserAvatar(default_avatar);
}
-async function convertCharacterToPersona() {
- const avatarUrl = characters[this_chid]?.avatar;
+export async function convertCharacterToPersona(characterId = null) {
+ if (null === characterId) characterId = this_chid;
+
+ const avatarUrl = characters[characterId]?.avatar;
if (!avatarUrl) {
console.log("No avatar found for this character");
return;
}
- const name = characters[this_chid]?.name;
- let description = characters[this_chid]?.description;
+ const name = characters[characterId]?.name;
+ let description = characters[characterId]?.description;
const overwriteName = `${name} (Persona).png`;
if (overwriteName in power_user.personas) {
diff --git a/public/scripts/tags.js b/public/scripts/tags.js
index df10177c9..44a84b164 100644
--- a/public/scripts/tags.js
+++ b/public/scripts/tags.js
@@ -137,8 +137,12 @@ function getTagKey() {
return null;
}
-function addTagToMap(tagId) {
- const key = getTagKey();
+export function getTagKeyForCharacter(characterId = null) {
+ return characters[characterId]?.avatar;
+}
+
+function addTagToMap(tagId, characterId = null) {
+ const key = getTagKey() ?? getTagKeyForCharacter(characterId);
if (!key) {
return;
@@ -152,8 +156,8 @@ function addTagToMap(tagId) {
}
}
-function removeTagFromMap(tagId) {
- const key = getTagKey();
+function removeTagFromMap(tagId, characterId = null) {
+ const key = getTagKey() ?? getTagKeyForCharacter(characterId);
if (!key) {
return;
@@ -197,7 +201,17 @@ function selectTag(event, ui, listSelector) {
// add tag to the UI and internal map
appendTagToList(listSelector, tag, { removable: true });
appendTagToList(getInlineListSelector(), tag, { removable: false });
- addTagToMap(tag.id);
+
+ // Optional, check for multiple character ids being present.
+ const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters;
+ const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
+
+ if (characterIds) {
+ characterIds.forEach((characterId) => addTagToMap(tag.id,characterId));
+ } else {
+ addTagToMap(tag.id);
+ }
+
saveSettingsDebounced();
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
@@ -383,8 +397,19 @@ function onTagRemoveClick(event) {
event.stopPropagation();
const tag = $(this).closest(".tag");
const tagId = tag.attr("id");
+
+ // Optional, check for multiple character ids being present.
+ const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters;
+ const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
+
tag.remove();
- removeTagFromMap(tagId);
+
+ if (characterIds) {
+ characterIds.forEach((characterId) => removeTagFromMap(tagId, characterId));
+ } else {
+ removeTagFromMap(tagId);
+ }
+
$(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove();
printTagFilters(tag_filter_types.character);
@@ -439,7 +464,7 @@ function applyTagsOnGroupSelect() {
}
}
-function createTagInput(inputSelector, listSelector) {
+export function createTagInput(inputSelector, listSelector) {
$(inputSelector)
.autocomplete({
source: (i, o) => findTag(i, o, listSelector),
diff --git a/public/style.css b/public/style.css
index b160f0c9d..f13ab2d98 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2,6 +2,7 @@
@import url(css/promptmanager.css);
@import url(css/loader.css);
+@import url(css/character-group-overlay.css);
:root {
--doc-height: 100%;
@@ -1863,6 +1864,7 @@ grammarly-extension {
/* Focus */
+#bulk_tag_popup,
#dialogue_popup {
width: 500px;
max-width: 90vw;
@@ -1905,6 +1907,7 @@ grammarly-extension {
width: unset !important;
}
+#bulk_tag_popup_holder,
#dialogue_popup_holder {
display: flex;
flex-direction: column;
@@ -1925,6 +1928,7 @@ grammarly-extension {
gap: 20px;
}
+#bulk_tag_popup_reset,
#dialogue_popup_ok {
background-color: var(--crimson70a);
cursor: pointer;
@@ -1935,6 +1939,7 @@ grammarly-extension {
width: 100%;
}
+#bulk_tag_popup_cancel,
#dialogue_popup_cancel {
cursor: pointer;
}
diff --git a/server.js b/server.js
index 704c5720c..c721e186f 100644
--- a/server.js
+++ b/server.js
@@ -57,7 +57,7 @@ const characterCardParser = require('./src/character-card-parser.js');
const contentManager = require('./src/content-manager');
const statsHelpers = require('./statsHelpers.js');
const { readSecret, migrateSecrets, SECRET_KEYS } = require('./src/secrets');
-const { delay, getVersion } = require('./src/util');
+const { delay, getVersion, deepMerge} = require('./src/util');
const { invalidateThumbnail, ensureThumbnailCache } = require('./src/thumbnails');
const { getTokenizerModel, getTiktokenTokenizer, loadTokenizers, TEXT_COMPLETION_MODELS } = require('./src/tokenizers');
const { convertClaudePrompt } = require('./src/chat-completion');
@@ -208,6 +208,7 @@ const AVATAR_HEIGHT = 600;
const jsonParser = express.json({ limit: '100mb' });
const urlencodedParser = express.urlencoded({ extended: true, limit: '100mb' });
const { DIRECTORIES, UPLOADS_PATH, PALM_SAFETY } = require('./src/constants');
+const {TavernCardValidator} = require("./src/validator/TavernCardValidator");
// CSRF Protection //
if (cliArguments.disableCsrf === false) {
@@ -1167,6 +1168,45 @@ app.post("/editcharacterattribute", jsonParser, async function (request, respons
}
});
+/**
+ * Handle a POST request to edit character properties.
+ *
+ * Merges the request body with the selected character and
+ * validates the result against TavernCard V2 specification.
+ *
+ * @param {Object} request - The HTTP request object.
+ * @param {Object} response - The HTTP response object.
+ *
+ * @returns {void}
+ * */
+app.post("/v2/editcharacterattribute", jsonParser, async function (request, response) {
+ const update = request.body;
+ const avatarPath = path.join(charactersPath, update.avatar);
+
+ try {
+ let character = JSON.parse(await charaRead(avatarPath));
+ character = deepMerge(character, update);
+
+ const validator = new TavernCardValidator(character);
+
+ //Accept either V1 or V2.
+ if (validator.validate()) {
+ await charaWrite(
+ avatarPath,
+ JSON.stringify(character),
+ (update.avatar).replace('.png', ''),
+ response,
+ 'Character saved'
+ );
+ } else {
+ console.log(validator.lastValidationError)
+ response.status(400).send({message: `Validation failed for ${character.name}`, error: validator.lastValidationError});
+ }
+ } catch (exception) {
+ response.status(500).send({message: 'Unexpected error while saving character.', error: exception.toString()});
+ }
+});
+
app.post("/deletecharacter", jsonParser, async function (request, response) {
if (!request.body || !request.body.avatar_url) {
return response.sendStatus(400);
diff --git a/src/util.js b/src/util.js
index 553f2f285..78a7f26f0 100644
--- a/src/util.js
+++ b/src/util.js
@@ -196,6 +196,27 @@ async function readAllChunks(readableStream) {
});
}
+function isObject(item) {
+ return (item && typeof item === 'object' && !Array.isArray(item));
+}
+
+function deepMerge(target, source) {
+ let output = Object.assign({}, target);
+ if (isObject(target) && isObject(source)) {
+ Object.keys(source).forEach(key => {
+ if (isObject(source[key])) {
+ if (!(key in target))
+ Object.assign(output, { [key]: source[key] });
+ else
+ output[key] = deepMerge(target[key], source[key]);
+ } else {
+ Object.assign(output, { [key]: source[key] });
+ }
+ });
+ }
+ return output;
+}
+
module.exports = {
getConfig,
getConfigValue,
@@ -205,4 +226,5 @@ module.exports = {
getImageBuffers,
readAllChunks,
delay,
+ deepMerge,
};
diff --git a/src/validator/TavernCardValidator.js b/src/validator/TavernCardValidator.js
new file mode 100644
index 000000000..dabd0b595
--- /dev/null
+++ b/src/validator/TavernCardValidator.js
@@ -0,0 +1,127 @@
+/**
+ * Validates the data structure of character cards.
+ * Supported specs: V1, V2
+ * Up to: 8083fb3
+ *
+ * @link https://github.com/malfoyslastname/character-card-spec-v2
+ */
+class TavernCardValidator {
+ #lastValidationError = null;
+
+ constructor(card) {
+ this.card = card;
+ }
+
+ /**
+ * Field that caused the validation to fail
+ *
+ * @returns {null|string}
+ */
+ get lastValidationError() {
+ return this.#lastValidationError;
+ }
+
+ /**
+ * Validate against V1 or V2 spec.
+ *
+ * @returns {number|boolean} - false when neither V1 nor V2 spec were matched. Specification version number otherwise.
+ */
+ validate() {
+ this.#lastValidationError = null;
+
+ if (this.validateV1()) {
+ return 1;
+ }
+
+ if (this.validateV2()) {
+ return 2;
+ }
+
+ return false;
+ }
+
+ /**
+ * Validate against V1 specification
+ *
+ * @returns {this is string[]}
+ */
+ validateV1() {
+ const requiredFields = ['name', 'description', 'personality', 'scenario', 'first_mes', 'mes_example'];
+ return requiredFields.every(field => {
+ if (!this.card.hasOwnProperty(field)) {
+ this.#lastValidationError = field;
+ return false;
+ }
+ return true;
+ });
+ }
+
+ /**
+ * Validate against V2 specification
+ *
+ * @returns {false|boolean|*}
+ */
+ validateV2() {
+ return this.#validateSpec()
+ && this.#validateSpecVersion()
+ && this.#validateData()
+ && this.#validateCharacterBook();
+ }
+
+ #validateSpec() {
+ if (this.card.spec !== 'chara_card_v2') {
+ this.#lastValidationError = 'spec';
+ return false;
+ }
+ return true;
+ }
+
+ #validateSpecVersion() {
+ if (this.card.spec_version !== '2.0') {
+ this.#lastValidationError = 'spec_version';
+ return false;
+ }
+ return true;
+ }
+
+ #validateData() {
+ const data = this.card.data;
+
+ if (!data) {
+ this.#lastValidationError = 'No tavern card data found';
+ return false;
+ }
+
+ const requiredFields = ['name', 'description', 'personality', 'scenario', 'first_mes', 'mes_example', 'creator_notes', 'system_prompt', 'post_history_instructions', 'alternate_greetings', 'tags', 'creator', 'character_version', 'extensions'];
+ const isAllRequiredFieldsPresent = requiredFields.every(field => {
+ if (!data.hasOwnProperty(field)) {
+ this.#lastValidationError = `data.${field}`;
+ return false;
+ }
+ return true;
+ });
+
+ return isAllRequiredFieldsPresent && Array.isArray(data.alternate_greetings) && Array.isArray(data.tags) && typeof data.extensions === 'object';
+ }
+
+ #validateCharacterBook() {
+ const characterBook = this.card.data.character_book;
+
+ if (!characterBook) {
+ return true;
+ }
+
+ const requiredFields = ['extensions', 'entries'];
+ const isAllRequiredFieldsPresent = requiredFields.every(field => {
+ if (!characterBook.hasOwnProperty(field)) {
+ this.#lastValidationError = `data.character_book.${field}`;
+ return false;
+ }
+ return true;
+ });
+
+ return isAllRequiredFieldsPresent && Array.isArray(characterBook.entries) && typeof characterBook.extensions === 'object';
+ }
+}
+
+module.exports = {TavernCardValidator}