diff --git a/public/css/character-group-overlay.css b/public/css/character-group-overlay.css new file mode 100644 index 000000000..78578fcc6 --- /dev/null +++ b/public/css/character-group-overlay.css @@ -0,0 +1,98 @@ +#rm_print_characters_block .character_select, +#rm_print_characters_block .group_select{ + cursor: pointer; +} + +#rm_print_characters_block.group_overlay_mode_select .character_select { + transition: background-color 0.4s ease; + margin-bottom: 1px; + background-color: rgba(170, 170, 170, 0.15); +} + +#rm_print_characters_block.group_overlay_mode_select .character_select input.bulk_select_checkbox { + display: none !important; +} + +#rm_print_characters_block.group_overlay_mode_select .character_select.character_selected { + background-color: var(--SmartThemeQuoteColor); +} + +#rm_print_characters_block.group_overlay_mode_select .character_select .bulk_select_checkbox { + visibility: hidden; + height: 0 !important; +} + +#character_context_menu.hidden { display: none; } +#character_context_menu { + position: absolute; + padding: 3px; + z-index: 10000; + background-color: var(--black90a); + border: 1px solid var(--black90a); + border-radius: 10px; +} + +#character_context_menu ul li button { + border: 0; + border-bottom-color: currentcolor; + color: var(--SmartThemeQuoteColor); + background-color: transparent; + font-weight: bold; + font-size: 1em; + padding: 0.5em; + border-bottom: 1px dotted var(--SmartThemeQuoteColor); + width: 100%; + cursor: pointer; +} + +#character_context_menu ul li button:hover { + background-color: var(--SmartThemeBlurTintColor); +} + +#character_context_menu ul li:last-child button { + border-bottom: 0; +} + +#character_context_menu ul li #character_context_menu_delete { + color: var(--fullred); +} + +#character_context_menu ul { + list-style-type: none; + padding: 0; + margin: 0; +} + +#character_context_menu .character_context_menu_separator { + height: 1px; + background-color: var(--SmartThemeBotMesBlurTintColor); +} + +#character_context_menu li:hover { + background-color: var(--SmartThemeBotMesBlurTintColor); +} + +#bulkEditButton.bulk_edit_overlay_active { + color: var(--golden); +} + +#bulk_tag_shadow_popup { + backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2)); + -webkit-backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2)); + background-color: var(--black30a); + position: absolute; + width: 100%; + height: 100vh; + height: 100svh; + z-index: 9998; + top: 0; +} + +#bulk_tag_shadow_popup #bulk_tag_popup { + padding: 1em; +} + +#bulk_tag_shadow_popup #bulk_tag_popup #dialogue_popup_controls .menu_button { + width: 100px; + padding: 0.25em; +} diff --git a/public/css/tags.css b/public/css/tags.css index 9fe53cdf6..788e7dc05 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -1,3 +1,4 @@ +#bulk_tags_div, #tags_div { min-width: 0; } @@ -86,10 +87,12 @@ align-items: flex-end; } +#bulkTagsList, #tagList.tags { margin: 5px 0; } +#bulkTagsList, #tagList .tag { opacity: 0.6; } diff --git a/public/index.html b/public/index.html index a334b7dba..79ecb9f4d 100644 --- a/public/index.html +++ b/public/index.html @@ -100,6 +100,16 @@
+ +
@@ -4309,6 +4319,7 @@ +
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 `
+
+
+

Add tags to ${characterIds.length} characters

+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ ` + }; + + /** + * 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}