mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			1506 lines
		
	
	
		
			52 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1506 lines
		
	
	
		
			52 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import { callPopup, eventSource, event_types, getRequestHeaders, saveSettingsDebounced } from '../../../script.js';
 | 
						||
import { dragElement, isMobile } from '../../RossAscends-mods.js';
 | 
						||
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplate } from '../../extensions.js';
 | 
						||
import { loadMovingUIState, power_user } from '../../power-user.js';
 | 
						||
import { registerSlashCommand } from '../../slash-commands.js';
 | 
						||
import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence } from '../../utils.js';
 | 
						||
import { hideMutedSprites } from '../../group-chats.js';
 | 
						||
export { MODULE_NAME };
 | 
						||
 | 
						||
const MODULE_NAME = 'expressions';
 | 
						||
const UPDATE_INTERVAL = 2000;
 | 
						||
const STREAMING_UPDATE_INTERVAL = 6000;
 | 
						||
const FALLBACK_EXPRESSION = 'joy';
 | 
						||
const DEFAULT_EXPRESSIONS = [
 | 
						||
    'talkinghead',
 | 
						||
    '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',
 | 
						||
];
 | 
						||
 | 
						||
let expressionsList = null;
 | 
						||
let lastCharacter = undefined;
 | 
						||
let lastMessage = null;
 | 
						||
let spriteCache = {};
 | 
						||
let inApiCall = false;
 | 
						||
let lastServerResponseTime = 0;
 | 
						||
 | 
						||
function isVisualNovelMode() {
 | 
						||
    return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId);
 | 
						||
}
 | 
						||
 | 
						||
async function forceUpdateVisualNovelMode() {
 | 
						||
    if (isVisualNovelMode()) {
 | 
						||
        await updateVisualNovelMode();
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, 100);
 | 
						||
 | 
						||
async function updateVisualNovelMode(name, expression) {
 | 
						||
    const container = $('#visual-novel-wrapper');
 | 
						||
 | 
						||
    await visualNovelRemoveInactive(container);
 | 
						||
 | 
						||
    const setSpritePromises = await visualNovelSetCharacterSprites(container, name, expression);
 | 
						||
 | 
						||
    // calculate layer indices based on recent messages
 | 
						||
    await visualNovelUpdateLayers(container);
 | 
						||
 | 
						||
    await Promise.allSettled(setSpritePromises);
 | 
						||
 | 
						||
    // update again based on new sprites
 | 
						||
    if (setSpritePromises.length > 0) {
 | 
						||
        await visualNovelUpdateLayers(container);
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
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);
 | 
						||
}
 | 
						||
 | 
						||
async function visualNovelSetCharacterSprites(container, name, expression) {
 | 
						||
    const context = getContext();
 | 
						||
    const group = context.groups.find(x => x.id == context.groupId);
 | 
						||
    const labels = await getExpressionsList();
 | 
						||
 | 
						||
    const createCharacterPromises = [];
 | 
						||
    const setSpritePromises = [];
 | 
						||
 | 
						||
    for (const avatar of group.members) {
 | 
						||
        const isDisabled = group.disabled_members.includes(avatar);
 | 
						||
 | 
						||
        // skip disabled characters
 | 
						||
        if (isDisabled && hideMutedSprites) {
 | 
						||
            continue;
 | 
						||
        }
 | 
						||
 | 
						||
        const character = context.characters.find(x => x.avatar == avatar);
 | 
						||
 | 
						||
        if (!character) {
 | 
						||
            continue;
 | 
						||
        }
 | 
						||
 | 
						||
        const spriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name);
 | 
						||
 | 
						||
        // download images if not downloaded yet
 | 
						||
        if (spriteCache[spriteFolderName] === undefined) {
 | 
						||
            spriteCache[spriteFolderName] = await getSpritesList(spriteFolderName);
 | 
						||
        }
 | 
						||
 | 
						||
        const sprites = spriteCache[spriteFolderName];
 | 
						||
        const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
 | 
						||
        const defaultSpritePath = sprites.find(x => x.label === FALLBACK_EXPRESSION)?.path;
 | 
						||
        const noSprites = sprites.length === 0;
 | 
						||
 | 
						||
        if (expressionImage.length > 0) {
 | 
						||
            if (name == spriteFolderName) {
 | 
						||
                await validateImages(spriteFolderName, true);
 | 
						||
                setExpressionOverrideHtml(true); // <= force clear expression override input
 | 
						||
                const currentSpritePath = labels.includes(expression) ? sprites.find(x => x.label === expression)?.path : '';
 | 
						||
 | 
						||
                const path = currentSpritePath || defaultSpritePath || '';
 | 
						||
                const img = expressionImage.find('img');
 | 
						||
                await setImage(img, path);
 | 
						||
            }
 | 
						||
            expressionImage.toggleClass('hidden', noSprites);
 | 
						||
        } 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', noSprites);
 | 
						||
            await setImage(template.find('img'), defaultSpritePath || '');
 | 
						||
            const fadeInPromise = new Promise(resolve => {
 | 
						||
                template.fadeIn(250, () => resolve());
 | 
						||
            });
 | 
						||
            createCharacterPromises.push(fadeInPromise);
 | 
						||
            const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels);
 | 
						||
            setSpritePromises.push(setSpritePromise);
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    await Promise.allSettled(createCharacterPromises);
 | 
						||
    return setSpritePromises;
 | 
						||
}
 | 
						||
 | 
						||
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 = $('#visual-novel-wrapper .expression-holder');
 | 
						||
    let imagesWidth = [];
 | 
						||
 | 
						||
    images.sort(sortFunction).each(function () {
 | 
						||
        imagesWidth.push($(this).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.sort(sortFunction).each((index, current) => {
 | 
						||
        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 => {
 | 
						||
            element.animate({ left: currentPosition + 'px' }, 500, () => {
 | 
						||
                resolve();
 | 
						||
            });
 | 
						||
        });
 | 
						||
 | 
						||
        currentPosition += imagesWidth[index];
 | 
						||
 | 
						||
        setLayerIndicesPromises.push(promise);
 | 
						||
    });
 | 
						||
 | 
						||
    await Promise.allSettled(setLayerIndicesPromises);
 | 
						||
}
 | 
						||
 | 
						||
async function setLastMessageSprite(img, avatar, labels) {
 | 
						||
    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 || '';
 | 
						||
        const spriteFolderName = getSpriteFolderName(lastMessage, lastMessage.name);
 | 
						||
        const sprites = spriteCache[spriteFolderName] || [];
 | 
						||
        const label = await getExpressionLabel(text);
 | 
						||
        const path = labels.includes(label) ? sprites.find(x => x.label === label)?.path : '';
 | 
						||
 | 
						||
        if (path) {
 | 
						||
            setImage(img, path);
 | 
						||
        }
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
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.attr('id');
 | 
						||
 | 
						||
        //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.attr('id', '').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.attr('id', originalId);
 | 
						||
                    expressionClone.removeClass('expression-animating');
 | 
						||
 | 
						||
                    // Reset the expression holder min height and width
 | 
						||
                    expressionHolder.css('min-width', 100);
 | 
						||
                    expressionHolder.css('min-height', 100);
 | 
						||
 | 
						||
                    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();
 | 
						||
        }
 | 
						||
    });
 | 
						||
}
 | 
						||
 | 
						||
function onExpressionsShowDefaultInput() {
 | 
						||
    const value = $(this).prop('checked');
 | 
						||
    extension_settings.expressions.showDefault = value;
 | 
						||
    saveSettingsDebounced();
 | 
						||
 | 
						||
    const existingImageSrc = $('img.expression').prop('src');
 | 
						||
    if (existingImageSrc !== undefined) {                      //if we have an image in src
 | 
						||
        if (!value && existingImageSrc.includes('/img/default-expressions/')) {    //and that image is from /img/ (default)
 | 
						||
            $('img.expression').prop('src', '');               //remove it
 | 
						||
            lastMessage = null;
 | 
						||
        }
 | 
						||
        if (value) {
 | 
						||
            lastMessage = null;
 | 
						||
        }
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
async function unloadLiveChar() {
 | 
						||
    if (!modules.includes('talkinghead')) {
 | 
						||
        console.debug('talkinghead module is disabled');
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    try {
 | 
						||
        const url = new URL(getApiUrl());
 | 
						||
        url.pathname = '/api/talkinghead/unload';
 | 
						||
        const loadResponse = await doExtrasFetch(url);
 | 
						||
        if (!loadResponse.ok) {
 | 
						||
            throw new Error(loadResponse.statusText);
 | 
						||
        }
 | 
						||
        //console.log(`Response: ${loadResponseText}`);
 | 
						||
    } catch (error) {
 | 
						||
        //console.error(`Error unloading - ${error}`);
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
async function loadLiveChar() {
 | 
						||
    if (!modules.includes('talkinghead')) {
 | 
						||
        console.debug('talkinghead module is disabled');
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    const spriteFolderName = getSpriteFolderName();
 | 
						||
 | 
						||
    const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`;
 | 
						||
 | 
						||
    try {
 | 
						||
        const spriteResponse = await fetch(talkingheadPath);
 | 
						||
 | 
						||
        if (!spriteResponse.ok) {
 | 
						||
            throw new Error(spriteResponse.statusText);
 | 
						||
        }
 | 
						||
 | 
						||
        const spriteBlob = await spriteResponse.blob();
 | 
						||
        const spriteFile = new File([spriteBlob], 'talkinghead.png', { type: 'image/png' });
 | 
						||
        const formData = new FormData();
 | 
						||
        formData.append('file', spriteFile);
 | 
						||
 | 
						||
        const url = new URL(getApiUrl());
 | 
						||
        url.pathname = '/api/talkinghead/load';
 | 
						||
 | 
						||
        const loadResponse = await doExtrasFetch(url, {
 | 
						||
            method: 'POST',
 | 
						||
            body: formData,
 | 
						||
        });
 | 
						||
 | 
						||
        if (!loadResponse.ok) {
 | 
						||
            throw new Error(loadResponse.statusText);
 | 
						||
        }
 | 
						||
 | 
						||
        const loadResponseText = await loadResponse.text();
 | 
						||
        console.log(`Load talkinghead response: ${loadResponseText}`);
 | 
						||
 | 
						||
    } catch (error) {
 | 
						||
        console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`);
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
function handleImageChange() {
 | 
						||
    const imgElement = document.querySelector('img#expression-image.expression');
 | 
						||
 | 
						||
    if (!imgElement || !(imgElement instanceof HTMLImageElement)) {
 | 
						||
        console.log('Cannot find addExpressionImage()');
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    if (extension_settings.expressions.talkinghead && !extension_settings.expressions.local) {
 | 
						||
        // Method get IP of endpoint
 | 
						||
        const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`;
 | 
						||
        $('#expression-holder').css({ display: '' });
 | 
						||
        if (imgElement.src !== talkingheadResultFeedSrc) {
 | 
						||
            const expressionImageElement = document.querySelector('.expression_list_image');
 | 
						||
 | 
						||
            if (expressionImageElement && expressionImageElement instanceof HTMLImageElement) {
 | 
						||
                doExtrasFetch(expressionImageElement.src, {
 | 
						||
                    method: 'HEAD',
 | 
						||
                })
 | 
						||
                    .then(response => {
 | 
						||
                        if (response.ok) {
 | 
						||
                            imgElement.src = talkingheadResultFeedSrc;
 | 
						||
                        }
 | 
						||
                    })
 | 
						||
                    .catch(error => {
 | 
						||
                        console.error(error); // Log the error if necessary
 | 
						||
                    });
 | 
						||
            }
 | 
						||
        }
 | 
						||
    } else {
 | 
						||
        imgElement.src = ''; //remove incase char doesnt have expressions
 | 
						||
        setExpression(getContext().name2, FALLBACK_EXPRESSION, true);
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
async function moduleWorker() {
 | 
						||
    const context = getContext();
 | 
						||
 | 
						||
    // Hide and disable talkinghead while in local mode
 | 
						||
    $('#image_type_block').toggle(!extension_settings.expressions.local);
 | 
						||
 | 
						||
    if (extension_settings.expressions.local && extension_settings.expressions.talkinghead) {
 | 
						||
        $('#image_type_toggle').prop('checked', false);
 | 
						||
        setTalkingHeadState(false);
 | 
						||
    }
 | 
						||
 | 
						||
    // non-characters not supported
 | 
						||
    if (!context.groupId && (context.characterId === undefined || context.characterId === 'invalid-safety-id')) {
 | 
						||
        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 = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();
 | 
						||
 | 
						||
    // 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.local) {
 | 
						||
        $('#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');
 | 
						||
    }
 | 
						||
 | 
						||
    // 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;
 | 
						||
    }
 | 
						||
 | 
						||
    // check if last message changed
 | 
						||
    if ((lastCharacter === context.characterId || lastCharacter === context.groupId)
 | 
						||
        && lastMessage === currentLastMessage.mes) {
 | 
						||
        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(FALLBACK_EXPRESSION)) {
 | 
						||
            expression = FALLBACK_EXPRESSION;
 | 
						||
        }
 | 
						||
 | 
						||
        await sendExpressionCall(spriteFolderName, expression, force, vnMode);
 | 
						||
    }
 | 
						||
    catch (error) {
 | 
						||
        console.log(error);
 | 
						||
    }
 | 
						||
    finally {
 | 
						||
        inApiCall = false;
 | 
						||
        lastCharacter = context.groupId || context.characterId;
 | 
						||
        lastMessage = currentLastMessage.mes;
 | 
						||
        lastServerResponseTime = Date.now();
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
async function talkingHeadCheck() {
 | 
						||
    let spriteFolderName = getSpriteFolderName();
 | 
						||
 | 
						||
    try {
 | 
						||
        await validateImages(spriteFolderName);
 | 
						||
 | 
						||
        let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead');
 | 
						||
        let talkingheadPath_f = talkingheadObj ? talkingheadObj.path : null;
 | 
						||
 | 
						||
        if (talkingheadPath_f != null) {
 | 
						||
            //console.log("talkingheadPath_f " + talkingheadPath_f);
 | 
						||
            return true;
 | 
						||
        } else {
 | 
						||
            //console.log("talkingheadPath_f is null");
 | 
						||
            unloadLiveChar();
 | 
						||
            return false;
 | 
						||
        }
 | 
						||
    } catch (err) {
 | 
						||
        return err;
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
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 setTalkingHeadState(switch_var) {
 | 
						||
    extension_settings.expressions.talkinghead = switch_var; // Store setting
 | 
						||
    saveSettingsDebounced();
 | 
						||
 | 
						||
    if (extension_settings.expressions.local) {
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    talkingHeadCheck().then(result => {
 | 
						||
        if (result) {
 | 
						||
            //console.log("talkinghead exists!");
 | 
						||
 | 
						||
            if (extension_settings.expressions.talkinghead) {
 | 
						||
                loadLiveChar();
 | 
						||
            } else {
 | 
						||
                unloadLiveChar();
 | 
						||
            }
 | 
						||
            handleImageChange(); // Change image as needed
 | 
						||
 | 
						||
 | 
						||
        } else {
 | 
						||
            //console.log("talkinghead does not exist.");
 | 
						||
        }
 | 
						||
    });
 | 
						||
}
 | 
						||
 | 
						||
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;
 | 
						||
}
 | 
						||
 | 
						||
async function sendExpressionCall(name, expression, force, vnMode) {
 | 
						||
    if (!vnMode) {
 | 
						||
        vnMode = isVisualNovelMode();
 | 
						||
    }
 | 
						||
 | 
						||
    if (vnMode) {
 | 
						||
        await updateVisualNovelMode(name, expression);
 | 
						||
    } else {
 | 
						||
        setExpression(name, expression, force);
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
async function setSpriteSetCommand(_, folder) {
 | 
						||
    if (!folder) {
 | 
						||
        console.log('Clearing sprite set');
 | 
						||
        folder = '';
 | 
						||
    }
 | 
						||
 | 
						||
    if (folder.startsWith('/') || folder.startsWith('\\')) {
 | 
						||
        folder = folder.slice(1);
 | 
						||
 | 
						||
        const currentLastMessage = getLastCharacterMessage();
 | 
						||
        folder = `${currentLastMessage.name}/${folder}`;
 | 
						||
    }
 | 
						||
 | 
						||
    $('#expression_override').val(folder.trim());
 | 
						||
    onClickExpressionOverrideButton();
 | 
						||
    removeExpression();
 | 
						||
    moduleWorker();
 | 
						||
}
 | 
						||
 | 
						||
async function setSpriteSlashCommand(_, spriteId) {
 | 
						||
    if (!spriteId) {
 | 
						||
        console.log('No sprite id provided');
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    spriteId = spriteId.trim().toLowerCase();
 | 
						||
 | 
						||
    const currentLastMessage = getLastCharacterMessage();
 | 
						||
    const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
 | 
						||
    await validateImages(spriteFolderName);
 | 
						||
 | 
						||
    // Fuzzy search for sprite
 | 
						||
    const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });
 | 
						||
    const results = fuse.search(spriteId);
 | 
						||
    const spriteItem = results[0]?.item;
 | 
						||
 | 
						||
    if (!spriteItem) {
 | 
						||
        console.log('No sprite found for search term ' + spriteId);
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    const vnMode = isVisualNovelMode();
 | 
						||
    await sendExpressionCall(spriteFolderName, spriteItem.label, true, vnMode);
 | 
						||
}
 | 
						||
 | 
						||
/**
 | 
						||
 * 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;
 | 
						||
    }
 | 
						||
 | 
						||
    // Remove asterisks and quotes
 | 
						||
    let result = text.replace(/[*"]/g, '');
 | 
						||
 | 
						||
    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();
 | 
						||
}
 | 
						||
 | 
						||
async function getExpressionLabel(text) {
 | 
						||
    // Return if text is undefined, saving a costly fetch request
 | 
						||
    if ((!modules.includes('classify') && !extension_settings.expressions.local) || !text) {
 | 
						||
        return FALLBACK_EXPRESSION;
 | 
						||
    }
 | 
						||
 | 
						||
    text = sampleClassifyText(text);
 | 
						||
 | 
						||
    try {
 | 
						||
        if (extension_settings.expressions.local) {
 | 
						||
            // Local transformers pipeline
 | 
						||
            const apiResult = await fetch('/api/extra/classify', {
 | 
						||
                method: 'POST',
 | 
						||
                headers: getRequestHeaders(),
 | 
						||
                body: JSON.stringify({ text: text }),
 | 
						||
            });
 | 
						||
 | 
						||
            if (apiResult.ok) {
 | 
						||
                const data = await apiResult.json();
 | 
						||
                return data.classification[0].label;
 | 
						||
            }
 | 
						||
        } else {
 | 
						||
            // Extras
 | 
						||
            const url = new URL(getApiUrl());
 | 
						||
            url.pathname = '/api/classify';
 | 
						||
 | 
						||
            const apiResult = await doExtrasFetch(url, {
 | 
						||
                method: 'POST',
 | 
						||
                headers: {
 | 
						||
                    'Content-Type': 'application/json',
 | 
						||
                    'Bypass-Tunnel-Reminder': 'bypass',
 | 
						||
                },
 | 
						||
                body: JSON.stringify({ text: text }),
 | 
						||
            });
 | 
						||
 | 
						||
            if (apiResult.ok) {
 | 
						||
                const data = await apiResult.json();
 | 
						||
                return data.classification[0].label;
 | 
						||
            }
 | 
						||
        }
 | 
						||
    } catch (error) {
 | 
						||
        console.log(error);
 | 
						||
        return 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) {
 | 
						||
            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();
 | 
						||
}
 | 
						||
 | 
						||
async function validateImages(character, forceRedrawCached) {
 | 
						||
    if (!character) {
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    const labels = await getExpressionsList();
 | 
						||
 | 
						||
    if (spriteCache[character]) {
 | 
						||
        if (forceRedrawCached && $('#image_list').data('name') !== character) {
 | 
						||
            console.debug('force redrawing character sprites list');
 | 
						||
            drawSpritesList(character, labels, spriteCache[character]);
 | 
						||
        }
 | 
						||
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    const sprites = await getSpritesList(character);
 | 
						||
    let validExpressions = drawSpritesList(character, labels, sprites);
 | 
						||
    spriteCache[character] = validExpressions;
 | 
						||
}
 | 
						||
 | 
						||
function drawSpritesList(character, labels, sprites) {
 | 
						||
    let validExpressions = [];
 | 
						||
    $('#no_chat_expressions').hide();
 | 
						||
    $('#open_chat_expressions').show();
 | 
						||
    $('#image_list').empty();
 | 
						||
    $('#image_list').data('name', character);
 | 
						||
    $('#image_list_header_name').text(character);
 | 
						||
 | 
						||
    if (!Array.isArray(labels)) {
 | 
						||
        return [];
 | 
						||
    }
 | 
						||
 | 
						||
    labels.sort().forEach((item) => {
 | 
						||
        const sprite = sprites.find(x => x.label == item);
 | 
						||
        const isCustom = extension_settings.expressions.custom.includes(item);
 | 
						||
 | 
						||
        if (sprite) {
 | 
						||
            validExpressions.push(sprite);
 | 
						||
            $('#image_list').append(getListItem(item, sprite.path, 'success', isCustom));
 | 
						||
        }
 | 
						||
        else {
 | 
						||
            $('#image_list').append(getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom));
 | 
						||
        }
 | 
						||
    });
 | 
						||
    return validExpressions;
 | 
						||
}
 | 
						||
 | 
						||
/**
 | 
						||
 * Renders a list item template for the expressions list.
 | 
						||
 * @param {string} item Expression name
 | 
						||
 * @param {string} imageSrc Path to image
 | 
						||
 * @param {'success' | 'failure'} textClass 'success' or 'failure'
 | 
						||
 * @param {boolean} isCustom If expression is added by user
 | 
						||
 * @returns {string} Rendered list item template
 | 
						||
 */
 | 
						||
function getListItem(item, imageSrc, textClass, isCustom) {
 | 
						||
    return renderExtensionTemplate(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom });
 | 
						||
}
 | 
						||
 | 
						||
async function getSpritesList(name) {
 | 
						||
    console.debug('getting sprites list');
 | 
						||
 | 
						||
    try {
 | 
						||
        const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`);
 | 
						||
        let sprites = result.ok ? (await result.json()) : [];
 | 
						||
        return sprites;
 | 
						||
    }
 | 
						||
    catch (err) {
 | 
						||
        console.log(err);
 | 
						||
        return [];
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
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 getExpressionsList() {
 | 
						||
    // Return cached list if available
 | 
						||
    if (Array.isArray(expressionsList)) {
 | 
						||
        return expressionsList;
 | 
						||
    }
 | 
						||
 | 
						||
    /**
 | 
						||
     * Returns the list of expressions from the API or fallback in offline mode.
 | 
						||
     * @returns {Promise<string[]>}
 | 
						||
     */
 | 
						||
    async function resolveExpressionsList() {
 | 
						||
        // get something for offline mode (default images)
 | 
						||
        if (!modules.includes('classify') && !extension_settings.expressions.local) {
 | 
						||
            return DEFAULT_EXPRESSIONS;
 | 
						||
        }
 | 
						||
 | 
						||
        try {
 | 
						||
            if (extension_settings.expressions.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;
 | 
						||
                }
 | 
						||
            } else {
 | 
						||
                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;
 | 
						||
                }
 | 
						||
            }
 | 
						||
        }
 | 
						||
        catch (error) {
 | 
						||
            console.log(error);
 | 
						||
            return [];
 | 
						||
        }
 | 
						||
    }
 | 
						||
 | 
						||
    const result = await resolveExpressionsList();
 | 
						||
    result.push(...extension_settings.expressions.custom);
 | 
						||
    return result;
 | 
						||
}
 | 
						||
 | 
						||
async function setExpression(character, expression, force) {
 | 
						||
    if (extension_settings.expressions.local || !extension_settings.expressions.talkinghead) {
 | 
						||
        console.debug('entered setExpressions');
 | 
						||
        await validateImages(character);
 | 
						||
        const img = $('img.expression');
 | 
						||
        const prevExpressionSrc = img.attr('src');
 | 
						||
        const expressionClone = img.clone();
 | 
						||
 | 
						||
        const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
 | 
						||
        console.debug('checking for expression images to show..');
 | 
						||
        if (sprite) {
 | 
						||
            console.debug('setting expression from character images folder');
 | 
						||
 | 
						||
            if (force && isVisualNovelMode()) {
 | 
						||
                const context = getContext();
 | 
						||
                const group = context.groups.find(x => x.id === context.groupId);
 | 
						||
 | 
						||
                for (const member of group.members) {
 | 
						||
                    const groupMember = context.characters.find(x => x.avatar === member);
 | 
						||
 | 
						||
                    if (!groupMember) {
 | 
						||
                        continue;
 | 
						||
                    }
 | 
						||
 | 
						||
                    if (groupMember.name == character) {
 | 
						||
                        await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path);
 | 
						||
                        return;
 | 
						||
                    }
 | 
						||
                }
 | 
						||
            }
 | 
						||
            //only swap expressions when necessary
 | 
						||
            if (prevExpressionSrc !== sprite.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.attr('id', '').css({ opacity: 0 });
 | 
						||
                //add new sprite path to clone src
 | 
						||
                expressionClone.attr('src', sprite.path);
 | 
						||
                //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 () {
 | 
						||
                    console.debug('Expression image error', sprite.path);
 | 
						||
                    $(this).attr('src', '');
 | 
						||
                    $(this).off('error');
 | 
						||
                    if (force && extension_settings.expressions.showDefault) {
 | 
						||
                        setDefault();
 | 
						||
                    }
 | 
						||
                });
 | 
						||
            }
 | 
						||
        }
 | 
						||
        else {
 | 
						||
            if (extension_settings.expressions.showDefault) {
 | 
						||
                setDefault();
 | 
						||
            }
 | 
						||
        }
 | 
						||
 | 
						||
        function setDefault() {
 | 
						||
            console.debug('setting default');
 | 
						||
            const defImgUrl = `/img/default-expressions/${expression}.png`;
 | 
						||
            //console.log(defImgUrl);
 | 
						||
            img.attr('src', defImgUrl);
 | 
						||
            img.addClass('default');
 | 
						||
        }
 | 
						||
        document.getElementById('expression-holder').style.display = '';
 | 
						||
 | 
						||
    } else {
 | 
						||
 | 
						||
 | 
						||
        talkingHeadCheck().then(result => {
 | 
						||
            if (result) {
 | 
						||
                // Find the <img> element with id="expression-image" and class="expression"
 | 
						||
                const imgElement = document.querySelector('img#expression-image.expression');
 | 
						||
                //console.log("searching");
 | 
						||
                if (imgElement && imgElement instanceof HTMLImageElement) {
 | 
						||
                    //console.log("setting value");
 | 
						||
                    imgElement.src = getApiUrl() + '/api/talkinghead/result_feed';
 | 
						||
                }
 | 
						||
 | 
						||
            } else {
 | 
						||
                //console.log("The fetch failed!");
 | 
						||
            }
 | 
						||
        });
 | 
						||
 | 
						||
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
function onClickExpressionImage() {
 | 
						||
    const expression = $(this).attr('id');
 | 
						||
    setSpriteSlashCommand({}, expression);
 | 
						||
}
 | 
						||
 | 
						||
async function onClickExpressionAddCustom() {
 | 
						||
    let expressionName = await callPopup(renderExtensionTemplate(MODULE_NAME, 'add-custom-expression'), 'input');
 | 
						||
 | 
						||
    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.info('Invalid custom expression name provided');
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    // Check if expression name already exists in default expressions
 | 
						||
    if (DEFAULT_EXPRESSIONS.includes(expressionName)) {
 | 
						||
        toastr.info('Expression name already exists');
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    // Check if expression name already exists in custom expressions
 | 
						||
    if (extension_settings.expressions.custom.includes(expressionName)) {
 | 
						||
        toastr.info('Custom expression already exists');
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    // Add custom expression into settings
 | 
						||
    extension_settings.expressions.custom.push(expressionName);
 | 
						||
    renderCustomExpressions();
 | 
						||
    saveSettingsDebounced();
 | 
						||
 | 
						||
    // Force refresh sprites list
 | 
						||
    expressionsList = null;
 | 
						||
    spriteCache = {};
 | 
						||
    moduleWorker();
 | 
						||
}
 | 
						||
 | 
						||
async function onClickExpressionRemoveCustom() {
 | 
						||
    const selectedExpression = $('#expression_custom').val();
 | 
						||
 | 
						||
    if (!selectedExpression) {
 | 
						||
        console.debug('No custom expression selected');
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    const confirmation = await callPopup(renderExtensionTemplate(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }), 'confirm');
 | 
						||
 | 
						||
    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);
 | 
						||
    renderCustomExpressions();
 | 
						||
    saveSettingsDebounced();
 | 
						||
 | 
						||
    // Force refresh sprites list
 | 
						||
    expressionsList = null;
 | 
						||
    spriteCache = {};
 | 
						||
    moduleWorker();
 | 
						||
}
 | 
						||
 | 
						||
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 validateImages(name);
 | 
						||
 | 
						||
        return data;
 | 
						||
    } catch (error) {
 | 
						||
        toastr.error('Failed to upload image');
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
async function onClickExpressionUpload(event) {
 | 
						||
    // Prevents the expression from being set
 | 
						||
    event.stopPropagation();
 | 
						||
 | 
						||
    const id = $(this).closest('.expression_list_item').attr('id');
 | 
						||
    const name = $('#image_list').data('name');
 | 
						||
 | 
						||
    const handleExpressionUploadChange = async (e) => {
 | 
						||
        const file = e.target.files[0];
 | 
						||
 | 
						||
        if (!file) {
 | 
						||
            return;
 | 
						||
        }
 | 
						||
 | 
						||
        const formData = new FormData();
 | 
						||
        formData.append('name', name);
 | 
						||
        formData.append('label', id);
 | 
						||
        formData.append('avatar', file);
 | 
						||
 | 
						||
        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 {
 | 
						||
        $('#visual-novel-wrapper').empty();
 | 
						||
        await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
 | 
						||
        const expression = await getExpressionLabel(currentLastMessage.mes);
 | 
						||
        await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true);
 | 
						||
        forceUpdateVisualNovelMode();
 | 
						||
    } catch (error) {
 | 
						||
        console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
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, 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 { count } = await handleFileUpload('/api/sprites/upload-zip', formData);
 | 
						||
        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 confirmation = await callPopup('<h3>Are you sure?</h3>Once deleted, it\'s gone forever!', 'confirm');
 | 
						||
 | 
						||
    if (!confirmation) {
 | 
						||
        return;
 | 
						||
    }
 | 
						||
 | 
						||
    const id = $(this).closest('.expression_list_item').attr('id');
 | 
						||
    const name = $('#image_list').data('name');
 | 
						||
 | 
						||
    try {
 | 
						||
        await fetch('/api/sprites/delete', {
 | 
						||
            method: 'POST',
 | 
						||
            headers: getRequestHeaders(),
 | 
						||
            body: JSON.stringify({ name, label: id }),
 | 
						||
        });
 | 
						||
    } catch (error) {
 | 
						||
        toastr.error('Failed to delete image. Try again later.');
 | 
						||
    }
 | 
						||
 | 
						||
    // Refresh sprites list
 | 
						||
    delete spriteCache[name];
 | 
						||
    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('');
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
(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);
 | 
						||
    }
 | 
						||
    function addSettings() {
 | 
						||
        $('#extensions_settings').append(renderExtensionTemplate(MODULE_NAME, 'settings'));
 | 
						||
        $('#expression_override_button').on('click', onClickExpressionOverrideButton);
 | 
						||
        $('#expressions_show_default').on('input', onExpressionsShowDefaultInput);
 | 
						||
        $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton);
 | 
						||
        $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input');
 | 
						||
        $('#expression_local').prop('checked', extension_settings.expressions.local).on('input', function () {
 | 
						||
            extension_settings.expressions.local = !!$(this).prop('checked');
 | 
						||
            moduleWorker();
 | 
						||
            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();
 | 
						||
 | 
						||
        $('#image_type_toggle').on('click', function () {
 | 
						||
            if (this instanceof HTMLInputElement) {
 | 
						||
                setTalkingHeadState(this.checked);
 | 
						||
            }
 | 
						||
        });
 | 
						||
 | 
						||
        renderCustomExpressions();
 | 
						||
 | 
						||
        $('#expression_custom_add').on('click', onClickExpressionAddCustom);
 | 
						||
        $('#expression_custom_remove').on('click', onClickExpressionRemoveCustom);
 | 
						||
    }
 | 
						||
 | 
						||
    addExpressionImage();
 | 
						||
    addVisualNovelMode();
 | 
						||
    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 = {};
 | 
						||
 | 
						||
        //clear expression
 | 
						||
        let imgElement = document.getElementById('expression-image');
 | 
						||
        if (imgElement && imgElement instanceof HTMLImageElement) {
 | 
						||
            imgElement.src = '';
 | 
						||
        }
 | 
						||
 | 
						||
        //set checkbox to global var
 | 
						||
        $('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead);
 | 
						||
        if (extension_settings.expressions.talkinghead) {
 | 
						||
            setTalkingHeadState(extension_settings.expressions.talkinghead);
 | 
						||
        }
 | 
						||
 | 
						||
        setExpressionOverrideHtml();
 | 
						||
 | 
						||
        if (isVisualNovelMode()) {
 | 
						||
            $('#visual-novel-wrapper').empty();
 | 
						||
        }
 | 
						||
 | 
						||
        updateFunction();
 | 
						||
    });
 | 
						||
    eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced);
 | 
						||
    eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
 | 
						||
    registerSlashCommand('sprite', setSpriteSlashCommand, ['emote'], '<span class="monospace">(spriteId)</span> – force sets the sprite for the current character', true, true);
 | 
						||
    registerSlashCommand('spriteoverride', setSpriteSetCommand, ['costume'], '<span class="monospace">(optional folder)</span> – sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.', true, true);
 | 
						||
})();
 |