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, +1164 -39Showing whitespace changes
public/script.js+53 -26
@@ -5862,12 +5862,33 @@ function addChatsSeparator(mesSendString) {
5862 }5862 }
5863}5863}
58645864
5865export async function duplicateCharacter() {5865/**
5866 * Duplicates a character.
5867 * @param {object} [options={}] - Options
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 */
5872export 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 {
5866 if (this_chid === undefined || !characters[this_chid]) {5883 if (this_chid === undefined || !characters[this_chid]) {
5867 toastr.warning(t`You must first select a character to duplicate!`);5884 toastr.warning(t`You must first select a character to duplicate!`);
5868 return '';5885 return '';
5869 }5886 }
5887 targetAvatar = characters[this_chid].avatar;
5888 }
58705889
5890 // Show confirmation unless silent
5891 if (!silent) {
5871 const confirmMessage = $(await renderTemplateAsync('duplicateConfirm'));5892 const confirmMessage = $(await renderTemplateAsync('duplicateConfirm'));
5872 const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);5893 const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM);
58735894
@@ -5875,21 +5896,26 @@ export async function duplicateCharacter() {
5875 console.log('User cancelled duplication');5896 console.log('User cancelled duplication');
5876 return '';5897 return '';
5877 }5898 }
5899 }
58785900
5879 const body = { avatar_url: characters[this_chid].avatar };5901 const body = { avatar_url: targetAvatar };
5880 const response = await fetch('/api/characters/duplicate', {5902 const response = await fetch('/api/characters/duplicate', {
5881 method: 'POST',5903 method: 'POST',
5882 headers: getRequestHeaders(),5904 headers: getRequestHeaders(),
5883 body: JSON.stringify(body),5905 body: JSON.stringify(body),
5884 });5906 });
5885 if (response.ok) {5907
5908 if (!response.ok) {
5909 toastr.error(t`Failed to duplicate character`);
5910 return '';
5911 }
5912
5886 toastr.success(t`Character Duplicated`);5913 toastr.success(t`Character Duplicated`);
5887 const data = await response.json();5914 const data = await response.json();
5888 await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path });5915 await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: targetAvatar, newAvatar: data.path });
5889 await getCharacters();5916 await getCharacters();
5890 }
58915917
5892 return '';5918 return data.path;
5893}5919}
58945920
5895function setInContextMessages(msgInContextCount, type) {5921function setInContextMessages(msgInContextCount, type) {
@@ -7308,29 +7334,26 @@ async function read_avatar_load(input) {
7308 }7334 }
73097335
7310 await createOrEditCharacter();7336 await createOrEditCharacter();
7311 await delay(DEFAULT_SAVE_EDIT_TIMEOUT);
73127337
7313 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));7338 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
7314 await fetch(getThumbnailUrl('avatar', formData.get('avatar_url').toString()), {7339 const 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;
7332 }7354 }
7333 }7355 }
7356 console.debug(`Refreshed ${avatarImages.length} avatar images for ${avatarKey}`);
73347357
7335 console.log('Avatar refreshed');7358 console.log('Avatar refreshed');
7336 }7359 }
@@ -10563,7 +10586,7 @@ export async function handleDeleteCharacter(this_chid, delete_chats) {
10563 * @param {string|string[]} characterKey - The key (avatar) of the character to be deleted10586 * @param {string|string[]} characterKey - The key (avatar) of the character to be deleted
10564 * @param {Object} [options] - Optional parameters for the deletion10587 * @param {Object} [options] - Optional parameters for the deletion
10565 * @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not10588 * @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not
10566 * @return {Promise<void>} - A promise that resolves when the character is successfully deleted10589 * @return {Promise<boolean>} - A promise that resolves when the character is successfully deleted
10567 */10590 */
10568export async function deleteCharacter(characterKey, { deleteChats = true } = {}) {10591export async function deleteCharacter(characterKey, { deleteChats = true } = {}) {
10569 if (!Array.isArray(characterKey)) {10592 if (!Array.isArray(characterKey)) {
@@ -10577,15 +10600,17 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
10577 t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`,10600 t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`,
10578 );10601 );
10579 if (!confirmClose) {10602 if (!confirmClose) {
10580 return;10603 return false;
10581 }10604 }
10582 }10605 }
1058310606
10584 const closeChatResult = await closeCurrentChat();10607 const closeChatResult = await closeCurrentChat();
10585 if (!closeChatResult) {10608 if (!closeChatResult) {
10586 return;10609 return false;
10587 }10610 }
1058810611
10612 let deleted = false;
10613
10589 for (const key of characterKey) {10614 for (const key of characterKey) {
10590 const character = characters.find(x => x.avatar == key);10615 const character = characters.find(x => x.avatar == key);
10591 if (!character) {10616 if (!character) {
@@ -10624,9 +10649,11 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {})
10624 }10649 }
1062510650
10626 await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character });10651 await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character });
10652 deleted = true;
10627 }10653 }
1062810654
10629 await removeCharacterFromUI();10655 await removeCharacterFromUI();
10656 return deleted;
10630}10657}
1063110658
10632/**10659/**
public/scripts/slash-commands.js+1015 -6
@@ -1,5 +1,5 @@
1import { Fuse, DOMPurify } from '../lib.js';1import { Fuse, DOMPurify } from '../lib.js';
2import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight } from './utils.js';2import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrl } from './utils.js';
33
4import {4import {
5 Generate,5 Generate,
@@ -12,6 +12,7 @@ import {
12 comment_avatar,12 comment_avatar,
13 deactivateSendButtons,13 deactivateSendButtons,
14 default_avatar,14 default_avatar,
15 deleteCharacter,
15 deleteSwipe,16 deleteSwipe,
16 displayPastChats,17 displayPastChats,
17 duplicateCharacter,18 duplicateCharacter,
@@ -22,9 +23,12 @@ import {
22 extractMessageBias,23 extractMessageBias,
23 generateQuietPrompt,24 generateQuietPrompt,
24 generateRaw,25 generateRaw,
26 getCharacters,
25 getCurrentChatDetails,27 getCurrentChatDetails,
26 getCurrentChatId,28 getCurrentChatId,
27 getFirstDisplayedMessageId,29 getFirstDisplayedMessageId,
30 getOneCharacter,
31 getRequestHeaders,
28 getThumbnailUrl,32 getThumbnailUrl,
29 is_send_press,33 is_send_press,
30 main_api,34 main_api,
@@ -40,6 +44,8 @@ import {
40 saveChatConditional,44 saveChatConditional,
41 saveSettings,45 saveSettings,
42 saveSettingsDebounced,46 saveSettingsDebounced,
47 selectCharacterById,
48 select_selected_character,
43 sendMessageAsUser,49 sendMessageAsUser,
44 sendSystemMessage,50 sendSystemMessage,
45 setActiveCharacter,51 setActiveCharacter,
@@ -230,11 +236,6 @@ export function initDefaultSlashCommands() {
230 }236 }
231237
232 SlashCommandParser.addCommandObject(SlashCommand.fromProps({238 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
233 name: 'dupe',
234 callback: duplicateCharacter,
235 helpString: t`Duplicates the currently selected character.`,
236 }));
237 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
238 name: 'api',239 name: 'api',
239 callback: async function (args, text) {240 callback: async function (args, text) {
240 if (!text?.toString()?.trim()) {241 if (!text?.toString()?.trim()) {
@@ -765,6 +766,414 @@ export function initDefaultSlashCommands() {
765 </div>766 </div>
766 `,767 `,
767 }));768 }));
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 }));
768 SlashCommandParser.addCommandObject(SlashCommand.fromProps({1177 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
769 name: 'message-role',1178 name: 'message-role',
770 callback: messageRoleCallback,1179 callback: messageRoleCallback,
@@ -4669,6 +5078,606 @@ async function openChat(chid) {
4669 await reloadCurrentChat();5078 await reloadCurrentChat();
4670}5079}
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 */
5085async 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 */
5119async 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 */
5188async 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 */
5262async 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 */
5358async 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 */
5529async 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 */
5565async 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 */
5637async 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
4672async function continueChatCallback(args, prompt) {5681async function continueChatCallback(args, prompt) {
4673 const shouldAwait = isTrueBoolean(args?.await);5682 const shouldAwait = isTrueBoolean(args?.await);
46745683
public/scripts/tags.js+77 -0
@@ -29,6 +29,7 @@ import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsPro
29import { renderTemplateAsync } from './templates.js';29import { renderTemplateAsync } from './templates.js';
30import { t, translate } from './i18n.js';30import { t, translate } from './i18n.js';
31import { accountStorage } from './util/AccountStorage.js';31import { accountStorage } from './util/AccountStorage.js';
32import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
3233
33export {34export {
34 TAG_FOLDER_TYPES,35 TAG_FOLDER_TYPES,
@@ -2515,6 +2516,82 @@ function registerTagsSlashCommands() {
2515 </div>2516 </div>
2516 `,2517 `,
2517 }));2518 }));
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 }));
2518}2595}
25192596
2520/**2597/**
public/scripts/utils.js+19 -7
@@ -180,6 +180,15 @@ export function isValidUrl(value) {
180}180}
181181
182/**182/**
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 */
187export function isExternalUrl(url) {
188 return (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin);
189}
190
191/**
183 * Checks if a string is a valid UUID (version 1-5).192 * Checks if a string is a valid UUID (version 1-5).
184 * @param {string} value String to check193 * @param {string} value String to check
185 * @returns {boolean} True if the string is a valid UUID, false otherwise.194 * @returns {boolean} True if the string is a valid UUID, false otherwise.
@@ -1733,12 +1742,9 @@ export function loadFileToDocument(url, type) {
1733}1742}
17341743
1735/**1744/**
1736 * Ensure that we can import war crime image formats like WEBP and AVIF.1745 * An array of all supported image MIME types.
1737 * @param {File} file Input file
1738 * @returns {Promise<File>} A promise that resolves to the supported file.
1739 */1746 */
1740export async function ensureImageFormatSupported(file) {1747export const supportedImageMimeTypes = Object.freeze([
1741 const supportedTypes = [
1742 'image/jpeg',1748 'image/jpeg',
1743 'image/png',1749 'image/png',
1744 'image/bmp',1750 'image/bmp',
@@ -1747,9 +1753,15 @@ export async function ensureImageFormatSupported(file) {
1747 'image/apng',1753 'image/apng',
1748 'image/webp',1754 'image/webp',
1749 'image/avif',1755 'image/avif',
1750 ];1756]);
17511757
1752 if (supportedTypes.includes(file.type) || !file.type.startsWith('image/')) {1758/**
1759 * Ensure that we can import war crime image formats like WEBP and AVIF.
1760 * @param {File} file Input file
1761 * @returns {Promise<File>} A promise that resolves to the supported file.
1762 */
1763export async function ensureImageFormatSupported(file) {
1764 if (supportedImageMimeTypes.includes(file.type) || !file.type.startsWith('image/')) {
1753 return file;1765 return file;
1754 }1766 }
17551767