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,
96 flex-direction: column;96 flex-direction: column;
97}97}
9898
99#user_avatar_block.gridView .avatar-container .avatar-buttons {
100 flex-wrap: wrap;
101 justify-content: space-evenly;
102}
103
104body.charListGrid #rm_print_characters_block .bogus_folder_select .character_select_container,99body.charListGrid #rm_print_characters_block .bogus_folder_select .character_select_container,
105body.charListGrid #rm_print_characters_block .character_select .character_select_container,100body.charListGrid #rm_print_characters_block .character_select .character_select_container,
106body.charListGrid #rm_print_characters_block .group_select .group_select_container,101body.charListGrid #rm_print_characters_block .group_select .group_select_container,
@@ -231,10 +226,16 @@ body.big-avatars .avatars_inline_small .avatar img {
231body.big-avatars .avatars_inline {226body.big-avatars .avatars_inline {
232 max-height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) + 2 * var(--avatar-base-border-radius));227 max-height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) + 2 * var(--avatar-base-border-radius));
233}228}
229body.big-avatars .avatars_inline.avatars_multiline {
230 max-height: fit-content;
231}
234232
235body.big-avatars .avatars_inline.avatars_inline_small {233body.big-avatars .avatars_inline.avatars_inline_small {
236 height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) * var(--inline-avatar-small-factor) + 2 * var(--avatar-base-border-radius));234 height: calc(var(--avatar-base-height) * var(--big-avatar-height-factor) * var(--inline-avatar-small-factor) + 2 * var(--avatar-base-border-radius));
237}235}
236body.big-avatars .avatars_inline.avatars_inline_small.avatars_multiline {
237 height: inherit;
238}
238239
239body:not(.big-avatars) .avatars_inline_small .avatar_collage {240body:not(.big-avatars) .avatars_inline_small .avatar_collage {
240 min-width: calc(var(--avatar-base-width) * var(--inline-avatar-small-factor));241 min-width: calc(var(--avatar-base-width) * var(--inline-avatar-small-factor));
public/index.html+89 -46
@@ -4987,7 +4987,7 @@
4987 </div>4987 </div>
4988 </div>4988 </div>
4989 <div id="persona-management-block" class="flex-container wide100p flexGap10">4989 <div id="persona-management-block" class="flex-container wide100p flexGap10">
4990 <div class="flex1 overflowHidden wide100p">4990 <div class="persona_management_left_column flex1 overflowHidden wide100p">
4991 <div class="flex-container marginBot10 alignitemscenter">4991 <div class="flex-container marginBot10 alignitemscenter">
4992 <div id="create_dummy_persona" class="menu_button menu_button_icon" title="Create a dummy persona" data-i18n="[title]Create a dummy persona">4992 <div id="create_dummy_persona" class="menu_button menu_button_icon" title="Create a dummy persona" data-i18n="[title]Create a dummy persona">
4993 <i class="fa-solid fa-person-circle-question fa-fw"></i>4993 <i class="fa-solid fa-person-circle-question fa-fw"></i>
@@ -5010,27 +5010,34 @@
5010 <input type="hidden" id="avatar_upload_overwrite" name="overwrite_name" value="">5010 <input type="hidden" id="avatar_upload_overwrite" name="overwrite_name" value="">
5011 </form>5011 </form>
5012 </div>5012 </div>
5013 <div class="flex1">5013 <div class="persona_management_right_column flex1">
5014 <h4 data-i18n="Name">Name</h4>5014 <div class="persona_management_current_persona">
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>
5021 <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">5021 <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>
5024 </div>5028 </div>
5025 </div>5029
5026 <div>
5027 <h4 data-i18n="Persona Description">Persona Description</h4>5030 <h4 data-i18n="Persona Description">Persona Description</h4>
5028 <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>5031 <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>
5031 </div>5038 </div>
5032 <div>5039
5033 <label for="persona_description_position" data-i18n="Position:">Position:</label>5040 <div class="persona_management_description_position_container">
5034 <select id="persona_description_position">5041 <select id="persona_description_position">
5035 <option value="9" data-i18n="None (disabled)">None (disabled)</option>5042 <option value="9" data-i18n="None (disabled)">None (disabled)</option>
5036 <option value="0" data-i18n="In Story String / Prompt Manager">In Story String / Prompt Manager</option>5043 <option value="0" data-i18n="In Story String / Prompt Manager">In Story String / Prompt Manager</option>
@@ -5053,14 +5060,54 @@
5053 </div>5060 </div>
5054 </div>5061 </div>
5055 </div>5062 </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>
5056 </div>5082 </div>
5057 <div class="range-block">5083
5058 <label for="persona_show_notifications" class="checkbox_label">5084 <div class="persona_management_global_settings">
5059 <input id="persona_show_notifications" type="checkbox" />5085 <h4 class="standoutHeader" data-i18n="Global Settings">Global Settings</h4>
5060 <span data-i18n="Show notifications on switching personas">5086
5061 Show notifications on switching personas5087 <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>
5064 </div>5111 </div>
5065 </div>5112 </div>
5066 </div>5113 </div>
@@ -5087,7 +5134,7 @@
5087 <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>5134 <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>
5088 </div>5135 </div>
5089 <div id="HotSwapWrapper" class="alignitemscenter flex-container margin0auto wide100p">5136 <div id="HotSwapWrapper" class="alignitemscenter flex-container margin0auto wide100p">
5090 <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>5137 <div class="hotswap avatars_inline 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>
5091 </div>5138 </div>
5092 </div>5139 </div>
5093 <hr>5140 <hr>
@@ -5125,13 +5172,13 @@
5125 </div>5172 </div>
5126 </div>5173 </div>
5127 <div class="flex-container flexFlowColumn expander flexNoGap">5174 <div class="flex-container flexFlowColumn expander flexNoGap">
5128 <div id="avatar_div" class="avatar_div alignitemsflexstart justifySpaceBetween flexnowrap">5175 <div id="avatar_div" class="avatar_div buttons_block alignitemsflexstart justifySpaceBetween flexnowrap">
5129 <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">5176 <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">
5130 <img id="avatar_load_preview" src="img/ai4.png" alt="avatar">5177 <img id="avatar_load_preview" src="img/ai4.png" alt="avatar">
5131 <input hidden type="file" id="add_avatar_button" name="avatar" accept="image/*">5178 <input hidden type="file" id="add_avatar_button" name="avatar" accept="image/*">
5132 </label>5179 </label>
5133 <div class="flex-container" id="avatar_controls">5180 <div class="flex-container" id="avatar_controls">
5134 <div class="form_create_bottom_buttons_block">5181 <div class="form_create_bottom_buttons_block buttons_block">
5135 <div id="rm_button_back" class="menu_button fa-solid fa-left-long "></div>5182 <div id="rm_button_back" class="menu_button fa-solid fa-left-long "></div>
5136 <!-- <div id="renameCharButton" class="menu_button fa-solid fa-user-pen" title="Rename Character"></div> -->5183 <!-- <div id="renameCharButton" class="menu_button fa-solid fa-user-pen" title="Rename Character"></div> -->
5137 <div id="favorite_button" class="menu_button fa-solid fa-star" title="Add to Favorites" data-i18n="[title]Add to Favorites"></div>5184 <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 @@
5139 <div id="advanced_div" class="menu_button fa-solid fa-book " title="Advanced Definitions" data-i18n="[title]Advanced Definition"></div>5186 <div id="advanced_div" class="menu_button fa-solid fa-book " title="Advanced Definitions" data-i18n="[title]Advanced Definition"></div>
5140 <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>5187 <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>
5141 <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>5188 <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>
5142 <div id="export_button" class="menu_button fa-solid fa-file-export " title="Export and Download" data-i18n="[title]Export and Download"></div>5190 <div id="export_button" class="menu_button fa-solid fa-file-export " title="Export and Download" data-i18n="[title]Export and Download"></div>
5143 <!-- <div id="set_chat_scenario" class="menu_button fa-solid fa-scroll" title="Set a chat scenario override"></div> -->5191 <!-- <div id="set_chat_scenario" class="menu_button fa-solid fa-scroll" title="Set a chat scenario override"></div> -->
5144 <!-- <div id="set_character_world" class="menu_button fa-solid fa-globe" title="Set a character World Info / Lorebook"></div> -->5192 <!-- <div id="set_character_world" class="menu_button fa-solid fa-globe" title="Set a character World Info / Lorebook"></div> -->
@@ -5146,7 +5194,7 @@
5146 <label for="create_button" id="create_button_label" class="menu_button fa-solid fa-user-check" title="Create Character" data-i18n="[title]Create Character">5194 <label for="create_button" id="create_button_label" class="menu_button fa-solid fa-user-check" title="Create Character" data-i18n="[title]Create Character">
5147 <input type="submit" id="create_button" name="create_button">5195 <input type="submit" id="create_button" name="create_button">
5148 </label>5196 </label>
5149 <div id="delete_button" class="menu_button fa-solid fa-skull " title="Delete Character" data-i18n="[title]Delete Character"></div>5197 <div id="delete_button" class="menu_button fa-solid fa-skull red_button" title="Delete Character" data-i18n="[title]Delete Character"></div>
5150 </div>5198 </div>
5151 <label class="flex1 height100p" for="char-management-dropdown">5199 <label class="flex1 height100p" for="char-management-dropdown">
5152 <select id="char-management-dropdown" class="text_pole">5200 <select id="char-management-dropdown" class="text_pole">
@@ -5483,25 +5531,20 @@
5483 <div class="flex-container wide100pLess70px character_select_container">5531 <div class="flex-container wide100pLess70px character_select_container">
5484 <div class="wide100p character_name_block">5532 <div class="wide100p character_name_block">
5485 <span class="ch_name flex1"></span>5533 <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>
5503 </div>5534 </div>
5504 <div class="ch_description"></div>5535 <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>
5505 </div>5548 </div>
5506 </div>5549 </div>
5507 </div>5550 </div>
public/script.js+27 -10
@@ -235,6 +235,8 @@ import {
235 initPersonas,235 initPersonas,
236 setPersonaDescription,236 setPersonaDescription,
237 initUserAvatar,237 initUserAvatar,
238 updatePersonaConnectionsAvatarList,
239 isPersonaPanelOpen,
238} from './scripts/personas.js';240} from './scripts/personas.js';
239import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js';241import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_settings } from './scripts/backgrounds.js';
240import { hideLoader, showLoader } from './scripts/loader.js';242import { hideLoader, showLoader } from './scripts/loader.js';
@@ -983,7 +985,7 @@ async function firstLoadInit() {
983 await initTokenizers();985 await initTokenizers();
984 initBackgrounds();986 initBackgrounds();
985 initAuthorsNote();987 initAuthorsNote();
986 initPersonas();988 await initPersonas();
987 initRossMods();989 initRossMods();
988 initStats();990 initStats();
989 initCfg();991 initCfg();
@@ -1362,6 +1364,14 @@ export function resultCheckStatus() {
1362 stopStatusLoading();1364 stopStatusLoading();
1363}1365}
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 */
1365export async function selectCharacterById(id) {1375export async function selectCharacterById(id) {
1366 if (characters[id] === undefined) {1376 if (characters[id] === undefined) {
1367 return;1377 return;
@@ -1435,7 +1445,7 @@ function getCharacterBlock(item, id) {
1435 }1445 }
1436 // Populate the template1446 // Populate the template
1437 const template = $('#character_template .character_select').clone();1447 const template = $('#character_template .character_select').clone();
1438 template.attr({ 'chid': id, 'id': `CharID${id}` });1448 template.attr({ 'data-chid': id, 'id': `CharID${id}` });
1439 template.find('img').attr('src', this_avatar).attr('alt', item.name);1449 template.find('img').attr('src', this_avatar).attr('alt', item.name);
1440 template.find('.avatar').attr('title', `[Character] ${item.name}\nFile: ${item.avatar}`);1450 template.find('.avatar').attr('title', `[Character] ${item.name}\nFile: ${item.avatar}`);
1441 template.find('.ch_name').text(item.name).attr('title', `[Character] ${item.name}`);1451 template.find('.ch_name').text(item.name).attr('title', `[Character] ${item.name}`);
@@ -1561,6 +1571,7 @@ export async function printCharacters(fullRefresh = false) {
1561 });1571 });
15621572
1563 favsToHotswap();1573 favsToHotswap();
1574 updatePersonaConnectionsAvatarList();
1564}1575}
15651576
1566/** Checks the state of the current search, and adds/removes the search sorting option accordingly */1577/** 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
6331 if (newChId !== -1) {6342 if (newChId !== -1) {
6332 // Select the character after the renaming6343 // Select the character after the renaming
6333 this_chid = -1;6344 this_chid = -1;
6334 await selectCharacterById(String(newChId));6345 await selectCharacterById(newChId);
63356346
6336 // Async delay to update UI6347 // Async delay to update UI
6337 await delay(1);6348 await delay(1);
@@ -6608,7 +6619,7 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t
6608 }6619 }
66096620
6610 avatarTemplate.attr('data-type', entity.type);6621 avatarTemplate.attr('data-type', entity.type);
6611 avatarTemplate.attr({ 'chid': id, 'id': `CharID${id}` });6622 avatarTemplate.attr('data-chid', id);
6612 avatarTemplate.find('img').attr('src', this_avatar).attr('alt', entity.item.name);6623 avatarTemplate.find('img').attr('src', this_avatar).attr('alt', entity.item.name);
6613 avatarTemplate.attr('title', `[Character] ${entity.item.name}\nFile: ${entity.item.avatar}`);6624 avatarTemplate.attr('title', `[Character] ${entity.item.name}\nFile: ${entity.item.avatar}`);
6614 if (highlightFavs) {6625 if (highlightFavs) {
@@ -6623,8 +6634,14 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t
6623 avatarTemplate.addClass(grpTemplate.attr('class'));6634 avatarTemplate.addClass(grpTemplate.attr('class'));
6624 avatarTemplate.empty();6635 avatarTemplate.empty();
6625 avatarTemplate.append(grpTemplate.children());6636 avatarTemplate.append(grpTemplate.children());
6637 avatarTemplate.attr({ 'data-grid': id, 'data-chid': null });
6626 avatarTemplate.attr('title', `[Group] ${entity.item.name}`);6638 avatarTemplate.attr('title', `[Group] ${entity.item.name}`);
6627 }6639 }
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
6629 if (interactable) {6646 if (interactable) {
6630 avatarTemplate.addClass(INTERACTABLE_CONTROL_CLASS);6647 avatarTemplate.addClass(INTERACTABLE_CONTROL_CLASS);
@@ -6861,14 +6878,14 @@ export function changeMainAPI() {
6861 forceCharacterEditorTokenize();6878 forceCharacterEditorTokenize();
6862}6879}
68636880
6864export function setUserName(value) {6881export function setUserName(value, { toastPersonaNameChange = true } = {}) {
6865 name1 = value;6882 name1 = value;
6866 if (name1 === undefined || name1 == '')6883 if (name1 === undefined || name1 == '')
6867 name1 = default_user_name;6884 name1 = default_user_name;
6868 console.log(`User name changed to ${name1}`);6885 console.log(`User name changed to ${name1}`);
6869 $('#your_name').val(name1);6886 $('#your_name').text(name1);
6870 if (power_user.persona_show_notifications) {6887 if (toastPersonaNameChange && power_user.persona_show_notifications && !isPersonaPanelOpen()) {
6871 toastr.success(t`Your messages will now be sent as ${name1}`, t`Current persona updated`);6888 toastr.success(t`Your messages will now be sent as ${name1}`, t`Persona Changed`);
6872 }6889 }
6873 saveSettingsDebounced();6890 saveSettingsDebounced();
6874}6891}
@@ -6920,7 +6937,7 @@ export async function getSettings() {
6920 settings = JSON.parse(data.settings);6937 settings = JSON.parse(data.settings);
6921 if (settings.username !== undefined && settings.username !== '') {6938 if (settings.username !== undefined && settings.username !== '') {
6922 name1 = settings.username;6939 name1 = settings.username;
6923 $('#your_name').val(name1);6940 $('#your_name').text(name1);
6924 }6941 }
69256942
6926 accountStorage.init(settings?.accountStorage);6943 accountStorage.init(settings?.accountStorage);
@@ -10054,7 +10071,7 @@ jQuery(async function () {
10054 });10071 });
1005510072
10056 $(document).on('click', '.character_select', async function () {10073 $(document).on('click', '.character_select', async function () {
10057 const id = $(this).attr('chid');10074 const id = Number($(this).attr('data-chid'));
10058 await selectCharacterById(id);10075 await selectCharacterById(id);
10059 });10076 });
1006010077
public/scripts/BulkEditOverlay.js+8 -8
@@ -672,7 +672,7 @@ class BulkEditOverlay {
672 * @param {HTMLElement} currentCharacter - The html element of the currently toggled character672 * @param {HTMLElement} currentCharacter - The html element of the currently toggled character
673 */673 */
674 handleShiftClick = (currentCharacter) => {674 handleShiftClick = (currentCharacter) => {
675 const characterId = currentCharacter.getAttribute('chid');675 const characterId = Number(currentCharacter.getAttribute('data-chid'));
676 const select = !this.selectedCharacters.includes(characterId);676 const select = !this.selectedCharacters.includes(characterId);
677677
678 if (this.lastSelected.characterId && this.lastSelected.select !== undefined) {678 if (this.lastSelected.characterId && this.lastSelected.select !== undefined) {
@@ -691,7 +691,7 @@ class BulkEditOverlay {
691 * @param {boolean} [param1.markState] - Whether the toggle of this character should be remembered as the last done toggle691 * @param {boolean} [param1.markState] - Whether the toggle of this character should be remembered as the last done toggle
692 */692 */
693 toggleSingleCharacter = (character, { markState = true } = {}) => {693 toggleSingleCharacter = (character, { markState = true } = {}) => {
694 const characterId = character.getAttribute('chid');694 const characterId = Number(character.getAttribute('data-chid'));
695695
696 const select = !this.selectedCharacters.includes(characterId);696 const select = !this.selectedCharacters.includes(characterId);
697 const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass);697 const legacyBulkEditCheckbox = character.querySelector('.' + BulkEditOverlay.legacySelectedClass);
@@ -699,11 +699,11 @@ class BulkEditOverlay {
699 if (select) {699 if (select) {
700 character.classList.add(BulkEditOverlay.selectedClass);700 character.classList.add(BulkEditOverlay.selectedClass);
701 if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;701 if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = true;
702 this.#selectedCharacters.push(String(characterId));702 this.#selectedCharacters.push(characterId);
703 } else {703 } else {
704 character.classList.remove(BulkEditOverlay.selectedClass);704 character.classList.remove(BulkEditOverlay.selectedClass);
705 if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;705 if (legacyBulkEditCheckbox) legacyBulkEditCheckbox.checked = false;
706 this.#selectedCharacters = this.#selectedCharacters.filter(item => String(characterId) !== item);706 this.#selectedCharacters = this.#selectedCharacters.filter(item => characterId !== item);
707 }707 }
708708
709 this.updateSelectedCount();709 this.updateSelectedCount();
@@ -732,15 +732,15 @@ class BulkEditOverlay {
732 * @param {boolean} select - <c>true</c> if the characters in the range are to be selected, <c>false</c> if deselected732 * @param {boolean} select - <c>true</c> if the characters in the range are to be selected, <c>false</c> if deselected
733 */733 */
734 toggleCharactersInRange = (currentCharacter, select) => {734 toggleCharactersInRange = (currentCharacter, select) => {
735 const currentCharacterId = currentCharacter.getAttribute('chid');735 const currentCharacterId = Number(currentCharacter.getAttribute('data-chid'));
736 const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));736 const characters = Array.from(document.querySelectorAll('#' + BulkEditOverlay.containerId + ' .' + BulkEditOverlay.characterClass));
737737
738 const startIndex = characters.findIndex(c => c.getAttribute('chid') === this.lastSelected.characterId);738 const startIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === Number(this.lastSelected.characterId));
739 const endIndex = characters.findIndex(c => c.getAttribute('chid') === currentCharacterId);739 const endIndex = characters.findIndex(c => Number(c.getAttribute('data-chid')) === currentCharacterId);
740740
741 for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) {741 for (let i = Math.min(startIndex, endIndex); i <= Math.max(startIndex, endIndex); i++) {
742 const character = characters[i];742 const character = characters[i];
743 const characterId = character.getAttribute('chid');743 const characterId = Number(character.getAttribute('data-chid'));
744 const isCharacterSelected = this.selectedCharacters.includes(characterId);744 const isCharacterSelected = this.selectedCharacters.includes(characterId);
745745
746 // Only toggle the character if it wasn't on the state we have are toggling towards.746 // 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() {
281 if (active_character !== null && active_character !== undefined) {281 if (active_character !== null && active_character !== undefined) {
282 const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character);282 const active_character_id = characters.findIndex(x => getTagKeyForEntity(x) === active_character);
283 if (active_character_id !== -1) {283 if (active_character_id !== -1) {
284 await selectCharacterById(String(active_character_id));284 await selectCharacterById(active_character_id);
285285
286 // Do a little tomfoolery to spoof the tag selector286 // Do a little tomfoolery to spoof the tag selector
287 const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`);287 const selectedCharElement = $(`#rm_print_characters_block .character_select[chid="${active_character_id}"]`);
@@ -894,14 +894,14 @@ export function initRossMods() {
894894
895 // when a char is selected from the list, save their name as the auto-load character for next page load895 // when a char is selected from the list, save their name as the auto-load character for next page load
896 $(document).on('click', '.character_select', function () {896 $(document).on('click', '.character_select', function () {
897 const characterId = $(this).attr('chid') || $(this).data('id');897 const characterId = $(this).attr('data-chid');
898 setActiveCharacter(characterId);898 setActiveCharacter(characterId);
899 setActiveGroup(null);899 setActiveGroup(null);
900 saveSettingsDebounced();900 saveSettingsDebounced();
901 });901 });
902902
903 $(document).on('click', '.group_select', function () {903 $(document).on('click', '.group_select', function () {
904 const groupId = $(this).attr('chid') || $(this).attr('grid') || $(this).data('id');904 const groupId = $(this).attr('data-chid') || $(this).attr('data-grid');
905 setActiveCharacter(null);905 setActiveCharacter(null);
906 setActiveGroup(groupId);906 setActiveGroup(groupId);
907 saveSettingsDebounced();907 saveSettingsDebounced();
public/scripts/group-chats.js+5 -5
@@ -691,7 +691,7 @@ export function getGroupBlock(group) {
691691
692 const template = $('#group_list_template .group_select').clone();692 const template = $('#group_list_template .group_select').clone();
693 template.data('id', group.id);693 template.data('id', group.id);
694 template.attr('grid', group.id);694 template.attr('data-grid', group.id);
695 template.find('.ch_name').text(group.name).attr('title', `[Group] ${group.name}`);695 template.find('.ch_name').text(group.name).attr('title', `[Group] ${group.name}`);
696 template.find('.group_fav_icon').css('display', 'none');696 template.find('.group_fav_icon').css('display', 'none');
697 template.addClass(group.fav ? 'is_fav' : '');697 template.addClass(group.fav ? 'is_fav' : '');
@@ -1359,7 +1359,7 @@ function getGroupCharacterBlock(character) {
1359 template.data('id', character.avatar);1359 template.data('id', character.avatar);
1360 template.find('.avatar img').attr({ 'src': avatar, 'title': character.avatar });1360 template.find('.avatar img').attr({ 'src': avatar, 'title': character.avatar });
1361 template.find('.ch_name').text(character.name);1361 template.find('.ch_name').text(character.name);
1362 template.attr('chid', characters.indexOf(character));1362 template.attr('data-chid', characters.indexOf(character));
1363 template.find('.ch_fav').val(isFav);1363 template.find('.ch_fav').val(isFav);
1364 template.toggleClass('is_fav', isFav);1364 template.toggleClass('is_fav', isFav);
13651365
@@ -1645,7 +1645,7 @@ async function onGroupActionClick(event) {
1645 }1645 }
16461646
1647 if (action === 'speak') {1647 if (action === 'speak') {
1648 const chid = Number(member.attr('chid'));1648 const chid = Number(member.attr('data-chid'));
1649 if (Number.isInteger(chid)) {1649 if (Number.isInteger(chid)) {
1650 Generate('normal', { force_chid: chid });1650 Generate('normal', { force_chid: chid });
1651 }1651 }
@@ -1700,7 +1700,7 @@ function openCharacterDefinition(characterSelect) {
1700 return;1700 return;
1701 }1701 }
17021702
1703 const chid = characterSelect.attr('chid');1703 const chid = characterSelect.attr('data-chid');
17041704
1705 if (chid === null || chid === undefined) {1705 if (chid === null || chid === undefined) {
1706 return;1706 return;
@@ -2018,7 +2018,7 @@ jQuery(() => {
2018 }2018 }
20192019
2020 $(document).on('click', '.group_select', function () {2020 $(document).on('click', '.group_select', function () {
2021 const groupId = $(this).attr('chid') || $(this).attr('grid') || $(this).data('id');2021 const groupId = $(this).attr('data-chid') || $(this).attr('data-grid');
2022 openGroupById(groupId);2022 openGroupById(groupId);
2023 });2023 });
2024 $('#rm_group_filter').on('input', filterGroupMembers);2024 $('#rm_group_filter').on('input', filterGroupMembers);
public/scripts/personas.js+1046 -273
@@ -1,4 +1,6 @@
1import {1import {
2 buildAvatarList,
3 characterToEntity,
2 characters,4 characters,
3 chat,5 chat,
4 chat_metadata,6 chat_metadata,
@@ -7,7 +9,10 @@ import {
7 event_types,9 event_types,
8 getRequestHeaders,10 getRequestHeaders,
9 getThumbnailUrl,11 getThumbnailUrl,
12 groupToEntity,
13 menu_type,
10 name1,14 name1,
15 name2,
11 reloadCurrentChat,16 reloadCurrentChat,
12 saveChatConditional,17 saveChatConditional,
13 saveMetadata,18 saveMetadata,
@@ -17,23 +22,63 @@ import {
17} from '../script.js';22} from '../script.js';
18import { persona_description_positions, power_user } from './power-user.js';23import { persona_description_positions, power_user } from './power-user.js';
19import { getTokenCountAsync } from './tokenizers.js';24import { getTokenCountAsync } from './tokenizers.js';
20import { PAGINATION_TEMPLATE, debounce, delay, download, ensureImageFormatSupported, flashHighlight, getBase64Async, parseJsonFile } from './utils.js';25import { PAGINATION_TEMPLATE, clearInfoBlock, debounce, delay, download, ensureImageFormatSupported, flashHighlight, getBase64Async, getCharIndex, isFalseBoolean, isTrueBoolean, onlyUnique, parseJsonFile, setInfoBlock } from './utils.js';
21import { debounce_timeout } from './constants.js';26import { debounce_timeout } from './constants.js';
22import { FILTER_TYPES, FilterHelper } from './filters.js';27import { FILTER_TYPES, FilterHelper } from './filters.js';
23import { selected_group } from './group-chats.js';28import { groups, selected_group } from './group-chats.js';
24import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';29import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
25import { t } from './i18n.js';30import { t } from './i18n.js';
26import { openWorldInfoEditor, world_names } from './world-info.js';31import { openWorldInfoEditor, world_names } from './world-info.js';
27import { renderTemplateAsync } from './templates.js';32import { renderTemplateAsync } from './templates.js';
33import { saveMetadataDebounced } from './extensions.js';
28import { accountStorage } from './util/AccountStorage.js';34import { accountStorage } from './util/AccountStorage.js';
35import { SlashCommand } from './slash-commands/SlashCommand.js';
36import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
37import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
38import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
39import { 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
58const USER_AVATAR_PATH = 'User Avatars/';
2959
30let savePersonasPage = 0;60let savePersonasPage = 0;
31const GRID_STORAGE_KEY = 'Personas_GridView';61const GRID_STORAGE_KEY = 'Personas_GridView';
32const DEFAULT_DEPTH = 2;62const DEFAULT_DEPTH = 2;
33const DEFAULT_ROLE = 0;63const DEFAULT_ROLE = 0;
64
65/** @type {string} The currently selected persona (identified by its avatar) */
34export let user_avatar = '';66export let user_avatar = '';
67
68/** @type {FilterHelper} Filter helper for the persona list */
35export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));69export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
3670
71/** @type {function(string): void} */
72let navigateToAvatar = () => { };
73
74/**
75 * Checks if the Persona Management panel is currently open
76 * @returns {boolean}
77 */
78export function isPersonaPanelOpen() {
79 return document.querySelector('#persona-management-button .drawer-content')?.classList.contains('openDrawer') ?? false;
80}
81
37function switchPersonaGridView() {82function switchPersonaGridView() {
38 const state = accountStorage.getItem(GRID_STORAGE_KEY) === 'true';83 const state = accountStorage.getItem(GRID_STORAGE_KEY) === 'true';
39 $('#user_avatar_block').toggleClass('gridView', state);84 $('#user_avatar_block').toggleClass('gridView', state);
@@ -45,33 +90,31 @@ function switchPersonaGridView() {
45 * @returns {string} User avatar URL90 * @returns {string} User avatar URL
46 */91 */
47export function getUserAvatar(avatarImg) {92export function getUserAvatar(avatarImg) {
48 return `User Avatars/${avatarImg}`;93 return `${USER_AVATAR_PATH}${avatarImg}`;
49}94}
5095
51export function initUserAvatar(avatar) {96export function initUserAvatar(avatar) {
52 user_avatar = avatar;97 user_avatar = avatar;
53 reloadUserAvatar();98 reloadUserAvatar();
54 highlightSelectedAvatar();99 updatePersonaUIStates();
55}100}
56101
57/**102/**
58 * Sets a user avatar file103 * Sets a user avatar file
59 * @param {string} imgfile Link to an image file104 * @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
60 */108 */
61export function setUserAvatar(imgfile) {109export function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigateToCurrent = false } = {}) {
62 user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('imgfile');110 user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('data-avatar-id');
63 reloadUserAvatar();111 reloadUserAvatar();
64 highlightSelectedAvatar();112 updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });
65 selectCurrentPersona();113 selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });
66 saveSettingsDebounced();114 saveSettingsDebounced();
67 $('.zoomed_avatar[forchar]').remove();115 $('.zoomed_avatar[forchar]').remove();
68}116}
69117
70function highlightSelectedAvatar() {
71 $('#user_avatar_block .avatar-container').removeClass('selected');
72 $(`#user_avatar_block .avatar-container[imgfile="${user_avatar}"]`).addClass('selected');
73}
74
75function reloadUserAvatar(force = false) {118function reloadUserAvatar(force = false) {
76 $('.mes').each(function () {119 $('.mes').each(function () {
77 const avatarImg = $(this).find('.avatar img');120 const avatarImg = $(this).find('.avatar img');
@@ -131,29 +174,49 @@ function verifyPersonaSearchSortRule() {
131174
132/**175/**
133 * Gets a rendered avatar block.176 * Gets a rendered avatar block.
134 * @param {string} name Avatar file name177 * @param {string} avatarId Avatar file name
135 * @returns {JQuery<HTMLElement>} Avatar block178 * @returns {JQuery<HTMLElement>} Avatar block
136 */179 */
137function getUserAvatarBlock(name) {180function getUserAvatarBlock(avatarId) {
138 const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;181 const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
139 const template = $('#user_avatar_template .avatar-container').clone();182 const template = $('#user_avatar_template .avatar-container').clone();
140 const personaName = power_user.personas[name];183 const personaName = power_user.personas[avatarId];
141 const personaDescription = power_user.persona_descriptions[name]?.description;184 const personaDescription = power_user.persona_descriptions[avatarId]?.description;
185
142 template.find('.ch_name').text(personaName || '[Unnamed Persona]');186 template.find('.ch_name').text(personaName || '[Unnamed Persona]');
143 template.find('.ch_description').text(personaDescription || $('#user_avatar_block').attr('no_desc_text')).toggleClass('text_muted', !personaDescription);187 template.find('.ch_description').text(personaDescription || $('#user_avatar_block').attr('no_desc_text')).toggleClass('text_muted', !personaDescription);
144 template.attr('imgfile', name);188 template.attr('data-avatar-id', avatarId);
145 template.find('.avatar').attr('imgfile', name).attr('title', name);189 template.find('.avatar').attr('data-avatar-id', avatarId).attr('title', avatarId);
146 template.toggleClass('default_persona', name === power_user.default_persona);190 template.toggleClass('default_persona', avatarId === power_user.default_persona);
147 let avatarUrl = getUserAvatar(name);191 let avatarUrl = getUserAvatar(avatarId);
148 if (isFirefox) {192 if (isFirefox) {
149 avatarUrl += '?t=' + Date.now();193 avatarUrl += '?t=' + Date.now();
150 }194 }
151 template.find('img').attr('src', avatarUrl);195 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
152 $('#user_avatar_block').append(template);203 $('#user_avatar_block').append(template);
153 return template;204 return template;
154}205}
155206
156/**207/**
208 * Initialize missing personas in the power user settings.
209 * @param {string[]} avatarsList List of avatar file names
210 */
211function addMissingPersonas(avatarsList) {
212 for (const persona of avatarsList) {
213 if (!power_user.personas[persona]) {
214 initPersona(persona, '[Unnamed Persona]', '');
215 }
216 }
217}
218
219/**
157 * Gets a list of user avatars.220 * Gets a list of user avatars.
158 * @param {boolean} doRender Whether to render the list221 * @param {boolean} doRender Whether to render the list
159 * @param {string} openPageAt Item to be opened at222 * @param {string} openPageAt Item to be opened at
@@ -175,6 +238,8 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
175 return allEntities;238 return allEntities;
176 }239 }
177240
241 // If any persona is missing from the power user settings, we add it
242 addMissingPersonas(allEntities);
178 // Before printing the personas, we check if we should enable/disable search sorting243 // Before printing the personas, we check if we should enable/disable search sorting
179 verifyPersonaSearchSortRule();244 verifyPersonaSearchSortRule();
180245
@@ -203,7 +268,7 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
203 for (const item of data) {268 for (const item of data) {
204 $(listId).append(getUserAvatarBlock(item));269 $(listId).append(getUserAvatarBlock(item));
205 }270 }
206 highlightSelectedAvatar();271 updatePersonaUIStates();
207 },272 },
208 afterSizeSelectorChange: function (e) {273 afterSizeSelectorChange: function (e) {
209 accountStorage.setItem(storageKey, e.target.value);274 accountStorage.setItem(storageKey, e.target.value);
@@ -216,14 +281,16 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
216 },281 },
217 });282 });
218283
219 if (openPageAt) {284 navigateToAvatar = (avatarId) => {
220 const avatarIndex = entities.indexOf(openPageAt);285 const avatarIndex = entities.indexOf(avatarId);
221 const page = Math.floor(avatarIndex / perPage) + 1;286 const page = Math.floor(avatarIndex / perPage) + 1;
222287
223 if (avatarIndex !== -1) {288 if (avatarIndex !== -1) {
224 $('#persona_pagination_container').pagination('go', page);289 $('#persona_pagination_container').pagination('go', page);
225 }290 }
226 }291 };
292
293 openPageAt && navigateToAvatar(openPageAt);
227294
228 return allEntities;295 return allEntities;
229 }296 }
@@ -233,7 +300,7 @@ export async function getUserAvatars(doRender = true, openPageAt = '') {
233 * Uploads an avatar file to the server300 * Uploads an avatar file to the server
234 * @param {string} url URL for the avatar file301 * @param {string} url URL for the avatar file
235 * @param {string} [name] Optional name for the avatar file302 * @param {string} [name] Optional name for the avatar file
236 * @returns {Promise} Promise object representing the AJAX request303 * @returns {Promise} Promise that resolves when the avatar is uploaded
237 */304 */
238async function uploadUserAvatar(url, name) {305async function uploadUserAvatar(url, name) {
239 const fetchResult = await fetch(url);306 const fetchResult = await fetch(url);
@@ -246,18 +313,17 @@ async function uploadUserAvatar(url, name) {
246 formData.append('overwrite_name', name);313 formData.append('overwrite_name', name);
247 }314 }
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',
254 cache: false,321 headers: headers,
255 contentType: false,322 cache: 'no-cache',
256 processData: false,323 body: formData,
257 success: async function () {
258 await getUserAvatars(true, name);
259 },
260 });324 });
325
326 await getUserAvatars(true, name);
261}327}
262328
263async function changeUserAvatar(e) {329async function changeUserAvatar(e) {
@@ -298,33 +364,35 @@ async function changeUserAvatar(e) {
298 formData.set('avatar', convertedFile);364 formData.set('avatar', convertedFile);
299 }365 }
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,
326 });375 });
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
328 // Will allow to select the same file twice in a row396 // Will allow to select the same file twice in a row
329 form.reset();397 form.reset();
330}398}
@@ -384,13 +452,25 @@ export function initPersona(avatarId, personaName, personaDescription) {
384 saveSettingsDebounced();452 saveSettingsDebounced();
385}453}
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 */
387export async function convertCharacterToPersona(characterId = null) {467export async function convertCharacterToPersona(characterId = null) {
388 if (null === characterId) characterId = this_chid;468 if (null === characterId) characterId = this_chid;
389469
390 const avatarUrl = characters[characterId]?.avatar;470 const avatarUrl = characters[characterId]?.avatar;
391 if (!avatarUrl) {471 if (!avatarUrl) {
392 console.log('No avatar found for this character');472 console.log('No avatar found for this character');
393 return;473 return false;
394 }474 }
395475
396 const name = characters[characterId]?.name;476 const name = characters[characterId]?.name;
@@ -401,7 +481,7 @@ export async function convertCharacterToPersona(characterId = null) {
401 const confirm = await Popup.show.confirm(t`Overwrite Existing Persona`, t`This character exists as a persona already. Do you want to overwrite it?`);481 const confirm = await Popup.show.confirm(t`Overwrite Existing Persona`, t`This character exists as a persona already. Do you want to overwrite it?`);
402 if (!confirm) {482 if (!confirm) {
403 console.log('User cancelled the overwrite of the persona');483 console.log('User cancelled the overwrite of the persona');
404 return;484 return false;
405 }485 }
406 }486 }
407487
@@ -439,6 +519,7 @@ export async function convertCharacterToPersona(characterId = null) {
439 await getUserAvatars(true, overwriteName);519 await getUserAvatars(true, overwriteName);
440 // Reload the persona description520 // Reload the persona description
441 setPersonaDescription();521 setPersonaDescription();
522 return true;
442}523}
443524
444/**525/**
@@ -450,7 +531,12 @@ const countPersonaDescriptionTokens = debounce(async () => {
450 $('#persona_description_token_count').text(String(count));531 $('#persona_description_token_count').text(String(count));
451}, debounce_timeout.relaxed);532}, debounce_timeout.relaxed);
452533
534/**
535 * Updates the UI for the Persona Management page with the current persona values
536 */
453export function setPersonaDescription() {537export function setPersonaDescription() {
538 $('#your_name').text(name1);
539
454 if (power_user.persona_description_position === persona_description_positions.AFTER_CHAR) {540 if (power_user.persona_description_position === persona_description_positions.AFTER_CHAR) {
455 power_user.persona_description_position = persona_description_positions.IN_PROMPT;541 power_user.persona_description_position = persona_description_positions.IN_PROMPT;
456 }542 }
@@ -468,116 +554,219 @@ export function setPersonaDescription() {
468 .prop('selected', String(true));554 .prop('selected', String(true));
469 $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);555 $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);
470 countPersonaDescriptionTokens();556 countPersonaDescriptionTokens();
557
558 updatePersonaUIStates();
559 updatePersonaConnectionsAvatarList();
471}560}
472561
473export 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;567function 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;
481}572}
482573
483/**574/**
484 * Updates the name of a persona if it exists.575 * Builds a list of persona avatars and populates the given block element with them.
485 * @param {string} avatarId User avatar id576 *
486 * @param {string} newName New name for the persona577 * @param {HTMLElement} block - The HTML element where the avatar 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
487 */583 */
488async function updatePersonaNameIfExists(avatarId, newName) {584export 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 || '',
495 description: '',591 avatar: 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();
506}597}
507598
508async 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 */
603export 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
512 if (!avatarId) {618 if (entities.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 anything625/**
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 */
638export 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
530 if (personaName && personaName.length > 0) {673 if (personasToHighlight && personasToHighlight.includes(block.dataset.pid)) {
531 // If the user clicked ok and entered a name, bind the name to the persona674 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 };
547 }677 }
678 });
548679
549 // If the user is currently using this persona, update the name680 /** @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 */
718export 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;
553 }724 }
554 } else {725 }
555 // If the user clicked ok, but didn't enter a name, delete the persona726 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 */
734async 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);
559 }747 }
560748
561 saveSettingsDebounced();749 saveSettingsDebounced();
562 await getUserAvatars(true, avatarId);750 await getUserAvatars(true, avatarId);
751 updatePersonaUIStates();
563 setPersonaDescription();752 setPersonaDescription();
753 return true;
564}754}
565755
566function 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 */
762async function selectCurrentPersona({ toastPersonaNameChange = true } = {}) {
567 const personaName = power_user.personas[user_avatar];763 const personaName = power_user.personas[user_avatar];
568 if (personaName) {764 if (personaName) {
569 const lockedPersona = chat_metadata['persona'];765 const shouldAutoLock = 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
578 if (personaName !== name1) {767 if (personaName !== name1) {
579 console.log(`Auto-updating user name to ${personaName}`);768 console.log(`Auto-updating user name to ${personaName}`);
580 setUserName(personaName);769 setUserName(personaName, { toastPersonaNameChange: !shouldAutoLock && toastPersonaNameChange });
581 }770 }
582771
583 const descriptor = power_user.persona_descriptions[user_avatar];772 const descriptor = power_user.persona_descriptions[user_avatar];
@@ -600,67 +789,144 @@ function selectCurrentPersona() {
600 depth: DEFAULT_DEPTH,789 depth: DEFAULT_DEPTH,
601 role: DEFAULT_ROLE,790 role: DEFAULT_ROLE,
602 lorebook: '',791 lorebook: '',
792 connections: [],
603 };793 };
604 }794 }
605795
606 setPersonaDescription();796 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 }
607 }820 }
608}821}
609822
610/**823/**
611 * Checks if the persona is locked for the current chat.824 * Checks if a connection is locked for the current character or group edit menu
825 * @param {PersonaConnection} connection - Connection to check
826 * @returns {boolean} Whether the connection is locked
827 */
828export 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
612 * @returns {boolean} Whether the persona is locked836 * @returns {boolean} Whether the persona is locked
613 */837 */
614function isPersonaLocked() {838export 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 }
616}849}
617850
618/**851/**
619 * Locks or unlocks the persona for the current chat.852 * Locks or unlocks the persona
620 * @param {boolean} state Desired lock state853 * @param {boolean} state Desired lock state
854 * @param {PersonaLockType} type - Lock type
621 * @returns {Promise<void>}855 * @returns {Promise<void>}
622 */856 */
623export async function setPersonaLockState(state) {857export async function setPersonaLockState(state, type = 'chat') {
624 return state ? await lockPersona() : await unlockPersona();858 return state ? await lockPersona(type) : await unlockPersona(type);
625}859}
626860
627/**861/**
628 * Toggle the persona lock state for the current chat.862 * Toggle the persona lock state
629 * @returns {Promise<void>}863 * @param {PersonaLockType} type - Lock type
864 * @returns {Promise<boolean>} - Whether the persona was locked
630 */865 */
631export async function togglePersonaLock() {866export async function togglePersonaLock(type = 'chat') {
632 return isPersonaLocked()867 if (isPersonaLocked(type)) {
633 ? await unlockPersona()868 await unlockPersona(type);
634 : await lockPersona();869 return false;
870 } else {
871 await lockPersona(type);
872 return true;
873 }
635}874}
636875
637/**876/**
638 * Unlock the persona for the current chat.877 * Unlock the persona
878 * @param {PersonaLockType} type - Lock type
879 * @returns {Promise<void>}
639 */880 */
640async function unlockPersona() {881async function unlockPersona(type = 'chat') {
641 if (chat_metadata['persona']) {882 switch (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;
647 }898 }
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}`);
649 }915 }
916
917 updatePersonaUIStates();
650}918}
651919
652/**920/**
653 * Lock the persona for the current chat.921 * Lock the persona
922 * @param {PersonaLockType} type - Lock type
654 */923 */
655async function lockPersona() {924async function lockPersona(type = 'chat') {
925 // First make sure that user_avatar is actually a persona
656 if (!(user_avatar in power_user.personas)) {926 if (!(user_avatar in power_user.personas)) {
657 console.log(`Creating a new persona ${user_avatar}`);927 console.log(`Creating a new persona ${user_avatar}`);
658 if (power_user.persona_show_notifications) {928 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 );
664 }930 }
665 power_user.personas[user_avatar] = name1;931 power_user.personas[user_avatar] = name1;
666 power_user.persona_descriptions[user_avatar] = {932 power_user.persona_descriptions[user_avatar] = {
@@ -669,36 +935,75 @@ async function lockPersona() {
669 depth: DEFAULT_DEPTH,935 depth: DEFAULT_DEPTH,
670 role: DEFAULT_ROLE,936 role: DEFAULT_ROLE,
671 lorebook: '',937 lorebook: '',
938 connections: [],
672 };939 };
673 }940 }
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}`);
681 }992 }
682 updateUserLockIcon();993
994 updatePersonaUIStates();
683}995}
684996
685997
686async function deleteUserAvatar(e) {998async function deleteUserAvatar() {
687 e?.stopPropagation();999 const avatarId = user_avatar;
688 const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
6891000
690 if (!avatarId) {1001 if (!avatarId) {
691 console.warn('No avatar id found');1002 console.warn('No avatar id found');
692 return;1003 return;
693 }1004 }
6941005 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
703 if (!confirm) {1008 if (!confirm) {
704 console.debug('User cancelled deleting avatar');1009 console.debug('User cancelled deleting avatar');
@@ -719,19 +1024,20 @@ async function deleteUserAvatar(e) {
719 delete power_user.persona_descriptions[avatarId];1024 delete power_user.persona_descriptions[avatarId];
7201025
721 if (avatarId === power_user.default_persona) {1026 if (avatarId === power_user.default_persona) {
722 toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default persona deleted`);1027 toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default Persona Deleted`);
723 power_user.default_persona = null;1028 power_user.default_persona = null;
724 }1029 }
7251030
726 if (avatarId === chat_metadata['persona']) {1031 if (avatarId === chat_metadata['persona']) {
727 toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona deleted`);1032 toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona Deleted`);
728 delete chat_metadata['persona'];1033 delete chat_metadata['persona'];
729 await saveMetadata();1034 await saveMetadata();
730 }1035 }
7311036
732 saveSettingsDebounced();1037 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 });
735 }1041 }
736}1042}
7371043
@@ -786,14 +1092,14 @@ function onPersonaDescriptionDepthRoleInput() {
7861092
787/**1093/**
788 * Opens a popup to set the lorebook for the current persona.1094 * Opens a popup to set the lorebook for the current persona.
789 * @param {PointerEvent} event Click event1095 * @param {JQuery.ClickEvent} event Click event
790 */1096 */
791async function onPersonaLoreButtonClick(event) {1097async function onPersonaLoreButtonClick(event) {
792 const personaName = power_user.personas[user_avatar];1098 const personaName = power_user.personas[user_avatar];
793 const selectedLorebook = power_user.persona_description_lorebook;1099 const selectedLorebook = power_user.persona_description_lorebook;
7941100
795 if (!personaName) {1101 if (!personaName) {
796 toastr.warning(t`You must bind a name to this persona before you can set a lorebook.`, t`Persona name not set`);1102 toastr.warning(t`You must bind a name to this persona before you can set a lorebook.`, t`Persona Name Not Set`);
797 return;1103 return;
798 }1104 }
7991105
@@ -854,16 +1160,21 @@ function getOrCreatePersonaDescriptor() {
854 depth: power_user.persona_description_depth,1160 depth: power_user.persona_description_depth,
855 role: power_user.persona_description_role,1161 role: power_user.persona_description_role,
856 lorebook: power_user.persona_description_lorebook,1162 lorebook: power_user.persona_description_lorebook,
1163 connections: [],
857 };1164 };
858 power_user.persona_descriptions[user_avatar] = object;1165 power_user.persona_descriptions[user_avatar] = object;
859 }1166 }
860 return object;1167 return object;
861}1168}
8621169
863async 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
8661173 * @param {object} [options] Optional arguments
1174 * @param {boolean} [options.quiet=false] If true, no confirmation popups will be shown
1175 * @returns {Promise<void>}
1176 */
1177async function toggleDefaultPersona(avatarId, { quiet = false } = {}) {
867 if (!avatarId) {1178 if (!avatarId) {
868 console.warn('No avatar id found');1179 console.warn('No avatar id found');
869 return;1180 return;
@@ -873,78 +1184,264 @@ async function setDefaultPersona(e) {
8731184
874 if (power_user.personas[avatarId] === undefined) {1185 if (power_user.personas[avatarId] === undefined) {
875 console.warn(`No persona name found for avatar ${avatarId}`);1186 console.warn(`No persona name found for avatar ${avatarId}`);
876 toastr.warning(t`You must bind a name to this persona before you can set it as the default.`, t`Persona name not set`);1187 toastr.warning(t`You must bind a name to this persona before you can set it as the default.`, t`Persona Name Not Set`);
877 return;1188 return;
878 }1189 }
8791190
880 const personaName = power_user.personas[avatarId];
8811191
882 if (avatarId === currentDefault) {1192 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) {
8841194 const confirm = await Popup.show.confirm(t`Are you sure you want to remove the default persona?`, power_user.personas[avatarId]);
885 if (!confirm) {1195 if (!confirm) {
886 console.debug('User cancelled removing default persona');1196 console.debug('User cancelled removing default persona');
887 return;1197 return;
1198 }
888 }1199 }
8891200
890 console.log(`Removing default persona ${avatarId}`);1201 console.log(`Removing default persona ${avatarId}`);
891 if (power_user.persona_show_notifications) {1202 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
892 toastr.info(t`This persona will no longer be used by default when you open a new chat.`, t`Default persona removed`);1203 toastr.info(t`This persona will no longer be used by default when you open a new chat.`, t`Default Persona Removed`);
893 }1204 }
894 delete power_user.default_persona;1205 delete power_user.default_persona;
895 } else {1206 } 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) {
8971208 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 }
901 }1216 }
9021217
903 power_user.default_persona = avatarId;1218 power_user.default_persona = avatarId;
904 if (power_user.persona_show_notifications) {1219 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
905 toastr.success(t`This persona will be used by default when you open a new chat.`, t`Default persona set to ${personaName}`);1220 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`);
906 }1221 }
907 }1222 }
9081223
909 saveSettingsDebounced();1224 saveSettingsDebounced();
910 await getUserAvatars(true, avatarId);1225 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 */
1239function 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
1269function 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 }
911}1317}
9121318
913function 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 */
1336function 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 };
917}1352}
9181353
919async 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 */
1361async function loadPersonaForCurrentChat({ doRender = false } = {}) {
1362 // Cache persona list to check if they exist
1363 const userAvatars = await getUserAvatars(doRender);
1364
920 // Define a persona for this chat1365 // Define a persona for this chat
921 let chatPersona = '';1366 let chatPersona = '';
9221367
1368 /** @type {'chat' | 'character' | 'default' | null} */
1369 let connectType = null;
1370
1371 // If persona is locked in chat metadata, select it
923 if (chat_metadata['persona']) {1372 if (chat_metadata['persona']) {
924 // If persona is locked in chat metadata, select it
925 console.log(`Using locked persona ${chat_metadata['persona']}`);1373 console.log(`Using locked persona ${chat_metadata['persona']}`);
926 chatPersona = chat_metadata['persona'];1374 chatPersona = chat_metadata['persona'];
927 } else if (power_user.default_persona) {1375
928 // If default persona is set, select it1376 // 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';
931 }1384 }
9321385
933 // No persona set: user current settings1386 // 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
934 if (!chatPersona) {1414 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';
937 }1431 }
9381432
939 // Find the avatar file1433 // 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
943 if (chat_metadata['persona'] && !userAvatars.includes(chatPersona)) {1442 if (chat_metadata['persona'] && !userAvatars.includes(chat_metadata['persona'])) {
944 console.warn('Persona avatar not found, unlocking persona');1443 console.warn('Persona avatar not found, unlocking persona');
945 delete chat_metadata['persona'];1444 delete chat_metadata['persona'];
946 updateUserLockIcon();
947 return;
948 }1445 }
9491446
950 // Default persona missing1447 // Default persona missing
@@ -952,12 +1449,101 @@ async function setChatLockedPersona() {
952 console.warn('Default persona avatar not found, clearing default persona');1449 console.warn('Default persona avatar not found, clearing default persona');
953 power_user.default_persona = null;1450 power_user.default_persona = null;
954 saveSettingsDebounced();1451 saveSettingsDebounced();
955 return;
956 }1452 }
9571453
958 // Persona avatar found, select it1454 // 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 */
1479export 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 */
1493export 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 */
1541export 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;
961}1547}
9621548
963function onBackupPersonas() {1549function onBackupPersonas() {
@@ -1038,10 +1624,10 @@ async function onPersonasRestoreInput(e) {
1038 }1624 }
10391625
1040 if (warnings.length) {1626 if (warnings.length) {
1041 toastr.success(t`Personas restored with warnings. Check console for details.`);1627 toastr.success(t`Personas restored with warnings. Check console for details.`, t`Persona Management`);
1042 console.warn(`PERSONA RESTORE REPORT\n====================\n${warnings.join('\n')}`);1628 console.warn(`PERSONA RESTORE REPORT\n====================\n${warnings.join('\n')}`);
1043 } else {1629 } else {
1044 toastr.success(t`Personas restored successfully.`);1630 toastr.success(t`Personas restored successfully.`, t`Persona Management`);
1045 }1631 }
10461632
1047 await getUserAvatars();1633 await getUserAvatars();
@@ -1068,6 +1654,11 @@ async function syncUserNameToPersona() {
1068 await reloadCurrentChat();1654 await reloadCurrentChat();
1069}1655}
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 */
1071export function retriggerFirstMessageOnEmptyChat() {1662export function retriggerFirstMessageOnEmptyChat() {
1072 if (this_chid >= 0 && !selected_group && chat.length === 1) {1663 if (this_chid >= 0 && !selected_group && chat.length === 1) {
1073 $('#firstmessage_textarea').trigger('input');1664 $('#firstmessage_textarea').trigger('input');
@@ -1083,7 +1674,7 @@ async function duplicatePersona(avatarId) {
1083 const personaName = power_user.personas[avatarId];1674 const personaName = power_user.personas[avatarId];
10841675
1085 if (!personaName) {1676 if (!personaName) {
1086 toastr.warning('Chosen avatar is not a persona');1677 toastr.warning('Chosen avatar is not a persona', t`Persona Management`);
1087 return;1678 return;
1088 }1679 }
10891680
@@ -1111,11 +1702,207 @@ async function duplicatePersona(avatarId) {
1111 saveSettingsDebounced();1702 saveSettingsDebounced();
1112}1703}
11131704
1114export 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);1708async 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 */
1725async 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 */
1762function 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
1798function syncCallback() {
1799 $('#sync_name_button').trigger('click');
1800 return '';
1801}
1802
1803function 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 */
1899export 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'));
1119 $('#create_dummy_persona').on('click', createDummyPersona);1906 $('#create_dummy_persona').on('click', createDummyPersona);
1120 $('#persona_description').on('input', onPersonaDescriptionInput);1907 $('#persona_description').on('input', onPersonaDescriptionInput);
1121 $('#persona_description_position').on('input', onPersonaDescriptionPositionInput);1908 $('#persona_description_position').on('input', onPersonaDescriptionPositionInput);
@@ -1151,55 +1938,41 @@ export function initPersonas() {
1151 $('#avatar_upload_file').on('change', changeUserAvatar);1938 $('#avatar_upload_file').on('change', changeUserAvatar);
11521939
1153 $(document).on('click', '#user_avatar_block .avatar-container', function () {1940 $(document).on('click', '#user_avatar_block .avatar-container', function () {
1154 const imgfile = $(this).attr('imgfile');1941 const imgfile = $(this).attr('data-avatar-id');
1155 setUserAvatar(imgfile);1942 setUserAvatar(imgfile);
11561943
1157 // force firstMes {{user}} update on persona switch1944 // force firstMes {{user}} update on persona switch
1158 retriggerFirstMessageOnEmptyChat();1945 retriggerFirstMessageOnEmptyChat();
1159 });1946 });
11601947
1161 $('#your_name_button').click(async function () {1948 $('#persona_rename_button').on('click', () => renamePersona(user_avatar));
1162 const userName = String($('#your_name').val()).trim();
1163 setUserName(userName);
1164 await updatePersonaNameIfExists(user_avatar, userName);
1165 retriggerFirstMessageOnEmptyChat();
1166 });
11671949
1168 $(document).on('click', '#user_avatar_block .avatar_upload', function () {1950 $(document).on('click', '#user_avatar_block .avatar_upload', function () {
1169 $('#avatar_upload_overwrite').val('');1951 $('#avatar_upload_overwrite').val('');
1170 $('#avatar_upload_file').trigger('click');1952 $('#avatar_upload_file').trigger('click');
1171 });1953 });
11721954
1173 $(document).on('click', '#user_avatar_block .duplicate_persona', function (e) {1955 $('#persona_duplicate_button').on('click', () => duplicatePersona(user_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) {
1190 console.log('no imgfile');1959 console.log('no imgfile');
1191 return;1960 return;
1192 }1961 }
11931962
1194 $('#avatar_upload_overwrite').val(avatarId);1963 $('#avatar_upload_overwrite').val(user_avatar);
1195 $('#avatar_upload_file').trigger('click');1964 $('#avatar_upload_file').trigger('click');
1196 });1965 });
11971966
1967 $('#char_connections_button').on('click', showCharConnections);
1968
1198 eventSource.on('charManagementDropdown', (target) => {1969 eventSource.on('charManagementDropdown', (target) => {
1199 if (target === 'convert_to_persona') {1970 if (target === 'convert_to_persona') {
1200 convertCharacterToPersona();1971 convertCharacterToPersona();
1201 }1972 }
1202 });1973 });
1203 eventSource.on(event_types.CHAT_CHANGED, setChatLockedPersona);1974 eventSource.on(event_types.CHAT_CHANGED, updatePersonaUIStates);
1975 eventSource.on(event_types.CHAT_CHANGED, loadPersonaForCurrentChat);
1204 switchPersonaGridView();1976 switchPersonaGridView();
1205}1977}
1978
public/scripts/power-user.js+12 -0
@@ -1489,6 +1489,8 @@ async function loadPowerUserSettings(settings, data) {
1489 $('#custom_stopping_strings_macro').prop('checked', power_user.custom_stopping_strings_macro);1489 $('#custom_stopping_strings_macro').prop('checked', power_user.custom_stopping_strings_macro);
1490 $('#fuzzy_search_checkbox').prop('checked', power_user.fuzzy_search);1490 $('#fuzzy_search_checkbox').prop('checked', power_user.fuzzy_search);
1491 $('#persona_show_notifications').prop('checked', power_user.persona_show_notifications);1491 $('#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);
1492 $('#encode_tags').prop('checked', power_user.encode_tags);1494 $('#encode_tags').prop('checked', power_user.encode_tags);
1493 $('#example_messages_behavior').val(getExampleMessagesBehavior());1495 $('#example_messages_behavior').val(getExampleMessagesBehavior());
1494 $(`#example_messages_behavior option[value="${getExampleMessagesBehavior()}"]`).prop('selected', true);1496 $(`#example_messages_behavior option[value="${getExampleMessagesBehavior()}"]`).prop('selected', true);
@@ -3705,6 +3707,16 @@ $(document).ready(() => {
3705 saveSettingsDebounced();3707 saveSettingsDebounced();
3706 });3708 });
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
3708 $('#encode_tags').on('input', async function () {3720 $('#encode_tags').on('input', async function () {
3709 power_user.encode_tags = !!$(this).prop('checked');3721 power_user.encode_tags = !!$(this).prop('checked');
3710 await reloadCurrentChat();3722 await reloadCurrentChat();
public/scripts/slash-commands.js+1 -109
@@ -38,7 +38,6 @@ import {
38 setCharacterId,38 setCharacterId,
39 setCharacterName,39 setCharacterName,
40 setExtensionPrompt,40 setExtensionPrompt,
41 setUserName,
42 showMoreMessages,41 showMoreMessages,
43 stopGeneration,42 stopGeneration,
44 substituteParams,43 substituteParams,
@@ -55,7 +54,7 @@ import { getContext, saveMetadataDebounced } from './extensions.js';
55import { getRegexedString, regex_placement } from './extensions/regex/engine.js';54import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
56import { findGroupMemberId, groups, is_group_generating, openGroupById, resetSelectedGroup, saveGroupChat, selected_group } from './group-chats.js';55import { findGroupMemberId, groups, is_group_generating, openGroupById, resetSelectedGroup, saveGroupChat, selected_group } from './group-chats.js';
57import { chat_completion_sources, oai_settings, promptManager } from './openai.js';56import { chat_completion_sources, oai_settings, promptManager } from './openai.js';
58import { autoSelectPersona, retriggerFirstMessageOnEmptyChat, setPersonaLockState, togglePersonaLock, user_avatar } from './personas.js';57import { user_avatar } from './personas.js';
59import { addEphemeralStoppingString, chat_styles, flushEphemeralStoppingStrings, power_user } from './power-user.js';58import { addEphemeralStoppingString, chat_styles, flushEphemeralStoppingStrings, power_user } from './power-user.js';
60import { SERVER_INPUTS, textgen_types, textgenerationwebui_settings } from './textgen-settings.js';59import { SERVER_INPUTS, textgen_types, textgenerationwebui_settings } from './textgen-settings.js';
61import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js';60import { decodeTextTokens, getAvailableTokenizers, getFriendlyTokenizerName, getTextTokens, getTokenCountAsync, selectTokenizer } from './tokenizers.js';
@@ -124,46 +123,6 @@ export function initDefaultSlashCommands() {
124 helpString: 'Get help on macros, chat formatting and commands.',123 helpString: 'Get help on macros, chat formatting and commands.',
125 }));124 }));
126 SlashCommandParser.addCommandObject(SlashCommand.fromProps({125 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({
167 name: 'bg',126 name: 'bg',
168 callback: setBackgroundCallback,127 callback: setBackgroundCallback,
169 aliases: ['background'],128 aliases: ['background'],
@@ -3439,31 +3398,6 @@ export async function generateSystemMessage(_, prompt) {
3439 return '';3398 return '';
3440}3399}
34413400
3442function syncCallback() {
3443 $('#sync_name_button').trigger('click');
3444 return '';
3445}
3446
3447async 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
3467function setStoryModeCallback() {3401function setStoryModeCallback() {
3468 $('#chat_display').val(chat_styles.DOCUMENT).trigger('change');3402 $('#chat_display').val(chat_styles.DOCUMENT).trigger('change');
3469 return '';3403 return '';
@@ -3479,48 +3413,6 @@ function setFlatModeCallback() {
3479 return '';3413 return '';
3480}3414}
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 */
3488function 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
3524async function setNarratorName(_, text) {3416async function setNarratorName(_, text) {
3525 const name = text || NARRATOR_NAME_DEFAULT;3417 const name = text || NARRATOR_NAME_DEFAULT;
3526 chat_metadata[NARRATOR_NAME_KEY] = name;3418 chat_metadata[NARRATOR_NAME_KEY] = name;
public/scripts/tags.js+2 -2
@@ -485,8 +485,8 @@ export function getTagKeyForEntityElement(element) {
485 }485 }
486 // Start with the given element and traverse up the DOM tree486 // Start with the given element and traverse up the DOM tree
487 while (element.length && element.parent().length) {487 while (element.length && element.parent().length) {
488 const grid = element.attr('grid');488 const grid = element.attr('data-grid');
489 const chid = element.attr('chid');489 const chid = element.attr('data-chid');
490 if (grid || chid) {490 if (grid || chid) {
491 const id = grid || chid;491 const id = grid || chid;
492 return getTagKeyForEntity(id);492 return getTagKeyForEntity(id);
public/scripts/utils.js+36 -0
@@ -2256,3 +2256,39 @@ export function arraysEqual(a, b) {
2256 }2256 }
2257 return true;2257 return true;
2258}2258}
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 */
2266export 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 */
2288export 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) {
4749 }4749 }
47504750
4751 if (characters[chid]?.data?.character_book) {4751 if (characters[chid]?.data?.character_book) {
4752 $('#import_character_info').data('chid', chid).show();4752 $('#import_character_info').data('data-chid', chid).show();
47534753
4754 // Only show the alert once per character4754 // Only show the alert once per character
4755 const checkKey = `AlertWI_${characters[chid].avatar}`;4755 const checkKey = `AlertWI_${characters[chid].avatar}`;
@@ -4783,7 +4783,7 @@ export function checkEmbeddedWorld(chid) {
4783}4783}
47844784
4785export async function importEmbeddedWorldInfo(skipPopup = false) {4785export async function importEmbeddedWorldInfo(skipPopup = false) {
4786 const chid = $('#import_character_info').data('chid');4786 const chid = $('#import_character_info').data('data-chid');
47874787
4788 if (chid === undefined) {4788 if (chid === undefined) {
4789 return;4789 return;
public/style.css+137 -45
@@ -228,6 +228,37 @@ table.responsiveTable {
228 animation-name: flash;228 animation-name: flash;
229}229}
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
231/* Keyboard/focus navigation styling */262/* Keyboard/focus navigation styling */
232/* Mimic the outline of keyboard navigation for most most focusable controls */263/* Mimic the outline of keyboard navigation for most most focusable controls */
233.interactable {264.interactable {
@@ -1339,6 +1370,8 @@ body.swipeAllMessages .mes:not(.last_mes) .swipes-counter {
1339}1370}
13401371
1341.avatars_inline {1372.avatars_inline {
1373 display: flex;
1374 gap: 5px;
1342 flex-wrap: wrap;1375 flex-wrap: wrap;
1343 overflow: hidden;1376 overflow: hidden;
1344 max-height: calc(var(--avatar-base-height) + 2 * var(--avatar-base-border-radius));1377 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 {
1375 margin-right: calc(var(--avatar-base-border-radius));1408 margin-right: calc(var(--avatar-base-border-radius));
1376}1409}
13771410
1411.avatars_multiline {
1412 max-height: fit-content;
1413 height: inherit;
1414}
1415
1378.mes_block {1416.mes_block {
1379 padding-top: 0;1417 padding-top: 0;
1380 padding-left: 10px;1418 padding-left: 10px;
@@ -2869,6 +2907,7 @@ select option:not(:checked) {
2869.menu_button.disabled {2907.menu_button.disabled {
2870 filter: brightness(75%) grayscale(1);2908 filter: brightness(75%) grayscale(1);
2871 opacity: 0.5;2909 opacity: 0.5;
2910 cursor: inherit;
2872 pointer-events: none;2911 pointer-events: none;
2873}2912}
28742913
@@ -2943,8 +2982,7 @@ select option:not(:checked) {
2943}2982}
29442983
2945#form_character_search_form .menu_button,2984#form_character_search_form .menu_button,
2946#GroupFavDelOkBack .menu_button,2985#GroupFavDelOkBack .menu_button {
2947.avatar-container .menu_button {
2948 margin: 0;2986 margin: 0;
2949 height: fit-content;2987 height: fit-content;
2950 padding: 5px;2988 padding: 5px;
@@ -2978,8 +3016,22 @@ select option:not(:checked) {
2978 width: max-content;3016 width: max-content;
2979}3017}
29803018
2981#persona-management-block .menu_button {3019#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;
2983}3035}
29843036
2985input[type=search]::-webkit-search-cancel-button {3037input[type=search]::-webkit-search-cancel-button {
@@ -3304,6 +3356,10 @@ input[type=search]:focus::-webkit-search-cancel-button {
3304 color: var(--golden);3356 color: var(--golden);
3305}3357}
33063358
3359.avatar.is_active {
3360 outline: 2px solid var(--active);
3361}
3362
3307#fav_chara_wrap {3363#fav_chara_wrap {
3308 display: flex;3364 display: flex;
3309 margin: 5px 0px;3365 margin: 5px 0px;
@@ -3371,28 +3427,6 @@ grammarly-extension {
3371 z-index: 35;3427 z-index: 35;
3372}3428}
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
3396.avatar_div .avatar {3430.avatar_div .avatar {
3397 /* margin-left: 4px;3431 /* margin-left: 4px;
3398 margin-right: 10px;3432 margin-right: 10px;
@@ -3455,12 +3489,7 @@ grammarly-extension {
3455 cursor: pointer;3489 cursor: pointer;
3456}3490}
34573491
3458#rm_characters_block .form_create_bottom_buttons_block {3492.buttons_block {
3459 justify-content: space-evenly !important;
3460 flex-grow: 0;
3461}
3462
3463.form_create_bottom_buttons_block {
3464 display: flex;3493 display: flex;
3465 flex: 1;3494 flex: 1;
3466 gap: 5px;3495 gap: 5px;
@@ -3469,18 +3498,21 @@ grammarly-extension {
3469 flex-wrap: wrap;3498 flex-wrap: wrap;
3470}3499}
34713500
3472.form_create_bottom_buttons_block .menu_button {3501.buttons_block .menu_button {
3473 display: flex;3502 display: flex;
3474 justify-content: center;3503 justify-content: center;
3475 align-items: center;3504 align-items: center;
3505 padding: 5px;
3506 margin: 0;
3507 filter: grayscale(0.5);
3476}3508}
34773509
3478#delete_button,3510.red_button,
3479.redWarningBG {3511.redWarningBG {
3480 background-color: var(--crimson70a) !important;3512 background-color: var(--crimson70a) !important;
3481}3513}
34823514
3483#delete_button:hover,3515.red_button:hover,
3484.redWarningBG:hover {3516.redWarningBG:hover {
3485 background-color: var(--fullred) !important;3517 background-color: var(--fullred) !important;
3486}3518}
@@ -3655,6 +3687,7 @@ grammarly-extension {
36553687
3656.menu_button {3688.menu_button {
3657 color: var(--SmartThemeBodyColor);3689 color: var(--SmartThemeBodyColor);
3690 filter: grayscale(0.5);
3658 background-color: var(--SmartThemeBlurTintColor);3691 background-color: var(--SmartThemeBlurTintColor);
3659 border: 1px solid var(--SmartThemeBorderColor);3692 border: 1px solid var(--SmartThemeBorderColor);
3660 border-radius: 5px;3693 border-radius: 5px;
@@ -3674,15 +3707,8 @@ grammarly-extension {
3674 min-width: calc(1.25em + 12px);3707 min-width: calc(1.25em + 12px);
3675}3708}
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 {
3686 background-color: var(--white30a);3712 background-color: var(--white30a);
3687}3713}
36883714
@@ -3931,10 +3957,39 @@ input[type='checkbox'].del_checkbox {
3931 outline: var(--avatar-base-border-radius) solid var(--golden);3957 outline: var(--avatar-base-border-radius) solid var(--golden);
3932}3958}
39333959
3934.avatar-container.default_persona .set_default_persona {3960.avatar-container .avatar_state .fa-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 {
3935 color: var(--golden);3972 color: var(--golden);
3936}3973}
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
3938#user_avatar_block .avatar_upload {3993#user_avatar_block .avatar_upload {
3939 cursor: pointer;3994 cursor: pointer;
3940 width: 60px;3995 width: 60px;
@@ -5958,3 +6013,40 @@ body:not(.movingUI) .drawer-content.maximized {
5958 min-width: fit-content;6013 min-width: fit-content;
5959 width: 40%;6014 width: 40%;
5960}6015}
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}