|
import { Fuse } from '../../../lib.js'; |
|
|
|
import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js'; |
|
import { dragElement, isMobile } from '../../RossAscends-mods.js'; |
|
import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; |
|
import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; |
|
import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean } from '../../utils.js'; |
|
import { hideMutedSprites, selected_group } from '../../group-chats.js'; |
|
import { isJsonSchemaSupported } from '../../textgen-settings.js'; |
|
import { debounce_timeout } from '../../constants.js'; |
|
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
|
import { SlashCommand } from '../../slash-commands/SlashCommand.js'; |
|
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
|
import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; |
|
import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
|
import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; |
|
import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
|
import { Popup, POPUP_RESULT } from '../../popup.js'; |
|
import { t } from '../../i18n.js'; |
|
import { removeReasoningFromString } from '../../reasoning.js'; |
|
export { MODULE_NAME }; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
|
|
const EXPRESSION_API = { |
|
local: 0, |
|
extras: 1, |
|
llm: 2, |
|
webllm: 3, |
|
none: 99, |
|
}; |
|
|
|
let expressionsList = null; |
|
let lastCharacter = undefined; |
|
let lastMessage = null; |
|
|
|
let spriteCache = {}; |
|
let inApiCall = false; |
|
let lastServerResponseTime = 0; |
|
|
|
|
|
export let lastExpression = {}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
await visualNovelUpdateLayers(vnContainer); |
|
|
|
await Promise.allSettled(setSpritePromises); |
|
|
|
|
|
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 = []; |
|
|
|
|
|
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(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) { |
|
|
|
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}"]`); |
|
|
|
let img; |
|
|
|
const memberSpriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name); |
|
|
|
|
|
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); |
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
async function getLastMessageSprite(avatar) { |
|
const context = getContext(); |
|
const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar)))); |
|
|
|
if (lastMessage) { |
|
const text = lastMessage.mes || ''; |
|
return await getExpressionLabel(text); |
|
} |
|
|
|
return null; |
|
} |
|
|
|
export async function visualNovelUpdateLayers(container) { |
|
const context = getContext(); |
|
const group = context.groups.find(x => x.id == context.groupId); |
|
const recentMessages = context.chat.map(x => x.original_avatar).filter(x => x).reverse().filter(onlyUnique); |
|
const filteredMembers = group.members.filter(x => !group.disabled_members.includes(x)); |
|
const layerIndices = filteredMembers.slice().sort((a, b) => { |
|
const aRecentIndex = recentMessages.indexOf(a); |
|
const bRecentIndex = recentMessages.indexOf(b); |
|
const aFilteredIndex = filteredMembers.indexOf(a); |
|
const bFilteredIndex = filteredMembers.indexOf(b); |
|
|
|
if (aRecentIndex !== -1 && bRecentIndex !== -1) { |
|
return bRecentIndex - aRecentIndex; |
|
} else if (aRecentIndex !== -1) { |
|
return 1; |
|
} else if (bRecentIndex !== -1) { |
|
return -1; |
|
} else { |
|
return aFilteredIndex - bFilteredIndex; |
|
} |
|
}); |
|
|
|
const setLayerIndicesPromises = []; |
|
|
|
const sortFunction = (a, b) => { |
|
const avatarA = $(a).data('avatar'); |
|
const avatarB = $(b).data('avatar'); |
|
const indexA = filteredMembers.indexOf(avatarA); |
|
const indexB = filteredMembers.indexOf(avatarB); |
|
return indexA - indexB; |
|
}; |
|
|
|
const containerWidth = container.width(); |
|
const pivotalPoint = containerWidth * 0.5; |
|
|
|
let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction); |
|
let imagesWidth = []; |
|
|
|
for (const image of images) { |
|
if (image instanceof HTMLImageElement && !image.complete) { |
|
await new Promise(resolve => image.addEventListener('load', resolve, { once: true })); |
|
} |
|
} |
|
|
|
images.forEach(image => { |
|
imagesWidth.push($(image).width()); |
|
}); |
|
|
|
let totalWidth = imagesWidth.reduce((a, b) => a + b, 0); |
|
let currentPosition = pivotalPoint - (totalWidth / 2); |
|
|
|
if (totalWidth > containerWidth) { |
|
let totalOverlap = totalWidth - containerWidth; |
|
let totalWidthWithoutWidest = imagesWidth.reduce((a, b) => a + b, 0) - Math.max(...imagesWidth); |
|
let overlaps = imagesWidth.map(width => (width / totalWidthWithoutWidest) * totalOverlap); |
|
imagesWidth = imagesWidth.map((width, index) => width - overlaps[index]); |
|
currentPosition = 0; |
|
} |
|
|
|
images.forEach((current, index) => { |
|
const element = $(current); |
|
const elementID = element.attr('id'); |
|
|
|
|
|
if (element.data('dragged') |
|
|| (power_user.movingUIState[elementID] |
|
&& (typeof power_user.movingUIState[elementID] === 'object') |
|
&& Object.keys(power_user.movingUIState[elementID]).length > 0)) { |
|
loadMovingUIState(); |
|
|
|
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); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function setImage(img, path) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return new Promise(resolve => { |
|
const prevExpressionSrc = img.attr('src'); |
|
const expressionClone = img.clone(); |
|
const originalId = img.data('filename'); |
|
|
|
|
|
if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { |
|
|
|
expressionClone.addClass('expression-clone'); |
|
|
|
|
|
expressionClone.data('filename', '').css({ opacity: 0 }); |
|
|
|
expressionClone.attr('src', path); |
|
|
|
expressionClone.appendTo(img.parent()); |
|
|
|
const duration = 200; |
|
|
|
|
|
|
|
img.addClass('expression-animating'); |
|
|
|
|
|
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); |
|
|
|
|
|
img.css('position', 'absolute').width(imgWidth).height(imgHeight); |
|
expressionClone.addClass('expression-animating'); |
|
|
|
expressionClone.css({ |
|
opacity: 0, |
|
}).animate({ |
|
opacity: 1, |
|
}, duration) |
|
|
|
.promise().done(function () { |
|
img.animate({ |
|
opacity: 0, |
|
}, duration); |
|
|
|
img.remove(); |
|
|
|
expressionClone.data('filename', originalId); |
|
expressionClone.removeClass('expression-animating'); |
|
|
|
|
|
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(); |
|
|
|
|
|
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); |
|
|
|
|
|
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 { |
|
|
|
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(); |
|
} |
|
|
|
|
|
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); |
|
|
|
|
|
if (!lastMessageChanged) { |
|
return; |
|
} |
|
|
|
|
|
if (extension_settings.expressions.api === EXPRESSION_API.llm && context.streamingProcessor && !context.streamingProcessor.isFinished) { |
|
return; |
|
} |
|
|
|
|
|
if (inApiCall) { |
|
console.debug('Classification API is busy'); |
|
return; |
|
} |
|
|
|
|
|
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 (spriteFolderName === currentLastMessage.name && !context.groupId) { |
|
spriteFolderName = context.name2; |
|
} |
|
|
|
const force = !!context.groupId; |
|
|
|
|
|
if (currentLastMessage.mes == '...' && expressionsList.includes(extension_settings.expressions.fallback_expression)) { |
|
expression = extension_settings.expressions.fallback_expression; |
|
} |
|
|
|
await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode }); |
|
} |
|
catch (error) { |
|
console.log(error); |
|
} |
|
finally { |
|
inApiCall = false; |
|
lastCharacter = context.groupId || context.characterId; |
|
lastMessage = currentLastMessage.mes; |
|
lastServerResponseTime = Date.now(); |
|
} |
|
} |
|
|
|
function getSpriteFolderName(characterMessage = null, characterName = null) { |
|
const context = getContext(); |
|
let spriteFolderName = characterName ?? context.name2; |
|
const message = characterMessage ?? getLastCharacterMessage(); |
|
const avatarFileName = getFolderNameByMessage(message); |
|
const expressionOverride = extension_settings.expressionOverrides.find(e => e.name == avatarFileName); |
|
|
|
if (expressionOverride && expressionOverride.path) { |
|
spriteFolderName = expressionOverride.path; |
|
} |
|
|
|
return spriteFolderName; |
|
} |
|
|
|
function getFolderNameByMessage(message) { |
|
const context = getContext(); |
|
let avatarPath = ''; |
|
|
|
if (context.groupId) { |
|
avatarPath = message.original_avatar || context.characters.find(x => message.force_avatar && message.force_avatar.includes(encodeURIComponent(x.avatar)))?.avatar; |
|
} |
|
else if (context.characterId !== undefined) { |
|
avatarPath = getCharaFilename(); |
|
} |
|
|
|
if (!avatarPath) { |
|
return ''; |
|
} |
|
|
|
const folderName = avatarPath.replace(/\.[^/.]+$/, ''); |
|
return folderName; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
return ''; |
|
} |
|
|
|
async function classifyCallback( { api = null, filter = null, prompt = null }, text) { |
|
if (!text) { |
|
toastr.error('No text provided'); |
|
return ''; |
|
} |
|
if (api && !Object.keys(EXPRESSION_API).includes(api)) { |
|
toastr.error('Invalid API provided'); |
|
return ''; |
|
} |
|
|
|
const expressionApi = EXPRESSION_API[api] || extension_settings.expressions.api; |
|
const filterAvailable = !isFalseBoolean(filter); |
|
|
|
if (expressionApi === EXPRESSION_API.none) { |
|
toastr.warning('No classifier API selected'); |
|
return ''; |
|
} |
|
|
|
if (!modules.includes('classify') && expressionApi == EXPRESSION_API.extras) { |
|
toastr.warning('Text classification is disabled or not available'); |
|
return ''; |
|
} |
|
|
|
const label = await getExpressionLabel(text, expressionApi, { filterAvailable: filterAvailable, customPrompt: prompt }); |
|
console.debug(`Classification result for "${text}": ${label}`); |
|
return label; |
|
} |
|
|
|
|
|
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; |
|
|
|
|
|
let spriteFile = null; |
|
|
|
await validateImages(spriteFolderName); |
|
|
|
|
|
if (searchTerm === RESET_SPRITE_LABEL) { |
|
await sendExpressionCall(spriteFolderName, label, { force: true }); |
|
return lastExpression[spriteFolderName] ?? ''; |
|
} |
|
|
|
switch (type) { |
|
case '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': { |
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function spriteFolderNameFromCharacter(char) { |
|
const avatarFileName = char.avatar.replace(/\.[^/.]+$/, ''); |
|
const expressionOverride = extension_settings.expressionOverrides.find(e => e.name === avatarFileName); |
|
return expressionOverride?.path ? expressionOverride.path : avatarFileName; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function generateUniqueSpriteName(expression, existingFiles) { |
|
let index = existingFiles.length; |
|
let newSpriteName; |
|
do { |
|
newSpriteName = `${expression}-${index++}`; |
|
} while (existingFiles.some(file => withoutExtension(file.fileName) === newSpriteName)); |
|
return newSpriteName; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
formData.append('label', label); |
|
formData.append('avatar', file); |
|
formData.append('spriteName', spriteName); |
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function sampleClassifyText(text) { |
|
if (!text) { |
|
return text; |
|
} |
|
|
|
|
|
let result = substituteParams(text).replace(/[*"]/g, ''); |
|
|
|
|
|
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(); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { |
|
|
|
emotionResponse = removeReasoningFromString(emotionResponse); |
|
|
|
const fuse = new Fuse(labels, { includeScore: true }); |
|
console.debug('Using fuzzy search in labels:', labels); |
|
const result = fuse.search(emotionResponse); |
|
if (result.length > 0) { |
|
console.debug(`fuzzy search found: ${result[0].item} as closest for the LLM response:`, emotionResponse); |
|
return result[0].item; |
|
} |
|
const lowerCaseResponse = String(emotionResponse || '').toLowerCase(); |
|
for (const label of labels) { |
|
if (lowerCaseResponse.includes(label.toLowerCase())) { |
|
console.debug(`Found label ${label} in the LLM response:`, emotionResponse); |
|
return label; |
|
} |
|
} |
|
} |
|
|
|
throw new Error('Could not parse emotion response ' + emotionResponse); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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) { |
|
|
|
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), |
|
}); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { filterAvailable = null, customPrompt = null } = {}) { |
|
|
|
if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) { |
|
return extension_settings.expressions.fallback_expression; |
|
} |
|
|
|
if (extension_settings.expressions.translate && typeof globalThis.translate === 'function') { |
|
text = await globalThis.translate(text, 'en'); |
|
} |
|
|
|
text = sampleClassifyText(text); |
|
|
|
filterAvailable ??= extension_settings.expressions.filterAvailable; |
|
if (filterAvailable && ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(expressionsApi)) { |
|
console.debug('Filter available is only supported for LLM and WebLLM expressions'); |
|
} |
|
|
|
try { |
|
switch (expressionsApi) { |
|
|
|
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; |
|
|
|
case EXPRESSION_API.llm: { |
|
try { |
|
await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250); |
|
} catch (error) { |
|
console.warn('No LLM connection. Using fallback expression', error); |
|
return extension_settings.expressions.fallback_expression; |
|
} |
|
|
|
const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable }); |
|
const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList); |
|
eventSource.once(event_types.TEXT_COMPLETION_SETTINGS_READY, onTextGenSettingsReady); |
|
const emotionResponse = await generateRaw(text, main_api, false, false, prompt); |
|
return parseLlmResponse(emotionResponse, expressionsList); |
|
} |
|
|
|
case EXPRESSION_API.webllm: { |
|
if (!isWebLlmSupported()) { |
|
console.warn('WebLLM is not supported. Using fallback expression'); |
|
return extension_settings.expressions.fallback_expression; |
|
} |
|
|
|
const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable }); |
|
const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList); |
|
const messages = [ |
|
{ role: 'user', content: text + '\n\n' + prompt }, |
|
]; |
|
|
|
const emotionResponse = await generateWebLlmChatPrompt(messages); |
|
return parseLlmResponse(emotionResponse, expressionsList); |
|
} |
|
|
|
case EXPRESSION_API.extras: { |
|
const url = new URL(getApiUrl()); |
|
url.pathname = '/api/classify'; |
|
|
|
const extrasResult = await doExtrasFetch(url, { |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'application/json', |
|
'Bypass-Tunnel-Reminder': 'bypass', |
|
}, |
|
body: JSON.stringify({ text: text }), |
|
}); |
|
|
|
if (extrasResult.ok) { |
|
const data = await extrasResult.json(); |
|
return data.classification[0].label; |
|
} |
|
} break; |
|
|
|
case EXPRESSION_API.none: { |
|
|
|
return ''; |
|
} |
|
default: { |
|
toastr.error('Invalid API selected'); |
|
return ''; |
|
} |
|
} |
|
} catch (error) { |
|
toastr.error('Could not classify expression. Check the console or your backend for more information.'); |
|
console.error(error); |
|
return extension_settings.expressions.fallback_expression; |
|
} |
|
} |
|
|
|
function getLastCharacterMessage() { |
|
const context = getContext(); |
|
const reversedChat = context.chat.slice().reverse(); |
|
|
|
for (let mes of reversedChat) { |
|
if (mes.is_user || mes.is_system || mes.extra?.type === system_message_types.NARRATOR) { |
|
continue; |
|
} |
|
|
|
return { mes: mes.mes, name: mes.name, original_avatar: mes.original_avatar, force_avatar: mes.force_avatar }; |
|
} |
|
|
|
return { mes: '', name: null, original_avatar: null, force_avatar: null }; |
|
} |
|
|
|
function removeExpression() { |
|
lastMessage = null; |
|
$('img.expression').off('error'); |
|
$('img.expression').prop('src', ''); |
|
$('img.expression').removeClass('default'); |
|
$('#open_chat_expressions').hide(); |
|
$('#no_chat_expressions').show(); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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), |
|
}; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function drawSpritesList(spriteFolderName, labels, sprites) { |
|
|
|
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 }); |
|
|
|
|
|
let listItem = await getListItem(expression, { |
|
isCustom, |
|
images, |
|
}); |
|
$('#image_list').append(listItem); |
|
} |
|
return validExpressions; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function getListItem(expression, { images, isCustom = false } = {}) { |
|
return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false }); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()) : []; |
|
|
|
|
|
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; |
|
}, []); |
|
|
|
|
|
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); |
|
}); |
|
|
|
|
|
for (let i = 1; i < expression.files.length; i++) { |
|
expression.files[i].type = 'additional'; |
|
} |
|
} |
|
|
|
return grouped; |
|
} |
|
catch (err) { |
|
console.log(err); |
|
return []; |
|
} |
|
} |
|
|
|
async function renderAdditionalExpressionSettings() { |
|
renderCustomExpressions(); |
|
await renderFallbackExpressionPicker(); |
|
} |
|
|
|
function renderCustomExpressions() { |
|
if (!Array.isArray(extension_settings.expressions.custom)) { |
|
extension_settings.expressions.custom = []; |
|
} |
|
|
|
const customExpressions = extension_settings.expressions.custom.sort((a, b) => a.localeCompare(b)); |
|
$('#expression_custom').empty(); |
|
|
|
for (const expression of customExpressions) { |
|
const option = document.createElement('option'); |
|
option.value = expression; |
|
option.text = expression; |
|
$('#expression_custom').append(option); |
|
} |
|
|
|
if (customExpressions.length === 0) { |
|
$('#expression_custom').append('<option value="" disabled selected>[ No custom expressions ]</option>'); |
|
} |
|
} |
|
|
|
async function renderFallbackExpressionPicker() { |
|
const expressions = await getExpressionsList(); |
|
|
|
const defaultPicker = $('#expression_fallback'); |
|
defaultPicker.empty(); |
|
|
|
|
|
addOption(OPTION_NO_FALLBACK, '[ No fallback ]', !extension_settings.expressions.fallback_expression); |
|
addOption(OPTION_EMOJI_FALLBACK, '[ Default emojis ]', !!extension_settings.expressions.showDefault); |
|
|
|
for (const expression of expressions) { |
|
addOption(expression, expression, expression == extension_settings.expressions.fallback_expression); |
|
} |
|
|
|
|
|
function addOption(value, label, isSelected) { |
|
const option = document.createElement('option'); |
|
option.value = value; |
|
option.text = label; |
|
option.selected = isSelected; |
|
defaultPicker.append(option); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function getCachedExpressions() { |
|
if (!Array.isArray(expressionsList)) { |
|
return []; |
|
} |
|
|
|
return [...expressionsList, ...extension_settings.expressions.custom].filter(onlyUnique); |
|
} |
|
|
|
export async function getExpressionsList({ filterAvailable = false } = {}) { |
|
|
|
if (!Array.isArray(expressionsList)) { |
|
expressionsList = await resolveExpressionsList(); |
|
} |
|
|
|
const expressions = getCachedExpressions(); |
|
|
|
|
|
if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) { |
|
return expressions; |
|
} |
|
|
|
|
|
const currentLastMessage = selected_group ? getLastCharacterMessage() : null; |
|
const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); |
|
|
|
return expressions.filter(label => { |
|
const expression = spriteCache[spriteFolderName]?.find(x => x.label === label); |
|
return (expression?.files.length ?? 0) > 0; |
|
}); |
|
|
|
|
|
|
|
|
|
|
|
async function resolveExpressionsList() { |
|
|
|
try { |
|
|
|
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 (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); |
|
} |
|
|
|
|
|
expressionsList = DEFAULT_EXPRESSIONS.slice(); |
|
return expressionsList; |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) { |
|
if (!spriteCache[spriteFolderName]) return null; |
|
if (expression === RESET_SPRITE_LABEL) return null; |
|
|
|
|
|
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 (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 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; |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
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; |
|
} |
|
} |
|
|
|
|
|
if (prevExpressionSrc !== spriteFile.imageSrc |
|
&& !img.hasClass('expression-animating')) { |
|
|
|
expressionClone.addClass('expression-clone'); |
|
|
|
|
|
expressionClone.attr('id', '').css({ opacity: 0 }); |
|
|
|
expressionClone.attr('src', spriteFile.imageSrc); |
|
|
|
expressionClone.attr('data-sprite-folder-name', spriteFolderName); |
|
expressionClone.attr('data-expression', expression); |
|
expressionClone.attr('data-sprite-filename', spriteFile.fileName); |
|
expressionClone.attr('title', expression); |
|
|
|
expressionClone.appendTo($('#expression-holder')); |
|
|
|
const duration = 200; |
|
|
|
|
|
|
|
img.addClass('expression-animating'); |
|
|
|
|
|
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); |
|
|
|
|
|
img.css('position', 'absolute').width(imgWidth).height(imgHeight); |
|
expressionClone.addClass('expression-animating'); |
|
|
|
expressionClone.css({ |
|
opacity: 0, |
|
}).animate({ |
|
opacity: 1, |
|
}, duration) |
|
|
|
.promise().done(function () { |
|
img.animate({ |
|
opacity: 0, |
|
}, duration); |
|
|
|
img.remove(); |
|
|
|
expressionClone.attr('id', 'expression-image'); |
|
expressionClone.removeClass('expression-animating'); |
|
|
|
|
|
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 = ''; |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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'); |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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 ($(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(); |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
extension_settings.expressions.custom.push(expressionName); |
|
await renderAdditionalExpressionSettings(); |
|
saveSettingsDebounced(); |
|
|
|
|
|
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; |
|
} |
|
|
|
|
|
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(); |
|
|
|
|
|
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() { |
|
|
|
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, |
|
}); |
|
|
|
|
|
const name = formData.get('name'); |
|
delete spriteCache[name]; |
|
await fetchImagesNoCache(); |
|
await validateImages(name); |
|
|
|
return data; |
|
} catch (error) { |
|
toastr.error('Failed to upload image'); |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
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) { |
|
|
|
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 (!clickedFileName && validFileName) { |
|
spriteName = fileNameWithoutExtension; |
|
} |
|
|
|
else if (clickedFileName === file.name) { |
|
spriteName = fileNameWithoutExtension; |
|
} |
|
|
|
else if (!matchesExisting && validFileName) { |
|
spriteName = fileNameWithoutExtension; |
|
} |
|
else { |
|
|
|
const customButtons = []; |
|
if (clickedFileName) { |
|
customButtons.push({ |
|
text: t`Replace Existing`, |
|
result: POPUP_RESULT.NEGATIVE, |
|
action: () => { |
|
console.debug('Replacing existing sprite'); |
|
spriteName = withoutExtension(clickedFileName); |
|
}, |
|
}); |
|
} |
|
|
|
spriteName = null; |
|
const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles); |
|
|
|
const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); |
|
|
|
const input = await Popup.show.input(t`Upload Expression Sprite`, message, |
|
suggestedSpriteName, { customButtons: customButtons }); |
|
|
|
if (input) { |
|
if (!validateExpressionSpriteName(expression, input)) { |
|
toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`); |
|
return; |
|
} |
|
spriteName = input; |
|
} |
|
} |
|
} else { |
|
spriteName = withoutExtension(expression); |
|
} |
|
|
|
if (!spriteName) { |
|
toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`); |
|
|
|
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); |
|
|
|
|
|
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 (!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 (overridePath === undefined || overridePath.length === 0) { |
|
if (existingOverrideIndex === -1) { |
|
return; |
|
} |
|
|
|
extension_settings.expressionOverrides.splice(existingOverrideIndex, 1); |
|
console.debug(`Removed existing override for ${avatarFileName}`); |
|
} else { |
|
|
|
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(); |
|
|
|
|
|
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() { |
|
|
|
for (const element of extension_settings.expressionOverrides) { |
|
delete spriteCache[element.name]; |
|
} |
|
|
|
extension_settings.expressionOverrides = []; |
|
saveSettingsDebounced(); |
|
|
|
console.debug('All expression image overrides have been cleared.'); |
|
|
|
|
|
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}`); |
|
|
|
|
|
e.target.form.reset(); |
|
}; |
|
|
|
$('#expression_upload_pack') |
|
.off('change') |
|
.on('change', handleFileUploadChange) |
|
.trigger('click'); |
|
} |
|
|
|
async function onClickExpressionDelete(event) { |
|
|
|
event.stopPropagation(); |
|
|
|
const expressionListItem = $(this).closest('.expression_list_item'); |
|
const expression = expressionListItem.data('expression'); |
|
|
|
if (expressionListItem.attr('data-expression-type') === 'failure') { |
|
return; |
|
} |
|
|
|
const confirmation = await Popup.show.confirm(t`Delete Expression`, t`Are you sure you want to delete this expression? Once deleted, it\'s gone forever!` |
|
+ '<br /><br />' |
|
+ t`Expression:` + ' <tt>' + expressionListItem.attr('data-filename') + '</tt>'); |
|
if (!confirmation) { |
|
return; |
|
} |
|
|
|
const fileName = withoutExtension(expressionListItem.attr('data-filename')); |
|
const name = $('#image_list').data('name'); |
|
|
|
try { |
|
await fetch('/api/sprites/delete', { |
|
method: 'POST', |
|
headers: getRequestHeaders(), |
|
body: JSON.stringify({ name, label: expression, spriteName: fileName }), |
|
}); |
|
} catch (error) { |
|
toastr.error('Failed to delete image. Try again later.'); |
|
} |
|
|
|
|
|
delete spriteCache[name]; |
|
await fetchImagesNoCache(); |
|
await validateImages(name); |
|
} |
|
|
|
function setExpressionOverrideHtml(forceClear = false) { |
|
const currentLastMessage = getLastCharacterMessage(); |
|
const avatarFileName = getFolderNameByMessage(currentLastMessage); |
|
if (!avatarFileName) { |
|
return; |
|
} |
|
|
|
const expressionOverride = extension_settings.expressionOverrides.find((e) => |
|
e.name == avatarFileName, |
|
); |
|
|
|
if (expressionOverride && expressionOverride.path) { |
|
$('#expression_override').val(expressionOverride.path); |
|
} else if (expressionOverride) { |
|
delete extension_settings.expressionOverrides[expressionOverride.name]; |
|
} |
|
|
|
if (forceClear && !expressionOverride) { |
|
$('#expression_override').val(''); |
|
} |
|
} |
|
|
|
async function fetchImagesNoCache() { |
|
const promises = []; |
|
$('#image_list img').each(function () { |
|
const src = $(this).attr('src'); |
|
|
|
if (!src) { |
|
return; |
|
} |
|
|
|
const promise = fetch(src, { |
|
method: 'GET', |
|
cache: 'no-cache', |
|
headers: { |
|
'Cache-Control': 'no-cache', |
|
'Pragma': 'no-cache', |
|
'Expires': '0', |
|
}, |
|
}); |
|
promises.push(promise); |
|
}); |
|
|
|
return await Promise.allSettled(promises); |
|
} |
|
|
|
function migrateSettings() { |
|
if (extension_settings.expressions.api === undefined) { |
|
extension_settings.expressions.api = EXPRESSION_API.none; |
|
saveSettingsDebounced(); |
|
} |
|
|
|
if (Object.keys(extension_settings.expressions).includes('local')) { |
|
if (extension_settings.expressions.local) { |
|
extension_settings.expressions.api = EXPRESSION_API.local; |
|
} |
|
|
|
delete extension_settings.expressions.local; |
|
saveSettingsDebounced(); |
|
} |
|
|
|
if (extension_settings.expressions.llmPrompt === undefined) { |
|
extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; |
|
saveSettingsDebounced(); |
|
} |
|
|
|
if (extension_settings.expressions.allowMultiple === undefined) { |
|
extension_settings.expressions.allowMultiple = true; |
|
saveSettingsDebounced(); |
|
} |
|
|
|
if (extension_settings.expressions.showDefault && extension_settings.expressions.fallback_expression !== undefined) { |
|
extension_settings.expressions.showDefault = false; |
|
saveSettingsDebounced(); |
|
} |
|
} |
|
|
|
(async function () { |
|
function addExpressionImage() { |
|
const html = ` |
|
<div id="expression-wrapper"> |
|
<div id="expression-holder" class="expression-holder" style="display:none;"> |
|
<div id="expression-holderheader" class="fa-solid fa-grip drag-grabber"></div> |
|
<img id="expression-image" class="expression"> |
|
</div> |
|
</div>`; |
|
$('body').append(html); |
|
loadMovingUIState(); |
|
} |
|
function addVisualNovelMode() { |
|
const html = ` |
|
<div id="visual-novel-wrapper"> |
|
</div>`; |
|
const element = $(html); |
|
element.hide(); |
|
$('body').append(element); |
|
} |
|
async function addSettings() { |
|
const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); |
|
$('#expressions_container').append(template); |
|
$('#expression_override_button').on('click', onClickExpressionOverrideButton); |
|
$('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton); |
|
$('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () { |
|
extension_settings.expressions.translate = !!$(this).prop('checked'); |
|
saveSettingsDebounced(); |
|
}); |
|
$('#expressions_allow_multiple').prop('checked', extension_settings.expressions.allowMultiple).on('input', function () { |
|
extension_settings.expressions.allowMultiple = !!$(this).prop('checked'); |
|
saveSettingsDebounced(); |
|
}); |
|
$('#expressions_reroll_if_same').prop('checked', extension_settings.expressions.rerollIfSame).on('input', function () { |
|
extension_settings.expressions.rerollIfSame = !!$(this).prop('checked'); |
|
saveSettingsDebounced(); |
|
}); |
|
$('#expressions_filter_available').prop('checked', extension_settings.expressions.filterAvailable).on('input', function () { |
|
extension_settings.expressions.filterAvailable = !!$(this).prop('checked'); |
|
saveSettingsDebounced(); |
|
}); |
|
$('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton); |
|
$(document).on('dragstart', '.expression', (e) => { |
|
e.preventDefault(); |
|
return false; |
|
}); |
|
$(document).on('click', '.expression_list_item', onClickExpressionImage); |
|
$(document).on('click', '.expression_list_upload', onClickExpressionUpload); |
|
$(document).on('click', '.expression_list_delete', onClickExpressionDelete); |
|
$(window).on('resize', () => updateVisualNovelModeDebounced()); |
|
$('#open_chat_expressions').hide(); |
|
|
|
await renderAdditionalExpressionSettings(); |
|
$('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.none); |
|
$('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); |
|
$('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? ''); |
|
$('#expression_llm_prompt').on('input', function () { |
|
extension_settings.expressions.llmPrompt = String($(this).val()); |
|
saveSettingsDebounced(); |
|
}); |
|
$('#expression_llm_prompt_restore').on('click', function () { |
|
$('#expression_llm_prompt').val(DEFAULT_LLM_PROMPT); |
|
extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; |
|
saveSettingsDebounced(); |
|
}); |
|
|
|
$('#expression_custom_add').on('click', onClickExpressionAddCustom); |
|
$('#expression_custom_remove').on('click', onClickExpressionRemoveCustom); |
|
$('#expression_fallback').on('change', onExpressionFallbackChanged); |
|
$('#expression_api').on('change', onExpressionApiChanged); |
|
} |
|
|
|
addExpressionImage(); |
|
addVisualNovelMode(); |
|
migrateSettings(); |
|
await addSettings(); |
|
const wrapper = new ModuleWorkerWrapper(moduleWorker); |
|
const updateFunction = wrapper.update.bind(wrapper); |
|
setInterval(updateFunction, UPDATE_INTERVAL); |
|
moduleWorker(); |
|
dragElement($('#expression-holder')); |
|
eventSource.on(event_types.CHAT_CHANGED, () => { |
|
|
|
removeExpression(); |
|
spriteCache = {}; |
|
lastExpression = {}; |
|
|
|
|
|
let imgElement = document.getElementById('expression-image'); |
|
if (imgElement && imgElement instanceof HTMLImageElement) { |
|
imgElement.src = ''; |
|
} |
|
|
|
setExpressionOverrideHtml(true); |
|
|
|
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, _) => { |
|
|
|
const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression'; |
|
if (type == 'sprite') return localEnumProviders.sprites(); |
|
else return [ |
|
...localEnumProviders.expressions(), |
|
new SlashCommandEnumValue(RESET_SPRITE_LABEL, 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'), |
|
]; |
|
}, |
|
}), |
|
], |
|
helpString: 'Force sets the expression for the current character.', |
|
returns: 'The currently set expression label after setting it.', |
|
})); |
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
|
name: 'expression-folder-override', |
|
aliases: ['spriteoverride', 'costume'], |
|
callback: setSpriteFolderCommand, |
|
unnamedArgumentList: [ |
|
new SlashCommandArgument( |
|
'optional folder', [ARGUMENT_TYPE.STRING], false, |
|
), |
|
], |
|
helpString: ` |
|
<div> |
|
Sets an override sprite folder for the current character.<br /> |
|
In groups, this will apply to the character who last sent a message. |
|
</div> |
|
<div> |
|
If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default. |
|
</div> |
|
`, |
|
})); |
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
|
name: 'expression-last', |
|
aliases: ['lastsprite'], |
|
|
|
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'], |
|
|
|
callback: async (args) => { |
|
let returnType = |
|
|
|
(args.return); |
|
|
|
const list = await getExpressionsList({ filterAvailable: !isFalseBoolean(args.filter) }); |
|
|
|
return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); |
|
}, |
|
namedArgumentList: [ |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'return', |
|
description: 'The way how you want the return value to be provided', |
|
typeList: [ARGUMENT_TYPE.STRING], |
|
defaultValue: 'pipe', |
|
enumList: slashCommandReturnHelper.enumList({ allowObject: true }), |
|
forceEnum: true, |
|
}), |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'filter', |
|
description: 'Filter the list to only include expressions that have available sprites for the current character.', |
|
typeList: [ARGUMENT_TYPE.BOOLEAN], |
|
enumList: commonEnumProviders.boolean('trueFalse')(), |
|
defaultValue: 'true', |
|
}), |
|
], |
|
returns: 'The comma-separated list of available expressions, including custom expressions.', |
|
helpString: 'Returns a list of available expressions, including custom expressions.', |
|
})); |
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
|
name: 'expression-classify', |
|
aliases: ['classify'], |
|
callback: classifyCallback, |
|
namedArgumentList: [ |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'api', |
|
description: 'The Classifier API to classify with. If not specified, the configured one will be used.', |
|
typeList: [ARGUMENT_TYPE.STRING], |
|
enumList: Object.keys(EXPRESSION_API).map(api => new SlashCommandEnumValue(api, null, enumTypes.enum)), |
|
}), |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'filter', |
|
description: 'Filter the list to only include expressions that have available sprites for the current character.', |
|
typeList: [ARGUMENT_TYPE.BOOLEAN], |
|
enumList: commonEnumProviders.boolean('trueFalse')(), |
|
defaultValue: 'true', |
|
}), |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'prompt', |
|
description: 'Custom prompt for classification. Only relevant if Classifier API is set to LLM.', |
|
typeList: [ARGUMENT_TYPE.STRING], |
|
}), |
|
], |
|
unnamedArgumentList: [ |
|
new SlashCommandArgument( |
|
'text', [ARGUMENT_TYPE.STRING], true, |
|
), |
|
], |
|
returns: 'emotion classification label for the given text', |
|
helpString: ` |
|
<div> |
|
Performs an emotion classification of the given text and returns a label. |
|
</div> |
|
<div> |
|
Allows to specify which Classifier API to perform the classification with. |
|
</div> |
|
<div> |
|
<strong>Example:</strong> |
|
<ul> |
|
<li> |
|
<pre><code>/classify I am so happy today!</code></pre> |
|
</li> |
|
</ul> |
|
</div> |
|
`, |
|
})); |
|
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
|
name: 'expression-upload', |
|
aliases: ['uploadsprite'], |
|
|
|
callback: async (args, url) => { |
|
return await uploadSpriteCommand(args, url); |
|
}, |
|
returns: 'the resulting sprite name', |
|
unnamedArgumentList: [ |
|
SlashCommandArgument.fromProps({ |
|
description: 'URL of the image to upload', |
|
typeList: [ARGUMENT_TYPE.STRING], |
|
isRequired: true, |
|
}), |
|
], |
|
namedArgumentList: [ |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'name', |
|
description: 'Character name or avatar key (default is current character)', |
|
typeList: [ARGUMENT_TYPE.STRING], |
|
isRequired: false, |
|
}), |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'label', |
|
description: 'Sprite label/expression name', |
|
typeList: [ARGUMENT_TYPE.STRING], |
|
enumProvider: localEnumProviders.expressions, |
|
isRequired: true, |
|
}), |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'folder', |
|
description: 'Override folder to upload into', |
|
typeList: [ARGUMENT_TYPE.STRING], |
|
isRequired: false, |
|
}), |
|
SlashCommandNamedArgument.fromProps({ |
|
name: 'spriteName', |
|
description: 'Override sprite name to allow multiple sprites per expressions. Has to follow the naming pattern. If unspecified, the label will be used as sprite name.', |
|
typeList: [ARGUMENT_TYPE.STRING], |
|
isRequired: false, |
|
}), |
|
], |
|
helpString: ` |
|
<div> |
|
Upload a sprite from a URL. |
|
</div> |
|
<div> |
|
<strong>Example:</strong> |
|
<ul> |
|
<li> |
|
<pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/[email protected]</code></pre> |
|
</li> |
|
</ul> |
|
</div> |
|
`, |
|
})); |
|
})(); |
|
|