Character CRUD Slash Commands (#5306)

* feat: add character CRUD slash commands (char-create, char-update, char-get, char-delete)

Implement comprehensive character management commands allowing programmatic creation, modification, retrieval, and deletion of characters without UI interaction. All commands support named arguments with proper validation and error handling.

* feat: enhance char CRUD commands with favorite, avatar, and improved defaults

Add `favorite` and `avatar` named arguments to char-create and char-update commands. Make `char` argument optional in char-delete (defaults to current character). Improve documentation with clarifications on card tags vs ST tags, avatar URL sources, and usage examples. Add `fav` field to char-get output.

* feat: add /tag-import slash command for programmatic character tag import

Implement /tag-import command with `name` (defaults to {{char}}) and `mode` arguments (all/existing/none/ask). Command imports character card tags as ST tags for filtering/organizing. Returns 'true' if tags were imported, 'false' otherwise. Mode argument maps to existing tag_import_setting enum values.

* feat: enhance char-create and char-update with avatar upload and tag parsing

Add avatar resolution and upload support for char-create and char-update commands. Implement resolveAvatarData() to handle URLs, base64 data URLs, and local paths. Add uploadCharacterAvatar() to upload resolved images via /api/characters/edit-avatar. Parse tags argument as comma-separated array. Refresh side panel after char-update when modifying current character. Add char-get2 alias for testing.

* feat: enhance avatar argument in char CRUD commands with file picker and local path support

Add `prompt` value to avatar argument to open file picker dialog. Implement promptForAvatarFile() to handle image selection with validation. Add folderEnumMatchProvider() for autocomplete matching of folder paths. Update avatar enumList with common ST paths (characters/, backgrounds/, User Avatars/, assets/, user/images/). Remove external URL support from resolveAvatarData() - now only accepts "prompt", base

* feat: add /char-duplicate slash command with avatar resize support and enhanced avatar upload handling

Implement /char-duplicate command with `char` and `select` named arguments. Add `avatarPromptResize` argument to char-create and char-update for optional resize/crop dialog. Refactor duplicateCharacter() to accept avatar and silent parameters, returning the new avatar key. Enhance uploadCharacterAvatar() to handle resize prompts and return success status. Add cache busting and DOM refresh for avatar thumbnails

* refactor: optimize avatar refresh after upload by using cache busting and DOM query

Replace message-specific avatar refresh logic with generic image refresh targeting all thumbnails using the updated avatar URL. Remove unnecessary delay and character name matching. Add cache busting for both thumbnail and full character image. Use querySelectorAll to find and refresh all affected img elements in one pass.

* refactor: rename char-get alias from char-get2 to char-data

* refactor: move /dupe command to alias of /char-duplicate and fix characterIndex type

Move standalone /dupe command to alias of /char-duplicate for consistency with character CRUD commands. Cast characterIndex to String in char-update to match expected type.

* docs: clarify /imagine command integration with avatar argument in char CRUD commands

Update avatar argument descriptions and examples to explicitly mention /imagine command return value support. Reorder piped /imagine example to show correct command flow (generate image first, then update character).

* feat: add i18n support to char-create and char-update command error messages

Wrap validation error toastr messages in t`` template literals for translation support in /char-create and /char-update commands.

* feat: add group chat validation to /tag-import command

Add early validation check to prevent tag import in group chats. Display warning toastr message and return 'false' when selected_group is not null.

* Fix char update world property

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Fix help text documentation to match arg name

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: return empty string instead of throwing errors in char-create and char-update commands

Replace `throw error` with `return ''` in error handlers for /char-create and /char-update commands to prevent unhandled promise rejections while still displaying error toastr messages.

* refactor: make deleteCharacter return boolean success status and update char-delete command to use it

Change deleteCharacter to return boolean indicating success/failure instead of void. Return false on user cancellation or failed chat close, true when character is successfully deleted. Update /char-delete command to use returned success value instead of always returning 'true'.

* refactor: make description and firstMessage optional in /char-create command

Remove description and firstMessage from required fields array, allowing character creation with only a name. Update help text to reflect that only name is required.

* refactor: extract external URL check to utility function and add same-origin URL support for avatar resolution

Extract external URL check into `isExternalUrl` utility function in utils.js. Update `resolveAvatarData` to use new utility and add support for same-origin URLs by converting them to pathname before fetching.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Wolfsblvt
2026-03-18 17:14:21 +01:00
committed by GitHub
parent bae4fd9f98
commit f9c42a32dd
4 changed files with 1179 additions and 54 deletions
+63 -36
View File
@@ -5862,34 +5862,60 @@ function addChatsSeparator(mesSendString) {
}
}
export async function duplicateCharacter() {
if (this_chid === undefined || !characters[this_chid]) {
toastr.warning(t`You must first select a character to duplicate!`);
return '';
/**
* Duplicates a character.
* @param {object} [options={}] - Options
* @param {string} [options.avatar] - Avatar key of the character to duplicate. Uses current character if not provided.
* @param {boolean} [options.silent=false] - Whether to skip the confirmation popup
* @returns {Promise<string>} The avatar key of the duplicated character, or empty string if cancelled/failed
*/
export async function duplicateCharacter({ avatar = null, silent = false } = {}) {
// Determine the character to duplicate
let targetAvatar;
if (avatar) {
const character = characters.find(c => c.avatar === avatar);
if (!character) {
toastr.warning(t`Character not found: ${avatar}`);
return '';
}
targetAvatar = avatar;
} else {
if (this_chid === undefined || !characters[this_chid]) {
toastr.warning(t`You must first select a character to duplicate!`);
return '';
}
targetAvatar = characters[this_chid].avatar;
}
const confirmMessage = $(await renderTemplateAsync('duplicateConfirm'));
const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
// Show confirmation unless silent
if (!silent) {
const confirmMessage = $(await renderTemplateAsync('duplicateConfirm'));
const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
if (!confirm) {
console.log('User cancelled duplication');
return '';
if (!confirm) {
console.log('User cancelled duplication');
return '';
}
}
const body = { avatar_url: characters[this_chid].avatar };
const body = { avatar_url: targetAvatar };
const response = await fetch('/api/characters/duplicate', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify(body),
});
if (response.ok) {
toastr.success(t`Character Duplicated`);
const data = await response.json();
await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });
await getCharacters();
if (!response.ok) {
toastr.error(t`Failed to duplicate character`);
return '';
}
return '';
toastr.success(t`Character Duplicated`);
const data = await response.json();
await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: targetAvatar, newAvatar: data.path });
await getCharacters();
return data.path;
}
function setInContextMessages(msgInContextCount, type) {
@@ -7308,29 +7334,26 @@ async function read_avatar_load(input) {
}
await createOrEditCharacter();
await delay(DEFAULT_SAVE_EDIT_TIMEOUT);
const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
await fetch(getThumbnailUrl('avatar', formData.get('avatar_url').toString()), {
method: 'GET',
cache: 'reload',
});
const avatarKey = formData.get('avatar_url').toString();
const messages = $('.mes').toArray();
for (const el of messages) {
const $el = $(el);
const nameMatch = $el.attr('ch_name') == formData.get('ch_name');
if ($el.attr('is_system') == 'true' && !nameMatch) continue;
if ($el.attr('is_user') == 'true') continue;
// Bust cache for the avatar thumbnail and character image
const thumbnailUrl = getThumbnailUrl('avatar', avatarKey);
await fetch(thumbnailUrl, { method: 'GET', cache: 'reload' });
await fetch(`/characters/${avatarKey}`, { method: 'GET', cache: 'reload' });
if (nameMatch) {
const previewSrc = $('#avatar_load_preview').attr('src');
const avatar = $el.find('.avatar img');
avatar.attr('src', default_avatar);
await delay(1);
avatar.attr('src', previewSrc);
// Refresh all visible avatar images that use this thumbnail URL
// This handles messages, character list, and any other place using the thumbnail
const avatarImages = document.querySelectorAll(`img[src^="${thumbnailUrl}"]`);
for (const img of avatarImages) {
if (img instanceof HTMLImageElement) {
const originalSrc = img.src;
img.src = '';
img.src = originalSrc;
}
}
console.debug(`Refreshed ${avatarImages.length} avatar images for ${avatarKey}`);
console.log('Avatar refreshed');
}
@@ -10563,7 +10586,7 @@ export async function handleDeleteCharacter(this_chid, delete_chats) {
* @param {string|string[]} characterKey - The key (avatar) of the character to be deleted
* @param {Object} [options] - Optional parameters for the deletion
* @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not
* @return {Promise<void>} - A promise that resolves when the character is successfully deleted
* @return {Promise<boolean>} - A promise that resolves when the character is successfully deleted
*/
export async function deleteCharacter(characterKey, { deleteChats = true } = {}) {
if (!Array.isArray(characterKey)) {
@@ -10577,15 +10600,17 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`,
);
if (!confirmClose) {
return;
return false;
}
}
const closeChatResult = await closeCurrentChat();
if (!closeChatResult) {
return;
return false;
}
let deleted = false;
for (const key of characterKey) {
const character = characters.find(x => x.avatar == key);
if (!character) {
@@ -10624,9 +10649,11 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
}
await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character });
deleted = true;
}
await removeCharacterFromUI();
return deleted;
}
/**
File diff suppressed because it is too large Load Diff
+77
View File
@@ -29,6 +29,7 @@ import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsPro
import { renderTemplateAsync } from './templates.js';
import { t, translate } from './i18n.js';
import { accountStorage } from './util/AccountStorage.js';
import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
export {
TAG_FOLDER_TYPES,
@@ -2515,6 +2516,82 @@ function registerTagsSlashCommands() {
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tag-import',
/** @param {{name: string, mode: 'all'|'existing'|'none'|'ask'}} namedArgs @returns {Promise<string>} */
callback: async ({ name, mode }) => {
if (selected_group !== null) {
toastr.warning(t`Tag import does not support group chats.`);
return 'false';
}
const key = searchCharByName(name);
if (!key) return 'false';
// Map mode argument to tag_import_setting
const modeMap = {
'all': tag_import_setting.ALL,
'existing': tag_import_setting.ONLY_EXISTING,
'none': tag_import_setting.NONE,
'ask': tag_import_setting.ASK,
};
if (mode && !modeMap[mode]) {
toastr.warning(`Invalid tag import mode: ${mode}. Valid modes are: ${Object.keys(modeMap).join(', ')}`);
return 'false';
}
const importSetting = mode ? modeMap[mode] : null;
const character = findChar({ name: key });
const result = await importTags(character, { importSetting });
return result ? 'true' : 'false';
},
returns: t`true if any tags were imported, false otherwise`,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'name',
description: 'Character name - or unique character identifier (avatar key)',
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: '{{char}}',
enumProvider: commonEnumProviders.characters(),
}),
SlashCommandNamedArgument.fromProps({
name: 'mode',
description: t`Import mode: "all" imports all tags, "existing" imports only existing ST tags, "none" skips import, "ask" shows the import popup (default: uses your saved setting)`,
typeList: [ARGUMENT_TYPE.STRING],
enumList: [
new SlashCommandEnumValue('all', t`Import all tags (create new ones if needed)`, enumTypes.enum),
new SlashCommandEnumValue('existing', t`Import only existing ST tags`, enumTypes.enum),
new SlashCommandEnumValue('none', t`Skip import`, enumTypes.enum),
new SlashCommandEnumValue('ask', t`Show the import popup`, enumTypes.enum),
],
}),
],
helpString: `
<div>
${t`Imports character card tags as SillyTavern tags for folder/filter use.`}
</div>
<div>
${t`Character cards can have embedded tags (set via <code>tags</code> argument in <code>/char-create</code> or <code>/char-update</code>). This command imports those embedded tags as ST tags that can be used for filtering and organizing characters.`}
</div>
<div>
${t`If no mode is specified, uses your saved tag import setting from preferences.`}
</div>
<div>
<strong>${t`Example:`}</strong>
<ul>
<li>
<pre><code>/tag-import</code></pre>
${t`Imports tags for the current character using your default setting.`}
</li>
<li>
<pre><code>/tag-import name="Alice" mode=all</code></pre>
${t`Imports all of Alice's card tags, creating new ST tags if needed.`}
</li>
</ul>
</div>
`,
}));
}
/**
+24 -12
View File
@@ -179,6 +179,15 @@ export function isValidUrl(value) {
}
}
/**
* Checks if a URL is external to the current domain.
* @param {string} url URL to check
* @returns {boolean} True if the URL is external, false otherwise
*/
export function isExternalUrl(url) {
return (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin);
}
/**
* Checks if a string is a valid UUID (version 1-5).
* @param {string} value String to check
@@ -1732,24 +1741,27 @@ export function loadFileToDocument(url, type) {
});
}
/**
* An array of all supported image MIME types.
*/
export const supportedImageMimeTypes = Object.freeze([
'image/jpeg',
'image/png',
'image/bmp',
'image/tiff',
'image/gif',
'image/apng',
'image/webp',
'image/avif',
]);
/**
* Ensure that we can import war crime image formats like WEBP and AVIF.
* @param {File} file Input file
* @returns {Promise<File>} A promise that resolves to the supported file.
*/
export async function ensureImageFormatSupported(file) {
const supportedTypes = [
'image/jpeg',
'image/png',
'image/bmp',
'image/tiff',
'image/gif',
'image/apng',
'image/webp',
'image/avif',
];
if (supportedTypes.includes(file.type) || !file.type.startsWith('image/')) {
if (supportedImageMimeTypes.includes(file.type) || !file.type.startsWith('image/')) {
return file;
}