Allow setting specific sprites as expressions - Update /expression-set command to allow setting specific sprites - Enhance enum completion for /expression-set to show expressions/sprites and more their info - Fix setting sprite folder reprinting stuff double - Fix not being able to unset expressions

198d10e7597ece5e444cc18cae0b94dce45d3466

Wolfsblvt <wolfsblvt@gmail.com>

3 files changed, +207 -65Showing whitespace changes
public/scripts/extensions/expressions/index.js+199 -61
@@ -3,7 +3,7 @@ import { Fuse } from '../../../lib.js';
33import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
44import { dragElement, isMobile } from '../../RossAscends-mods.js';
55import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
66import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
77import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
88import { hideMutedSprites, selected_group } from '../../group-chats.js';
99import { isJsonSchemaSupported } from '../../textgen-settings.js';
@@ -12,7 +12,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
1212import { SlashCommand } from '../../slash-commands/SlashCommand.js';
1313import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
1414import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
1515import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
1616import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
1717import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
1818import { Popup, POPUP_RESULT } from '../../popup.js';
@@ -27,8 +27,8 @@ export { MODULE_NAME };
2727
2828/**
2929 * @typedef {object} ExpressionImage An expression image
3030 * @property {string?} [expression=null] - The expression
3131 * @property {boolean?} [isCustom=nullfalse] - If the expression is added by user
3232 * @property {string} fileName - The filename with extension
3333 * @property {string} title - The title for the image
3434 * @property {string} imageSrc - The image source / full path
@@ -78,9 +78,6 @@ const EXPRESSION_API = {
7878 webllm: 3,
7979};
8080
81-/** @type {ExpressionImage} */
82-const NO_IMAGE_PLACEHOLDER = { title: 'No Image', type: 'failure', fileName: 'No-Image-Placeholder.svg', imageSrc: '/img/No-Image-Placeholder.svg' };
83-
8481let expressionsList = null;
8582let lastCharacter = undefined;
8683let lastMessage = null;
@@ -93,6 +90,24 @@ let lastServerResponseTime = 0;
9390export let lastExpression = {};
9491
9592/**
93+ * Returns a placeholder image object for a given expression
94+ * @param {string} expression - The expression label
95+ * @param {boolean} [isCustom=false] - Whether the expression is custom
96+ * @returns {ExpressionImage} The placeholder image object
97+ */
98+function getPlaceholderImage(expression, isCustom = false) {
99+ return {
100+ expression: expression,
101+ isCustom: isCustom,
102+ title: 'No Image',
103+ type: 'failure',
104+ fileName: 'No-Image-Placeholder.svg',
105+ imageSrc: '/img/No-Image-Placeholder.svg',
106+ };
107+}
108+
109+
110+/**
96111 * Returns the fallback expression if explicitly chosen, otherwise the default one
97112 * @returns {string} expression name
98113 */
@@ -189,6 +204,7 @@ async function visualNovelSetCharacterSprites(container, name, expression) {
189204 const sprites = spriteCache[spriteFolderName];
190205 const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
191206 const defaultExpression = getFallbackExpression();
207+ // TODO: Visual novel sprites need fixing, currently do not update based on multiple sprites, etc
192208 const defaultSpritePath = sprites.find(x => x.label === defaultExpression)?.path;
193209 const noSprites = sprites.length === 0;
194210
@@ -460,7 +476,7 @@ async function moduleWorker() {
460476 }
461477
462478 const currentLastMessage = getLastCharacterMessage();
463479 let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();
464480
465481 // character has no expressions or it is not loaded
466482 if (Object.keys(spriteCache).length === 0) {
@@ -550,7 +566,7 @@ async function moduleWorker() {
550566 expression = getFallbackExpression();
551567 }
552568
553569 await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
554570 }
555571 catch (error) {
556572 console.log(error);
@@ -596,48 +612,55 @@ function getFolderNameByMessage(message) {
596612 return folderName;
597613}
598614
599-async function sendExpressionCall(name, expression, force, vnMode) {
615+/**
616+ * Update the expression for the given character.
617+ *
618+ * @param {string} name The character name, optionally with a sprite folder override, e.g. "folder/expression".
619+ * @param {string} expression The expression label, e.g. "amusement", "joy", etc.
620+ * @param {Object} [options] Additional options
621+ * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression.
622+ * @param {boolean} [options.vnMode=null] If true, the expression will be sent in Visual Novel mode. If null, it will be determined by the current chat mode.
623+ * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
624+ */
625+async function sendExpressionCall(name, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
600626 lastExpression[name.split('/')[0]] = expression;
601627 if (!vnMode === null) {
602628 vnMode = isVisualNovelMode();
603629 }
604630
605631 if (vnMode) {
606632 await updateVisualNovelMode(name, expression);
607633 } else {
608634 setExpression(name, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
609635 }
610636}
611637
612638async function setSpriteSetCommandsetSpriteFolderCommand(_, folder) {
613639 if (!folder) {
614640 console.log('Clearing sprite set');
615641 folder = '';
616642 }
617643
618644 if (folder.startsWith('/') || folder.startsWith('\\')) {
619- folder = folder.slice(1);
620-
621645 const currentLastMessage = getLastCharacterMessage();
646+ folder = folder.slice(1);
622647 folder = `${currentLastMessage.name}/${folder}`;
623648 }
624649
625650 $('#expression_override').val(folder.trim());
626651 onClickExpressionOverrideButton();
627- // removeExpression();
652+
628- // moduleWorker();
653+ // No need to resend the expression, the folder override will automatically update the currently displayed one.
629- const vnMode = isVisualNovelMode();
630- await sendExpressionCall(folder, lastExpression, true, vnMode);
631654 return '';
632655}
633656
634657async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {
635658 if (!text) {
636659 toastr.warningerror('No text provided');
637660 return '';
638661 }
639662 if (api && !Object.keys(EXPRESSION_API).includes(api)) {
640663 toastr.warningerror('Invalid API provided');
641664 return '';
642665 }
643666
@@ -653,31 +676,68 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
653676 return label;
654677}
655678
656-async function setSpriteSlashCommand(_, spriteId) {
679+/** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
657- spriteId = spriteId.trim().toLowerCase();
680+async function setSpriteSlashCommand({ type }, searchTerm) {
658- if (!spriteId) {
681+ type ??= 'expression';
659- console.log('No sprite id provided');
682+ searchTerm = searchTerm.trim().toLowerCase();
683+ if (!searchTerm) {
684+ toastr.error(t`No expression or sprite name provided`, t`Set Sprite`);
660685 return '';
661686 }
662687
663688 const spriteFolderName = getSpriteFolderName();
664689
690+ let label = searchTerm;
691+
692+ /** @type {string?} */
693+ let spriteFile = null;
694+
665695 await validateImages(spriteFolderName);
666696
667- // Fuzzy search for sprite
697+ // Handle reset as a special term and just reset the sprite via expression call
668- const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
698+ if (searchTerm === '#reset') {
669- const results = fuse.search(spriteId);
699+ await sendExpressionCall(spriteFolderName, label, { force: true });
670- const spriteItem = results[0]?.item;
700+ return lastExpression[spriteFolderName] ?? '';
701+ }
702+
703+ switch (type) {
704+ case 'expression': {
705+ // Fuzzy search for expression
706+ const existingExpressions = getCachedExpressions().map(x => ({ label: x }));
707+ const results = performFuzzySearch('expression-expressions', existingExpressions, [
708+ { name: 'label', weight: 1 },
709+ ], searchTerm);
710+ const matchedExpression = results[0]?.item;
711+ if (!matchedExpression) {
712+ toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`);
713+ return '';
714+ }
671715
672- if (!spriteItem) {
716+ label = matchedExpression.label;
673- console.log('No sprite found for search term ' + spriteId);
717+ break;
718+ }
719+ case 'sprite': {
720+ // Fuzzy search for sprite file
721+ const sprites = spriteCache[spriteFolderName].map(x => x.files).flat();
722+ const results = performFuzzySearch('expression-expressions', sprites, [
723+ { name: 'title', weight: 1 },
724+ { name: 'fileName', weight: 1 },
725+ ], searchTerm);
726+ const matchedSprite = results[0]?.item;
727+ if (!matchedSprite) {
728+ toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`);
674729 return '';
675730 }
676731
677732 const label = spriteItemmatchedSprite.labelexpression;
733+ spriteFile = matchedSprite.fileName;
734+ break;
735+ }
736+ default: throw Error('Invalid sprite set type: ' + type);
737+ }
738+
739+ await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
678740
679- const vnMode = isVisualNovelMode();
680- await sendExpressionCall(spriteFolderName, label, true, vnMode);
681741 return label;
682742}
683743
@@ -714,13 +774,22 @@ function spriteFolderNameFromCharacter(char) {
714774 */
715775async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
716776 if (!imageUrl) throw new Error('Image URL is required');
717777 if (!label || typeof label !== 'string') throw new Error('Expression label is required');{
778+ toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
779+ return '';
780+ }
718781
719782 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
720- if (!label) throw new Error('Expression label must contain at least one letter');
783+ if (!label) {
784+ toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`);
785+ return '';
786+ }
721787
722788 spriteName = spriteName || label;
723- if (!validateExpressionSpriteName(label, spriteName)) throw new Error('Invalid sprite name. Must follow the naming pattern for expression sprites.');
789+ if (!validateExpressionSpriteName(label, spriteName)) {
790+ toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`);
791+ return '';
792+ }
724793
725794 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
726795 const char = findChar({ name });
@@ -1066,7 +1135,7 @@ async function drawSpritesList(character, labels, sprites) {
10661135 if (images.length === 0) {
10671136 const listItem = await getListItem(expression, {
10681137 isCustom,
10691138 images: [{ getPlaceholderImage(expression, isCustom, ...NO_IMAGE_PLACEHOLDER })],
10701139 });
10711140 $('#image_list').append(listItem);
10721141 continue;
@@ -1264,12 +1333,13 @@ export async function getExpressionsList() {
12641333/**
12651334 * Set the expression of a character.
12661335 * @param {string} character - The name of the character
12671336 * @param {string} expression - The expression or sprite name to set
1268- * @param {boolean} [force=false] - Whether to force the expression change even if Visual Novel mode is on.
1337+ * @param {Object} options - Optional parameters
1338+ * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on
1339+ * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
12691340 * @returns {Promise<void>} A promise that resolves when the expression has been set.
12701341 */
12711342async function setExpression(character, expression, { force = false, overrideSpriteFile = null } = {}) {
1272- console.debug('entered setExpressions');
12731343 await validateImages(character);
12741344 const img = $('img.expression');
12751345 const prevExpressionSrc = img.attr('src');
@@ -1277,14 +1347,17 @@ async function setExpression(character, expression, force = false) {
12771347
12781348 /** @type {Expression} */
12791349 const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
1280- console.debug('checking for expression images to show..');
12811350 if (sprite && sprite.files.length > 0) {
1282- console.debug('setting expression from character images folder');
1283-
12841351 let spriteFile = sprite.files[0];
12851352
1286- // Calculate next expression, if multiple are allowed
1353+ // If a specific sprite file should be set, we are looking it up here
1287- if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
1354+ if (overrideSpriteFile) {
1355+ const searched = sprite.files.find(x => x.fileName === overrideSpriteFile);
1356+ if (searched) spriteFile = searched;
1357+ else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`);
1358+ }
1359+ // Else calculate next expression, if multiple are allowed
1360+ else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
12881361 let possibleFiles = sprite.files;
12891362 if (extension_settings.expressions.rerollIfSame) {
12901363 possibleFiles = possibleFiles.filter(x => x.imageSrc !== prevExpressionSrc);
@@ -1309,6 +1382,7 @@ async function setExpression(character, expression, force = false) {
13091382 }
13101383 }
13111384 }
1385+
13121386 //only swap expressions when necessary
13131387 if (prevExpressionSrc !== spriteFile.imageSrc
13141388 && !img.hasClass('expression-animating')) {
@@ -1360,7 +1434,6 @@ async function setExpression(character, expression, force = false) {
13601434 expressionHolder.css('min-height', 100);
13611435 });
13621436
1363-
13641437 expressionClone.removeClass('expression-clone');
13651438
13661439 expressionClone.removeClass('default');
@@ -1374,26 +1447,44 @@ async function setExpression(character, expression, force = false) {
13741447 }
13751448 });
13761449 }
1450+
1451+ console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
13771452 }
13781453 else {
13791454 if (extension_settings.expressions.showDefault) {
13801455 setDefault();
1456+ } else {
1457+ setNone();
13811458 }
1459+ console.debug('Expression unset');
13821460 }
13831461
13841462 function setDefault() {
13851463 console.debug('setting default expression');
13861464 const defImgUrl = `/img/default-expressions/${expression}.png`;
13871465 //console.log(defImgUrl);
13881466 img.attr('src', defImgUrl);
13891467 img.addClass('default');
13901468 }
1469+ function setNone() {
1470+ console.debug('setting no expression');
1471+ img.attr('src', '');
1472+ img.removeClass('default');
1473+ }
1474+
13911475 document.getElementById('expression-holder').style.display = '';
13921476}
13931477
13941478function onClickExpressionImage() {
1395- const expression = $(this).data('expression');
1479+ // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label
1396- setSpriteSlashCommand({}, expression);
1480+ if ($(this).attr('data-expression-type') === 'failure') {
1481+ const label = $(this).attr('data-expression');
1482+ setSpriteSlashCommand({ type: 'expression' }, label);
1483+ return;
1484+ }
1485+
1486+ const spriteFile = $(this).attr('data-filename');
1487+ setSpriteSlashCommand({ type: 'sprite' }, spriteFile);
13971488}
13981489
13991490async function onClickExpressionAddCustom() {
@@ -1667,8 +1758,9 @@ async function onClickExpressionOverrideButton() {
16671758 inApiCall = true;
16681759 $('#visual-novel-wrapper').empty();
16691760 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
1761+ const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
16701762 const expression = await getExpressionLabel(currentLastMessage.mes);
16711763 await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, { force: true });
16721764 forceUpdateVisualNovelMode();
16731765 } catch (error) {
16741766 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
@@ -1694,7 +1786,7 @@ async function onClickExpressionOverrideRemoveAllButton() {
16941786 const currentLastMessage = getLastCharacterMessage();
16951787 await validateImages(currentLastMessage.name, true);
16961788 const expression = await getExpressionLabel(currentLastMessage.mes);
16971789 await sendExpressionCall(currentLastMessage.name, expression, { force: true });
16981790 forceUpdateVisualNovelMode();
16991791
17001792 console.debug(extension_settings.expressionOverrides);
@@ -1933,22 +2025,60 @@ function migrateSettings() {
19332025 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
19342026
19352027 const localEnumProviders = {
19362028 expressions: () => getCachedExpressions().map(expression => {
2029+ const spriteFolderName = getSpriteFolderName();
2030+ const expressions = getCachedExpressions();
2031+ return expressions.map(expression => {
2032+ const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
19372033 const isCustom = extension_settings.expressions.custom?.includes(expression);
1938- return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D');
2034+ const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
1939- }),
2035+ spriteCount > 1 ? `${spriteCount} sprites` : null;
2036+ return new SlashCommandEnumValue(expression,
2037+ subtitle,
2038+ isCustom ? enumTypes.name : enumTypes.enum,
2039+ isCustom ? 'C' : 'D');
2040+ });
2041+ },
2042+ sprites: () => {
2043+ const spriteFolderName = getSpriteFolderName();
2044+ const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? [];
2045+ return sprites.map(x => {
2046+ return new SlashCommandEnumValue(x.title,
2047+ x.title !== x.expression ? x.expression : null,
2048+ x.isCustom ? enumTypes.name : enumTypes.enum,
2049+ x.isCustom ? 'C' : 'D');
2050+ });
2051+ },
19402052 };
19412053
19422054 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
19432055 name: 'expression-set',
19442056 aliases: ['sprite', 'emote'],
19452057 callback: setSpriteSlashCommand,
2058+ namedArgumentList: [
2059+ SlashCommandNamedArgument.fromProps({
2060+ name: 'type',
2061+ description: 'Whether to set an expression or a specific sprite.',
2062+ typeList: [ARGUMENT_TYPE.STRING],
2063+ isRequired: false,
2064+ defaultValue: 'expression',
2065+ enumList: ['expression', 'sprite'],
2066+ }),
2067+ ],
19462068 unnamedArgumentList: [
19472069 SlashCommandArgument.fromProps({
19482070 description: 'expression label to set',
19492071 typeList: [ARGUMENT_TYPE.STRING],
19502072 isRequired: true,
1951- enumProvider: localEnumProviders.expressions,
2073+ enumProvider: (executor, _) => {
2074+ // Check if command is used to set a sprite, then use those enums
2075+ const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression';
2076+ if (type == 'sprite') return localEnumProviders.sprites();
2077+ else return [
2078+ ...localEnumProviders.expressions(),
2079+ new SlashCommandEnumValue('#reset', 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'),
2080+ ];
2081+ },
19522082 }),
19532083 ],
19542084 helpString: 'Force sets the expression for the current character.',
@@ -1957,13 +2087,21 @@ function migrateSettings() {
19572087 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
19582088 name: 'expression-folder-override',
19592089 aliases: ['spriteoverride', 'costume'],
19602090 callback: setSpriteSetCommandsetSpriteFolderCommand,
19612091 unnamedArgumentList: [
19622092 new SlashCommandArgument(
19632093 'optional folder', [ARGUMENT_TYPE.STRING], false,
19642094 ),
19652095 ],
1966- 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.',
2096+ helpString: `
2097+ <div>
2098+ Sets an override sprite folder for the current character.<br />
2099+ In groups, this will apply to the character who last sent a message.
2100+ </div>
2101+ <div>
2102+ 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.
2103+ </div>
2104+ `,
19672105 }));
19682106 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
19692107 name: 'expression-last',
@@ -1997,8 +2135,8 @@ function migrateSettings() {
19972135 helpString: 'Returns the last set expression for the named character.',
19982136 }));
19992137 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
20002138 name: 'expression-classifylist',
20012139 aliases: ['classify-expressions', 'expressions'],
20022140 /** @type {(args: {return: string}) => Promise<string>} */
20032141 callback: async (args) => {
20042142 let returnType =
public/scripts/extensions/expressions/style.css+3 -0
@@ -109,6 +109,9 @@ img.expression.default {
109109 flex-direction: column;
110110 align-items: center;
111111 justify-content: center;
112+}
113+
114+.expression_list_image_container {
112115 overflow: hidden;
113116}
114117
public/scripts/power-user.js+5 -4
@@ -1833,14 +1833,15 @@ async function loadContextSettings() {
18331833
18341834/**
18351835 * Common function to perform fuzzy search with optional caching
1836+ * @template T
18361837 * @param {string} type - Type of search from fuzzySearchCategories
18371838 * @param {anyT[]} data - Data array to search in
18381839 * @param {Array<{name: string, weight: number, getFn?: (obj: anyT) => string}>} keys - Fuse.js keys configuration
18391840 * @param {string} searchValue - The search term
18401841 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
18411842 * @returns {import('fuse.js').FuseResult<anyT>[]} Results as items with their score
18421843 */
18431844export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
18441845 // Check cache if provided
18451846 if (fuzzySearchCaches) {
18461847 const cache = fuzzySearchCaches[type];