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>
Signed| @@ -5862,12 +5862,33 @@ function addChatsSeparator(mesSendString) { | ||
| 5862 | 5862 | } |
| 5863 | 5863 | } |
| 5864 | 5864 | |
| 5865 | -export 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 | + */ | |
| 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 { | |
| 5866 | 5883 | if (this_chid === undefined || !characters[this_chid]) { |
| 5867 | 5884 | toastr.warning(t`You must first select a character to duplicate!`); |
| 5868 | 5885 | return ''; |
| 5869 | 5886 | } |
| 5887 | + targetAvatar = characters[this_chid].avatar; | |
| 5888 | + } | |
| 5870 | 5889 | |
| 5890 | + // Show confirmation unless silent | |
| 5891 | + if (!silent) { | |
| 5871 | 5892 | const confirmMessage = $(await renderTemplateAsync('duplicateConfirm')); |
| 5872 | 5893 | const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM); |
| 5873 | 5894 | |
| @@ -5875,21 +5896,26 @@ export async function duplicateCharacter() { | ||
| 5875 | 5896 | console.log('User cancelled duplication'); |
| 5876 | 5897 | return ''; |
| 5877 | 5898 | } |
| 5899 | + } | |
| 5878 | 5900 | |
| 5879 | 5901 | const body = { avatar_url: characters[this_chid].avatartargetAvatar }; |
| 5880 | 5902 | const response = await fetch('/api/characters/duplicate', { |
| 5881 | 5903 | method: 'POST', |
| 5882 | 5904 | headers: getRequestHeaders(), |
| 5883 | 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 | 5913 | toastr.success(t`Character Duplicated`); |
| 5887 | 5914 | const data = await response.json(); |
| 5888 | 5915 | await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_urltargetAvatar, newAvatar: data.path }); |
| 5889 | 5916 | await getCharacters(); |
| 5890 | - } | |
| 5891 | 5917 | |
| 5892 | 5918 | return ''data.path; |
| 5893 | 5919 | } |
| 5894 | 5920 | |
| 5895 | 5921 | function setInContextMessages(msgInContextCount, type) { |
| @@ -7308,29 +7334,26 @@ async function read_avatar_load(input) { | ||
| 7308 | 7334 | } |
| 7309 | 7335 | |
| 7310 | 7336 | await createOrEditCharacter(); |
| 7311 | - await delay(DEFAULT_SAVE_EDIT_TIMEOUT); | |
| 7312 | 7337 | |
| 7313 | 7338 | const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0))); |
| 7314 | 7339 | awaitconst fetch(getThumbnailUrl('avatar',avatarKey = formData.get('avatar_url').toString()), {; |
| 7315 | - method: 'GET', | |
| 7316 | - cache: 'reload', | |
| 7317 | - }); | |
| 7318 | 7340 | |
| 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; | |
| 7325 | 7345 | |
| 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}`); | |
| 7334 | 7357 | |
| 7335 | 7358 | console.log('Avatar refreshed'); |
| 7336 | 7359 | } |
| @@ -10563,7 +10586,7 @@ export async function handleDeleteCharacter(this_chid, delete_chats) { | ||
| 10563 | 10586 | * @param {string|string[]} characterKey - The key (avatar) of the character to be deleted |
| 10564 | 10587 | * @param {Object} [options] - Optional parameters for the deletion |
| 10565 | 10588 | * @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not |
| 10566 | 10589 | * @return {Promise<voidboolean>} - A promise that resolves when the character is successfully deleted |
| 10567 | 10590 | */ |
| 10568 | 10591 | export async function deleteCharacter(characterKey, { deleteChats = true } = {}) { |
| 10569 | 10592 | if (!Array.isArray(characterKey)) { |
| @@ -10577,15 +10600,17 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) | ||
| 10577 | 10600 | t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`, |
| 10578 | 10601 | ); |
| 10579 | 10602 | if (!confirmClose) { |
| 10580 | 10603 | return false; |
| 10581 | 10604 | } |
| 10582 | 10605 | } |
| 10583 | 10606 | |
| 10584 | 10607 | const closeChatResult = await closeCurrentChat(); |
| 10585 | 10608 | if (!closeChatResult) { |
| 10586 | 10609 | return false; |
| 10587 | 10610 | } |
| 10588 | 10611 | |
| 10612 | + let deleted = false; | |
| 10613 | + | |
| 10589 | 10614 | for (const key of characterKey) { |
| 10590 | 10615 | const character = characters.find(x => x.avatar == key); |
| 10591 | 10616 | if (!character) { |
| @@ -10624,9 +10649,11 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) | ||
| 10624 | 10649 | } |
| 10625 | 10650 | |
| 10626 | 10651 | await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character }); |
| 10652 | + deleted = true; | |
| 10627 | 10653 | } |
| 10628 | 10654 | |
| 10629 | 10655 | await removeCharacterFromUI(); |
| 10656 | + return deleted; | |
| 10630 | 10657 | } |
| 10631 | 10658 | |
| 10632 | 10659 | /** |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { Fuse, DOMPurify } from '../lib.js'; |
| 2 | 2 | import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrl } from './utils.js'; |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | Generate, |
| @@ -12,6 +12,7 @@ import { | ||
| 12 | 12 | comment_avatar, |
| 13 | 13 | deactivateSendButtons, |
| 14 | 14 | default_avatar, |
| 15 | + deleteCharacter, | |
| 15 | 16 | deleteSwipe, |
| 16 | 17 | displayPastChats, |
| 17 | 18 | duplicateCharacter, |
| @@ -22,9 +23,12 @@ import { | ||
| 22 | 23 | extractMessageBias, |
| 23 | 24 | generateQuietPrompt, |
| 24 | 25 | generateRaw, |
| 26 | + getCharacters, | |
| 25 | 27 | getCurrentChatDetails, |
| 26 | 28 | getCurrentChatId, |
| 27 | 29 | getFirstDisplayedMessageId, |
| 30 | + getOneCharacter, | |
| 31 | + getRequestHeaders, | |
| 28 | 32 | getThumbnailUrl, |
| 29 | 33 | is_send_press, |
| 30 | 34 | main_api, |
| @@ -40,6 +44,8 @@ import { | ||
| 40 | 44 | saveChatConditional, |
| 41 | 45 | saveSettings, |
| 42 | 46 | saveSettingsDebounced, |
| 47 | + selectCharacterById, | |
| 48 | + select_selected_character, | |
| 43 | 49 | sendMessageAsUser, |
| 44 | 50 | sendSystemMessage, |
| 45 | 51 | setActiveCharacter, |
| @@ -230,11 +236,6 @@ export function initDefaultSlashCommands() { | ||
| 230 | 236 | } |
| 231 | 237 | |
| 232 | 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 | 239 | name: 'api', |
| 239 | 240 | callback: async function (args, text) { |
| 240 | 241 | if (!text?.toString()?.trim()) { |
| @@ -765,6 +766,414 @@ export function initDefaultSlashCommands() { | ||
| 765 | 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 | 1177 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 769 | 1178 | name: 'message-role', |
| 770 | 1179 | callback: messageRoleCallback, |
| @@ -4669,6 +5078,606 @@ async function openChat(chid) { | ||
| 4669 | 5078 | await reloadCurrentChat(); |
| 4670 | 5079 | } |
| 4671 | 5080 | |
| 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 | + | |
| 4672 | 5681 | async function continueChatCallback(args, prompt) { |
| 4673 | 5682 | const shouldAwait = isTrueBoolean(args?.await); |
| 4674 | 5683 | |
| @@ -29,6 +29,7 @@ import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsPro | ||
| 29 | 29 | import { renderTemplateAsync } from './templates.js'; |
| 30 | 30 | import { t, translate } from './i18n.js'; |
| 31 | 31 | import { accountStorage } from './util/AccountStorage.js'; |
| 32 | +import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; | |
| 32 | 33 | |
| 33 | 34 | export { |
| 34 | 35 | TAG_FOLDER_TYPES, |
| @@ -2515,6 +2516,82 @@ function registerTagsSlashCommands() { | ||
| 2515 | 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 | } |
| 2519 | 2596 | |
| 2520 | 2597 | /** |
| @@ -180,6 +180,15 @@ export function isValidUrl(value) { | ||
| 180 | 180 | } |
| 181 | 181 | |
| 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 | + */ | |
| 187 | +export function isExternalUrl(url) { | |
| 188 | + return (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin); | |
| 189 | +} | |
| 190 | + | |
| 191 | +/** | |
| 183 | 192 | * Checks if a string is a valid UUID (version 1-5). |
| 184 | 193 | * @param {string} value String to check |
| 185 | 194 | * @returns {boolean} True if the string is a valid UUID, false otherwise. |
| @@ -1733,12 +1742,9 @@ export function loadFileToDocument(url, type) { | ||
| 1733 | 1742 | } |
| 1734 | 1743 | |
| 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 | */ |
| 1740 | -export async function ensureImageFormatSupported(file) { | |
| 1747 | +export const supportedImageMimeTypes = Object.freeze([ | |
| 1741 | - const supportedTypes = [ | |
| 1742 | 1748 | 'image/jpeg', |
| 1743 | 1749 | 'image/png', |
| 1744 | 1750 | 'image/bmp', |
| @@ -1747,9 +1753,15 @@ export async function ensureImageFormatSupported(file) { | ||
| 1747 | 1753 | 'image/apng', |
| 1748 | 1754 | 'image/webp', |
| 1749 | 1755 | 'image/avif', |
| 1750 | 1756 | ]); |
| 1751 | 1757 | |
| 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 | + */ | |
| 1763 | +export async function ensureImageFormatSupported(file) { | |
| 1764 | + if (supportedImageMimeTypes.includes(file.type) || !file.type.startsWith('image/')) { | |
| 1753 | 1765 | return file; |
| 1754 | 1766 | } |
| 1755 | 1767 | |