Merge pull request #3403 from SillyTavern/support-multiple-expressions Support multiple expressions

fb06e7afa1bc5d18fe37b06cea08a45fa0cce3c3

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
17 files changed, +900 -768Showing whitespace changes
public/global.d.ts+8 -0
@@ -40,4 +40,12 @@ declare global {
4040 searchInputCssClass?: string;
4141 }
4242 }
43+
44+ /**
45+ * Translates a text to a target language using a translation provider.
46+ * @param text Text to translate
47+ * @param lang Target language
48+ * @param provider Translation provider
49+ */
50+ async function translate(text: string, lang: string, provider: string = null): Promise<string>;
4351}
public/locales/fr-fr.json+0 -1
@@ -1602,7 +1602,6 @@
16021602 "Character Expressions": "Expressions de personnages",
16031603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",
16041604 "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",
1605- "Image Type - talkinghead (extras)": "Type d'image - talkinghead (extras)",
16061605 "Classifier API": "API de classification",
16071606 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",
16081607 "Main API": "API principale",
public/locales/ko-kr.json+0 -1
@@ -1467,7 +1467,6 @@
14671467 "menu within": "내의 메뉴",
14681468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",
14691469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",
1470- "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)",
14711470 "Classifier API": "분류를 위한 API",
14721471 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",
14731472 "Local": "로컬",
public/locales/zh-cn.json+0 -1
@@ -1349,7 +1349,6 @@
13491349 "Character Expressions": "角色表情",
13501350 "Translate text to English before classification": "分类之前将文本翻译成英文",
13511351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",
1352- "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)",
13531352 "Classifier API": "分类器 API",
13541353 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
13551354 "Main API": "主要 API",
public/locales/zh-tw.json+0 -1
@@ -1653,7 +1653,6 @@
16531653 "HuggingFace Token": "HuggingFace 符元",
16541654 "Image Captioning": "圖片註解",
16551655 "Generate Caption": "產生圖片註解",
1656- "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)",
16571656 "Injection Position": "插入位置",
16581657 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
16591658 "Injection Template": "插入範本",
public/scripts/extensions.js+10 -0
@@ -154,8 +154,18 @@ export const extension_settings = {
154154 refine_mode: false,
155155 },
156156 expressions: {
157+ /** @type {number} see `EXPRESSION_API` */
158+ api: undefined,
157159 /** @type {string[]} */
158160 custom: [],
161+ showDefault: false,
162+ translate: false,
163+ /** @type {string} */
164+ fallback_expression: undefined,
165+ /** @type {string} */
166+ llmPrompt: undefined,
167+ allowMultiple: true,
168+ rerollIfSame: false,
159169 },
160170 connectionManager: {
161171 selectedProfile: '',
public/scripts/extensions/expressions/index.js+788 -707
@@ -1,11 +1,11 @@
11import { Fuse } from '../../../lib.js';
22
33import { callPopupcharacters, 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';
1010import { debounce_timeout } from '../../constants.js';
1111import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -15,16 +15,32 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
1515import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
1616import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
1717import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
18+import { Popup, POPUP_RESULT } from '../../popup.js';
19+import { t } from '../../i18n.js';
1820export { MODULE_NAME };
1921
22+/**
23+* @typedef {object} Expression Expression definition with label and file path
24+* @property {string} label The label of the expression
25+* @property {ExpressionImage[]} files One or more images to represent this expression
26+*/
27+
28+/**
29+ * @typedef {object} ExpressionImage An expression image
30+ * @property {string} expression - The expression
31+ * @property {boolean} [isCustom=false] - If the expression is added by user
32+ * @property {string} fileName - The filename with extension
33+ * @property {string} title - The title for the image
34+ * @property {string} imageSrc - The image source / full path
35+ * @property {'success' | 'additional' | 'failure'} type - The type of the image
36+ */
37+
2038const MODULE_NAME = 'expressions';
2139const UPDATE_INTERVAL = 2000;
2240const STREAMING_UPDATE_INTERVAL = 10000;
23-const TALKINGCHECK_UPDATE_INTERVAL = 500;
2441const DEFAULT_FALLBACK_EXPRESSION = 'joy';
2542const DEFAULT_LLM_PROMPT = 'Ignore previous instructions. Classify the emotion of the last message. Output just one word, e.g. "joy" or "anger". Choose only one of the following labels: {{labels}}';
2643const DEFAULT_EXPRESSIONS = [
27- 'talkinghead',
2844 'admiration',
2945 'amusement',
3046 'anger',
@@ -54,6 +70,12 @@ const DEFAULT_EXPRESSIONS = [
5470 'surprise',
5571 'neutral',
5672];
73+
74+const OPTION_NO_FALLBACK = '#none';
75+const OPTION_EMOJI_FALLBACK = '#emoji';
76+const RESET_SPRITE_LABEL = '#reset';
77+
78+
5779/** @enum {number} */
5880const EXPRESSION_API = {
5981 local: 0,
@@ -65,35 +87,29 @@ const EXPRESSION_API = {
6587let expressionsList = null;
6688let lastCharacter = undefined;
6789let lastMessage = null;
68-let lastTalkingState = false;
90+/** @type {{[characterKey: string]: Expression[]}} */
69-let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
7091let spriteCache = {};
7192let inApiCall = false;
7293let lastServerResponseTime = 0;
73-export let lastExpression = {};
74-
75-function isTalkingHeadEnabled() {
76- return extension_settings.expressions.talkinghead && extension_settings.expressions.api == EXPRESSION_API.extras;
77-}
7894
79-/**
95+/** @type {{[characterName: string]: string}} */
80- * Returns the fallback expression if explicitly chosen, otherwise the default one
96+export let lastExpression = {};
81- * @returns {string} expression name
82- */
83-function getFallbackExpression() {
84- return extension_settings.expressions.fallback_expression ?? DEFAULT_FALLBACK_EXPRESSION;
85-}
8697
8798/**
88- * Toggles Talkinghead mode on/off.
99+ * Returns a placeholder image object for a given expression
89- *
100+ * @param {string} expression - The expression label
90- * Implements the `/th` slash command, which is meant to be bound to a Quick Reply button
101+ * @param {boolean} [isCustom=false] - Whether the expression is custom
91- * as a quick way to switch Talkinghead on or off (e.g. to conserve GPU resources when AFK
102+ * @returns {ExpressionImage} The placeholder image object
92- * for a long time).
93103 */
94104function toggleTalkingHeadCommandgetPlaceholderImage(_expression, isCustom = false) {
95- setTalkingHeadState(!extension_settings.expressions.talkinghead);
105+ return {
96- return String(extension_settings.expressions.talkinghead);
106+ expression: expression,
107+ isCustom: isCustom,
108+ title: 'No Image',
109+ type: 'failure',
110+ fileName: 'No-Image-Placeholder.svg',
111+ imageSrc: '/img/No-Image-Placeholder.svg',
112+ };
97113}
98114
99115function isVisualNovelMode() {
@@ -108,21 +124,21 @@ async function forceUpdateVisualNovelMode() {
108124
109125const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);
110126
111127async function updateVisualNovelMode(namespriteFolderName, expression) {
112128 const containervnContainer = $('#visual-novel-wrapper');
113129
114130 await visualNovelRemoveInactive(containervnContainer);
115131
116132 const setSpritePromises = await visualNovelSetCharacterSprites(containervnContainer, namespriteFolderName, expression);
117133
118134 // calculate layer indices based on recent messages
119135 await visualNovelUpdateLayers(containervnContainer);
120136
121137 await Promise.allSettled(setSpritePromises);
122138
123139 // update again based on new sprites
124140 if (setSpritePromises.length > 0) {
125141 await visualNovelUpdateLayers(containervnContainer);
126142 }
127143}
128144
@@ -153,52 +169,60 @@ async function visualNovelRemoveInactive(container) {
153169 await Promise.allSettled(removeInactiveCharactersPromises);
154170}
155171
156-async function visualNovelSetCharacterSprites(container, name, expression) {
172+/**
173+ * Sets the character sprites for visual novel mode based on the provided container, name, and expression.
174+ *
175+ * @param {JQuery<HTMLElement>} vnContainer - The container element where the sprites will be set
176+ * @param {string} spriteFolderName - The name of the sprite folder
177+ * @param {string} expression - The expression to set for the characters
178+ * @returns {Promise<Array>} - An array of promises that resolve when the sprites are set
179+ */
180+async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) {
181+ const originalExpression = expression;
157182 const context = getContext();
158183 const group = context.groups.find(x => x.id == context.groupId);
159- const labels = await getExpressionsList();
160184
161- const createCharacterPromises = [];
162185 const setSpritePromises = [];
163186
164187 for (const avatar of group.members) {
165- const isDisabled = group.disabled_members.includes(avatar);
166-
167188 // skip disabled characters
189+ const isDisabled = group.disabled_members.includes(avatar);
168190 if (isDisabled && hideMutedSprites) {
169191 continue;
170192 }
171193
172194 const character = context.characters.find(x => x.avatar == avatar);
173-
174195 if (!character) {
175196 continue;
176197 }
177198
178- const spriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
199+ const expressionImage = vnContainer.find(`.expression-holder[data-avatar="${avatar}"]`);
200+ /** @type {JQuery<HTMLElement>} */
201+ let img;
202+
203+ const memberSpriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
179204
180205 // download images if not downloaded yet
181206 if (spriteCache[spriteFolderNamememberSpriteFolderName] === undefined) {
182207 spriteCache[spriteFolderNamememberSpriteFolderName] = await getSpritesList(spriteFolderNamememberSpriteFolderName);
183208 }
184209
185- const sprites = spriteCache[spriteFolderName];
210+ const prevExpressionSrc = expressionImage.find('img').attr('src') || null;
186- const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
187- const defaultExpression = getFallbackExpression();
188- const defaultSpritePath = sprites.find(x => x.label === defaultExpression)?.path;
189- const noSprites = sprites.length === 0;
190211
191212 if (expressionImage!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) {
192- if (name == spriteFolderName) {
213+ expression = await getLastMessageSprite(avatar);
193- await validateImages(spriteFolderName, true);
214+ }
194- setExpressionOverrideHtml(true); // <= force clear expression override input
195- const currentSpritePath = labels.includes(expression) ? sprites.find(x => x.label === expression)?.path : '';
196215
197- const path = currentSpritePath || defaultSpritePath || '';
216+ const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc });
198- const img = expressionImage.find('img');
217+ if (expressionImage.length) {
218+ if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) {
219+ await validateImages(memberSpriteFolderName, true);
220+ setExpressionOverrideHtml(true); // <= force clear expression override input
221+ const path = spriteFile?.imageSrc || '';
222+ img = expressionImage.find('img');
199223 await setImage(img, path);
200224 }
201225 expressionImage.toggleClass('hidden', noSprites!spriteFile);
202226 } else {
203227 const template = $('#expression-holder').clone();
204228 template.attr('id', `expression-${avatar}`);
@@ -206,21 +230,49 @@ async function visualNovelSetCharacterSprites(container, name, expression) {
206230 template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
207231 $('#visual-novel-wrapper').append(template);
208232 dragElement($(template[0]));
209233 template.toggleClass('hidden', noSprites!spriteFile);
210234 awaitimg setImage(= template.find('img'), defaultSpritePath || '');
235+ await setImage(img, spriteFile?.imageSrc || '');
211236 const fadeInPromise = new Promise(resolve => {
212237 template.fadeIn(250, () => resolve());
213238 });
214239 createCharacterPromisessetSpritePromises.push(fadeInPromise);
215- const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
216- setSpritePromises.push(setSpritePromise);
217240 }
241+
242+ if (!img) {
243+ continue;
244+ }
245+
246+ img.attr('data-sprite-folder-name', spriteFolderName);
247+ img.attr('data-expression', expression);
248+ img.attr('data-sprite-filename', spriteFile?.fileName || null);
249+ img.attr('title', expression);
250+
251+ if (spriteFile) console.info(`Expression set for group member ${character.name}`, { expression: spriteFile.expression, file: spriteFile.fileName });
252+ else if (expressionImage.length) console.info(`Expression unset for group member ${character.name} - No sprite found`, { expression: expression });
253+ else console.info(`Expression not available for group member ${character.name}`, { expression: expression });
218254 }
219255
220- await Promise.allSettled(createCharacterPromises);
221256 return setSpritePromises;
222257}
223258
259+/**
260+ * Classifies the text of the latest message and returns the expression label.
261+ * @param {string} avatar - The avatar of the character to get the last message for
262+ * @returns {Promise<string>} - The expression label
263+ */
264+async function getLastMessageSprite(avatar) {
265+ const context = getContext();
266+ const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
267+
268+ if (lastMessage) {
269+ const text = lastMessage.mes || '';
270+ return await getExpressionLabel(text);
271+ }
272+
273+ return null;
274+}
275+
224276async function visualNovelUpdateLayers(container) {
225277 const context = getContext();
226278 const group = context.groups.find(x => x.id == context.groupId);
@@ -256,11 +308,17 @@ async function visualNovelUpdateLayers(container) {
256308 const containerWidth = container.width();
257309 const pivotalPoint = containerWidth * 0.5;
258310
259311 let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction);
260312 let imagesWidth = [];
261313
262- images.sort(sortFunction).each(function () {
314+ for (const image of images) {
263- imagesWidth.push($(this).width());
315+ if (image instanceof HTMLImageElement && !image.complete) {
316+ await new Promise(resolve => image.addEventListener('load', resolve, { once: true }));
317+ }
318+ }
319+
320+ images.forEach(image => {
321+ imagesWidth.push($(image).width());
264322 });
265323
266324 let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
@@ -274,7 +332,7 @@ async function visualNovelUpdateLayers(container) {
274332 currentPosition = 0; // Reset the initial position to 0
275333 }
276334
277335 images.sort(sortFunction).eachforEach((indexcurrent, currentindex) => {
278336 const element = $(current);
279337 const elementID = element.attr('id');
280338
@@ -294,9 +352,15 @@ async function visualNovelUpdateLayers(container) {
294352 element.show();
295353
296354 const promise = new Promise(resolve => {
355+ if (power_user.reduced_motion) {
356+ element.css('left', currentPosition + 'px');
357+ requestAnimationFrame(() => resolve());
358+ }
359+ else {
297360 element.animate({ left: currentPosition + 'px' }, 500, () => {
298361 resolve();
299362 });
363+ }
300364 });
301365
302366 currentPosition += imagesWidth[index];
@@ -307,23 +371,12 @@ async function visualNovelUpdateLayers(container) {
307371 await Promise.allSettled(setLayerIndicesPromises);
308372}
309373
310-async function setLastMessageSprite(img, avatar, labels) {
374+/**
311- const context = getContext();
375+ * Sets the expression for the given character image.
312- const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
376+ * @param {JQuery<HTMLElement>} img - The image element to set the image on
313-
377+ * @param {string} path - The path to the image
314- if (lastMessage) {
378+ * @returns {Promise<void>} - A promise that resolves when the image is set
315- const text = lastMessage.mes || '';
379+ */
316- const spriteFolderName = getSpriteFolderName(lastMessage, lastMessage.name);
317- const sprites = spriteCache[spriteFolderName] || [];
318- const label = await getExpressionLabel(text);
319- const path = labels.includes(label) ? sprites.find(x => x.label === label)?.path : '';
320-
321- if (path) {
322- setImage(img, path);
323- }
324- }
325-}
326-
327380async function setImage(img, path) {
328381 // Cohee: If something goes wrong, uncomment this to return to the old behavior
329382 /*
@@ -340,7 +393,7 @@ async function setImage(img, path) {
340393 return new Promise(resolve => {
341394 const prevExpressionSrc = img.attr('src');
342395 const expressionClone = img.clone();
343396 const originalId = img.attrdata('idfilename');
344397
345398 //only swap expressions when necessary
346399 if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
@@ -348,7 +401,7 @@ async function setImage(img, path) {
348401 expressionClone.addClass('expression-clone');
349402 //make invisible and remove id to prevent double ids
350403 //must be made invisible to start because they share the same Z-index
351404 expressionClone.attrdata('idfilename', '').css({ opacity: 0 });
352405 //add new sprite path to clone src
353406 expressionClone.attr('src', path);
354407 //add invisible clone to html
@@ -384,14 +437,18 @@ async function setImage(img, path) {
384437 //remove old expression
385438 img.remove();
386439 //replace ID so it becomes the new 'original' expression for next change
387440 expressionClone.attrdata('idfilename', originalId);
388441 expressionClone.removeClass('expression-animating');
389442
390443 // Reset the expression holder min height and width
391444 expressionHolder.css('min-width', 100);
392445 expressionHolder.css('min-height', 100);
393446
447+ if (expressionClone.prop('complete')) {
394448 resolve();
449+ } else {
450+ expressionClone.one('load', () => resolve());
451+ }
395452 });
396453
397454 expressionClone.removeClass('expression-clone');
@@ -410,216 +467,9 @@ async function setImage(img, path) {
410467 });
411468}
412469
413-function onExpressionsShowDefaultInput() {
470+async function moduleWorker({ newChat = false } = {}) {
414- const value = $(this).prop('checked');
415- extension_settings.expressions.showDefault = value;
416- saveSettingsDebounced();
417-
418- const existingImageSrc = $('img.expression').prop('src');
419- if (existingImageSrc !== undefined) { //if we have an image in src
420- if (!value && existingImageSrc.includes('/img/default-expressions/')) { //and that image is from /img/ (default)
421- $('img.expression').prop('src', ''); //remove it
422- lastMessage = null;
423- }
424- if (value) {
425- lastMessage = null;
426- }
427- }
428-}
429-
430-/**
431- * Stops animating Talkinghead.
432- */
433-async function unloadTalkingHead() {
434- if (!modules.includes('talkinghead')) {
435- console.debug('talkinghead module is disabled');
436- return;
437- }
438- console.debug('expressions: Stopping Talkinghead');
439-
440- try {
441- const url = new URL(getApiUrl());
442- url.pathname = '/api/talkinghead/unload';
443- const loadResponse = await doExtrasFetch(url);
444- if (!loadResponse.ok) {
445- throw new Error(loadResponse.statusText);
446- }
447- //console.log(`Response: ${loadResponseText}`);
448- } catch (error) {
449- //console.error(`Error unloading - ${error}`);
450- }
451-}
452-
453-/**
454- * Posts `talkinghead.png` of the current character to the talkinghead module in SillyTavern-extras, to start animating it.
455- */
456-async function loadTalkingHead() {
457- if (!modules.includes('talkinghead')) {
458- console.debug('talkinghead module is disabled');
459- return;
460- }
461- console.debug('expressions: Starting Talkinghead');
462-
463- const spriteFolderName = getSpriteFolderName();
464-
465- const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
466- const emotionsSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_emotions.json`;
467- const animatorSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_animator.json`;
468-
469- try {
470- const spriteResponse = await fetch(talkingheadPath);
471-
472- if (!spriteResponse.ok) {
473- throw new Error(spriteResponse.statusText);
474- }
475-
476- const spriteBlob = await spriteResponse.blob();
477- const spriteFile = new File([spriteBlob], 'talkinghead.png', { type: 'image/png' });
478- const formData = new FormData();
479- formData.append('file', spriteFile);
480-
481- const url = new URL(getApiUrl());
482- url.pathname = '/api/talkinghead/load';
483-
484- const loadResponse = await doExtrasFetch(url, {
485- method: 'POST',
486- body: formData,
487- });
488-
489- if (!loadResponse.ok) {
490- throw new Error(loadResponse.statusText);
491- }
492-
493- const loadResponseText = await loadResponse.text();
494- console.log(`Load talkinghead response: ${loadResponseText}`);
495-
496- // Optional: per-character emotion templates
497- let emotionsSettings;
498- try {
499- const emotionsResponse = await fetch(emotionsSettingsPath);
500- if (emotionsResponse.ok) {
501- emotionsSettings = await emotionsResponse.json();
502- console.log(`Loaded ${emotionsSettingsPath}`);
503- } else {
504- throw new Error();
505- }
506- }
507- catch (error) {
508- emotionsSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
509- console.log(`No valid config at ${emotionsSettingsPath}, using server defaults`);
510- }
511- try {
512- const url = new URL(getApiUrl());
513- url.pathname = '/api/talkinghead/load_emotion_templates';
514- const apiResult = await doExtrasFetch(url, {
515- method: 'POST',
516- headers: {
517- 'Content-Type': 'application/json',
518- 'Bypass-Tunnel-Reminder': 'bypass',
519- },
520- body: JSON.stringify(emotionsSettings),
521- });
522-
523- if (!apiResult.ok) {
524- throw new Error(apiResult.statusText);
525- }
526- }
527- catch (error) {
528- // it's ok if not supported
529- console.log('Failed to send _emotions.json (backend too old?), ignoring');
530- }
531-
532- // Optional: per-character animator and postprocessor config
533- let animatorSettings;
534- try {
535- const animatorResponse = await fetch(animatorSettingsPath);
536- if (animatorResponse.ok) {
537- animatorSettings = await animatorResponse.json();
538- console.log(`Loaded ${animatorSettingsPath}`);
539- } else {
540- throw new Error();
541- }
542- }
543- catch (error) {
544- animatorSettings = {}; // blank -> use server defaults (to unload the previous character's customizations)
545- console.log(`No valid config at ${animatorSettingsPath}, using server defaults`);
546- }
547- try {
548- const url = new URL(getApiUrl());
549- url.pathname = '/api/talkinghead/load_animator_settings';
550- const apiResult = await doExtrasFetch(url, {
551- method: 'POST',
552- headers: {
553- 'Content-Type': 'application/json',
554- 'Bypass-Tunnel-Reminder': 'bypass',
555- },
556- body: JSON.stringify(animatorSettings),
557- });
558-
559- if (!apiResult.ok) {
560- throw new Error(apiResult.statusText);
561- }
562- }
563- catch (error) {
564- // it's ok if not supported
565- console.log('Failed to send _animator.json (backend too old?), ignoring');
566- }
567- } catch (error) {
568- console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
569- }
570-}
571-
572-function handleImageChange() {
573- const imgElement = document.querySelector('img#expression-image.expression');
574-
575- if (!imgElement || !(imgElement instanceof HTMLImageElement)) {
576- console.log('Cannot find addExpressionImage()');
577- return;
578- }
579-
580- if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
581- const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
582- $('#expression-holder').css({ display: '' });
583- if (imgElement.src !== talkingheadResultFeedSrc) {
584- const expressionImageElement = document.querySelector('.expression_list_image');
585-
586- if (expressionImageElement && expressionImageElement instanceof HTMLImageElement) {
587- doExtrasFetch(expressionImageElement.src, {
588- method: 'HEAD',
589- })
590- .then(response => {
591- if (response.ok) {
592- imgElement.src = talkingheadResultFeedSrc;
593- }
594- })
595- .catch(error => {
596- console.error(error);
597- });
598- }
599- }
600- } else {
601- imgElement.src = ''; // remove in case char doesn't have expressions
602-
603- // When switching Talkinghead off, force-set the character to the last known expression, if any.
604- // This preserves the same expression Talkinghead had at the moment it was switched off.
605- const charName = getContext().name2;
606- const last = lastExpression[charName];
607- const targetExpression = last ? last : getFallbackExpression();
608- setExpression(charName, targetExpression, true);
609- }
610-}
611-
612-async function moduleWorker() {
613471 const context = getContext();
614472
615- // Hide and disable Talkinghead while not in extras
616- $('#image_type_block').toggle(extension_settings.expressions.api == EXPRESSION_API.extras);
617-
618- if (extension_settings.expressions.api != EXPRESSION_API.extras && extension_settings.expressions.talkinghead) {
619- $('#image_type_toggle').prop('checked', false);
620- setTalkingHeadState(false);
621- }
622-
623473 // non-characters not supported
624474 if (!context.groupId && context.characterId === undefined) {
625475 removeExpression();
@@ -646,7 +496,7 @@ async function moduleWorker() {
646496 }
647497
648498 const currentLastMessage = getLastCharacterMessage();
649499 let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();
650500
651501 // character has no expressions or it is not loaded
652502 if (Object.keys(spriteCache).length === 0) {
@@ -686,6 +536,10 @@ async function moduleWorker() {
686536 offlineMode.css('display', 'none');
687537 }
688538
539+ if (context.groupId && vnMode && newChat) {
540+ await forceUpdateVisualNovelMode();
541+ }
542+
689543 // Don't bother classifying if current char has no sprites and no default expressions are enabled
690544 if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {
691545 return;
@@ -732,11 +586,11 @@ async function moduleWorker() {
732586 const force = !!context.groupId;
733587
734588 // Character won't be angry on you for swiping
735589 if (currentLastMessage.mes == '...' && expressionsList.includes(getFallbackExpression()extension_settings.expressions.fallback_expression)) {
736590 expression = getFallbackExpression()extension_settings.expressions.fallback_expression;
737591 }
738592
739593 await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
740594 }
741595 catch (error) {
742596 console.log(error);
@@ -749,91 +603,6 @@ async function moduleWorker() {
749603 }
750604}
751605
752-/**
753- * Starts/stops Talkinghead talking animation.
754- *
755- * Talking starts only when all the following conditions are met:
756- * - The LLM is currently streaming its output.
757- * - The AI's current last message is non-empty, and also not just '...' (as produced by a swipe).
758- * - The AI's current last message has changed from what we saw during the previous call.
759- *
760- * In all other cases, talking stops.
761- *
762- * A Talkinghead API call is made only when the talking state changes.
763- *
764- * Note that also the TTS system, if enabled, starts/stops the Talkinghead talking animation.
765- * See `talkingAnimation` in `SillyTavern/public/scripts/extensions/tts/index.js`.
766- */
767-async function updateTalkingState() {
768- // Don't bother if Talkinghead is disabled or not loaded.
769- if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
770- return;
771- }
772-
773- const context = getContext();
774- const currentLastMessage = getLastCharacterMessage();
775-
776- try {
777- // TODO: Not sure if we need also "&& !context.groupId" here - the classify check in `moduleWorker`
778- // (that similarly checks the streaming processor state) does that for some reason.
779- // Talkinghead isn't currently designed to work with groups.
780- const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastTalkingStateMessage === currentLastMessage.mes);
781- const url = new URL(getApiUrl());
782- let newTalkingState;
783- if (context.streamingProcessor && !context.streamingProcessor.isFinished &&
784- currentLastMessage.mes.length !== 0 && currentLastMessage.mes !== '...' && lastMessageChanged) {
785- url.pathname = '/api/talkinghead/start_talking';
786- newTalkingState = true;
787- } else {
788- url.pathname = '/api/talkinghead/stop_talking';
789- newTalkingState = false;
790- }
791- try {
792- // Call the Talkinghead API only if the talking state changed.
793- if (newTalkingState !== lastTalkingState) {
794- console.debug(`updateTalkingState: calling ${url.pathname}`);
795- await doExtrasFetch(url);
796- }
797- }
798- catch (error) {
799- // it's ok if not supported
800- }
801- finally {
802- lastTalkingState = newTalkingState;
803- }
804- }
805- catch (error) {
806- // console.log(error);
807- }
808- finally {
809- lastTalkingStateMessage = currentLastMessage.mes;
810- }
811-}
812-
813-/**
814- * Checks whether the current character has a talkinghead image available.
815- * @returns {Promise<boolean>} True if the character has a talkinghead image available, false otherwise.
816- */
817-async function isTalkingHeadAvailable() {
818- let spriteFolderName = getSpriteFolderName();
819-
820- try {
821- await validateImages(spriteFolderName);
822-
823- let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
824- let talkingheadPath = talkingheadObj ? talkingheadObj.path : null;
825-
826- if (talkingheadPath != null) {
827- return true;
828- } else {
829- await unloadTalkingHead();
830- return false;
831- }
832- } catch (err) {
833- return err;
834- }
835-}
836-
837606function getSpriteFolderName(characterMessage = null, characterName = null) {
838607 const context = getContext();
839608 let spriteFolderName = characterName ?? context.name2;
@@ -848,33 +617,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
848617 return spriteFolderName;
849618}
850619
851-function setTalkingHeadState(newState) {
852- console.debug(`expressions: New talkinghead state: ${newState}`);
853- extension_settings.expressions.talkinghead = newState; // Store setting
854- saveSettingsDebounced();
855-
856- if ([EXPRESSION_API.local, EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) {
857- return;
858- }
859-
860- isTalkingHeadAvailable().then(result => {
861- if (result) {
862- //console.log("talkinghead exists!");
863-
864- if (extension_settings.expressions.talkinghead) {
865- loadTalkingHead();
866- } else {
867- unloadTalkingHead();
868- }
869- handleImageChange(); // Change image as needed
870-
871-
872- } else {
873- //console.log("talkinghead does not exist.");
874- }
875- });
876-}
877-
878620function getFolderNameByMessage(message) {
879621 const context = getContext();
880622 let avatarPath = '';
@@ -894,48 +636,55 @@ function getFolderNameByMessage(message) {
894636 return folderName;
895637}
896638
897-async function sendExpressionCall(name, expression, force, vnMode) {
639+/**
898- lastExpression[name.split('/')[0]] = expression;
640+ * Update the expression for the given character.
899- if (!vnMode) {
641+ *
642+ * @param {string} spriteFolderName The character name, optionally with a sprite folder override, e.g. "folder/expression".
643+ * @param {string} expression The expression label, e.g. "amusement", "joy", etc.
644+ * @param {Object} [options] Additional options
645+ * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression.
646+ * @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.
647+ * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
648+ */
649+export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
650+ lastExpression[spriteFolderName.split('/')[0]] = expression;
651+ if (vnMode === null) {
900652 vnMode = isVisualNovelMode();
901653 }
902654
903655 if (vnMode) {
904656 await updateVisualNovelMode(namespriteFolderName, expression);
905657 } else {
906658 setExpression(namespriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
907659 }
908660}
909661
910662async function setSpriteSetCommandsetSpriteFolderCommand(_, folder) {
911663 if (!folder) {
912664 console.log('Clearing sprite set');
913665 folder = '';
914666 }
915667
916668 if (folder.startsWith('/') || folder.startsWith('\\')) {
917- folder = folder.slice(1);
918-
919669 const currentLastMessage = getLastCharacterMessage();
670+ folder = folder.slice(1);
920671 folder = `${currentLastMessage.name}/${folder}`;
921672 }
922673
923674 $('#expression_override').val(folder.trim());
924675 onClickExpressionOverrideButton();
925- // removeExpression();
676+
926- // moduleWorker();
677+ // No need to resend the expression, the folder override will automatically update the currently displayed one.
927- const vnMode = isVisualNovelMode();
928- await sendExpressionCall(folder, lastExpression, true, vnMode);
929678 return '';
930679}
931680
932681async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {
933682 if (!text) {
934683 toastr.warningerror('No text provided');
935684 return '';
936685 }
937686 if (api && !Object.keys(EXPRESSION_API).includes(api)) {
938687 toastr.warningerror('Invalid API provided');
939688 return '';
940689 }
941690
@@ -951,37 +700,69 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
951700 return label;
952701}
953702
954-async function setSpriteSlashCommand(_, spriteId) {
703+/** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
955- if (!spriteId) {
704+async function setSpriteSlashCommand({ type }, searchTerm) {
956- console.log('No sprite id provided');
705+ type ??= 'expression';
706+ searchTerm = searchTerm.trim().toLowerCase();
707+ if (!searchTerm) {
708+ toastr.error(t`No expression or sprite name provided`, t`Set Sprite`);
957709 return '';
958710 }
959711
960- spriteId = spriteId.trim().toLowerCase();
712+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
713+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
714+
715+ let label = searchTerm;
716+
717+ /** @type {string?} */
718+ let spriteFile = null;
961719
962- // In Talkinghead mode, don't check for the existence of the sprite
963- // (emotion names are the same as for sprites, but it only needs "talkinghead.png").
964- const currentLastMessage = getLastCharacterMessage();
965- const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
966- let label = spriteId;
967- if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
968720 await validateImages(spriteFolderName);
969721
970- // Fuzzy search for sprite
722+ // Handle reset as a special term and just reset the sprite via expression call
971- const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
723+ if (searchTerm === RESET_SPRITE_LABEL) {
972- const results = fuse.search(spriteId);
724+ await sendExpressionCall(spriteFolderName, label, { force: true });
973- const spriteItem = results[0]?.item;
725+ return lastExpression[spriteFolderName] ?? '';
726+ }
727+
728+ switch (type) {
729+ case 'expression': {
730+ // Fuzzy search for expression
731+ const existingExpressions = getCachedExpressions().map(x => ({ label: x }));
732+ const results = performFuzzySearch('expression-expressions', existingExpressions, [
733+ { name: 'label', weight: 1 },
734+ ], searchTerm);
735+ const matchedExpression = results[0]?.item;
736+ if (!matchedExpression) {
737+ toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`);
738+ return '';
739+ }
974740
975- if (!spriteItem) {
741+ label = matchedExpression.label;
976- console.log('No sprite found for search term ' + spriteId);
742+ break;
743+ }
744+ case 'sprite': {
745+ // Fuzzy search for sprite file
746+ const sprites = spriteCache[spriteFolderName].map(x => x.files).flat();
747+ const results = performFuzzySearch('expression-expressions', sprites, [
748+ { name: 'title', weight: 1 },
749+ { name: 'fileName', weight: 1 },
750+ ], searchTerm);
751+ const matchedSprite = results[0]?.item;
752+ if (!matchedSprite) {
753+ toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`);
977754 return '';
978755 }
979756
980757 label = spriteItemmatchedSprite.labelexpression;
758+ spriteFile = matchedSprite.fileName;
759+ break;
760+ }
761+ default: throw Error('Invalid sprite set type: ' + type);
981762 }
982763
983- const vnMode = isVisualNovelMode();
764+ await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
984- await sendExpressionCall(spriteFolderName, label, true, vnMode);
765+
985766 return label;
986767}
987768
@@ -999,6 +780,21 @@ function spriteFolderNameFromCharacter(char) {
999780}
1000781
1001782/**
783+ * Generates a unique sprite name by appending an index to the given expression. *
784+ * @param {string} expression - The base expression to be used as the prefix for the sprite name.
785+ * @param {ExpressionImage[]} existingFiles - An array of existing file objects, each containing a fileName property.
786+ * @returns {string} - A unique sprite name with the format "expression-index".
787+ */
788+function generateUniqueSpriteName(expression, existingFiles) {
789+ let index = existingFiles.length;
790+ let newSpriteName;
791+ do {
792+ newSpriteName = `${expression}-${index++}`;
793+ } while (existingFiles.some(file => withoutExtension(file.fileName) === newSpriteName));
794+ return newSpriteName;
795+}
796+
797+/**
1002798 * Slash command callback for /uploadsprite
1003799 *
1004800 * label= is required
@@ -1011,16 +807,29 @@ function spriteFolderNameFromCharacter(char) {
1011807 * @param {object} args
1012808 * @param {string} args.name Character name or avatar key, passed through findChar
1013809 * @param {string} args.label Expression label
1014810 * @param {string} [args.folder=null] SpriteOptional sprite folder path, processed using backslash rules
811+ * @param {string?} [args.spriteName=null] Optional sprite name
1015812 * @param {string} imageUrl Image URI to fetch and upload
1016813 * @returns {Promise<voidstring>} the sprite name
1017814 */
1018815async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
1019816 if (!imageUrl) throw new Error('Image URL is required');
1020817 if (!label || typeof label !== 'string') throw new Error('Expression label is required');{
818+ toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
819+ return '';
820+ }
1021821
1022822 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
1023- if (!label) throw new Error('Expression label must contain at least one letter');
823+ if (!label) {
824+ toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`);
825+ return '';
826+ }
827+
828+ spriteName = spriteName || label;
829+ if (!validateExpressionSpriteName(label, spriteName)) {
830+ toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`);
831+ return '';
832+ }
1024833
1025834 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
1026835 const char = findChar({ name });
@@ -1041,6 +850,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1041850 formData.append('name', folder); // this is the folder or character name
1042851 formData.append('label', label); // this is the expression label
1043852 formData.append('avatar', file); // this is the image file
853+ formData.append('spriteName', spriteName); // this is a redundant comment
1044854
1045855 await handleFileUpload('/api/sprites/upload', formData);
1046856 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
@@ -1048,6 +858,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1048858 console.error(`[${MODULE_NAME}] Error uploading file:`, error);
1049859 throw error;
1050860 }
861+
862+ return spriteName;
1051863}
1052864
1053865/**
@@ -1159,7 +971,7 @@ function getJsonSchema(emotions) {
1159971function onTextGenSettingsReady(args) {
1160972 // Only call if inside an API call
1161973 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {
1162- const emotions = DEFAULT_EXPRESSIONS.filter((e) => e != 'talkinghead');
974+ const emotions = DEFAULT_EXPRESSIONS;
1163975 Object.assign(args, {
1164976 top_k: 1,
1165977 stop: [],
@@ -1177,16 +989,16 @@ function onTextGenSettingsReady(args) {
1177989 * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.
1178990 * @param {object} [options={}] - Optional arguments.
1179991 * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.
1180992 * @returns {Promise<string?>} - The label of the expression.
1181993 */
1182994export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) {
1183995 // Return if text is undefined, saving a costly fetch request
1184996 if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {
1185997 return getFallbackExpression()extension_settings.expressions.fallback_expression;
1186998 }
1187999
11881000 if (extension_settings.expressions.translate && typeof window['globalThis.translate'] === 'function') {
11891001 text = await window['globalThis.translate'](text, 'en');
11901002 }
11911003
11921004 text = sampleClassifyText(text);
@@ -1212,7 +1024,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12121024 await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
12131025 } catch (error) {
12141026 console.warn('No LLM connection. Using fallback expression', error);
12151027 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12161028 }
12171029
12181030 const expressionsList = await getExpressionsList();
@@ -1225,7 +1037,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12251037 case EXPRESSION_API.webllm: {
12261038 if (!isWebLlmSupported()) {
12271039 console.warn('WebLLM is not supported. Using fallback expression');
12281040 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12291041 }
12301042
12311043 const expressionsList = await getExpressionsList();
@@ -1258,9 +1070,9 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
12581070 } break;
12591071 }
12601072 } catch (error) {
12611073 toastr.infoerror('Could not classify expression. Check the console or your backend for more information.');
12621074 console.error(error);
12631075 return getFallbackExpression()extension_settings.expressions.fallback_expression;
12641076 }
12651077}
12661078
@@ -1288,75 +1100,155 @@ function removeExpression() {
12881100 $('#no_chat_expressions').show();
12891101}
12901102
1291-async function validateImages(character, forceRedrawCached) {
1103+/**
1292- if (!character) {
1104+ * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw.
1105+ * @param {string} spriteFolderName - The character sprite folder to validate
1106+ * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before
1107+ */
1108+async function validateImages(spriteFolderName, forceRedrawCached = false) {
1109+ if (!spriteFolderName) {
12931110 return;
12941111 }
12951112
12961113 const labels = await getExpressionsList();
12971114
12981115 if (spriteCache[characterspriteFolderName]) {
12991116 if (forceRedrawCached && $('#image_list').data('name') !== characterspriteFolderName) {
13001117 console.debug('force redrawing character sprites list');
13011118 await drawSpritesList(characterspriteFolderName, labels, spriteCache[characterspriteFolderName]);
13021119 }
13031120
13041121 return;
13051122 }
13061123
13071124 const sprites = await getSpritesList(characterspriteFolderName);
13081125 let validExpressions = await drawSpritesList(characterspriteFolderName, labels, sprites);
13091126 spriteCache[characterspriteFolderName] = validExpressions;
1127+}
1128+
1129+/**
1130+ * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting
1131+ * @param {{ path: string, label: string }} sprite
1132+ * @returns {ExpressionImage}
1133+ */
1134+function getExpressionImageData(sprite) {
1135+ const fileName = sprite.path.split('/').pop().split('?')[0];
1136+ const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
1137+ return {
1138+ expression: sprite.label,
1139+ fileName: fileName,
1140+ title: fileNameWithoutExtension,
1141+ imageSrc: sprite.path,
1142+ type: 'success',
1143+ isCustom: extension_settings.expressions.custom?.includes(sprite.label),
1144+ };
13101145}
13111146
1312-async function drawSpritesList(character, labels, sprites) {
1147+/**
1148+ * Populate the character expression list with sprites for the given character.
1149+ * @param {string} spriteFolderName - The name of the character to populate the list for
1150+ * @param {string[]} labels - An array of expression labels that are valid
1151+ * @param {Expression[]} sprites - An array of sprites
1152+ * @returns {Promise<Expression[]>} An array of valid expression labels
1153+ */
1154+async function drawSpritesList(spriteFolderName, labels, sprites) {
1155+ /** @type {Expression[]} */
13131156 let validExpressions = [];
1157+
13141158 $('#no_chat_expressions').hide();
13151159 $('#open_chat_expressions').show();
13161160 $('#image_list').empty();
13171161 $('#image_list').data('name', characterspriteFolderName);
13181162 $('#image_list_header_name').text(characterspriteFolderName);
13191163
13201164 if (!Array.isArray(labels)) {
13211165 return [];
13221166 }
13231167
13241168 for (const itemexpression of labels.sort()) {
1325- const sprite = sprites.find(x => x.label == item);
1169+ const isCustom = extension_settings.expressions.custom?.includes(expression);
1326- const isCustom = extension_settings.expressions.custom.includes(item);
1170+ const images = sprites
1327-
1171+ .filter(s => s.label === expression)
1328- if (sprite) {
1172+ .map(s => s.files)
13291173 validExpressions.pushflat(sprite);
1330- const listItem = await getListItem(item, sprite.path, 'success', isCustom);
1174+
1175+ if (images.length === 0) {
1176+ const listItem = await getListItem(expression, {
1177+ isCustom,
1178+ images: [getPlaceholderImage(expression, isCustom)],
1179+ });
13311180 $('#image_list').append(listItem);
1181+ continue;
13321182 }
1333- else {
1183+
1334- const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom);
1184+ validExpressions.push({ label: expression, files: images });
1185+
1186+ // Render main = first file, additional = rest
1187+ let listItem = await getListItem(expression, {
1188+ isCustom,
1189+ images,
1190+ });
13351191 $('#image_list').append(listItem);
13361192 }
1337- }
13381193 return validExpressions;
13391194}
13401195
13411196/**
13421197 * Renders a list item template for the expressions list.
13431198 * @param {string} itemexpression Expression name
13441199 * @param {stringobject} imageSrc Pathargs toArguments imageobject
1345- * @param {'success' | 'failure'} textClass 'success' or 'failure'
1200+ * @param {ExpressionImage[]} [args.images] Array of image objects
13461201 * @param {boolean} [args.isCustom=false] If expression is added by user
13471202 * @returns {Promise<string>} Rendered list item template
13481203 */
13491204async function getListItem(itemexpression, imageSrc,{ textClassimages, isCustom = false } = {}) {
13501205 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { itemexpression, imageSrcimages, textClass,isCustom: isCustom ?? false });
13511206}
13521207
1208+/**
1209+ * Fetches and processes the list of sprites for a given character name.
1210+ * Retrieves sprite data from the server and organizes it into labeled groups.
1211+ *
1212+ * @param {string} name - The character name to fetch sprites for
1213+ * @returns {Promise<Expression[]>} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data
1214+ */
1215+
13531216async function getSpritesList(name) {
13541217 console.debug('getting sprites list');
13551218
13561219 try {
13571220 const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
1221+ /** @type {{ label: string, path: string }[]} */
13581222 let sprites = result.ok ? (await result.json()) : [];
1359- return sprites;
1223+
1224+ /** @type {Expression[]} */
1225+ const grouped = sprites.reduce((acc, sprite) => {
1226+ const imageData = getExpressionImageData(sprite);
1227+ let existingExpression = acc.find(exp => exp.label === sprite.label);
1228+ if (existingExpression) {
1229+ existingExpression.files.push(imageData);
1230+ } else {
1231+ acc.push({ label: sprite.label, files: [imageData] });
1232+ }
1233+
1234+ return acc;
1235+ }, []);
1236+
1237+ // Sort the sprites for each expression alphabetically, but keep the main expression file at the front
1238+ for (const expression of grouped) {
1239+ expression.files.sort((a, b) => {
1240+ if (a.title === expression.label) return -1;
1241+ if (b.title === expression.label) return 1;
1242+ return a.title.localeCompare(b.title);
1243+ });
1244+
1245+ // Mark all besides the first sprite as 'additional'
1246+ for (let i = 1; i < expression.files.length; i++) {
1247+ expression.files[i].type = 'additional';
1248+ }
1249+ }
1250+
1251+ return grouped;
13601252 }
13611253 catch (err) {
13621254 console.log(err);
@@ -1395,17 +1287,31 @@ async function renderFallbackExpressionPicker() {
13951287 const defaultPicker = $('#expression_fallback');
13961288 defaultPicker.empty();
13971289
1398- const fallbackExpression = getFallbackExpression();
1290+
1291+ addOption(OPTION_NO_FALLBACK, '[ No fallback ]', !extension_settings.expressions.fallback_expression);
1292+ addOption(OPTION_EMOJI_FALLBACK, '[ Default emojis ]', !!extension_settings.expressions.showDefault);
13991293
14001294 for (const expression of expressions) {
1295+ addOption(expression, expression, expression == extension_settings.expressions.fallback_expression);
1296+ }
1297+
1298+ /** @type {(value: string, label: string, isSelected: boolean) => void} */
1299+ function addOption(value, label, isSelected) {
14011300 const option = document.createElement('option');
14021301 option.value = expressionvalue;
14031302 option.text = expressionlabel;
14041303 option.selected = expression == fallbackExpressionisSelected;
14051304 defaultPicker.append(option);
14061305 }
14071306}
14081307
1308+/**
1309+ * Retrieves a unique list of cached expressions.
1310+ * Combines the default expressions list with custom user-defined expressions.
1311+ *
1312+ * @returns {string[]} An array of unique expression labels
1313+ */
1314+
14091315function getCachedExpressions() {
14101316 if (!Array.isArray(expressionsList)) {
14111317 return [];
@@ -1463,7 +1369,7 @@ export async function getExpressionsList() {
14631369 }
14641370
14651371 // If there was no specific list, or an error, just return the default expressions
14661372 expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice();
14671373 return expressionsList;
14681374 }
14691375
@@ -1471,38 +1377,88 @@ export async function getExpressionsList() {
14711377 return [...result, ...extension_settings.expressions.custom].filter(onlyUnique);
14721378}
14731379
1474-async function setExpression(character, expression, force) {
1380+/**
1475- if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) {
1381+ * Selects a sprite from the given sprite folder for the given expression.
1476- console.debug('entered setExpressions');
1382+ *
1477- await validateImages(character);
1383+ * If multiple sprites are allowed for the expression, it will randomly select one.
1384+ * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same.
1385+ * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one.
1386+ *
1387+ * @param {string} spriteFolderName - The name of the sprite folder
1388+ * @param {string} expression - The expression to find the sprite for
1389+ * @param {object} [options] - Options to select the sprite
1390+ * @param {string} [options.prevExpressionSrc=null] - The source of the previous expression
1391+ * @param {string} [options.overrideSpriteFile=null] - The file name of the sprite to select
1392+ * @returns {ExpressionImage?} - The selected sprite
1393+ */
1394+function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) {
1395+ if (!spriteCache[spriteFolderName]) return null;
1396+ if (expression === RESET_SPRITE_LABEL) return null;
1397+
1398+ // Search for sprites of that expression - or fallback expression sprites if enabled
1399+ let sprite = spriteCache[spriteFolderName].find(x => x.label === expression);
1400+ if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) {
1401+ sprite = spriteCache[spriteFolderName].find(x => x.label === extension_settings.expressions.fallback_expression);
1402+ console.debug('Expression', expression, 'not found. Using fallback expression', extension_settings.expressions.fallback_expression);
1403+ }
1404+ if (!(sprite?.files.length > 0)) return null;
1405+
1406+ let spriteFile = sprite.files[0];
1407+
1408+ // If a specific sprite file should be set, we are looking it up here
1409+ if (overrideSpriteFile) {
1410+ const searched = sprite.files.find(x => x.fileName === overrideSpriteFile);
1411+ if (searched) spriteFile = searched;
1412+ else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`);
1413+ }
1414+ // Else calculate next expression, if multiple are allowed
1415+ else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
1416+ let possibleFiles = sprite.files;
1417+ if (extension_settings.expressions.rerollIfSame) {
1418+ possibleFiles = possibleFiles.filter(x => !prevExpressionSrc || x.imageSrc !== prevExpressionSrc);
1419+ }
1420+ spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)];
1421+ }
1422+
1423+ return spriteFile;
1424+
1425+}
1426+
1427+/**
1428+ * Set the expression of a character.
1429+ * @param {string} spriteFolderName - The name of the character (folder name - can also be a costume override)
1430+ * @param {string} expression - The expression or sprite name to set
1431+ * @param {Object} options - Optional parameters
1432+ * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on
1433+ * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
1434+ * @returns {Promise<void>} A promise that resolves when the expression has been set.
1435+ */
1436+async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) {
1437+ await validateImages(spriteFolderName);
14781438 const img = $('img.expression');
14791439 const prevExpressionSrc = img.attr('src');
14801440 const expressionClone = img.clone();
14811441
1482- const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
1442+ const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile });
1483- console.debug('checking for expression images to show..');
1443+ if (spriteFile) {
1484- if (sprite) {
1485- console.debug('setting expression from character images folder');
1486-
14871444 if (force && isVisualNovelMode()) {
14881445 const context = getContext();
14891446 const group = context.groups.find(x => x.id === context.groupId);
14901447
1491- for (const member of group.members) {
1448+ // If it's a folder, make sure we find the group member based on the actual name
1492- const groupMember = context.characters.find(x => x.avatar === member);
1449+ const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName;
1493-
1494- if (!groupMember) {
1495- continue;
1496- }
14971450
1498- if (groupMember.name == character) {
1451+ const groupMember = group.members
1499- await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);
1452+ .map(member => context.characters.find(x => x.avatar === member))
1453+ .find(groupMember => groupMember && groupMember.name === memberName);
1454+ if (groupMember) {
1455+ await setImage($(`.expression-holder[data-avatar="${groupMember.avatar}"] img`), spriteFile.imageSrc);
15001456 return;
15011457 }
15021458 }
1503- }
1459+
15041460 //only swap expressions when necessary
15051461 if (prevExpressionSrc !== spritespriteFile.pathimageSrc
15061462 && !img.hasClass('expression-animating')) {
15071463 //clone expression
15081464 expressionClone.addClass('expression-clone');
@@ -1510,7 +1466,12 @@ async function setExpression(character, expression, force) {
15101466 //must be made invisible to start because they share the same Z-index
15111467 expressionClone.attr('id', '').css({ opacity: 0 });
15121468 //add new sprite path to clone src
15131469 expressionClone.attr('src', spritespriteFile.pathimageSrc);
1470+ //set relevant data tags
1471+ expressionClone.attr('data-sprite-folder-name', spriteFolderName);
1472+ expressionClone.attr('data-expression', expression);
1473+ expressionClone.attr('data-sprite-filename', spriteFile.fileName);
1474+ expressionClone.attr('title', expression);
15141475 //add invisible clone to html
15151476 expressionClone.appendTo($('#expression-holder'));
15161477
@@ -1552,80 +1513,85 @@ async function setExpression(character, expression, force) {
15521513 expressionHolder.css('min-height', 100);
15531514 });
15541515
1555-
15561516 expressionClone.removeClass('expression-clone');
15571517
15581518 expressionClone.removeClass('default');
15591519 expressionClone.off('error');
15601520 expressionClone.on('error', function (error) {
15611521 console.debug('Expression image error', spritespriteFile.pathimageSrc, error);
15621522 $(this).attr('src', '');
15631523 $(this).off('error');
15641524 if (force && extension_settings.expressions.showDefault) {
1565- setDefault();
1525+ setDefaultEmojiForImage(img, expression);
15661526 }
15671527 });
15681528 }
1529+
1530+ console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
15691531 }
15701532 else {
1571- if (extension_settings.expressions.showDefault) {
1533+ img.attr('data-sprite-folder-name', spriteFolderName);
1572- setDefault();
1573- }
1574- }
15751534
1576- function setDefault() {
1535+ img.off('error');
1577- console.debug('setting default');
1578- const defImgUrl = `/img/default-expressions/${expression}.png`;
1579- //console.log(defImgUrl);
1580- img.attr('src', defImgUrl);
1581- img.addClass('default');
1582- }
1583- document.getElementById('expression-holder').style.display = '';
15841536
1537+ if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) {
1538+ setDefaultEmojiForImage(img, expression);
15851539 } else {
1586- // Set the Talkinghead emotion to the specified expression
1540+ setNoneForImage(img, expression);
1587- // TODO: For now, Talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode.
1588- try {
1589- let result = await isTalkingHeadAvailable();
1590- if (result) {
1591- const url = new URL(getApiUrl());
1592- url.pathname = '/api/talkinghead/set_emotion';
1593- await doExtrasFetch(url, {
1594- method: 'POST',
1595- headers: {
1596- 'Content-Type': 'application/json',
1597- },
1598- body: JSON.stringify({ emotion_name: expression }),
1599- });
1600- }
16011541 }
1602- catch (error) {
1542+ console.debug('Expression unset - No sprite found', { expression: expression });
1603- // `set_emotion` is not present in old versions, so let it 404.
16041543 }
16051544
1606- try {
1545+ document.getElementById('expression-holder').style.display = '';
1607- // Find the <img> element with id="expression-image" and class="expression"
1608- const imgElement = document.querySelector('img#expression-image.expression');
1609- //console.log("searching");
1610- if (imgElement && imgElement instanceof HTMLImageElement) {
1611- //console.log("setting value");
1612- imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
1613- }
16141546}
1615- catch (error) {
1547+
1616- //console.log("The fetch failed!");
1548+/**
1549+ * Sets the default expression image for the given image element and expression
1550+ * @param {JQuery<HTMLElement>} img - The image element to set the default expression for
1551+ * @param {string} expression - The expression label to use for the default image
1552+ */
1553+function setDefaultEmojiForImage(img, expression) {
1554+ if (extension_settings.expressions.custom?.includes(expression)) {
1555+ console.debug(`Can't set default emoji for a custom expression (${expression}). setting to ${DEFAULT_FALLBACK_EXPRESSION} instead.`);
1556+ expression = DEFAULT_FALLBACK_EXPRESSION;
16171557 }
1558+
1559+ const defImgUrl = `/img/default-expressions/${expression}.png`;
1560+ img.attr('src', defImgUrl);
1561+ img.attr('data-expression', expression);
1562+ img.attr('data-sprite-filename', null);
1563+ img.attr('title', expression);
1564+ img.addClass('default');
16181565}
1566+
1567+/**
1568+ * Sets the image element to display no expression by clearing its source attribute.
1569+ * @param {JQuery<HTMLElement>} img - The image element to clear the expression for
1570+ * @param {string} expression - The expression label to use
1571+ */
1572+function setNoneForImage(img, expression) {
1573+ img.attr('src', '');
1574+ img.attr('data-expression', expression);
1575+ img.attr('data-sprite-filename', null);
1576+ img.attr('title', expression);
1577+ img.removeClass('default');
16191578}
16201579
16211580function onClickExpressionImage() {
1622- const expression = $(this).attr('id');
1581+ // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label
1623- setSpriteSlashCommand({}, expression);
1582+ if ($(this).attr('data-expression-type') === 'failure') {
1583+ const label = $(this).attr('data-expression');
1584+ setSpriteSlashCommand({ type: 'expression' }, label);
1585+ return;
1586+ }
1587+
1588+ const spriteFile = $(this).attr('data-filename');
1589+ setSpriteSlashCommand({ type: 'sprite' }, spriteFile);
16241590}
16251591
16261592async function onClickExpressionAddCustom() {
16271593 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');
16281594 let expressionName = await callPopupPopup.show.input(templatenull, 'input'template);
16291595
16301596 if (!expressionName) {
16311597 console.debug('No custom expression name provided');
@@ -1636,19 +1602,15 @@ async function onClickExpressionAddCustom() {
16361602
16371603 // a-z, 0-9, dashes and underscores only
16381604 if (!/^[a-z0-9-_]+$/.test(expressionName)) {
16391605 toastr.infowarning('Invalid custom expression name provided', 'Add Custom Expression');
16401606 return;
16411607 }
1642-
1608+ if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) {
1643- // Check if expression name already exists in default expressions
1609+ toastr.warning('Expression name already exists', 'Add Custom Expression');
1644- if (DEFAULT_EXPRESSIONS.includes(expressionName)) {
1645- toastr.info('Expression name already exists');
16461610 return;
16471611 }
1648-
1649- // Check if expression name already exists in custom expressions
16501612 if (extension_settings.expressions.custom.includes(expressionName)) {
16511613 toastr.infowarning('Custom expression already exists', 'Add Custom Expression');
16521614 return;
16531615 }
16541616
@@ -1665,14 +1627,15 @@ async function onClickExpressionAddCustom() {
16651627
16661628async function onClickExpressionRemoveCustom() {
16671629 const selectedExpression = String($('#expression_custom').val());
1630+ const noCustomExpressions = extension_settings.expressions.custom.length === 0;
16681631
16691632 if (!selectedExpression || noCustomExpressions) {
16701633 console.debug('No custom expression selected');
16711634 return;
16721635 }
16731636
16741637 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });
16751638 const confirmation = await callPopupPopup.show.confirm(templatenull, 'confirm'template);
16761639
16771640 if (!confirmation) {
16781641 console.debug('Custom expression removal cancelled');
@@ -1682,8 +1645,8 @@ async function onClickExpressionRemoveCustom() {
16821645 // Remove custom expression from settings
16831646 const index = extension_settings.expressions.custom.indexOf(selectedExpression);
16841647 extension_settings.expressions.custom.splice(index, 1);
16851648 if (selectedExpression == getFallbackExpression()extension_settings.expressions.fallback_expression) {
16861649 toastr.warning(`Deleted custom expression '${selectedExpression}' that was also selected as the fallback expression.\nFallback expression has been reset to '${DEFAULT_FALLBACK_EXPRESSION}'.`, 'Remove Custom Expression');
16871650 extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;
16881651 }
16891652 await renderAdditionalExpressionSettings();
@@ -1707,12 +1670,35 @@ function onExpressionApiChanged() {
17071670 }
17081671}
17091672
17101673async function onExpressionFallbackChanged() {
1711- const expression = this.value;
1674+ /** @type {HTMLSelectElement} */
1712- if (expression) {
1675+ const select = this;
1713- extension_settings.expressions.fallback_expression = expression;
1676+ const selectedValue = select.value;
1714- saveSettingsDebounced();
1677+
1678+ switch (selectedValue) {
1679+ case OPTION_NO_FALLBACK:
1680+ extension_settings.expressions.fallback_expression = null;
1681+ extension_settings.expressions.showDefault = false;
1682+ break;
1683+ case OPTION_EMOJI_FALLBACK:
1684+ extension_settings.expressions.fallback_expression = null;
1685+ extension_settings.expressions.showDefault = true;
1686+ break;
1687+ default:
1688+ extension_settings.expressions.fallback_expression = selectedValue;
1689+ extension_settings.expressions.showDefault = false;
1690+ break;
1691+ }
1692+
1693+ const img = $('img.expression');
1694+ const spriteFolderName = img.attr('data-sprite-folder-name');
1695+ const expression = img.attr('data-expression');
1696+
1697+ if (spriteFolderName && expression) {
1698+ await sendExpressionCall(spriteFolderName, expression, { force: true });
17151699 }
1700+
1701+ saveSettingsDebounced();
17161702}
17171703
17181704async function handleFileUpload(url, formData) {
@@ -1739,34 +1725,111 @@ async function handleFileUpload(url, formData) {
17391725 }
17401726}
17411727
1728+/**
1729+ * Removes the file extension from a file name
1730+ * @param {string} fileName The file name to remove the extension from
1731+ * @returns {string} The file name without the extension
1732+ */
1733+function withoutExtension(fileName) {
1734+ return fileName.replace(/\.[^/.]+$/, '');
1735+}
1736+
1737+function validateExpressionSpriteName(expression, spriteName) {
1738+ const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1739+ const validFileName = filenameValidationRegex.test(spriteName);
1740+ return validFileName;
1741+}
1742+
17421743async function onClickExpressionUpload(event) {
17431744 // Prevents the expression from being set
17441745 event.stopPropagation();
17451746
17461747 const idexpressionListItem = $(this).closest('.expression_list_item').attr('id');
1748+
1749+ const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null;
1750+ const expression = expressionListItem.data('expression');
17471751 const name = $('#image_list').data('name');
17481752
17491753 const handleExpressionUploadChange = async (e) => {
17501754 const file = e.target.files[0];
17511755
17521756 if (!file || !file.name) {
1757+ console.debug('No valid file selected');
1758+ return;
1759+ }
1760+
1761+ const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || [];
1762+
1763+ let spriteName = expression;
1764+
1765+ if (extension_settings.expressions.allowMultiple) {
1766+ const matchesExisting = existingFiles.some(x => x.fileName === file.name);
1767+ const fileNameWithoutExtension = withoutExtension(file.name);
1768+ const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
1769+
1770+ // If there is no expression yet and it's a valid expression, we just take it
1771+ if (!clickedFileName && validFileName) {
1772+ spriteName = fileNameWithoutExtension;
1773+ }
1774+ // If the filename matches the one that was clicked, we just take it and replace it
1775+ else if (clickedFileName === file.name) {
1776+ spriteName = fileNameWithoutExtension;
1777+ }
1778+ // If it's a valid filename and there's no existing file with the same name, we just take it
1779+ else if (!matchesExisting && validFileName) {
1780+ spriteName = fileNameWithoutExtension;
1781+ }
1782+ else {
1783+ /** @type {import('../../popup.js').CustomPopupButton[]} */
1784+ const customButtons = [];
1785+ if (clickedFileName) {
1786+ customButtons.push({
1787+ text: t`Replace Existing`,
1788+ result: POPUP_RESULT.NEGATIVE,
1789+ action: () => {
1790+ console.debug('Replacing existing sprite');
1791+ spriteName = withoutExtension(clickedFileName);
1792+ },
1793+ });
1794+ }
1795+
1796+ spriteName = null;
1797+ const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles);
1798+
1799+ const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
1800+
1801+ const input = await Popup.show.input(t`Upload Expression Sprite`, message,
1802+ suggestedSpriteName, { customButtons: customButtons });
1803+
1804+ if (input) {
1805+ if (!validateExpressionSpriteName(expression, input)) {
1806+ toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
1807+ return;
1808+ }
1809+ spriteName = input;
1810+ }
1811+ }
1812+ } else {
1813+ spriteName = withoutExtension(clickedFileName);
1814+ }
1815+
1816+ if (!spriteName) {
1817+ toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`);
1818+ // Reset the input
1819+ e.target.form.reset();
17531820 return;
17541821 }
17551822
17561823 const formData = new FormData();
17571824 formData.append('name', name);
17581825 formData.append('label', idexpression);
17591826 formData.append('avatar', file);
1827+ formData.append('spriteName', spriteName);
17601828
17611829 await handleFileUpload('/api/sprites/upload', formData);
17621830
17631831 // Reset the input
17641832 e.target.form.reset();
1765-
1766- // In Talkinghead mode, when a new talkinghead image is uploaded, refresh the live char.
1767- if (id === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1768- await loadTalkingHead();
1769- }
17701833 };
17711834
17721835 $('#expression_upload')
@@ -1822,8 +1885,9 @@ async function onClickExpressionOverrideButton() {
18221885 inApiCall = true;
18231886 $('#visual-novel-wrapper').empty();
18241887 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
1888+ const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
18251889 const expression = await getExpressionLabel(currentLastMessage.mes);
18261890 await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, { force: true });
18271891 forceUpdateVisualNovelMode();
18281892 } catch (error) {
18291893 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
@@ -1849,7 +1913,7 @@ async function onClickExpressionOverrideRemoveAllButton() {
18491913 const currentLastMessage = getLastCharacterMessage();
18501914 await validateImages(currentLastMessage.name, true);
18511915 const expression = await getExpressionLabel(currentLastMessage.mes);
18521916 await sendExpressionCall(currentLastMessage.name, expression, { force: true });
18531917 forceUpdateVisualNovelMode();
18541918
18551919 console.debug(extension_settings.expressionOverrides);
@@ -1872,16 +1936,13 @@ async function onClickExpressionUploadPackButton() {
18721936 formData.append('name', name);
18731937 formData.append('avatar', file);
18741938
1939+ const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 });
18751940 const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
1941+ toastr.clear(uploadToast);
18761942 toastr.success(`Uploaded ${count} image(s) for ${name}`);
18771943
18781944 // Reset the input
18791945 e.target.form.reset();
1880-
1881- // In Talkinghead mode, refresh the live char.
1882- if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1883- await loadTalkingHead();
1884- }
18851946 };
18861947
18871948 $('#expression_upload_pack')
@@ -1894,20 +1955,28 @@ async function onClickExpressionDelete(event) {
18941955 // Prevents the expression from being set
18951956 event.stopPropagation();
18961957
1897- const confirmation = await callPopup('<h3>Are you sure?</h3>Once deleted, it\'s gone forever!', 'confirm');
1958+ const expressionListItem = $(this).closest('.expression_list_item');
1959+ const expression = expressionListItem.data('expression');
1960+
1961+ if (expressionListItem.attr('data-expression-type') === 'failure') {
1962+ return;
1963+ }
18981964
1965+ 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!`
1966+ + '<br /><br />'
1967+ + t`Expression:` + ' <tt>' + expressionListItem.attr('data-filename') + '</tt>');
18991968 if (!confirmation) {
19001969 return;
19011970 }
19021971
19031972 const idfileName = $(this).closestwithoutExtension('.expression_list_item')expressionListItem.attr('iddata-filename'));
19041973 const name = $('#image_list').data('name');
19051974
19061975 try {
19071976 await fetch('/api/sprites/delete', {
19081977 method: 'POST',
19091978 headers: getRequestHeaders(),
19101979 body: JSON.stringify({ name, label: idexpression, spriteName: fileName }),
19111980 });
19121981 } catch (error) {
19131982 toastr.error('Failed to delete image. Try again later.');
@@ -1984,6 +2053,16 @@ function migrateSettings() {
19842053 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
19852054 saveSettingsDebounced();
19862055 }
2056+
2057+ if (extension_settings.expressions.allowMultiple === undefined) {
2058+ extension_settings.expressions.allowMultiple = true;
2059+ saveSettingsDebounced();
2060+ }
2061+
2062+ if (extension_settings.expressions.showDefault && extension_settings.expressions.fallback_expression !== undefined) {
2063+ extension_settings.expressions.showDefault = false;
2064+ saveSettingsDebounced();
2065+ }
19872066}
19882067
19892068(async function () {
@@ -2010,13 +2089,19 @@ function migrateSettings() {
20102089 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
20112090 $('#expressions_container').append(template);
20122091 $('#expression_override_button').on('click', onClickExpressionOverrideButton);
2013- $('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
20142092 $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
2015- $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
20162093 $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {
20172094 extension_settings.expressions.translate = !!$(this).prop('checked');
20182095 saveSettingsDebounced();
20192096 });
2097+ $('#expressions_allow_multiple').prop('checked', extension_settings.expressions.allowMultiple).on('input', function () {
2098+ extension_settings.expressions.allowMultiple = !!$(this).prop('checked');
2099+ saveSettingsDebounced();
2100+ });
2101+ $('#expressions_reroll_if_same').prop('checked', extension_settings.expressions.rerollIfSame).on('input', function () {
2102+ extension_settings.expressions.rerollIfSame = !!$(this).prop('checked');
2103+ saveSettingsDebounced();
2104+ });
20202105 $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
20212106 $(document).on('dragstart', '.expression', (e) => {
20222107 e.preventDefault();
@@ -2025,21 +2110,15 @@ function migrateSettings() {
20252110 $(document).on('click', '.expression_list_item', onClickExpressionImage);
20262111 $(document).on('click', '.expression_list_upload', onClickExpressionUpload);
20272112 $(document).on('click', '.expression_list_delete', onClickExpressionDelete);
20282113 $(window).on('resize', () => updateVisualNovelModeDebounced());
20292114 $('#open_chat_expressions').hide();
20302115
2031- $('#image_type_toggle').on('click', function () {
2032- if (this instanceof HTMLInputElement) {
2033- setTalkingHeadState(this.checked);
2034- }
2035- });
2036-
20372116 await renderAdditionalExpressionSettings();
20382117 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);
20392118 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
20402119 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');
20412120 $('#expression_llm_prompt').on('input', function () {
20422121 extension_settings.expressions.llmPrompt = String($(this).val());
20432122 saveSettingsDebounced();
20442123 });
20452124 $('#expression_llm_prompt_restore').on('click', function () {
@@ -2054,34 +2133,6 @@ function migrateSettings() {
20542133 $('#expression_api').on('change', onExpressionApiChanged);
20552134 }
20562135
2057- // Pause Talkinghead to save resources when the ST tab is not visible or the window is minimized.
2058- // We currently do this via loading/unloading. Could be improved by adding new pause/unpause endpoints to Extras.
2059- document.addEventListener('visibilitychange', function (event) {
2060- let pageIsVisible;
2061- if (document.hidden) {
2062- console.debug('expressions: SillyTavern is now hidden');
2063- pageIsVisible = false;
2064- } else {
2065- console.debug('expressions: SillyTavern is now visible');
2066- pageIsVisible = true;
2067- }
2068-
2069- if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
2070- isTalkingHeadAvailable().then(result => {
2071- if (result) {
2072- if (pageIsVisible) {
2073- loadTalkingHead();
2074- } else {
2075- unloadTalkingHead();
2076- }
2077- handleImageChange(); // Change image as needed
2078- } else {
2079- //console.log("talkinghead does not exist.");
2080- }
2081- });
2082- }
2083- });
2084-
20852136 addExpressionImage();
20862137 addVisualNovelMode();
20872138 migrateSettings();
@@ -2090,11 +2141,6 @@ function migrateSettings() {
20902141 const updateFunction = wrapper.update.bind(wrapper);
20912142 setInterval(updateFunction, UPDATE_INTERVAL);
20922143 moduleWorker();
2093- // For setting the Talkinghead talking animation on/off quickly enough for realtime use, we need another timer on a shorter schedule.
2094- const wrapperTalkingState = new ModuleWorkerWrapper(updateTalkingState);
2095- const updateTalkingStateFunction = wrapperTalkingState.update.bind(wrapperTalkingState);
2096- setInterval(updateTalkingStateFunction, TALKINGCHECK_UPDATE_INTERVAL);
2097- updateTalkingState();
20982144 dragElement($('#expression-holder'));
20992145 eventSource.on(event_types.CHAT_CHANGED, () => {
21002146 // character changed
@@ -2108,110 +2154,137 @@ function migrateSettings() {
21082154 imgElement.src = '';
21092155 }
21102156
2111- //set checkbox to global var
2112- $('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead);
2113- if (extension_settings.expressions.talkinghead) {
2114- setTalkingHeadState(extension_settings.expressions.talkinghead);
2115- }
2116-
21172157 setExpressionOverrideHtml();
21182158
21192159 if (isVisualNovelMode()) {
21202160 $('#visual-novel-wrapper').empty();
21212161 }
21222162
21232163 updateFunction({ newChat: true });
21242164 });
21252165 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
21262166 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
2127- eventSource.on(event_types.EXTRAS_CONNECTED, () => {
2128- if (extension_settings.expressions.talkinghead) {
2129- setTalkingHeadState(extension_settings.expressions.talkinghead);
2130- }
2131- });
21322167
21332168 const localEnumProviders = {
21342169 expressions: () => getCachedExpressions().map(expression => {
2170+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2171+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2172+ const expressions = getCachedExpressions();
2173+ return expressions.map(expression => {
2174+ const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
21352175 const isCustom = extension_settings.expressions.custom?.includes(expression);
2136- return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D');
2176+ const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
2137- }),
2177+ spriteCount > 1 ? `${spriteCount} sprites` : null;
2178+ return new SlashCommandEnumValue(expression,
2179+ subtitle,
2180+ isCustom ? enumTypes.name : enumTypes.enum,
2181+ isCustom ? 'C' : 'D');
2182+ });
2183+ },
2184+ sprites: () => {
2185+ const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2186+ const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2187+ const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? [];
2188+ return sprites.map(x => {
2189+ return new SlashCommandEnumValue(x.title,
2190+ x.title !== x.expression ? x.expression : null,
2191+ x.isCustom ? enumTypes.name : enumTypes.enum,
2192+ x.isCustom ? 'C' : 'D');
2193+ });
2194+ },
21382195 };
21392196
21402197 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21412198 name: 'spriteexpression-set',
21422199 aliases: ['sprite', 'emote'],
21432200 callback: setSpriteSlashCommand,
2201+ namedArgumentList: [
2202+ SlashCommandNamedArgument.fromProps({
2203+ name: 'type',
2204+ description: 'Whether to set an expression or a specific sprite.',
2205+ typeList: [ARGUMENT_TYPE.STRING],
2206+ isRequired: false,
2207+ defaultValue: 'expression',
2208+ enumList: ['expression', 'sprite'],
2209+ }),
2210+ ],
21442211 unnamedArgumentList: [
21452212 SlashCommandArgument.fromProps({
21462213 description: 'spriteIdexpression label to set',
21472214 typeList: [ARGUMENT_TYPE.STRING],
21482215 isRequired: true,
2149- enumProvider: localEnumProviders.expressions,
2216+ enumProvider: (executor, _) => {
2217+ // Check if command is used to set a sprite, then use those enums
2218+ const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression';
2219+ if (type == 'sprite') return localEnumProviders.sprites();
2220+ else return [
2221+ ...localEnumProviders.expressions(),
2222+ new SlashCommandEnumValue(RESET_SPRITE_LABEL, 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'),
2223+ ];
2224+ },
21502225 }),
21512226 ],
21522227 helpString: 'Force sets the spriteexpression for the current character.',
21532228 returns: 'theThe currently set spriteexpression label after setting it.',
21542229 }));
21552230 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21562231 name: 'spriteoverrideexpression-folder-override',
21572232 aliases: ['spriteoverride', 'costume'],
21582233 callback: setSpriteSetCommandsetSpriteFolderCommand,
21592234 unnamedArgumentList: [
21602235 new SlashCommandArgument(
21612236 'optional folder', [ARGUMENT_TYPE.STRING], false,
21622237 ),
21632238 ],
2164- 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.',
2239+ helpString: `
2240+ <div>
2241+ Sets an override sprite folder for the current character.<br />
2242+ In groups, this will apply to the character who last sent a message.
2243+ </div>
2244+ <div>
2245+ 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.
2246+ </div>
2247+ `,
21652248 }));
21662249 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21672250 name: 'lastspriteexpression-last',
2168- callback: (_, name) => {
2251+ aliases: ['lastsprite'],
2252+ /** @type {(args: object, name: string) => Promise<string>} */
2253+ callback: async (_, name) => {
21692254 if (typeof name !== 'string') throw new Error('name must be a string');
2255+ if (!name) {
2256+ if (selected_group) {
2257+ toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`);
2258+ return '';
2259+ }
2260+ name = characters[this_chid]?.avatar;
2261+ }
2262+
21702263 const char = findChar({ name: name });
2264+ if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
2265+
21712266 const sprite = lastExpression[char?.name ?? name] ?? '';
21722267 return sprite;
21732268 },
21742269 returns: 'the last set sprite / expression for the named character.',
21752270 unnamedArgumentList: [
21762271 SlashCommandArgument.fromProps({
21772272 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)',
21782273 typeList: [ARGUMENT_TYPE.STRING],
2179- isRequired: true,
21802274 enumProvider: commonEnumProviders.characters('character'),
21812275 }),
21822276 ],
21832277 helpString: 'Returns the last set sprite / expression for the named character.',
2184- }));
2185- SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2186- name: 'th',
2187- callback: toggleTalkingHeadCommand,
2188- aliases: ['talkinghead'],
2189- helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
2190- returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
21912278 }));
21922279 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
21932280 name: 'classifyexpression-expressionslist',
21942281 aliases: ['expressions'],
2282+ /** @type {(args: {return: string}) => Promise<string>} */
21952283 callback: async (args) => {
2284+ let returnType =
21962285 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2197- // @ts-ignore
2286+ (args.return);
2198- let returnType = args.return;
2199-
2200- // Old legacy return type handling
2201- if (args.format) {
2202- toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning');
2203- const type = String(args?.format).toLowerCase().trim();
2204- switch (type) {
2205- case 'json':
2206- returnType = 'object';
2207- break;
2208- default:
2209- returnType = 'pipe';
2210- break;
2211- }
2212- }
22132287
2214- // Now the actual new return type handling
22152288 const list = await getExpressionsList();
22162289
22172290 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
@@ -2225,22 +2298,13 @@ function migrateSettings() {
22252298 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
22262299 forceEnum: true,
22272300 }),
2228- // TODO remove some day
2229- SlashCommandNamedArgument.fromProps({
2230- name: 'format',
2231- description: '!!! DEPRECATED - use "return" instead !!! The format to return the list in: comma-separated plain text or JSON array. Default is plain text.',
2232- typeList: [ARGUMENT_TYPE.STRING],
2233- enumList: [
2234- new SlashCommandEnumValue('plain', null, enumTypes.enum, ', '),
2235- new SlashCommandEnumValue('json', null, enumTypes.enum, '[]'),
2236- ],
2237- }),
22382301 ],
22392302 returns: 'The comma-separated list of available expressions, including custom expressions.',
22402303 helpString: 'Returns a list of available expressions, including custom expressions.',
22412304 }));
22422305 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
22432306 name: 'expression-classify',
2307+ aliases: ['classify'],
22442308 callback: classifyCallback,
22452309 namedArgumentList: [
22462310 SlashCommandNamedArgument.fromProps({
@@ -2279,11 +2343,13 @@ function migrateSettings() {
22792343 `,
22802344 }));
22812345 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
22822346 name: 'uploadspriteexpression-upload',
2347+ aliases: ['uploadsprite'],
2348+ /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
22832349 callback: async (args, url) => {
22842350 return await uploadSpriteCommand(args, url);
2285- return '';
22862351 },
2352+ returns: 'the resulting sprite name',
22872353 unnamedArgumentList: [
22882354 SlashCommandArgument.fromProps({
22892355 description: 'URL of the image to upload',
@@ -2297,7 +2363,6 @@ function migrateSettings() {
22972363 description: 'Character name or avatar key (default is current character)',
22982364 typeList: [ARGUMENT_TYPE.STRING],
22992365 isRequired: false,
2300- acceptsMultiple: false,
23012366 }),
23022367 SlashCommandNamedArgument.fromProps({
23032368 name: 'label',
@@ -2305,16 +2370,32 @@ function migrateSettings() {
23052370 typeList: [ARGUMENT_TYPE.STRING],
23062371 enumProvider: localEnumProviders.expressions,
23072372 isRequired: true,
2308- acceptsMultiple: false,
23092373 }),
23102374 SlashCommandNamedArgument.fromProps({
23112375 name: 'folder',
23122376 description: 'Override folder to upload into',
23132377 typeList: [ARGUMENT_TYPE.STRING],
23142378 isRequired: false,
2315- acceptsMultiple: false,
2379+ }),
2380+ SlashCommandNamedArgument.fromProps({
2381+ name: 'spriteName',
2382+ 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.',
2383+ typeList: [ARGUMENT_TYPE.STRING],
2384+ isRequired: false,
23162385 }),
23172386 ],
2318- helpString: '<div>Upload a sprite from a URL.</div><div>Example:</div><pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>',
2387+ helpString: `
2388+ <div>
2389+ Upload a sprite from a URL.
2390+ </div>
2391+ <div>
2392+ <strong>Example:</strong>
2393+ <ul>
2394+ <li>
2395+ <pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>
2396+ </li>
2397+ </ul>
2398+ </div>
2399+ `,
23192400 }));
23202401})();
public/scripts/extensions/expressions/list-item.html+9 -5
@@ -1,4 +1,5 @@
1-<div id="{{item}}" class="expression_list_item">
1+{{#each images}}
2+<div class="expression_list_item interactable" data-expression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}">
23 <div class="expression_list_buttons">
34 <div class="menu_button expression_list_upload" title="Upload image">
45 <i class="fa-solid fa-upload"></i>
@@ -7,11 +8,14 @@
78 <i class="fa-solid fa-trash"></i>
89 </div>
910 </div>
1011 <div class="expression_list_title {{textClass}}">
1112 <span>{{item../expression}}</span>
1213 {{#if ../isCustom}}
1314 <small class="expression_list_custom">(custom)</small>
1415 {{/if}}
1516 </div>
1617 <imgdiv class="expression_list_imageexpression_list_image_container" srctitle="{{imageSrcthis.title}}" />
18+ <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
1719 </div>
20+</div>
21+{{/each}}
public/scripts/extensions/expressions/settings.html+21 -9
@@ -6,17 +6,17 @@
66 </div>
77
88 <div class="inline-drawer-content">
99 <label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings." data-i18n="[title]Use the selected API from Chat Translation extension settings.">
1010 <input id="expression_translate" type="checkbox">
1111 <span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
1212 </label>
13- <label class="checkbox_label" for="expressions_show_default">
13+ <label class="checkbox_label" for="expressions_allow_multiple" title="A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected." data-i18n="[title]A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected.">
1414 <input id="expressions_show_defaultexpressions_allow_multiple" type="checkbox">
1515 <span data-i18n="Show default imagesAllow (emojis)multiple ifsprites spriteper missingexpression">Show default imagesAllow (emojis)multiple ifsprites spriteper missingexpression</span>
1616 </label>
17- <label id="image_type_block" class="checkbox_label" for="image_type_toggle">
17+ <label class="checkbox_label" for="expressions_reroll_if_same" title="If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned." data-i18n="[title]If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned.">
1818 <input id="image_type_toggleexpressions_reroll_if_same" type="checkbox">
1919 <span data-i18n="ImageRe-roll Typeif -same talkingheadexpression (extras)is used again">ImageRe-roll Typeif -same talkingheadsprite (extras)is used again</span>
2020 </label>
2121 <div class="expression_api_block m-b-1 m-t-1">
2222 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>
@@ -75,8 +75,20 @@
7575 <span data-i18n="Remove all image overrides">Remove all image overrides</span>
7676 </div>
7777 </div>
78- <p class="hint"><b data-i18n="Hint:">Hint:</b> <i><span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
78+ <p class="hint">
79- <span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt></i></p>
79+ <b data-i18n="Hint:">Hint:</b>
80+ <i>
81+ <span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span>
82+ <span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt>
83+ </i>
84+ </p>
85+ <p>
86+ <i>
87+ <span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a
88+ dash.
89+ Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt>
90+ </i>
91+ </p>
8092 <h3 id="image_list_header">
8193 <strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
8294 </h3>
public/scripts/extensions/expressions/style.css+31 -2
@@ -111,6 +111,10 @@ img.expression.default {
111111 justify-content: center;
112112}
113113
114+.expression_list_image_container {
115+ overflow: hidden;
116+}
117+
114118.expression_list_title {
115119 position: absolute;
116120 bottom: 0;
@@ -126,6 +130,9 @@ img.expression.default {
126130 flex-direction: column;
127131 line-height: 1;
128132}
133+.expression_list_custom {
134+ font-size: 0.66rem;
135+}
129136
130137.expression_list_buttons {
131138 position: absolute;
@@ -162,11 +169,24 @@ img.expression.default {
162169 row-gap: 1rem;
163170}
164171
165172#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
166173 color: green;
167174}
168175
169-#image_list .failure {
176+#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title {
177+ color: darkolivegreen;
178+}
179+#image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before {
180+ content: '➕';
181+ position: absolute;
182+ top: -7px;
183+ left: -9px;
184+ font-size: 14px;
185+ color: transparent;
186+ text-shadow: 0 0 0 darkolivegreen;
187+}
188+
189+#image_list .expression_list_item[data-expression-type="failure"] .expression_list_title {
170190 color: red;
171191}
172192
@@ -189,3 +209,12 @@ img.expression.default {
189209 flex-direction: row;
190210}
191211
212+#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"],
213+#expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] {
214+ opacity: 0.3;
215+ transition: opacity var(--animation-duration) ease;
216+}
217+#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover,
218+#expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus {
219+ opacity: unset;
220+}
public/scripts/extensions/expressions/templates/upload-expression.html+12 -0
@@ -0,0 +1,12 @@
1+<div class="m-b-1" data-i18n="upload_expression_request">Please enter a name for the sprite (without extension).</div>
2+<div class="m-b-1" data-i18n="upload_expression_naming_1">
3+ Sprite names must follow the naming schema for the selected expression: {{expression}}
4+</div>
5+<div data-i18n="upload_expression_naming_2">
6+ For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'.
7+</div>
8+<span class="m-b-1" data-i18n="Examples:">Examples:</span> <tt>{{expression}}.png</tt>, <tt>{{expression}}-1.png</tt>, <tt>{{expression}}.expressive.png</tt>
9+{{#if clickedFileName}}
10+<div class="m-t-1" data-i18n="upload_expression_replace">Click 'Replace' to replace the existing expression:</div>
11+<tt>{{clickedFileName}}</tt>
12+{{/if}}
public/scripts/extensions/translate/index.js+1 -1
@@ -605,7 +605,7 @@ const handleOutgoingMessage = createEventHandler(translateOutgoingMessage, () =>
605605const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));
606606const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);
607607
608608window['globalThis.translate'] = translate;
609609
610610jQuery(async () => {
611611 const html = await renderExtensionTemplateAsync('translate', 'index');
public/scripts/extensions/tts/index.js+0 -26
@@ -27,14 +27,12 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
2727import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
2828import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
2929import { GoogleTranslateTtsProvider } from './google-translate.js';
30-export { talkingAnimation };
3130
3231const UPDATE_INTERVAL = 1000;
3332const wrapper = new ModuleWorkerWrapper(moduleWorker);
3433
3534let voiceMapEntries = [];
3635let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
37-let talkingHeadState = false;
3836let lastChatId = null;
3937let lastMessage = null;
4038let lastMessageHash = null;
@@ -166,27 +164,6 @@ async function moduleWorker() {
166164 updateUiAudioPlayState();
167165}
168166
169-function talkingAnimation(switchValue) {
170- if (!modules.includes('talkinghead')) {
171- console.debug('Talking Animation module not loaded');
172- return;
173- }
174-
175- const apiUrl = getApiUrl();
176- const animationType = switchValue ? 'start' : 'stop';
177-
178- if (switchValue !== talkingHeadState) {
179- try {
180- console.log(animationType + ' Talking Animation');
181- doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`);
182- talkingHeadState = switchValue;
183- } catch (error) {
184- // Handle the error here or simply ignore it to prevent logging
185- }
186- }
187- updateUiAudioPlayState();
188-}
189-
190167function resetTtsPlayback() {
191168 // Stop system TTS utterance
192169 cancelTtsPlay();
@@ -378,7 +355,6 @@ function onAudioControlClicked() {
378355 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
379356 if (!audioElement.paused || isTtsProcessing()) {
380357 resetTtsPlayback();
381- talkingAnimation(false);
382358 } else {
383359 // Default play behavior if not processing or playing is to play the last message.
384360 processAndQueueTtsMessage(context.chat[context.chat.length - 1]);
@@ -405,7 +381,6 @@ function addAudioControl() {
405381function completeCurrentAudioJob() {
406382 audioQueueProcessorReady = true;
407383 currentAudioJob = null;
408- talkingAnimation(false); //stop lip animation
409384 // updateUiPlayState();
410385 wrapper.update();
411386}
@@ -436,7 +411,6 @@ async function processAudioJobQueue() {
436411 audioQueueProcessorReady = false;
437412 currentAudioJob = audioJobQueue.shift();
438413 playAudioData(currentAudioJob);
439- talkingAnimation(true);
440414 } catch (error) {
441415 toastr.error(error.toString());
442416 console.error(error);
public/scripts/extensions/tts/system.js+0 -3
@@ -1,6 +1,5 @@
11import { isMobile } from '../../RossAscends-mods.js';
22import { getPreviewString } from './index.js';
3-import { talkingAnimation } from './index.js';
43import { saveTtsProviderSettings } from './index.js';
54export { SystemTtsProvider };
65
@@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) {
7069 //placing the speak invocation inside a callback fixes ordering and onend issues.
7170 setTimeout(function () {
7271 speechSynthesis.speak(newUtt);
73- talkingAnimation(true);
7472 }, 0);
7573};
7674
@@ -240,7 +238,6 @@ class SystemTtsProvider {
240238 //some code to execute when done
241239 resolve(silence);
242240 console.log('System TTS done');
243- talkingAnimation(false);
244241 });
245242 });
246243 }
public/scripts/extensions/vectors/index.js+2 -2
@@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) {
561561 */
562562async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {
563563 try {
564564 if (settings.translate_files && typeof window['globalThis.translate'] === 'function') {
565565 console.log(`Vectors: Translating file ${fileName} to English...`);
566566 const translatedText = await window['globalThis.translate'](fileText, 'en');
567567 fileText = translatedText;
568568 }
569569
public/scripts/power-user.js+5 -4
@@ -1845,14 +1845,15 @@ async function loadContextSettings() {
18451845
18461846/**
18471847 * Common function to perform fuzzy search with optional caching
1848+ * @template T
18481849 * @param {string} type - Type of search from fuzzySearchCategories
18491850 * @param {anyT[]} data - Data array to search in
18501851 * @param {Array<{name: string, weight: number, getFn?: (obj: anyT) => string}>} keys - Fuse.js keys configuration
18511852 * @param {string} searchValue - The search term
18521853 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
18531854 * @returns {import('fuse.js').FuseResult<anyT>[]} Results as items with their score
18541855 */
18551856export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
18561857 // Check cache if provided
18571858 if (fuzzySearchCaches) {
18581859 const cache = fuzzySearchCaches[type];
src/endpoints/sprites.js+13 -5
@@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
125125 .map((file) => {
126126 const pathToSprite = path.join(spritesPath, file);
127127 const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);
128+
129+ const fileName = path.parse(pathToSprite).name.toLowerCase();
130+ // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot.
131+ // Examples: joy.png, joy-1.png, joy.expressive.png
132+ const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName;
133+
128134 return {
129- label: path.parse(pathToSprite).name.toLowerCase(),
135+ label: label,
130136 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
131137 };
132138 });
@@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) {
141147router.post('/delete', jsonParser, async (request, response) => {
142148 const label = request.body.label;
143149 const name = request.body.name;
150+ const spriteName = request.body.spriteName || label;
144151
145152 if (!labelspriteName || !name) {
146153 return response.sendStatus(400);
147154 }
148155
@@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => {
158165
159166 // Remove existing sprite with the same label
160167 for (const file of files) {
161168 if (path.parse(file).name === labelspriteName) {
162169 fs.rmSync(path.join(spritesPath, file));
163170 }
164171 }
@@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => {
221228 const file = request.file;
222229 const label = request.body.label;
223230 const name = request.body.name;
231+ const spriteName = request.body.spriteName || label;
224232
225233 if (!file || !label || !name) {
226234 return response.sendStatus(400);
@@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => {
243251
244252 // Remove existing sprite with the same label
245253 for (const file of files) {
246254 if (path.parse(file).name === labelspriteName) {
247255 fs.rmSync(path.join(spritesPath, file));
248256 }
249257 }
250258
251259 const filename = labelspriteName + path.parse(file.originalname).ext;
252260 const spritePath = path.join(file.destination, file.filename);
253261 const pathToFile = path.join(spritesPath, filename);
254262 // Copy uploaded file to sprites folder