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, delay } 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'; 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, }; 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} 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} - 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} */ 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} - The expression label */ async function getLastMessageSprite(avatar) { const context = getContext(); const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar)))); if (lastMessage) { const text = lastMessage.mes || ''; return await getExpressionLabel(text); } return null; } async function visualNovelUpdateLayers(container) { const context = getContext(); const group = context.groups.find(x => x.id == context.groupId); 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} img - The image element to set the image on * @param {string} path - The path to the image * @returns {Promise} - A promise that resolves when the image is set */ async function setImage(img, path) { // Cohee: If something goes wrong, uncomment this to return to the old behavior /* 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) { 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?, prompt: string?}} */ { api = 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; if (!modules.includes('classify') && expressionApi == EXPRESSION_API.extras) { toastr.warning('Text classification is disabled or not available'); return ''; } const label = await getExpressionLabel(text, expressionApi, { customPrompt: prompt }); console.debug(`Classification result for "${text}": ${label}`); return label; } /** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise} */ 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} 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} 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 { 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 {string?} [options.customPrompt=null] - The custom prompt to use for classification. * @returns {Promise} - The label of the expression. */ export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { 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); 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(); 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(); 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 default: { 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; } } 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} 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} 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} 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(''); } } 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() { // Return cached list if available if (Array.isArray(expressionsList)) { return getCachedExpressions(); } /** * Returns the list of expressions from the API or fallback in offline mode. * @returns {Promise} */ 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; } const result = await resolveExpressionsList(); return [...result, ...extension_settings.expressions.custom].filter(onlyUnique); } /** * 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} 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} 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} 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(clickedFileName); } 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!` + '

' + t`Expression:` + ' ' + expressionListItem.attr('data-filename') + ''); 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.extras; 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 = `
`; $('body').append(html); loadMovingUIState(); } function addVisualNovelMode() { const html = `
`; 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(); }); $('#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.extras); $('.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(); 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: `
Sets an override sprite folder for the current character.
In groups, this will apply to the character who last sent a message.
If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.
`, })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'expression-last', aliases: ['lastsprite'], /** @type {(args: object, name: string) => Promise} */ callback: async (_, name) => { if (typeof name !== 'string') throw new Error('name must be a string'); if (!name) { if (selected_group) { toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`); return ''; } name = characters[this_chid]?.avatar; } const char = findChar({ name: name }); if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`); const sprite = lastExpression[char?.name ?? name] ?? ''; return sprite; }, returns: 'the last set 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}) => Promise} */ callback: async (args) => { let returnType = /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ (args.return); const list = await getExpressionsList(); 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, }), ], 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: '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: `
Performs an emotion classification of the given text and returns a label.
Allows to specify which Classifier API to perform the classification with.
Example:
  • /classify I am so happy today!
`, })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'expression-upload', aliases: ['uploadsprite'], /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise} */ 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: `
Upload a sprite from a URL.
Example:
  • /uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png
`, })); })();