mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
Rework expression slash commands
- Common naming schema for slash commands, all starting with the name of the expression - moved the original names to aliases - Make char name optional for /expression-last if not in group chat - Removed legacy 'format' argument handling from /expression-classify - Fixed /expression-upload to the new backend call, added optional 'spriteName' argument
This commit is contained in:
@ -1,11 +1,11 @@
|
|||||||
import { Fuse } from '../../../lib.js';
|
import { Fuse } from '../../../lib.js';
|
||||||
|
|
||||||
import { eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js';
|
import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
|
||||||
import { dragElement, isMobile } from '../../RossAscends-mods.js';
|
import { dragElement, isMobile } from '../../RossAscends-mods.js';
|
||||||
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
|
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
|
||||||
import { loadMovingUIState, power_user } from '../../power-user.js';
|
import { loadMovingUIState, power_user } from '../../power-user.js';
|
||||||
import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
|
import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
|
||||||
import { hideMutedSprites } from '../../group-chats.js';
|
import { hideMutedSprites, selected_group } from '../../group-chats.js';
|
||||||
import { isJsonSchemaSupported } from '../../textgen-settings.js';
|
import { isJsonSchemaSupported } from '../../textgen-settings.js';
|
||||||
import { debounce_timeout } from '../../constants.js';
|
import { debounce_timeout } from '../../constants.js';
|
||||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||||
@ -92,6 +92,8 @@ let lastTalkingStateMessage = null; // last message as seen by `updateTalkingSt
|
|||||||
let spriteCache = {};
|
let spriteCache = {};
|
||||||
let inApiCall = false;
|
let inApiCall = false;
|
||||||
let lastServerResponseTime = 0;
|
let lastServerResponseTime = 0;
|
||||||
|
|
||||||
|
/** @type {{[characterName: string]: string}} */
|
||||||
export let lastExpression = {};
|
export let lastExpression = {};
|
||||||
|
|
||||||
function isTalkingHeadEnabled() {
|
function isTalkingHeadEnabled() {
|
||||||
@ -1033,17 +1035,21 @@ function spriteFolderNameFromCharacter(char) {
|
|||||||
* @param {object} args
|
* @param {object} args
|
||||||
* @param {string} args.name Character name or avatar key, passed through findChar
|
* @param {string} args.name Character name or avatar key, passed through findChar
|
||||||
* @param {string} args.label Expression label
|
* @param {string} args.label Expression label
|
||||||
* @param {string} args.folder Sprite folder path, processed using backslash rules
|
* @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules
|
||||||
|
* @param {string?} [args.spriteName=null] Optional sprite name
|
||||||
* @param {string} imageUrl Image URI to fetch and upload
|
* @param {string} imageUrl Image URI to fetch and upload
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<string>} the sprite name
|
||||||
*/
|
*/
|
||||||
async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
|
async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
|
||||||
if (!imageUrl) throw new Error('Image URL is required');
|
if (!imageUrl) throw new Error('Image URL is required');
|
||||||
if (!label || typeof label !== 'string') throw new Error('Expression label is required');
|
if (!label || typeof label !== 'string') throw new Error('Expression label is required');
|
||||||
|
|
||||||
label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
|
label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
|
||||||
if (!label) throw new Error('Expression label must contain at least one letter');
|
if (!label) throw new Error('Expression label must contain at least one letter');
|
||||||
|
|
||||||
|
spriteName = spriteName || label;
|
||||||
|
if (!validateExpressionSpriteName(label, spriteName)) throw new Error('Invalid sprite name. Must follow the naming pattern for expression sprites.');
|
||||||
|
|
||||||
name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
|
name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
|
||||||
const char = findChar({ name });
|
const char = findChar({ name });
|
||||||
|
|
||||||
@ -1062,7 +1068,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('name', folder); // this is the folder or character name
|
formData.append('name', folder); // this is the folder or character name
|
||||||
formData.append('label', label); // this is the expression label
|
formData.append('label', label); // this is the expression label
|
||||||
formData.append('avatar', file); // this is the image file
|
formData.append('avatar', file); // this is the image file
|
||||||
|
formData.append('spriteName', spriteName); // this is a redundant comment
|
||||||
|
|
||||||
await handleFileUpload('/api/sprites/upload', formData);
|
await handleFileUpload('/api/sprites/upload', formData);
|
||||||
console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
|
console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
|
||||||
@ -1070,6 +1077,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
|
|||||||
console.error(`[${MODULE_NAME}] Error uploading file:`, error);
|
console.error(`[${MODULE_NAME}] Error uploading file:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return spriteName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1876,6 +1885,12 @@ function withoutExtension(fileName) {
|
|||||||
return fileName.replace(/\.[^/.]+$/, '');
|
return fileName.replace(/\.[^/.]+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateExpressionSpriteName(expression, spriteName) {
|
||||||
|
const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
|
||||||
|
const validFileName = filenameValidationRegex.test(spriteName);
|
||||||
|
return validFileName;
|
||||||
|
}
|
||||||
|
|
||||||
async function onClickExpressionUpload(event) {
|
async function onClickExpressionUpload(event) {
|
||||||
// Prevents the expression from being set
|
// Prevents the expression from being set
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -1900,8 +1915,7 @@ async function onClickExpressionUpload(event) {
|
|||||||
if (extension_settings.expressions.allowMultiple) {
|
if (extension_settings.expressions.allowMultiple) {
|
||||||
const matchesExisting = existingFiles.some(x => x.fileName === file.name);
|
const matchesExisting = existingFiles.some(x => x.fileName === file.name);
|
||||||
const fileNameWithoutExtension = withoutExtension(file.name);
|
const fileNameWithoutExtension = withoutExtension(file.name);
|
||||||
const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
|
const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
|
||||||
const validFileName = filenameValidationRegex.test(fileNameWithoutExtension);
|
|
||||||
|
|
||||||
// If there is no expression yet and it's a valid expression, we just take it
|
// If there is no expression yet and it's a valid expression, we just take it
|
||||||
if (!clickedFileName && validFileName) {
|
if (!clickedFileName && validFileName) {
|
||||||
@ -1932,15 +1946,15 @@ async function onClickExpressionUpload(event) {
|
|||||||
const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
|
const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
|
||||||
|
|
||||||
spriteName = null;
|
spriteName = null;
|
||||||
const result = await Popup.show.input(t`Upload Expression Sprite`, message,
|
const input = await Popup.show.input(t`Upload Expression Sprite`, message,
|
||||||
`${expression}-${existingFiles.length}`, { customButtons: customButtons });
|
`${expression}-${existingFiles.length}`, { customButtons: customButtons });
|
||||||
|
|
||||||
if (result) {
|
if (input) {
|
||||||
if (!filenameValidationRegex.test(result)) {
|
if (!validateExpressionSpriteName(expression, input)) {
|
||||||
toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
|
toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
spriteName = result;
|
spriteName = input;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -2350,23 +2364,23 @@ function migrateSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||||
name: 'sprite',
|
name: 'expression-set',
|
||||||
aliases: ['emote'],
|
aliases: ['sprite', 'emote'],
|
||||||
callback: setSpriteSlashCommand,
|
callback: setSpriteSlashCommand,
|
||||||
unnamedArgumentList: [
|
unnamedArgumentList: [
|
||||||
SlashCommandArgument.fromProps({
|
SlashCommandArgument.fromProps({
|
||||||
description: 'spriteId',
|
description: 'expression label to set',
|
||||||
typeList: [ARGUMENT_TYPE.STRING],
|
typeList: [ARGUMENT_TYPE.STRING],
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
enumProvider: localEnumProviders.expressions,
|
enumProvider: localEnumProviders.expressions,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
helpString: 'Force sets the sprite for the current character.',
|
helpString: 'Force sets the expression for the current character.',
|
||||||
returns: 'the currently set sprite label after setting it.',
|
returns: 'The currently set expression label after setting it.',
|
||||||
}));
|
}));
|
||||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||||
name: 'spriteoverride',
|
name: 'expression-folder-override',
|
||||||
aliases: ['costume'],
|
aliases: ['spriteoverride', 'costume'],
|
||||||
callback: setSpriteSetCommand,
|
callback: setSpriteSetCommand,
|
||||||
unnamedArgumentList: [
|
unnamedArgumentList: [
|
||||||
new SlashCommandArgument(
|
new SlashCommandArgument(
|
||||||
@ -2376,55 +2390,52 @@ function migrateSettings() {
|
|||||||
helpString: 'Sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.',
|
helpString: 'Sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.',
|
||||||
}));
|
}));
|
||||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||||
name: 'lastsprite',
|
name: 'expression-last',
|
||||||
callback: (_, name) => {
|
aliases: ['lastsprite'],
|
||||||
|
/** @type {(args: object, name: string) => Promise<string>} */
|
||||||
|
callback: async (_, name) => {
|
||||||
if (typeof name !== 'string') throw new Error('name must be a string');
|
if (typeof name !== 'string') throw new Error('name must be a string');
|
||||||
|
if (!name) {
|
||||||
|
if (selected_group) {
|
||||||
|
toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
name = characters[this_chid]?.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
const char = findChar({ name: name });
|
const char = findChar({ name: name });
|
||||||
|
if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
|
||||||
|
|
||||||
const sprite = lastExpression[char?.name ?? name] ?? '';
|
const sprite = lastExpression[char?.name ?? name] ?? '';
|
||||||
return sprite;
|
return sprite;
|
||||||
},
|
},
|
||||||
returns: 'the last set sprite / expression for the named character.',
|
returns: 'the last set expression for the named character.',
|
||||||
unnamedArgumentList: [
|
unnamedArgumentList: [
|
||||||
SlashCommandArgument.fromProps({
|
SlashCommandArgument.fromProps({
|
||||||
description: 'Character name - or unique character identifier (avatar key)',
|
description: 'Character name - or unique character identifier (avatar key). If not provided, the current character for this chat will be used (does not work in group chats)',
|
||||||
typeList: [ARGUMENT_TYPE.STRING],
|
typeList: [ARGUMENT_TYPE.STRING],
|
||||||
isRequired: true,
|
|
||||||
enumProvider: commonEnumProviders.characters('character'),
|
enumProvider: commonEnumProviders.characters('character'),
|
||||||
forceEnum: true,
|
forceEnum: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
helpString: 'Returns the last set sprite / expression for the named character.',
|
helpString: 'Returns the last set expression for the named character.',
|
||||||
}));
|
}));
|
||||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||||
name: 'th',
|
name: 'expression-talkinghead',
|
||||||
callback: toggleTalkingHeadCommand,
|
callback: toggleTalkingHeadCommand,
|
||||||
aliases: ['talkinghead'],
|
aliases: ['th', 'talkinghead'],
|
||||||
helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
|
helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
|
||||||
returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
|
returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
|
||||||
}));
|
}));
|
||||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||||
name: 'classify-expressions',
|
name: 'expression-classify',
|
||||||
aliases: ['expressions'],
|
aliases: ['classify-expressions', 'expressions'],
|
||||||
|
/** @type {(args: {return: string}) => Promise<string>} */
|
||||||
callback: async (args) => {
|
callback: async (args) => {
|
||||||
/** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
|
let returnType =
|
||||||
// @ts-ignore
|
/** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
|
||||||
let returnType = args.return;
|
(args.return);
|
||||||
|
|
||||||
// Old legacy return type handling
|
|
||||||
if (args.format) {
|
|
||||||
toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning');
|
|
||||||
const type = String(args?.format).toLowerCase().trim();
|
|
||||||
switch (type) {
|
|
||||||
case 'json':
|
|
||||||
returnType = 'object';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
returnType = 'pipe';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now the actual new return type handling
|
|
||||||
const list = await getExpressionsList();
|
const list = await getExpressionsList();
|
||||||
|
|
||||||
return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
|
return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
|
||||||
@ -2438,22 +2449,13 @@ function migrateSettings() {
|
|||||||
enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
|
enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
|
||||||
forceEnum: true,
|
forceEnum: true,
|
||||||
}),
|
}),
|
||||||
// TODO remove some day
|
|
||||||
SlashCommandNamedArgument.fromProps({
|
|
||||||
name: 'format',
|
|
||||||
description: '!!! DEPRECATED - use "return" instead !!! The format to return the list in: comma-separated plain text or JSON array. Default is plain text.',
|
|
||||||
typeList: [ARGUMENT_TYPE.STRING],
|
|
||||||
enumList: [
|
|
||||||
new SlashCommandEnumValue('plain', null, enumTypes.enum, ', '),
|
|
||||||
new SlashCommandEnumValue('json', null, enumTypes.enum, '[]'),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
returns: 'The comma-separated list of available expressions, including custom expressions.',
|
returns: 'The comma-separated list of available expressions, including custom expressions.',
|
||||||
helpString: 'Returns a list of available expressions, including custom expressions.',
|
helpString: 'Returns a list of available expressions, including custom expressions.',
|
||||||
}));
|
}));
|
||||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||||
name: 'classify',
|
name: 'expression-classify',
|
||||||
|
aliases: ['classify'],
|
||||||
callback: classifyCallback,
|
callback: classifyCallback,
|
||||||
namedArgumentList: [
|
namedArgumentList: [
|
||||||
SlashCommandNamedArgument.fromProps({
|
SlashCommandNamedArgument.fromProps({
|
||||||
@ -2492,11 +2494,13 @@ function migrateSettings() {
|
|||||||
`,
|
`,
|
||||||
}));
|
}));
|
||||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||||
name: 'uploadsprite',
|
name: 'expression-upload',
|
||||||
|
aliases: ['uploadsprite'],
|
||||||
|
/** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
|
||||||
callback: async (args, url) => {
|
callback: async (args, url) => {
|
||||||
await uploadSpriteCommand(args, url);
|
return await uploadSpriteCommand(args, url);
|
||||||
return '';
|
|
||||||
},
|
},
|
||||||
|
returns: 'the resulting sprite name',
|
||||||
unnamedArgumentList: [
|
unnamedArgumentList: [
|
||||||
SlashCommandArgument.fromProps({
|
SlashCommandArgument.fromProps({
|
||||||
description: 'URL of the image to upload',
|
description: 'URL of the image to upload',
|
||||||
@ -2510,7 +2514,6 @@ function migrateSettings() {
|
|||||||
description: 'Character name or avatar key (default is current character)',
|
description: 'Character name or avatar key (default is current character)',
|
||||||
typeList: [ARGUMENT_TYPE.STRING],
|
typeList: [ARGUMENT_TYPE.STRING],
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
acceptsMultiple: false,
|
|
||||||
}),
|
}),
|
||||||
SlashCommandNamedArgument.fromProps({
|
SlashCommandNamedArgument.fromProps({
|
||||||
name: 'label',
|
name: 'label',
|
||||||
@ -2518,16 +2521,32 @@ function migrateSettings() {
|
|||||||
typeList: [ARGUMENT_TYPE.STRING],
|
typeList: [ARGUMENT_TYPE.STRING],
|
||||||
enumProvider: localEnumProviders.expressions,
|
enumProvider: localEnumProviders.expressions,
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
acceptsMultiple: false,
|
|
||||||
}),
|
}),
|
||||||
SlashCommandNamedArgument.fromProps({
|
SlashCommandNamedArgument.fromProps({
|
||||||
name: 'folder',
|
name: 'folder',
|
||||||
description: 'Override folder to upload into',
|
description: 'Override folder to upload into',
|
||||||
typeList: [ARGUMENT_TYPE.STRING],
|
typeList: [ARGUMENT_TYPE.STRING],
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
acceptsMultiple: false,
|
}),
|
||||||
|
SlashCommandNamedArgument.fromProps({
|
||||||
|
name: 'spriteName',
|
||||||
|
description: 'Override sprite name to allow multiple sprites per expressions. Has to follow the naming pattern. If unspecified, the label will be used as sprite name.',
|
||||||
|
typeList: [ARGUMENT_TYPE.STRING],
|
||||||
|
isRequired: false,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
helpString: '<div>Upload a sprite from a URL.</div><div>Example:</div><pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>',
|
helpString: `
|
||||||
|
<div>
|
||||||
|
Upload a sprite from a URL.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Example:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
}));
|
}));
|
||||||
})();
|
})();
|
||||||
|
Reference in New Issue
Block a user