Blame Raw
Cohee · 51ad27fb · · 3006 lines (118.9 KB)
2 contributors
1import {
2 buildAvatarList,
3 characterToEntity,
4 characters,
5 chat,
6 chat_metadata,
7 createOrEditCharacter,
8 default_user_avatar,
9 eventSource,
10 event_types,
11 getCurrentChatId,
12 getRequestHeaders,
13 getThumbnailUrl,
14 groupToEntity,
15 menu_type,
16 name1,
17 name2,
18 reloadCurrentChat,
19 saveChatConditional,
20 saveMetadata,
21 saveSettingsDebounced,
22 setUserName,
23 this_chid,
24} from '../script.js';
25import { power_user } from './power-user.js';
26import { getTokenCountAsync } from './tokenizers.js';
27import {
28 PAGINATION_TEMPLATE,
29 clearInfoBlock,
30 debounce,
31 delay,
32 download,
33 ensureImageFormatSupported,
34 flashHighlight,
35 getBase64Async,
36 getCharIndex,
37 isFalseBoolean,
38 isTrueBoolean,
39 onlyUnique,
40 parseJsonFile,
41 setInfoBlock,
42 localizePagination,
43 renderPaginationDropdown,
44 paginationDropdownChangeHandler,
45 addLongPressEvent,
46 stringToRange,
47 sortIgnoreCaseAndAccents,
48 equalsIgnoreCaseAndAccents,
49 uuidv4,
50 resolveAvatarData,
51 findPersona,
52 escapeHtml,
53} from './utils.js';
54import { debounce_timeout } from './constants.js';
55import { FILTER_TYPES, FilterHelper } from './filters.js';
56import { groups, selected_group } from './group-chats.js';
57import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
58import { t } from './i18n.js';
59import { openWorldInfoEditor, world_names } from './world-info.js';
60import { renderTemplateAsync } from './templates.js';
61import { saveMetadataDebounced } from './extensions.js';
62import { accountStorage } from './util/AccountStorage.js';
63import { SlashCommand } from './slash-commands/SlashCommand.js';
64import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
65import { commonEnumMatchProviders, commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
66import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
67import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
68import { isFirefox } from './browser-fixes.js';
69import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
70
71/**
72 * @typedef {object} PersonaConnection A connection between a character and a character or group entity
73 * @property {'character' | 'group'} type - Type of connection
74 * @property {string} id - ID of the connection (character key (avatar url), group id)
75 */
76
77/** @typedef {'chat' | 'character' | 'default'} PersonaLockType Type of the persona lock */
78
79/**
80 * @typedef {object} PersonaState
81 * @property {string} avatarId - The avatar id of the persona
82 * @property {boolean} default - Whether this persona is the default one for all new chats
83 * @property {object} locked - An object containing the lock states
84 * @property {boolean} locked.chat - Whether the persona is locked to the currently open chat
85 * @property {boolean} locked.character - Whether the persona is locked to the currently open character or group
86 */
87
88export const persona_description_positions = {
89 IN_PROMPT: 0,
90 /**
91 * @deprecated Use persona_description_positions.IN_PROMPT instead.
92 */
93 AFTER_CHAR: 1,
94 TOP_AN: 2,
95 BOTTOM_AN: 3,
96 AT_DEPTH: 4,
97 NONE: 9,
98};
99
100const USER_AVATAR_PATH = 'User Avatars/';
101
102let savePersonasPage = 0;
103const GRID_STORAGE_KEY = 'Personas_GridView';
104const DEFAULT_DEPTH = 2;
105const DEFAULT_ROLE = 0;
106
107/** @type {string} The currently selected persona (identified by its avatar) */
108export let user_avatar = '';
109
110/** @type {FilterHelper} Filter helper for the persona list */
111export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
112
113/** @type {string} The last loaded chat id to remember for persona loading */
114let personaLastLoadedChatId = null;
115
116/** @type {function(string): void} */
117let navigateToAvatar = () => { };
118
119/**
120 * Checks if the Persona Management panel is currently open
121 * @returns {boolean}
122 */
123export function isPersonaPanelOpen() {
124 return document.querySelector('#persona-management-button .drawer-content')?.classList.contains('openDrawer') ?? false;
125}
126
127function switchPersonaGridView() {
128 const state = accountStorage.getItem(GRID_STORAGE_KEY) === 'true';
129 $('#user_avatar_block').toggleClass('gridView', state);
130}
131
132/**
133 * Returns the URL of the avatar for the given user avatar Id.
134 * @param {string} avatarImg User avatar Id
135 * @returns {string} User avatar URL
136 */
137export function getUserAvatar(avatarImg) {
138 return `${USER_AVATAR_PATH}${avatarImg}`;
139}
140
141export function initUserAvatar(avatar) {
142 user_avatar = avatar;
143 reloadUserAvatar();
144 updatePersonaUIStates();
145}
146
147/**
148 * Sets a user avatar file
149 * @param {string} imgfile Link to an image file
150 * @param {object} [options] Optional settings
151 * @param {boolean} [options.toastPersonaNameChange=true] Whether to show a toast when the persona name is changed
152 * @param {boolean} [options.navigateToCurrent=false] Whether to navigate to the current persona after setting the avatar
153 */
154export async function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigateToCurrent = false } = {}) {
155 const currentUserAvatar = user_avatar;
156 user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('data-avatar-id');
157 if (currentUserAvatar === user_avatar) {
158 return;
159 }
160 reloadUserAvatar();
161 updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });
162 selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });
163 await retriggerFirstMessageOnEmptyChat();
164 saveSettingsDebounced();
165 $('.zoomed_avatar[forchar]').remove();
166 await eventSource.emit(event_types.PERSONA_CHANGED, user_avatar);
167}
168
169function reloadUserAvatar(force = false) {
170 $('.mes').each(function () {
171 const avatarImg = $(this).find('.avatar img');
172 if (force) {
173 avatarImg.attr('src', avatarImg.attr('src'));
174 }
175
176 if ($(this).attr('is_user') == 'true' && $(this).attr('force_avatar') == 'false') {
177 avatarImg.attr('src', getThumbnailUrl('persona', user_avatar));
178 }
179 });
180}
181
182/**
183 * Sort the given personas
184 * @param {string[]} personas - The persona names to sort
185 * @returns {string[]} The sorted persona names array, same reference as passed in
186 */
187function sortPersonas(personas) {
188 const option = $('#persona_sort_order').find(':selected');
189 if (option.attr('value') === 'search') {
190 personas.sort((a, b) => {
191 const aScore = personasFilter.getScore(FILTER_TYPES.PERSONA_SEARCH, a);
192 const bScore = personasFilter.getScore(FILTER_TYPES.PERSONA_SEARCH, b);
193 return (aScore - bScore);
194 });
195 } else {
196 personas.sort((a, b) => {
197 const aName = String(power_user.personas[a] || a);
198 const bName = String(power_user.personas[b] || b);
199 return power_user.persona_sort_order === 'asc' ? aName.localeCompare(bName) : bName.localeCompare(aName);
200 });
201 }
202
203 return personas;
204}
205
206/** Checks the state of the current search, and adds/removes the search sorting option accordingly */
207function verifyPersonaSearchSortRule() {
208 const searchTerm = personasFilter.getFilterData(FILTER_TYPES.PERSONA_SEARCH);
209 const searchOption = $('#persona_sort_order option[value="search"]');
210 const selector = $('#persona_sort_order');
211 const isHidden = searchOption.attr('hidden') !== undefined;
212
213 // If we have a search term, we are displaying the sorting option for it
214 if (searchTerm && isHidden) {
215 searchOption.removeAttr('hidden');
216 selector.val(searchOption.attr('value'));
217 flashHighlight(selector);
218 }
219 // If search got cleared, we make sure to hide the option and go back to the one before
220 if (!searchTerm) {
221 searchOption.attr('hidden', '');
222 selector.val(power_user.persona_sort_order);
223 }
224}
225
226/**
227 * Gets a rendered avatar block.
228 * @param {string} avatarId Avatar file name
229 * @returns {JQuery<HTMLElement>} Avatar block
230 */
231function getUserAvatarBlock(avatarId) {
232 const template = $('#user_avatar_template .avatar-container').clone();
233 const personaName = power_user.personas[avatarId];
234 const personaDescription = power_user.persona_descriptions[avatarId]?.description;
235 const personaTitle = power_user.persona_descriptions[avatarId]?.title;
236
237 template.find('.ch_name').text(personaName || '[Unnamed Persona]');
238 template.find('.ch_description').text(personaDescription || $('#user_avatar_block').attr('no_desc_text')).toggleClass('text_muted', !personaDescription);
239 template.find('.ch_additional_info').text(personaTitle || '');
240 template.attr('data-avatar-id', avatarId);
241 template.find('.avatar').attr('data-avatar-id', avatarId).attr('title', avatarId);
242 template.toggleClass('default_persona', avatarId === power_user.default_persona);
243 const avatarUrl = getThumbnailUrl('persona', avatarId, isFirefox());
244 template.find('img').attr('src', avatarUrl);
245
246 // Make sure description block has at least three rows. Otherwise height looks inconsistent. I don't have a better idea for this.
247 const currentText = template.find('.ch_description').text();
248 if (currentText.split('\n').length < 3) {
249 template.find('.ch_description').text(currentText + '\n\xa0\n\xa0');
250 }
251
252 $('#user_avatar_block').append(template);
253 return template;
254}
255
256/**
257 * Initialize missing personas in the power user settings.
258 * @param {string[]} avatarsList List of avatar file names
259 * @returns {Promise<void>}
260 */
261async function addMissingPersonas(avatarsList) {
262 for (const persona of avatarsList) {
263 if (!power_user.personas[persona]) {
264 await initPersona(persona, '[Unnamed Persona]', '', '', { silent: true });
265 }
266 }
267}
268
269/**
270 * Gets a list of user avatars.
271 * @param {boolean} doRender Whether to render the list
272 * @param {string} openPageAt Item to be opened at
273 * @returns {Promise<string[]>} List of avatar file names
274 */
275export async function getUserAvatars(doRender = true, openPageAt = '') {
276 const response = await fetch('/api/avatars/get', {
277 method: 'POST',
278 headers: getRequestHeaders({ omitContentType: true }),
279 });
280 if (response.ok) {
281 const allEntities = await response.json();
282
283 if (!Array.isArray(allEntities)) {
284 return [];
285 }
286
287 if (!doRender) {
288 return allEntities;
289 }
290
291 // If any persona is missing from the power user settings, we add it
292 await addMissingPersonas(allEntities);
293 // Before printing the personas, we check if we should enable/disable search sorting
294 verifyPersonaSearchSortRule();
295
296 let entities = personasFilter.applyFilters(allEntities);
297 entities = sortPersonas(entities);
298
299 const storageKey = 'Personas_PerPage';
300 const listId = '#user_avatar_block';
301 const perPage = Number(accountStorage.getItem(storageKey)) || 5;
302 const sizeChangerOptions = [5, 10, 25, 50, 100, 250, 500, 1000];
303
304 $('#persona_pagination_container').pagination({
305 dataSource: entities,
306 pageSize: perPage,
307 sizeChangerOptions,
308 pageRange: 1,
309 pageNumber: savePersonasPage || 1,
310 position: 'top',
311 showPageNumbers: false,
312 showSizeChanger: true,
313 formatSizeChanger: renderPaginationDropdown(perPage, sizeChangerOptions),
314 prevText: '<',
315 nextText: '>',
316 formatNavigator: PAGINATION_TEMPLATE,
317 showNavigator: true,
318 callback: function (data) {
319 $(listId).empty();
320 for (const item of data) {
321 $(listId).append(getUserAvatarBlock(item));
322 }
323 updatePersonaUIStates();
324 localizePagination($('#persona_pagination_container'));
325 },
326 afterSizeSelectorChange: function (e, size) {
327 accountStorage.setItem(storageKey, e.target.value);
328 paginationDropdownChangeHandler(e, size);
329 },
330 afterPaging: function (e) {
331 savePersonasPage = e;
332 },
333 afterRender: function () {
334 $(listId).scrollTop(0);
335 },
336 });
337
338 navigateToAvatar = (avatarId) => {
339 const avatarIndex = entities.indexOf(avatarId);
340 const page = Math.floor(avatarIndex / perPage) + 1;
341
342 if (avatarIndex !== -1) {
343 $('#persona_pagination_container').pagination('go', page);
344 }
345 };
346
347 openPageAt && navigateToAvatar(openPageAt);
348
349 return allEntities;
350 }
351}
352
353/**
354 * Uploads an avatar file to the server
355 * @param {string} url URL for the avatar file
356 * @param {string} [name] Optional name for the avatar file
357 * @returns {Promise} Promise that resolves when the avatar is uploaded
358 */
359async function uploadUserAvatar(url, name) {
360 const fetchResult = await fetch(url);
361 const blob = await fetchResult.blob();
362 const file = new File([blob], 'avatar.png', { type: 'image/png' });
363 const formData = new FormData();
364 formData.append('avatar', file);
365
366 if (name) {
367 formData.append('overwrite_name', name);
368 }
369
370 const response = await fetch('/api/avatars/upload', {
371 method: 'POST',
372 headers: getRequestHeaders({ omitContentType: true }),
373 cache: 'no-cache',
374 body: formData,
375 });
376
377 if (!response.ok) {
378 throw new Error(`Failed to upload avatar: ${response.statusText}`);
379 }
380
381 // Get the actual path from the response
382 const data = await response.json();
383 await getUserAvatars(true, data?.path || name);
384}
385
386async function changeUserAvatar(e) {
387 const form = document.getElementById('form_upload_avatar');
388
389 if (!(form instanceof HTMLFormElement)) {
390 console.error('Form not found');
391 return;
392 }
393
394 const file = e.target.files[0];
395
396 if (!file) {
397 form.reset();
398 return;
399 }
400
401 const formData = new FormData(form);
402 const dataUrl = await getBase64Async(file);
403 let url = '/api/avatars/upload';
404
405 if (!power_user.never_resize_avatars) {
406 const dlg = new Popup(t`Set the crop position of the avatar image`, POPUP_TYPE.CROP, '', { cropImage: dataUrl });
407 const result = await dlg.show();
408
409 if (!result) {
410 return;
411 }
412
413 if (dlg.cropData !== undefined) {
414 url += `?crop=${encodeURIComponent(JSON.stringify(dlg.cropData))}`;
415 }
416 }
417
418 const rawFile = formData.get('avatar');
419 if (rawFile instanceof File) {
420 const convertedFile = await ensureImageFormatSupported(rawFile);
421 formData.set('avatar', convertedFile);
422 }
423
424 const response = await fetch(url, {
425 method: 'POST',
426 headers: getRequestHeaders({ omitContentType: true }),
427 cache: 'no-cache',
428 body: formData,
429 });
430
431 if (response.ok) {
432 const data = await response.json();
433
434 const overwriteName = formData.get('overwrite_name');
435 const dataPath = data?.path;
436
437 // If the user uploaded a new avatar, we want to make sure it's not cached
438 if (overwriteName && dataPath) {
439 await fetch(getUserAvatar(String(dataPath)), { cache: 'reload' });
440 await fetch(getThumbnailUrl('persona', String(dataPath)), { cache: 'reload' });
441 reloadUserAvatar(true);
442 }
443
444 if (!overwriteName && dataPath) {
445 await getUserAvatars();
446 await delay(1);
447 await createPersona(dataPath);
448 }
449
450 await getUserAvatars(true, dataPath || overwriteName);
451 }
452
453 // Will allow to select the same file twice in a row
454 form.reset();
455}
456
457/**
458 * Prompts the user to create a persona for the uploaded avatar.
459 * @param {string} avatarId User avatar id
460 * @returns {Promise} Promise that resolves when the persona is set
461 */
462export async function createPersona(avatarId) {
463 const personaName = await Popup.show.input(t`Enter a name for this persona:`, t`Cancel if you're just uploading an avatar.`, '');
464
465 if (!personaName) {
466 console.debug('User cancelled creating a persona');
467 return;
468 }
469
470 const personaDescription = await Popup.show.input(t`Enter a description for this persona:`, t`You can always add or change it later.`, '', { rows: 4 });
471
472 await initPersona(avatarId, personaName, personaDescription, '');
473 if (power_user.persona_show_notifications) {
474 toastr.success(t`You can now pick ${personaName} as a persona in the Persona Management menu.`, t`Persona Created`);
475 }
476}
477
478async function createDummyPersona() {
479 const popup = new Popup(t`Enter a name for this persona:`, POPUP_TYPE.INPUT, '', {
480 customInputs: [{
481 id: 'persona_title',
482 type: 'text',
483 label: t`Persona Title (optional, display only)`,
484 }],
485 });
486
487 const personaName = await popup.show();
488 const personaTitle = String(popup.inputResults.get('persona_title') || '').trim();
489
490 if (!personaName || typeof personaName !== 'string') {
491 console.debug('User cancelled creating dummy persona');
492 return;
493 }
494
495 // Date + name (only ASCII) to make it unique
496 const avatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
497 await initPersona(avatarId, personaName, '', personaTitle);
498 await uploadUserAvatar(default_user_avatar, avatarId);
499}
500
501/**
502 * Initializes a persona for the given avatar id.
503 * @param {string} avatarId User avatar id
504 * @param {string} personaName Name for the persona
505 * @param {string} personaDescription Optional description for the persona
506 * @param {string} personaTitle Optional title for the persona
507 * @param {object} [options={}] Optional settings
508 * @param {boolean} [options.silent=false] If true, no PERSONA_CREATED event is emitted (used for background migrations)
509 * @param {number} [options.position=persona_description_positions.IN_PROMPT] Description position (defaults to IN_PROMPT)
510 * @param {number} [options.depth=DEFAULT_DEPTH] Description depth (defaults to DEFAULT_DEPTH)
511 * @param {number} [options.role=DEFAULT_ROLE] Description role (defaults to DEFAULT_ROLE)
512 * @param {string} [options.lorebook=''] Attached lorebook name
513 * @returns {Promise<void>}
514 */
515export async function initPersona(avatarId, personaName, personaDescription, personaTitle, {
516 silent = false,
517 position = persona_description_positions.IN_PROMPT,
518 depth = DEFAULT_DEPTH,
519 role = DEFAULT_ROLE,
520 lorebook = '',
521} = {}) {
522 power_user.personas[avatarId] = personaName;
523 power_user.persona_descriptions[avatarId] = {
524 description: personaDescription || '',
525 position: position,
526 depth: depth,
527 role: role,
528 lorebook: lorebook,
529 title: personaTitle || '',
530 };
531
532 saveSettingsDebounced();
533
534 if (!silent) {
535 await eventSource.emit(event_types.PERSONA_CREATED, { avatarId, name: personaName, description: personaDescription || '', title: personaTitle || '' });
536 }
537}
538
539/**
540 * Converts a character given character (either by character id or the current character) to a persona.
541 *
542 * If a persona with the same name already exists, the user is prompted to confirm whether or not to overwrite it.
543 * If the character description contains {{char}} or {{user}} macros, the user is prompted to confirm whether or not to swap them for persona macros.
544 *
545 * 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.
546 * The function also saves the settings and refreshes the persona selector.
547 *
548 * @param {number} [characterId] - The ID of the character to convert to a persona. Defaults to the current character ID.
549 * @returns {Promise<boolean>} A promise that resolves to true if the character was converted, false otherwise.
550 */
551export async function convertCharacterToPersona(characterId = null) {
552 if (null === characterId) characterId = Number(this_chid);
553
554 const avatarUrl = characters[characterId]?.avatar;
555 if (!avatarUrl) {
556 console.log('No avatar found for this character');
557 return false;
558 }
559
560 const name = characters[characterId]?.name;
561 let description = characters[characterId]?.description;
562 const overwriteName = `${name} (Persona).png`;
563
564 if (overwriteName in power_user.personas) {
565 const confirm = await Popup.show.confirm(t`Overwrite Existing Persona`, t`This character exists as a persona already. Do you want to overwrite it?`);
566 if (!confirm) {
567 console.log('User cancelled the overwrite of the persona');
568 return false;
569 }
570 }
571
572 if (description.includes('{{char}}') || description.includes('{{user}}')) {
573 const confirm = await Popup.show.confirm(t`Persona Description Macros`, t`This character has a description that uses <code>{{char}}</code> or <code>{{user}}</code> macros. Do you want to swap them in the persona description?`);
574 if (confirm) {
575 description = description.replace(/{{char}}/gi, '{{personaChar}}').replace(/{{user}}/gi, '{{personaUser}}');
576 description = description.replace(/{{personaUser}}/gi, '{{char}}').replace(/{{personaChar}}/gi, '{{user}}');
577 }
578 }
579
580 const thumbnailAvatar = getThumbnailUrl('avatar', avatarUrl);
581 await uploadUserAvatar(thumbnailAvatar, overwriteName);
582
583 power_user.personas[overwriteName] = name;
584 power_user.persona_descriptions[overwriteName] = {
585 description: description,
586 position: persona_description_positions.IN_PROMPT,
587 depth: DEFAULT_DEPTH,
588 role: DEFAULT_ROLE,
589 lorebook: '',
590 title: '',
591 };
592
593 // If the user is currently using this persona, update the description
594 if (user_avatar === overwriteName) {
595 power_user.persona_description = description;
596 }
597
598 saveSettingsDebounced();
599 await eventSource.emit(event_types.PERSONA_CREATED, { avatarId: overwriteName, name, description, title: '' });
600
601 console.log('Persona for character created');
602 toastr.success(t`You can now pick ${name} as a persona in the Persona Management menu.`, t`Persona Created`);
603
604 // Refresh the persona selector
605 await getUserAvatars(true, overwriteName);
606 // Reload the persona description
607 setPersonaDescription();
608 return true;
609}
610
611/**
612 * Counts the number of tokens in a persona description.
613 */
614const countPersonaDescriptionTokens = debounce(async () => {
615 const description = String($('#persona_description').val());
616 const count = await getTokenCountAsync(description);
617 $('#persona_description_token_count').text(String(count));
618}, debounce_timeout.relaxed);
619
620/**
621 * Updates the UI for the Persona Management page with the current persona values
622 */
623export function setPersonaDescription() {
624 $('#your_name').text(name1);
625
626 if (power_user.persona_description_position === persona_description_positions.AFTER_CHAR) {
627 power_user.persona_description_position = persona_description_positions.IN_PROMPT;
628 }
629
630 $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
631 $('#persona_description').val(power_user.persona_description);
632 $('#persona_depth_value').val(power_user.persona_description_depth ?? DEFAULT_DEPTH);
633 $('#persona_description_position')
634 .val(power_user.persona_description_position)
635 .find(`option[value="${power_user.persona_description_position}"]`)
636 .attr('selected', String(true));
637 $('#persona_depth_role')
638 .val(power_user.persona_description_role)
639 .find(`option[value="${power_user.persona_description_role}"]`)
640 .prop('selected', String(true));
641 $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);
642 countPersonaDescriptionTokens();
643
644 updatePersonaUIStates();
645 updatePersonaConnectionsAvatarList();
646}
647
648/**
649 * Gets a list of all personas in the current chat.
650 *
651 * @returns {string[]} An array of persona identifiers
652 */
653function getPersonasOfCurrentChat() {
654 const personas = chat.filter(message => String(message.force_avatar).startsWith(USER_AVATAR_PATH))
655 .map(message => message.force_avatar.replace(USER_AVATAR_PATH, ''))
656 .filter(onlyUnique);
657 return personas;
658}
659
660/**
661 * Builds a list of persona avatars and populates the given block element with them.
662 *
663 * @param {HTMLElement} block - The HTML element where the avatar list will be rendered
664 * @param {string[]} personas - An array of persona identifiers
665 * @param {Object} [options] - Optional settings for building the avatar list
666 * @param {boolean} [options.empty=true] - Whether to clear the block element before adding avatars
667 * @param {boolean} [options.interactable=false] - Whether the avatars should be interactable
668 * @param {boolean} [options.highlightFavs=true] - Whether to highlight favorite avatars
669 */
670export function buildPersonaAvatarList(block, personas, { empty = true, interactable = false, highlightFavs = true } = {}) {
671 const personaEntities = personas.map(avatar => ({
672 type: 'persona',
673 id: avatar,
674 item: {
675 name: power_user.personas[avatar],
676 description: power_user.persona_descriptions[avatar]?.description || '',
677 avatar: avatar,
678 fav: power_user.default_persona === avatar,
679 },
680 }));
681
682 buildAvatarList($(block), personaEntities, { empty: empty, interactable: interactable, highlightFavs: highlightFavs });
683}
684
685/**
686 * Displays avatar connections for the current persona.
687 * Converts connections to entities and populates the avatar list. Shows a message if no connections are found.
688 */
689export function updatePersonaConnectionsAvatarList() {
690 /** @type {PersonaConnection[]} */
691 const connections = power_user.persona_descriptions[user_avatar]?.connections ?? [];
692 const entities = connections.map(connection => {
693 if (connection.type === 'character') {
694 const character = characters.find(c => c.avatar === connection.id);
695 if (character) return characterToEntity(character, getCharIndex(character));
696 }
697 if (connection.type === 'group') {
698 const group = groups.find(g => g.id === connection.id);
699 if (group) return groupToEntity(group);
700 }
701 return undefined;
702 }).filter(entity => entity?.item !== undefined);
703
704 if (entities.length)
705 buildAvatarList($('#persona_connections_list'), entities, { interactable: true });
706 else
707 $('#persona_connections_list').text(t`[No character connections. Click one of the buttons above to connect this persona.]`);
708}
709
710
711/**
712 * Displays a popup for persona selection and returns the selected persona.
713 *
714 * @param {string} title - The title to display in the popup
715 * @param {string} text - The text to display in the popup
716 * @param {string[]} personas - An array of persona ids to display for selection
717 * @param {Object} [options] - Optional settings for the popup
718 * @param {string} [options.okButton='None'] - The label for the OK button
719 * @param {(element: HTMLElement, ev: MouseEvent) => any} [options.shiftClickHandler] - A function to handle shift-click
720 * @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
721 * @param {PersonaConnection} [options.targetedChar] - The targeted character or gorup for this persona selection
722 * @returns {Promise<string?>} - A promise that resolves to the selected persona id or null if no selection was made
723 */
724export async function askForPersonaSelection(title, text, personas, { okButton = 'None', shiftClickHandler = undefined, highlightPersonas = false, targetedChar = undefined } = {}) {
725 const content = document.createElement('div');
726 const titleElement = document.createElement('h3');
727 titleElement.textContent = title;
728 content.appendChild(titleElement);
729
730 const textElement = document.createElement('div');
731 textElement.classList.add('multiline', 'm-b-1');
732 textElement.textContent = text;
733 content.appendChild(textElement);
734
735 const personaListBlock = document.createElement('div');
736 personaListBlock.classList.add('persona-list', 'avatars_inline', 'avatars_multiline', 'text_muted');
737 content.appendChild(personaListBlock);
738
739 if (personas.length > 0)
740 buildPersonaAvatarList(personaListBlock, personas, { interactable: true });
741 else
742 personaListBlock.textContent = t`[Currently no personas connected]`;
743
744 const personasToHighlight = highlightPersonas instanceof Array ? highlightPersonas : (highlightPersonas ? getPersonasOfCurrentChat() : []);
745
746 // Make the persona blocks clickable and close the popup
747 personaListBlock.querySelectorAll('.avatar[data-type="persona"]').forEach(block => {
748 if (!(block instanceof HTMLElement)) return;
749 block.dataset.result = String(100 + personas.indexOf(block.dataset.pid));
750
751 if (shiftClickHandler) {
752 block.addEventListener('click', function (ev) {
753 if (ev.shiftKey) {
754 shiftClickHandler(this, ev);
755 }
756 });
757 }
758
759 if (personasToHighlight && personasToHighlight.includes(block.dataset.pid)) {
760 block.classList.add('is_active');
761 block.title = block.title + '\n\n' + t`Was used in current chat.`;
762 if (block.classList.contains('is_fav')) block.title = block.title + '\n' + t`Is your default persona.`;
763 }
764 });
765
766 /** @type {import('./popup.js').CustomPopupButton[]} */
767 const customButtons = [];
768 if (targetedChar) {
769 customButtons.push({
770 text: t`Remove All Connections`,
771 result: 2,
772 action: () => {
773 for (const [personaId, description] of Object.entries(power_user.persona_descriptions)) {
774 /** @type {PersonaConnection[]} */
775 const connections = description.connections;
776 if (connections) {
777 power_user.persona_descriptions[personaId].connections = connections.filter(c => {
778 if (targetedChar.type == c.type && targetedChar.id == c.id) return false;
779 return true;
780 });
781 }
782 }
783
784 saveSettingsDebounced();
785 updatePersonaConnectionsAvatarList();
786 if (power_user.persona_show_notifications) {
787 const name = targetedChar.type == 'character' ? characters[targetedChar.id]?.name : groups[targetedChar.id]?.name;
788 toastr.info(t`All connections to ${name} have been removed.`, t`Personas Unlocked`);
789 }
790 },
791 });
792 }
793
794 const popup = new Popup(content, POPUP_TYPE.TEXT, '', { okButton: okButton, customButtons: customButtons });
795 const result = await popup.show();
796 return Number(result) >= 100 ? personas[Number(result) - 100] : null;
797}
798
799/**
800 * Automatically selects a persona based on the given name if a matching persona exists.
801 * @param {string} name - The name to search for
802 * @param {Object} [options={}]
803 * @param {string} [options.personaKey=null] - Optionally a persona avatar key to target (if multiple persona have the same name); must match the name
804 * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise
805 */
806export async function autoSelectPersona(name, { personaKey = null } = {}) {
807 const persona = findPersona({ name: personaKey ?? name, allowAvatar: !!personaKey });
808 if (persona) {
809 console.log(`Auto-selecting persona ${persona.avatar} for name ${name}`);
810 await setUserAvatar(persona.avatar);
811 return true;
812 }
813 return false;
814}
815
816/**
817 * Edits the title of a persona based on the input from a popup.
818 * @param {Popup} popup Popup instance
819 * @param {string} avatarId Avatar ID of the persona to edit
820 * @param {string} currentTitle Current title of the persona
821 */
822async function editPersonaTitle(popup, avatarId, currentTitle) {
823 if (popup.result !== POPUP_RESULT.AFFIRMATIVE) {
824 return;
825 }
826
827 if (!power_user.persona_descriptions[avatarId]) {
828 console.warn('Uninitialized persona descriptor for avatar:', avatarId);
829 return;
830 }
831
832 const newTitle = String(popup.inputResults.get('persona_title') || '').trim();
833
834 if (!newTitle && currentTitle) {
835 console.log(`Removed persona title for ${avatarId}`);
836 delete power_user.persona_descriptions[avatarId].title;
837 await getUserAvatars(true, avatarId);
838 saveSettingsDebounced();
839 await eventSource.emit(event_types.PERSONA_UPDATED, avatarId);
840 return;
841 }
842
843 if (newTitle !== currentTitle) {
844 power_user.persona_descriptions[avatarId].title = newTitle;
845 console.log(`Updated persona title for ${avatarId} to ${newTitle}`);
846 await getUserAvatars(true, avatarId);
847 saveSettingsDebounced();
848 await eventSource.emit(event_types.PERSONA_UPDATED, avatarId);
849 return;
850 }
851}
852
853/**
854 * Renames the persona with the given avatar ID by showing a popup to enter a new name.
855 * @param {string} avatarId - ID of the avatar to rename
856 * @returns {Promise<boolean>} A promise that resolves to true if the persona was renamed, false otherwise
857 */
858async function renamePersona(avatarId) {
859 const currentName = power_user.personas[avatarId];
860 const currentTitle = power_user.persona_descriptions[avatarId]?.title || '';
861 const newName = await Popup.show.input(t`Rename Persona`, t`Enter a new name for this persona:`, currentName, {
862 customInputs: [{
863 id: 'persona_title',
864 type: 'text',
865 label: t`Persona Title (optional, display only)`,
866 defaultState: currentTitle,
867 }],
868 onClose: (popup) => editPersonaTitle(popup, avatarId, currentTitle),
869 });
870
871 if (!newName || newName === currentName) {
872 console.debug('User cancelled renaming persona or name is unchanged');
873 return false;
874 }
875
876 power_user.personas[avatarId] = newName;
877 console.log(`Renamed persona ${avatarId} to ${newName}`);
878
879 if (avatarId === user_avatar) {
880 setUserName(newName);
881 }
882
883 saveSettingsDebounced();
884 await eventSource.emit(event_types.PERSONA_RENAMED, { avatarId, oldName: currentName, newName });
885 await getUserAvatars(true, avatarId);
886 updatePersonaUIStates();
887 setPersonaDescription();
888 return true;
889}
890
891/**
892 * 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.
893 * @param {object} [options={}] - Optional settings
894 * @param {boolean} [options.toastPersonaNameChange=true] - Whether to show a toast when the persona name is changed
895 * @returns {Promise<void>}
896 */
897async function selectCurrentPersona({ toastPersonaNameChange = true } = {}) {
898 const personaName = power_user.personas[user_avatar];
899 if (personaName) {
900 const shouldAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata.persona;
901
902 if (personaName !== name1) {
903 console.log(`Auto-updating user name to ${personaName}`);
904 setUserName(personaName, { toastPersonaNameChange: !shouldAutoLock && toastPersonaNameChange });
905 }
906
907 const descriptor = power_user.persona_descriptions[user_avatar];
908
909 if (descriptor) {
910 power_user.persona_description = descriptor.description ?? '';
911 power_user.persona_description_position = descriptor.position ?? persona_description_positions.IN_PROMPT;
912 power_user.persona_description_depth = descriptor.depth ?? DEFAULT_DEPTH;
913 power_user.persona_description_role = descriptor.role ?? DEFAULT_ROLE;
914 power_user.persona_description_lorebook = descriptor.lorebook ?? '';
915 } else {
916 power_user.persona_description = '';
917 power_user.persona_description_position = persona_description_positions.IN_PROMPT;
918 power_user.persona_description_depth = DEFAULT_DEPTH;
919 power_user.persona_description_role = DEFAULT_ROLE;
920 power_user.persona_description_lorebook = '';
921 power_user.persona_descriptions[user_avatar] = {
922 description: '',
923 position: persona_description_positions.IN_PROMPT,
924 depth: DEFAULT_DEPTH,
925 role: DEFAULT_ROLE,
926 lorebook: '',
927 connections: [],
928 title: '',
929 };
930 }
931
932 setPersonaDescription();
933
934 // Update the locked persona if setting is enabled
935 if (shouldAutoLock) {
936 chat_metadata.persona = user_avatar;
937 console.log(`Auto locked persona to ${user_avatar}`);
938 if (toastPersonaNameChange && power_user.persona_show_notifications) {
939 toastr.success(t`Persona ${personaName} selected and auto-locked to current chat`, t`Persona Selected`);
940 }
941 saveMetadataDebounced();
942 updatePersonaUIStates();
943 }
944
945 // As the last step, inform user if the persona is only temporarily chosen
946 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
947 const temporary = getPersonaTemporaryLockInfo();
948 if (temporary.isTemporary) {
949 toastr.info(t`This persona is only temporarily chosen. Click for more info.`, t`Temporary Persona`, {
950 preventDuplicates: true,
951 onclick: () => {
952 toastr.info(escapeHtml(temporary.info).replaceAll('\n', '<br />'), t`Temporary Persona`, { escapeHtml: false });
953 },
954 });
955 }
956 }
957 }
958}
959
960/**
961 * Checks if a connection is locked for the current character or group edit menu
962 * @param {PersonaConnection} connection - Connection to check
963 * @returns {boolean} Whether the connection is locked
964 */
965export function isPersonaConnectionLocked(connection) {
966 return (!selected_group && connection.type === 'character' && connection.id === characters[this_chid]?.avatar)
967 || (selected_group && connection.type === 'group' && connection.id === selected_group);
968}
969
970/**
971 * Checks if the persona is locked
972 * @param {PersonaLockType} type - Lock type
973 * @returns {boolean} Whether the persona is locked
974 */
975export function isPersonaLocked(type = 'chat') {
976 switch (type) {
977 case 'default':
978 return power_user.default_persona === user_avatar;
979 case 'chat':
980 return chat_metadata.persona == user_avatar;
981 case 'character': {
982 return !!power_user.persona_descriptions[user_avatar]?.connections?.some(isPersonaConnectionLocked);
983 }
984 default: throw new Error(`Unknown persona lock type: ${type}`);
985 }
986}
987
988/**
989 * Locks or unlocks the persona
990 * @param {boolean} state Desired lock state
991 * @param {PersonaLockType} type - Lock type
992 * @returns {Promise<void>}
993 */
994export async function setPersonaLockState(state, type = 'chat') {
995 return state ? await lockPersona(type) : await unlockPersona(type);
996}
997
998/**
999 * Toggle the persona lock state
1000 * @param {PersonaLockType} type - Lock type
1001 * @returns {Promise<boolean>} - Whether the persona was locked
1002 */
1003export async function togglePersonaLock(type = 'chat') {
1004 if (isPersonaLocked(type)) {
1005 await unlockPersona(type);
1006 return false;
1007 } else {
1008 await lockPersona(type);
1009 return true;
1010 }
1011}
1012
1013/**
1014 * Unlock the persona
1015 * @param {PersonaLockType} type - Lock type
1016 * @returns {Promise<void>}
1017 */
1018async function unlockPersona(type = 'chat') {
1019 switch (type) {
1020 case 'default': {
1021 // TODO: Make this toggle-able
1022 await toggleDefaultPersona(user_avatar, { quiet: true });
1023 break;
1024 }
1025 case 'chat': {
1026 if (chat_metadata.persona) {
1027 console.log(`Unlocking persona ${user_avatar} from this chat`);
1028 delete chat_metadata.persona;
1029 await saveMetadata();
1030 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
1031 toastr.info(t`Persona ${name1} is now unlocked from this chat.`, t`Persona Unlocked`);
1032 }
1033 }
1034 break;
1035 }
1036 case 'character': {
1037 /** @type {PersonaConnection[]} */
1038 const connections = power_user.persona_descriptions[user_avatar]?.connections;
1039 if (connections) {
1040 console.log(`Unlocking persona ${user_avatar} from this character ${name2}`);
1041 power_user.persona_descriptions[user_avatar].connections = connections.filter(c => !isPersonaConnectionLocked(c));
1042 saveSettingsDebounced();
1043 updatePersonaConnectionsAvatarList();
1044 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
1045 toastr.info(t`Persona ${name1} is now unlocked from character ${name2}.`, t`Persona Unlocked`);
1046 }
1047 }
1048 break;
1049 }
1050 default:
1051 throw new Error(`Unknown persona lock type: ${type}`);
1052 }
1053
1054 updatePersonaUIStates();
1055}
1056
1057/**
1058 * Lock the persona
1059 * @param {PersonaLockType} type - Lock type
1060 */
1061async function lockPersona(type = 'chat') {
1062 // First make sure that user_avatar is actually a persona
1063 if (!(user_avatar in power_user.personas)) {
1064 console.log(`Creating a new persona ${user_avatar}`);
1065 if (power_user.persona_show_notifications) {
1066 toastr.info(t`Creating a new persona for currently selected user name and avatar...`, t`Persona Not Found`);
1067 }
1068 power_user.personas[user_avatar] = name1;
1069 power_user.persona_descriptions[user_avatar] = {
1070 description: '',
1071 position: persona_description_positions.IN_PROMPT,
1072 depth: DEFAULT_DEPTH,
1073 role: DEFAULT_ROLE,
1074 lorebook: '',
1075 connections: [],
1076 title: '',
1077 };
1078 await eventSource.emit(event_types.PERSONA_CREATED, { avatarId: user_avatar, name: name1, description: '', title: '' });
1079 }
1080
1081 switch (type) {
1082 case 'default': {
1083 await toggleDefaultPersona(user_avatar, { quiet: true });
1084 break;
1085 }
1086 case 'chat': {
1087 console.log(`Locking persona ${user_avatar} to this chat`);
1088 chat_metadata.persona = user_avatar;
1089 saveMetadataDebounced();
1090 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
1091 toastr.success(t`User persona ${name1} is locked to ${name2} in this chat`, t`Persona Locked`);
1092 }
1093 break;
1094 }
1095 case 'character': {
1096 const newConnection = getCurrentConnectionObj();
1097 /** @type {PersonaConnection[]} */
1098 const connections = power_user.persona_descriptions[user_avatar].connections?.filter(c => !isPersonaConnectionLocked(c)) ?? [];
1099 if (newConnection && newConnection.id) {
1100 console.log(`Locking persona ${user_avatar} to this character ${name2}`);
1101 power_user.persona_descriptions[user_avatar].connections = [...connections, newConnection];
1102
1103 const unlinkedCharacters = [];
1104 if (!power_user.persona_allow_multi_connections) {
1105 for (const [avatarId, description] of Object.entries(power_user.persona_descriptions)) {
1106 if (avatarId === user_avatar) continue;
1107
1108 const filteredConnections = description.connections?.filter(c => !(c.type === newConnection.type && c.id === newConnection.id)) ?? [];
1109 if (filteredConnections.length !== description.connections?.length) {
1110 description.connections = filteredConnections;
1111 unlinkedCharacters.push(power_user.personas[avatarId]);
1112 }
1113 }
1114 }
1115
1116 saveSettingsDebounced();
1117 updatePersonaConnectionsAvatarList();
1118 if (power_user.persona_show_notifications) {
1119 let additional = '';
1120 if (unlinkedCharacters.length)
1121 additional += `<br /><br />${t`Unlinked existing persona${unlinkedCharacters.length > 1 ? 's' : ''}: ${unlinkedCharacters.map(escapeHtml).join(', ')}`}`;
1122 if (additional || !isPersonaPanelOpen()) {
1123 toastr.success(t`User persona ${escapeHtml(name1)} is locked to character ${escapeHtml(name2)}${additional}`, t`Persona Locked`, { escapeHtml: false });
1124 }
1125 }
1126 }
1127 break;
1128 }
1129 default:
1130 throw new Error(`Unknown persona lock type: ${type}`);
1131 }
1132
1133 updatePersonaUIStates();
1134}
1135
1136
1137/**
1138 * Click handler for the delete persona button. Delegates to deletePersona with the current user avatar.
1139 */
1140async function deleteUserAvatar() {
1141 await deletePersona(user_avatar);
1142}
1143
1144/**
1145 * Deletes a persona by avatar id.
1146 * @param {string} avatarId The persona's avatar id to delete
1147 * @param {object} [options] Options
1148 * @param {boolean} [options.silent=false] If true, skips the confirmation popup and suppresses toast notifications
1149 * @returns {Promise<boolean>} True if the persona was deleted
1150 */
1151async function deletePersona(avatarId, { silent = false } = {}) {
1152 if (!avatarId) {
1153 console.warn('No avatar id found');
1154 return false;
1155 }
1156
1157 const name = power_user.personas[avatarId] || '';
1158
1159 if (!silent) {
1160 const confirm = await Popup.show.confirm(
1161 t`Delete Persona` + `: ${name}`,
1162 t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`);
1163
1164 if (!confirm) {
1165 console.debug('User cancelled deleting avatar');
1166 return false;
1167 }
1168 }
1169
1170 const request = await fetch('/api/avatars/delete', {
1171 method: 'POST',
1172 headers: getRequestHeaders(),
1173 body: JSON.stringify({
1174 'avatar': avatarId,
1175 }),
1176 });
1177
1178 if (request.ok) {
1179 console.log(`Deleted avatar ${avatarId}`);
1180 delete power_user.personas[avatarId];
1181 delete power_user.persona_descriptions[avatarId];
1182
1183 if (avatarId === power_user.default_persona) {
1184 if (!silent) toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default Persona Deleted`);
1185 power_user.default_persona = null;
1186 }
1187
1188 if (avatarId === chat_metadata.persona) {
1189 if (!silent) toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona Deleted`);
1190 delete chat_metadata.persona;
1191 await saveMetadata();
1192 }
1193
1194 saveSettingsDebounced();
1195 await eventSource.emit(event_types.PERSONA_DELETED, { avatarId, name });
1196
1197 // Use the existing mechanism to re-render the persona list and choose the next persona here
1198 personaLastLoadedChatId = uuidv4(); // Force reload by making a dummy chat id
1199 await loadPersonaForCurrentChat({ doRender: true });
1200 return true;
1201 }
1202
1203 return false;
1204}
1205
1206async function onPersonaDescriptionInput() {
1207 power_user.persona_description = String($('#persona_description').val());
1208 countPersonaDescriptionTokens();
1209
1210 if (power_user.personas[user_avatar]) {
1211 let object = power_user.persona_descriptions[user_avatar];
1212
1213 if (!object) {
1214 object = {
1215 description: power_user.persona_description,
1216 position: Number($('#persona_description_position').find(':selected').val()),
1217 depth: Number($('#persona_depth_value').val()),
1218 role: Number($('#persona_depth_role').find(':selected').val()),
1219 lorebook: '',
1220 title: '',
1221 };
1222 power_user.persona_descriptions[user_avatar] = object;
1223 }
1224
1225 object.description = power_user.persona_description;
1226 }
1227
1228 $(`.avatar-container[data-avatar-id="${user_avatar}"] .ch_description`)
1229 .text(power_user.persona_description || $('#user_avatar_block').attr('no_desc_text'))
1230 .toggleClass('text_muted', !power_user.persona_description);
1231 saveSettingsDebounced();
1232
1233 if (power_user.personas[user_avatar]) {
1234 await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
1235 }
1236}
1237
1238async function onPersonaDescriptionDepthValueInput() {
1239 power_user.persona_description_depth = Number($('#persona_depth_value').val());
1240
1241 if (power_user.personas[user_avatar]) {
1242 const object = getOrCreatePersonaDescriptor();
1243 object.depth = power_user.persona_description_depth;
1244 saveSettingsDebounced();
1245 await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
1246 return;
1247 }
1248
1249 saveSettingsDebounced();
1250}
1251
1252async function onPersonaDescriptionDepthRoleInput() {
1253 power_user.persona_description_role = Number($('#persona_depth_role').find(':selected').val());
1254
1255 if (power_user.personas[user_avatar]) {
1256 const object = getOrCreatePersonaDescriptor();
1257 object.role = power_user.persona_description_role;
1258 saveSettingsDebounced();
1259 await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
1260 return;
1261 }
1262
1263 saveSettingsDebounced();
1264}
1265
1266/**
1267 * Opens a popup to set the lorebook for the current persona.
1268 * @param {Pick<JQuery.ClickEvent, 'shiftKey' | 'altKey'>} event Click event
1269 */
1270async function onPersonaLoreButtonClick({ shiftKey, altKey }) {
1271 const personaName = power_user.personas[user_avatar];
1272 const selectedLorebook = power_user.persona_description_lorebook;
1273
1274 if (!personaName) {
1275 toastr.warning(t`You must bind a name to this persona before you can set a lorebook.`, t`Persona Name Not Set`);
1276 return;
1277 }
1278
1279 if (selectedLorebook && !shiftKey && !altKey) {
1280 openWorldInfoEditor(selectedLorebook);
1281 return;
1282 }
1283
1284 const template = $(await renderTemplateAsync('personaLorebook'));
1285
1286 const worldSelect = template.find('select');
1287 template.find('.persona_name').text(personaName);
1288
1289 for (const worldName of world_names) {
1290 const option = document.createElement('option');
1291 option.value = worldName;
1292 option.innerText = worldName;
1293 option.selected = selectedLorebook === worldName;
1294 worldSelect.append(option);
1295 }
1296
1297 worldSelect.on('change', async function () {
1298 power_user.persona_description_lorebook = String($(this).val());
1299
1300 if (power_user.personas[user_avatar]) {
1301 const object = getOrCreatePersonaDescriptor();
1302 object.lorebook = power_user.persona_description_lorebook;
1303 }
1304
1305 $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);
1306 saveSettingsDebounced();
1307
1308 if (power_user.personas[user_avatar]) {
1309 await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
1310 }
1311 });
1312
1313 await callGenericPopup(template, POPUP_TYPE.TEXT);
1314}
1315
1316async function onPersonaDescriptionPositionInput() {
1317 power_user.persona_description_position = Number(
1318 $('#persona_description_position').find(':selected').val(),
1319 );
1320
1321 if (power_user.personas[user_avatar]) {
1322 const object = getOrCreatePersonaDescriptor();
1323 object.position = power_user.persona_description_position;
1324 saveSettingsDebounced();
1325 await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
1326 $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
1327 return;
1328 }
1329
1330 saveSettingsDebounced();
1331 $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
1332}
1333
1334export function getOrCreatePersonaDescriptor() {
1335 let object = power_user.persona_descriptions[user_avatar];
1336
1337 if (!object) {
1338 object = {
1339 description: power_user.persona_description,
1340 position: power_user.persona_description_position,
1341 depth: power_user.persona_description_depth,
1342 role: power_user.persona_description_role,
1343 lorebook: power_user.persona_description_lorebook,
1344 connections: [],
1345 title: '',
1346 };
1347 power_user.persona_descriptions[user_avatar] = object;
1348 }
1349 return object;
1350}
1351
1352/**
1353 * Sets a persona as the default one to be used for all new chats and unlocked existing chats
1354 * @param {string} avatarId The avatar id of the persona to set as the default
1355 * @param {object} [options] Optional arguments
1356 * @param {boolean} [options.quiet=false] If true, no confirmation popups will be shown
1357 * @returns {Promise<void>}
1358 */
1359async function toggleDefaultPersona(avatarId, { quiet = false } = {}) {
1360 if (!avatarId) {
1361 console.warn('No avatar id found');
1362 return;
1363 }
1364
1365 const currentDefault = power_user.default_persona;
1366
1367 if (power_user.personas[avatarId] === undefined) {
1368 console.warn(`No persona name found for avatar ${avatarId}`);
1369 toastr.warning(t`You must bind a name to this persona before you can set it as the default.`, t`Persona Name Not Set`);
1370 return;
1371 }
1372
1373
1374 if (avatarId === currentDefault) {
1375 if (!quiet) {
1376 const confirm = await Popup.show.confirm(t`Are you sure you want to remove the default persona?`, power_user.personas[avatarId]);
1377 if (!confirm) {
1378 console.debug('User cancelled removing default persona');
1379 return;
1380 }
1381 }
1382
1383 console.log(`Removing default persona ${avatarId}`);
1384 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
1385 toastr.info(t`This persona will no longer be used by default when you open a new chat.`, t`Default Persona Removed`);
1386 }
1387 delete power_user.default_persona;
1388 } else {
1389 if (!quiet) {
1390 const confirm = await Popup.show.confirm(t`Set Default Persona`,
1391 t`Are you sure you want to set \"${power_user.personas[avatarId]}\" as the default persona?`
1392 + '<br /><br />'
1393 + t`This name and avatar will be used for all new chats, as well as existing chats where the user persona is not locked.`);
1394 if (!confirm) {
1395 console.debug('User cancelled setting default persona');
1396 return;
1397 }
1398 }
1399
1400 power_user.default_persona = avatarId;
1401 if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
1402 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`);
1403 }
1404 }
1405
1406 saveSettingsDebounced();
1407 await getUserAvatars(true, avatarId);
1408 updatePersonaUIStates();
1409}
1410
1411/**
1412 * Returns an object with 3 properties that describe the state of the given persona
1413 *
1414 * - default: Whether this persona is the default one for all new chats
1415 * - locked: An object containing the lock states
1416 * - chat: Whether the persona is locked to the currently open chat
1417 * - character: Whether the persona is locked to the currently open character or group
1418 * @param {string} avatarId - The avatar id of the persona to get the state for
1419 * @returns {PersonaState} An object describing the state of the given persona
1420 */
1421function getPersonaStates(avatarId) {
1422 const isDefaultPersona = power_user.default_persona === avatarId;
1423 const hasChatLock = chat_metadata.persona == avatarId;
1424
1425 /** @type {PersonaConnection[]} */
1426 const connections = power_user.persona_descriptions[avatarId]?.connections;
1427 const hasCharLock = !!connections?.some(c =>
1428 (!selected_group && c.type === 'character' && c.id === characters[Number(this_chid)]?.avatar)
1429 || (selected_group && c.type === 'group' && c.id === selected_group));
1430
1431 return {
1432 avatarId: avatarId,
1433 default: isDefaultPersona,
1434 locked: {
1435 chat: hasChatLock,
1436 character: hasCharLock,
1437 },
1438 };
1439}
1440
1441/**
1442 * Updates the UI to reflect the current states of all personas and the selected user's persona.
1443 * This includes updating class states on avatar containers to indicate default status, chat lock,
1444 * and character lock, as well as updating icons and labels in the persona management panel to reflect
1445 * the current state of the user's persona.
1446 * Additionally, it manages the display of temporary persona lock information.
1447 * @param {Object} [options={}] - Optional settings
1448 * @param {boolean} [options.navigateToCurrent=false] - Whether to navigate to the current persona in the persona list
1449 */
1450
1451function updatePersonaUIStates({ navigateToCurrent = false } = {}) {
1452 if (navigateToCurrent) {
1453 navigateToAvatar(user_avatar);
1454 }
1455
1456 // Update the persona list
1457 $('#user_avatar_block .avatar-container').each(function () {
1458 const avatarId = $(this).attr('data-avatar-id');
1459 const states = getPersonaStates(avatarId);
1460 $(this).toggleClass('default_persona', states.default);
1461 $(this).toggleClass('locked_to_chat', states.locked.chat);
1462 $(this).toggleClass('locked_to_character', states.locked.character);
1463 $(this).toggleClass('selected', avatarId === user_avatar);
1464 });
1465
1466 // Buttons for the persona panel on the right
1467 const personaStates = getPersonaStates(user_avatar);
1468
1469 $('#lock_persona_default').toggleClass('locked', personaStates.default);
1470
1471 $('#lock_user_name').toggleClass('locked', personaStates.locked.chat);
1472 $('#lock_user_name i.icon').toggleClass('fa-lock', personaStates.locked.chat);
1473 $('#lock_user_name i.icon').toggleClass('fa-unlock', !personaStates.locked.chat);
1474
1475 $('#lock_persona_to_char').toggleClass('locked', personaStates.locked.character);
1476 $('#lock_persona_to_char i.icon').toggleClass('fa-lock', personaStates.locked.character);
1477 $('#lock_persona_to_char i.icon').toggleClass('fa-unlock', !personaStates.locked.character);
1478
1479 // Persona panel info block
1480 const { isTemporary, info } = getPersonaTemporaryLockInfo();
1481 if (isTemporary) {
1482 const messageContainer = document.createElement('div');
1483 const messageSpan = document.createElement('span');
1484 messageSpan.textContent = t`Temporary persona in use.`;
1485 messageContainer.appendChild(messageSpan);
1486 messageContainer.classList.add('flex-container', 'alignItemsBaseline');
1487
1488 const infoIcon = document.createElement('i');
1489 infoIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
1490 infoIcon.title = info;
1491 messageContainer.appendChild(infoIcon);
1492
1493 // Set the info block content
1494 setInfoBlock('#persona_connections_info_block', messageContainer, 'hint');
1495 } else {
1496 // Clear the info block if no condition applies
1497 clearInfoBlock('#persona_connections_info_block');
1498 }
1499}
1500
1501/**
1502 * @typedef {Object} PersonaLockInfo
1503 * @property {boolean} isTemporary - Whether the selected persona is temporary based on current locks.
1504 * @property {boolean} hasDifferentChatLock - True if the chat persona is set and differs from the user avatar.
1505 * @property {boolean} hasDifferentDefaultLock - True if the default persona is set and differs from the user avatar.
1506 * @property {string} info - Detailed information about the current, chat, and default personas.
1507 */
1508
1509/**
1510 * Computes temporary lock information for the current persona.
1511 *
1512 * This function checks whether the currently selected persona is temporary by comparing
1513 * the chat persona and the default persona to the user avatar. If either is different,
1514 * the currently selected persona is considered temporary and a detailed message is generated.
1515 *
1516 * @returns {PersonaLockInfo} An object containing flags and a message describing the persona lock status.
1517 */
1518function getPersonaTemporaryLockInfo() {
1519 const hasDifferentChatLock = !!chat_metadata.persona && chat_metadata.persona !== user_avatar;
1520 const hasDifferentDefaultLock = power_user.default_persona && power_user.default_persona !== user_avatar;
1521 const isTemporary = hasDifferentChatLock || (!chat_metadata.persona && hasDifferentDefaultLock);
1522 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.`
1523 + '\n\n'
1524 + t`Current Persona: ${power_user.personas[user_avatar]}`
1525 + (hasDifferentChatLock ? '\n' + t`Chat persona: ${power_user.personas[chat_metadata.persona]}` : '')
1526 + (hasDifferentDefaultLock ? '\n' + t`Default persona: ${power_user.personas[power_user.default_persona]}` : '') : '';
1527
1528 return {
1529 isTemporary: isTemporary,
1530 hasDifferentChatLock: hasDifferentChatLock,
1531 hasDifferentDefaultLock: hasDifferentDefaultLock,
1532 info: info,
1533 };
1534}
1535
1536/**
1537 * Loads the appropriate persona for the current chat session based on locks (chat lock, char lock, default persona)
1538 *
1539 * @param {Object} [options={}] - Optional arguments
1540 * @param {boolean} [options.doRender=false] - Whether to render the persona immediately
1541 * @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether a persona was selected
1542 */
1543async function loadPersonaForCurrentChat({ doRender = false } = {}) {
1544 const currentChatId = getCurrentChatId();
1545 if (currentChatId === personaLastLoadedChatId) return;
1546 personaLastLoadedChatId = currentChatId;
1547
1548 // Cache persona list to check if they exist
1549 const userAvatars = await getUserAvatars(doRender);
1550
1551 // Check if the user avatar is set and exists in the list of user avatars
1552 if (userAvatars.length && !userAvatars.includes(user_avatar)) {
1553 console.log(`User avatar ${user_avatar} not found in user avatars list, pick the first available one`);
1554 await setUserAvatar(userAvatars[0], { toastPersonaNameChange: false, navigateToCurrent: true });
1555 }
1556
1557 // Define a persona for this chat
1558 let chatPersona = '';
1559
1560 /** @type {'chat' | 'character' | 'default' | null} */
1561 let connectType = null;
1562
1563 // If persona is locked in chat metadata, select it
1564 if (chat_metadata.persona) {
1565 console.log(`Using locked persona ${chat_metadata.persona}`);
1566 chatPersona = chat_metadata.persona;
1567
1568 // Verify it exists
1569 if (!userAvatars.includes(chatPersona)) {
1570 console.warn('Chat-locked persona avatar not found, unlocking persona');
1571 delete chat_metadata.persona;
1572 saveSettingsDebounced();
1573 chatPersona = '';
1574 }
1575 if (chatPersona) connectType = 'chat';
1576 }
1577
1578 // If the persona panel is open when the chat changes, this is likely because a character was selected from that panel.
1579 // In that case, we are not automatically switching persona - but need to make changes if there is any chat-bound connection
1580 /*
1581 if (isPersonaPanelOpen()) {
1582 if (chatPersona) {
1583 // If the chat-bound persona is the currently selected one, we can simply exit out
1584 if (chatPersona === user_avatar) {
1585 return false;
1586 }
1587 // Otherwise ask if we want to switch
1588 const autoLock = power_user.persona_auto_lock;
1589 const result = await Popup.show.confirm(t`Switch Persona?`,
1590 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?`,
1591 { okButton: autoLock ? t`Keep and Lock` : t`Keep`, cancelButton: t`Switch` });
1592 if (result === POPUP_RESULT.AFFIRMATIVE) {
1593 if (autoLock) {
1594 lockPersona('chat');
1595 }
1596 return false;
1597 }
1598 } else {
1599 // If we don't have a chat-bound persona, we simply return and keep the current one we have
1600 return false;
1601 }
1602 }
1603 */
1604
1605 // Check if we have any persona connected to the current character
1606 if (!chatPersona) {
1607 const connectedPersonas = getConnectedPersonas();
1608
1609 if (connectedPersonas.length > 0) {
1610 if (connectedPersonas.length === 1) {
1611 chatPersona = connectedPersonas[0];
1612 } else if (!power_user.persona_allow_multi_connections) {
1613 console.warn('More than one persona is connected to this character.Using the first available persona for this chat.');
1614 chatPersona = connectedPersonas[0];
1615 } else {
1616 chatPersona = await askForPersonaSelection(t`Select Persona`,
1617 t`Multiple personas are connected to this character.\nSelect a persona to use for this chat.`,
1618 connectedPersonas, { highlightPersonas: true, targetedChar: getCurrentConnectionObj() });
1619 }
1620 }
1621
1622 if (chatPersona) connectType = 'character';
1623 }
1624
1625 // Last check if default persona is set, select it
1626 if (!chatPersona && power_user.default_persona) {
1627 console.log(`Using default persona ${power_user.default_persona}`);
1628 chatPersona = power_user.default_persona;
1629
1630 if (chatPersona) connectType = 'default';
1631 }
1632
1633 // Whatever way we selected a persona, if it doesn't exist, unlock this chat
1634 if (chat_metadata.persona && !userAvatars.includes(chat_metadata.persona)) {
1635 console.warn('Persona avatar not found, unlocking persona');
1636 delete chat_metadata.persona;
1637 }
1638
1639 // Default persona missing
1640 if (power_user.default_persona && !userAvatars.includes(power_user.default_persona)) {
1641 console.warn('Default persona avatar not found, clearing default persona');
1642 power_user.default_persona = null;
1643 saveSettingsDebounced();
1644 }
1645
1646 // Persona avatar found, select it
1647 if (chatPersona && user_avatar !== chatPersona) {
1648 const willAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata.persona;
1649 await setUserAvatar(chatPersona, { toastPersonaNameChange: false, navigateToCurrent: true });
1650
1651 if (power_user.persona_show_notifications) {
1652 let message = t`Auto-selected persona based on ${connectType} connection.<br />Your messages will now be sent as ${power_user.personas[chatPersona]}.`;
1653 if (willAutoLock) {
1654 message += '<br /><br />' + t`Auto-locked this persona to current chat.`;
1655 }
1656 toastr.success(message, t`Persona Auto Selected`, { escapeHtml: false });
1657 }
1658 } else if (chatPersona && power_user.persona_auto_lock && !chat_metadata.persona) {
1659 // Even if it's the same persona, we still might need to auto-lock to chat if that's enabled
1660 await lockPersona('chat');
1661 }
1662
1663 updatePersonaUIStates();
1664
1665 return !!chatPersona;
1666}
1667
1668/**
1669 * Returns an array of persona keys that are connected to the given character key.
1670 * If the character key is not provided, it defaults to the currently selected group or character.
1671 * @param {string} [characterKey] - The character key to query
1672 * @returns {string[]} - An array of persona keys that are connected to the given character key
1673 */
1674export function getConnectedPersonas(characterKey = undefined) {
1675 characterKey ??= selected_group || characters[Number(this_chid)]?.avatar;
1676 const connectedPersonas = Object.entries(power_user.persona_descriptions)
1677 .filter(([_, { connections }]) => connections?.some(conn => conn.id === characterKey))
1678 .map(([key, _]) => key);
1679 return connectedPersonas;
1680}
1681
1682
1683/**
1684 * Shows a popup with all personas connected to the currently selected character or group.
1685 * In the popup, the user can select a persona to load for the current character or group, or shift-click to remove the connection.
1686 * @return {Promise<void>}
1687 */
1688export async function showCharConnections() {
1689 let isRemoving = false;
1690
1691 const connections = getConnectedPersonas();
1692 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.`;
1693 const selectedPersona = await askForPersonaSelection(t`Persona Connections`, message, connections, {
1694 okButton: t`Ok`,
1695 highlightPersonas: true,
1696 targetedChar: getCurrentConnectionObj(),
1697 shiftClickHandler: (element, ev) => {
1698 const personaId = $(element).attr('data-pid');
1699
1700 /** @type {PersonaConnection[]} */
1701 const connections = power_user.persona_descriptions[personaId]?.connections;
1702 if (connections) {
1703 console.log(`Unlocking persona ${personaId} from current character ${name2}`);
1704 power_user.persona_descriptions[personaId].connections = connections.filter(c => {
1705 if (menu_type == 'group_edit' && c.type == 'group' && c.id == selected_group) return false;
1706 else if (c.type == 'character' && c.id == characters[Number(this_chid)]?.avatar) return false;
1707 return true;
1708 });
1709 saveSettingsDebounced();
1710 updatePersonaConnectionsAvatarList();
1711 if (power_user.persona_show_notifications) {
1712 toastr.info(t`User persona ${power_user.personas[personaId]} is now unlocked from the current character ${name2}.`, t`Persona unlocked`);
1713 }
1714
1715 isRemoving = true;
1716 $('#char_connections_button').trigger('click');
1717 }
1718 },
1719 });
1720
1721 // One of the persona was selected. So load it.
1722 if (!isRemoving && selectedPersona) {
1723 await setUserAvatar(selectedPersona, { toastPersonaNameChange: false });
1724 if (power_user.persona_show_notifications) {
1725 toastr.success(t`Selected persona ${power_user.personas[selectedPersona]} for current chat.`, t`Connected Persona Selected`);
1726 }
1727 }
1728}
1729
1730/**
1731 * Retrieves the current connection object based on whether the current chat is with a char or a group.
1732 *
1733 * @returns {PersonaConnection} An object representing the current connection
1734 */
1735export function getCurrentConnectionObj() {
1736 if (selected_group)
1737 return { type: 'group', id: selected_group };
1738 if (characters[Number(this_chid)]?.avatar)
1739 return { type: 'character', id: characters[Number(this_chid)]?.avatar };
1740 return null;
1741}
1742
1743function onBackupPersonas() {
1744 const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
1745 const filename = `personas_${timestamp}.json`;
1746 const data = JSON.stringify({
1747 'personas': power_user.personas,
1748 'persona_descriptions': power_user.persona_descriptions,
1749 'default_persona': power_user.default_persona,
1750 }, null, 2);
1751
1752 const blob = new Blob([data], { type: 'application/json' });
1753 download(blob, filename, 'application/json');
1754}
1755
1756async function onPersonasRestoreInput(e) {
1757 const file = e.target.files[0];
1758
1759 if (!file) {
1760 console.debug('No file selected');
1761 return;
1762 }
1763
1764 const data = await parseJsonFile(file);
1765
1766 if (!data) {
1767 toastr.warning(t`Invalid file selected`, t`Persona Management`);
1768 console.debug('Invalid file selected');
1769 return;
1770 }
1771
1772 if (!data.personas || !data.persona_descriptions || typeof data.personas !== 'object' || typeof data.persona_descriptions !== 'object') {
1773 toastr.warning(t`Invalid file format`, t`Persona Management`);
1774 console.debug('Invalid file selected');
1775 return;
1776 }
1777
1778 const avatarsList = await getUserAvatars(false);
1779 const warnings = [];
1780
1781 // Merge personas with existing ones
1782 for (const [key, value] of Object.entries(data.personas)) {
1783 if (key in power_user.personas) {
1784 warnings.push(`Persona "${key}" (${value}) already exists, skipping`);
1785 continue;
1786 }
1787
1788 power_user.personas[key] = value;
1789
1790 // If the avatar is missing, upload it
1791 if (!avatarsList.includes(key)) {
1792 warnings.push(`Persona image "${key}" (${value}) is missing, uploading default avatar`);
1793 await uploadUserAvatar(default_user_avatar, key);
1794 }
1795 }
1796
1797 // Merge persona descriptions with existing ones
1798 for (const [key, value] of Object.entries(data.persona_descriptions)) {
1799 if (key in power_user.persona_descriptions) {
1800 warnings.push(`Persona description for "${key}" (${power_user.personas[key]}) already exists, skipping`);
1801 continue;
1802 }
1803
1804 if (!power_user.personas[key]) {
1805 warnings.push(`Persona for "${key}" does not exist, skipping`);
1806 continue;
1807 }
1808
1809 power_user.persona_descriptions[key] = value;
1810 }
1811
1812 if (data.default_persona) {
1813 if (data.default_persona in power_user.personas) {
1814 power_user.default_persona = data.default_persona;
1815 } else {
1816 warnings.push(`Default persona "${data.default_persona}" does not exist, skipping`);
1817 }
1818 }
1819
1820 if (warnings.length) {
1821 toastr.success(t`Personas restored with warnings. Check console for details.`, t`Persona Management`);
1822 console.warn(`PERSONA RESTORE REPORT\n====================\n${warnings.join('\n')}`);
1823 } else {
1824 toastr.success(t`Personas restored successfully.`, t`Persona Management`);
1825 }
1826
1827 await getUserAvatars();
1828 setPersonaDescription();
1829 saveSettingsDebounced();
1830 $('#personas_restore_input').val('');
1831}
1832
1833/**
1834 * Synchronizes user-sent messages in the chat to the current persona.
1835 * @param {object} [options={}] - Optional parameters
1836 * @param {number} [options.start=0] - Start index of the message range (inclusive)
1837 * @param {number} [options.end=chat.length - 1] - End index of the message range (inclusive)
1838 * @param {boolean} [options.quiet=false] - If true, skips the confirmation popup
1839 * @param {string} [options.nameFilter=''] - Filter messages by name (case-insensitive)
1840 * @returns {Promise<void>}
1841 */
1842async function syncUserNameToPersona({ start = 0, end = chat.length - 1, quiet = false, nameFilter = '' } = {}) {
1843 const isRangeAll = start === 0 && end === chat.length - 1;
1844 const hasNameFilter = nameFilter?.trim();
1845 const confirmMessage = isRangeAll && !hasNameFilter
1846 ? t`All user-sent messages in this chat will be attributed to ${name1}.`
1847 : isRangeAll && hasNameFilter
1848 ? t`User-sent messages with name "${nameFilter}" will be attributed to ${name1}.`
1849 : !isRangeAll && !hasNameFilter
1850 ? t`User-sent messages in the specified range will be attributed to ${name1}.`
1851 : t`User-sent messages with name "${nameFilter}" in the specified range will be attributed to ${name1}.`;
1852
1853 if (!quiet) {
1854 const confirmation = await Popup.show.confirm(t`Are you sure?`, confirmMessage);
1855 if (!confirmation) {
1856 return;
1857 }
1858 }
1859
1860 for (let i = start; i <= end; i++) {
1861 const mes = chat[i];
1862 if (mes?.is_user && (!hasNameFilter || equalsIgnoreCaseAndAccents(mes.name, nameFilter))) {
1863 mes.name = name1;
1864 mes.force_avatar = getThumbnailUrl('persona', user_avatar);
1865 }
1866 }
1867
1868 await saveChatConditional();
1869 await reloadCurrentChat();
1870}
1871
1872/**
1873 * Retriggers the first message to reload it from the char definition.
1874 */
1875export async function retriggerFirstMessageOnEmptyChat() {
1876 if (chat_metadata.tainted) {
1877 return;
1878 }
1879 if (selected_group) {
1880 await reloadCurrentChat();
1881 }
1882 if (!selected_group && Number(this_chid) >= 0 && chat.length === 1) {
1883 await createOrEditCharacter();
1884 }
1885}
1886
1887/**
1888 * Duplicates a persona.
1889 * @param {string} avatarId Source persona avatar id
1890 * @param {object} [options] Options
1891 * @param {boolean} [options.silent=false] If true, skips the confirmation popup
1892 * @param {boolean} [options.select=false] If true, selects/activates the duplicated persona
1893 * @returns {Promise<string>} The avatar id of the new persona, or empty string on failure/cancellation
1894 */
1895async function duplicatePersona(avatarId, { silent = false, select = false } = {}) {
1896 const personaName = power_user.personas[avatarId];
1897
1898 if (!personaName) {
1899 toastr.warning(t`Chosen avatar is not a persona`, t`Persona Management`);
1900 return '';
1901 }
1902
1903 if (!silent) {
1904 const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName);
1905
1906 if (!confirm) {
1907 console.debug('User cancelled duplicating persona');
1908 return '';
1909 }
1910 }
1911
1912 const newAvatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
1913 const descriptor = power_user.persona_descriptions[avatarId];
1914
1915 power_user.personas[newAvatarId] = personaName;
1916 power_user.persona_descriptions[newAvatarId] = {
1917 description: descriptor?.description ?? '',
1918 position: descriptor?.position ?? persona_description_positions.IN_PROMPT,
1919 depth: descriptor?.depth ?? DEFAULT_DEPTH,
1920 role: descriptor?.role ?? DEFAULT_ROLE,
1921 lorebook: descriptor?.lorebook ?? '',
1922 title: descriptor?.title ?? '',
1923 };
1924
1925 await uploadUserAvatar(getUserAvatar(avatarId), newAvatarId);
1926
1927 const eventData = {
1928 avatarId: newAvatarId,
1929 name: personaName,
1930 description: descriptor?.description ?? '',
1931 title: descriptor?.title ?? '',
1932 duplicatedFromAvatarId: avatarId,
1933 };
1934 await eventSource.emit(event_types.PERSONA_CREATED, eventData);
1935
1936 await getUserAvatars(true, newAvatarId);
1937 saveSettingsDebounced();
1938
1939 if (select) {
1940 await setUserAvatar(newAvatarId);
1941 }
1942
1943 return newAvatarId;
1944}
1945
1946/**
1947 * If a current user avatar is not bound to persona, bind it.
1948 */
1949async function migrateNonPersonaUser() {
1950 if (user_avatar in power_user.personas) {
1951 return;
1952 }
1953
1954 await initPersona(user_avatar, name1, '', '', { silent: true });
1955 setPersonaDescription();
1956 await getUserAvatars(true, user_avatar);
1957}
1958
1959
1960// #region Persona CRUD Slash Command Utilities
1961
1962/**
1963 * Mapping of human-readable position names to persona_description_positions enum values.
1964 * @type {Record<string, number>}
1965 */
1966const POSITION_NAME_MAP = Object.freeze({
1967 'inprompt': persona_description_positions.IN_PROMPT,
1968 'topan': persona_description_positions.TOP_AN,
1969 'bottoman': persona_description_positions.BOTTOM_AN,
1970 'atdepth': persona_description_positions.AT_DEPTH,
1971 'none': persona_description_positions.NONE,
1972});
1973
1974/**
1975 * Mapping of human-readable role names to numeric role values.
1976 * @type {Record<string, number>}
1977 */
1978const ROLE_NAME_MAP = Object.freeze({
1979 'system': 0,
1980 'user': 1,
1981 'assistant': 2,
1982});
1983
1984/**
1985 * Parses a persona description position from a string or number value.
1986 * @param {string|number|undefined} value Position value (name or number)
1987 * @returns {number|null} Parsed position value, or null if invalid/undefined
1988 */
1989function parsePersonaPosition(value) {
1990 if (value === undefined || value === null) return null;
1991 const strValue = String(value).toLowerCase();
1992 if (strValue in POSITION_NAME_MAP) return POSITION_NAME_MAP[strValue];
1993 const numValue = Number(value);
1994 if (!isNaN(numValue) && Object.values(persona_description_positions).includes(numValue)) return numValue;
1995 return null;
1996}
1997
1998/**
1999 * Parses a persona description role from a string or number value.
2000 * @param {string|number|undefined} value Role value (name or number)
2001 * @returns {number|null} Parsed role value, or null if invalid/undefined
2002 */
2003function parsePersonaRole(value) {
2004 if (value === undefined || value === null) return null;
2005 const strValue = String(value).toLowerCase();
2006 if (strValue in ROLE_NAME_MAP) return ROLE_NAME_MAP[strValue];
2007 const numValue = Number(value);
2008 if (!isNaN(numValue) && numValue >= 0 && numValue <= 2) return numValue;
2009 return null;
2010}
2011
2012/**
2013 * Uploads base64 avatar data to a persona, optionally showing a crop dialog.
2014 * @param {string} avatarId The persona's avatar file name
2015 * @param {string} base64Data Base64 data URL of the image
2016 * @param {object} [options] Options
2017 * @param {boolean} [options.resizePrompt=false] Whether to show the crop dialog
2018 * @returns {Promise<boolean>} True if upload was successful
2019 */
2020async function uploadPersonaAvatar(avatarId, base64Data, { resizePrompt = false } = {}) {
2021 if (!base64Data || !avatarId) return false;
2022
2023 let finalImageData = base64Data;
2024
2025 if (resizePrompt && !power_user.never_resize_avatars) {
2026 const dlg = new Popup(t`Set the crop position of the avatar image`, POPUP_TYPE.CROP, '', { cropImage: base64Data });
2027 const croppedImage = await dlg.show();
2028 if (!croppedImage) return false;
2029 finalImageData = String(croppedImage);
2030 }
2031
2032 try {
2033 const response = await fetch(finalImageData);
2034 const blob = await response.blob();
2035 const file = new File([blob], 'avatar.png', { type: 'image/png' });
2036 const formData = new FormData();
2037 formData.append('avatar', file);
2038 formData.append('overwrite_name', avatarId);
2039
2040 const uploadResponse = await fetch('/api/avatars/upload', {
2041 method: 'POST',
2042 headers: getRequestHeaders({ omitContentType: true }),
2043 cache: 'no-cache',
2044 body: formData,
2045 });
2046
2047 if (!uploadResponse.ok) {
2048 throw new Error(`Upload failed: ${uploadResponse.statusText}`);
2049 }
2050
2051 // Cache bust for the updated avatar
2052 await fetch(getUserAvatar(avatarId), { cache: 'reload' });
2053 await fetch(getThumbnailUrl('persona', avatarId), { cache: 'reload' });
2054 reloadUserAvatar(true);
2055 return true;
2056 } catch (error) {
2057 console.error('Error uploading persona avatar:', error);
2058 toastr.warning(t`Failed to upload avatar: ${error.message}`);
2059 return false;
2060 }
2061}
2062
2063/**
2064 * Resolves a persona from the given argument or falls back to the currently active persona.
2065 * @param {string} [personaArg] Persona name or avatar key argument
2066 * @returns {import('./utils.js').PersonaViewModel|null} The resolved persona, or null if not found
2067 */
2068function getTargetPersona(personaArg) {
2069 if (personaArg) {
2070 const persona = findPersona({ name: personaArg });
2071 if (!persona) {
2072 toastr.warning(t`Persona "${personaArg}" not found`);
2073 return null;
2074 }
2075 return persona;
2076 }
2077
2078 // Fall back to currently active persona
2079 const persona = findPersona({ preferCurrentPersona: true });
2080 if (!persona) {
2081 toastr.warning(t`No persona selected and no persona argument provided`);
2082 return null;
2083 }
2084 return persona;
2085}
2086
2087// #endregion
2088
2089// #region Persona CRUD Slash Command Callbacks
2090
2091/**
2092 * Creates a new persona with the specified attributes.
2093 * @param {object} args Named arguments from the slash command
2094 * @returns {Promise<string>} Avatar key of the created persona, or empty string on failure
2095 */
2096async function createPersonaCallback(args) {
2097 const name = args.name;
2098 if (!name || typeof name !== 'string' || !name.trim()) {
2099 toastr.warning(t`Persona name is required`);
2100 return '';
2101 }
2102
2103 const trimmedName = name.trim();
2104 const avatarId = `${Date.now()}-${trimmedName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
2105
2106 const description = args.description ?? '';
2107 const title = args.title ?? '';
2108 const position = parsePersonaPosition(args.descriptionPosition) ?? persona_description_positions.IN_PROMPT;
2109 const role = parsePersonaRole(args.descriptionRole) ?? DEFAULT_ROLE;
2110 const lorebook = args.lorebook ?? '';
2111
2112 let depth = args.descriptionDepth !== undefined ? Number(args.descriptionDepth) : DEFAULT_DEPTH;
2113 if (isNaN(depth)) {
2114 toastr.warning(t`Invalid description depth "${args.descriptionDepth}", defaulting to ${DEFAULT_DEPTH}`);
2115 depth = DEFAULT_DEPTH;
2116 }
2117
2118 // Initialize persona data with all fields
2119 await initPersona(avatarId, trimmedName, description, title, {
2120 position, depth, role, lorebook,
2121 });
2122
2123 // Handle avatar upload
2124 const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null;
2125 if (avatarData) {
2126 const resizePrompt = !isFalseBoolean(args.avatarPromptResize ?? 'true');
2127 const uploaded = await uploadPersonaAvatar(avatarId, avatarData, { resizePrompt });
2128 if (!uploaded) {
2129 // Crop was cancelled or upload failed — use default avatar
2130 await uploadUserAvatar(default_user_avatar, avatarId);
2131 }
2132 } else {
2133 await uploadUserAvatar(default_user_avatar, avatarId);
2134 }
2135
2136 saveSettingsDebounced();
2137 await getUserAvatars(true, avatarId);
2138
2139 // Select/activate if requested (default: true)
2140 if (!isFalseBoolean(args.select ?? 'true')) {
2141 await setUserAvatar(avatarId);
2142 }
2143
2144 toastr.success(t`Persona "${trimmedName}" created successfully`);
2145 return avatarId;
2146}
2147
2148/**
2149 * Updates an existing persona's attributes.
2150 * @param {object} args Named arguments from the slash command
2151 * @returns {Promise<string>} Avatar key of the updated persona, or empty string on failure
2152 */
2153async function updatePersonaCallback(args) {
2154 const persona = getTargetPersona(args.persona);
2155 if (!persona) return '';
2156
2157 const avatarId = persona.avatar;
2158 const descriptor = power_user.persona_descriptions[avatarId];
2159
2160 if (!descriptor) {
2161 toastr.warning(t`Persona data not found for "${persona.name}"`);
2162 return '';
2163 }
2164
2165 let hasUpdates = false;
2166
2167 // Update name
2168 if (args.name !== undefined) {
2169 const newName = String(args.name).trim();
2170 if (newName) {
2171 const oldName = power_user.personas[avatarId];
2172 power_user.personas[avatarId] = newName;
2173 if (avatarId === user_avatar) {
2174 setUserName(newName);
2175 }
2176 await eventSource.emit(event_types.PERSONA_RENAMED, { avatarId, oldName, newName });
2177 hasUpdates = true;
2178 }
2179 }
2180
2181 // Update description
2182 if (args.description !== undefined) {
2183 descriptor.description = args.description;
2184 if (avatarId === user_avatar) {
2185 power_user.persona_description = args.description;
2186 }
2187 hasUpdates = true;
2188 }
2189
2190 // Update title
2191 if (args.title !== undefined) {
2192 descriptor.title = args.title;
2193 hasUpdates = true;
2194 }
2195
2196 // Update description position
2197 if (args.descriptionPosition !== undefined) {
2198 const position = parsePersonaPosition(args.descriptionPosition);
2199 if (position !== null) {
2200 descriptor.position = position;
2201 if (avatarId === user_avatar) {
2202 power_user.persona_description_position = position;
2203 }
2204 hasUpdates = true;
2205 }
2206 }
2207
2208 // Update description depth
2209 if (args.descriptionDepth !== undefined) {
2210 const depth = Number(args.descriptionDepth);
2211 if (!isNaN(depth)) {
2212 descriptor.depth = depth;
2213 if (avatarId === user_avatar) {
2214 power_user.persona_description_depth = depth;
2215 }
2216 hasUpdates = true;
2217 }
2218 }
2219
2220 // Update description role
2221 if (args.descriptionRole !== undefined) {
2222 const role = parsePersonaRole(args.descriptionRole);
2223 if (role !== null) {
2224 descriptor.role = role;
2225 if (avatarId === user_avatar) {
2226 power_user.persona_description_role = role;
2227 }
2228 hasUpdates = true;
2229 }
2230 }
2231
2232 // Update lorebook
2233 if (args.lorebook !== undefined) {
2234 descriptor.lorebook = args.lorebook;
2235 if (avatarId === user_avatar) {
2236 power_user.persona_description_lorebook = args.lorebook;
2237 }
2238 hasUpdates = true;
2239 }
2240
2241 // Handle avatar
2242 const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null;
2243 if (avatarData) {
2244 const resizePrompt = !isFalseBoolean(args.avatarPromptResize ?? 'true');
2245 const uploaded = await uploadPersonaAvatar(avatarId, avatarData, { resizePrompt });
2246 if (uploaded) {
2247 hasUpdates = true;
2248 }
2249 }
2250
2251 if (!hasUpdates) {
2252 toastr.info(t`No fields provided to update`);
2253 return avatarId;
2254 }
2255
2256 saveSettingsDebounced();
2257 await eventSource.emit(event_types.PERSONA_UPDATED, avatarId);
2258
2259 // Refresh UI if the updated persona is the active one
2260 if (avatarId === user_avatar) {
2261 setPersonaDescription();
2262 }
2263 await getUserAvatars(true, avatarId);
2264 updatePersonaUIStates();
2265
2266 toastr.success(t`Persona "${power_user.personas[avatarId]}" updated successfully`);
2267 return avatarId;
2268}
2269
2270/**
2271 * Retrieves persona data or a specific field.
2272 * @param {object} args Named arguments from the slash command
2273 * @returns {Promise<string>} The persona data or field value
2274 */
2275async function getPersonaDataCallback(args) {
2276 const persona = getTargetPersona(args.persona);
2277 if (!persona) return '';
2278
2279 const avatarId = persona.avatar;
2280 const descriptor = power_user.persona_descriptions[avatarId] ?? {};
2281
2282 if (args.field) {
2283 /** @type {Record<string, unknown>} */
2284 const fieldMap = {
2285 name: power_user.personas[avatarId] ?? '',
2286 description: descriptor.description ?? '',
2287 title: descriptor.title ?? '',
2288 position: descriptor.position ?? persona_description_positions.IN_PROMPT,
2289 depth: descriptor.depth ?? DEFAULT_DEPTH,
2290 role: descriptor.role ?? DEFAULT_ROLE,
2291 lorebook: descriptor.lorebook ?? '',
2292 avatar: avatarId,
2293 default: power_user.default_persona === avatarId,
2294 connections: descriptor.connections ?? [],
2295 };
2296
2297 const value = fieldMap[args.field];
2298 if (value === undefined) {
2299 toastr.warning(t`Unknown persona field "${args.field}"`);
2300 return '';
2301 }
2302
2303 return await slashCommandReturnHelper.doReturn(
2304 args.return ?? 'pipe', value,
2305 { objectToStringFunc: x => typeof x === 'object' ? JSON.stringify(x) : String(x) },
2306 );
2307 }
2308
2309 // Return full persona data
2310 const personaData = {
2311 avatar: avatarId,
2312 name: power_user.personas[avatarId] ?? '',
2313 description: descriptor.description ?? '',
2314 title: descriptor.title ?? '',
2315 position: descriptor.position ?? persona_description_positions.IN_PROMPT,
2316 depth: descriptor.depth ?? DEFAULT_DEPTH,
2317 role: descriptor.role ?? DEFAULT_ROLE,
2318 lorebook: descriptor.lorebook ?? '',
2319 default: power_user.default_persona === avatarId,
2320 connections: descriptor.connections ?? [],
2321 };
2322
2323 return await slashCommandReturnHelper.doReturn(
2324 args.return ?? 'pipe', personaData,
2325 { objectToStringFunc: x => JSON.stringify(x, null, 2) },
2326 );
2327}
2328
2329/**
2330 * Deletes a persona via slash command.
2331 * @param {object} args Named arguments from the slash command
2332 * @returns {Promise<string>} 'true' if deleted, 'false' otherwise
2333 */
2334async function deletePersonaCallback(args) {
2335 const persona = getTargetPersona(args.persona);
2336 if (!persona) return 'false';
2337
2338 const silent = isTrueBoolean(args.silent);
2339 const success = await deletePersona(persona.avatar, { silent });
2340 return String(success);
2341}
2342
2343/**
2344 * Duplicates a persona via slash command.
2345 * @param {object} args Named arguments from the slash command
2346 * @returns {Promise<string>} Avatar key of the duplicated persona, or empty string on failure
2347 */
2348async function duplicatePersonaCallback(args) {
2349 const persona = getTargetPersona(args.persona);
2350 if (!persona) return '';
2351
2352 const shouldSelect = isTrueBoolean(args.select);
2353 const newAvatarId = await duplicatePersona(persona.avatar, { silent: true, select: shouldSelect });
2354
2355 if (!newAvatarId) {
2356 toastr.error(t`Failed to duplicate persona`);
2357 return '';
2358 }
2359
2360 toastr.success(t`Persona "${power_user.personas[newAvatarId]}" duplicated successfully`);
2361 return newAvatarId;
2362}
2363
2364// #endregion
2365
2366/**
2367 * Locks or unlocks the persona of the current chat.
2368 * @param {{type: string}} _args Named arguments
2369 * @param {string} value The value to set the lock to
2370 * @returns {Promise<string>} The value of the lock after setting
2371 */
2372async function lockPersonaCallback(_args, value) {
2373 const type = /** @type {PersonaLockType} */ (_args.type ?? 'chat');
2374
2375 if (!['chat', 'character', 'default'].includes(type)) {
2376 toastr.warning(t`Unknown lock type "${type}"`, t`Persona Management`);
2377 return '';
2378 }
2379
2380 if (!value) {
2381 return String(isPersonaLocked(type));
2382 }
2383
2384 if (['toggle', 't'].includes(value.trim().toLowerCase())) {
2385 const result = await togglePersonaLock(type);
2386 return String(result);
2387 }
2388
2389 if (isTrueBoolean(value)) {
2390 await setPersonaLockState(true, type);
2391 return 'true';
2392 }
2393
2394 if (isFalseBoolean(value)) {
2395 await setPersonaLockState(false, type);
2396 return 'false';
2397 }
2398
2399 return '';
2400}
2401
2402/**
2403 * Sets a persona name and optionally an avatar.
2404 * @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments
2405 * @param {string} name Name to set
2406 * @returns {Promise<string>}
2407 */
2408async function setNameCallback({ mode = 'all' }, name) {
2409 if (!name) {
2410 toastr.warning('You must specify a name to change to');
2411 return '';
2412 }
2413
2414 if (!['lookup', 'temp', 'all'].includes(mode)) {
2415 toastr.warning('Mode must be one of "lookup", "temp" or "all"');
2416 return '';
2417 }
2418
2419 name = name.trim();
2420
2421 // If the name matches a persona avatar, or a name, auto-select it
2422 if (['lookup', 'all'].includes(mode)) {
2423 const persona = findPersona({ name });
2424 if (persona) {
2425 await autoSelectPersona(persona.name, { personaKey: persona.avatar });
2426 return '';
2427 } else if (mode === 'lookup') {
2428 toastr.warning(`Persona ${name} not found`);
2429 return '';
2430 }
2431 }
2432
2433 if (['temp', 'all'].includes(mode)) {
2434 // Otherwise, set just the name
2435 setUserName(name); //this prevented quickReply usage
2436 }
2437
2438 return '';
2439}
2440
2441async function syncCallback(args, value) {
2442 const range = value ? stringToRange(value, 0, chat.length - 1) : null;
2443
2444 if (value && !range) {
2445 console.warn(`WARN: Invalid range provided for /persona-sync command: ${value}`);
2446 return '';
2447 }
2448
2449 const quiet = !isFalseBoolean(args?.quiet);
2450 const nameFilter = typeof args?.from === 'string' ? args.from.trim() : '';
2451 const start = range ? range.start : 0;
2452 const end = range ? range.end : chat.length - 1;
2453
2454 await syncUserNameToPersona({ start, end, quiet, nameFilter });
2455
2456 return '';
2457}
2458
2459/**
2460 * Returns all unique user message names in the current chat for enum autocomplete.
2461 * @returns {SlashCommandEnumValue[]}
2462 */
2463function userMessageNamesEnumProvider() {
2464 return chat
2465 .filter(mes => mes.is_user)
2466 .map(mes => mes.name)
2467 .filter(onlyUnique)
2468 .sort(sortIgnoreCaseAndAccents)
2469 .map(name => new SlashCommandEnumValue(name, null, enumTypes.name, enumIcons.persona));
2470}
2471
2472function registerPersonaSlashCommands() {
2473 // Shared persona field definitions for persona CRUD commands
2474 const getPersonaFieldArgs = ({ requiredFields = [] } = {}) => [
2475 SlashCommandNamedArgument.fromProps({
2476 name: 'name',
2477 description: t`The name of the persona`,
2478 typeList: [ARGUMENT_TYPE.STRING],
2479 isRequired: requiredFields.includes('name'),
2480 }),
2481 SlashCommandNamedArgument.fromProps({
2482 name: 'description',
2483 description: t`The persona description (sent with messages for AI context)`,
2484 typeList: [ARGUMENT_TYPE.STRING],
2485 isRequired: requiredFields.includes('description'),
2486 }),
2487 SlashCommandNamedArgument.fromProps({
2488 name: 'title',
2489 description: t`A display title for the persona (not sent to the AI, display only)`,
2490 typeList: [ARGUMENT_TYPE.STRING],
2491 isRequired: requiredFields.includes('title'),
2492 }),
2493 SlashCommandNamedArgument.fromProps({
2494 name: 'avatar',
2495 description: t`Avatar image. Use "prompt" to open file picker, or provide a local ST file path or base64 data URL. Can also be the return value of /imagine.`,
2496 typeList: [ARGUMENT_TYPE.STRING],
2497 isRequired: requiredFields.includes('avatar'),
2498 enumList: [
2499 new SlashCommandEnumValue('prompt', 'Open file picker to select an image', enumTypes.enum, '📁'),
2500 new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'),
2501 new SlashCommandEnumValue('backgrounds/...', 'Background image path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'),
2502 new SlashCommandEnumValue('User Avatars/...', 'User avatar path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'),
2503 new SlashCommandEnumValue('assets/...', 'Asset file path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'),
2504 new SlashCommandEnumValue('user/images/...', 'User image path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'),
2505 ],
2506 }),
2507 SlashCommandNamedArgument.fromProps({
2508 name: 'avatarPromptResize',
2509 description: t`Whether to show the avatar resize/crop dialog when uploading. Ignored if "Never resize avatars" is enabled in settings.`,
2510 typeList: [ARGUMENT_TYPE.BOOLEAN],
2511 defaultValue: 'true',
2512 enumProvider: commonEnumProviders.boolean('trueFalse'),
2513 }),
2514 SlashCommandNamedArgument.fromProps({
2515 name: 'descriptionPosition',
2516 description: t`Where to inject the persona description in the prompt`,
2517 typeList: [ARGUMENT_TYPE.STRING],
2518 enumList: [
2519 new SlashCommandEnumValue('inPrompt', t`In Prompt (default)`, enumTypes.enum),
2520 new SlashCommandEnumValue('topAN', t`Top of Author's Note`, enumTypes.enum),
2521 new SlashCommandEnumValue('bottomAN', t`Bottom of Author's Note`, enumTypes.enum),
2522 new SlashCommandEnumValue('atDepth', t`At a specific depth (uses descriptionDepth and descriptionRole)`, enumTypes.enum),
2523 new SlashCommandEnumValue('none', t`None (don't inject)`, enumTypes.enum),
2524 ],
2525 }),
2526 SlashCommandNamedArgument.fromProps({
2527 name: 'descriptionDepth',
2528 description: t`Depth for the persona description (when position is "atDepth")`,
2529 typeList: [ARGUMENT_TYPE.NUMBER],
2530 }),
2531 SlashCommandNamedArgument.fromProps({
2532 name: 'descriptionRole',
2533 description: t`Role for the persona description (when position is "atDepth")`,
2534 typeList: [ARGUMENT_TYPE.STRING],
2535 enumList: commonEnumProviders.messageRoles(),
2536 }),
2537 SlashCommandNamedArgument.fromProps({
2538 name: 'lorebook',
2539 description: t`The name of the lorebook/world info to attach to this persona`,
2540 typeList: [ARGUMENT_TYPE.STRING],
2541 enumProvider: commonEnumProviders.worlds,
2542 }),
2543 ];
2544
2545 // Shared persona target argument (for commands that operate on an existing persona)
2546 const personaTargetArg = SlashCommandNamedArgument.fromProps({
2547 name: 'persona',
2548 description: t`Persona name or avatar key. If not provided, uses the currently active persona.`,
2549 typeList: [ARGUMENT_TYPE.STRING],
2550 enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
2551 });
2552
2553 // ========================
2554 // New CRUD commands
2555 // ========================
2556
2557 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2558 name: 'persona-create',
2559 callback: createPersonaCallback,
2560 returns: t`the avatar key (unique identifier) of the created persona`,
2561 namedArgumentList: [
2562 ...getPersonaFieldArgs({ requiredFields: ['name'] }),
2563 SlashCommandNamedArgument.fromProps({
2564 name: 'select',
2565 description: t`Whether to select/activate the persona after creation`,
2566 typeList: [ARGUMENT_TYPE.BOOLEAN],
2567 defaultValue: 'true',
2568 enumProvider: commonEnumProviders.boolean('trueFalse'),
2569 }),
2570 ],
2571 helpString: `
2572 <div>
2573 ${t`Creates a new persona with the specified attributes. Returns the avatar key of the created persona.`}
2574 </div>
2575 <div>
2576 <strong>${t`Required arguments:`}</strong>
2577 <ul>
2578 <li><code>name</code> – ${t`The persona's display name.`}</li>
2579 </ul>
2580 </div>
2581 <div>
2582 <strong>${t`Note on avatar:`}</strong>
2583 ${t`The <code>avatar</code> argument accepts <code>prompt</code> to open a file picker, a local ST file path, or a base64 data URL. Can also be the return value of <code>/imagine</code>. If not provided, a default avatar will be used.`}
2584 </div>
2585 <div>
2586 <strong>${t`Example:`}</strong>
2587 <ul>
2588 <li>
2589 <pre><code>/persona-create name="Alice" description="A curious adventurer"</code></pre>
2590 </li>
2591 <li>
2592 <pre><code>/persona-create name="Bob" avatar=prompt lorebook="detective_lore" select=false</code></pre>
2593 </li>
2594 <li>
2595 <pre><code>/imagine portrait of an elf | /persona-create name="Elf" avatar="{{pipe}}"</code></pre>
2596 </li>
2597 </ul>
2598 </div>
2599 `,
2600 }));
2601
2602 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2603 name: 'persona-update',
2604 callback: updatePersonaCallback,
2605 returns: t`the avatar key of the updated persona`,
2606 namedArgumentList: [
2607 personaTargetArg,
2608 ...getPersonaFieldArgs(),
2609 ],
2610 helpString: `
2611 <div>
2612 ${t`Updates an existing persona's attributes. Only the provided fields are changed; others are left untouched.`}
2613 </div>
2614 <div>
2615 ${t`If no <code>persona</code> argument is provided, updates the currently active persona.`}
2616 </div>
2617 <div>
2618 <strong>${t`Example:`}</strong>
2619 <ul>
2620 <li>
2621 <pre><code>/persona-update description="An updated description"</code></pre>
2622 ${t`Updates the current persona's description.`}
2623 </li>
2624 <li>
2625 <pre><code>/persona-update persona="Alice" name="Alice 2.0" descriptionPosition=atDepth descriptionDepth=3</code></pre>
2626 ${t`Renames Alice and sets her description to inject at depth 3.`}
2627 </li>
2628 <li>
2629 <pre><code>/imagine portrait | /persona-update avatar="{{pipe}}"</code></pre>
2630 ${t`Generates an image and sets it as the current persona's avatar.`}
2631 </li>
2632 </ul>
2633 </div>
2634 `,
2635 }));
2636
2637 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2638 name: 'persona-get',
2639 aliases: ['persona-data'],
2640 callback: getPersonaDataCallback,
2641 returns: t`persona data as JSON or a specific field value`,
2642 namedArgumentList: [
2643 personaTargetArg,
2644 SlashCommandNamedArgument.fromProps({
2645 name: 'field',
2646 description: t`Specific field to retrieve. If not provided, returns the entire persona data as JSON.`,
2647 typeList: [ARGUMENT_TYPE.STRING],
2648 enumList: [
2649 new SlashCommandEnumValue('name', t`Persona name`, enumTypes.enum, enumIcons.persona),
2650 new SlashCommandEnumValue('description', t`Persona description`, enumTypes.enum, enumIcons.default),
2651 new SlashCommandEnumValue('title', t`Display title`, enumTypes.enum, enumIcons.default),
2652 new SlashCommandEnumValue('position', t`Description position (numeric)`, enumTypes.enum, enumIcons.default),
2653 new SlashCommandEnumValue('depth', t`Description depth`, enumTypes.enum, enumIcons.default),
2654 new SlashCommandEnumValue('role', t`Description role (numeric)`, enumTypes.enum, enumIcons.default),
2655 new SlashCommandEnumValue('lorebook', t`Attached lorebook name`, enumTypes.enum, enumIcons.world),
2656 new SlashCommandEnumValue('avatar', t`Avatar filename (unique key)`, enumTypes.enum, enumIcons.persona),
2657 new SlashCommandEnumValue('default', t`Whether this is the default persona`, enumTypes.enum, enumIcons.default),
2658 new SlashCommandEnumValue('connections', t`Character/group connections (array)`, enumTypes.enum, enumIcons.character),
2659 ],
2660 }),
2661 SlashCommandNamedArgument.fromProps({
2662 name: 'return',
2663 description: t`The way to return the result`,
2664 typeList: [ARGUMENT_TYPE.STRING],
2665 defaultValue: 'pipe',
2666 enumList: slashCommandReturnHelper.enumList({ allowPipe: true, allowObject: true, allowPopup: true, allowTextVersion: false }),
2667 }),
2668 ],
2669 helpString: `
2670 <div>
2671 ${t`Retrieves persona data. Can return all data as JSON or a specific field value.`}
2672 </div>
2673 <div>
2674 ${t`If no <code>persona</code> argument is provided, uses the currently active persona.`}
2675 </div>
2676 <div>
2677 <strong>${t`Example:`}</strong>
2678 <ul>
2679 <li>
2680 <pre><code>/persona-get field=description | /echo</code></pre>
2681 ${t`Outputs the current persona's description.`}
2682 </li>
2683 <li>
2684 <pre><code>/persona-get persona="Alice" field=name</code></pre>
2685 ${t`Returns Alice's persona name.`}
2686 </li>
2687 <li>
2688 <pre><code>/persona-get return=object | /json-get key=avatar</code></pre>
2689 ${t`Returns the current persona's full data as an object, then extracts the avatar key.`}
2690 </li>
2691 </ul>
2692 </div>
2693 `,
2694 }));
2695
2696 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2697 name: 'persona-delete',
2698 callback: deletePersonaCallback,
2699 returns: t`true if the persona was deleted, false otherwise`,
2700 namedArgumentList: [
2701 personaTargetArg,
2702 SlashCommandNamedArgument.fromProps({
2703 name: 'silent',
2704 description: t`Skip the confirmation popup`,
2705 typeList: [ARGUMENT_TYPE.BOOLEAN],
2706 defaultValue: 'false',
2707 enumProvider: commonEnumProviders.boolean('trueFalse'),
2708 }),
2709 ],
2710 helpString: `
2711 <div>
2712 ${t`Deletes a persona and its avatar from the system.`}
2713 </div>
2714 <div>
2715 ${t`If no <code>persona</code> argument is provided, deletes the currently active persona.`}
2716 </div>
2717 <div>
2718 <strong>⚠️ ${t`Warning:`}</strong> ${t`This action is irreversible. All data associated with the persona will be lost.`}
2719 </div>
2720 <div>
2721 <strong>${t`Example:`}</strong>
2722 <ul>
2723 <li>
2724 <pre><code>/persona-delete</code></pre>
2725 ${t`Deletes the current persona (shows confirmation popup).`}
2726 </li>
2727 <li>
2728 <pre><code>/persona-delete persona="Bob" silent=true</code></pre>
2729 ${t`Deletes Bob without confirmation.`}
2730 </li>
2731 </ul>
2732 </div>
2733 `,
2734 }));
2735
2736 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2737 name: 'persona-duplicate',
2738 callback: duplicatePersonaCallback,
2739 returns: t`the avatar key (unique identifier) of the duplicated persona`,
2740 namedArgumentList: [
2741 personaTargetArg,
2742 SlashCommandNamedArgument.fromProps({
2743 name: 'select',
2744 description: t`Whether to select/activate the duplicated persona after creation`,
2745 typeList: [ARGUMENT_TYPE.BOOLEAN],
2746 defaultValue: 'false',
2747 enumProvider: commonEnumProviders.boolean('trueFalse'),
2748 }),
2749 ],
2750 helpString: `
2751 <div>
2752 ${t`Duplicates a persona including all its data and avatar. Returns the avatar key of the new persona.`}
2753 </div>
2754 <div>
2755 ${t`Use <code>/persona-update</code> afterwards to rename or modify the duplicated persona's fields.`}
2756 </div>
2757 <div>
2758 <strong>${t`Example:`}</strong>
2759 <ul>
2760 <li>
2761 <pre><code>/persona-duplicate</code></pre>
2762 ${t`Duplicates the currently active persona.`}
2763 </li>
2764 <li>
2765 <pre><code>/persona-duplicate persona="Alice" select=true</code></pre>
2766 ${t`Duplicates Alice and selects the new persona.`}
2767 </li>
2768 <li>
2769 <pre><code>/persona-duplicate | /persona-update persona="{{pipe}}" name="Clone"</code></pre>
2770 ${t`Duplicates the current persona, then renames the clone.`}
2771 </li>
2772 </ul>
2773 </div>
2774 `,
2775 }));
2776
2777 // ========================
2778 // Existing commands (enhanced help strings)
2779 // ========================
2780
2781 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2782 name: 'persona-lock',
2783 aliases: ['lock', 'bind'],
2784 callback: lockPersonaCallback,
2785 returns: t`The current lock state for the given type`,
2786 helpString: `
2787 <div>
2788 ${t`Locks/unlocks the current persona to a chat, character, or as the default. Returns the lock state if no value is provided.`}
2789 </div>
2790 <div>
2791 <strong>${t`Example:`}</strong>
2792 <ul>
2793 <li><pre><code>/persona-lock on</code></pre> ${t`Locks persona to this chat.`}</li>
2794 <li><pre><code>/persona-lock type=character on</code></pre> ${t`Locks persona to the current character.`}</li>
2795 <li><pre><code>/persona-lock type=default on</code></pre> ${t`Sets persona as the default for new chats.`}</li>
2796 <li><pre><code>/persona-lock</code></pre> ${t`Returns whether the persona is locked to this chat.`}</li>
2797 </ul>
2798 </div>
2799 `,
2800 namedArgumentList: [
2801 SlashCommandNamedArgument.fromProps({
2802 name: 'type',
2803 description: t`The type of the lock, where it should apply to`,
2804 typeList: [ARGUMENT_TYPE.STRING],
2805 defaultValue: 'chat',
2806 enumList: [
2807 new SlashCommandEnumValue('chat', t`Lock the persona to the current chat.`),
2808 new SlashCommandEnumValue('character', t`Lock this persona to the currently selected character. If the setting is enabled, multiple personas can be locked to the same character.`),
2809 new SlashCommandEnumValue('default', t`Lock this persona as the default persona for all new chats.`),
2810 ],
2811 }),
2812 ],
2813 unnamedArgumentList: [
2814 SlashCommandArgument.fromProps({
2815 description: 'state',
2816 typeList: [ARGUMENT_TYPE.STRING],
2817 enumProvider: commonEnumProviders.boolean('onOffToggle'),
2818 }),
2819 ],
2820 }));
2821
2822 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2823 name: 'persona-set',
2824 callback: setNameCallback,
2825 aliases: ['persona', 'name'],
2826 namedArgumentList: [
2827 SlashCommandNamedArgument.fromProps({
2828 name: 'mode',
2829 description: t`The mode for persona selection`,
2830 typeList: [ARGUMENT_TYPE.STRING],
2831 defaultValue: 'all',
2832 enumList: [
2833 new SlashCommandEnumValue('lookup', t`Search for an existing persona only`),
2834 new SlashCommandEnumValue('temp', t`Set a temporary name only (no persona lookup)`),
2835 new SlashCommandEnumValue('all', t`Try persona lookup first, fall back to temporary name`),
2836 ],
2837 }),
2838 ],
2839 unnamedArgumentList: [
2840 SlashCommandArgument.fromProps({
2841 description: 'persona name',
2842 typeList: [ARGUMENT_TYPE.STRING],
2843 isRequired: true,
2844 enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
2845 }),
2846 ],
2847 helpString: `
2848 <div>
2849 ${t`Selects an existing persona by name or avatar key, or sets a temporary user name.`}
2850 </div>
2851 <div>
2852 ${t`If a matching persona exists, it will be selected with its name and avatar. Otherwise (in "all" or "temp" mode), only the display name is changed temporarily.`}
2853 </div>
2854 <div>
2855 <strong>${t`Example:`}</strong>
2856 <ul>
2857 <li><pre><code>/persona-set Alice</code></pre> ${t`Selects persona "Alice", or sets name to "Alice" if not found.`}</li>
2858 <li><pre><code>/persona-set mode=lookup Alice</code></pre> ${t`Only selects if persona "Alice" exists.`}</li>
2859 </ul>
2860 </div>
2861 `,
2862 }));
2863
2864 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2865 name: 'persona-sync',
2866 aliases: ['sync'],
2867 callback: syncCallback,
2868 namedArgumentList: [
2869 SlashCommandNamedArgument.fromProps({
2870 name: 'from',
2871 description: t`only sync messages from a certain persona name`,
2872 typeList: [ARGUMENT_TYPE.STRING],
2873 enumProvider: userMessageNamesEnumProvider,
2874 }),
2875 SlashCommandNamedArgument.fromProps({
2876 name: 'quiet',
2877 description: t`suppress the confirmation popup`,
2878 typeList: [ARGUMENT_TYPE.BOOLEAN],
2879 enumList: commonEnumProviders.boolean('trueFalse')(),
2880 defaultValue: 'true',
2881 }),
2882 ],
2883 unnamedArgumentList: [
2884 SlashCommandArgument.fromProps({
2885 description: t`message index (starts with 0) or range, syncs all user messages if not provided`,
2886 typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE],
2887 defaultValue: '0-{{lastMessageId}}',
2888 }),
2889 ],
2890 helpString: `
2891 <div>
2892 ${t`Syncs the user persona (name and avatar) in user-attributed messages in the current chat.`}
2893 </div>
2894 <div>
2895 ${t`If <code>from</code> is set, only messages with that specific persona name will be synced. Useful when multiple personas have been used in the same chat.`}
2896 </div>
2897 <div>
2898 ${t`If <code>quiet</code> is set to <code>false</code>, a confirmation popup will be shown before syncing.`}
2899 </div>
2900 <div>
2901 <strong>${t`Examples:`}</strong>
2902 <ul>
2903 <li><pre><code>/persona-sync</code></pre> ${t`- Sync all user messages`}</li>
2904 <li><pre><code>/persona-sync 5</code></pre> ${t`- Sync only message 5`}</li>
2905 <li><pre><code>/persona-sync 0-10</code></pre> ${t`- Sync messages 0 through 10`}</li>
2906 <li><pre><code>/persona-sync from=OldPersona 0-20</code></pre> ${t`- Sync only messages with name "OldPersona" in range 0-20`}</li>
2907 <li><pre><code>/persona-sync quiet=false</code></pre> ${t`- Sync all with confirmation popup`}</li>
2908 <li><pre><code>/persona-sync from=TempName quiet=false 5-15</code></pre> ${t`- Sync messages with name "TempName" in range 5-15 with confirmation`}</li>
2909 </ul>
2910 </div>
2911 `,
2912 }));
2913}
2914
2915/**
2916 * Initializes the persona management and all its functionality.
2917 * This is called during the initialization of the page.
2918 */
2919export async function initPersonas() {
2920 await migrateNonPersonaUser();
2921 registerPersonaSlashCommands();
2922 $('#persona_delete_button').on('click', deleteUserAvatar);
2923 $('#lock_persona_default').on('click', () => togglePersonaLock('default'));
2924 $('#lock_user_name').on('click', () => togglePersonaLock('chat'));
2925 $('#lock_persona_to_char').on('click', () => togglePersonaLock('character'));
2926 $('#create_dummy_persona').on('click', createDummyPersona);
2927 $('#persona_description').on('input', onPersonaDescriptionInput);
2928 $('#persona_description_position').on('input', onPersonaDescriptionPositionInput);
2929 $('#persona_depth_value').on('input', onPersonaDescriptionDepthValueInput);
2930 $('#persona_depth_role').on('input', onPersonaDescriptionDepthRoleInput);
2931 $('#persona_lore_button').on('click', onPersonaLoreButtonClick);
2932 addLongPressEvent('#persona_lore_button', function () {
2933 onPersonaLoreButtonClick({ shiftKey: true, altKey: false });
2934 });
2935 $('#persona-management-dropdown').on('change', async function () {
2936 const target = $(this).find(':selected').attr('id');
2937 $(this).prop('selectedIndex', 0);
2938 switch (target) {
2939 case 'persona_lorebook_link':
2940 await onPersonaLoreButtonClick({ shiftKey: true, altKey: false });
2941 break;
2942 }
2943 });
2944 $('#personas_backup').on('click', onBackupPersonas);
2945 $('#personas_restore').on('click', () => $('#personas_restore_input').trigger('click'));
2946 $('#personas_restore_input').on('change', onPersonasRestoreInput);
2947 $('#persona_sort_order').val(power_user.persona_sort_order).on('input', function () {
2948 const value = String($(this).val());
2949 // Save sort order, but do not save search sorting, as this is a temporary sorting option
2950 if (value !== 'search') power_user.persona_sort_order = value;
2951 getUserAvatars(true, user_avatar);
2952 saveSettingsDebounced();
2953 });
2954 $('#persona_grid_toggle').on('click', () => {
2955 const state = accountStorage.getItem(GRID_STORAGE_KEY) === 'true';
2956 accountStorage.setItem(GRID_STORAGE_KEY, String(!state));
2957 switchPersonaGridView();
2958 });
2959
2960 const debouncedPersonaSearch = debounce((searchQuery) => {
2961 personasFilter.setFilterData(FILTER_TYPES.PERSONA_SEARCH, searchQuery);
2962 });
2963
2964 $('#persona_search_bar').on('input', function () {
2965 const searchQuery = String($(this).val());
2966 debouncedPersonaSearch(searchQuery);
2967 });
2968
2969 $('#sync_name_button').on('click', async () => await syncUserNameToPersona());
2970 $('#avatar_upload_file').on('change', changeUserAvatar);
2971
2972 $(document).on('click', '#user_avatar_block .avatar-container', async function () {
2973 const imgfile = $(this).attr('data-avatar-id');
2974 await setUserAvatar(imgfile);
2975 });
2976
2977 $('#persona_rename_button').on('click', () => renamePersona(user_avatar));
2978
2979 $(document).on('click', '#user_avatar_block .avatar_upload', function () {
2980 $('#avatar_upload_overwrite').val('');
2981 $('#avatar_upload_file').trigger('click');
2982 });
2983
2984 $('#persona_duplicate_button').on('click', () => duplicatePersona(user_avatar));
2985
2986 $('#persona_set_image_button').on('click', function () {
2987 if (!user_avatar) {
2988 console.log('no imgfile');
2989 return;
2990 }
2991
2992 $('#avatar_upload_overwrite').val(user_avatar);
2993 $('#avatar_upload_file').trigger('click');
2994 });
2995
2996 $('#char_connections_button').on('click', showCharConnections);
2997
2998 eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => {
2999 if (target === 'convert_to_persona') {
3000 convertCharacterToPersona();
3001 }
3002 });
3003 eventSource.on(event_types.CHAT_CHANGED, updatePersonaUIStates);
3004 eventSource.on(event_types.CHAT_CHANGED, loadPersonaForCurrentChat);
3005 switchPersonaGridView();
3006}