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} 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} */ 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} 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} */ 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} 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 {{char}} or {{user}} 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} - 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} 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} 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} */ 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', '
'), 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} */ 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} - 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} */ 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 += `

${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} 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?` + '
' + 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} 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} */ 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?` + '

' + 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} - 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.
Your messages will now be sent as ${power_user.personas[chatPersona]}.`; if (willAutoLock) { message += '

' + 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} */ 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} */ 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} 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} */ 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} */ 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} 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} 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} 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} 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} */ 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} '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} 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} 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} */ 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: `
${t`Creates a new persona with the specified attributes. Returns the avatar key of the created persona.`}
${t`Required arguments:`}
  • name – ${t`The persona's display name.`}
${t`Note on avatar:`} ${t`The avatar argument accepts prompt to open a file picker, a local ST file path, or a base64 data URL. Can also be the return value of /imagine. If not provided, a default avatar will be used.`}
${t`Example:`}
  • /persona-create name="Alice" description="A curious adventurer"
  • /persona-create name="Bob" avatar=prompt lorebook="detective_lore" select=false
  • /imagine portrait of an elf | /persona-create name="Elf" avatar="{{pipe}}"
`, })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'persona-update', callback: updatePersonaCallback, returns: t`the avatar key of the updated persona`, namedArgumentList: [ personaTargetArg, ...getPersonaFieldArgs(), ], helpString: `
${t`Updates an existing persona's attributes. Only the provided fields are changed; others are left untouched.`}
${t`If no persona argument is provided, updates the currently active persona.`}
${t`Example:`}
  • /persona-update description="An updated description"
    ${t`Updates the current persona's description.`}
  • /persona-update persona="Alice" name="Alice 2.0" descriptionPosition=atDepth descriptionDepth=3
    ${t`Renames Alice and sets her description to inject at depth 3.`}
  • /imagine portrait | /persona-update avatar="{{pipe}}"
    ${t`Generates an image and sets it as the current persona's avatar.`}
`, })); 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: `
${t`Retrieves persona data. Can return all data as JSON or a specific field value.`}
${t`If no persona argument is provided, uses the currently active persona.`}
${t`Example:`}
  • /persona-get field=description | /echo
    ${t`Outputs the current persona's description.`}
  • /persona-get persona="Alice" field=name
    ${t`Returns Alice's persona name.`}
  • /persona-get return=object | /json-get key=avatar
    ${t`Returns the current persona's full data as an object, then extracts the avatar key.`}
`, })); 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: `
${t`Deletes a persona and its avatar from the system.`}
${t`If no persona argument is provided, deletes the currently active persona.`}
⚠️ ${t`Warning:`} ${t`This action is irreversible. All data associated with the persona will be lost.`}
${t`Example:`}
  • /persona-delete
    ${t`Deletes the current persona (shows confirmation popup).`}
  • /persona-delete persona="Bob" silent=true
    ${t`Deletes Bob without confirmation.`}
`, })); 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: `
${t`Duplicates a persona including all its data and avatar. Returns the avatar key of the new persona.`}
${t`Use /persona-update afterwards to rename or modify the duplicated persona's fields.`}
${t`Example:`}
  • /persona-duplicate
    ${t`Duplicates the currently active persona.`}
  • /persona-duplicate persona="Alice" select=true
    ${t`Duplicates Alice and selects the new persona.`}
  • /persona-duplicate | /persona-update persona="{{pipe}}" name="Clone"
    ${t`Duplicates the current persona, then renames the clone.`}
`, })); // ======================== // 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: `
${t`Locks/unlocks the current persona to a chat, character, or as the default. Returns the lock state if no value is provided.`}
${t`Example:`}
  • /persona-lock on
    ${t`Locks persona to this chat.`}
  • /persona-lock type=character on
    ${t`Locks persona to the current character.`}
  • /persona-lock type=default on
    ${t`Sets persona as the default for new chats.`}
  • /persona-lock
    ${t`Returns whether the persona is locked to this chat.`}
`, 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: `
${t`Selects an existing persona by name or avatar key, or sets a temporary user name.`}
${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.`}
${t`Example:`}
  • /persona-set Alice
    ${t`Selects persona "Alice", or sets name to "Alice" if not found.`}
  • /persona-set mode=lookup Alice
    ${t`Only selects if persona "Alice" exists.`}
`, })); 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: `
${t`Syncs the user persona (name and avatar) in user-attributed messages in the current chat.`}
${t`If from is set, only messages with that specific persona name will be synced. Useful when multiple personas have been used in the same chat.`}
${t`If quiet is set to false, a confirmation popup will be shown before syncing.`}
${t`Examples:`}
  • /persona-sync
    ${t`- Sync all user messages`}
  • /persona-sync 5
    ${t`- Sync only message 5`}
  • /persona-sync 0-10
    ${t`- Sync messages 0 through 10`}
  • /persona-sync from=OldPersona 0-20
    ${t`- Sync only messages with name "OldPersona" in range 0-20`}
  • /persona-sync quiet=false
    ${t`- Sync all with confirmation popup`}
  • /persona-sync from=TempName quiet=false 5-15
    ${t`- Sync messages with name "TempName" in range 5-15 with confirmation`}
`, })); } /** * 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(); }