Blame Raw
Cohee · 51ad27fb · · 2576 lines (101.5 KB)
3 contributors
1import { Fuse } from '../../../lib.js';
2
3import { characters, eventSource, event_types, generateQuietPrompt, generateRaw, getRequestHeaders, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
4import { dragElement, isMobile } from '../../RossAscends-mods.js';
5import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
6import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
7import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean, includesIgnoreCaseAndAccents } from '../../utils.js';
8import { hideMutedSprites, selected_group } from '../../group-chats.js';
9import { isJsonSchemaSupported } from '../../textgen-settings.js';
10import { debounce_timeout } from '../../constants.js';
11import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
12import { SlashCommand } from '../../slash-commands/SlashCommand.js';
13import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
14import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
15import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
18import { Popup, POPUP_RESULT } from '../../popup.js';
19import { t } from '../../i18n.js';
20import { removeReasoningFromString } from '../../reasoning.js';
21export { MODULE_NAME };
22
23/**
24* @typedef {object} Expression Expression definition with label and file path
25* @property {string} label The label of the expression
26* @property {ExpressionImage[]} files One or more images to represent this expression
27*/
28
29/**
30 * @typedef {object} ExpressionImage An expression image
31 * @property {string} expression - The expression
32 * @property {boolean} [isCustom=false] - If the expression is added by user
33 * @property {string} fileName - The filename with extension
34 * @property {string} title - The title for the image
35 * @property {string} imageSrc - The image source / full path
36 * @property {'success' | 'additional' | 'failure'} type - The type of the image
37 */
38
39const MODULE_NAME = 'expressions';
40const UPDATE_INTERVAL = 2000;
41const STREAMING_UPDATE_INTERVAL = 10000;
42const DEFAULT_FALLBACK_EXPRESSION = 'joy';
43const 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}}';
44const DEFAULT_EXPRESSIONS = [
45 'admiration',
46 'amusement',
47 'anger',
48 'annoyance',
49 'approval',
50 'caring',
51 'confusion',
52 'curiosity',
53 'desire',
54 'disappointment',
55 'disapproval',
56 'disgust',
57 'embarrassment',
58 'excitement',
59 'fear',
60 'gratitude',
61 'grief',
62 'joy',
63 'love',
64 'nervousness',
65 'optimism',
66 'pride',
67 'realization',
68 'relief',
69 'remorse',
70 'sadness',
71 'surprise',
72 'neutral',
73];
74
75const OPTION_NO_FALLBACK = '#none';
76const OPTION_EMOJI_FALLBACK = '#emoji';
77const RESET_SPRITE_LABEL = '#reset';
78
79
80/** @enum {number} */
81const EXPRESSION_API = {
82 local: 0,
83 extras: 1,
84 llm: 2,
85 webllm: 3,
86 none: 99,
87};
88
89/** @enum {string} */
90const PROMPT_TYPE = {
91 raw: 'raw',
92 full: 'full',
93};
94
95let expressionsList = null;
96let lastCharacter = undefined;
97let lastMessage = null;
98/** @type {{[characterKey: string]: Expression[]}} */
99let spriteCache = {};
100let inApiCall = false;
101let lastServerResponseTime = 0;
102
103/** @type {{[characterName: string]: string}} */
104export let lastExpression = {};
105
106/**
107 * Returns a placeholder image object for a given expression
108 * @param {string} expression - The expression label
109 * @param {boolean} [isCustom=false] - Whether the expression is custom
110 * @returns {ExpressionImage} The placeholder image object
111 */
112function getPlaceholderImage(expression, isCustom = false) {
113 return {
114 expression: expression,
115 isCustom: isCustom,
116 title: 'No Image',
117 type: 'failure',
118 fileName: 'No-Image-Placeholder.svg',
119 imageSrc: '/img/No-Image-Placeholder.svg',
120 };
121}
122
123function isVisualNovelMode() {
124 return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
125}
126
127async function forceUpdateVisualNovelMode() {
128 if (isVisualNovelMode()) {
129 await updateVisualNovelMode();
130 }
131}
132
133const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);
134
135async function updateVisualNovelMode(spriteFolderName, expression) {
136 const vnContainer = $('#visual-novel-wrapper');
137
138 await visualNovelRemoveInactive(vnContainer);
139
140 const setSpritePromises = await visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression);
141
142 // calculate layer indices based on recent messages
143 await visualNovelUpdateLayers(vnContainer);
144
145 await Promise.allSettled(setSpritePromises);
146
147 // update again based on new sprites
148 if (setSpritePromises.length > 0) {
149 await visualNovelUpdateLayers(vnContainer);
150 }
151}
152
153async function visualNovelRemoveInactive(container) {
154 const context = getContext();
155 const group = context.groups.find(x => x.id == context.groupId);
156 const removeInactiveCharactersPromises = [];
157
158 // remove inactive characters after 1 second
159 container.find('.expression-holder').each((_, current) => {
160 const promise = new Promise(resolve => {
161 const element = $(current);
162 const avatar = element.data('avatar');
163
164 if (!group.members.includes(avatar) || group.disabled_members.includes(avatar)) {
165 element.fadeOut(250, () => {
166 element.remove();
167 resolve();
168 });
169 } else {
170 resolve();
171 }
172 });
173
174 removeInactiveCharactersPromises.push(promise);
175 });
176
177 await Promise.allSettled(removeInactiveCharactersPromises);
178}
179
180/**
181 * Sets the character sprites for visual novel mode based on the provided container, name, and expression.
182 *
183 * @param {JQuery<HTMLElement>} vnContainer - The container element where the sprites will be set
184 * @param {string} spriteFolderName - The name of the sprite folder
185 * @param {string} expression - The expression to set for the characters
186 * @returns {Promise<Array>} - An array of promises that resolve when the sprites are set
187 */
188async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) {
189 const originalExpression = expression;
190 const context = getContext();
191 const group = context.groups.find(x => x.id == context.groupId);
192
193 const setSpritePromises = [];
194
195 for (const avatar of group.members) {
196 // skip disabled characters
197 const isDisabled = group.disabled_members.includes(avatar);
198 if (isDisabled && hideMutedSprites) {
199 continue;
200 }
201
202 const character = context.characters.find(x => x.avatar == avatar);
203 if (!character) {
204 continue;
205 }
206
207 const expressionImage = vnContainer.find(`.expression-holder[data-avatar="${avatar}"]`);
208 /** @type {JQuery<HTMLElement>} */
209 let img;
210
211 const memberSpriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
212
213 // download images if not downloaded yet
214 if (spriteCache[memberSpriteFolderName] === undefined) {
215 spriteCache[memberSpriteFolderName] = await getSpritesList(memberSpriteFolderName);
216 }
217
218 const prevExpressionSrc = expressionImage.find('img').attr('src') || null;
219
220 if (!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) {
221 expression = await getLastMessageSprite(avatar);
222 }
223
224 const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc });
225 if (expressionImage.length) {
226 if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) {
227 await validateImages(memberSpriteFolderName, true);
228 setExpressionOverrideHtml(true); // <= force clear expression override input
229 const path = spriteFile?.imageSrc || '';
230 img = expressionImage.find('img');
231 await setImage(img, path);
232 }
233 expressionImage.toggleClass('hidden', !spriteFile);
234 } else {
235 const template = $('#expression-holder').clone();
236 template.attr('id', `expression-${avatar}`);
237 template.attr('data-avatar', avatar);
238 template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
239 $('#visual-novel-wrapper').append(template);
240 dragElement($(template[0]));
241 template.toggleClass('hidden', !spriteFile);
242 img = template.find('img');
243 await setImage(img, spriteFile?.imageSrc || '');
244 const fadeInPromise = new Promise(resolve => {
245 template.fadeIn(250, () => resolve());
246 });
247 setSpritePromises.push(fadeInPromise);
248 }
249
250 if (!img) {
251 continue;
252 }
253
254 img.attr('data-sprite-folder-name', spriteFolderName);
255 img.attr('data-expression', expression);
256 img.attr('data-sprite-filename', spriteFile?.fileName || null);
257 img.attr('title', expression);
258
259 if (spriteFile) console.info(`Expression set for group member ${character.name}`, { expression: spriteFile.expression, file: spriteFile.fileName });
260 else if (expressionImage.length) console.info(`Expression unset for group member ${character.name} - No sprite found`, { expression: expression });
261 else console.info(`Expression not available for group member ${character.name}`, { expression: expression });
262 }
263
264 return setSpritePromises;
265}
266
267/**
268 * Classifies the text of the latest message and returns the expression label.
269 * @param {string} avatar - The avatar of the character to get the last message for
270 * @returns {Promise<string>} - The expression label
271 */
272async function getLastMessageSprite(avatar) {
273 const context = getContext();
274 const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
275
276 if (lastMessage) {
277 const text = lastMessage.mes || '';
278 return await getExpressionLabel(text);
279 }
280
281 return null;
282}
283
284export async function visualNovelUpdateLayers(container) {
285 const context = getContext();
286 const group = context.groups.find(x => x.id == context.groupId);
287 const recentMessages = context.chat.map(x => x.original_avatar).filter(x => x).reverse().filter(onlyUnique);
288 const filteredMembers = group.members.filter(x => !group.disabled_members.includes(x));
289 const layerIndices = filteredMembers.slice().sort((a, b) => {
290 const aRecentIndex = recentMessages.indexOf(a);
291 const bRecentIndex = recentMessages.indexOf(b);
292 const aFilteredIndex = filteredMembers.indexOf(a);
293 const bFilteredIndex = filteredMembers.indexOf(b);
294
295 if (aRecentIndex !== -1 && bRecentIndex !== -1) {
296 return bRecentIndex - aRecentIndex;
297 } else if (aRecentIndex !== -1) {
298 return 1;
299 } else if (bRecentIndex !== -1) {
300 return -1;
301 } else {
302 return aFilteredIndex - bFilteredIndex;
303 }
304 });
305
306 const setLayerIndicesPromises = [];
307
308 const sortFunction = (a, b) => {
309 const avatarA = $(a).data('avatar');
310 const avatarB = $(b).data('avatar');
311 const indexA = filteredMembers.indexOf(avatarA);
312 const indexB = filteredMembers.indexOf(avatarB);
313 return indexA - indexB;
314 };
315
316 const containerWidth = container.width();
317 const pivotalPoint = containerWidth * 0.5;
318
319 let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction);
320 let imagesWidth = [];
321
322 for (const image of images) {
323 if (image instanceof HTMLImageElement && !image.complete) {
324 await new Promise(resolve => image.addEventListener('load', resolve, { once: true }));
325 }
326 }
327
328 images.forEach(image => {
329 imagesWidth.push($(image).width());
330 });
331
332 let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
333 let currentPosition = pivotalPoint - (totalWidth / 2);
334
335 if (totalWidth > containerWidth) {
336 let totalOverlap = totalWidth - containerWidth;
337 let totalWidthWithoutWidest = imagesWidth.reduce((a, b) => a + b, 0) - Math.max(...imagesWidth);
338 let overlaps = imagesWidth.map(width => (width / totalWidthWithoutWidest) * totalOverlap);
339 imagesWidth = imagesWidth.map((width, index) => width - overlaps[index]);
340 currentPosition = 0; // Reset the initial position to 0
341 }
342
343 images.forEach((current, index) => {
344 const element = $(current);
345 const elementID = element.attr('id');
346
347 // skip repositioning of dragged elements
348 if (element.data('dragged')
349 || (power_user.movingUIState[elementID]
350 && (typeof power_user.movingUIState[elementID] === 'object')
351 && Object.keys(power_user.movingUIState[elementID]).length > 0)) {
352 loadMovingUIState();
353 //currentPosition += imagesWidth[index];
354 return;
355 }
356
357 const avatar = element.data('avatar');
358 const layerIndex = layerIndices.indexOf(avatar);
359 element.css('z-index', layerIndex);
360 element.show();
361
362 const promise = new Promise(resolve => {
363 if (power_user.reduced_motion) {
364 element.css('left', currentPosition + 'px');
365 requestAnimationFrame(() => resolve());
366 } else {
367 element.animate({ left: currentPosition + 'px' }, 500, () => {
368 resolve();
369 });
370 }
371 });
372
373 currentPosition += imagesWidth[index];
374
375 setLayerIndicesPromises.push(promise);
376 });
377
378 await Promise.allSettled(setLayerIndicesPromises);
379}
380
381/**
382 * Sets the expression for the given character image.
383 * @param {JQuery<HTMLElement>} img - The image element to set the image on
384 * @param {string} path - The path to the image
385 * @returns {Promise<void>} - A promise that resolves when the image is set
386 */
387async function setImage(img, path) {
388 // Cohee: If something goes wrong, uncomment this to return to the old behavior
389 /*
390 img.attr('src', path);
391 img.removeClass('default');
392 img.off('error');
393 img.on('error', function () {
394 console.debug('Error loading image', path);
395 $(this).off('error');
396 $(this).attr('src', '');
397 });
398 */
399
400 return new Promise(resolve => {
401 const prevExpressionSrc = img.attr('src');
402 const expressionClone = img.clone();
403 const originalId = img.data('filename');
404
405 //only swap expressions when necessary
406 if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
407 //clone expression
408 expressionClone.addClass('expression-clone');
409 //make invisible and remove id to prevent double ids
410 //must be made invisible to start because they share the same Z-index
411 expressionClone.data('filename', '').css({ opacity: 0 });
412 //add new sprite path to clone src
413 expressionClone.attr('src', path);
414 //add invisible clone to html
415 expressionClone.appendTo(img.parent());
416
417 const duration = 200;
418
419 //add animation flags to both images
420 //to prevent multiple expression changes happening simultaneously
421 img.addClass('expression-animating');
422
423 // Set the parent container's min width and height before running the transition
424 const imgWidth = img.width();
425 const imgHeight = img.height();
426 const expressionHolder = img.parent();
427 expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
428 expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
429
430 //position absolute prevent the original from jumping around during transition
431 img.css('position', 'absolute').width(imgWidth).height(imgHeight);
432 expressionClone.addClass('expression-animating');
433 //fade the clone in
434 expressionClone.css({
435 opacity: 0,
436 }).animate({
437 opacity: 1,
438 }, duration)
439 //when finshed fading in clone, fade out the original
440 .promise().done(function () {
441 img.animate({
442 opacity: 0,
443 }, duration);
444 //remove old expression
445 img.remove();
446 //replace ID so it becomes the new 'original' expression for next change
447 expressionClone.data('filename', originalId);
448 expressionClone.removeClass('expression-animating');
449
450 // Reset the expression holder min height and width
451 expressionHolder.css('min-width', 100);
452 expressionHolder.css('min-height', 100);
453
454 if (expressionClone.prop('complete')) {
455 resolve();
456 } else {
457 expressionClone.one('load', () => resolve());
458 }
459 });
460
461 expressionClone.removeClass('expression-clone');
462
463 expressionClone.removeClass('default');
464 expressionClone.off('error');
465 expressionClone.on('error', function () {
466 console.debug('Expression image error', path);
467 $(this).attr('src', '');
468 $(this).off('error');
469 resolve();
470 });
471 } else {
472 resolve();
473 }
474 });
475}
476
477async function moduleWorker({ newChat = false } = {}) {
478 const context = getContext();
479
480 // non-characters not supported
481 if (!context.groupId && context.characterId === undefined) {
482 removeExpression();
483 return;
484 }
485
486 const vnMode = isVisualNovelMode();
487 const vnWrapperVisible = $('#visual-novel-wrapper').is(':visible');
488
489 if (vnMode) {
490 $('#expression-wrapper').hide();
491 $('#visual-novel-wrapper').show();
492 } else {
493 $('#expression-wrapper').show();
494 $('#visual-novel-wrapper').hide();
495 }
496
497 const vnStateChanged = vnMode !== vnWrapperVisible;
498
499 if (vnStateChanged) {
500 lastMessage = null;
501 $('#visual-novel-wrapper').empty();
502 $('#expression-holder').css({ top: '', left: '', right: '', bottom: '', height: '', width: '', margin: '' });
503 }
504
505 const currentLastMessage = getLastCharacterMessage();
506 let spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
507
508 // character has no expressions or it is not loaded
509 if (Object.keys(spriteCache).length === 0) {
510 await validateImages(spriteFolderName);
511 lastCharacter = context.groupId || context.characterId;
512 }
513
514 const offlineMode = $('.expression_settings .offline_mode');
515 if (!modules.includes('classify') && extension_settings.expressions.api == EXPRESSION_API.extras) {
516 $('#open_chat_expressions').show();
517 $('#no_chat_expressions').hide();
518 offlineMode.css('display', 'block');
519 lastCharacter = context.groupId || context.characterId;
520
521 if (context.groupId) {
522 await validateImages(spriteFolderName, true);
523 await forceUpdateVisualNovelMode();
524 }
525
526 return;
527 } else {
528 // force reload expressions list on connect to API
529 if (offlineMode.is(':visible')) {
530 expressionsList = null;
531 spriteCache = {};
532 expressionsList = await getExpressionsList();
533 await validateImages(spriteFolderName, true);
534 await forceUpdateVisualNovelMode();
535 }
536
537 if (context.groupId && !Array.isArray(spriteCache[spriteFolderName])) {
538 await validateImages(spriteFolderName, true);
539 await forceUpdateVisualNovelMode();
540 }
541
542 offlineMode.css('display', 'none');
543 }
544
545 if (context.groupId && vnMode && newChat) {
546 await forceUpdateVisualNovelMode();
547 }
548
549 // Don't bother classifying if current char has no sprites and no default expressions are enabled
550 if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {
551 return;
552 }
553
554 const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastMessage === currentLastMessage.mes);
555
556 // check if last message changed
557 if (!lastMessageChanged) {
558 return;
559 }
560
561 // If using LLM api then check if streamingProcessor is finished to avoid sending multiple requests to the API
562 if (extension_settings.expressions.api === EXPRESSION_API.llm && context.streamingProcessor && !context.streamingProcessor.isFinished) {
563 return;
564 }
565
566 // API is busy
567 if (inApiCall) {
568 console.debug('Classification API is busy');
569 return;
570 }
571
572 // Throttle classification requests during streaming
573 if (!context.groupId && context.streamingProcessor && !context.streamingProcessor.isFinished) {
574 const now = Date.now();
575 const timeSinceLastServerResponse = now - lastServerResponseTime;
576
577 if (timeSinceLastServerResponse < STREAMING_UPDATE_INTERVAL) {
578 console.log('Streaming in progress: throttling expression update. Next update at ' + new Date(lastServerResponseTime + STREAMING_UPDATE_INTERVAL));
579 return;
580 }
581 }
582
583 try {
584 inApiCall = true;
585 let expression = await getExpressionLabel(currentLastMessage.mes);
586
587 // If we're not already overriding the folder name, account for group chats.
588 if (spriteFolderName === currentLastMessage.name && !context.groupId) {
589 spriteFolderName = context.name2;
590 }
591
592 const force = !!context.groupId;
593
594 // Character won't be angry on you for swiping
595 if (currentLastMessage.mes == '...' && expressionsList.includes(extension_settings.expressions.fallback_expression)) {
596 expression = extension_settings.expressions.fallback_expression;
597 }
598
599 await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
600 } catch (error) {
601 console.log(error);
602 } finally {
603 inApiCall = false;
604 lastCharacter = context.groupId || context.characterId;
605 lastMessage = currentLastMessage.mes;
606 lastServerResponseTime = Date.now();
607 }
608}
609
610function getSpriteFolderName(characterMessage = null, characterName = null) {
611 const context = getContext();
612 let spriteFolderName = characterName ?? context.name2;
613 const message = characterMessage ?? getLastCharacterMessage();
614 const avatarFileName = getFolderNameByMessage(message);
615 const expressionOverride = extension_settings.expressionOverrides.find(e => e.name == avatarFileName);
616
617 if (expressionOverride && expressionOverride.path) {
618 spriteFolderName = expressionOverride.path;
619 }
620
621 return spriteFolderName;
622}
623
624function getFolderNameByMessage(message) {
625 const context = getContext();
626 let avatarPath = '';
627
628 if (context.groupId) {
629 avatarPath = message.original_avatar || context.characters.find(x => message.force_avatar && message.force_avatar.includes(encodeURIComponent(x.avatar)))?.avatar;
630 } else if (context.characterId !== undefined) {
631 avatarPath = getCharaFilename();
632 }
633
634 if (!avatarPath) {
635 return '';
636 }
637
638 const folderName = avatarPath.replace(/\.[^/.]+$/, '');
639 return folderName;
640}
641
642/**
643 * Update the expression for the given character.
644 *
645 * @param {string} spriteFolderName The character name, optionally with a sprite folder override, e.g. "folder/expression".
646 * @param {string} expression The expression label, e.g. "amusement", "joy", etc.
647 * @param {Object} [options] Additional options
648 * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression.
649 * @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.
650 * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
651 */
652export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
653 lastExpression[spriteFolderName.split('/')[0]] = expression;
654 if (vnMode === null) {
655 vnMode = isVisualNovelMode();
656 }
657
658 if (vnMode) {
659 await updateVisualNovelMode(spriteFolderName, expression);
660 } else {
661 setExpression(spriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
662 }
663}
664
665/**
666 * Slash command callback for /setspritefolder
667 * @param {object} param Command parameters
668 * @param {string} param.name Character name override
669 * @param {string} folder Folder path, can be full or partial with leading slash
670 * @returns {Promise<string>} Empty string
671 */
672async function setSpriteFolderCommand({ name }, folder) {
673 if (!folder) {
674 console.log('Clearing sprite set');
675 folder = '';
676 }
677
678 if (folder.startsWith('/') || folder.startsWith('\\')) {
679 const currentLastMessage = getLastCharacterMessage();
680 if (currentLastMessage.name === null && !name) {
681 toastr.error('At least one character message is required to set a sprites subfolder.', 'Provide the name with "name=" argument.');
682 return '';
683 }
684 folder = folder.slice(1);
685 folder = `${name || currentLastMessage.name}/${folder}`;
686 }
687
688 $('#expression_override').val(folder.trim());
689 onClickExpressionOverrideButton();
690
691 // No need to resend the expression, the folder override will automatically update the currently displayed one.
692 return '';
693}
694
695async function classifyCallback(/** @type {{api: string?, filter: string?, prompt: string?}} */ { api = null, filter = null, prompt = null }, text) {
696 if (!text) {
697 toastr.error('No text provided');
698 return '';
699 }
700 if (api && !Object.keys(EXPRESSION_API).includes(api)) {
701 toastr.error('Invalid API provided');
702 return '';
703 }
704
705 const expressionApi = EXPRESSION_API[api] || extension_settings.expressions.api;
706 const filterAvailable = !isFalseBoolean(filter);
707
708 if (expressionApi === EXPRESSION_API.none) {
709 toastr.warning('No classifier API selected');
710 return '';
711 }
712
713 if (!modules.includes('classify') && expressionApi == EXPRESSION_API.extras) {
714 toastr.warning('Text classification is disabled or not available');
715 return '';
716 }
717
718 const label = await getExpressionLabel(text, expressionApi, { filterAvailable: filterAvailable, customPrompt: prompt });
719 console.debug(`Classification result for "${text}": ${label}`);
720 return label;
721}
722
723/** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
724async function setSpriteSlashCommand({ type }, searchTerm) {
725 type ??= 'expression';
726 searchTerm = searchTerm.trim().toLowerCase();
727 if (!searchTerm) {
728 toastr.error(t`No expression or sprite name provided`, t`Set Sprite`);
729 return '';
730 }
731
732 const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
733 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
734
735 let label = searchTerm;
736
737 /** @type {string?} */
738 let spriteFile = null;
739
740 await validateImages(spriteFolderName);
741
742 // Handle reset as a special term and just reset the sprite via expression call
743 if (searchTerm === RESET_SPRITE_LABEL) {
744 await sendExpressionCall(spriteFolderName, label, { force: true });
745 return lastExpression[spriteFolderName] ?? '';
746 }
747
748 switch (type) {
749 case 'expression': {
750 // Fuzzy search for expression
751 const existingExpressions = getCachedExpressions().map(x => ({ label: x }));
752 const results = performFuzzySearch('expression-expressions', existingExpressions, [
753 { name: 'label', weight: 1 },
754 ], searchTerm);
755 const matchedExpression = results[0]?.item;
756 if (!matchedExpression) {
757 toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`);
758 return '';
759 }
760
761 label = matchedExpression.label;
762 break;
763 }
764 case 'sprite': {
765 // Fuzzy search for sprite file
766 const sprites = spriteCache[spriteFolderName].map(x => x.files).flat();
767 const results = performFuzzySearch('expression-expressions', sprites, [
768 { name: 'title', weight: 1 },
769 { name: 'fileName', weight: 1 },
770 ], searchTerm);
771 const matchedSprite = results[0]?.item;
772 if (!matchedSprite) {
773 toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`);
774 return '';
775 }
776
777 label = matchedSprite.expression;
778 spriteFile = matchedSprite.fileName;
779 break;
780 }
781 default: throw Error('Invalid sprite set type: ' + type);
782 }
783
784 await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
785
786 return label;
787}
788
789/**
790 * @param {string} expressionName - Label of the expression to set as fallback
791 */
792function setFallBackExpressionSlashCommand(args, expressionName) {
793 expressionName = expressionName.trim().toLowerCase();
794
795 if (!expressionName) return extension_settings?.expressions?.fallback_expression || '';
796
797 const select = /** @type {HTMLSelectElement} */(document.getElementById('expression_fallback'));
798 const fallbackExpressions = Array
799 .from(select?.options || [])
800 .map(option => option.value)
801 .filter(expression => expression?.length > 0);
802
803 const expressionMatch = fallbackExpressions.find(expression => includesIgnoreCaseAndAccents(expression, expressionName));
804
805 if (!expressionMatch) {
806 toastr.warning(t`No expression found for search term ${expressionName}`, t`Set Fallback Expression`);
807 return '';
808 }
809
810 $(select).val(expressionMatch).trigger('change');
811
812 return expressionMatch;
813}
814
815/**
816 * Returns the sprite folder name (including override) for a character.
817 * @param {object} char Character object
818 * @param {string} char.avatar Avatar filename with extension
819 * @returns {string} Sprite folder name
820 * @throws {Error} If character not found or avatar not set
821 */
822function spriteFolderNameFromCharacter(char) {
823 const avatarFileName = char.avatar.replace(/\.[^/.]+$/, '');
824 const expressionOverride = extension_settings.expressionOverrides.find(e => e.name === avatarFileName);
825 return expressionOverride?.path ? expressionOverride.path : avatarFileName;
826}
827
828/**
829 * Generates a unique sprite name by appending an index to the given expression. *
830 * @param {string} expression - The base expression to be used as the prefix for the sprite name.
831 * @param {ExpressionImage[]} existingFiles - An array of existing file objects, each containing a fileName property.
832 * @returns {string} - A unique sprite name with the format "expression-index".
833 */
834function generateUniqueSpriteName(expression, existingFiles) {
835 let index = existingFiles.length;
836 let newSpriteName;
837 do {
838 newSpriteName = `${expression}-${index++}`;
839 } while (existingFiles.some(file => withoutExtension(file.fileName) === newSpriteName));
840 return newSpriteName;
841}
842
843/**
844 * Slash command callback for /uploadsprite
845 *
846 * label= is required
847 * if name= is provided, it will be used as a findChar lookup
848 * if name= is not provided, the last character's name will be used
849 * if folder= is a full path, it will be used as the folder
850 * if folder= is a partial path, it will be appended to the character's name
851 * if folder= is not provided, the character's override folder will be used, if set
852 *
853 * @param {object} args
854 * @param {string} args.name Character name or avatar key, passed through findChar
855 * @param {string} args.label Expression label
856 * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules
857 * @param {string?} [args.spriteName=null] Optional sprite name
858 * @param {string} imageUrl Image URI to fetch and upload
859 * @returns {Promise<string>} the sprite name
860 */
861async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
862 if (!imageUrl) throw new Error('Image URL is required');
863 if (!label || typeof label !== 'string') {
864 toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
865 return '';
866 }
867
868 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
869 if (!label) {
870 toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`);
871 return '';
872 }
873
874 spriteName = spriteName || label;
875 if (!validateExpressionSpriteName(label, spriteName)) {
876 toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`);
877 return '';
878 }
879
880 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
881 const char = findChar({ name });
882
883 if (!folder) {
884 folder = spriteFolderNameFromCharacter(char);
885 } else if (folder.startsWith('/') || folder.startsWith('\\')) {
886 const subfolder = folder.slice(1);
887 folder = `${char.name}/${subfolder}`;
888 }
889
890 try {
891 const response = await fetch(imageUrl);
892 const blob = await response.blob();
893 const file = new File([blob], 'image.png', { type: 'image/png' });
894
895 const formData = new FormData();
896 formData.append('name', folder); // this is the folder or character name
897 formData.append('label', label); // this is the expression label
898 formData.append('avatar', file); // this is the image file
899 formData.append('spriteName', spriteName); // this is a redundant comment
900
901 await handleFileUpload('/api/sprites/upload', formData);
902 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
903 } catch (error) {
904 console.error(`[${MODULE_NAME}] Error uploading file:`, error);
905 throw error;
906 }
907
908 return spriteName;
909}
910
911/**
912 * Processes the classification text to reduce the amount of text sent to the API.
913 * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is.
914 * If the text is more than 300 characters, the first and last 150 characters are returned.
915 * The result is trimmed to the end of sentence.
916 * @param {string} text The text to process.
917 * @returns {string}
918 */
919function sampleClassifyText(text) {
920 if (!text) {
921 return text;
922 }
923
924 // Replace macros, remove asterisks and quotes
925 let result = substituteParams(text).replace(/[*"]/g, '');
926
927 // If using LLM api there is no need to check length of characters
928 if (extension_settings.expressions.api === EXPRESSION_API.llm) {
929 return result.trim();
930 }
931
932 const SAMPLE_THRESHOLD = 500;
933 const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2;
934
935 if (text.length < SAMPLE_THRESHOLD) {
936 result = trimToEndSentence(result);
937 } else {
938 result = trimToEndSentence(result.slice(0, HALF_SAMPLE_THRESHOLD)) + ' ' + trimToStartSentence(result.slice(-HALF_SAMPLE_THRESHOLD));
939 }
940
941 return result.trim();
942}
943
944/**
945 * Gets the classification prompt for the LLM API.
946 * @param {string[]} labels A list of labels to search for.
947 * @returns {Promise<string>} Prompt for the LLM API.
948 */
949async function getLlmPrompt(labels) {
950 const labelsString = labels.map(x => `"${x}"`).join(', ');
951 const prompt = substituteParamsExtended(String(extension_settings.expressions.llmPrompt), { labels: labelsString });
952 return prompt;
953}
954
955/**
956 * Parses the emotion response from the LLM API.
957 * @param {string} emotionResponse The response from the LLM API.
958 * @param {string[]} labels A list of labels to search for.
959 * @returns {string} The parsed emotion or the fallback expression.
960 */
961function parseLlmResponse(emotionResponse, labels) {
962 try {
963 const parsedEmotion = JSON.parse(emotionResponse);
964 const response = parsedEmotion?.emotion?.trim()?.toLowerCase();
965
966 if (!response || !labels.includes(response)) {
967 console.debug(`Parsed emotion response: ${response} not in labels: ${labels}`);
968 throw new Error('Emotion not in labels');
969 }
970
971 return response;
972 } catch {
973 // Clean possible reasoning from response
974 emotionResponse = removeReasoningFromString(emotionResponse);
975
976 const fuse = new Fuse(labels, { includeScore: true });
977 console.debug('Using fuzzy search in labels:', labels);
978 const result = fuse.search(emotionResponse);
979 if (result.length > 0) {
980 console.debug(`fuzzy search found: ${result[0].item} as closest for the LLM response:`, emotionResponse);
981 return result[0].item;
982 }
983 const lowerCaseResponse = String(emotionResponse || '').toLowerCase();
984 for (const label of labels) {
985 if (lowerCaseResponse.includes(label.toLowerCase())) {
986 console.debug(`Found label ${label} in the LLM response:`, emotionResponse);
987 return label;
988 }
989 }
990 }
991
992 throw new Error('Could not parse emotion response ' + emotionResponse);
993}
994
995/**
996 * Gets the JSON schema for the LLM API.
997 * @param {string[]} emotions A list of emotions to search for.
998 * @returns {object} The JSON schema for the LLM API.
999 */
1000function getJsonSchema(emotions) {
1001 return {
1002 $schema: 'http://json-schema.org/draft-04/schema#',
1003 type: 'object',
1004 properties: {
1005 emotion: {
1006 type: 'string',
1007 enum: emotions,
1008 },
1009 },
1010 required: [
1011 'emotion',
1012 ],
1013 additionalProperties: false,
1014 };
1015}
1016
1017function onTextGenSettingsReady(args) {
1018 // Only call if inside an API call
1019 if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {
1020 const emotions = DEFAULT_EXPRESSIONS;
1021 Object.assign(args, {
1022 top_k: 1,
1023 stop: [],
1024 stopping_strings: [],
1025 custom_token_bans: [],
1026 json_schema: getJsonSchema(emotions),
1027 });
1028 }
1029}
1030
1031/**
1032 * Retrieves the label of an expression via classification based on the provided text.
1033 * Optionally allows to override the expressions API being used.
1034 * @param {string} text - The text to classify and retrieve the expression label for.
1035 * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.
1036 * @param {object} [options={}] - Optional arguments.
1037 * @param {boolean?} [options.filterAvailable=null] - Whether to filter available expressions. If not specified, uses the extension setting.
1038 * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.
1039 * @returns {Promise<string?>} - The label of the expression.
1040 */
1041export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { filterAvailable = null, customPrompt = null } = {}) {
1042 // Return if text is undefined, saving a costly fetch request
1043 if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {
1044 return extension_settings.expressions.fallback_expression;
1045 }
1046
1047 if (extension_settings.expressions.translate && typeof globalThis.translate === 'function') {
1048 text = await globalThis.translate(text, 'en');
1049 }
1050
1051 text = sampleClassifyText(text);
1052
1053 filterAvailable ??= extension_settings.expressions.filterAvailable;
1054 if (filterAvailable && ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(expressionsApi)) {
1055 console.debug('Filter available is only supported for LLM and WebLLM expressions');
1056 }
1057
1058 try {
1059 switch (expressionsApi) {
1060 // Local BERT pipeline
1061 case EXPRESSION_API.local: {
1062 const localResult = await fetch('/api/extra/classify', {
1063 method: 'POST',
1064 headers: getRequestHeaders(),
1065 body: JSON.stringify({ text: text }),
1066 });
1067
1068 if (localResult.ok) {
1069 const data = await localResult.json();
1070 return data.classification[0].label;
1071 }
1072 } break;
1073 // Using LLM
1074 case EXPRESSION_API.llm: {
1075 try {
1076 await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
1077 } catch (error) {
1078 console.warn('No LLM connection. Using fallback expression', error);
1079 return extension_settings.expressions.fallback_expression;
1080 }
1081
1082 const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable });
1083 const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList);
1084 eventSource.once(event_types.TEXT_COMPLETION_SETTINGS_READY, onTextGenSettingsReady);
1085
1086 let emotionResponse;
1087 try {
1088 inApiCall = true;
1089 switch (extension_settings.expressions.promptType) {
1090 case PROMPT_TYPE.raw:
1091 emotionResponse = await generateRaw({ prompt: text, systemPrompt: prompt });
1092 break;
1093 case PROMPT_TYPE.full:
1094 emotionResponse = await generateQuietPrompt({ quietPrompt: prompt });
1095 break;
1096 }
1097 } finally {
1098 inApiCall = false;
1099 }
1100 return parseLlmResponse(emotionResponse, expressionsList);
1101 }
1102 // Using WebLLM
1103 case EXPRESSION_API.webllm: {
1104 if (!isWebLlmSupported()) {
1105 console.warn('WebLLM is not supported. Using fallback expression');
1106 return extension_settings.expressions.fallback_expression;
1107 }
1108
1109 const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable });
1110 const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList);
1111 const messages = [
1112 { role: 'user', content: text + '\n\n' + prompt },
1113 ];
1114
1115 const emotionResponse = await generateWebLlmChatPrompt(messages);
1116 return parseLlmResponse(emotionResponse, expressionsList);
1117 }
1118 // Extras
1119 case EXPRESSION_API.extras: {
1120 const url = new URL(getApiUrl());
1121 url.pathname = '/api/classify';
1122
1123 const extrasResult = await doExtrasFetch(url, {
1124 method: 'POST',
1125 headers: {
1126 'Content-Type': 'application/json',
1127 'Bypass-Tunnel-Reminder': 'bypass',
1128 },
1129 body: JSON.stringify({ text: text }),
1130 });
1131
1132 if (extrasResult.ok) {
1133 const data = await extrasResult.json();
1134 return data.classification[0].label;
1135 }
1136 } break;
1137 // None
1138 case EXPRESSION_API.none: {
1139 // Return empty, the fallback expression will be used
1140 return '';
1141 }
1142 default: {
1143 toastr.error('Invalid API selected');
1144 return '';
1145 }
1146 }
1147 } catch (error) {
1148 toastr.error('Could not classify expression. Check the console or your backend for more information.');
1149 console.error(error);
1150 return extension_settings.expressions.fallback_expression;
1151 }
1152}
1153
1154function getLastCharacterMessage() {
1155 const context = getContext();
1156 const reversedChat = context.chat.slice().reverse();
1157
1158 for (let mes of reversedChat) {
1159 if (mes.is_user || mes.is_system || mes.extra?.type === system_message_types.NARRATOR) {
1160 continue;
1161 }
1162
1163 return { mes: mes.mes, name: mes.name, original_avatar: mes.original_avatar, force_avatar: mes.force_avatar };
1164 }
1165
1166 return { mes: '', name: null, original_avatar: null, force_avatar: null };
1167}
1168
1169function removeExpression() {
1170 lastMessage = null;
1171 $('img.expression').off('error');
1172 $('img.expression').prop('src', '');
1173 $('img.expression').removeClass('default');
1174 $('#open_chat_expressions').hide();
1175 $('#no_chat_expressions').show();
1176}
1177
1178/**
1179 * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw.
1180 * @param {string} spriteFolderName - The character sprite folder to validate
1181 * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before
1182 */
1183async function validateImages(spriteFolderName, forceRedrawCached = false) {
1184 if (!spriteFolderName) {
1185 return;
1186 }
1187
1188 const labels = await getExpressionsList();
1189
1190 if (spriteCache[spriteFolderName]) {
1191 if (forceRedrawCached && $('#image_list').data('name') !== spriteFolderName) {
1192 console.debug('force redrawing character sprites list');
1193 await drawSpritesList(spriteFolderName, labels, spriteCache[spriteFolderName]);
1194 }
1195
1196 return;
1197 }
1198
1199 const sprites = await getSpritesList(spriteFolderName);
1200 let validExpressions = await drawSpritesList(spriteFolderName, labels, sprites);
1201 spriteCache[spriteFolderName] = validExpressions;
1202}
1203
1204/**
1205 * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting
1206 * @param {{ path: string, label: string }} sprite
1207 * @returns {ExpressionImage}
1208 */
1209function getExpressionImageData(sprite) {
1210 const fileName = sprite.path.split('/').pop().split('?')[0];
1211 const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
1212 return {
1213 expression: sprite.label,
1214 fileName: fileName,
1215 title: fileNameWithoutExtension,
1216 imageSrc: sprite.path,
1217 type: 'success',
1218 isCustom: extension_settings.expressions.custom?.includes(sprite.label),
1219 };
1220}
1221
1222/**
1223 * Populate the character expression list with sprites for the given character.
1224 * @param {string} spriteFolderName - The name of the character to populate the list for
1225 * @param {string[]} labels - An array of expression labels that are valid
1226 * @param {Expression[]} sprites - An array of sprites
1227 * @returns {Promise<Expression[]>} An array of valid expression labels
1228 */
1229async function drawSpritesList(spriteFolderName, labels, sprites) {
1230 /** @type {Expression[]} */
1231 let validExpressions = [];
1232
1233 $('#no_chat_expressions').hide();
1234 $('#open_chat_expressions').show();
1235 $('#image_list').empty();
1236 $('#image_list').data('name', spriteFolderName);
1237 $('#image_list_header_name').text(spriteFolderName);
1238
1239 if (!Array.isArray(labels)) {
1240 return [];
1241 }
1242
1243 for (const expression of labels.sort()) {
1244 const isCustom = extension_settings.expressions.custom?.includes(expression);
1245 const images = sprites
1246 .filter(s => s.label === expression)
1247 .map(s => s.files)
1248 .flat();
1249
1250 if (images.length === 0) {
1251 const listItem = await getListItem(expression, {
1252 isCustom,
1253 images: [getPlaceholderImage(expression, isCustom)],
1254 });
1255 $('#image_list').append(listItem);
1256 continue;
1257 }
1258
1259 validExpressions.push({ label: expression, files: images });
1260
1261 // Render main = first file, additional = rest
1262 let listItem = await getListItem(expression, {
1263 isCustom,
1264 images,
1265 });
1266 $('#image_list').append(listItem);
1267 }
1268 return validExpressions;
1269}
1270
1271/**
1272 * Renders a list item template for the expressions list.
1273 * @param {string} expression Expression name
1274 * @param {object} args Arguments object
1275 * @param {ExpressionImage[]} [args.images] Array of image objects
1276 * @param {boolean} [args.isCustom=false] If expression is added by user
1277 * @returns {Promise<string>} Rendered list item template
1278 */
1279async function getListItem(expression, { images, isCustom = false } = {}) {
1280 return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false });
1281}
1282
1283/**
1284 * Fetches and processes the list of sprites for a given character name.
1285 * Retrieves sprite data from the server and organizes it into labeled groups.
1286 *
1287 * @param {string} name - The character name to fetch sprites for
1288 * @returns {Promise<Expression[]>} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data
1289 */
1290
1291async function getSpritesList(name) {
1292 console.debug('getting sprites list');
1293
1294 try {
1295 const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
1296 /** @type {{ label: string, path: string }[]} */
1297 let sprites = result.ok ? (await result.json()) : [];
1298
1299 /** @type {Expression[]} */
1300 const grouped = sprites.reduce((acc, sprite) => {
1301 const imageData = getExpressionImageData(sprite);
1302 let existingExpression = acc.find(exp => exp.label === sprite.label);
1303 if (existingExpression) {
1304 existingExpression.files.push(imageData);
1305 } else {
1306 acc.push({ label: sprite.label, files: [imageData] });
1307 }
1308
1309 return acc;
1310 }, []);
1311
1312 // Sort the sprites for each expression alphabetically, but keep the main expression file at the front
1313 for (const expression of grouped) {
1314 expression.files.sort((a, b) => {
1315 if (a.title === expression.label) return -1;
1316 if (b.title === expression.label) return 1;
1317 return a.title.localeCompare(b.title);
1318 });
1319
1320 // Mark all besides the first sprite as 'additional'
1321 for (let i = 1; i < expression.files.length; i++) {
1322 expression.files[i].type = 'additional';
1323 }
1324 }
1325
1326 return grouped;
1327 } catch (err) {
1328 console.log(err);
1329 return [];
1330 }
1331}
1332
1333async function renderAdditionalExpressionSettings() {
1334 renderCustomExpressions();
1335 await renderFallbackExpressionPicker();
1336}
1337
1338function renderCustomExpressions() {
1339 if (!Array.isArray(extension_settings.expressions.custom)) {
1340 extension_settings.expressions.custom = [];
1341 }
1342
1343 const customExpressions = extension_settings.expressions.custom.sort((a, b) => a.localeCompare(b));
1344 $('#expression_custom').empty();
1345
1346 for (const expression of customExpressions) {
1347 const option = document.createElement('option');
1348 option.value = expression;
1349 option.text = expression;
1350 $('#expression_custom').append(option);
1351 }
1352
1353 if (customExpressions.length === 0) {
1354 $('#expression_custom').append('<option value="" disabled selected>[ No custom expressions ]</option>');
1355 }
1356}
1357
1358async function renderFallbackExpressionPicker() {
1359 const expressions = await getExpressionsList();
1360
1361 const defaultPicker = $('#expression_fallback');
1362 defaultPicker.empty();
1363
1364
1365 addOption(OPTION_NO_FALLBACK, '[ No fallback ]', !extension_settings.expressions.fallback_expression && !extension_settings.expressions.showDefault);
1366 addOption(OPTION_EMOJI_FALLBACK, '[ Default emojis ]', !!extension_settings.expressions.showDefault);
1367
1368 for (const expression of expressions) {
1369 addOption(expression, expression, expression == extension_settings.expressions.fallback_expression);
1370 }
1371
1372 /** @type {(value: string, label: string, isSelected: boolean) => void} */
1373 function addOption(value, label, isSelected) {
1374 const option = document.createElement('option');
1375 option.value = value;
1376 option.text = label;
1377 option.selected = isSelected;
1378 defaultPicker.append(option);
1379 }
1380}
1381
1382/**
1383 * Retrieves a unique list of cached expressions.
1384 * Combines the default expressions list with custom user-defined expressions.
1385 *
1386 * @returns {string[]} An array of unique expression labels
1387 */
1388
1389function getCachedExpressions() {
1390 if (!Array.isArray(expressionsList)) {
1391 return [];
1392 }
1393
1394 return [...expressionsList, ...extension_settings.expressions.custom].filter(onlyUnique);
1395}
1396
1397export async function getExpressionsList({ filterAvailable = false } = {}) {
1398 // If there is no cached list, load and cache it
1399 if (!Array.isArray(expressionsList)) {
1400 expressionsList = await resolveExpressionsList();
1401 }
1402
1403 const expressions = getCachedExpressions();
1404
1405 // Filtering is only available for llm and webllm APIs
1406 if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) {
1407 return expressions;
1408 }
1409
1410 // Get expressions with available sprites
1411 const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
1412 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
1413
1414 return expressions.filter(label => {
1415 const expression = spriteCache[spriteFolderName]?.find(x => x.label === label);
1416 return (expression?.files.length ?? 0) > 0;
1417 });
1418
1419 /**
1420 * Returns the list of expressions from the API or fallback in offline mode.
1421 * @returns {Promise<string[]>}
1422 */
1423 async function resolveExpressionsList() {
1424 // See if we can retrieve a specific expression list from the API
1425 try {
1426 // Check Extras api first, if enabled and that module active
1427 if (extension_settings.expressions.api == EXPRESSION_API.extras && modules.includes('classify')) {
1428 const url = new URL(getApiUrl());
1429 url.pathname = '/api/classify/labels';
1430
1431 const apiResult = await doExtrasFetch(url, {
1432 method: 'GET',
1433 headers: { 'Bypass-Tunnel-Reminder': 'bypass' },
1434 });
1435
1436 if (apiResult.ok) {
1437 const data = await apiResult.json();
1438 expressionsList = data.labels;
1439 return expressionsList;
1440 }
1441 }
1442
1443 // If running the local classify model (not using the LLM), we ask that one
1444 if (extension_settings.expressions.api == EXPRESSION_API.local) {
1445 const apiResult = await fetch('/api/extra/classify/labels', {
1446 method: 'POST',
1447 headers: getRequestHeaders({ omitContentType: true }),
1448 });
1449
1450 if (apiResult.ok) {
1451 const data = await apiResult.json();
1452 expressionsList = data.labels;
1453 return expressionsList;
1454 }
1455 }
1456 } catch (error) {
1457 console.log(error);
1458 }
1459
1460 // If there was no specific list, or an error, just return the default expressions
1461 expressionsList = DEFAULT_EXPRESSIONS.slice();
1462 return expressionsList;
1463 }
1464}
1465
1466/**
1467 * Selects a sprite from the given sprite folder for the given expression.
1468 *
1469 * If multiple sprites are allowed for the expression, it will randomly select one.
1470 * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same.
1471 * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one.
1472 *
1473 * @param {string} spriteFolderName - The name of the sprite folder
1474 * @param {string} expression - The expression to find the sprite for
1475 * @param {object} [options] - Options to select the sprite
1476 * @param {string} [options.prevExpressionSrc=null] - The source of the previous expression
1477 * @param {string} [options.overrideSpriteFile=null] - The file name of the sprite to select
1478 * @returns {ExpressionImage?} - The selected sprite
1479 */
1480function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) {
1481 if (!spriteCache[spriteFolderName]) return null;
1482 if (expression === RESET_SPRITE_LABEL) return null;
1483
1484 // Search for sprites of that expression - or fallback expression sprites if enabled
1485 let sprite = spriteCache[spriteFolderName].find(x => x.label === expression);
1486 if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) {
1487 sprite = spriteCache[spriteFolderName].find(x => x.label === extension_settings.expressions.fallback_expression);
1488 console.debug('Expression', expression, 'not found. Using fallback expression', extension_settings.expressions.fallback_expression);
1489 }
1490 if (!(sprite?.files.length > 0)) return null;
1491
1492 let spriteFile = sprite.files[0];
1493
1494 // If a specific sprite file should be set, we are looking it up here
1495 if (overrideSpriteFile) {
1496 const searched = sprite.files.find(x => x.fileName === overrideSpriteFile);
1497 if (searched) spriteFile = searched;
1498 else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`);
1499 } else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
1500 // Else calculate next expression, if multiple are allowed
1501 let possibleFiles = sprite.files;
1502 if (extension_settings.expressions.rerollIfSame) {
1503 possibleFiles = possibleFiles.filter(x => !prevExpressionSrc || x.imageSrc !== prevExpressionSrc);
1504 }
1505 spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)];
1506 }
1507
1508 return spriteFile;
1509}
1510
1511/**
1512 * Set the expression of a character.
1513 * @param {string} spriteFolderName - The name of the character (folder name - can also be a costume override)
1514 * @param {string} expression - The expression or sprite name to set
1515 * @param {Object} options - Optional parameters
1516 * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on
1517 * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
1518 * @returns {Promise<void>} A promise that resolves when the expression has been set.
1519 */
1520async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) {
1521 await validateImages(spriteFolderName);
1522 const img = $('img.expression');
1523 const prevExpressionSrc = img.attr('src');
1524 const expressionClone = img.clone();
1525
1526 const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile });
1527 if (spriteFile) {
1528 if (force && isVisualNovelMode()) {
1529 const context = getContext();
1530 const group = context.groups.find(x => x.id === context.groupId);
1531
1532 // If it's a folder, make sure we find the group member based on the actual name
1533 const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName;
1534
1535 const groupMember = group.members
1536 .map(member => context.characters.find(x => x.avatar === member))
1537 .find(groupMember => groupMember && groupMember.name === memberName);
1538 if (groupMember) {
1539 await setImage($(`.expression-holder[data-avatar="${groupMember.avatar}"] img`), spriteFile.imageSrc);
1540 return;
1541 }
1542 }
1543
1544 //only swap expressions when necessary
1545 if (prevExpressionSrc !== spriteFile.imageSrc
1546 && !img.hasClass('expression-animating')) {
1547 //clone expression
1548 expressionClone.addClass('expression-clone');
1549 //make invisible and remove id to prevent double ids
1550 //must be made invisible to start because they share the same Z-index
1551 expressionClone.attr('id', '').css({ opacity: 0 });
1552 //add new sprite path to clone src
1553 expressionClone.attr('src', spriteFile.imageSrc);
1554 //set relevant data tags
1555 expressionClone.attr('data-sprite-folder-name', spriteFolderName);
1556 expressionClone.attr('data-expression', expression);
1557 expressionClone.attr('data-sprite-filename', spriteFile.fileName);
1558 expressionClone.attr('title', expression);
1559 //add invisible clone to html
1560 expressionClone.appendTo($('#expression-holder'));
1561
1562 const duration = 200;
1563
1564 //add animation flags to both images
1565 //to prevent multiple expression changes happening simultaneously
1566 img.addClass('expression-animating');
1567
1568 // Set the parent container's min width and height before running the transition
1569 const imgWidth = img.width();
1570 const imgHeight = img.height();
1571 const expressionHolder = img.parent();
1572 expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
1573 expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
1574
1575 //position absolute prevent the original from jumping around during transition
1576 img.css('position', 'absolute').width(imgWidth).height(imgHeight);
1577 expressionClone.addClass('expression-animating');
1578 //fade the clone in
1579 expressionClone.css({
1580 opacity: 0,
1581 }).animate({
1582 opacity: 1,
1583 }, duration)
1584 //when finshed fading in clone, fade out the original
1585 .promise().done(function () {
1586 img.animate({
1587 opacity: 0,
1588 }, duration);
1589 //remove old expression
1590 img.remove();
1591 //replace ID so it becomes the new 'original' expression for next change
1592 expressionClone.attr('id', 'expression-image');
1593 expressionClone.removeClass('expression-animating');
1594
1595 // Reset the expression holder min height and width
1596 expressionHolder.css('min-width', 100);
1597 expressionHolder.css('min-height', 100);
1598 });
1599
1600 expressionClone.removeClass('expression-clone');
1601
1602 expressionClone.removeClass('default');
1603 expressionClone.off('error');
1604 expressionClone.on('error', function (error) {
1605 console.debug('Expression image error', spriteFile.imageSrc, error);
1606 $(this).attr('src', '');
1607 $(this).off('error');
1608 if (force && extension_settings.expressions.showDefault) {
1609 setDefaultEmojiForImage(img, expression);
1610 }
1611 });
1612 }
1613
1614 console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
1615 } else {
1616 img.attr('data-sprite-folder-name', spriteFolderName);
1617
1618 img.off('error');
1619
1620 if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) {
1621 setDefaultEmojiForImage(img, expression);
1622 } else {
1623 setNoneForImage(img, expression);
1624 }
1625 console.debug('Expression unset - No sprite found', { expression: expression });
1626 }
1627
1628 document.getElementById('expression-holder').style.display = '';
1629}
1630
1631/**
1632 * Sets the default expression image for the given image element and expression
1633 * @param {JQuery<HTMLElement>} img - The image element to set the default expression for
1634 * @param {string} expression - The expression label to use for the default image
1635 */
1636function setDefaultEmojiForImage(img, expression) {
1637 if (extension_settings.expressions.custom?.includes(expression)) {
1638 console.debug(`Can't set default emoji for a custom expression (${expression}). setting to ${DEFAULT_FALLBACK_EXPRESSION} instead.`);
1639 expression = DEFAULT_FALLBACK_EXPRESSION;
1640 }
1641
1642 const defImgUrl = `/img/default-expressions/${expression}.png`;
1643 img.attr('src', defImgUrl);
1644 img.attr('data-expression', expression);
1645 img.attr('data-sprite-filename', null);
1646 img.attr('title', expression);
1647 img.addClass('default');
1648}
1649
1650/**
1651 * Sets the image element to display no expression by clearing its source attribute.
1652 * @param {JQuery<HTMLElement>} img - The image element to clear the expression for
1653 * @param {string} expression - The expression label to use
1654 */
1655function setNoneForImage(img, expression) {
1656 img.attr('src', '');
1657 img.attr('data-expression', expression);
1658 img.attr('data-sprite-filename', null);
1659 img.attr('title', expression);
1660 img.removeClass('default');
1661}
1662
1663function onClickExpressionImage() {
1664 // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label
1665 if ($(this).attr('data-expression-type') === 'failure') {
1666 const label = $(this).attr('data-expression');
1667 setSpriteSlashCommand({ type: 'expression' }, label);
1668 return;
1669 }
1670
1671 const spriteFile = $(this).attr('data-filename');
1672 setSpriteSlashCommand({ type: 'sprite' }, spriteFile);
1673}
1674
1675async function onClickExpressionAddCustom() {
1676 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');
1677 let expressionName = await Popup.show.input(null, template);
1678
1679 if (!expressionName) {
1680 console.debug('No custom expression name provided');
1681 return;
1682 }
1683
1684 expressionName = expressionName.trim().toLowerCase();
1685
1686 // a-z, 0-9, dashes and underscores only
1687 if (!/^[a-z0-9-_]+$/.test(expressionName)) {
1688 toastr.warning('Invalid custom expression name provided', 'Add Custom Expression');
1689 return;
1690 }
1691 if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) {
1692 toastr.warning('Expression name already exists', 'Add Custom Expression');
1693 return;
1694 }
1695 if (extension_settings.expressions.custom.includes(expressionName)) {
1696 toastr.warning('Custom expression already exists', 'Add Custom Expression');
1697 return;
1698 }
1699
1700 // Add custom expression into settings
1701 extension_settings.expressions.custom.push(expressionName);
1702 await renderAdditionalExpressionSettings();
1703 saveSettingsDebounced();
1704
1705 // Force refresh sprites list
1706 expressionsList = null;
1707 spriteCache = {};
1708 moduleWorker();
1709}
1710
1711async function onClickExpressionRemoveCustom() {
1712 const selectedExpression = String($('#expression_custom').val());
1713 const noCustomExpressions = extension_settings.expressions.custom.length === 0;
1714
1715 if (!selectedExpression || noCustomExpressions) {
1716 console.debug('No custom expression selected');
1717 return;
1718 }
1719
1720 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });
1721 const confirmation = await Popup.show.confirm(null, template);
1722
1723 if (!confirmation) {
1724 console.debug('Custom expression removal cancelled');
1725 return;
1726 }
1727
1728 // Remove custom expression from settings
1729 const index = extension_settings.expressions.custom.indexOf(selectedExpression);
1730 extension_settings.expressions.custom.splice(index, 1);
1731 if (selectedExpression == extension_settings.expressions.fallback_expression) {
1732 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');
1733 extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;
1734 }
1735 await renderAdditionalExpressionSettings();
1736 saveSettingsDebounced();
1737
1738 // Force refresh sprites list
1739 expressionsList = null;
1740 spriteCache = {};
1741 moduleWorker();
1742}
1743
1744function onExpressionApiChanged() {
1745 const tempApi = this.value;
1746 if (tempApi) {
1747 extension_settings.expressions.api = Number(tempApi);
1748 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
1749 $('.expression_prompt_type_block').toggle(extension_settings.expressions.api === EXPRESSION_API.llm);
1750 expressionsList = null;
1751 spriteCache = {};
1752 moduleWorker();
1753 saveSettingsDebounced();
1754 }
1755}
1756
1757async function onExpressionFallbackChanged() {
1758 /** @type {HTMLSelectElement} */
1759 const select = this;
1760 const selectedValue = select.value;
1761
1762 switch (selectedValue) {
1763 case OPTION_NO_FALLBACK:
1764 extension_settings.expressions.fallback_expression = null;
1765 extension_settings.expressions.showDefault = false;
1766 break;
1767 case OPTION_EMOJI_FALLBACK:
1768 extension_settings.expressions.fallback_expression = null;
1769 extension_settings.expressions.showDefault = true;
1770 break;
1771 default:
1772 extension_settings.expressions.fallback_expression = selectedValue;
1773 extension_settings.expressions.showDefault = false;
1774 break;
1775 }
1776
1777 const img = $('img.expression');
1778 const spriteFolderName = img.attr('data-sprite-folder-name');
1779 const expression = img.attr('data-expression');
1780
1781 if (spriteFolderName && expression) {
1782 await sendExpressionCall(spriteFolderName, expression, { force: true });
1783 }
1784
1785 saveSettingsDebounced();
1786}
1787
1788/**
1789 * Handles the file upload process for a sprite image.
1790 * @param {string} url URL to upload the file to
1791 * @param {FormData} formData FormData object containing the file and other data to upload
1792 * @returns {Promise<any>} - The response data from the server
1793 */
1794async function handleFileUpload(url, formData) {
1795 try {
1796 const result = await fetch(url, {
1797 method: 'POST',
1798 headers: getRequestHeaders({ omitContentType: true }),
1799 body: formData,
1800 cache: 'no-cache',
1801 });
1802
1803 if (!result.ok) {
1804 throw new Error(`Upload failed with status ${result.status}`);
1805 }
1806
1807 const data = await result.json();
1808
1809 // Refresh sprites list
1810 const name = formData.get('name').toString();
1811 delete spriteCache[name];
1812 await fetchImagesNoCache();
1813 await validateImages(name);
1814
1815 return data ?? {};
1816 } catch (error) {
1817 console.error('Error uploading image:', error);
1818 toastr.error('Failed to upload image');
1819 return {};
1820 }
1821}
1822
1823/**
1824 * Removes the file extension from a file name
1825 * @param {string} fileName The file name to remove the extension from
1826 * @returns {string} The file name without the extension
1827 */
1828function withoutExtension(fileName) {
1829 return fileName.replace(/\.[^/.]+$/, '');
1830}
1831
1832function validateExpressionSpriteName(expression, spriteName) {
1833 const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1834 const validFileName = filenameValidationRegex.test(spriteName);
1835 return validFileName;
1836}
1837
1838async function onClickExpressionUpload(event) {
1839 // Prevents the expression from being set
1840 event.stopPropagation();
1841
1842 const expressionListItem = $(this).closest('.expression_list_item');
1843
1844 const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null;
1845 const expression = expressionListItem.data('expression');
1846 const name = $('#image_list').data('name');
1847
1848 const handleExpressionUploadChange = async (e) => {
1849 const file = e.target.files[0];
1850
1851 if (!file || !file.name) {
1852 console.debug('No valid file selected');
1853 return;
1854 }
1855
1856 const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || [];
1857
1858 let spriteName = expression;
1859
1860 if (extension_settings.expressions.allowMultiple) {
1861 const matchesExisting = existingFiles.some(x => x.fileName === file.name);
1862 const fileNameWithoutExtension = withoutExtension(file.name);
1863 const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
1864
1865 if (!clickedFileName && validFileName) {
1866 // If there is no expression yet and it's a valid expression, we just take it
1867 spriteName = fileNameWithoutExtension;
1868 } else if (clickedFileName === file.name) {
1869 // If the filename matches the one that was clicked, we just take it and replace it
1870 spriteName = fileNameWithoutExtension;
1871 } else if (!matchesExisting && validFileName) {
1872 // If it's a valid filename and there's no existing file with the same name, we just take it
1873 spriteName = fileNameWithoutExtension;
1874 } else {
1875 /** @type {import('../../popup.js').CustomPopupButton[]} */
1876 const customButtons = [];
1877 if (clickedFileName) {
1878 customButtons.push({
1879 text: t`Replace Existing`,
1880 result: POPUP_RESULT.NEGATIVE,
1881 action: () => {
1882 console.debug('Replacing existing sprite');
1883 spriteName = withoutExtension(clickedFileName);
1884 },
1885 });
1886 }
1887
1888 spriteName = null;
1889 const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles);
1890
1891 const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
1892
1893 const input = await Popup.show.input(t`Upload Expression Sprite`, message,
1894 suggestedSpriteName, { customButtons: customButtons });
1895
1896 if (input) {
1897 if (!validateExpressionSpriteName(expression, input)) {
1898 toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
1899 return;
1900 }
1901 spriteName = input;
1902 }
1903 }
1904 } else {
1905 spriteName = withoutExtension(expression);
1906 }
1907
1908 if (!spriteName) {
1909 toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`);
1910 // Reset the input
1911 e.target.form.reset();
1912 return;
1913 }
1914
1915 const formData = new FormData();
1916 formData.append('name', name);
1917 formData.append('label', expression);
1918 formData.append('avatar', file);
1919 formData.append('spriteName', spriteName);
1920
1921 await handleFileUpload('/api/sprites/upload', formData);
1922
1923 // Reset the input
1924 e.target.form.reset();
1925 };
1926
1927 $('#expression_upload')
1928 .off('change')
1929 .on('change', handleExpressionUploadChange)
1930 .trigger('click');
1931}
1932
1933async function onClickExpressionOverrideButton() {
1934 const context = getContext();
1935 const currentLastMessage = getLastCharacterMessage();
1936 const avatarFileName = getFolderNameByMessage(currentLastMessage);
1937
1938 // If the avatar name couldn't be found, abort.
1939 if (!avatarFileName) {
1940 console.debug(`Could not find filename for character with name ${currentLastMessage.name} and ID ${context.characterId}`);
1941
1942 return;
1943 }
1944
1945 const overridePath = String($('#expression_override').val());
1946 const existingOverrideIndex = extension_settings.expressionOverrides.findIndex((e) =>
1947 e.name == avatarFileName,
1948 );
1949
1950 // If the path is empty, delete the entry from overrides
1951 if (overridePath === undefined || overridePath.length === 0) {
1952 if (existingOverrideIndex === -1) {
1953 return;
1954 }
1955
1956 extension_settings.expressionOverrides.splice(existingOverrideIndex, 1);
1957 console.debug(`Removed existing override for ${avatarFileName}`);
1958 } else {
1959 // Properly override objects and clear the sprite cache of the previously set names
1960 const existingOverride = extension_settings.expressionOverrides[existingOverrideIndex];
1961 if (existingOverride) {
1962 Object.assign(existingOverride, { path: overridePath });
1963 delete spriteCache[existingOverride.name];
1964 } else {
1965 const characterOverride = { name: avatarFileName, path: overridePath };
1966 extension_settings.expressionOverrides.push(characterOverride);
1967 delete spriteCache[currentLastMessage.name];
1968 }
1969
1970 console.debug(`Added/edited expression override for character with filename ${avatarFileName} to folder ${overridePath}`);
1971 }
1972
1973 saveSettingsDebounced();
1974
1975 // Refresh sprites list. Assume the override path has been properly handled.
1976 try {
1977 inApiCall = true;
1978 $('#visual-novel-wrapper').empty();
1979 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
1980 const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
1981 const expression = await getExpressionLabel(currentLastMessage.mes);
1982 await sendExpressionCall(name, expression, { force: true });
1983 forceUpdateVisualNovelMode();
1984 } catch (error) {
1985 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
1986 } finally {
1987 inApiCall = false;
1988 }
1989}
1990
1991async function onClickExpressionOverrideRemoveAllButton() {
1992 // Remove all the overrided entries from sprite cache
1993 for (const element of extension_settings.expressionOverrides) {
1994 delete spriteCache[element.name];
1995 }
1996
1997 extension_settings.expressionOverrides = [];
1998 saveSettingsDebounced();
1999
2000 console.debug('All expression image overrides have been cleared.');
2001
2002 // Refresh sprites list to use the default name if applicable
2003 try {
2004 $('#visual-novel-wrapper').empty();
2005 const currentLastMessage = getLastCharacterMessage();
2006 await validateImages(currentLastMessage.name, true);
2007 const expression = await getExpressionLabel(currentLastMessage.mes);
2008 await sendExpressionCall(currentLastMessage.name, expression, { force: true });
2009 forceUpdateVisualNovelMode();
2010
2011 console.debug(extension_settings.expressionOverrides);
2012 } catch (error) {
2013 console.debug(`The current expression could not be set because of error: ${error}`);
2014 }
2015}
2016
2017async function onClickExpressionUploadPackButton() {
2018 const name = $('#image_list').data('name');
2019
2020 const handleFileUploadChange = async (e) => {
2021 const file = e.target.files[0];
2022
2023 if (!file) {
2024 return;
2025 }
2026
2027 const formData = new FormData();
2028 formData.append('name', name);
2029 formData.append('avatar', file);
2030
2031 const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 });
2032 const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
2033 toastr.clear(uploadToast);
2034
2035 // Only show success message if at least one image was uploaded
2036 if (count) {
2037 toastr.success(`Uploaded ${count} image(s) for ${name}`);
2038 }
2039
2040 // Reset the input
2041 e.target.form.reset();
2042 };
2043
2044 $('#expression_upload_pack')
2045 .off('change')
2046 .on('change', handleFileUploadChange)
2047 .trigger('click');
2048}
2049
2050async function onClickExpressionDelete(event) {
2051 // Prevents the expression from being set
2052 event.stopPropagation();
2053
2054 const expressionListItem = $(this).closest('.expression_list_item');
2055 const expression = expressionListItem.data('expression');
2056
2057 if (expressionListItem.attr('data-expression-type') === 'failure') {
2058 return;
2059 }
2060
2061 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!`
2062 + '<br /><br />'
2063 + t`Expression:` + ' <tt>' + expressionListItem.attr('data-filename') + '</tt>');
2064 if (!confirmation) {
2065 return;
2066 }
2067
2068 const fileName = withoutExtension(expressionListItem.attr('data-filename'));
2069 const name = $('#image_list').data('name');
2070
2071 try {
2072 await fetch('/api/sprites/delete', {
2073 method: 'POST',
2074 headers: getRequestHeaders(),
2075 body: JSON.stringify({ name, label: expression, spriteName: fileName }),
2076 });
2077 } catch (error) {
2078 toastr.error('Failed to delete image. Try again later.');
2079 }
2080
2081 // Refresh sprites list
2082 delete spriteCache[name];
2083 await fetchImagesNoCache();
2084 await validateImages(name);
2085}
2086
2087function setExpressionOverrideHtml(forceClear = false) {
2088 const currentLastMessage = getLastCharacterMessage();
2089 const avatarFileName = getFolderNameByMessage(currentLastMessage);
2090 if (!avatarFileName) {
2091 return;
2092 }
2093
2094 const expressionOverride = extension_settings.expressionOverrides.find((e) =>
2095 e.name == avatarFileName,
2096 );
2097
2098 if (expressionOverride && expressionOverride.path) {
2099 $('#expression_override').val(expressionOverride.path);
2100 } else if (expressionOverride) {
2101 delete extension_settings.expressionOverrides[expressionOverride.name];
2102 }
2103
2104 if (forceClear && !expressionOverride) {
2105 $('#expression_override').val('');
2106 }
2107}
2108
2109async function fetchImagesNoCache() {
2110 const promises = [];
2111 $('#image_list img').each(function () {
2112 const src = $(this).attr('src');
2113
2114 if (!src) {
2115 return;
2116 }
2117
2118 const promise = fetch(src, {
2119 method: 'GET',
2120 cache: 'no-cache',
2121 headers: {
2122 'Cache-Control': 'no-cache',
2123 'Pragma': 'no-cache',
2124 'Expires': '0',
2125 },
2126 });
2127 promises.push(promise);
2128 });
2129
2130 return await Promise.allSettled(promises);
2131}
2132
2133function migrateSettings() {
2134 if (extension_settings.expressions.api === undefined) {
2135 extension_settings.expressions.api = EXPRESSION_API.none;
2136 saveSettingsDebounced();
2137 }
2138
2139 if (Object.keys(extension_settings.expressions).includes('local')) {
2140 if (extension_settings.expressions.local) {
2141 extension_settings.expressions.api = EXPRESSION_API.local;
2142 }
2143
2144 delete extension_settings.expressions.local;
2145 saveSettingsDebounced();
2146 }
2147
2148 if (extension_settings.expressions.llmPrompt === undefined) {
2149 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
2150 saveSettingsDebounced();
2151 }
2152
2153 if (extension_settings.expressions.allowMultiple === undefined) {
2154 extension_settings.expressions.allowMultiple = true;
2155 saveSettingsDebounced();
2156 }
2157
2158 if (extension_settings.expressions.showDefault && extension_settings.expressions.fallback_expression) {
2159 extension_settings.expressions.showDefault = false;
2160 saveSettingsDebounced();
2161 }
2162
2163 if (extension_settings.expressions.promptType === undefined) {
2164 extension_settings.expressions.promptType = PROMPT_TYPE.raw;
2165 saveSettingsDebounced();
2166 }
2167}
2168
2169export async function init() {
2170 function addExpressionImage() {
2171 const html = `
2172 <div id="expression-wrapper">
2173 <div id="expression-holder" class="expression-holder" style="display:none;">
2174 <div id="expression-holderheader" class="fa-solid fa-grip drag-grabber"></div>
2175 <img id="expression-image" class="expression">
2176 </div>
2177 </div>`;
2178 $('body').append(html);
2179 loadMovingUIState();
2180 }
2181 function addVisualNovelMode() {
2182 const html = `
2183 <div id="visual-novel-wrapper">
2184 </div>`;
2185 const element = $(html);
2186 element.hide();
2187 $('body').append(element);
2188 }
2189 async function addSettings() {
2190 const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
2191 $('#expressions_container').append(template);
2192 $('#expression_override_button').on('click', onClickExpressionOverrideButton);
2193 $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
2194 $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {
2195 extension_settings.expressions.translate = !!$(this).prop('checked');
2196 saveSettingsDebounced();
2197 });
2198 $('#expressions_allow_multiple').prop('checked', extension_settings.expressions.allowMultiple).on('input', function () {
2199 extension_settings.expressions.allowMultiple = !!$(this).prop('checked');
2200 saveSettingsDebounced();
2201 });
2202 $('#expressions_reroll_if_same').prop('checked', extension_settings.expressions.rerollIfSame).on('input', function () {
2203 extension_settings.expressions.rerollIfSame = !!$(this).prop('checked');
2204 saveSettingsDebounced();
2205 });
2206 $('#expressions_filter_available').prop('checked', extension_settings.expressions.filterAvailable).on('input', function () {
2207 extension_settings.expressions.filterAvailable = !!$(this).prop('checked');
2208 saveSettingsDebounced();
2209 });
2210 $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
2211 $(document).on('dragstart', '.expression', (e) => {
2212 e.preventDefault();
2213 return false;
2214 });
2215 $(document).on('click', '.expression_list_item', onClickExpressionImage);
2216 $(document).on('click', '.expression_list_upload', onClickExpressionUpload);
2217 $(document).on('click', '.expression_list_delete', onClickExpressionDelete);
2218 $(window).on('resize', () => updateVisualNovelModeDebounced());
2219 $('#open_chat_expressions').hide();
2220
2221 await renderAdditionalExpressionSettings();
2222 $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.none);
2223 $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
2224 $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');
2225 $('#expression_llm_prompt').on('input', function () {
2226 extension_settings.expressions.llmPrompt = String($(this).val());
2227 saveSettingsDebounced();
2228 });
2229 $('#expression_llm_prompt_restore').on('click', function () {
2230 $('#expression_llm_prompt').val(DEFAULT_LLM_PROMPT);
2231 extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
2232 saveSettingsDebounced();
2233 });
2234 $('#expression_prompt_raw').on('input', function () {
2235 extension_settings.expressions.promptType = PROMPT_TYPE.raw;
2236 saveSettingsDebounced();
2237 });
2238 $('#expression_prompt_full').on('input', function () {
2239 extension_settings.expressions.promptType = PROMPT_TYPE.full;
2240 saveSettingsDebounced();
2241 });
2242 $(`input[name="expression_prompt_type"][value="${extension_settings.expressions.promptType}"]`).prop('checked', true);
2243 $('.expression_prompt_type_block').toggle(extension_settings.expressions.api === EXPRESSION_API.llm);
2244
2245 $('#expression_custom_add').on('click', onClickExpressionAddCustom);
2246 $('#expression_custom_remove').on('click', onClickExpressionRemoveCustom);
2247 $('#expression_fallback').on('change', onExpressionFallbackChanged);
2248 $('#expression_api').on('change', onExpressionApiChanged);
2249 }
2250
2251 addExpressionImage();
2252 addVisualNovelMode();
2253 migrateSettings();
2254 await addSettings();
2255 const wrapper = new ModuleWorkerWrapper(moduleWorker);
2256 const updateFunction = wrapper.update.bind(wrapper);
2257 setInterval(updateFunction, UPDATE_INTERVAL);
2258 moduleWorker();
2259 dragElement($('#expression-holder'));
2260 eventSource.on(event_types.CHAT_CHANGED, () => {
2261 // character changed
2262 removeExpression();
2263 spriteCache = {};
2264 lastExpression = {};
2265
2266 //clear expression
2267 let imgElement = document.getElementById('expression-image');
2268 if (imgElement && imgElement instanceof HTMLImageElement) {
2269 imgElement.src = '';
2270 }
2271
2272 setExpressionOverrideHtml(true); // force-clear, as the character might not have an override defined
2273
2274 if (isVisualNovelMode()) {
2275 $('#visual-novel-wrapper').empty();
2276 }
2277
2278 updateFunction({ newChat: true });
2279 });
2280 eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
2281 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
2282
2283 const localEnumProviders = {
2284 expressions: () => {
2285 const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2286 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2287 const expressions = getCachedExpressions();
2288 return expressions.map(expression => {
2289 const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
2290 const isCustom = extension_settings.expressions.custom?.includes(expression);
2291 const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
2292 spriteCount > 1 ? `${spriteCount} sprites` : null;
2293 return new SlashCommandEnumValue(expression,
2294 subtitle,
2295 isCustom ? enumTypes.name : enumTypes.enum,
2296 isCustom ? 'C' : 'D');
2297 });
2298 },
2299 sprites: () => {
2300 const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
2301 const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
2302 const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? [];
2303 return sprites.map(x => {
2304 return new SlashCommandEnumValue(x.title,
2305 x.title !== x.expression ? x.expression : null,
2306 x.isCustom ? enumTypes.name : enumTypes.enum,
2307 x.isCustom ? 'C' : 'D');
2308 });
2309 },
2310 };
2311
2312 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2313 name: 'expression-set',
2314 aliases: ['sprite', 'emote'],
2315 callback: setSpriteSlashCommand,
2316 namedArgumentList: [
2317 SlashCommandNamedArgument.fromProps({
2318 name: 'type',
2319 description: 'Whether to set an expression or a specific sprite.',
2320 typeList: [ARGUMENT_TYPE.STRING],
2321 isRequired: false,
2322 defaultValue: 'expression',
2323 enumList: ['expression', 'sprite'],
2324 }),
2325 ],
2326 unnamedArgumentList: [
2327 SlashCommandArgument.fromProps({
2328 description: 'expression label to set',
2329 typeList: [ARGUMENT_TYPE.STRING],
2330 isRequired: true,
2331 enumProvider: (executor, _) => {
2332 // Check if command is used to set a sprite, then use those enums
2333 const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression';
2334 if (type == 'sprite') return localEnumProviders.sprites();
2335 else return [
2336 ...localEnumProviders.expressions(),
2337 new SlashCommandEnumValue(RESET_SPRITE_LABEL, 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'),
2338 ];
2339 },
2340 }),
2341 ],
2342 helpString: 'Force sets the expression for the current character.',
2343 returns: 'The currently set expression label after setting it.',
2344 }));
2345 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2346 name: 'expression-fallback',
2347 callback: setFallBackExpressionSlashCommand,
2348 unnamedArgumentList: [
2349 SlashCommandArgument.fromProps({
2350 description: 'expression label to set',
2351 typeList: [ARGUMENT_TYPE.STRING],
2352 isRequired: false,
2353 enumProvider: () => [
2354 new SlashCommandEnumValue('#none', 'Sets the fallback expression to no image'),
2355 new SlashCommandEnumValue('#emoji', 'Sets the fallback expression to emojis'),
2356 ...localEnumProviders.expressions(),
2357 ],
2358 }),
2359 ],
2360 helpString: `
2361 <div>
2362 Gets the currently selected expression fallback for all characters.<br />
2363 If a valid expression label is sent, it will be set as the new fallback.
2364 </div>
2365 <div>
2366 <strong>Example:</strong>
2367 <ul>
2368 <li>
2369 <pre><code>/expression-fallback | /echo</code></pre>
2370 <small>Returns the currently selected fallback.</small>
2371 </li>
2372 <li>
2373 <pre><code>/expression-fallback admiration</code></pre>
2374 <small>Sets a new expression as fallback.</small>
2375 </li>
2376 </ul>
2377 </div>
2378 `,
2379 returns: 'The currently set expression label after setting it.',
2380 }));
2381 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2382 name: 'expression-folder-override',
2383 aliases: ['spriteoverride', 'costume'],
2384 callback: setSpriteFolderCommand,
2385 namedArgumentList: [
2386 SlashCommandNamedArgument.fromProps({
2387 name: 'name',
2388 description: 'Character name to set a subfolder for. If not provided, the character who last sent a message will be used.',
2389 typeList: [ARGUMENT_TYPE.STRING],
2390 enumProvider: commonEnumProviders.characters('character'),
2391 isRequired: false,
2392 acceptsMultiple: false,
2393 }),
2394 ],
2395 unnamedArgumentList: [
2396 new SlashCommandArgument(
2397 'optional folder', [ARGUMENT_TYPE.STRING], false,
2398 ),
2399 ],
2400 helpString: `
2401 <div>
2402 Sets an override sprite folder for the current character.<br />
2403 In groups, this will apply to the character who last sent a message.
2404 </div>
2405 <div>
2406 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.
2407 </div>
2408 `,
2409 }));
2410 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2411 name: 'expression-last',
2412 aliases: ['lastsprite'],
2413 /** @type {(args: object, name: string) => Promise<string>} */
2414 callback: async (_, name) => {
2415 if (typeof name !== 'string') throw new Error('name must be a string');
2416 if (!name) {
2417 if (selected_group) {
2418 toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`);
2419 return '';
2420 }
2421 name = characters[this_chid]?.avatar;
2422 }
2423
2424 const char = findChar({ name: name });
2425 if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
2426
2427 const sprite = lastExpression[char?.name ?? name] ?? '';
2428 return sprite;
2429 },
2430 returns: 'the last set expression for the named character.',
2431 unnamedArgumentList: [
2432 SlashCommandArgument.fromProps({
2433 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)',
2434 typeList: [ARGUMENT_TYPE.STRING],
2435 enumProvider: commonEnumProviders.characters('character'),
2436 }),
2437 ],
2438 helpString: 'Returns the last set expression for the named character.',
2439 }));
2440 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2441 name: 'expression-list',
2442 aliases: ['expressions'],
2443 /** @type {(args: {return: string, filter: string}) => Promise<string>} */
2444 callback: async (args) => {
2445 let returnType =
2446 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2447 (args.return);
2448
2449 const list = await getExpressionsList({ filterAvailable: !isFalseBoolean(args.filter) });
2450
2451 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
2452 },
2453 namedArgumentList: [
2454 SlashCommandNamedArgument.fromProps({
2455 name: 'return',
2456 description: 'The way how you want the return value to be provided',
2457 typeList: [ARGUMENT_TYPE.STRING],
2458 defaultValue: 'pipe',
2459 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
2460 forceEnum: true,
2461 }),
2462 SlashCommandNamedArgument.fromProps({
2463 name: 'filter',
2464 description: 'Filter the list to only include expressions that have available sprites for the current character.',
2465 typeList: [ARGUMENT_TYPE.BOOLEAN],
2466 enumList: commonEnumProviders.boolean('trueFalse')(),
2467 defaultValue: 'true',
2468 }),
2469 ],
2470 returns: 'The comma-separated list of available expressions, including custom expressions.',
2471 helpString: 'Returns a list of available expressions, including custom expressions.',
2472 }));
2473 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2474 name: 'expression-classify',
2475 aliases: ['classify'],
2476 callback: classifyCallback,
2477 namedArgumentList: [
2478 SlashCommandNamedArgument.fromProps({
2479 name: 'api',
2480 description: 'The Classifier API to classify with. If not specified, the configured one will be used.',
2481 typeList: [ARGUMENT_TYPE.STRING],
2482 enumList: Object.keys(EXPRESSION_API).map(api => new SlashCommandEnumValue(api, null, enumTypes.enum)),
2483 }),
2484 SlashCommandNamedArgument.fromProps({
2485 name: 'filter',
2486 description: 'Filter the list to only include expressions that have available sprites for the current character.',
2487 typeList: [ARGUMENT_TYPE.BOOLEAN],
2488 enumList: commonEnumProviders.boolean('trueFalse')(),
2489 defaultValue: 'true',
2490 }),
2491 SlashCommandNamedArgument.fromProps({
2492 name: 'prompt',
2493 description: 'Custom prompt for classification. Only relevant if Classifier API is set to LLM.',
2494 typeList: [ARGUMENT_TYPE.STRING],
2495 }),
2496 ],
2497 unnamedArgumentList: [
2498 new SlashCommandArgument(
2499 'text', [ARGUMENT_TYPE.STRING], true,
2500 ),
2501 ],
2502 returns: 'emotion classification label for the given text',
2503 helpString: `
2504 <div>
2505 Performs an emotion classification of the given text and returns a label.
2506 </div>
2507 <div>
2508 Allows to specify which Classifier API to perform the classification with.
2509 </div>
2510 <div>
2511 <strong>Example:</strong>
2512 <ul>
2513 <li>
2514 <pre><code>/classify I am so happy today!</code></pre>
2515 </li>
2516 </ul>
2517 </div>
2518 `,
2519 }));
2520 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2521 name: 'expression-upload',
2522 aliases: ['uploadsprite'],
2523 /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
2524 callback: async (args, url) => {
2525 return await uploadSpriteCommand(args, url);
2526 },
2527 returns: 'the resulting sprite name',
2528 unnamedArgumentList: [
2529 SlashCommandArgument.fromProps({
2530 description: 'URL of the image to upload',
2531 typeList: [ARGUMENT_TYPE.STRING],
2532 isRequired: true,
2533 }),
2534 ],
2535 namedArgumentList: [
2536 SlashCommandNamedArgument.fromProps({
2537 name: 'name',
2538 description: 'Character name or avatar key (default is current character)',
2539 typeList: [ARGUMENT_TYPE.STRING],
2540 isRequired: false,
2541 }),
2542 SlashCommandNamedArgument.fromProps({
2543 name: 'label',
2544 description: 'Sprite label/expression name',
2545 typeList: [ARGUMENT_TYPE.STRING],
2546 enumProvider: localEnumProviders.expressions,
2547 isRequired: true,
2548 }),
2549 SlashCommandNamedArgument.fromProps({
2550 name: 'folder',
2551 description: 'Override folder to upload into',
2552 typeList: [ARGUMENT_TYPE.STRING],
2553 isRequired: false,
2554 }),
2555 SlashCommandNamedArgument.fromProps({
2556 name: 'spriteName',
2557 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.',
2558 typeList: [ARGUMENT_TYPE.STRING],
2559 isRequired: false,
2560 }),
2561 ],
2562 helpString: `
2563 <div>
2564 Upload a sprite from a URL.
2565 </div>
2566 <div>
2567 <strong>Example:</strong>
2568 <ul>
2569 <li>
2570 <pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>
2571 </li>
2572 </ul>
2573 </div>
2574 `,
2575 }));
2576}