feat: /uploadsprite slash command

c3a12cc1a27ba3026df361409d0c00daa569af16

ceruleandeep <cerulean@cerulean.foo>

1 files changed, +97 -1Ignore whitespace
public/scripts/extensions/expressions/index.js+97 -1
@@ -14,7 +14,6 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
1414import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
1515import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
1616import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
17-import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
1817import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
1918export { MODULE_NAME };
2019
@@ -987,6 +986,71 @@ async function setSpriteSlashCommand(_, spriteId) {
987986}
988987
989988/**
989+ * Returns the sprite folder name (including override) for a character.
990+ * @param {object} char Character object
991+ * @param {string} char.avatar Avatar filename with extension
992+ * @returns {string} Sprite folder name
993+ * @throws {Error} If character not found or avatar not set
994+ */
995+function spriteFolderNameFromCharacter(char) {
996+ const avatarFileName = char.avatar.replace(/\.[^/.]+$/, '');
997+ const expressionOverride = extension_settings.expressionOverrides.find(e => e.name === avatarFileName);
998+ return expressionOverride?.path ? expressionOverride.path : avatarFileName;
999+}
1000+
1001+/**
1002+ * Slash command callback for /uploadsprite
1003+ *
1004+ * label= is required
1005+ * if name= is provided, it will be used as a findChar lookup
1006+ * if name= is not provided, the last character's name will be used
1007+ * if folder= is a full path, it will be used as the folder
1008+ * if folder= is a partial path, it will be appended to the character's name
1009+ * if folder= is not provided, the character's override folder will be used, if set
1010+ *
1011+ * @param {object} args
1012+ * @param {string} args.name Character name or avatar key, passed through findChar
1013+ * @param {string} args.label Expression label
1014+ * @param {string} args.folder Sprite folder path, processed using backslash rules
1015+ * @param {string} imageUrl Image URI to fetch and upload
1016+ * @returns {Promise<void>}
1017+ */
1018+async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1019+ if (!imageUrl) throw new Error('Image URL is required');
1020+ if (!label || typeof label !== 'string') throw new Error('Expression label is required');
1021+
1022+ label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
1023+ if (!label) throw new Error('Expression label must contain at least one letter');
1024+
1025+ name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
1026+ const char = findChar({ name });
1027+
1028+ if (!folder) {
1029+ folder = spriteFolderNameFromCharacter(char);
1030+ } else if (folder.startsWith('/') || folder.startsWith('\\')) {
1031+ const subfolder = folder.slice(1);
1032+ folder = `${char.name}/${subfolder}`;
1033+ }
1034+
1035+ try {
1036+ const response = await fetch(imageUrl);
1037+ const blob = await response.blob();
1038+ const file = new File([blob], 'image.png', { type: 'image/png' });
1039+
1040+ const formData = new FormData();
1041+ formData.append('name', folder); // this is the folder or character name
1042+ formData.append('label', label); // this is the expression label
1043+ formData.append('avatar', file); // this is the image file
1044+
1045+ await handleFileUpload('/api/sprites/upload', formData);
1046+ console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
1047+ } catch (error) {
1048+ console.error(`[${MODULE_NAME}] Error uploading file:`, error);
1049+ throw error;
1050+ }
1051+}
1052+
1053+/**
9901054 * Processes the classification text to reduce the amount of text sent to the API.
9911055 * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is.
9921056 * If the text is more than 300 characters, the first and last 150 characters are returned.
@@ -2215,4 +2279,36 @@ function migrateSettings() {
22152279 </div>
22162280 `,
22172281 }));
2282+ SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2283+ name: 'uploadsprite',
2284+ description: 'Upload a sprite',
2285+ callback: async (args, url) => {
2286+ await uploadSpriteCommand(args, url);
2287+ },
2288+ unnamedArgumentList: [
2289+ SlashCommandArgument.fromProps({
2290+ description: 'URL of the image to upload',
2291+ typeList: [ARGUMENT_TYPE.STRING],
2292+ isRequired: true,
2293+ }),
2294+ ],
2295+ namedArgumentList: [
2296+ SlashCommandNamedArgument.fromProps({
2297+ name: 'name',
2298+ description: 'Character name or avatar key (default is current character)',
2299+ type: ARGUMENT_TYPE.STRING,
2300+ }),
2301+ SlashCommandNamedArgument.fromProps({
2302+ name: 'label',
2303+ description: 'Sprite label/expression name',
2304+ type: ARGUMENT_TYPE.STRING,
2305+ }),
2306+ SlashCommandNamedArgument.fromProps({
2307+ name: 'folder',
2308+ description: 'Override folder to upload into',
2309+ type: ARGUMENT_TYPE.STRING,
2310+ }),
2311+ ],
2312+ helpString: 'Upload a sprite from a URL. Example: /uploadsprite name=Seraphina label=happy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png',
2313+ }));
22182314})();