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>

f9c42a32dd97fc9b841f8e527ad8fb4acb56b7ef

Wolfsblvt <wolfsblvt@gmail.com>

Signed
4 files changed, +1179 -54Ignore whitespace
public/script.js+63 -36
@@ -5862,34 +5862,60 @@ function addChatsSeparator(mesSendString) {
58625862 }
58635863}
58645864
5865-export async function duplicateCharacter() {
5865+/**
5866- if (this_chid === undefined || !characters[this_chid]) {
5866+ * Duplicates a character.
5867- toastr.warning(t`You must first select a character to duplicate!`);
5867+ * @param {object} [options={}] - Options
5868- return '';
5868+ * @param {string} [options.avatar] - Avatar key of the character to duplicate. Uses current character if not provided.
5869+ * @param {boolean} [options.silent=false] - Whether to skip the confirmation popup
5870+ * @returns {Promise<string>} The avatar key of the duplicated character, or empty string if cancelled/failed
5871+ */
5872+export async function duplicateCharacter({ avatar = null, silent = false } = {}) {
5873+ // Determine the character to duplicate
5874+ let targetAvatar;
5875+ if (avatar) {
5876+ const character = characters.find(c => c.avatar === avatar);
5877+ if (!character) {
5878+ toastr.warning(t`Character not found: ${avatar}`);
5879+ return '';
5880+ }
5881+ targetAvatar = avatar;
5882+ } else {
5883+ if (this_chid === undefined || !characters[this_chid]) {
5884+ toastr.warning(t`You must first select a character to duplicate!`);
5885+ return '';
5886+ }
5887+ targetAvatar = characters[this_chid].avatar;
58695888 }
58705889
5871- const confirmMessage = $(await renderTemplateAsync('duplicateConfirm'));
5890+ // Show confirmation unless silent
5872- const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
5891+ if (!silent) {
5892+ const confirmMessage = $(await renderTemplateAsync('duplicateConfirm'));
5893+ const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
58735894
58745895 if (!confirm) {
58755896 console.log('User cancelled duplication');
58765897 return '';
5898+ }
58775899 }
58785900
58795901 const body = { avatar_url: characters[this_chid].avatartargetAvatar };
58805902 const response = await fetch('/api/characters/duplicate', {
58815903 method: 'POST',
58825904 headers: getRequestHeaders(),
58835905 body: JSON.stringify(body),
58845906 });
5885- if (response.ok) {
5907+
5886- toastr.success(t`Character Duplicated`);
5908+ if (!response.ok) {
5887- const data = await response.json();
5909+ toastr.error(t`Failed to duplicate character`);
5888- await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });
5910+ return '';
5889- await getCharacters();
58905911 }
58915912
5892- return '';
5913+ toastr.success(t`Character Duplicated`);
5914+ const data = await response.json();
5915+ await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: targetAvatar, newAvatar: data.path });
5916+ await getCharacters();
5917+
5918+ return data.path;
58935919}
58945920
58955921function setInContextMessages(msgInContextCount, type) {
@@ -7308,29 +7334,26 @@ async function read_avatar_load(input) {
73087334 }
73097335
73107336 await createOrEditCharacter();
7311- await delay(DEFAULT_SAVE_EDIT_TIMEOUT);
73127337
73137338 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
73147339 awaitconst fetch(getThumbnailUrl('avatar',avatarKey = formData.get('avatar_url').toString()), {;
7315- method: 'GET',
7316- cache: 'reload',
7317- });
73187340
7319- const messages = $('.mes').toArray();
7341+ // Bust cache for the avatar thumbnail and character image
7320- for (const el of messages) {
7342+ const thumbnailUrl = getThumbnailUrl('avatar', avatarKey);
7321- const $el = $(el);
7343+ await fetch(thumbnailUrl, { method: 'GET', cache: 'reload' });
7322- const nameMatch = $el.attr('ch_name') == formData.get('ch_name');
7344+ await fetch(`/characters/${avatarKey}`, { method: 'GET', cache: 'reload' });
7323- if ($el.attr('is_system') == 'true' && !nameMatch) continue;
7324- if ($el.attr('is_user') == 'true') continue;
73257345
7326- if (nameMatch) {
7346+ // Refresh all visible avatar images that use this thumbnail URL
7327- const previewSrc = $('#avatar_load_preview').attr('src');
7347+ // This handles messages, character list, and any other place using the thumbnail
7328- const avatar = $el.find('.avatar img');
7348+ const avatarImages = document.querySelectorAll(`img[src^="${thumbnailUrl}"]`);
7329- avatar.attr('src', default_avatar);
7349+ for (const img of avatarImages) {
7330- await delay(1);
7350+ if (img instanceof HTMLImageElement) {
7331- avatar.attr('src', previewSrc);
7351+ const originalSrc = img.src;
7352+ img.src = '';
7353+ img.src = originalSrc;
73327354 }
73337355 }
7356+ console.debug(`Refreshed ${avatarImages.length} avatar images for ${avatarKey}`);
73347357
73357358 console.log('Avatar refreshed');
73367359 }
@@ -10563,7 +10586,7 @@ export async function handleDeleteCharacter(this_chid, delete_chats) {
1056310586 * @param {string|string[]} characterKey - The key (avatar) of the character to be deleted
1056410587 * @param {Object} [options] - Optional parameters for the deletion
1056510588 * @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not
1056610589 * @return {Promise<voidboolean>} - A promise that resolves when the character is successfully deleted
1056710590 */
1056810591export async function deleteCharacter(characterKey, { deleteChats = true } = {}) {
1056910592 if (!Array.isArray(characterKey)) {
@@ -10577,15 +10600,17 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
1057710600 t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`,
1057810601 );
1057910602 if (!confirmClose) {
1058010603 return false;
1058110604 }
1058210605 }
1058310606
1058410607 const closeChatResult = await closeCurrentChat();
1058510608 if (!closeChatResult) {
1058610609 return false;
1058710610 }
1058810611
10612+ let deleted = false;
10613+
1058910614 for (const key of characterKey) {
1059010615 const character = characters.find(x => x.avatar == key);
1059110616 if (!character) {
@@ -10624,9 +10649,11 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
1062410649 }
1062510650
1062610651 await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character });
10652+ deleted = true;
1062710653 }
1062810654
1062910655 await removeCharacterFromUI();
10656+ return deleted;
1063010657}
1063110658
1063210659/**
public/scripts/slash-commands.js+1015 -6
@@ -1,5 +1,5 @@
11import { Fuse, DOMPurify } from '../lib.js';
22import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrl } from './utils.js';
33
44import {
55 Generate,
@@ -12,6 +12,7 @@ import {
1212 comment_avatar,
1313 deactivateSendButtons,
1414 default_avatar,
15+ deleteCharacter,
1516 deleteSwipe,
1617 displayPastChats,
1718 duplicateCharacter,
@@ -22,9 +23,12 @@ import {
2223 extractMessageBias,
2324 generateQuietPrompt,
2425 generateRaw,
26+ getCharacters,
2527 getCurrentChatDetails,
2628 getCurrentChatId,
2729 getFirstDisplayedMessageId,
30+ getOneCharacter,
31+ getRequestHeaders,
2832 getThumbnailUrl,
2933 is_send_press,
3034 main_api,
@@ -40,6 +44,8 @@ import {
4044 saveChatConditional,
4145 saveSettings,
4246 saveSettingsDebounced,
47+ selectCharacterById,
48+ select_selected_character,
4349 sendMessageAsUser,
4450 sendSystemMessage,
4551 setActiveCharacter,
@@ -230,11 +236,6 @@ export function initDefaultSlashCommands() {
230236 }
231237
232238 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
233- name: 'dupe',
234- callback: duplicateCharacter,
235- helpString: t`Duplicates the currently selected character.`,
236- }));
237- SlashCommandParser.addCommandObject(SlashCommand.fromProps({
238239 name: 'api',
239240 callback: async function (args, text) {
240241 if (!text?.toString()?.trim()) {
@@ -765,6 +766,414 @@ export function initDefaultSlashCommands() {
765766 </div>
766767 `,
767768 }));
769+
770+ /**
771+ * Provides autocomplete matching for folder names.
772+ * Matches if the input starts with the check or vice versa (case-insensitive).
773+ * @param {string} input - The input string to match against
774+ * @param {string} check - The check string to match with
775+ * @param {object} [options={}] - Options
776+ * @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty
777+ * @returns {boolean} - True if the strings match according to the folder matching rules
778+ */
779+ function folderEnumMatchProvider(input, check, { trueOnEmpty = true } = {}) {
780+ if (!check) return false;
781+ if (!input) return trueOnEmpty;
782+ const inputLower = input.toLowerCase();
783+ const checkLower = check.toLowerCase();
784+ return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower);
785+ }
786+
787+ // Shared character field definitions for char CRUD commands
788+ const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [
789+ SlashCommandNamedArgument.fromProps({
790+ name: 'name',
791+ description: t`The name of the character`,
792+ typeList: [ARGUMENT_TYPE.STRING],
793+ isRequired: requiredFields.includes('name'),
794+ }),
795+ SlashCommandNamedArgument.fromProps({
796+ name: 'description',
797+ description: t`The character's description/personality definition`,
798+ typeList: [ARGUMENT_TYPE.STRING],
799+ isRequired: requiredFields.includes('description'),
800+ }),
801+ SlashCommandNamedArgument.fromProps({
802+ name: 'firstMessage',
803+ description: t`The character's first message/greeting`,
804+ typeList: [ARGUMENT_TYPE.STRING],
805+ isRequired: requiredFields.includes('firstMessage'),
806+ }),
807+ SlashCommandNamedArgument.fromProps({
808+ name: 'personality',
809+ description: t`A brief description of the personality`,
810+ typeList: [ARGUMENT_TYPE.STRING],
811+ isRequired: requiredFields.includes('personality'),
812+ }),
813+ SlashCommandNamedArgument.fromProps({
814+ name: 'scenario',
815+ description: t`The scenario or circumstances for the conversation`,
816+ typeList: [ARGUMENT_TYPE.STRING],
817+ isRequired: requiredFields.includes('scenario'),
818+ }),
819+ SlashCommandNamedArgument.fromProps({
820+ name: 'messageExamples',
821+ description: t`Example messages for the character`,
822+ typeList: [ARGUMENT_TYPE.STRING],
823+ isRequired: requiredFields.includes('messageExamples'),
824+ }),
825+ SlashCommandNamedArgument.fromProps({
826+ name: 'creatorNotes',
827+ description: t`Notes from the character creator`,
828+ typeList: [ARGUMENT_TYPE.STRING],
829+ isRequired: requiredFields.includes('creatorNotes'),
830+ }),
831+ SlashCommandNamedArgument.fromProps({
832+ name: 'systemPrompt',
833+ description: t`The character's system prompt`,
834+ typeList: [ARGUMENT_TYPE.STRING],
835+ isRequired: requiredFields.includes('systemPrompt'),
836+ }),
837+ SlashCommandNamedArgument.fromProps({
838+ name: 'postHistoryInstructions',
839+ description: t`Post-history instructions (jailbreak)`,
840+ typeList: [ARGUMENT_TYPE.STRING],
841+ isRequired: requiredFields.includes('postHistoryInstructions'),
842+ }),
843+ SlashCommandNamedArgument.fromProps({
844+ name: 'creator',
845+ description: t`The creator of the character`,
846+ typeList: [ARGUMENT_TYPE.STRING],
847+ isRequired: requiredFields.includes('creator'),
848+ }),
849+ SlashCommandNamedArgument.fromProps({
850+ name: 'characterVersion',
851+ description: t`The version of the character`,
852+ typeList: [ARGUMENT_TYPE.STRING],
853+ isRequired: requiredFields.includes('characterVersion'),
854+ }),
855+ SlashCommandNamedArgument.fromProps({
856+ name: 'tags',
857+ description: t`Comma-separated list of character card tags (embedded in the card, not ST's folder/filter tags). Use /tag-add for ST tags or /tag-import to import card tags as ST tags.`,
858+ typeList: [ARGUMENT_TYPE.STRING],
859+ isRequired: requiredFields.includes('tags'),
860+ }),
861+ SlashCommandNamedArgument.fromProps({
862+ name: 'favorite',
863+ description: t`Whether this character is a favorite`,
864+ typeList: [ARGUMENT_TYPE.BOOLEAN],
865+ enumProvider: commonEnumProviders.boolean('trueFalse'),
866+ isRequired: requiredFields.includes('favorite'),
867+ }),
868+ SlashCommandNamedArgument.fromProps({
869+ name: 'avatar',
870+ description: t`Avatar image. Use "prompt" to open file picker, or provide a local ST file path (e.g., characters/Name.png, backgrounds/image.png). This can also be the return value from the /imagine command. External URLs are not supported.`,
871+ typeList: [ARGUMENT_TYPE.STRING],
872+ isRequired: requiredFields.includes('avatar'),
873+ enumList: [
874+ new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'),
875+ new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'characters/'), () => 'characters/'),
876+ new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'backgrounds/'), () => 'backgrounds/'),
877+ new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'User Avatars/'), () => 'User Avatars/'),
878+ new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'assets/'), () => 'assets/'),
879+ new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'user/images/'), () => 'user/images/'),
880+ ],
881+ }),
882+ SlashCommandNamedArgument.fromProps({
883+ name: 'avatarPromptResize',
884+ description: t`Whether to show the avatar resize/crop dialog when uploading (default: true). Ignored if "Never resize avatars" is enabled in settings.`,
885+ typeList: [ARGUMENT_TYPE.BOOLEAN],
886+ defaultValue: 'true',
887+ enumProvider: commonEnumProviders.boolean('trueFalse'),
888+ }),
889+ SlashCommandNamedArgument.fromProps({
890+ name: 'talkativeness',
891+ description: t`How often the character speaks in group chats (0.0 to 1.0)`,
892+ typeList: [ARGUMENT_TYPE.NUMBER],
893+ isRequired: requiredFields.includes('talkativeness'),
894+ }),
895+ SlashCommandNamedArgument.fromProps({
896+ name: 'world',
897+ description: t`The name of the lorebook to attach`,
898+ typeList: [ARGUMENT_TYPE.STRING],
899+ enumProvider: commonEnumProviders.worlds,
900+ isRequired: requiredFields.includes('world'),
901+ }),
902+ SlashCommandNamedArgument.fromProps({
903+ name: 'depthPrompt',
904+ description: t`Character-specific depth prompt content`,
905+ typeList: [ARGUMENT_TYPE.STRING],
906+ isRequired: requiredFields.includes('depthPrompt'),
907+ }),
908+ SlashCommandNamedArgument.fromProps({
909+ name: 'depthPromptDepth',
910+ description: t`Depth for the character-specific depth prompt`,
911+ typeList: [ARGUMENT_TYPE.NUMBER],
912+ isRequired: requiredFields.includes('depthPromptDepth'),
913+ }),
914+ SlashCommandNamedArgument.fromProps({
915+ name: 'depthPromptRole',
916+ description: t`Role for the depth prompt`,
917+ typeList: [ARGUMENT_TYPE.STRING],
918+ enumList: commonEnumProviders.messageRoles(),
919+ isRequired: requiredFields.includes('depthPromptRole'),
920+ }),
921+ ];
922+
923+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
924+ name: 'char-create',
925+ callback: createCharacterCallback,
926+ returns: t`the avatar key (unique identifier) of the created character`,
927+ namedArgumentList: [
928+ ...getCharacterFieldArgs({ requiredFields: ['name'] }),
929+ SlashCommandNamedArgument.fromProps({
930+ name: 'select',
931+ description: t`Whether to select/open the character after creation (default: true)`,
932+ typeList: [ARGUMENT_TYPE.BOOLEAN],
933+ defaultValue: 'true',
934+ enumProvider: commonEnumProviders.boolean('trueFalse'),
935+ }),
936+ ],
937+ helpString: `
938+ <div>
939+ ${t`Creates a new character with the specified attributes. Returns the avatar key of the created character.`}
940+ </div>
941+ <div>
942+ <strong>${t`Required arguments:`}</strong>
943+ <ul>
944+ <li><code>name</code> - ${t`The character's name`}</li>
945+ </ul>
946+ </div>
947+ <div>
948+ <strong>${t`Note on tags:`}</strong> ${t`The <code>tags</code> argument sets character card tags (embedded in the character file), not SillyTavern's folder/filter tags. To add ST tags after creation, use <code>/tag-add</code>. To import card tags as ST tags, use <code>/tag-import</code>.`}
949+ </div>
950+ <div>
951+ <strong>${t`Note on avatar:`}</strong> ${t`The <code>avatar</code> argument accepts <code>prompt</code> to open a file picker, or a local ST file path. Supported paths include: <code>characters/Name.png</code>, <code>backgrounds/image.png</code>, <code>User Avatars/avatar.png</code>, <code>assets/category/file.png</code>. This can also be the return value from the /imagine command. External URLs are not supported.`}
952+ </div>
953+ <div>
954+ <strong>${t`Example:`}</strong>
955+ <ul>
956+ <li>
957+ <pre><code>/char-create name="Alice" description="A friendly AI assistant" firstMessage="Hello! How can I help you today?"</code></pre>
958+ </li>
959+ <li>
960+ <pre><code>/char-create name="Bob" description="A wise wizard" firstMessage="Greetings, traveler." personality="Wise, patient" scenario="A magical library" favorite=true</code></pre>
961+ </li>
962+ <li>
963+ <pre><code>/char-create name="Clone" description="A clone" firstMessage="Hi!" avatar=prompt</code></pre>
964+ <span>${t`(opens file picker for avatar)`}</span>
965+ </li>
966+ </ul>
967+ </div>
968+ `,
969+ }));
970+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
971+ name: 'char-update',
972+ callback: updateCharacterCallback,
973+ returns: t`the avatar key of the updated character`,
974+ namedArgumentList: [
975+ SlashCommandNamedArgument.fromProps({
976+ name: 'char',
977+ description: t`Character name or avatar key. If not provided, uses the currently selected character.`,
978+ typeList: [ARGUMENT_TYPE.STRING],
979+ enumProvider: commonEnumProviders.characters('character'),
980+ }),
981+ ...getCharacterFieldArgs(),
982+ ],
983+ helpString: `
984+ <div>
985+ ${t`Updates an existing character's attributes. The character does not need to be currently selected.`}
986+ </div>
987+ <div>
988+ ${t`If no <code>char</code> argument is provided, updates the currently selected character.`}
989+ </div>
990+ <div>
991+ <strong>${t`Note on tags:`}</strong> ${t`The <code>tags</code> argument sets character card tags (embedded in the PNG), not SillyTavern's folder/filter tags. To add ST tags, use <code>/tag-add</code>. To import card tags as ST tags, use <code>/tag-import</code>.`}
992+ </div>
993+ <div>
994+ <strong>${t`Note on avatar:`}</strong> ${t`The <code>avatar</code> argument accepts <code>prompt</code> to open a file picker, or a local ST file path. Supported paths: <code>characters/Name.png</code>, <code>backgrounds/image.png</code>, <code>User Avatars/avatar.png</code>, <code>assets/category/file.png</code>. This can also be the return value from the /imagine command. External URLs are not supported.`}
995+ </div>
996+ <div>
997+ <strong>${t`Example:`}</strong>
998+ <ul>
999+ <li>
1000+ <pre><code>/char-update description="An updated description for this character"</code></pre>
1001+ ${t`Updates the currently selected character's description.`}
1002+ </li>
1003+ <li>
1004+ <pre><code>/char-update char="Alice" personality="Cheerful and energetic" favorite=true</code></pre>
1005+ ${t`Updates Alice's personality and marks her as a favorite.`}
1006+ </li>
1007+ <li>
1008+ <pre><code>/imagine you | /char-update avatar="{{pipe}}"</code></pre>
1009+ ${t`Generates an image and sets it as the current character's avatar.`}
1010+ </li>
1011+ </ul>
1012+ </div>
1013+ `,
1014+ }));
1015+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1016+ name: 'char-duplicate',
1017+ aliases: ['dupe'],
1018+ callback: duplicateCharacterCallback,
1019+ returns: t`the avatar key (unique identifier) of the duplicated character`,
1020+ namedArgumentList: [
1021+ SlashCommandNamedArgument.fromProps({
1022+ name: 'char',
1023+ description: t`Character name or avatar key to duplicate. If not provided, uses the currently selected character.`,
1024+ typeList: [ARGUMENT_TYPE.STRING],
1025+ enumProvider: commonEnumProviders.characters('character'),
1026+ }),
1027+ SlashCommandNamedArgument.fromProps({
1028+ name: 'select',
1029+ description: t`Whether to select/open the duplicated character after creation (default: false)`,
1030+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1031+ defaultValue: 'false',
1032+ enumProvider: commonEnumProviders.boolean('trueFalse'),
1033+ }),
1034+ ],
1035+ helpString: `
1036+ <div>
1037+ ${t`Duplicates a character. Returns the avatar key of the duplicated character.`}
1038+ </div>
1039+ <div>
1040+ ${t`Use <code>/char-update</code> afterwards to modify the duplicated character's fields.`}
1041+ </div>
1042+ <div>
1043+ <strong>${t`Example:`}</strong>
1044+ <ul>
1045+ <li>
1046+ <pre><code>/char-duplicate</code></pre>
1047+ ${t`Duplicates the currently selected character.`}
1048+ </li>
1049+ <li>
1050+ <pre><code>/char-duplicate char="Alice" select=true</code></pre>
1051+ ${t`Duplicates Alice and selects the new character.`}
1052+ </li>
1053+ <li>
1054+ <pre><code>/char-duplicate | /setvar key=newChar | /char-update char="{{getvar::newChar}}" name="Clone"</code></pre>
1055+ ${t`Duplicates the current character and renames the clone.`}
1056+ </li>
1057+ </ul>
1058+ </div>
1059+ `,
1060+ }));
1061+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1062+ name: 'char-get',
1063+ aliases: ['char-data'],
1064+ callback: getCharacterDataCallback,
1065+ returns: t`character data as JSON or a specific field value`,
1066+ namedArgumentList: [
1067+ SlashCommandNamedArgument.fromProps({
1068+ name: 'char',
1069+ description: t`Character name or avatar key. If not provided, uses the currently selected character.`,
1070+ typeList: [ARGUMENT_TYPE.STRING],
1071+ enumProvider: commonEnumProviders.characters('character'),
1072+ }),
1073+ SlashCommandNamedArgument.fromProps({
1074+ name: 'field',
1075+ description: t`Specific field to retrieve. If not provided, returns the entire character data.`,
1076+ typeList: [ARGUMENT_TYPE.STRING],
1077+ enumList: [
1078+ new SlashCommandEnumValue('name', t`Character name`, enumTypes.enum),
1079+ new SlashCommandEnumValue('description', t`Character description`, enumTypes.enum),
1080+ new SlashCommandEnumValue('personality', t`Character personality`, enumTypes.enum),
1081+ new SlashCommandEnumValue('scenario', t`Character scenario`, enumTypes.enum),
1082+ new SlashCommandEnumValue('first_mes', t`First message`, enumTypes.enum),
1083+ new SlashCommandEnumValue('mes_example', t`Message examples`, enumTypes.enum),
1084+ new SlashCommandEnumValue('creator_notes', t`Creator notes`, enumTypes.enum),
1085+ new SlashCommandEnumValue('system_prompt', t`System prompt`, enumTypes.enum),
1086+ new SlashCommandEnumValue('post_history_instructions', t`Post-history instructions`, enumTypes.enum),
1087+ new SlashCommandEnumValue('creator', t`Creator name`, enumTypes.enum),
1088+ new SlashCommandEnumValue('character_version', t`Character version`, enumTypes.enum),
1089+ new SlashCommandEnumValue('tags', t`Character tags`, enumTypes.enum),
1090+ new SlashCommandEnumValue('talkativeness', t`Talkativeness`, enumTypes.enum),
1091+ new SlashCommandEnumValue('avatar', t`Avatar filename`, enumTypes.enum),
1092+ new SlashCommandEnumValue('fav', t`Favorite status`, enumTypes.enum),
1093+ ],
1094+ }),
1095+ SlashCommandNamedArgument.fromProps({
1096+ name: 'return',
1097+ description: t`The way to return the result`,
1098+ typeList: [ARGUMENT_TYPE.STRING],
1099+ defaultValue: 'pipe',
1100+ enumList: slashCommandReturnHelper.enumList({ allowPipe: true, allowObject: true, allowChat: false, allowPopup: true, allowTextVersion: false }),
1101+ }),
1102+ ],
1103+ helpString: `
1104+ <div>
1105+ ${t`Retrieves character data. Can get all data or a specific field.`}
1106+ </div>
1107+ <div>
1108+ <strong>${t`Example:`}</strong>
1109+ <ul>
1110+ <li>
1111+ <pre><code>/char-get field=description | /echo</code></pre>
1112+ ${t`Outputs the current character's description.`}
1113+ </li>
1114+ <li>
1115+ <pre><code>/char-get char="Alice" field=personality</code></pre>
1116+ ${t`Returns Alice's personality field.`}
1117+ </li>
1118+ <li>
1119+ <pre><code>/char-get char="Bob" return=object</code></pre>
1120+ ${t`Returns Bob's entire character data as an object.`}
1121+ </li>
1122+ </ul>
1123+ </div>
1124+ `,
1125+ }));
1126+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1127+ name: 'char-delete',
1128+ callback: deleteCharacterCallback,
1129+ returns: t`true if the character was deleted, false otherwise`,
1130+ namedArgumentList: [
1131+ SlashCommandNamedArgument.fromProps({
1132+ name: 'char',
1133+ description: t`Character name or avatar key. If not provided, uses the currently selected character.`,
1134+ typeList: [ARGUMENT_TYPE.STRING],
1135+ enumProvider: commonEnumProviders.characters('character'),
1136+ }),
1137+ SlashCommandNamedArgument.fromProps({
1138+ name: 'deleteChats',
1139+ description: t`Whether to also delete all chats with this character`,
1140+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1141+ defaultValue: 'false',
1142+ enumProvider: commonEnumProviders.boolean('trueFalse'),
1143+ }),
1144+ SlashCommandNamedArgument.fromProps({
1145+ name: 'silent',
1146+ description: t`Skip the confirmation popup`,
1147+ typeList: [ARGUMENT_TYPE.BOOLEAN],
1148+ defaultValue: 'false',
1149+ enumProvider: commonEnumProviders.boolean('trueFalse'),
1150+ }),
1151+ ],
1152+ helpString: `
1153+ <div>
1154+ ${t`Deletes a character from the system.`}
1155+ </div>
1156+ <div>
1157+ ${t`If no <code>char</code> argument is provided, deletes the currently selected character.`}
1158+ </div>
1159+ <div>
1160+ <strong>${t`Warning:`}</strong> ${t`This action is irreversible!`}
1161+ </div>
1162+ <div>
1163+ <strong>${t`Example:`}</strong>
1164+ <ul>
1165+ <li>
1166+ <pre><code>/char-delete</code></pre>
1167+ ${t`Deletes the currently selected character (will show confirmation popup).`}
1168+ </li>
1169+ <li>
1170+ <pre><code>/char-delete char="Bob" deleteChats=true silent=true</code></pre>
1171+ ${t`Deletes Bob and all associated chats without confirmation.`}
1172+ </li>
1173+ </ul>
1174+ </div>
1175+ `,
1176+ }));
7681177 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
7691178 name: 'message-role',
7701179 callback: messageRoleCallback,
@@ -4669,6 +5078,606 @@ async function openChat(chid) {
46695078 await reloadCurrentChat();
46705079}
46715080
5081+/**
5082+ * Opens a file picker dialog for selecting an image.
5083+ * @returns {Promise<string|null>} Base64 data URL of selected image, or null if cancelled
5084+ */
5085+async function promptForAvatarFile() {
5086+ return new Promise(resolve => {
5087+ const input = document.createElement('input');
5088+ input.type = 'file';
5089+ input.accept = supportedImageMimeTypes.join(',');
5090+ input.onchange = async (e) => {
5091+ if (!(e.target instanceof HTMLInputElement)) {
5092+ return '';
5093+ }
5094+ const file = e.target?.files?.[0];
5095+ if (!file) {
5096+ resolve(null);
5097+ return;
5098+ }
5099+ try {
5100+ const converted = await ensureImageFormatSupported(file);
5101+ const base64 = await getBase64Async(converted);
5102+ resolve(base64);
5103+ } catch (error) {
5104+ console.error('Error processing selected image:', error);
5105+ toastr.error(t`Failed to process selected image: ${error.message}`);
5106+ resolve(null);
5107+ }
5108+ };
5109+ input.oncancel = () => resolve(null);
5110+ input.click();
5111+ });
5112+}
5113+
5114+/**
5115+ * Resolves avatar data from various input formats (base64, local path, or prompt).
5116+ * @param {string} input - "prompt" to open file picker, base64 data URL, or local file path
5117+ * @returns {Promise<string|null>} Base64 data URL or null if invalid/cancelled
5118+ */
5119+async function resolveAvatarData(input) {
5120+ if (!input || typeof input !== 'string') {
5121+ return null;
5122+ }
5123+
5124+ const trimmed = input.trim();
5125+
5126+ // Special value "prompt" opens file picker
5127+ if (trimmed.toLowerCase() === 'prompt') {
5128+ return await promptForAvatarFile();
5129+ }
5130+
5131+ // Already a base64 data URL
5132+ if (trimmed.startsWith('data:image/')) {
5133+ return trimmed;
5134+ }
5135+
5136+ // External URLs are not supported
5137+ if (isExternalUrl(trimmed)) {
5138+ toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`);
5139+ return null;
5140+ }
5141+ // Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin
5142+ // Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/*
5143+ // Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png)
5144+ if (trimmed.includes('/') || trimmed.endsWith('.png')) {
5145+ try {
5146+ // Construct the URL to fetch the local file
5147+ let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
5148+ // Handle same-origin URLs
5149+ if (trimmed.startsWith(window.location.origin)) {
5150+ url = new URL(trimmed).pathname;
5151+ }
5152+ // If there is no subfolder, we guess this should be a character image
5153+ if (!url.includes('/', 1)) {
5154+ url = '/characters/' + trimmed;
5155+ }
5156+
5157+ const response = await fetch(url);
5158+ if (!response.ok) {
5159+ throw new Error(`File not found or inaccessible: ${response.status}`);
5160+ }
5161+ const blob = await response.blob();
5162+ if (!blob.type.startsWith('image/')) {
5163+ throw new Error('File is not an image');
5164+ }
5165+ const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type }));
5166+ return await getBase64Async(converted);
5167+ } catch (error) {
5168+ console.error('Error fetching local avatar:', error);
5169+ toastr.warning(t`Failed to load avatar from path: ${error.message}`);
5170+ return null;
5171+ }
5172+ }
5173+
5174+ // Unknown format
5175+ console.warn('Unknown avatar format:', trimmed.substring(0, 50));
5176+ toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`);
5177+ return null;
5178+}
5179+
5180+/**
5181+ * Uploads an avatar image to a character.
5182+ * @param {string} avatarKey - The character's avatar filename (e.g., "name.png")
5183+ * @param {string} base64Data - Base64 data URL of the image
5184+ * @param {object} [options={}] - Options
5185+ * @param {boolean} [options.resizePrompt=false] - Whether to show the resize/crop prompt
5186+ * @returns {Promise<boolean>} True if upload was successful, false if cancelled or failed
5187+ */
5188+async function uploadCharacterAvatar(avatarKey, base64Data, { resizePrompt = false } = {}) {
5189+ if (!base64Data || !avatarKey) {
5190+ return false;
5191+ }
5192+
5193+ let finalImageData = base64Data;
5194+
5195+ // Handle resize prompt
5196+ if (resizePrompt) {
5197+ if (power_user.never_resize_avatars) {
5198+ toastr.warning(t`Avatar resizing is disabled in settings. The image will be uploaded as-is.`);
5199+ } else {
5200+ const dlg = new Popup(t`Set the crop position of the avatar image`, POPUP_TYPE.CROP, '', { cropImage: base64Data });
5201+ const croppedImage = await dlg.show();
5202+ if (!croppedImage) {
5203+ // User cancelled the crop dialog
5204+ return false;
5205+ }
5206+ // The dialog returns the already-cropped image
5207+ finalImageData = String(croppedImage);
5208+ }
5209+ }
5210+
5211+ try {
5212+ // Convert base64 to blob
5213+ const response = await fetch(finalImageData);
5214+ const blob = await response.blob();
5215+
5216+ // Create form data for upload
5217+ const formData = new FormData();
5218+ formData.append('avatar', blob, 'avatar.png');
5219+ formData.append('avatar_url', avatarKey);
5220+
5221+ const uploadResponse = await fetch('/api/characters/edit-avatar', {
5222+ method: 'POST',
5223+ headers: getRequestHeaders({ omitContentType: true }),
5224+ body: formData,
5225+ });
5226+
5227+ if (!uploadResponse.ok) {
5228+ const errorText = await uploadResponse.text();
5229+ throw new Error(errorText); // Will be caught and logged below
5230+ }
5231+
5232+ // Bust cache for the avatar thumbnail and character image
5233+ const thumbnailUrl = getThumbnailUrl('avatar', avatarKey);
5234+ await fetch(thumbnailUrl, { method: 'GET', cache: 'reload' });
5235+ await fetch(`/characters/${avatarKey}`, { method: 'GET', cache: 'reload' });
5236+
5237+ // Refresh all visible avatar images that use this thumbnail URL
5238+ // This handles messages, character list, and any other place using the thumbnail
5239+ const avatarImages = document.querySelectorAll(`img[src^="${thumbnailUrl}"]`);
5240+ for (const img of avatarImages) {
5241+ if (img instanceof HTMLImageElement) {
5242+ const originalSrc = img.src;
5243+ img.src = '';
5244+ img.src = originalSrc;
5245+ }
5246+ }
5247+ console.debug(`Refreshed ${avatarImages.length} avatar images for ${avatarKey}`);
5248+
5249+ return true;
5250+ } catch (error) {
5251+ console.error('Error uploading character avatar:', error);
5252+ toastr.warning(t`Failed to upload avatar: ${error.message}`);
5253+ return false;
5254+ }
5255+}
5256+
5257+/**
5258+ * Creates a new character via the API.
5259+ * @param {object} args Named arguments
5260+ * @returns {Promise<string>} The avatar key of the created character
5261+ */
5262+async function createCharacterCallback(args) {
5263+ const name = args.name;
5264+ const description = args.description;
5265+ const firstMessage = args.firstMessage;
5266+
5267+ if (!name || typeof name !== 'string' || !name.trim()) {
5268+ toastr.warning(t`Character name is required`);
5269+ return '';
5270+ }
5271+ if (!description || typeof description !== 'string') {
5272+ toastr.warning(t`Character description is required`);
5273+ return '';
5274+ }
5275+ if (!firstMessage || typeof firstMessage !== 'string') {
5276+ toastr.warning(t`Character first message is required`);
5277+ return '';
5278+ }
5279+
5280+ // Build the character data object matching the server's expected format
5281+ const characterData = {
5282+ ch_name: name.trim(),
5283+ description: description,
5284+ first_mes: firstMessage,
5285+ personality: args.personality ?? '',
5286+ scenario: args.scenario ?? '',
5287+ mes_example: args.messageExamples ?? '',
5288+ creator_notes: args.creatorNotes ?? '',
5289+ system_prompt: args.systemPrompt ?? '',
5290+ post_history_instructions: args.postHistoryInstructions ?? '',
5291+ creator: args.creator ?? '',
5292+ character_version: args.characterVersion ?? '',
5293+ tags: args.tags ? args.tags.split(',').map(t => t.trim()).filter(t => t) : [],
5294+ talkativeness: args.talkativeness ?? '0.5',
5295+ world: args.world ?? '',
5296+ depth_prompt_prompt: args.depthPrompt ?? '',
5297+ depth_prompt_depth: args.depthPromptDepth ?? '4',
5298+ depth_prompt_role: args.depthPromptRole ?? 'system',
5299+ fav: isTrueBoolean(args.favorite) ? 'true' : 'false',
5300+ alternate_greetings: [],
5301+ extensions: '{}',
5302+ };
5303+
5304+ // Handle avatar if provided (URL or base64)
5305+ const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null;
5306+
5307+ try {
5308+ const response = await fetch('/api/characters/create', {
5309+ method: 'POST',
5310+ headers: getRequestHeaders(),
5311+ body: JSON.stringify(characterData),
5312+ });
5313+
5314+ if (!response.ok) {
5315+ const errorText = await response.text();
5316+ throw new Error(errorText); // Will be caught and logged below
5317+ }
5318+
5319+ const avatarKey = await response.text();
5320+
5321+ // Upload avatar if provided
5322+ if (avatarData) {
5323+ const resizePrompt = !isFalseBoolean(args.avatarPromptResize);
5324+ const uploaded = await uploadCharacterAvatar(avatarKey, avatarData, { resizePrompt });
5325+ if (!uploaded && resizePrompt) {
5326+ // User cancelled the resize dialog, but character was still created
5327+ toastr.info(t`Character created without avatar (resize cancelled)`);
5328+ }
5329+ }
5330+
5331+ // Refresh the character list
5332+ await getCharacters();
5333+
5334+ // Select the character if requested (default: true)
5335+ const shouldSelect = !isFalseBoolean(args.select);
5336+ if (shouldSelect) {
5337+ const characterIndex = characters.findIndex(c => c.avatar === avatarKey);
5338+ if (characterIndex !== -1) {
5339+ // selectCharacterById handles group reset and active character setting
5340+ await selectCharacterById(characterIndex);
5341+ }
5342+ }
5343+
5344+ toastr.success(t`Character "${name}" created successfully`);
5345+ return avatarKey;
5346+ } catch (error) {
5347+ console.error('Error creating character:', error);
5348+ toastr.error(t`Failed to create character: ${error.message}`);
5349+ return '';
5350+ }
5351+}
5352+
5353+/**
5354+ * Updates an existing character via the merge-attributes API.
5355+ * @param {object} args Named arguments
5356+ * @returns {Promise<string>} The avatar key of the updated character
5357+ */
5358+async function updateCharacterCallback(args) {
5359+ // Find the target character
5360+ let character;
5361+ let characterIndex;
5362+ if (args.char) {
5363+ character = findChar({ name: args.char });
5364+ if (!character) {
5365+ toastr.warning(t`Character "${args.char}" not found`);
5366+ return '';
5367+ }
5368+ characterIndex = String(characters.indexOf(character));
5369+ } else {
5370+ // Use currently selected character
5371+ if (this_chid === undefined || !characters[this_chid]) {
5372+ toastr.warning(t`No character selected and no char argument provided`);
5373+ return '';
5374+ }
5375+ character = characters[this_chid];
5376+ characterIndex = this_chid;
5377+ }
5378+
5379+ // Build the update object with only provided fields
5380+ const updateData = {
5381+ avatar: character.avatar,
5382+ };
5383+
5384+ // Map argument names to character data field names
5385+ const fieldMappings = {
5386+ name: 'name',
5387+ description: 'description',
5388+ firstMessage: 'first_mes',
5389+ personality: 'personality',
5390+ scenario: 'scenario',
5391+ messageExamples: 'mes_example',
5392+ creatorNotes: 'creator_notes',
5393+ systemPrompt: 'system_prompt',
5394+ postHistoryInstructions: 'post_history_instructions',
5395+ creator: 'creator',
5396+ characterVersion: 'character_version',
5397+ tags: 'tags',
5398+ };
5399+
5400+ // Add provided fields to update data
5401+ let hasUpdates = false;
5402+ for (const [argName, fieldName] of Object.entries(fieldMappings)) {
5403+ if (args[argName] !== undefined) {
5404+ let value = args[argName];
5405+ // Handle tags as comma-separated array
5406+ if (fieldName === 'tags' && typeof value === 'string') {
5407+ value = value.split(',').map(t => t.trim()).filter(t => t);
5408+ }
5409+ updateData[fieldName] = value;
5410+ // Also set in data object for V2 spec compliance
5411+ if (!updateData.data) updateData.data = {};
5412+ updateData.data[fieldName] = value;
5413+ hasUpdates = true;
5414+ }
5415+ }
5416+
5417+ // Special handling for world / lorebook: store under data.extensions.world
5418+ if (args.world !== undefined) {
5419+ const value = args.world;
5420+ if (!updateData.data) {
5421+ updateData.data = {};
5422+ }
5423+ if (!updateData.data.extensions) {
5424+ updateData.data.extensions = {};
5425+ }
5426+ updateData.data.extensions.world = value;
5427+ hasUpdates = true;
5428+ }
5429+
5430+ // Handle talkativeness (stored in extensions)
5431+ if (args.talkativeness !== undefined) {
5432+ const talkValue = parseFloat(args.talkativeness);
5433+ if (!isNaN(talkValue)) {
5434+ updateData.talkativeness = talkValue;
5435+ if (!updateData.data) updateData.data = {};
5436+ if (!updateData.data.extensions) updateData.data.extensions = {};
5437+ updateData.data.extensions.talkativeness = talkValue;
5438+ hasUpdates = true;
5439+ }
5440+ }
5441+
5442+ // Handle favorite
5443+ if (args.favorite !== undefined) {
5444+ const favValue = isTrueBoolean(args.favorite);
5445+ updateData.fav = favValue;
5446+ if (!updateData.data) updateData.data = {};
5447+ if (!updateData.data.extensions) updateData.data.extensions = {};
5448+ updateData.data.extensions.fav = favValue;
5449+ hasUpdates = true;
5450+ }
5451+
5452+ // Handle avatar (resolve URL/base64, upload separately after merge)
5453+ const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null;
5454+ if (avatarData) {
5455+ hasUpdates = true;
5456+ }
5457+
5458+ // Handle depth prompt fields
5459+ if (args.depthPrompt !== undefined || args.depthPromptDepth !== undefined || args.depthPromptRole !== undefined) {
5460+ if (!updateData.data) updateData.data = {};
5461+ if (!updateData.data.extensions) updateData.data.extensions = {};
5462+ if (!updateData.data.extensions.depth_prompt) updateData.data.extensions.depth_prompt = {};
5463+
5464+ if (args.depthPrompt !== undefined) {
5465+ updateData.data.extensions.depth_prompt.prompt = args.depthPrompt;
5466+ hasUpdates = true;
5467+ }
5468+ if (args.depthPromptDepth !== undefined) {
5469+ updateData.data.extensions.depth_prompt.depth = parseInt(args.depthPromptDepth);
5470+ hasUpdates = true;
5471+ }
5472+ if (args.depthPromptRole !== undefined) {
5473+ updateData.data.extensions.depth_prompt.role = args.depthPromptRole;
5474+ hasUpdates = true;
5475+ }
5476+ }
5477+
5478+ if (!hasUpdates) {
5479+ toastr.warning(t`No fields provided to update`);
5480+ return character.avatar;
5481+ }
5482+
5483+ try {
5484+ const response = await fetch('/api/characters/merge-attributes', {
5485+ method: 'POST',
5486+ headers: getRequestHeaders(),
5487+ body: JSON.stringify(updateData),
5488+ });
5489+
5490+ if (!response.ok) {
5491+ const errorData = await response.json().catch(() => ({}));
5492+ throw new Error(errorData.message || `Server returned ${response.status}`); // Will be caught and logged below
5493+ }
5494+
5495+ // Upload avatar if provided
5496+ if (avatarData) {
5497+ const resizePrompt = !isFalseBoolean(args.avatarPromptResize);
5498+ const uploaded = await uploadCharacterAvatar(character.avatar, avatarData, { resizePrompt });
5499+ if (!uploaded && resizePrompt) {
5500+ // User cancelled the resize dialog
5501+ toastr.warning(t`Avatar update cancelled`);
5502+ }
5503+ }
5504+
5505+ // Refresh the character data
5506+ await getOneCharacter(character.avatar);
5507+
5508+ await eventSource.emit(event_types.CHARACTER_EDITED, { detail: { id: characterIndex, character: characters[characterIndex] } });
5509+
5510+ // Update the side panel if this is the currently selected character
5511+ if (characterIndex === this_chid) {
5512+ select_selected_character(this_chid, { switchMenu: false });
5513+ }
5514+
5515+ toastr.success(t`Character "${character.name}" updated successfully`);
5516+ return character.avatar;
5517+ } catch (error) {
5518+ console.error('Error updating character:', error);
5519+ toastr.error(t`Failed to update character: ${error.message}`);
5520+ return '';
5521+ }
5522+}
5523+
5524+/**
5525+ * Duplicates a character via the slash command.
5526+ * @param {object} args Named arguments
5527+ * @returns {Promise<string>} The avatar key of the duplicated character
5528+ */
5529+async function duplicateCharacterCallback(args) {
5530+ // Find the target character if specified
5531+ let targetAvatar = null;
5532+ if (args.char) {
5533+ const character = findChar({ name: args.char });
5534+ if (!character) {
5535+ toastr.warning(t`Character "${args.char}" not found`);
5536+ return '';
5537+ }
5538+ targetAvatar = character.avatar;
5539+ }
5540+
5541+ // Call the duplicateCharacter utility with silent mode (no popup)
5542+ const newAvatarKey = await duplicateCharacter({ avatar: targetAvatar, silent: true });
5543+ if (!newAvatarKey) {
5544+ toastr.error(t`Failed to duplicate character`);
5545+ return '';
5546+ }
5547+
5548+ // Select the character if requested (default: false)
5549+ const shouldSelect = isTrueBoolean(args.select);
5550+ if (shouldSelect) {
5551+ const characterIndex = characters.findIndex(c => c.avatar === newAvatarKey);
5552+ if (characterIndex !== -1) {
5553+ await selectCharacterById(characterIndex);
5554+ }
5555+ }
5556+
5557+ return newAvatarKey;
5558+}
5559+
5560+/**
5561+ * Gets character data or a specific field.
5562+ * @param {object} args Named arguments
5563+ * @returns {Promise<string>} Character data or field value
5564+ */
5565+async function getCharacterDataCallback(args) {
5566+ // Find the target character
5567+ let character;
5568+ if (args.char) {
5569+ character = findChar({ name: args.char });
5570+ if (!character) {
5571+ toastr.warning(t`Character "${args.char}" not found`);
5572+ return '';
5573+ }
5574+ } else {
5575+ // Use currently selected character
5576+ if (this_chid === undefined || !characters[this_chid]) {
5577+ toastr.warning(t`No character selected and no char argument provided`);
5578+ return '';
5579+ }
5580+ character = characters[this_chid];
5581+ }
5582+
5583+ // If a specific field is requested
5584+ if (args.field) {
5585+ const fieldName = args.field;
5586+
5587+ // Try to get from data object first (V2 spec), then fall back to root
5588+ let value = character.data?.[fieldName] ?? character[fieldName];
5589+
5590+ // Handle special cases for nested fields
5591+ if (fieldName === 'talkativeness') {
5592+ value = character.data?.extensions?.talkativeness ?? character.talkativeness ?? 0.5;
5593+ }
5594+ if (fieldName === 'tags') {
5595+ value = character.data?.tags ?? character.tags ?? [];
5596+ if (Array.isArray(value)) {
5597+ value = value.join(', ');
5598+ }
5599+ }
5600+
5601+ if (value === undefined) {
5602+ return '';
5603+ }
5604+
5605+ return await slashCommandReturnHelper.doReturn(args.return ?? 'pipe', value, { objectToStringFunc: x => String(x) });
5606+ }
5607+
5608+ // Return entire character data
5609+ const charData = {
5610+ avatar: character.avatar,
5611+ name: character.name,
5612+ description: character.description ?? character.data?.description ?? '',
5613+ personality: character.personality ?? character.data?.personality ?? '',
5614+ scenario: character.scenario ?? character.data?.scenario ?? '',
5615+ first_mes: character.first_mes ?? character.data?.first_mes ?? '',
5616+ mes_example: character.mes_example ?? character.data?.mes_example ?? '',
5617+ creator_notes: character.data?.creator_notes ?? '',
5618+ system_prompt: character.data?.system_prompt ?? '',
5619+ post_history_instructions: character.data?.post_history_instructions ?? '',
5620+ creator: character.data?.creator ?? '',
5621+ character_version: character.data?.character_version ?? '',
5622+ tags: character.data?.tags ?? character.tags ?? [],
5623+ talkativeness: character.data?.extensions?.talkativeness ?? character.talkativeness ?? 0.5,
5624+ fav: character.fav ?? character.data?.extensions?.fav ?? false,
5625+ chat: character.chat,
5626+ create_date: character.create_date,
5627+ };
5628+
5629+ return await slashCommandReturnHelper.doReturn(args.return ?? 'pipe', charData, { objectToStringFunc: x => JSON.stringify(x, null, 2) });
5630+}
5631+
5632+/**
5633+ * Deletes a character using the core deleteCharacter function.
5634+ * @param {object} args Named arguments
5635+ * @returns {Promise<string>} 'true' if deleted, 'false' otherwise
5636+ */
5637+async function deleteCharacterCallback(args) {
5638+ // Find the target character
5639+ let character;
5640+ if (args.char) {
5641+ character = findChar({ name: args.char });
5642+ if (!character) {
5643+ toastr.warning(t`Character "${args.char}" not found`);
5644+ return 'false';
5645+ }
5646+ } else {
5647+ // Use currently selected character
5648+ if (this_chid === undefined || !characters[this_chid]) {
5649+ toastr.warning(t`No character selected and no char argument provided`);
5650+ return 'false';
5651+ }
5652+ character = characters[this_chid];
5653+ }
5654+
5655+ const deleteChats = isTrueBoolean(args.deleteChats);
5656+ const silent = isTrueBoolean(args.silent);
5657+
5658+ // Show confirmation popup unless silent mode
5659+ if (!silent) {
5660+ const confirmMessage = deleteChats
5661+ ? t`Are you sure you want to delete "${character.name}" and all associated chats? This action cannot be undone.`
5662+ : t`Are you sure you want to delete "${character.name}"? This action cannot be undone.`;
5663+
5664+ const result = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
5665+ if (result !== POPUP_RESULT.AFFIRMATIVE) {
5666+ return 'false';
5667+ }
5668+ }
5669+
5670+ try {
5671+ // Use the core deleteCharacter function which handles all cleanup and events
5672+ const success = await deleteCharacter(character.avatar, { deleteChats });
5673+ return success ? 'true' : 'false';
5674+ } catch (error) {
5675+ console.error('Error deleting character:', error);
5676+ toastr.error(t`Failed to delete character: ${error.message}`);
5677+ return 'false';
5678+ }
5679+}
5680+
46725681async function continueChatCallback(args, prompt) {
46735682 const shouldAwait = isTrueBoolean(args?.await);
46745683
public/scripts/tags.js+77 -0
@@ -29,6 +29,7 @@ import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsPro
2929import { renderTemplateAsync } from './templates.js';
3030import { t, translate } from './i18n.js';
3131import { accountStorage } from './util/AccountStorage.js';
32+import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
3233
3334export {
3435 TAG_FOLDER_TYPES,
@@ -2515,6 +2516,82 @@ function registerTagsSlashCommands() {
25152516 </div>
25162517 `,
25172518 }));
2519+
2520+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2521+ name: 'tag-import',
2522+ /** @param {{name: string, mode: 'all'|'existing'|'none'|'ask'}} namedArgs @returns {Promise<string>} */
2523+ callback: async ({ name, mode }) => {
2524+ if (selected_group !== null) {
2525+ toastr.warning(t`Tag import does not support group chats.`);
2526+ return 'false';
2527+ }
2528+ const key = searchCharByName(name);
2529+ if (!key) return 'false';
2530+
2531+ // Map mode argument to tag_import_setting
2532+ const modeMap = {
2533+ 'all': tag_import_setting.ALL,
2534+ 'existing': tag_import_setting.ONLY_EXISTING,
2535+ 'none': tag_import_setting.NONE,
2536+ 'ask': tag_import_setting.ASK,
2537+ };
2538+ if (mode && !modeMap[mode]) {
2539+ toastr.warning(`Invalid tag import mode: ${mode}. Valid modes are: ${Object.keys(modeMap).join(', ')}`);
2540+ return 'false';
2541+ }
2542+
2543+ const importSetting = mode ? modeMap[mode] : null;
2544+ const character = findChar({ name: key });
2545+
2546+ const result = await importTags(character, { importSetting });
2547+ return result ? 'true' : 'false';
2548+ },
2549+ returns: t`true if any tags were imported, false otherwise`,
2550+ namedArgumentList: [
2551+ SlashCommandNamedArgument.fromProps({
2552+ name: 'name',
2553+ description: 'Character name - or unique character identifier (avatar key)',
2554+ typeList: [ARGUMENT_TYPE.STRING],
2555+ defaultValue: '{{char}}',
2556+ enumProvider: commonEnumProviders.characters(),
2557+ }),
2558+ SlashCommandNamedArgument.fromProps({
2559+ name: 'mode',
2560+ 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)`,
2561+ typeList: [ARGUMENT_TYPE.STRING],
2562+ enumList: [
2563+ new SlashCommandEnumValue('all', t`Import all tags (create new ones if needed)`, enumTypes.enum),
2564+ new SlashCommandEnumValue('existing', t`Import only existing ST tags`, enumTypes.enum),
2565+ new SlashCommandEnumValue('none', t`Skip import`, enumTypes.enum),
2566+ new SlashCommandEnumValue('ask', t`Show the import popup`, enumTypes.enum),
2567+ ],
2568+ }),
2569+ ],
2570+ helpString: `
2571+ <div>
2572+ ${t`Imports character card tags as SillyTavern tags for folder/filter use.`}
2573+ </div>
2574+ <div>
2575+ ${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.`}
2576+ </div>
2577+ <div>
2578+ ${t`If no mode is specified, uses your saved tag import setting from preferences.`}
2579+ </div>
2580+ <div>
2581+ <strong>${t`Example:`}</strong>
2582+ <ul>
2583+ <li>
2584+ <pre><code>/tag-import</code></pre>
2585+ ${t`Imports tags for the current character using your default setting.`}
2586+ </li>
2587+ <li>
2588+ <pre><code>/tag-import name="Alice" mode=all</code></pre>
2589+ ${t`Imports all of Alice's card tags, creating new ST tags if needed.`}
2590+ </li>
2591+ </ul>
2592+ </div>
2593+ `,
2594+ }));
25182595}
25192596
25202597/**
public/scripts/utils.js+24 -12
@@ -180,6 +180,15 @@ export function isValidUrl(value) {
180180}
181181
182182/**
183+ * Checks if a URL is external to the current domain.
184+ * @param {string} url URL to check
185+ * @returns {boolean} True if the URL is external, false otherwise
186+ */
187+export function isExternalUrl(url) {
188+ return (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin);
189+}
190+
191+/**
183192 * Checks if a string is a valid UUID (version 1-5).
184193 * @param {string} value String to check
185194 * @returns {boolean} True if the string is a valid UUID, false otherwise.
@@ -1733,23 +1742,26 @@ export function loadFileToDocument(url, type) {
17331742}
17341743
17351744/**
1745+ * An array of all supported image MIME types.
1746+ */
1747+export const supportedImageMimeTypes = Object.freeze([
1748+ 'image/jpeg',
1749+ 'image/png',
1750+ 'image/bmp',
1751+ 'image/tiff',
1752+ 'image/gif',
1753+ 'image/apng',
1754+ 'image/webp',
1755+ 'image/avif',
1756+]);
1757+
1758+/**
17361759 * Ensure that we can import war crime image formats like WEBP and AVIF.
17371760 * @param {File} file Input file
17381761 * @returns {Promise<File>} A promise that resolves to the supported file.
17391762 */
17401763export async function ensureImageFormatSupported(file) {
1741- const supportedTypes = [
1764+ if (supportedImageMimeTypes.includes(file.type) || !file.type.startsWith('image/')) {
1742- 'image/jpeg',
1743- 'image/png',
1744- 'image/bmp',
1745- 'image/tiff',
1746- 'image/gif',
1747- 'image/apng',
1748- 'image/webp',
1749- 'image/avif',
1750- ];
1751-
1752- if (supportedTypes.includes(file.type) || !file.type.startsWith('image/')) {
17531765 return file;
17541766 }
17551767