Prototyping

This commit is contained in:
artisticMink 2023-10-21 15:12:09 +02:00
parent a5b5dfdcb6
commit 6eb6527d11
10 changed files with 664 additions and 19 deletions

View File

@ -0,0 +1,63 @@
#rm_print_characters_block.group_overlay_mode_select .character_select {
background-color: rgba(170, 170, 170, 0.2);
}
#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);
}

View File

@ -100,6 +100,16 @@
<div id="bg_custom"></div>
<div id="bg1"></div>
<div id="character_context_menu" class="hidden">
<ul>
<li><button id="character_context_menu_favorite">Favorite</button></li>
<li><button id="character_context_menu_collection">Collection</button></li>
<li><button id="character_context_menu_persona">To Persona</button></li>
<li><button id="character_context_menu_duplicate">Duplicate</button></li>
<li><button id="character_context_menu_delete">Delete</button></li>
</ul>
</div>
<!-- top bar central settings buttons -->
<div id="top-bar">
</div>
@ -4303,6 +4313,7 @@
</form>
</div>
</div>
<div id="character_template" class="template_element">
<div class="character_select flex-container wide100p alignitemsflexstart" chid="" id="">
<div class="avatar" title="">

View File

@ -187,6 +187,7 @@ import { getTokenCount, getTokenizerModel, initTokenizers, saveTokenCache } from
import { initPersonas, selectCurrentPersona, setPersonaDescription } from "./scripts/personas.js";
import { getBackgrounds, initBackgrounds } from "./scripts/backgrounds.js";
import { hideLoader, showLoader } from "./scripts/loader.js";
import {CharacterContextMenu, CharacterGroupOverlay} from "./scripts/CharacterGroupOverlay.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 CharacterGroupOverlay();
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(),

View File

@ -0,0 +1,351 @@
"use strict";
import {
callPopup,
characters, deleteCharacter,
event_types,
eventSource,
getCharacters,
getRequestHeaders, handleDeleteCharacter, this_chid
} from "../script.js";
import {favsToHotswap} from "./RossAscends-mods.js";
import {convertCharacterToPersona} from "./personas.js";
const popupMessage = {
deleteChat(characterCount) {
return `<h3>Delete ${characterCount} characters?</h3>
<b>THIS IS PERMANENT!<br><br>
<label for="del_char_checkbox" class="checkbox_label justifyCenter">
<input type="checkbox" id="del_char_checkbox" />
<span>Also delete the chat files</span>
</label><br></b>`;
},
exportCharacters(characterCount) {
return `<h3>Export ${characterCount} characters?</h3>`;
}
}
const toggleFavoriteHighlight = (characterId) => {
const element = document.getElementById(`CharID${characterId}`);
element.classList.toggle('is_fav');
}
/**
* Implement a SingletonPattern, allowing access to the group overlay instance
* from everywhere via (new CharacterGroupOverlay())
*
* @type CharacterGroupOverlay
*/
let characterGroupOverlayInstance = null;
class CharacterGroupOverlayState {
static browse = 0;
static select = 1;
}
class CharacterContextMenu {
/**
* Duplicate a character
*
* @param characterId
* @returns {Promise<Response>}
*/
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 toggle its ui element.
*
* @param characterId
* @returns {Promise<void>}
*/
static favorite = async (characterId) => {
const character = CharacterContextMenu.getCharacter(characterId);
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) toggleFavoriteHighlight(characterId)
else toastr.error('Character not saved. Error: ' + response.json()?.message)
});
}
static persona = async (characterId) => convertCharacterToPersona(characterId);
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;
static show = (positionX, positionY) => {
let contextMenu = document.getElementById(CharacterGroupOverlay.contextMenuId);
contextMenu.style.left = `${positionX}px`;
contextMenu.style.top = `${positionY}px`;
document.getElementById(CharacterGroupOverlay.contextMenuId).classList.remove('hidden');
}
static hide = () => document.getElementById(CharacterGroupOverlay.contextMenuId).classList.add('hidden');
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}
];
contextMenuItems.forEach(contextMenuItem => document.getElementById(contextMenuItem.id).addEventListener('click', contextMenuItem.callback))
}
}
class CharacterGroupOverlay {
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';
#state = CharacterGroupOverlayState.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;
}
get selectedCharacters() {
return this.#selectedCharacters;
}
constructor() {
if (characterGroupOverlayInstance instanceof CharacterGroupOverlay)
return characterGroupOverlayInstance
this.container = document.getElementById(CharacterGroupOverlay.containerId);
this.container.addEventListener('click', this.handleCancelClick);
eventSource.on(event_types.CHARACTER_GROUP_OVERLAY_STATE_CHANGE_AFTER, this.handleStateChange);
characterGroupOverlayInstance = Object.freeze(this);
}
browseState = () => this.state = CharacterGroupOverlayState.browse;
selectState = () => this.state = CharacterGroupOverlayState.select;
/**
* Set up a Sortable grid for the loaded page
*/
onPageLoad = () => {
const elements = [...document.getElementsByClassName(CharacterGroupOverlay.characterClass)];
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(CharacterGroupOverlay.containerId);
grid.addEventListener('click', this.handleCancelClick);
}
/**
* 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 = () => {
this.isLongPress = true;
setTimeout(() => {
if (this.isLongPress) {
this.state = CharacterGroupOverlayState.select;
}
}, 3000);
}
handleLongPressEnd = () => {
this.isLongPress = false;
}
handleCancelClick = () => {
this.state = CharacterGroupOverlayState.browse;
}
handleStateChange = () => {
switch (this.state) {
case CharacterGroupOverlayState.browse:
this.container.classList.remove(CharacterGroupOverlay.selectModeClass);
[...this.container.getElementsByClassName(CharacterGroupOverlay.characterClass)]
.forEach(element => element.removeEventListener('click', this.toggleCharacterSelected));
this.clearSelectedCharacters();
this.disableContextMenu();
CharacterContextMenu.hide();
break;
case CharacterGroupOverlayState.select:
this.container.classList.add(CharacterGroupOverlay.selectModeClass);
[...this.container.getElementsByClassName(CharacterGroupOverlay.characterClass)]
.forEach(element => element.addEventListener('click', this.toggleCharacterSelected));
this.enableContextMenu();
break;
}
this.stateChangeCallbacks.forEach(callback => callback(this.state));
}
toggleCharacterSelected = event => {
event.stopPropagation();
const character = event.currentTarget;
const characterId = character.getAttribute('chid');
const alreadySelected = this.selectedCharacters.includes(characterId)
if (alreadySelected) {
character.classList.remove(CharacterGroupOverlay.selectedClass);
this.dismissCharacter(characterId);
} else {
character.classList.add(CharacterGroupOverlay.selectedClass);
this.selectCharacter(characterId);
}
}
handleContextMenuShow = (event) => {
event.preventDefault();
document.getElementById(CharacterGroupOverlay.containerId).style.pointerEvents = 'none';
CharacterContextMenu.show(event.clientX, event.clientY);
}
handleContextMenuHide = (event) => {
document.getElementById(CharacterGroupOverlay.containerId).style.pointerEvents = '';
let contextMenu = document.getElementById(CharacterGroupOverlay.contextMenuId);
if (false === contextMenu.contains(event.target)) {
CharacterContextMenu.hide();
}
}
handleContextMenuFavorite = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.favorite(characterId)))
.then(() => getCharacters())
.then(() => favsToHotswap())
.then(() => this.browseState())
handleContextMenuDuplicate = () => Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.duplicate(characterId)))
.then(() => getCharacters())
.then(() => this.browseState())
handleContextMenuPersona = () => { Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.persona(characterId)))
.then(() => this.browseState());
}
handleContextMenuDelete = () => {
callPopup(
popupMessage.deleteChat(this.selectedCharacters.length), null)
.then(deleteChats =>
Promise.all(this.selectedCharacters.map(async characterId => CharacterContextMenu.delete(characterId, deleteChats)))
.then(() => getCharacters())
.then(() => this.browseState())
);
}
addStateChangeCallback = callback => this.stateChangeCallbacks.push(callback);
selectCharacter = characterId => this.selectedCharacters.push(String(characterId));
dismissCharacter = characterId => this.#selectedCharacters = this.selectedCharacters.filter(item => String(characterId) !== item);
clearSelectedCharacters = () => {
this.selectedCharacters.forEach(characterId => document.querySelector('.character_select[chid="' + characterId + '"]')?.classList.remove(CharacterGroupOverlay.selectedClass))
this.selectedCharacters.length = 0;
}
}
export {CharacterGroupOverlayState, CharacterContextMenu, CharacterGroupOverlay};

View File

@ -1,24 +1,44 @@
import { characters, getCharacters, handleDeleteCharacter, callPopup } from "../script.js";
import {CharacterGroupOverlay, CharacterGroupOverlayState} from "./CharacterGroupOverlay.js";
let is_bulk_edit = false;
const enableBulkEdit = () => {
enableBulkSelect();
(new CharacterGroupOverlay()).selectState();
// show the delete button
$("#bulkDeleteButton").show();
is_bulk_edit = true;
}
const disableBulkEdit = () => {
disableBulkSelect();
(new CharacterGroupOverlay()).browseState();
// hide the delete button
$("#bulkDeleteButton").hide();
is_bulk_edit = false;
}
const toggleBulkEditMode = (isBulkEdit) => {
if (isBulkEdit) {
disableBulkEdit();
} else {
enableBulkEdit();
}
}
(new CharacterGroupOverlay()).addStateChangeCallback((state) => {
if (state === CharacterGroupOverlayState.select) enableBulkEdit();
if (state === CharacterGroupOverlayState.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);
}
/**

View File

@ -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) {

View File

@ -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%;

View File

@ -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,42 @@ 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);
if (validator.validateV2()) {
await charaWrite(
avatarPath,
JSON.stringify(character),
(update.avatar).replace('.png', ''),
response,
'Character saved'
);
} else {
response.status(400).send({message: `Validation failed for card ${character.name}`, field: validator.getValidationError});
}
} 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);

View File

@ -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,
};

View File

@ -0,0 +1,129 @@
const characterBook = require("lodash/object");
/**
* 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 {
#validationError = null;
constructor(card) {
this.card = card;
}
/**
* Field that caused the validation to fail
*
* @returns {null|string}
*/
get validationError() {
return this.#validationError;
}
/**
* Validate against V1 and V2 spec.
*
* @returns {number|boolean} - false when neither V1 nor V2 spec were matched. Specification version number otherwise.
*/
validate() {
this.#validationError = null;
if (this.validateV1()) {
return 2;
}
if (this.validateV2()) {
return 1;
}
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.#validationError = 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.#validationError = 'spec';
return false;
}
return true;
}
#validateSpecVersion() {
if (this.card.spec_version !== '2.0') {
this.#validationError = 'spec_version';
return false;
}
return true;
}
#validateData() {
const data = this.card.data;
if (!data) {
this.#validationError = 'data';
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.#validationError = `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.#validationError = `data.character_book.${field}`;
return false;
}
return true;
});
return isAllRequiredFieldsPresent && Array.isArray(characterBook.entries) && typeof characterBook.extensions === 'object';
}
}
module.exports = {TavernCardValidator}