From 126e4fa6983caee3798e7ba843a5e26a14fe52e0 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Wed, 8 Jan 2025 01:20:25 +0100 Subject: [PATCH 01/39] Display additional images in expression list - Update Expressions List to display additional images per expression - Make additional images appear visually distinct - Fix small issues with custom labels not always being shown - Add tooltip on all expression images - Modify /api/sprites/get endpoint to correctly parse the label from filenames that might be additional files --- .../scripts/extensions/expressions/index.js | 90 +++++++++++++++---- .../extensions/expressions/list-item.html | 14 +-- .../scripts/extensions/expressions/style.css | 21 ++++- src/endpoints/sprites.js | 8 +- 4 files changed, 108 insertions(+), 25 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index e5ba1ed21..1180d2fd1 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -17,6 +17,22 @@ import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandRetur import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; export { MODULE_NAME }; +/** + * @typedef {object} Expression Expression definition with label and file path + * @property {string} label The label of the expression + * @property {string} path The path to the expression image + */ + +/** + * @typedef {object} ExpressionImage An expression image + * @property {string?} [expression=null] - The expression + * @property {boolean?} [isCustom=null] - If the expression is added by user + * @property {string} fileName - The filename with extension + * @property {string} title - The title for the image + * @property {string} imageSrc - The image source / full path + * @property {'success' | 'additional' | 'failure'} type - The type of the image + */ + const MODULE_NAME = 'expressions'; const UPDATE_INTERVAL = 2000; const STREAMING_UPDATE_INTERVAL = 10000; @@ -62,6 +78,9 @@ const EXPRESSION_API = { webllm: 3, }; +/** @type {ExpressionImage} */ +const NO_IMAGE_PLACEHOLDER = { title: 'No Image', type: 'failure', fileName: 'No-Image-Placeholder.svg', imageSrc: '/img/No-Image-Placeholder.svg' }; + let expressionsList = null; let lastCharacter = undefined; let lastMessage = null; @@ -1309,8 +1328,35 @@ async function validateImages(character, forceRedrawCached) { spriteCache[character] = validExpressions; } +/** + * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting + * @param {Expression} sprite + * @returns {ExpressionImage} + */ +function getExpressionImageData(sprite) { + const fileName = sprite.path.split('/').pop().split('?')[0]; + const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, ''); + return { + expression: sprite.label, + fileName: fileName, + title: fileNameWithoutExtension, + imageSrc: sprite.path, + type: fileNameWithoutExtension == sprite.label ? 'success' : 'additional', + isCustom: extension_settings.expressions.custom?.includes(sprite.label), + }; +} + +/** + * Populate the character expression list with sprites for the given character. + * @param {string} character - The name of the character to populate the list for + * @param {string[]} labels - An array of expression labels that are valid + * @param {Expression[]} sprites - An array of sprites + * @returns {Promise} An array of valid expression labels + */ async function drawSpritesList(character, labels, sprites) { + /** @type {Expression[]} */ let validExpressions = []; + $('#no_chat_expressions').hide(); $('#open_chat_expressions').show(); $('#image_list').empty(); @@ -1321,33 +1367,45 @@ async function drawSpritesList(character, labels, sprites) { return []; } - for (const item of labels.sort()) { - const sprite = sprites.find(x => x.label == item); - const isCustom = extension_settings.expressions.custom.includes(item); + for (const expression of labels.sort()) { + const isCustom = extension_settings.expressions.custom?.includes(expression); + const images = sprites + .filter(s => s.label === expression) + .map(getExpressionImageData) + .sort((a, b) => a.title.localeCompare(b.title)); - if (sprite) { - validExpressions.push(sprite); - const listItem = await getListItem(item, sprite.path, 'success', isCustom); - $('#image_list').append(listItem); - } - else { - const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom); + if (images.length === 0) { + const listItem = await getListItem(expression, { + isCustom, + images: [{ expression, isCustom, ...NO_IMAGE_PLACEHOLDER }], + }); $('#image_list').append(listItem); + continue; } + + // TODO: Fix valid expression lists/caching and group them correctly + validExpressions.push({ label: expression, paths: images }); + + // Render main = first file, additional = rest + let listItem = await getListItem(expression, { + isCustom, + images, + }); + $('#image_list').append(listItem); } return validExpressions; } /** * Renders a list item template for the expressions list. - * @param {string} item Expression name - * @param {string} imageSrc Path to image - * @param {'success' | 'failure'} textClass 'success' or 'failure' - * @param {boolean} isCustom If expression is added by user + * @param {string} expression Expression name + * @param {object} args Arguments object + * @param {ExpressionImage[]} [args.images] Array of image objects + * @param {boolean} [args.isCustom=false] If expression is added by user * @returns {Promise} Rendered list item template */ -async function getListItem(item, imageSrc, textClass, isCustom) { - return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); +async function getListItem(expression, { images, isCustom = false } = {}) { + return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false }); } async function getSpritesList(name) { diff --git a/public/scripts/extensions/expressions/list-item.html b/public/scripts/extensions/expressions/list-item.html index aaeec5cec..33c0bfe3d 100644 --- a/public/scripts/extensions/expressions/list-item.html +++ b/public/scripts/extensions/expressions/list-item.html @@ -1,4 +1,5 @@ -
+{{#each images}} +
-
- {{item}} - {{#if isCustom}} +
+ {{../expression}} + {{#if ../isCustom}} (custom) {{/if}}
- +
+ {{this.title}} +
+{{/each}} diff --git a/public/scripts/extensions/expressions/style.css b/public/scripts/extensions/expressions/style.css index 48062c272..db58cb6f0 100644 --- a/public/scripts/extensions/expressions/style.css +++ b/public/scripts/extensions/expressions/style.css @@ -126,6 +126,9 @@ img.expression.default { flex-direction: column; line-height: 1; } +.expression_list_custom { + font-size: 0.66rem; +} .expression_list_buttons { position: absolute; @@ -162,11 +165,24 @@ img.expression.default { row-gap: 1rem; } -#image_list .success { +#image_list .expression_list_item[data-expression-type="success"] .expression_list_title { color: green; } -#image_list .failure { +#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title { + color: darkolivegreen; +} +#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before { + content: '➕'; + position: absolute; + top: -7px; + left: -9px; + font-size: 14px; + color: transparent; + text-shadow: 0 0 0 darkolivegreen; +} + +#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title { color: red; } @@ -188,4 +204,3 @@ img.expression.default { align-items: baseline; flex-direction: row; } - diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index 6a2e5e6b9..a87c554bd 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) { .map((file) => { const pathToSprite = path.join(spritesPath, file); const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); + + const fileName = path.parse(pathToSprite).name.toLowerCase(); + // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot. + // Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png + const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName; + return { - label: path.parse(pathToSprite).name.toLowerCase(), + label: label, path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), }; }); From a072951102f40be153a019ccb4443305654387da Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sun, 26 Jan 2025 00:15:46 +0100 Subject: [PATCH 02/39] Update backend returned sprites list --- .../scripts/extensions/expressions/index.js | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 1180d2fd1..6ff09a9af 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -18,10 +18,10 @@ import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; export { MODULE_NAME }; /** - * @typedef {object} Expression Expression definition with label and file path - * @property {string} label The label of the expression - * @property {string} path The path to the expression image - */ +* @typedef {object} Expression Expression definition with label and file path +* @property {string} label The label of the expression +* @property {ExpressionImage[]} files One or more images to represent this expression +*/ /** * @typedef {object} ExpressionImage An expression image @@ -86,6 +86,7 @@ let lastCharacter = undefined; let lastMessage = null; let lastTalkingState = false; let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer) +/** @type {{[characterKey: string]: Expression[]}} */ let spriteCache = {}; let inApiCall = false; let lastServerResponseTime = 0; @@ -1307,6 +1308,11 @@ function removeExpression() { $('#no_chat_expressions').show(); } +/** + * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw. + * @param {string} character - The character to validate + * @param {boolean} forceRedrawCached - Whether to force redrawing the sprites list even if it's already been drawn before + */ async function validateImages(character, forceRedrawCached) { if (!character) { return; @@ -1330,7 +1336,7 @@ async function validateImages(character, forceRedrawCached) { /** * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting - * @param {Expression} sprite + * @param {{ path: string, label: string }} sprite * @returns {ExpressionImage} */ function getExpressionImageData(sprite) { @@ -1371,7 +1377,8 @@ async function drawSpritesList(character, labels, sprites) { const isCustom = extension_settings.expressions.custom?.includes(expression); const images = sprites .filter(s => s.label === expression) - .map(getExpressionImageData) + .map(s => s.files) + .flat() .sort((a, b) => a.title.localeCompare(b.title)); if (images.length === 0) { @@ -1384,7 +1391,7 @@ async function drawSpritesList(character, labels, sprites) { } // TODO: Fix valid expression lists/caching and group them correctly - validExpressions.push({ label: expression, paths: images }); + validExpressions.push({ label: expression, files: images }); // Render main = first file, additional = rest let listItem = await getListItem(expression, { @@ -1408,13 +1415,35 @@ async function getListItem(expression, { images, isCustom = false } = {}) { return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false }); } +/** + * Fetches and processes the list of sprites for a given character name. + * Retrieves sprite data from the server and organizes it into labeled groups. + * + * @param {string} name - The character name to fetch sprites for + * @returns {Promise} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data + */ + async function getSpritesList(name) { console.debug('getting sprites list'); try { const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`); + /** @type {{ label: string, path: string }[]} */ let sprites = result.ok ? (await result.json()) : []; - return sprites; + + /** @type {Expression[]} */ + const grouped = sprites.reduce((acc, sprite) => { + const imageData = getExpressionImageData(sprite); + let existingExpression = acc.find(exp => exp.label === sprite.label); + if (existingExpression) { + existingExpression.files.push(imageData); + } else { + acc.push({ label: sprite.label, files: [imageData] }); + } + + return acc; + }, []); + return grouped; } catch (err) { console.log(err); From adede8b6befe1c6c21e8b6ae3b28c44e746580ec Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sun, 26 Jan 2025 19:12:37 +0100 Subject: [PATCH 03/39] Roll on the sprite to use for an expression --- .../scripts/extensions/expressions/index.js | 69 ++++++++++++++----- .../extensions/expressions/list-item.html | 2 +- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 6ff09a9af..f150b8d65 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -360,7 +360,7 @@ async function setImage(img, path) { return new Promise(resolve => { const prevExpressionSrc = img.attr('src'); const expressionClone = img.clone(); - const originalId = img.attr('id'); + const originalId = img.data('filename'); //only swap expressions when necessary if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { @@ -368,7 +368,7 @@ async function setImage(img, path) { expressionClone.addClass('expression-clone'); //make invisible and remove id to prevent double ids //must be made invisible to start because they share the same Z-index - expressionClone.attr('id', '').css({ opacity: 0 }); + expressionClone.data('filename', '').css({ opacity: 0 }); //add new sprite path to clone src expressionClone.attr('src', path); //add invisible clone to html @@ -404,7 +404,7 @@ async function setImage(img, path) { //remove old expression img.remove(); //replace ID so it becomes the new 'original' expression for next change - expressionClone.attr('id', originalId); + expressionClone.data('filename', originalId); expressionClone.removeClass('expression-animating'); // Reset the expression holder min height and width @@ -1311,9 +1311,9 @@ function removeExpression() { /** * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw. * @param {string} character - The character to validate - * @param {boolean} forceRedrawCached - Whether to force redrawing the sprites list even if it's already been drawn before + * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before */ -async function validateImages(character, forceRedrawCached) { +async function validateImages(character, forceRedrawCached = false) { if (!character) { return; } @@ -1493,6 +1493,13 @@ async function renderFallbackExpressionPicker() { } } +/** + * Retrieves a unique list of cached expressions. + * Combines the default expressions list with custom user-defined expressions. + * + * @returns {string[]} An array of unique expression labels + */ + function getCachedExpressions() { if (!Array.isArray(expressionsList)) { return []; @@ -1558,7 +1565,14 @@ export async function getExpressionsList() { return [...result, ...extension_settings.expressions.custom].filter(onlyUnique); } -async function setExpression(character, expression, force) { +/** + * Set the expression of a character. + * @param {string} character - The name of the character + * @param {string} expression - The expression to set + * @param {boolean} [force=false] - Whether to force the expression change even if Visual Novel mode is on. + * @returns {Promise} A promise that resolves when the expression has been set. + */ +async function setExpression(character, expression, force = false) { if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) { console.debug('entered setExpressions'); await validateImages(character); @@ -1566,11 +1580,23 @@ async function setExpression(character, expression, force) { const prevExpressionSrc = img.attr('src'); const expressionClone = img.clone(); + /** @type {Expression} */ const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression)); console.debug('checking for expression images to show..'); if (sprite) { console.debug('setting expression from character images folder'); + let spriteFile = sprite.files[0]; + + // Calculate next expression + if (sprite.files.length > 1) { + let possibleFiles = sprite.files; + if (extension_settings.expressions_reroll_if_same) { + possibleFiles = possibleFiles.filter(x => x.imageSrc !== prevExpressionSrc); + } + spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)]; + } + if (force && isVisualNovelMode()) { const context = getContext(); const group = context.groups.find(x => x.id === context.groupId); @@ -1583,13 +1609,13 @@ async function setExpression(character, expression, force) { } if (groupMember.name == character) { - await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path); + await setImage($(`.expression-holder[data-avatar="${member}"] img`), spriteFile.imageSrc); return; } } } //only swap expressions when necessary - if (prevExpressionSrc !== sprite.path + if (prevExpressionSrc !== spriteFile.imageSrc && !img.hasClass('expression-animating')) { //clone expression expressionClone.addClass('expression-clone'); @@ -1597,7 +1623,7 @@ async function setExpression(character, expression, force) { //must be made invisible to start because they share the same Z-index expressionClone.attr('id', '').css({ opacity: 0 }); //add new sprite path to clone src - expressionClone.attr('src', sprite.path); + expressionClone.attr('src', spriteFile.imageSrc); //add invisible clone to html expressionClone.appendTo($('#expression-holder')); @@ -1645,7 +1671,7 @@ async function setExpression(character, expression, force) { expressionClone.removeClass('default'); expressionClone.off('error'); expressionClone.on('error', function () { - console.debug('Expression image error', sprite.path); + console.debug('Expression image error', spriteFile.imageSrc); $(this).attr('src', ''); $(this).off('error'); if (force && extension_settings.expressions.showDefault) { @@ -1706,7 +1732,7 @@ async function setExpression(character, expression, force) { } function onClickExpressionImage() { - const expression = $(this).attr('id'); + const expression = $(this).data('expression'); setSpriteSlashCommand({}, expression); } @@ -1830,7 +1856,7 @@ async function onClickExpressionUpload(event) { // Prevents the expression from being set event.stopPropagation(); - const id = $(this).closest('.expression_list_item').attr('id'); + const expression = $(this).closest('.expression_list_item').data('expression'); const name = $('#image_list').data('name'); const handleExpressionUploadChange = async (e) => { @@ -1840,9 +1866,20 @@ async function onClickExpressionUpload(event) { return; } + // // If extension_settings.expressions.allowMultiple is false and there's already a main image, ask user: + // let hasMainImage = true; // Check from your item data + // if (!extension_settings.expressions.allowMultiple && hasMainImage) { + // let userChoice = await callPopup('

Replace existing main image?

Press Ok to replace, Cancel to abort.

', 'confirm'); + // if (!userChoice) { + // return; + // } + // // If user chooses replace, remove the old file, then proceed + // // ...existing code to remove old file... + // } + const formData = new FormData(); formData.append('name', name); - formData.append('label', id); + formData.append('label', expression); formData.append('avatar', file); await handleFileUpload('/api/sprites/upload', formData); @@ -1851,7 +1888,7 @@ async function onClickExpressionUpload(event) { e.target.form.reset(); // In Talkinghead mode, when a new talkinghead image is uploaded, refresh the live char. - if (id === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) { + if (expression === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) { await loadTalkingHead(); } }; @@ -1987,14 +2024,14 @@ async function onClickExpressionDelete(event) { return; } - const id = $(this).closest('.expression_list_item').attr('id'); + const expression = $(this).closest('.expression_list_item').data('expression'); const name = $('#image_list').data('name'); try { await fetch('/api/sprites/delete', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ name, label: id }), + body: JSON.stringify({ name, label: expression }), }); } catch (error) { toastr.error('Failed to delete image. Try again later.'); diff --git a/public/scripts/extensions/expressions/list-item.html b/public/scripts/extensions/expressions/list-item.html index 33c0bfe3d..dc5132600 100644 --- a/public/scripts/extensions/expressions/list-item.html +++ b/public/scripts/extensions/expressions/list-item.html @@ -1,5 +1,5 @@ {{#each images}} -
+
+
+ + +
Open a chat to see the character expressions.
From 3282c9426c72d22c0ed82e224eac5f491f391ca2 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 05:39:51 +0100 Subject: [PATCH 05/39] Upload expressions update --- .../scripts/extensions/expressions/index.js | 85 ++++++++++++++++--- .../extensions/expressions/settings.html | 1 + .../templates/upload-expression.html | 12 +++ src/endpoints/sprites.js | 5 +- 4 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 public/scripts/extensions/expressions/templates/upload-expression.html diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index a076f15a2..9e91bd888 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -15,6 +15,8 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; +import { Popup, POPUP_RESULT } from '../../popup.js'; +import { t } from '../../i18n.js'; export { MODULE_NAME }; /** @@ -1852,11 +1854,23 @@ async function handleFileUpload(url, formData) { } } +/** + * Removes the file extension from a file name + * @param {string} fileName The file name to remove the extension from + * @returns {string} The file name without the extension + */ +function withoutExtension(fileName) { + return fileName.replace(/\.[^/.]+$/, ''); +} + async function onClickExpressionUpload(event) { // Prevents the expression from being set event.stopPropagation(); - const expression = $(this).closest('.expression_list_item').data('expression'); + const expressionListItem = $(this).closest('.expression_list_item'); + + const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null; + const expression = expressionListItem.data('expression'); const name = $('#image_list').data('name'); const handleExpressionUploadChange = async (e) => { @@ -1866,21 +1880,70 @@ async function onClickExpressionUpload(event) { return; } - // // If extension_settings.expressions.allowMultiple is false and there's already a main image, ask user: - // let hasMainImage = true; // Check from your item data - // if (!extension_settings.expressions.allowMultiple && hasMainImage) { - // let userChoice = await callPopup('

Replace existing main image?

Press Ok to replace, Cancel to abort.

', 'confirm'); - // if (!userChoice) { - // return; - // } - // // If user chooses replace, remove the old file, then proceed - // // ...existing code to remove old file... - // } + const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || []; + + let spriteName = expression; + + 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); + + // If there is no expression yet and it's a valid expression, we just take it + if (!clickedFileName && validFileName) { + spriteName = fileNameWithoutExtension; + } + // If the filename matches the one that was clicked, we just take it and replace it + else if (clickedFileName === file.name) { + spriteName = fileNameWithoutExtension; + } + // If it's a valid filename and there's no existing file with the same name, we just take it + else if (!matchesExisting && validFileName) { + spriteName = fileNameWithoutExtension; + } + else { + /** @type {import('../../popup.js').CustomPopupButton[]} */ + const customButtons = []; + if (clickedFileName) { + customButtons.push({ + text: t`Replace Existing`, + result: POPUP_RESULT.NEGATIVE, + action: () => { + console.debug('Replacing existing sprite'); + spriteName = withoutExtension(clickedFileName); + }, + }); + } + + const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); + + spriteName = null; + const result = await Popup.show.input(t`Upload Expression Sprite`, message, + `${expression}-${existingFiles.length}`, { customButtons: customButtons }); + + if (result) { + if (!filenameValidationRegex.test(result)) { + 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; + } + } + } else { + spriteName = withoutExtension(clickedFileName); + } + + if (!spriteName) { + toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`); + return; + } const formData = new FormData(); formData.append('name', name); formData.append('label', expression); formData.append('avatar', file); + formData.append('spriteName', spriteName); await handleFileUpload('/api/sprites/upload', formData); diff --git a/public/scripts/extensions/expressions/settings.html b/public/scripts/extensions/expressions/settings.html index 66c55b3a4..fd5f9c3a6 100644 --- a/public/scripts/extensions/expressions/settings.html +++ b/public/scripts/extensions/expressions/settings.html @@ -87,6 +87,7 @@

Hint: Create new folder in the /characters/ folder of your user data directory and name it as the name of the character. Put images with expressions there. File names should follow the pattern: [expression_label].[image_format]

+ In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a dash. Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png

Sprite set: 

diff --git a/public/scripts/extensions/expressions/templates/upload-expression.html b/public/scripts/extensions/expressions/templates/upload-expression.html new file mode 100644 index 000000000..a7901ae7f --- /dev/null +++ b/public/scripts/extensions/expressions/templates/upload-expression.html @@ -0,0 +1,12 @@ +
Please enter a name for the sprite (without extension).
+
+ Sprite names must follow the naming schema for the selected expression: {{expression}} +
+
+ For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'. +
+Examples: {{expression}}.png, {{expression}}-1.png, {{expression}}.expressive.png, 美しい-17.png +{{#if clickedFileName}} +
Click 'Replace' to replace the existing expression:
+{{clickedFileName}} +{{/if}} diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index a87c554bd..4c5c5af85 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -227,6 +227,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { const file = request.file; const label = request.body.label; const name = request.body.name; + const spriteName = request.body.spriteName || label; if (!file || !label || !name) { return response.sendStatus(400); @@ -249,12 +250,12 @@ router.post('/upload', urlencodedParser, async (request, response) => { // Remove existing sprite with the same label for (const file of files) { - if (path.parse(file).name === label) { + if (path.parse(file).name === spriteName) { fs.rmSync(path.join(spritesPath, file)); } } - const filename = label + path.parse(file.originalname).ext; + const filename = spriteName + path.parse(file.originalname).ext; const spritePath = path.join(file.destination, file.filename); const pathToFile = path.join(spritesPath, filename); // Copy uploaded file to sprites folder From ef127df623e63014ad272491dacdea23342c19ec Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 08:33:14 +0100 Subject: [PATCH 06/39] Update sprite delete call --- public/scripts/extensions/expressions/index.js | 11 ++++++++--- src/endpoints/sprites.js | 5 +++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 9e91bd888..338778579 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -2081,20 +2081,25 @@ async function onClickExpressionDelete(event) { // Prevents the expression from being set event.stopPropagation(); - const confirmation = await callPopup('

Are you sure?

Once deleted, it\'s gone forever!', 'confirm'); + const expressionListItem = $(this).closest('.expression_list_item'); + const expression = expressionListItem.data('expression'); + const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` + + '

' + + t`Expression: ` + + expressionListItem.attr('data-filename')); if (!confirmation) { return; } - const expression = $(this).closest('.expression_list_item').data('expression'); + const fileName = expressionListItem.attr('data-filename'); const name = $('#image_list').data('name'); try { await fetch('/api/sprites/delete', { method: 'POST', headers: getRequestHeaders(), - body: JSON.stringify({ name, label: expression }), + body: JSON.stringify({ name, label: expression, spriteName: fileName }), }); } catch (error) { toastr.error('Failed to delete image. Try again later.'); diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index 4c5c5af85..4ddd4fe96 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -147,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) { router.post('/delete', jsonParser, async (request, response) => { const label = request.body.label; const name = request.body.name; + const spriteName = request.body.spriteName || label; - if (!label || !name) { + if (!spriteName || !name) { return response.sendStatus(400); } @@ -164,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => { // Remove existing sprite with the same label for (const file of files) { - if (path.parse(file).name === label) { + if (path.parse(file).name === spriteName) { fs.rmSync(path.join(spritesPath, file)); } } From 65ad79adceb3e170600599c7871dbb5b64658f1b Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 21:50:13 +0100 Subject: [PATCH 07/39] Fix expression delete --- public/scripts/extensions/expressions/index.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 338778579..26afd614f 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1392,7 +1392,6 @@ async function drawSpritesList(character, labels, sprites) { continue; } - // TODO: Fix valid expression lists/caching and group them correctly validExpressions.push({ label: expression, files: images }); // Render main = first file, additional = rest @@ -2086,13 +2085,12 @@ async function onClickExpressionDelete(event) { const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` + '

' - + t`Expression: ` - + expressionListItem.attr('data-filename')); + + t`Expression:` + ' ' + expressionListItem.attr('data-filename')) + ''; if (!confirmation) { return; } - const fileName = expressionListItem.attr('data-filename'); + const fileName = withoutExtension(expressionListItem.attr('data-filename')); const name = $('#image_list').data('name'); try { From 3d6f48786d9bd7a5543397c442fdaa961d0b7782 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 21:57:40 +0100 Subject: [PATCH 08/39] Refactor expression popups to modern popup --- public/scripts/extensions/expressions/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 26afd614f..5907dd845 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1,6 +1,6 @@ import { Fuse } from '../../../lib.js'; -import { callPopup, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js'; +import { eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } 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'; @@ -1739,7 +1739,7 @@ function onClickExpressionImage() { async function onClickExpressionAddCustom() { const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression'); - let expressionName = await callPopup(template, 'input'); + let expressionName = await Popup.show.input(null, template); if (!expressionName) { console.debug('No custom expression name provided'); @@ -1786,7 +1786,7 @@ async function onClickExpressionRemoveCustom() { } const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }); - const confirmation = await callPopup(template, 'confirm'); + const confirmation = await Popup.show.confirm(null, template); if (!confirmation) { console.debug('Custom expression removal cancelled'); From 7063af73637a8a2b239890855c391650ed4daca2 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 22:10:31 +0100 Subject: [PATCH 09/39] Move new expression settings, add tooltip --- .../extensions/expressions/settings.html | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/public/scripts/extensions/expressions/settings.html b/public/scripts/extensions/expressions/settings.html index fd5f9c3a6..93cfeb22a 100644 --- a/public/scripts/extensions/expressions/settings.html +++ b/public/scripts/extensions/expressions/settings.html @@ -6,7 +6,7 @@
- + +
-
- - -
Open a chat to see the character expressions.
From 84a8a2bc2b7dfe6dea5b198f45b834ad44c9556c Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 23:01:29 +0100 Subject: [PATCH 10/39] Fix expression sprite sorting, fade additional - Sort alphabetically, but keep the main expression file first - Fade additional sprite images if "allow multiple" is not chosen --- .../scripts/extensions/expressions/index.js | 22 +++++++++++++++---- .../scripts/extensions/expressions/style.css | 9 ++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 5907dd845..1c00a89a8 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1349,7 +1349,7 @@ function getExpressionImageData(sprite) { fileName: fileName, title: fileNameWithoutExtension, imageSrc: sprite.path, - type: fileNameWithoutExtension == sprite.label ? 'success' : 'additional', + type: 'success', isCustom: extension_settings.expressions.custom?.includes(sprite.label), }; } @@ -1380,8 +1380,7 @@ async function drawSpritesList(character, labels, sprites) { const images = sprites .filter(s => s.label === expression) .map(s => s.files) - .flat() - .sort((a, b) => a.title.localeCompare(b.title)); + .flat(); if (images.length === 0) { const listItem = await getListItem(expression, { @@ -1444,6 +1443,21 @@ async function getSpritesList(name) { return acc; }, []); + + // Sort the sprites for each expression alphabetically, but keep the main expression file at the front + for (const expression of grouped) { + expression.files.sort((a, b) => { + if (a.title === expression.label) return -1; + if (b.title === expression.label) return 1; + return a.title.localeCompare(b.title); + }); + + // Mark all besides the first sprite as 'additional' + for (let i = 1; i < expression.files.length; i++) { + expression.files[i].type = 'additional'; + } + } + return grouped; } catch (err) { @@ -1584,7 +1598,7 @@ async function setExpression(character, expression, force = false) { /** @type {Expression} */ const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression)); console.debug('checking for expression images to show..'); - if (sprite) { + if (sprite && sprite.files.length > 0) { console.debug('setting expression from character images folder'); let spriteFile = sprite.files[0]; diff --git a/public/scripts/extensions/expressions/style.css b/public/scripts/extensions/expressions/style.css index db58cb6f0..92d3d9bed 100644 --- a/public/scripts/extensions/expressions/style.css +++ b/public/scripts/extensions/expressions/style.css @@ -204,3 +204,12 @@ img.expression.default { align-items: baseline; flex-direction: row; } + +#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"] { + opacity: 0.3; + transition: opacity var(--animation-duration) ease; +} +#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover, +#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus { + opacity: unset; +} From d316d51c0be9b1e66ec2b85c74cfc6b102198f0c Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 27 Jan 2025 23:48:37 +0100 Subject: [PATCH 11/39] 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 --- .../scripts/extensions/expressions/index.js | 149 ++++++++++-------- 1 file changed, 84 insertions(+), 65 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 1c00a89a8..15759d280 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -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} + * @returns {Promise} 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} */ + 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 Image Type - talkinghead (extras) on/off.', returns: 'the current state of the Image Type - talkinghead (extras) on/off.', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'classify-expressions', - aliases: ['expressions'], + name: 'expression-classify', + aliases: ['classify-expressions', 'expressions'], + /** @type {(args: {return: string}) => Promise} */ 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} */ 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: '
Upload a sprite from a URL.
Example:
/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png
', + helpString: ` +
+ Upload a sprite from a URL. +
+
+ Example: +
    +
  • +
    /uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png
    +
  • +
+
+ `, })); })(); From 6348d1f19a93e48bbe51771748df019dc44041df Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Tue, 28 Jan 2025 00:17:42 +0100 Subject: [PATCH 12/39] CSS fixes (hide overflow, sprites interactable) --- public/scripts/extensions/expressions/list-item.html | 2 +- public/scripts/extensions/expressions/style.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/list-item.html b/public/scripts/extensions/expressions/list-item.html index dc5132600..dd18e32d5 100644 --- a/public/scripts/extensions/expressions/list-item.html +++ b/public/scripts/extensions/expressions/list-item.html @@ -1,5 +1,5 @@ {{#each images}} -
+
-

Hint: Create new folder in the /characters/ folder of your user data directory and name it as the name of the character. - Put images with expressions there. File names should follow the pattern: [expression_label].[image_format]

- In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a dash. Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png +

+ Hint: + + Create new folder in the /characters/ folder of your user data directory and name it as the name of the character. + Put images with expressions there. File names should follow the pattern: [expression_label].[image_format] + +

+

+ + In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a + dash. + Examples: joy.png, joy-1.png, joy.expressive.png + +

Sprite set: 

diff --git a/public/scripts/extensions/expressions/templates/upload-expression.html b/public/scripts/extensions/expressions/templates/upload-expression.html index a7901ae7f..a80ff6b90 100644 --- a/public/scripts/extensions/expressions/templates/upload-expression.html +++ b/public/scripts/extensions/expressions/templates/upload-expression.html @@ -5,7 +5,7 @@
For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
-Examples: {{expression}}.png, {{expression}}-1.png, {{expression}}.expressive.png, 美しい-17.png +Examples: {{expression}}.png, {{expression}}-1.png, {{expression}}.expressive.png {{#if clickedFileName}}
Click 'Replace' to replace the existing expression:
{{clickedFileName}} diff --git a/src/endpoints/sprites.js b/src/endpoints/sprites.js index ac6790982..0677ddb32 100644 --- a/src/endpoints/sprites.js +++ b/src/endpoints/sprites.js @@ -128,7 +128,7 @@ router.get('/get', jsonParser, function (request, response) { const fileName = path.parse(pathToSprite).name.toLowerCase(); // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot. - // Examples: joy.png, joy-1.png, joy.expressive.png, 美しい-17.png + // Examples: joy.png, joy-1.png, joy.expressive.png const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName; return { From 135bf8a55b87e0637708a7f008317ffb7cec5025 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Thu, 20 Feb 2025 00:12:40 +0200 Subject: [PATCH 21/39] Add progress toast for sprite ZIP upload --- public/scripts/extensions/expressions/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index c4d008750..54025adf7 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1880,7 +1880,9 @@ async function onClickExpressionUploadPackButton() { formData.append('name', name); formData.append('avatar', file); + const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 }); const { count } = await handleFileUpload('/api/sprites/upload-zip', formData); + toastr.clear(uploadToast); toastr.success(`Uploaded ${count} image(s) for ${name}`); // Reset the input From a40f56840909e7c2b0edb996dac83434ae89bb71 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 18:51:44 +0100 Subject: [PATCH 22/39] Fix sprite deletion 'no' option --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 54025adf7..f1aa7c7d9 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1904,7 +1904,7 @@ async function onClickExpressionDelete(event) { const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` + '

' - + t`Expression:` + ' ' + expressionListItem.attr('data-filename')) + ''; + + t`Expression:` + ' ' + expressionListItem.attr('data-filename') + ''); if (!confirmation) { return; } From a58e026a40513a75722ba1aa01a0001b249c4b67 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 19:02:29 +0100 Subject: [PATCH 23/39] Don't show del popup on placeholder sprite --- public/scripts/extensions/expressions/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index f1aa7c7d9..21b8fa9e4 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1902,6 +1902,10 @@ async function onClickExpressionDelete(event) { const expressionListItem = $(this).closest('.expression_list_item'); const expression = expressionListItem.data('expression'); + if (expressionListItem.attr('data-expression-type') === 'failure') { + return; + } + const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` + '

' + t`Expression:` + ' ' + expressionListItem.attr('data-filename') + ''); From 19e2a2f7d24ed1c4a60191bd0c06f764f3dd18da Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 19:05:20 +0100 Subject: [PATCH 24/39] safety check on upload on sprite name --- public/scripts/extensions/expressions/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 21b8fa9e4..bf2bb52c7 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1699,7 +1699,8 @@ async function onClickExpressionUpload(event) { const handleExpressionUploadChange = async (e) => { const file = e.target.files[0]; - if (!file) { + if (!file || !file.name) { + console.debug('No valid file selected'); return; } From 2834681a4b11a5e7dc45796d37f2b9f0e3978db2 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 19:25:20 +0100 Subject: [PATCH 25/39] Fix sprite upload replace existing - Also fix form not resetting on cancel of replace popup --- public/scripts/extensions/expressions/index.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index bf2bb52c7..6b7c76532 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1739,12 +1739,13 @@ async function onClickExpressionUpload(event) { }); } + spriteName = null; + const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles); + const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); - spriteName = generateUniqueSpriteName(expression, existingFiles); - const input = await Popup.show.input(t`Upload Expression Sprite`, message, - spriteName, { customButtons: customButtons }); + suggestedSpriteName, { customButtons: customButtons }); if (input) { if (!validateExpressionSpriteName(expression, input)) { @@ -1752,8 +1753,6 @@ async function onClickExpressionUpload(event) { return; } spriteName = input; - } else { - spriteName = null; } } } else { @@ -1762,6 +1761,8 @@ async function onClickExpressionUpload(event) { if (!spriteName) { toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`); + // Reset the input + e.target.form.reset(); return; } From 35745277808ba35199808ae7158e6c807f4a15de Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Thu, 20 Feb 2025 19:28:54 +0100 Subject: [PATCH 26/39] fade "reroll if same" if no multi sprites enabled --- public/scripts/extensions/expressions/style.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/style.css b/public/scripts/extensions/expressions/style.css index 1d4782e30..688b89378 100644 --- a/public/scripts/extensions/expressions/style.css +++ b/public/scripts/extensions/expressions/style.css @@ -209,7 +209,8 @@ img.expression.default { flex-direction: row; } -#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"] { +#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"], +#expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] { opacity: 0.3; transition: opacity var(--animation-duration) ease; } From 94f53835f42e3f5305e555802ec870114dda4334 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 00:29:05 +0200 Subject: [PATCH 27/39] Forbid custom expressions to be prefixed with defaults --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 6b7c76532..ece85405f 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1552,7 +1552,7 @@ async function onClickExpressionAddCustom() { toastr.warning('Invalid custom expression name provided', 'Add Custom Expression'); return; } - if (DEFAULT_EXPRESSIONS.includes(expressionName)) { + if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) { toastr.warning('Expression name already exists', 'Add Custom Expression'); return; } From 179153ae67219dcaa8c8f15a1bdb97ea5d19ccbe Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 00:51:17 +0200 Subject: [PATCH 28/39] Fix group VN mode with reduced motion --- public/scripts/extensions/expressions/index.js | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index ece85405f..d74faa5d2 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -285,6 +285,12 @@ async function visualNovelUpdateLayers(container) { let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction); let imagesWidth = []; + for (const image of images) { + if (image instanceof HTMLImageElement && !image.complete) { + await new Promise(resolve => image.addEventListener('load', resolve, { once: true })); + } + } + images.forEach(image => { imagesWidth.push($(image).width()); }); @@ -320,9 +326,15 @@ async function visualNovelUpdateLayers(container) { element.show(); const promise = new Promise(resolve => { - element.animate({ left: currentPosition + 'px' }, 500, () => { - resolve(); - }); + if (power_user.reduced_motion) { + element.css('left', currentPosition + 'px'); + requestAnimationFrame(() => resolve()); + } + else { + element.animate({ left: currentPosition + 'px' }, 500, () => { + resolve(); + }); + } }); currentPosition += imagesWidth[index]; From 07160e0e6022aef8edbfdebf6913a25f1257828e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:01:30 +0200 Subject: [PATCH 29/39] Fix group VN mode not updating on kicking group members --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index d74faa5d2..5e8094996 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -210,7 +210,7 @@ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, exp const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc }); if (expressionImage.length) { - if (spriteFolderName == memberSpriteFolderName) { + if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) { await validateImages(memberSpriteFolderName, true); setExpressionOverrideHtml(true); // <= force clear expression override input const path = spriteFile?.imageSrc || ''; From aca1cb7f9973683beba19e2f3f9264af30c93e6b Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:19:19 +0200 Subject: [PATCH 30/39] Fix first reorder of group VN with reduced motion --- public/scripts/extensions/expressions/index.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 5e8094996..bff903b79 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -4,7 +4,7 @@ import { characters, eventSource, event_types, generateRaw, getRequestHeaders, m import { dragElement, isMobile } from '../../RossAscends-mods.js'; import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; import { loadMovingUIState, performFuzzySearch, 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, delay } from '../../utils.js'; import { hideMutedSprites, selected_group } from '../../group-chats.js'; import { isJsonSchemaSupported } from '../../textgen-settings.js'; import { debounce_timeout } from '../../constants.js'; @@ -345,6 +345,12 @@ async function visualNovelUpdateLayers(container) { await Promise.allSettled(setLayerIndicesPromises); } +/** + * Sets the expression for the given character image. + * @param {JQuery} img - The image element to set the image on + * @param {string} path - The path to the image + * @returns {Promise} - A promise that resolves when the image is set + */ async function setImage(img, path) { // Cohee: If something goes wrong, uncomment this to return to the old behavior /* @@ -412,7 +418,9 @@ async function setImage(img, path) { expressionHolder.css('min-width', 100); expressionHolder.css('min-height', 100); - resolve(); + expressionClone.one('load', function () { + resolve(); + }); }); expressionClone.removeClass('expression-clone'); From e35217e7e3f66205957fe54443fb244dbf24f5b7 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:32:13 +0200 Subject: [PATCH 31/39] Fix image loading resolve --- public/scripts/extensions/expressions/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index bff903b79..f32083671 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -418,9 +418,11 @@ async function setImage(img, path) { expressionHolder.css('min-width', 100); expressionHolder.css('min-height', 100); - expressionClone.one('load', function () { + if (expressionClone.prop('complete')) { resolve(); - }); + } else { + expressionClone.one('load', () => resolve()); + } }); expressionClone.removeClass('expression-clone'); From bdbcf8623e7b6671a14f1a41e0216632cb39cf21 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 01:46:10 +0200 Subject: [PATCH 32/39] Fix force set emote in group --- public/scripts/extensions/expressions/index.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index f32083671..2afcca74b 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -234,6 +234,10 @@ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, exp setSpritePromises.push(fadeInPromise); } + if (!img) { + continue; + } + img.attr('data-sprite-folder-name', spriteFolderName); img.attr('data-expression', expression); img.attr('data-sprite-filename', spriteFile?.fileName || null); @@ -679,7 +683,8 @@ async function setSpriteSlashCommand({ type }, searchTerm) { return ''; } - const spriteFolderName = getSpriteFolderName(); + const currentLastMessage = selected_group ? getLastCharacterMessage() : null; + const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); let label = searchTerm; @@ -2135,7 +2140,8 @@ function migrateSettings() { const localEnumProviders = { expressions: () => { - const spriteFolderName = getSpriteFolderName(); + const currentLastMessage = selected_group ? getLastCharacterMessage() : null; + const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); const expressions = getCachedExpressions(); return expressions.map(expression => { const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0; @@ -2149,7 +2155,8 @@ function migrateSettings() { }); }, sprites: () => { - const spriteFolderName = getSpriteFolderName(); + const currentLastMessage = selected_group ? getLastCharacterMessage() : null; + const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? []; return sprites.map(x => { return new SlashCommandEnumValue(x.title, From cb6adc30ce0e5e97a30fec160859a21427a4495b Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 02:05:43 +0200 Subject: [PATCH 33/39] Fix null confrimation when no custom expressions --- public/scripts/extensions/expressions/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 2afcca74b..a5177bd9e 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -1601,8 +1601,9 @@ async function onClickExpressionAddCustom() { async function onClickExpressionRemoveCustom() { const selectedExpression = String($('#expression_custom').val()); + const noCustomExpressions = extension_settings.expressions.custom.length === 0; - if (!selectedExpression) { + if (!selectedExpression || noCustomExpressions) { console.debug('No custom expression selected'); return; } From a7d7b6fb0fc4e35ece5aa056a6c3e0bdaf102160 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Fri, 21 Feb 2025 22:05:08 +0200 Subject: [PATCH 34/39] Fix group VN updates on switching to chat --- .../scripts/extensions/expressions/index.js | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index a5177bd9e..42606d8c0 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -178,6 +178,7 @@ async function visualNovelRemoveInactive(container) { * @returns {Promise} - An array of promises that resolve when the sprites are set */ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) { + const originalExpression = expression; const context = getContext(); const group = context.groups.find(x => x.id == context.groupId); @@ -208,6 +209,10 @@ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, exp const prevExpressionSrc = expressionImage.find('img').attr('src') || null; + if (!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) { + expression = await getLastMessageSprite(avatar); + } + const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc }); if (expressionImage.length) { if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) { @@ -251,6 +256,23 @@ async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, exp return setSpritePromises; } +/** + * Classifies the text of the latest message and returns the expression label. + * @param {string} avatar - The avatar of the character to get the last message for + * @returns {Promise} - The expression label + */ +async function getLastMessageSprite(avatar) { + const context = getContext(); + const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar)))); + + if (lastMessage) { + const text = lastMessage.mes || ''; + return await getExpressionLabel(text); + } + + return null; +} + async function visualNovelUpdateLayers(container) { const context = getContext(); const group = context.groups.find(x => x.id == context.groupId); @@ -445,7 +467,7 @@ async function setImage(img, path) { }); } -async function moduleWorker() { +async function moduleWorker({ newChat = false } = {}) { const context = getContext(); // non-characters not supported @@ -514,6 +536,10 @@ async function moduleWorker() { offlineMode.css('display', 'none'); } + if (context.groupId && vnMode && newChat) { + await forceUpdateVisualNovelMode(); + } + // Don't bother classifying if current char has no sprites and no default expressions are enabled if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) { return; @@ -2134,7 +2160,7 @@ function migrateSettings() { $('#visual-novel-wrapper').empty(); } - updateFunction(); + updateFunction({ newChat: true }); }); eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced); eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced); From afbe21b6b45d2ee2ffcbd1df6fa77a15c573342c Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Sat, 22 Feb 2025 01:44:42 +0100 Subject: [PATCH 35/39] Make sendExpressionCall exportable - For compatibility with existing extensions --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 42606d8c0..21b8c18a6 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -646,7 +646,7 @@ function getFolderNameByMessage(message) { * @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. * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name. */ -async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) { +export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) { lastExpression[spriteFolderName.split('/')[0]] = expression; if (vnMode === null) { vnMode = isVisualNovelMode(); From db988411fd21c7cbf6903cd2bfc8d6025ecf2255 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:40:43 +0200 Subject: [PATCH 36/39] Return fallback expression if no group message found --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 21b8c18a6..d8a8c6e21 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -270,7 +270,7 @@ async function getLastMessageSprite(avatar) { return await getExpressionLabel(text); } - return null; + return extension_settings.expressions.fallback_expression; } async function visualNovelUpdateLayers(container) { From d21b0f1b5e3c581b1889fd44ad00d5ec34bec49b Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 13:45:54 +0200 Subject: [PATCH 37/39] Add default fallback expression --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index d8a8c6e21..986370cf5 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -270,7 +270,7 @@ async function getLastMessageSprite(avatar) { return await getExpressionLabel(text); } - return extension_settings.expressions.fallback_expression; + return extension_settings.expressions.fallback_expression || DEFAULT_FALLBACK_EXPRESSION; } async function visualNovelUpdateLayers(container) { From f2cac8e7f749fd797ac6bb5eff3714e56731231f Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:02:34 +0200 Subject: [PATCH 38/39] Revert "Return fallback expression if no group message found" --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 986370cf5..21b8c18a6 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -270,7 +270,7 @@ async function getLastMessageSprite(avatar) { return await getExpressionLabel(text); } - return extension_settings.expressions.fallback_expression || DEFAULT_FALLBACK_EXPRESSION; + return null; } async function visualNovelUpdateLayers(container) { From 3cf4be8e859e5f6c7120b8d3f3f070561f074f5e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 22 Feb 2025 20:12:19 +0200 Subject: [PATCH 39/39] Remove unused import --- public/scripts/extensions/expressions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/scripts/extensions/expressions/index.js b/public/scripts/extensions/expressions/index.js index 21b8c18a6..2dade5f6a 100644 --- a/public/scripts/extensions/expressions/index.js +++ b/public/scripts/extensions/expressions/index.js @@ -4,7 +4,7 @@ import { characters, eventSource, event_types, generateRaw, getRequestHeaders, m import { dragElement, isMobile } from '../../RossAscends-mods.js'; import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; -import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, delay } from '../../utils.js'; +import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; import { hideMutedSprites, selected_group } from '../../group-chats.js'; import { isJsonSchemaSupported } from '../../textgen-settings.js'; import { debounce_timeout } from '../../constants.js';