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()`
This commit is contained in:
Wolfsblvt
2026-04-20 01:26:08 +02:00
committed by GitHub
parent 15a3e3f072
commit 8aeda4a101
5 changed files with 999 additions and 208 deletions
File diff suppressed because it is too large Load Diff
+2 -11
View File
@@ -68,6 +68,7 @@ import { bindModelTemplates } from './chat-templates.js';
import { IMAGE_OVERSWIPE, MEDIA_DISPLAY } from './constants.js';
import { t } from './i18n.js';
import { getBackgroundPath, isCustomBackgroundUrl } from './backgrounds.js';
import { persona_description_positions as _persona_description_positions } from './personas.js';
export const toastPositionClasses = [
'toast-top-left',
@@ -110,17 +111,7 @@ export const send_on_enter_options = {
ENABLED: 1,
};
export const persona_description_positions = {
IN_PROMPT: 0,
/**
* @deprecated Use persona_description_positions.IN_PROMPT instead.
*/
AFTER_CHAR: 1,
TOP_AN: 2,
BOTTOM_AN: 3,
AT_DEPTH: 4,
NONE: 9,
};
export const persona_description_positions = _persona_description_positions;
export const power_user = {
charListGrid: false,
+10 -143
View File
@@ -1,5 +1,5 @@
import { Fuse, DOMPurify } from '../lib.js';
import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrl } from './utils.js';
import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, resolveAvatarData } from './utils.js';
import {
Generate,
@@ -88,7 +88,7 @@ import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortC
import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js';
import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js';
import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { commonEnumProviders, enumIcons, commonEnumMatchProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
import { slashCommandReturnHelper } from './slash-commands/SlashCommandReturnHelper.js';
@@ -768,23 +768,6 @@ export function initDefaultSlashCommands() {
`,
}));
/**
* Provides autocomplete matching for folder names.
* Matches if the input starts with the check or vice versa (case-insensitive).
* @param {string} input - The input string to match against
* @param {string} check - The check string to match with
* @param {object} [options={}] - Options
* @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty
* @returns {boolean} - True if the strings match according to the folder matching rules
*/
function folderEnumMatchProvider(input, check, { trueOnEmpty = true } = {}) {
if (!check) return false;
if (!input) return trueOnEmpty;
const inputLower = input.toLowerCase();
const checkLower = check.toLowerCase();
return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower);
}
// Shared character field definitions for char CRUD commands
const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [
SlashCommandNamedArgument.fromProps({
@@ -873,11 +856,11 @@ export function initDefaultSlashCommands() {
isRequired: requiredFields.includes('avatar'),
enumList: [
new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'),
new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'characters/'), () => 'characters/'),
new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'backgrounds/'), () => 'backgrounds/'),
new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'User Avatars/'), () => 'User Avatars/'),
new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'assets/'), () => 'assets/'),
new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'user/images/'), () => 'user/images/'),
new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'characters/'), () => 'characters/'),
new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'backgrounds/'), () => 'backgrounds/'),
new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'User Avatars/'), () => 'User Avatars/'),
new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'assets/'), () => 'assets/'),
new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => commonEnumMatchProviders.folderEnum(input, 'user/images/'), () => 'user/images/'),
],
}),
SlashCommandNamedArgument.fromProps({
@@ -1241,7 +1224,7 @@ export function initDefaultSlashCommands() {
modifyAt = chat.length + modifyAt;
}
return chat[modifyAt]?.is_user
? commonEnumProviders.personas()
? commonEnumProviders.personas()()
: commonEnumProviders.characters('character')();
},
}),
@@ -1769,7 +1752,7 @@ export function initDefaultSlashCommands() {
description: t`display name`,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: '{{user}}',
enumProvider: commonEnumProviders.personas,
enumProvider: commonEnumProviders.personas({ allowPersonaKey: true }),
}),
SlashCommandNamedArgument.fromProps({
name: 'return',
@@ -5034,23 +5017,6 @@ async function triggerGenerationCallback(args, value) {
return '';
}
/**
* Find persona by name.
* @param {string} name Name to search for
* @returns {string} Persona name
*/
function findPersonaByName(name) {
if (!name) {
return null;
}
for (const persona of Object.entries(power_user.personas)) {
if (equalsIgnoreCaseAndAccents(persona[1], name)) {
return persona[0];
}
}
return null;
}
async function sendUserMessageCallback(args, text) {
text = String(text ?? '').trim();
@@ -5068,7 +5034,7 @@ async function sendUserMessageCallback(args, text) {
let message;
if ('name' in args) {
const name = args.name || '';
const avatar = findPersonaByName(name) || user_avatar;
const avatar = findPersona({ name })?.avatar || user_avatar;
message = await sendMessageAsUser(text, bias, insertAt, compact, name, avatar);
} else {
message = await sendMessageAsUser(text, bias, insertAt, compact);
@@ -5146,105 +5112,6 @@ async function openChat(chid) {
await reloadCurrentChat();
}
/**
* Opens a file picker dialog for selecting an image.
* @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled
*/
async function promptForAvatarFile() {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = supportedImageMimeTypes.join(',');
input.onchange = async (e) => {
if (!(e.target instanceof HTMLInputElement)) {
return '';
}
const file = e.target?.files?.[0];
if (!file) {
resolve(null);
return;
}
try {
const converted = await ensureImageFormatSupported(file);
const base64 = await getBase64Async(converted);
resolve(base64);
} catch (error) {
console.error('Error processing selected image:', error);
toastr.error(t`Failed to process selected image: ${error.message}`);
resolve(null);
}
};
input.oncancel = () => resolve(null);
input.click();
});
}
/**
* Resolves avatar data from various input formats (base64, local path, or prompt).
* @param {string} input - "prompt" to open file picker, base64 data URL, or local file path
* @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled
*/
async function resolveAvatarData(input) {
if (!input || typeof input !== 'string') {
return null;
}
const trimmed = input.trim();
// Special value "prompt" opens file picker
if (trimmed.toLowerCase() === 'prompt') {
return await promptForAvatarFile();
}
// Already a base64 data URL
if (trimmed.startsWith('data:image/')) {
return trimmed;
}
// External URLs are not supported
if (isExternalUrl(trimmed)) {
toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`);
return null;
}
// Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin
// Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/*
// Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png)
if (trimmed.includes('/') || trimmed.endsWith('.png')) {
try {
// Construct the URL to fetch the local file
let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
// Handle same-origin URLs
if (trimmed.startsWith(window.location.origin)) {
url = new URL(trimmed).pathname;
}
// If there is no subfolder, we guess this should be a character image
if (!url.includes('/', 1)) {
url = '/characters/' + trimmed;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`File not found or inaccessible: ${response.status}`);
}
const blob = await response.blob();
if (!blob.type.startsWith('image/')) {
throw new Error('File is not an image');
}
const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type }));
return await getBase64Async(converted);
} catch (error) {
console.error('Error fetching local avatar:', error);
toastr.warning(t`Failed to load avatar from path: ${error.message}`);
return null;
}
}
// Unknown format
console.warn('Unknown avatar format:', trimmed.substring(0, 50));
toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`);
return null;
}
/**
* Uploads an avatar image to a character.
* @param {string} avatarKey - The character's avatar filename (e.g., "name.png")
@@ -214,9 +214,13 @@ export const commonEnumProviders = {
/**
* All possible personas
*
* @returns {SlashCommandEnumValue[]}
* @returns {() => SlashCommandEnumValue[]}
*/
personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),
personas: ({ allowPersonaKey = false } = {}) => () => Object.entries(power_user.personas).map(([personaKey, personaName]) => {
const existsMultiple = Object.values(power_user.personas).filter(p => p === personaName).length > 1;
const returnValue = allowPersonaKey && existsMultiple ? personaKey : personaName;
return new SlashCommandEnumValue(returnValue, allowPersonaKey && existsMultiple ? personaName : null, enumTypes.name, enumIcons.persona);
}),
/**
* All possible tags, or only those that have been assigned
@@ -234,9 +238,9 @@ export const commonEnumProviders = {
* All possible tags for a given char/group entity
*
* @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show
* @returns {() => SlashCommandEnumValue[]}
* @returns {(executor:SlashCommandExecutor, scope:SlashCommandScope) => SlashCommandEnumValue[]}
*/
tagsForChar: (mode = 'all') => (/** @type {SlashCommandExecutor} */ executor) => {
tagsForChar: (mode = 'all') => (executor, _scope) => {
// Try to see if we can find the char during execution to filter down the tags list some more. Otherwise take all tags.
const charName = executor.namedArgumentList.find(it => it.name == 'name')?.value;
if (charName instanceof SlashCommandClosure) throw new Error('Argument \'name\' does not support closures');
@@ -347,3 +351,28 @@ export const commonEnumProviders = {
...extension_settings.connectionManager.profiles.map(p => new SlashCommandEnumValue(p.name, null, enumTypes.name, enumIcons.server)),
],
};
/**
* A collection of common enum match providers
*
* Can be used on `SlashCommandEnumValue` and their `matchProvider` property.
*/
export const commonEnumMatchProviders = {
/**
* Provides autocomplete matching for folder-like enum values.
* Matches if the input starts with the check or vice versa (case-insensitive).
* @param {string} input - The input string to match against
* @param {string} check - The check string to match with
* @param {object} [options={}] - Options
* @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty
* @returns {boolean} - True if the strings match according to the folder matching rules
*/
folderEnum: (input, check, { trueOnEmpty = true } = {}) => {
if (!check) return false;
if (!input) return trueOnEmpty;
const inputLower = input.toLowerCase();
const checkLower = check.toLowerCase();
return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower);
},
};
+99
View File
@@ -1741,6 +1741,105 @@ export function loadFileToDocument(url, type) {
});
}
/**
* Opens a file picker dialog for selecting an image.
* @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled
*/
export async function promptForAvatarFile() {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = supportedImageMimeTypes.join(',');
input.onchange = async (e) => {
if (!(e.target instanceof HTMLInputElement)) {
return '';
}
const file = e.target?.files?.[0];
if (!file) {
resolve(null);
return;
}
try {
const converted = await ensureImageFormatSupported(file);
const base64 = await getBase64Async(converted);
resolve(base64);
} catch (error) {
console.error('Error processing selected image:', error);
toastr.error(t`Failed to process selected image: ${error.message}`);
resolve(null);
}
};
input.oncancel = () => resolve(null);
input.click();
});
}
/**
* Resolves avatar data from various input formats (base64, local path, or prompt).
* @param {string} input - "prompt" to open file picker, base64 data URL, or local file path
* @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled
*/
export async function resolveAvatarData(input) {
if (!input || typeof input !== 'string') {
return null;
}
const trimmed = input.trim();
// Special value "prompt" opens file picker
if (trimmed.toLowerCase() === 'prompt') {
return await promptForAvatarFile();
}
// Already a base64 data URL
if (trimmed.startsWith('data:image/')) {
return trimmed;
}
// External URLs are not supported
if (isExternalUrl(trimmed)) {
toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`);
return null;
}
// Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin
// Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/*
// Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png)
if (trimmed.includes('/') || trimmed.endsWith('.png')) {
try {
// Construct the URL to fetch the local file
let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
// Handle same-origin URLs
if (trimmed.startsWith(window.location.origin)) {
url = new URL(trimmed).pathname;
}
// If there is no subfolder, we guess this should be a character image
if (!url.includes('/', 1)) {
url = '/characters/' + trimmed;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`File not found or inaccessible: ${response.status}`);
}
const blob = await response.blob();
if (!blob.type.startsWith('image/')) {
throw new Error('File is not an image');
}
const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type }));
return await getBase64Async(converted);
} catch (error) {
console.error('Error fetching local avatar:', error);
toastr.warning(t`Failed to load avatar from path: ${error.message}`);
return null;
}
}
// Unknown format
console.warn('Unknown avatar format:', trimmed.substring(0, 50));
toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`);
return null;
}
/**
* An array of all supported image MIME types.
*/