Add Persona CRUD Slash Commands with Shared Avatar Utilities (#5466) * Add persona CRUD slash commands with enhanced utilities - Add `/persona-create`, `/persona-update`, `/persona-delete`, `/persona-duplicate`, and `/persona-get` slash commands for programmatic persona management - Move `persona_description_positions` enum from power-user.js to personas.js for better encapsulation - Add position and role parsing utilities (`parsePersonaPosition`, `parsePersonaRole`) with name-to-value mapping - Extend `initPersona()` to accept optional position, depth, role, and lorebook parameters - Refactor `deleteUserAvatar()` to delegate to new `deletePersona * Add NaN validation for descriptionDepth parameter in createPersonaCallback - Add isNaN() check for depth parameter after Number() conversion - Display warning toast when invalid depth value is provided - Fall back to DEFAULT_DEPTH when depth is NaN - Change depth from const to let to allow reassignment after validation * Refactor persona lookup to support avatar key targeting and fix enum provider currying - Refactor `autoSelectPersona()` to accept optional `personaKey` parameter for targeting specific persona when multiple share the same name - Replace manual persona lookup loops with `findPersona()` helper calls in `autoSelectPersona()` and `setNameCallback()` - Update `setNameCallback()` to pass both persona name and avatar key to `autoSelectPersona()` for precise targeting - Refactor `commonEnumProviders.personas()`

8aeda4a101b5cd179029cbb9d4ba1f7392f08c1f

Wolfsblvt <wolfsblvt@gmail.com>

Signed
5 files changed, +989 -198Showing whitespace changes
public/scripts/personas.js+845 -40
@@ -22,7 +22,7 @@ import {
22 setUserName,22 setUserName,
23 this_chid,23 this_chid,
24} from '../script.js';24} from '../script.js';
25import { persona_description_positions, power_user } from './power-user.js';25import { power_user } from './power-user.js';
26import { getTokenCountAsync } from './tokenizers.js';26import { getTokenCountAsync } from './tokenizers.js';
27import {27import {
28 PAGINATION_TEMPLATE,28 PAGINATION_TEMPLATE,
@@ -47,6 +47,8 @@ import {
47 sortIgnoreCaseAndAccents,47 sortIgnoreCaseAndAccents,
48 equalsIgnoreCaseAndAccents,48 equalsIgnoreCaseAndAccents,
49 uuidv4,49 uuidv4,
50 resolveAvatarData,
51 findPersona,
50} from './utils.js';52} from './utils.js';
51import { debounce_timeout } from './constants.js';53import { debounce_timeout } from './constants.js';
52import { FILTER_TYPES, FilterHelper } from './filters.js';54import { FILTER_TYPES, FilterHelper } from './filters.js';
@@ -59,10 +61,11 @@ import { saveMetadataDebounced } from './extensions.js';
59import { accountStorage } from './util/AccountStorage.js';61import { accountStorage } from './util/AccountStorage.js';
60import { SlashCommand } from './slash-commands/SlashCommand.js';62import { SlashCommand } from './slash-commands/SlashCommand.js';
61import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';63import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
62import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';64import { commonEnumMatchProviders, commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
63import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';65import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
64import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';66import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
65import { isFirefox } from './browser-fixes.js';67import { isFirefox } from './browser-fixes.js';
68import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
6669
67/**70/**
68 * @typedef {object} PersonaConnection A connection between a character and a character or group entity71 * @typedef {object} PersonaConnection A connection between a character and a character or group entity
@@ -81,6 +84,18 @@ import { isFirefox } from './browser-fixes.js';
81 * @property {boolean} locked.character - Whether the persona is locked to the currently open character or group84 * @property {boolean} locked.character - Whether the persona is locked to the currently open character or group
82 */85 */
8386
87export const persona_description_positions = {
88 IN_PROMPT: 0,
89 /**
90 * @deprecated Use persona_description_positions.IN_PROMPT instead.
91 */
92 AFTER_CHAR: 1,
93 TOP_AN: 2,
94 BOTTOM_AN: 3,
95 AT_DEPTH: 4,
96 NONE: 9,
97};
98
84const USER_AVATAR_PATH = 'User Avatars/';99const USER_AVATAR_PATH = 'User Avatars/';
85100
86let savePersonasPage = 0;101let savePersonasPage = 0;
@@ -488,18 +503,28 @@ async function createDummyPersona() {
488 * @param {string} personaName Name for the persona503 * @param {string} personaName Name for the persona
489 * @param {string} personaDescription Optional description for the persona504 * @param {string} personaDescription Optional description for the persona
490 * @param {string} personaTitle Optional title for the persona505 * @param {string} personaTitle Optional title for the persona
491 * @param {object} [options] Optional settings506 * @param {object} [options={}] Optional settings
492 * @param {boolean} [options.silent=false] If true, no PERSONA_CREATED event is emitted (used for background migrations)507 * @param {boolean} [options.silent=false] If true, no PERSONA_CREATED event is emitted (used for background migrations)
508 * @param {number} [options.position=persona_description_positions.IN_PROMPT] Description position (defaults to IN_PROMPT)
509 * @param {number} [options.depth=DEFAULT_DEPTH] Description depth (defaults to DEFAULT_DEPTH)
510 * @param {number} [options.role=DEFAULT_ROLE] Description role (defaults to DEFAULT_ROLE)
511 * @param {string} [options.lorebook=''] Attached lorebook name
493 * @returns {Promise<void>}512 * @returns {Promise<void>}
494 */513 */
495export async function initPersona(avatarId, personaName, personaDescription, personaTitle, { silent = false } = {}) {514export async function initPersona(avatarId, personaName, personaDescription, personaTitle, {
515 silent = false,
516 position = persona_description_positions.IN_PROMPT,
517 depth = DEFAULT_DEPTH,
518 role = DEFAULT_ROLE,
519 lorebook = '',
520} = {}) {
496 power_user.personas[avatarId] = personaName;521 power_user.personas[avatarId] = personaName;
497 power_user.persona_descriptions[avatarId] = {522 power_user.persona_descriptions[avatarId] = {
498 description: personaDescription || '',523 description: personaDescription || '',
499 position: persona_description_positions.IN_PROMPT,524 position: position,
500 depth: DEFAULT_DEPTH,525 depth: depth,
501 role: DEFAULT_ROLE,526 role: role,
502 lorebook: '',527 lorebook: lorebook,
503 title: personaTitle || '',528 title: personaTitle || '',
504 };529 };
505530
@@ -773,16 +798,17 @@ export async function askForPersonaSelection(title, text, personas, { okButton =
773/**798/**
774 * Automatically selects a persona based on the given name if a matching persona exists.799 * Automatically selects a persona based on the given name if a matching persona exists.
775 * @param {string} name - The name to search for800 * @param {string} name - The name to search for
801 * @param {Object} [options={}]
802 * @param {string} [options.personaKey=null] - Optionally a persona avatar key to target (if multiple persona have the same name); must match the name
776 * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise803 * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise
777 */804 */
778export async function autoSelectPersona(name) {805export async function autoSelectPersona(name, { personaKey = null } = {}) {
779 for (const [key, value] of Object.entries(power_user.personas)) {806 const persona = findPersona({ name: personaKey ?? name, allowAvatar: !!personaKey });
780 if (value === name) {807 if (persona) {
781 console.log(`Auto-selecting persona ${key} for name ${name}`);808 console.log(`Auto-selecting persona ${persona.avatar} for name ${name}`);
782 await setUserAvatar(key);809 await setUserAvatar(persona.avatar);
783 return true;810 return true;
784 }811 }
785 }
786 return false;812 return false;
787}813}
788814
@@ -1106,21 +1132,37 @@ async function lockPersona(type = 'chat') {
1106}1132}
11071133
11081134
1135/**
1136 * Click handler for the delete persona button. Delegates to deletePersona with the current user avatar.
1137 */
1109async function deleteUserAvatar() {1138async function deleteUserAvatar() {
1110 const avatarId = user_avatar;1139 await deletePersona(user_avatar);
1140}
11111141
1142/**
1143 * Deletes a persona by avatar id.
1144 * @param {string} avatarId The persona's avatar id to delete
1145 * @param {object} [options] Options
1146 * @param {boolean} [options.silent=false] If true, skips the confirmation popup and suppresses toast notifications
1147 * @returns {Promise<boolean>} True if the persona was deleted
1148 */
1149async function deletePersona(avatarId, { silent = false } = {}) {
1112 if (!avatarId) {1150 if (!avatarId) {
1113 console.warn('No avatar id found');1151 console.warn('No avatar id found');
1114 return;1152 return false;
1115 }1153 }
1154
1116 const name = power_user.personas[avatarId] || '';1155 const name = power_user.personas[avatarId] || '';
1156
1157 if (!silent) {
1117 const confirm = await Popup.show.confirm(1158 const confirm = await Popup.show.confirm(
1118 t`Delete Persona` + `: ${name}`,1159 t`Delete Persona` + `: ${name}`,
1119 t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`);1160 t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`);
11201161
1121 if (!confirm) {1162 if (!confirm) {
1122 console.debug('User cancelled deleting avatar');1163 console.debug('User cancelled deleting avatar');
1123 return;1164 return false;
1165 }
1124 }1166 }
11251167
1126 const request = await fetch('/api/avatars/delete', {1168 const request = await fetch('/api/avatars/delete', {
@@ -1137,12 +1179,12 @@ async function deleteUserAvatar() {
1137 delete power_user.persona_descriptions[avatarId];1179 delete power_user.persona_descriptions[avatarId];
11381180
1139 if (avatarId === power_user.default_persona) {1181 if (avatarId === power_user.default_persona) {
1140 toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default Persona Deleted`);1182 if (!silent) toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default Persona Deleted`);
1141 power_user.default_persona = null;1183 power_user.default_persona = null;
1142 }1184 }
11431185
1144 if (avatarId === chat_metadata.persona) {1186 if (avatarId === chat_metadata.persona) {
1145 toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona Deleted`);1187 if (!silent) toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona Deleted`);
1146 delete chat_metadata.persona;1188 delete chat_metadata.persona;
1147 await saveMetadata();1189 await saveMetadata();
1148 }1190 }
@@ -1153,7 +1195,10 @@ async function deleteUserAvatar() {
1153 // Use the existing mechanism to re-render the persona list and choose the next persona here1195 // Use the existing mechanism to re-render the persona list and choose the next persona here
1154 personaLastLoadedChatId = uuidv4(); // Force reload by making a dummy chat id1196 personaLastLoadedChatId = uuidv4(); // Force reload by making a dummy chat id
1155 await loadPersonaForCurrentChat({ doRender: true });1197 await loadPersonaForCurrentChat({ doRender: true });
1198 return true;
1156 }1199 }
1200
1201 return false;
1157}1202}
11581203
1159async function onPersonaDescriptionInput() {1204async function onPersonaDescriptionInput() {
@@ -1839,22 +1884,27 @@ export async function retriggerFirstMessageOnEmptyChat() {
18391884
1840/**1885/**
1841 * Duplicates a persona.1886 * Duplicates a persona.
1842 * @param {string} avatarId1887 * @param {string} avatarId Source persona avatar id
1843 * @returns {Promise<void>}1888 * @param {object} [options] Options
1889 * @param {boolean} [options.silent=false] If true, skips the confirmation popup
1890 * @param {boolean} [options.select=false] If true, selects/activates the duplicated persona
1891 * @returns {Promise<string>} The avatar id of the new persona, or empty string on failure/cancellation
1844 */1892 */
1845async function duplicatePersona(avatarId) {1893async function duplicatePersona(avatarId, { silent = false, select = false } = {}) {
1846 const personaName = power_user.personas[avatarId];1894 const personaName = power_user.personas[avatarId];
18471895
1848 if (!personaName) {1896 if (!personaName) {
1849 toastr.warning('Chosen avatar is not a persona', t`Persona Management`);1897 toastr.warning(t`Chosen avatar is not a persona`, t`Persona Management`);
1850 return;1898 return '';
1851 }1899 }
18521900
1901 if (!silent) {
1853 const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName);1902 const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName);
18541903
1855 if (!confirm) {1904 if (!confirm) {
1856 console.debug('User cancelled duplicating persona');1905 console.debug('User cancelled duplicating persona');
1857 return;1906 return '';
1907 }
1858 }1908 }
18591909
1860 const newAvatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`;1910 const newAvatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
@@ -1883,6 +1933,12 @@ async function duplicatePersona(avatarId) {
18831933
1884 await getUserAvatars(true, newAvatarId);1934 await getUserAvatars(true, newAvatarId);
1885 saveSettingsDebounced();1935 saveSettingsDebounced();
1936
1937 if (select) {
1938 await setUserAvatar(newAvatarId);
1939 }
1940
1941 return newAvatarId;
1886}1942}
18871943
1888/**1944/**
@@ -1899,6 +1955,412 @@ async function migrateNonPersonaUser() {
1899}1955}
19001956
19011957
1958// #region Persona CRUD Slash Command Utilities
1959
1960/**
1961 * Mapping of human-readable position names to persona_description_positions enum values.
1962 * @type {Record<string, number>}
1963 */
1964const POSITION_NAME_MAP = Object.freeze({
1965 'inprompt': persona_description_positions.IN_PROMPT,
1966 'topan': persona_description_positions.TOP_AN,
1967 'bottoman': persona_description_positions.BOTTOM_AN,
1968 'atdepth': persona_description_positions.AT_DEPTH,
1969 'none': persona_description_positions.NONE,
1970});
1971
1972/**
1973 * Mapping of human-readable role names to numeric role values.
1974 * @type {Record<string, number>}
1975 */
1976const ROLE_NAME_MAP = Object.freeze({
1977 'system': 0,
1978 'user': 1,
1979 'assistant': 2,
1980});
1981
1982/**
1983 * Parses a persona description position from a string or number value.
1984 * @param {string|number|undefined} value Position value (name or number)
1985 * @returns {number|null} Parsed position value, or null if invalid/undefined
1986 */
1987function parsePersonaPosition(value) {
1988 if (value === undefined || value === null) return null;
1989 const strValue = String(value).toLowerCase();
1990 if (strValue in POSITION_NAME_MAP) return POSITION_NAME_MAP[strValue];
1991 const numValue = Number(value);
1992 if (!isNaN(numValue) && Object.values(persona_description_positions).includes(numValue)) return numValue;
1993 return null;
1994}
1995
1996/**
1997 * Parses a persona description role from a string or number value.
1998 * @param {string|number|undefined} value Role value (name or number)
1999 * @returns {number|null} Parsed role value, or null if invalid/undefined
2000 */
2001function parsePersonaRole(value) {
2002 if (value === undefined || value === null) return null;
2003 const strValue = String(value).toLowerCase();
2004 if (strValue in ROLE_NAME_MAP) return ROLE_NAME_MAP[strValue];
2005 const numValue = Number(value);
2006 if (!isNaN(numValue) && numValue >= 0 && numValue <= 2) return numValue;
2007 return null;
2008}
2009
2010/**
2011 * Uploads base64 avatar data to a persona, optionally showing a crop dialog.
2012 * @param {string} avatarId The persona's avatar file name
2013 * @param {string} base64Data Base64 data URL of the image
2014 * @param {object} [options] Options
2015 * @param {boolean} [options.resizePrompt=false] Whether to show the crop dialog
2016 * @returns {Promise<boolean>} True if upload was successful
2017 */
2018async function uploadPersonaAvatar(avatarId, base64Data, { resizePrompt = false } = {}) {
2019 if (!base64Data || !avatarId) return false;
2020
2021 let finalImageData = base64Data;
2022
2023 if (resizePrompt && !power_user.never_resize_avatars) {
2024 const dlg = new Popup(t`Set the crop position of the avatar image`, POPUP_TYPE.CROP, '', { cropImage: base64Data });
2025 const croppedImage = await dlg.show();
2026 if (!croppedImage) return false;
2027 finalImageData = String(croppedImage);
2028 }
2029
2030 try {
2031 const response = await fetch(finalImageData);
2032 const blob = await response.blob();
2033 const file = new File([blob], 'avatar.png', { type: 'image/png' });
2034 const formData = new FormData();
2035 formData.append('avatar', file);
2036 formData.append('overwrite_name', avatarId);
2037
2038 const uploadResponse = await fetch('/api/avatars/upload', {
2039 method: 'POST',
2040 headers: getRequestHeaders({ omitContentType: true }),
2041 cache: 'no-cache',
2042 body: formData,
2043 });
2044
2045 if (!uploadResponse.ok) {
2046 throw new Error(`Upload failed: ${uploadResponse.statusText}`);
2047 }
2048
2049 // Cache bust for the updated avatar
2050 await fetch(getUserAvatar(avatarId), { cache: 'reload' });
2051 await fetch(getThumbnailUrl('persona', avatarId), { cache: 'reload' });
2052 reloadUserAvatar(true);
2053 return true;
2054 } catch (error) {
2055 console.error('Error uploading persona avatar:', error);
2056 toastr.warning(t`Failed to upload avatar: ${error.message}`);
2057 return false;
2058 }
2059}
2060
2061/**
2062 * Resolves a persona from the given argument or falls back to the currently active persona.
2063 * @param {string} [personaArg] Persona name or avatar key argument
2064 * @returns {import('./utils.js').PersonaViewModel|null} The resolved persona, or null if not found
2065 */
2066function getTargetPersona(personaArg) {
2067 if (personaArg) {
2068 const persona = findPersona({ name: personaArg });
2069 if (!persona) {
2070 toastr.warning(t`Persona "${personaArg}" not found`);
2071 return null;
2072 }
2073 return persona;
2074 }
2075
2076 // Fall back to currently active persona
2077 const persona = findPersona({ preferCurrentPersona: true });
2078 if (!persona) {
2079 toastr.warning(t`No persona selected and no persona argument provided`);
2080 return null;
2081 }
2082 return persona;
2083}
2084
2085// #endregion
2086
2087// #region Persona CRUD Slash Command Callbacks
2088
2089/**
2090 * Creates a new persona with the specified attributes.
2091 * @param {object} args Named arguments from the slash command
2092 * @returns {Promise<string>} Avatar key of the created persona, or empty string on failure
2093 */
2094async function createPersonaCallback(args) {
2095 const name = args.name;
2096 if (!name || typeof name !== 'string' || !name.trim()) {
2097 toastr.warning(t`Persona name is required`);
2098 return '';
2099 }
2100
2101 const trimmedName = name.trim();
2102 const avatarId = `${Date.now()}-${trimmedName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
2103
2104 const description = args.description ?? '';
2105 const title = args.title ?? '';
2106 const position = parsePersonaPosition(args.descriptionPosition) ?? persona_description_positions.IN_PROMPT;
2107 const role = parsePersonaRole(args.descriptionRole) ?? DEFAULT_ROLE;
2108 const lorebook = args.lorebook ?? '';
2109
2110 let depth = args.descriptionDepth !== undefined ? Number(args.descriptionDepth) : DEFAULT_DEPTH;
2111 if (isNaN(depth)) {
2112 toastr.warning(t`Invalid description depth "${args.descriptionDepth}", defaulting to ${DEFAULT_DEPTH}`);
2113 depth = DEFAULT_DEPTH;
2114 }
2115
2116 // Initialize persona data with all fields
2117 await initPersona(avatarId, trimmedName, description, title, {
2118 position, depth, role, lorebook,
2119 });
2120
2121 // Handle avatar upload
2122 const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null;
2123 if (avatarData) {
2124 const resizePrompt = !isFalseBoolean(args.avatarPromptResize ?? 'true');
2125 const uploaded = await uploadPersonaAvatar(avatarId, avatarData, { resizePrompt });
2126 if (!uploaded) {
2127 // Crop was cancelled or upload failed — use default avatar
2128 await uploadUserAvatar(default_user_avatar, avatarId);
2129 }
2130 } else {
2131 await uploadUserAvatar(default_user_avatar, avatarId);
2132 }
2133
2134 saveSettingsDebounced();
2135 await getUserAvatars(true, avatarId);
2136
2137 // Select/activate if requested (default: true)
2138 if (!isFalseBoolean(args.select ?? 'true')) {
2139 await setUserAvatar(avatarId);
2140 }
2141
2142 toastr.success(t`Persona "${trimmedName}" created successfully`);
2143 return avatarId;
2144}
2145
2146/**
2147 * Updates an existing persona's attributes.
2148 * @param {object} args Named arguments from the slash command
2149 * @returns {Promise<string>} Avatar key of the updated persona, or empty string on failure
2150 */
2151async function updatePersonaCallback(args) {
2152 const persona = getTargetPersona(args.persona);
2153 if (!persona) return '';
2154
2155 const avatarId = persona.avatar;
2156 const descriptor = power_user.persona_descriptions[avatarId];
2157
2158 if (!descriptor) {
2159 toastr.warning(t`Persona data not found for "${persona.name}"`);
2160 return '';
2161 }
2162
2163 let hasUpdates = false;
2164
2165 // Update name
2166 if (args.name !== undefined) {
2167 const newName = String(args.name).trim();
2168 if (newName) {
2169 const oldName = power_user.personas[avatarId];
2170 power_user.personas[avatarId] = newName;
2171 if (avatarId === user_avatar) {
2172 setUserName(newName);
2173 }
2174 await eventSource.emit(event_types.PERSONA_RENAMED, { avatarId, oldName, newName });
2175 hasUpdates = true;
2176 }
2177 }
2178
2179 // Update description
2180 if (args.description !== undefined) {
2181 descriptor.description = args.description;
2182 if (avatarId === user_avatar) {
2183 power_user.persona_description = args.description;
2184 }
2185 hasUpdates = true;
2186 }
2187
2188 // Update title
2189 if (args.title !== undefined) {
2190 descriptor.title = args.title;
2191 hasUpdates = true;
2192 }
2193
2194 // Update description position
2195 if (args.descriptionPosition !== undefined) {
2196 const position = parsePersonaPosition(args.descriptionPosition);
2197 if (position !== null) {
2198 descriptor.position = position;
2199 if (avatarId === user_avatar) {
2200 power_user.persona_description_position = position;
2201 }
2202 hasUpdates = true;
2203 }
2204 }
2205
2206 // Update description depth
2207 if (args.descriptionDepth !== undefined) {
2208 const depth = Number(args.descriptionDepth);
2209 if (!isNaN(depth)) {
2210 descriptor.depth = depth;
2211 if (avatarId === user_avatar) {
2212 power_user.persona_description_depth = depth;
2213 }
2214 hasUpdates = true;
2215 }
2216 }
2217
2218 // Update description role
2219 if (args.descriptionRole !== undefined) {
2220 const role = parsePersonaRole(args.descriptionRole);
2221 if (role !== null) {
2222 descriptor.role = role;
2223 if (avatarId === user_avatar) {
2224 power_user.persona_description_role = role;
2225 }
2226 hasUpdates = true;
2227 }
2228 }
2229
2230 // Update lorebook
2231 if (args.lorebook !== undefined) {
2232 descriptor.lorebook = args.lorebook;
2233 if (avatarId === user_avatar) {
2234 power_user.persona_description_lorebook = args.lorebook;
2235 }
2236 hasUpdates = true;
2237 }
2238
2239 // Handle avatar
2240 const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null;
2241 if (avatarData) {
2242 const resizePrompt = !isFalseBoolean(args.avatarPromptResize ?? 'true');
2243 const uploaded = await uploadPersonaAvatar(avatarId, avatarData, { resizePrompt });
2244 if (uploaded) {
2245 hasUpdates = true;
2246 }
2247 }
2248
2249 if (!hasUpdates) {
2250 toastr.info(t`No fields provided to update`);
2251 return avatarId;
2252 }
2253
2254 saveSettingsDebounced();
2255 await eventSource.emit(event_types.PERSONA_UPDATED, avatarId);
2256
2257 // Refresh UI if the updated persona is the active one
2258 if (avatarId === user_avatar) {
2259 setPersonaDescription();
2260 }
2261 await getUserAvatars(true, avatarId);
2262 updatePersonaUIStates();
2263
2264 toastr.success(t`Persona "${power_user.personas[avatarId]}" updated successfully`);
2265 return avatarId;
2266}
2267
2268/**
2269 * Retrieves persona data or a specific field.
2270 * @param {object} args Named arguments from the slash command
2271 * @returns {Promise<string>} The persona data or field value
2272 */
2273async function getPersonaDataCallback(args) {
2274 const persona = getTargetPersona(args.persona);
2275 if (!persona) return '';
2276
2277 const avatarId = persona.avatar;
2278 const descriptor = power_user.persona_descriptions[avatarId] ?? {};
2279
2280 if (args.field) {
2281 /** @type {Record<string, unknown>} */
2282 const fieldMap = {
2283 name: power_user.personas[avatarId] ?? '',
2284 description: descriptor.description ?? '',
2285 title: descriptor.title ?? '',
2286 position: descriptor.position ?? persona_description_positions.IN_PROMPT,
2287 depth: descriptor.depth ?? DEFAULT_DEPTH,
2288 role: descriptor.role ?? DEFAULT_ROLE,
2289 lorebook: descriptor.lorebook ?? '',
2290 avatar: avatarId,
2291 default: power_user.default_persona === avatarId,
2292 connections: descriptor.connections ?? [],
2293 };
2294
2295 const value = fieldMap[args.field];
2296 if (value === undefined) {
2297 toastr.warning(t`Unknown persona field "${args.field}"`);
2298 return '';
2299 }
2300
2301 return await slashCommandReturnHelper.doReturn(
2302 args.return ?? 'pipe', value,
2303 { objectToStringFunc: x => typeof x === 'object' ? JSON.stringify(x) : String(x) },
2304 );
2305 }
2306
2307 // Return full persona data
2308 const personaData = {
2309 avatar: avatarId,
2310 name: power_user.personas[avatarId] ?? '',
2311 description: descriptor.description ?? '',
2312 title: descriptor.title ?? '',
2313 position: descriptor.position ?? persona_description_positions.IN_PROMPT,
2314 depth: descriptor.depth ?? DEFAULT_DEPTH,
2315 role: descriptor.role ?? DEFAULT_ROLE,
2316 lorebook: descriptor.lorebook ?? '',
2317 default: power_user.default_persona === avatarId,
2318 connections: descriptor.connections ?? [],
2319 };
2320
2321 return await slashCommandReturnHelper.doReturn(
2322 args.return ?? 'pipe', personaData,
2323 { objectToStringFunc: x => JSON.stringify(x, null, 2) },
2324 );
2325}
2326
2327/**
2328 * Deletes a persona via slash command.
2329 * @param {object} args Named arguments from the slash command
2330 * @returns {Promise<string>} 'true' if deleted, 'false' otherwise
2331 */
2332async function deletePersonaCallback(args) {
2333 const persona = getTargetPersona(args.persona);
2334 if (!persona) return 'false';
2335
2336 const silent = isTrueBoolean(args.silent);
2337 const success = await deletePersona(persona.avatar, { silent });
2338 return String(success);
2339}
2340
2341/**
2342 * Duplicates a persona via slash command.
2343 * @param {object} args Named arguments from the slash command
2344 * @returns {Promise<string>} Avatar key of the duplicated persona, or empty string on failure
2345 */
2346async function duplicatePersonaCallback(args) {
2347 const persona = getTargetPersona(args.persona);
2348 if (!persona) return '';
2349
2350 const shouldSelect = isTrueBoolean(args.select);
2351 const newAvatarId = await duplicatePersona(persona.avatar, { silent: true, select: shouldSelect });
2352
2353 if (!newAvatarId) {
2354 toastr.error(t`Failed to duplicate persona`);
2355 return '';
2356 }
2357
2358 toastr.success(t`Persona "${power_user.personas[newAvatarId]}" duplicated successfully`);
2359 return newAvatarId;
2360}
2361
2362// #endregion
2363
1902/**2364/**
1903 * Locks or unlocks the persona of the current chat.2365 * Locks or unlocks the persona of the current chat.
1904 * @param {{type: string}} _args Named arguments2366 * @param {{type: string}} _args Named arguments
@@ -1956,10 +2418,9 @@ async function setNameCallback({ mode = 'all' }, name) {
19562418
1957 // If the name matches a persona avatar, or a name, auto-select it2419 // If the name matches a persona avatar, or a name, auto-select it
1958 if (['lookup', 'all'].includes(mode)) {2420 if (['lookup', 'all'].includes(mode)) {
1959 let persona = Object.entries(power_user.personas).find(([avatar, _]) => avatar === name)?.[1];2421 const persona = findPersona({ name });
1960 if (!persona) persona = Object.entries(power_user.personas).find(([_, personaName]) => personaName.toLowerCase() === name.toLowerCase())?.[1];
1961 if (persona) {2422 if (persona) {
1962 await autoSelectPersona(persona);2423 await autoSelectPersona(persona.name, { personaKey: persona.avatar });
1963 return '';2424 return '';
1964 } else if (mode === 'lookup') {2425 } else if (mode === 'lookup') {
1965 toastr.warning(`Persona ${name} not found`);2426 toastr.warning(`Persona ${name} not found`);
@@ -2007,22 +2468,343 @@ function userMessageNamesEnumProvider() {
2007}2468}
20082469
2009function registerPersonaSlashCommands() {2470function registerPersonaSlashCommands() {
2471 // Shared persona field definitions for persona CRUD commands
2472 const getPersonaFieldArgs = ({ requiredFields = [] } = {}) => [
2473 SlashCommandNamedArgument.fromProps({
2474 name: 'name',
2475 description: t`The name of the persona`,
2476 typeList: [ARGUMENT_TYPE.STRING],
2477 isRequired: requiredFields.includes('name'),
2478 }),
2479 SlashCommandNamedArgument.fromProps({
2480 name: 'description',
2481 description: t`The persona description (sent with messages for AI context)`,
2482 typeList: [ARGUMENT_TYPE.STRING],
2483 isRequired: requiredFields.includes('description'),
2484 }),
2485 SlashCommandNamedArgument.fromProps({
2486 name: 'title',
2487 description: t`A display title for the persona (not sent to the AI, display only)`,
2488 typeList: [ARGUMENT_TYPE.STRING],
2489 isRequired: requiredFields.includes('title'),
2490 }),
2491 SlashCommandNamedArgument.fromProps({
2492 name: 'avatar',
2493 description: t`Avatar image. Use "prompt" to open file picker, or provide a local ST file path or base64 data URL. Can also be the return value of /imagine.`,
2494 typeList: [ARGUMENT_TYPE.STRING],
2495 isRequired: requiredFields.includes('avatar'),
2496 enumList: [
2497 new SlashCommandEnumValue('prompt', 'Open file picker to select an image', enumTypes.enum, '📁'),
2498 new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'),
2499 new SlashCommandEnumValue('backgrounds/...', 'Background image path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'),
2500 new SlashCommandEnumValue('User Avatars/...', 'User avatar path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'),
2501 new SlashCommandEnumValue('assets/...', 'Asset file path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'),
2502 new SlashCommandEnumValue('user/images/...', 'User image path', enumTypes.enum, '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'),
2503 ],
2504 }),
2505 SlashCommandNamedArgument.fromProps({
2506 name: 'avatarPromptResize',
2507 description: t`Whether to show the avatar resize/crop dialog when uploading. Ignored if "Never resize avatars" is enabled in settings.`,
2508 typeList: [ARGUMENT_TYPE.BOOLEAN],
2509 defaultValue: 'true',
2510 enumProvider: commonEnumProviders.boolean('trueFalse'),
2511 }),
2512 SlashCommandNamedArgument.fromProps({
2513 name: 'descriptionPosition',
2514 description: t`Where to inject the persona description in the prompt`,
2515 typeList: [ARGUMENT_TYPE.STRING],
2516 enumList: [
2517 new SlashCommandEnumValue('inPrompt', t`In Prompt (default)`, enumTypes.enum),
2518 new SlashCommandEnumValue('topAN', t`Top of Author's Note`, enumTypes.enum),
2519 new SlashCommandEnumValue('bottomAN', t`Bottom of Author's Note`, enumTypes.enum),
2520 new SlashCommandEnumValue('atDepth', t`At a specific depth (uses descriptionDepth and descriptionRole)`, enumTypes.enum),
2521 new SlashCommandEnumValue('none', t`None (don't inject)`, enumTypes.enum),
2522 ],
2523 }),
2524 SlashCommandNamedArgument.fromProps({
2525 name: 'descriptionDepth',
2526 description: t`Depth for the persona description (when position is "atDepth")`,
2527 typeList: [ARGUMENT_TYPE.NUMBER],
2528 }),
2529 SlashCommandNamedArgument.fromProps({
2530 name: 'descriptionRole',
2531 description: t`Role for the persona description (when position is "atDepth")`,
2532 typeList: [ARGUMENT_TYPE.STRING],
2533 enumList: commonEnumProviders.messageRoles(),
2534 }),
2535 SlashCommandNamedArgument.fromProps({
2536 name: 'lorebook',
2537 description: t`The name of the lorebook/world info to attach to this persona`,
2538 typeList: [ARGUMENT_TYPE.STRING],
2539 enumProvider: commonEnumProviders.worlds,
2540 }),
2541 ];
2542
2543 // Shared persona target argument (for commands that operate on an existing persona)
2544 const personaTargetArg = SlashCommandNamedArgument.fromProps({
2545 name: 'persona',
2546 description: t`Persona name or avatar key. If not provided, uses the currently active persona.`,
2547 typeList: [ARGUMENT_TYPE.STRING],
2548 enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
2549 });
2550
2551 // ========================
2552 // New CRUD commands
2553 // ========================
2554
2555 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2556 name: 'persona-create',
2557 callback: createPersonaCallback,
2558 returns: t`the avatar key (unique identifier) of the created persona`,
2559 namedArgumentList: [
2560 ...getPersonaFieldArgs({ requiredFields: ['name'] }),
2561 SlashCommandNamedArgument.fromProps({
2562 name: 'select',
2563 description: t`Whether to select/activate the persona after creation`,
2564 typeList: [ARGUMENT_TYPE.BOOLEAN],
2565 defaultValue: 'true',
2566 enumProvider: commonEnumProviders.boolean('trueFalse'),
2567 }),
2568 ],
2569 helpString: `
2570 <div>
2571 ${t`Creates a new persona with the specified attributes. Returns the avatar key of the created persona.`}
2572 </div>
2573 <div>
2574 <strong>${t`Required arguments:`}</strong>
2575 <ul>
2576 <li><code>name</code> – ${t`The persona's display name.`}</li>
2577 </ul>
2578 </div>
2579 <div>
2580 <strong>${t`Note on avatar:`}</strong>
2581 ${t`The <code>avatar</code> argument accepts <code>prompt</code> to open a file picker, a local ST file path, or a base64 data URL. Can also be the return value of <code>/imagine</code>. If not provided, a default avatar will be used.`}
2582 </div>
2583 <div>
2584 <strong>${t`Example:`}</strong>
2585 <ul>
2586 <li>
2587 <pre><code>/persona-create name="Alice" description="A curious adventurer"</code></pre>
2588 </li>
2589 <li>
2590 <pre><code>/persona-create name="Bob" avatar=prompt lorebook="detective_lore" select=false</code></pre>
2591 </li>
2592 <li>
2593 <pre><code>/imagine portrait of an elf | /persona-create name="Elf" avatar="{{pipe}}"</code></pre>
2594 </li>
2595 </ul>
2596 </div>
2597 `,
2598 }));
2599
2600 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2601 name: 'persona-update',
2602 callback: updatePersonaCallback,
2603 returns: t`the avatar key of the updated persona`,
2604 namedArgumentList: [
2605 personaTargetArg,
2606 ...getPersonaFieldArgs(),
2607 ],
2608 helpString: `
2609 <div>
2610 ${t`Updates an existing persona's attributes. Only the provided fields are changed; others are left untouched.`}
2611 </div>
2612 <div>
2613 ${t`If no <code>persona</code> argument is provided, updates the currently active persona.`}
2614 </div>
2615 <div>
2616 <strong>${t`Example:`}</strong>
2617 <ul>
2618 <li>
2619 <pre><code>/persona-update description="An updated description"</code></pre>
2620 ${t`Updates the current persona's description.`}
2621 </li>
2622 <li>
2623 <pre><code>/persona-update persona="Alice" name="Alice 2.0" descriptionPosition=atDepth descriptionDepth=3</code></pre>
2624 ${t`Renames Alice and sets her description to inject at depth 3.`}
2625 </li>
2626 <li>
2627 <pre><code>/imagine portrait | /persona-update avatar="{{pipe}}"</code></pre>
2628 ${t`Generates an image and sets it as the current persona's avatar.`}
2629 </li>
2630 </ul>
2631 </div>
2632 `,
2633 }));
2634
2635 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2636 name: 'persona-get',
2637 aliases: ['persona-data'],
2638 callback: getPersonaDataCallback,
2639 returns: t`persona data as JSON or a specific field value`,
2640 namedArgumentList: [
2641 personaTargetArg,
2642 SlashCommandNamedArgument.fromProps({
2643 name: 'field',
2644 description: t`Specific field to retrieve. If not provided, returns the entire persona data as JSON.`,
2645 typeList: [ARGUMENT_TYPE.STRING],
2646 enumList: [
2647 new SlashCommandEnumValue('name', t`Persona name`, enumTypes.enum, enumIcons.persona),
2648 new SlashCommandEnumValue('description', t`Persona description`, enumTypes.enum, enumIcons.default),
2649 new SlashCommandEnumValue('title', t`Display title`, enumTypes.enum, enumIcons.default),
2650 new SlashCommandEnumValue('position', t`Description position (numeric)`, enumTypes.enum, enumIcons.default),
2651 new SlashCommandEnumValue('depth', t`Description depth`, enumTypes.enum, enumIcons.default),
2652 new SlashCommandEnumValue('role', t`Description role (numeric)`, enumTypes.enum, enumIcons.default),
2653 new SlashCommandEnumValue('lorebook', t`Attached lorebook name`, enumTypes.enum, enumIcons.world),
2654 new SlashCommandEnumValue('avatar', t`Avatar filename (unique key)`, enumTypes.enum, enumIcons.persona),
2655 new SlashCommandEnumValue('default', t`Whether this is the default persona`, enumTypes.enum, enumIcons.default),
2656 new SlashCommandEnumValue('connections', t`Character/group connections (array)`, enumTypes.enum, enumIcons.character),
2657 ],
2658 }),
2659 SlashCommandNamedArgument.fromProps({
2660 name: 'return',
2661 description: t`The way to return the result`,
2662 typeList: [ARGUMENT_TYPE.STRING],
2663 defaultValue: 'pipe',
2664 enumList: slashCommandReturnHelper.enumList({ allowPipe: true, allowObject: true, allowPopup: true, allowTextVersion: false }),
2665 }),
2666 ],
2667 helpString: `
2668 <div>
2669 ${t`Retrieves persona data. Can return all data as JSON or a specific field value.`}
2670 </div>
2671 <div>
2672 ${t`If no <code>persona</code> argument is provided, uses the currently active persona.`}
2673 </div>
2674 <div>
2675 <strong>${t`Example:`}</strong>
2676 <ul>
2677 <li>
2678 <pre><code>/persona-get field=description | /echo</code></pre>
2679 ${t`Outputs the current persona's description.`}
2680 </li>
2681 <li>
2682 <pre><code>/persona-get persona="Alice" field=name</code></pre>
2683 ${t`Returns Alice's persona name.`}
2684 </li>
2685 <li>
2686 <pre><code>/persona-get return=object | /json-get key=avatar</code></pre>
2687 ${t`Returns the current persona's full data as an object, then extracts the avatar key.`}
2688 </li>
2689 </ul>
2690 </div>
2691 `,
2692 }));
2693
2694 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2695 name: 'persona-delete',
2696 callback: deletePersonaCallback,
2697 returns: t`true if the persona was deleted, false otherwise`,
2698 namedArgumentList: [
2699 personaTargetArg,
2700 SlashCommandNamedArgument.fromProps({
2701 name: 'silent',
2702 description: t`Skip the confirmation popup`,
2703 typeList: [ARGUMENT_TYPE.BOOLEAN],
2704 defaultValue: 'false',
2705 enumProvider: commonEnumProviders.boolean('trueFalse'),
2706 }),
2707 ],
2708 helpString: `
2709 <div>
2710 ${t`Deletes a persona and its avatar from the system.`}
2711 </div>
2712 <div>
2713 ${t`If no <code>persona</code> argument is provided, deletes the currently active persona.`}
2714 </div>
2715 <div>
2716 <strong>⚠️ ${t`Warning:`}</strong> ${t`This action is irreversible. All data associated with the persona will be lost.`}
2717 </div>
2718 <div>
2719 <strong>${t`Example:`}</strong>
2720 <ul>
2721 <li>
2722 <pre><code>/persona-delete</code></pre>
2723 ${t`Deletes the current persona (shows confirmation popup).`}
2724 </li>
2725 <li>
2726 <pre><code>/persona-delete persona="Bob" silent=true</code></pre>
2727 ${t`Deletes Bob without confirmation.`}
2728 </li>
2729 </ul>
2730 </div>
2731 `,
2732 }));
2733
2734 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2735 name: 'persona-duplicate',
2736 callback: duplicatePersonaCallback,
2737 returns: t`the avatar key (unique identifier) of the duplicated persona`,
2738 namedArgumentList: [
2739 personaTargetArg,
2740 SlashCommandNamedArgument.fromProps({
2741 name: 'select',
2742 description: t`Whether to select/activate the duplicated persona after creation`,
2743 typeList: [ARGUMENT_TYPE.BOOLEAN],
2744 defaultValue: 'false',
2745 enumProvider: commonEnumProviders.boolean('trueFalse'),
2746 }),
2747 ],
2748 helpString: `
2749 <div>
2750 ${t`Duplicates a persona including all its data and avatar. Returns the avatar key of the new persona.`}
2751 </div>
2752 <div>
2753 ${t`Use <code>/persona-update</code> afterwards to rename or modify the duplicated persona's fields.`}
2754 </div>
2755 <div>
2756 <strong>${t`Example:`}</strong>
2757 <ul>
2758 <li>
2759 <pre><code>/persona-duplicate</code></pre>
2760 ${t`Duplicates the currently active persona.`}
2761 </li>
2762 <li>
2763 <pre><code>/persona-duplicate persona="Alice" select=true</code></pre>
2764 ${t`Duplicates Alice and selects the new persona.`}
2765 </li>
2766 <li>
2767 <pre><code>/persona-duplicate | /persona-update persona="{{pipe}}" name="Clone"</code></pre>
2768 ${t`Duplicates the current persona, then renames the clone.`}
2769 </li>
2770 </ul>
2771 </div>
2772 `,
2773 }));
2774
2775 // ========================
2776 // Existing commands (enhanced help strings)
2777 // ========================
2778
2010 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2779 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2011 name: 'persona-lock',2780 name: 'persona-lock',
2012 aliases: ['lock', 'bind'],2781 aliases: ['lock', 'bind'],
2013 callback: lockPersonaCallback,2782 callback: lockPersonaCallback,
2014 returns: 'The current lock state for the given type',2783 returns: t`The current lock state for the given type`,
2015 helpString: 'Locks/unlocks a persona (name and avatar) to the current chat. Gets the current lock state for the given type if no state is provided.',2784 helpString: `
2785 <div>
2786 ${t`Locks/unlocks the current persona to a chat, character, or as the default. Returns the lock state if no value is provided.`}
2787 </div>
2788 <div>
2789 <strong>${t`Example:`}</strong>
2790 <ul>
2791 <li><pre><code>/persona-lock on</code></pre> ${t`Locks persona to this chat.`}</li>
2792 <li><pre><code>/persona-lock type=character on</code></pre> ${t`Locks persona to the current character.`}</li>
2793 <li><pre><code>/persona-lock type=default on</code></pre> ${t`Sets persona as the default for new chats.`}</li>
2794 <li><pre><code>/persona-lock</code></pre> ${t`Returns whether the persona is locked to this chat.`}</li>
2795 </ul>
2796 </div>
2797 `,
2016 namedArgumentList: [2798 namedArgumentList: [
2017 SlashCommandNamedArgument.fromProps({2799 SlashCommandNamedArgument.fromProps({
2018 name: 'type',2800 name: 'type',
2019 description: 'The type of the lock, where it should apply to',2801 description: t`The type of the lock, where it should apply to`,
2020 typeList: [ARGUMENT_TYPE.STRING],2802 typeList: [ARGUMENT_TYPE.STRING],
2021 defaultValue: 'chat',2803 defaultValue: 'chat',
2022 enumList: [2804 enumList: [
2023 new SlashCommandEnumValue('chat', 'Lock the persona to the current chat.'),2805 new SlashCommandEnumValue('chat', t`Lock the persona to the current chat.`),
2024 new SlashCommandEnumValue('character', 'Lock this persona to the currently selected character. If the setting is enabled, multiple personas can be locked to the same character.'),2806 new SlashCommandEnumValue('character', t`Lock this persona to the currently selected character. If the setting is enabled, multiple personas can be locked to the same character.`),
2025 new SlashCommandEnumValue('default', 'Lock this persona as the default persona for all new chats.'),2807 new SlashCommandEnumValue('default', t`Lock this persona as the default persona for all new chats.`),
2026 ],2808 ],
2027 }),2809 }),
2028 ],2810 ],
@@ -2034,26 +2816,49 @@ function registerPersonaSlashCommands() {
2034 }),2816 }),
2035 ],2817 ],
2036 }));2818 }));
2819
2037 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2820 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2038 name: 'persona-set',2821 name: 'persona-set',
2039 callback: setNameCallback,2822 callback: setNameCallback,
2040 aliases: ['persona', 'name'],2823 aliases: ['persona', 'name'],
2041 namedArgumentList: [2824 namedArgumentList: [
2042 new SlashCommandNamedArgument(2825 SlashCommandNamedArgument.fromProps({
2043 'mode', 'The mode for persona selection. ("lookup" = search for existing persona, "temp" = create a temporary name, set a temporary name, "all" = allow both in the same command)',2826 name: 'mode',
2044 [ARGUMENT_TYPE.STRING], false, false, 'all', ['lookup', 'temp', 'all'],2827 description: t`The mode for persona selection`,
2045 ),2828 typeList: [ARGUMENT_TYPE.STRING],
2829 defaultValue: 'all',
2830 enumList: [
2831 new SlashCommandEnumValue('lookup', t`Search for an existing persona only`),
2832 new SlashCommandEnumValue('temp', t`Set a temporary name only (no persona lookup)`),
2833 new SlashCommandEnumValue('all', t`Try persona lookup first, fall back to temporary name`),
2834 ],
2835 }),
2046 ],2836 ],
2047 unnamedArgumentList: [2837 unnamedArgumentList: [
2048 SlashCommandArgument.fromProps({2838 SlashCommandArgument.fromProps({
2049 description: 'persona name',2839 description: 'persona name',
2050 typeList: [ARGUMENT_TYPE.STRING],2840 typeList: [ARGUMENT_TYPE.STRING],
2051 isRequired: true,2841 isRequired: true,
2052 enumProvider: commonEnumProviders.personas,2842 enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
2053 }),2843 }),
2054 ],2844 ],
2055 helpString: 'Selects the given persona with its name and avatar (by name or avatar url). If no matching persona exists, applies a temporary name.',2845 helpString: `
2846 <div>
2847 ${t`Selects an existing persona by name or avatar key, or sets a temporary user name.`}
2848 </div>
2849 <div>
2850 ${t`If a matching persona exists, it will be selected with its name and avatar. Otherwise (in "all" or "temp" mode), only the display name is changed temporarily.`}
2851 </div>
2852 <div>
2853 <strong>${t`Example:`}</strong>
2854 <ul>
2855 <li><pre><code>/persona-set Alice</code></pre> ${t`Selects persona "Alice", or sets name to "Alice" if not found.`}</li>
2856 <li><pre><code>/persona-set mode=lookup Alice</code></pre> ${t`Only selects if persona "Alice" exists.`}</li>
2857 </ul>
2858 </div>
2859 `,
2056 }));2860 }));
2861
2057 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2862 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2058 name: 'persona-sync',2863 name: 'persona-sync',
2059 aliases: ['sync'],2864 aliases: ['sync'],
public/scripts/power-user.js+2 -11
@@ -68,6 +68,7 @@ import { bindModelTemplates } from './chat-templates.js';
68import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js';68import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js';
69import { t } from './i18n.js';69import { t } from './i18n.js';
70import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js';70import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js';
71import { persona_description_positions as _persona_description_positions } from './personas.js';
7172
72export const toastPositionClasses = [73export const toastPositionClasses = [
73 'toast-top-left',74 'toast-top-left',
@@ -110,17 +111,7 @@ export const send_on_enter_options = {
110 ENABLED: 1,111 ENABLED: 1,
111};112};
112113
113export const persona_description_positions = {114export const persona_description_positions = _persona_description_positions;
114 IN_PROMPT: 0,
115 /**
116 * @deprecated Use persona_description_positions.IN_PROMPT instead.
117 */
118 AFTER_CHAR: 1,
119 TOP_AN: 2,
120 BOTTOM_AN: 3,
121 AT_DEPTH: 4,
122 NONE: 9,
123};
124115
125export const power_user = {116export const power_user = {
126 charListGrid: false,117 charListGrid: false,
public/scripts/slash-commands.js+10 -143
@@ -1,5 +1,5 @@
1import { Fuse, DOMPurify } from '../lib.js';1import { Fuse, DOMPurify } from '../lib.js';
2import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrl } from './utils.js';2import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, resolveAvatarData } from './utils.js';
33
4import {4import {
5 Generate,5 Generate,
@@ -88,7 +88,7 @@ import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortC
88import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';88import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
89import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';89import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
90import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';90import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
91import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';91import { commonEnumProviders, enumIcons, commonEnumMatchProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
92import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';92import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
93import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';93import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
94import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';94import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
@@ -768,23 +768,6 @@ export function initDefaultSlashCommands() {
768 `,768 `,
769 }));769 }));
770770
771 /**
772 * Provides autocomplete matching for folder names.
773 * Matches if the input starts with the check or vice versa (case-insensitive).
774 * @param {string} input - The input string to match against
775 * @param {string} check - The check string to match with
776 * @param {object} [options={}] - Options
777 * @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty
778 * @returns {boolean} - True if the strings match according to the folder matching rules
779 */
780 function folderEnumMatchProvider(input, check, { trueOnEmpty = true } = {}) {
781 if (!check) return false;
782 if (!input) return trueOnEmpty;
783 const inputLower = input.toLowerCase();
784 const checkLower = check.toLowerCase();
785 return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower);
786 }
787
788 // Shared character field definitions for char CRUD commands771 // Shared character field definitions for char CRUD commands
789 const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [772 const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [
790 SlashCommandNamedArgument.fromProps({773 SlashCommandNamedArgument.fromProps({
@@ -873,11 +856,11 @@ export function initDefaultSlashCommands() {
873 isRequired: requiredFields.includes('avatar'),856 isRequired: requiredFields.includes('avatar'),
874 enumList: [857 enumList: [
875 new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'),858 new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'),
876 new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'characters/'), () => 'characters/'),859 new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'),
877 new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'backgrounds/'), () => 'backgrounds/'),860 new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'),
878 new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'User Avatars/'), () => 'User Avatars/'),861 new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'),
879 new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'assets/'), () => 'assets/'),862 new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'),
880 new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'user/images/'), () => 'user/images/'),863 new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'),
881 ],864 ],
882 }),865 }),
883 SlashCommandNamedArgument.fromProps({866 SlashCommandNamedArgument.fromProps({
@@ -1241,7 +1224,7 @@ export function initDefaultSlashCommands() {
1241 modifyAt = chat.length + modifyAt;1224 modifyAt = chat.length + modifyAt;
1242 }1225 }
1243 return chat[modifyAt]?.is_user1226 return chat[modifyAt]?.is_user
1244 ? commonEnumProviders.personas()1227 ? commonEnumProviders.personas()()
1245 : commonEnumProviders.characters('character')();1228 : commonEnumProviders.characters('character')();
1246 },1229 },
1247 }),1230 }),
@@ -1769,7 +1752,7 @@ export function initDefaultSlashCommands() {
1769 description: t`display name`,1752 description: t`display name`,
1770 typeList: [ARGUMENT_TYPE.STRING],1753 typeList: [ARGUMENT_TYPE.STRING],
1771 defaultValue: '{{user}}',1754 defaultValue: '{{user}}',
1772 enumProvider: commonEnumProviders.personas,1755 enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
1773 }),1756 }),
1774 SlashCommandNamedArgument.fromProps({1757 SlashCommandNamedArgument.fromProps({
1775 name: 'return',1758 name: 'return',
@@ -5034,23 +5017,6 @@ async function triggerGenerationCallback(args, value) {
50345017
5035 return '';5018 return '';
5036}5019}
5037/**
5038 * Find persona by name.
5039 * @param {string} name Name to search for
5040 * @returns {string} Persona name
5041 */
5042function findPersonaByName(name) {
5043 if (!name) {
5044 return null;
5045 }
5046
5047 for (const persona of Object.entries(power_user.personas)) {
5048 if (equalsIgnoreCaseAndAccents(persona[1], name)) {
5049 return persona[0];
5050 }
5051 }
5052 return null;
5053}
50545020
5055async function sendUserMessageCallback(args, text) {5021async function sendUserMessageCallback(args, text) {
5056 text = String(text ?? '').trim();5022 text = String(text ?? '').trim();
@@ -5068,7 +5034,7 @@ async function sendUserMessageCallback(args, text) {
5068 let message;5034 let message;
5069 if ('name' in args) {5035 if ('name' in args) {
5070 const name = args.name || '';5036 const name = args.name || '';
5071 const avatar = findPersonaByName(name) || user_avatar;5037 const avatar = findPersona({ name })?.avatar || user_avatar;
5072 message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar);5038 message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar);
5073 } else {5039 } else {
5074 message = await sendMessageAsUser(text, bias, insertAt, compact);5040 message = await sendMessageAsUser(text, bias, insertAt, compact);
@@ -5147,105 +5113,6 @@ async function openChat(chid) {
5147}5113}
51485114
5149/**5115/**
5150 * Opens a file picker dialog for selecting an image.
5151 * @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled
5152 */
5153async function promptForAvatarFile() {
5154 return new Promise(resolve => {
5155 const input = document.createElement('input');
5156 input.type = 'file';
5157 input.accept = supportedImageMimeTypes.join(',');
5158 input.onchange = async (e) => {
5159 if (!(e.target instanceof HTMLInputElement)) {
5160 return '';
5161 }
5162 const file = e.target?.files?.[0];
5163 if (!file) {
5164 resolve(null);
5165 return;
5166 }
5167 try {
5168 const converted = await ensureImageFormatSupported(file);
5169 const base64 = await getBase64Async(converted);
5170 resolve(base64);
5171 } catch (error) {
5172 console.error('Error processing selected image:', error);
5173 toastr.error(t`Failed to process selected image: ${error.message}`);
5174 resolve(null);
5175 }
5176 };
5177 input.oncancel = () => resolve(null);
5178 input.click();
5179 });
5180}
5181
5182/**
5183 * Resolves avatar data from various input formats (base64, local path, or prompt).
5184 * @param {string} input - "prompt" to open file picker, base64 data URL, or local file path
5185 * @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled
5186 */
5187async function resolveAvatarData(input) {
5188 if (!input || typeof input !== 'string') {
5189 return null;
5190 }
5191
5192 const trimmed = input.trim();
5193
5194 // Special value "prompt" opens file picker
5195 if (trimmed.toLowerCase() === 'prompt') {
5196 return await promptForAvatarFile();
5197 }
5198
5199 // Already a base64 data URL
5200 if (trimmed.startsWith('data:image/')) {
5201 return trimmed;
5202 }
5203
5204 // External URLs are not supported
5205 if (isExternalUrl(trimmed)) {
5206 toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`);
5207 return null;
5208 }
5209 // Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin
5210 // Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/*
5211 // Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png)
5212 if (trimmed.includes('/') || trimmed.endsWith('.png')) {
5213 try {
5214 // Construct the URL to fetch the local file
5215 let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
5216 // Handle same-origin URLs
5217 if (trimmed.startsWith(window.location.origin)) {
5218 url = new URL(trimmed).pathname;
5219 }
5220 // If there is no subfolder, we guess this should be a character image
5221 if (!url.includes('/', 1)) {
5222 url = '/characters/' + trimmed;
5223 }
5224
5225 const response = await fetch(url);
5226 if (!response.ok) {
5227 throw new Error(`File not found or inaccessible: ${response.status}`);
5228 }
5229 const blob = await response.blob();
5230 if (!blob.type.startsWith('image/')) {
5231 throw new Error('File is not an image');
5232 }
5233 const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type }));
5234 return await getBase64Async(converted);
5235 } catch (error) {
5236 console.error('Error fetching local avatar:', error);
5237 toastr.warning(t`Failed to load avatar from path: ${error.message}`);
5238 return null;
5239 }
5240 }
5241
5242 // Unknown format
5243 console.warn('Unknown avatar format:', trimmed.substring(0, 50));
5244 toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`);
5245 return null;
5246}
5247
5248/**
5249 * Uploads an avatar image to a character.5116 * Uploads an avatar image to a character.
5250 * @param {string} avatarKey - The character's avatar filename (e.g., "name.png")5117 * @param {string} avatarKey - The character's avatar filename (e.g., "name.png")
5251 * @param {string} base64Data - Base64 data URL of the image5118 * @param {string} base64Data - Base64 data URL of the image
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+33 -4
@@ -214,9 +214,13 @@ export const commonEnumProviders = {
214 /**214 /**
215 * All possible personas215 * All possible personas
216 *216 *
217 * @returns {SlashCommandEnumValue[]}217 * @returns {() => SlashCommandEnumValue[]}
218 */218 */
219 personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),219 personas: ({ allowPersonaKey = false } = {}) => () => Object.entries(power_user.personas).map(([personaKey, personaName]) => {
220 const existsMultiple = Object.values(power_user.personas).filter(p => p === personaName).length > 1;
221 const returnValue = allowPersonaKey && existsMultiple ? personaKey : personaName;
222 return new SlashCommandEnumValue(returnValue, allowPersonaKey && existsMultiple ? personaName : null, enumTypes.name, enumIcons.persona);
223 }),
220224
221 /**225 /**
222 * All possible tags, or only those that have been assigned226 * All possible tags, or only those that have been assigned
@@ -234,9 +238,9 @@ export const commonEnumProviders = {
234 * All possible tags for a given char/group entity238 * All possible tags for a given char/group entity
235 *239 *
236 * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show240 * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show
237 * @returns {() => SlashCommandEnumValue[]}241 * @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
238 */242 */
239 tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor) => {243 tagsForChar: (mode = 'all') => (executor, _scope) => {
240 // Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags.244 // Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags.
241 const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value;245 const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value;
242 if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');246 if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
@@ -347,3 +351,28 @@ export const commonEnumProviders = {
347 ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),351 ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
348 ],352 ],
349};353};
354
355
356/**
357 * A collection of common enum match providers
358 *
359 * Can be used on `SlashCommandEnumValue` and their `matchProvider` property.
360 */
361export const commonEnumMatchProviders = {
362 /**
363 * Provides autocomplete matching for folder-like enum values.
364 * Matches if the input starts with the check or vice versa (case-insensitive).
365 * @param {string} input - The input string to match against
366 * @param {string} check - The check string to match with
367 * @param {object} [options={}] - Options
368 * @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty
369 * @returns {boolean} - True if the strings match according to the folder matching rules
370 */
371 folderEnum: (input, check, { trueOnEmpty = true } = {}) => {
372 if (!check) return false;
373 if (!input) return trueOnEmpty;
374 const inputLower = input.toLowerCase();
375 const checkLower = check.toLowerCase();
376 return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower);
377 },
378};
public/scripts/utils.js+99 -0
@@ -1742,6 +1742,105 @@ export function loadFileToDocument(url, type) {
1742}1742}
17431743
1744/**1744/**
1745 * Opens a file picker dialog for selecting an image.
1746 * @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled
1747 */
1748export async function promptForAvatarFile() {
1749 return new Promise(resolve => {
1750 const input = document.createElement('input');
1751 input.type = 'file';
1752 input.accept = supportedImageMimeTypes.join(',');
1753 input.onchange = async (e) => {
1754 if (!(e.target instanceof HTMLInputElement)) {
1755 return '';
1756 }
1757 const file = e.target?.files?.[0];
1758 if (!file) {
1759 resolve(null);
1760 return;
1761 }
1762 try {
1763 const converted = await ensureImageFormatSupported(file);
1764 const base64 = await getBase64Async(converted);
1765 resolve(base64);
1766 } catch (error) {
1767 console.error('Error processing selected image:', error);
1768 toastr.error(t`Failed to process selected image: ${error.message}`);
1769 resolve(null);
1770 }
1771 };
1772 input.oncancel = () => resolve(null);
1773 input.click();
1774 });
1775}
1776
1777/**
1778 * Resolves avatar data from various input formats (base64, local path, or prompt).
1779 * @param {string} input - "prompt" to open file picker, base64 data URL, or local file path
1780 * @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled
1781 */
1782export async function resolveAvatarData(input) {
1783 if (!input || typeof input !== 'string') {
1784 return null;
1785 }
1786
1787 const trimmed = input.trim();
1788
1789 // Special value "prompt" opens file picker
1790 if (trimmed.toLowerCase() === 'prompt') {
1791 return await promptForAvatarFile();
1792 }
1793
1794 // Already a base64 data URL
1795 if (trimmed.startsWith('data:image/')) {
1796 return trimmed;
1797 }
1798
1799 // External URLs are not supported
1800 if (isExternalUrl(trimmed)) {
1801 toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`);
1802 return null;
1803 }
1804 // Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin
1805 // Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/*
1806 // Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png)
1807 if (trimmed.includes('/') || trimmed.endsWith('.png')) {
1808 try {
1809 // Construct the URL to fetch the local file
1810 let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
1811 // Handle same-origin URLs
1812 if (trimmed.startsWith(window.location.origin)) {
1813 url = new URL(trimmed).pathname;
1814 }
1815 // If there is no subfolder, we guess this should be a character image
1816 if (!url.includes('/', 1)) {
1817 url = '/characters/' + trimmed;
1818 }
1819
1820 const response = await fetch(url);
1821 if (!response.ok) {
1822 throw new Error(`File not found or inaccessible: ${response.status}`);
1823 }
1824 const blob = await response.blob();
1825 if (!blob.type.startsWith('image/')) {
1826 throw new Error('File is not an image');
1827 }
1828 const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type }));
1829 return await getBase64Async(converted);
1830 } catch (error) {
1831 console.error('Error fetching local avatar:', error);
1832 toastr.warning(t`Failed to load avatar from path: ${error.message}`);
1833 return null;
1834 }
1835 }
1836
1837 // Unknown format
1838 console.warn('Unknown avatar format:', trimmed.substring(0, 50));
1839 toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`);
1840 return null;
1841}
1842
1843/**
1745 * An array of all supported image MIME types.1844 * An array of all supported image MIME types.
1746 */1845 */
1747export const supportedImageMimeTypes = Object.freeze([1846export const supportedImageMimeTypes = Object.freeze([