mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-03-16 20:10:14 +01:00
1620 lines
62 KiB
JavaScript
1620 lines
62 KiB
JavaScript
import {
|
|
buildAvatarList,
|
|
characterToEntity,
|
|
characters,
|
|
chat,
|
|
chat_metadata,
|
|
default_user_avatar,
|
|
eventSource,
|
|
event_types,
|
|
getRequestHeaders,
|
|
getThumbnailUrl,
|
|
groupToEntity,
|
|
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, onlyUnique, parseJsonFile, setInfoBlock } 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';
|
|
|
|
/**
|
|
* @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;
|
|
export let user_avatar = '';
|
|
export const personasFilter = new FilterHelper(debounce(getUserAvatars, debounce_timeout.quick));
|
|
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export function setUserAvatar(imgfile, { toastPersonaNameChange = true } = {}) {
|
|
user_avatar = imgfile && typeof imgfile === 'string' ? imgfile : $(this).attr('data-avatar-id');
|
|
reloadUserAvatar();
|
|
updatePersonaUIStates();
|
|
selectCurrentPersona({ toastPersonaNameChange: toastPersonaNameChange });
|
|
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 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} 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;
|
|
}
|
|
|
|
/**
|
|
* 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(accountStorage.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));
|
|
}
|
|
updatePersonaUIStates();
|
|
},
|
|
afterSizeSelectorChange: function (e) {
|
|
accountStorage.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 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();
|
|
}
|
|
|
|
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(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;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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() {
|
|
$('#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);
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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(`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 confirm = await Popup.show.confirm(t`Delete Persona`,
|
|
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[this_chid]?.avatar)
|
|
|| (selected_group && c.type === 'group' && c.id === selected_group));
|
|
|
|
return {
|
|
avatarId: avatarId,
|
|
default: isDefaultPersona,
|
|
locked: {
|
|
chat: hasChatLock,
|
|
character: hasCharLock,
|
|
},
|
|
};
|
|
}
|
|
|
|
function updatePersonaUIStates() {
|
|
// 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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if the currently selected persona is temporary due to either a different default persona
|
|
* or a different persona being locked to the current chat. If so, it also returns a string that
|
|
* can be used to describe this situation to the user.
|
|
*
|
|
* @returns {{isTemporary: boolean, hasDifferentChatLock: boolean, hasDifferentDefaultLock: boolean, info: string?}} An object containing 4 properties:
|
|
* - isTemporary: A boolean indicating if the current persona is temporary
|
|
* - hasDifferentChatLock: A boolean indicating if the current chat has a different persona locked to it
|
|
* - hasDifferentDefaultLock: A boolean indicating if there is a different default persona set
|
|
* - info: A string describing the situation, or an empty if not temporary
|
|
*/
|
|
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,
|
|
};
|
|
}
|
|
|
|
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';
|
|
}
|
|
|
|
// 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) {
|
|
const willAutoLock = power_user.persona_auto_lock && user_avatar !== chat_metadata['persona'];
|
|
setUserAvatar(chatPersona, { toastPersonaNameChange: false });
|
|
|
|
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 });
|
|
}
|
|
}
|
|
|
|
updatePersonaUIStates();
|
|
}
|
|
|
|
/**
|
|
* 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[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;
|
|
}
|
|
|
|
/**
|
|
* 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[this_chid]?.avatar)
|
|
return { type: 'character', id: characters[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();
|
|
}
|
|
|
|
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', 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.
|
|
*/
|
|
function migrateNonPersonaUser() {
|
|
if (user_avatar in power_user.personas) {
|
|
return;
|
|
}
|
|
|
|
power_user.personas[user_avatar] = name1;
|
|
void getOrCreatePersonaDescriptor();
|
|
setPersonaDescription();
|
|
saveSettingsDebounced();
|
|
}
|
|
|
|
export function initPersonas() {
|
|
migrateNonPersonaUser();
|
|
$('#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);
|
|
|
|
// force firstMes {{user}} update on persona switch
|
|
retriggerFirstMessageOnEmptyChat();
|
|
});
|
|
|
|
$('#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');
|
|
});
|
|
|
|
eventSource.on('charManagementDropdown', (target) => {
|
|
if (target === 'convert_to_persona') {
|
|
convertCharacterToPersona();
|
|
}
|
|
});
|
|
eventSource.on(event_types.CHAT_CHANGED, loadPersonaForCurrentChat);
|
|
switchPersonaGridView();
|
|
}
|