Merge pull request #3346 from SillyTavern/persona-improvements Expand Persona links/locking to characters and allowing more control on which persona to choose/load

12ef33bca4774e9d6369feaa5b2503ccfc8d6656

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
13 files changed, +1374 -508Ignore whitespace
public/css/toggle-dependent.css+6 -5
@@ -96,11 +96,6 @@ body.charListGrid #rm_print_characters_block .group_select .group_name_block,
9696 flex-direction: column;
9797}
9898
99-#user_avatar_block.gridView .avatar-container .avatar-buttons {
100- flex-wrap: wrap;
101- justify-content: space-evenly;
102-}
103-
10499body.charListGrid #rm_print_characters_block .bogus_folder_select .character_select_container,
105100body.charListGrid #rm_print_characters_block .character_select .character_select_container,
106101body.charListGrid #rm_print_characters_block .group_select .group_select_container,
@@ -231,10 +226,16 @@ body.big-avatars .avatars_inline_small .avatar img {
231226body.big-avatars .avatars_inline {
232227 max-height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) + 2 * var(--avatar-base-border-radius));
233228}
229+body.big-avatars .avatars_inline.avatars_multiline {
230+ max-height: fit-content;
231+}
234232
235233body.big-avatars .avatars_inline.avatars_inline_small {
236234 height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) * var(--inline-avatar-small-factor) + 2 * var(--avatar-base-border-radius));
237235}
236+body.big-avatars .avatars_inline.avatars_inline_small.avatars_multiline {
237+ height: inherit;
238+}
238239
239240body:not(.big-avatars) .avatars_inline_small .avatar_collage {
240241 min-width: calc(var(--avatar-base-width) * var(--inline-avatar-small-factor));
public/index.html+89 -46
@@ -4987,7 +4987,7 @@
49874987 </div>
49884988 </div>
49894989 <div id="persona-management-block" class="flex-container wide100p flexGap10">
49904990 <div class="persona_management_left_column flex1 overflowHidden wide100p">
49914991 <div class="flex-container marginBot10 alignitemscenter">
49924992 <div id="create_dummy_persona" class="menu_button menu_button_icon" title="Create a dummy persona" data-i18n="[title]Create a dummy persona">
49934993 <i class="fa-solid fa-person-circle-question fa-fw"></i>
@@ -5010,27 +5010,34 @@
50105010 <input type="hidden" id="avatar_upload_overwrite" name="overwrite_name" value="">
50115011 </form>
50125012 </div>
50135013 <div class="persona_management_right_column flex1">
50145014 <h4div data-i18nclass="Namepersona_management_current_persona">Name</h4>
5015- <div class="change_name">
5015+ <h4 class="standoutHeader" data-i18n="Current Persona">Current Persona</h4>
5016- <input id="your_name" name="your_name" data-i18n="[placeholder]Enter your name" placeholder="Enter your name" class="text_pole wide100p" value="" autocomplete="off">
5016+
5017- <div id="your_name_button" class="menu_button fa-solid fa-check" title="Click to set a new User Name" data-i18n="[title]Click to set a new User Name">
5017+ <div id="persona_controls" class="flex-container">
5018- </div>
5018+ <h5 id="your_name" class="persona_name">[Persona Name]</h5>
5019- <div id="lock_user_name" class="menu_button fa-solid fa-unlock" title="Click to lock your selected persona to the current chat. Click again to remove the lock." data-i18n="[title]Click to lock your selected persona to the current chat. Click again to remove the lock.">
5019+ <div class="persona_controls_buttons_block buttons_block">
5020- </div>
5020+ <div id="persona_rename_button" class="menu_button fa-solid fa-pencil" title="Rename Persona" data-i18n="[title]Rename Persona"></div>
50215021 <div id="sync_name_button" class="menu_button fa-solid fa-sync" title="Click to set user name for all messages" data-i18n="[title]Click to set user name for all messages"></div>
5022- </div>
5022+ <div id="persona_lore_button" class="menu_button fa-solid fa-globe" title="Persona Lore&#10;Alt+Click to open the lorebook" data-i18n="[title]Persona Lore Alt+Click to open the lorebook"></div>
5023- <div id="persona_lore_button" class="menu_button fa-solid fa-globe" title="Persona Lore&#10;Alt+Click to open the lorebook" data-i18n="[title]Persona Lore Alt+Click to open the lorebook">
5023+
5024+ <div id="persona_set_image_button" class="menu_button fa-solid fa-image" title="Change Persona Image" data-i18n="[title]Change Persona Image"></div>
5025+ <div id="persona_duplicate_button" class="menu_button fa-solid fa-clone" title="Duplicate Persona" data-i18n="[title]Duplicate Persona"></div>
5026+ <div id="persona_delete_button" class="menu_button fa-solid fa-skull red_button" title="Delete Persona" data-i18n="[title]Delete Persona"></div>
5027+ </div>
50245028 </div>
5025- </div>
5029+
5026- <div>
50275030 <h4 data-i18n="Persona Description">Persona Description</h4>
50285031 <textarea id="persona_description" name="persona_description" data-i18n="[placeholder]Example: [{{user}} is a 28-year-old Romanian cat girl.]" placeholder="Example:&#10;[{{user}} is a 28-year-old Romanian cat girl.]" class="text_pole textarea_compact" value="" autocomplete="off" rows="8"></textarea>
5029- <div class="extension_token_counter">
5032+
5030- <span data-i18n="Tokens persona description">Tokens</span>: <span id="persona_description_token_count">0</span>
5033+ <div class="flex-container justifySpaceBetween">
5034+ <h4 data-i18n="Position">Position</h4>
5035+ <div class="extension_token_counter widthFitContent">
5036+ <span data-i18n="Tokens persona description">Tokens</span>: <span id="persona_description_token_count">0</span>
5037+ </div>
50315038 </div>
5032- <div>
5039+
5033- <label for="persona_description_position" data-i18n="Position:">Position:</label>
5040+ <div class="persona_management_description_position_container">
50345041 <select id="persona_description_position">
50355042 <option value="9" data-i18n="None (disabled)">None (disabled)</option>
50365043 <option value="0" data-i18n="In Story String / Prompt Manager">In Story String / Prompt Manager</option>
@@ -5053,14 +5060,54 @@
50535060 </div>
50545061 </div>
50555062 </div>
5063+
5064+ <h4 data-i18n="Connections">Connections</h4>
5065+ <div id="persona_connections_buttons" class="flex-container">
5066+ <div id="lock_persona_default" class="menu_button menu_button_icon" title="Click to select this as default persona for the new chats. Click again to remove it." data-i18n="[title]Click to select this as default persona for the new chats. Click again to remove it.">
5067+ <i class="icon fa-solid fa-crown fa-fw"></i>
5068+ <div data-i18n="Default">Default</div>
5069+ </div>
5070+ <div id="lock_user_name" class="menu_button menu_button_icon" title="Click to lock your selected persona to the current chat. Click again to remove the lock." data-i18n="[title]Click to lock your selected persona to the current chat. Click again to remove the lock.">
5071+ <i class="icon fa-solid fa-unlock fa-fw"></i>
5072+ <div data-i18n="Chat">Chat</div>
5073+ </div>
5074+ <div id="lock_persona_to_char" class="menu_button menu_button_icon" title="Click to lock your selected persona to the current character. Click again to remove the lock." data-i18n="[title]Click to lock your selected persona to the current character. Click again to remove the lock.">
5075+ <i class="icon fa-solid fa-unlock fa-fw"></i>
5076+ <div data-i18n="Character">Character</div>
5077+ </div>
5078+ </div>
5079+ <div id="persona_connections_info_block"></div>
5080+ <div id="persona_connections_list" class="text_muted m-b-1 avatars_inline avatars_multiline scroll-reset-container expander">
5081+ </div>
50565082 </div>
5057- <div class="range-block">
5083+
50585084 <label for="persona_show_notifications"div class="checkbox_labelpersona_management_global_settings">
50595085 <inputh4 idclass="persona_show_notificationsstandoutHeader" typedata-i18n="checkboxGlobal Settings">Global Settings</h4>
5060- <span data-i18n="Show notifications on switching personas">
5086+
5061- Show notifications on switching personas
5087+ <div class="range-block">
5062- </span>
5088+ <label for="persona_show_notifications" class="checkbox_label">
5063- </label>
5089+ <input id="persona_show_notifications" type="checkbox" />
5090+ <span data-i18n="Show notifications on switching personas">
5091+ Show notifications on switching personas
5092+ </span>
5093+ </label>
5094+ </div>
5095+ <div class="range-block">
5096+ <label for="persona_allow_multi_connections" class="checkbox_label" title="When multiple personas are connected to a character, a popup will appear to select which one to use." data-i18n="[title]When multiple personas are connected to a character, a popup will appear to select which one to use">
5097+ <input id="persona_allow_multi_connections" type="checkbox" />
5098+ <span data-i18n="Allow multiple persona connections per character">
5099+ Allow multiple persona connections per character
5100+ </span>
5101+ </label>
5102+ </div>
5103+ <div class="range-block">
5104+ <label for="persona_auto_lock" class="checkbox_label" title="Whenever a persona is selected, it will be locked to the current chat and automatically selected when the chat is opened." data-i18n="[title]Whenever a persona is selected, it will be locked to the current chat and automatically selected when the chat is opened.">
5105+ <input id="persona_auto_lock" type="checkbox" />
5106+ <span data-i18n="Auto-lock a chosen persona to the chat">
5107+ Auto-lock a chosen persona to the chat
5108+ </span>
5109+ </label>
5110+ </div>
50645111 </div>
50655112 </div>
50665113 </div>
@@ -5087,7 +5134,7 @@
50875134 <div class="right_menu_button fa-solid fa-list-ul" id="rm_button_characters" title="Select/Create Characters" data-i18n="[title]Select/Create Characters"></div>
50885135 </div>
50895136 <div id="HotSwapWrapper" class="alignitemscenter flex-container margin0auto wide100p">
50905137 <div class="hotswap avatars_inline flex-container scroll-reset-container expander" data-i18n="[no_favs]Favorite characters to add them to HotSwaps" no_favs="Favorite characters to add them to HotSwaps"></div>
50915138 </div>
50925139 </div>
50935140 <hr>
@@ -5125,13 +5172,13 @@
51255172 </div>
51265173 </div>
51275174 <div class="flex-container flexFlowColumn expander flexNoGap">
51285175 <div id="avatar_div" class="avatar_div buttons_block alignitemsflexstart justifySpaceBetween flexnowrap">
51295176 <label id="avatar_div_div" class="add_avatar avatar" for="add_avatar_button" title="Click to select a new avatar for this character" data-i18n="[title]Click to select a new avatar for this character">
51305177 <img id="avatar_load_preview" src="img/ai4.png" alt="avatar">
51315178 <input hidden type="file" id="add_avatar_button" name="avatar" accept="image/*">
51325179 </label>
51335180 <div class="flex-container" id="avatar_controls">
51345181 <div class="form_create_bottom_buttons_block buttons_block">
51355182 <div id="rm_button_back" class="menu_button fa-solid fa-left-long "></div>
51365183 <!-- <div id="renameCharButton" class="menu_button fa-solid fa-user-pen" title="Rename Character"></div> -->
51375184 <div id="favorite_button" class="menu_button fa-solid fa-star" title="Add to Favorites" data-i18n="[title]Add to Favorites"></div>
@@ -5139,6 +5186,7 @@
51395186 <div id="advanced_div" class="menu_button fa-solid fa-book " title="Advanced Definitions" data-i18n="[title]Advanced Definition"></div>
51405187 <div id="world_button" class="menu_button fa-solid fa-globe" title="Character Lore&#10;&#10;Click to load&#10;Shift-click to open 'Link to World Info' popup" data-i18n="[title]world_button_title"></div>
51415188 <div class="chat_lorebook_button menu_button fa-solid fa-passport" title="Chat Lore&#10;Alt+Click to open the lorebook" data-i18n="[title]Chat Lore Alt+Click to open the lorebook"></div>
5189+ <div id="char_connections_button" class="menu_button fa-solid fa-face-smile" title="Connected Personas" data-i18n="[title]Connected Personas"></div>
51425190 <div id="export_button" class="menu_button fa-solid fa-file-export " title="Export and Download" data-i18n="[title]Export and Download"></div>
51435191 <!-- <div id="set_chat_scenario" class="menu_button fa-solid fa-scroll" title="Set a chat scenario override"></div> -->
51445192 <!-- <div id="set_character_world" class="menu_button fa-solid fa-globe" title="Set a character World Info / Lorebook"></div> -->
@@ -5146,7 +5194,7 @@
51465194 <label for="create_button" id="create_button_label" class="menu_button fa-solid fa-user-check" title="Create Character" data-i18n="[title]Create Character">
51475195 <input type="submit" id="create_button" name="create_button">
51485196 </label>
51495197 <div id="delete_button" class="menu_button fa-solid fa-skull red_button" title="Delete Character" data-i18n="[title]Delete Character"></div>
51505198 </div>
51515199 <label class="flex1 height100p" for="char-management-dropdown">
51525200 <select id="char-management-dropdown" class="text_pole">
@@ -5483,25 +5531,20 @@
54835531 <div class="flex-container wide100pLess70px character_select_container">
54845532 <div class="wide100p character_name_block">
54855533 <span class="ch_name flex1"></span>
5486- <div class="avatar-buttons">
5487- <button class="menu_button bind_user_name" title="Bind user name to that avatar" data-i18n="[title]Bind user name to that avatar">
5488- <i class="fa-fw fa-solid fa-user-edit fa-sm"></i>
5489- </button>
5490- <button class="menu_button set_persona_image" title="Change persona image" data-i18n="[title]Change persona image">
5491- <i class="fa-fw fa-solid fa-image fa-sm"></i>
5492- </button>
5493- <button class="menu_button set_default_persona" title="Select this as default persona for the new chats." data-i18n="[title]Select this as default persona for the new chats.">
5494- <i class="fa-fw fa-solid fa-crown fa-sm"></i>
5495- </button>
5496- <button class="menu_button duplicate_persona" title="Duplicate persona" data-i18n="[title]Duplicate persona">
5497- <i class="fa-fw fa-solid fa-clone fa-sm"></i>
5498- </button>
5499- <button class="menu_button delete_avatar" title="Delete persona" data-i18n="[title]Delete persona">
5500- <i class="fa-fw fa-solid fa-trash-alt fa-sm"></i>
5501- </button>
5502- </div>
55035534 </div>
55045535 <div class="ch_description"></div>
5536+ <div class="avatar_container_states buttons_block">
5537+ <div class="locked_to_chat_label avatar_state has_hover_label menu_button menu_button_icon disabled" title="Persona is locked to the current chat" data-i18n="[title]Persona is locked to the current chat">
5538+ <i class="icon fa-solid fa-lock fa-fw"></i>
5539+ <i class="label_icon icon fa-solid fa-comments fa-fw"></i>
5540+ <div class="label" data-i18n="Chat">Chat</div>
5541+ </div>
5542+ <div class="locked_to_character_label avatar_state has_hover_label menu_button menu_button_icon disabled " title="Persona is locked to the current character" data-i18n="[title]Persona is locked to the current character">
5543+ <i class="icon fa-solid fa-lock fa-fw"></i>
5544+ <i class="label_icon icon fa-solid fa-user fa-fw"></i>
5545+ <div class="label" data-i18n="Character">Character</div>
5546+ </div>
5547+ </div>
55055548 </div>
55065549 </div>
55075550 </div>
public/script.js+27 -10
@@ -235,6 +235,8 @@ import {
235235 initPersonas,
236236 setPersonaDescription,
237237 initUserAvatar,
238+ updatePersonaConnectionsAvatarList,
239+ isPersonaPanelOpen,
238240} from './scripts/personas.js';
239241import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js';
240242import { hideLoader, showLoader } from './scripts/loader.js';
@@ -983,7 +985,7 @@ async function firstLoadInit() {
983985 await initTokenizers();
984986 initBackgrounds();
985987 initAuthorsNote();
986988 await initPersonas();
987989 initRossMods();
988990 initStats();
989991 initCfg();
@@ -1362,6 +1364,14 @@ export function resultCheckStatus() {
13621364 stopStatusLoading();
13631365}
13641366
1367+
1368+/**
1369+ * Switches the currently selected character to the one with the given ID. (character index, not the character key!)
1370+ *
1371+ * If the character ID doesn't exist, if the chat is being saved, or if a group is being generated, this function does nothing.
1372+ * If the character is different from the currently selected one, it will clear the chat and reset any selected character or group.
1373+ * @param {number} id The ID of the character to switch to.
1374+ */
13651375export async function selectCharacterById(id) {
13661376 if (characters[id] === undefined) {
13671377 return;
@@ -1435,7 +1445,7 @@ function getCharacterBlock(item, id) {
14351445 }
14361446 // Populate the template
14371447 const template = $('#character_template .character_select').clone();
14381448 template.attr({ 'data-chid': id, 'id': `CharID${id}` });
14391449 template.find('img').attr('src', this_avatar).attr('alt', item.name);
14401450 template.find('.avatar').attr('title', `[Character] ${item.name}\nFile: ${item.avatar}`);
14411451 template.find('.ch_name').text(item.name).attr('title', `[Character] ${item.name}`);
@@ -1561,6 +1571,7 @@ export async function printCharacters(fullRefresh = false) {
15611571 });
15621572
15631573 favsToHotswap();
1574+ updatePersonaConnectionsAvatarList();
15641575}
15651576
15661577/** Checks the state of the current search, and adds/removes the search sorting option accordingly */
@@ -6331,7 +6342,7 @@ export async function renameCharacter(name = null, { silent = false, renameChats
63316342 if (newChId !== -1) {
63326343 // Select the character after the renaming
63336344 this_chid = -1;
63346345 await selectCharacterById(String(newChId));
63356346
63366347 // Async delay to update UI
63376348 await delay(1);
@@ -6608,7 +6619,7 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t
66086619 }
66096620
66106621 avatarTemplate.attr('data-type', entity.type);
66116622 avatarTemplate.attr({ 'data-chid': id, 'id': `CharID${id}` });
66126623 avatarTemplate.find('img').attr('src', this_avatar).attr('alt', entity.item.name);
66136624 avatarTemplate.attr('title', `[Character] ${entity.item.name}\nFile: ${entity.item.avatar}`);
66146625 if (highlightFavs) {
@@ -6623,8 +6634,14 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t
66236634 avatarTemplate.addClass(grpTemplate.attr('class'));
66246635 avatarTemplate.empty();
66256636 avatarTemplate.append(grpTemplate.children());
6637+ avatarTemplate.attr({ 'data-grid': id, 'data-chid': null });
66266638 avatarTemplate.attr('title', `[Group] ${entity.item.name}`);
66276639 }
6640+ else if (entity.type === 'persona') {
6641+ avatarTemplate.attr({ 'data-pid': id, 'data-chid': null });
6642+ avatarTemplate.find('img').attr('src', getUserAvatar(entity.item.avatar));
6643+ avatarTemplate.attr('title', `[Persona] ${entity.item.name}\nFile: ${entity.item.avatar}`);
6644+ }
66286645
66296646 if (interactable) {
66306647 avatarTemplate.addClass(INTERACTABLE_CONTROL_CLASS);
@@ -6861,14 +6878,14 @@ export function changeMainAPI() {
68616878 forceCharacterEditorTokenize();
68626879}
68636880
68646881export function setUserName(value, { toastPersonaNameChange = true } = {}) {
68656882 name1 = value;
68666883 if (name1 === undefined || name1 == '')
68676884 name1 = default_user_name;
68686885 console.log(`User name changed to ${name1}`);
68696886 $('#your_name').valtext(name1);
68706887 if (toastPersonaNameChange && power_user.persona_show_notifications && !isPersonaPanelOpen()) {
68716888 toastr.success(t`Your messages will now be sent as ${name1}`, t`Current personaPersona updatedChanged`);
68726889 }
68736890 saveSettingsDebounced();
68746891}
@@ -6920,7 +6937,7 @@ export async function getSettings() {
69206937 settings = JSON.parse(data.settings);
69216938 if (settings.username !== undefined && settings.username !== '') {
69226939 name1 = settings.username;
69236940 $('#your_name').valtext(name1);
69246941 }
69256942
69266943 accountStorage.init(settings?.accountStorage);
@@ -10054,7 +10071,7 @@ jQuery(async function () {
1005410071 });
1005510072
1005610073 $(document).on('click', '.character_select', async function () {
1005710074 const id = Number($(this).attr('data-chid'));
1005810075 await selectCharacterById(id);
1005910076 });
1006010077
public/scripts/BulkEditOverlay.js+8 -8
@@ -672,7 +672,7 @@ class BulkEditOverlay {
672672 * @param {HTMLElement} currentCharacter - The html element of the currently toggled character
673673 */
674674 handleShiftClick = (currentCharacter) => {
675675 const characterId = Number(currentCharacter.getAttribute('data-chid'));
676676 const select = !this.selectedCharacters.includes(characterId);
677677
678678 if (this.lastSelected.characterId && this.lastSelected.select !== undefined) {
@@ -691,7 +691,7 @@ class BulkEditOverlay {
691691 * @param {boolean} [param1.markState] - Whether the toggle of this character should be remembered as the last done toggle
692692 */
693693 toggleSingleCharacter = (character, { markState = true } = {}) => {
694694 const characterId = Number(character.getAttribute('data-chid'));
695695
696696 const select = !this.selectedCharacters.includes(characterId);
697697 const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass);
@@ -699,11 +699,11 @@ class BulkEditOverlay {
699699 if (select) {
700700 character.classList.add(BulkEditOverlay.selectedClass);
701701 if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;
702702 this.#selectedCharacters.push(String(characterId));
703703 } else {
704704 character.classList.remove(BulkEditOverlay.selectedClass);
705705 if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
706706 this.#selectedCharacters = this.#selectedCharacters.filter(item => String(characterId) !== item);
707707 }
708708
709709 this.updateSelectedCount();
@@ -732,15 +732,15 @@ class BulkEditOverlay {
732732 * @param {boolean} select - <c>true</c> if the characters in the range are to be selected, <c>false</c> if deselected
733733 */
734734 toggleCharactersInRange = (currentCharacter, select) => {
735735 const currentCharacterId = Number(currentCharacter.getAttribute('data-chid'));
736736 const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));
737737
738738 const startIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === Number(this.lastSelected.characterId));
739739 const endIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === currentCharacterId);
740740
741741 for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) {
742742 const character = characters[i];
743743 const characterId = Number(character.getAttribute('data-chid'));
744744 const isCharacterSelected = this.selectedCharacters.includes(characterId);
745745
746746 // Only toggle the character if it wasn't on the state we have are toggling towards.
public/scripts/RossAscends-mods.js+3 -3
@@ -281,7 +281,7 @@ async function RA_autoloadchat() {
281281 if (active_character !== null && active_character !== undefined) {
282282 const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character);
283283 if (active_character_id !== -1) {
284284 await selectCharacterById(String(active_character_id));
285285
286286 // Do a little tomfoolery to spoof the tag selector
287287 const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`);
@@ -894,14 +894,14 @@ export function initRossMods() {
894894
895895 // when a char is selected from the list, save their name as the auto-load character for next page load
896896 $(document).on('click', '.character_select', function () {
897897 const characterId = $(this).attr('chid') || $(this).data('id-chid');
898898 setActiveCharacter(characterId);
899899 setActiveGroup(null);
900900 saveSettingsDebounced();
901901 });
902902
903903 $(document).on('click', '.group_select', function () {
904904 const groupId = $(this).attr('data-chid') || $(this).attr('grid') || $(this).data('id-grid');
905905 setActiveCharacter(null);
906906 setActiveGroup(groupId);
907907 saveSettingsDebounced();
public/scripts/group-chats.js+5 -5
@@ -691,7 +691,7 @@ export function getGroupBlock(group) {
691691
692692 const template = $('#group_list_template .group_select').clone();
693693 template.data('id', group.id);
694694 template.attr('data-grid', group.id);
695695 template.find('.ch_name').text(group.name).attr('title', `[Group] ${group.name}`);
696696 template.find('.group_fav_icon').css('display', 'none');
697697 template.addClass(group.fav ? 'is_fav' : '');
@@ -1359,7 +1359,7 @@ function getGroupCharacterBlock(character) {
13591359 template.data('id', character.avatar);
13601360 template.find('.avatar img').attr({ 'src': avatar, 'title': character.avatar });
13611361 template.find('.ch_name').text(character.name);
13621362 template.attr('data-chid', characters.indexOf(character));
13631363 template.find('.ch_fav').val(isFav);
13641364 template.toggleClass('is_fav', isFav);
13651365
@@ -1645,7 +1645,7 @@ async function onGroupActionClick(event) {
16451645 }
16461646
16471647 if (action === 'speak') {
16481648 const chid = Number(member.attr('data-chid'));
16491649 if (Number.isInteger(chid)) {
16501650 Generate('normal', { force_chid: chid });
16511651 }
@@ -1700,7 +1700,7 @@ function openCharacterDefinition(characterSelect) {
17001700 return;
17011701 }
17021702
17031703 const chid = characterSelect.attr('data-chid');
17041704
17051705 if (chid === null || chid === undefined) {
17061706 return;
@@ -2018,7 +2018,7 @@ jQuery(() => {
20182018 }
20192019
20202020 $(document).on('click', '.group_select', function () {
20212021 const groupId = $(this).attr('data-chid') || $(this).attr('grid') || $(this).data('id-grid');
20222022 openGroupById(groupId);
20232023 });
20242024 $('#rm_group_filter').on('input', filterGroupMembers);
public/scripts/personas.js+1046 -273
@@ -1,4 +1,6 @@
11import {
2+ buildAvatarList,
3+ characterToEntity,
24 characters,
35 chat,
46 chat_metadata,
@@ -7,7 +9,10 @@ import {
79 event_types,
810 getRequestHeaders,
911 getThumbnailUrl,
12+ groupToEntity,
13+ menu_type,
1014 name1,
15+ name2,
1116 reloadCurrentChat,
1217 saveChatConditional,
1318 saveMetadata,
@@ -17,23 +22,63 @@ import {
1722} from '../script.js';
1823import { persona_description_positions, power_user } from './power-user.js';
1924import { getTokenCountAsync } from './tokenizers.js';
2025import { PAGINATION_TEMPLATE, clearInfoBlock, debounce, delay, download, ensureImageFormatSupported, flashHighlight, getBase64Async, getCharIndex, isFalseBoolean, isTrueBoolean, onlyUnique, parseJsonFile, setInfoBlock } from './utils.js';
2126import { debounce_timeout } from './constants.js';
2227import { FILTER_TYPES, FilterHelper } from './filters.js';
2328import { groups, selected_group } from './group-chats.js';
2429import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
2530import { t } from './i18n.js';
2631import { openWorldInfoEditor, world_names } from './world-info.js';
2732import { renderTemplateAsync } from './templates.js';
33+import { saveMetadataDebounced } from './extensions.js';
2834import { accountStorage } from './util/AccountStorage.js';
35+import { SlashCommand } from './slash-commands/SlashCommand.js';
36+import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
37+import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
38+import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
39+import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
40+
41+/**
42+ * @typedef {object} PersonaConnection A connection between a character and a character or group entity
43+ * @property {'character' | 'group'} type - Type of connection
44+ * @property {string} id - ID of the connection (character key (avatar url), group id)
45+ */
46+
47+/** @typedef {'chat' | 'character' | 'default'} PersonaLockType Type of the persona lock */
48+
49+/**
50+ * @typedef {object} PersonaState
51+ * @property {string} avatarId - The avatar id of the persona
52+ * @property {boolean} default - Whether this persona is the default one for all new chats
53+ * @property {object} locked - An object containing the lock states
54+ * @property {boolean} locked.chat - Whether the persona is locked to the currently open chat
55+ * @property {boolean} locked.character - Whether the persona is locked to the currently open character or group
56+ */
57+
58+const USER_AVATAR_PATH = 'User Avatars/';
2959
3060let savePersonasPage = 0;
3161const GRID_STORAGE_KEY = 'Personas_GridView';
3262const DEFAULT_DEPTH = 2;
3363const DEFAULT_ROLE = 0;
64+
65+/** @type {string} The currently selected persona (identified by its avatar) */
3466export let user_avatar = '';
67+
68+/** @type {FilterHelper} Filter helper for the persona list */
3569export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
3670
71+/** @type {function(string): void} */
72+let navigateToAvatar = () => { };
73+
74+/**
75+ * Checks if the Persona Management panel is currently open
76+ * @returns {boolean}
77+ */
78+export function isPersonaPanelOpen() {
79+ return document.querySelector('#persona-management-button .drawer-content')?.classList.contains('openDrawer') ?? false;
80+}
81+
3782function switchPersonaGridView() {
3883 const state = accountStorage.getItem(GRID_STORAGE_KEY) === 'true';
3984 $('#user_avatar_block').toggleClass('gridView', state);
@@ -45,33 +90,31 @@ function switchPersonaGridView() {
4590 * @returns {string} User avatar URL
4691 */
4792export function getUserAvatar(avatarImg) {
4893 return `User Avatars/${USER_AVATAR_PATH}${avatarImg}`;
4994}
5095
5196export function initUserAvatar(avatar) {
5297 user_avatar = avatar;
5398 reloadUserAvatar();
5499 highlightSelectedAvatarupdatePersonaUIStates();
55100}
56101
57102/**
58103 * Sets a user avatar file
59104 * @param {string} imgfile Link to an image file
105+ * @param {object} [options] Optional settings
106+ * @param {boolean} [options.toastPersonaNameChange=true] Whether to show a toast when the persona name is changed
107+ * @param {boolean} [options.navigateToCurrent=false] Whether to navigate to the current persona after setting the avatar
60108 */
61-export function setUserAvatar(imgfile) {
109+export function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigateToCurrent = false } = {}) {
62110 user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('imgfiledata-avatar-id');
63111 reloadUserAvatar();
64- highlightSelectedAvatar();
112+ updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });
65113 selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });
66114 saveSettingsDebounced();
67115 $('.zoomed_avatar[forchar]').remove();
68116}
69117
70-function highlightSelectedAvatar() {
71- $('#user_avatar_block .avatar-container').removeClass('selected');
72- $(`#user_avatar_block .avatar-container[imgfile="${user_avatar}"]`).addClass('selected');
73-}
74-
75118function reloadUserAvatar(force = false) {
76119 $('.mes').each(function () {
77120 const avatarImg = $(this).find('.avatar img');
@@ -131,29 +174,49 @@ function verifyPersonaSearchSortRule() {
131174
132175/**
133176 * Gets a rendered avatar block.
134177 * @param {string} nameavatarId Avatar file name
135178 * @returns {JQuery<HTMLElement>} Avatar block
136179 */
137180function getUserAvatarBlock(nameavatarId) {
138181 const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
139182 const template = $('#user_avatar_template .avatar-container').clone();
140183 const personaName = power_user.personas[nameavatarId];
141184 const personaDescription = power_user.persona_descriptions[nameavatarId]?.description;
185+
142186 template.find('.ch_name').text(personaName || '[Unnamed Persona]');
143187 template.find('.ch_description').text(personaDescription || $('#user_avatar_block').attr('no_desc_text')).toggleClass('text_muted', !personaDescription);
144188 template.attr('imgfiledata-avatar-id', nameavatarId);
145189 template.find('.avatar').attr('imgfiledata-avatar-id', nameavatarId).attr('title', nameavatarId);
146190 template.toggleClass('default_persona', nameavatarId === power_user.default_persona);
147191 let avatarUrl = getUserAvatar(nameavatarId);
148192 if (isFirefox) {
149193 avatarUrl += '?t=' + Date.now();
150194 }
151195 template.find('img').attr('src', avatarUrl);
196+
197+ // Make sure description block has at least three rows. Otherwise height looks inconsistent. I don't have a better idea for this.
198+ const currentText = template.find('.ch_description').text();
199+ if (currentText.split('\n').length < 3) {
200+ template.find('.ch_description').text(currentText + '\n\xa0\n\xa0');
201+ }
202+
152203 $('#user_avatar_block').append(template);
153204 return template;
154205}
155206
156207/**
208+ * Initialize missing personas in the power user settings.
209+ * @param {string[]} avatarsList List of avatar file names
210+ */
211+function addMissingPersonas(avatarsList) {
212+ for (const persona of avatarsList) {
213+ if (!power_user.personas[persona]) {
214+ initPersona(persona, '[Unnamed Persona]', '');
215+ }
216+ }
217+}
218+
219+/**
157220 * Gets a list of user avatars.
158221 * @param {boolean} doRender Whether to render the list
159222 * @param {string} openPageAt Item to be opened at
@@ -175,6 +238,8 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
175238 return allEntities;
176239 }
177240
241+ // If any persona is missing from the power user settings, we add it
242+ addMissingPersonas(allEntities);
178243 // Before printing the personas, we check if we should enable/disable search sorting
179244 verifyPersonaSearchSortRule();
180245
@@ -203,7 +268,7 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
203268 for (const item of data) {
204269 $(listId).append(getUserAvatarBlock(item));
205270 }
206271 highlightSelectedAvatarupdatePersonaUIStates();
207272 },
208273 afterSizeSelectorChange: function (e) {
209274 accountStorage.setItem(storageKey, e.target.value);
@@ -216,14 +281,16 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
216281 },
217282 });
218283
219284 ifnavigateToAvatar = (openPageAtavatarId) => {
220285 const avatarIndex = entities.indexOf(openPageAtavatarId);
221286 const page = Math.floor(avatarIndex / perPage) + 1;
222287
223288 if (avatarIndex !== -1) {
224289 $('#persona_pagination_container').pagination('go', page);
225290 }
226291 };
292+
293+ openPageAt && navigateToAvatar(openPageAt);
227294
228295 return allEntities;
229296 }
@@ -233,7 +300,7 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
233300 * Uploads an avatar file to the server
234301 * @param {string} url URL for the avatar file
235302 * @param {string} [name] Optional name for the avatar file
236303 * @returns {Promise} Promise objectthat representingresolves when the AJAXavatar requestis uploaded
237304 */
238305async function uploadUserAvatar(url, name) {
239306 const fetchResult = await fetch(url);
@@ -246,18 +313,17 @@ async function uploadUserAvatar(url, name) {
246313 formData.append('overwrite_name', name);
247314 }
248315
249- return jQuery.ajax({
316+ const headers = getRequestHeaders();
250- type: 'POST',
317+ delete headers['Content-Type'];
251- url: '/api/avatars/upload',
318+
252- data: formData,
319+ await fetch('/api/avatars/upload', {
253- beforeSend: () => { },
320+ method: 'POST',
254321 cacheheaders: falseheaders,
255322 contentTypecache: false'no-cache',
256323 processDatabody: falseformData,
257- success: async function () {
258- await getUserAvatars(true, name);
259- },
260324 });
325+
326+ await getUserAvatars(true, name);
261327}
262328
263329async function changeUserAvatar(e) {
@@ -298,33 +364,35 @@ async function changeUserAvatar(e) {
298364 formData.set('avatar', convertedFile);
299365 }
300366
301- jQuery.ajax({
367+ const headers = getRequestHeaders();
302- type: 'POST',
368+ delete headers['Content-Type'];
303- url: url,
304- data: formData,
305- beforeSend: () => { },
306- cache: false,
307- contentType: false,
308- processData: false,
309- success: async function (data) {
310- // If the user uploaded a new avatar, we want to make sure it's not cached
311- const name = formData.get('overwrite_name');
312- if (name) {
313- await fetch(getUserAvatar(String(name)), { cache: 'no-cache' });
314- reloadUserAvatar(true);
315- }
316-
317- if (!name && data.path) {
318- await getUserAvatars();
319- await delay(500);
320- await createPersona(data.path);
321- }
322369
323- await getUserAvatars(true, name || data.path);
370+ const response = await fetch(url, {
324- },
371+ method: 'POST',
325- error: (jqXHR, exception) => { },
372+ headers: headers,
373+ cache: 'no-cache',
374+ body: formData,
326375 });
327376
377+ if (response.ok) {
378+ const data = await response.json();
379+
380+ // If the user uploaded a new avatar, we want to make sure it's not cached
381+ const name = formData.get('overwrite_name');
382+ if (name) {
383+ await fetch(getUserAvatar(String(name)), { cache: 'no-cache' });
384+ reloadUserAvatar(true);
385+ }
386+
387+ if (!name && data.path) {
388+ await getUserAvatars();
389+ await delay(500);
390+ await createPersona(data.path);
391+ }
392+
393+ await getUserAvatars(true, name || data.path);
394+ }
395+
328396 // Will allow to select the same file twice in a row
329397 form.reset();
330398}
@@ -384,13 +452,25 @@ export function initPersona(avatarId, personaName, personaDescription) {
384452 saveSettingsDebounced();
385453}
386454
455+/**
456+ * Converts a character given character (either by character id or the current character) to a persona.
457+ *
458+ * If a persona with the same name already exists, the user is prompted to confirm whether or not to overwrite it.
459+ * If the character description contains {{char}} or {{user}} macros, the user is prompted to confirm whether or not to swap them for persona macros.
460+ *
461+ * The function creates a new persona with the same name as the character, and sets the persona description to the character description with the macros swapped.
462+ * The function also saves the settings and refreshes the persona selector.
463+ *
464+ * @param {number} [characterId] - The ID of the character to convert to a persona. Defaults to the current character ID.
465+ * @returns {Promise<boolean>} A promise that resolves to true if the character was converted, false otherwise.
466+ */
387467export async function convertCharacterToPersona(characterId = null) {
388468 if (null === characterId) characterId = this_chid;
389469
390470 const avatarUrl = characters[characterId]?.avatar;
391471 if (!avatarUrl) {
392472 console.log('No avatar found for this character');
393473 return false;
394474 }
395475
396476 const name = characters[characterId]?.name;
@@ -401,7 +481,7 @@ export async function convertCharacterToPersona(characterId = null) {
401481 const confirm = await Popup.show.confirm(t`Overwrite Existing Persona`, t`This character exists as a persona already. Do you want to overwrite it?`);
402482 if (!confirm) {
403483 console.log('User cancelled the overwrite of the persona');
404484 return false;
405485 }
406486 }
407487
@@ -439,6 +519,7 @@ export async function convertCharacterToPersona(characterId = null) {
439519 await getUserAvatars(true, overwriteName);
440520 // Reload the persona description
441521 setPersonaDescription();
522+ return true;
442523}
443524
444525/**
@@ -450,7 +531,12 @@ const countPersonaDescriptionTokens = debounce(async () => {
450531 $('#persona_description_token_count').text(String(count));
451532}, debounce_timeout.relaxed);
452533
534+/**
535+ * Updates the UI for the Persona Management page with the current persona values
536+ */
453537export function setPersonaDescription() {
538+ $('#your_name').text(name1);
539+
454540 if (power_user.persona_description_position === persona_description_positions.AFTER_CHAR) {
455541 power_user.persona_description_position = persona_description_positions.IN_PROMPT;
456542 }
@@ -468,116 +554,219 @@ export function setPersonaDescription() {
468554 .prop('selected', String(true));
469555 $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);
470556 countPersonaDescriptionTokens();
557+
558+ updatePersonaUIStates();
559+ updatePersonaConnectionsAvatarList();
471560}
472561
473-export function autoSelectPersona(name) {
562+/**
474- for (const [key, value] of Object.entries(power_user.personas)) {
563+ * Gets a list of all personas in the current chat.
475- if (value === name) {
564+ *
476- console.log(`Auto-selecting persona ${key} for name ${name}`);
565+ * @returns {string[]} An array of persona identifiers
477- setUserAvatar(key);
566+ */
478- return;
567+function getPersonasOfCurrentChat() {
479- }
568+ const personas = chat.filter(message => String(message.force_avatar).startsWith(USER_AVATAR_PATH))
480- }
569+ .map(message => message.force_avatar.replace(USER_AVATAR_PATH, ''))
570+ .filter(onlyUnique);
571+ return personas;
481572}
482573
483574/**
484575 * UpdatesBuilds thea namelist of a persona ifavatars itand existspopulates the given block element with them.
485- * @param {string} avatarId User avatar id
576+ *
486577 * @param {stringHTMLElement} newNameblock New- nameThe forHTML element where the personaavatar list will be rendered
578+ * @param {string[]} personas - An array of persona identifiers
579+ * @param {Object} [options] - Optional settings for building the avatar list
580+ * @param {boolean} [options.empty=true] - Whether to clear the block element before adding avatars
581+ * @param {boolean} [options.interactable=false] - Whether the avatars should be interactable
582+ * @param {boolean} [options.highlightFavs=true] - Whether to highlight favorite avatars
487583 */
488-async function updatePersonaNameIfExists(avatarId, newName) {
584+export function buildPersonaAvatarList(block, personas, { empty = true, interactable = false, highlightFavs = true } = {}) {
489- if (avatarId in power_user.personas) {
585+ const personaEntities = personas.map(avatar => ({
490- power_user.personas[avatarId] = newName;
586+ type: 'persona',
491- console.log(`Updated persona name for ${avatarId} to ${newName}`);
587+ id: avatar,
492- } else {
588+ item: {
493- power_user.personas[avatarId] = newName;
589+ name: power_user.personas[avatar],
494- power_user.persona_descriptions[avatarId] = {
590+ description: power_user.persona_descriptions[avatar]?.description || '',
495591 descriptionavatar: ''avatar,
496- position: persona_description_positions.IN_PROMPT,
592+ fav: power_user.default_persona === avatar,
497- depth: DEFAULT_DEPTH,
593+ },
498- role: DEFAULT_ROLE,
594+ }));
499- lorebook: '',
500- };
501- console.log(`Created persona name for ${avatarId} as ${newName}`);
502- }
503595
504- await getUserAvatars(true, avatarId);
596+ buildAvatarList($(block), personaEntities, { empty: empty, interactable: interactable, highlightFavs: highlightFavs });
505- saveSettingsDebounced();
506597}
507598
508-async function bindUserNameToPersona(e) {
599+/**
509- e?.stopPropagation();
600+ * Displays avatar connections for the current persona.
510- const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
601+ * Converts connections to entities and populates the avatar list. Shows a message if no connections are found.
602+ */
603+export function updatePersonaConnectionsAvatarList() {
604+ /** @type {PersonaConnection[]} */
605+ const connections = power_user.persona_descriptions[user_avatar]?.connections ?? [];
606+ const entities = connections.map(connection => {
607+ if (connection.type === 'character') {
608+ const character = characters.find(c => c.avatar === connection.id);
609+ if (character) return characterToEntity(character, getCharIndex(character));
610+ }
611+ if (connection.type === 'group') {
612+ const group = groups.find(g => g.id === connection.id);
613+ if (group) return groupToEntity(group);
614+ }
615+ return undefined;
616+ }).filter(entity => entity?.item !== undefined);
511617
512618 if (!avatarIdentities.length) {
513- console.warn('No avatar id found');
619+ buildAvatarList($('#persona_connections_list'), entities, { interactable: true });
514- return;
620+ else
515- }
621+ $('#persona_connections_list').text(t`[No character connections. Click one of the buttons above to connect this persona.]`);
622+}
516623
517- let personaUnbind = false;
518- const existingPersona = power_user.personas[avatarId];
519- const personaName = await Popup.show.input(
520- t`Enter a name for this persona:`,
521- t`(If empty name is provided, this will unbind the name from this avatar)`,
522- existingPersona || '',
523- { onClose: (p) => { personaUnbind = p.value === '' && p.result === POPUP_RESULT.AFFIRMATIVE; } });
524624
525- // If the user clicked cancel, don't do anything
625+/**
526- if (personaName === null && !personaUnbind) {
626+ * Displays a popup for persona selection and returns the selected persona.
527- return;
627+ *
528- }
628+ * @param {string} title - The title to display in the popup
629+ * @param {string} text - The text to display in the popup
630+ * @param {string[]} personas - An array of persona ids to display for selection
631+ * @param {Object} [options] - Optional settings for the popup
632+ * @param {string} [options.okButton='None'] - The label for the OK button
633+ * @param {(element: HTMLElement, ev: MouseEvent) => any} [options.shiftClickHandler] - A function to handle shift-click
634+ * @param {boolean|string[]} [options.highlightPersonas=false] - Whether to highlight personas - either by providing a list of persona keys, or true to highlight all present in current chat
635+ * @param {PersonaConnection} [options.targetedChar] - The targeted character or gorup for this persona selection
636+ * @returns {Promise<string?>} - A promise that resolves to the selected persona id or null if no selection was made
637+ */
638+export async function askForPersonaSelection(title, text, personas, { okButton = 'None', shiftClickHandler = undefined, highlightPersonas = false, targetedChar = undefined } = {}) {
639+ const content = document.createElement('div');
640+ const titleElement = document.createElement('h3');
641+ titleElement.textContent = title;
642+ content.appendChild(titleElement);
643+
644+ const textElement = document.createElement('div');
645+ textElement.classList.add('multiline', 'm-b-1');
646+ textElement.textContent = text;
647+ content.appendChild(textElement);
648+
649+ const personaListBlock = document.createElement('div');
650+ personaListBlock.classList.add('persona-list', 'avatars_inline', 'avatars_multiline', 'text_muted');
651+ content.appendChild(personaListBlock);
652+
653+ if (personas.length > 0)
654+ buildPersonaAvatarList(personaListBlock, personas, { interactable: true });
655+ else
656+ personaListBlock.textContent = t`[Currently no personas connected]`;
657+
658+ const personasToHighlight = highlightPersonas instanceof Array ? highlightPersonas : (highlightPersonas ? getPersonasOfCurrentChat() : []);
659+
660+ // Make the persona blocks clickable and close the popup
661+ personaListBlock.querySelectorAll('.avatar[data-type="persona"]').forEach(block => {
662+ if (!(block instanceof HTMLElement)) return;
663+ block.dataset.result = String(100 + personas.indexOf(block.dataset.pid));
664+
665+ if (shiftClickHandler) {
666+ block.addEventListener('click', function (ev) {
667+ if (ev.shiftKey) {
668+ shiftClickHandler(this, ev);
669+ }
670+ });
671+ }
529672
530673 if (personaNamepersonasToHighlight && personaNamepersonasToHighlight.length > 0includes(block.dataset.pid)) {
531- // If the user clicked ok and entered a name, bind the name to the persona
674+ block.classList.add('is_active');
532- console.log(`Binding persona ${avatarId} to name ${personaName}`);
675+ block.title = block.title + '\n\n' + t`Was used in current chat.`;
533- power_user.personas[avatarId] = personaName;
676+ if (block.classList.contains('is_fav')) block.title = block.title + '\n' + t`Is your default persona.`;
534- const descriptor = power_user.persona_descriptions[avatarId];
535- const isCurrentPersona = avatarId === user_avatar;
536-
537- // Create a description object if it doesn't exist
538- if (!descriptor) {
539- // If the user is currently using this persona, set the description to the current description
540- power_user.persona_descriptions[avatarId] = {
541- description: isCurrentPersona ? power_user.persona_description : '',
542- position: isCurrentPersona ? power_user.persona_description_position : persona_description_positions.IN_PROMPT,
543- depth: isCurrentPersona ? power_user.persona_description_depth : DEFAULT_DEPTH,
544- role: isCurrentPersona ? power_user.persona_description_role : DEFAULT_ROLE,
545- lorebook: isCurrentPersona ? power_user.persona_description_lorebook : '',
546- };
547677 }
678+ });
548679
549- // If the user is currently using this persona, update the name
680+ /** @type {import('./popup.js').CustomPopupButton[]} */
550- if (isCurrentPersona) {
681+ const customButtons = [];
551- console.log(`Auto-updating user name to ${personaName}`);
682+ if (targetedChar) {
552- setUserName(personaName);
683+ customButtons.push({
684+ text: t`Remove All Connections`,
685+ result: 2,
686+ action: () => {
687+ for (const [personaId, description] of Object.entries(power_user.persona_descriptions)) {
688+ /** @type {PersonaConnection[]} */
689+ const connections = description.connections;
690+ if (connections) {
691+ power_user.persona_descriptions[personaId].connections = connections.filter(c => {
692+ if (targetedChar.type == c.type && targetedChar.id == c.id) return false;
693+ return true;
694+ });
695+ }
696+ }
697+
698+ saveSettingsDebounced();
699+ updatePersonaConnectionsAvatarList();
700+ if (power_user.persona_show_notifications) {
701+ const name = targetedChar.type == 'character' ? characters[targetedChar.id]?.name : groups[targetedChar.id]?.name;
702+ toastr.info(t`All connections to ${name} have been removed.`, t`Personas Unlocked`);
703+ }
704+ },
705+ });
706+ }
707+
708+ const popup = new Popup(content, POPUP_TYPE.TEXT, '', { okButton: okButton, customButtons: customButtons });
709+ const result = await popup.show();
710+ return Number(result) >= 100 ? personas[Number(result) - 100] : null;
711+}
712+
713+/**
714+ * Automatically selects a persona based on the given name if a matching persona exists.
715+ * @param {string} name - The name to search for
716+ * @returns {boolean} True if a matching persona was found and selected, false otherwise
717+ */
718+export function autoSelectPersona(name) {
719+ for (const [key, value] of Object.entries(power_user.personas)) {
720+ if (value === name) {
721+ console.log(`Auto-selecting persona ${key} for name ${name}`);
722+ setUserAvatar(key);
723+ return true;
553724 }
554725 } else {
555- // If the user clicked ok, but didn't enter a name, delete the persona
726+ return false;
556- console.log(`Unbinding persona ${avatarId}`);
727+}
557- delete power_user.personas[avatarId];
728+
558- delete power_user.persona_descriptions[avatarId];
729+/**
730+ * Renames the persona with the given avatar ID by showing a popup to enter a new name.
731+ * @param {string} avatarId - ID of the avatar to rename
732+ * @returns {Promise<boolean>} A promise that resolves to true if the persona was renamed, false otherwise
733+ */
734+async function renamePersona(avatarId) {
735+ const currentName = power_user.personas[avatarId];
736+ const newName = await Popup.show.input(t`Rename Persona`, t`Enter a new name for this persona:`, currentName);
737+ if (!newName || newName === currentName) {
738+ console.debug('User cancelled renaming persona or name is unchanged');
739+ return false;
740+ }
741+
742+ power_user.personas[avatarId] = newName;
743+ console.log(`Renamed persona ${avatarId} to ${newName}`);
744+
745+ if (avatarId === user_avatar) {
746+ setUserName(newName);
559747 }
560748
561749 saveSettingsDebounced();
562750 await getUserAvatars(true, avatarId);
751+ updatePersonaUIStates();
563752 setPersonaDescription();
753+ return true;
564754}
565755
566-function selectCurrentPersona() {
756+/**
757+ * Selects the persona with the currently set avatar ID by updating the user name and persona description, and updating the locked persona if the setting is enabled.
758+ * @param {object} [options={}] - Optional settings
759+ * @param {boolean} [options.toastPersonaNameChange=true] - Whether to show a toast when the persona name is changed
760+ * @returns {Promise<void>}
761+ */
762+async function selectCurrentPersona({ toastPersonaNameChange = true } = {}) {
567763 const personaName = power_user.personas[user_avatar];
568764 if (personaName) {
569765 const lockedPersonashouldAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata['persona'];
570- if (lockedPersona && lockedPersona !== user_avatar && power_user.persona_show_notifications) {
571- toastr.info(
572- t`To permanently set "${personaName}" as the selected persona, unlock and relock it using the "Lock" button. Otherwise, the selection resets upon reloading the chat.`,
573- t`This chat is locked to a different persona (${power_user.personas[lockedPersona]}).`,
574- { timeOut: 10000, extendedTimeOut: 20000, preventDuplicates: true },
575- );
576- }
577766
578767 if (personaName !== name1) {
579768 console.log(`Auto-updating user name to ${personaName}`);
580- setUserName(personaName);
769+ setUserName(personaName, { toastPersonaNameChange: !shouldAutoLock && toastPersonaNameChange });
581770 }
582771
583772 const descriptor = power_user.persona_descriptions[user_avatar];
@@ -600,67 +789,144 @@ function selectCurrentPersona() {
600789 depth: DEFAULT_DEPTH,
601790 role: DEFAULT_ROLE,
602791 lorebook: '',
792+ connections: [],
603793 };
604794 }
605795
606796 setPersonaDescription();
797+
798+ // Update the locked persona if setting is enabled
799+ if (shouldAutoLock) {
800+ chat_metadata['persona'] = user_avatar;
801+ console.log(`Auto locked persona to ${user_avatar}`);
802+ if (toastPersonaNameChange && power_user.persona_show_notifications) {
803+ toastr.success(`Persona ${personaName} selected and auto-locked to current chat`, t`Persona Selected`);
804+ }
805+ saveMetadataDebounced();
806+ updatePersonaUIStates();
807+ }
808+
809+ // As the last step, inform user if the persona is only temporarily chosen
810+ if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
811+ const temporary = getPersonaTemporaryLockInfo();
812+ if (temporary.isTemporary) {
813+ toastr.info(t`This persona is only temporarily chosen. Click for more info.`, t`Temporary Persona`, {
814+ preventDuplicates: true, onclick: () => {
815+ toastr.info(temporary.info.replaceAll('\n', '<br />'), t`Temporary Persona`, { escapeHtml: false });
816+ },
817+ });
818+ }
819+ }
607820 }
608821}
609822
610823/**
611824 * Checks if thea personaconnection is locked for the current chat.character or group edit menu
825+ * @param {PersonaConnection} connection - Connection to check
826+ * @returns {boolean} Whether the connection is locked
827+ */
828+export function isPersonaConnectionLocked(connection) {
829+ return (!selected_group && connection.type === 'character' && connection.id === characters[this_chid]?.avatar)
830+ || (selected_group && connection.type === 'group' && connection.id === selected_group);
831+}
832+
833+/**
834+ * Checks if the persona is locked
835+ * @param {PersonaLockType} type - Lock type
612836 * @returns {boolean} Whether the persona is locked
613837 */
614838export function isPersonaLocked(type = 'chat') {
615- return !!chat_metadata['persona'];
839+ switch (type) {
840+ case 'default':
841+ return power_user.default_persona === user_avatar;
842+ case 'chat':
843+ return chat_metadata['persona'] == user_avatar;
844+ case 'character': {
845+ return !!power_user.persona_descriptions[user_avatar]?.connections?.some(isPersonaConnectionLocked);
846+ }
847+ default: throw new Error(`Unknown persona lock type: ${type}`);
848+ }
616849}
617850
618851/**
619852 * Locks or unlocks the persona for the current chat.
620853 * @param {boolean} state Desired lock state
854+ * @param {PersonaLockType} type - Lock type
621855 * @returns {Promise<void>}
622856 */
623857export async function setPersonaLockState(state, type = 'chat') {
624858 return state ? await lockPersona(type) : await unlockPersona(type);
625859}
626860
627861/**
628862 * Toggle the persona lock state for the current chat.
629- * @returns {Promise<void>}
863+ * @param {PersonaLockType} type - Lock type
864+ * @returns {Promise<boolean>} - Whether the persona was locked
630865 */
631866export async function togglePersonaLock(type = 'chat') {
632867 returnif (isPersonaLocked(type)) {
633868 ? await unlockPersona(type);
634- : await lockPersona();
869+ return false;
870+ } else {
871+ await lockPersona(type);
872+ return true;
873+ }
635874}
636875
637876/**
638877 * Unlock the persona for the current chat.
878+ * @param {PersonaLockType} type - Lock type
879+ * @returns {Promise<void>}
639880 */
640881async function unlockPersona(type = 'chat') {
641882 ifswitch (chat_metadata['persona']type) {
642- console.log(`Unlocking persona for this chat ${chat_metadata['persona']}`);
883+ case 'default': {
643- delete chat_metadata['persona'];
884+ // TODO: Make this toggle-able
644- await saveMetadata();
885+ await toggleDefaultPersona(user_avatar, { quiet: true });
645- if (power_user.persona_show_notifications) {
886+ break;
646- toastr.info(t`User persona is now unlocked for this chat. Click the "Lock" again to revert.`, t`Persona unlocked`);
887+ }
888+ case 'chat': {
889+ if (chat_metadata['persona']) {
890+ console.log(`Unlocking persona ${user_avatar} from this chat`);
891+ delete chat_metadata['persona'];
892+ await saveMetadata();
893+ if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
894+ toastr.info(t`Persona ${name1} is now unlocked from this chat.`, t`Persona Unlocked`);
895+ }
896+ }
897+ break;
647898 }
648- updateUserLockIcon();
899+ case 'character': {
900+ /** @type {PersonaConnection[]} */
901+ const connections = power_user.persona_descriptions[user_avatar]?.connections;
902+ if (connections) {
903+ console.log(`Unlocking persona ${user_avatar} from this character ${name2}`);
904+ power_user.persona_descriptions[user_avatar].connections = connections.filter(c => !isPersonaConnectionLocked(c));
905+ saveSettingsDebounced();
906+ updatePersonaConnectionsAvatarList();
907+ if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
908+ toastr.info(t`Persona ${name1} is now unlocked from character ${name2}.`, t`Persona Unlocked`);
909+ }
910+ }
911+ break;
912+ }
913+ default:
914+ throw new Error(`Unknown persona lock type: ${type}`);
649915 }
916+
917+ updatePersonaUIStates();
650918}
651919
652920/**
653921 * Lock the persona for the current chat.
922+ * @param {PersonaLockType} type - Lock type
654923 */
655924async function lockPersona(type = 'chat') {
925+ // First make sure that user_avatar is actually a persona
656926 if (!(user_avatar in power_user.personas)) {
657927 console.log(`Creating a new persona ${user_avatar}`);
658928 if (power_user.persona_show_notifications) {
659- toastr.info(
929+ toastr.info(t`Creating a new persona for currently selected user name and avatar...`, t`Persona Not Found`);
660- t`Creating a new persona for currently selected user name and avatar...`,
661- t`Persona not set for this avatar`,
662- { timeOut: 10000, extendedTimeOut: 20000 },
663- );
664930 }
665931 power_user.personas[user_avatar] = name1;
666932 power_user.persona_descriptions[user_avatar] = {
@@ -669,36 +935,75 @@ async function lockPersona() {
669935 depth: DEFAULT_DEPTH,
670936 role: DEFAULT_ROLE,
671937 lorebook: '',
938+ connections: [],
672939 };
673940 }
674941
675- chat_metadata['persona'] = user_avatar;
942+ switch (type) {
676- await saveMetadata();
943+ case 'default': {
677- saveSettingsDebounced();
944+ await toggleDefaultPersona(user_avatar, { quiet: true });
678- console.log(`Locking persona for this chat ${user_avatar}`);
945+ break;
679- if (power_user.persona_show_notifications) {
946+ }
680- toastr.success(t`User persona is locked to ${name1} in this chat`);
947+ case 'chat': {
948+ console.log(`Locking persona ${user_avatar} to this chat`);
949+ chat_metadata['persona'] = user_avatar;
950+ saveMetadataDebounced();
951+ if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
952+ toastr.success(t`User persona ${name1} is locked to ${name2} in this chat`, t`Persona Locked`);
953+ }
954+ break;
955+ }
956+ case 'character': {
957+ const newConnection = getCurrentConnectionObj();
958+ /** @type {PersonaConnection[]} */
959+ const connections = power_user.persona_descriptions[user_avatar].connections?.filter(c => !isPersonaConnectionLocked(c)) ?? [];
960+ if (newConnection && newConnection.id) {
961+ console.log(`Locking persona ${user_avatar} to this character ${name2}`);
962+ power_user.persona_descriptions[user_avatar].connections = [...connections, newConnection];
963+
964+ const unlinkedCharacters = [];
965+ if (!power_user.persona_allow_multi_connections) {
966+ for (const [avatarId, description] of Object.entries(power_user.persona_descriptions)) {
967+ if (avatarId === user_avatar) continue;
968+
969+ const filteredConnections = description.connections?.filter(c => !(c.type === newConnection.type && c.id === newConnection.id)) ?? [];
970+ if (filteredConnections.length !== description.connections?.length) {
971+ description.connections = filteredConnections;
972+ unlinkedCharacters.push(power_user.personas[avatarId]);
973+ }
974+ }
975+ }
976+
977+ saveSettingsDebounced();
978+ updatePersonaConnectionsAvatarList();
979+ if (power_user.persona_show_notifications) {
980+ let additional = '';
981+ if (unlinkedCharacters.length)
982+ additional += `<br /><br />${t`Unlinked existing persona${unlinkedCharacters.length > 1 ? 's' : ''}: ${unlinkedCharacters.join(', ')}`}`;
983+ if (additional || !isPersonaPanelOpen()) {
984+ toastr.success(t`User persona ${name1} is locked to character ${name2}${additional}`, t`Persona Locked`, { escapeHtml: false });
985+ }
986+ }
987+ }
988+ break;
989+ }
990+ default:
991+ throw new Error(`Unknown persona lock type: ${type}`);
681992 }
682- updateUserLockIcon();
993+
994+ updatePersonaUIStates();
683995}
684996
685997
686998async function deleteUserAvatar(e) {
687- e?.stopPropagation();
999+ const avatarId = user_avatar;
688- const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
6891000
6901001 if (!avatarId) {
6911002 console.warn('No avatar id found');
6921003 return;
6931004 }
694-
1005+ const confirm = await Popup.show.confirm(t`Delete Persona`,
695- if (avatarId == user_avatar) {
1006+ t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`);
696- console.warn(`User tried to delete their current avatar ${avatarId}`);
697- toastr.warning(t`You cannot delete the avatar you are currently using`, t`Warning`);
698- return;
699- }
700-
701- const confirm = await Popup.show.confirm(t`Are you sure you want to delete this avatar?`, t`All information associated with its linked persona will be lost.`);
7021007
7031008 if (!confirm) {
7041009 console.debug('User cancelled deleting avatar');
@@ -719,19 +1024,20 @@ async function deleteUserAvatar(e) {
7191024 delete power_user.persona_descriptions[avatarId];
7201025
7211026 if (avatarId === power_user.default_persona) {
7221027 toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default personaPersona deletedDeleted`);
7231028 power_user.default_persona = null;
7241029 }
7251030
7261031 if (avatarId === chat_metadata['persona']) {
7271032 toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona deletedDeleted`);
7281033 delete chat_metadata['persona'];
7291034 await saveMetadata();
7301035 }
7311036
7321037 saveSettingsDebounced();
733- await getUserAvatars();
1038+
734- updateUserLockIcon();
1039+ // Use the existing mechanism to re-render the persona list and choose the next persona here
1040+ await loadPersonaForCurrentChat({ doRender: true });
7351041 }
7361042}
7371043
@@ -786,14 +1092,14 @@ function onPersonaDescriptionDepthRoleInput() {
7861092
7871093/**
7881094 * Opens a popup to set the lorebook for the current persona.
7891095 * @param {PointerEventJQuery.ClickEvent} event Click event
7901096 */
7911097async function onPersonaLoreButtonClick(event) {
7921098 const personaName = power_user.personas[user_avatar];
7931099 const selectedLorebook = power_user.persona_description_lorebook;
7941100
7951101 if (!personaName) {
7961102 toastr.warning(t`You must bind a name to this persona before you can set a lorebook.`, t`Persona nameName notNot setSet`);
7971103 return;
7981104 }
7991105
@@ -854,16 +1160,21 @@ function getOrCreatePersonaDescriptor() {
8541160 depth: power_user.persona_description_depth,
8551161 role: power_user.persona_description_role,
8561162 lorebook: power_user.persona_description_lorebook,
1163+ connections: [],
8571164 };
8581165 power_user.persona_descriptions[user_avatar] = object;
8591166 }
8601167 return object;
8611168}
8621169
863-async function setDefaultPersona(e) {
1170+/**
864- e?.stopPropagation();
1171+ * Sets a persona as the default one to be used for all new chats and unlocked existing chats
865- const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
1172+ * @param {string} avatarId The avatar id of the persona to set as the default
866-
1173+ * @param {object} [options] Optional arguments
1174+ * @param {boolean} [options.quiet=false] If true, no confirmation popups will be shown
1175+ * @returns {Promise<void>}
1176+ */
1177+async function toggleDefaultPersona(avatarId, { quiet = false } = {}) {
8671178 if (!avatarId) {
8681179 console.warn('No avatar id found');
8691180 return;
@@ -873,78 +1184,264 @@ async function setDefaultPersona(e) {
8731184
8741185 if (power_user.personas[avatarId] === undefined) {
8751186 console.warn(`No persona name found for avatar ${avatarId}`);
8761187 toastr.warning(t`You must bind a name to this persona before you can set it as the default.`, t`Persona nameName notNot setSet`);
8771188 return;
8781189 }
8791190
880- const personaName = power_user.personas[avatarId];
8811191
8821192 if (avatarId === currentDefault) {
883- const confirm = await Popup.show.confirm(t`Are you sure you want to remove the default persona?`, personaName);
1193+ if (!quiet) {
884-
1194+ const confirm = await Popup.show.confirm(t`Are you sure you want to remove the default persona?`, power_user.personas[avatarId]);
8851195 if (!confirm) {
8861196 console.debug('User cancelled removing default persona');
8871197 return;
1198+ }
8881199 }
8891200
8901201 console.log(`Removing default persona ${avatarId}`);
8911202 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
8921203 toastr.info(t`This persona will no longer be used by default when you open a new chat.`, t`Default personaPersona removedRemoved`);
8931204 }
8941205 delete power_user.default_persona;
8951206 } else {
896- const confirm = await Popup.show.confirm(t`Are you sure you want to set \"${personaName}\" as the default persona?`, t`This name and avatar will be used for all new chats, as well as existing chats where the user persona is not locked.`);
1207+ if (!quiet) {
897-
1208+ const confirm = await Popup.show.confirm(t`Set Default Persona`,
898- if (!confirm) {
1209+ t`Are you sure you want to set \"${power_user.personas[avatarId]}\" as the default persona?`
899- console.debug('User cancelled setting default persona');
1210+ + '<br /><br />'
900- return;
1211+ + t`This name and avatar will be used for all new chats, as well as existing chats where the user persona is not locked.`);
1212+ if (!confirm) {
1213+ console.debug('User cancelled setting default persona');
1214+ return;
1215+ }
9011216 }
9021217
9031218 power_user.default_persona = avatarId;
9041219 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
9051220 toastr.success(t`Set to ${power_user.personas[avatarId]}.This persona will be used by default when you open a new chat.`, t`Default persona set to ${personaName}Persona`);
9061221 }
9071222 }
9081223
9091224 saveSettingsDebounced();
9101225 await getUserAvatars(true, avatarId);
1226+ updatePersonaUIStates();
1227+}
1228+
1229+/**
1230+ * Returns an object with 3 properties that describe the state of the given persona
1231+ *
1232+ * - default: Whether this persona is the default one for all new chats
1233+ * - locked: An object containing the lock states
1234+ * - chat: Whether the persona is locked to the currently open chat
1235+ * - character: Whether the persona is locked to the currently open character or group
1236+ * @param {string} avatarId - The avatar id of the persona to get the state for
1237+ * @returns {PersonaState} An object describing the state of the given persona
1238+ */
1239+function getPersonaStates(avatarId) {
1240+ const isDefaultPersona = power_user.default_persona === avatarId;
1241+ const hasChatLock = chat_metadata['persona'] == avatarId;
1242+
1243+ /** @type {PersonaConnection[]} */
1244+ const connections = power_user.persona_descriptions[avatarId]?.connections;
1245+ const hasCharLock = !!connections?.some(c =>
1246+ (!selected_group && c.type === 'character' && c.id === characters[this_chid]?.avatar)
1247+ || (selected_group && c.type === 'group' && c.id === selected_group));
1248+
1249+ return {
1250+ avatarId: avatarId,
1251+ default: isDefaultPersona,
1252+ locked: {
1253+ chat: hasChatLock,
1254+ character: hasCharLock,
1255+ },
1256+ };
1257+}
1258+
1259+/**
1260+ * Updates the UI to reflect the current states of all personas and the selected user's persona.
1261+ * This includes updating class states on avatar containers to indicate default status, chat lock,
1262+ * and character lock, as well as updating icons and labels in the persona management panel to reflect
1263+ * the current state of the user's persona.
1264+ * Additionally, it manages the display of temporary persona lock information.
1265+ * @param {Object} [options={}] - Optional settings
1266+ * @param {boolean} [options.navigateToCurrent=false] - Whether to navigate to the current persona in the persona list
1267+ */
1268+
1269+function updatePersonaUIStates({ navigateToCurrent = false } = {}) {
1270+ if (navigateToCurrent) {
1271+ navigateToAvatar(user_avatar);
1272+ }
1273+
1274+ // Update the persona list
1275+ $('#user_avatar_block .avatar-container').each(function () {
1276+ const avatarId = $(this).attr('data-avatar-id');
1277+ const states = getPersonaStates(avatarId);
1278+ $(this).toggleClass('default_persona', states.default);
1279+ $(this).toggleClass('locked_to_chat', states.locked.chat);
1280+ $(this).toggleClass('locked_to_character', states.locked.character);
1281+ $(this).toggleClass('selected', avatarId === user_avatar);
1282+ });
1283+
1284+ // Buttons for the persona panel on the right
1285+ const personaStates = getPersonaStates(user_avatar);
1286+
1287+ $('#lock_persona_default').toggleClass('locked', personaStates.default);
1288+
1289+ $('#lock_user_name').toggleClass('locked', personaStates.locked.chat);
1290+ $('#lock_user_name i.icon').toggleClass('fa-lock', personaStates.locked.chat);
1291+ $('#lock_user_name i.icon').toggleClass('fa-unlock', !personaStates.locked.chat);
1292+
1293+ $('#lock_persona_to_char').toggleClass('locked', personaStates.locked.character);
1294+ $('#lock_persona_to_char i.icon').toggleClass('fa-lock', personaStates.locked.character);
1295+ $('#lock_persona_to_char i.icon').toggleClass('fa-unlock', !personaStates.locked.character);
1296+
1297+ // Persona panel info block
1298+ const { isTemporary, info } = getPersonaTemporaryLockInfo();
1299+ if (isTemporary) {
1300+ const messageContainer = document.createElement('div');
1301+ const messageSpan = document.createElement('span');
1302+ messageSpan.textContent = t`Temporary persona in use.`;
1303+ messageContainer.appendChild(messageSpan);
1304+ messageContainer.classList.add('flex-container', 'alignItemsBaseline');
1305+
1306+ const infoIcon = document.createElement('i');
1307+ infoIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
1308+ infoIcon.title = info;
1309+ messageContainer.appendChild(infoIcon);
1310+
1311+ // Set the info block content
1312+ setInfoBlock('#persona_connections_info_block', messageContainer, 'hint');
1313+ } else {
1314+ // Clear the info block if no condition applies
1315+ clearInfoBlock('#persona_connections_info_block');
1316+ }
9111317}
9121318
913-function updateUserLockIcon() {
1319+/**
914- const hasLock = !!chat_metadata['persona'];
1320+ * @typedef {Object} PersonaLockInfo
915- $('#lock_user_name').toggleClass('fa-unlock', !hasLock);
1321+ * @property {boolean} isTemporary - Whether the selected persona is temporary based on current locks.
916- $('#lock_user_name').toggleClass('fa-lock', hasLock);
1322+ * @property {boolean} hasDifferentChatLock - True if the chat persona is set and differs from the user avatar.
1323+ * @property {boolean} hasDifferentDefaultLock - True if the default persona is set and differs from the user avatar.
1324+ * @property {string} info - Detailed information about the current, chat, and default personas.
1325+ */
1326+
1327+/**
1328+ * Computes temporary lock information for the current persona.
1329+ *
1330+ * This function checks whether the currently selected persona is temporary by comparing
1331+ * the chat persona and the default persona to the user avatar. If either is different,
1332+ * the currently selected persona is considered temporary and a detailed message is generated.
1333+ *
1334+ * @returns {PersonaLockInfo} An object containing flags and a message describing the persona lock status.
1335+ */
1336+function getPersonaTemporaryLockInfo() {
1337+ const hasDifferentChatLock = !!chat_metadata['persona'] && chat_metadata['persona'] !== user_avatar;
1338+ const hasDifferentDefaultLock = power_user.default_persona && power_user.default_persona !== user_avatar;
1339+ const isTemporary = hasDifferentChatLock || (!chat_metadata['persona'] && hasDifferentDefaultLock);
1340+ const info = isTemporary ? t`A different persona is locked to this chat, or you have a different default persona set. The currently selected persona will only be temporary, and resets on reload. Consider locking this persona to the chat if you want to permanently use it.`
1341+ + '\n\n'
1342+ + t`Current Persona: ${power_user.personas[user_avatar]}`
1343+ + (hasDifferentChatLock ? '\n' + t`Chat persona: ${power_user.personas[chat_metadata['persona']]}` : '')
1344+ + (hasDifferentDefaultLock ? '\n' + t`Default persona: ${power_user.personas[power_user.default_persona]}` : '') : '';
1345+
1346+ return {
1347+ isTemporary: isTemporary,
1348+ hasDifferentChatLock: hasDifferentChatLock,
1349+ hasDifferentDefaultLock: hasDifferentDefaultLock,
1350+ info: info,
1351+ };
9171352}
9181353
919-async function setChatLockedPersona() {
1354+/**
1355+ * Loads the appropriate persona for the current chat session based on locks (chat lock, char lock, default persona)
1356+ *
1357+ * @param {Object} [options={}] - Optional arguments
1358+ * @param {boolean} [options.doRender=false] - Whether to render the persona immediately
1359+ * @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether a persona was selected
1360+ */
1361+async function loadPersonaForCurrentChat({ doRender = false } = {}) {
1362+ // Cache persona list to check if they exist
1363+ const userAvatars = await getUserAvatars(doRender);
1364+
9201365 // Define a persona for this chat
9211366 let chatPersona = '';
9221367
1368+ /** @type {'chat' | 'character' | 'default' | null} */
1369+ let connectType = null;
1370+
1371+ // If persona is locked in chat metadata, select it
9231372 if (chat_metadata['persona']) {
924- // If persona is locked in chat metadata, select it
9251373 console.log(`Using locked persona ${chat_metadata['persona']}`);
9261374 chatPersona = chat_metadata['persona'];
927- } else if (power_user.default_persona) {
1375+
928- // If default persona is set, select it
1376+ // Verify it exists
929- console.log(`Using default persona ${power_user.default_persona}`);
1377+ if (!userAvatars.includes(chatPersona)) {
930- chatPersona = power_user.default_persona;
1378+ console.warn('Chat-locked persona avatar not found, unlocking persona');
1379+ delete chat_metadata['persona'];
1380+ saveSettingsDebounced();
1381+ chatPersona = '';
1382+ }
1383+ if (chatPersona) connectType = 'chat';
9311384 }
9321385
933- // No persona set: user current settings
1386+ // If the persona panel is open when the chat changes, this is likely because a character was selected from that panel.
1387+ // In that case, we are not automatically switching persona - but need to make changes if there is any chat-bound connection
1388+ /*
1389+ if (isPersonaPanelOpen()) {
1390+ if (chatPersona) {
1391+ // If the chat-bound persona is the currently selected one, we can simply exit out
1392+ if (chatPersona === user_avatar) {
1393+ return false;
1394+ }
1395+ // Otherwise ask if we want to switch
1396+ const autoLock = power_user.persona_auto_lock;
1397+ const result = await Popup.show.confirm(t`Switch Persona?`,
1398+ t`You have a connected persona for the current chat (${power_user.personas[chatPersona]}). Do you want to stick to the current persona (${power_user.personas[user_avatar]}) ${(autoLock ? t`and lock that to the chat` : '')}, or switch to ${power_user.personas[chatPersona]} instead?`,
1399+ { okButton: autoLock ? t`Keep and Lock` : t`Keep`, cancelButton: t`Switch` });
1400+ if (result === POPUP_RESULT.AFFIRMATIVE) {
1401+ if (autoLock) {
1402+ lockPersona('chat');
1403+ }
1404+ return false;
1405+ }
1406+ } else {
1407+ // If we don't have a chat-bound persona, we simply return and keep the current one we have
1408+ return false;
1409+ }
1410+ }
1411+ */
1412+
1413+ // Check if we have any persona connected to the current character
9341414 if (!chatPersona) {
935- console.debug('No default or locked persona set for this chat');
1415+ const connectedPersonas = getConnectedPersonas();
936- return;
1416+
1417+ if (connectedPersonas.length > 0) {
1418+ if (connectedPersonas.length === 1) {
1419+ chatPersona = connectedPersonas[0];
1420+ } else if (!power_user.persona_allow_multi_connections) {
1421+ console.warn('More than one persona is connected to this character.Using the first available persona for this chat.');
1422+ chatPersona = connectedPersonas[0];
1423+ } else {
1424+ chatPersona = await askForPersonaSelection(t`Select Persona`,
1425+ t`Multiple personas are connected to this character.\nSelect a persona to use for this chat.`,
1426+ connectedPersonas, { highlightPersonas: true, targetedChar: getCurrentConnectionObj() });
1427+ }
1428+ }
1429+
1430+ if (chatPersona) connectType = 'character';
9371431 }
9381432
939- // Find the avatar file
1433+ // Last check if default persona is set, select it
940- const userAvatars = await getUserAvatars(false);
1434+ if (!chatPersona && power_user.default_persona) {
1435+ console.log(`Using default persona ${power_user.default_persona}`);
1436+ chatPersona = power_user.default_persona;
1437+
1438+ if (chatPersona) connectType = 'default';
1439+ }
9411440
942- // Avatar missing (persona deleted)
1441+ // Whatever way we selected a persona, if it doesn't exist, unlock this chat
9431442 if (chat_metadata['persona'] && !userAvatars.includes(chatPersonachat_metadata['persona'])) {
9441443 console.warn('Persona avatar not found, unlocking persona');
9451444 delete chat_metadata['persona'];
946- updateUserLockIcon();
947- return;
9481445 }
9491446
9501447 // Default persona missing
@@ -952,12 +1449,101 @@ async function setChatLockedPersona() {
9521449 console.warn('Default persona avatar not found, clearing default persona');
9531450 power_user.default_persona = null;
9541451 saveSettingsDebounced();
955- return;
9561452 }
9571453
9581454 // Persona avatar found, select it
959- setUserAvatar(chatPersona);
1455+ if (chatPersona && user_avatar !== chatPersona) {
960- updateUserLockIcon();
1456+ const willAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata['persona'];
1457+ setUserAvatar(chatPersona, { toastPersonaNameChange: false, navigateToCurrent: true });
1458+
1459+ if (power_user.persona_show_notifications) {
1460+ let message = t`Auto-selected persona based on ${connectType} connection.<br />Your messages will now be sent as ${power_user.personas[chatPersona]}.`;
1461+ if (willAutoLock) {
1462+ message += '<br /><br />' + t`Auto-locked this persona to current chat.`;
1463+ }
1464+ toastr.success(message, t`Persona Auto Selected`, { escapeHtml: false });
1465+ }
1466+ }
1467+
1468+ updatePersonaUIStates();
1469+
1470+ return !!chatPersona;
1471+}
1472+
1473+/**
1474+ * Returns an array of persona keys that are connected to the given character key.
1475+ * If the character key is not provided, it defaults to the currently selected group or character.
1476+ * @param {string} [characterKey] - The character key to query
1477+ * @returns {string[]} - An array of persona keys that are connected to the given character key
1478+ */
1479+export function getConnectedPersonas(characterKey = undefined) {
1480+ characterKey ??= selected_group || characters[this_chid]?.avatar;
1481+ const connectedPersonas = Object.entries(power_user.persona_descriptions)
1482+ .filter(([_, desc]) => desc.connections?.some(conn => conn.type === 'character' && conn.id === characterKey))
1483+ .map(([key, _]) => key);
1484+ return connectedPersonas;
1485+}
1486+
1487+
1488+/**
1489+ * Shows a popup with all personas connected to the currently selected character or group.
1490+ * In the popup, the user can select a persona to load for the current character or group, or shift-click to remove the connection.
1491+ * @return {Promise<void>}
1492+ */
1493+export async function showCharConnections() {
1494+ let isRemoving = false;
1495+
1496+ const connections = getConnectedPersonas();
1497+ const message = t`The following personas are connected to the current character.\n\nClick on a persona to select it for the current character.\nShift + Click to unlink the persona from the character.`;
1498+ const selectedPersona = await askForPersonaSelection(t`Persona Connections`, message, connections, {
1499+ okButton: t`Ok`,
1500+ highlightPersonas: true,
1501+ targetedChar: getCurrentConnectionObj(),
1502+ shiftClickHandler: (element, ev) => {
1503+
1504+ const personaId = $(element).attr('data-pid');
1505+
1506+ /** @type {PersonaConnection[]} */
1507+ const connections = power_user.persona_descriptions[personaId]?.connections;
1508+ if (connections) {
1509+ console.log(`Unlocking persona ${personaId} from current character ${name2}`);
1510+ power_user.persona_descriptions[personaId].connections = connections.filter(c => {
1511+ if (menu_type == 'group_edit' && c.type == 'group' && c.id == selected_group) return false;
1512+ else if (c.type == 'character' && c.id == characters[this_chid]?.avatar) return false;
1513+ return true;
1514+ });
1515+ saveSettingsDebounced();
1516+ updatePersonaConnectionsAvatarList();
1517+ if (power_user.persona_show_notifications) {
1518+ toastr.info(t`User persona ${power_user.personas[personaId]} is now unlocked from the current character ${name2}.`, t`Persona unlocked`);
1519+ }
1520+
1521+ isRemoving = true;
1522+ $('#char_connections_button').trigger('click');
1523+ }
1524+ },
1525+ });
1526+
1527+ // One of the persona was selected. So load it.
1528+ if (!isRemoving && selectedPersona) {
1529+ setUserAvatar(selectedPersona, { toastPersonaNameChange: false });
1530+ if (power_user.persona_show_notifications) {
1531+ toastr.success(t`Selected persona ${power_user.personas[selectedPersona]} for current chat.`, t`Connected Persona Selected`);
1532+ }
1533+ }
1534+}
1535+
1536+/**
1537+ * Retrieves the current connection object based on whether the current chat is with a char or a group.
1538+ *
1539+ * @returns {PersonaConnection} An object representing the current connection
1540+ */
1541+export function getCurrentConnectionObj() {
1542+ if (selected_group)
1543+ return { type: 'group', id: selected_group };
1544+ if (characters[this_chid]?.avatar)
1545+ return { type: 'character', id: characters[this_chid]?.avatar };
1546+ return null;
9611547}
9621548
9631549function onBackupPersonas() {
@@ -1038,10 +1624,10 @@ async function onPersonasRestoreInput(e) {
10381624 }
10391625
10401626 if (warnings.length) {
10411627 toastr.success(t`Personas restored with warnings. Check console for details.`, t`Persona Management`);
10421628 console.warn(`PERSONA RESTORE REPORT\n====================\n${warnings.join('\n')}`);
10431629 } else {
10441630 toastr.success(t`Personas restored successfully.`, t`Persona Management`);
10451631 }
10461632
10471633 await getUserAvatars();
@@ -1068,6 +1654,11 @@ async function syncUserNameToPersona() {
10681654 await reloadCurrentChat();
10691655}
10701656
1657+/**
1658+ * Retriggers the first message to reload it from the char definition.
1659+ *
1660+ * Only works if only the first message is present, and not in group mode.
1661+ */
10711662export function retriggerFirstMessageOnEmptyChat() {
10721663 if (this_chid >= 0 && !selected_group && chat.length === 1) {
10731664 $('#firstmessage_textarea').trigger('input');
@@ -1083,7 +1674,7 @@ async function duplicatePersona(avatarId) {
10831674 const personaName = power_user.personas[avatarId];
10841675
10851676 if (!personaName) {
10861677 toastr.warning('Chosen avatar is not a persona', t`Persona Management`);
10871678 return;
10881679 }
10891680
@@ -1111,11 +1702,207 @@ async function duplicatePersona(avatarId) {
11111702 saveSettingsDebounced();
11121703}
11131704
1114-export function initPersonas() {
1705+/**
1115- $(document).on('click', '.bind_user_name', bindUserNameToPersona);
1706+ * If a current user avatar is not bound to persona, bind it.
1116- $(document).on('click', '.set_default_persona', setDefaultPersona);
1707+ */
1117- $(document).on('click', '.delete_avatar', deleteUserAvatar);
1708+async function migrateNonPersonaUser() {
1118- $('#lock_user_name').on('click', togglePersonaLock);
1709+ if (user_avatar in power_user.personas) {
1710+ return;
1711+ }
1712+
1713+ initPersona(user_avatar, name1, '');
1714+ setPersonaDescription();
1715+ await getUserAvatars(true, user_avatar);
1716+}
1717+
1718+
1719+/**
1720+ * Locks or unlocks the persona of the current chat.
1721+ * @param {{type: string}} _args Named arguments
1722+ * @param {string} value The value to set the lock to
1723+ * @returns {Promise<string>} The value of the lock after setting
1724+ */
1725+async function lockPersonaCallback(_args, value) {
1726+ const type = /** @type {PersonaLockType} */ (_args.type ?? 'chat');
1727+
1728+ if (!['chat', 'character', 'default'].includes(type)) {
1729+ toastr.warning(t`Unknown lock type "${type}"`, t`Persona Management`);
1730+ return '';
1731+ }
1732+
1733+ if (!value) {
1734+ return String(isPersonaLocked(type));
1735+ }
1736+
1737+ if (['toggle', 't'].includes(value.trim().toLowerCase())) {
1738+ const result = await togglePersonaLock(type);
1739+ return String(result);
1740+ }
1741+
1742+ if (isTrueBoolean(value)) {
1743+ await setPersonaLockState(true, type);
1744+ return 'true';
1745+ }
1746+
1747+ if (isFalseBoolean(value)) {
1748+ await setPersonaLockState(false, type);
1749+ return 'false';
1750+
1751+ }
1752+
1753+ return '';
1754+}
1755+
1756+/**
1757+ * Sets a persona name and optionally an avatar.
1758+ * @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments
1759+ * @param {string} name Name to set
1760+ * @returns {string}
1761+ */
1762+function setNameCallback({ mode = 'all' }, name) {
1763+ if (!name) {
1764+ toastr.warning('You must specify a name to change to');
1765+ return '';
1766+ }
1767+
1768+ if (!['lookup', 'temp', 'all'].includes(mode)) {
1769+ toastr.warning('Mode must be one of "lookup", "temp" or "all"');
1770+ return '';
1771+ }
1772+
1773+ name = name.trim();
1774+
1775+ // If the name matches a persona avatar, or a name, auto-select it
1776+ if (['lookup', 'all'].includes(mode)) {
1777+ let persona = Object.entries(power_user.personas).find(([avatar, _]) => avatar === name)?.[1];
1778+ if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1];
1779+ if (persona) {
1780+ autoSelectPersona(persona);
1781+ retriggerFirstMessageOnEmptyChat();
1782+ return '';
1783+ } else if (mode === 'lookup') {
1784+ toastr.warning(`Persona ${name} not found`);
1785+ return '';
1786+ }
1787+ }
1788+
1789+ if (['temp', 'all'].includes(mode)) {
1790+ // Otherwise, set just the name
1791+ setUserName(name); //this prevented quickReply usage
1792+ retriggerFirstMessageOnEmptyChat();
1793+ }
1794+
1795+ return '';
1796+}
1797+
1798+function syncCallback() {
1799+ $('#sync_name_button').trigger('click');
1800+ return '';
1801+}
1802+
1803+function registerPersonaSlashCommands() {
1804+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1805+ name: 'persona-lock',
1806+ callback: lockPersonaCallback,
1807+ returns: 'The current lock state for the given type',
1808+ helpString: 'Locks/unlocks a persona (name and avatar) to the current chat. Gets the current lock state for the given type if no state is provided.',
1809+ namedArgumentList: [
1810+ SlashCommandNamedArgument.fromProps({
1811+ name: 'type',
1812+ description: 'The type of the lock, where it should apply to',
1813+ typeList: [ARGUMENT_TYPE.STRING],
1814+ defaultValue: 'chat',
1815+ enumList: [
1816+ new SlashCommandEnumValue('chat', 'Lock the persona to the current chat.'),
1817+ new SlashCommandEnumValue('character', 'Lock this persona to the currently selected character. If the setting is enabled, mutliple personas can be locked to the same character.'),
1818+ new SlashCommandEnumValue('default', 'Lock this persona as the default persona for all new chats.'),
1819+ ],
1820+ }),
1821+ ],
1822+ unnamedArgumentList: [
1823+ SlashCommandArgument.fromProps({
1824+ description: 'state',
1825+ typeList: [ARGUMENT_TYPE.STRING],
1826+ enumProvider: commonEnumProviders.boolean('onOffToggle'),
1827+ }),
1828+ ],
1829+ }));
1830+ // TODO: Legacy command. Might be removed in the future and replaced by /persona-lock with aliases.
1831+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1832+ name: 'lock',
1833+ /** @type {(args: { type: string }, value: string) => Promise<string>} */
1834+ callback: (args, value) => {
1835+ if (!value) {
1836+ value = 'toggle';
1837+ toastr.warning(t`Using /lock without a provided state to toggle the persona is deprecated. Please use /persona-lock instead.
1838+ In the future this command with no state provided will return the current state, instead of toggling it.`, t`Deprecation Warning`);
1839+ }
1840+ return lockPersonaCallback(args, value);
1841+ },
1842+ returns: 'The current lock state for the given type',
1843+ aliases: ['bind'],
1844+ helpString: 'Locks/unlocks a persona (name and avatar) to the current chat. Gets the current lock state for the given type if no state is provided.',
1845+ namedArgumentList: [
1846+ SlashCommandNamedArgument.fromProps({
1847+ name: 'type',
1848+ description: 'The type of the lock, where it should apply to',
1849+ typeList: [ARGUMENT_TYPE.STRING],
1850+ defaultValue: 'chat',
1851+ enumList: [
1852+ new SlashCommandEnumValue('chat', 'Lock the persona to the current chat.'),
1853+ new SlashCommandEnumValue('character', 'Lock this persona to the currently selected character. If the setting is enabled, mutliple personas can be locked to the same character.'),
1854+ new SlashCommandEnumValue('default', 'Lock this persona as the default persona for all new chats.'),
1855+ ],
1856+ }),
1857+ ],
1858+ unnamedArgumentList: [
1859+ SlashCommandArgument.fromProps({
1860+ description: 'state',
1861+ typeList: [ARGUMENT_TYPE.STRING],
1862+ defaultValue: 'toggle',
1863+ enumProvider: commonEnumProviders.boolean('onOffToggle'),
1864+ }),
1865+ ],
1866+ }));
1867+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1868+ name: 'persona-set',
1869+ callback: setNameCallback,
1870+ aliases: ['persona', 'name'],
1871+ namedArgumentList: [
1872+ new SlashCommandNamedArgument(
1873+ 'mode', 'The mode for persona selection. ("lookup" = search for existing persona, "temp" = create a temporary name, set a temporary name, "all" = allow both in the same command)',
1874+ [ARGUMENT_TYPE.STRING], false, false, 'all', ['lookup', 'temp', 'all'],
1875+ ),
1876+ ],
1877+ unnamedArgumentList: [
1878+ SlashCommandArgument.fromProps({
1879+ description: 'persona name',
1880+ typeList: [ARGUMENT_TYPE.STRING],
1881+ isRequired: true,
1882+ enumProvider: commonEnumProviders.personas,
1883+ }),
1884+ ],
1885+ helpString: 'Selects the given persona with its name and avatar (by name or avatar url). If no matching persona exists, applies a temporary name.',
1886+ }));
1887+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1888+ name: 'persona-sync',
1889+ aliases: ['sync'],
1890+ callback: syncCallback,
1891+ helpString: 'Syncs the user persona in user-attributed messages in the current chat.',
1892+ }));
1893+}
1894+
1895+/**
1896+ * Initializes the persona management and all its functionality.
1897+ * This is called during the initialization of the page.
1898+ */
1899+export async function initPersonas() {
1900+ await migrateNonPersonaUser();
1901+ registerPersonaSlashCommands();
1902+ $('#persona_delete_button').on('click', deleteUserAvatar);
1903+ $('#lock_persona_default').on('click', () => togglePersonaLock('default'));
1904+ $('#lock_user_name').on('click', () => togglePersonaLock('chat'));
1905+ $('#lock_persona_to_char').on('click', () => togglePersonaLock('character'));
11191906 $('#create_dummy_persona').on('click', createDummyPersona);
11201907 $('#persona_description').on('input', onPersonaDescriptionInput);
11211908 $('#persona_description_position').on('input', onPersonaDescriptionPositionInput);
@@ -1151,55 +1938,41 @@ export function initPersonas() {
11511938 $('#avatar_upload_file').on('change', changeUserAvatar);
11521939
11531940 $(document).on('click', '#user_avatar_block .avatar-container', function () {
11541941 const imgfile = $(this).attr('imgfiledata-avatar-id');
11551942 setUserAvatar(imgfile);
11561943
11571944 // force firstMes {{user}} update on persona switch
11581945 retriggerFirstMessageOnEmptyChat();
11591946 });
11601947
11611948 $('#your_name_buttonpersona_rename_button').on('click', (async) function=> renamePersona(user_avatar) {);
1162- const userName = String($('#your_name').val()).trim();
1163- setUserName(userName);
1164- await updatePersonaNameIfExists(user_avatar, userName);
1165- retriggerFirstMessageOnEmptyChat();
1166- });
11671949
11681950 $(document).on('click', '#user_avatar_block .avatar_upload', function () {
11691951 $('#avatar_upload_overwrite').val('');
11701952 $('#avatar_upload_file').trigger('click');
11711953 });
11721954
11731955 $(document'#persona_duplicate_button').on('click', '#user_avatar_block .duplicate_persona',() function=> duplicatePersona(euser_avatar) {);
1174- e.stopPropagation();
1175- const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
1176-
1177- if (!avatarId) {
1178- console.log('no imgfile');
1179- return;
1180- }
1181-
1182- duplicatePersona(avatarId);
1183- });
1184-
1185- $(document).on('click', '#user_avatar_block .set_persona_image', function (e) {
1186- e.stopPropagation();
1187- const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
11881956
1189- if (!avatarId) {
1957+ $('#persona_set_image_button').on('click', function () {
1958+ if (!user_avatar) {
11901959 console.log('no imgfile');
11911960 return;
11921961 }
11931962
11941963 $('#avatar_upload_overwrite').val(avatarIduser_avatar);
11951964 $('#avatar_upload_file').trigger('click');
11961965 });
11971966
1967+ $('#char_connections_button').on('click', showCharConnections);
1968+
11981969 eventSource.on('charManagementDropdown', (target) => {
11991970 if (target === 'convert_to_persona') {
12001971 convertCharacterToPersona();
12011972 }
12021973 });
12031974 eventSource.on(event_types.CHAT_CHANGED, setChatLockedPersonaupdatePersonaUIStates);
1975+ eventSource.on(event_types.CHAT_CHANGED, loadPersonaForCurrentChat);
12041976 switchPersonaGridView();
12051977}
1978+
public/scripts/power-user.js+12 -0
@@ -1489,6 +1489,8 @@ async function loadPowerUserSettings(settings, data) {
14891489 $('#custom_stopping_strings_macro').prop('checked', power_user.custom_stopping_strings_macro);
14901490 $('#fuzzy_search_checkbox').prop('checked', power_user.fuzzy_search);
14911491 $('#persona_show_notifications').prop('checked', power_user.persona_show_notifications);
1492+ $('#persona_allow_multi_connections').prop('checked', power_user.persona_allow_multi_connections);
1493+ $('#persona_auto_lock').prop('checked', power_user.persona_auto_lock);
14921494 $('#encode_tags').prop('checked', power_user.encode_tags);
14931495 $('#example_messages_behavior').val(getExampleMessagesBehavior());
14941496 $(`#example_messages_behavior option[value="${getExampleMessagesBehavior()}"]`).prop('selected', true);
@@ -3705,6 +3707,16 @@ $(document).ready(() => {
37053707 saveSettingsDebounced();
37063708 });
37073709
3710+ $('#persona_allow_multi_connections').on('input', function () {
3711+ power_user.persona_allow_multi_connections = !!$(this).prop('checked');
3712+ saveSettingsDebounced();
3713+ });
3714+
3715+ $('#persona_auto_lock').on('input', function () {
3716+ power_user.persona_auto_lock = !!$(this).prop('checked');
3717+ saveSettingsDebounced();
3718+ });
3719+
37083720 $('#encode_tags').on('input', async function () {
37093721 power_user.encode_tags = !!$(this).prop('checked');
37103722 await reloadCurrentChat();
public/scripts/slash-commands.js+1 -109
@@ -38,7 +38,6 @@ import {
3838 setCharacterId,
3939 setCharacterName,
4040 setExtensionPrompt,
41- setUserName,
4241 showMoreMessages,
4342 stopGeneration,
4443 substituteParams,
@@ -55,7 +54,7 @@ import { getContext, saveMetadataDebounced } from './extensions.js';
5554import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
5655import { findGroupMemberId, groups, is_group_generating, openGroupById, resetSelectedGroup, saveGroupChat, selected_group } from './group-chats.js';
5756import { chat_completion_sources, oai_settings, promptManager } from './openai.js';
5857import { autoSelectPersona, retriggerFirstMessageOnEmptyChat, setPersonaLockState, togglePersonaLock, user_avatar } from './personas.js';
5958import { addEphemeralStoppingString, chat_styles, flushEphemeralStoppingStrings, power_user } from './power-user.js';
6059import { SERVER_INPUTS, textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
6160import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js';
@@ -124,46 +123,6 @@ export function initDefaultSlashCommands() {
124123 helpString: 'Get help on macros, chat formatting and commands.',
125124 }));
126125 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
127- name: 'persona',
128- callback: setNameCallback,
129- aliases: ['name'],
130- namedArgumentList: [
131- new SlashCommandNamedArgument(
132- 'mode', 'The mode for persona selection. ("lookup" = search for existing persona, "temp" = create a temporary name, set a temporary name, "all" = allow both in the same command)',
133- [ARGUMENT_TYPE.STRING], false, false, 'all', ['lookup', 'temp', 'all'],
134- ),
135- ],
136- unnamedArgumentList: [
137- SlashCommandArgument.fromProps({
138- description: 'persona name',
139- typeList: [ARGUMENT_TYPE.STRING],
140- isRequired: true,
141- enumProvider: commonEnumProviders.personas,
142- }),
143- ],
144- helpString: 'Selects the given persona with its name and avatar (by name or avatar url). If no matching persona exists, applies a temporary name.',
145- }));
146- SlashCommandParser.addCommandObject(SlashCommand.fromProps({
147- name: 'sync',
148- callback: syncCallback,
149- helpString: 'Syncs the user persona in user-attributed messages in the current chat.',
150- }));
151- SlashCommandParser.addCommandObject(SlashCommand.fromProps({
152- name: 'lock',
153- callback: lockPersonaCallback,
154- aliases: ['bind'],
155- helpString: 'Locks/unlocks a persona (name and avatar) to the current chat',
156- unnamedArgumentList: [
157- SlashCommandArgument.fromProps({
158- description: 'state',
159- typeList: [ARGUMENT_TYPE.STRING],
160- isRequired: true,
161- defaultValue: 'toggle',
162- enumProvider: commonEnumProviders.boolean('onOffToggle'),
163- }),
164- ],
165- }));
166- SlashCommandParser.addCommandObject(SlashCommand.fromProps({
167126 name: 'bg',
168127 callback: setBackgroundCallback,
169128 aliases: ['background'],
@@ -3439,31 +3398,6 @@ export async function generateSystemMessage(_, prompt) {
34393398 return '';
34403399}
34413400
3442-function syncCallback() {
3443- $('#sync_name_button').trigger('click');
3444- return '';
3445-}
3446-
3447-async function lockPersonaCallback(_args, value) {
3448- if (['toggle', 't', ''].includes(value.trim().toLowerCase())) {
3449- await togglePersonaLock();
3450- return '';
3451- }
3452-
3453- if (isTrueBoolean(value)) {
3454- await setPersonaLockState(true);
3455- return '';
3456- }
3457-
3458- if (isFalseBoolean(value)) {
3459- await setPersonaLockState(false);
3460- return '';
3461-
3462- }
3463-
3464- return '';
3465-}
3466-
34673401function setStoryModeCallback() {
34683402 $('#chat_display').val(chat_styles.DOCUMENT).trigger('change');
34693403 return '';
@@ -3479,48 +3413,6 @@ function setFlatModeCallback() {
34793413 return '';
34803414}
34813415
3482-/**
3483- * Sets a persona name and optionally an avatar.
3484- * @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments
3485- * @param {string} name Name to set
3486- * @returns {string}
3487- */
3488-function setNameCallback({ mode = 'all' }, name) {
3489- if (!name) {
3490- toastr.warning('You must specify a name to change to');
3491- return '';
3492- }
3493-
3494- if (!['lookup', 'temp', 'all'].includes(mode)) {
3495- toastr.warning('Mode must be one of "lookup", "temp" or "all"');
3496- return '';
3497- }
3498-
3499- name = name.trim();
3500-
3501- // If the name matches a persona avatar, or a name, auto-select it
3502- if (['lookup', 'all'].includes(mode)) {
3503- let persona = Object.entries(power_user.personas).find(([avatar, _]) => avatar === name)?.[1];
3504- if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1];
3505- if (persona) {
3506- autoSelectPersona(persona);
3507- retriggerFirstMessageOnEmptyChat();
3508- return '';
3509- } else if (mode === 'lookup') {
3510- toastr.warning(`Persona ${name} not found`);
3511- return '';
3512- }
3513- }
3514-
3515- if (['temp', 'all'].includes(mode)) {
3516- // Otherwise, set just the name
3517- setUserName(name); //this prevented quickReply usage
3518- retriggerFirstMessageOnEmptyChat();
3519- }
3520-
3521- return '';
3522-}
3523-
35243416async function setNarratorName(_, text) {
35253417 const name = text || NARRATOR_NAME_DEFAULT;
35263418 chat_metadata[NARRATOR_NAME_KEY] = name;
public/scripts/tags.js+2 -2
@@ -485,8 +485,8 @@ export function getTagKeyForEntityElement(element) {
485485 }
486486 // Start with the given element and traverse up the DOM tree
487487 while (element.length && element.parent().length) {
488488 const grid = element.attr('data-grid');
489489 const chid = element.attr('data-chid');
490490 if (grid || chid) {
491491 const id = grid || chid;
492492 return getTagKeyForEntity(id);
public/scripts/utils.js+36 -0
@@ -2256,3 +2256,39 @@ export function arraysEqual(a, b) {
22562256 }
22572257 return true;
22582258}
2259+
2260+/**
2261+ * Updates the content and style of an information block
2262+ * @param {string | HTMLElement} target - The CSS selector or the HTML element of the information block
2263+ * @param {string | HTMLElement?} content - The message to display inside the information block (supports HTML) or an HTML element
2264+ * @param {'hint' | 'info' | 'warning' | 'error'} [type='info'] - The type of message, which determines the styling of the information block
2265+ */
2266+export function setInfoBlock(target, content, type = 'info') {
2267+ if (!content) {
2268+ clearInfoBlock(target);
2269+ return;
2270+ }
2271+
2272+ const infoBlock = typeof target === 'string' ? document.querySelector(target) : target;
2273+ if (infoBlock) {
2274+ infoBlock.className = `info-block ${type}`;
2275+ if (typeof content === 'string') {
2276+ infoBlock.innerHTML = content;
2277+ } else {
2278+ infoBlock.innerHTML = '';
2279+ infoBlock.appendChild(content);
2280+ }
2281+ }
2282+}
2283+
2284+/**
2285+ * Clears the content and style of an information block.
2286+ * @param {string | HTMLElement} target - The CSS selector or the HTML element of the information block
2287+ */
2288+export function clearInfoBlock(target) {
2289+ const infoBlock = typeof target === 'string' ? document.querySelector(target) : target;
2290+ if (infoBlock && infoBlock.classList.contains('info-block')) {
2291+ infoBlock.className = '';
2292+ infoBlock.innerHTML = '';
2293+ }
2294+}
public/scripts/world-info.js+2 -2
@@ -4749,7 +4749,7 @@ export function checkEmbeddedWorld(chid) {
47494749 }
47504750
47514751 if (characters[chid]?.data?.character_book) {
47524752 $('#import_character_info').data('data-chid', chid).show();
47534753
47544754 // Only show the alert once per character
47554755 const checkKey = `AlertWI_${characters[chid].avatar}`;
@@ -4783,7 +4783,7 @@ export function checkEmbeddedWorld(chid) {
47834783}
47844784
47854785export async function importEmbeddedWorldInfo(skipPopup = false) {
47864786 const chid = $('#import_character_info').data('data-chid');
47874787
47884788 if (chid === undefined) {
47894789 return;
public/style.css+137 -45
@@ -228,6 +228,37 @@ table.responsiveTable {
228228 animation-name: flash;
229229}
230230
231+.has_hover_label .label_icon {
232+ transition: opacity var(--animation-duration) ease, max-width var(--animation-duration) ease;
233+}
234+.has_hover_label .label {
235+ transition: opacity var(--animation-duration-slow) ease, max-width var(--animation-duration-slow) ease;
236+ /* Prevent double gap on hidden icon */
237+ margin-left: -5px;
238+}
239+
240+.has_hover_label .label_icon,
241+.has_hover_label .label {
242+ transition-delay: var(--animation-duration-slow);
243+}
244+.has_hover_label.fast .label_icon,
245+.has_hover_label.fast .label {
246+ transition-delay: var(--animation-duration);
247+}
248+
249+.has_hover_label .label_icon,
250+.has_hover_label:hover .label {
251+ opacity: 1;
252+ max-width: 100px;
253+}
254+
255+.has_hover_label:hover .label_icon,
256+.has_hover_label .label {
257+ opacity: 0;
258+ max-width: 0;
259+ overflow: hidden;
260+}
261+
231262/* Keyboard/focus navigation styling */
232263/* Mimic the outline of keyboard navigation for most most focusable controls */
233264.interactable {
@@ -1339,6 +1370,8 @@ body.swipeAllMessages .mes:not(.last_mes) .swipes-counter {
13391370}
13401371
13411372.avatars_inline {
1373+ display: flex;
1374+ gap: 5px;
13421375 flex-wrap: wrap;
13431376 overflow: hidden;
13441377 max-height: calc(var(--avatar-base-height) + 2 * var(--avatar-base-border-radius));
@@ -1375,6 +1408,11 @@ body.swipeAllMessages .mes:not(.last_mes) .swipes-counter {
13751408 margin-right: calc(var(--avatar-base-border-radius));
13761409}
13771410
1411+.avatars_multiline {
1412+ max-height: fit-content;
1413+ height: inherit;
1414+}
1415+
13781416.mes_block {
13791417 padding-top: 0;
13801418 padding-left: 10px;
@@ -2869,6 +2907,7 @@ select option:not(:checked) {
28692907.menu_button.disabled {
28702908 filter: brightness(75%) grayscale(1);
28712909 opacity: 0.5;
2910+ cursor: inherit;
28722911 pointer-events: none;
28732912}
28742913
@@ -2943,8 +2982,7 @@ select option:not(:checked) {
29432982}
29442983
29452984#form_character_search_form .menu_button,
29462985#GroupFavDelOkBack .menu_button, {
2947-.avatar-container .menu_button {
29482986 margin: 0;
29492987 height: fit-content;
29502988 padding: 5px;
@@ -2978,8 +3016,22 @@ select option:not(:checked) {
29783016 width: max-content;
29793017}
29803018
29813019#persona-management-block .avatar_container_states .menu_button {
2982- filter: grayscale(0.5);
3020+ padding: 3px 5px;
3021+ pointer-events: initial;
3022+}
3023+
3024+#persona_controls .persona_name {
3025+ text-overflow: ellipsis;
3026+ overflow: hidden;
3027+ text-align: left;
3028+ white-space: nowrap;
3029+ font-size: calc(var(--mainFontSize) * 1.25);
3030+ opacity: 0.5;
3031+}
3032+
3033+#persona_connections_buttons {
3034+ margin-bottom: 5px;
29833035}
29843036
29853037input[type=search]::-webkit-search-cancel-button {
@@ -3304,6 +3356,10 @@ input[type=search]:focus::-webkit-search-cancel-button {
33043356 color: var(--golden);
33053357}
33063358
3359+.avatar.is_active {
3360+ outline: 2px solid var(--active);
3361+}
3362+
33073363#fav_chara_wrap {
33083364 display: flex;
33093365 margin: 5px 0px;
@@ -3371,28 +3427,6 @@ grammarly-extension {
33713427 z-index: 35;
33723428}
33733429
3374-.avatar-container .avatar-buttons {
3375- display: flex;
3376- flex-direction: row;
3377- gap: 3px;
3378- opacity: 0.3;
3379- transition: opacity 0.25s ease-in-out;
3380-}
3381-
3382-.avatar-container .avatar-buttons:hover {
3383- opacity: 1;
3384-}
3385-
3386-.avatar-container .avatar-buttons .menu_button {
3387- padding: 3px;
3388-}
3389-
3390-/* Ross should be able to handle this later */
3391-/*.big-avatars .avatar-buttons{
3392- justify-content: center;
3393- width: fit-content;
3394-}*/
3395-
33963430.avatar_div .avatar {
33973431 /* margin-left: 4px;
33983432 margin-right: 10px;
@@ -3455,12 +3489,7 @@ grammarly-extension {
34553489 cursor: pointer;
34563490}
34573491
34583492#rm_characters_block .form_create_bottom_buttons_blockbuttons_block {
3459- justify-content: space-evenly !important;
3460- flex-grow: 0;
3461-}
3462-
3463-.form_create_bottom_buttons_block {
34643493 display: flex;
34653494 flex: 1;
34663495 gap: 5px;
@@ -3469,18 +3498,21 @@ grammarly-extension {
34693498 flex-wrap: wrap;
34703499}
34713500
34723501.form_create_bottom_buttons_blockbuttons_block .menu_button {
34733502 display: flex;
34743503 justify-content: center;
34753504 align-items: center;
3505+ padding: 5px;
3506+ margin: 0;
3507+ filter: grayscale(0.5);
34763508}
34773509
3478-#delete_button,
3510+.red_button,
34793511.redWarningBG {
34803512 background-color: var(--crimson70a) !important;
34813513}
34823514
34833515#delete_button.red_button:hover,
34843516.redWarningBG:hover {
34853517 background-color: var(--fullred) !important;
34863518}
@@ -3655,6 +3687,7 @@ grammarly-extension {
36553687
36563688.menu_button {
36573689 color: var(--SmartThemeBodyColor);
3690+ filter: grayscale(0.5);
36583691 background-color: var(--SmartThemeBlurTintColor);
36593692 border: 1px solid var(--SmartThemeBorderColor);
36603693 border-radius: 5px;
@@ -3674,15 +3707,8 @@ grammarly-extension {
36743707 min-width: calc(1.25em + 12px);
36753708}
36763709
3677-.avatar_div .menu_button,
3710+.menu_button:not(.disabled):hover,
3678-.form_create_bottom_buttons_block .menu_button {
3711+.menu_button:not(.disabled).active {
3679- padding: 5px;
3680- margin: 0;
3681- filter: grayscale(0.5);
3682-}
3683-
3684-.menu_button:hover,
3685-.menu_button.active {
36863712 background-color: var(--white30a);
36873713}
36883714
@@ -3931,10 +3957,39 @@ input[type='checkbox'].del_checkbox {
39313957 outline: var(--avatar-base-border-radius) solid var(--golden);
39323958}
39333959
39343960.avatar-container .default_personaavatar_state .set_default_personafa-lock {
3961+ color: var(--active);
3962+}
3963+
3964+.avatar-container:not(.locked_to_chat) .locked_to_chat_label {
3965+ display: none;
3966+}
3967+.avatar-container:not(.locked_to_character) .locked_to_character_label {
3968+ display: none;
3969+}
3970+
3971+#lock_persona_default.locked i.icon {
39353972 color: var(--golden);
39363973}
39373974
3975+#lock_user_name.locked .icon,
3976+.avatar-container.locked_to_chat .locked_to_chat_label .icon {
3977+ color: var(--SmartThemeQuoteColor);
3978+}
3979+
3980+#lock_persona_to_char.locked .icon,
3981+.avatar-container.locked_to_character .locked_to_character_label .icon {
3982+ color: var(--active);
3983+}
3984+
3985+#lock_user_name.locked {
3986+ border-color: color-mix(in srgb, var(--SmartThemeQuoteColor) 50%, var(--SmartThemeBorderColor));;
3987+}
3988+
3989+#lock_persona_to_char.locked {
3990+ border-color: color-mix(in srgb, var(--active) 50%, var(--SmartThemeBorderColor));
3991+}
3992+
39383993#user_avatar_block .avatar_upload {
39393994 cursor: pointer;
39403995 width: 60px;
@@ -5958,3 +6013,40 @@ body:not(.movingUI) .drawer-content.maximized {
59586013 min-width: fit-content;
59596014 width: 40%;
59606015}
6016+
6017+.multiline {
6018+ white-space: pre-wrap;
6019+}
6020+
6021+.info-block {
6022+ padding: 10px 1em;
6023+ margin: 1em 0;
6024+ border-radius: 10px;
6025+ border-left: 5px solid;
6026+ background-color: var(--SmartThemeBlurTintColor);
6027+ color: var(--SmartThemeBodyColor);
6028+ backdrop-filter: blur(calc(var(--SmartThemeBlurStrength)));
6029+ -webkit-backdrop-filter: blur(calc(var(--SmartThemeBlurStrength)));
6030+}
6031+
6032+.info-block.hint {
6033+ border-color: var(--info-color, #7fb3e8);
6034+ background-color: rgba(163, 201, 241, 0.2);
6035+}
6036+
6037+.info-block.info {
6038+ border-color: var(--default-color, #e8e07f);
6039+ background-color: rgba(255, 255, 224, 0.2);
6040+}
6041+
6042+.info-block.warning {
6043+ border-color: var(--warning-color, #e8a97f);
6044+ background-color: rgba(241, 198, 163, 0.2);
6045+ /* Reset the font weight from that main warning class */
6046+ font-weight: unset;
6047+}
6048+
6049+.info-block.error {
6050+ border-color: var(--error-color, #e87f7f);
6051+ background-color: rgba(241, 163, 163, 0.2);
6052+}