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, +998 -207Ignore whitespace
public/scripts/personas.js+854 -49
@@ -22,7 +22,7 @@ import {
2222 setUserName,
2323 this_chid,
2424} from '../script.js';
2525import { persona_description_positions, power_user } from './power-user.js';
2626import { getTokenCountAsync } from './tokenizers.js';
2727import {
2828 PAGINATION_TEMPLATE,
@@ -47,6 +47,8 @@ import {
4747 sortIgnoreCaseAndAccents,
4848 equalsIgnoreCaseAndAccents,
4949 uuidv4,
50+ resolveAvatarData,
51+ findPersona,
5052} from './utils.js';
5153import { debounce_timeout } from './constants.js';
5254import { FILTER_TYPES, FilterHelper } from './filters.js';
@@ -59,10 +61,11 @@ import { saveMetadataDebounced } from './extensions.js';
5961import { accountStorage } from './util/AccountStorage.js';
6062import { SlashCommand } from './slash-commands/SlashCommand.js';
6163import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
6264import { commonEnumMatchProviders, commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
6365import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
6466import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
6567import { isFirefox } from './browser-fixes.js';
68+import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
6669
6770/**
6871 * @typedef {object} PersonaConnection A connection between a character and a character or group entity
@@ -81,6 +84,18 @@ import { isFirefox } from './browser-fixes.js';
8184 * @property {boolean} locked.character - Whether the persona is locked to the currently open character or group
8285 */
8386
87+export 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+
8499const USER_AVATAR_PATH = 'User Avatars/';
85100
86101let savePersonasPage = 0;
@@ -488,18 +503,28 @@ async function createDummyPersona() {
488503 * @param {string} personaName Name for the persona
489504 * @param {string} personaDescription Optional description for the persona
490505 * @param {string} personaTitle Optional title for the persona
491506 * @param {object} [options={}] Optional settings
492507 * @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
493512 * @returns {Promise<void>}
494513 */
495514export async function initPersona(avatarId, personaName, personaDescription, personaTitle, { silent = false } = {}) {
515+ silent = false,
516+ position = persona_description_positions.IN_PROMPT,
517+ depth = DEFAULT_DEPTH,
518+ role = DEFAULT_ROLE,
519+ lorebook = '',
520+} = {}) {
496521 power_user.personas[avatarId] = personaName;
497522 power_user.persona_descriptions[avatarId] = {
498523 description: personaDescription || '',
499524 position: persona_description_positions.IN_PROMPTposition,
500525 depth: DEFAULT_DEPTHdepth,
501526 role: DEFAULT_ROLErole,
502527 lorebook: ''lorebook,
503528 title: personaTitle || '',
504529 };
505530
@@ -773,15 +798,16 @@ export async function askForPersonaSelection(title, text, personas, { okButton =
773798/**
774799 * Automatically selects a persona based on the given name if a matching persona exists.
775800 * @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
776803 * @returns {Promise<boolean>} True if a matching persona was found and selected, false otherwise
777804 */
778805export 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 });
780807 if (value === namepersona) {
781808 console.log(`Auto-selecting persona ${keypersona.avatar} for name ${name}`);
782809 await setUserAvatar(keypersona.avatar);
783810 return true;
784- }
785811 }
786812 return false;
787813}
@@ -1106,21 +1132,37 @@ async function lockPersona(type = 'chat') {
11061132}
11071133
11081134
1135+/**
1136+ * Click handler for the delete persona button. Delegates to deletePersona with the current user avatar.
1137+ */
11091138async 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+ */
1149+async function deletePersona(avatarId, { silent = false } = {}) {
11121150 if (!avatarId) {
11131151 console.warn('No avatar id found');
11141152 return false;
11151153 }
1154+
11161155 const name = power_user.personas[avatarId] || '';
1117- const confirm = await Popup.show.confirm(
1118- 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.`);
11201156
11211157 if (!confirmsilent) {
1122- console.debug('User cancelled deleting avatar');
1158+ const confirm = await Popup.show.confirm(
1123- return;
1159+ t`Delete Persona` + `: ${name}`,
1160+ t`Are you sure you want to delete this avatar?` + '<br />' + t`All information associated with its linked persona will be lost.`);
1161+
1162+ if (!confirm) {
1163+ console.debug('User cancelled deleting avatar');
1164+ return false;
1165+ }
11241166 }
11251167
11261168 const request = await fetch('/api/avatars/delete', {
@@ -1137,12 +1179,12 @@ async function deleteUserAvatar() {
11371179 delete power_user.persona_descriptions[avatarId];
11381180
11391181 if (avatarId === power_user.default_persona) {
11401182 if (!silent) toastr.warning(t`The default persona was deleted. You will need to set a new default persona.`, t`Default Persona Deleted`);
11411183 power_user.default_persona = null;
11421184 }
11431185
11441186 if (avatarId === chat_metadata.persona) {
11451187 if (!silent) toastr.warning(t`The locked persona was deleted. You will need to set a new persona for this chat.`, t`Persona Deleted`);
11461188 delete chat_metadata.persona;
11471189 await saveMetadata();
11481190 }
@@ -1153,7 +1195,10 @@ async function deleteUserAvatar() {
11531195 // Use the existing mechanism to re-render the persona list and choose the next persona here
11541196 personaLastLoadedChatId = uuidv4(); // Force reload by making a dummy chat id
11551197 await loadPersonaForCurrentChat({ doRender: true });
1198+ return true;
11561199 }
1200+
1201+ return false;
11571202}
11581203
11591204async function onPersonaDescriptionInput() {
@@ -1839,22 +1884,27 @@ export async function retriggerFirstMessageOnEmptyChat() {
18391884
18401885/**
18411886 * Duplicates a persona.
18421887 * @param {string} avatarId Source persona avatar id
18431888 * @returnsparam {Promise<void>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
18441892 */
1845-async function duplicatePersona(avatarId) {
1893+async function duplicatePersona(avatarId, { silent = false, select = false } = {}) {
18461894 const personaName = power_user.personas[avatarId];
18471895
18481896 if (!personaName) {
18491897 toastr.warning('t`Chosen avatar is not a persona'`, t`Persona Management`);
18501898 return '';
18511899 }
18521900
1853- const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName);
1901+ if (!silent) {
1902+ const confirm = await Popup.show.confirm(t`Are you sure you want to duplicate this persona?`, personaName);
18541903
18551904 if (!confirm) {
18561905 console.debug('User cancelled duplicating persona');
1857- return;
1906+ return '';
1907+ }
18581908 }
18591909
18601910 const newAvatarId = `${Date.now()}-${personaName.replace(/[^a-zA-Z0-9]/g, '')}.png`;
@@ -1883,6 +1933,12 @@ async function duplicatePersona(avatarId) {
18831933
18841934 await getUserAvatars(true, newAvatarId);
18851935 saveSettingsDebounced();
1936+
1937+ if (select) {
1938+ await setUserAvatar(newAvatarId);
1939+ }
1940+
1941+ return newAvatarId;
18861942}
18871943
18881944/**
@@ -1899,6 +1955,412 @@ async function migrateNonPersonaUser() {
18991955}
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+ */
1964+const 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+ */
1976+const 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+ */
1987+function 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+ */
2001+function 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+ */
2018+async 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+ */
2066+function 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+ */
2094+async 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+ */
2151+async 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+ */
2273+async 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+ */
2332+async 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+ */
2346+async 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+
19022364/**
19032365 * Locks or unlocks the persona of the current chat.
19042366 * @param {{type: string}} _args Named arguments
@@ -1956,10 +2418,9 @@ async function setNameCallback({ mode = 'all' }, name) {
19562418
19572419 // If the name matches a persona avatar, or a name, auto-select it
19582420 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];
19612422 if (persona) {
19622423 await autoSelectPersona(persona.name, { personaKey: persona.avatar });
19632424 return '';
19642425 } else if (mode === 'lookup') {
19652426 toastr.warning(`Persona ${name} not found`);
@@ -2007,22 +2468,343 @@ function userMessageNamesEnumProvider() {
20072468}
20082469
20092470function 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+
20102779 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
20112780 name: 'persona-lock',
20122781 aliases: ['lock', 'bind'],
20132782 callback: lockPersonaCallback,
20142783 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+ `,
20162798 namedArgumentList: [
20172799 SlashCommandNamedArgument.fromProps({
20182800 name: 'type',
20192801 description: 't`The type of the lock, where it should apply to'`,
20202802 typeList: [ARGUMENT_TYPE.STRING],
20212803 defaultValue: 'chat',
20222804 enumList: [
20232805 new SlashCommandEnumValue('chat', 't`Lock the persona to the current chat.'`),
20242806 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.'`),
20252807 new SlashCommandEnumValue('default', 't`Lock this persona as the default persona for all new chats.'`),
20262808 ],
20272809 }),
20282810 ],
@@ -2034,26 +2816,49 @@ function registerPersonaSlashCommands() {
20342816 }),
20352817 ],
20362818 }));
2819+
20372820 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
20382821 name: 'persona-set',
20392822 callback: setNameCallback,
20402823 aliases: ['persona', 'name'],
20412824 namedArgumentList: [
20422825 new 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+ }),
20462836 ],
20472837 unnamedArgumentList: [
20482838 SlashCommandArgument.fromProps({
20492839 description: 'persona name',
20502840 typeList: [ARGUMENT_TYPE.STRING],
20512841 isRequired: true,
20522842 enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
20532843 }),
20542844 ],
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+ `,
20562860 }));
2861+
20572862 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
20582863 name: 'persona-sync',
20592864 aliases: ['sync'],
public/scripts/power-user.js+2 -11
@@ -68,6 +68,7 @@ import { bindModelTemplates } from './chat-templates.js';
6868import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js';
6969import { t } from './i18n.js';
7070import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js';
71+import { persona_description_positions as _persona_description_positions } from './personas.js';
7172
7273export const toastPositionClasses = [
7374 'toast-top-left',
@@ -110,17 +111,7 @@ export const send_on_enter_options = {
110111 ENABLED: 1,
111112};
112113
113114export 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
125116export const power_user = {
126117 charListGrid: false,
public/scripts/slash-commands.js+10 -143
@@ -1,5 +1,5 @@
11import { Fuse, DOMPurify } from '../lib.js';
22import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrlresolveAvatarData } from './utils.js';
33
44import {
55 Generate,
@@ -88,7 +88,7 @@ import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortC
8888import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
8989import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
9090import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
9191import { commonEnumProviders, enumIcons, commonEnumMatchProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
9292import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
9393import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
9494import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
@@ -768,23 +768,6 @@ export function initDefaultSlashCommands() {
768768 `,
769769 }));
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-
788771 // Shared character field definitions for char CRUD commands
789772 const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [
790773 SlashCommandNamedArgument.fromProps({
@@ -873,11 +856,11 @@ export function initDefaultSlashCommands() {
873856 isRequired: requiredFields.includes('avatar'),
874857 enumList: [
875858 new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'),
876859 new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'),
877860 new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'),
878861 new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'),
879862 new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'),
880863 new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => folderEnumMatchProvidercommonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'),
881864 ],
882865 }),
883866 SlashCommandNamedArgument.fromProps({
@@ -1241,7 +1224,7 @@ export function initDefaultSlashCommands() {
12411224 modifyAt = chat.length + modifyAt;
12421225 }
12431226 return chat[modifyAt]?.is_user
12441227 ? commonEnumProviders.personas()()
12451228 : commonEnumProviders.characters('character')();
12461229 },
12471230 }),
@@ -1769,7 +1752,7 @@ export function initDefaultSlashCommands() {
17691752 description: t`display name`,
17701753 typeList: [ARGUMENT_TYPE.STRING],
17711754 defaultValue: '{{user}}',
17721755 enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
17731756 }),
17741757 SlashCommandNamedArgument.fromProps({
17751758 name: 'return',
@@ -5034,23 +5017,6 @@ async function triggerGenerationCallback(args, value) {
50345017
50355018 return '';
50365019}
5037-/**
5038- * Find persona by name.
5039- * @param {string} name Name to search for
5040- * @returns {string} Persona name
5041- */
5042-function 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
50555021async function sendUserMessageCallback(args, text) {
50565022 text = String(text ?? '').trim();
@@ -5068,7 +5034,7 @@ async function sendUserMessageCallback(args, text) {
50685034 let message;
50695035 if ('name' in args) {
50705036 const name = args.name || '';
50715037 const avatar = findPersonaByNamefindPersona({ name })?.avatar || user_avatar;
50725038 message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar);
50735039 } else {
50745040 message = await sendMessageAsUser(text, bias, insertAt, compact);
@@ -5147,105 +5113,6 @@ async function openChat(chid) {
51475113}
51485114
51495115/**
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- */
5153-async 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- */
5187-async 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-/**
52495116 * Uploads an avatar image to a character.
52505117 * @param {string} avatarKey - The character's avatar filename (e.g., "name.png")
52515118 * @param {string} base64Data - Base64 data URL of the image
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+33 -4
@@ -214,9 +214,13 @@ export const commonEnumProviders = {
214214 /**
215215 * All possible personas
216216 *
217217 * @returns {() => SlashCommandEnumValue[]}
218218 */
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
221225 /**
222226 * All possible tags, or only those that have been assigned
@@ -234,9 +238,9 @@ export const commonEnumProviders = {
234238 * All possible tags for a given char/group entity
235239 *
236240 * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show
237241 * @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
238242 */
239243 tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor, _scope) => {
240244 // Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags.
241245 const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value;
242246 if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
@@ -347,3 +351,28 @@ export const commonEnumProviders = {
347351 ...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
348352 ],
349353};
354+
355+
356+/**
357+ * A collection of common enum match providers
358+ *
359+ * Can be used on `SlashCommandEnumValue` and their `matchProvider` property.
360+ */
361+export 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) {
17421742}
17431743
17441744/**
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+ */
1748+export 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+ */
1782+export 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+/**
17451844 * An array of all supported image MIME types.
17461845 */
17471846export const supportedImageMimeTypes = Object.freeze([