diff --git a/public/script.js b/public/script.js index f7b730098..856da8cc5 100644 --- a/public/script.js +++ b/public/script.js @@ -5862,34 +5862,60 @@ function addChatsSeparator(mesSendString) { } } -export async function duplicateCharacter() { - if (this_chid === undefined || !characters[this_chid]) { - toastr.warning(t`You must first select a character to duplicate!`); - return ''; +/** + * Duplicates a character. + * @param {object} [options={}] - Options + * @param {string} [options.avatar] - Avatar key of the character to duplicate. Uses current character if not provided. + * @param {boolean} [options.silent=false] - Whether to skip the confirmation popup + * @returns {Promise} The avatar key of the duplicated character, or empty string if cancelled/failed + */ +export async function duplicateCharacter({ avatar = null, silent = false } = {}) { + // Determine the character to duplicate + let targetAvatar; + if (avatar) { + const character = characters.find(c => c.avatar === avatar); + if (!character) { + toastr.warning(t`Character not found: ${avatar}`); + return ''; + } + targetAvatar = avatar; + } else { + if (this_chid === undefined || !characters[this_chid]) { + toastr.warning(t`You must first select a character to duplicate!`); + return ''; + } + targetAvatar = characters[this_chid].avatar; } - const confirmMessage = $(await renderTemplateAsync('duplicateConfirm')); - const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM); + // Show confirmation unless silent + if (!silent) { + const confirmMessage = $(await renderTemplateAsync('duplicateConfirm')); + const confirm = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM); - if (!confirm) { - console.log('User cancelled duplication'); - return ''; + if (!confirm) { + console.log('User cancelled duplication'); + return ''; + } } - const body = { avatar_url: characters[this_chid].avatar }; + const body = { avatar_url: targetAvatar }; const response = await fetch('/api/characters/duplicate', { method: 'POST', headers: getRequestHeaders(), body: JSON.stringify(body), }); - if (response.ok) { - toastr.success(t`Character Duplicated`); - const data = await response.json(); - await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path }); - await getCharacters(); + + if (!response.ok) { + toastr.error(t`Failed to duplicate character`); + return ''; } - return ''; + toastr.success(t`Character Duplicated`); + const data = await response.json(); + await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: targetAvatar, newAvatar: data.path }); + await getCharacters(); + + return data.path; } function setInContextMessages(msgInContextCount, type) { @@ -7308,29 +7334,26 @@ async function read_avatar_load(input) { } await createOrEditCharacter(); - await delay(DEFAULT_SAVE_EDIT_TIMEOUT); const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0))); - await fetch(getThumbnailUrl('avatar', formData.get('avatar_url').toString()), { - method: 'GET', - cache: 'reload', - }); + const avatarKey = formData.get('avatar_url').toString(); - const messages = $('.mes').toArray(); - for (const el of messages) { - const $el = $(el); - const nameMatch = $el.attr('ch_name') == formData.get('ch_name'); - if ($el.attr('is_system') == 'true' && !nameMatch) continue; - if ($el.attr('is_user') == 'true') continue; + // Bust cache for the avatar thumbnail and character image + const thumbnailUrl = getThumbnailUrl('avatar', avatarKey); + await fetch(thumbnailUrl, { method: 'GET', cache: 'reload' }); + await fetch(`/characters/${avatarKey}`, { method: 'GET', cache: 'reload' }); - if (nameMatch) { - const previewSrc = $('#avatar_load_preview').attr('src'); - const avatar = $el.find('.avatar img'); - avatar.attr('src', default_avatar); - await delay(1); - avatar.attr('src', previewSrc); + // Refresh all visible avatar images that use this thumbnail URL + // This handles messages, character list, and any other place using the thumbnail + const avatarImages = document.querySelectorAll(`img[src^="${thumbnailUrl}"]`); + for (const img of avatarImages) { + if (img instanceof HTMLImageElement) { + const originalSrc = img.src; + img.src = ''; + img.src = originalSrc; } } + console.debug(`Refreshed ${avatarImages.length} avatar images for ${avatarKey}`); console.log('Avatar refreshed'); } @@ -10563,7 +10586,7 @@ export async function handleDeleteCharacter(this_chid, delete_chats) { * @param {string|string[]} characterKey - The key (avatar) of the character to be deleted * @param {Object} [options] - Optional parameters for the deletion * @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not - * @return {Promise} - A promise that resolves when the character is successfully deleted + * @return {Promise} - A promise that resolves when the character is successfully deleted */ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) { if (!Array.isArray(characterKey)) { @@ -10577,15 +10600,17 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) t`Deleting this character will close the chat and you will lose any unsaved messages. Do you want to proceed?`, ); if (!confirmClose) { - return; + return false; } } const closeChatResult = await closeCurrentChat(); if (!closeChatResult) { - return; + return false; } + let deleted = false; + for (const key of characterKey) { const character = characters.find(x => x.avatar == key); if (!character) { @@ -10624,9 +10649,11 @@ export async function deleteCharacter(characterKey, { deleteChats = true } = {}) } await eventSource.emit(event_types.CHARACTER_DELETED, { id: chid, character: character }); + deleted = true; } await removeCharacterFromUI(); + return deleted; } /** diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 0196b1ccf..3de5ff0c8 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -1,5 +1,5 @@ import { Fuse, DOMPurify } from '../lib.js'; -import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight } from './utils.js'; +import { canUseNegativeLookbehind, copyText, findPersona, flashHighlight, getBase64Async, ensureImageFormatSupported, supportedImageMimeTypes, isExternalUrl } from './utils.js'; import { Generate, @@ -12,6 +12,7 @@ import { comment_avatar, deactivateSendButtons, default_avatar, + deleteCharacter, deleteSwipe, displayPastChats, duplicateCharacter, @@ -22,9 +23,12 @@ import { extractMessageBias, generateQuietPrompt, generateRaw, + getCharacters, getCurrentChatDetails, getCurrentChatId, getFirstDisplayedMessageId, + getOneCharacter, + getRequestHeaders, getThumbnailUrl, is_send_press, main_api, @@ -40,6 +44,8 @@ import { saveChatConditional, saveSettings, saveSettingsDebounced, + selectCharacterById, + select_selected_character, sendMessageAsUser, sendSystemMessage, setActiveCharacter, @@ -229,11 +235,6 @@ export function initDefaultSlashCommands() { return ''; } - SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'dupe', - callback: duplicateCharacter, - helpString: t`Duplicates the currently selected character.`, - })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'api', callback: async function (args, text) { @@ -765,6 +766,414 @@ export function initDefaultSlashCommands() { `, })); + + /** + * Provides autocomplete matching for folder names. + * Matches if the input starts with the check or vice versa (case-insensitive). + * @param {string} input - The input string to match against + * @param {string} check - The check string to match with + * @param {object} [options={}] - Options + * @param {boolean} [options.trueOnEmpty=true] - Whether to return true when input is empty + * @returns {boolean} - True if the strings match according to the folder matching rules + */ + function folderEnumMatchProvider(input, check, { trueOnEmpty = true } = {}) { + if (!check) return false; + if (!input) return trueOnEmpty; + const inputLower = input.toLowerCase(); + const checkLower = check.toLowerCase(); + return inputLower.startsWith(checkLower) || checkLower.startsWith(inputLower); + } + + // Shared character field definitions for char CRUD commands + const getCharacterFieldArgs = ({ requiredFields = [] } = {}) => [ + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: t`The name of the character`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('name'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'description', + description: t`The character's description/personality definition`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('description'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'firstMessage', + description: t`The character's first message/greeting`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('firstMessage'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'personality', + description: t`A brief description of the personality`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('personality'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'scenario', + description: t`The scenario or circumstances for the conversation`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('scenario'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'messageExamples', + description: t`Example messages for the character`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('messageExamples'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'creatorNotes', + description: t`Notes from the character creator`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('creatorNotes'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'systemPrompt', + description: t`The character's system prompt`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('systemPrompt'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'postHistoryInstructions', + description: t`Post-history instructions (jailbreak)`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('postHistoryInstructions'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'creator', + description: t`The creator of the character`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('creator'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'characterVersion', + description: t`The version of the character`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('characterVersion'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'tags', + 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.`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('tags'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'favorite', + description: t`Whether this character is a favorite`, + typeList: [ARGUMENT_TYPE.BOOLEAN], + enumProvider: commonEnumProviders.boolean('trueFalse'), + isRequired: requiredFields.includes('favorite'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'avatar', + 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.`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('avatar'), + enumList: [ + new SlashCommandEnumValue('prompt', 'Open file picker to select an image', 'enum', '📁'), + new SlashCommandEnumValue('characters/...', 'Character avatars path (e.g., characters/Name.png)', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'characters/'), () => 'characters/'), + new SlashCommandEnumValue('backgrounds/...', 'Background image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'backgrounds/'), () => 'backgrounds/'), + new SlashCommandEnumValue('User Avatars/...', 'User avatar path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'User Avatars/'), () => 'User Avatars/'), + new SlashCommandEnumValue('assets/...', 'Asset file path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'assets/'), () => 'assets/'), + new SlashCommandEnumValue('user/images/...', 'User image path', 'enum', '📄', (input) => folderEnumMatchProvider(input, 'user/images/'), () => 'user/images/'), + ], + }), + SlashCommandNamedArgument.fromProps({ + name: 'avatarPromptResize', + description: t`Whether to show the avatar resize/crop dialog when uploading (default: true). Ignored if "Never resize avatars" is enabled in settings.`, + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'true', + enumProvider: commonEnumProviders.boolean('trueFalse'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'talkativeness', + description: t`How often the character speaks in group chats (0.0 to 1.0)`, + typeList: [ARGUMENT_TYPE.NUMBER], + isRequired: requiredFields.includes('talkativeness'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'world', + description: t`The name of the lorebook to attach`, + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: commonEnumProviders.worlds, + isRequired: requiredFields.includes('world'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'depthPrompt', + description: t`Character-specific depth prompt content`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: requiredFields.includes('depthPrompt'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'depthPromptDepth', + description: t`Depth for the character-specific depth prompt`, + typeList: [ARGUMENT_TYPE.NUMBER], + isRequired: requiredFields.includes('depthPromptDepth'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'depthPromptRole', + description: t`Role for the depth prompt`, + typeList: [ARGUMENT_TYPE.STRING], + enumList: commonEnumProviders.messageRoles(), + isRequired: requiredFields.includes('depthPromptRole'), + }), + ]; + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'char-create', + callback: createCharacterCallback, + returns: t`the avatar key (unique identifier) of the created character`, + namedArgumentList: [ + ...getCharacterFieldArgs({ requiredFields: ['name'] }), + SlashCommandNamedArgument.fromProps({ + name: 'select', + description: t`Whether to select/open the character after creation (default: true)`, + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'true', + enumProvider: commonEnumProviders.boolean('trueFalse'), + }), + ], + helpString: ` +
+ ${t`Creates a new character with the specified attributes. Returns the avatar key of the created character.`} +
+
+ ${t`Required arguments:`} +
    +
  • name - ${t`The character's name`}
  • +
+
+
+ ${t`Note on tags:`} ${t`The tags argument sets character card tags (embedded in the character file), not SillyTavern's folder/filter tags. To add ST tags after creation, use /tag-add. To import card tags as ST tags, use /tag-import.`} +
+
+ ${t`Note on avatar:`} ${t`The avatar argument accepts prompt to open a file picker, or a local ST file path. Supported paths include: characters/Name.png, backgrounds/image.png, User Avatars/avatar.png, assets/category/file.png. This can also be the return value from the /imagine command. External URLs are not supported.`} +
+
+ ${t`Example:`} +
    +
  • +
    /char-create name="Alice" description="A friendly AI assistant" firstMessage="Hello! How can I help you today?"
    +
  • +
  • +
    /char-create name="Bob" description="A wise wizard" firstMessage="Greetings, traveler." personality="Wise, patient" scenario="A magical library" favorite=true
    +
  • +
  • +
    /char-create name="Clone" description="A clone" firstMessage="Hi!" avatar=prompt
    + ${t`(opens file picker for avatar)`} +
  • +
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'char-update', + callback: updateCharacterCallback, + returns: t`the avatar key of the updated character`, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'char', + description: t`Character name or avatar key. If not provided, uses the currently selected character.`, + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: commonEnumProviders.characters('character'), + }), + ...getCharacterFieldArgs(), + ], + helpString: ` +
+ ${t`Updates an existing character's attributes. The character does not need to be currently selected.`} +
+
+ ${t`If no char argument is provided, updates the currently selected character.`} +
+
+ ${t`Note on tags:`} ${t`The tags argument sets character card tags (embedded in the PNG), not SillyTavern's folder/filter tags. To add ST tags, use /tag-add. To import card tags as ST tags, use /tag-import.`} +
+
+ ${t`Note on avatar:`} ${t`The avatar argument accepts prompt to open a file picker, or a local ST file path. Supported paths: characters/Name.png, backgrounds/image.png, User Avatars/avatar.png, assets/category/file.png. This can also be the return value from the /imagine command. External URLs are not supported.`} +
+
+ ${t`Example:`} +
    +
  • +
    /char-update description="An updated description for this character"
    + ${t`Updates the currently selected character's description.`} +
  • +
  • +
    /char-update char="Alice" personality="Cheerful and energetic" favorite=true
    + ${t`Updates Alice's personality and marks her as a favorite.`} +
  • +
  • +
    /imagine you | /char-update avatar="{{pipe}}"
    + ${t`Generates an image and sets it as the current character's avatar.`} +
  • +
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'char-duplicate', + aliases: ['dupe'], + callback: duplicateCharacterCallback, + returns: t`the avatar key (unique identifier) of the duplicated character`, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'char', + description: t`Character name or avatar key to duplicate. If not provided, uses the currently selected character.`, + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: commonEnumProviders.characters('character'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'select', + description: t`Whether to select/open the duplicated character after creation (default: false)`, + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'false', + enumProvider: commonEnumProviders.boolean('trueFalse'), + }), + ], + helpString: ` +
+ ${t`Duplicates a character. Returns the avatar key of the duplicated character.`} +
+
+ ${t`Use /char-update afterwards to modify the duplicated character's fields.`} +
+
+ ${t`Example:`} +
    +
  • +
    /char-duplicate
    + ${t`Duplicates the currently selected character.`} +
  • +
  • +
    /char-duplicate char="Alice" select=true
    + ${t`Duplicates Alice and selects the new character.`} +
  • +
  • +
    /char-duplicate | /setvar key=newChar | /char-update char="{{getvar::newChar}}" name="Clone"
    + ${t`Duplicates the current character and renames the clone.`} +
  • +
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'char-get', + aliases: ['char-data'], + callback: getCharacterDataCallback, + returns: t`character data as JSON or a specific field value`, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'char', + description: t`Character name or avatar key. If not provided, uses the currently selected character.`, + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: commonEnumProviders.characters('character'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'field', + description: t`Specific field to retrieve. If not provided, returns the entire character data.`, + typeList: [ARGUMENT_TYPE.STRING], + enumList: [ + new SlashCommandEnumValue('name', t`Character name`, enumTypes.enum), + new SlashCommandEnumValue('description', t`Character description`, enumTypes.enum), + new SlashCommandEnumValue('personality', t`Character personality`, enumTypes.enum), + new SlashCommandEnumValue('scenario', t`Character scenario`, enumTypes.enum), + new SlashCommandEnumValue('first_mes', t`First message`, enumTypes.enum), + new SlashCommandEnumValue('mes_example', t`Message examples`, enumTypes.enum), + new SlashCommandEnumValue('creator_notes', t`Creator notes`, enumTypes.enum), + new SlashCommandEnumValue('system_prompt', t`System prompt`, enumTypes.enum), + new SlashCommandEnumValue('post_history_instructions', t`Post-history instructions`, enumTypes.enum), + new SlashCommandEnumValue('creator', t`Creator name`, enumTypes.enum), + new SlashCommandEnumValue('character_version', t`Character version`, enumTypes.enum), + new SlashCommandEnumValue('tags', t`Character tags`, enumTypes.enum), + new SlashCommandEnumValue('talkativeness', t`Talkativeness`, enumTypes.enum), + new SlashCommandEnumValue('avatar', t`Avatar filename`, enumTypes.enum), + new SlashCommandEnumValue('fav', t`Favorite status`, enumTypes.enum), + ], + }), + SlashCommandNamedArgument.fromProps({ + name: 'return', + description: t`The way to return the result`, + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: 'pipe', + enumList: slashCommandReturnHelper.enumList({ allowPipe: true, allowObject: true, allowChat: false, allowPopup: true, allowTextVersion: false }), + }), + ], + helpString: ` +
+ ${t`Retrieves character data. Can get all data or a specific field.`} +
+
+ ${t`Example:`} +
    +
  • +
    /char-get field=description | /echo
    + ${t`Outputs the current character's description.`} +
  • +
  • +
    /char-get char="Alice" field=personality
    + ${t`Returns Alice's personality field.`} +
  • +
  • +
    /char-get char="Bob" return=object
    + ${t`Returns Bob's entire character data as an object.`} +
  • +
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'char-delete', + callback: deleteCharacterCallback, + returns: t`true if the character was deleted, false otherwise`, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'char', + description: t`Character name or avatar key. If not provided, uses the currently selected character.`, + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: commonEnumProviders.characters('character'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'deleteChats', + description: t`Whether to also delete all chats with this character`, + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'false', + enumProvider: commonEnumProviders.boolean('trueFalse'), + }), + SlashCommandNamedArgument.fromProps({ + name: 'silent', + description: t`Skip the confirmation popup`, + typeList: [ARGUMENT_TYPE.BOOLEAN], + defaultValue: 'false', + enumProvider: commonEnumProviders.boolean('trueFalse'), + }), + ], + helpString: ` +
+ ${t`Deletes a character from the system.`} +
+
+ ${t`If no char argument is provided, deletes the currently selected character.`} +
+
+ ${t`Warning:`} ${t`This action is irreversible!`} +
+
+ ${t`Example:`} +
    +
  • +
    /char-delete
    + ${t`Deletes the currently selected character (will show confirmation popup).`} +
  • +
  • +
    /char-delete char="Bob" deleteChats=true silent=true
    + ${t`Deletes Bob and all associated chats without confirmation.`} +
  • +
+
+ `, + })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'message-role', callback: messageRoleCallback, @@ -4669,6 +5078,606 @@ async function openChat(chid) { await reloadCurrentChat(); } +/** + * Opens a file picker dialog for selecting an image. + * @returns {Promise} Base64 data URL of selected image, or null if cancelled + */ +async function promptForAvatarFile() { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = supportedImageMimeTypes.join(','); + input.onchange = async (e) => { + if (!(e.target instanceof HTMLInputElement)) { + return ''; + } + const file = e.target?.files?.[0]; + if (!file) { + resolve(null); + return; + } + try { + const converted = await ensureImageFormatSupported(file); + const base64 = await getBase64Async(converted); + resolve(base64); + } catch (error) { + console.error('Error processing selected image:', error); + toastr.error(t`Failed to process selected image: ${error.message}`); + resolve(null); + } + }; + input.oncancel = () => resolve(null); + input.click(); + }); +} + +/** + * Resolves avatar data from various input formats (base64, local path, or prompt). + * @param {string} input - "prompt" to open file picker, base64 data URL, or local file path + * @returns {Promise} Base64 data URL or null if invalid/cancelled + */ +async function resolveAvatarData(input) { + if (!input || typeof input !== 'string') { + return null; + } + + const trimmed = input.trim(); + + // Special value "prompt" opens file picker + if (trimmed.toLowerCase() === 'prompt') { + return await promptForAvatarFile(); + } + + // Already a base64 data URL + if (trimmed.startsWith('data:image/')) { + return trimmed; + } + + // External URLs are not supported + if (isExternalUrl(trimmed)) { + toastr.warning(t`External URLs are not supported for avatars. Use a local file path or "prompt" to select a file.`); + return null; + } + // Local path or URL (e.g., characters/name.png) - fetch from ST server or same origin + // Supported paths: /characters/*, /backgrounds/*, /User Avatars/*, /assets/*, /user/images/* + // Also supports same-origin URLs (e.g., https://localhost:8000/characters/name.png) + if (trimmed.includes('/') || trimmed.endsWith('.png')) { + try { + // Construct the URL to fetch the local file + let url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + // Handle same-origin URLs + if (trimmed.startsWith(window.location.origin)) { + url = new URL(trimmed).pathname; + } + // If there is no subfolder, we guess this should be a character image + if (!url.includes('/', 1)) { + url = '/characters/' + trimmed; + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`File not found or inaccessible: ${response.status}`); + } + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) { + throw new Error('File is not an image'); + } + const converted = await ensureImageFormatSupported(new File([blob], 'avatar.png', { type: blob.type })); + return await getBase64Async(converted); + } catch (error) { + console.error('Error fetching local avatar:', error); + toastr.warning(t`Failed to load avatar from path: ${error.message}`); + return null; + } + } + + // Unknown format + console.warn('Unknown avatar format:', trimmed.substring(0, 50)); + toastr.warning(t`Unknown avatar format. Use "prompt" to select a file, or provide a local file path.`); + return null; +} + +/** + * Uploads an avatar image to a character. + * @param {string} avatarKey - The character's avatar filename (e.g., "name.png") + * @param {string} base64Data - Base64 data URL of the image + * @param {object} [options={}] - Options + * @param {boolean} [options.resizePrompt=false] - Whether to show the resize/crop prompt + * @returns {Promise} True if upload was successful, false if cancelled or failed + */ +async function uploadCharacterAvatar(avatarKey, base64Data, { resizePrompt = false } = {}) { + if (!base64Data || !avatarKey) { + return false; + } + + let finalImageData = base64Data; + + // Handle resize prompt + if (resizePrompt) { + if (power_user.never_resize_avatars) { + toastr.warning(t`Avatar resizing is disabled in settings. The image will be uploaded as-is.`); + } else { + const dlg = new Popup(t`Set the crop position of the avatar image`, POPUP_TYPE.CROP, '', { cropImage: base64Data }); + const croppedImage = await dlg.show(); + if (!croppedImage) { + // User cancelled the crop dialog + return false; + } + // The dialog returns the already-cropped image + finalImageData = String(croppedImage); + } + } + + try { + // Convert base64 to blob + const response = await fetch(finalImageData); + const blob = await response.blob(); + + // Create form data for upload + const formData = new FormData(); + formData.append('avatar', blob, 'avatar.png'); + formData.append('avatar_url', avatarKey); + + const uploadResponse = await fetch('/api/characters/edit-avatar', { + method: 'POST', + headers: getRequestHeaders({ omitContentType: true }), + body: formData, + }); + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text(); + throw new Error(errorText); // Will be caught and logged below + } + + // Bust cache for the avatar thumbnail and character image + const thumbnailUrl = getThumbnailUrl('avatar', avatarKey); + await fetch(thumbnailUrl, { method: 'GET', cache: 'reload' }); + await fetch(`/characters/${avatarKey}`, { method: 'GET', cache: 'reload' }); + + // Refresh all visible avatar images that use this thumbnail URL + // This handles messages, character list, and any other place using the thumbnail + const avatarImages = document.querySelectorAll(`img[src^="${thumbnailUrl}"]`); + for (const img of avatarImages) { + if (img instanceof HTMLImageElement) { + const originalSrc = img.src; + img.src = ''; + img.src = originalSrc; + } + } + console.debug(`Refreshed ${avatarImages.length} avatar images for ${avatarKey}`); + + return true; + } catch (error) { + console.error('Error uploading character avatar:', error); + toastr.warning(t`Failed to upload avatar: ${error.message}`); + return false; + } +} + +/** + * Creates a new character via the API. + * @param {object} args Named arguments + * @returns {Promise} The avatar key of the created character + */ +async function createCharacterCallback(args) { + const name = args.name; + const description = args.description; + const firstMessage = args.firstMessage; + + if (!name || typeof name !== 'string' || !name.trim()) { + toastr.warning(t`Character name is required`); + return ''; + } + if (!description || typeof description !== 'string') { + toastr.warning(t`Character description is required`); + return ''; + } + if (!firstMessage || typeof firstMessage !== 'string') { + toastr.warning(t`Character first message is required`); + return ''; + } + + // Build the character data object matching the server's expected format + const characterData = { + ch_name: name.trim(), + description: description, + first_mes: firstMessage, + personality: args.personality ?? '', + scenario: args.scenario ?? '', + mes_example: args.messageExamples ?? '', + creator_notes: args.creatorNotes ?? '', + system_prompt: args.systemPrompt ?? '', + post_history_instructions: args.postHistoryInstructions ?? '', + creator: args.creator ?? '', + character_version: args.characterVersion ?? '', + tags: args.tags ? args.tags.split(',').map(t => t.trim()).filter(t => t) : [], + talkativeness: args.talkativeness ?? '0.5', + world: args.world ?? '', + depth_prompt_prompt: args.depthPrompt ?? '', + depth_prompt_depth: args.depthPromptDepth ?? '4', + depth_prompt_role: args.depthPromptRole ?? 'system', + fav: isTrueBoolean(args.favorite) ? 'true' : 'false', + alternate_greetings: [], + extensions: '{}', + }; + + // Handle avatar if provided (URL or base64) + const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null; + + try { + const response = await fetch('/api/characters/create', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(characterData), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText); // Will be caught and logged below + } + + const avatarKey = await response.text(); + + // Upload avatar if provided + if (avatarData) { + const resizePrompt = !isFalseBoolean(args.avatarPromptResize); + const uploaded = await uploadCharacterAvatar(avatarKey, avatarData, { resizePrompt }); + if (!uploaded && resizePrompt) { + // User cancelled the resize dialog, but character was still created + toastr.info(t`Character created without avatar (resize cancelled)`); + } + } + + // Refresh the character list + await getCharacters(); + + // Select the character if requested (default: true) + const shouldSelect = !isFalseBoolean(args.select); + if (shouldSelect) { + const characterIndex = characters.findIndex(c => c.avatar === avatarKey); + if (characterIndex !== -1) { + // selectCharacterById handles group reset and active character setting + await selectCharacterById(characterIndex); + } + } + + toastr.success(t`Character "${name}" created successfully`); + return avatarKey; + } catch (error) { + console.error('Error creating character:', error); + toastr.error(t`Failed to create character: ${error.message}`); + return ''; + } +} + +/** + * Updates an existing character via the merge-attributes API. + * @param {object} args Named arguments + * @returns {Promise} The avatar key of the updated character + */ +async function updateCharacterCallback(args) { + // Find the target character + let character; + let characterIndex; + if (args.char) { + character = findChar({ name: args.char }); + if (!character) { + toastr.warning(t`Character "${args.char}" not found`); + return ''; + } + characterIndex = String(characters.indexOf(character)); + } else { + // Use currently selected character + if (this_chid === undefined || !characters[this_chid]) { + toastr.warning(t`No character selected and no char argument provided`); + return ''; + } + character = characters[this_chid]; + characterIndex = this_chid; + } + + // Build the update object with only provided fields + const updateData = { + avatar: character.avatar, + }; + + // Map argument names to character data field names + const fieldMappings = { + name: 'name', + description: 'description', + firstMessage: 'first_mes', + personality: 'personality', + scenario: 'scenario', + messageExamples: 'mes_example', + creatorNotes: 'creator_notes', + systemPrompt: 'system_prompt', + postHistoryInstructions: 'post_history_instructions', + creator: 'creator', + characterVersion: 'character_version', + tags: 'tags', + }; + + // Add provided fields to update data + let hasUpdates = false; + for (const [argName, fieldName] of Object.entries(fieldMappings)) { + if (args[argName] !== undefined) { + let value = args[argName]; + // Handle tags as comma-separated array + if (fieldName === 'tags' && typeof value === 'string') { + value = value.split(',').map(t => t.trim()).filter(t => t); + } + updateData[fieldName] = value; + // Also set in data object for V2 spec compliance + if (!updateData.data) updateData.data = {}; + updateData.data[fieldName] = value; + hasUpdates = true; + } + } + + // Special handling for world / lorebook: store under data.extensions.world + if (args.world !== undefined) { + const value = args.world; + if (!updateData.data) { + updateData.data = {}; + } + if (!updateData.data.extensions) { + updateData.data.extensions = {}; + } + updateData.data.extensions.world = value; + hasUpdates = true; + } + + // Handle talkativeness (stored in extensions) + if (args.talkativeness !== undefined) { + const talkValue = parseFloat(args.talkativeness); + if (!isNaN(talkValue)) { + updateData.talkativeness = talkValue; + if (!updateData.data) updateData.data = {}; + if (!updateData.data.extensions) updateData.data.extensions = {}; + updateData.data.extensions.talkativeness = talkValue; + hasUpdates = true; + } + } + + // Handle favorite + if (args.favorite !== undefined) { + const favValue = isTrueBoolean(args.favorite); + updateData.fav = favValue; + if (!updateData.data) updateData.data = {}; + if (!updateData.data.extensions) updateData.data.extensions = {}; + updateData.data.extensions.fav = favValue; + hasUpdates = true; + } + + // Handle avatar (resolve URL/base64, upload separately after merge) + const avatarData = args.avatar ? await resolveAvatarData(args.avatar) : null; + if (avatarData) { + hasUpdates = true; + } + + // Handle depth prompt fields + if (args.depthPrompt !== undefined || args.depthPromptDepth !== undefined || args.depthPromptRole !== undefined) { + if (!updateData.data) updateData.data = {}; + if (!updateData.data.extensions) updateData.data.extensions = {}; + if (!updateData.data.extensions.depth_prompt) updateData.data.extensions.depth_prompt = {}; + + if (args.depthPrompt !== undefined) { + updateData.data.extensions.depth_prompt.prompt = args.depthPrompt; + hasUpdates = true; + } + if (args.depthPromptDepth !== undefined) { + updateData.data.extensions.depth_prompt.depth = parseInt(args.depthPromptDepth); + hasUpdates = true; + } + if (args.depthPromptRole !== undefined) { + updateData.data.extensions.depth_prompt.role = args.depthPromptRole; + hasUpdates = true; + } + } + + if (!hasUpdates) { + toastr.warning(t`No fields provided to update`); + return character.avatar; + } + + try { + const response = await fetch('/api/characters/merge-attributes', { + method: 'POST', + headers: getRequestHeaders(), + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Server returned ${response.status}`); // Will be caught and logged below + } + + // Upload avatar if provided + if (avatarData) { + const resizePrompt = !isFalseBoolean(args.avatarPromptResize); + const uploaded = await uploadCharacterAvatar(character.avatar, avatarData, { resizePrompt }); + if (!uploaded && resizePrompt) { + // User cancelled the resize dialog + toastr.warning(t`Avatar update cancelled`); + } + } + + // Refresh the character data + await getOneCharacter(character.avatar); + + await eventSource.emit(event_types.CHARACTER_EDITED, { detail: { id: characterIndex, character: characters[characterIndex] } }); + + // Update the side panel if this is the currently selected character + if (characterIndex === this_chid) { + select_selected_character(this_chid, { switchMenu: false }); + } + + toastr.success(t`Character "${character.name}" updated successfully`); + return character.avatar; + } catch (error) { + console.error('Error updating character:', error); + toastr.error(t`Failed to update character: ${error.message}`); + return ''; + } +} + +/** + * Duplicates a character via the slash command. + * @param {object} args Named arguments + * @returns {Promise} The avatar key of the duplicated character + */ +async function duplicateCharacterCallback(args) { + // Find the target character if specified + let targetAvatar = null; + if (args.char) { + const character = findChar({ name: args.char }); + if (!character) { + toastr.warning(t`Character "${args.char}" not found`); + return ''; + } + targetAvatar = character.avatar; + } + + // Call the duplicateCharacter utility with silent mode (no popup) + const newAvatarKey = await duplicateCharacter({ avatar: targetAvatar, silent: true }); + if (!newAvatarKey) { + toastr.error(t`Failed to duplicate character`); + return ''; + } + + // Select the character if requested (default: false) + const shouldSelect = isTrueBoolean(args.select); + if (shouldSelect) { + const characterIndex = characters.findIndex(c => c.avatar === newAvatarKey); + if (characterIndex !== -1) { + await selectCharacterById(characterIndex); + } + } + + return newAvatarKey; +} + +/** + * Gets character data or a specific field. + * @param {object} args Named arguments + * @returns {Promise} Character data or field value + */ +async function getCharacterDataCallback(args) { + // Find the target character + let character; + if (args.char) { + character = findChar({ name: args.char }); + if (!character) { + toastr.warning(t`Character "${args.char}" not found`); + return ''; + } + } else { + // Use currently selected character + if (this_chid === undefined || !characters[this_chid]) { + toastr.warning(t`No character selected and no char argument provided`); + return ''; + } + character = characters[this_chid]; + } + + // If a specific field is requested + if (args.field) { + const fieldName = args.field; + + // Try to get from data object first (V2 spec), then fall back to root + let value = character.data?.[fieldName] ?? character[fieldName]; + + // Handle special cases for nested fields + if (fieldName === 'talkativeness') { + value = character.data?.extensions?.talkativeness ?? character.talkativeness ?? 0.5; + } + if (fieldName === 'tags') { + value = character.data?.tags ?? character.tags ?? []; + if (Array.isArray(value)) { + value = value.join(', '); + } + } + + if (value === undefined) { + return ''; + } + + return await slashCommandReturnHelper.doReturn(args.return ?? 'pipe', value, { objectToStringFunc: x => String(x) }); + } + + // Return entire character data + const charData = { + avatar: character.avatar, + name: character.name, + description: character.description ?? character.data?.description ?? '', + personality: character.personality ?? character.data?.personality ?? '', + scenario: character.scenario ?? character.data?.scenario ?? '', + first_mes: character.first_mes ?? character.data?.first_mes ?? '', + mes_example: character.mes_example ?? character.data?.mes_example ?? '', + creator_notes: character.data?.creator_notes ?? '', + system_prompt: character.data?.system_prompt ?? '', + post_history_instructions: character.data?.post_history_instructions ?? '', + creator: character.data?.creator ?? '', + character_version: character.data?.character_version ?? '', + tags: character.data?.tags ?? character.tags ?? [], + talkativeness: character.data?.extensions?.talkativeness ?? character.talkativeness ?? 0.5, + fav: character.fav ?? character.data?.extensions?.fav ?? false, + chat: character.chat, + create_date: character.create_date, + }; + + return await slashCommandReturnHelper.doReturn(args.return ?? 'pipe', charData, { objectToStringFunc: x => JSON.stringify(x, null, 2) }); +} + +/** + * Deletes a character using the core deleteCharacter function. + * @param {object} args Named arguments + * @returns {Promise} 'true' if deleted, 'false' otherwise + */ +async function deleteCharacterCallback(args) { + // Find the target character + let character; + if (args.char) { + character = findChar({ name: args.char }); + if (!character) { + toastr.warning(t`Character "${args.char}" not found`); + return 'false'; + } + } else { + // Use currently selected character + if (this_chid === undefined || !characters[this_chid]) { + toastr.warning(t`No character selected and no char argument provided`); + return 'false'; + } + character = characters[this_chid]; + } + + const deleteChats = isTrueBoolean(args.deleteChats); + const silent = isTrueBoolean(args.silent); + + // Show confirmation popup unless silent mode + if (!silent) { + const confirmMessage = deleteChats + ? t`Are you sure you want to delete "${character.name}" and all associated chats? This action cannot be undone.` + : t`Are you sure you want to delete "${character.name}"? This action cannot be undone.`; + + const result = await callGenericPopup(confirmMessage, POPUP_TYPE.CONFIRM); + if (result !== POPUP_RESULT.AFFIRMATIVE) { + return 'false'; + } + } + + try { + // Use the core deleteCharacter function which handles all cleanup and events + const success = await deleteCharacter(character.avatar, { deleteChats }); + return success ? 'true' : 'false'; + } catch (error) { + console.error('Error deleting character:', error); + toastr.error(t`Failed to delete character: ${error.message}`); + return 'false'; + } +} + async function continueChatCallback(args, prompt) { const shouldAwait = isTrueBoolean(args?.await); diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 5e425b140..1a49b871b 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -29,6 +29,7 @@ import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsPro import { renderTemplateAsync } from './templates.js'; import { t, translate } from './i18n.js'; import { accountStorage } from './util/AccountStorage.js'; +import { enumTypes, SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; export { TAG_FOLDER_TYPES, @@ -2515,6 +2516,82 @@ function registerTagsSlashCommands() { `, })); + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'tag-import', + /** @param {{name: string, mode: 'all'|'existing'|'none'|'ask'}} namedArgs @returns {Promise} */ + callback: async ({ name, mode }) => { + if (selected_group !== null) { + toastr.warning(t`Tag import does not support group chats.`); + return 'false'; + } + const key = searchCharByName(name); + if (!key) return 'false'; + + // Map mode argument to tag_import_setting + const modeMap = { + 'all': tag_import_setting.ALL, + 'existing': tag_import_setting.ONLY_EXISTING, + 'none': tag_import_setting.NONE, + 'ask': tag_import_setting.ASK, + }; + if (mode && !modeMap[mode]) { + toastr.warning(`Invalid tag import mode: ${mode}. Valid modes are: ${Object.keys(modeMap).join(', ')}`); + return 'false'; + } + + const importSetting = mode ? modeMap[mode] : null; + const character = findChar({ name: key }); + + const result = await importTags(character, { importSetting }); + return result ? 'true' : 'false'; + }, + returns: t`true if any tags were imported, false otherwise`, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'name', + description: 'Character name - or unique character identifier (avatar key)', + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: '{{char}}', + enumProvider: commonEnumProviders.characters(), + }), + SlashCommandNamedArgument.fromProps({ + name: 'mode', + description: t`Import mode: "all" imports all tags, "existing" imports only existing ST tags, "none" skips import, "ask" shows the import popup (default: uses your saved setting)`, + typeList: [ARGUMENT_TYPE.STRING], + enumList: [ + new SlashCommandEnumValue('all', t`Import all tags (create new ones if needed)`, enumTypes.enum), + new SlashCommandEnumValue('existing', t`Import only existing ST tags`, enumTypes.enum), + new SlashCommandEnumValue('none', t`Skip import`, enumTypes.enum), + new SlashCommandEnumValue('ask', t`Show the import popup`, enumTypes.enum), + ], + }), + ], + helpString: ` +
+ ${t`Imports character card tags as SillyTavern tags for folder/filter use.`} +
+
+ ${t`Character cards can have embedded tags (set via tags argument in /char-create or /char-update). This command imports those embedded tags as ST tags that can be used for filtering and organizing characters.`} +
+
+ ${t`If no mode is specified, uses your saved tag import setting from preferences.`} +
+
+ ${t`Example:`} +
    +
  • +
    /tag-import
    + ${t`Imports tags for the current character using your default setting.`} +
  • +
  • +
    /tag-import name="Alice" mode=all
    + ${t`Imports all of Alice's card tags, creating new ST tags if needed.`} +
  • +
+
+ `, + })); } /** diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 94dce24af..1d8464d1c 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -179,6 +179,15 @@ export function isValidUrl(value) { } } +/** + * Checks if a URL is external to the current domain. + * @param {string} url URL to check + * @returns {boolean} True if the URL is external, false otherwise + */ +export function isExternalUrl(url) { + return (url.indexOf('://') > 0 || url.indexOf('//') === 0) && !url.startsWith(window.location.origin); +} + /** * Checks if a string is a valid UUID (version 1-5). * @param {string} value String to check @@ -1732,24 +1741,27 @@ export function loadFileToDocument(url, type) { }); } +/** + * An array of all supported image MIME types. + */ +export const supportedImageMimeTypes = Object.freeze([ + 'image/jpeg', + 'image/png', + 'image/bmp', + 'image/tiff', + 'image/gif', + 'image/apng', + 'image/webp', + 'image/avif', +]); + /** * Ensure that we can import war crime image formats like WEBP and AVIF. * @param {File} file Input file * @returns {Promise} A promise that resolves to the supported file. */ export async function ensureImageFormatSupported(file) { - const supportedTypes = [ - 'image/jpeg', - 'image/png', - 'image/bmp', - 'image/tiff', - 'image/gif', - 'image/apng', - 'image/webp', - 'image/avif', - ]; - - if (supportedTypes.includes(file.type) || !file.type.startsWith('image/')) { + if (supportedImageMimeTypes.includes(file.type) || !file.type.startsWith('image/')) { return file; }