mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			2459 lines
		
	
	
		
			96 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			2459 lines
		
	
	
		
			96 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import { Fuse } from '../../../lib.js';
 | |
| 
 | |
| import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
 | |
| import { dragElement, isMobile } from '../../RossAscends-mods.js';
 | |
| import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
 | |
| import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js';
 | |
| import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean } from '../../utils.js';
 | |
| import { hideMutedSprites, selected_group } from '../../group-chats.js';
 | |
| import { isJsonSchemaSupported } from '../../textgen-settings.js';
 | |
| import { debounce_timeout } from '../../constants.js';
 | |
| import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
 | |
| import { SlashCommand } from '../../slash-commands/SlashCommand.js';
 | |
| import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
 | |
| import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
 | |
| import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
 | |
| import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
 | |
| import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
 | |
| import { Popup, POPUP_RESULT } from '../../popup.js';
 | |
| import { t } from '../../i18n.js';
 | |
| import { removeReasoningFromString } from '../../reasoning.js';
 | |
| export { MODULE_NAME };
 | |
| 
 | |
| /**
 | |
| * @typedef {object} Expression Expression definition with label and file path
 | |
| * @property {string} label The label of the expression
 | |
| * @property {ExpressionImage[]} files One or more images to represent this expression
 | |
| */
 | |
| 
 | |
| /**
 | |
|  * @typedef {object} ExpressionImage An expression image
 | |
|  * @property {string} expression - The expression
 | |
|  * @property {boolean} [isCustom=false] - If the expression is added by user
 | |
|  * @property {string} fileName - The filename with extension
 | |
|  * @property {string} title - The title for the image
 | |
|  * @property {string} imageSrc - The image source / full path
 | |
|  * @property {'success' | 'additional' | 'failure'} type - The type of the image
 | |
|  */
 | |
| 
 | |
| const MODULE_NAME = 'expressions';
 | |
| const UPDATE_INTERVAL = 2000;
 | |
| const STREAMING_UPDATE_INTERVAL = 10000;
 | |
| const DEFAULT_FALLBACK_EXPRESSION = 'joy';
 | |
| const 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}}';
 | |
| const DEFAULT_EXPRESSIONS = [
 | |
|     'admiration',
 | |
|     'amusement',
 | |
|     'anger',
 | |
|     'annoyance',
 | |
|     'approval',
 | |
|     'caring',
 | |
|     'confusion',
 | |
|     'curiosity',
 | |
|     'desire',
 | |
|     'disappointment',
 | |
|     'disapproval',
 | |
|     'disgust',
 | |
|     'embarrassment',
 | |
|     'excitement',
 | |
|     'fear',
 | |
|     'gratitude',
 | |
|     'grief',
 | |
|     'joy',
 | |
|     'love',
 | |
|     'nervousness',
 | |
|     'optimism',
 | |
|     'pride',
 | |
|     'realization',
 | |
|     'relief',
 | |
|     'remorse',
 | |
|     'sadness',
 | |
|     'surprise',
 | |
|     'neutral',
 | |
| ];
 | |
| 
 | |
| const OPTION_NO_FALLBACK = '#none';
 | |
| const OPTION_EMOJI_FALLBACK = '#emoji';
 | |
| const RESET_SPRITE_LABEL = '#reset';
 | |
| 
 | |
| 
 | |
| /** @enum {number} */
 | |
| const EXPRESSION_API = {
 | |
|     local: 0,
 | |
|     extras: 1,
 | |
|     llm: 2,
 | |
|     webllm: 3,
 | |
|     none: 99,
 | |
| };
 | |
| 
 | |
| let expressionsList = null;
 | |
| let lastCharacter = undefined;
 | |
| let lastMessage = null;
 | |
| /** @type {{[characterKey: string]: Expression[]}} */
 | |
| let spriteCache = {};
 | |
| let inApiCall = false;
 | |
| let lastServerResponseTime = 0;
 | |
| 
 | |
| /** @type {{[characterName: string]: string}} */
 | |
| export let lastExpression = {};
 | |
| 
 | |
| /**
 | |
|  * Returns a placeholder image object for a given expression
 | |
|  * @param {string} expression - The expression label
 | |
|  * @param {boolean} [isCustom=false] - Whether the expression is custom
 | |
|  * @returns {ExpressionImage} The placeholder image object
 | |
|  */
 | |
| function getPlaceholderImage(expression, isCustom = false) {
 | |
|     return {
 | |
|         expression: expression,
 | |
|         isCustom: isCustom,
 | |
|         title: 'No Image',
 | |
|         type: 'failure',
 | |
|         fileName: 'No-Image-Placeholder.svg',
 | |
|         imageSrc: '/img/No-Image-Placeholder.svg',
 | |
|     };
 | |
| }
 | |
| 
 | |
| function isVisualNovelMode() {
 | |
|     return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
 | |
| }
 | |
| 
 | |
| async function forceUpdateVisualNovelMode() {
 | |
|     if (isVisualNovelMode()) {
 | |
|         await updateVisualNovelMode();
 | |
|     }
 | |
| }
 | |
| 
 | |
| const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick);
 | |
| 
 | |
| async function updateVisualNovelMode(spriteFolderName, expression) {
 | |
|     const vnContainer = $('#visual-novel-wrapper');
 | |
| 
 | |
|     await visualNovelRemoveInactive(vnContainer);
 | |
| 
 | |
|     const setSpritePromises = await visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression);
 | |
| 
 | |
|     // calculate layer indices based on recent messages
 | |
|     await visualNovelUpdateLayers(vnContainer);
 | |
| 
 | |
|     await Promise.allSettled(setSpritePromises);
 | |
| 
 | |
|     // update again based on new sprites
 | |
|     if (setSpritePromises.length > 0) {
 | |
|         await visualNovelUpdateLayers(vnContainer);
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function visualNovelRemoveInactive(container) {
 | |
|     const context = getContext();
 | |
|     const group = context.groups.find(x => x.id == context.groupId);
 | |
|     const removeInactiveCharactersPromises = [];
 | |
| 
 | |
|     // remove inactive characters after 1 second
 | |
|     container.find('.expression-holder').each((_, current) => {
 | |
|         const promise = new Promise(resolve => {
 | |
|             const element = $(current);
 | |
|             const avatar = element.data('avatar');
 | |
| 
 | |
|             if (!group.members.includes(avatar) || group.disabled_members.includes(avatar)) {
 | |
|                 element.fadeOut(250, () => {
 | |
|                     element.remove();
 | |
|                     resolve();
 | |
|                 });
 | |
|             } else {
 | |
|                 resolve();
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         removeInactiveCharactersPromises.push(promise);
 | |
|     });
 | |
| 
 | |
|     await Promise.allSettled(removeInactiveCharactersPromises);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Sets the character sprites for visual novel mode based on the provided container, name, and expression.
 | |
|  *
 | |
|  * @param {JQuery<HTMLElement>} vnContainer - The container element where the sprites will be set
 | |
|  * @param {string} spriteFolderName - The name of the sprite folder
 | |
|  * @param {string} expression - The expression to set for the characters
 | |
|  * @returns {Promise<Array>} - An array of promises that resolve when the sprites are set
 | |
|  */
 | |
| async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) {
 | |
|     const originalExpression = expression;
 | |
|     const context = getContext();
 | |
|     const group = context.groups.find(x => x.id == context.groupId);
 | |
| 
 | |
|     const setSpritePromises = [];
 | |
| 
 | |
|     for (const avatar of group.members) {
 | |
|         // skip disabled characters
 | |
|         const isDisabled = group.disabled_members.includes(avatar);
 | |
|         if (isDisabled && hideMutedSprites) {
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         const character = context.characters.find(x => x.avatar == avatar);
 | |
|         if (!character) {
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         const expressionImage = vnContainer.find(`.expression-holder[data-avatar="${avatar}"]`);
 | |
|         /** @type {JQuery<HTMLElement>} */
 | |
|         let img;
 | |
| 
 | |
|         const memberSpriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
 | |
| 
 | |
|         // download images if not downloaded yet
 | |
|         if (spriteCache[memberSpriteFolderName] === undefined) {
 | |
|             spriteCache[memberSpriteFolderName] = await getSpritesList(memberSpriteFolderName);
 | |
|         }
 | |
| 
 | |
|         const prevExpressionSrc = expressionImage.find('img').attr('src') || null;
 | |
| 
 | |
|         if (!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) {
 | |
|             expression = await getLastMessageSprite(avatar);
 | |
|         }
 | |
| 
 | |
|         const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc });
 | |
|         if (expressionImage.length) {
 | |
|             if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) {
 | |
|                 await validateImages(memberSpriteFolderName, true);
 | |
|                 setExpressionOverrideHtml(true); // <= force clear expression override input
 | |
|                 const path = spriteFile?.imageSrc || '';
 | |
|                 img = expressionImage.find('img');
 | |
|                 await setImage(img, path);
 | |
|             }
 | |
|             expressionImage.toggleClass('hidden', !spriteFile);
 | |
|         } else {
 | |
|             const template = $('#expression-holder').clone();
 | |
|             template.attr('id', `expression-${avatar}`);
 | |
|             template.attr('data-avatar', avatar);
 | |
|             template.find('.drag-grabber').attr('id', `expression-${avatar}header`);
 | |
|             $('#visual-novel-wrapper').append(template);
 | |
|             dragElement($(template[0]));
 | |
|             template.toggleClass('hidden', !spriteFile);
 | |
|             img = template.find('img');
 | |
|             await setImage(img, spriteFile?.imageSrc || '');
 | |
|             const fadeInPromise = new Promise(resolve => {
 | |
|                 template.fadeIn(250, () => resolve());
 | |
|             });
 | |
|             setSpritePromises.push(fadeInPromise);
 | |
|         }
 | |
| 
 | |
|         if (!img) {
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         img.attr('data-sprite-folder-name', spriteFolderName);
 | |
|         img.attr('data-expression', expression);
 | |
|         img.attr('data-sprite-filename', spriteFile?.fileName || null);
 | |
|         img.attr('title', expression);
 | |
| 
 | |
|         if (spriteFile) console.info(`Expression set for group member ${character.name}`, { expression: spriteFile.expression, file: spriteFile.fileName });
 | |
|         else if (expressionImage.length) console.info(`Expression unset for group member ${character.name} - No sprite found`, { expression: expression });
 | |
|         else console.info(`Expression not available for group member ${character.name}`, { expression: expression });
 | |
|     }
 | |
| 
 | |
|     return setSpritePromises;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Classifies the text of the latest message and returns the expression label.
 | |
|  * @param {string} avatar - The avatar of the character to get the last message for
 | |
|  * @returns {Promise<string>} - The expression label
 | |
|  */
 | |
| async function getLastMessageSprite(avatar) {
 | |
|     const context = getContext();
 | |
|     const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar))));
 | |
| 
 | |
|     if (lastMessage) {
 | |
|         const text = lastMessage.mes || '';
 | |
|         return await getExpressionLabel(text);
 | |
|     }
 | |
| 
 | |
|     return null;
 | |
| }
 | |
| 
 | |
| export async function visualNovelUpdateLayers(container) {
 | |
|     const context = getContext();
 | |
|     const group = context.groups.find(x => x.id == context.groupId);
 | |
|     const recentMessages = context.chat.map(x => x.original_avatar).filter(x => x).reverse().filter(onlyUnique);
 | |
|     const filteredMembers = group.members.filter(x => !group.disabled_members.includes(x));
 | |
|     const layerIndices = filteredMembers.slice().sort((a, b) => {
 | |
|         const aRecentIndex = recentMessages.indexOf(a);
 | |
|         const bRecentIndex = recentMessages.indexOf(b);
 | |
|         const aFilteredIndex = filteredMembers.indexOf(a);
 | |
|         const bFilteredIndex = filteredMembers.indexOf(b);
 | |
| 
 | |
|         if (aRecentIndex !== -1 && bRecentIndex !== -1) {
 | |
|             return bRecentIndex - aRecentIndex;
 | |
|         } else if (aRecentIndex !== -1) {
 | |
|             return 1;
 | |
|         } else if (bRecentIndex !== -1) {
 | |
|             return -1;
 | |
|         } else {
 | |
|             return aFilteredIndex - bFilteredIndex;
 | |
|         }
 | |
|     });
 | |
| 
 | |
|     const setLayerIndicesPromises = [];
 | |
| 
 | |
|     const sortFunction = (a, b) => {
 | |
|         const avatarA = $(a).data('avatar');
 | |
|         const avatarB = $(b).data('avatar');
 | |
|         const indexA = filteredMembers.indexOf(avatarA);
 | |
|         const indexB = filteredMembers.indexOf(avatarB);
 | |
|         return indexA - indexB;
 | |
|     };
 | |
| 
 | |
|     const containerWidth = container.width();
 | |
|     const pivotalPoint = containerWidth * 0.5;
 | |
| 
 | |
|     let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction);
 | |
|     let imagesWidth = [];
 | |
| 
 | |
|     for (const image of images) {
 | |
|         if (image instanceof HTMLImageElement && !image.complete) {
 | |
|             await new Promise(resolve => image.addEventListener('load', resolve, { once: true }));
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     images.forEach(image => {
 | |
|         imagesWidth.push($(image).width());
 | |
|     });
 | |
| 
 | |
|     let totalWidth = imagesWidth.reduce((a, b) => a + b, 0);
 | |
|     let currentPosition = pivotalPoint - (totalWidth / 2);
 | |
| 
 | |
|     if (totalWidth > containerWidth) {
 | |
|         let totalOverlap = totalWidth - containerWidth;
 | |
|         let totalWidthWithoutWidest = imagesWidth.reduce((a, b) => a + b, 0) - Math.max(...imagesWidth);
 | |
|         let overlaps = imagesWidth.map(width => (width / totalWidthWithoutWidest) * totalOverlap);
 | |
|         imagesWidth = imagesWidth.map((width, index) => width - overlaps[index]);
 | |
|         currentPosition = 0; // Reset the initial position to 0
 | |
|     }
 | |
| 
 | |
|     images.forEach((current, index) => {
 | |
|         const element = $(current);
 | |
|         const elementID = element.attr('id');
 | |
| 
 | |
|         // skip repositioning of dragged elements
 | |
|         if (element.data('dragged')
 | |
|             || (power_user.movingUIState[elementID]
 | |
|                 && (typeof power_user.movingUIState[elementID] === 'object')
 | |
|                 && Object.keys(power_user.movingUIState[elementID]).length > 0)) {
 | |
|             loadMovingUIState();
 | |
|             //currentPosition += imagesWidth[index];
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const avatar = element.data('avatar');
 | |
|         const layerIndex = layerIndices.indexOf(avatar);
 | |
|         element.css('z-index', layerIndex);
 | |
|         element.show();
 | |
| 
 | |
|         const promise = new Promise(resolve => {
 | |
|             if (power_user.reduced_motion) {
 | |
|                 element.css('left', currentPosition + 'px');
 | |
|                 requestAnimationFrame(() => resolve());
 | |
|             }
 | |
|             else {
 | |
|                 element.animate({ left: currentPosition + 'px' }, 500, () => {
 | |
|                     resolve();
 | |
|                 });
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         currentPosition += imagesWidth[index];
 | |
| 
 | |
|         setLayerIndicesPromises.push(promise);
 | |
|     });
 | |
| 
 | |
|     await Promise.allSettled(setLayerIndicesPromises);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Sets the expression for the given character image.
 | |
|  * @param {JQuery<HTMLElement>} img - The image element to set the image on
 | |
|  * @param {string} path - The path to the image
 | |
|  * @returns {Promise<void>} - A promise that resolves when the image is set
 | |
|  */
 | |
| async function setImage(img, path) {
 | |
|     // Cohee: If something goes wrong, uncomment this to return to the old behavior
 | |
|     /*
 | |
|     img.attr('src', path);
 | |
|     img.removeClass('default');
 | |
|     img.off('error');
 | |
|     img.on('error', function () {
 | |
|         console.debug('Error loading image', path);
 | |
|         $(this).off('error');
 | |
|         $(this).attr('src', '');
 | |
|     });
 | |
|     */
 | |
| 
 | |
|     return new Promise(resolve => {
 | |
|         const prevExpressionSrc = img.attr('src');
 | |
|         const expressionClone = img.clone();
 | |
|         const originalId = img.data('filename');
 | |
| 
 | |
|         //only swap expressions when necessary
 | |
|         if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) {
 | |
|             //clone expression
 | |
|             expressionClone.addClass('expression-clone');
 | |
|             //make invisible and remove id to prevent double ids
 | |
|             //must be made invisible to start because they share the same Z-index
 | |
|             expressionClone.data('filename', '').css({ opacity: 0 });
 | |
|             //add new sprite path to clone src
 | |
|             expressionClone.attr('src', path);
 | |
|             //add invisible clone to html
 | |
|             expressionClone.appendTo(img.parent());
 | |
| 
 | |
|             const duration = 200;
 | |
| 
 | |
|             //add animation flags to both images
 | |
|             //to prevent multiple expression changes happening simultaneously
 | |
|             img.addClass('expression-animating');
 | |
| 
 | |
|             // Set the parent container's min width and height before running the transition
 | |
|             const imgWidth = img.width();
 | |
|             const imgHeight = img.height();
 | |
|             const expressionHolder = img.parent();
 | |
|             expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
 | |
|             expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
 | |
| 
 | |
|             //position absolute prevent the original from jumping around during transition
 | |
|             img.css('position', 'absolute').width(imgWidth).height(imgHeight);
 | |
|             expressionClone.addClass('expression-animating');
 | |
|             //fade the clone in
 | |
|             expressionClone.css({
 | |
|                 opacity: 0,
 | |
|             }).animate({
 | |
|                 opacity: 1,
 | |
|             }, duration)
 | |
|                 //when finshed fading in clone, fade out the original
 | |
|                 .promise().done(function () {
 | |
|                     img.animate({
 | |
|                         opacity: 0,
 | |
|                     }, duration);
 | |
|                     //remove old expression
 | |
|                     img.remove();
 | |
|                     //replace ID so it becomes the new 'original' expression for next change
 | |
|                     expressionClone.data('filename', originalId);
 | |
|                     expressionClone.removeClass('expression-animating');
 | |
| 
 | |
|                     // Reset the expression holder min height and width
 | |
|                     expressionHolder.css('min-width', 100);
 | |
|                     expressionHolder.css('min-height', 100);
 | |
| 
 | |
|                     if (expressionClone.prop('complete')) {
 | |
|                         resolve();
 | |
|                     } else {
 | |
|                         expressionClone.one('load', () => resolve());
 | |
|                     }
 | |
|                 });
 | |
| 
 | |
|             expressionClone.removeClass('expression-clone');
 | |
| 
 | |
|             expressionClone.removeClass('default');
 | |
|             expressionClone.off('error');
 | |
|             expressionClone.on('error', function () {
 | |
|                 console.debug('Expression image error', path);
 | |
|                 $(this).attr('src', '');
 | |
|                 $(this).off('error');
 | |
|                 resolve();
 | |
|             });
 | |
|         } else {
 | |
|             resolve();
 | |
|         }
 | |
|     });
 | |
| }
 | |
| 
 | |
| async function moduleWorker({ newChat = false } = {}) {
 | |
|     const context = getContext();
 | |
| 
 | |
|     // non-characters not supported
 | |
|     if (!context.groupId && context.characterId === undefined) {
 | |
|         removeExpression();
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const vnMode = isVisualNovelMode();
 | |
|     const vnWrapperVisible = $('#visual-novel-wrapper').is(':visible');
 | |
| 
 | |
|     if (vnMode) {
 | |
|         $('#expression-wrapper').hide();
 | |
|         $('#visual-novel-wrapper').show();
 | |
|     } else {
 | |
|         $('#expression-wrapper').show();
 | |
|         $('#visual-novel-wrapper').hide();
 | |
|     }
 | |
| 
 | |
|     const vnStateChanged = vnMode !== vnWrapperVisible;
 | |
| 
 | |
|     if (vnStateChanged) {
 | |
|         lastMessage = null;
 | |
|         $('#visual-novel-wrapper').empty();
 | |
|         $('#expression-holder').css({ top: '', left: '', right: '', bottom: '', height: '', width: '', margin: '' });
 | |
|     }
 | |
| 
 | |
|     const currentLastMessage = getLastCharacterMessage();
 | |
|     let spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
 | |
| 
 | |
|     // character has no expressions or it is not loaded
 | |
|     if (Object.keys(spriteCache).length === 0) {
 | |
|         await validateImages(spriteFolderName);
 | |
|         lastCharacter = context.groupId || context.characterId;
 | |
|     }
 | |
| 
 | |
|     const offlineMode = $('.expression_settings .offline_mode');
 | |
|     if (!modules.includes('classify') && extension_settings.expressions.api == EXPRESSION_API.extras) {
 | |
|         $('#open_chat_expressions').show();
 | |
|         $('#no_chat_expressions').hide();
 | |
|         offlineMode.css('display', 'block');
 | |
|         lastCharacter = context.groupId || context.characterId;
 | |
| 
 | |
|         if (context.groupId) {
 | |
|             await validateImages(spriteFolderName, true);
 | |
|             await forceUpdateVisualNovelMode();
 | |
|         }
 | |
| 
 | |
|         return;
 | |
|     }
 | |
|     else {
 | |
|         // force reload expressions list on connect to API
 | |
|         if (offlineMode.is(':visible')) {
 | |
|             expressionsList = null;
 | |
|             spriteCache = {};
 | |
|             expressionsList = await getExpressionsList();
 | |
|             await validateImages(spriteFolderName, true);
 | |
|             await forceUpdateVisualNovelMode();
 | |
|         }
 | |
| 
 | |
|         if (context.groupId && !Array.isArray(spriteCache[spriteFolderName])) {
 | |
|             await validateImages(spriteFolderName, true);
 | |
|             await forceUpdateVisualNovelMode();
 | |
|         }
 | |
| 
 | |
|         offlineMode.css('display', 'none');
 | |
|     }
 | |
| 
 | |
|     if (context.groupId && vnMode && newChat) {
 | |
|         await forceUpdateVisualNovelMode();
 | |
|     }
 | |
| 
 | |
|     // Don't bother classifying if current char has no sprites and no default expressions are enabled
 | |
|     if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastMessage === currentLastMessage.mes);
 | |
| 
 | |
|     // check if last message changed
 | |
|     if (!lastMessageChanged) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // If using LLM api then check if streamingProcessor is finished to avoid sending multiple requests to the API
 | |
|     if (extension_settings.expressions.api === EXPRESSION_API.llm && context.streamingProcessor && !context.streamingProcessor.isFinished) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // API is busy
 | |
|     if (inApiCall) {
 | |
|         console.debug('Classification API is busy');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Throttle classification requests during streaming
 | |
|     if (!context.groupId && context.streamingProcessor && !context.streamingProcessor.isFinished) {
 | |
|         const now = Date.now();
 | |
|         const timeSinceLastServerResponse = now - lastServerResponseTime;
 | |
| 
 | |
|         if (timeSinceLastServerResponse < STREAMING_UPDATE_INTERVAL) {
 | |
|             console.log('Streaming in progress: throttling expression update. Next update at ' + new Date(lastServerResponseTime + STREAMING_UPDATE_INTERVAL));
 | |
|             return;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|         inApiCall = true;
 | |
|         let expression = await getExpressionLabel(currentLastMessage.mes);
 | |
| 
 | |
|         // If we're not already overriding the folder name, account for group chats.
 | |
|         if (spriteFolderName === currentLastMessage.name && !context.groupId) {
 | |
|             spriteFolderName = context.name2;
 | |
|         }
 | |
| 
 | |
|         const force = !!context.groupId;
 | |
| 
 | |
|         // Character won't be angry on you for swiping
 | |
|         if (currentLastMessage.mes == '...' && expressionsList.includes(extension_settings.expressions.fallback_expression)) {
 | |
|             expression = extension_settings.expressions.fallback_expression;
 | |
|         }
 | |
| 
 | |
|         await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
 | |
|     }
 | |
|     catch (error) {
 | |
|         console.log(error);
 | |
|     }
 | |
|     finally {
 | |
|         inApiCall = false;
 | |
|         lastCharacter = context.groupId || context.characterId;
 | |
|         lastMessage = currentLastMessage.mes;
 | |
|         lastServerResponseTime = Date.now();
 | |
|     }
 | |
| }
 | |
| 
 | |
| function getSpriteFolderName(characterMessage = null, characterName = null) {
 | |
|     const context = getContext();
 | |
|     let spriteFolderName = characterName ?? context.name2;
 | |
|     const message = characterMessage ?? getLastCharacterMessage();
 | |
|     const avatarFileName = getFolderNameByMessage(message);
 | |
|     const expressionOverride = extension_settings.expressionOverrides.find(e => e.name == avatarFileName);
 | |
| 
 | |
|     if (expressionOverride && expressionOverride.path) {
 | |
|         spriteFolderName = expressionOverride.path;
 | |
|     }
 | |
| 
 | |
|     return spriteFolderName;
 | |
| }
 | |
| 
 | |
| function getFolderNameByMessage(message) {
 | |
|     const context = getContext();
 | |
|     let avatarPath = '';
 | |
| 
 | |
|     if (context.groupId) {
 | |
|         avatarPath = message.original_avatar || context.characters.find(x => message.force_avatar && message.force_avatar.includes(encodeURIComponent(x.avatar)))?.avatar;
 | |
|     }
 | |
|     else if (context.characterId !== undefined) {
 | |
|         avatarPath = getCharaFilename();
 | |
|     }
 | |
| 
 | |
|     if (!avatarPath) {
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     const folderName = avatarPath.replace(/\.[^/.]+$/, '');
 | |
|     return folderName;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Update the expression for the given character.
 | |
|  *
 | |
|  * @param {string} spriteFolderName The character name, optionally with a sprite folder override, e.g. "folder/expression".
 | |
|  * @param {string} expression The expression label, e.g. "amusement", "joy", etc.
 | |
|  * @param {Object} [options] Additional options
 | |
|  * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression.
 | |
|  * @param {boolean} [options.vnMode=null] If true, the expression will be sent in Visual Novel mode. If null, it will be determined by the current chat mode.
 | |
|  * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
 | |
|  */
 | |
| export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
 | |
|     lastExpression[spriteFolderName.split('/')[0]] = expression;
 | |
|     if (vnMode === null) {
 | |
|         vnMode = isVisualNovelMode();
 | |
|     }
 | |
| 
 | |
|     if (vnMode) {
 | |
|         await updateVisualNovelMode(spriteFolderName, expression);
 | |
|     } else {
 | |
|         setExpression(spriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function setSpriteFolderCommand(_, folder) {
 | |
|     if (!folder) {
 | |
|         console.log('Clearing sprite set');
 | |
|         folder = '';
 | |
|     }
 | |
| 
 | |
|     if (folder.startsWith('/') || folder.startsWith('\\')) {
 | |
|         const currentLastMessage = getLastCharacterMessage();
 | |
|         folder = folder.slice(1);
 | |
|         folder = `${currentLastMessage.name}/${folder}`;
 | |
|     }
 | |
| 
 | |
|     $('#expression_override').val(folder.trim());
 | |
|     onClickExpressionOverrideButton();
 | |
| 
 | |
|     // No need to resend the expression, the folder override will automatically update the currently displayed one.
 | |
|     return '';
 | |
| }
 | |
| 
 | |
| async function classifyCallback(/** @type {{api: string?, filter: string?, prompt: string?}} */ { api = null, filter = null, prompt = null }, text) {
 | |
|     if (!text) {
 | |
|         toastr.error('No text provided');
 | |
|         return '';
 | |
|     }
 | |
|     if (api && !Object.keys(EXPRESSION_API).includes(api)) {
 | |
|         toastr.error('Invalid API provided');
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     const expressionApi = EXPRESSION_API[api] || extension_settings.expressions.api;
 | |
|     const filterAvailable = !isFalseBoolean(filter);
 | |
| 
 | |
|     if (expressionApi === EXPRESSION_API.none) {
 | |
|         toastr.warning('No classifier API selected');
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     if (!modules.includes('classify') && expressionApi == EXPRESSION_API.extras) {
 | |
|         toastr.warning('Text classification is disabled or not available');
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     const label = await getExpressionLabel(text, expressionApi, { filterAvailable: filterAvailable, customPrompt: prompt });
 | |
|     console.debug(`Classification result for "${text}": ${label}`);
 | |
|     return label;
 | |
| }
 | |
| 
 | |
| /** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
 | |
| async function setSpriteSlashCommand({ type }, searchTerm) {
 | |
|     type ??= 'expression';
 | |
|     searchTerm = searchTerm.trim().toLowerCase();
 | |
|     if (!searchTerm) {
 | |
|         toastr.error(t`No expression or sprite name provided`, t`Set Sprite`);
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
 | |
|     const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
 | |
| 
 | |
|     let label = searchTerm;
 | |
| 
 | |
|     /** @type {string?} */
 | |
|     let spriteFile = null;
 | |
| 
 | |
|     await validateImages(spriteFolderName);
 | |
| 
 | |
|     // Handle reset as a special term and just reset the sprite via expression call
 | |
|     if (searchTerm === RESET_SPRITE_LABEL) {
 | |
|         await sendExpressionCall(spriteFolderName, label, { force: true });
 | |
|         return lastExpression[spriteFolderName] ?? '';
 | |
|     }
 | |
| 
 | |
|     switch (type) {
 | |
|         case 'expression': {
 | |
|             // Fuzzy search for expression
 | |
|             const existingExpressions = getCachedExpressions().map(x => ({ label: x }));
 | |
|             const results = performFuzzySearch('expression-expressions', existingExpressions, [
 | |
|                 { name: 'label', weight: 1 },
 | |
|             ], searchTerm);
 | |
|             const matchedExpression = results[0]?.item;
 | |
|             if (!matchedExpression) {
 | |
|                 toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`);
 | |
|                 return '';
 | |
|             }
 | |
| 
 | |
|             label = matchedExpression.label;
 | |
|             break;
 | |
|         }
 | |
|         case 'sprite': {
 | |
|             // Fuzzy search for sprite file
 | |
|             const sprites = spriteCache[spriteFolderName].map(x => x.files).flat();
 | |
|             const results = performFuzzySearch('expression-expressions', sprites, [
 | |
|                 { name: 'title', weight: 1 },
 | |
|                 { name: 'fileName', weight: 1 },
 | |
|             ], searchTerm);
 | |
|             const matchedSprite = results[0]?.item;
 | |
|             if (!matchedSprite) {
 | |
|                 toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`);
 | |
|                 return '';
 | |
|             }
 | |
| 
 | |
|             label = matchedSprite.expression;
 | |
|             spriteFile = matchedSprite.fileName;
 | |
|             break;
 | |
|         }
 | |
|         default: throw Error('Invalid sprite set type: ' + type);
 | |
|     }
 | |
| 
 | |
|     await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
 | |
| 
 | |
|     return label;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Returns the sprite folder name (including override) for a character.
 | |
|  * @param {object} char Character object
 | |
|  * @param {string} char.avatar Avatar filename with extension
 | |
|  * @returns {string} Sprite folder name
 | |
|  * @throws {Error} If character not found or avatar not set
 | |
|  */
 | |
| function spriteFolderNameFromCharacter(char) {
 | |
|     const avatarFileName = char.avatar.replace(/\.[^/.]+$/, '');
 | |
|     const expressionOverride = extension_settings.expressionOverrides.find(e => e.name === avatarFileName);
 | |
|     return expressionOverride?.path ? expressionOverride.path : avatarFileName;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Generates a unique sprite name by appending an index to the given expression. *
 | |
|  * @param {string} expression - The base expression to be used as the prefix for the sprite name.
 | |
|  * @param {ExpressionImage[]} existingFiles - An array of existing file objects, each containing a fileName property.
 | |
|  * @returns {string} - A unique sprite name with the format "expression-index".
 | |
|  */
 | |
| function generateUniqueSpriteName(expression, existingFiles) {
 | |
|     let index = existingFiles.length;
 | |
|     let newSpriteName;
 | |
|     do {
 | |
|         newSpriteName = `${expression}-${index++}`;
 | |
|     } while (existingFiles.some(file => withoutExtension(file.fileName) === newSpriteName));
 | |
|     return newSpriteName;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Slash command callback for /uploadsprite
 | |
|  *
 | |
|  * label= is required
 | |
|  * if name= is provided, it will be used as a findChar lookup
 | |
|  * if name= is not provided, the last character's name will be used
 | |
|  * if folder= is a full path, it will be used as the folder
 | |
|  * if folder= is a partial path, it will be appended to the character's name
 | |
|  * if folder= is not provided, the character's override folder will be used, if set
 | |
|  *
 | |
|  * @param {object} args
 | |
|  * @param {string} args.name Character name or avatar key, passed through findChar
 | |
|  * @param {string} args.label Expression label
 | |
|  * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules
 | |
|  * @param {string?} [args.spriteName=null] Optional sprite name
 | |
|  * @param {string} imageUrl Image URI to fetch and upload
 | |
|  * @returns {Promise<string>} the sprite name
 | |
|  */
 | |
| async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
 | |
|     if (!imageUrl) throw new Error('Image URL is required');
 | |
|     if (!label || typeof label !== 'string') {
 | |
|         toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
 | |
|     if (!label) {
 | |
|         toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`);
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     spriteName = spriteName || label;
 | |
|     if (!validateExpressionSpriteName(label, spriteName)) {
 | |
|         toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`);
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
 | |
|     const char = findChar({ name });
 | |
| 
 | |
|     if (!folder) {
 | |
|         folder = spriteFolderNameFromCharacter(char);
 | |
|     } else if (folder.startsWith('/') || folder.startsWith('\\')) {
 | |
|         const subfolder = folder.slice(1);
 | |
|         folder = `${char.name}/${subfolder}`;
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|         const response = await fetch(imageUrl);
 | |
|         const blob = await response.blob();
 | |
|         const file = new File([blob], 'image.png', { type: 'image/png' });
 | |
| 
 | |
|         const formData = new FormData();
 | |
|         formData.append('name', folder); // this is the folder or character name
 | |
|         formData.append('label', label); // this is the expression label
 | |
|         formData.append('avatar', file); // this is the image file
 | |
|         formData.append('spriteName', spriteName); // this is a redundant comment
 | |
| 
 | |
|         await handleFileUpload('/api/sprites/upload', formData);
 | |
|         console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
 | |
|     } catch (error) {
 | |
|         console.error(`[${MODULE_NAME}] Error uploading file:`, error);
 | |
|         throw error;
 | |
|     }
 | |
| 
 | |
|     return spriteName;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Processes the classification text to reduce the amount of text sent to the API.
 | |
|  * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is.
 | |
|  * If the text is more than 300 characters, the first and last 150 characters are returned.
 | |
|  * The result is trimmed to the end of sentence.
 | |
|  * @param {string} text The text to process.
 | |
|  * @returns {string}
 | |
|  */
 | |
| function sampleClassifyText(text) {
 | |
|     if (!text) {
 | |
|         return text;
 | |
|     }
 | |
| 
 | |
|     // Replace macros, remove asterisks and quotes
 | |
|     let result = substituteParams(text).replace(/[*"]/g, '');
 | |
| 
 | |
|     // If using LLM api there is no need to check length of characters
 | |
|     if (extension_settings.expressions.api === EXPRESSION_API.llm) {
 | |
|         return result.trim();
 | |
|     }
 | |
| 
 | |
|     const SAMPLE_THRESHOLD = 500;
 | |
|     const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2;
 | |
| 
 | |
|     if (text.length < SAMPLE_THRESHOLD) {
 | |
|         result = trimToEndSentence(result);
 | |
|     } else {
 | |
|         result = trimToEndSentence(result.slice(0, HALF_SAMPLE_THRESHOLD)) + ' ' + trimToStartSentence(result.slice(-HALF_SAMPLE_THRESHOLD));
 | |
|     }
 | |
| 
 | |
|     return result.trim();
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Gets the classification prompt for the LLM API.
 | |
|  * @param {string[]} labels A list of labels to search for.
 | |
|  * @returns {Promise<string>} Prompt for the LLM API.
 | |
|  */
 | |
| async function getLlmPrompt(labels) {
 | |
|     if (isJsonSchemaSupported()) {
 | |
|         return '';
 | |
|     }
 | |
| 
 | |
|     const labelsString = labels.map(x => `"${x}"`).join(', ');
 | |
|     const prompt = substituteParamsExtended(String(extension_settings.expressions.llmPrompt), { labels: labelsString });
 | |
|     return prompt;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Parses the emotion response from the LLM API.
 | |
|  * @param {string} emotionResponse The response from the LLM API.
 | |
|  * @param {string[]} labels A list of labels to search for.
 | |
|  * @returns {string} The parsed emotion or the fallback expression.
 | |
|  */
 | |
| function parseLlmResponse(emotionResponse, labels) {
 | |
|     try {
 | |
|         const parsedEmotion = JSON.parse(emotionResponse);
 | |
|         const response = parsedEmotion?.emotion?.trim()?.toLowerCase();
 | |
| 
 | |
|         if (!response || !labels.includes(response)) {
 | |
|             console.debug(`Parsed emotion response: ${response} not in labels: ${labels}`);
 | |
|             throw new Error('Emotion not in labels');
 | |
|         }
 | |
| 
 | |
|         return response;
 | |
|     } catch {
 | |
|         // Clean possible reasoning from response
 | |
|         emotionResponse = removeReasoningFromString(emotionResponse);
 | |
| 
 | |
|         const fuse = new Fuse(labels, { includeScore: true });
 | |
|         console.debug('Using fuzzy search in labels:', labels);
 | |
|         const result = fuse.search(emotionResponse);
 | |
|         if (result.length > 0) {
 | |
|             console.debug(`fuzzy search found: ${result[0].item} as closest for the LLM response:`, emotionResponse);
 | |
|             return result[0].item;
 | |
|         }
 | |
|         const lowerCaseResponse = String(emotionResponse || '').toLowerCase();
 | |
|         for (const label of labels) {
 | |
|             if (lowerCaseResponse.includes(label.toLowerCase())) {
 | |
|                 console.debug(`Found label ${label} in the LLM response:`, emotionResponse);
 | |
|                 return label;
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     throw new Error('Could not parse emotion response ' + emotionResponse);
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Gets the JSON schema for the LLM API.
 | |
|  * @param {string[]} emotions A list of emotions to search for.
 | |
|  * @returns {object} The JSON schema for the LLM API.
 | |
|  */
 | |
| function getJsonSchema(emotions) {
 | |
|     return {
 | |
|         $schema: 'http://json-schema.org/draft-04/schema#',
 | |
|         type: 'object',
 | |
|         properties: {
 | |
|             emotion: {
 | |
|                 type: 'string',
 | |
|                 enum: emotions,
 | |
|             },
 | |
|         },
 | |
|         required: [
 | |
|             'emotion',
 | |
|         ],
 | |
|     };
 | |
| }
 | |
| 
 | |
| function onTextGenSettingsReady(args) {
 | |
|     // Only call if inside an API call
 | |
|     if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) {
 | |
|         const emotions = DEFAULT_EXPRESSIONS;
 | |
|         Object.assign(args, {
 | |
|             top_k: 1,
 | |
|             stop: [],
 | |
|             stopping_strings: [],
 | |
|             custom_token_bans: [],
 | |
|             json_schema: getJsonSchema(emotions),
 | |
|         });
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Retrieves the label of an expression via classification based on the provided text.
 | |
|  * Optionally allows to override the expressions API being used.
 | |
|  * @param {string} text - The text to classify and retrieve the expression label for.
 | |
|  * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification.
 | |
|  * @param {object} [options={}] - Optional arguments.
 | |
|  * @param {boolean?} [options.filterAvailable=null] - Whether to filter available expressions. If not specified, uses the extension setting.
 | |
|  * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification.
 | |
|  * @returns {Promise<string?>} - The label of the expression.
 | |
|  */
 | |
| export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { filterAvailable = null, customPrompt = null } = {}) {
 | |
|     // Return if text is undefined, saving a costly fetch request
 | |
|     if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) {
 | |
|         return extension_settings.expressions.fallback_expression;
 | |
|     }
 | |
| 
 | |
|     if (extension_settings.expressions.translate && typeof globalThis.translate === 'function') {
 | |
|         text = await globalThis.translate(text, 'en');
 | |
|     }
 | |
| 
 | |
|     text = sampleClassifyText(text);
 | |
| 
 | |
|     filterAvailable ??= extension_settings.expressions.filterAvailable;
 | |
|     if (filterAvailable && ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(expressionsApi)) {
 | |
|         console.debug('Filter available is only supported for LLM and WebLLM expressions');
 | |
|     }
 | |
| 
 | |
|     try {
 | |
|         switch (expressionsApi) {
 | |
|             // Local BERT pipeline
 | |
|             case EXPRESSION_API.local: {
 | |
|                 const localResult = await fetch('/api/extra/classify', {
 | |
|                     method: 'POST',
 | |
|                     headers: getRequestHeaders(),
 | |
|                     body: JSON.stringify({ text: text }),
 | |
|                 });
 | |
| 
 | |
|                 if (localResult.ok) {
 | |
|                     const data = await localResult.json();
 | |
|                     return data.classification[0].label;
 | |
|                 }
 | |
|             } break;
 | |
|             // Using LLM
 | |
|             case EXPRESSION_API.llm: {
 | |
|                 try {
 | |
|                     await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250);
 | |
|                 } catch (error) {
 | |
|                     console.warn('No LLM connection. Using fallback expression', error);
 | |
|                     return extension_settings.expressions.fallback_expression;
 | |
|                 }
 | |
| 
 | |
|                 const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable });
 | |
|                 const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList);
 | |
|                 eventSource.once(event_types.TEXT_COMPLETION_SETTINGS_READY, onTextGenSettingsReady);
 | |
|                 const emotionResponse = await generateRaw(text, main_api, false, false, prompt);
 | |
|                 return parseLlmResponse(emotionResponse, expressionsList);
 | |
|             }
 | |
|             // Using WebLLM
 | |
|             case EXPRESSION_API.webllm: {
 | |
|                 if (!isWebLlmSupported()) {
 | |
|                     console.warn('WebLLM is not supported. Using fallback expression');
 | |
|                     return extension_settings.expressions.fallback_expression;
 | |
|                 }
 | |
| 
 | |
|                 const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable });
 | |
|                 const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList);
 | |
|                 const messages = [
 | |
|                     { role: 'user', content: text + '\n\n' + prompt },
 | |
|                 ];
 | |
| 
 | |
|                 const emotionResponse = await generateWebLlmChatPrompt(messages);
 | |
|                 return parseLlmResponse(emotionResponse, expressionsList);
 | |
|             }
 | |
|             // Extras
 | |
|             case EXPRESSION_API.extras: {
 | |
|                 const url = new URL(getApiUrl());
 | |
|                 url.pathname = '/api/classify';
 | |
| 
 | |
|                 const extrasResult = await doExtrasFetch(url, {
 | |
|                     method: 'POST',
 | |
|                     headers: {
 | |
|                         'Content-Type': 'application/json',
 | |
|                         'Bypass-Tunnel-Reminder': 'bypass',
 | |
|                     },
 | |
|                     body: JSON.stringify({ text: text }),
 | |
|                 });
 | |
| 
 | |
|                 if (extrasResult.ok) {
 | |
|                     const data = await extrasResult.json();
 | |
|                     return data.classification[0].label;
 | |
|                 }
 | |
|             } break;
 | |
|             // None
 | |
|             case EXPRESSION_API.none: {
 | |
|                 // Return empty, the fallback expression will be used
 | |
|                 return '';
 | |
|             }
 | |
|             default: {
 | |
|                 toastr.error('Invalid API selected');
 | |
|                 return '';
 | |
|             }
 | |
|         }
 | |
|     } catch (error) {
 | |
|         toastr.error('Could not classify expression. Check the console or your backend for more information.');
 | |
|         console.error(error);
 | |
|         return extension_settings.expressions.fallback_expression;
 | |
|     }
 | |
| }
 | |
| 
 | |
| function getLastCharacterMessage() {
 | |
|     const context = getContext();
 | |
|     const reversedChat = context.chat.slice().reverse();
 | |
| 
 | |
|     for (let mes of reversedChat) {
 | |
|         if (mes.is_user || mes.is_system || mes.extra?.type === system_message_types.NARRATOR) {
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         return { mes: mes.mes, name: mes.name, original_avatar: mes.original_avatar, force_avatar: mes.force_avatar };
 | |
|     }
 | |
| 
 | |
|     return { mes: '', name: null, original_avatar: null, force_avatar: null };
 | |
| }
 | |
| 
 | |
| function removeExpression() {
 | |
|     lastMessage = null;
 | |
|     $('img.expression').off('error');
 | |
|     $('img.expression').prop('src', '');
 | |
|     $('img.expression').removeClass('default');
 | |
|     $('#open_chat_expressions').hide();
 | |
|     $('#no_chat_expressions').show();
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw.
 | |
|  * @param {string} spriteFolderName - The character sprite folder to validate
 | |
|  * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before
 | |
|  */
 | |
| async function validateImages(spriteFolderName, forceRedrawCached = false) {
 | |
|     if (!spriteFolderName) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const labels = await getExpressionsList();
 | |
| 
 | |
|     if (spriteCache[spriteFolderName]) {
 | |
|         if (forceRedrawCached && $('#image_list').data('name') !== spriteFolderName) {
 | |
|             console.debug('force redrawing character sprites list');
 | |
|             await drawSpritesList(spriteFolderName, labels, spriteCache[spriteFolderName]);
 | |
|         }
 | |
| 
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const sprites = await getSpritesList(spriteFolderName);
 | |
|     let validExpressions = await drawSpritesList(spriteFolderName, labels, sprites);
 | |
|     spriteCache[spriteFolderName] = validExpressions;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting
 | |
|  * @param {{ path: string, label: string }} sprite
 | |
|  * @returns {ExpressionImage}
 | |
|  */
 | |
| function getExpressionImageData(sprite) {
 | |
|     const fileName = sprite.path.split('/').pop().split('?')[0];
 | |
|     const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, '');
 | |
|     return {
 | |
|         expression: sprite.label,
 | |
|         fileName: fileName,
 | |
|         title: fileNameWithoutExtension,
 | |
|         imageSrc: sprite.path,
 | |
|         type: 'success',
 | |
|         isCustom: extension_settings.expressions.custom?.includes(sprite.label),
 | |
|     };
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Populate the character expression list with sprites for the given character.
 | |
|  * @param {string} spriteFolderName - The name of the character to populate the list for
 | |
|  * @param {string[]} labels - An array of expression labels that are valid
 | |
|  * @param {Expression[]} sprites - An array of sprites
 | |
|  * @returns {Promise<Expression[]>} An array of valid expression labels
 | |
|  */
 | |
| async function drawSpritesList(spriteFolderName, labels, sprites) {
 | |
|     /** @type {Expression[]} */
 | |
|     let validExpressions = [];
 | |
| 
 | |
|     $('#no_chat_expressions').hide();
 | |
|     $('#open_chat_expressions').show();
 | |
|     $('#image_list').empty();
 | |
|     $('#image_list').data('name', spriteFolderName);
 | |
|     $('#image_list_header_name').text(spriteFolderName);
 | |
| 
 | |
|     if (!Array.isArray(labels)) {
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     for (const expression of labels.sort()) {
 | |
|         const isCustom = extension_settings.expressions.custom?.includes(expression);
 | |
|         const images = sprites
 | |
|             .filter(s => s.label === expression)
 | |
|             .map(s => s.files)
 | |
|             .flat();
 | |
| 
 | |
|         if (images.length === 0) {
 | |
|             const listItem = await getListItem(expression, {
 | |
|                 isCustom,
 | |
|                 images: [getPlaceholderImage(expression, isCustom)],
 | |
|             });
 | |
|             $('#image_list').append(listItem);
 | |
|             continue;
 | |
|         }
 | |
| 
 | |
|         validExpressions.push({ label: expression, files: images });
 | |
| 
 | |
|         // Render main = first file, additional = rest
 | |
|         let listItem = await getListItem(expression, {
 | |
|             isCustom,
 | |
|             images,
 | |
|         });
 | |
|         $('#image_list').append(listItem);
 | |
|     }
 | |
|     return validExpressions;
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Renders a list item template for the expressions list.
 | |
|  * @param {string} expression Expression name
 | |
|  * @param {object} args Arguments object
 | |
|  * @param {ExpressionImage[]} [args.images] Array of image objects
 | |
|  * @param {boolean} [args.isCustom=false] If expression is added by user
 | |
|  * @returns {Promise<string>} Rendered list item template
 | |
|  */
 | |
| async function getListItem(expression, { images, isCustom = false } = {}) {
 | |
|     return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false });
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Fetches and processes the list of sprites for a given character name.
 | |
|  * Retrieves sprite data from the server and organizes it into labeled groups.
 | |
|  *
 | |
|  * @param {string} name - The character name to fetch sprites for
 | |
|  * @returns {Promise<Expression[]>} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data
 | |
|  */
 | |
| 
 | |
| async function getSpritesList(name) {
 | |
|     console.debug('getting sprites list');
 | |
| 
 | |
|     try {
 | |
|         const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
 | |
|         /** @type {{ label: string, path: string }[]} */
 | |
|         let sprites = result.ok ? (await result.json()) : [];
 | |
| 
 | |
|         /** @type {Expression[]} */
 | |
|         const grouped = sprites.reduce((acc, sprite) => {
 | |
|             const imageData = getExpressionImageData(sprite);
 | |
|             let existingExpression = acc.find(exp => exp.label === sprite.label);
 | |
|             if (existingExpression) {
 | |
|                 existingExpression.files.push(imageData);
 | |
|             } else {
 | |
|                 acc.push({ label: sprite.label, files: [imageData] });
 | |
|             }
 | |
| 
 | |
|             return acc;
 | |
|         }, []);
 | |
| 
 | |
|         // Sort the sprites for each expression alphabetically, but keep the main expression file at the front
 | |
|         for (const expression of grouped) {
 | |
|             expression.files.sort((a, b) => {
 | |
|                 if (a.title === expression.label) return -1;
 | |
|                 if (b.title === expression.label) return 1;
 | |
|                 return a.title.localeCompare(b.title);
 | |
|             });
 | |
| 
 | |
|             // Mark all besides the first sprite as 'additional'
 | |
|             for (let i = 1; i < expression.files.length; i++) {
 | |
|                 expression.files[i].type = 'additional';
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return grouped;
 | |
|     }
 | |
|     catch (err) {
 | |
|         console.log(err);
 | |
|         return [];
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function renderAdditionalExpressionSettings() {
 | |
|     renderCustomExpressions();
 | |
|     await renderFallbackExpressionPicker();
 | |
| }
 | |
| 
 | |
| function renderCustomExpressions() {
 | |
|     if (!Array.isArray(extension_settings.expressions.custom)) {
 | |
|         extension_settings.expressions.custom = [];
 | |
|     }
 | |
| 
 | |
|     const customExpressions = extension_settings.expressions.custom.sort((a, b) => a.localeCompare(b));
 | |
|     $('#expression_custom').empty();
 | |
| 
 | |
|     for (const expression of customExpressions) {
 | |
|         const option = document.createElement('option');
 | |
|         option.value = expression;
 | |
|         option.text = expression;
 | |
|         $('#expression_custom').append(option);
 | |
|     }
 | |
| 
 | |
|     if (customExpressions.length === 0) {
 | |
|         $('#expression_custom').append('<option value="" disabled selected>[ No custom expressions ]</option>');
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function renderFallbackExpressionPicker() {
 | |
|     const expressions = await getExpressionsList();
 | |
| 
 | |
|     const defaultPicker = $('#expression_fallback');
 | |
|     defaultPicker.empty();
 | |
| 
 | |
| 
 | |
|     addOption(OPTION_NO_FALLBACK, '[ No fallback ]', !extension_settings.expressions.fallback_expression);
 | |
|     addOption(OPTION_EMOJI_FALLBACK, '[ Default emojis ]', !!extension_settings.expressions.showDefault);
 | |
| 
 | |
|     for (const expression of expressions) {
 | |
|         addOption(expression, expression, expression == extension_settings.expressions.fallback_expression);
 | |
|     }
 | |
| 
 | |
|     /** @type {(value: string, label: string, isSelected: boolean) => void} */
 | |
|     function addOption(value, label, isSelected) {
 | |
|         const option = document.createElement('option');
 | |
|         option.value = value;
 | |
|         option.text = label;
 | |
|         option.selected = isSelected;
 | |
|         defaultPicker.append(option);
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Retrieves a unique list of cached expressions.
 | |
|  * Combines the default expressions list with custom user-defined expressions.
 | |
|  *
 | |
|  * @returns {string[]} An array of unique expression labels
 | |
|  */
 | |
| 
 | |
| function getCachedExpressions() {
 | |
|     if (!Array.isArray(expressionsList)) {
 | |
|         return [];
 | |
|     }
 | |
| 
 | |
|     return [...expressionsList, ...extension_settings.expressions.custom].filter(onlyUnique);
 | |
| }
 | |
| 
 | |
| export async function getExpressionsList({ filterAvailable = false } = {}) {
 | |
|     // If there is no cached list, load and cache it
 | |
|     if (!Array.isArray(expressionsList)) {
 | |
|         expressionsList = await resolveExpressionsList();
 | |
|     }
 | |
| 
 | |
|     const expressions = getCachedExpressions();
 | |
| 
 | |
|     // Filtering is only available for llm and webllm APIs
 | |
|     if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) {
 | |
|         return expressions;
 | |
|     }
 | |
| 
 | |
|     // Get expressions with available sprites
 | |
|     const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
 | |
|     const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
 | |
| 
 | |
|     return expressions.filter(label => {
 | |
|         const expression = spriteCache[spriteFolderName]?.find(x => x.label === label);
 | |
|         return (expression?.files.length ?? 0) > 0;
 | |
|     });
 | |
| 
 | |
|     /**
 | |
|      * Returns the list of expressions from the API or fallback in offline mode.
 | |
|      * @returns {Promise<string[]>}
 | |
|      */
 | |
|     async function resolveExpressionsList() {
 | |
|         // See if we can retrieve a specific expression list from the API
 | |
|         try {
 | |
|             // Check Extras api first, if enabled and that module active
 | |
|             if (extension_settings.expressions.api == EXPRESSION_API.extras && modules.includes('classify')) {
 | |
|                 const url = new URL(getApiUrl());
 | |
|                 url.pathname = '/api/classify/labels';
 | |
| 
 | |
|                 const apiResult = await doExtrasFetch(url, {
 | |
|                     method: 'GET',
 | |
|                     headers: { 'Bypass-Tunnel-Reminder': 'bypass' },
 | |
|                 });
 | |
| 
 | |
|                 if (apiResult.ok) {
 | |
| 
 | |
|                     const data = await apiResult.json();
 | |
|                     expressionsList = data.labels;
 | |
|                     return expressionsList;
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             // If running the local classify model (not using the LLM), we ask that one
 | |
|             if (extension_settings.expressions.api == EXPRESSION_API.local) {
 | |
|                 const apiResult = await fetch('/api/extra/classify/labels', {
 | |
|                     method: 'POST',
 | |
|                     headers: getRequestHeaders(),
 | |
|                 });
 | |
| 
 | |
|                 if (apiResult.ok) {
 | |
|                     const data = await apiResult.json();
 | |
|                     expressionsList = data.labels;
 | |
|                     return expressionsList;
 | |
|                 }
 | |
|             }
 | |
|         } catch (error) {
 | |
|             console.log(error);
 | |
|         }
 | |
| 
 | |
|         // If there was no specific list, or an error, just return the default expressions
 | |
|         expressionsList = DEFAULT_EXPRESSIONS.slice();
 | |
|         return expressionsList;
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Selects a sprite from the given sprite folder for the given expression.
 | |
|  *
 | |
|  * If multiple sprites are allowed for the expression, it will randomly select one.
 | |
|  * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same.
 | |
|  * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one.
 | |
|  *
 | |
|  * @param {string} spriteFolderName - The name of the sprite folder
 | |
|  * @param {string} expression - The expression to find the sprite for
 | |
|  * @param {object} [options] - Options to select the sprite
 | |
|  * @param {string} [options.prevExpressionSrc=null] - The source of the previous expression
 | |
|  * @param {string} [options.overrideSpriteFile=null] - The file name of the sprite to select
 | |
|  * @returns {ExpressionImage?} - The selected sprite
 | |
|  */
 | |
| function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) {
 | |
|     if (!spriteCache[spriteFolderName]) return null;
 | |
|     if (expression === RESET_SPRITE_LABEL) return null;
 | |
| 
 | |
|     // Search for sprites of that expression - or fallback expression sprites if enabled
 | |
|     let sprite = spriteCache[spriteFolderName].find(x => x.label === expression);
 | |
|     if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) {
 | |
|         sprite = spriteCache[spriteFolderName].find(x => x.label === extension_settings.expressions.fallback_expression);
 | |
|         console.debug('Expression', expression, 'not found. Using fallback expression', extension_settings.expressions.fallback_expression);
 | |
|     }
 | |
|     if (!(sprite?.files.length > 0)) return null;
 | |
| 
 | |
|     let spriteFile = sprite.files[0];
 | |
| 
 | |
|     // If a specific sprite file should be set, we are looking it up here
 | |
|     if (overrideSpriteFile) {
 | |
|         const searched = sprite.files.find(x => x.fileName === overrideSpriteFile);
 | |
|         if (searched) spriteFile = searched;
 | |
|         else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`);
 | |
|     }
 | |
|     // Else calculate next expression, if multiple are allowed
 | |
|     else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
 | |
|         let possibleFiles = sprite.files;
 | |
|         if (extension_settings.expressions.rerollIfSame) {
 | |
|             possibleFiles = possibleFiles.filter(x => !prevExpressionSrc || x.imageSrc !== prevExpressionSrc);
 | |
|         }
 | |
|         spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)];
 | |
|     }
 | |
| 
 | |
|     return spriteFile;
 | |
| 
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Set the expression of a character.
 | |
|  * @param {string} spriteFolderName - The name of the character (folder name - can also be a costume override)
 | |
|  * @param {string} expression - The expression or sprite name to set
 | |
|  * @param {Object} options - Optional parameters
 | |
|  * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on
 | |
|  * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
 | |
|  * @returns {Promise<void>} A promise that resolves when the expression has been set.
 | |
|  */
 | |
| async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) {
 | |
|     await validateImages(spriteFolderName);
 | |
|     const img = $('img.expression');
 | |
|     const prevExpressionSrc = img.attr('src');
 | |
|     const expressionClone = img.clone();
 | |
| 
 | |
|     const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile });
 | |
|     if (spriteFile) {
 | |
|         if (force && isVisualNovelMode()) {
 | |
|             const context = getContext();
 | |
|             const group = context.groups.find(x => x.id === context.groupId);
 | |
| 
 | |
|             // If it's a folder, make sure we find the group member based on the actual name
 | |
|             const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName;
 | |
| 
 | |
|             const groupMember = group.members
 | |
|                 .map(member => context.characters.find(x => x.avatar === member))
 | |
|                 .find(groupMember => groupMember && groupMember.name === memberName);
 | |
|             if (groupMember) {
 | |
|                 await setImage($(`.expression-holder[data-avatar="${groupMember.avatar}"] img`), spriteFile.imageSrc);
 | |
|                 return;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         //only swap expressions when necessary
 | |
|         if (prevExpressionSrc !== spriteFile.imageSrc
 | |
|             && !img.hasClass('expression-animating')) {
 | |
|             //clone expression
 | |
|             expressionClone.addClass('expression-clone');
 | |
|             //make invisible and remove id to prevent double ids
 | |
|             //must be made invisible to start because they share the same Z-index
 | |
|             expressionClone.attr('id', '').css({ opacity: 0 });
 | |
|             //add new sprite path to clone src
 | |
|             expressionClone.attr('src', spriteFile.imageSrc);
 | |
|             //set relevant data tags
 | |
|             expressionClone.attr('data-sprite-folder-name', spriteFolderName);
 | |
|             expressionClone.attr('data-expression', expression);
 | |
|             expressionClone.attr('data-sprite-filename', spriteFile.fileName);
 | |
|             expressionClone.attr('title', expression);
 | |
|             //add invisible clone to html
 | |
|             expressionClone.appendTo($('#expression-holder'));
 | |
| 
 | |
|             const duration = 200;
 | |
| 
 | |
|             //add animation flags to both images
 | |
|             //to prevent multiple expression changes happening simultaneously
 | |
|             img.addClass('expression-animating');
 | |
| 
 | |
|             // Set the parent container's min width and height before running the transition
 | |
|             const imgWidth = img.width();
 | |
|             const imgHeight = img.height();
 | |
|             const expressionHolder = img.parent();
 | |
|             expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100);
 | |
|             expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100);
 | |
| 
 | |
|             //position absolute prevent the original from jumping around during transition
 | |
|             img.css('position', 'absolute').width(imgWidth).height(imgHeight);
 | |
|             expressionClone.addClass('expression-animating');
 | |
|             //fade the clone in
 | |
|             expressionClone.css({
 | |
|                 opacity: 0,
 | |
|             }).animate({
 | |
|                 opacity: 1,
 | |
|             }, duration)
 | |
|                 //when finshed fading in clone, fade out the original
 | |
|                 .promise().done(function () {
 | |
|                     img.animate({
 | |
|                         opacity: 0,
 | |
|                     }, duration);
 | |
|                     //remove old expression
 | |
|                     img.remove();
 | |
|                     //replace ID so it becomes the new 'original' expression for next change
 | |
|                     expressionClone.attr('id', 'expression-image');
 | |
|                     expressionClone.removeClass('expression-animating');
 | |
| 
 | |
|                     // Reset the expression holder min height and width
 | |
|                     expressionHolder.css('min-width', 100);
 | |
|                     expressionHolder.css('min-height', 100);
 | |
|                 });
 | |
| 
 | |
|             expressionClone.removeClass('expression-clone');
 | |
| 
 | |
|             expressionClone.removeClass('default');
 | |
|             expressionClone.off('error');
 | |
|             expressionClone.on('error', function (error) {
 | |
|                 console.debug('Expression image error', spriteFile.imageSrc, error);
 | |
|                 $(this).attr('src', '');
 | |
|                 $(this).off('error');
 | |
|                 if (force && extension_settings.expressions.showDefault) {
 | |
|                     setDefaultEmojiForImage(img, expression);
 | |
|                 }
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
 | |
|     }
 | |
|     else {
 | |
|         img.attr('data-sprite-folder-name', spriteFolderName);
 | |
| 
 | |
|         img.off('error');
 | |
| 
 | |
|         if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) {
 | |
|             setDefaultEmojiForImage(img, expression);
 | |
|         } else {
 | |
|             setNoneForImage(img, expression);
 | |
|         }
 | |
|         console.debug('Expression unset - No sprite found', { expression: expression });
 | |
|     }
 | |
| 
 | |
|     document.getElementById('expression-holder').style.display = '';
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Sets the default expression image for the given image element and expression
 | |
|  * @param {JQuery<HTMLElement>} img - The image element to set the default expression for
 | |
|  * @param {string} expression - The expression label to use for the default image
 | |
|  */
 | |
| function setDefaultEmojiForImage(img, expression) {
 | |
|     if (extension_settings.expressions.custom?.includes(expression)) {
 | |
|         console.debug(`Can't set default emoji for a custom expression (${expression}). setting to ${DEFAULT_FALLBACK_EXPRESSION} instead.`);
 | |
|         expression = DEFAULT_FALLBACK_EXPRESSION;
 | |
|     }
 | |
| 
 | |
|     const defImgUrl = `/img/default-expressions/${expression}.png`;
 | |
|     img.attr('src', defImgUrl);
 | |
|     img.attr('data-expression', expression);
 | |
|     img.attr('data-sprite-filename', null);
 | |
|     img.attr('title', expression);
 | |
|     img.addClass('default');
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Sets the image element to display no expression by clearing its source attribute.
 | |
|  * @param {JQuery<HTMLElement>} img - The image element to clear the expression for
 | |
|  * @param {string} expression - The expression label to use
 | |
|  */
 | |
| function setNoneForImage(img, expression) {
 | |
|     img.attr('src', '');
 | |
|     img.attr('data-expression', expression);
 | |
|     img.attr('data-sprite-filename', null);
 | |
|     img.attr('title', expression);
 | |
|     img.removeClass('default');
 | |
| }
 | |
| 
 | |
| function onClickExpressionImage() {
 | |
|     // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label
 | |
|     if ($(this).attr('data-expression-type') === 'failure') {
 | |
|         const label = $(this).attr('data-expression');
 | |
|         setSpriteSlashCommand({ type: 'expression' }, label);
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const spriteFile = $(this).attr('data-filename');
 | |
|     setSpriteSlashCommand({ type: 'sprite' }, spriteFile);
 | |
| }
 | |
| 
 | |
| async function onClickExpressionAddCustom() {
 | |
|     const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression');
 | |
|     let expressionName = await Popup.show.input(null, template);
 | |
| 
 | |
|     if (!expressionName) {
 | |
|         console.debug('No custom expression name provided');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     expressionName = expressionName.trim().toLowerCase();
 | |
| 
 | |
|     // a-z, 0-9, dashes and underscores only
 | |
|     if (!/^[a-z0-9-_]+$/.test(expressionName)) {
 | |
|         toastr.warning('Invalid custom expression name provided', 'Add Custom Expression');
 | |
|         return;
 | |
|     }
 | |
|     if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) {
 | |
|         toastr.warning('Expression name already exists', 'Add Custom Expression');
 | |
|         return;
 | |
|     }
 | |
|     if (extension_settings.expressions.custom.includes(expressionName)) {
 | |
|         toastr.warning('Custom expression already exists', 'Add Custom Expression');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Add custom expression into settings
 | |
|     extension_settings.expressions.custom.push(expressionName);
 | |
|     await renderAdditionalExpressionSettings();
 | |
|     saveSettingsDebounced();
 | |
| 
 | |
|     // Force refresh sprites list
 | |
|     expressionsList = null;
 | |
|     spriteCache = {};
 | |
|     moduleWorker();
 | |
| }
 | |
| 
 | |
| async function onClickExpressionRemoveCustom() {
 | |
|     const selectedExpression = String($('#expression_custom').val());
 | |
|     const noCustomExpressions = extension_settings.expressions.custom.length === 0;
 | |
| 
 | |
|     if (!selectedExpression || noCustomExpressions) {
 | |
|         console.debug('No custom expression selected');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression });
 | |
|     const confirmation = await Popup.show.confirm(null, template);
 | |
| 
 | |
|     if (!confirmation) {
 | |
|         console.debug('Custom expression removal cancelled');
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Remove custom expression from settings
 | |
|     const index = extension_settings.expressions.custom.indexOf(selectedExpression);
 | |
|     extension_settings.expressions.custom.splice(index, 1);
 | |
|     if (selectedExpression == extension_settings.expressions.fallback_expression) {
 | |
|         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');
 | |
|         extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION;
 | |
|     }
 | |
|     await renderAdditionalExpressionSettings();
 | |
|     saveSettingsDebounced();
 | |
| 
 | |
|     // Force refresh sprites list
 | |
|     expressionsList = null;
 | |
|     spriteCache = {};
 | |
|     moduleWorker();
 | |
| }
 | |
| 
 | |
| function onExpressionApiChanged() {
 | |
|     const tempApi = this.value;
 | |
|     if (tempApi) {
 | |
|         extension_settings.expressions.api = Number(tempApi);
 | |
|         $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
 | |
|         expressionsList = null;
 | |
|         spriteCache = {};
 | |
|         moduleWorker();
 | |
|         saveSettingsDebounced();
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function onExpressionFallbackChanged() {
 | |
|     /** @type {HTMLSelectElement} */
 | |
|     const select = this;
 | |
|     const selectedValue = select.value;
 | |
| 
 | |
|     switch (selectedValue) {
 | |
|         case OPTION_NO_FALLBACK:
 | |
|             extension_settings.expressions.fallback_expression = null;
 | |
|             extension_settings.expressions.showDefault = false;
 | |
|             break;
 | |
|         case OPTION_EMOJI_FALLBACK:
 | |
|             extension_settings.expressions.fallback_expression = null;
 | |
|             extension_settings.expressions.showDefault = true;
 | |
|             break;
 | |
|         default:
 | |
|             extension_settings.expressions.fallback_expression = selectedValue;
 | |
|             extension_settings.expressions.showDefault = false;
 | |
|             break;
 | |
|     }
 | |
| 
 | |
|     const img = $('img.expression');
 | |
|     const spriteFolderName = img.attr('data-sprite-folder-name');
 | |
|     const expression = img.attr('data-expression');
 | |
| 
 | |
|     if (spriteFolderName && expression) {
 | |
|         await sendExpressionCall(spriteFolderName, expression, { force: true });
 | |
|     }
 | |
| 
 | |
|     saveSettingsDebounced();
 | |
| }
 | |
| 
 | |
| async function handleFileUpload(url, formData) {
 | |
|     try {
 | |
|         const data = await jQuery.ajax({
 | |
|             type: 'POST',
 | |
|             url: url,
 | |
|             data: formData,
 | |
|             beforeSend: function () { },
 | |
|             cache: false,
 | |
|             contentType: false,
 | |
|             processData: false,
 | |
|         });
 | |
| 
 | |
|         // Refresh sprites list
 | |
|         const name = formData.get('name');
 | |
|         delete spriteCache[name];
 | |
|         await fetchImagesNoCache();
 | |
|         await validateImages(name);
 | |
| 
 | |
|         return data;
 | |
|     } catch (error) {
 | |
|         toastr.error('Failed to upload image');
 | |
|     }
 | |
| }
 | |
| 
 | |
| /**
 | |
|  * Removes the file extension from a file name
 | |
|  * @param {string} fileName The file name to remove the extension from
 | |
|  * @returns {string} The file name without the extension
 | |
|  */
 | |
| function withoutExtension(fileName) {
 | |
|     return fileName.replace(/\.[^/.]+$/, '');
 | |
| }
 | |
| 
 | |
| function validateExpressionSpriteName(expression, spriteName) {
 | |
|     const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
 | |
|     const validFileName = filenameValidationRegex.test(spriteName);
 | |
|     return validFileName;
 | |
| }
 | |
| 
 | |
| async function onClickExpressionUpload(event) {
 | |
|     // Prevents the expression from being set
 | |
|     event.stopPropagation();
 | |
| 
 | |
|     const expressionListItem = $(this).closest('.expression_list_item');
 | |
| 
 | |
|     const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null;
 | |
|     const expression = expressionListItem.data('expression');
 | |
|     const name = $('#image_list').data('name');
 | |
| 
 | |
|     const handleExpressionUploadChange = async (e) => {
 | |
|         const file = e.target.files[0];
 | |
| 
 | |
|         if (!file || !file.name) {
 | |
|             console.debug('No valid file selected');
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || [];
 | |
| 
 | |
|         let spriteName = expression;
 | |
| 
 | |
|         if (extension_settings.expressions.allowMultiple) {
 | |
|             const matchesExisting = existingFiles.some(x => x.fileName === file.name);
 | |
|             const fileNameWithoutExtension = withoutExtension(file.name);
 | |
|             const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
 | |
| 
 | |
|             // If there is no expression yet and it's a valid expression, we just take it
 | |
|             if (!clickedFileName && validFileName) {
 | |
|                 spriteName = fileNameWithoutExtension;
 | |
|             }
 | |
|             // If the filename matches the one that was clicked, we just take it and replace it
 | |
|             else if (clickedFileName === file.name) {
 | |
|                 spriteName = fileNameWithoutExtension;
 | |
|             }
 | |
|             // If it's a valid filename and there's no existing file with the same name, we just take it
 | |
|             else if (!matchesExisting && validFileName) {
 | |
|                 spriteName = fileNameWithoutExtension;
 | |
|             }
 | |
|             else {
 | |
|                 /** @type {import('../../popup.js').CustomPopupButton[]} */
 | |
|                 const customButtons = [];
 | |
|                 if (clickedFileName) {
 | |
|                     customButtons.push({
 | |
|                         text: t`Replace Existing`,
 | |
|                         result: POPUP_RESULT.NEGATIVE,
 | |
|                         action: () => {
 | |
|                             console.debug('Replacing existing sprite');
 | |
|                             spriteName = withoutExtension(clickedFileName);
 | |
|                         },
 | |
|                     });
 | |
|                 }
 | |
| 
 | |
|                 spriteName = null;
 | |
|                 const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles);
 | |
| 
 | |
|                 const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
 | |
| 
 | |
|                 const input = await Popup.show.input(t`Upload Expression Sprite`, message,
 | |
|                     suggestedSpriteName, { customButtons: customButtons });
 | |
| 
 | |
|                 if (input) {
 | |
|                     if (!validateExpressionSpriteName(expression, input)) {
 | |
|                         toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
 | |
|                         return;
 | |
|                     }
 | |
|                     spriteName = input;
 | |
|                 }
 | |
|             }
 | |
|         } else {
 | |
|             spriteName = withoutExtension(expression);
 | |
|         }
 | |
| 
 | |
|         if (!spriteName) {
 | |
|             toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`);
 | |
|             // Reset the input
 | |
|             e.target.form.reset();
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const formData = new FormData();
 | |
|         formData.append('name', name);
 | |
|         formData.append('label', expression);
 | |
|         formData.append('avatar', file);
 | |
|         formData.append('spriteName', spriteName);
 | |
| 
 | |
|         await handleFileUpload('/api/sprites/upload', formData);
 | |
| 
 | |
|         // Reset the input
 | |
|         e.target.form.reset();
 | |
|     };
 | |
| 
 | |
|     $('#expression_upload')
 | |
|         .off('change')
 | |
|         .on('change', handleExpressionUploadChange)
 | |
|         .trigger('click');
 | |
| }
 | |
| 
 | |
| async function onClickExpressionOverrideButton() {
 | |
|     const context = getContext();
 | |
|     const currentLastMessage = getLastCharacterMessage();
 | |
|     const avatarFileName = getFolderNameByMessage(currentLastMessage);
 | |
| 
 | |
|     // If the avatar name couldn't be found, abort.
 | |
|     if (!avatarFileName) {
 | |
|         console.debug(`Could not find filename for character with name ${currentLastMessage.name} and ID ${context.characterId}`);
 | |
| 
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const overridePath = String($('#expression_override').val());
 | |
|     const existingOverrideIndex = extension_settings.expressionOverrides.findIndex((e) =>
 | |
|         e.name == avatarFileName,
 | |
|     );
 | |
| 
 | |
|     // If the path is empty, delete the entry from overrides
 | |
|     if (overridePath === undefined || overridePath.length === 0) {
 | |
|         if (existingOverrideIndex === -1) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         extension_settings.expressionOverrides.splice(existingOverrideIndex, 1);
 | |
|         console.debug(`Removed existing override for ${avatarFileName}`);
 | |
|     } else {
 | |
|         // Properly override objects and clear the sprite cache of the previously set names
 | |
|         const existingOverride = extension_settings.expressionOverrides[existingOverrideIndex];
 | |
|         if (existingOverride) {
 | |
|             Object.assign(existingOverride, { path: overridePath });
 | |
|             delete spriteCache[existingOverride.name];
 | |
|         } else {
 | |
|             const characterOverride = { name: avatarFileName, path: overridePath };
 | |
|             extension_settings.expressionOverrides.push(characterOverride);
 | |
|             delete spriteCache[currentLastMessage.name];
 | |
|         }
 | |
| 
 | |
|         console.debug(`Added/edited expression override for character with filename ${avatarFileName} to folder ${overridePath}`);
 | |
|     }
 | |
| 
 | |
|     saveSettingsDebounced();
 | |
| 
 | |
|     // Refresh sprites list. Assume the override path has been properly handled.
 | |
|     try {
 | |
|         inApiCall = true;
 | |
|         $('#visual-novel-wrapper').empty();
 | |
|         await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
 | |
|         const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
 | |
|         const expression = await getExpressionLabel(currentLastMessage.mes);
 | |
|         await sendExpressionCall(name, expression, { force: true });
 | |
|         forceUpdateVisualNovelMode();
 | |
|     } catch (error) {
 | |
|         console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
 | |
|     } finally {
 | |
|         inApiCall = false;
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function onClickExpressionOverrideRemoveAllButton() {
 | |
|     // Remove all the overrided entries from sprite cache
 | |
|     for (const element of extension_settings.expressionOverrides) {
 | |
|         delete spriteCache[element.name];
 | |
|     }
 | |
| 
 | |
|     extension_settings.expressionOverrides = [];
 | |
|     saveSettingsDebounced();
 | |
| 
 | |
|     console.debug('All expression image overrides have been cleared.');
 | |
| 
 | |
|     // Refresh sprites list to use the default name if applicable
 | |
|     try {
 | |
|         $('#visual-novel-wrapper').empty();
 | |
|         const currentLastMessage = getLastCharacterMessage();
 | |
|         await validateImages(currentLastMessage.name, true);
 | |
|         const expression = await getExpressionLabel(currentLastMessage.mes);
 | |
|         await sendExpressionCall(currentLastMessage.name, expression, { force: true });
 | |
|         forceUpdateVisualNovelMode();
 | |
| 
 | |
|         console.debug(extension_settings.expressionOverrides);
 | |
|     } catch (error) {
 | |
|         console.debug(`The current expression could not be set because of error: ${error}`);
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function onClickExpressionUploadPackButton() {
 | |
|     const name = $('#image_list').data('name');
 | |
| 
 | |
|     const handleFileUploadChange = async (e) => {
 | |
|         const file = e.target.files[0];
 | |
| 
 | |
|         if (!file) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const formData = new FormData();
 | |
|         formData.append('name', name);
 | |
|         formData.append('avatar', file);
 | |
| 
 | |
|         const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 });
 | |
|         const { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
 | |
|         toastr.clear(uploadToast);
 | |
|         toastr.success(`Uploaded ${count} image(s) for ${name}`);
 | |
| 
 | |
|         // Reset the input
 | |
|         e.target.form.reset();
 | |
|     };
 | |
| 
 | |
|     $('#expression_upload_pack')
 | |
|         .off('change')
 | |
|         .on('change', handleFileUploadChange)
 | |
|         .trigger('click');
 | |
| }
 | |
| 
 | |
| async function onClickExpressionDelete(event) {
 | |
|     // Prevents the expression from being set
 | |
|     event.stopPropagation();
 | |
| 
 | |
|     const expressionListItem = $(this).closest('.expression_list_item');
 | |
|     const expression = expressionListItem.data('expression');
 | |
| 
 | |
|     if (expressionListItem.attr('data-expression-type') === 'failure') {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!`
 | |
|         + '<br /><br />'
 | |
|         + t`Expression:` + ' <tt>' + expressionListItem.attr('data-filename') + '</tt>');
 | |
|     if (!confirmation) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const fileName = withoutExtension(expressionListItem.attr('data-filename'));
 | |
|     const name = $('#image_list').data('name');
 | |
| 
 | |
|     try {
 | |
|         await fetch('/api/sprites/delete', {
 | |
|             method: 'POST',
 | |
|             headers: getRequestHeaders(),
 | |
|             body: JSON.stringify({ name, label: expression, spriteName: fileName }),
 | |
|         });
 | |
|     } catch (error) {
 | |
|         toastr.error('Failed to delete image. Try again later.');
 | |
|     }
 | |
| 
 | |
|     // Refresh sprites list
 | |
|     delete spriteCache[name];
 | |
|     await fetchImagesNoCache();
 | |
|     await validateImages(name);
 | |
| }
 | |
| 
 | |
| function setExpressionOverrideHtml(forceClear = false) {
 | |
|     const currentLastMessage = getLastCharacterMessage();
 | |
|     const avatarFileName = getFolderNameByMessage(currentLastMessage);
 | |
|     if (!avatarFileName) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     const expressionOverride = extension_settings.expressionOverrides.find((e) =>
 | |
|         e.name == avatarFileName,
 | |
|     );
 | |
| 
 | |
|     if (expressionOverride && expressionOverride.path) {
 | |
|         $('#expression_override').val(expressionOverride.path);
 | |
|     } else if (expressionOverride) {
 | |
|         delete extension_settings.expressionOverrides[expressionOverride.name];
 | |
|     }
 | |
| 
 | |
|     if (forceClear && !expressionOverride) {
 | |
|         $('#expression_override').val('');
 | |
|     }
 | |
| }
 | |
| 
 | |
| async function fetchImagesNoCache() {
 | |
|     const promises = [];
 | |
|     $('#image_list img').each(function () {
 | |
|         const src = $(this).attr('src');
 | |
| 
 | |
|         if (!src) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const promise = fetch(src, {
 | |
|             method: 'GET',
 | |
|             cache: 'no-cache',
 | |
|             headers: {
 | |
|                 'Cache-Control': 'no-cache',
 | |
|                 'Pragma': 'no-cache',
 | |
|                 'Expires': '0',
 | |
|             },
 | |
|         });
 | |
|         promises.push(promise);
 | |
|     });
 | |
| 
 | |
|     return await Promise.allSettled(promises);
 | |
| }
 | |
| 
 | |
| function migrateSettings() {
 | |
|     if (extension_settings.expressions.api === undefined) {
 | |
|         extension_settings.expressions.api = EXPRESSION_API.none;
 | |
|         saveSettingsDebounced();
 | |
|     }
 | |
| 
 | |
|     if (Object.keys(extension_settings.expressions).includes('local')) {
 | |
|         if (extension_settings.expressions.local) {
 | |
|             extension_settings.expressions.api = EXPRESSION_API.local;
 | |
|         }
 | |
| 
 | |
|         delete extension_settings.expressions.local;
 | |
|         saveSettingsDebounced();
 | |
|     }
 | |
| 
 | |
|     if (extension_settings.expressions.llmPrompt === undefined) {
 | |
|         extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
 | |
|         saveSettingsDebounced();
 | |
|     }
 | |
| 
 | |
|     if (extension_settings.expressions.allowMultiple === undefined) {
 | |
|         extension_settings.expressions.allowMultiple = true;
 | |
|         saveSettingsDebounced();
 | |
|     }
 | |
| 
 | |
|     if (extension_settings.expressions.showDefault && extension_settings.expressions.fallback_expression !== undefined) {
 | |
|         extension_settings.expressions.showDefault = false;
 | |
|         saveSettingsDebounced();
 | |
|     }
 | |
| }
 | |
| 
 | |
| (async function () {
 | |
|     function addExpressionImage() {
 | |
|         const html = `
 | |
|         <div id="expression-wrapper">
 | |
|             <div id="expression-holder" class="expression-holder" style="display:none;">
 | |
|                 <div id="expression-holderheader" class="fa-solid fa-grip drag-grabber"></div>
 | |
|                 <img id="expression-image" class="expression">
 | |
|             </div>
 | |
|         </div>`;
 | |
|         $('body').append(html);
 | |
|         loadMovingUIState();
 | |
|     }
 | |
|     function addVisualNovelMode() {
 | |
|         const html = `
 | |
|         <div id="visual-novel-wrapper">
 | |
|         </div>`;
 | |
|         const element = $(html);
 | |
|         element.hide();
 | |
|         $('body').append(element);
 | |
|     }
 | |
|     async function addSettings() {
 | |
|         const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings');
 | |
|         $('#expressions_container').append(template);
 | |
|         $('#expression_override_button').on('click', onClickExpressionOverrideButton);
 | |
|         $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
 | |
|         $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () {
 | |
|             extension_settings.expressions.translate = !!$(this).prop('checked');
 | |
|             saveSettingsDebounced();
 | |
|         });
 | |
|         $('#expressions_allow_multiple').prop('checked', extension_settings.expressions.allowMultiple).on('input', function () {
 | |
|             extension_settings.expressions.allowMultiple = !!$(this).prop('checked');
 | |
|             saveSettingsDebounced();
 | |
|         });
 | |
|         $('#expressions_reroll_if_same').prop('checked', extension_settings.expressions.rerollIfSame).on('input', function () {
 | |
|             extension_settings.expressions.rerollIfSame = !!$(this).prop('checked');
 | |
|             saveSettingsDebounced();
 | |
|         });
 | |
|         $('#expressions_filter_available').prop('checked', extension_settings.expressions.filterAvailable).on('input', function () {
 | |
|             extension_settings.expressions.filterAvailable = !!$(this).prop('checked');
 | |
|             saveSettingsDebounced();
 | |
|         });
 | |
|         $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton);
 | |
|         $(document).on('dragstart', '.expression', (e) => {
 | |
|             e.preventDefault();
 | |
|             return false;
 | |
|         });
 | |
|         $(document).on('click', '.expression_list_item', onClickExpressionImage);
 | |
|         $(document).on('click', '.expression_list_upload', onClickExpressionUpload);
 | |
|         $(document).on('click', '.expression_list_delete', onClickExpressionDelete);
 | |
|         $(window).on('resize', () => updateVisualNovelModeDebounced());
 | |
|         $('#open_chat_expressions').hide();
 | |
| 
 | |
|         await renderAdditionalExpressionSettings();
 | |
|         $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.none);
 | |
|         $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api));
 | |
|         $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? '');
 | |
|         $('#expression_llm_prompt').on('input', function () {
 | |
|             extension_settings.expressions.llmPrompt = String($(this).val());
 | |
|             saveSettingsDebounced();
 | |
|         });
 | |
|         $('#expression_llm_prompt_restore').on('click', function () {
 | |
|             $('#expression_llm_prompt').val(DEFAULT_LLM_PROMPT);
 | |
|             extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT;
 | |
|             saveSettingsDebounced();
 | |
|         });
 | |
| 
 | |
|         $('#expression_custom_add').on('click', onClickExpressionAddCustom);
 | |
|         $('#expression_custom_remove').on('click', onClickExpressionRemoveCustom);
 | |
|         $('#expression_fallback').on('change', onExpressionFallbackChanged);
 | |
|         $('#expression_api').on('change', onExpressionApiChanged);
 | |
|     }
 | |
| 
 | |
|     addExpressionImage();
 | |
|     addVisualNovelMode();
 | |
|     migrateSettings();
 | |
|     await addSettings();
 | |
|     const wrapper = new ModuleWorkerWrapper(moduleWorker);
 | |
|     const updateFunction = wrapper.update.bind(wrapper);
 | |
|     setInterval(updateFunction, UPDATE_INTERVAL);
 | |
|     moduleWorker();
 | |
|     dragElement($('#expression-holder'));
 | |
|     eventSource.on(event_types.CHAT_CHANGED, () => {
 | |
|         // character changed
 | |
|         removeExpression();
 | |
|         spriteCache = {};
 | |
|         lastExpression = {};
 | |
| 
 | |
|         //clear expression
 | |
|         let imgElement = document.getElementById('expression-image');
 | |
|         if (imgElement && imgElement instanceof HTMLImageElement) {
 | |
|             imgElement.src = '';
 | |
|         }
 | |
| 
 | |
|         setExpressionOverrideHtml(true); // force-clear, as the character might not have an override defined
 | |
| 
 | |
|         if (isVisualNovelMode()) {
 | |
|             $('#visual-novel-wrapper').empty();
 | |
|         }
 | |
| 
 | |
|         updateFunction({ newChat: true });
 | |
|     });
 | |
|     eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
 | |
|     eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
 | |
| 
 | |
|     const localEnumProviders = {
 | |
|         expressions: () => {
 | |
|             const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
 | |
|             const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
 | |
|             const expressions = getCachedExpressions();
 | |
|             return expressions.map(expression => {
 | |
|                 const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
 | |
|                 const isCustom = extension_settings.expressions.custom?.includes(expression);
 | |
|                 const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
 | |
|                     spriteCount > 1 ? `${spriteCount} sprites` : null;
 | |
|                 return new SlashCommandEnumValue(expression,
 | |
|                     subtitle,
 | |
|                     isCustom ? enumTypes.name : enumTypes.enum,
 | |
|                     isCustom ? 'C' : 'D');
 | |
|             });
 | |
|         },
 | |
|         sprites: () => {
 | |
|             const currentLastMessage = selected_group ? getLastCharacterMessage() : null;
 | |
|             const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name);
 | |
|             const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? [];
 | |
|             return sprites.map(x => {
 | |
|                 return new SlashCommandEnumValue(x.title,
 | |
|                     x.title !== x.expression ? x.expression : null,
 | |
|                     x.isCustom ? enumTypes.name : enumTypes.enum,
 | |
|                     x.isCustom ? 'C' : 'D');
 | |
|             });
 | |
|         },
 | |
|     };
 | |
| 
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'expression-set',
 | |
|         aliases: ['sprite', 'emote'],
 | |
|         callback: setSpriteSlashCommand,
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'type',
 | |
|                 description: 'Whether to set an expression or a specific sprite.',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: false,
 | |
|                 defaultValue: 'expression',
 | |
|                 enumList: ['expression', 'sprite'],
 | |
|             }),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             SlashCommandArgument.fromProps({
 | |
|                 description: 'expression label to set',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|                 enumProvider: (executor, _) => {
 | |
|                     // Check if command is used to set a sprite, then use those enums
 | |
|                     const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression';
 | |
|                     if (type == 'sprite') return localEnumProviders.sprites();
 | |
|                     else return [
 | |
|                         ...localEnumProviders.expressions(),
 | |
|                         new SlashCommandEnumValue(RESET_SPRITE_LABEL, 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'),
 | |
|                     ];
 | |
|                 },
 | |
|             }),
 | |
|         ],
 | |
|         helpString: 'Force sets the expression for the current character.',
 | |
|         returns: 'The currently set expression label after setting it.',
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'expression-folder-override',
 | |
|         aliases: ['spriteoverride', 'costume'],
 | |
|         callback: setSpriteFolderCommand,
 | |
|         unnamedArgumentList: [
 | |
|             new SlashCommandArgument(
 | |
|                 'optional folder', [ARGUMENT_TYPE.STRING], false,
 | |
|             ),
 | |
|         ],
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Sets an override sprite folder for the current character.<br />
 | |
|                 In groups, this will apply to the character who last sent a message.
 | |
|             </div>
 | |
|             <div>
 | |
|                 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.
 | |
|             </div>
 | |
|         `,
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'expression-last',
 | |
|         aliases: ['lastsprite'],
 | |
|         /** @type {(args: object, name: string) => Promise<string>} */
 | |
|         callback: async (_, name) => {
 | |
|             if (typeof name !== 'string') throw new Error('name must be a string');
 | |
|             if (!name) {
 | |
|                 if (selected_group) {
 | |
|                     toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`);
 | |
|                     return '';
 | |
|                 }
 | |
|                 name = characters[this_chid]?.avatar;
 | |
|             }
 | |
| 
 | |
|             const char = findChar({ name: name });
 | |
|             if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
 | |
| 
 | |
|             const sprite = lastExpression[char?.name ?? name] ?? '';
 | |
|             return sprite;
 | |
|         },
 | |
|         returns: 'the last set expression for the named character.',
 | |
|         unnamedArgumentList: [
 | |
|             SlashCommandArgument.fromProps({
 | |
|                 description: 'Character name - or unique character identifier (avatar key). If not provided, the current character for this chat will be used (does not work in group chats)',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 enumProvider: commonEnumProviders.characters('character'),
 | |
|             }),
 | |
|         ],
 | |
|         helpString: 'Returns the last set expression for the named character.',
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'expression-list',
 | |
|         aliases: ['expressions'],
 | |
|         /** @type {(args: {return: string, filter: string}) => Promise<string>} */
 | |
|         callback: async (args) => {
 | |
|             let returnType =
 | |
|                 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
 | |
|                 (args.return);
 | |
| 
 | |
|             const list = await getExpressionsList({ filterAvailable: !isFalseBoolean(args.filter) });
 | |
| 
 | |
|             return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
 | |
|         },
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'return',
 | |
|                 description: 'The way how you want the return value to be provided',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 defaultValue: 'pipe',
 | |
|                 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
 | |
|                 forceEnum: true,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'filter',
 | |
|                 description: 'Filter the list to only include expressions that have available sprites for the current character.',
 | |
|                 typeList: [ARGUMENT_TYPE.BOOLEAN],
 | |
|                 enumList: commonEnumProviders.boolean('trueFalse')(),
 | |
|                 defaultValue: 'true',
 | |
|             }),
 | |
|         ],
 | |
|         returns: 'The comma-separated list of available expressions, including custom expressions.',
 | |
|         helpString: 'Returns a list of available expressions, including custom expressions.',
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'expression-classify',
 | |
|         aliases: ['classify'],
 | |
|         callback: classifyCallback,
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'api',
 | |
|                 description: 'The Classifier API to classify with. If not specified, the configured one will be used.',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 enumList: Object.keys(EXPRESSION_API).map(api => new SlashCommandEnumValue(api, null, enumTypes.enum)),
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'filter',
 | |
|                 description: 'Filter the list to only include expressions that have available sprites for the current character.',
 | |
|                 typeList: [ARGUMENT_TYPE.BOOLEAN],
 | |
|                 enumList: commonEnumProviders.boolean('trueFalse')(),
 | |
|                 defaultValue: 'true',
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'prompt',
 | |
|                 description: 'Custom prompt for classification. Only relevant if Classifier API is set to LLM.',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|             }),
 | |
|         ],
 | |
|         unnamedArgumentList: [
 | |
|             new SlashCommandArgument(
 | |
|                 'text', [ARGUMENT_TYPE.STRING], true,
 | |
|             ),
 | |
|         ],
 | |
|         returns: 'emotion classification label for the given text',
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Performs an emotion classification of the given text and returns a label.
 | |
|             </div>
 | |
|             <div>
 | |
|                 Allows to specify which Classifier API to perform the classification with.
 | |
|             </div>
 | |
|             <div>
 | |
|                 <strong>Example:</strong>
 | |
|                 <ul>
 | |
|                     <li>
 | |
|                         <pre><code>/classify I am so happy today!</code></pre>
 | |
|                     </li>
 | |
|                 </ul>
 | |
|             </div>
 | |
|         `,
 | |
|     }));
 | |
|     SlashCommandParser.addCommandObject(SlashCommand.fromProps({
 | |
|         name: 'expression-upload',
 | |
|         aliases: ['uploadsprite'],
 | |
|         /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
 | |
|         callback: async (args, url) => {
 | |
|             return await uploadSpriteCommand(args, url);
 | |
|         },
 | |
|         returns: 'the resulting sprite name',
 | |
|         unnamedArgumentList: [
 | |
|             SlashCommandArgument.fromProps({
 | |
|                 description: 'URL of the image to upload',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: true,
 | |
|             }),
 | |
|         ],
 | |
|         namedArgumentList: [
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'name',
 | |
|                 description: 'Character name or avatar key (default is current character)',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: false,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'label',
 | |
|                 description: 'Sprite label/expression name',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 enumProvider: localEnumProviders.expressions,
 | |
|                 isRequired: true,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'folder',
 | |
|                 description: 'Override folder to upload into',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: false,
 | |
|             }),
 | |
|             SlashCommandNamedArgument.fromProps({
 | |
|                 name: 'spriteName',
 | |
|                 description: 'Override sprite name to allow multiple sprites per expressions. Has to follow the naming pattern. If unspecified, the label will be used as sprite name.',
 | |
|                 typeList: [ARGUMENT_TYPE.STRING],
 | |
|                 isRequired: false,
 | |
|             }),
 | |
|         ],
 | |
|         helpString: `
 | |
|             <div>
 | |
|                 Upload a sprite from a URL.
 | |
|             </div>
 | |
|             <div>
 | |
|                 <strong>Example:</strong>
 | |
|                 <ul>
 | |
|                     <li>
 | |
|                         <pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>
 | |
|                     </li>
 | |
|                 </ul>
 | |
|             </div>
 | |
|         `,
 | |
|     }));
 | |
| })();
 |