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 {
40 searchInputCssClass?: string;40 searchInputCssClass?: string;
41 }41 }
42 }42 }
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>;
43}51}
public/locales/fr-fr.json+0 -1
@@ -1602,7 +1602,6 @@
1602 "Character Expressions": "Expressions de personnages",1602 "Character Expressions": "Expressions de personnages",
1603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",1603 "Translate text to English before classification": "Traduire le texte en anglais avant de le classer",
1604 "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant",1604 "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)",
1606 "Classifier API": "API de classification",1605 "Classifier API": "API de classification",
1607 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",1606 "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.",
1608 "Main API": "API principale",1607 "Main API": "API principale",
public/locales/ko-kr.json+0 -1
@@ -1467,7 +1467,6 @@
1467 "menu within": "내의 메뉴",1467 "menu within": "내의 메뉴",
1468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",1468 "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.",
1469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",1469 "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.",
1470 "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)",
1471 "Classifier API": "분류를 위한 API",1470 "Classifier API": "분류를 위한 API",
1472 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",1471 "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.",
1473 "Local": "로컬",1472 "Local": "로컬",
public/locales/zh-cn.json+0 -1
@@ -1349,7 +1349,6 @@
1349 "Character Expressions": "角色表情",1349 "Character Expressions": "角色表情",
1350 "Translate text to English before classification": "分类之前将文本翻译成英文",1350 "Translate text to English before classification": "分类之前将文本翻译成英文",
1351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",1351 "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)",
1352 "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)",
1353 "Classifier API": "分类器 API",1352 "Classifier API": "分类器 API",
1354 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",1353 "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。",
1355 "Main API": "主要 API",1354 "Main API": "主要 API",
public/locales/zh-tw.json+0 -1
@@ -1653,7 +1653,6 @@
1653 "HuggingFace Token": "HuggingFace 符元",1653 "HuggingFace Token": "HuggingFace 符元",
1654 "Image Captioning": "圖片註解",1654 "Image Captioning": "圖片註解",
1655 "Generate Caption": "產生圖片註解",1655 "Generate Caption": "產生圖片註解",
1656 "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)",
1657 "Injection Position": "插入位置",1656 "Injection Position": "插入位置",
1658 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",1657 "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。",
1659 "Injection Template": "插入範本",1658 "Injection Template": "插入範本",
public/scripts/extensions.js+10 -0
@@ -154,8 +154,18 @@ export const extension_settings = {
154 refine_mode: false,154 refine_mode: false,
155 },155 },
156 expressions: {156 expressions: {
157 /** @type {number} see `EXPRESSION_API` */
158 api: undefined,
157 /** @type {string[]} */159 /** @type {string[]} */
158 custom: [],160 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,
159 },169 },
160 connectionManager: {170 connectionManager: {
161 selectedProfile: '',171 selectedProfile: '',
public/scripts/extensions/expressions/index.js+788 -707
@@ -1,11 +1,11 @@
1import { Fuse } from '../../../lib.js';1import { Fuse } from '../../../lib.js';
22
3import { callPopup, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js';3import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
4import { dragElement, isMobile } from '../../RossAscends-mods.js';4import { dragElement, isMobile } from '../../RossAscends-mods.js';
5import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';5import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
6import { loadMovingUIState, power_user } from '../../power-user.js';6import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
7import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';7import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
8import { hideMutedSprites } from '../../group-chats.js';8import { hideMutedSprites, selected_group } from '../../group-chats.js';
9import { isJsonSchemaSupported } from '../../textgen-settings.js';9import { isJsonSchemaSupported } from '../../textgen-settings.js';
10import { debounce_timeout } from '../../constants.js';10import { debounce_timeout } from '../../constants.js';
11import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';11import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -15,16 +15,32 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm
15import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';15import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
18import { Popup, POPUP_RESULT } from '../../popup.js';
19import { t } from '../../i18n.js';
18export { MODULE_NAME };20export { 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
20const MODULE_NAME = 'expressions';38const MODULE_NAME = 'expressions';
21const UPDATE_INTERVAL = 2000;39const UPDATE_INTERVAL = 2000;
22const STREAMING_UPDATE_INTERVAL = 10000;40const STREAMING_UPDATE_INTERVAL = 10000;
23const TALKINGCHECK_UPDATE_INTERVAL = 500;
24const DEFAULT_FALLBACK_EXPRESSION = 'joy';41const DEFAULT_FALLBACK_EXPRESSION = 'joy';
25const 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}}';42const 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}}';
26const DEFAULT_EXPRESSIONS = [43const DEFAULT_EXPRESSIONS = [
27 'talkinghead',
28 'admiration',44 'admiration',
29 'amusement',45 'amusement',
30 'anger',46 'anger',
@@ -54,6 +70,12 @@ const DEFAULT_EXPRESSIONS = [
54 'surprise',70 'surprise',
55 'neutral',71 'neutral',
56];72];
73
74const OPTION_NO_FALLBACK = '#none';
75const OPTION_EMOJI_FALLBACK = '#emoji';
76const RESET_SPRITE_LABEL = '#reset';
77
78
57/** @enum {number} */79/** @enum {number} */
58const EXPRESSION_API = {80const EXPRESSION_API = {
59 local: 0,81 local: 0,
@@ -65,35 +87,29 @@ const EXPRESSION_API = {
65let expressionsList = null;87let expressionsList = null;
66let lastCharacter = undefined;88let lastCharacter = undefined;
67let lastMessage = null;89let lastMessage = null;
68let lastTalkingState = false;90/** @type {{[characterKey: string]: Expression[]}} */
69let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer)
70let spriteCache = {};91let spriteCache = {};
71let inApiCall = false;92let inApiCall = false;
72let lastServerResponseTime = 0;93let lastServerResponseTime = 0;
73export let lastExpression = {};
74
75function 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 one96export let lastExpression = {};
81 * @returns {string} expression name
82 */
83function getFallbackExpression() {
84 return extension_settings.expressions.fallback_expression ?? DEFAULT_FALLBACK_EXPRESSION;
85}
8697
87/**98/**
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 button101 * @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 AFK102 * @returns {ExpressionImage} The placeholder image object
92 * for a long time).
93 */103 */
94function toggleTalkingHeadCommand(_) {104function getPlaceholderImage(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 };
97}113}
98114
99function isVisualNovelMode() {115function isVisualNovelMode() {
@@ -108,21 +124,21 @@ async function forceUpdateVisualNovelMode() {
108124
109const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);125const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);
110126
111async function updateVisualNovelMode(name, expression) {127async function updateVisualNovelMode(spriteFolderName, expression) {
112 const container = $('#visual-novel-wrapper');128 const vnContainer = $('#visual-novel-wrapper');
113129
114 await visualNovelRemoveInactive(container);130 await visualNovelRemoveInactive(vnContainer);
115131
116 const setSpritePromises = await visualNovelSetCharacterSprites(container, name, expression);132 const setSpritePromises = await visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression);
117133
118 // calculate layer indices based on recent messages134 // calculate layer indices based on recent messages
119 await visualNovelUpdateLayers(container);135 await visualNovelUpdateLayers(vnContainer);
120136
121 await Promise.allSettled(setSpritePromises);137 await Promise.allSettled(setSpritePromises);
122138
123 // update again based on new sprites139 // update again based on new sprites
124 if (setSpritePromises.length > 0) {140 if (setSpritePromises.length > 0) {
125 await visualNovelUpdateLayers(container);141 await visualNovelUpdateLayers(vnContainer);
126 }142 }
127}143}
128144
@@ -153,52 +169,60 @@ async function visualNovelRemoveInactive(container) {
153 await Promise.allSettled(removeInactiveCharactersPromises);169 await Promise.allSettled(removeInactiveCharactersPromises);
154}170}
155171
156async 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 */
180async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) {
181 const originalExpression = expression;
157 const context = getContext();182 const context = getContext();
158 const group = context.groups.find(x => x.id == context.groupId);183 const group = context.groups.find(x => x.id == context.groupId);
159 const labels = await getExpressionsList();
160184
161 const createCharacterPromises = [];
162 const setSpritePromises = [];185 const setSpritePromises = [];
163186
164 for (const avatar of group.members) {187 for (const avatar of group.members) {
165 const isDisabled = group.disabled_members.includes(avatar);
166
167 // skip disabled characters188 // skip disabled characters
189 const isDisabled = group.disabled_members.includes(avatar);
168 if (isDisabled && hideMutedSprites) {190 if (isDisabled && hideMutedSprites) {
169 continue;191 continue;
170 }192 }
171193
172 const character = context.characters.find(x => x.avatar == avatar);194 const character = context.characters.find(x => x.avatar == avatar);
173
174 if (!character) {195 if (!character) {
175 continue;196 continue;
176 }197 }
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
180 // download images if not downloaded yet205 // download images if not downloaded yet
181 if (spriteCache[spriteFolderName] === undefined) {206 if (spriteCache[memberSpriteFolderName] === undefined) {
182 spriteCache[spriteFolderName] = await getSpritesList(spriteFolderName);207 spriteCache[memberSpriteFolderName] = await getSpritesList(memberSpriteFolderName);
183 }208 }
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
191 if (expressionImage.length > 0) {212 if (!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');
199 await setImage(img, path);223 await setImage(img, path);
200 }224 }
201 expressionImage.toggleClass('hidden', noSprites);225 expressionImage.toggleClass('hidden', !spriteFile);
202 } else {226 } else {
203 const template = $('#expression-holder').clone();227 const template = $('#expression-holder').clone();
204 template.attr('id', `expression-${avatar}`);228 template.attr('id', `expression-${avatar}`);
@@ -206,21 +230,49 @@ async function visualNovelSetCharacterSprites(container, name, expression) {
206 template.find('.drag-grabber').attr('id', `expression-${avatar}header`);230 template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
207 $('#visual-novel-wrapper').append(template);231 $('#visual-novel-wrapper').append(template);
208 dragElement($(template[0]));232 dragElement($(template[0]));
209 template.toggleClass('hidden', noSprites);233 template.toggleClass('hidden', !spriteFile);
210 await setImage(template.find('img'), defaultSpritePath || '');234 img = template.find('img');
235 await setImage(img, spriteFile?.imageSrc || '');
211 const fadeInPromise = new Promise(resolve => {236 const fadeInPromise = new Promise(resolve => {
212 template.fadeIn(250, () => resolve());237 template.fadeIn(250, () => resolve());
213 });238 });
214 createCharacterPromises.push(fadeInPromise);239 setSpritePromises.push(fadeInPromise);
215 const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
216 setSpritePromises.push(setSpritePromise);
217 }240 }
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 });
218 }254 }
219255
220 await Promise.allSettled(createCharacterPromises);
221 return setSpritePromises;256 return setSpritePromises;
222}257}
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 */
264async 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
224async function visualNovelUpdateLayers(container) {276async function visualNovelUpdateLayers(container) {
225 const context = getContext();277 const context = getContext();
226 const group = context.groups.find(x => x.id == context.groupId);278 const group = context.groups.find(x => x.id == context.groupId);
@@ -256,11 +308,17 @@ async function visualNovelUpdateLayers(container) {
256 const containerWidth = container.width();308 const containerWidth = container.width();
257 const pivotalPoint = containerWidth * 0.5;309 const pivotalPoint = containerWidth * 0.5;
258310
259 let images = $('#visual-novel-wrapper .expression-holder');311 let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction);
260 let imagesWidth = [];312 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());
264 });322 });
265323
266 let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);324 let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
@@ -274,7 +332,7 @@ async function visualNovelUpdateLayers(container) {
274 currentPosition = 0; // Reset the initial position to 0332 currentPosition = 0; // Reset the initial position to 0
275 }333 }
276334
277 images.sort(sortFunction).each((index, current) => {335 images.forEach((current, index) => {
278 const element = $(current);336 const element = $(current);
279 const elementID = element.attr('id');337 const elementID = element.attr('id');
280338
@@ -294,9 +352,15 @@ async function visualNovelUpdateLayers(container) {
294 element.show();352 element.show();
295353
296 const promise = new Promise(resolve => {354 const promise = new Promise(resolve => {
355 if (power_user.reduced_motion) {
356 element.css('left', currentPosition + 'px');
357 requestAnimationFrame(() => resolve());
358 }
359 else {
297 element.animate({ left: currentPosition + 'px' }, 500, () => {360 element.animate({ left: currentPosition + 'px' }, 500, () => {
298 resolve();361 resolve();
299 });362 });
363 }
300 });364 });
301365
302 currentPosition += imagesWidth[index];366 currentPosition += imagesWidth[index];
@@ -307,23 +371,12 @@ async function visualNovelUpdateLayers(container) {
307 await Promise.allSettled(setLayerIndicesPromises);371 await Promise.allSettled(setLayerIndicesPromises);
308}372}
309373
310async 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
313377 * @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
327async function setImage(img, path) {380async function setImage(img, path) {
328 // Cohee: If something goes wrong, uncomment this to return to the old behavior381 // Cohee: If something goes wrong, uncomment this to return to the old behavior
329 /*382 /*
@@ -340,7 +393,7 @@ async function setImage(img, path) {
340 return new Promise(resolve => {393 return new Promise(resolve => {
341 const prevExpressionSrc = img.attr('src');394 const prevExpressionSrc = img.attr('src');
342 const expressionClone = img.clone();395 const expressionClone = img.clone();
343 const originalId = img.attr('id');396 const originalId = img.data('filename');
344397
345 //only swap expressions when necessary398 //only swap expressions when necessary
346 if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {399 if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
@@ -348,7 +401,7 @@ async function setImage(img, path) {
348 expressionClone.addClass('expression-clone');401 expressionClone.addClass('expression-clone');
349 //make invisible and remove id to prevent double ids402 //make invisible and remove id to prevent double ids
350 //must be made invisible to start because they share the same Z-index403 //must be made invisible to start because they share the same Z-index
351 expressionClone.attr('id', '').css({ opacity: 0 });404 expressionClone.data('filename', '').css({ opacity: 0 });
352 //add new sprite path to clone src405 //add new sprite path to clone src
353 expressionClone.attr('src', path);406 expressionClone.attr('src', path);
354 //add invisible clone to html407 //add invisible clone to html
@@ -384,14 +437,18 @@ async function setImage(img, path) {
384 //remove old expression437 //remove old expression
385 img.remove();438 img.remove();
386 //replace ID so it becomes the new 'original' expression for next change439 //replace ID so it becomes the new 'original' expression for next change
387 expressionClone.attr('id', originalId);440 expressionClone.data('filename', originalId);
388 expressionClone.removeClass('expression-animating');441 expressionClone.removeClass('expression-animating');
389442
390 // Reset the expression holder min height and width443 // Reset the expression holder min height and width
391 expressionHolder.css('min-width', 100);444 expressionHolder.css('min-width', 100);
392 expressionHolder.css('min-height', 100);445 expressionHolder.css('min-height', 100);
393446
447 if (expressionClone.prop('complete')) {
394 resolve();448 resolve();
449 } else {
450 expressionClone.one('load', () => resolve());
451 }
395 });452 });
396453
397 expressionClone.removeClass('expression-clone');454 expressionClone.removeClass('expression-clone');
@@ -410,216 +467,9 @@ async function setImage(img, path) {
410 });467 });
411}468}
412469
413function onExpressionsShowDefaultInput() {470async 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 */
433async 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 */
456async 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
572function 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
612async function moduleWorker() {
613 const context = getContext();471 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
623 // non-characters not supported473 // non-characters not supported
624 if (!context.groupId && context.characterId === undefined) {474 if (!context.groupId && context.characterId === undefined) {
625 removeExpression();475 removeExpression();
@@ -646,7 +496,7 @@ async function moduleWorker() {
646 }496 }
647497
648 const currentLastMessage = getLastCharacterMessage();498 const currentLastMessage = getLastCharacterMessage();
649 let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();499 let spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
650500
651 // character has no expressions or it is not loaded501 // character has no expressions or it is not loaded
652 if (Object.keys(spriteCache).length === 0) {502 if (Object.keys(spriteCache).length === 0) {
@@ -686,6 +536,10 @@ async function moduleWorker() {
686 offlineMode.css('display', 'none');536 offlineMode.css('display', 'none');
687 }537 }
688538
539 if (context.groupId && vnMode && newChat) {
540 await forceUpdateVisualNovelMode();
541 }
542
689 // Don't bother classifying if current char has no sprites and no default expressions are enabled543 // Don't bother classifying if current char has no sprites and no default expressions are enabled
690 if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {544 if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {
691 return;545 return;
@@ -732,11 +586,11 @@ async function moduleWorker() {
732 const force = !!context.groupId;586 const force = !!context.groupId;
733587
734 // Character won't be angry on you for swiping588 // Character won't be angry on you for swiping
735 if (currentLastMessage.mes == '...' && expressionsList.includes(getFallbackExpression())) {589 if (currentLastMessage.mes == '...' && expressionsList.includes(extension_settings.expressions.fallback_expression)) {
736 expression = getFallbackExpression();590 expression = extension_settings.expressions.fallback_expression;
737 }591 }
738592
739 await sendExpressionCall(spriteFolderName, expression, force, vnMode);593 await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
740 }594 }
741 catch (error) {595 catch (error) {
742 console.log(error);596 console.log(error);
@@ -749,91 +603,6 @@ async function moduleWorker() {
749 }603 }
750}604}
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 */
767async 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 */
817async 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
837function getSpriteFolderName(characterMessage = null, characterName = null) {606function getSpriteFolderName(characterMessage = null, characterName = null) {
838 const context = getContext();607 const context = getContext();
839 let spriteFolderName = characterName ?? context.name2;608 let spriteFolderName = characterName ?? context.name2;
@@ -848,33 +617,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) {
848 return spriteFolderName;617 return spriteFolderName;
849}618}
850619
851function 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
878function getFolderNameByMessage(message) {620function getFolderNameByMessage(message) {
879 const context = getContext();621 const context = getContext();
880 let avatarPath = '';622 let avatarPath = '';
@@ -894,48 +636,55 @@ function getFolderNameByMessage(message) {
894 return folderName;636 return folderName;
895}637}
896638
897async 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 */
649export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
650 lastExpression[spriteFolderName.split('/')[0]] = expression;
651 if (vnMode === null) {
900 vnMode = isVisualNovelMode();652 vnMode = isVisualNovelMode();
901 }653 }
902654
903 if (vnMode) {655 if (vnMode) {
904 await updateVisualNovelMode(name, expression);656 await updateVisualNovelMode(spriteFolderName, expression);
905 } else {657 } else {
906 setExpression(name, expression, force);658 setExpression(spriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
907 }659 }
908}660}
909661
910async function setSpriteSetCommand(_, folder) {662async function setSpriteFolderCommand(_, folder) {
911 if (!folder) {663 if (!folder) {
912 console.log('Clearing sprite set');664 console.log('Clearing sprite set');
913 folder = '';665 folder = '';
914 }666 }
915667
916 if (folder.startsWith('/') || folder.startsWith('\\')) {668 if (folder.startsWith('/') || folder.startsWith('\\')) {
917 folder = folder.slice(1);
918
919 const currentLastMessage = getLastCharacterMessage();669 const currentLastMessage = getLastCharacterMessage();
670 folder = folder.slice(1);
920 folder = `${currentLastMessage.name}/${folder}`;671 folder = `${currentLastMessage.name}/${folder}`;
921 }672 }
922673
923 $('#expression_override').val(folder.trim());674 $('#expression_override').val(folder.trim());
924 onClickExpressionOverrideButton();675 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);
929 return '';678 return '';
930}679}
931680
932async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {681async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {
933 if (!text) {682 if (!text) {
934 toastr.warning('No text provided');683 toastr.error('No text provided');
935 return '';684 return '';
936 }685 }
937 if (api && !Object.keys(EXPRESSION_API).includes(api)) {686 if (api && !Object.keys(EXPRESSION_API).includes(api)) {
938 toastr.warning('Invalid API provided');687 toastr.error('Invalid API provided');
939 return '';688 return '';
940 }689 }
941690
@@ -951,37 +700,69 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
951 return label;700 return label;
952}701}
953702
954async function setSpriteSlashCommand(_, spriteId) {703/** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
955 if (!spriteId) {704async 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`);
957 return '';709 return '';
958 }710 }
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')) {
968 await validateImages(spriteFolderName);720 await validateImages(spriteFolderName);
969721
970 // Fuzzy search for sprite722 // 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`);
977 return '';754 return '';
978 }755 }
979756
980 label = spriteItem.label;757 label = matchedSprite.expression;
758 spriteFile = matchedSprite.fileName;
759 break;
760 }
761 default: throw Error('Invalid sprite set type: ' + type);
981 }762 }
982763
983 const vnMode = isVisualNovelMode();764 await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
984 await sendExpressionCall(spriteFolderName, label, true, vnMode);765
985 return label;766 return label;
986}767}
987768
@@ -999,6 +780,21 @@ function spriteFolderNameFromCharacter(char) {
999}780}
1000781
1001/**782/**
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 */
788function 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/**
1002 * Slash command callback for /uploadsprite798 * Slash command callback for /uploadsprite
1003 *799 *
1004 * label= is required800 * label= is required
@@ -1011,16 +807,29 @@ function spriteFolderNameFromCharacter(char) {
1011 * @param {object} args807 * @param {object} args
1012 * @param {string} args.name Character name or avatar key, passed through findChar808 * @param {string} args.name Character name or avatar key, passed through findChar
1013 * @param {string} args.label Expression label809 * @param {string} args.label Expression label
1014 * @param {string} args.folder Sprite folder path, processed using backslash rules810 * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules
811 * @param {string?} [args.spriteName=null] Optional sprite name
1015 * @param {string} imageUrl Image URI to fetch and upload812 * @param {string} imageUrl Image URI to fetch and upload
1016 * @returns {Promise<void>}813 * @returns {Promise<string>} the sprite name
1017 */814 */
1018async function uploadSpriteCommand({ name, label, folder }, imageUrl) {815async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
1019 if (!imageUrl) throw new Error('Image URL is required');816 if (!imageUrl) throw new Error('Image URL is required');
1020 if (!label || typeof label !== 'string') throw new Error('Expression label is required');817 if (!label || typeof label !== 'string') {
818 toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
819 return '';
820 }
1021821
1022 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();822 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
1025 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;834 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
1026 const char = findChar({ name });835 const char = findChar({ name });
@@ -1041,6 +850,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1041 formData.append('name', folder); // this is the folder or character name850 formData.append('name', folder); // this is the folder or character name
1042 formData.append('label', label); // this is the expression label851 formData.append('label', label); // this is the expression label
1043 formData.append('avatar', file); // this is the image file852 formData.append('avatar', file); // this is the image file
853 formData.append('spriteName', spriteName); // this is a redundant comment
1044854
1045 await handleFileUpload('/api/sprites/upload', formData);855 await handleFileUpload('/api/sprites/upload', formData);
1046 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);856 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
@@ -1048,6 +858,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1048 console.error(`[${MODULE_NAME}] Error uploading file:`, error);858 console.error(`[${MODULE_NAME}] Error uploading file:`, error);
1049 throw error;859 throw error;
1050 }860 }
861
862 return spriteName;
1051}863}
1052864
1053/**865/**
@@ -1159,7 +971,7 @@ function getJsonSchema(emotions) {
1159function onTextGenSettingsReady(args) {971function onTextGenSettingsReady(args) {
1160 // Only call if inside an API call972 // Only call if inside an API call
1161 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {973 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {
1162 const emotions = DEFAULT_EXPRESSIONS.filter((e) => e != 'talkinghead');974 const emotions = DEFAULT_EXPRESSIONS;
1163 Object.assign(args, {975 Object.assign(args, {
1164 top_k: 1,976 top_k: 1,
1165 stop: [],977 stop: [],
@@ -1177,16 +989,16 @@ function onTextGenSettingsReady(args) {
1177 * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.989 * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.
1178 * @param {object} [options={}] - Optional arguments.990 * @param {object} [options={}] - Optional arguments.
1179 * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.991 * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.
1180 * @returns {Promise<string>} - The label of the expression.992 * @returns {Promise<string?>} - The label of the expression.
1181 */993 */
1182export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) {994export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) {
1183 // Return if text is undefined, saving a costly fetch request995 // Return if text is undefined, saving a costly fetch request
1184 if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {996 if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {
1185 return getFallbackExpression();997 return extension_settings.expressions.fallback_expression;
1186 }998 }
1187999
1188 if (extension_settings.expressions.translate && typeof window['translate'] === 'function') {1000 if (extension_settings.expressions.translate && typeof globalThis.translate === 'function') {
1189 text = await window['translate'](text, 'en');1001 text = await globalThis.translate(text, 'en');
1190 }1002 }
11911003
1192 text = sampleClassifyText(text);1004 text = sampleClassifyText(text);
@@ -1212,7 +1024,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
1212 await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);1024 await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
1213 } catch (error) {1025 } catch (error) {
1214 console.warn('No LLM connection. Using fallback expression', error);1026 console.warn('No LLM connection. Using fallback expression', error);
1215 return getFallbackExpression();1027 return extension_settings.expressions.fallback_expression;
1216 }1028 }
12171029
1218 const expressionsList = await getExpressionsList();1030 const expressionsList = await getExpressionsList();
@@ -1225,7 +1037,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
1225 case EXPRESSION_API.webllm: {1037 case EXPRESSION_API.webllm: {
1226 if (!isWebLlmSupported()) {1038 if (!isWebLlmSupported()) {
1227 console.warn('WebLLM is not supported. Using fallback expression');1039 console.warn('WebLLM is not supported. Using fallback expression');
1228 return getFallbackExpression();1040 return extension_settings.expressions.fallback_expression;
1229 }1041 }
12301042
1231 const expressionsList = await getExpressionsList();1043 const expressionsList = await getExpressionsList();
@@ -1258,9 +1070,9 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin
1258 } break;1070 } break;
1259 }1071 }
1260 } catch (error) {1072 } catch (error) {
1261 toastr.info('Could not classify expression. Check the console or your backend for more information.');1073 toastr.error('Could not classify expression. Check the console or your backend for more information.');
1262 console.error(error);1074 console.error(error);
1263 return getFallbackExpression();1075 return extension_settings.expressions.fallback_expression;
1264 }1076 }
1265}1077}
12661078
@@ -1288,75 +1100,155 @@ function removeExpression() {
1288 $('#no_chat_expressions').show();1100 $('#no_chat_expressions').show();
1289}1101}
12901102
1291async 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 */
1108async function validateImages(spriteFolderName, forceRedrawCached = false) {
1109 if (!spriteFolderName) {
1293 return;1110 return;
1294 }1111 }
12951112
1296 const labels = await getExpressionsList();1113 const labels = await getExpressionsList();
12971114
1298 if (spriteCache[character]) {1115 if (spriteCache[spriteFolderName]) {
1299 if (forceRedrawCached && $('#image_list').data('name') !== character) {1116 if (forceRedrawCached && $('#image_list').data('name') !== spriteFolderName) {
1300 console.debug('force redrawing character sprites list');1117 console.debug('force redrawing character sprites list');
1301 await drawSpritesList(character, labels, spriteCache[character]);1118 await drawSpritesList(spriteFolderName, labels, spriteCache[spriteFolderName]);
1302 }1119 }
13031120
1304 return;1121 return;
1305 }1122 }
13061123
1307 const sprites = await getSpritesList(character);1124 const sprites = await getSpritesList(spriteFolderName);
1308 let validExpressions = await drawSpritesList(character, labels, sprites);1125 let validExpressions = await drawSpritesList(spriteFolderName, labels, sprites);
1309 spriteCache[character] = validExpressions;1126 spriteCache[spriteFolderName] = 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 */
1134function 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 };
1310}1145}
13111146
1312async 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 */
1154async function drawSpritesList(spriteFolderName, labels, sprites) {
1155 /** @type {Expression[]} */
1313 let validExpressions = [];1156 let validExpressions = [];
1157
1314 $('#no_chat_expressions').hide();1158 $('#no_chat_expressions').hide();
1315 $('#open_chat_expressions').show();1159 $('#open_chat_expressions').show();
1316 $('#image_list').empty();1160 $('#image_list').empty();
1317 $('#image_list').data('name', character);1161 $('#image_list').data('name', spriteFolderName);
1318 $('#image_list_header_name').text(character);1162 $('#image_list_header_name').text(spriteFolderName);
13191163
1320 if (!Array.isArray(labels)) {1164 if (!Array.isArray(labels)) {
1321 return [];1165 return [];
1322 }1166 }
13231167
1324 for (const item of labels.sort()) {1168 for (const expression 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
13271171 .filter(s => s.label === expression)
1328 if (sprite) {1172 .map(s => s.files)
1329 validExpressions.push(sprite);1173 .flat();
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 });
1331 $('#image_list').append(listItem);1180 $('#image_list').append(listItem);
1181 continue;
1332 }1182 }
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 });
1335 $('#image_list').append(listItem);1191 $('#image_list').append(listItem);
1336 }1192 }
1337 }
1338 return validExpressions;1193 return validExpressions;
1339}1194}
13401195
1341/**1196/**
1342 * Renders a list item template for the expressions list.1197 * Renders a list item template for the expressions list.
1343 * @param {string} item Expression name1198 * @param {string} expression Expression name
1344 * @param {string} imageSrc Path to image1199 * @param {object} args Arguments object
1345 * @param {'success' | 'failure'} textClass 'success' or 'failure'1200 * @param {ExpressionImage[]} [args.images] Array of image objects
1346 * @param {boolean} isCustom If expression is added by user1201 * @param {boolean} [args.isCustom=false] If expression is added by user
1347 * @returns {Promise<string>} Rendered list item template1202 * @returns {Promise<string>} Rendered list item template
1348 */1203 */
1349async function getListItem(item, imageSrc, textClass, isCustom) {1204async function getListItem(expression, { images, isCustom = false } = {}) {
1350 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom });1205 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false });
1351}1206}
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
1353async function getSpritesList(name) {1216async function getSpritesList(name) {
1354 console.debug('getting sprites list');1217 console.debug('getting sprites list');
13551218
1356 try {1219 try {
1357 const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);1220 const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
1221 /** @type {{ label: string, path: string }[]} */
1358 let sprites = result.ok ? (await result.json()) : [];1222 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;
1360 }1252 }
1361 catch (err) {1253 catch (err) {
1362 console.log(err);1254 console.log(err);
@@ -1395,17 +1287,31 @@ async function renderFallbackExpressionPicker() {
1395 const defaultPicker = $('#expression_fallback');1287 const defaultPicker = $('#expression_fallback');
1396 defaultPicker.empty();1288 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
1400 for (const expression of expressions) {1294 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) {
1401 const option = document.createElement('option');1300 const option = document.createElement('option');
1402 option.value = expression;1301 option.value = value;
1403 option.text = expression;1302 option.text = label;
1404 option.selected = expression == fallbackExpression;1303 option.selected = isSelected;
1405 defaultPicker.append(option);1304 defaultPicker.append(option);
1406 }1305 }
1407}1306}
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
1409function getCachedExpressions() {1315function getCachedExpressions() {
1410 if (!Array.isArray(expressionsList)) {1316 if (!Array.isArray(expressionsList)) {
1411 return [];1317 return [];
@@ -1463,7 +1369,7 @@ export async function getExpressionsList() {
1463 }1369 }
14641370
1465 // If there was no specific list, or an error, just return the default expressions1371 // If there was no specific list, or an error, just return the default expressions
1466 expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice();1372 expressionsList = DEFAULT_EXPRESSIONS.slice();
1467 return expressionsList;1373 return expressionsList;
1468 }1374 }
14691375
@@ -1471,38 +1377,88 @@ export async function getExpressionsList() {
1471 return [...result, ...extension_settings.expressions.custom].filter(onlyUnique);1377 return [...result, ...extension_settings.expressions.custom].filter(onlyUnique);
1472}1378}
14731379
1474async 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 */
1394function 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 */
1436async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) {
1437 await validateImages(spriteFolderName);
1478 const img = $('img.expression');1438 const img = $('img.expression');
1479 const prevExpressionSrc = img.attr('src');1439 const prevExpressionSrc = img.attr('src');
1480 const expressionClone = img.clone();1440 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
1487 if (force && isVisualNovelMode()) {1444 if (force && isVisualNovelMode()) {
1488 const context = getContext();1445 const context = getContext();
1489 const group = context.groups.find(x => x.id === context.groupId);1446 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);
1500 return;1456 return;
1501 }1457 }
1502 }1458 }
1503 }1459
1504 //only swap expressions when necessary1460 //only swap expressions when necessary
1505 if (prevExpressionSrc !== sprite.path1461 if (prevExpressionSrc !== spriteFile.imageSrc
1506 && !img.hasClass('expression-animating')) {1462 && !img.hasClass('expression-animating')) {
1507 //clone expression1463 //clone expression
1508 expressionClone.addClass('expression-clone');1464 expressionClone.addClass('expression-clone');
@@ -1510,7 +1466,12 @@ async function setExpression(character, expression, force) {
1510 //must be made invisible to start because they share the same Z-index1466 //must be made invisible to start because they share the same Z-index
1511 expressionClone.attr('id', '').css({ opacity: 0 });1467 expressionClone.attr('id', '').css({ opacity: 0 });
1512 //add new sprite path to clone src1468 //add new sprite path to clone src
1513 expressionClone.attr('src', sprite.path);1469 expressionClone.attr('src', spriteFile.imageSrc);
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);
1514 //add invisible clone to html1475 //add invisible clone to html
1515 expressionClone.appendTo($('#expression-holder'));1476 expressionClone.appendTo($('#expression-holder'));
15161477
@@ -1552,80 +1513,85 @@ async function setExpression(character, expression, force) {
1552 expressionHolder.css('min-height', 100);1513 expressionHolder.css('min-height', 100);
1553 });1514 });
15541515
1555
1556 expressionClone.removeClass('expression-clone');1516 expressionClone.removeClass('expression-clone');
15571517
1558 expressionClone.removeClass('default');1518 expressionClone.removeClass('default');
1559 expressionClone.off('error');1519 expressionClone.off('error');
1560 expressionClone.on('error', function () {1520 expressionClone.on('error', function (error) {
1561 console.debug('Expression image error', sprite.path);1521 console.debug('Expression image error', spriteFile.imageSrc, error);
1562 $(this).attr('src', '');1522 $(this).attr('src', '');
1563 $(this).off('error');1523 $(this).off('error');
1564 if (force && extension_settings.expressions.showDefault) {1524 if (force && extension_settings.expressions.showDefault) {
1565 setDefault();1525 setDefaultEmojiForImage(img, expression);
1566 }1526 }
1567 });1527 });
1568 }1528 }
1529
1530 console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
1569 }1531 }
1570 else {1532 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);
1585 } else {1539 } else {
1586 // Set the Talkinghead emotion to the specified expression1540 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 }
1601 }1541 }
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.
1604 }1543 }
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 }
1614}1546}
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 */
1553function 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;
1617 }1557 }
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');
1618}1565}
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 */
1572function 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');
1619}1578}
16201579
1621function onClickExpressionImage() {1580function 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);
1624}1590}
16251591
1626async function onClickExpressionAddCustom() {1592async function onClickExpressionAddCustom() {
1627 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');1593 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');
1628 let expressionName = await callPopup(template, 'input');1594 let expressionName = await Popup.show.input(null, template);
16291595
1630 if (!expressionName) {1596 if (!expressionName) {
1631 console.debug('No custom expression name provided');1597 console.debug('No custom expression name provided');
@@ -1636,19 +1602,15 @@ async function onClickExpressionAddCustom() {
16361602
1637 // a-z, 0-9, dashes and underscores only1603 // a-z, 0-9, dashes and underscores only
1638 if (!/^[a-z0-9-_]+$/.test(expressionName)) {1604 if (!/^[a-z0-9-_]+$/.test(expressionName)) {
1639 toastr.info('Invalid custom expression name provided');1605 toastr.warning('Invalid custom expression name provided', 'Add Custom Expression');
1640 return;1606 return;
1641 }1607 }
16421608 if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) {
1643 // Check if expression name already exists in default expressions1609 toastr.warning('Expression name already exists', 'Add Custom Expression');
1644 if (DEFAULT_EXPRESSIONS.includes(expressionName)) {
1645 toastr.info('Expression name already exists');
1646 return;1610 return;
1647 }1611 }
1648
1649 // Check if expression name already exists in custom expressions
1650 if (extension_settings.expressions.custom.includes(expressionName)) {1612 if (extension_settings.expressions.custom.includes(expressionName)) {
1651 toastr.info('Custom expression already exists');1613 toastr.warning('Custom expression already exists', 'Add Custom Expression');
1652 return;1614 return;
1653 }1615 }
16541616
@@ -1665,14 +1627,15 @@ async function onClickExpressionAddCustom() {
16651627
1666async function onClickExpressionRemoveCustom() {1628async function onClickExpressionRemoveCustom() {
1667 const selectedExpression = String($('#expression_custom').val());1629 const selectedExpression = String($('#expression_custom').val());
1630 const noCustomExpressions = extension_settings.expressions.custom.length === 0;
16681631
1669 if (!selectedExpression) {1632 if (!selectedExpression || noCustomExpressions) {
1670 console.debug('No custom expression selected');1633 console.debug('No custom expression selected');
1671 return;1634 return;
1672 }1635 }
16731636
1674 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });1637 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });
1675 const confirmation = await callPopup(template, 'confirm');1638 const confirmation = await Popup.show.confirm(null, template);
16761639
1677 if (!confirmation) {1640 if (!confirmation) {
1678 console.debug('Custom expression removal cancelled');1641 console.debug('Custom expression removal cancelled');
@@ -1682,8 +1645,8 @@ async function onClickExpressionRemoveCustom() {
1682 // Remove custom expression from settings1645 // Remove custom expression from settings
1683 const index = extension_settings.expressions.custom.indexOf(selectedExpression);1646 const index = extension_settings.expressions.custom.indexOf(selectedExpression);
1684 extension_settings.expressions.custom.splice(index, 1);1647 extension_settings.expressions.custom.splice(index, 1);
1685 if (selectedExpression == getFallbackExpression()) {1648 if (selectedExpression == extension_settings.expressions.fallback_expression) {
1686 toastr.warning(`Deleted custom expression '${selectedExpression}' that was also selected as the fallback expression.\nFallback expression has been reset to '${DEFAULT_FALLBACK_EXPRESSION}'.`);1649 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');
1687 extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;1650 extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;
1688 }1651 }
1689 await renderAdditionalExpressionSettings();1652 await renderAdditionalExpressionSettings();
@@ -1707,12 +1670,35 @@ function onExpressionApiChanged() {
1707 }1670 }
1708}1671}
17091672
1710function onExpressionFallbackChanged() {1673async 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 });
1715 }1699 }
1700
1701 saveSettingsDebounced();
1716}1702}
17171703
1718async function handleFileUpload(url, formData) {1704async function handleFileUpload(url, formData) {
@@ -1739,34 +1725,111 @@ async function handleFileUpload(url, formData) {
1739 }1725 }
1740}1726}
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 */
1733function withoutExtension(fileName) {
1734 return fileName.replace(/\.[^/.]+$/, '');
1735}
1736
1737function validateExpressionSpriteName(expression, spriteName) {
1738 const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1739 const validFileName = filenameValidationRegex.test(spriteName);
1740 return validFileName;
1741}
1742
1742async function onClickExpressionUpload(event) {1743async function onClickExpressionUpload(event) {
1743 // Prevents the expression from being set1744 // Prevents the expression from being set
1744 event.stopPropagation();1745 event.stopPropagation();
17451746
1746 const id = $(this).closest('.expression_list_item').attr('id');1747 const expressionListItem = $(this).closest('.expression_list_item');
1748
1749 const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null;
1750 const expression = expressionListItem.data('expression');
1747 const name = $('#image_list').data('name');1751 const name = $('#image_list').data('name');
17481752
1749 const handleExpressionUploadChange = async (e) => {1753 const handleExpressionUploadChange = async (e) => {
1750 const file = e.target.files[0];1754 const file = e.target.files[0];
17511755
1752 if (!file) {1756 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();
1753 return;1820 return;
1754 }1821 }
17551822
1756 const formData = new FormData();1823 const formData = new FormData();
1757 formData.append('name', name);1824 formData.append('name', name);
1758 formData.append('label', id);1825 formData.append('label', expression);
1759 formData.append('avatar', file);1826 formData.append('avatar', file);
1827 formData.append('spriteName', spriteName);
17601828
1761 await handleFileUpload('/api/sprites/upload', formData);1829 await handleFileUpload('/api/sprites/upload', formData);
17621830
1763 // Reset the input1831 // Reset the input
1764 e.target.form.reset();1832 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 }
1770 };1833 };
17711834
1772 $('#expression_upload')1835 $('#expression_upload')
@@ -1822,8 +1885,9 @@ async function onClickExpressionOverrideButton() {
1822 inApiCall = true;1885 inApiCall = true;
1823 $('#visual-novel-wrapper').empty();1886 $('#visual-novel-wrapper').empty();
1824 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);1887 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
1888 const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
1825 const expression = await getExpressionLabel(currentLastMessage.mes);1889 const expression = await getExpressionLabel(currentLastMessage.mes);
1826 await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true);1890 await sendExpressionCall(name, expression, { force: true });
1827 forceUpdateVisualNovelMode();1891 forceUpdateVisualNovelMode();
1828 } catch (error) {1892 } catch (error) {
1829 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);1893 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
@@ -1849,7 +1913,7 @@ async function onClickExpressionOverrideRemoveAllButton() {
1849 const currentLastMessage = getLastCharacterMessage();1913 const currentLastMessage = getLastCharacterMessage();
1850 await validateImages(currentLastMessage.name, true);1914 await validateImages(currentLastMessage.name, true);
1851 const expression = await getExpressionLabel(currentLastMessage.mes);1915 const expression = await getExpressionLabel(currentLastMessage.mes);
1852 await sendExpressionCall(currentLastMessage.name, expression, true);1916 await sendExpressionCall(currentLastMessage.name, expression, { force: true });
1853 forceUpdateVisualNovelMode();1917 forceUpdateVisualNovelMode();
18541918
1855 console.debug(extension_settings.expressionOverrides);1919 console.debug(extension_settings.expressionOverrides);
@@ -1872,16 +1936,13 @@ async function onClickExpressionUploadPackButton() {
1872 formData.append('name', name);1936 formData.append('name', name);
1873 formData.append('avatar', file);1937 formData.append('avatar', file);
18741938
1939 const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 });
1875 const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);1940 const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
1941 toastr.clear(uploadToast);
1876 toastr.success(`Uploaded ${count} image(s) for ${name}`);1942 toastr.success(`Uploaded ${count} image(s) for ${name}`);
18771943
1878 // Reset the input1944 // Reset the input
1879 e.target.form.reset();1945 e.target.form.reset();
1880
1881 // In Talkinghead mode, refresh the live char.
1882 if (isTalkingHeadEnabled() && modules.includes('talkinghead')) {
1883 await loadTalkingHead();
1884 }
1885 };1946 };
18861947
1887 $('#expression_upload_pack')1948 $('#expression_upload_pack')
@@ -1894,20 +1955,28 @@ async function onClickExpressionDelete(event) {
1894 // Prevents the expression from being set1955 // Prevents the expression from being set
1895 event.stopPropagation();1956 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>');
1899 if (!confirmation) {1968 if (!confirmation) {
1900 return;1969 return;
1901 }1970 }
19021971
1903 const id = $(this).closest('.expression_list_item').attr('id');1972 const fileName = withoutExtension(expressionListItem.attr('data-filename'));
1904 const name = $('#image_list').data('name');1973 const name = $('#image_list').data('name');
19051974
1906 try {1975 try {
1907 await fetch('/api/sprites/delete', {1976 await fetch('/api/sprites/delete', {
1908 method: 'POST',1977 method: 'POST',
1909 headers: getRequestHeaders(),1978 headers: getRequestHeaders(),
1910 body: JSON.stringify({ name, label: id }),1979 body: JSON.stringify({ name, label: expression, spriteName: fileName }),
1911 });1980 });
1912 } catch (error) {1981 } catch (error) {
1913 toastr.error('Failed to delete image. Try again later.');1982 toastr.error('Failed to delete image. Try again later.');
@@ -1984,6 +2053,16 @@ function migrateSettings() {
1984 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;2053 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
1985 saveSettingsDebounced();2054 saveSettingsDebounced();
1986 }2055 }
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 }
1987}2066}
19882067
1989(async function () {2068(async function () {
@@ -2010,13 +2089,19 @@ function migrateSettings() {
2010 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');2089 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
2011 $('#expressions_container').append(template);2090 $('#expressions_container').append(template);
2012 $('#expression_override_button').on('click', onClickExpressionOverrideButton);2091 $('#expression_override_button').on('click', onClickExpressionOverrideButton);
2013 $('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
2014 $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);2092 $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
2015 $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
2016 $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {2093 $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {
2017 extension_settings.expressions.translate = !!$(this).prop('checked');2094 extension_settings.expressions.translate = !!$(this).prop('checked');
2018 saveSettingsDebounced();2095 saveSettingsDebounced();
2019 });2096 });
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 });
2020 $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);2105 $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
2021 $(document).on('dragstart', '.expression', (e) => {2106 $(document).on('dragstart', '.expression', (e) => {
2022 e.preventDefault();2107 e.preventDefault();
@@ -2025,21 +2110,15 @@ function migrateSettings() {
2025 $(document).on('click', '.expression_list_item', onClickExpressionImage);2110 $(document).on('click', '.expression_list_item', onClickExpressionImage);
2026 $(document).on('click', '.expression_list_upload', onClickExpressionUpload);2111 $(document).on('click', '.expression_list_upload', onClickExpressionUpload);
2027 $(document).on('click', '.expression_list_delete', onClickExpressionDelete);2112 $(document).on('click', '.expression_list_delete', onClickExpressionDelete);
2028 $(window).on('resize', updateVisualNovelModeDebounced);2113 $(window).on('resize', () => updateVisualNovelModeDebounced());
2029 $('#open_chat_expressions').hide();2114 $('#open_chat_expressions').hide();
20302115
2031 $('#image_type_toggle').on('click', function () {
2032 if (this instanceof HTMLInputElement) {
2033 setTalkingHeadState(this.checked);
2034 }
2035 });
2036
2037 await renderAdditionalExpressionSettings();2116 await renderAdditionalExpressionSettings();
2038 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);2117 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras);
2039 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));2118 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
2040 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');2119 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');
2041 $('#expression_llm_prompt').on('input', function () {2120 $('#expression_llm_prompt').on('input', function () {
2042 extension_settings.expressions.llmPrompt = $(this).val();2121 extension_settings.expressions.llmPrompt = String($(this).val());
2043 saveSettingsDebounced();2122 saveSettingsDebounced();
2044 });2123 });
2045 $('#expression_llm_prompt_restore').on('click', function () {2124 $('#expression_llm_prompt_restore').on('click', function () {
@@ -2054,34 +2133,6 @@ function migrateSettings() {
2054 $('#expression_api').on('change', onExpressionApiChanged);2133 $('#expression_api').on('change', onExpressionApiChanged);
2055 }2134 }
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
2085 addExpressionImage();2136 addExpressionImage();
2086 addVisualNovelMode();2137 addVisualNovelMode();
2087 migrateSettings();2138 migrateSettings();
@@ -2090,11 +2141,6 @@ function migrateSettings() {
2090 const updateFunction = wrapper.update.bind(wrapper);2141 const updateFunction = wrapper.update.bind(wrapper);
2091 setInterval(updateFunction, UPDATE_INTERVAL);2142 setInterval(updateFunction, UPDATE_INTERVAL);
2092 moduleWorker();2143 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();
2098 dragElement($('#expression-holder'));2144 dragElement($('#expression-holder'));
2099 eventSource.on(event_types.CHAT_CHANGED, () => {2145 eventSource.on(event_types.CHAT_CHANGED, () => {
2100 // character changed2146 // character changed
@@ -2108,110 +2154,137 @@ function migrateSettings() {
2108 imgElement.src = '';2154 imgElement.src = '';
2109 }2155 }
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
2117 setExpressionOverrideHtml();2157 setExpressionOverrideHtml();
21182158
2119 if (isVisualNovelMode()) {2159 if (isVisualNovelMode()) {
2120 $('#visual-novel-wrapper').empty();2160 $('#visual-novel-wrapper').empty();
2121 }2161 }
21222162
2123 updateFunction();2163 updateFunction({ newChat: true });
2124 });2164 });
2125 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);2165 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
2126 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);2166 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
2133 const localEnumProviders = {2168 const localEnumProviders = {
2134 expressions: () => getCachedExpressions().map(expression => {2169 expressions: () => {
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;
2135 const isCustom = extension_settings.expressions.custom?.includes(expression);2175 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 },
2138 };2195 };
21392196
2140 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2197 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2141 name: 'sprite',2198 name: 'expression-set',
2142 aliases: ['emote'],2199 aliases: ['sprite', 'emote'],
2143 callback: setSpriteSlashCommand,2200 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 ],
2144 unnamedArgumentList: [2211 unnamedArgumentList: [
2145 SlashCommandArgument.fromProps({2212 SlashCommandArgument.fromProps({
2146 description: 'spriteId',2213 description: 'expression label to set',
2147 typeList: [ARGUMENT_TYPE.STRING],2214 typeList: [ARGUMENT_TYPE.STRING],
2148 isRequired: true,2215 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 },
2150 }),2225 }),
2151 ],2226 ],
2152 helpString: 'Force sets the sprite for the current character.',2227 helpString: 'Force sets the expression for the current character.',
2153 returns: 'the currently set sprite label after setting it.',2228 returns: 'The currently set expression label after setting it.',
2154 }));2229 }));
2155 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2230 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2156 name: 'spriteoverride',2231 name: 'expression-folder-override',
2157 aliases: ['costume'],2232 aliases: ['spriteoverride', 'costume'],
2158 callback: setSpriteSetCommand,2233 callback: setSpriteFolderCommand,
2159 unnamedArgumentList: [2234 unnamedArgumentList: [
2160 new SlashCommandArgument(2235 new SlashCommandArgument(
2161 'optional folder', [ARGUMENT_TYPE.STRING], false,2236 'optional folder', [ARGUMENT_TYPE.STRING], false,
2162 ),2237 ),
2163 ],2238 ],
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 `,
2165 }));2248 }));
2166 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2249 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2167 name: 'lastsprite',2250 name: 'expression-last',
2168 callback: (_, name) => {2251 aliases: ['lastsprite'],
2252 /** @type {(args: object, name: string) => Promise<string>} */
2253 callback: async (_, name) => {
2169 if (typeof name !== 'string') throw new Error('name must be a string');2254 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
2170 const char = findChar({ name: name });2263 const char = findChar({ name: name });
2264 if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
2265
2171 const sprite = lastExpression[char?.name ?? name] ?? '';2266 const sprite = lastExpression[char?.name ?? name] ?? '';
2172 return sprite;2267 return sprite;
2173 },2268 },
2174 returns: 'the last set sprite / expression for the named character.',2269 returns: 'the last set expression for the named character.',
2175 unnamedArgumentList: [2270 unnamedArgumentList: [
2176 SlashCommandArgument.fromProps({2271 SlashCommandArgument.fromProps({
2177 description: 'Character name - or unique character identifier (avatar key)',2272 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)',
2178 typeList: [ARGUMENT_TYPE.STRING],2273 typeList: [ARGUMENT_TYPE.STRING],
2179 isRequired: true,
2180 enumProvider: commonEnumProviders.characters('character'),2274 enumProvider: commonEnumProviders.characters('character'),
2181 }),2275 }),
2182 ],2276 ],
2183 helpString: 'Returns the last set sprite / expression for the named character.',2277 helpString: 'Returns the last set 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.',
2191 }));2278 }));
2192 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2279 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2193 name: 'classify-expressions',2280 name: 'expression-list',
2194 aliases: ['expressions'],2281 aliases: ['expressions'],
2282 /** @type {(args: {return: string}) => Promise<string>} */
2195 callback: async (args) => {2283 callback: async (args) => {
2284 let returnType =
2196 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */2285 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2197 // @ts-ignore2286 (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
2215 const list = await getExpressionsList();2288 const list = await getExpressionsList();
22162289
2217 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });2290 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
@@ -2225,22 +2298,13 @@ function migrateSettings() {
2225 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),2298 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
2226 forceEnum: true,2299 forceEnum: true,
2227 }),2300 }),
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 }),
2238 ],2301 ],
2239 returns: 'The comma-separated list of available expressions, including custom expressions.',2302 returns: 'The comma-separated list of available expressions, including custom expressions.',
2240 helpString: 'Returns a list of available expressions, including custom expressions.',2303 helpString: 'Returns a list of available expressions, including custom expressions.',
2241 }));2304 }));
2242 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2305 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2243 name: 'classify',2306 name: 'expression-classify',
2307 aliases: ['classify'],
2244 callback: classifyCallback,2308 callback: classifyCallback,
2245 namedArgumentList: [2309 namedArgumentList: [
2246 SlashCommandNamedArgument.fromProps({2310 SlashCommandNamedArgument.fromProps({
@@ -2279,11 +2343,13 @@ function migrateSettings() {
2279 `,2343 `,
2280 }));2344 }));
2281 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2345 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2282 name: 'uploadsprite',2346 name: 'expression-upload',
2347 aliases: ['uploadsprite'],
2348 /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
2283 callback: async (args, url) => {2349 callback: async (args, url) => {
2284 await uploadSpriteCommand(args, url);2350 return await uploadSpriteCommand(args, url);
2285 return '';
2286 },2351 },
2352 returns: 'the resulting sprite name',
2287 unnamedArgumentList: [2353 unnamedArgumentList: [
2288 SlashCommandArgument.fromProps({2354 SlashCommandArgument.fromProps({
2289 description: 'URL of the image to upload',2355 description: 'URL of the image to upload',
@@ -2297,7 +2363,6 @@ function migrateSettings() {
2297 description: 'Character name or avatar key (default is current character)',2363 description: 'Character name or avatar key (default is current character)',
2298 typeList: [ARGUMENT_TYPE.STRING],2364 typeList: [ARGUMENT_TYPE.STRING],
2299 isRequired: false,2365 isRequired: false,
2300 acceptsMultiple: false,
2301 }),2366 }),
2302 SlashCommandNamedArgument.fromProps({2367 SlashCommandNamedArgument.fromProps({
2303 name: 'label',2368 name: 'label',
@@ -2305,16 +2370,32 @@ function migrateSettings() {
2305 typeList: [ARGUMENT_TYPE.STRING],2370 typeList: [ARGUMENT_TYPE.STRING],
2306 enumProvider: localEnumProviders.expressions,2371 enumProvider: localEnumProviders.expressions,
2307 isRequired: true,2372 isRequired: true,
2308 acceptsMultiple: false,
2309 }),2373 }),
2310 SlashCommandNamedArgument.fromProps({2374 SlashCommandNamedArgument.fromProps({
2311 name: 'folder',2375 name: 'folder',
2312 description: 'Override folder to upload into',2376 description: 'Override folder to upload into',
2313 typeList: [ARGUMENT_TYPE.STRING],2377 typeList: [ARGUMENT_TYPE.STRING],
2314 isRequired: false,2378 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,
2316 }),2385 }),
2317 ],2386 ],
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 `,
2319 }));2400 }));
2320})();2401})();
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}}">
2 <div class="expression_list_buttons">3 <div class="expression_list_buttons">
3 <div class="menu_button expression_list_upload" title="Upload image">4 <div class="menu_button expression_list_upload" title="Upload image">
4 <i class="fa-solid fa-upload"></i>5 <i class="fa-solid fa-upload"></i>
@@ -7,11 +8,14 @@
7 <i class="fa-solid fa-trash"></i>8 <i class="fa-solid fa-trash"></i>
8 </div>9 </div>
9 </div>10 </div>
10 <div class="expression_list_title {{textClass}}">11 <div class="expression_list_title">
11 <span>{{item}}</span>12 <span>{{../expression}}</span>
12 {{#if isCustom}}13 {{#if ../isCustom}}
13 <small class="expression_list_custom">(custom)</small>14 <small class="expression_list_custom">(custom)</small>
14 {{/if}}15 {{/if}}
15 </div>16 </div>
16 <img class="expression_list_image" src="{{imageSrc}}" />17 <div class="expression_list_image_container" title="{{this.title}}">
18 <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" />
17 </div>19 </div>
20</div>
21{{/each}}
public/scripts/extensions/expressions/settings.html+21 -9
@@ -6,17 +6,17 @@
6 </div>6 </div>
77
8 <div class="inline-drawer-content">8 <div class="inline-drawer-content">
9 <label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings.">9 <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.">
10 <input id="expression_translate" type="checkbox">10 <input id="expression_translate" type="checkbox">
11 <span data-i18n="Translate text to English before classification">Translate text to English before classification</span>11 <span data-i18n="Translate text to English before classification">Translate text to English before classification</span>
12 </label>12 </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.">
14 <input id="expressions_show_default" type="checkbox">14 <input id="expressions_allow_multiple" type="checkbox">
15 <span data-i18n="Show default images (emojis) if sprite missing">Show default images (emojis) if sprite missing</span>15 <span data-i18n="Allow multiple sprites per expression">Allow multiple sprites per expression</span>
16 </label>16 </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.">
18 <input id="image_type_toggle" type="checkbox">18 <input id="expressions_reroll_if_same" type="checkbox">
19 <span data-i18n="Image Type - talkinghead (extras)">Image Type - talkinghead (extras)</span>19 <span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span>
20 </label>20 </label>
21 <div class="expression_api_block m-b-1 m-t-1">21 <div class="expression_api_block m-b-1 m-t-1">
22 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>22 <label for="expression_api" data-i18n="Classifier API">Classifier API</label>
@@ -75,8 +75,20 @@
75 <span data-i18n="Remove all image overrides">Remove all image overrides</span>75 <span data-i18n="Remove all image overrides">Remove all image overrides</span>
76 </div>76 </div>
77 </div>77 </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>
80 <h3 id="image_list_header">92 <h3 id="image_list_header">
81 <strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>93 <strong data-i18n="Sprite set:">Sprite set:</strong>&nbsp;<span id="image_list_header_name"></span>
82 </h3>94 </h3>
public/scripts/extensions/expressions/style.css+31 -2
@@ -111,6 +111,10 @@ img.expression.default {
111 justify-content: center;111 justify-content: center;
112}112}
113113
114.expression_list_image_container {
115 overflow: hidden;
116}
117
114.expression_list_title {118.expression_list_title {
115 position: absolute;119 position: absolute;
116 bottom: 0;120 bottom: 0;
@@ -126,6 +130,9 @@ img.expression.default {
126 flex-direction: column;130 flex-direction: column;
127 line-height: 1;131 line-height: 1;
128}132}
133.expression_list_custom {
134 font-size: 0.66rem;
135}
129136
130.expression_list_buttons {137.expression_list_buttons {
131 position: absolute;138 position: absolute;
@@ -162,11 +169,24 @@ img.expression.default {
162 row-gap: 1rem;169 row-gap: 1rem;
163}170}
164171
165#image_list .success {172#image_list .expression_list_item[data-expression-type="success"] .expression_list_title {
166 color: green;173 color: green;
167}174}
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 {
170 color: red;190 color: red;
171}191}
172192
@@ -189,3 +209,12 @@ img.expression.default {
189 flex-direction: row;209 flex-direction: row;
190}210}
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, () =>
605const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));605const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes));
606const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);606const handleMessageEdit = createEventHandler(translateMessageEdit, () => true);
607607
608window['translate'] = translate;608globalThis.translate = translate;
609609
610jQuery(async () => {610jQuery(async () => {
611 const html = await renderExtensionTemplateAsync('translate', 'index');611 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
27import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';27import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';28import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
29import { GoogleTranslateTtsProvider } from './google-translate.js';29import { GoogleTranslateTtsProvider } from './google-translate.js';
30export { talkingAnimation };
3130
32const UPDATE_INTERVAL = 1000;31const UPDATE_INTERVAL = 1000;
33const wrapper = new ModuleWorkerWrapper(moduleWorker);32const wrapper = new ModuleWorkerWrapper(moduleWorker);
3433
35let voiceMapEntries = [];34let voiceMapEntries = [];
36let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}35let voiceMap = {}; // {charName:voiceid, charName2:voiceid2}
37let talkingHeadState = false;
38let lastChatId = null;36let lastChatId = null;
39let lastMessage = null;37let lastMessage = null;
40let lastMessageHash = null;38let lastMessageHash = null;
@@ -166,27 +164,6 @@ async function moduleWorker() {
166 updateUiAudioPlayState();164 updateUiAudioPlayState();
167}165}
168166
169function 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
190function resetTtsPlayback() {167function resetTtsPlayback() {
191 // Stop system TTS utterance168 // Stop system TTS utterance
192 cancelTtsPlay();169 cancelTtsPlay();
@@ -378,7 +355,6 @@ function onAudioControlClicked() {
378 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful355 // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful
379 if (!audioElement.paused || isTtsProcessing()) {356 if (!audioElement.paused || isTtsProcessing()) {
380 resetTtsPlayback();357 resetTtsPlayback();
381 talkingAnimation(false);
382 } else {358 } else {
383 // Default play behavior if not processing or playing is to play the last message.359 // Default play behavior if not processing or playing is to play the last message.
384 processAndQueueTtsMessage(context.chat[context.chat.length - 1]);360 processAndQueueTtsMessage(context.chat[context.chat.length - 1]);
@@ -405,7 +381,6 @@ function addAudioControl() {
405function completeCurrentAudioJob() {381function completeCurrentAudioJob() {
406 audioQueueProcessorReady = true;382 audioQueueProcessorReady = true;
407 currentAudioJob = null;383 currentAudioJob = null;
408 talkingAnimation(false); //stop lip animation
409 // updateUiPlayState();384 // updateUiPlayState();
410 wrapper.update();385 wrapper.update();
411}386}
@@ -436,7 +411,6 @@ async function processAudioJobQueue() {
436 audioQueueProcessorReady = false;411 audioQueueProcessorReady = false;
437 currentAudioJob = audioJobQueue.shift();412 currentAudioJob = audioJobQueue.shift();
438 playAudioData(currentAudioJob);413 playAudioData(currentAudioJob);
439 talkingAnimation(true);
440 } catch (error) {414 } catch (error) {
441 toastr.error(error.toString());415 toastr.error(error.toString());
442 console.error(error);416 console.error(error);
public/scripts/extensions/tts/system.js+0 -3
@@ -1,6 +1,5 @@
1import { isMobile } from '../../RossAscends-mods.js';1import { isMobile } from '../../RossAscends-mods.js';
2import { getPreviewString } from './index.js';2import { getPreviewString } from './index.js';
3import { talkingAnimation } from './index.js';
4import { saveTtsProviderSettings } from './index.js';3import { saveTtsProviderSettings } from './index.js';
5export { SystemTtsProvider };4export { SystemTtsProvider };
65
@@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) {
70 //placing the speak invocation inside a callback fixes ordering and onend issues.69 //placing the speak invocation inside a callback fixes ordering and onend issues.
71 setTimeout(function () {70 setTimeout(function () {
72 speechSynthesis.speak(newUtt);71 speechSynthesis.speak(newUtt);
73 talkingAnimation(true);
74 }, 0);72 }, 0);
75};73};
7674
@@ -240,7 +238,6 @@ class SystemTtsProvider {
240 //some code to execute when done238 //some code to execute when done
241 resolve(silence);239 resolve(silence);
242 console.log('System TTS done');240 console.log('System TTS done');
243 talkingAnimation(false);
244 });241 });
245 });242 });
246 }243 }
public/scripts/extensions/vectors/index.js+2 -2
@@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) {
561 */561 */
562async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {562async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) {
563 try {563 try {
564 if (settings.translate_files && typeof window['translate'] === 'function') {564 if (settings.translate_files && typeof globalThis.translate === 'function') {
565 console.log(`Vectors: Translating file ${fileName} to English...`);565 console.log(`Vectors: Translating file ${fileName} to English...`);
566 const translatedText = await window['translate'](fileText, 'en');566 const translatedText = await globalThis.translate(fileText, 'en');
567 fileText = translatedText;567 fileText = translatedText;
568 }568 }
569569
public/scripts/power-user.js+5 -4
@@ -1845,14 +1845,15 @@ async function loadContextSettings() {
18451845
1846/**1846/**
1847 * Common function to perform fuzzy search with optional caching1847 * Common function to perform fuzzy search with optional caching
1848 * @template T
1848 * @param {string} type - Type of search from fuzzySearchCategories1849 * @param {string} type - Type of search from fuzzySearchCategories
1849 * @param {any[]} data - Data array to search in1850 * @param {T[]} data - Data array to search in
1850 * @param {Array<{name: string, weight: number, getFn?: (obj: any) => string}>} keys - Fuse.js keys configuration1851 * @param {Array<{name: string, weight: number, getFn?: (obj: T) => string}>} keys - Fuse.js keys configuration
1851 * @param {string} searchValue - The search term1852 * @param {string} searchValue - The search term
1852 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches1853 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1853 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score1854 * @returns {import('fuse.js').FuseResult<T>[]} Results as items with their score
1854 */1855 */
1855function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {1856export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
1856 // Check cache if provided1857 // Check cache if provided
1857 if (fuzzySearchCaches) {1858 if (fuzzySearchCaches) {
1858 const cache = fuzzySearchCaches[type];1859 const cache = fuzzySearchCaches[type];
src/endpoints/sprites.js+13 -5
@@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) {
125 .map((file) => {125 .map((file) => {
126 const pathToSprite = path.join(spritesPath, file);126 const pathToSprite = path.join(spritesPath, file);
127 const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14);127 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
128 return {134 return {
129 label: path.parse(pathToSprite).name.toLowerCase(),135 label: label,
130 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),136 path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''),
131 };137 };
132 });138 });
@@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) {
141router.post('/delete', jsonParser, async (request, response) => {147router.post('/delete', jsonParser, async (request, response) => {
142 const label = request.body.label;148 const label = request.body.label;
143 const name = request.body.name;149 const name = request.body.name;
150 const spriteName = request.body.spriteName || label;
144151
145 if (!label || !name) {152 if (!spriteName || !name) {
146 return response.sendStatus(400);153 return response.sendStatus(400);
147 }154 }
148155
@@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => {
158165
159 // Remove existing sprite with the same label166 // Remove existing sprite with the same label
160 for (const file of files) {167 for (const file of files) {
161 if (path.parse(file).name === label) {168 if (path.parse(file).name === spriteName) {
162 fs.rmSync(path.join(spritesPath, file));169 fs.rmSync(path.join(spritesPath, file));
163 }170 }
164 }171 }
@@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => {
221 const file = request.file;228 const file = request.file;
222 const label = request.body.label;229 const label = request.body.label;
223 const name = request.body.name;230 const name = request.body.name;
231 const spriteName = request.body.spriteName || label;
224232
225 if (!file || !label || !name) {233 if (!file || !label || !name) {
226 return response.sendStatus(400);234 return response.sendStatus(400);
@@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => {
243251
244 // Remove existing sprite with the same label252 // Remove existing sprite with the same label
245 for (const file of files) {253 for (const file of files) {
246 if (path.parse(file).name === label) {254 if (path.parse(file).name === spriteName) {
247 fs.rmSync(path.join(spritesPath, file));255 fs.rmSync(path.join(spritesPath, file));
248 }256 }
249 }257 }
250258
251 const filename = label + path.parse(file.originalname).ext;259 const filename = spriteName + path.parse(file.originalname).ext;
252 const spritePath = path.join(file.destination, file.filename);260 const spritePath = path.join(file.destination, file.filename);
253 const pathToFile = path.join(spritesPath, filename);261 const pathToFile = path.join(spritesPath, filename);
254 // Copy uploaded file to sprites folder262 // Copy uploaded file to sprites folder