Files
SillyTavern/public/scripts/personas.js
T
Cohee 2e4ca3dabf fix: improve sanitation of toasts that bypass HTML escaping (#5540)
* fix: improve sanitation of toasts that bypass HTML escaping

* fix: replace absolute lib.js import with relative

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 01:00:31 +03:00

3007 lines
119 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
buildAvatarList,
characterToEntity,
characters,
chat,
chat_metadata,
createOrEditCharacter,
default_user_avatar,
eventSource,
event_types,
getCurrentChatId,
getRequestHeaders,
getThumbnailUrl,
groupToEntity,
menu_type,
name1,
name2,
reloadCurrentChat,
saveChatConditional,
saveMetadata,
saveSettingsDebounced,
setUserName,
this_chid,
} from '../script.js';
import { power_user } from './power-user.js';
import { getTokenCountAsync } from './tokenizers.js';
import {
PAGINATION_TEMPLATE,
clearInfoBlock,
debounce,
delay,
download,
ensureImageFormatSupported,
flashHighlight,
getBase64Async,
getCharIndex,
isFalseBoolean,
isTrueBoolean,
onlyUnique,
parseJsonFile,
setInfoBlock,
localizePagination,
renderPaginationDropdown,
paginationDropdownChangeHandler,
addLongPressEvent,
stringToRange,
sortIgnoreCaseAndAccents,
equalsIgnoreCaseAndAccents,
uuidv4,
resolveAvatarData,
findPersona,
escapeHtml,
} from './utils.js';
import { debounce_timeout } from './constants.js';
import { FILTER_TYPES, FilterHelper } from './filters.js';
import { groups, selected_group } from './group-chats.js';
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
import { t } from './i18n.js';
import { openWorldInfoEditor, world_names } from './world-info.js';
import { renderTemplateAsync } from './templates.js';
import { saveMetadataDebounced } from './extensions.js';
import { accountStorage } from './util/AccountStorage.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
import { commonEnumMatchProviders, commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { isFirefox } from './browser-fixes.js';
import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
/**
* @typedef {object} PersonaConnection A connection between a character and a character or group entity
* @property {'character' | 'group'} type - Type of connection
* @property {string} id - ID of the connection (character key (avatar url), group id)
*/
/** @typedef {'chat' | 'character' | 'default'} PersonaLockType Type of the persona lock */
/**
* @typedef {object} PersonaState
* @property {string} avatarId - The avatar id of the persona
* @property {boolean} default - Whether this persona is the default one for all new chats
* @property {object} locked - An object containing the lock states
* @property {boolean} locked.chat - Whether the persona is locked to the currently open chat
* @property {boolean} locked.character - Whether the persona is locked to the currently open character or group
*/
export const persona_description_positions = {
IN_PROMPT: 0,
/**
* @deprecated Use persona_description_positions.IN_PROMPT instead.
*/
AFTER_CHAR: 1,
TOP_AN: 2,
BOTTOM_AN: 3,
AT_DEPTH: 4,
NONE: 9,
};
const USER_AVATAR_PATH = 'User Avatars/';
let savePersonasPage = 0;
const GRID_STORAGE_KEY = 'Personas_GridView';
const DEFAULT_DEPTH = 2;
const DEFAULT_ROLE = 0;
/** @type {string} The currently selected persona (identified by its avatar) */
export let user_avatar = '';
/** @type {FilterHelper} Filter helper for the persona list */
export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
/** @type {string} The last loaded chat id to remember for persona loading */
let personaLastLoadedChatId = null;
/** @type {function(string): void} */
let navigateToAvatar = () => { };
/**
* Checks if the Persona Management panel is currently open
* @returns {boolean}
*/
export function isPersonaPanelOpen() {
return document.querySelector('#persona-management-button .drawer-content')?.classList.contains('openDrawer') ?? false;
}
function switchPersonaGridView() {
const state = accountStorage.getItem(GRID_STORAGE_KEY) === 'true';
$('#user_avatar_block').toggleClass('gridView', state);
}
/**
* Returns the URL of the avatar for the given user avatar Id.
* @param {string} avatarImg User avatar Id
* @returns {string} User avatar URL
*/
export function getUserAvatar(avatarImg) {
return `${USER_AVATAR_PATH}${avatarImg}`;
}
export function initUserAvatar(avatar) {
user_avatar = avatar;
reloadUserAvatar();
updatePersonaUIStates();
}
/**
* Sets a user avatar file
* @param {string} imgfile Link to an image file
* @param {object} [options] Optional settings
* @param {boolean} [options.toastPersonaNameChange=true] Whether to show a toast when the persona name is changed
* @param {boolean} [options.navigateToCurrent=false] Whether to navigate to the current persona after setting the avatar
*/
export async function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigateToCurrent = false } = {}) {
const currentUserAvatar = user_avatar;
user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('data-avatar-id');
if (currentUserAvatar === user_avatar) {
return;
}
reloadUserAvatar();
updatePersonaUIStates({ navigateToCurrent: navigateToCurrent });
selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });
await retriggerFirstMessageOnEmptyChat();
saveSettingsDebounced();
$('.zoomed_avatar[forchar]').remove();
await eventSource.emit(event_types.PERSONA_CHANGED, user_avatar);
}
function reloadUserAvatar(force = false) {
$('.mes').each(function () {
const avatarImg = $(this).find('.avatar img');
if (force) {
avatarImg.attr('src', avatarImg.attr('src'));
}
if ($(this).attr('is_user') == 'true' && $(this).attr('force_avatar') == 'false') {
avatarImg.attr('src', getThumbnailUrl('persona', user_avatar));
}
});
}
/**
* Sort the given personas
* @param {string[]} personas - The persona names to sort
* @returns {string[]} The sorted persona names array, same reference as passed in
*/
function sortPersonas(personas) {
const option = $('#persona_sort_order').find(':selected');
if (option.attr('value') === 'search') {
personas.sort((a, b) => {
const aScore = personasFilter.getScore(FILTER_TYPES.PERSONA_SEARCH, a);
const bScore = personasFilter.getScore(FILTER_TYPES.PERSONA_SEARCH, b);
return (aScore - bScore);
});
} else {
personas.sort((a, b) => {
const aName = String(power_user.personas[a] || a);
const bName = String(power_user.personas[b] || b);
return power_user.persona_sort_order === 'asc' ? aName.localeCompare(bName) : bName.localeCompare(aName);
});
}
return personas;
}
/** Checks the state of the current search, and adds/removes the search sorting option accordingly */
function verifyPersonaSearchSortRule() {
const searchTerm = personasFilter.getFilterData(FILTER_TYPES.PERSONA_SEARCH);
const searchOption = $('#persona_sort_order option[value="search"]');
const selector = $('#persona_sort_order');
const isHidden = searchOption.attr('hidden') !== undefined;
// If we have a search term, we are displaying the sorting option for it
if (searchTerm && isHidden) {
searchOption.removeAttr('hidden');
selector.val(searchOption.attr('value'));
flashHighlight(selector);
}
// If search got cleared, we make sure to hide the option and go back to the one before
if (!searchTerm) {
searchOption.attr('hidden', '');
selector.val(power_user.persona_sort_order);
}
}
/**
* Gets a rendered avatar block.
* @param {string} avatarId Avatar file name
* @returns {JQuery<HTMLElement>} Avatar block
*/
function getUserAvatarBlock(avatarId) {
const template = $('#user_avatar_template .avatar-container').clone();
const personaName = power_user.personas[avatarId];
const personaDescription = power_user.persona_descriptions[avatarId]?.description;
const personaTitle = power_user.persona_descriptions[avatarId]?.title;
template.find('.ch_name').text(personaName || '[Unnamed Persona]');
template.find('.ch_description').text(personaDescription || $('#user_avatar_block').attr('no_desc_text')).toggleClass('text_muted', !personaDescription);
template.find('.ch_additional_info').text(personaTitle || '');
template.attr('data-avatar-id', avatarId);
template.find('.avatar').attr('data-avatar-id', avatarId).attr('title', avatarId);
template.toggleClass('default_persona', avatarId === power_user.default_persona);
const avatarUrl = getThumbnailUrl('persona', avatarId, isFirefox());
template.find('img').attr('src', avatarUrl);
// Make sure description block has at least three rows. Otherwise height looks inconsistent. I don't have a better idea for this.
const currentText = template.find('.ch_description').text();
if (currentText.split('\n').length < 3) {
template.find('.ch_description').text(currentText + '\n\xa0\n\xa0');
}
$('#user_avatar_block').append(template);
return template;
}
/**
* Initialize missing personas in the power user settings.
* @param {string[]} avatarsList List of avatar file names
* @returns {Promise<void>}
*/
async function addMissingPersonas(avatarsList) {
for (const persona of avatarsList) {
if (!power_user.personas[persona]) {
await initPersona(persona, '[Unnamed Persona]', '', '', { silent: true });
}
}
}
/**
* Gets a list of user avatars.
* @param {boolean} doRender Whether to render the list
* @param {string} openPageAt Item to be opened at
* @returns {Promise<string[]>} List of avatar file names
*/
export async function getUserAvatars(doRender = true, openPageAt = '') {
const response = await fetch('/api/avatars/get', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
});
if (response.ok) {
const allEntities = await response.json();
if (!Array.isArray(allEntities)) {
return [];
}
if (!doRender) {
return allEntities;
}
// If any persona is missing from the power user settings, we add it
await addMissingPersonas(allEntities);
// Before printing the personas, we check if we should enable/disable search sorting
verifyPersonaSearchSortRule();
let entities = personasFilter.applyFilters(allEntities);
entities = sortPersonas(entities);
const storageKey = 'Personas_PerPage';
const listId = '#user_avatar_block';
const perPage = Number(accountStorage.getItem(storageKey)) || 5;
const sizeChangerOptions = [5, 10, 25, 50, 100, 250, 500, 1000];
$('#persona_pagination_container').pagination({
dataSource: entities,
pageSize: perPage,
sizeChangerOptions,
pageRange: 1,
pageNumber: savePersonasPage || 1,
position: 'top',
showPageNumbers: false,
showSizeChanger: true,
formatSizeChanger: renderPaginationDropdown(perPage, sizeChangerOptions),
prevText: '<',
nextText: '>',
formatNavigator: PAGINATION_TEMPLATE,
showNavigator: true,
callback: function (data) {
$(listId).empty();
for (const item of data) {
$(listId).append(getUserAvatarBlock(item));
}
updatePersonaUIStates();
localizePagination($('#persona_pagination_container'));
},
afterSizeSelectorChange: function (e, size) {
accountStorage.setItem(storageKey, e.target.value);
paginationDropdownChangeHandler(e, size);
},
afterPaging: function (e) {
savePersonasPage = e;
},
afterRender: function () {
$(listId).scrollTop(0);
},
});
navigateToAvatar = (avatarId) => {
const avatarIndex = entities.indexOf(avatarId);
const page = Math.floor(avatarIndex / perPage) + 1;
if (avatarIndex !== -1) {
$('#persona_pagination_container').pagination('go', page);
}
};
openPageAt && navigateToAvatar(openPageAt);
return allEntities;
}
}
/**
* Uploads an avatar file to the server
* @param {string} url URL for the avatar file
* @param {string} [name] Optional name for the avatar file
* @returns {Promise} Promise that resolves when the avatar is uploaded
*/
async function uploadUserAvatar(url, name) {
const fetchResult = await fetch(url);
const blob = await fetchResult.blob();
const file = new File([blob], 'avatar.png', { type: 'image/png' });
const formData = new FormData();
formData.append('avatar', file);
if (name) {
formData.append('overwrite_name', name);
}
const response = await fetch('/api/avatars/upload', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
cache: 'no-cache',
body: formData,
});
if (!response.ok) {
throw new Error(`Failed to upload avatar: ${response.statusText}`);
}
// Get the actual path from the response
const data = await response.json();
await getUserAvatars(true, data?.path || name);
}
async function changeUserAvatar(e) {
const form = document.getElementById('form_upload_avatar');
if (!(form instanceof HTMLFormElement)) {
console.error('Form not found');
return;
}
const file = e.target.files[0];
if (!file) {
form.reset();
return;
}
const formData = new FormData(form);
const dataUrl = await getBase64Async(file);
let url = '/api/avatars/upload';
if (!power_user.never_resize_avatars) {
const dlg = new Popup(t`Set the crop position of the avatar image`, POPUP_TYPE.CROP, '', { cropImage: dataUrl });
const result = await dlg.show();
if (!result) {
return;
}
if (dlg.cropData !== undefined) {
url += `?crop=${encodeURIComponent(JSON.stringify(dlg.cropData))}`;
}
}
const rawFile = formData.get('avatar');
if (rawFile instanceof File) {
const convertedFile = await ensureImageFormatSupported(rawFile);
formData.set('avatar', convertedFile);
}
const response = await fetch(url, {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
cache: 'no-cache',
body: formData,
});
if (response.ok) {
const data = await response.json();
const overwriteName = formData.get('overwrite_name');
const dataPath = data?.path;
// If the user uploaded a new avatar, we want to make sure it's not cached
if (overwriteName && dataPath) {
await fetch(getUserAvatar(String(dataPath)), { cache: 'reload' });
await fetch(getThumbnailUrl('persona', String(dataPath)), { cache: 'reload' });
reloadUserAvatar(true);
}
if (!overwriteName && dataPath) {
await getUserAvatars();
await delay(1);
await createPersona(dataPath);
}
await getUserAvatars(true, dataPath || overwriteName);
}
// Will allow to select the same file twice in a row
form.reset();
}
/**
* Prompts the user to create a persona for the uploaded avatar.
* @param {string} avatarId User avatar id
* @returns {Promise} Promise that resolves when the persona is set
*/
export async function createPersona(avatarId) {
const personaName = await Popup.show.input(t`Enter a name for this persona:`, t`Cancel if you're just uploading an avatar.`, '');
if (!personaName) {
console.debug('User cancelled creating a persona');
return;
}
const personaDescription = await Popup.show.input(t`Enter a description for this persona:`, t`You can always add or change it later.`, '', { rows: 4 });
await initPersona(avatarId, personaName, personaDescription, '');
if (power_user.persona_show_notifications) {
toastr.success(t`You can now pick ${personaName} as a persona in the Persona Management menu.`, t`Persona Created`);
}
}
async function createDummyPersona() {
const popup = new Popup(t`Enter a name for this persona:`, POPUP_TYPE.INPUT, '', {
customInputs: [{
id: 'persona_title',
type: 'text',
label: t`Persona Title (optional, display only)`,
}],
});
const personaName = await popup.show();
const personaTitle = String(popup.inputResults.get('persona_title') || '').trim();
if (!personaName || typeof personaName !== 'string') {
console.debug('User cancelled creating dummy persona');
return;
}
// Date + name (only ASCII) to make it unique
const avatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
await initPersona(avatarId, personaName, '', personaTitle);
await uploadUserAvatar(default_user_avatar, avatarId);
}
/**
* Initializes a persona for the given avatar id.
* @param {string} avatarId User avatar id
* @param {string} personaName Name for the persona
* @param {string} personaDescription Optional description for the persona
* @param {string} personaTitle Optional title for the persona
* @param {object} [options={}] Optional settings
* @param {boolean} [options.silent=false] If true, no PERSONA_CREATED event is emitted (used for background migrations)
* @param {number} [options.position=persona_description_positions.IN_PROMPT] Description position (defaults to IN_PROMPT)
* @param {number} [options.depth=DEFAULT_DEPTH] Description depth (defaults to DEFAULT_DEPTH)
* @param {number} [options.role=DEFAULT_ROLE] Description role (defaults to DEFAULT_ROLE)
* @param {string} [options.lorebook=''] Attached lorebook name
* @returns {Promise<void>}
*/
export async function initPersona(avatarId, personaName, personaDescription, personaTitle, {
silent = false,
position = persona_description_positions.IN_PROMPT,
depth = DEFAULT_DEPTH,
role = DEFAULT_ROLE,
lorebook = '',
} = {}) {
power_user.personas[avatarId] = personaName;
power_user.persona_descriptions[avatarId] = {
description: personaDescription || '',
position: position,
depth: depth,
role: role,
lorebook: lorebook,
title: personaTitle || '',
};
saveSettingsDebounced();
if (!silent) {
await eventSource.emit(event_types.PERSONA_CREATED, { avatarId, name: personaName, description: personaDescription || '', title: personaTitle || '' });
}
}
/**
* Converts a character given character (either by character id or the current character) to a persona.
*
* If a persona with the same name already exists, the user is prompted to confirm whether or not to overwrite it.
* If the character description contains {{char}} or {{user}} macros, the user is prompted to confirm whether or not to swap them for persona macros.
*
* 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.
* The function also saves the settings and refreshes the persona selector.
*
* @param {number} [characterId] - The ID of the character to convert to a persona. Defaults to the current character ID.
* @returns {Promise<boolean>} A promise that resolves to true if the character was converted, false otherwise.
*/
export async function convertCharacterToPersona(characterId = null) {
if (null === characterId) characterId = Number(this_chid);
const avatarUrl = characters[characterId]?.avatar;
if (!avatarUrl) {
console.log('No avatar found for this character');
return false;
}
const name = characters[characterId]?.name;
let description = characters[characterId]?.description;
const overwriteName = `${name} (Persona).png`;
if (overwriteName in power_user.personas) {
const confirm = await Popup.show.confirm(t`Overwrite Existing Persona`, t`This character exists as a persona already. Do you want to overwrite it?`);
if (!confirm) {
console.log('User cancelled the overwrite of the persona');
return false;
}
}
if (description.includes('{{char}}') || description.includes('{{user}}')) {
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?`);
if (confirm) {
description = description.replace(/{{char}}/gi, '{{personaChar}}').replace(/{{user}}/gi, '{{personaUser}}');
description = description.replace(/{{personaUser}}/gi, '{{char}}').replace(/{{personaChar}}/gi, '{{user}}');
}
}
const thumbnailAvatar = getThumbnailUrl('avatar', avatarUrl);
await uploadUserAvatar(thumbnailAvatar, overwriteName);
power_user.personas[overwriteName] = name;
power_user.persona_descriptions[overwriteName] = {
description: description,
position: persona_description_positions.IN_PROMPT,
depth: DEFAULT_DEPTH,
role: DEFAULT_ROLE,
lorebook: '',
title: '',
};
// If the user is currently using this persona, update the description
if (user_avatar === overwriteName) {
power_user.persona_description = description;
}
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_CREATED, { avatarId: overwriteName, name, description, title: '' });
console.log('Persona for character created');
toastr.success(t`You can now pick ${name} as a persona in the Persona Management menu.`, t`Persona Created`);
// Refresh the persona selector
await getUserAvatars(true, overwriteName);
// Reload the persona description
setPersonaDescription();
return true;
}
/**
* Counts the number of tokens in a persona description.
*/
const countPersonaDescriptionTokens = debounce(async () => {
const description = String($('#persona_description').val());
const count = await getTokenCountAsync(description);
$('#persona_description_token_count').text(String(count));
}, debounce_timeout.relaxed);
/**
* Updates the UI for the Persona Management page with the current persona values
*/
export function setPersonaDescription() {
$('#your_name').text(name1);
if (power_user.persona_description_position === persona_description_positions.AFTER_CHAR) {
power_user.persona_description_position = persona_description_positions.IN_PROMPT;
}
$('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
$('#persona_description').val(power_user.persona_description);
$('#persona_depth_value').val(power_user.persona_description_depth ?? DEFAULT_DEPTH);
$('#persona_description_position')
.val(power_user.persona_description_position)
.find(`option[value="${power_user.persona_description_position}"]`)
.attr('selected', String(true));
$('#persona_depth_role')
.val(power_user.persona_description_role)
.find(`option[value="${power_user.persona_description_role}"]`)
.prop('selected', String(true));
$('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);
countPersonaDescriptionTokens();
updatePersonaUIStates();
updatePersonaConnectionsAvatarList();
}
/**
* Gets a list of all personas in the current chat.
*
* @returns {string[]} An array of persona identifiers
*/
function getPersonasOfCurrentChat() {
const personas = chat.filter(message => String(message.force_avatar).startsWith(USER_AVATAR_PATH))
.map(message => message.force_avatar.replace(USER_AVATAR_PATH, ''))
.filter(onlyUnique);
return personas;
}
/**
* Builds a list of persona avatars and populates the given block element with them.
*
* @param {HTMLElement} block - The HTML element where the avatar list will be rendered
* @param {string[]} personas - An array of persona identifiers
* @param {Object} [options] - Optional settings for building the avatar list
* @param {boolean} [options.empty=true] - Whether to clear the block element before adding avatars
* @param {boolean} [options.interactable=false] - Whether the avatars should be interactable
* @param {boolean} [options.highlightFavs=true] - Whether to highlight favorite avatars
*/
export function buildPersonaAvatarList(block, personas, { empty = true, interactable = false, highlightFavs = true } = {}) {
const personaEntities = personas.map(avatar => ({
type: 'persona',
id: avatar,
item: {
name: power_user.personas[avatar],
description: power_user.persona_descriptions[avatar]?.description || '',
avatar: avatar,
fav: power_user.default_persona === avatar,
},
}));
buildAvatarList($(block), personaEntities, { empty: empty, interactable: interactable, highlightFavs: highlightFavs });
}
/**
* Displays avatar connections for the current persona.
* Converts connections to entities and populates the avatar list. Shows a message if no connections are found.
*/
export function updatePersonaConnectionsAvatarList() {
/** @type {PersonaConnection[]} */
const connections = power_user.persona_descriptions[user_avatar]?.connections ?? [];
const entities = connections.map(connection => {
if (connection.type === 'character') {
const character = characters.find(c => c.avatar === connection.id);
if (character) return characterToEntity(character, getCharIndex(character));
}
if (connection.type === 'group') {
const group = groups.find(g => g.id === connection.id);
if (group) return groupToEntity(group);
}
return undefined;
}).filter(entity => entity?.item !== undefined);
if (entities.length)
buildAvatarList($('#persona_connections_list'), entities, { interactable: true });
else
$('#persona_connections_list').text(t`[No character connections. Click one of the buttons above to connect this persona.]`);
}
/**
* Displays a popup for persona selection and returns the selected persona.
*
* @param {string} title - The title to display in the popup
* @param {string} text - The text to display in the popup
* @param {string[]} personas - An array of persona ids to display for selection
* @param {Object} [options] - Optional settings for the popup
* @param {string} [options.okButton='None'] - The label for the OK button
* @param {(element: HTMLElement, ev: MouseEvent) => any} [options.shiftClickHandler] - A function to handle shift-click
* @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
* @param {PersonaConnection} [options.targetedChar] - The targeted character or gorup for this persona selection
* @returns {Promise<string?>} - A promise that resolves to the selected persona id or null if no selection was made
*/
export async function askForPersonaSelection(title, text, personas, { okButton = 'None', shiftClickHandler = undefined, highlightPersonas = false, targetedChar = undefined } = {}) {
const content = document.createElement('div');
const titleElement = document.createElement('h3');
titleElement.textContent = title;
content.appendChild(titleElement);
const textElement = document.createElement('div');
textElement.classList.add('multiline', 'm-b-1');
textElement.textContent = text;
content.appendChild(textElement);
const personaListBlock = document.createElement('div');
personaListBlock.classList.add('persona-list', 'avatars_inline', 'avatars_multiline', 'text_muted');
content.appendChild(personaListBlock);
if (personas.length > 0)
buildPersonaAvatarList(personaListBlock, personas, { interactable: true });
else
personaListBlock.textContent = t`[Currently no personas connected]`;
const personasToHighlight = highlightPersonas instanceof Array ? highlightPersonas : (highlightPersonas ? getPersonasOfCurrentChat() : []);
// Make the persona blocks clickable and close the popup
personaListBlock.querySelectorAll('.avatar[data-type="persona"]').forEach(block => {
if (!(block instanceof HTMLElement)) return;
block.dataset.result = String(100 + personas.indexOf(block.dataset.pid));
if (shiftClickHandler) {
block.addEventListener('click', function (ev) {
if (ev.shiftKey) {
shiftClickHandler(this, ev);
}
});
}
if (personasToHighlight && personasToHighlight.includes(block.dataset.pid)) {
block.classList.add('is_active');
block.title = block.title + '\n\n' + t`Was used in current chat.`;
if (block.classList.contains('is_fav')) block.title = block.title + '\n' + t`Is your default persona.`;
}
});
/** @type {import('./popup.js').CustomPopupButton[]} */
const customButtons = [];
if (targetedChar) {
customButtons.push({
text: t`Remove All Connections`,
result: 2,
action: () => {
for (const [personaId, description] of Object.entries(power_user.persona_descriptions)) {
/** @type {PersonaConnection[]} */
const connections = description.connections;
if (connections) {
power_user.persona_descriptions[personaId].connections = connections.filter(c => {
if (targetedChar.type == c.type && targetedChar.id == c.id) return false;
return true;
});
}
}
saveSettingsDebounced();
updatePersonaConnectionsAvatarList();
if (power_user.persona_show_notifications) {
const name = targetedChar.type == 'character' ? characters[targetedChar.id]?.name : groups[targetedChar.id]?.name;
toastr.info(t`All connections to ${name} have been removed.`, t`Personas Unlocked`);
}
},
});
}
const popup = new Popup(content, POPUP_TYPE.TEXT, '', { okButton: okButton, customButtons: customButtons });
const result = await popup.show();
return Number(result) >= 100 ? personas[Number(result) - 100] : null;
}
/**
* Automatically selects a persona based on the given name if a matching persona exists.
* @param {string} name - The name to search for
* @param {Object} [options={}]
* @param {string} [options.personaKey=null] - Optionally a persona avatar key to target (if multiple persona have the same name); must match the name
* @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise
*/
export async function autoSelectPersona(name, { personaKey = null } = {}) {
const persona = findPersona({ name: personaKey ?? name, allowAvatar: !!personaKey });
if (persona) {
console.log(`Auto-selecting persona ${persona.avatar} for name ${name}`);
await setUserAvatar(persona.avatar);
return true;
}
return false;
}
/**
* Edits the title of a persona based on the input from a popup.
* @param {Popup} popup Popup instance
* @param {string} avatarId Avatar ID of the persona to edit
* @param {string} currentTitle Current title of the persona
*/
async function editPersonaTitle(popup, avatarId, currentTitle) {
if (popup.result !== POPUP_RESULT.AFFIRMATIVE) {
return;
}
if (!power_user.persona_descriptions[avatarId]) {
console.warn('Uninitialized persona descriptor for avatar:', avatarId);
return;
}
const newTitle = String(popup.inputResults.get('persona_title') || '').trim();
if (!newTitle && currentTitle) {
console.log(`Removed persona title for ${avatarId}`);
delete power_user.persona_descriptions[avatarId].title;
await getUserAvatars(true, avatarId);
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_UPDATED, avatarId);
return;
}
if (newTitle !== currentTitle) {
power_user.persona_descriptions[avatarId].title = newTitle;
console.log(`Updated persona title for ${avatarId} to ${newTitle}`);
await getUserAvatars(true, avatarId);
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_UPDATED, avatarId);
return;
}
}
/**
* Renames the persona with the given avatar ID by showing a popup to enter a new name.
* @param {string} avatarId - ID of the avatar to rename
* @returns {Promise<boolean>} A promise that resolves to true if the persona was renamed, false otherwise
*/
async function renamePersona(avatarId) {
const currentName = power_user.personas[avatarId];
const currentTitle = power_user.persona_descriptions[avatarId]?.title || '';
const newName = await Popup.show.input(t`Rename Persona`, t`Enter a new name for this persona:`, currentName, {
customInputs: [{
id: 'persona_title',
type: 'text',
label: t`Persona Title (optional, display only)`,
defaultState: currentTitle,
}],
onClose: (popup) => editPersonaTitle(popup, avatarId, currentTitle),
});
if (!newName || newName === currentName) {
console.debug('User cancelled renaming persona or name is unchanged');
return false;
}
power_user.personas[avatarId] = newName;
console.log(`Renamed persona ${avatarId} to ${newName}`);
if (avatarId === user_avatar) {
setUserName(newName);
}
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_RENAMED, { avatarId, oldName: currentName, newName });
await getUserAvatars(true, avatarId);
updatePersonaUIStates();
setPersonaDescription();
return true;
}
/**
* 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.
* @param {object} [options={}] - Optional settings
* @param {boolean} [options.toastPersonaNameChange=true] - Whether to show a toast when the persona name is changed
* @returns {Promise<void>}
*/
async function selectCurrentPersona({ toastPersonaNameChange = true } = {}) {
const personaName = power_user.personas[user_avatar];
if (personaName) {
const shouldAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata.persona;
if (personaName !== name1) {
console.log(`Auto-updating user name to ${personaName}`);
setUserName(personaName, { toastPersonaNameChange: !shouldAutoLock && toastPersonaNameChange });
}
const descriptor = power_user.persona_descriptions[user_avatar];
if (descriptor) {
power_user.persona_description = descriptor.description ?? '';
power_user.persona_description_position = descriptor.position ?? persona_description_positions.IN_PROMPT;
power_user.persona_description_depth = descriptor.depth ?? DEFAULT_DEPTH;
power_user.persona_description_role = descriptor.role ?? DEFAULT_ROLE;
power_user.persona_description_lorebook = descriptor.lorebook ?? '';
} else {
power_user.persona_description = '';
power_user.persona_description_position = persona_description_positions.IN_PROMPT;
power_user.persona_description_depth = DEFAULT_DEPTH;
power_user.persona_description_role = DEFAULT_ROLE;
power_user.persona_description_lorebook = '';
power_user.persona_descriptions[user_avatar] = {
description: '',
position: persona_description_positions.IN_PROMPT,
depth: DEFAULT_DEPTH,
role: DEFAULT_ROLE,
lorebook: '',
connections: [],
title: '',
};
}
setPersonaDescription();
// Update the locked persona if setting is enabled
if (shouldAutoLock) {
chat_metadata.persona = user_avatar;
console.log(`Auto locked persona to ${user_avatar}`);
if (toastPersonaNameChange && power_user.persona_show_notifications) {
toastr.success(t`Persona ${personaName} selected and auto-locked to current chat`, t`Persona Selected`);
}
saveMetadataDebounced();
updatePersonaUIStates();
}
// As the last step, inform user if the persona is only temporarily chosen
if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
const temporary = getPersonaTemporaryLockInfo();
if (temporary.isTemporary) {
toastr.info(t`This persona is only temporarily chosen. Click for more info.`, t`Temporary Persona`, {
preventDuplicates: true,
onclick: () => {
toastr.info(escapeHtml(temporary.info).replaceAll('\n', '<br />'), t`Temporary Persona`, { escapeHtml: false });
},
});
}
}
}
}
/**
* Checks if a connection is locked for the current character or group edit menu
* @param {PersonaConnection} connection - Connection to check
* @returns {boolean} Whether the connection is locked
*/
export function isPersonaConnectionLocked(connection) {
return (!selected_group && connection.type === 'character' && connection.id === characters[this_chid]?.avatar)
|| (selected_group && connection.type === 'group' && connection.id === selected_group);
}
/**
* Checks if the persona is locked
* @param {PersonaLockType} type - Lock type
* @returns {boolean} Whether the persona is locked
*/
export function isPersonaLocked(type = 'chat') {
switch (type) {
case 'default':
return power_user.default_persona === user_avatar;
case 'chat':
return chat_metadata.persona == user_avatar;
case 'character': {
return !!power_user.persona_descriptions[user_avatar]?.connections?.some(isPersonaConnectionLocked);
}
default: throw new Error(`Unknown persona lock type: ${type}`);
}
}
/**
* Locks or unlocks the persona
* @param {boolean} state Desired lock state
* @param {PersonaLockType} type - Lock type
* @returns {Promise<void>}
*/
export async function setPersonaLockState(state, type = 'chat') {
return state ? await lockPersona(type) : await unlockPersona(type);
}
/**
* Toggle the persona lock state
* @param {PersonaLockType} type - Lock type
* @returns {Promise<boolean>} - Whether the persona was locked
*/
export async function togglePersonaLock(type = 'chat') {
if (isPersonaLocked(type)) {
await unlockPersona(type);
return false;
} else {
await lockPersona(type);
return true;
}
}
/**
* Unlock the persona
* @param {PersonaLockType} type - Lock type
* @returns {Promise<void>}
*/
async function unlockPersona(type = 'chat') {
switch (type) {
case 'default': {
// TODO: Make this toggle-able
await toggleDefaultPersona(user_avatar, { quiet: true });
break;
}
case 'chat': {
if (chat_metadata.persona) {
console.log(`Unlocking persona ${user_avatar} from this chat`);
delete chat_metadata.persona;
await saveMetadata();
if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
toastr.info(t`Persona ${name1} is now unlocked from this chat.`, t`Persona Unlocked`);
}
}
break;
}
case 'character': {
/** @type {PersonaConnection[]} */
const connections = power_user.persona_descriptions[user_avatar]?.connections;
if (connections) {
console.log(`Unlocking persona ${user_avatar} from this character ${name2}`);
power_user.persona_descriptions[user_avatar].connections = connections.filter(c => !isPersonaConnectionLocked(c));
saveSettingsDebounced();
updatePersonaConnectionsAvatarList();
if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
toastr.info(t`Persona ${name1} is now unlocked from character ${name2}.`, t`Persona Unlocked`);
}
}
break;
}
default:
throw new Error(`Unknown persona lock type: ${type}`);
}
updatePersonaUIStates();
}
/**
* Lock the persona
* @param {PersonaLockType} type - Lock type
*/
async function lockPersona(type = 'chat') {
// First make sure that user_avatar is actually a persona
if (!(user_avatar in power_user.personas)) {
console.log(`Creating a new persona ${user_avatar}`);
if (power_user.persona_show_notifications) {
toastr.info(t`Creating a new persona for currently selected user name and avatar...`, t`Persona Not Found`);
}
power_user.personas[user_avatar] = name1;
power_user.persona_descriptions[user_avatar] = {
description: '',
position: persona_description_positions.IN_PROMPT,
depth: DEFAULT_DEPTH,
role: DEFAULT_ROLE,
lorebook: '',
connections: [],
title: '',
};
await eventSource.emit(event_types.PERSONA_CREATED, { avatarId: user_avatar, name: name1, description: '', title: '' });
}
switch (type) {
case 'default': {
await toggleDefaultPersona(user_avatar, { quiet: true });
break;
}
case 'chat': {
console.log(`Locking persona ${user_avatar} to this chat`);
chat_metadata.persona = user_avatar;
saveMetadataDebounced();
if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
toastr.success(t`User persona ${name1} is locked to ${name2} in this chat`, t`Persona Locked`);
}
break;
}
case 'character': {
const newConnection = getCurrentConnectionObj();
/** @type {PersonaConnection[]} */
const connections = power_user.persona_descriptions[user_avatar].connections?.filter(c => !isPersonaConnectionLocked(c)) ?? [];
if (newConnection && newConnection.id) {
console.log(`Locking persona ${user_avatar} to this character ${name2}`);
power_user.persona_descriptions[user_avatar].connections = [...connections, newConnection];
const unlinkedCharacters = [];
if (!power_user.persona_allow_multi_connections) {
for (const [avatarId, description] of Object.entries(power_user.persona_descriptions)) {
if (avatarId === user_avatar) continue;
const filteredConnections = description.connections?.filter(c => !(c.type === newConnection.type && c.id === newConnection.id)) ?? [];
if (filteredConnections.length !== description.connections?.length) {
description.connections = filteredConnections;
unlinkedCharacters.push(power_user.personas[avatarId]);
}
}
}
saveSettingsDebounced();
updatePersonaConnectionsAvatarList();
if (power_user.persona_show_notifications) {
let additional = '';
if (unlinkedCharacters.length)
additional += `<br /><br />${t`Unlinked existing persona${unlinkedCharacters.length > 1 ? 's' : ''}: ${unlinkedCharacters.map(escapeHtml).join(', ')}`}`;
if (additional || !isPersonaPanelOpen()) {
toastr.success(t`User persona ${escapeHtml(name1)} is locked to character ${escapeHtml(name2)}${additional}`, t`Persona Locked`, { escapeHtml: false });
}
}
}
break;
}
default:
throw new Error(`Unknown persona lock type: ${type}`);
}
updatePersonaUIStates();
}
/**
* Click handler for the delete persona button. Delegates to deletePersona with the current user avatar.
*/
async function deleteUserAvatar() {
await deletePersona(user_avatar);
}
/**
* Deletes a persona by avatar id.
* @param {string} avatarId The persona's avatar id to delete
* @param {object} [options] Options
* @param {boolean} [options.silent=false] If true, skips the confirmation popup and suppresses toast notifications
* @returns {Promise<boolean>} True if the persona was deleted
*/
async function deletePersona(avatarId, { silent = false } = {}) {
if (!avatarId) {
console.warn('No avatar id found');
return false;
}
const name = power_user.personas[avatarId] || '';
if (!silent) {
const confirm = await Popup.show.confirm(
t`Delete Persona` + `: ${name}`,
t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`);
if (!confirm) {
console.debug('User cancelled deleting avatar');
return false;
}
}
const request = await fetch('/api/avatars/delete', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({
'avatar': avatarId,
}),
});
if (request.ok) {
console.log(`Deleted avatar ${avatarId}`);
delete power_user.personas[avatarId];
delete power_user.persona_descriptions[avatarId];
if (avatarId === power_user.default_persona) {
if (!silent) toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default Persona Deleted`);
power_user.default_persona = null;
}
if (avatarId === chat_metadata.persona) {
if (!silent) toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona Deleted`);
delete chat_metadata.persona;
await saveMetadata();
}
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_DELETED, { avatarId, name });
// Use the existing mechanism to re-render the persona list and choose the next persona here
personaLastLoadedChatId = uuidv4(); // Force reload by making a dummy chat id
await loadPersonaForCurrentChat({ doRender: true });
return true;
}
return false;
}
async function onPersonaDescriptionInput() {
power_user.persona_description = String($('#persona_description').val());
countPersonaDescriptionTokens();
if (power_user.personas[user_avatar]) {
let object = power_user.persona_descriptions[user_avatar];
if (!object) {
object = {
description: power_user.persona_description,
position: Number($('#persona_description_position').find(':selected').val()),
depth: Number($('#persona_depth_value').val()),
role: Number($('#persona_depth_role').find(':selected').val()),
lorebook: '',
title: '',
};
power_user.persona_descriptions[user_avatar] = object;
}
object.description = power_user.persona_description;
}
$(`.avatar-container[data-avatar-id="${user_avatar}"] .ch_description`)
.text(power_user.persona_description || $('#user_avatar_block').attr('no_desc_text'))
.toggleClass('text_muted', !power_user.persona_description);
saveSettingsDebounced();
if (power_user.personas[user_avatar]) {
await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
}
}
async function onPersonaDescriptionDepthValueInput() {
power_user.persona_description_depth = Number($('#persona_depth_value').val());
if (power_user.personas[user_avatar]) {
const object = getOrCreatePersonaDescriptor();
object.depth = power_user.persona_description_depth;
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
return;
}
saveSettingsDebounced();
}
async function onPersonaDescriptionDepthRoleInput() {
power_user.persona_description_role = Number($('#persona_depth_role').find(':selected').val());
if (power_user.personas[user_avatar]) {
const object = getOrCreatePersonaDescriptor();
object.role = power_user.persona_description_role;
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
return;
}
saveSettingsDebounced();
}
/**
* Opens a popup to set the lorebook for the current persona.
* @param {Pick<JQuery.ClickEvent, 'shiftKey' | 'altKey'>} event Click event
*/
async function onPersonaLoreButtonClick({ shiftKey, altKey }) {
const personaName = power_user.personas[user_avatar];
const selectedLorebook = power_user.persona_description_lorebook;
if (!personaName) {
toastr.warning(t`You must bind a name to this persona before you can set a lorebook.`, t`Persona Name Not Set`);
return;
}
if (selectedLorebook && !shiftKey && !altKey) {
openWorldInfoEditor(selectedLorebook);
return;
}
const template = $(await renderTemplateAsync('personaLorebook'));
const worldSelect = template.find('select');
template.find('.persona_name').text(personaName);
for (const worldName of world_names) {
const option = document.createElement('option');
option.value = worldName;
option.innerText = worldName;
option.selected = selectedLorebook === worldName;
worldSelect.append(option);
}
worldSelect.on('change', async function () {
power_user.persona_description_lorebook = String($(this).val());
if (power_user.personas[user_avatar]) {
const object = getOrCreatePersonaDescriptor();
object.lorebook = power_user.persona_description_lorebook;
}
$('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);
saveSettingsDebounced();
if (power_user.personas[user_avatar]) {
await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
}
});
await callGenericPopup(template, POPUP_TYPE.TEXT);
}
async function onPersonaDescriptionPositionInput() {
power_user.persona_description_position = Number(
$('#persona_description_position').find(':selected').val(),
);
if (power_user.personas[user_avatar]) {
const object = getOrCreatePersonaDescriptor();
object.position = power_user.persona_description_position;
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_UPDATED, user_avatar);
$('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
return;
}
saveSettingsDebounced();
$('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH);
}
export function getOrCreatePersonaDescriptor() {
let object = power_user.persona_descriptions[user_avatar];
if (!object) {
object = {
description: power_user.persona_description,
position: power_user.persona_description_position,
depth: power_user.persona_description_depth,
role: power_user.persona_description_role,
lorebook: power_user.persona_description_lorebook,
connections: [],
title: '',
};
power_user.persona_descriptions[user_avatar] = object;
}
return object;
}
/**
* Sets a persona as the default one to be used for all new chats and unlocked existing chats
* @param {string} avatarId The avatar id of the persona to set as the default
* @param {object} [options] Optional arguments
* @param {boolean} [options.quiet=false] If true, no confirmation popups will be shown
* @returns {Promise<void>}
*/
async function toggleDefaultPersona(avatarId, { quiet = false } = {}) {
if (!avatarId) {
console.warn('No avatar id found');
return;
}
const currentDefault = power_user.default_persona;
if (power_user.personas[avatarId] === undefined) {
console.warn(`No persona name found for avatar ${avatarId}`);
toastr.warning(t`You must bind a name to this persona before you can set it as the default.`, t`Persona Name Not Set`);
return;
}
if (avatarId === currentDefault) {
if (!quiet) {
const confirm = await Popup.show.confirm(t`Are you sure you want to remove the default persona?`, power_user.personas[avatarId]);
if (!confirm) {
console.debug('User cancelled removing default persona');
return;
}
}
console.log(`Removing default persona ${avatarId}`);
if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
toastr.info(t`This persona will no longer be used by default when you open a new chat.`, t`Default Persona Removed`);
}
delete power_user.default_persona;
} else {
if (!quiet) {
const confirm = await Popup.show.confirm(t`Set Default Persona`,
t`Are you sure you want to set \"${power_user.personas[avatarId]}\" as the default persona?`
+ '<br /><br />'
+ t`This name and avatar will be used for all new chats, as well as existing chats where the user persona is not locked.`);
if (!confirm) {
console.debug('User cancelled setting default persona');
return;
}
}
power_user.default_persona = avatarId;
if (power_user.persona_show_notifications && !isPersonaPanelOpen()) {
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`);
}
}
saveSettingsDebounced();
await getUserAvatars(true, avatarId);
updatePersonaUIStates();
}
/**
* Returns an object with 3 properties that describe the state of the given persona
*
* - default: Whether this persona is the default one for all new chats
* - locked: An object containing the lock states
* - chat: Whether the persona is locked to the currently open chat
* - character: Whether the persona is locked to the currently open character or group
* @param {string} avatarId - The avatar id of the persona to get the state for
* @returns {PersonaState} An object describing the state of the given persona
*/
function getPersonaStates(avatarId) {
const isDefaultPersona = power_user.default_persona === avatarId;
const hasChatLock = chat_metadata.persona == avatarId;
/** @type {PersonaConnection[]} */
const connections = power_user.persona_descriptions[avatarId]?.connections;
const hasCharLock = !!connections?.some(c =>
(!selected_group && c.type === 'character' && c.id === characters[Number(this_chid)]?.avatar)
|| (selected_group && c.type === 'group' && c.id === selected_group));
return {
avatarId: avatarId,
default: isDefaultPersona,
locked: {
chat: hasChatLock,
character: hasCharLock,
},
};
}
/**
* Updates the UI to reflect the current states of all personas and the selected user's persona.
* This includes updating class states on avatar containers to indicate default status, chat lock,
* and character lock, as well as updating icons and labels in the persona management panel to reflect
* the current state of the user's persona.
* Additionally, it manages the display of temporary persona lock information.
* @param {Object} [options={}] - Optional settings
* @param {boolean} [options.navigateToCurrent=false] - Whether to navigate to the current persona in the persona list
*/
function updatePersonaUIStates({ navigateToCurrent = false } = {}) {
if (navigateToCurrent) {
navigateToAvatar(user_avatar);
}
// Update the persona list
$('#user_avatar_block .avatar-container').each(function () {
const avatarId = $(this).attr('data-avatar-id');
const states = getPersonaStates(avatarId);
$(this).toggleClass('default_persona', states.default);
$(this).toggleClass('locked_to_chat', states.locked.chat);
$(this).toggleClass('locked_to_character', states.locked.character);
$(this).toggleClass('selected', avatarId === user_avatar);
});
// Buttons for the persona panel on the right
const personaStates = getPersonaStates(user_avatar);
$('#lock_persona_default').toggleClass('locked', personaStates.default);
$('#lock_user_name').toggleClass('locked', personaStates.locked.chat);
$('#lock_user_name i.icon').toggleClass('fa-lock', personaStates.locked.chat);
$('#lock_user_name i.icon').toggleClass('fa-unlock', !personaStates.locked.chat);
$('#lock_persona_to_char').toggleClass('locked', personaStates.locked.character);
$('#lock_persona_to_char i.icon').toggleClass('fa-lock', personaStates.locked.character);
$('#lock_persona_to_char i.icon').toggleClass('fa-unlock', !personaStates.locked.character);
// Persona panel info block
const { isTemporary, info } = getPersonaTemporaryLockInfo();
if (isTemporary) {
const messageContainer = document.createElement('div');
const messageSpan = document.createElement('span');
messageSpan.textContent = t`Temporary persona in use.`;
messageContainer.appendChild(messageSpan);
messageContainer.classList.add('flex-container', 'alignItemsBaseline');
const infoIcon = document.createElement('i');
infoIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
infoIcon.title = info;
messageContainer.appendChild(infoIcon);
// Set the info block content
setInfoBlock('#persona_connections_info_block', messageContainer, 'hint');
} else {
// Clear the info block if no condition applies
clearInfoBlock('#persona_connections_info_block');
}
}
/**
* @typedef {Object} PersonaLockInfo
* @property {boolean} isTemporary - Whether the selected persona is temporary based on current locks.
* @property {boolean} hasDifferentChatLock - True if the chat persona is set and differs from the user avatar.
* @property {boolean} hasDifferentDefaultLock - True if the default persona is set and differs from the user avatar.
* @property {string} info - Detailed information about the current, chat, and default personas.
*/
/**
* Computes temporary lock information for the current persona.
*
* This function checks whether the currently selected persona is temporary by comparing
* the chat persona and the default persona to the user avatar. If either is different,
* the currently selected persona is considered temporary and a detailed message is generated.
*
* @returns {PersonaLockInfo} An object containing flags and a message describing the persona lock status.
*/
function getPersonaTemporaryLockInfo() {
const hasDifferentChatLock = !!chat_metadata.persona && chat_metadata.persona !== user_avatar;
const hasDifferentDefaultLock = power_user.default_persona && power_user.default_persona !== user_avatar;
const isTemporary = hasDifferentChatLock || (!chat_metadata.persona && hasDifferentDefaultLock);
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.`
+ '\n\n'
+ t`Current Persona: ${power_user.personas[user_avatar]}`
+ (hasDifferentChatLock ? '\n' + t`Chat persona: ${power_user.personas[chat_metadata.persona]}` : '')
+ (hasDifferentDefaultLock ? '\n' + t`Default persona: ${power_user.personas[power_user.default_persona]}` : '') : '';
return {
isTemporary: isTemporary,
hasDifferentChatLock: hasDifferentChatLock,
hasDifferentDefaultLock: hasDifferentDefaultLock,
info: info,
};
}
/**
* Loads the appropriate persona for the current chat session based on locks (chat lock, char lock, default persona)
*
* @param {Object} [options={}] - Optional arguments
* @param {boolean} [options.doRender=false] - Whether to render the persona immediately
* @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether a persona was selected
*/
async function loadPersonaForCurrentChat({ doRender = false } = {}) {
const currentChatId = getCurrentChatId();
if (currentChatId === personaLastLoadedChatId) return;
personaLastLoadedChatId = currentChatId;
// Cache persona list to check if they exist
const userAvatars = await getUserAvatars(doRender);
// Check if the user avatar is set and exists in the list of user avatars
if (userAvatars.length && !userAvatars.includes(user_avatar)) {
console.log(`User avatar ${user_avatar} not found in user avatars list, pick the first available one`);
await setUserAvatar(userAvatars[0], { toastPersonaNameChange: false, navigateToCurrent: true });
}
// Define a persona for this chat
let chatPersona = '';
/** @type {'chat' | 'character' | 'default' | null} */
let connectType = null;
// If persona is locked in chat metadata, select it
if (chat_metadata.persona) {
console.log(`Using locked persona ${chat_metadata.persona}`);
chatPersona = chat_metadata.persona;
// Verify it exists
if (!userAvatars.includes(chatPersona)) {
console.warn('Chat-locked persona avatar not found, unlocking persona');
delete chat_metadata.persona;
saveSettingsDebounced();
chatPersona = '';
}
if (chatPersona) connectType = 'chat';
}
// If the persona panel is open when the chat changes, this is likely because a character was selected from that panel.
// In that case, we are not automatically switching persona - but need to make changes if there is any chat-bound connection
/*
if (isPersonaPanelOpen()) {
if (chatPersona) {
// If the chat-bound persona is the currently selected one, we can simply exit out
if (chatPersona === user_avatar) {
return false;
}
// Otherwise ask if we want to switch
const autoLock = power_user.persona_auto_lock;
const result = await Popup.show.confirm(t`Switch Persona?`,
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?`,
{ okButton: autoLock ? t`Keep and Lock` : t`Keep`, cancelButton: t`Switch` });
if (result === POPUP_RESULT.AFFIRMATIVE) {
if (autoLock) {
lockPersona('chat');
}
return false;
}
} else {
// If we don't have a chat-bound persona, we simply return and keep the current one we have
return false;
}
}
*/
// Check if we have any persona connected to the current character
if (!chatPersona) {
const connectedPersonas = getConnectedPersonas();
if (connectedPersonas.length > 0) {
if (connectedPersonas.length === 1) {
chatPersona = connectedPersonas[0];
} else if (!power_user.persona_allow_multi_connections) {
console.warn('More than one persona is connected to this character.Using the first available persona for this chat.');
chatPersona = connectedPersonas[0];
} else {
chatPersona = await askForPersonaSelection(t`Select Persona`,
t`Multiple personas are connected to this character.\nSelect a persona to use for this chat.`,
connectedPersonas, { highlightPersonas: true, targetedChar: getCurrentConnectionObj() });
}
}
if (chatPersona) connectType = 'character';
}
// Last check if default persona is set, select it
if (!chatPersona && power_user.default_persona) {
console.log(`Using default persona ${power_user.default_persona}`);
chatPersona = power_user.default_persona;
if (chatPersona) connectType = 'default';
}
// Whatever way we selected a persona, if it doesn't exist, unlock this chat
if (chat_metadata.persona && !userAvatars.includes(chat_metadata.persona)) {
console.warn('Persona avatar not found, unlocking persona');
delete chat_metadata.persona;
}
// Default persona missing
if (power_user.default_persona && !userAvatars.includes(power_user.default_persona)) {
console.warn('Default persona avatar not found, clearing default persona');
power_user.default_persona = null;
saveSettingsDebounced();
}
// Persona avatar found, select it
if (chatPersona && user_avatar !== chatPersona) {
const willAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata.persona;
await setUserAvatar(chatPersona, { toastPersonaNameChange: false, navigateToCurrent: true });
if (power_user.persona_show_notifications) {
let message = t`Auto-selected persona based on ${connectType} connection.<br />Your messages will now be sent as ${power_user.personas[chatPersona]}.`;
if (willAutoLock) {
message += '<br /><br />' + t`Auto-locked this persona to current chat.`;
}
toastr.success(message, t`Persona Auto Selected`, { escapeHtml: false });
}
} else if (chatPersona && power_user.persona_auto_lock && !chat_metadata.persona) {
// Even if it's the same persona, we still might need to auto-lock to chat if that's enabled
await lockPersona('chat');
}
updatePersonaUIStates();
return !!chatPersona;
}
/**
* Returns an array of persona keys that are connected to the given character key.
* If the character key is not provided, it defaults to the currently selected group or character.
* @param {string} [characterKey] - The character key to query
* @returns {string[]} - An array of persona keys that are connected to the given character key
*/
export function getConnectedPersonas(characterKey = undefined) {
characterKey ??= selected_group || characters[Number(this_chid)]?.avatar;
const connectedPersonas = Object.entries(power_user.persona_descriptions)
.filter(([_, { connections }]) => connections?.some(conn => conn.id === characterKey))
.map(([key, _]) => key);
return connectedPersonas;
}
/**
* Shows a popup with all personas connected to the currently selected character or group.
* In the popup, the user can select a persona to load for the current character or group, or shift-click to remove the connection.
* @return {Promise<void>}
*/
export async function showCharConnections() {
let isRemoving = false;
const connections = getConnectedPersonas();
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.`;
const selectedPersona = await askForPersonaSelection(t`Persona Connections`, message, connections, {
okButton: t`Ok`,
highlightPersonas: true,
targetedChar: getCurrentConnectionObj(),
shiftClickHandler: (element, ev) => {
const personaId = $(element).attr('data-pid');
/** @type {PersonaConnection[]} */
const connections = power_user.persona_descriptions[personaId]?.connections;
if (connections) {
console.log(`Unlocking persona ${personaId} from current character ${name2}`);
power_user.persona_descriptions[personaId].connections = connections.filter(c => {
if (menu_type == 'group_edit' && c.type == 'group' && c.id == selected_group) return false;
else if (c.type == 'character' && c.id == characters[Number(this_chid)]?.avatar) return false;
return true;
});
saveSettingsDebounced();
updatePersonaConnectionsAvatarList();
if (power_user.persona_show_notifications) {
toastr.info(t`User persona ${power_user.personas[personaId]} is now unlocked from the current character ${name2}.`, t`Persona unlocked`);
}
isRemoving = true;
$('#char_connections_button').trigger('click');
}
},
});
// One of the persona was selected. So load it.
if (!isRemoving && selectedPersona) {
await setUserAvatar(selectedPersona, { toastPersonaNameChange: false });
if (power_user.persona_show_notifications) {
toastr.success(t`Selected persona ${power_user.personas[selectedPersona]} for current chat.`, t`Connected Persona Selected`);
}
}
}
/**
* Retrieves the current connection object based on whether the current chat is with a char or a group.
*
* @returns {PersonaConnection} An object representing the current connection
*/
export function getCurrentConnectionObj() {
if (selected_group)
return { type: 'group', id: selected_group };
if (characters[Number(this_chid)]?.avatar)
return { type: 'character', id: characters[Number(this_chid)]?.avatar };
return null;
}
function onBackupPersonas() {
const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
const filename = `personas_${timestamp}.json`;
const data = JSON.stringify({
'personas': power_user.personas,
'persona_descriptions': power_user.persona_descriptions,
'default_persona': power_user.default_persona,
}, null, 2);
const blob = new Blob([data], { type: 'application/json' });
download(blob, filename, 'application/json');
}
async function onPersonasRestoreInput(e) {
const file = e.target.files[0];
if (!file) {
console.debug('No file selected');
return;
}
const data = await parseJsonFile(file);
if (!data) {
toastr.warning(t`Invalid file selected`, t`Persona Management`);
console.debug('Invalid file selected');
return;
}
if (!data.personas || !data.persona_descriptions || typeof data.personas !== 'object' || typeof data.persona_descriptions !== 'object') {
toastr.warning(t`Invalid file format`, t`Persona Management`);
console.debug('Invalid file selected');
return;
}
const avatarsList = await getUserAvatars(false);
const warnings = [];
// Merge personas with existing ones
for (const [key, value] of Object.entries(data.personas)) {
if (key in power_user.personas) {
warnings.push(`Persona "${key}" (${value}) already exists, skipping`);
continue;
}
power_user.personas[key] = value;
// If the avatar is missing, upload it
if (!avatarsList.includes(key)) {
warnings.push(`Persona image "${key}" (${value}) is missing, uploading default avatar`);
await uploadUserAvatar(default_user_avatar, key);
}
}
// Merge persona descriptions with existing ones
for (const [key, value] of Object.entries(data.persona_descriptions)) {
if (key in power_user.persona_descriptions) {
warnings.push(`Persona description for "${key}" (${power_user.personas[key]}) already exists, skipping`);
continue;
}
if (!power_user.personas[key]) {
warnings.push(`Persona for "${key}" does not exist, skipping`);
continue;
}
power_user.persona_descriptions[key] = value;
}
if (data.default_persona) {
if (data.default_persona in power_user.personas) {
power_user.default_persona = data.default_persona;
} else {
warnings.push(`Default persona "${data.default_persona}" does not exist, skipping`);
}
}
if (warnings.length) {
toastr.success(t`Personas restored with warnings. Check console for details.`, t`Persona Management`);
console.warn(`PERSONA RESTORE REPORT\n====================\n${warnings.join('\n')}`);
} else {
toastr.success(t`Personas restored successfully.`, t`Persona Management`);
}
await getUserAvatars();
setPersonaDescription();
saveSettingsDebounced();
$('#personas_restore_input').val('');
}
/**
* Synchronizes user-sent messages in the chat to the current persona.
* @param {object} [options={}] - Optional parameters
* @param {number} [options.start=0] - Start index of the message range (inclusive)
* @param {number} [options.end=chat.length - 1] - End index of the message range (inclusive)
* @param {boolean} [options.quiet=false] - If true, skips the confirmation popup
* @param {string} [options.nameFilter=''] - Filter messages by name (case-insensitive)
* @returns {Promise<void>}
*/
async function syncUserNameToPersona({ start = 0, end = chat.length - 1, quiet = false, nameFilter = '' } = {}) {
const isRangeAll = start === 0 && end === chat.length - 1;
const hasNameFilter = nameFilter?.trim();
const confirmMessage = isRangeAll && !hasNameFilter
? t`All user-sent messages in this chat will be attributed to ${name1}.`
: isRangeAll && hasNameFilter
? t`User-sent messages with name "${nameFilter}" will be attributed to ${name1}.`
: !isRangeAll && !hasNameFilter
? t`User-sent messages in the specified range will be attributed to ${name1}.`
: t`User-sent messages with name "${nameFilter}" in the specified range will be attributed to ${name1}.`;
if (!quiet) {
const confirmation = await Popup.show.confirm(t`Are you sure?`, confirmMessage);
if (!confirmation) {
return;
}
}
for (let i = start; i <= end; i++) {
const mes = chat[i];
if (mes?.is_user && (!hasNameFilter || equalsIgnoreCaseAndAccents(mes.name, nameFilter))) {
mes.name = name1;
mes.force_avatar = getThumbnailUrl('persona', user_avatar);
}
}
await saveChatConditional();
await reloadCurrentChat();
}
/**
* Retriggers the first message to reload it from the char definition.
*/
export async function retriggerFirstMessageOnEmptyChat() {
if (chat_metadata.tainted) {
return;
}
if (selected_group) {
await reloadCurrentChat();
}
if (!selected_group && Number(this_chid) >= 0 && chat.length === 1) {
await createOrEditCharacter();
}
}
/**
* Duplicates a persona.
* @param {string} avatarId Source persona avatar id
* @param {object} [options] Options
* @param {boolean} [options.silent=false] If true, skips the confirmation popup
* @param {boolean} [options.select=false] If true, selects/activates the duplicated persona
* @returns {Promise<string>} The avatar id of the new persona, or empty string on failure/cancellation
*/
async function duplicatePersona(avatarId, { silent = false, select = false } = {}) {
const personaName = power_user.personas[avatarId];
if (!personaName) {
toastr.warning(t`Chosen avatar is not a persona`, t`Persona Management`);
return '';
}
if (!silent) {
const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName);
if (!confirm) {
console.debug('User cancelled duplicating persona');
return '';
}
}
const newAvatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
const descriptor = power_user.persona_descriptions[avatarId];
power_user.personas[newAvatarId] = personaName;
power_user.persona_descriptions[newAvatarId] = {
description: descriptor?.description ?? '',
position: descriptor?.position ?? persona_description_positions.IN_PROMPT,
depth: descriptor?.depth ?? DEFAULT_DEPTH,
role: descriptor?.role ?? DEFAULT_ROLE,
lorebook: descriptor?.lorebook ?? '',
title: descriptor?.title ?? '',
};
await uploadUserAvatar(getUserAvatar(avatarId), newAvatarId);
const eventData = {
avatarId: newAvatarId,
name: personaName,
description: descriptor?.description ?? '',
title: descriptor?.title ?? '',
duplicatedFromAvatarId: avatarId,
};
await eventSource.emit(event_types.PERSONA_CREATED, eventData);
await getUserAvatars(true, newAvatarId);
saveSettingsDebounced();
if (select) {
await setUserAvatar(newAvatarId);
}
return newAvatarId;
}
/**
* If a current user avatar is not bound to persona, bind it.
*/
async function migrateNonPersonaUser() {
if (user_avatar in power_user.personas) {
return;
}
await initPersona(user_avatar, name1, '', '', { silent: true });
setPersonaDescription();
await getUserAvatars(true, user_avatar);
}
// #region Persona CRUD Slash Command Utilities
/**
* Mapping of human-readable position names to persona_description_positions enum values.
* @type {Record<string, number>}
*/
const POSITION_NAME_MAP = Object.freeze({
'inprompt': persona_description_positions.IN_PROMPT,
'topan': persona_description_positions.TOP_AN,
'bottoman': persona_description_positions.BOTTOM_AN,
'atdepth': persona_description_positions.AT_DEPTH,
'none': persona_description_positions.NONE,
});
/**
* Mapping of human-readable role names to numeric role values.
* @type {Record<string, number>}
*/
const ROLE_NAME_MAP = Object.freeze({
'system': 0,
'user': 1,
'assistant': 2,
});
/**
* Parses a persona description position from a string or number value.
* @param {string|number|undefined} value Position value (name or number)
* @returns {number|null} Parsed position value, or null if invalid/undefined
*/
function parsePersonaPosition(value) {
if (value === undefined || value === null) return null;
const strValue = String(value).toLowerCase();
if (strValue in POSITION_NAME_MAP) return POSITION_NAME_MAP[strValue];
const numValue = Number(value);
if (!isNaN(numValue) && Object.values(persona_description_positions).includes(numValue)) return numValue;
return null;
}
/**
* Parses a persona description role from a string or number value.
* @param {string|number|undefined} value Role value (name or number)
* @returns {number|null} Parsed role value, or null if invalid/undefined
*/
function parsePersonaRole(value) {
if (value === undefined || value === null) return null;
const strValue = String(value).toLowerCase();
if (strValue in ROLE_NAME_MAP) return ROLE_NAME_MAP[strValue];
const numValue = Number(value);
if (!isNaN(numValue) && numValue >= 0 && numValue <= 2) return numValue;
return null;
}
/**
* Uploads base64 avatar data to a persona, optionally showing a crop dialog.
* @param {string} avatarId The persona's avatar file name
* @param {string} base64Data Base64 data URL of the image
* @param {object} [options] Options
* @param {boolean} [options.resizePrompt=false] Whether to show the crop dialog
* @returns {Promise<boolean>} True if upload was successful
*/
async function uploadPersonaAvatar(avatarId, base64Data, { resizePrompt = false } = {}) {
if (!base64Data || !avatarId) return false;
let finalImageData = base64Data;
if (resizePrompt && !power_user.never_resize_avatars) {
const dlg = new Popup(t`Set the crop position of the avatar image`, POPUP_TYPE.CROP, '', { cropImage: base64Data });
const croppedImage = await dlg.show();
if (!croppedImage) return false;
finalImageData = String(croppedImage);
}
try {
const response = await fetch(finalImageData);
const blob = await response.blob();
const file = new File([blob], 'avatar.png', { type: 'image/png' });
const formData = new FormData();
formData.append('avatar', file);
formData.append('overwrite_name', avatarId);
const uploadResponse = await fetch('/api/avatars/upload', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),
cache: 'no-cache',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}
// Cache bust for the updated avatar
await fetch(getUserAvatar(avatarId), { cache: 'reload' });
await fetch(getThumbnailUrl('persona', avatarId), { cache: 'reload' });
reloadUserAvatar(true);
return true;
} catch (error) {
console.error('Error uploading persona avatar:', error);
toastr.warning(t`Failed to upload avatar: ${error.message}`);
return false;
}
}
/**
* Resolves a persona from the given argument or falls back to the currently active persona.
* @param {string} [personaArg] Persona name or avatar key argument
* @returns {import('./utils.js').PersonaViewModel|null} The resolved persona, or null if not found
*/
function getTargetPersona(personaArg) {
if (personaArg) {
const persona = findPersona({ name: personaArg });
if (!persona) {
toastr.warning(t`Persona "${personaArg}" not found`);
return null;
}
return persona;
}
// Fall back to currently active persona
const persona = findPersona({ preferCurrentPersona: true });
if (!persona) {
toastr.warning(t`No persona selected and no persona argument provided`);
return null;
}
return persona;
}
// #endregion
// #region Persona CRUD Slash Command Callbacks
/**
* Creates a new persona with the specified attributes.
* @param {object} args Named arguments from the slash command
* @returns {Promise<string>} Avatar key of the created persona, or empty string on failure
*/
async function createPersonaCallback(args) {
const name = args.name;
if (!name || typeof name !== 'string' || !name.trim()) {
toastr.warning(t`Persona name is required`);
return '';
}
const trimmedName = name.trim();
const avatarId = `${Date.now()}-${trimmedName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
const description = args.description ?? '';
const title = args.title ?? '';
const position = parsePersonaPosition(args.descriptionPosition) ?? persona_description_positions.IN_PROMPT;
const role = parsePersonaRole(args.descriptionRole) ?? DEFAULT_ROLE;
const lorebook = args.lorebook ?? '';
let depth = args.descriptionDepth !== undefined ? Number(args.descriptionDepth) : DEFAULT_DEPTH;
if (isNaN(depth)) {
toastr.warning(t`Invalid description depth "${args.descriptionDepth}", defaulting to ${DEFAULT_DEPTH}`);
depth = DEFAULT_DEPTH;
}
// Initialize persona data with all fields
await initPersona(avatarId, trimmedName, description, title, {
position, depth, role, lorebook,
});
// Handle avatar upload
const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null;
if (avatarData) {
const resizePrompt = !isFalseBoolean(args.avatarPromptResize ?? 'true');
const uploaded = await uploadPersonaAvatar(avatarId, avatarData, { resizePrompt });
if (!uploaded) {
// Crop was cancelled or upload failed — use default avatar
await uploadUserAvatar(default_user_avatar, avatarId);
}
} else {
await uploadUserAvatar(default_user_avatar, avatarId);
}
saveSettingsDebounced();
await getUserAvatars(true, avatarId);
// Select/activate if requested (default: true)
if (!isFalseBoolean(args.select ?? 'true')) {
await setUserAvatar(avatarId);
}
toastr.success(t`Persona "${trimmedName}" created successfully`);
return avatarId;
}
/**
* Updates an existing persona's attributes.
* @param {object} args Named arguments from the slash command
* @returns {Promise<string>} Avatar key of the updated persona, or empty string on failure
*/
async function updatePersonaCallback(args) {
const persona = getTargetPersona(args.persona);
if (!persona) return '';
const avatarId = persona.avatar;
const descriptor = power_user.persona_descriptions[avatarId];
if (!descriptor) {
toastr.warning(t`Persona data not found for "${persona.name}"`);
return '';
}
let hasUpdates = false;
// Update name
if (args.name !== undefined) {
const newName = String(args.name).trim();
if (newName) {
const oldName = power_user.personas[avatarId];
power_user.personas[avatarId] = newName;
if (avatarId === user_avatar) {
setUserName(newName);
}
await eventSource.emit(event_types.PERSONA_RENAMED, { avatarId, oldName, newName });
hasUpdates = true;
}
}
// Update description
if (args.description !== undefined) {
descriptor.description = args.description;
if (avatarId === user_avatar) {
power_user.persona_description = args.description;
}
hasUpdates = true;
}
// Update title
if (args.title !== undefined) {
descriptor.title = args.title;
hasUpdates = true;
}
// Update description position
if (args.descriptionPosition !== undefined) {
const position = parsePersonaPosition(args.descriptionPosition);
if (position !== null) {
descriptor.position = position;
if (avatarId === user_avatar) {
power_user.persona_description_position = position;
}
hasUpdates = true;
}
}
// Update description depth
if (args.descriptionDepth !== undefined) {
const depth = Number(args.descriptionDepth);
if (!isNaN(depth)) {
descriptor.depth = depth;
if (avatarId === user_avatar) {
power_user.persona_description_depth = depth;
}
hasUpdates = true;
}
}
// Update description role
if (args.descriptionRole !== undefined) {
const role = parsePersonaRole(args.descriptionRole);
if (role !== null) {
descriptor.role = role;
if (avatarId === user_avatar) {
power_user.persona_description_role = role;
}
hasUpdates = true;
}
}
// Update lorebook
if (args.lorebook !== undefined) {
descriptor.lorebook = args.lorebook;
if (avatarId === user_avatar) {
power_user.persona_description_lorebook = args.lorebook;
}
hasUpdates = true;
}
// Handle avatar
const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null;
if (avatarData) {
const resizePrompt = !isFalseBoolean(args.avatarPromptResize ?? 'true');
const uploaded = await uploadPersonaAvatar(avatarId, avatarData, { resizePrompt });
if (uploaded) {
hasUpdates = true;
}
}
if (!hasUpdates) {
toastr.info(t`No fields provided to update`);
return avatarId;
}
saveSettingsDebounced();
await eventSource.emit(event_types.PERSONA_UPDATED, avatarId);
// Refresh UI if the updated persona is the active one
if (avatarId === user_avatar) {
setPersonaDescription();
}
await getUserAvatars(true, avatarId);
updatePersonaUIStates();
toastr.success(t`Persona "${power_user.personas[avatarId]}" updated successfully`);
return avatarId;
}
/**
* Retrieves persona data or a specific field.
* @param {object} args Named arguments from the slash command
* @returns {Promise<string>} The persona data or field value
*/
async function getPersonaDataCallback(args) {
const persona = getTargetPersona(args.persona);
if (!persona) return '';
const avatarId = persona.avatar;
const descriptor = power_user.persona_descriptions[avatarId] ?? {};
if (args.field) {
/** @type {Record<string, unknown>} */
const fieldMap = {
name: power_user.personas[avatarId] ?? '',
description: descriptor.description ?? '',
title: descriptor.title ?? '',
position: descriptor.position ?? persona_description_positions.IN_PROMPT,
depth: descriptor.depth ?? DEFAULT_DEPTH,
role: descriptor.role ?? DEFAULT_ROLE,
lorebook: descriptor.lorebook ?? '',
avatar: avatarId,
default: power_user.default_persona === avatarId,
connections: descriptor.connections ?? [],
};
const value = fieldMap[args.field];
if (value === undefined) {
toastr.warning(t`Unknown persona field "${args.field}"`);
return '';
}
return await slashCommandReturnHelper.doReturn(
args.return ?? 'pipe', value,
{ objectToStringFunc: x => typeof x === 'object' ? JSON.stringify(x) : String(x) },
);
}
// Return full persona data
const personaData = {
avatar: avatarId,
name: power_user.personas[avatarId] ?? '',
description: descriptor.description ?? '',
title: descriptor.title ?? '',
position: descriptor.position ?? persona_description_positions.IN_PROMPT,
depth: descriptor.depth ?? DEFAULT_DEPTH,
role: descriptor.role ?? DEFAULT_ROLE,
lorebook: descriptor.lorebook ?? '',
default: power_user.default_persona === avatarId,
connections: descriptor.connections ?? [],
};
return await slashCommandReturnHelper.doReturn(
args.return ?? 'pipe', personaData,
{ objectToStringFunc: x => JSON.stringify(x, null, 2) },
);
}
/**
* Deletes a persona via slash command.
* @param {object} args Named arguments from the slash command
* @returns {Promise<string>} 'true' if deleted, 'false' otherwise
*/
async function deletePersonaCallback(args) {
const persona = getTargetPersona(args.persona);
if (!persona) return 'false';
const silent = isTrueBoolean(args.silent);
const success = await deletePersona(persona.avatar, { silent });
return String(success);
}
/**
* Duplicates a persona via slash command.
* @param {object} args Named arguments from the slash command
* @returns {Promise<string>} Avatar key of the duplicated persona, or empty string on failure
*/
async function duplicatePersonaCallback(args) {
const persona = getTargetPersona(args.persona);
if (!persona) return '';
const shouldSelect = isTrueBoolean(args.select);
const newAvatarId = await duplicatePersona(persona.avatar, { silent: true, select: shouldSelect });
if (!newAvatarId) {
toastr.error(t`Failed to duplicate persona`);
return '';
}
toastr.success(t`Persona "${power_user.personas[newAvatarId]}" duplicated successfully`);
return newAvatarId;
}
// #endregion
/**
* Locks or unlocks the persona of the current chat.
* @param {{type: string}} _args Named arguments
* @param {string} value The value to set the lock to
* @returns {Promise<string>} The value of the lock after setting
*/
async function lockPersonaCallback(_args, value) {
const type = /** @type {PersonaLockType} */ (_args.type ?? 'chat');
if (!['chat', 'character', 'default'].includes(type)) {
toastr.warning(t`Unknown lock type "${type}"`, t`Persona Management`);
return '';
}
if (!value) {
return String(isPersonaLocked(type));
}
if (['toggle', 't'].includes(value.trim().toLowerCase())) {
const result = await togglePersonaLock(type);
return String(result);
}
if (isTrueBoolean(value)) {
await setPersonaLockState(true, type);
return 'true';
}
if (isFalseBoolean(value)) {
await setPersonaLockState(false, type);
return 'false';
}
return '';
}
/**
* Sets a persona name and optionally an avatar.
* @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments
* @param {string} name Name to set
* @returns {Promise<string>}
*/
async function setNameCallback({ mode = 'all' }, name) {
if (!name) {
toastr.warning('You must specify a name to change to');
return '';
}
if (!['lookup', 'temp', 'all'].includes(mode)) {
toastr.warning('Mode must be one of "lookup", "temp" or "all"');
return '';
}
name = name.trim();
// If the name matches a persona avatar, or a name, auto-select it
if (['lookup', 'all'].includes(mode)) {
const persona = findPersona({ name });
if (persona) {
await autoSelectPersona(persona.name, { personaKey: persona.avatar });
return '';
} else if (mode === 'lookup') {
toastr.warning(`Persona ${name} not found`);
return '';
}
}
if (['temp', 'all'].includes(mode)) {
// Otherwise, set just the name
setUserName(name); //this prevented quickReply usage
}
return '';
}
async function syncCallback(args, value) {
const range = value ? stringToRange(value, 0, chat.length - 1) : null;
if (value && !range) {
console.warn(`WARN: Invalid range provided for /persona-sync command: ${value}`);
return '';
}
const quiet = !isFalseBoolean(args?.quiet);
const nameFilter = typeof args?.from === 'string' ? args.from.trim() : '';
const start = range ? range.start : 0;
const end = range ? range.end : chat.length - 1;
await syncUserNameToPersona({ start, end, quiet, nameFilter });
return '';
}
/**
* Returns all unique user message names in the current chat for enum autocomplete.
* @returns {SlashCommandEnumValue[]}
*/
function userMessageNamesEnumProvider() {
return chat
.filter(mes => mes.is_user)
.map(mes => mes.name)
.filter(onlyUnique)
.sort(sortIgnoreCaseAndAccents)
.map(name => new SlashCommandEnumValue(name, null, enumTypes.name, enumIcons.persona));
}
function registerPersonaSlashCommands() {
// Shared persona field definitions for persona CRUD commands
const getPersonaFieldArgs = ({ requiredFields = [] } = {}) => [
SlashCommandNamedArgument.fromProps({
name: 'name',
description: t`The name of the persona`,
typeList: [ARGUMENT_TYPE.STRING],
isRequired: requiredFields.includes('name'),
}),
SlashCommandNamedArgument.fromProps({
name: 'description',
description: t`The persona description (sent with messages for AI context)`,
typeList: [ARGUMENT_TYPE.STRING],
isRequired: requiredFields.includes('description'),
}),
SlashCommandNamedArgument.fromProps({
name: 'title',
description: t`A display title for the persona (not sent to the AI, display only)`,
typeList: [ARGUMENT_TYPE.STRING],
isRequired: requiredFields.includes('title'),
}),
SlashCommandNamedArgument.fromProps({
name: 'avatar',
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.`,
typeList: [ARGUMENT_TYPE.STRING],
isRequired: requiredFields.includes('avatar'),
enumList: [
new SlashCommandEnumValue('prompt', 'Open file picker to select an image', enumTypes.enum, '📁'),
new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'),
new SlashCommandEnumValue('backgrounds/...', 'Background image path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'),
new SlashCommandEnumValue('User Avatars/...', 'User avatar path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'),
new SlashCommandEnumValue('assets/...', 'Asset file path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'),
new SlashCommandEnumValue('user/images/...', 'User image path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'),
],
}),
SlashCommandNamedArgument.fromProps({
name: 'avatarPromptResize',
description: t`Whether to show the avatar resize/crop dialog when uploading. Ignored if "Never resize avatars" is enabled in settings.`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumProvider: commonEnumProviders.boolean('trueFalse'),
}),
SlashCommandNamedArgument.fromProps({
name: 'descriptionPosition',
description: t`Where to inject the persona description in the prompt`,
typeList: [ARGUMENT_TYPE.STRING],
enumList: [
new SlashCommandEnumValue('inPrompt', t`In Prompt (default)`, enumTypes.enum),
new SlashCommandEnumValue('topAN', t`Top of Author's Note`, enumTypes.enum),
new SlashCommandEnumValue('bottomAN', t`Bottom of Author's Note`, enumTypes.enum),
new SlashCommandEnumValue('atDepth', t`At a specific depth (uses descriptionDepth and descriptionRole)`, enumTypes.enum),
new SlashCommandEnumValue('none', t`None (don't inject)`, enumTypes.enum),
],
}),
SlashCommandNamedArgument.fromProps({
name: 'descriptionDepth',
description: t`Depth for the persona description (when position is "atDepth")`,
typeList: [ARGUMENT_TYPE.NUMBER],
}),
SlashCommandNamedArgument.fromProps({
name: 'descriptionRole',
description: t`Role for the persona description (when position is "atDepth")`,
typeList: [ARGUMENT_TYPE.STRING],
enumList: commonEnumProviders.messageRoles(),
}),
SlashCommandNamedArgument.fromProps({
name: 'lorebook',
description: t`The name of the lorebook/world info to attach to this persona`,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.worlds,
}),
];
// Shared persona target argument (for commands that operate on an existing persona)
const personaTargetArg = SlashCommandNamedArgument.fromProps({
name: 'persona',
description: t`Persona name or avatar key. If not provided, uses the currently active persona.`,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
});
// ========================
// New CRUD commands
// ========================
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'persona-create',
callback: createPersonaCallback,
returns: t`the avatar key (unique identifier) of the created persona`,
namedArgumentList: [
...getPersonaFieldArgs({ requiredFields: ['name'] }),
SlashCommandNamedArgument.fromProps({
name: 'select',
description: t`Whether to select/activate the persona after creation`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumProvider: commonEnumProviders.boolean('trueFalse'),
}),
],
helpString: `
<div>
${t`Creates a new persona with the specified attributes. Returns the avatar key of the created persona.`}
</div>
<div>
<strong>${t`Required arguments:`}</strong>
<ul>
<li><code>name</code> ${t`The persona's display name.`}</li>
</ul>
</div>
<div>
<strong>${t`Note on avatar:`}</strong>
${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.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li>
<pre><code>/persona-create name="Alice" description="A curious adventurer"</code></pre>
</li>
<li>
<pre><code>/persona-create name="Bob" avatar=prompt lorebook="detective_lore" select=false</code></pre>
</li>
<li>
<pre><code>/imagine portrait of an elf | /persona-create name="Elf" avatar="{{pipe}}"</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'persona-update',
callback: updatePersonaCallback,
returns: t`the avatar key of the updated persona`,
namedArgumentList: [
personaTargetArg,
...getPersonaFieldArgs(),
],
helpString: `
<div>
${t`Updates an existing persona's attributes. Only the provided fields are changed; others are left untouched.`}
</div>
<div>
${t`If no <code>persona</code> argument is provided, updates the currently active persona.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li>
<pre><code>/persona-update description="An updated description"</code></pre>
${t`Updates the current persona's description.`}
</li>
<li>
<pre><code>/persona-update persona="Alice" name="Alice 2.0" descriptionPosition=atDepth descriptionDepth=3</code></pre>
${t`Renames Alice and sets her description to inject at depth 3.`}
</li>
<li>
<pre><code>/imagine portrait | /persona-update avatar="{{pipe}}"</code></pre>
${t`Generates an image and sets it as the current persona's avatar.`}
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'persona-get',
aliases: ['persona-data'],
callback: getPersonaDataCallback,
returns: t`persona data as JSON or a specific field value`,
namedArgumentList: [
personaTargetArg,
SlashCommandNamedArgument.fromProps({
name: 'field',
description: t`Specific field to retrieve. If not provided, returns the entire persona data as JSON.`,
typeList: [ARGUMENT_TYPE.STRING],
enumList: [
new SlashCommandEnumValue('name', t`Persona name`, enumTypes.enum, enumIcons.persona),
new SlashCommandEnumValue('description', t`Persona description`, enumTypes.enum, enumIcons.default),
new SlashCommandEnumValue('title', t`Display title`, enumTypes.enum, enumIcons.default),
new SlashCommandEnumValue('position', t`Description position (numeric)`, enumTypes.enum, enumIcons.default),
new SlashCommandEnumValue('depth', t`Description depth`, enumTypes.enum, enumIcons.default),
new SlashCommandEnumValue('role', t`Description role (numeric)`, enumTypes.enum, enumIcons.default),
new SlashCommandEnumValue('lorebook', t`Attached lorebook name`, enumTypes.enum, enumIcons.world),
new SlashCommandEnumValue('avatar', t`Avatar filename (unique key)`, enumTypes.enum, enumIcons.persona),
new SlashCommandEnumValue('default', t`Whether this is the default persona`, enumTypes.enum, enumIcons.default),
new SlashCommandEnumValue('connections', t`Character/group connections (array)`, enumTypes.enum, enumIcons.character),
],
}),
SlashCommandNamedArgument.fromProps({
name: 'return',
description: t`The way to return the result`,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: 'pipe',
enumList: slashCommandReturnHelper.enumList({ allowPipe: true, allowObject: true, allowPopup: true, allowTextVersion: false }),
}),
],
helpString: `
<div>
${t`Retrieves persona data. Can return all data as JSON or a specific field value.`}
</div>
<div>
${t`If no <code>persona</code> argument is provided, uses the currently active persona.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li>
<pre><code>/persona-get field=description | /echo</code></pre>
${t`Outputs the current persona's description.`}
</li>
<li>
<pre><code>/persona-get persona="Alice" field=name</code></pre>
${t`Returns Alice's persona name.`}
</li>
<li>
<pre><code>/persona-get return=object | /json-get key=avatar</code></pre>
${t`Returns the current persona's full data as an object, then extracts the avatar key.`}
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'persona-delete',
callback: deletePersonaCallback,
returns: t`true if the persona was deleted, false otherwise`,
namedArgumentList: [
personaTargetArg,
SlashCommandNamedArgument.fromProps({
name: 'silent',
description: t`Skip the confirmation popup`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'false',
enumProvider: commonEnumProviders.boolean('trueFalse'),
}),
],
helpString: `
<div>
${t`Deletes a persona and its avatar from the system.`}
</div>
<div>
${t`If no <code>persona</code> argument is provided, deletes the currently active persona.`}
</div>
<div>
<strong>⚠️ ${t`Warning:`}</strong> ${t`This action is irreversible. All data associated with the persona will be lost.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li>
<pre><code>/persona-delete</code></pre>
${t`Deletes the current persona (shows confirmation popup).`}
</li>
<li>
<pre><code>/persona-delete persona="Bob" silent=true</code></pre>
${t`Deletes Bob without confirmation.`}
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'persona-duplicate',
callback: duplicatePersonaCallback,
returns: t`the avatar key (unique identifier) of the duplicated persona`,
namedArgumentList: [
personaTargetArg,
SlashCommandNamedArgument.fromProps({
name: 'select',
description: t`Whether to select/activate the duplicated persona after creation`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'false',
enumProvider: commonEnumProviders.boolean('trueFalse'),
}),
],
helpString: `
<div>
${t`Duplicates a persona including all its data and avatar. Returns the avatar key of the new persona.`}
</div>
<div>
${t`Use <code>/persona-update</code> afterwards to rename or modify the duplicated persona's fields.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li>
<pre><code>/persona-duplicate</code></pre>
${t`Duplicates the currently active persona.`}
</li>
<li>
<pre><code>/persona-duplicate persona="Alice" select=true</code></pre>
${t`Duplicates Alice and selects the new persona.`}
</li>
<li>
<pre><code>/persona-duplicate | /persona-update persona="{{pipe}}" name="Clone"</code></pre>
${t`Duplicates the current persona, then renames the clone.`}
</li>
</ul>
</div>
`,
}));
// ========================
// Existing commands (enhanced help strings)
// ========================
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'persona-lock',
aliases: ['lock', 'bind'],
callback: lockPersonaCallback,
returns: t`The current lock state for the given type`,
helpString: `
<div>
${t`Locks/unlocks the current persona to a chat, character, or as the default. Returns the lock state if no value is provided.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li><pre><code>/persona-lock on</code></pre> ${t`Locks persona to this chat.`}</li>
<li><pre><code>/persona-lock type=character on</code></pre> ${t`Locks persona to the current character.`}</li>
<li><pre><code>/persona-lock type=default on</code></pre> ${t`Sets persona as the default for new chats.`}</li>
<li><pre><code>/persona-lock</code></pre> ${t`Returns whether the persona is locked to this chat.`}</li>
</ul>
</div>
`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'type',
description: t`The type of the lock, where it should apply to`,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: 'chat',
enumList: [
new SlashCommandEnumValue('chat', t`Lock the persona to the current chat.`),
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.`),
new SlashCommandEnumValue('default', t`Lock this persona as the default persona for all new chats.`),
],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'state',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.boolean('onOffToggle'),
}),
],
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'persona-set',
callback: setNameCallback,
aliases: ['persona', 'name'],
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'mode',
description: t`The mode for persona selection`,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: 'all',
enumList: [
new SlashCommandEnumValue('lookup', t`Search for an existing persona only`),
new SlashCommandEnumValue('temp', t`Set a temporary name only (no persona lookup)`),
new SlashCommandEnumValue('all', t`Try persona lookup first, fall back to temporary name`),
],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'persona name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
}),
],
helpString: `
<div>
${t`Selects an existing persona by name or avatar key, or sets a temporary user name.`}
</div>
<div>
${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.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li><pre><code>/persona-set Alice</code></pre> ${t`Selects persona "Alice", or sets name to "Alice" if not found.`}</li>
<li><pre><code>/persona-set mode=lookup Alice</code></pre> ${t`Only selects if persona "Alice" exists.`}</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'persona-sync',
aliases: ['sync'],
callback: syncCallback,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'from',
description: t`only sync messages from a certain persona name`,
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: userMessageNamesEnumProvider,
}),
SlashCommandNamedArgument.fromProps({
name: 'quiet',
description: t`suppress the confirmation popup`,
typeList: [ARGUMENT_TYPE.BOOLEAN],
enumList: commonEnumProviders.boolean('trueFalse')(),
defaultValue: 'true',
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: t`message index (starts with 0) or range, syncs all user messages if not provided`,
typeList: [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.RANGE],
defaultValue: '0-{{lastMessageId}}',
}),
],
helpString: `
<div>
${t`Syncs the user persona (name and avatar) in user-attributed messages in the current chat.`}
</div>
<div>
${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.`}
</div>
<div>
${t`If <code>quiet</code> is set to <code>false</code>, a confirmation popup will be shown before syncing.`}
</div>
<div>
<strong>${t`Examples:`}</strong>
<ul>
<li><pre><code>/persona-sync</code></pre> ${t`- Sync all user messages`}</li>
<li><pre><code>/persona-sync 5</code></pre> ${t`- Sync only message 5`}</li>
<li><pre><code>/persona-sync 0-10</code></pre> ${t`- Sync messages 0 through 10`}</li>
<li><pre><code>/persona-sync from=OldPersona 0-20</code></pre> ${t`- Sync only messages with name "OldPersona" in range 0-20`}</li>
<li><pre><code>/persona-sync quiet=false</code></pre> ${t`- Sync all with confirmation popup`}</li>
<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>
</ul>
</div>
`,
}));
}
/**
* Initializes the persona management and all its functionality.
* This is called during the initialization of the page.
*/
export async function initPersonas() {
await migrateNonPersonaUser();
registerPersonaSlashCommands();
$('#persona_delete_button').on('click', deleteUserAvatar);
$('#lock_persona_default').on('click', () => togglePersonaLock('default'));
$('#lock_user_name').on('click', () => togglePersonaLock('chat'));
$('#lock_persona_to_char').on('click', () => togglePersonaLock('character'));
$('#create_dummy_persona').on('click', createDummyPersona);
$('#persona_description').on('input', onPersonaDescriptionInput);
$('#persona_description_position').on('input', onPersonaDescriptionPositionInput);
$('#persona_depth_value').on('input', onPersonaDescriptionDepthValueInput);
$('#persona_depth_role').on('input', onPersonaDescriptionDepthRoleInput);
$('#persona_lore_button').on('click', onPersonaLoreButtonClick);
addLongPressEvent('#persona_lore_button', function () {
onPersonaLoreButtonClick({ shiftKey: true, altKey: false });
});
$('#persona-management-dropdown').on('change', async function () {
const target = $(this).find(':selected').attr('id');
$(this).prop('selectedIndex', 0);
switch (target) {
case 'persona_lorebook_link':
await onPersonaLoreButtonClick({ shiftKey: true, altKey: false });
break;
}
});
$('#personas_backup').on('click', onBackupPersonas);
$('#personas_restore').on('click', () => $('#personas_restore_input').trigger('click'));
$('#personas_restore_input').on('change', onPersonasRestoreInput);
$('#persona_sort_order').val(power_user.persona_sort_order).on('input', function () {
const value = String($(this).val());
// Save sort order, but do not save search sorting, as this is a temporary sorting option
if (value !== 'search') power_user.persona_sort_order = value;
getUserAvatars(true, user_avatar);
saveSettingsDebounced();
});
$('#persona_grid_toggle').on('click', () => {
const state = accountStorage.getItem(GRID_STORAGE_KEY) === 'true';
accountStorage.setItem(GRID_STORAGE_KEY, String(!state));
switchPersonaGridView();
});
const debouncedPersonaSearch = debounce((searchQuery) => {
personasFilter.setFilterData(FILTER_TYPES.PERSONA_SEARCH, searchQuery);
});
$('#persona_search_bar').on('input', function () {
const searchQuery = String($(this).val());
debouncedPersonaSearch(searchQuery);
});
$('#sync_name_button').on('click', async () => await syncUserNameToPersona());
$('#avatar_upload_file').on('change', changeUserAvatar);
$(document).on('click', '#user_avatar_block .avatar-container', async function () {
const imgfile = $(this).attr('data-avatar-id');
await setUserAvatar(imgfile);
});
$('#persona_rename_button').on('click', () => renamePersona(user_avatar));
$(document).on('click', '#user_avatar_block .avatar_upload', function () {
$('#avatar_upload_overwrite').val('');
$('#avatar_upload_file').trigger('click');
});
$('#persona_duplicate_button').on('click', () => duplicatePersona(user_avatar));
$('#persona_set_image_button').on('click', function () {
if (!user_avatar) {
console.log('no imgfile');
return;
}
$('#avatar_upload_overwrite').val(user_avatar);
$('#avatar_upload_file').trigger('click');
});
$('#char_connections_button').on('click', showCharConnections);
eventSource.on(event_types.CHARACTER_MANAGEMENT_DROPDOWN, (target) => {
if (target === 'convert_to_persona') {
convertCharacterToPersona();
}
});
eventSource.on(event_types.CHAT_CHANGED, updatePersonaUIStates);
eventSource.on(event_types.CHAT_CHANGED, loadPersonaForCurrentChat);
switchPersonaGridView();
}