mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-03-13 10:30:14 +01: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:
parent
84a8a2bc2b
commit
d316d51c0b
@ -1,11 +1,11 @@
|
||||
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 { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
|
||||
import { loadMovingUIState, power_user } from '../../power-user.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 { debounce_timeout } from '../../constants.js';
|
||||
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
|
||||
@ -92,6 +92,8 @@ let lastTalkingStateMessage = null; // last message as seen by `updateTalkingSt
|
||||
let spriteCache = {};
|
||||
let inApiCall = false;
|
||||
let lastServerResponseTime = 0;
|
||||
|
||||
/** @type {{[characterName: string]: string}} */
|
||||
export let lastExpression = {};
|
||||
|
||||
function isTalkingHeadEnabled() {
|
||||
@ -1033,17 +1035,21 @@ function spriteFolderNameFromCharacter(char) {
|
||||
* @param {object} args
|
||||
* @param {string} args.name Character name or avatar key, passed through findChar
|
||||
* @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
|
||||
* @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 (!label || typeof label !== 'string') throw new Error('Expression label is required');
|
||||
|
||||
label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
|
||||
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;
|
||||
const char = findChar({ name });
|
||||
|
||||
@ -1062,7 +1068,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
|
||||
const formData = new FormData();
|
||||
formData.append('name', folder); // this is the folder or character name
|
||||
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);
|
||||
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);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return spriteName;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1876,6 +1885,12 @@ function withoutExtension(fileName) {
|
||||
return fileName.replace(/\.[^/.]+$/, '');
|
||||
}
|
||||
|
||||
function validateExpressionSpriteName(expression, spriteName) {
|
||||
const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
|
||||
const validFileName = filenameValidationRegex.test(spriteName);
|
||||
return validFileName;
|
||||
}
|
||||
|
||||
async function onClickExpressionUpload(event) {
|
||||
// Prevents the expression from being set
|
||||
event.stopPropagation();
|
||||
@ -1900,8 +1915,7 @@ async function onClickExpressionUpload(event) {
|
||||
if (extension_settings.expressions.allowMultiple) {
|
||||
const matchesExisting = existingFiles.some(x => x.fileName === file.name);
|
||||
const fileNameWithoutExtension = withoutExtension(file.name);
|
||||
const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
|
||||
const validFileName = filenameValidationRegex.test(fileNameWithoutExtension);
|
||||
const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
|
||||
|
||||
// If there is no expression yet and it's a valid expression, we just take it
|
||||
if (!clickedFileName && validFileName) {
|
||||
@ -1932,15 +1946,15 @@ async function onClickExpressionUpload(event) {
|
||||
const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
|
||||
|
||||
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 });
|
||||
|
||||
if (result) {
|
||||
if (!filenameValidationRegex.test(result)) {
|
||||
if (input) {
|
||||
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`);
|
||||
return;
|
||||
}
|
||||
spriteName = result;
|
||||
spriteName = input;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2350,23 +2364,23 @@ function migrateSettings() {
|
||||
};
|
||||
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'sprite',
|
||||
aliases: ['emote'],
|
||||
name: 'expression-set',
|
||||
aliases: ['sprite', 'emote'],
|
||||
callback: setSpriteSlashCommand,
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'spriteId',
|
||||
description: 'expression label to set',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumProvider: localEnumProviders.expressions,
|
||||
}),
|
||||
],
|
||||
helpString: 'Force sets the sprite for the current character.',
|
||||
returns: 'the currently set sprite label after setting it.',
|
||||
helpString: 'Force sets the expression for the current character.',
|
||||
returns: 'The currently set expression label after setting it.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'spriteoverride',
|
||||
aliases: ['costume'],
|
||||
name: 'expression-folder-override',
|
||||
aliases: ['spriteoverride', 'costume'],
|
||||
callback: setSpriteSetCommand,
|
||||
unnamedArgumentList: [
|
||||
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.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'lastsprite',
|
||||
callback: (_, name) => {
|
||||
name: 'expression-last',
|
||||
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 (!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 });
|
||||
if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
|
||||
|
||||
const sprite = lastExpression[char?.name ?? name] ?? '';
|
||||
return sprite;
|
||||
},
|
||||
returns: 'the last set sprite / expression for the named character.',
|
||||
returns: 'the last set expression for the named character.',
|
||||
unnamedArgumentList: [
|
||||
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],
|
||||
isRequired: true,
|
||||
enumProvider: commonEnumProviders.characters('character'),
|
||||
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({
|
||||
name: 'th',
|
||||
name: 'expression-talkinghead',
|
||||
callback: toggleTalkingHeadCommand,
|
||||
aliases: ['talkinghead'],
|
||||
aliases: ['th', 'talkinghead'],
|
||||
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.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'classify-expressions',
|
||||
aliases: ['expressions'],
|
||||
name: 'expression-classify',
|
||||
aliases: ['classify-expressions', 'expressions'],
|
||||
/** @type {(args: {return: string}) => Promise<string>} */
|
||||
callback: async (args) => {
|
||||
/** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
|
||||
// @ts-ignore
|
||||
let returnType = args.return;
|
||||
let returnType =
|
||||
/** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
|
||||
(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();
|
||||
|
||||
return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
|
||||
@ -2438,22 +2449,13 @@ function migrateSettings() {
|
||||
enumList: slashCommandReturnHelper.enumList({ allowObject: 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.',
|
||||
helpString: 'Returns a list of available expressions, including custom expressions.',
|
||||
}));
|
||||
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
|
||||
name: 'classify',
|
||||
name: 'expression-classify',
|
||||
aliases: ['classify'],
|
||||
callback: classifyCallback,
|
||||
namedArgumentList: [
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
@ -2492,11 +2494,13 @@ function migrateSettings() {
|
||||
`,
|
||||
}));
|
||||
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) => {
|
||||
await uploadSpriteCommand(args, url);
|
||||
return '';
|
||||
return await uploadSpriteCommand(args, url);
|
||||
},
|
||||
returns: 'the resulting sprite name',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'URL of the image to upload',
|
||||
@ -2510,7 +2514,6 @@ function migrateSettings() {
|
||||
description: 'Character name or avatar key (default is current character)',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: false,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'label',
|
||||
@ -2518,16 +2521,32 @@ function migrateSettings() {
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
enumProvider: localEnumProviders.expressions,
|
||||
isRequired: true,
|
||||
acceptsMultiple: false,
|
||||
}),
|
||||
SlashCommandNamedArgument.fromProps({
|
||||
name: 'folder',
|
||||
description: 'Override folder to upload into',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
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>
|
||||
`,
|
||||
}));
|
||||
})();
|
||||
|
Loading…
x
Reference in New Issue
Block a user