Spaces:
Paused
Paused
| import { | |
| buildAvatarList, | |
| characterToEntity, | |
| characters, | |
| chat, | |
| chat_metadata, | |
| default_user_avatar, | |
| eventSource, | |
| event_types, | |
| getRequestHeaders, | |
| getThumbnailUrl, | |
| groupToEntity, | |
| menu_type, | |
| name1, | |
| name2, | |
| reloadCurrentChat, | |
| saveChatConditional, | |
| saveMetadata, | |
| saveSettingsDebounced, | |
| setUserName, | |
| this_chid, | |
| } from '../script.js'; | |
| import { persona_description_positions, 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 } 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_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 { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js'; | |
| import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; | |
| import { SlashCommandParser } from './slash-commands/SlashCommandParser.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 | |
| */ | |
| 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 {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 function setUserAvatar(imgfile, { toastPersonaNameChange = true, navigateToCurrent = false } = {}) { | |
| user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('data-avatar-id'); | |
| reloadUserAvatar(); | |
| updatePersonaUIStates({ navigateToCurrent: navigateToCurrent }); | |
| selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange }); | |
| retriggerFirstMessageOnEmptyChat(); | |
| saveSettingsDebounced(); | |
| $('.zoomed_avatar[forchar]').remove(); | |
| } | |
| 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', getUserAvatar(user_avatar)); | |
| } | |
| }); | |
| } | |
| /** | |
| * Sort the given personas | |
| * @param {string[]} personas - The persona names to sort | |
| * @returns {string[]} The sorted persona names array, same reference as passed in | |
| */ | |
| function sortPersonas(personas) { | |
| const option = $('#persona_sort_order').find(':selected'); | |
| if (option.attr('value') === 'search') { | |
| personas.sort((a, b) => { | |
| const aScore = personasFilter.getScore(FILTER_TYPES.PERSONA_SEARCH, a); | |
| const bScore = personasFilter.getScore(FILTER_TYPES.PERSONA_SEARCH, b); | |
| return (aScore - bScore); | |
| }); | |
| } else { | |
| personas.sort((a, b) => { | |
| const aName = String(power_user.personas[a] || a); | |
| const bName = String(power_user.personas[b] || b); | |
| return power_user.persona_sort_order === 'asc' ? aName.localeCompare(bName) : bName.localeCompare(aName); | |
| }); | |
| } | |
| return personas; | |
| } | |
| /** Checks the state of the current search, and adds/removes the search sorting option accordingly */ | |
| function verifyPersonaSearchSortRule() { | |
| const searchTerm = personasFilter.getFilterData(FILTER_TYPES.PERSONA_SEARCH); | |
| const searchOption = $('#persona_sort_order option[value="search"]'); | |
| const selector = $('#persona_sort_order'); | |
| const isHidden = searchOption.attr('hidden') !== undefined; | |
| // If we have a search term, we are displaying the sorting option for it | |
| if (searchTerm && isHidden) { | |
| searchOption.removeAttr('hidden'); | |
| selector.val(searchOption.attr('value')); | |
| flashHighlight(selector); | |
| } | |
| // If search got cleared, we make sure to hide the option and go back to the one before | |
| if (!searchTerm) { | |
| searchOption.attr('hidden', ''); | |
| selector.val(power_user.persona_sort_order); | |
| } | |
| } | |
| /** | |
| * Gets a rendered avatar block. | |
| * @param {string} avatarId Avatar file name | |
| * @returns {JQuery<HTMLElement>} Avatar block | |
| */ | |
| function getUserAvatarBlock(avatarId) { | |
| const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; | |
| const template = $('#user_avatar_template .avatar-container').clone(); | |
| const personaName = power_user.personas[avatarId]; | |
| const personaDescription = power_user.persona_descriptions[avatarId]?.description; | |
| 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.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); | |
| let avatarUrl = getUserAvatar(avatarId); | |
| if (isFirefox) { | |
| avatarUrl += '?t=' + Date.now(); | |
| } | |
| 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 | |
| */ | |
| function addMissingPersonas(avatarsList) { | |
| for (const persona of avatarsList) { | |
| if (!power_user.personas[persona]) { | |
| initPersona(persona, '[Unnamed Persona]', ''); | |
| } | |
| } | |
| } | |
| /** | |
| * Gets a list of user avatars. | |
| * @param {boolean} doRender Whether to render the list | |
| * @param {string} openPageAt Item to be opened at | |
| * @returns {Promise<string[]>} List of avatar file names | |
| */ | |
| export async function getUserAvatars(doRender = true, openPageAt = '') { | |
| const response = await fetch('/api/avatars/get', { | |
| method: 'POST', | |
| headers: getRequestHeaders(), | |
| }); | |
| 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 | |
| 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 headers = getRequestHeaders(); | |
| delete headers['Content-Type']; | |
| await fetch('/api/avatars/upload', { | |
| method: 'POST', | |
| headers: headers, | |
| cache: 'no-cache', | |
| body: formData, | |
| }); | |
| await getUserAvatars(true, 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 headers = getRequestHeaders(); | |
| delete headers['Content-Type']; | |
| const response = await fetch(url, { | |
| method: 'POST', | |
| headers: headers, | |
| cache: 'no-cache', | |
| body: formData, | |
| }); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| // If the user uploaded a new avatar, we want to make sure it's not cached | |
| const name = formData.get('overwrite_name'); | |
| if (name) { | |
| await fetch(getUserAvatar(String(name)), { cache: 'no-cache' }); | |
| reloadUserAvatar(true); | |
| } | |
| if (!name && data.path) { | |
| await getUserAvatars(); | |
| await delay(500); | |
| await createPersona(data.path); | |
| } | |
| await getUserAvatars(true, name || data.path); | |
| } | |
| // 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 }); | |
| 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 personaName = await Popup.show.input(t`Enter a name for this persona:`, null); | |
| if (!personaName) { | |
| 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`; | |
| initPersona(avatarId, personaName, ''); | |
| 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 | |
| * @returns {void} | |
| */ | |
| export function initPersona(avatarId, personaName, personaDescription) { | |
| power_user.personas[avatarId] = personaName; | |
| power_user.persona_descriptions[avatarId] = { | |
| description: personaDescription || '', | |
| position: persona_description_positions.IN_PROMPT, | |
| depth: DEFAULT_DEPTH, | |
| role: DEFAULT_ROLE, | |
| lorebook: '', | |
| }; | |
| saveSettingsDebounced(); | |
| } | |
| /** | |
| * Converts a character given character (either by character id or the current character) to a persona. | |
| * | |
| * If a persona with the same name already exists, the user is prompted to confirm whether or not to overwrite it. | |
| * If the character description contains {{char}} or {{user}} macros, the user is prompted to confirm whether or not to swap them for persona macros. | |
| * | |
| * The function creates a new persona with the same name as the character, and sets the persona description to the character description with the macros swapped. | |
| * The function also saves the settings and refreshes the persona selector. | |
| * | |
| * @param {number} [characterId] - The ID of the character to convert to a persona. Defaults to the current character ID. | |
| * @returns {Promise<boolean>} A promise that resolves to true if the character was converted, false otherwise. | |
| */ | |
| export async function convertCharacterToPersona(characterId = null) { | |
| if (null === characterId) characterId = Number(this_chid); | |
| const avatarUrl = characters[characterId]?.avatar; | |
| if (!avatarUrl) { | |
| console.log('No avatar found for this character'); | |
| return false; | |
| } | |
| const name = characters[characterId]?.name; | |
| let description = characters[characterId]?.description; | |
| const overwriteName = `${name} (Persona).png`; | |
| if (overwriteName in power_user.personas) { | |
| const confirm = await Popup.show.confirm(t`Overwrite Existing Persona`, t`This character exists as a persona already. Do you want to overwrite it?`); | |
| if (!confirm) { | |
| console.log('User cancelled the overwrite of the persona'); | |
| return false; | |
| } | |
| } | |
| if (description.includes('{{char}}') || description.includes('{{user}}')) { | |
| const confirm = await Popup.show.confirm(t`Persona Description Macros`, t`This character has a description that uses <code>{{char}}</code> or <code>{{user}}</code> macros. Do you want to swap them in the persona description?`); | |
| if (confirm) { | |
| description = description.replace(/{{char}}/gi, '{{personaChar}}').replace(/{{user}}/gi, '{{personaUser}}'); | |
| description = description.replace(/{{personaUser}}/gi, '{{char}}').replace(/{{personaChar}}/gi, '{{user}}'); | |
| } | |
| } | |
| const thumbnailAvatar = getThumbnailUrl('avatar', avatarUrl); | |
| await uploadUserAvatar(thumbnailAvatar, overwriteName); | |
| power_user.personas[overwriteName] = name; | |
| power_user.persona_descriptions[overwriteName] = { | |
| description: description, | |
| position: persona_description_positions.IN_PROMPT, | |
| depth: DEFAULT_DEPTH, | |
| role: DEFAULT_ROLE, | |
| lorebook: '', | |
| }; | |
| // If the user is currently using this persona, update the description | |
| if (user_avatar === overwriteName) { | |
| power_user.persona_description = description; | |
| } | |
| saveSettingsDebounced(); | |
| console.log('Persona for character created'); | |
| toastr.success(t`You can now pick ${name} as a persona in the Persona Management menu.`, t`Persona Created`); | |
| // Refresh the persona selector | |
| await getUserAvatars(true, overwriteName); | |
| // Reload the persona description | |
| setPersonaDescription(); | |
| return true; | |
| } | |
| /** | |
| * Counts the number of tokens in a persona description. | |
| */ | |
| const countPersonaDescriptionTokens = debounce(async () => { | |
| const description = String($('#persona_description').val()); | |
| const count = await getTokenCountAsync(description); | |
| $('#persona_description_token_count').text(String(count)); | |
| }, debounce_timeout.relaxed); | |
| /** | |
| * Updates the UI for the Persona Management page with the current persona values | |
| */ | |
| export function setPersonaDescription() { | |
| $('#your_name').text(name1); | |
| if (power_user.persona_description_position === persona_description_positions.AFTER_CHAR) { | |
| power_user.persona_description_position = persona_description_positions.IN_PROMPT; | |
| } | |
| $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH); | |
| $('#persona_description').val(power_user.persona_description); | |
| $('#persona_depth_value').val(power_user.persona_description_depth ?? DEFAULT_DEPTH); | |
| $('#persona_description_position') | |
| .val(power_user.persona_description_position) | |
| .find(`option[value="${power_user.persona_description_position}"]`) | |
| .attr('selected', String(true)); | |
| $('#persona_depth_role') | |
| .val(power_user.persona_description_role) | |
| .find(`option[value="${power_user.persona_description_role}"]`) | |
| .prop('selected', String(true)); | |
| $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook); | |
| countPersonaDescriptionTokens(); | |
| updatePersonaUIStates(); | |
| updatePersonaConnectionsAvatarList(); | |
| } | |
| /** | |
| * Gets a list of all personas in the current chat. | |
| * | |
| * @returns {string[]} An array of persona identifiers | |
| */ | |
| function getPersonasOfCurrentChat() { | |
| const personas = chat.filter(message => String(message.force_avatar).startsWith(USER_AVATAR_PATH)) | |
| .map(message => message.force_avatar.replace(USER_AVATAR_PATH, '')) | |
| .filter(onlyUnique); | |
| return personas; | |
| } | |
| /** | |
| * Builds a list of persona avatars and populates the given block element with them. | |
| * | |
| * @param {HTMLElement} block - The HTML element where the avatar list will be rendered | |
| * @param {string[]} personas - An array of persona identifiers | |
| * @param {Object} [options] - Optional settings for building the avatar list | |
| * @param {boolean} [options.empty=true] - Whether to clear the block element before adding avatars | |
| * @param {boolean} [options.interactable=false] - Whether the avatars should be interactable | |
| * @param {boolean} [options.highlightFavs=true] - Whether to highlight favorite avatars | |
| */ | |
| export function buildPersonaAvatarList(block, personas, { empty = true, interactable = false, highlightFavs = true } = {}) { | |
| const personaEntities = personas.map(avatar => ({ | |
| type: 'persona', | |
| id: avatar, | |
| item: { | |
| name: power_user.personas[avatar], | |
| description: power_user.persona_descriptions[avatar]?.description || '', | |
| avatar: avatar, | |
| fav: power_user.default_persona === avatar, | |
| }, | |
| })); | |
| buildAvatarList($(block), personaEntities, { empty: empty, interactable: interactable, highlightFavs: highlightFavs }); | |
| } | |
| /** | |
| * Displays avatar connections for the current persona. | |
| * Converts connections to entities and populates the avatar list. Shows a message if no connections are found. | |
| */ | |
| export function updatePersonaConnectionsAvatarList() { | |
| /** @type {PersonaConnection[]} */ | |
| const connections = power_user.persona_descriptions[user_avatar]?.connections ?? []; | |
| const entities = connections.map(connection => { | |
| if (connection.type === 'character') { | |
| const character = characters.find(c => c.avatar === connection.id); | |
| if (character) return characterToEntity(character, getCharIndex(character)); | |
| } | |
| if (connection.type === 'group') { | |
| const group = groups.find(g => g.id === connection.id); | |
| if (group) return groupToEntity(group); | |
| } | |
| return undefined; | |
| }).filter(entity => entity?.item !== undefined); | |
| if (entities.length) | |
| buildAvatarList($('#persona_connections_list'), entities, { interactable: true }); | |
| else | |
| $('#persona_connections_list').text(t`[No character connections. Click one of the buttons above to connect this persona.]`); | |
| } | |
| /** | |
| * Displays a popup for persona selection and returns the selected persona. | |
| * | |
| * @param {string} title - The title to display in the popup | |
| * @param {string} text - The text to display in the popup | |
| * @param {string[]} personas - An array of persona ids to display for selection | |
| * @param {Object} [options] - Optional settings for the popup | |
| * @param {string} [options.okButton='None'] - The label for the OK button | |
| * @param {(element: HTMLElement, ev: MouseEvent) => any} [options.shiftClickHandler] - A function to handle shift-click | |
| * @param {boolean|string[]} [options.highlightPersonas=false] - Whether to highlight personas - either by providing a list of persona keys, or true to highlight all present in current chat | |
| * @param {PersonaConnection} [options.targetedChar] - The targeted character or gorup for this persona selection | |
| * @returns {Promise<string?>} - A promise that resolves to the selected persona id or null if no selection was made | |
| */ | |
| export async function askForPersonaSelection(title, text, personas, { okButton = 'None', shiftClickHandler = undefined, highlightPersonas = false, targetedChar = undefined } = {}) { | |
| const content = document.createElement('div'); | |
| const titleElement = document.createElement('h3'); | |
| titleElement.textContent = title; | |
| content.appendChild(titleElement); | |
| const textElement = document.createElement('div'); | |
| textElement.classList.add('multiline', 'm-b-1'); | |
| textElement.textContent = text; | |
| content.appendChild(textElement); | |
| const personaListBlock = document.createElement('div'); | |
| personaListBlock.classList.add('persona-list', 'avatars_inline', 'avatars_multiline', 'text_muted'); | |
| content.appendChild(personaListBlock); | |
| if (personas.length > 0) | |
| buildPersonaAvatarList(personaListBlock, personas, { interactable: true }); | |
| else | |
| personaListBlock.textContent = t`[Currently no personas connected]`; | |
| const personasToHighlight = highlightPersonas instanceof Array ? highlightPersonas : (highlightPersonas ? getPersonasOfCurrentChat() : []); | |
| // Make the persona blocks clickable and close the popup | |
| personaListBlock.querySelectorAll('.avatar[data-type="persona"]').forEach(block => { | |
| if (!(block instanceof HTMLElement)) return; | |
| block.dataset.result = String(100 + personas.indexOf(block.dataset.pid)); | |
| if (shiftClickHandler) { | |
| block.addEventListener('click', function (ev) { | |
| if (ev.shiftKey) { | |
| shiftClickHandler(this, ev); | |
| } | |
| }); | |
| } | |
| if (personasToHighlight && personasToHighlight.includes(block.dataset.pid)) { | |
| block.classList.add('is_active'); | |
| block.title = block.title + '\n\n' + t`Was used in current chat.`; | |
| if (block.classList.contains('is_fav')) block.title = block.title + '\n' + t`Is your default persona.`; | |
| } | |
| }); | |
| /** @type {import('./popup.js').CustomPopupButton[]} */ | |
| const customButtons = []; | |
| if (targetedChar) { | |
| customButtons.push({ | |
| text: t`Remove All Connections`, | |
| result: 2, | |
| action: () => { | |
| for (const [personaId, description] of Object.entries(power_user.persona_descriptions)) { | |
| /** @type {PersonaConnection[]} */ | |
| const connections = description.connections; | |
| if (connections) { | |
| power_user.persona_descriptions[personaId].connections = connections.filter(c => { | |
| if (targetedChar.type == c.type && targetedChar.id == c.id) return false; | |
| return true; | |
| }); | |
| } | |
| } | |
| saveSettingsDebounced(); | |
| updatePersonaConnectionsAvatarList(); | |
| if (power_user.persona_show_notifications) { | |
| const name = targetedChar.type == 'character' ? characters[targetedChar.id]?.name : groups[targetedChar.id]?.name; | |
| toastr.info(t`All connections to ${name} have been removed.`, t`Personas Unlocked`); | |
| } | |
| }, | |
| }); | |
| } | |
| const popup = new Popup(content, POPUP_TYPE.TEXT, '', { okButton: okButton, customButtons: customButtons }); | |
| const result = await popup.show(); | |
| return Number(result) >= 100 ? personas[Number(result) - 100] : null; | |
| } | |
| /** | |
| * Automatically selects a persona based on the given name if a matching persona exists. | |
| * @param {string} name - The name to search for | |
| * @returns {boolean} True if a matching persona was found and selected, false otherwise | |
| */ | |
| export function autoSelectPersona(name) { | |
| for (const [key, value] of Object.entries(power_user.personas)) { | |
| if (value === name) { | |
| console.log(`Auto-selecting persona ${key} for name ${name}`); | |
| setUserAvatar(key); | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| /** | |
| * Renames the persona with the given avatar ID by showing a popup to enter a new name. | |
| * @param {string} avatarId - ID of the avatar to rename | |
| * @returns {Promise<boolean>} A promise that resolves to true if the persona was renamed, false otherwise | |
| */ | |
| async function renamePersona(avatarId) { | |
| const currentName = power_user.personas[avatarId]; | |
| const newName = await Popup.show.input(t`Rename Persona`, t`Enter a new name for this persona:`, currentName); | |
| 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 getUserAvatars(true, avatarId); | |
| updatePersonaUIStates(); | |
| setPersonaDescription(); | |
| return true; | |
| } | |
| /** | |
| * Selects the persona with the currently set avatar ID by updating the user name and persona description, and updating the locked persona if the setting is enabled. | |
| * @param {object} [options={}] - Optional settings | |
| * @param {boolean} [options.toastPersonaNameChange=true] - Whether to show a toast when the persona name is changed | |
| * @returns {Promise<void>} | |
| */ | |
| async function selectCurrentPersona({ toastPersonaNameChange = true } = {}) { | |
| const personaName = power_user.personas[user_avatar]; | |
| if (personaName) { | |
| const shouldAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata['persona']; | |
| if (personaName !== name1) { | |
| console.log(`Auto-updating user name to ${personaName}`); | |
| setUserName(personaName, { toastPersonaNameChange: !shouldAutoLock && toastPersonaNameChange }); | |
| } | |
| const descriptor = power_user.persona_descriptions[user_avatar]; | |
| if (descriptor) { | |
| power_user.persona_description = descriptor.description ?? ''; | |
| power_user.persona_description_position = descriptor.position ?? persona_description_positions.IN_PROMPT; | |
| power_user.persona_description_depth = descriptor.depth ?? DEFAULT_DEPTH; | |
| power_user.persona_description_role = descriptor.role ?? DEFAULT_ROLE; | |
| power_user.persona_description_lorebook = descriptor.lorebook ?? ''; | |
| } else { | |
| power_user.persona_description = ''; | |
| power_user.persona_description_position = persona_description_positions.IN_PROMPT; | |
| power_user.persona_description_depth = DEFAULT_DEPTH; | |
| power_user.persona_description_role = DEFAULT_ROLE; | |
| power_user.persona_description_lorebook = ''; | |
| power_user.persona_descriptions[user_avatar] = { | |
| description: '', | |
| position: persona_description_positions.IN_PROMPT, | |
| depth: DEFAULT_DEPTH, | |
| role: DEFAULT_ROLE, | |
| lorebook: '', | |
| connections: [], | |
| }; | |
| } | |
| 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(temporary.info.replaceAll('\n', '<br />'), t`Temporary Persona`, { escapeHtml: false }); | |
| }, | |
| }); | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Checks if a connection is locked for the current character or group edit menu | |
| * @param {PersonaConnection} connection - Connection to check | |
| * @returns {boolean} Whether the connection is locked | |
| */ | |
| export function isPersonaConnectionLocked(connection) { | |
| return (!selected_group && connection.type === 'character' && connection.id === characters[this_chid]?.avatar) | |
| || (selected_group && connection.type === 'group' && connection.id === selected_group); | |
| } | |
| /** | |
| * Checks if the persona is locked | |
| * @param {PersonaLockType} type - Lock type | |
| * @returns {boolean} Whether the persona is locked | |
| */ | |
| export function isPersonaLocked(type = 'chat') { | |
| switch (type) { | |
| case 'default': | |
| return power_user.default_persona === user_avatar; | |
| case 'chat': | |
| return chat_metadata['persona'] == user_avatar; | |
| case 'character': { | |
| return !!power_user.persona_descriptions[user_avatar]?.connections?.some(isPersonaConnectionLocked); | |
| } | |
| default: throw new Error(`Unknown persona lock type: ${type}`); | |
| } | |
| } | |
| /** | |
| * Locks or unlocks the persona | |
| * @param {boolean} state Desired lock state | |
| * @param {PersonaLockType} type - Lock type | |
| * @returns {Promise<void>} | |
| */ | |
| export async function setPersonaLockState(state, type = 'chat') { | |
| return state ? await lockPersona(type) : await unlockPersona(type); | |
| } | |
| /** | |
| * Toggle the persona lock state | |
| * @param {PersonaLockType} type - Lock type | |
| * @returns {Promise<boolean>} - Whether the persona was locked | |
| */ | |
| export async function togglePersonaLock(type = 'chat') { | |
| if (isPersonaLocked(type)) { | |
| await unlockPersona(type); | |
| return false; | |
| } else { | |
| await lockPersona(type); | |
| return true; | |
| } | |
| } | |
| /** | |
| * Unlock the persona | |
| * @param {PersonaLockType} type - Lock type | |
| * @returns {Promise<void>} | |
| */ | |
| async function unlockPersona(type = 'chat') { | |
| switch (type) { | |
| case 'default': { | |
| // TODO: Make this toggle-able | |
| await toggleDefaultPersona(user_avatar, { quiet: true }); | |
| break; | |
| } | |
| case 'chat': { | |
| if (chat_metadata['persona']) { | |
| console.log(`Unlocking persona ${user_avatar} from this chat`); | |
| delete chat_metadata['persona']; | |
| await saveMetadata(); | |
| if (power_user.persona_show_notifications && !isPersonaPanelOpen()) { | |
| toastr.info(t`Persona ${name1} is now unlocked from this chat.`, t`Persona Unlocked`); | |
| } | |
| } | |
| break; | |
| } | |
| case 'character': { | |
| /** @type {PersonaConnection[]} */ | |
| const connections = power_user.persona_descriptions[user_avatar]?.connections; | |
| if (connections) { | |
| console.log(`Unlocking persona ${user_avatar} from this character ${name2}`); | |
| power_user.persona_descriptions[user_avatar].connections = connections.filter(c => !isPersonaConnectionLocked(c)); | |
| saveSettingsDebounced(); | |
| updatePersonaConnectionsAvatarList(); | |
| if (power_user.persona_show_notifications && !isPersonaPanelOpen()) { | |
| toastr.info(t`Persona ${name1} is now unlocked from character ${name2}.`, t`Persona Unlocked`); | |
| } | |
| } | |
| break; | |
| } | |
| default: | |
| throw new Error(`Unknown persona lock type: ${type}`); | |
| } | |
| updatePersonaUIStates(); | |
| } | |
| /** | |
| * Lock the persona | |
| * @param {PersonaLockType} type - Lock type | |
| */ | |
| async function lockPersona(type = 'chat') { | |
| // First make sure that user_avatar is actually a persona | |
| if (!(user_avatar in power_user.personas)) { | |
| console.log(`Creating a new persona ${user_avatar}`); | |
| if (power_user.persona_show_notifications) { | |
| toastr.info(t`Creating a new persona for currently selected user name and avatar...`, t`Persona Not Found`); | |
| } | |
| power_user.personas[user_avatar] = name1; | |
| power_user.persona_descriptions[user_avatar] = { | |
| description: '', | |
| position: persona_description_positions.IN_PROMPT, | |
| depth: DEFAULT_DEPTH, | |
| role: DEFAULT_ROLE, | |
| lorebook: '', | |
| connections: [], | |
| }; | |
| } | |
| switch (type) { | |
| case 'default': { | |
| await toggleDefaultPersona(user_avatar, { quiet: true }); | |
| break; | |
| } | |
| case 'chat': { | |
| console.log(`Locking persona ${user_avatar} to this chat`); | |
| chat_metadata['persona'] = user_avatar; | |
| saveMetadataDebounced(); | |
| if (power_user.persona_show_notifications && !isPersonaPanelOpen()) { | |
| toastr.success(t`User persona ${name1} is locked to ${name2} in this chat`, t`Persona Locked`); | |
| } | |
| break; | |
| } | |
| case 'character': { | |
| const newConnection = getCurrentConnectionObj(); | |
| /** @type {PersonaConnection[]} */ | |
| const connections = power_user.persona_descriptions[user_avatar].connections?.filter(c => !isPersonaConnectionLocked(c)) ?? []; | |
| if (newConnection && newConnection.id) { | |
| console.log(`Locking persona ${user_avatar} to this character ${name2}`); | |
| power_user.persona_descriptions[user_avatar].connections = [...connections, newConnection]; | |
| const unlinkedCharacters = []; | |
| if (!power_user.persona_allow_multi_connections) { | |
| for (const [avatarId, description] of Object.entries(power_user.persona_descriptions)) { | |
| if (avatarId === user_avatar) continue; | |
| const filteredConnections = description.connections?.filter(c => !(c.type === newConnection.type && c.id === newConnection.id)) ?? []; | |
| if (filteredConnections.length !== description.connections?.length) { | |
| description.connections = filteredConnections; | |
| unlinkedCharacters.push(power_user.personas[avatarId]); | |
| } | |
| } | |
| } | |
| saveSettingsDebounced(); | |
| updatePersonaConnectionsAvatarList(); | |
| if (power_user.persona_show_notifications) { | |
| let additional = ''; | |
| if (unlinkedCharacters.length) | |
| additional += `<br /><br />${t`Unlinked existing persona${unlinkedCharacters.length > 1 ? 's' : ''}: ${unlinkedCharacters.join(', ')}`}`; | |
| if (additional || !isPersonaPanelOpen()) { | |
| toastr.success(t`User persona ${name1} is locked to character ${name2}${additional}`, t`Persona Locked`, { escapeHtml: false }); | |
| } | |
| } | |
| } | |
| break; | |
| } | |
| default: | |
| throw new Error(`Unknown persona lock type: ${type}`); | |
| } | |
| updatePersonaUIStates(); | |
| } | |
| async function deleteUserAvatar() { | |
| const avatarId = user_avatar; | |
| if (!avatarId) { | |
| console.warn('No avatar id found'); | |
| return; | |
| } | |
| const name = power_user.personas[avatarId] || ''; | |
| const confirm = await Popup.show.confirm( | |
| t`Delete Persona` + `: ${name}`, | |
| t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`); | |
| if (!confirm) { | |
| console.debug('User cancelled deleting avatar'); | |
| return; | |
| } | |
| 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) { | |
| 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']) { | |
| 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(); | |
| // Use the existing mechanism to re-render the persona list and choose the next persona here | |
| await loadPersonaForCurrentChat({ doRender: true }); | |
| } | |
| } | |
| 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: '', | |
| }; | |
| power_user.persona_descriptions[user_avatar] = object; | |
| } | |
| object.description = power_user.persona_description; | |
| } | |
| $(`.avatar-container[imgfile="${user_avatar}"] .ch_description`) | |
| .text(power_user.persona_description || $('#user_avatar_block').attr('no_desc_text')) | |
| .toggleClass('text_muted', !power_user.persona_description); | |
| saveSettingsDebounced(); | |
| } | |
| 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(); | |
| } | |
| 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(); | |
| } | |
| /** | |
| * Opens a popup to set the lorebook for the current persona. | |
| * @param {JQuery.ClickEvent} event Click event | |
| */ | |
| async function onPersonaLoreButtonClick(event) { | |
| 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 (event.altKey && selectedLorebook) { | |
| 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', 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(); | |
| }); | |
| await callGenericPopup(template, POPUP_TYPE.TEXT); | |
| } | |
| 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(); | |
| $('#persona_depth_position_settings').toggle(power_user.persona_description_position === persona_description_positions.AT_DEPTH); | |
| } | |
| 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: [], | |
| }; | |
| power_user.persona_descriptions[user_avatar] = object; | |
| } | |
| return object; | |
| } | |
| /** | |
| * Sets a persona as the default one to be used for all new chats and unlocked existing chats | |
| * @param {string} avatarId The avatar id of the persona to set as the default | |
| * @param {object} [options] Optional arguments | |
| * @param {boolean} [options.quiet=false] If true, no confirmation popups will be shown | |
| * @returns {Promise<void>} | |
| */ | |
| async function toggleDefaultPersona(avatarId, { quiet = false } = {}) { | |
| if (!avatarId) { | |
| console.warn('No avatar id found'); | |
| return; | |
| } | |
| const currentDefault = power_user.default_persona; | |
| if (power_user.personas[avatarId] === undefined) { | |
| console.warn(`No persona name found for avatar ${avatarId}`); | |
| toastr.warning(t`You must bind a name to this persona before you can set it as the default.`, t`Persona Name Not Set`); | |
| return; | |
| } | |
| if (avatarId === currentDefault) { | |
| if (!quiet) { | |
| const confirm = await Popup.show.confirm(t`Are you sure you want to remove the default persona?`, power_user.personas[avatarId]); | |
| if (!confirm) { | |
| console.debug('User cancelled removing default persona'); | |
| return; | |
| } | |
| } | |
| console.log(`Removing default persona ${avatarId}`); | |
| if (power_user.persona_show_notifications && !isPersonaPanelOpen()) { | |
| toastr.info(t`This persona will no longer be used by default when you open a new chat.`, t`Default Persona Removed`); | |
| } | |
| delete power_user.default_persona; | |
| } else { | |
| if (!quiet) { | |
| const confirm = await Popup.show.confirm(t`Set Default Persona`, | |
| t`Are you sure you want to set \"${power_user.personas[avatarId]}\" as the default persona?` | |
| + '<br /><br />' | |
| + t`This name and avatar will be used for all new chats, as well as existing chats where the user persona is not locked.`); | |
| if (!confirm) { | |
| console.debug('User cancelled setting default persona'); | |
| return; | |
| } | |
| } | |
| power_user.default_persona = avatarId; | |
| if (power_user.persona_show_notifications && !isPersonaPanelOpen()) { | |
| toastr.success(t`Set to ${power_user.personas[avatarId]}.This persona will be used by default when you open a new chat.`, t`Default Persona`); | |
| } | |
| } | |
| saveSettingsDebounced(); | |
| await getUserAvatars(true, avatarId); | |
| updatePersonaUIStates(); | |
| } | |
| /** | |
| * Returns an object with 3 properties that describe the state of the given persona | |
| * | |
| * - default: Whether this persona is the default one for all new chats | |
| * - locked: An object containing the lock states | |
| * - chat: Whether the persona is locked to the currently open chat | |
| * - character: Whether the persona is locked to the currently open character or group | |
| * @param {string} avatarId - The avatar id of the persona to get the state for | |
| * @returns {PersonaState} An object describing the state of the given persona | |
| */ | |
| function getPersonaStates(avatarId) { | |
| const isDefaultPersona = power_user.default_persona === avatarId; | |
| const hasChatLock = chat_metadata['persona'] == avatarId; | |
| /** @type {PersonaConnection[]} */ | |
| const connections = power_user.persona_descriptions[avatarId]?.connections; | |
| const hasCharLock = !!connections?.some(c => | |
| (!selected_group && c.type === 'character' && c.id === characters[Number(this_chid)]?.avatar) | |
| || (selected_group && c.type === 'group' && c.id === selected_group)); | |
| return { | |
| avatarId: avatarId, | |
| default: isDefaultPersona, | |
| locked: { | |
| chat: hasChatLock, | |
| character: hasCharLock, | |
| }, | |
| }; | |
| } | |
| /** | |
| * Updates the UI to reflect the current states of all personas and the selected user's persona. | |
| * This includes updating class states on avatar containers to indicate default status, chat lock, | |
| * and character lock, as well as updating icons and labels in the persona management panel to reflect | |
| * the current state of the user's persona. | |
| * Additionally, it manages the display of temporary persona lock information. | |
| * @param {Object} [options={}] - Optional settings | |
| * @param {boolean} [options.navigateToCurrent=false] - Whether to navigate to the current persona in the persona list | |
| */ | |
| function updatePersonaUIStates({ navigateToCurrent = false } = {}) { | |
| if (navigateToCurrent) { | |
| navigateToAvatar(user_avatar); | |
| } | |
| // Update the persona list | |
| $('#user_avatar_block .avatar-container').each(function () { | |
| const avatarId = $(this).attr('data-avatar-id'); | |
| const states = getPersonaStates(avatarId); | |
| $(this).toggleClass('default_persona', states.default); | |
| $(this).toggleClass('locked_to_chat', states.locked.chat); | |
| $(this).toggleClass('locked_to_character', states.locked.character); | |
| $(this).toggleClass('selected', avatarId === user_avatar); | |
| }); | |
| // Buttons for the persona panel on the right | |
| const personaStates = getPersonaStates(user_avatar); | |
| $('#lock_persona_default').toggleClass('locked', personaStates.default); | |
| $('#lock_user_name').toggleClass('locked', personaStates.locked.chat); | |
| $('#lock_user_name i.icon').toggleClass('fa-lock', personaStates.locked.chat); | |
| $('#lock_user_name i.icon').toggleClass('fa-unlock', !personaStates.locked.chat); | |
| $('#lock_persona_to_char').toggleClass('locked', personaStates.locked.character); | |
| $('#lock_persona_to_char i.icon').toggleClass('fa-lock', personaStates.locked.character); | |
| $('#lock_persona_to_char i.icon').toggleClass('fa-unlock', !personaStates.locked.character); | |
| // Persona panel info block | |
| const { isTemporary, info } = getPersonaTemporaryLockInfo(); | |
| if (isTemporary) { | |
| const messageContainer = document.createElement('div'); | |
| const messageSpan = document.createElement('span'); | |
| messageSpan.textContent = t`Temporary persona in use.`; | |
| messageContainer.appendChild(messageSpan); | |
| messageContainer.classList.add('flex-container', 'alignItemsBaseline'); | |
| const infoIcon = document.createElement('i'); | |
| infoIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p'); | |
| infoIcon.title = info; | |
| messageContainer.appendChild(infoIcon); | |
| // Set the info block content | |
| setInfoBlock('#persona_connections_info_block', messageContainer, 'hint'); | |
| } else { | |
| // Clear the info block if no condition applies | |
| clearInfoBlock('#persona_connections_info_block'); | |
| } | |
| } | |
| /** | |
| * @typedef {Object} PersonaLockInfo | |
| * @property {boolean} isTemporary - Whether the selected persona is temporary based on current locks. | |
| * @property {boolean} hasDifferentChatLock - True if the chat persona is set and differs from the user avatar. | |
| * @property {boolean} hasDifferentDefaultLock - True if the default persona is set and differs from the user avatar. | |
| * @property {string} info - Detailed information about the current, chat, and default personas. | |
| */ | |
| /** | |
| * Computes temporary lock information for the current persona. | |
| * | |
| * This function checks whether the currently selected persona is temporary by comparing | |
| * the chat persona and the default persona to the user avatar. If either is different, | |
| * the currently selected persona is considered temporary and a detailed message is generated. | |
| * | |
| * @returns {PersonaLockInfo} An object containing flags and a message describing the persona lock status. | |
| */ | |
| function getPersonaTemporaryLockInfo() { | |
| const hasDifferentChatLock = !!chat_metadata['persona'] && chat_metadata['persona'] !== user_avatar; | |
| const hasDifferentDefaultLock = power_user.default_persona && power_user.default_persona !== user_avatar; | |
| const isTemporary = hasDifferentChatLock || (!chat_metadata['persona'] && hasDifferentDefaultLock); | |
| const info = isTemporary ? t`A different persona is locked to this chat, or you have a different default persona set. The currently selected persona will only be temporary, and resets on reload. Consider locking this persona to the chat if you want to permanently use it.` | |
| + '\n\n' | |
| + t`Current Persona: ${power_user.personas[user_avatar]}` | |
| + (hasDifferentChatLock ? '\n' + t`Chat persona: ${power_user.personas[chat_metadata['persona']]}` : '') | |
| + (hasDifferentDefaultLock ? '\n' + t`Default persona: ${power_user.personas[power_user.default_persona]}` : '') : ''; | |
| return { | |
| isTemporary: isTemporary, | |
| hasDifferentChatLock: hasDifferentChatLock, | |
| hasDifferentDefaultLock: hasDifferentDefaultLock, | |
| info: info, | |
| }; | |
| } | |
| /** | |
| * Loads the appropriate persona for the current chat session based on locks (chat lock, char lock, default persona) | |
| * | |
| * @param {Object} [options={}] - Optional arguments | |
| * @param {boolean} [options.doRender=false] - Whether to render the persona immediately | |
| * @returns {Promise<boolean>} - A promise that resolves to a boolean indicating whether a persona was selected | |
| */ | |
| async function loadPersonaForCurrentChat({ doRender = false } = {}) { | |
| // Cache persona list to check if they exist | |
| const userAvatars = await getUserAvatars(doRender); | |
| // 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']; | |
| setUserAvatar(chatPersona, { toastPersonaNameChange: false, navigateToCurrent: true }); | |
| if (power_user.persona_show_notifications) { | |
| let message = t`Auto-selected persona based on ${connectType} connection.<br />Your messages will now be sent as ${power_user.personas[chatPersona]}.`; | |
| if (willAutoLock) { | |
| message += '<br /><br />' + t`Auto-locked this persona to current chat.`; | |
| } | |
| toastr.success(message, t`Persona Auto Selected`, { escapeHtml: false }); | |
| } | |
| } | |
| // Even if it's the same persona, we still might need to auto-lock to chat if that's enabled | |
| else if (chatPersona && power_user.persona_auto_lock && !chat_metadata['persona']) { | |
| 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(([_, desc]) => desc.connections?.some(conn => conn.type === 'character' && conn.id === characterKey)) | |
| .map(([key, _]) => key); | |
| return connectedPersonas; | |
| } | |
| /** | |
| * Shows a popup with all personas connected to the currently selected character or group. | |
| * In the popup, the user can select a persona to load for the current character or group, or shift-click to remove the connection. | |
| * @return {Promise<void>} | |
| */ | |
| export async function showCharConnections() { | |
| let isRemoving = false; | |
| const connections = getConnectedPersonas(); | |
| const message = t`The following personas are connected to the current character.\n\nClick on a persona to select it for the current character.\nShift + Click to unlink the persona from the character.`; | |
| const selectedPersona = await askForPersonaSelection(t`Persona Connections`, message, connections, { | |
| okButton: t`Ok`, | |
| highlightPersonas: true, | |
| targetedChar: getCurrentConnectionObj(), | |
| shiftClickHandler: (element, ev) => { | |
| const personaId = $(element).attr('data-pid'); | |
| /** @type {PersonaConnection[]} */ | |
| const connections = power_user.persona_descriptions[personaId]?.connections; | |
| if (connections) { | |
| console.log(`Unlocking persona ${personaId} from current character ${name2}`); | |
| power_user.persona_descriptions[personaId].connections = connections.filter(c => { | |
| if (menu_type == 'group_edit' && c.type == 'group' && c.id == selected_group) return false; | |
| else if (c.type == 'character' && c.id == characters[Number(this_chid)]?.avatar) return false; | |
| return true; | |
| }); | |
| saveSettingsDebounced(); | |
| updatePersonaConnectionsAvatarList(); | |
| if (power_user.persona_show_notifications) { | |
| toastr.info(t`User persona ${power_user.personas[personaId]} is now unlocked from the current character ${name2}.`, t`Persona unlocked`); | |
| } | |
| isRemoving = true; | |
| $('#char_connections_button').trigger('click'); | |
| } | |
| }, | |
| }); | |
| // One of the persona was selected. So load it. | |
| if (!isRemoving && selectedPersona) { | |
| 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(''); | |
| } | |
| async function syncUserNameToPersona() { | |
| const confirmation = await Popup.show.confirm(t`Are you sure?`, t`All user-sent messages in this chat will be attributed to ${name1}.`); | |
| if (!confirmation) { | |
| return; | |
| } | |
| for (const mes of chat) { | |
| if (mes.is_user) { | |
| mes.name = name1; | |
| mes.force_avatar = getUserAvatar(user_avatar); | |
| } | |
| } | |
| await saveChatConditional(); | |
| await reloadCurrentChat(); | |
| } | |
| /** | |
| * Retriggers the first message to reload it from the char definition. | |
| * | |
| * Only works if only the first message is present, and not in group mode. | |
| */ | |
| export function retriggerFirstMessageOnEmptyChat() { | |
| if (Number(this_chid) >= 0 && !selected_group && chat.length === 1) { | |
| $('#firstmessage_textarea').trigger('input'); | |
| } | |
| } | |
| /** | |
| * Duplicates a persona. | |
| * @param {string} avatarId | |
| * @returns {Promise<void>} | |
| */ | |
| async function duplicatePersona(avatarId) { | |
| const personaName = power_user.personas[avatarId]; | |
| if (!personaName) { | |
| toastr.warning('Chosen avatar is not a persona', t`Persona Management`); | |
| return; | |
| } | |
| 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 ?? '', | |
| }; | |
| await uploadUserAvatar(getUserAvatar(avatarId), newAvatarId); | |
| await getUserAvatars(true, newAvatarId); | |
| saveSettingsDebounced(); | |
| } | |
| /** | |
| * If a current user avatar is not bound to persona, bind it. | |
| */ | |
| async function migrateNonPersonaUser() { | |
| if (user_avatar in power_user.personas) { | |
| return; | |
| } | |
| initPersona(user_avatar, name1, ''); | |
| setPersonaDescription(); | |
| await getUserAvatars(true, user_avatar); | |
| } | |
| /** | |
| * Locks or unlocks the persona of the current chat. | |
| * @param {{type: string}} _args Named arguments | |
| * @param {string} value The value to set the lock to | |
| * @returns {Promise<string>} The value of the lock after setting | |
| */ | |
| async function lockPersonaCallback(_args, value) { | |
| const type = /** @type {PersonaLockType} */ (_args.type ?? 'chat'); | |
| if (!['chat', 'character', 'default'].includes(type)) { | |
| toastr.warning(t`Unknown lock type "${type}"`, t`Persona Management`); | |
| return ''; | |
| } | |
| if (!value) { | |
| return String(isPersonaLocked(type)); | |
| } | |
| if (['toggle', 't'].includes(value.trim().toLowerCase())) { | |
| const result = await togglePersonaLock(type); | |
| return String(result); | |
| } | |
| if (isTrueBoolean(value)) { | |
| await setPersonaLockState(true, type); | |
| return 'true'; | |
| } | |
| if (isFalseBoolean(value)) { | |
| await setPersonaLockState(false, type); | |
| return 'false'; | |
| } | |
| return ''; | |
| } | |
| /** | |
| * Sets a persona name and optionally an avatar. | |
| * @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments | |
| * @param {string} name Name to set | |
| * @returns {string} | |
| */ | |
| 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)) { | |
| let persona = Object.entries(power_user.personas).find(([avatar, _]) => avatar === name)?.[1]; | |
| if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1]; | |
| if (persona) { | |
| autoSelectPersona(persona); | |
| 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 ''; | |
| } | |
| function syncCallback() { | |
| $('#sync_name_button').trigger('click'); | |
| return ''; | |
| } | |
| function registerPersonaSlashCommands() { | |
| SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| name: 'persona-lock', | |
| callback: lockPersonaCallback, | |
| returns: 'The current lock state for the given type', | |
| helpString: 'Locks/unlocks a persona (name and avatar) to the current chat. Gets the current lock state for the given type if no state is provided.', | |
| namedArgumentList: [ | |
| SlashCommandNamedArgument.fromProps({ | |
| name: 'type', | |
| description: 'The type of the lock, where it should apply to', | |
| typeList: [ARGUMENT_TYPE.STRING], | |
| defaultValue: 'chat', | |
| enumList: [ | |
| new SlashCommandEnumValue('chat', 'Lock the persona to the current chat.'), | |
| new SlashCommandEnumValue('character', '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', 'Lock this persona as the default persona for all new chats.'), | |
| ], | |
| }), | |
| ], | |
| unnamedArgumentList: [ | |
| SlashCommandArgument.fromProps({ | |
| description: 'state', | |
| typeList: [ARGUMENT_TYPE.STRING], | |
| enumProvider: commonEnumProviders.boolean('onOffToggle'), | |
| }), | |
| ], | |
| })); | |
| // TODO: Legacy command. Might be removed in the future and replaced by /persona-lock with aliases. | |
| SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| name: 'lock', | |
| /** @type {(args: { type: string }, value: string) => Promise<string>} */ | |
| callback: (args, value) => { | |
| if (!value) { | |
| value = 'toggle'; | |
| toastr.warning(t`Using /lock without a provided state to toggle the persona is deprecated. Please use /persona-lock instead. | |
| In the future this command with no state provided will return the current state, instead of toggling it.`, t`Deprecation Warning`); | |
| } | |
| return lockPersonaCallback(args, value); | |
| }, | |
| returns: 'The current lock state for the given type', | |
| aliases: ['bind'], | |
| helpString: 'Locks/unlocks a persona (name and avatar) to the current chat. Gets the current lock state for the given type if no state is provided.', | |
| namedArgumentList: [ | |
| SlashCommandNamedArgument.fromProps({ | |
| name: 'type', | |
| description: 'The type of the lock, where it should apply to', | |
| typeList: [ARGUMENT_TYPE.STRING], | |
| defaultValue: 'chat', | |
| enumList: [ | |
| new SlashCommandEnumValue('chat', 'Lock the persona to the current chat.'), | |
| new SlashCommandEnumValue('character', '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', 'Lock this persona as the default persona for all new chats.'), | |
| ], | |
| }), | |
| ], | |
| unnamedArgumentList: [ | |
| SlashCommandArgument.fromProps({ | |
| description: 'state', | |
| typeList: [ARGUMENT_TYPE.STRING], | |
| defaultValue: 'toggle', | |
| enumProvider: commonEnumProviders.boolean('onOffToggle'), | |
| }), | |
| ], | |
| })); | |
| SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| name: 'persona-set', | |
| callback: setNameCallback, | |
| aliases: ['persona', 'name'], | |
| namedArgumentList: [ | |
| new SlashCommandNamedArgument( | |
| 'mode', 'The mode for persona selection. ("lookup" = search for existing persona, "temp" = create a temporary name, set a temporary name, "all" = allow both in the same command)', | |
| [ARGUMENT_TYPE.STRING], false, false, 'all', ['lookup', 'temp', 'all'], | |
| ), | |
| ], | |
| unnamedArgumentList: [ | |
| SlashCommandArgument.fromProps({ | |
| description: 'persona name', | |
| typeList: [ARGUMENT_TYPE.STRING], | |
| isRequired: true, | |
| enumProvider: commonEnumProviders.personas, | |
| }), | |
| ], | |
| helpString: 'Selects the given persona with its name and avatar (by name or avatar url). If no matching persona exists, applies a temporary name.', | |
| })); | |
| SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| name: 'persona-sync', | |
| aliases: ['sync'], | |
| callback: syncCallback, | |
| helpString: 'Syncs the user persona in user-attributed messages in the current chat.', | |
| })); | |
| } | |
| /** | |
| * 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); | |
| $('#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', syncUserNameToPersona); | |
| $('#avatar_upload_file').on('change', changeUserAvatar); | |
| $(document).on('click', '#user_avatar_block .avatar-container', function () { | |
| const imgfile = $(this).attr('data-avatar-id'); | |
| 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(); | |
| } | |