SillyTavern/public/scripts/personas.js

1138 lines
40 KiB
JavaScript

import {
characters,
chat,
chat_metadata,
default_avatar,
eventSource,
event_types,
getRequestHeaders,
getThumbnailUrl,
name1,
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, debounce, delay, download, ensureImageFormatSupported, flashHighlight, getBase64Async, parseJsonFile } from './utils.js';
import { debounce_timeout } from './constants.js';
import { FILTER_TYPES, FilterHelper } from './filters.js';
import { selected_group } from './group-chats.js';
import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
let savePersonasPage = 0;
const GRID_STORAGE_KEY = 'Personas_GridView';
const DEFAULT_DEPTH = 2;
const DEFAULT_ROLE = 0;
export let user_avatar = '';
export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
function switchPersonaGridView() {
const state = localStorage.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 Avatars/${avatarImg}`;
}
export function initUserAvatar(avatar) {
user_avatar = avatar;
reloadUserAvatar();
highlightSelectedAvatar();
}
/**
* Sets a user avatar file
* @param {string} imgfile Link to an image file
*/
export function setUserAvatar(imgfile) {
user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('imgfile');
reloadUserAvatar();
highlightSelectedAvatar();
selectCurrentPersona();
saveSettingsDebounced();
$('.zoomed_avatar[forchar]').remove();
}
function highlightSelectedAvatar() {
$('#user_avatar_block .avatar-container').removeClass('selected');
$(`#user_avatar_block .avatar-container[imgfile="${user_avatar}"]`).addClass('selected');
}
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 arrray, 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} name Avatar file name
* @returns {JQuery<HTMLElement>} Avatar block
*/
function getUserAvatarBlock(name) {
const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
const template = $('#user_avatar_template .avatar-container').clone();
const personaName = power_user.personas[name];
const personaDescription = power_user.persona_descriptions[name]?.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('imgfile', name);
template.find('.avatar').attr('imgfile', name).attr('title', name);
template.toggleClass('default_persona', name === power_user.default_persona);
let avatarUrl = getUserAvatar(name);
if (isFirefox) {
avatarUrl += '?t=' + Date.now();
}
template.find('img').attr('src', avatarUrl);
$('#user_avatar_block').append(template);
return template;
}
/**
* 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;
}
// 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(localStorage.getItem(storageKey)) || 5;
$('#persona_pagination_container').pagination({
dataSource: entities,
pageSize: perPage,
sizeChangerOptions: [5, 10, 25, 50, 100, 250, 500, 1000],
pageRange: 1,
pageNumber: savePersonasPage || 1,
position: 'top',
showPageNumbers: false,
showSizeChanger: true,
prevText: '<',
nextText: '>',
formatNavigator: PAGINATION_TEMPLATE,
showNavigator: true,
callback: function (data) {
$(listId).empty();
for (const item of data) {
$(listId).append(getUserAvatarBlock(item));
}
highlightSelectedAvatar();
},
afterSizeSelectorChange: function (e) {
localStorage.setItem(storageKey, e.target.value);
},
afterPaging: function (e) {
savePersonasPage = e;
},
afterRender: function () {
$(listId).scrollTop(0);
},
});
if (openPageAt) {
const avatarIndex = entities.indexOf(openPageAt);
const page = Math.floor(avatarIndex / perPage) + 1;
if (avatarIndex !== -1) {
$('#persona_pagination_container').pagination('go', page);
}
}
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 object representing the AJAX request
*/
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);
}
return jQuery.ajax({
type: 'POST',
url: '/api/avatars/upload',
data: formData,
beforeSend: () => { },
cache: false,
contentType: false,
processData: false,
success: async function () {
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('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);
}
jQuery.ajax({
type: 'POST',
url: url,
data: formData,
beforeSend: () => { },
cache: false,
contentType: false,
processData: false,
success: async function (data) {
// 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);
},
error: (jqXHR, exception) => { },
});
// 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('Enter a name for this persona:', 'Cancel if you\'re just uploading an avatar.', '');
if (!personaName) {
console.debug('User cancelled creating a persona');
return;
}
const personaDescription = await Popup.show.input('Enter a description for this persona:', 'You can always add or change it later.', '', { rows: 4 });
initPersona(avatarId, personaName, personaDescription);
if (power_user.persona_show_notifications) {
toastr.success(`You can now pick ${personaName} as a persona in the Persona Management menu.`, 'Persona Created');
}
}
async function createDummyPersona() {
const personaName = await Popup.show.input('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_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,
};
saveSettingsDebounced();
}
export async function convertCharacterToPersona(characterId = null) {
if (null === characterId) characterId = this_chid;
const avatarUrl = characters[characterId]?.avatar;
if (!avatarUrl) {
console.log('No avatar found for this character');
return;
}
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('Overwrite Existing Persona', '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;
}
}
if (description.includes('{{char}}') || description.includes('{{user}}')) {
const confirm = await Popup.show.confirm('Persona Description Macros', '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,
};
// 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(`You can now select ${name} as a persona in the Persona Management menu.`, 'Persona Created');
// Refresh the persona selector
await getUserAvatars(true, overwriteName);
// Reload the persona description
setPersonaDescription();
}
/**
* 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);
export function setPersonaDescription() {
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));
countPersonaDescriptionTokens();
}
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;
}
}
}
/**
* Updates the name of a persona if it exists.
* @param {string} avatarId User avatar id
* @param {string} newName New name for the persona
*/
async function updatePersonaNameIfExists(avatarId, newName) {
if (avatarId in power_user.personas) {
power_user.personas[avatarId] = newName;
console.log(`Updated persona name for ${avatarId} to ${newName}`);
} else {
power_user.personas[avatarId] = newName;
power_user.persona_descriptions[avatarId] = {
description: '',
position: persona_description_positions.IN_PROMPT,
depth: DEFAULT_DEPTH,
role: DEFAULT_ROLE,
};
console.log(`Created persona name for ${avatarId} as ${newName}`);
}
await getUserAvatars(true, avatarId);
saveSettingsDebounced();
}
async function bindUserNameToPersona(e) {
e?.stopPropagation();
const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
if (!avatarId) {
console.warn('No avatar id found');
return;
}
let personaUnbind = false;
const existingPersona = power_user.personas[avatarId];
const personaName = await Popup.show.input(
'Enter a name for this persona:',
'(If empty name is provided, this will unbind the name from this avatar)',
existingPersona || '',
{ onClose: (p) => { personaUnbind = p.value === '' && p.result === POPUP_RESULT.AFFIRMATIVE; } });
// If the user clicked cancel, don't do anything
if (personaName === null && !personaUnbind) {
return;
}
if (personaName && personaName.length > 0) {
// If the user clicked ok and entered a name, bind the name to the persona
console.log(`Binding persona ${avatarId} to name ${personaName}`);
power_user.personas[avatarId] = personaName;
const descriptor = power_user.persona_descriptions[avatarId];
const isCurrentPersona = avatarId === user_avatar;
// Create a description object if it doesn't exist
if (!descriptor) {
// If the user is currently using this persona, set the description to the current description
power_user.persona_descriptions[avatarId] = {
description: isCurrentPersona ? power_user.persona_description : '',
position: isCurrentPersona ? power_user.persona_description_position : persona_description_positions.IN_PROMPT,
depth: isCurrentPersona ? power_user.persona_description_depth : DEFAULT_DEPTH,
role: isCurrentPersona ? power_user.persona_description_role : DEFAULT_ROLE,
};
}
// If the user is currently using this persona, update the name
if (isCurrentPersona) {
console.log(`Auto-updating user name to ${personaName}`);
setUserName(personaName);
}
} else {
// If the user clicked ok, but didn't enter a name, delete the persona
console.log(`Unbinding persona ${avatarId}`);
delete power_user.personas[avatarId];
delete power_user.persona_descriptions[avatarId];
}
saveSettingsDebounced();
await getUserAvatars(true, avatarId);
setPersonaDescription();
}
function selectCurrentPersona() {
const personaName = power_user.personas[user_avatar];
if (personaName) {
const lockedPersona = chat_metadata['persona'];
if (lockedPersona && lockedPersona !== user_avatar && power_user.persona_show_notifications) {
toastr.info(
`To permanently set "${personaName}" as the selected persona, unlock and relock it using the "Lock" button. Otherwise, the selection resets upon reloading the chat.`,
`This chat is locked to a different persona (${power_user.personas[lockedPersona]}).`,
{ timeOut: 10000, extendedTimeOut: 20000, preventDuplicates: true },
);
}
if (personaName !== name1) {
console.log(`Auto-updating user name to ${personaName}`);
setUserName(personaName);
}
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;
} 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_descriptions[user_avatar] = { description: '', position: persona_description_positions.IN_PROMPT, depth: DEFAULT_DEPTH, role: DEFAULT_ROLE };
}
setPersonaDescription();
}
}
/**
* Checks if the persona is locked for the current chat.
* @returns {boolean} Whether the persona is locked
*/
function isPersonaLocked() {
return !!chat_metadata['persona'];
}
/**
* Locks or unlocks the persona for the current chat.
* @param {boolean} state Desired lock state
* @returns {Promise<void>}
*/
export async function setPersonaLockState(state) {
return state ? await lockPersona() : await unlockPersona();
}
/**
* Toggle the persona lock state for the current chat.
* @returns {Promise<void>}
*/
export async function togglePersonaLock() {
return isPersonaLocked()
? await unlockPersona()
: await lockPersona();
}
/**
* Unlock the persona for the current chat.
*/
async function unlockPersona() {
if (chat_metadata['persona']) {
console.log(`Unlocking persona for this chat ${chat_metadata['persona']}`);
delete chat_metadata['persona'];
await saveMetadata();
if (power_user.persona_show_notifications) {
toastr.info('User persona is now unlocked for this chat. Click the "Lock" again to revert.', 'Persona unlocked');
}
updateUserLockIcon();
}
}
/**
* Lock the persona for the current chat.
*/
async function lockPersona() {
if (!(user_avatar in power_user.personas)) {
console.log(`Creating a new persona ${user_avatar}`);
if (power_user.persona_show_notifications) {
toastr.info(
'Creating a new persona for currently selected user name and avatar...',
'Persona not set for this avatar',
{ timeOut: 10000, extendedTimeOut: 20000 },
);
}
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,
};
}
chat_metadata['persona'] = user_avatar;
await saveMetadata();
saveSettingsDebounced();
console.log(`Locking persona for this chat ${user_avatar}`);
if (power_user.persona_show_notifications) {
toastr.success(`User persona is locked to ${name1} in this chat`);
}
updateUserLockIcon();
}
async function deleteUserAvatar(e) {
e?.stopPropagation();
const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
if (!avatarId) {
console.warn('No avatar id found');
return;
}
if (avatarId == user_avatar) {
console.warn(`User tried to delete their current avatar ${avatarId}`);
toastr.warning('You cannot delete the avatar you are currently using', 'Warning');
return;
}
const confirm = await Popup.show.confirm('Are you sure you want to delete this avatar?', '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('The default persona was deleted. You will need to set a new default persona.', 'Default persona deleted');
power_user.default_persona = null;
}
if (avatarId === chat_metadata['persona']) {
toastr.warning('The locked persona was deleted. You will need to set a new persona for this chat.', 'Persona deleted');
delete chat_metadata['persona'];
await saveMetadata();
}
saveSettingsDebounced();
await getUserAvatars();
updateUserLockIcon();
}
}
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()),
};
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();
}
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,
};
power_user.persona_descriptions[user_avatar] = object;
}
return object;
}
async function setDefaultPersona(e) {
e?.stopPropagation();
const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
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('You must bind a name to this persona before you can set it as the default.', 'Persona name not set');
return;
}
const personaName = power_user.personas[avatarId];
if (avatarId === currentDefault) {
const confirm = await Popup.show.confirm('Are you sure you want to remove the default persona?', personaName);
if (!confirm) {
console.debug('User cancelled removing default persona');
return;
}
console.log(`Removing default persona ${avatarId}`);
if (power_user.persona_show_notifications) {
toastr.info('This persona will no longer be used by default when you open a new chat.', 'Default persona removed');
}
delete power_user.default_persona;
} else {
const confirm = await Popup.show.confirm(`Are you sure you want to set "${personaName}" as the default persona?`, '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) {
toastr.success('This persona will be used by default when you open a new chat.', `Default persona set to ${personaName}`);
}
}
saveSettingsDebounced();
await getUserAvatars(true, avatarId);
}
function updateUserLockIcon() {
const hasLock = !!chat_metadata['persona'];
$('#lock_user_name').toggleClass('fa-unlock', !hasLock);
$('#lock_user_name').toggleClass('fa-lock', hasLock);
}
async function setChatLockedPersona() {
// Define a persona for this chat
let chatPersona = '';
if (chat_metadata['persona']) {
// If persona is locked in chat metadata, select it
console.log(`Using locked persona ${chat_metadata['persona']}`);
chatPersona = chat_metadata['persona'];
} else if (power_user.default_persona) {
// If default persona is set, select it
console.log(`Using default persona ${power_user.default_persona}`);
chatPersona = power_user.default_persona;
}
// No persona set: user current settings
if (!chatPersona) {
console.debug('No default or locked persona set for this chat');
return;
}
// Find the avatar file
const userAvatars = await getUserAvatars(false);
// Avatar missing (persona deleted)
if (chat_metadata['persona'] && !userAvatars.includes(chatPersona)) {
console.warn('Persona avatar not found, unlocking persona');
delete chat_metadata['persona'];
updateUserLockIcon();
return;
}
// 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();
return;
}
// Persona avatar found, select it
setUserAvatar(chatPersona);
updateUserLockIcon();
}
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('Invalid file selected', '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('Invalid file format', '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_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('Personas restored with warnings. Check console for details.');
console.warn(`PERSONA RESTORE REPORT\n====================\n${warnings.join('\n')}`);
} else {
toastr.success('Personas restored successfully.');
}
await getUserAvatars();
setPersonaDescription();
saveSettingsDebounced();
$('#personas_restore_input').val('');
}
async function syncUserNameToPersona() {
const confirmation = await Popup.show.confirm('Are you sure?', `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();
}
export function retriggerFirstMessageOnEmptyChat() {
if (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');
return;
}
const confirm = await Popup.show.confirm('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,
};
await uploadUserAvatar(getUserAvatar(avatarId), newAvatarId);
await getUserAvatars(true, newAvatarId);
saveSettingsDebounced();
}
export function initPersonas() {
$(document).on('click', '.bind_user_name', bindUserNameToPersona);
$(document).on('click', '.set_default_persona', setDefaultPersona);
$(document).on('click', '.delete_avatar', deleteUserAvatar);
$('#lock_user_name').on('click', togglePersonaLock);
$('#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);
$('#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 = localStorage.getItem(GRID_STORAGE_KEY) === 'true';
localStorage.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('imgfile');
setUserAvatar(imgfile);
// force firstMes {{user}} update on persona switch
retriggerFirstMessageOnEmptyChat();
});
$('#your_name_button').click(async function () {
const userName = String($('#your_name').val()).trim();
setUserName(userName);
await updatePersonaNameIfExists(user_avatar, userName);
retriggerFirstMessageOnEmptyChat();
});
$(document).on('click', '#user_avatar_block .avatar_upload', function () {
$('#avatar_upload_overwrite').val('');
$('#avatar_upload_file').trigger('click');
});
$(document).on('click', '#user_avatar_block .duplicate_persona', function (e) {
e.stopPropagation();
const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
if (!avatarId) {
console.log('no imgfile');
return;
}
duplicatePersona(avatarId);
});
$(document).on('click', '#user_avatar_block .set_persona_image', function (e) {
e.stopPropagation();
const avatarId = $(this).closest('.avatar-container').find('.avatar').attr('imgfile');
if (!avatarId) {
console.log('no imgfile');
return;
}
$('#avatar_upload_overwrite').val(avatarId);
$('#avatar_upload_file').trigger('click');
});
eventSource.on('charManagementDropdown', (target) => {
if (target === 'convert_to_persona') {
convertCharacterToPersona();
}
});
eventSource.on(event_types.CHAT_CHANGED, setChatLockedPersona);
switchPersonaGridView();
}