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:
+63
-36
@@ -5862,34 +5862,60 @@ function addChatsSeparator(mesSendString) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function duplicateCharacter() {
|
/**
|
||||||
if (this_chid === undefined || !characters[this_chid]) {
|
* Duplicates a character.
|
||||||
toastr.warning(t`You must first select a character to duplicate!`);
|
* @param {object} [options={}] - Options
|
||||||
return '';
|
* @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'));
|
// Show confirmation unless silent
|
||||||
const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
|
if (!silent) {
|
||||||
|
const confirmMessage = $(await renderTemplateAsync('duplicateConfirm'));
|
||||||
|
const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
|
||||||
|
|
||||||
if (!confirm) {
|
if (!confirm) {
|
||||||
console.log('User cancelled duplication');
|
console.log('User cancelled duplication');
|
||||||
return '';
|
return '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = { avatar_url: characters[this_chid].avatar };
|
const body = { avatar_url: targetAvatar };
|
||||||
const response = await fetch('/api/characters/duplicate', {
|
const response = await fetch('/api/characters/duplicate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getRequestHeaders(),
|
headers: getRequestHeaders(),
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
|
||||||
toastr.success(t`Character Duplicated`);
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
toastr.error(t`Failed to duplicate character`);
|
||||||
await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });
|
return '';
|
||||||
await getCharacters();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function setInContextMessages(msgInContextCount, type) {
|
||||||
@@ -7308,29 +7334,26 @@ async function read_avatar_load(input) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await createOrEditCharacter();
|
await createOrEditCharacter();
|
||||||
await delay(DEFAULT_SAVE_EDIT_TIMEOUT);
|
|
||||||
|
|
||||||
const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
|
const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
|
||||||
await fetch(getThumbnailUrl('avatar', formData.get('avatar_url').toString()), {
|
const avatarKey = formData.get('avatar_url').toString();
|
||||||
method: 'GET',
|
|
||||||
cache: 'reload',
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages = $('.mes').toArray();
|
// Bust cache for the avatar thumbnail and character image
|
||||||
for (const el of messages) {
|
const thumbnailUrl = getThumbnailUrl('avatar', avatarKey);
|
||||||
const $el = $(el);
|
await fetch(thumbnailUrl, { method: 'GET', cache: 'reload' });
|
||||||
const nameMatch = $el.attr('ch_name') == formData.get('ch_name');
|
await fetch(`/characters/${avatarKey}`, { method: 'GET', cache: 'reload' });
|
||||||
if ($el.attr('is_system') == 'true' && !nameMatch) continue;
|
|
||||||
if ($el.attr('is_user') == 'true') continue;
|
|
||||||
|
|
||||||
if (nameMatch) {
|
// Refresh all visible avatar images that use this thumbnail URL
|
||||||
const previewSrc = $('#avatar_load_preview').attr('src');
|
// This handles messages, character list, and any other place using the thumbnail
|
||||||
const avatar = $el.find('.avatar img');
|
const avatarImages = document.querySelectorAll(`img[src^="${thumbnailUrl}"]`);
|
||||||
avatar.attr('src', default_avatar);
|
for (const img of avatarImages) {
|
||||||
await delay(1);
|
if (img instanceof HTMLImageElement) {
|
||||||
avatar.attr('src', previewSrc);
|
const originalSrc = img.src;
|
||||||
|
img.src = '';
|
||||||
|
img.src = originalSrc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.debug(`Refreshed ${avatarImages.length} avatar images for ${avatarKey}`);
|
||||||
|
|
||||||
console.log('Avatar refreshed');
|
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 {string|string[]} characterKey - The key (avatar) of the character to be deleted
|
||||||
* @param {Object} [options] - Optional parameters for the deletion
|
* @param {Object} [options] - Optional parameters for the deletion
|
||||||
* @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not
|
* @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 } = {}) {
|
export async function deleteCharacter(characterKey, { deleteChats = true } = {}) {
|
||||||
if (!Array.isArray(characterKey)) {
|
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?`,
|
t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`,
|
||||||
);
|
);
|
||||||
if (!confirmClose) {
|
if (!confirmClose) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeChatResult = await closeCurrentChat();
|
const closeChatResult = await closeCurrentChat();
|
||||||
if (!closeChatResult) {
|
if (!closeChatResult) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
for (const key of characterKey) {
|
for (const key of characterKey) {
|
||||||
const character = characters.find(x => x.avatar == key);
|
const character = characters.find(x => x.avatar == key);
|
||||||
if (!character) {
|
if (!character) {
|
||||||
@@ -10624,9 +10649,11 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
|
|||||||
}
|
}
|
||||||
|
|
||||||
await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character });
|
await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character });
|
||||||
|
deleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await removeCharacterFromUI();
|
await removeCharacterFromUI();
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsPro
|
|||||||
import { renderTemplateAsync } from './templates.js';
|
import { renderTemplateAsync } from './templates.js';
|
||||||
import { t, translate } from './i18n.js';
|
import { t, translate } from './i18n.js';
|
||||||
import { accountStorage } from './util/AccountStorage.js';
|
import { accountStorage } from './util/AccountStorage.js';
|
||||||
|
import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
TAG_FOLDER_TYPES,
|
TAG_FOLDER_TYPES,
|
||||||
@@ -2515,6 +2516,82 @@ function registerTagsSlashCommands() {
|
|||||||
</div>
|
</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
@@ -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).
|
* Checks if a string is a valid UUID (version 1-5).
|
||||||
* @param {string} value String to check
|
* @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.
|
* Ensure that we can import war crime image formats like WEBP and AVIF.
|
||||||
* @param {File} file Input file
|
* @param {File} file Input file
|
||||||
* @returns {Promise<File>} A promise that resolves to the supported file.
|
* @returns {Promise<File>} A promise that resolves to the supported file.
|
||||||
*/
|
*/
|
||||||
export async function ensureImageFormatSupported(file) {
|
export async function ensureImageFormatSupported(file) {
|
||||||
const supportedTypes = [
|
if (supportedImageMimeTypes.includes(file.type) || !file.type.startsWith('image/')) {
|
||||||
'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/')) {
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user