Merge pull request #3403 from SillyTavern/support-multiple-expressions Support multiple expressions
Signed| @@ -40,4 +40,12 @@ declare global { | |||
| 40 | searchInputCssClass?: string; | 40 | searchInputCssClass?: string; |
| 41 | } | 41 | } |
| 42 | } | 42 | } |
| 43 | |||
| 44 | /** | ||
| 45 | * Translates a text to a target language using a translation provider. | ||
| 46 | * @param text Text to translate | ||
| 47 | * @param lang Target language | ||
| 48 | * @param provider Translation provider | ||
| 49 | */ | ||
| 50 | async function translate(text: string, lang: string, provider: string = null): Promise<string>; | ||
| 43 | } | 51 | } |
| @@ -1602,7 +1602,6 @@ | |||
| 1602 | "Character Expressions": "Expressions de personnages", | 1602 | "Character Expressions": "Expressions de personnages", |
| 1603 | "Translate text to English before classification": "Traduire le texte en anglais avant de le classer", | 1603 | "Translate text to English before classification": "Traduire le texte en anglais avant de le classer", |
| 1604 | "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant", | 1604 | "Show default images (emojis) if sprite missing": "Afficher les images par défaut (emojis) si le sprite est manquant", |
| 1605 | "Image Type - talkinghead (extras)": "Type d'image - talkinghead (extras)", | ||
| 1606 | "Classifier API": "API de classification", | 1605 | "Classifier API": "API de classification", |
| 1607 | "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.", | 1606 | "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.", |
| 1608 | "Main API": "API principale", | 1607 | "Main API": "API principale", |
| @@ -1467,7 +1467,6 @@ | |||
| 1467 | "menu within": "내의 메뉴", | 1467 | "menu within": "내의 메뉴", |
| 1468 | "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.", | 1468 | "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.", |
| 1469 | "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.", | 1469 | "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.", |
| 1470 | "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)", | ||
| 1471 | "Classifier API": "분류를 위한 API", | 1470 | "Classifier API": "분류를 위한 API", |
| 1472 | "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.", | 1471 | "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.", |
| 1473 | "Local": "로컬", | 1472 | "Local": "로컬", |
| @@ -1349,7 +1349,6 @@ | |||
| 1349 | "Character Expressions": "角色表情", | 1349 | "Character Expressions": "角色表情", |
| 1350 | "Translate text to English before classification": "分类之前将文本翻译成英文", | 1350 | "Translate text to English before classification": "分类之前将文本翻译成英文", |
| 1351 | "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)", | 1351 | "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)", |
| 1352 | "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)", | ||
| 1353 | "Classifier API": "分类器 API", | 1352 | "Classifier API": "分类器 API", |
| 1354 | "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。", | 1353 | "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。", |
| 1355 | "Main API": "主要 API", | 1354 | "Main API": "主要 API", |
| @@ -1653,7 +1653,6 @@ | |||
| 1653 | "HuggingFace Token": "HuggingFace 符元", | 1653 | "HuggingFace Token": "HuggingFace 符元", |
| 1654 | "Image Captioning": "圖片註解", | 1654 | "Image Captioning": "圖片註解", |
| 1655 | "Generate Caption": "產生圖片註解", | 1655 | "Generate Caption": "產生圖片註解", |
| 1656 | "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)", | ||
| 1657 | "Injection Position": "插入位置", | 1656 | "Injection Position": "插入位置", |
| 1658 | "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。", | 1657 | "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。", |
| 1659 | "Injection Template": "插入範本", | 1658 | "Injection Template": "插入範本", |
| @@ -154,8 +154,18 @@ export const extension_settings = { | |||
| 154 | refine_mode: false, | 154 | refine_mode: false, |
| 155 | }, | 155 | }, |
| 156 | expressions: { | 156 | expressions: { |
| 157 | /** @type {number} see `EXPRESSION_API` */ | ||
| 158 | api: undefined, | ||
| 157 | /** @type {string[]} */ | 159 | /** @type {string[]} */ |
| 158 | custom: [], | 160 | custom: [], |
| 161 | showDefault: false, | ||
| 162 | translate: false, | ||
| 163 | /** @type {string} */ | ||
| 164 | fallback_expression: undefined, | ||
| 165 | /** @type {string} */ | ||
| 166 | llmPrompt: undefined, | ||
| 167 | allowMultiple: true, | ||
| 168 | rerollIfSame: false, | ||
| 159 | }, | 169 | }, |
| 160 | connectionManager: { | 170 | connectionManager: { |
| 161 | selectedProfile: '', | 171 | selectedProfile: '', |
| @@ -1,11 +1,11 @@ | |||
| 1 | import { Fuse } from '../../../lib.js'; | 1 | import { Fuse } from '../../../lib.js'; |
| 2 | 2 | ||
| 3 | import { callPopup, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js'; | 3 | import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js'; |
| 4 | import { dragElement, isMobile } from '../../RossAscends-mods.js'; | 4 | import { dragElement, isMobile } from '../../RossAscends-mods.js'; |
| 5 | import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; | 5 | import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 6 | import { loadMovingUIState, power_user } from '../../power-user.js'; | 6 | import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; |
| 7 | import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; | 7 | import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; |
| 8 | import { hideMutedSprites } from '../../group-chats.js'; | 8 | import { hideMutedSprites, selected_group } from '../../group-chats.js'; |
| 9 | import { isJsonSchemaSupported } from '../../textgen-settings.js'; | 9 | import { isJsonSchemaSupported } from '../../textgen-settings.js'; |
| 10 | import { debounce_timeout } from '../../constants.js'; | 10 | import { debounce_timeout } from '../../constants.js'; |
| 11 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; | 11 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| @@ -15,16 +15,32 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm | |||
| 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; | 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; |
| 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; | 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 18 | import { Popup, POPUP_RESULT } from '../../popup.js'; | ||
| 19 | import { t } from '../../i18n.js'; | ||
| 18 | export { MODULE_NAME }; | 20 | export { MODULE_NAME }; |
| 19 | 21 | ||
| 22 | /** | ||
| 23 | * @typedef {object} Expression Expression definition with label and file path | ||
| 24 | * @property {string} label The label of the expression | ||
| 25 | * @property {ExpressionImage[]} files One or more images to represent this expression | ||
| 26 | */ | ||
| 27 | |||
| 28 | /** | ||
| 29 | * @typedef {object} ExpressionImage An expression image | ||
| 30 | * @property {string} expression - The expression | ||
| 31 | * @property {boolean} [isCustom=false] - If the expression is added by user | ||
| 32 | * @property {string} fileName - The filename with extension | ||
| 33 | * @property {string} title - The title for the image | ||
| 34 | * @property {string} imageSrc - The image source / full path | ||
| 35 | * @property {'success' | 'additional' | 'failure'} type - The type of the image | ||
| 36 | */ | ||
| 37 | |||
| 20 | const MODULE_NAME = 'expressions'; | 38 | const MODULE_NAME = 'expressions'; |
| 21 | const UPDATE_INTERVAL = 2000; | 39 | const UPDATE_INTERVAL = 2000; |
| 22 | const STREAMING_UPDATE_INTERVAL = 10000; | 40 | const STREAMING_UPDATE_INTERVAL = 10000; |
| 23 | const TALKINGCHECK_UPDATE_INTERVAL = 500; | ||
| 24 | const DEFAULT_FALLBACK_EXPRESSION = 'joy'; | 41 | const DEFAULT_FALLBACK_EXPRESSION = 'joy'; |
| 25 | 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}}'; | 42 | 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}}'; |
| 26 | const DEFAULT_EXPRESSIONS = [ | 43 | const DEFAULT_EXPRESSIONS = [ |
| 27 | 'talkinghead', | ||
| 28 | 'admiration', | 44 | 'admiration', |
| 29 | 'amusement', | 45 | 'amusement', |
| 30 | 'anger', | 46 | 'anger', |
| @@ -54,6 +70,12 @@ const DEFAULT_EXPRESSIONS = [ | |||
| 54 | 'surprise', | 70 | 'surprise', |
| 55 | 'neutral', | 71 | 'neutral', |
| 56 | ]; | 72 | ]; |
| 73 | |||
| 74 | const OPTION_NO_FALLBACK = '#none'; | ||
| 75 | const OPTION_EMOJI_FALLBACK = '#emoji'; | ||
| 76 | const RESET_SPRITE_LABEL = '#reset'; | ||
| 77 | |||
| 78 | |||
| 57 | /** @enum {number} */ | 79 | /** @enum {number} */ |
| 58 | const EXPRESSION_API = { | 80 | const EXPRESSION_API = { |
| 59 | local: 0, | 81 | local: 0, |
| @@ -65,35 +87,29 @@ const EXPRESSION_API = { | |||
| 65 | let expressionsList = null; | 87 | let expressionsList = null; |
| 66 | let lastCharacter = undefined; | 88 | let lastCharacter = undefined; |
| 67 | let lastMessage = null; | 89 | let lastMessage = null; |
| 68 | let lastTalkingState = false; | 90 | /** @type {{[characterKey: string]: Expression[]}} */ |
| 69 | let lastTalkingStateMessage = null; // last message as seen by `updateTalkingState` (tracked separately, different timer) | ||
| 70 | let spriteCache = {}; | 91 | let spriteCache = {}; |
| 71 | let inApiCall = false; | 92 | let inApiCall = false; |
| 72 | let lastServerResponseTime = 0; | 93 | let lastServerResponseTime = 0; |
| 73 | export let lastExpression = {}; | ||
| 74 | |||
| 75 | function isTalkingHeadEnabled() { | ||
| 76 | return extension_settings.expressions.talkinghead && extension_settings.expressions.api == EXPRESSION_API.extras; | ||
| 77 | } | ||
| 78 | 94 | ||
| 79 | /** | 95 | /** @type {{[characterName: string]: string}} */ |
| 80 | * Returns the fallback expression if explicitly chosen, otherwise the default one | 96 | export let lastExpression = {}; |
| 81 | * @returns {string} expression name | ||
| 82 | */ | ||
| 83 | function getFallbackExpression() { | ||
| 84 | return extension_settings.expressions.fallback_expression ?? DEFAULT_FALLBACK_EXPRESSION; | ||
| 85 | } | ||
| 86 | 97 | ||
| 87 | /** | 98 | /** |
| 88 | * Toggles Talkinghead mode on/off. | 99 | * Returns a placeholder image object for a given expression |
| 89 | * | 100 | * @param {string} expression - The expression label |
| 90 | * Implements the `/th` slash command, which is meant to be bound to a Quick Reply button | 101 | * @param {boolean} [isCustom=false] - Whether the expression is custom |
| 91 | * as a quick way to switch Talkinghead on or off (e.g. to conserve GPU resources when AFK | 102 | * @returns {ExpressionImage} The placeholder image object |
| 92 | * for a long time). | ||
| 93 | */ | 103 | */ |
| 94 | function toggleTalkingHeadCommand(_) { | 104 | function getPlaceholderImage(expression, isCustom = false) { |
| 95 | setTalkingHeadState(!extension_settings.expressions.talkinghead); | 105 | return { |
| 96 | return String(extension_settings.expressions.talkinghead); | 106 | expression: expression, |
| 107 | isCustom: isCustom, | ||
| 108 | title: 'No Image', | ||
| 109 | type: 'failure', | ||
| 110 | fileName: 'No-Image-Placeholder.svg', | ||
| 111 | imageSrc: '/img/No-Image-Placeholder.svg', | ||
| 112 | }; | ||
| 97 | } | 113 | } |
| 98 | 114 | ||
| 99 | function isVisualNovelMode() { | 115 | function isVisualNovelMode() { |
| @@ -108,21 +124,21 @@ async function forceUpdateVisualNovelMode() { | |||
| 108 | 124 | ||
| 109 | const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick); | 125 | const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick); |
| 110 | 126 | ||
| 111 | async function updateVisualNovelMode(name, expression) { | 127 | async function updateVisualNovelMode(spriteFolderName, expression) { |
| 112 | const container = $('#visual-novel-wrapper'); | 128 | const vnContainer = $('#visual-novel-wrapper'); |
| 113 | 129 | ||
| 114 | await visualNovelRemoveInactive(container); | 130 | await visualNovelRemoveInactive(vnContainer); |
| 115 | 131 | ||
| 116 | const setSpritePromises = await visualNovelSetCharacterSprites(container, name, expression); | 132 | const setSpritePromises = await visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression); |
| 117 | 133 | ||
| 118 | // calculate layer indices based on recent messages | 134 | // calculate layer indices based on recent messages |
| 119 | await visualNovelUpdateLayers(container); | 135 | await visualNovelUpdateLayers(vnContainer); |
| 120 | 136 | ||
| 121 | await Promise.allSettled(setSpritePromises); | 137 | await Promise.allSettled(setSpritePromises); |
| 122 | 138 | ||
| 123 | // update again based on new sprites | 139 | // update again based on new sprites |
| 124 | if (setSpritePromises.length > 0) { | 140 | if (setSpritePromises.length > 0) { |
| 125 | await visualNovelUpdateLayers(container); | 141 | await visualNovelUpdateLayers(vnContainer); |
| 126 | } | 142 | } |
| 127 | } | 143 | } |
| 128 | 144 | ||
| @@ -153,52 +169,60 @@ async function visualNovelRemoveInactive(container) { | |||
| 153 | await Promise.allSettled(removeInactiveCharactersPromises); | 169 | await Promise.allSettled(removeInactiveCharactersPromises); |
| 154 | } | 170 | } |
| 155 | 171 | ||
| 156 | async function visualNovelSetCharacterSprites(container, name, expression) { | 172 | /** |
| 173 | * Sets the character sprites for visual novel mode based on the provided container, name, and expression. | ||
| 174 | * | ||
| 175 | * @param {JQuery<HTMLElement>} vnContainer - The container element where the sprites will be set | ||
| 176 | * @param {string} spriteFolderName - The name of the sprite folder | ||
| 177 | * @param {string} expression - The expression to set for the characters | ||
| 178 | * @returns {Promise<Array>} - An array of promises that resolve when the sprites are set | ||
| 179 | */ | ||
| 180 | async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) { | ||
| 181 | const originalExpression = expression; | ||
| 157 | const context = getContext(); | 182 | const context = getContext(); |
| 158 | const group = context.groups.find(x => x.id == context.groupId); | 183 | const group = context.groups.find(x => x.id == context.groupId); |
| 159 | const labels = await getExpressionsList(); | ||
| 160 | 184 | ||
| 161 | const createCharacterPromises = []; | ||
| 162 | const setSpritePromises = []; | 185 | const setSpritePromises = []; |
| 163 | 186 | ||
| 164 | for (const avatar of group.members) { | 187 | for (const avatar of group.members) { |
| 165 | const isDisabled = group.disabled_members.includes(avatar); | ||
| 166 | |||
| 167 | // skip disabled characters | 188 | // skip disabled characters |
| 189 | const isDisabled = group.disabled_members.includes(avatar); | ||
| 168 | if (isDisabled && hideMutedSprites) { | 190 | if (isDisabled && hideMutedSprites) { |
| 169 | continue; | 191 | continue; |
| 170 | } | 192 | } |
| 171 | 193 | ||
| 172 | const character = context.characters.find(x => x.avatar == avatar); | 194 | const character = context.characters.find(x => x.avatar == avatar); |
| 173 | |||
| 174 | if (!character) { | 195 | if (!character) { |
| 175 | continue; | 196 | continue; |
| 176 | } | 197 | } |
| 177 | 198 | ||
| 178 | const spriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name); | 199 | const expressionImage = vnContainer.find(`.expression-holder[data-avatar="${avatar}"]`); |
| 200 | /** @type {JQuery<HTMLElement>} */ | ||
| 201 | let img; | ||
| 202 | |||
| 203 | const memberSpriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name); | ||
| 179 | 204 | ||
| 180 | // download images if not downloaded yet | 205 | // download images if not downloaded yet |
| 181 | if (spriteCache[spriteFolderName] === undefined) { | 206 | if (spriteCache[memberSpriteFolderName] === undefined) { |
| 182 | spriteCache[spriteFolderName] = await getSpritesList(spriteFolderName); | 207 | spriteCache[memberSpriteFolderName] = await getSpritesList(memberSpriteFolderName); |
| 183 | } | 208 | } |
| 184 | 209 | ||
| 185 | const sprites = spriteCache[spriteFolderName]; | 210 | const prevExpressionSrc = expressionImage.find('img').attr('src') || null; |
| 186 | const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`); | ||
| 187 | const defaultExpression = getFallbackExpression(); | ||
| 188 | const defaultSpritePath = sprites.find(x => x.label === defaultExpression)?.path; | ||
| 189 | const noSprites = sprites.length === 0; | ||
| 190 | 211 | ||
| 191 | if (expressionImage.length > 0) { | 212 | if (!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) { |
| 192 | if (name == spriteFolderName) { | 213 | expression = await getLastMessageSprite(avatar); |
| 193 | await validateImages(spriteFolderName, true); | 214 | } |
| 194 | setExpressionOverrideHtml(true); // <= force clear expression override input | ||
| 195 | const currentSpritePath = labels.includes(expression) ? sprites.find(x => x.label === expression)?.path : ''; | ||
| 196 | 215 | ||
| 197 | const path = currentSpritePath || defaultSpritePath || ''; | 216 | const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc }); |
| 198 | const img = expressionImage.find('img'); | 217 | if (expressionImage.length) { |
| 218 | if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) { | ||
| 219 | await validateImages(memberSpriteFolderName, true); | ||
| 220 | setExpressionOverrideHtml(true); // <= force clear expression override input | ||
| 221 | const path = spriteFile?.imageSrc || ''; | ||
| 222 | img = expressionImage.find('img'); | ||
| 199 | await setImage(img, path); | 223 | await setImage(img, path); |
| 200 | } | 224 | } |
| 201 | expressionImage.toggleClass('hidden', noSprites); | 225 | expressionImage.toggleClass('hidden', !spriteFile); |
| 202 | } else { | 226 | } else { |
| 203 | const template = $('#expression-holder').clone(); | 227 | const template = $('#expression-holder').clone(); |
| 204 | template.attr('id', `expression-${avatar}`); | 228 | template.attr('id', `expression-${avatar}`); |
| @@ -206,21 +230,49 @@ async function visualNovelSetCharacterSprites(container, name, expression) { | |||
| 206 | template.find('.drag-grabber').attr('id', `expression-${avatar}header`); | 230 | template.find('.drag-grabber').attr('id', `expression-${avatar}header`); |
| 207 | $('#visual-novel-wrapper').append(template); | 231 | $('#visual-novel-wrapper').append(template); |
| 208 | dragElement($(template[0])); | 232 | dragElement($(template[0])); |
| 209 | template.toggleClass('hidden', noSprites); | 233 | template.toggleClass('hidden', !spriteFile); |
| 210 | await setImage(template.find('img'), defaultSpritePath || ''); | 234 | img = template.find('img'); |
| 235 | await setImage(img, spriteFile?.imageSrc || ''); | ||
| 211 | const fadeInPromise = new Promise(resolve => { | 236 | const fadeInPromise = new Promise(resolve => { |
| 212 | template.fadeIn(250, () => resolve()); | 237 | template.fadeIn(250, () => resolve()); |
| 213 | }); | 238 | }); |
| 214 | createCharacterPromises.push(fadeInPromise); | 239 | setSpritePromises.push(fadeInPromise); |
| 215 | const setSpritePromise = setLastMessageSprite(template.find('img'), avatar, labels); | ||
| 216 | setSpritePromises.push(setSpritePromise); | ||
| 217 | } | 240 | } |
| 241 | |||
| 242 | if (!img) { | ||
| 243 | continue; | ||
| 244 | } | ||
| 245 | |||
| 246 | img.attr('data-sprite-folder-name', spriteFolderName); | ||
| 247 | img.attr('data-expression', expression); | ||
| 248 | img.attr('data-sprite-filename', spriteFile?.fileName || null); | ||
| 249 | img.attr('title', expression); | ||
| 250 | |||
| 251 | if (spriteFile) console.info(`Expression set for group member ${character.name}`, { expression: spriteFile.expression, file: spriteFile.fileName }); | ||
| 252 | else if (expressionImage.length) console.info(`Expression unset for group member ${character.name} - No sprite found`, { expression: expression }); | ||
| 253 | else console.info(`Expression not available for group member ${character.name}`, { expression: expression }); | ||
| 218 | } | 254 | } |
| 219 | 255 | ||
| 220 | await Promise.allSettled(createCharacterPromises); | ||
| 221 | return setSpritePromises; | 256 | return setSpritePromises; |
| 222 | } | 257 | } |
| 223 | 258 | ||
| 259 | /** | ||
| 260 | * Classifies the text of the latest message and returns the expression label. | ||
| 261 | * @param {string} avatar - The avatar of the character to get the last message for | ||
| 262 | * @returns {Promise<string>} - The expression label | ||
| 263 | */ | ||
| 264 | async function getLastMessageSprite(avatar) { | ||
| 265 | const context = getContext(); | ||
| 266 | const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar)))); | ||
| 267 | |||
| 268 | if (lastMessage) { | ||
| 269 | const text = lastMessage.mes || ''; | ||
| 270 | return await getExpressionLabel(text); | ||
| 271 | } | ||
| 272 | |||
| 273 | return null; | ||
| 274 | } | ||
| 275 | |||
| 224 | async function visualNovelUpdateLayers(container) { | 276 | async function visualNovelUpdateLayers(container) { |
| 225 | const context = getContext(); | 277 | const context = getContext(); |
| 226 | const group = context.groups.find(x => x.id == context.groupId); | 278 | const group = context.groups.find(x => x.id == context.groupId); |
| @@ -256,11 +308,17 @@ async function visualNovelUpdateLayers(container) { | |||
| 256 | const containerWidth = container.width(); | 308 | const containerWidth = container.width(); |
| 257 | const pivotalPoint = containerWidth * 0.5; | 309 | const pivotalPoint = containerWidth * 0.5; |
| 258 | 310 | ||
| 259 | let images = $('#visual-novel-wrapper .expression-holder'); | 311 | let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction); |
| 260 | let imagesWidth = []; | 312 | let imagesWidth = []; |
| 261 | 313 | ||
| 262 | images.sort(sortFunction).each(function () { | 314 | for (const image of images) { |
| 263 | imagesWidth.push($(this).width()); | 315 | if (image instanceof HTMLImageElement && !image.complete) { |
| 316 | await new Promise(resolve => image.addEventListener('load', resolve, { once: true })); | ||
| 317 | } | ||
| 318 | } | ||
| 319 | |||
| 320 | images.forEach(image => { | ||
| 321 | imagesWidth.push($(image).width()); | ||
| 264 | }); | 322 | }); |
| 265 | 323 | ||
| 266 | let totalWidth = imagesWidth.reduce((a, b) => a + b, 0); | 324 | let totalWidth = imagesWidth.reduce((a, b) => a + b, 0); |
| @@ -274,7 +332,7 @@ async function visualNovelUpdateLayers(container) { | |||
| 274 | currentPosition = 0; // Reset the initial position to 0 | 332 | currentPosition = 0; // Reset the initial position to 0 |
| 275 | } | 333 | } |
| 276 | 334 | ||
| 277 | images.sort(sortFunction).each((index, current) => { | 335 | images.forEach((current, index) => { |
| 278 | const element = $(current); | 336 | const element = $(current); |
| 279 | const elementID = element.attr('id'); | 337 | const elementID = element.attr('id'); |
| 280 | 338 | ||
| @@ -294,9 +352,15 @@ async function visualNovelUpdateLayers(container) { | |||
| 294 | element.show(); | 352 | element.show(); |
| 295 | 353 | ||
| 296 | const promise = new Promise(resolve => { | 354 | const promise = new Promise(resolve => { |
| 355 | if (power_user.reduced_motion) { | ||
| 356 | element.css('left', currentPosition + 'px'); | ||
| 357 | requestAnimationFrame(() => resolve()); | ||
| 358 | } | ||
| 359 | else { | ||
| 297 | element.animate({ left: currentPosition + 'px' }, 500, () => { | 360 | element.animate({ left: currentPosition + 'px' }, 500, () => { |
| 298 | resolve(); | 361 | resolve(); |
| 299 | }); | 362 | }); |
| 363 | } | ||
| 300 | }); | 364 | }); |
| 301 | 365 | ||
| 302 | currentPosition += imagesWidth[index]; | 366 | currentPosition += imagesWidth[index]; |
| @@ -307,23 +371,12 @@ async function visualNovelUpdateLayers(container) { | |||
| 307 | await Promise.allSettled(setLayerIndicesPromises); | 371 | await Promise.allSettled(setLayerIndicesPromises); |
| 308 | } | 372 | } |
| 309 | 373 | ||
| 310 | async function setLastMessageSprite(img, avatar, labels) { | 374 | /** |
| 311 | const context = getContext(); | 375 | * Sets the expression for the given character image. |
| 312 | const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar)))); | 376 | * @param {JQuery<HTMLElement>} img - The image element to set the image on |
| 313 | 377 | * @param {string} path - The path to the image | |
| 314 | if (lastMessage) { | 378 | * @returns {Promise<void>} - A promise that resolves when the image is set |
| 315 | const text = lastMessage.mes || ''; | 379 | */ |
| 316 | const spriteFolderName = getSpriteFolderName(lastMessage, lastMessage.name); | ||
| 317 | const sprites = spriteCache[spriteFolderName] || []; | ||
| 318 | const label = await getExpressionLabel(text); | ||
| 319 | const path = labels.includes(label) ? sprites.find(x => x.label === label)?.path : ''; | ||
| 320 | |||
| 321 | if (path) { | ||
| 322 | setImage(img, path); | ||
| 323 | } | ||
| 324 | } | ||
| 325 | } | ||
| 326 | |||
| 327 | async function setImage(img, path) { | 380 | async function setImage(img, path) { |
| 328 | // Cohee: If something goes wrong, uncomment this to return to the old behavior | 381 | // Cohee: If something goes wrong, uncomment this to return to the old behavior |
| 329 | /* | 382 | /* |
| @@ -340,7 +393,7 @@ async function setImage(img, path) { | |||
| 340 | return new Promise(resolve => { | 393 | return new Promise(resolve => { |
| 341 | const prevExpressionSrc = img.attr('src'); | 394 | const prevExpressionSrc = img.attr('src'); |
| 342 | const expressionClone = img.clone(); | 395 | const expressionClone = img.clone(); |
| 343 | const originalId = img.attr('id'); | 396 | const originalId = img.data('filename'); |
| 344 | 397 | ||
| 345 | //only swap expressions when necessary | 398 | //only swap expressions when necessary |
| 346 | if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { | 399 | if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { |
| @@ -348,7 +401,7 @@ async function setImage(img, path) { | |||
| 348 | expressionClone.addClass('expression-clone'); | 401 | expressionClone.addClass('expression-clone'); |
| 349 | //make invisible and remove id to prevent double ids | 402 | //make invisible and remove id to prevent double ids |
| 350 | //must be made invisible to start because they share the same Z-index | 403 | //must be made invisible to start because they share the same Z-index |
| 351 | expressionClone.attr('id', '').css({ opacity: 0 }); | 404 | expressionClone.data('filename', '').css({ opacity: 0 }); |
| 352 | //add new sprite path to clone src | 405 | //add new sprite path to clone src |
| 353 | expressionClone.attr('src', path); | 406 | expressionClone.attr('src', path); |
| 354 | //add invisible clone to html | 407 | //add invisible clone to html |
| @@ -384,14 +437,18 @@ async function setImage(img, path) { | |||
| 384 | //remove old expression | 437 | //remove old expression |
| 385 | img.remove(); | 438 | img.remove(); |
| 386 | //replace ID so it becomes the new 'original' expression for next change | 439 | //replace ID so it becomes the new 'original' expression for next change |
| 387 | expressionClone.attr('id', originalId); | 440 | expressionClone.data('filename', originalId); |
| 388 | expressionClone.removeClass('expression-animating'); | 441 | expressionClone.removeClass('expression-animating'); |
| 389 | 442 | ||
| 390 | // Reset the expression holder min height and width | 443 | // Reset the expression holder min height and width |
| 391 | expressionHolder.css('min-width', 100); | 444 | expressionHolder.css('min-width', 100); |
| 392 | expressionHolder.css('min-height', 100); | 445 | expressionHolder.css('min-height', 100); |
| 393 | 446 | ||
| 447 | if (expressionClone.prop('complete')) { | ||
| 394 | resolve(); | 448 | resolve(); |
| 449 | } else { | ||
| 450 | expressionClone.one('load', () => resolve()); | ||
| 451 | } | ||
| 395 | }); | 452 | }); |
| 396 | 453 | ||
| 397 | expressionClone.removeClass('expression-clone'); | 454 | expressionClone.removeClass('expression-clone'); |
| @@ -410,216 +467,9 @@ async function setImage(img, path) { | |||
| 410 | }); | 467 | }); |
| 411 | } | 468 | } |
| 412 | 469 | ||
| 413 | function onExpressionsShowDefaultInput() { | 470 | async function moduleWorker({ newChat = false } = {}) { |
| 414 | const value = $(this).prop('checked'); | ||
| 415 | extension_settings.expressions.showDefault = value; | ||
| 416 | saveSettingsDebounced(); | ||
| 417 | |||
| 418 | const existingImageSrc = $('img.expression').prop('src'); | ||
| 419 | if (existingImageSrc !== undefined) { //if we have an image in src | ||
| 420 | if (!value && existingImageSrc.includes('/img/default-expressions/')) { //and that image is from /img/ (default) | ||
| 421 | $('img.expression').prop('src', ''); //remove it | ||
| 422 | lastMessage = null; | ||
| 423 | } | ||
| 424 | if (value) { | ||
| 425 | lastMessage = null; | ||
| 426 | } | ||
| 427 | } | ||
| 428 | } | ||
| 429 | |||
| 430 | /** | ||
| 431 | * Stops animating Talkinghead. | ||
| 432 | */ | ||
| 433 | async function unloadTalkingHead() { | ||
| 434 | if (!modules.includes('talkinghead')) { | ||
| 435 | console.debug('talkinghead module is disabled'); | ||
| 436 | return; | ||
| 437 | } | ||
| 438 | console.debug('expressions: Stopping Talkinghead'); | ||
| 439 | |||
| 440 | try { | ||
| 441 | const url = new URL(getApiUrl()); | ||
| 442 | url.pathname = '/api/talkinghead/unload'; | ||
| 443 | const loadResponse = await doExtrasFetch(url); | ||
| 444 | if (!loadResponse.ok) { | ||
| 445 | throw new Error(loadResponse.statusText); | ||
| 446 | } | ||
| 447 | //console.log(`Response: ${loadResponseText}`); | ||
| 448 | } catch (error) { | ||
| 449 | //console.error(`Error unloading - ${error}`); | ||
| 450 | } | ||
| 451 | } | ||
| 452 | |||
| 453 | /** | ||
| 454 | * Posts `talkinghead.png` of the current character to the talkinghead module in SillyTavern-extras, to start animating it. | ||
| 455 | */ | ||
| 456 | async function loadTalkingHead() { | ||
| 457 | if (!modules.includes('talkinghead')) { | ||
| 458 | console.debug('talkinghead module is disabled'); | ||
| 459 | return; | ||
| 460 | } | ||
| 461 | console.debug('expressions: Starting Talkinghead'); | ||
| 462 | |||
| 463 | const spriteFolderName = getSpriteFolderName(); | ||
| 464 | |||
| 465 | const talkingheadPath = `/characters/${encodeURIComponent(spriteFolderName)}/talkinghead.png`; | ||
| 466 | const emotionsSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_emotions.json`; | ||
| 467 | const animatorSettingsPath = `/characters/${encodeURIComponent(spriteFolderName)}/_animator.json`; | ||
| 468 | |||
| 469 | try { | ||
| 470 | const spriteResponse = await fetch(talkingheadPath); | ||
| 471 | |||
| 472 | if (!spriteResponse.ok) { | ||
| 473 | throw new Error(spriteResponse.statusText); | ||
| 474 | } | ||
| 475 | |||
| 476 | const spriteBlob = await spriteResponse.blob(); | ||
| 477 | const spriteFile = new File([spriteBlob], 'talkinghead.png', { type: 'image/png' }); | ||
| 478 | const formData = new FormData(); | ||
| 479 | formData.append('file', spriteFile); | ||
| 480 | |||
| 481 | const url = new URL(getApiUrl()); | ||
| 482 | url.pathname = '/api/talkinghead/load'; | ||
| 483 | |||
| 484 | const loadResponse = await doExtrasFetch(url, { | ||
| 485 | method: 'POST', | ||
| 486 | body: formData, | ||
| 487 | }); | ||
| 488 | |||
| 489 | if (!loadResponse.ok) { | ||
| 490 | throw new Error(loadResponse.statusText); | ||
| 491 | } | ||
| 492 | |||
| 493 | const loadResponseText = await loadResponse.text(); | ||
| 494 | console.log(`Load talkinghead response: ${loadResponseText}`); | ||
| 495 | |||
| 496 | // Optional: per-character emotion templates | ||
| 497 | let emotionsSettings; | ||
| 498 | try { | ||
| 499 | const emotionsResponse = await fetch(emotionsSettingsPath); | ||
| 500 | if (emotionsResponse.ok) { | ||
| 501 | emotionsSettings = await emotionsResponse.json(); | ||
| 502 | console.log(`Loaded ${emotionsSettingsPath}`); | ||
| 503 | } else { | ||
| 504 | throw new Error(); | ||
| 505 | } | ||
| 506 | } | ||
| 507 | catch (error) { | ||
| 508 | emotionsSettings = {}; // blank -> use server defaults (to unload the previous character's customizations) | ||
| 509 | console.log(`No valid config at ${emotionsSettingsPath}, using server defaults`); | ||
| 510 | } | ||
| 511 | try { | ||
| 512 | const url = new URL(getApiUrl()); | ||
| 513 | url.pathname = '/api/talkinghead/load_emotion_templates'; | ||
| 514 | const apiResult = await doExtrasFetch(url, { | ||
| 515 | method: 'POST', | ||
| 516 | headers: { | ||
| 517 | 'Content-Type': 'application/json', | ||
| 518 | 'Bypass-Tunnel-Reminder': 'bypass', | ||
| 519 | }, | ||
| 520 | body: JSON.stringify(emotionsSettings), | ||
| 521 | }); | ||
| 522 | |||
| 523 | if (!apiResult.ok) { | ||
| 524 | throw new Error(apiResult.statusText); | ||
| 525 | } | ||
| 526 | } | ||
| 527 | catch (error) { | ||
| 528 | // it's ok if not supported | ||
| 529 | console.log('Failed to send _emotions.json (backend too old?), ignoring'); | ||
| 530 | } | ||
| 531 | |||
| 532 | // Optional: per-character animator and postprocessor config | ||
| 533 | let animatorSettings; | ||
| 534 | try { | ||
| 535 | const animatorResponse = await fetch(animatorSettingsPath); | ||
| 536 | if (animatorResponse.ok) { | ||
| 537 | animatorSettings = await animatorResponse.json(); | ||
| 538 | console.log(`Loaded ${animatorSettingsPath}`); | ||
| 539 | } else { | ||
| 540 | throw new Error(); | ||
| 541 | } | ||
| 542 | } | ||
| 543 | catch (error) { | ||
| 544 | animatorSettings = {}; // blank -> use server defaults (to unload the previous character's customizations) | ||
| 545 | console.log(`No valid config at ${animatorSettingsPath}, using server defaults`); | ||
| 546 | } | ||
| 547 | try { | ||
| 548 | const url = new URL(getApiUrl()); | ||
| 549 | url.pathname = '/api/talkinghead/load_animator_settings'; | ||
| 550 | const apiResult = await doExtrasFetch(url, { | ||
| 551 | method: 'POST', | ||
| 552 | headers: { | ||
| 553 | 'Content-Type': 'application/json', | ||
| 554 | 'Bypass-Tunnel-Reminder': 'bypass', | ||
| 555 | }, | ||
| 556 | body: JSON.stringify(animatorSettings), | ||
| 557 | }); | ||
| 558 | |||
| 559 | if (!apiResult.ok) { | ||
| 560 | throw new Error(apiResult.statusText); | ||
| 561 | } | ||
| 562 | } | ||
| 563 | catch (error) { | ||
| 564 | // it's ok if not supported | ||
| 565 | console.log('Failed to send _animator.json (backend too old?), ignoring'); | ||
| 566 | } | ||
| 567 | } catch (error) { | ||
| 568 | console.error(`Error loading talkinghead image: ${talkingheadPath} - ${error}`); | ||
| 569 | } | ||
| 570 | } | ||
| 571 | |||
| 572 | function handleImageChange() { | ||
| 573 | const imgElement = document.querySelector('img#expression-image.expression'); | ||
| 574 | |||
| 575 | if (!imgElement || !(imgElement instanceof HTMLImageElement)) { | ||
| 576 | console.log('Cannot find addExpressionImage()'); | ||
| 577 | return; | ||
| 578 | } | ||
| 579 | |||
| 580 | if (isTalkingHeadEnabled() && modules.includes('talkinghead')) { | ||
| 581 | const talkingheadResultFeedSrc = `${getApiUrl()}/api/talkinghead/result_feed`; | ||
| 582 | $('#expression-holder').css({ display: '' }); | ||
| 583 | if (imgElement.src !== talkingheadResultFeedSrc) { | ||
| 584 | const expressionImageElement = document.querySelector('.expression_list_image'); | ||
| 585 | |||
| 586 | if (expressionImageElement && expressionImageElement instanceof HTMLImageElement) { | ||
| 587 | doExtrasFetch(expressionImageElement.src, { | ||
| 588 | method: 'HEAD', | ||
| 589 | }) | ||
| 590 | .then(response => { | ||
| 591 | if (response.ok) { | ||
| 592 | imgElement.src = talkingheadResultFeedSrc; | ||
| 593 | } | ||
| 594 | }) | ||
| 595 | .catch(error => { | ||
| 596 | console.error(error); | ||
| 597 | }); | ||
| 598 | } | ||
| 599 | } | ||
| 600 | } else { | ||
| 601 | imgElement.src = ''; // remove in case char doesn't have expressions | ||
| 602 | |||
| 603 | // When switching Talkinghead off, force-set the character to the last known expression, if any. | ||
| 604 | // This preserves the same expression Talkinghead had at the moment it was switched off. | ||
| 605 | const charName = getContext().name2; | ||
| 606 | const last = lastExpression[charName]; | ||
| 607 | const targetExpression = last ? last : getFallbackExpression(); | ||
| 608 | setExpression(charName, targetExpression, true); | ||
| 609 | } | ||
| 610 | } | ||
| 611 | |||
| 612 | async function moduleWorker() { | ||
| 613 | const context = getContext(); | 471 | const context = getContext(); |
| 614 | 472 | ||
| 615 | // Hide and disable Talkinghead while not in extras | ||
| 616 | $('#image_type_block').toggle(extension_settings.expressions.api == EXPRESSION_API.extras); | ||
| 617 | |||
| 618 | if (extension_settings.expressions.api != EXPRESSION_API.extras && extension_settings.expressions.talkinghead) { | ||
| 619 | $('#image_type_toggle').prop('checked', false); | ||
| 620 | setTalkingHeadState(false); | ||
| 621 | } | ||
| 622 | |||
| 623 | // non-characters not supported | 473 | // non-characters not supported |
| 624 | if (!context.groupId && context.characterId === undefined) { | 474 | if (!context.groupId && context.characterId === undefined) { |
| 625 | removeExpression(); | 475 | removeExpression(); |
| @@ -646,7 +496,7 @@ async function moduleWorker() { | |||
| 646 | } | 496 | } |
| 647 | 497 | ||
| 648 | const currentLastMessage = getLastCharacterMessage(); | 498 | const currentLastMessage = getLastCharacterMessage(); |
| 649 | let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName(); | 499 | let spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name); |
| 650 | 500 | ||
| 651 | // character has no expressions or it is not loaded | 501 | // character has no expressions or it is not loaded |
| 652 | if (Object.keys(spriteCache).length === 0) { | 502 | if (Object.keys(spriteCache).length === 0) { |
| @@ -686,6 +536,10 @@ async function moduleWorker() { | |||
| 686 | offlineMode.css('display', 'none'); | 536 | offlineMode.css('display', 'none'); |
| 687 | } | 537 | } |
| 688 | 538 | ||
| 539 | if (context.groupId && vnMode && newChat) { | ||
| 540 | await forceUpdateVisualNovelMode(); | ||
| 541 | } | ||
| 542 | |||
| 689 | // Don't bother classifying if current char has no sprites and no default expressions are enabled | 543 | // Don't bother classifying if current char has no sprites and no default expressions are enabled |
| 690 | if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) { | 544 | if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) { |
| 691 | return; | 545 | return; |
| @@ -732,11 +586,11 @@ async function moduleWorker() { | |||
| 732 | const force = !!context.groupId; | 586 | const force = !!context.groupId; |
| 733 | 587 | ||
| 734 | // Character won't be angry on you for swiping | 588 | // Character won't be angry on you for swiping |
| 735 | if (currentLastMessage.mes == '...' && expressionsList.includes(getFallbackExpression())) { | 589 | if (currentLastMessage.mes == '...' && expressionsList.includes(extension_settings.expressions.fallback_expression)) { |
| 736 | expression = getFallbackExpression(); | 590 | expression = extension_settings.expressions.fallback_expression; |
| 737 | } | 591 | } |
| 738 | 592 | ||
| 739 | await sendExpressionCall(spriteFolderName, expression, force, vnMode); | 593 | await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode }); |
| 740 | } | 594 | } |
| 741 | catch (error) { | 595 | catch (error) { |
| 742 | console.log(error); | 596 | console.log(error); |
| @@ -749,91 +603,6 @@ async function moduleWorker() { | |||
| 749 | } | 603 | } |
| 750 | } | 604 | } |
| 751 | 605 | ||
| 752 | /** | ||
| 753 | * Starts/stops Talkinghead talking animation. | ||
| 754 | * | ||
| 755 | * Talking starts only when all the following conditions are met: | ||
| 756 | * - The LLM is currently streaming its output. | ||
| 757 | * - The AI's current last message is non-empty, and also not just '...' (as produced by a swipe). | ||
| 758 | * - The AI's current last message has changed from what we saw during the previous call. | ||
| 759 | * | ||
| 760 | * In all other cases, talking stops. | ||
| 761 | * | ||
| 762 | * A Talkinghead API call is made only when the talking state changes. | ||
| 763 | * | ||
| 764 | * Note that also the TTS system, if enabled, starts/stops the Talkinghead talking animation. | ||
| 765 | * See `talkingAnimation` in `SillyTavern/public/scripts/extensions/tts/index.js`. | ||
| 766 | */ | ||
| 767 | async function updateTalkingState() { | ||
| 768 | // Don't bother if Talkinghead is disabled or not loaded. | ||
| 769 | if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) { | ||
| 770 | return; | ||
| 771 | } | ||
| 772 | |||
| 773 | const context = getContext(); | ||
| 774 | const currentLastMessage = getLastCharacterMessage(); | ||
| 775 | |||
| 776 | try { | ||
| 777 | // TODO: Not sure if we need also "&& !context.groupId" here - the classify check in `moduleWorker` | ||
| 778 | // (that similarly checks the streaming processor state) does that for some reason. | ||
| 779 | // Talkinghead isn't currently designed to work with groups. | ||
| 780 | const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastTalkingStateMessage === currentLastMessage.mes); | ||
| 781 | const url = new URL(getApiUrl()); | ||
| 782 | let newTalkingState; | ||
| 783 | if (context.streamingProcessor && !context.streamingProcessor.isFinished && | ||
| 784 | currentLastMessage.mes.length !== 0 && currentLastMessage.mes !== '...' && lastMessageChanged) { | ||
| 785 | url.pathname = '/api/talkinghead/start_talking'; | ||
| 786 | newTalkingState = true; | ||
| 787 | } else { | ||
| 788 | url.pathname = '/api/talkinghead/stop_talking'; | ||
| 789 | newTalkingState = false; | ||
| 790 | } | ||
| 791 | try { | ||
| 792 | // Call the Talkinghead API only if the talking state changed. | ||
| 793 | if (newTalkingState !== lastTalkingState) { | ||
| 794 | console.debug(`updateTalkingState: calling ${url.pathname}`); | ||
| 795 | await doExtrasFetch(url); | ||
| 796 | } | ||
| 797 | } | ||
| 798 | catch (error) { | ||
| 799 | // it's ok if not supported | ||
| 800 | } | ||
| 801 | finally { | ||
| 802 | lastTalkingState = newTalkingState; | ||
| 803 | } | ||
| 804 | } | ||
| 805 | catch (error) { | ||
| 806 | // console.log(error); | ||
| 807 | } | ||
| 808 | finally { | ||
| 809 | lastTalkingStateMessage = currentLastMessage.mes; | ||
| 810 | } | ||
| 811 | } | ||
| 812 | |||
| 813 | /** | ||
| 814 | * Checks whether the current character has a talkinghead image available. | ||
| 815 | * @returns {Promise<boolean>} True if the character has a talkinghead image available, false otherwise. | ||
| 816 | */ | ||
| 817 | async function isTalkingHeadAvailable() { | ||
| 818 | let spriteFolderName = getSpriteFolderName(); | ||
| 819 | |||
| 820 | try { | ||
| 821 | await validateImages(spriteFolderName); | ||
| 822 | |||
| 823 | let talkingheadObj = spriteCache[spriteFolderName].find(obj => obj.label === 'talkinghead'); | ||
| 824 | let talkingheadPath = talkingheadObj ? talkingheadObj.path : null; | ||
| 825 | |||
| 826 | if (talkingheadPath != null) { | ||
| 827 | return true; | ||
| 828 | } else { | ||
| 829 | await unloadTalkingHead(); | ||
| 830 | return false; | ||
| 831 | } | ||
| 832 | } catch (err) { | ||
| 833 | return err; | ||
| 834 | } | ||
| 835 | } | ||
| 836 | |||
| 837 | function getSpriteFolderName(characterMessage = null, characterName = null) { | 606 | function getSpriteFolderName(characterMessage = null, characterName = null) { |
| 838 | const context = getContext(); | 607 | const context = getContext(); |
| 839 | let spriteFolderName = characterName ?? context.name2; | 608 | let spriteFolderName = characterName ?? context.name2; |
| @@ -848,33 +617,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) { | |||
| 848 | return spriteFolderName; | 617 | return spriteFolderName; |
| 849 | } | 618 | } |
| 850 | 619 | ||
| 851 | function setTalkingHeadState(newState) { | ||
| 852 | console.debug(`expressions: New talkinghead state: ${newState}`); | ||
| 853 | extension_settings.expressions.talkinghead = newState; // Store setting | ||
| 854 | saveSettingsDebounced(); | ||
| 855 | |||
| 856 | if ([EXPRESSION_API.local, EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) { | ||
| 857 | return; | ||
| 858 | } | ||
| 859 | |||
| 860 | isTalkingHeadAvailable().then(result => { | ||
| 861 | if (result) { | ||
| 862 | //console.log("talkinghead exists!"); | ||
| 863 | |||
| 864 | if (extension_settings.expressions.talkinghead) { | ||
| 865 | loadTalkingHead(); | ||
| 866 | } else { | ||
| 867 | unloadTalkingHead(); | ||
| 868 | } | ||
| 869 | handleImageChange(); // Change image as needed | ||
| 870 | |||
| 871 | |||
| 872 | } else { | ||
| 873 | //console.log("talkinghead does not exist."); | ||
| 874 | } | ||
| 875 | }); | ||
| 876 | } | ||
| 877 | |||
| 878 | function getFolderNameByMessage(message) { | 620 | function getFolderNameByMessage(message) { |
| 879 | const context = getContext(); | 621 | const context = getContext(); |
| 880 | let avatarPath = ''; | 622 | let avatarPath = ''; |
| @@ -894,48 +636,55 @@ function getFolderNameByMessage(message) { | |||
| 894 | return folderName; | 636 | return folderName; |
| 895 | } | 637 | } |
| 896 | 638 | ||
| 897 | async function sendExpressionCall(name, expression, force, vnMode) { | 639 | /** |
| 898 | lastExpression[name.split('/')[0]] = expression; | 640 | * Update the expression for the given character. |
| 899 | if (!vnMode) { | 641 | * |
| 642 | * @param {string} spriteFolderName The character name, optionally with a sprite folder override, e.g. "folder/expression". | ||
| 643 | * @param {string} expression The expression label, e.g. "amusement", "joy", etc. | ||
| 644 | * @param {Object} [options] Additional options | ||
| 645 | * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression. | ||
| 646 | * @param {boolean} [options.vnMode=null] If true, the expression will be sent in Visual Novel mode. If null, it will be determined by the current chat mode. | ||
| 647 | * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name. | ||
| 648 | */ | ||
| 649 | export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) { | ||
| 650 | lastExpression[spriteFolderName.split('/')[0]] = expression; | ||
| 651 | if (vnMode === null) { | ||
| 900 | vnMode = isVisualNovelMode(); | 652 | vnMode = isVisualNovelMode(); |
| 901 | } | 653 | } |
| 902 | 654 | ||
| 903 | if (vnMode) { | 655 | if (vnMode) { |
| 904 | await updateVisualNovelMode(name, expression); | 656 | await updateVisualNovelMode(spriteFolderName, expression); |
| 905 | } else { | 657 | } else { |
| 906 | setExpression(name, expression, force); | 658 | setExpression(spriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile }); |
| 907 | } | 659 | } |
| 908 | } | 660 | } |
| 909 | 661 | ||
| 910 | async function setSpriteSetCommand(_, folder) { | 662 | async function setSpriteFolderCommand(_, folder) { |
| 911 | if (!folder) { | 663 | if (!folder) { |
| 912 | console.log('Clearing sprite set'); | 664 | console.log('Clearing sprite set'); |
| 913 | folder = ''; | 665 | folder = ''; |
| 914 | } | 666 | } |
| 915 | 667 | ||
| 916 | if (folder.startsWith('/') || folder.startsWith('\\')) { | 668 | if (folder.startsWith('/') || folder.startsWith('\\')) { |
| 917 | folder = folder.slice(1); | ||
| 918 | |||
| 919 | const currentLastMessage = getLastCharacterMessage(); | 669 | const currentLastMessage = getLastCharacterMessage(); |
| 670 | folder = folder.slice(1); | ||
| 920 | folder = `${currentLastMessage.name}/${folder}`; | 671 | folder = `${currentLastMessage.name}/${folder}`; |
| 921 | } | 672 | } |
| 922 | 673 | ||
| 923 | $('#expression_override').val(folder.trim()); | 674 | $('#expression_override').val(folder.trim()); |
| 924 | onClickExpressionOverrideButton(); | 675 | onClickExpressionOverrideButton(); |
| 925 | // removeExpression(); | 676 | |
| 926 | // moduleWorker(); | 677 | // No need to resend the expression, the folder override will automatically update the currently displayed one. |
| 927 | const vnMode = isVisualNovelMode(); | ||
| 928 | await sendExpressionCall(folder, lastExpression, true, vnMode); | ||
| 929 | return ''; | 678 | return ''; |
| 930 | } | 679 | } |
| 931 | 680 | ||
| 932 | async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) { | 681 | async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) { |
| 933 | if (!text) { | 682 | if (!text) { |
| 934 | toastr.warning('No text provided'); | 683 | toastr.error('No text provided'); |
| 935 | return ''; | 684 | return ''; |
| 936 | } | 685 | } |
| 937 | if (api && !Object.keys(EXPRESSION_API).includes(api)) { | 686 | if (api && !Object.keys(EXPRESSION_API).includes(api)) { |
| 938 | toastr.warning('Invalid API provided'); | 687 | toastr.error('Invalid API provided'); |
| 939 | return ''; | 688 | return ''; |
| 940 | } | 689 | } |
| 941 | 690 | ||
| @@ -951,37 +700,69 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { | |||
| 951 | return label; | 700 | return label; |
| 952 | } | 701 | } |
| 953 | 702 | ||
| 954 | async function setSpriteSlashCommand(_, spriteId) { | 703 | /** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */ |
| 955 | if (!spriteId) { | 704 | async function setSpriteSlashCommand({ type }, searchTerm) { |
| 956 | console.log('No sprite id provided'); | 705 | type ??= 'expression'; |
| 706 | searchTerm = searchTerm.trim().toLowerCase(); | ||
| 707 | if (!searchTerm) { | ||
| 708 | toastr.error(t`No expression or sprite name provided`, t`Set Sprite`); | ||
| 957 | return ''; | 709 | return ''; |
| 958 | } | 710 | } |
| 959 | 711 | ||
| 960 | spriteId = spriteId.trim().toLowerCase(); | 712 | const currentLastMessage = selected_group ? getLastCharacterMessage() : null; |
| 713 | const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); | ||
| 714 | |||
| 715 | let label = searchTerm; | ||
| 716 | |||
| 717 | /** @type {string?} */ | ||
| 718 | let spriteFile = null; | ||
| 961 | 719 | ||
| 962 | // In Talkinghead mode, don't check for the existence of the sprite | ||
| 963 | // (emotion names are the same as for sprites, but it only needs "talkinghead.png"). | ||
| 964 | const currentLastMessage = getLastCharacterMessage(); | ||
| 965 | const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name); | ||
| 966 | let label = spriteId; | ||
| 967 | if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) { | ||
| 968 | await validateImages(spriteFolderName); | 720 | await validateImages(spriteFolderName); |
| 969 | 721 | ||
| 970 | // Fuzzy search for sprite | 722 | // Handle reset as a special term and just reset the sprite via expression call |
| 971 | const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] }); | 723 | if (searchTerm === RESET_SPRITE_LABEL) { |
| 972 | const results = fuse.search(spriteId); | 724 | await sendExpressionCall(spriteFolderName, label, { force: true }); |
| 973 | const spriteItem = results[0]?.item; | 725 | return lastExpression[spriteFolderName] ?? ''; |
| 726 | } | ||
| 727 | |||
| 728 | switch (type) { | ||
| 729 | case 'expression': { | ||
| 730 | // Fuzzy search for expression | ||
| 731 | const existingExpressions = getCachedExpressions().map(x => ({ label: x })); | ||
| 732 | const results = performFuzzySearch('expression-expressions', existingExpressions, [ | ||
| 733 | { name: 'label', weight: 1 }, | ||
| 734 | ], searchTerm); | ||
| 735 | const matchedExpression = results[0]?.item; | ||
| 736 | if (!matchedExpression) { | ||
| 737 | toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`); | ||
| 738 | return ''; | ||
| 739 | } | ||
| 974 | 740 | ||
| 975 | if (!spriteItem) { | 741 | label = matchedExpression.label; |
| 976 | console.log('No sprite found for search term ' + spriteId); | 742 | break; |
| 743 | } | ||
| 744 | case 'sprite': { | ||
| 745 | // Fuzzy search for sprite file | ||
| 746 | const sprites = spriteCache[spriteFolderName].map(x => x.files).flat(); | ||
| 747 | const results = performFuzzySearch('expression-expressions', sprites, [ | ||
| 748 | { name: 'title', weight: 1 }, | ||
| 749 | { name: 'fileName', weight: 1 }, | ||
| 750 | ], searchTerm); | ||
| 751 | const matchedSprite = results[0]?.item; | ||
| 752 | if (!matchedSprite) { | ||
| 753 | toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`); | ||
| 977 | return ''; | 754 | return ''; |
| 978 | } | 755 | } |
| 979 | 756 | ||
| 980 | label = spriteItem.label; | 757 | label = matchedSprite.expression; |
| 758 | spriteFile = matchedSprite.fileName; | ||
| 759 | break; | ||
| 760 | } | ||
| 761 | default: throw Error('Invalid sprite set type: ' + type); | ||
| 981 | } | 762 | } |
| 982 | 763 | ||
| 983 | const vnMode = isVisualNovelMode(); | 764 | await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile }); |
| 984 | await sendExpressionCall(spriteFolderName, label, true, vnMode); | 765 | |
| 985 | return label; | 766 | return label; |
| 986 | } | 767 | } |
| 987 | 768 | ||
| @@ -999,6 +780,21 @@ function spriteFolderNameFromCharacter(char) { | |||
| 999 | } | 780 | } |
| 1000 | 781 | ||
| 1001 | /** | 782 | /** |
| 783 | * Generates a unique sprite name by appending an index to the given expression. * | ||
| 784 | * @param {string} expression - The base expression to be used as the prefix for the sprite name. | ||
| 785 | * @param {ExpressionImage[]} existingFiles - An array of existing file objects, each containing a fileName property. | ||
| 786 | * @returns {string} - A unique sprite name with the format "expression-index". | ||
| 787 | */ | ||
| 788 | function generateUniqueSpriteName(expression, existingFiles) { | ||
| 789 | let index = existingFiles.length; | ||
| 790 | let newSpriteName; | ||
| 791 | do { | ||
| 792 | newSpriteName = `${expression}-${index++}`; | ||
| 793 | } while (existingFiles.some(file => withoutExtension(file.fileName) === newSpriteName)); | ||
| 794 | return newSpriteName; | ||
| 795 | } | ||
| 796 | |||
| 797 | /** | ||
| 1002 | * Slash command callback for /uploadsprite | 798 | * Slash command callback for /uploadsprite |
| 1003 | * | 799 | * |
| 1004 | * label= is required | 800 | * label= is required |
| @@ -1011,16 +807,29 @@ function spriteFolderNameFromCharacter(char) { | |||
| 1011 | * @param {object} args | 807 | * @param {object} args |
| 1012 | * @param {string} args.name Character name or avatar key, passed through findChar | 808 | * @param {string} args.name Character name or avatar key, passed through findChar |
| 1013 | * @param {string} args.label Expression label | 809 | * @param {string} args.label Expression label |
| 1014 | * @param {string} args.folder Sprite folder path, processed using backslash rules | 810 | * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules |
| 811 | * @param {string?} [args.spriteName=null] Optional sprite name | ||
| 1015 | * @param {string} imageUrl Image URI to fetch and upload | 812 | * @param {string} imageUrl Image URI to fetch and upload |
| 1016 | * @returns {Promise<void>} | 813 | * @returns {Promise<string>} the sprite name |
| 1017 | */ | 814 | */ |
| 1018 | async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | 815 | async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) { |
| 1019 | if (!imageUrl) throw new Error('Image URL is required'); | 816 | if (!imageUrl) throw new Error('Image URL is required'); |
| 1020 | if (!label || typeof label !== 'string') throw new Error('Expression label is required'); | 817 | if (!label || typeof label !== 'string') { |
| 818 | toastr.error(t`Expression label is required`, t`Error Uploading Sprite`); | ||
| 819 | return ''; | ||
| 820 | } | ||
| 1021 | 821 | ||
| 1022 | label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); | 822 | label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); |
| 1023 | if (!label) throw new Error('Expression label must contain at least one letter'); | 823 | if (!label) { |
| 824 | toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`); | ||
| 825 | return ''; | ||
| 826 | } | ||
| 827 | |||
| 828 | spriteName = spriteName || label; | ||
| 829 | if (!validateExpressionSpriteName(label, spriteName)) { | ||
| 830 | toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`); | ||
| 831 | return ''; | ||
| 832 | } | ||
| 1024 | 833 | ||
| 1025 | name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; | 834 | name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; |
| 1026 | const char = findChar({ name }); | 835 | const char = findChar({ name }); |
| @@ -1041,6 +850,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | |||
| 1041 | formData.append('name', folder); // this is the folder or character name | 850 | formData.append('name', folder); // this is the folder or character name |
| 1042 | formData.append('label', label); // this is the expression label | 851 | formData.append('label', label); // this is the expression label |
| 1043 | formData.append('avatar', file); // this is the image file | 852 | formData.append('avatar', file); // this is the image file |
| 853 | formData.append('spriteName', spriteName); // this is a redundant comment | ||
| 1044 | 854 | ||
| 1045 | await handleFileUpload('/api/sprites/upload', formData); | 855 | await handleFileUpload('/api/sprites/upload', formData); |
| 1046 | console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); | 856 | console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); |
| @@ -1048,6 +858,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | |||
| 1048 | console.error(`[${MODULE_NAME}] Error uploading file:`, error); | 858 | console.error(`[${MODULE_NAME}] Error uploading file:`, error); |
| 1049 | throw error; | 859 | throw error; |
| 1050 | } | 860 | } |
| 861 | |||
| 862 | return spriteName; | ||
| 1051 | } | 863 | } |
| 1052 | 864 | ||
| 1053 | /** | 865 | /** |
| @@ -1159,7 +971,7 @@ function getJsonSchema(emotions) { | |||
| 1159 | function onTextGenSettingsReady(args) { | 971 | function onTextGenSettingsReady(args) { |
| 1160 | // Only call if inside an API call | 972 | // Only call if inside an API call |
| 1161 | if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) { | 973 | if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) { |
| 1162 | const emotions = DEFAULT_EXPRESSIONS.filter((e) => e != 'talkinghead'); | 974 | const emotions = DEFAULT_EXPRESSIONS; |
| 1163 | Object.assign(args, { | 975 | Object.assign(args, { |
| 1164 | top_k: 1, | 976 | top_k: 1, |
| 1165 | stop: [], | 977 | stop: [], |
| @@ -1177,16 +989,16 @@ function onTextGenSettingsReady(args) { | |||
| 1177 | * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification. | 989 | * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification. |
| 1178 | * @param {object} [options={}] - Optional arguments. | 990 | * @param {object} [options={}] - Optional arguments. |
| 1179 | * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification. | 991 | * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification. |
| 1180 | * @returns {Promise<string>} - The label of the expression. | 992 | * @returns {Promise<string?>} - The label of the expression. |
| 1181 | */ | 993 | */ |
| 1182 | export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) { | 994 | export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) { |
| 1183 | // Return if text is undefined, saving a costly fetch request | 995 | // Return if text is undefined, saving a costly fetch request |
| 1184 | if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) { | 996 | if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) { |
| 1185 | return getFallbackExpression(); | 997 | return extension_settings.expressions.fallback_expression; |
| 1186 | } | 998 | } |
| 1187 | 999 | ||
| 1188 | if (extension_settings.expressions.translate && typeof window['translate'] === 'function') { | 1000 | if (extension_settings.expressions.translate && typeof globalThis.translate === 'function') { |
| 1189 | text = await window['translate'](text, 'en'); | 1001 | text = await globalThis.translate(text, 'en'); |
| 1190 | } | 1002 | } |
| 1191 | 1003 | ||
| 1192 | text = sampleClassifyText(text); | 1004 | text = sampleClassifyText(text); |
| @@ -1212,7 +1024,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | |||
| 1212 | await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250); | 1024 | await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250); |
| 1213 | } catch (error) { | 1025 | } catch (error) { |
| 1214 | console.warn('No LLM connection. Using fallback expression', error); | 1026 | console.warn('No LLM connection. Using fallback expression', error); |
| 1215 | return getFallbackExpression(); | 1027 | return extension_settings.expressions.fallback_expression; |
| 1216 | } | 1028 | } |
| 1217 | 1029 | ||
| 1218 | const expressionsList = await getExpressionsList(); | 1030 | const expressionsList = await getExpressionsList(); |
| @@ -1225,7 +1037,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | |||
| 1225 | case EXPRESSION_API.webllm: { | 1037 | case EXPRESSION_API.webllm: { |
| 1226 | if (!isWebLlmSupported()) { | 1038 | if (!isWebLlmSupported()) { |
| 1227 | console.warn('WebLLM is not supported. Using fallback expression'); | 1039 | console.warn('WebLLM is not supported. Using fallback expression'); |
| 1228 | return getFallbackExpression(); | 1040 | return extension_settings.expressions.fallback_expression; |
| 1229 | } | 1041 | } |
| 1230 | 1042 | ||
| 1231 | const expressionsList = await getExpressionsList(); | 1043 | const expressionsList = await getExpressionsList(); |
| @@ -1258,9 +1070,9 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | |||
| 1258 | } break; | 1070 | } break; |
| 1259 | } | 1071 | } |
| 1260 | } catch (error) { | 1072 | } catch (error) { |
| 1261 | toastr.info('Could not classify expression. Check the console or your backend for more information.'); | 1073 | toastr.error('Could not classify expression. Check the console or your backend for more information.'); |
| 1262 | console.error(error); | 1074 | console.error(error); |
| 1263 | return getFallbackExpression(); | 1075 | return extension_settings.expressions.fallback_expression; |
| 1264 | } | 1076 | } |
| 1265 | } | 1077 | } |
| 1266 | 1078 | ||
| @@ -1288,75 +1100,155 @@ function removeExpression() { | |||
| 1288 | $('#no_chat_expressions').show(); | 1100 | $('#no_chat_expressions').show(); |
| 1289 | } | 1101 | } |
| 1290 | 1102 | ||
| 1291 | async function validateImages(character, forceRedrawCached) { | 1103 | /** |
| 1292 | if (!character) { | 1104 | * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw. |
| 1105 | * @param {string} spriteFolderName - The character sprite folder to validate | ||
| 1106 | * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before | ||
| 1107 | */ | ||
| 1108 | async function validateImages(spriteFolderName, forceRedrawCached = false) { | ||
| 1109 | if (!spriteFolderName) { | ||
| 1293 | return; | 1110 | return; |
| 1294 | } | 1111 | } |
| 1295 | 1112 | ||
| 1296 | const labels = await getExpressionsList(); | 1113 | const labels = await getExpressionsList(); |
| 1297 | 1114 | ||
| 1298 | if (spriteCache[character]) { | 1115 | if (spriteCache[spriteFolderName]) { |
| 1299 | if (forceRedrawCached && $('#image_list').data('name') !== character) { | 1116 | if (forceRedrawCached && $('#image_list').data('name') !== spriteFolderName) { |
| 1300 | console.debug('force redrawing character sprites list'); | 1117 | console.debug('force redrawing character sprites list'); |
| 1301 | await drawSpritesList(character, labels, spriteCache[character]); | 1118 | await drawSpritesList(spriteFolderName, labels, spriteCache[spriteFolderName]); |
| 1302 | } | 1119 | } |
| 1303 | 1120 | ||
| 1304 | return; | 1121 | return; |
| 1305 | } | 1122 | } |
| 1306 | 1123 | ||
| 1307 | const sprites = await getSpritesList(character); | 1124 | const sprites = await getSpritesList(spriteFolderName); |
| 1308 | let validExpressions = await drawSpritesList(character, labels, sprites); | 1125 | let validExpressions = await drawSpritesList(spriteFolderName, labels, sprites); |
| 1309 | spriteCache[character] = validExpressions; | 1126 | spriteCache[spriteFolderName] = validExpressions; |
| 1127 | } | ||
| 1128 | |||
| 1129 | /** | ||
| 1130 | * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting | ||
| 1131 | * @param {{ path: string, label: string }} sprite | ||
| 1132 | * @returns {ExpressionImage} | ||
| 1133 | */ | ||
| 1134 | function getExpressionImageData(sprite) { | ||
| 1135 | const fileName = sprite.path.split('/').pop().split('?')[0]; | ||
| 1136 | const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, ''); | ||
| 1137 | return { | ||
| 1138 | expression: sprite.label, | ||
| 1139 | fileName: fileName, | ||
| 1140 | title: fileNameWithoutExtension, | ||
| 1141 | imageSrc: sprite.path, | ||
| 1142 | type: 'success', | ||
| 1143 | isCustom: extension_settings.expressions.custom?.includes(sprite.label), | ||
| 1144 | }; | ||
| 1310 | } | 1145 | } |
| 1311 | 1146 | ||
| 1312 | async function drawSpritesList(character, labels, sprites) { | 1147 | /** |
| 1148 | * Populate the character expression list with sprites for the given character. | ||
| 1149 | * @param {string} spriteFolderName - The name of the character to populate the list for | ||
| 1150 | * @param {string[]} labels - An array of expression labels that are valid | ||
| 1151 | * @param {Expression[]} sprites - An array of sprites | ||
| 1152 | * @returns {Promise<Expression[]>} An array of valid expression labels | ||
| 1153 | */ | ||
| 1154 | async function drawSpritesList(spriteFolderName, labels, sprites) { | ||
| 1155 | /** @type {Expression[]} */ | ||
| 1313 | let validExpressions = []; | 1156 | let validExpressions = []; |
| 1157 | |||
| 1314 | $('#no_chat_expressions').hide(); | 1158 | $('#no_chat_expressions').hide(); |
| 1315 | $('#open_chat_expressions').show(); | 1159 | $('#open_chat_expressions').show(); |
| 1316 | $('#image_list').empty(); | 1160 | $('#image_list').empty(); |
| 1317 | $('#image_list').data('name', character); | 1161 | $('#image_list').data('name', spriteFolderName); |
| 1318 | $('#image_list_header_name').text(character); | 1162 | $('#image_list_header_name').text(spriteFolderName); |
| 1319 | 1163 | ||
| 1320 | if (!Array.isArray(labels)) { | 1164 | if (!Array.isArray(labels)) { |
| 1321 | return []; | 1165 | return []; |
| 1322 | } | 1166 | } |
| 1323 | 1167 | ||
| 1324 | for (const item of labels.sort()) { | 1168 | for (const expression of labels.sort()) { |
| 1325 | const sprite = sprites.find(x => x.label == item); | 1169 | const isCustom = extension_settings.expressions.custom?.includes(expression); |
| 1326 | const isCustom = extension_settings.expressions.custom.includes(item); | 1170 | const images = sprites |
| 1327 | 1171 | .filter(s => s.label === expression) | |
| 1328 | if (sprite) { | 1172 | .map(s => s.files) |
| 1329 | validExpressions.push(sprite); | 1173 | .flat(); |
| 1330 | const listItem = await getListItem(item, sprite.path, 'success', isCustom); | 1174 | |
| 1175 | if (images.length === 0) { | ||
| 1176 | const listItem = await getListItem(expression, { | ||
| 1177 | isCustom, | ||
| 1178 | images: [getPlaceholderImage(expression, isCustom)], | ||
| 1179 | }); | ||
| 1331 | $('#image_list').append(listItem); | 1180 | $('#image_list').append(listItem); |
| 1181 | continue; | ||
| 1332 | } | 1182 | } |
| 1333 | else { | 1183 | |
| 1334 | const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom); | 1184 | validExpressions.push({ label: expression, files: images }); |
| 1185 | |||
| 1186 | // Render main = first file, additional = rest | ||
| 1187 | let listItem = await getListItem(expression, { | ||
| 1188 | isCustom, | ||
| 1189 | images, | ||
| 1190 | }); | ||
| 1335 | $('#image_list').append(listItem); | 1191 | $('#image_list').append(listItem); |
| 1336 | } | 1192 | } |
| 1337 | } | ||
| 1338 | return validExpressions; | 1193 | return validExpressions; |
| 1339 | } | 1194 | } |
| 1340 | 1195 | ||
| 1341 | /** | 1196 | /** |
| 1342 | * Renders a list item template for the expressions list. | 1197 | * Renders a list item template for the expressions list. |
| 1343 | * @param {string} item Expression name | 1198 | * @param {string} expression Expression name |
| 1344 | * @param {string} imageSrc Path to image | 1199 | * @param {object} args Arguments object |
| 1345 | * @param {'success' | 'failure'} textClass 'success' or 'failure' | 1200 | * @param {ExpressionImage[]} [args.images] Array of image objects |
| 1346 | * @param {boolean} isCustom If expression is added by user | 1201 | * @param {boolean} [args.isCustom=false] If expression is added by user |
| 1347 | * @returns {Promise<string>} Rendered list item template | 1202 | * @returns {Promise<string>} Rendered list item template |
| 1348 | */ | 1203 | */ |
| 1349 | async function getListItem(item, imageSrc, textClass, isCustom) { | 1204 | async function getListItem(expression, { images, isCustom = false } = {}) { |
| 1350 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { item, imageSrc, textClass, isCustom }); | 1205 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false }); |
| 1351 | } | 1206 | } |
| 1352 | 1207 | ||
| 1208 | /** | ||
| 1209 | * Fetches and processes the list of sprites for a given character name. | ||
| 1210 | * Retrieves sprite data from the server and organizes it into labeled groups. | ||
| 1211 | * | ||
| 1212 | * @param {string} name - The character name to fetch sprites for | ||
| 1213 | * @returns {Promise<Expression[]>} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data | ||
| 1214 | */ | ||
| 1215 | |||
| 1353 | async function getSpritesList(name) { | 1216 | async function getSpritesList(name) { |
| 1354 | console.debug('getting sprites list'); | 1217 | console.debug('getting sprites list'); |
| 1355 | 1218 | ||
| 1356 | try { | 1219 | try { |
| 1357 | const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`); | 1220 | const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`); |
| 1221 | /** @type {{ label: string, path: string }[]} */ | ||
| 1358 | let sprites = result.ok ? (await result.json()) : []; | 1222 | let sprites = result.ok ? (await result.json()) : []; |
| 1359 | return sprites; | 1223 | |
| 1224 | /** @type {Expression[]} */ | ||
| 1225 | const grouped = sprites.reduce((acc, sprite) => { | ||
| 1226 | const imageData = getExpressionImageData(sprite); | ||
| 1227 | let existingExpression = acc.find(exp => exp.label === sprite.label); | ||
| 1228 | if (existingExpression) { | ||
| 1229 | existingExpression.files.push(imageData); | ||
| 1230 | } else { | ||
| 1231 | acc.push({ label: sprite.label, files: [imageData] }); | ||
| 1232 | } | ||
| 1233 | |||
| 1234 | return acc; | ||
| 1235 | }, []); | ||
| 1236 | |||
| 1237 | // Sort the sprites for each expression alphabetically, but keep the main expression file at the front | ||
| 1238 | for (const expression of grouped) { | ||
| 1239 | expression.files.sort((a, b) => { | ||
| 1240 | if (a.title === expression.label) return -1; | ||
| 1241 | if (b.title === expression.label) return 1; | ||
| 1242 | return a.title.localeCompare(b.title); | ||
| 1243 | }); | ||
| 1244 | |||
| 1245 | // Mark all besides the first sprite as 'additional' | ||
| 1246 | for (let i = 1; i < expression.files.length; i++) { | ||
| 1247 | expression.files[i].type = 'additional'; | ||
| 1248 | } | ||
| 1249 | } | ||
| 1250 | |||
| 1251 | return grouped; | ||
| 1360 | } | 1252 | } |
| 1361 | catch (err) { | 1253 | catch (err) { |
| 1362 | console.log(err); | 1254 | console.log(err); |
| @@ -1395,17 +1287,31 @@ async function renderFallbackExpressionPicker() { | |||
| 1395 | const defaultPicker = $('#expression_fallback'); | 1287 | const defaultPicker = $('#expression_fallback'); |
| 1396 | defaultPicker.empty(); | 1288 | defaultPicker.empty(); |
| 1397 | 1289 | ||
| 1398 | const fallbackExpression = getFallbackExpression(); | 1290 | |
| 1291 | addOption(OPTION_NO_FALLBACK, '[ No fallback ]', !extension_settings.expressions.fallback_expression); | ||
| 1292 | addOption(OPTION_EMOJI_FALLBACK, '[ Default emojis ]', !!extension_settings.expressions.showDefault); | ||
| 1399 | 1293 | ||
| 1400 | for (const expression of expressions) { | 1294 | for (const expression of expressions) { |
| 1295 | addOption(expression, expression, expression == extension_settings.expressions.fallback_expression); | ||
| 1296 | } | ||
| 1297 | |||
| 1298 | /** @type {(value: string, label: string, isSelected: boolean) => void} */ | ||
| 1299 | function addOption(value, label, isSelected) { | ||
| 1401 | const option = document.createElement('option'); | 1300 | const option = document.createElement('option'); |
| 1402 | option.value = expression; | 1301 | option.value = value; |
| 1403 | option.text = expression; | 1302 | option.text = label; |
| 1404 | option.selected = expression == fallbackExpression; | 1303 | option.selected = isSelected; |
| 1405 | defaultPicker.append(option); | 1304 | defaultPicker.append(option); |
| 1406 | } | 1305 | } |
| 1407 | } | 1306 | } |
| 1408 | 1307 | ||
| 1308 | /** | ||
| 1309 | * Retrieves a unique list of cached expressions. | ||
| 1310 | * Combines the default expressions list with custom user-defined expressions. | ||
| 1311 | * | ||
| 1312 | * @returns {string[]} An array of unique expression labels | ||
| 1313 | */ | ||
| 1314 | |||
| 1409 | function getCachedExpressions() { | 1315 | function getCachedExpressions() { |
| 1410 | if (!Array.isArray(expressionsList)) { | 1316 | if (!Array.isArray(expressionsList)) { |
| 1411 | return []; | 1317 | return []; |
| @@ -1463,7 +1369,7 @@ export async function getExpressionsList() { | |||
| 1463 | } | 1369 | } |
| 1464 | 1370 | ||
| 1465 | // If there was no specific list, or an error, just return the default expressions | 1371 | // If there was no specific list, or an error, just return the default expressions |
| 1466 | expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice(); | 1372 | expressionsList = DEFAULT_EXPRESSIONS.slice(); |
| 1467 | return expressionsList; | 1373 | return expressionsList; |
| 1468 | } | 1374 | } |
| 1469 | 1375 | ||
| @@ -1471,38 +1377,88 @@ export async function getExpressionsList() { | |||
| 1471 | return [...result, ...extension_settings.expressions.custom].filter(onlyUnique); | 1377 | return [...result, ...extension_settings.expressions.custom].filter(onlyUnique); |
| 1472 | } | 1378 | } |
| 1473 | 1379 | ||
| 1474 | async function setExpression(character, expression, force) { | 1380 | /** |
| 1475 | if (!isTalkingHeadEnabled() || !modules.includes('talkinghead')) { | 1381 | * Selects a sprite from the given sprite folder for the given expression. |
| 1476 | console.debug('entered setExpressions'); | 1382 | * |
| 1477 | await validateImages(character); | 1383 | * If multiple sprites are allowed for the expression, it will randomly select one. |
| 1384 | * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same. | ||
| 1385 | * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one. | ||
| 1386 | * | ||
| 1387 | * @param {string} spriteFolderName - The name of the sprite folder | ||
| 1388 | * @param {string} expression - The expression to find the sprite for | ||
| 1389 | * @param {object} [options] - Options to select the sprite | ||
| 1390 | * @param {string} [options.prevExpressionSrc=null] - The source of the previous expression | ||
| 1391 | * @param {string} [options.overrideSpriteFile=null] - The file name of the sprite to select | ||
| 1392 | * @returns {ExpressionImage?} - The selected sprite | ||
| 1393 | */ | ||
| 1394 | function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) { | ||
| 1395 | if (!spriteCache[spriteFolderName]) return null; | ||
| 1396 | if (expression === RESET_SPRITE_LABEL) return null; | ||
| 1397 | |||
| 1398 | // Search for sprites of that expression - or fallback expression sprites if enabled | ||
| 1399 | let sprite = spriteCache[spriteFolderName].find(x => x.label === expression); | ||
| 1400 | if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) { | ||
| 1401 | sprite = spriteCache[spriteFolderName].find(x => x.label === extension_settings.expressions.fallback_expression); | ||
| 1402 | console.debug('Expression', expression, 'not found. Using fallback expression', extension_settings.expressions.fallback_expression); | ||
| 1403 | } | ||
| 1404 | if (!(sprite?.files.length > 0)) return null; | ||
| 1405 | |||
| 1406 | let spriteFile = sprite.files[0]; | ||
| 1407 | |||
| 1408 | // If a specific sprite file should be set, we are looking it up here | ||
| 1409 | if (overrideSpriteFile) { | ||
| 1410 | const searched = sprite.files.find(x => x.fileName === overrideSpriteFile); | ||
| 1411 | if (searched) spriteFile = searched; | ||
| 1412 | else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`); | ||
| 1413 | } | ||
| 1414 | // Else calculate next expression, if multiple are allowed | ||
| 1415 | else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) { | ||
| 1416 | let possibleFiles = sprite.files; | ||
| 1417 | if (extension_settings.expressions.rerollIfSame) { | ||
| 1418 | possibleFiles = possibleFiles.filter(x => !prevExpressionSrc || x.imageSrc !== prevExpressionSrc); | ||
| 1419 | } | ||
| 1420 | spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)]; | ||
| 1421 | } | ||
| 1422 | |||
| 1423 | return spriteFile; | ||
| 1424 | |||
| 1425 | } | ||
| 1426 | |||
| 1427 | /** | ||
| 1428 | * Set the expression of a character. | ||
| 1429 | * @param {string} spriteFolderName - The name of the character (folder name - can also be a costume override) | ||
| 1430 | * @param {string} expression - The expression or sprite name to set | ||
| 1431 | * @param {Object} options - Optional parameters | ||
| 1432 | * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on | ||
| 1433 | * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name. | ||
| 1434 | * @returns {Promise<void>} A promise that resolves when the expression has been set. | ||
| 1435 | */ | ||
| 1436 | async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) { | ||
| 1437 | await validateImages(spriteFolderName); | ||
| 1478 | const img = $('img.expression'); | 1438 | const img = $('img.expression'); |
| 1479 | const prevExpressionSrc = img.attr('src'); | 1439 | const prevExpressionSrc = img.attr('src'); |
| 1480 | const expressionClone = img.clone(); | 1440 | const expressionClone = img.clone(); |
| 1481 | 1441 | ||
| 1482 | const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression)); | 1442 | const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile }); |
| 1483 | console.debug('checking for expression images to show..'); | 1443 | if (spriteFile) { |
| 1484 | if (sprite) { | ||
| 1485 | console.debug('setting expression from character images folder'); | ||
| 1486 | |||
| 1487 | if (force && isVisualNovelMode()) { | 1444 | if (force && isVisualNovelMode()) { |
| 1488 | const context = getContext(); | 1445 | const context = getContext(); |
| 1489 | const group = context.groups.find(x => x.id === context.groupId); | 1446 | const group = context.groups.find(x => x.id === context.groupId); |
| 1490 | 1447 | ||
| 1491 | for (const member of group.members) { | 1448 | // If it's a folder, make sure we find the group member based on the actual name |
| 1492 | const groupMember = context.characters.find(x => x.avatar === member); | 1449 | const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName; |
| 1493 | |||
| 1494 | if (!groupMember) { | ||
| 1495 | continue; | ||
| 1496 | } | ||
| 1497 | 1450 | ||
| 1498 | if (groupMember.name == character) { | 1451 | const groupMember = group.members |
| 1499 | await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path); | 1452 | .map(member => context.characters.find(x => x.avatar === member)) |
| 1453 | .find(groupMember => groupMember && groupMember.name === memberName); | ||
| 1454 | if (groupMember) { | ||
| 1455 | await setImage($(`.expression-holder[data-avatar="${groupMember.avatar}"] img`), spriteFile.imageSrc); | ||
| 1500 | return; | 1456 | return; |
| 1501 | } | 1457 | } |
| 1502 | } | 1458 | } |
| 1503 | } | 1459 | |
| 1504 | //only swap expressions when necessary | 1460 | //only swap expressions when necessary |
| 1505 | if (prevExpressionSrc !== sprite.path | 1461 | if (prevExpressionSrc !== spriteFile.imageSrc |
| 1506 | && !img.hasClass('expression-animating')) { | 1462 | && !img.hasClass('expression-animating')) { |
| 1507 | //clone expression | 1463 | //clone expression |
| 1508 | expressionClone.addClass('expression-clone'); | 1464 | expressionClone.addClass('expression-clone'); |
| @@ -1510,7 +1466,12 @@ async function setExpression(character, expression, force) { | |||
| 1510 | //must be made invisible to start because they share the same Z-index | 1466 | //must be made invisible to start because they share the same Z-index |
| 1511 | expressionClone.attr('id', '').css({ opacity: 0 }); | 1467 | expressionClone.attr('id', '').css({ opacity: 0 }); |
| 1512 | //add new sprite path to clone src | 1468 | //add new sprite path to clone src |
| 1513 | expressionClone.attr('src', sprite.path); | 1469 | expressionClone.attr('src', spriteFile.imageSrc); |
| 1470 | //set relevant data tags | ||
| 1471 | expressionClone.attr('data-sprite-folder-name', spriteFolderName); | ||
| 1472 | expressionClone.attr('data-expression', expression); | ||
| 1473 | expressionClone.attr('data-sprite-filename', spriteFile.fileName); | ||
| 1474 | expressionClone.attr('title', expression); | ||
| 1514 | //add invisible clone to html | 1475 | //add invisible clone to html |
| 1515 | expressionClone.appendTo($('#expression-holder')); | 1476 | expressionClone.appendTo($('#expression-holder')); |
| 1516 | 1477 | ||
| @@ -1552,80 +1513,85 @@ async function setExpression(character, expression, force) { | |||
| 1552 | expressionHolder.css('min-height', 100); | 1513 | expressionHolder.css('min-height', 100); |
| 1553 | }); | 1514 | }); |
| 1554 | 1515 | ||
| 1555 | |||
| 1556 | expressionClone.removeClass('expression-clone'); | 1516 | expressionClone.removeClass('expression-clone'); |
| 1557 | 1517 | ||
| 1558 | expressionClone.removeClass('default'); | 1518 | expressionClone.removeClass('default'); |
| 1559 | expressionClone.off('error'); | 1519 | expressionClone.off('error'); |
| 1560 | expressionClone.on('error', function () { | 1520 | expressionClone.on('error', function (error) { |
| 1561 | console.debug('Expression image error', sprite.path); | 1521 | console.debug('Expression image error', spriteFile.imageSrc, error); |
| 1562 | $(this).attr('src', ''); | 1522 | $(this).attr('src', ''); |
| 1563 | $(this).off('error'); | 1523 | $(this).off('error'); |
| 1564 | if (force && extension_settings.expressions.showDefault) { | 1524 | if (force && extension_settings.expressions.showDefault) { |
| 1565 | setDefault(); | 1525 | setDefaultEmojiForImage(img, expression); |
| 1566 | } | 1526 | } |
| 1567 | }); | 1527 | }); |
| 1568 | } | 1528 | } |
| 1529 | |||
| 1530 | console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName }); | ||
| 1569 | } | 1531 | } |
| 1570 | else { | 1532 | else { |
| 1571 | if (extension_settings.expressions.showDefault) { | 1533 | img.attr('data-sprite-folder-name', spriteFolderName); |
| 1572 | setDefault(); | ||
| 1573 | } | ||
| 1574 | } | ||
| 1575 | 1534 | ||
| 1576 | function setDefault() { | 1535 | img.off('error'); |
| 1577 | console.debug('setting default'); | ||
| 1578 | const defImgUrl = `/img/default-expressions/${expression}.png`; | ||
| 1579 | //console.log(defImgUrl); | ||
| 1580 | img.attr('src', defImgUrl); | ||
| 1581 | img.addClass('default'); | ||
| 1582 | } | ||
| 1583 | document.getElementById('expression-holder').style.display = ''; | ||
| 1584 | 1536 | ||
| 1537 | if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) { | ||
| 1538 | setDefaultEmojiForImage(img, expression); | ||
| 1585 | } else { | 1539 | } else { |
| 1586 | // Set the Talkinghead emotion to the specified expression | 1540 | setNoneForImage(img, expression); |
| 1587 | // TODO: For now, Talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode. | ||
| 1588 | try { | ||
| 1589 | let result = await isTalkingHeadAvailable(); | ||
| 1590 | if (result) { | ||
| 1591 | const url = new URL(getApiUrl()); | ||
| 1592 | url.pathname = '/api/talkinghead/set_emotion'; | ||
| 1593 | await doExtrasFetch(url, { | ||
| 1594 | method: 'POST', | ||
| 1595 | headers: { | ||
| 1596 | 'Content-Type': 'application/json', | ||
| 1597 | }, | ||
| 1598 | body: JSON.stringify({ emotion_name: expression }), | ||
| 1599 | }); | ||
| 1600 | } | ||
| 1601 | } | 1541 | } |
| 1602 | catch (error) { | 1542 | console.debug('Expression unset - No sprite found', { expression: expression }); |
| 1603 | // `set_emotion` is not present in old versions, so let it 404. | ||
| 1604 | } | 1543 | } |
| 1605 | 1544 | ||
| 1606 | try { | 1545 | document.getElementById('expression-holder').style.display = ''; |
| 1607 | // Find the <img> element with id="expression-image" and class="expression" | ||
| 1608 | const imgElement = document.querySelector('img#expression-image.expression'); | ||
| 1609 | //console.log("searching"); | ||
| 1610 | if (imgElement && imgElement instanceof HTMLImageElement) { | ||
| 1611 | //console.log("setting value"); | ||
| 1612 | imgElement.src = getApiUrl() + '/api/talkinghead/result_feed'; | ||
| 1613 | } | ||
| 1614 | } | 1546 | } |
| 1615 | catch (error) { | 1547 | |
| 1616 | //console.log("The fetch failed!"); | 1548 | /** |
| 1549 | * Sets the default expression image for the given image element and expression | ||
| 1550 | * @param {JQuery<HTMLElement>} img - The image element to set the default expression for | ||
| 1551 | * @param {string} expression - The expression label to use for the default image | ||
| 1552 | */ | ||
| 1553 | function setDefaultEmojiForImage(img, expression) { | ||
| 1554 | if (extension_settings.expressions.custom?.includes(expression)) { | ||
| 1555 | console.debug(`Can't set default emoji for a custom expression (${expression}). setting to ${DEFAULT_FALLBACK_EXPRESSION} instead.`); | ||
| 1556 | expression = DEFAULT_FALLBACK_EXPRESSION; | ||
| 1617 | } | 1557 | } |
| 1558 | |||
| 1559 | const defImgUrl = `/img/default-expressions/${expression}.png`; | ||
| 1560 | img.attr('src', defImgUrl); | ||
| 1561 | img.attr('data-expression', expression); | ||
| 1562 | img.attr('data-sprite-filename', null); | ||
| 1563 | img.attr('title', expression); | ||
| 1564 | img.addClass('default'); | ||
| 1618 | } | 1565 | } |
| 1566 | |||
| 1567 | /** | ||
| 1568 | * Sets the image element to display no expression by clearing its source attribute. | ||
| 1569 | * @param {JQuery<HTMLElement>} img - The image element to clear the expression for | ||
| 1570 | * @param {string} expression - The expression label to use | ||
| 1571 | */ | ||
| 1572 | function setNoneForImage(img, expression) { | ||
| 1573 | img.attr('src', ''); | ||
| 1574 | img.attr('data-expression', expression); | ||
| 1575 | img.attr('data-sprite-filename', null); | ||
| 1576 | img.attr('title', expression); | ||
| 1577 | img.removeClass('default'); | ||
| 1619 | } | 1578 | } |
| 1620 | 1579 | ||
| 1621 | function onClickExpressionImage() { | 1580 | function onClickExpressionImage() { |
| 1622 | const expression = $(this).attr('id'); | 1581 | // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label |
| 1623 | setSpriteSlashCommand({}, expression); | 1582 | if ($(this).attr('data-expression-type') === 'failure') { |
| 1583 | const label = $(this).attr('data-expression'); | ||
| 1584 | setSpriteSlashCommand({ type: 'expression' }, label); | ||
| 1585 | return; | ||
| 1586 | } | ||
| 1587 | |||
| 1588 | const spriteFile = $(this).attr('data-filename'); | ||
| 1589 | setSpriteSlashCommand({ type: 'sprite' }, spriteFile); | ||
| 1624 | } | 1590 | } |
| 1625 | 1591 | ||
| 1626 | async function onClickExpressionAddCustom() { | 1592 | async function onClickExpressionAddCustom() { |
| 1627 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression'); | 1593 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression'); |
| 1628 | let expressionName = await callPopup(template, 'input'); | 1594 | let expressionName = await Popup.show.input(null, template); |
| 1629 | 1595 | ||
| 1630 | if (!expressionName) { | 1596 | if (!expressionName) { |
| 1631 | console.debug('No custom expression name provided'); | 1597 | console.debug('No custom expression name provided'); |
| @@ -1636,19 +1602,15 @@ async function onClickExpressionAddCustom() { | |||
| 1636 | 1602 | ||
| 1637 | // a-z, 0-9, dashes and underscores only | 1603 | // a-z, 0-9, dashes and underscores only |
| 1638 | if (!/^[a-z0-9-_]+$/.test(expressionName)) { | 1604 | if (!/^[a-z0-9-_]+$/.test(expressionName)) { |
| 1639 | toastr.info('Invalid custom expression name provided'); | 1605 | toastr.warning('Invalid custom expression name provided', 'Add Custom Expression'); |
| 1640 | return; | 1606 | return; |
| 1641 | } | 1607 | } |
| 1642 | 1608 | if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) { | |
| 1643 | // Check if expression name already exists in default expressions | 1609 | toastr.warning('Expression name already exists', 'Add Custom Expression'); |
| 1644 | if (DEFAULT_EXPRESSIONS.includes(expressionName)) { | ||
| 1645 | toastr.info('Expression name already exists'); | ||
| 1646 | return; | 1610 | return; |
| 1647 | } | 1611 | } |
| 1648 | |||
| 1649 | // Check if expression name already exists in custom expressions | ||
| 1650 | if (extension_settings.expressions.custom.includes(expressionName)) { | 1612 | if (extension_settings.expressions.custom.includes(expressionName)) { |
| 1651 | toastr.info('Custom expression already exists'); | 1613 | toastr.warning('Custom expression already exists', 'Add Custom Expression'); |
| 1652 | return; | 1614 | return; |
| 1653 | } | 1615 | } |
| 1654 | 1616 | ||
| @@ -1665,14 +1627,15 @@ async function onClickExpressionAddCustom() { | |||
| 1665 | 1627 | ||
| 1666 | async function onClickExpressionRemoveCustom() { | 1628 | async function onClickExpressionRemoveCustom() { |
| 1667 | const selectedExpression = String($('#expression_custom').val()); | 1629 | const selectedExpression = String($('#expression_custom').val()); |
| 1630 | const noCustomExpressions = extension_settings.expressions.custom.length === 0; | ||
| 1668 | 1631 | ||
| 1669 | if (!selectedExpression) { | 1632 | if (!selectedExpression || noCustomExpressions) { |
| 1670 | console.debug('No custom expression selected'); | 1633 | console.debug('No custom expression selected'); |
| 1671 | return; | 1634 | return; |
| 1672 | } | 1635 | } |
| 1673 | 1636 | ||
| 1674 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }); | 1637 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }); |
| 1675 | const confirmation = await callPopup(template, 'confirm'); | 1638 | const confirmation = await Popup.show.confirm(null, template); |
| 1676 | 1639 | ||
| 1677 | if (!confirmation) { | 1640 | if (!confirmation) { |
| 1678 | console.debug('Custom expression removal cancelled'); | 1641 | console.debug('Custom expression removal cancelled'); |
| @@ -1682,8 +1645,8 @@ async function onClickExpressionRemoveCustom() { | |||
| 1682 | // Remove custom expression from settings | 1645 | // Remove custom expression from settings |
| 1683 | const index = extension_settings.expressions.custom.indexOf(selectedExpression); | 1646 | const index = extension_settings.expressions.custom.indexOf(selectedExpression); |
| 1684 | extension_settings.expressions.custom.splice(index, 1); | 1647 | extension_settings.expressions.custom.splice(index, 1); |
| 1685 | if (selectedExpression == getFallbackExpression()) { | 1648 | if (selectedExpression == extension_settings.expressions.fallback_expression) { |
| 1686 | toastr.warning(`Deleted custom expression '${selectedExpression}' that was also selected as the fallback expression.\nFallback expression has been reset to '${DEFAULT_FALLBACK_EXPRESSION}'.`); | 1649 | 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'); |
| 1687 | extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION; | 1650 | extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION; |
| 1688 | } | 1651 | } |
| 1689 | await renderAdditionalExpressionSettings(); | 1652 | await renderAdditionalExpressionSettings(); |
| @@ -1707,12 +1670,35 @@ function onExpressionApiChanged() { | |||
| 1707 | } | 1670 | } |
| 1708 | } | 1671 | } |
| 1709 | 1672 | ||
| 1710 | function onExpressionFallbackChanged() { | 1673 | async function onExpressionFallbackChanged() { |
| 1711 | const expression = this.value; | 1674 | /** @type {HTMLSelectElement} */ |
| 1712 | if (expression) { | 1675 | const select = this; |
| 1713 | extension_settings.expressions.fallback_expression = expression; | 1676 | const selectedValue = select.value; |
| 1714 | saveSettingsDebounced(); | 1677 | |
| 1678 | switch (selectedValue) { | ||
| 1679 | case OPTION_NO_FALLBACK: | ||
| 1680 | extension_settings.expressions.fallback_expression = null; | ||
| 1681 | extension_settings.expressions.showDefault = false; | ||
| 1682 | break; | ||
| 1683 | case OPTION_EMOJI_FALLBACK: | ||
| 1684 | extension_settings.expressions.fallback_expression = null; | ||
| 1685 | extension_settings.expressions.showDefault = true; | ||
| 1686 | break; | ||
| 1687 | default: | ||
| 1688 | extension_settings.expressions.fallback_expression = selectedValue; | ||
| 1689 | extension_settings.expressions.showDefault = false; | ||
| 1690 | break; | ||
| 1691 | } | ||
| 1692 | |||
| 1693 | const img = $('img.expression'); | ||
| 1694 | const spriteFolderName = img.attr('data-sprite-folder-name'); | ||
| 1695 | const expression = img.attr('data-expression'); | ||
| 1696 | |||
| 1697 | if (spriteFolderName && expression) { | ||
| 1698 | await sendExpressionCall(spriteFolderName, expression, { force: true }); | ||
| 1715 | } | 1699 | } |
| 1700 | |||
| 1701 | saveSettingsDebounced(); | ||
| 1716 | } | 1702 | } |
| 1717 | 1703 | ||
| 1718 | async function handleFileUpload(url, formData) { | 1704 | async function handleFileUpload(url, formData) { |
| @@ -1739,34 +1725,111 @@ async function handleFileUpload(url, formData) { | |||
| 1739 | } | 1725 | } |
| 1740 | } | 1726 | } |
| 1741 | 1727 | ||
| 1728 | /** | ||
| 1729 | * Removes the file extension from a file name | ||
| 1730 | * @param {string} fileName The file name to remove the extension from | ||
| 1731 | * @returns {string} The file name without the extension | ||
| 1732 | */ | ||
| 1733 | function withoutExtension(fileName) { | ||
| 1734 | return fileName.replace(/\.[^/.]+$/, ''); | ||
| 1735 | } | ||
| 1736 | |||
| 1737 | function validateExpressionSpriteName(expression, spriteName) { | ||
| 1738 | const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`); | ||
| 1739 | const validFileName = filenameValidationRegex.test(spriteName); | ||
| 1740 | return validFileName; | ||
| 1741 | } | ||
| 1742 | |||
| 1742 | async function onClickExpressionUpload(event) { | 1743 | async function onClickExpressionUpload(event) { |
| 1743 | // Prevents the expression from being set | 1744 | // Prevents the expression from being set |
| 1744 | event.stopPropagation(); | 1745 | event.stopPropagation(); |
| 1745 | 1746 | ||
| 1746 | const id = $(this).closest('.expression_list_item').attr('id'); | 1747 | const expressionListItem = $(this).closest('.expression_list_item'); |
| 1748 | |||
| 1749 | const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null; | ||
| 1750 | const expression = expressionListItem.data('expression'); | ||
| 1747 | const name = $('#image_list').data('name'); | 1751 | const name = $('#image_list').data('name'); |
| 1748 | 1752 | ||
| 1749 | const handleExpressionUploadChange = async (e) => { | 1753 | const handleExpressionUploadChange = async (e) => { |
| 1750 | const file = e.target.files[0]; | 1754 | const file = e.target.files[0]; |
| 1751 | 1755 | ||
| 1752 | if (!file) { | 1756 | if (!file || !file.name) { |
| 1757 | console.debug('No valid file selected'); | ||
| 1758 | return; | ||
| 1759 | } | ||
| 1760 | |||
| 1761 | const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || []; | ||
| 1762 | |||
| 1763 | let spriteName = expression; | ||
| 1764 | |||
| 1765 | if (extension_settings.expressions.allowMultiple) { | ||
| 1766 | const matchesExisting = existingFiles.some(x => x.fileName === file.name); | ||
| 1767 | const fileNameWithoutExtension = withoutExtension(file.name); | ||
| 1768 | const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension); | ||
| 1769 | |||
| 1770 | // If there is no expression yet and it's a valid expression, we just take it | ||
| 1771 | if (!clickedFileName && validFileName) { | ||
| 1772 | spriteName = fileNameWithoutExtension; | ||
| 1773 | } | ||
| 1774 | // If the filename matches the one that was clicked, we just take it and replace it | ||
| 1775 | else if (clickedFileName === file.name) { | ||
| 1776 | spriteName = fileNameWithoutExtension; | ||
| 1777 | } | ||
| 1778 | // If it's a valid filename and there's no existing file with the same name, we just take it | ||
| 1779 | else if (!matchesExisting && validFileName) { | ||
| 1780 | spriteName = fileNameWithoutExtension; | ||
| 1781 | } | ||
| 1782 | else { | ||
| 1783 | /** @type {import('../../popup.js').CustomPopupButton[]} */ | ||
| 1784 | const customButtons = []; | ||
| 1785 | if (clickedFileName) { | ||
| 1786 | customButtons.push({ | ||
| 1787 | text: t`Replace Existing`, | ||
| 1788 | result: POPUP_RESULT.NEGATIVE, | ||
| 1789 | action: () => { | ||
| 1790 | console.debug('Replacing existing sprite'); | ||
| 1791 | spriteName = withoutExtension(clickedFileName); | ||
| 1792 | }, | ||
| 1793 | }); | ||
| 1794 | } | ||
| 1795 | |||
| 1796 | spriteName = null; | ||
| 1797 | const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles); | ||
| 1798 | |||
| 1799 | const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); | ||
| 1800 | |||
| 1801 | const input = await Popup.show.input(t`Upload Expression Sprite`, message, | ||
| 1802 | suggestedSpriteName, { customButtons: customButtons }); | ||
| 1803 | |||
| 1804 | if (input) { | ||
| 1805 | if (!validateExpressionSpriteName(expression, input)) { | ||
| 1806 | toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`); | ||
| 1807 | return; | ||
| 1808 | } | ||
| 1809 | spriteName = input; | ||
| 1810 | } | ||
| 1811 | } | ||
| 1812 | } else { | ||
| 1813 | spriteName = withoutExtension(clickedFileName); | ||
| 1814 | } | ||
| 1815 | |||
| 1816 | if (!spriteName) { | ||
| 1817 | toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`); | ||
| 1818 | // Reset the input | ||
| 1819 | e.target.form.reset(); | ||
| 1753 | return; | 1820 | return; |
| 1754 | } | 1821 | } |
| 1755 | 1822 | ||
| 1756 | const formData = new FormData(); | 1823 | const formData = new FormData(); |
| 1757 | formData.append('name', name); | 1824 | formData.append('name', name); |
| 1758 | formData.append('label', id); | 1825 | formData.append('label', expression); |
| 1759 | formData.append('avatar', file); | 1826 | formData.append('avatar', file); |
| 1827 | formData.append('spriteName', spriteName); | ||
| 1760 | 1828 | ||
| 1761 | await handleFileUpload('/api/sprites/upload', formData); | 1829 | await handleFileUpload('/api/sprites/upload', formData); |
| 1762 | 1830 | ||
| 1763 | // Reset the input | 1831 | // Reset the input |
| 1764 | e.target.form.reset(); | 1832 | e.target.form.reset(); |
| 1765 | |||
| 1766 | // In Talkinghead mode, when a new talkinghead image is uploaded, refresh the live char. | ||
| 1767 | if (id === 'talkinghead' && isTalkingHeadEnabled() && modules.includes('talkinghead')) { | ||
| 1768 | await loadTalkingHead(); | ||
| 1769 | } | ||
| 1770 | }; | 1833 | }; |
| 1771 | 1834 | ||
| 1772 | $('#expression_upload') | 1835 | $('#expression_upload') |
| @@ -1822,8 +1885,9 @@ async function onClickExpressionOverrideButton() { | |||
| 1822 | inApiCall = true; | 1885 | inApiCall = true; |
| 1823 | $('#visual-novel-wrapper').empty(); | 1886 | $('#visual-novel-wrapper').empty(); |
| 1824 | await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true); | 1887 | await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true); |
| 1888 | const name = overridePath.length === 0 ? currentLastMessage.name : overridePath; | ||
| 1825 | const expression = await getExpressionLabel(currentLastMessage.mes); | 1889 | const expression = await getExpressionLabel(currentLastMessage.mes); |
| 1826 | await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true); | 1890 | await sendExpressionCall(name, expression, { force: true }); |
| 1827 | forceUpdateVisualNovelMode(); | 1891 | forceUpdateVisualNovelMode(); |
| 1828 | } catch (error) { | 1892 | } catch (error) { |
| 1829 | console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`); | 1893 | console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`); |
| @@ -1849,7 +1913,7 @@ async function onClickExpressionOverrideRemoveAllButton() { | |||
| 1849 | const currentLastMessage = getLastCharacterMessage(); | 1913 | const currentLastMessage = getLastCharacterMessage(); |
| 1850 | await validateImages(currentLastMessage.name, true); | 1914 | await validateImages(currentLastMessage.name, true); |
| 1851 | const expression = await getExpressionLabel(currentLastMessage.mes); | 1915 | const expression = await getExpressionLabel(currentLastMessage.mes); |
| 1852 | await sendExpressionCall(currentLastMessage.name, expression, true); | 1916 | await sendExpressionCall(currentLastMessage.name, expression, { force: true }); |
| 1853 | forceUpdateVisualNovelMode(); | 1917 | forceUpdateVisualNovelMode(); |
| 1854 | 1918 | ||
| 1855 | console.debug(extension_settings.expressionOverrides); | 1919 | console.debug(extension_settings.expressionOverrides); |
| @@ -1872,16 +1936,13 @@ async function onClickExpressionUploadPackButton() { | |||
| 1872 | formData.append('name', name); | 1936 | formData.append('name', name); |
| 1873 | formData.append('avatar', file); | 1937 | formData.append('avatar', file); |
| 1874 | 1938 | ||
| 1939 | const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 }); | ||
| 1875 | const { count } = await handleFileUpload('/api/sprites/upload-zip', formData); | 1940 | const { count } = await handleFileUpload('/api/sprites/upload-zip', formData); |
| 1941 | toastr.clear(uploadToast); | ||
| 1876 | toastr.success(`Uploaded ${count} image(s) for ${name}`); | 1942 | toastr.success(`Uploaded ${count} image(s) for ${name}`); |
| 1877 | 1943 | ||
| 1878 | // Reset the input | 1944 | // Reset the input |
| 1879 | e.target.form.reset(); | 1945 | e.target.form.reset(); |
| 1880 | |||
| 1881 | // In Talkinghead mode, refresh the live char. | ||
| 1882 | if (isTalkingHeadEnabled() && modules.includes('talkinghead')) { | ||
| 1883 | await loadTalkingHead(); | ||
| 1884 | } | ||
| 1885 | }; | 1946 | }; |
| 1886 | 1947 | ||
| 1887 | $('#expression_upload_pack') | 1948 | $('#expression_upload_pack') |
| @@ -1894,20 +1955,28 @@ async function onClickExpressionDelete(event) { | |||
| 1894 | // Prevents the expression from being set | 1955 | // Prevents the expression from being set |
| 1895 | event.stopPropagation(); | 1956 | event.stopPropagation(); |
| 1896 | 1957 | ||
| 1897 | const confirmation = await callPopup('<h3>Are you sure?</h3>Once deleted, it\'s gone forever!', 'confirm'); | 1958 | const expressionListItem = $(this).closest('.expression_list_item'); |
| 1959 | const expression = expressionListItem.data('expression'); | ||
| 1960 | |||
| 1961 | if (expressionListItem.attr('data-expression-type') === 'failure') { | ||
| 1962 | return; | ||
| 1963 | } | ||
| 1898 | 1964 | ||
| 1965 | 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!` | ||
| 1966 | + '<br /><br />' | ||
| 1967 | + t`Expression:` + ' <tt>' + expressionListItem.attr('data-filename') + '</tt>'); | ||
| 1899 | if (!confirmation) { | 1968 | if (!confirmation) { |
| 1900 | return; | 1969 | return; |
| 1901 | } | 1970 | } |
| 1902 | 1971 | ||
| 1903 | const id = $(this).closest('.expression_list_item').attr('id'); | 1972 | const fileName = withoutExtension(expressionListItem.attr('data-filename')); |
| 1904 | const name = $('#image_list').data('name'); | 1973 | const name = $('#image_list').data('name'); |
| 1905 | 1974 | ||
| 1906 | try { | 1975 | try { |
| 1907 | await fetch('/api/sprites/delete', { | 1976 | await fetch('/api/sprites/delete', { |
| 1908 | method: 'POST', | 1977 | method: 'POST', |
| 1909 | headers: getRequestHeaders(), | 1978 | headers: getRequestHeaders(), |
| 1910 | body: JSON.stringify({ name, label: id }), | 1979 | body: JSON.stringify({ name, label: expression, spriteName: fileName }), |
| 1911 | }); | 1980 | }); |
| 1912 | } catch (error) { | 1981 | } catch (error) { |
| 1913 | toastr.error('Failed to delete image. Try again later.'); | 1982 | toastr.error('Failed to delete image. Try again later.'); |
| @@ -1984,6 +2053,16 @@ function migrateSettings() { | |||
| 1984 | extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; | 2053 | extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; |
| 1985 | saveSettingsDebounced(); | 2054 | saveSettingsDebounced(); |
| 1986 | } | 2055 | } |
| 2056 | |||
| 2057 | if (extension_settings.expressions.allowMultiple === undefined) { | ||
| 2058 | extension_settings.expressions.allowMultiple = true; | ||
| 2059 | saveSettingsDebounced(); | ||
| 2060 | } | ||
| 2061 | |||
| 2062 | if (extension_settings.expressions.showDefault && extension_settings.expressions.fallback_expression !== undefined) { | ||
| 2063 | extension_settings.expressions.showDefault = false; | ||
| 2064 | saveSettingsDebounced(); | ||
| 2065 | } | ||
| 1987 | } | 2066 | } |
| 1988 | 2067 | ||
| 1989 | (async function () { | 2068 | (async function () { |
| @@ -2010,13 +2089,19 @@ function migrateSettings() { | |||
| 2010 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); | 2089 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); |
| 2011 | $('#expressions_container').append(template); | 2090 | $('#expressions_container').append(template); |
| 2012 | $('#expression_override_button').on('click', onClickExpressionOverrideButton); | 2091 | $('#expression_override_button').on('click', onClickExpressionOverrideButton); |
| 2013 | $('#expressions_show_default').on('input', onExpressionsShowDefaultInput); | ||
| 2014 | $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton); | 2092 | $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton); |
| 2015 | $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input'); | ||
| 2016 | $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () { | 2093 | $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () { |
| 2017 | extension_settings.expressions.translate = !!$(this).prop('checked'); | 2094 | extension_settings.expressions.translate = !!$(this).prop('checked'); |
| 2018 | saveSettingsDebounced(); | 2095 | saveSettingsDebounced(); |
| 2019 | }); | 2096 | }); |
| 2097 | $('#expressions_allow_multiple').prop('checked', extension_settings.expressions.allowMultiple).on('input', function () { | ||
| 2098 | extension_settings.expressions.allowMultiple = !!$(this).prop('checked'); | ||
| 2099 | saveSettingsDebounced(); | ||
| 2100 | }); | ||
| 2101 | $('#expressions_reroll_if_same').prop('checked', extension_settings.expressions.rerollIfSame).on('input', function () { | ||
| 2102 | extension_settings.expressions.rerollIfSame = !!$(this).prop('checked'); | ||
| 2103 | saveSettingsDebounced(); | ||
| 2104 | }); | ||
| 2020 | $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton); | 2105 | $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton); |
| 2021 | $(document).on('dragstart', '.expression', (e) => { | 2106 | $(document).on('dragstart', '.expression', (e) => { |
| 2022 | e.preventDefault(); | 2107 | e.preventDefault(); |
| @@ -2025,21 +2110,15 @@ function migrateSettings() { | |||
| 2025 | $(document).on('click', '.expression_list_item', onClickExpressionImage); | 2110 | $(document).on('click', '.expression_list_item', onClickExpressionImage); |
| 2026 | $(document).on('click', '.expression_list_upload', onClickExpressionUpload); | 2111 | $(document).on('click', '.expression_list_upload', onClickExpressionUpload); |
| 2027 | $(document).on('click', '.expression_list_delete', onClickExpressionDelete); | 2112 | $(document).on('click', '.expression_list_delete', onClickExpressionDelete); |
| 2028 | $(window).on('resize', updateVisualNovelModeDebounced); | 2113 | $(window).on('resize', () => updateVisualNovelModeDebounced()); |
| 2029 | $('#open_chat_expressions').hide(); | 2114 | $('#open_chat_expressions').hide(); |
| 2030 | 2115 | ||
| 2031 | $('#image_type_toggle').on('click', function () { | ||
| 2032 | if (this instanceof HTMLInputElement) { | ||
| 2033 | setTalkingHeadState(this.checked); | ||
| 2034 | } | ||
| 2035 | }); | ||
| 2036 | |||
| 2037 | await renderAdditionalExpressionSettings(); | 2116 | await renderAdditionalExpressionSettings(); |
| 2038 | $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras); | 2117 | $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras); |
| 2039 | $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); | 2118 | $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); |
| 2040 | $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? ''); | 2119 | $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? ''); |
| 2041 | $('#expression_llm_prompt').on('input', function () { | 2120 | $('#expression_llm_prompt').on('input', function () { |
| 2042 | extension_settings.expressions.llmPrompt = $(this).val(); | 2121 | extension_settings.expressions.llmPrompt = String($(this).val()); |
| 2043 | saveSettingsDebounced(); | 2122 | saveSettingsDebounced(); |
| 2044 | }); | 2123 | }); |
| 2045 | $('#expression_llm_prompt_restore').on('click', function () { | 2124 | $('#expression_llm_prompt_restore').on('click', function () { |
| @@ -2054,34 +2133,6 @@ function migrateSettings() { | |||
| 2054 | $('#expression_api').on('change', onExpressionApiChanged); | 2133 | $('#expression_api').on('change', onExpressionApiChanged); |
| 2055 | } | 2134 | } |
| 2056 | 2135 | ||
| 2057 | // Pause Talkinghead to save resources when the ST tab is not visible or the window is minimized. | ||
| 2058 | // We currently do this via loading/unloading. Could be improved by adding new pause/unpause endpoints to Extras. | ||
| 2059 | document.addEventListener('visibilitychange', function (event) { | ||
| 2060 | let pageIsVisible; | ||
| 2061 | if (document.hidden) { | ||
| 2062 | console.debug('expressions: SillyTavern is now hidden'); | ||
| 2063 | pageIsVisible = false; | ||
| 2064 | } else { | ||
| 2065 | console.debug('expressions: SillyTavern is now visible'); | ||
| 2066 | pageIsVisible = true; | ||
| 2067 | } | ||
| 2068 | |||
| 2069 | if (isTalkingHeadEnabled() && modules.includes('talkinghead')) { | ||
| 2070 | isTalkingHeadAvailable().then(result => { | ||
| 2071 | if (result) { | ||
| 2072 | if (pageIsVisible) { | ||
| 2073 | loadTalkingHead(); | ||
| 2074 | } else { | ||
| 2075 | unloadTalkingHead(); | ||
| 2076 | } | ||
| 2077 | handleImageChange(); // Change image as needed | ||
| 2078 | } else { | ||
| 2079 | //console.log("talkinghead does not exist."); | ||
| 2080 | } | ||
| 2081 | }); | ||
| 2082 | } | ||
| 2083 | }); | ||
| 2084 | |||
| 2085 | addExpressionImage(); | 2136 | addExpressionImage(); |
| 2086 | addVisualNovelMode(); | 2137 | addVisualNovelMode(); |
| 2087 | migrateSettings(); | 2138 | migrateSettings(); |
| @@ -2090,11 +2141,6 @@ function migrateSettings() { | |||
| 2090 | const updateFunction = wrapper.update.bind(wrapper); | 2141 | const updateFunction = wrapper.update.bind(wrapper); |
| 2091 | setInterval(updateFunction, UPDATE_INTERVAL); | 2142 | setInterval(updateFunction, UPDATE_INTERVAL); |
| 2092 | moduleWorker(); | 2143 | moduleWorker(); |
| 2093 | // For setting the Talkinghead talking animation on/off quickly enough for realtime use, we need another timer on a shorter schedule. | ||
| 2094 | const wrapperTalkingState = new ModuleWorkerWrapper(updateTalkingState); | ||
| 2095 | const updateTalkingStateFunction = wrapperTalkingState.update.bind(wrapperTalkingState); | ||
| 2096 | setInterval(updateTalkingStateFunction, TALKINGCHECK_UPDATE_INTERVAL); | ||
| 2097 | updateTalkingState(); | ||
| 2098 | dragElement($('#expression-holder')); | 2144 | dragElement($('#expression-holder')); |
| 2099 | eventSource.on(event_types.CHAT_CHANGED, () => { | 2145 | eventSource.on(event_types.CHAT_CHANGED, () => { |
| 2100 | // character changed | 2146 | // character changed |
| @@ -2108,110 +2154,137 @@ function migrateSettings() { | |||
| 2108 | imgElement.src = ''; | 2154 | imgElement.src = ''; |
| 2109 | } | 2155 | } |
| 2110 | 2156 | ||
| 2111 | //set checkbox to global var | ||
| 2112 | $('#image_type_toggle').prop('checked', extension_settings.expressions.talkinghead); | ||
| 2113 | if (extension_settings.expressions.talkinghead) { | ||
| 2114 | setTalkingHeadState(extension_settings.expressions.talkinghead); | ||
| 2115 | } | ||
| 2116 | |||
| 2117 | setExpressionOverrideHtml(); | 2157 | setExpressionOverrideHtml(); |
| 2118 | 2158 | ||
| 2119 | if (isVisualNovelMode()) { | 2159 | if (isVisualNovelMode()) { |
| 2120 | $('#visual-novel-wrapper').empty(); | 2160 | $('#visual-novel-wrapper').empty(); |
| 2121 | } | 2161 | } |
| 2122 | 2162 | ||
| 2123 | updateFunction(); | 2163 | updateFunction({ newChat: true }); |
| 2124 | }); | 2164 | }); |
| 2125 | eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced); | 2165 | eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced); |
| 2126 | eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced); | 2166 | eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced); |
| 2127 | eventSource.on(event_types.EXTRAS_CONNECTED, () => { | ||
| 2128 | if (extension_settings.expressions.talkinghead) { | ||
| 2129 | setTalkingHeadState(extension_settings.expressions.talkinghead); | ||
| 2130 | } | ||
| 2131 | }); | ||
| 2132 | 2167 | ||
| 2133 | const localEnumProviders = { | 2168 | const localEnumProviders = { |
| 2134 | expressions: () => getCachedExpressions().map(expression => { | 2169 | expressions: () => { |
| 2170 | const currentLastMessage = selected_group ? getLastCharacterMessage() : null; | ||
| 2171 | const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); | ||
| 2172 | const expressions = getCachedExpressions(); | ||
| 2173 | return expressions.map(expression => { | ||
| 2174 | const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0; | ||
| 2135 | const isCustom = extension_settings.expressions.custom?.includes(expression); | 2175 | const isCustom = extension_settings.expressions.custom?.includes(expression); |
| 2136 | return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D'); | 2176 | const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' : |
| 2137 | }), | 2177 | spriteCount > 1 ? `${spriteCount} sprites` : null; |
| 2178 | return new SlashCommandEnumValue(expression, | ||
| 2179 | subtitle, | ||
| 2180 | isCustom ? enumTypes.name : enumTypes.enum, | ||
| 2181 | isCustom ? 'C' : 'D'); | ||
| 2182 | }); | ||
| 2183 | }, | ||
| 2184 | sprites: () => { | ||
| 2185 | const currentLastMessage = selected_group ? getLastCharacterMessage() : null; | ||
| 2186 | const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); | ||
| 2187 | const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? []; | ||
| 2188 | return sprites.map(x => { | ||
| 2189 | return new SlashCommandEnumValue(x.title, | ||
| 2190 | x.title !== x.expression ? x.expression : null, | ||
| 2191 | x.isCustom ? enumTypes.name : enumTypes.enum, | ||
| 2192 | x.isCustom ? 'C' : 'D'); | ||
| 2193 | }); | ||
| 2194 | }, | ||
| 2138 | }; | 2195 | }; |
| 2139 | 2196 | ||
| 2140 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2197 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2141 | name: 'sprite', | 2198 | name: 'expression-set', |
| 2142 | aliases: ['emote'], | 2199 | aliases: ['sprite', 'emote'], |
| 2143 | callback: setSpriteSlashCommand, | 2200 | callback: setSpriteSlashCommand, |
| 2201 | namedArgumentList: [ | ||
| 2202 | SlashCommandNamedArgument.fromProps({ | ||
| 2203 | name: 'type', | ||
| 2204 | description: 'Whether to set an expression or a specific sprite.', | ||
| 2205 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2206 | isRequired: false, | ||
| 2207 | defaultValue: 'expression', | ||
| 2208 | enumList: ['expression', 'sprite'], | ||
| 2209 | }), | ||
| 2210 | ], | ||
| 2144 | unnamedArgumentList: [ | 2211 | unnamedArgumentList: [ |
| 2145 | SlashCommandArgument.fromProps({ | 2212 | SlashCommandArgument.fromProps({ |
| 2146 | description: 'spriteId', | 2213 | description: 'expression label to set', |
| 2147 | typeList: [ARGUMENT_TYPE.STRING], | 2214 | typeList: [ARGUMENT_TYPE.STRING], |
| 2148 | isRequired: true, | 2215 | isRequired: true, |
| 2149 | enumProvider: localEnumProviders.expressions, | 2216 | enumProvider: (executor, _) => { |
| 2217 | // Check if command is used to set a sprite, then use those enums | ||
| 2218 | const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression'; | ||
| 2219 | if (type == 'sprite') return localEnumProviders.sprites(); | ||
| 2220 | else return [ | ||
| 2221 | ...localEnumProviders.expressions(), | ||
| 2222 | new SlashCommandEnumValue(RESET_SPRITE_LABEL, 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'), | ||
| 2223 | ]; | ||
| 2224 | }, | ||
| 2150 | }), | 2225 | }), |
| 2151 | ], | 2226 | ], |
| 2152 | helpString: 'Force sets the sprite for the current character.', | 2227 | helpString: 'Force sets the expression for the current character.', |
| 2153 | returns: 'the currently set sprite label after setting it.', | 2228 | returns: 'The currently set expression label after setting it.', |
| 2154 | })); | 2229 | })); |
| 2155 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2230 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2156 | name: 'spriteoverride', | 2231 | name: 'expression-folder-override', |
| 2157 | aliases: ['costume'], | 2232 | aliases: ['spriteoverride', 'costume'], |
| 2158 | callback: setSpriteSetCommand, | 2233 | callback: setSpriteFolderCommand, |
| 2159 | unnamedArgumentList: [ | 2234 | unnamedArgumentList: [ |
| 2160 | new SlashCommandArgument( | 2235 | new SlashCommandArgument( |
| 2161 | 'optional folder', [ARGUMENT_TYPE.STRING], false, | 2236 | 'optional folder', [ARGUMENT_TYPE.STRING], false, |
| 2162 | ), | 2237 | ), |
| 2163 | ], | 2238 | ], |
| 2164 | helpString: '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.', | 2239 | helpString: ` |
| 2240 | <div> | ||
| 2241 | Sets an override sprite folder for the current character.<br /> | ||
| 2242 | In groups, this will apply to the character who last sent a message. | ||
| 2243 | </div> | ||
| 2244 | <div> | ||
| 2245 | 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. | ||
| 2246 | </div> | ||
| 2247 | `, | ||
| 2165 | })); | 2248 | })); |
| 2166 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2249 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2167 | name: 'lastsprite', | 2250 | name: 'expression-last', |
| 2168 | callback: (_, name) => { | 2251 | aliases: ['lastsprite'], |
| 2252 | /** @type {(args: object, name: string) => Promise<string>} */ | ||
| 2253 | callback: async (_, name) => { | ||
| 2169 | if (typeof name !== 'string') throw new Error('name must be a string'); | 2254 | if (typeof name !== 'string') throw new Error('name must be a string'); |
| 2255 | if (!name) { | ||
| 2256 | if (selected_group) { | ||
| 2257 | toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`); | ||
| 2258 | return ''; | ||
| 2259 | } | ||
| 2260 | name = characters[this_chid]?.avatar; | ||
| 2261 | } | ||
| 2262 | |||
| 2170 | const char = findChar({ name: name }); | 2263 | const char = findChar({ name: name }); |
| 2264 | if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`); | ||
| 2265 | |||
| 2171 | const sprite = lastExpression[char?.name ?? name] ?? ''; | 2266 | const sprite = lastExpression[char?.name ?? name] ?? ''; |
| 2172 | return sprite; | 2267 | return sprite; |
| 2173 | }, | 2268 | }, |
| 2174 | returns: 'the last set sprite / expression for the named character.', | 2269 | returns: 'the last set expression for the named character.', |
| 2175 | unnamedArgumentList: [ | 2270 | unnamedArgumentList: [ |
| 2176 | SlashCommandArgument.fromProps({ | 2271 | SlashCommandArgument.fromProps({ |
| 2177 | description: 'Character name - or unique character identifier (avatar key)', | 2272 | 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)', |
| 2178 | typeList: [ARGUMENT_TYPE.STRING], | 2273 | typeList: [ARGUMENT_TYPE.STRING], |
| 2179 | isRequired: true, | ||
| 2180 | enumProvider: commonEnumProviders.characters('character'), | 2274 | enumProvider: commonEnumProviders.characters('character'), |
| 2181 | }), | 2275 | }), |
| 2182 | ], | 2276 | ], |
| 2183 | helpString: 'Returns the last set sprite / expression for the named character.', | 2277 | helpString: 'Returns the last set expression for the named character.', |
| 2184 | })); | ||
| 2185 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | ||
| 2186 | name: 'th', | ||
| 2187 | callback: toggleTalkingHeadCommand, | ||
| 2188 | aliases: ['talkinghead'], | ||
| 2189 | helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.', | ||
| 2190 | returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.', | ||
| 2191 | })); | 2278 | })); |
| 2192 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2279 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2193 | name: 'classify-expressions', | 2280 | name: 'expression-list', |
| 2194 | aliases: ['expressions'], | 2281 | aliases: ['expressions'], |
| 2282 | /** @type {(args: {return: string}) => Promise<string>} */ | ||
| 2195 | callback: async (args) => { | 2283 | callback: async (args) => { |
| 2284 | let returnType = | ||
| 2196 | /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ | 2285 | /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ |
| 2197 | // @ts-ignore | 2286 | (args.return); |
| 2198 | let returnType = args.return; | ||
| 2199 | |||
| 2200 | // Old legacy return type handling | ||
| 2201 | if (args.format) { | ||
| 2202 | toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning'); | ||
| 2203 | const type = String(args?.format).toLowerCase().trim(); | ||
| 2204 | switch (type) { | ||
| 2205 | case 'json': | ||
| 2206 | returnType = 'object'; | ||
| 2207 | break; | ||
| 2208 | default: | ||
| 2209 | returnType = 'pipe'; | ||
| 2210 | break; | ||
| 2211 | } | ||
| 2212 | } | ||
| 2213 | 2287 | ||
| 2214 | // Now the actual new return type handling | ||
| 2215 | const list = await getExpressionsList(); | 2288 | const list = await getExpressionsList(); |
| 2216 | 2289 | ||
| 2217 | return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); | 2290 | return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); |
| @@ -2225,22 +2298,13 @@ function migrateSettings() { | |||
| 2225 | enumList: slashCommandReturnHelper.enumList({ allowObject: true }), | 2298 | enumList: slashCommandReturnHelper.enumList({ allowObject: true }), |
| 2226 | forceEnum: true, | 2299 | forceEnum: true, |
| 2227 | }), | 2300 | }), |
| 2228 | // TODO remove some day | ||
| 2229 | SlashCommandNamedArgument.fromProps({ | ||
| 2230 | name: 'format', | ||
| 2231 | description: '!!! DEPRECATED - use "return" instead !!! The format to return the list in: comma-separated plain text or JSON array. Default is plain text.', | ||
| 2232 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2233 | enumList: [ | ||
| 2234 | new SlashCommandEnumValue('plain', null, enumTypes.enum, ', '), | ||
| 2235 | new SlashCommandEnumValue('json', null, enumTypes.enum, '[]'), | ||
| 2236 | ], | ||
| 2237 | }), | ||
| 2238 | ], | 2301 | ], |
| 2239 | returns: 'The comma-separated list of available expressions, including custom expressions.', | 2302 | returns: 'The comma-separated list of available expressions, including custom expressions.', |
| 2240 | helpString: 'Returns a list of available expressions, including custom expressions.', | 2303 | helpString: 'Returns a list of available expressions, including custom expressions.', |
| 2241 | })); | 2304 | })); |
| 2242 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2305 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2243 | name: 'classify', | 2306 | name: 'expression-classify', |
| 2307 | aliases: ['classify'], | ||
| 2244 | callback: classifyCallback, | 2308 | callback: classifyCallback, |
| 2245 | namedArgumentList: [ | 2309 | namedArgumentList: [ |
| 2246 | SlashCommandNamedArgument.fromProps({ | 2310 | SlashCommandNamedArgument.fromProps({ |
| @@ -2279,11 +2343,13 @@ function migrateSettings() { | |||
| 2279 | `, | 2343 | `, |
| 2280 | })); | 2344 | })); |
| 2281 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | 2345 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2282 | name: 'uploadsprite', | 2346 | name: 'expression-upload', |
| 2347 | aliases: ['uploadsprite'], | ||
| 2348 | /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */ | ||
| 2283 | callback: async (args, url) => { | 2349 | callback: async (args, url) => { |
| 2284 | await uploadSpriteCommand(args, url); | 2350 | return await uploadSpriteCommand(args, url); |
| 2285 | return ''; | ||
| 2286 | }, | 2351 | }, |
| 2352 | returns: 'the resulting sprite name', | ||
| 2287 | unnamedArgumentList: [ | 2353 | unnamedArgumentList: [ |
| 2288 | SlashCommandArgument.fromProps({ | 2354 | SlashCommandArgument.fromProps({ |
| 2289 | description: 'URL of the image to upload', | 2355 | description: 'URL of the image to upload', |
| @@ -2297,7 +2363,6 @@ function migrateSettings() { | |||
| 2297 | description: 'Character name or avatar key (default is current character)', | 2363 | description: 'Character name or avatar key (default is current character)', |
| 2298 | typeList: [ARGUMENT_TYPE.STRING], | 2364 | typeList: [ARGUMENT_TYPE.STRING], |
| 2299 | isRequired: false, | 2365 | isRequired: false, |
| 2300 | acceptsMultiple: false, | ||
| 2301 | }), | 2366 | }), |
| 2302 | SlashCommandNamedArgument.fromProps({ | 2367 | SlashCommandNamedArgument.fromProps({ |
| 2303 | name: 'label', | 2368 | name: 'label', |
| @@ -2305,16 +2370,32 @@ function migrateSettings() { | |||
| 2305 | typeList: [ARGUMENT_TYPE.STRING], | 2370 | typeList: [ARGUMENT_TYPE.STRING], |
| 2306 | enumProvider: localEnumProviders.expressions, | 2371 | enumProvider: localEnumProviders.expressions, |
| 2307 | isRequired: true, | 2372 | isRequired: true, |
| 2308 | acceptsMultiple: false, | ||
| 2309 | }), | 2373 | }), |
| 2310 | SlashCommandNamedArgument.fromProps({ | 2374 | SlashCommandNamedArgument.fromProps({ |
| 2311 | name: 'folder', | 2375 | name: 'folder', |
| 2312 | description: 'Override folder to upload into', | 2376 | description: 'Override folder to upload into', |
| 2313 | typeList: [ARGUMENT_TYPE.STRING], | 2377 | typeList: [ARGUMENT_TYPE.STRING], |
| 2314 | isRequired: false, | 2378 | isRequired: false, |
| 2315 | acceptsMultiple: false, | 2379 | }), |
| 2380 | SlashCommandNamedArgument.fromProps({ | ||
| 2381 | name: 'spriteName', | ||
| 2382 | 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.', | ||
| 2383 | typeList: [ARGUMENT_TYPE.STRING], | ||
| 2384 | isRequired: false, | ||
| 2316 | }), | 2385 | }), |
| 2317 | ], | 2386 | ], |
| 2318 | helpString: '<div>Upload a sprite from a URL.</div><div>Example:</div><pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>', | 2387 | helpString: ` |
| 2388 | <div> | ||
| 2389 | Upload a sprite from a URL. | ||
| 2390 | </div> | ||
| 2391 | <div> | ||
| 2392 | <strong>Example:</strong> | ||
| 2393 | <ul> | ||
| 2394 | <li> | ||
| 2395 | <pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre> | ||
| 2396 | </li> | ||
| 2397 | </ul> | ||
| 2398 | </div> | ||
| 2399 | `, | ||
| 2319 | })); | 2400 | })); |
| 2320 | })(); | 2401 | })(); |
| @@ -1,4 +1,5 @@ | |||
| 1 | <div id="{{item}}" class="expression_list_item"> | 1 | {{#each images}} |
| 2 | <div class="expression_list_item interactable" data-expression="{{../expression}}" data-expression-type="{{this.type}}" data-filename="{{this.fileName}}"> | ||
| 2 | <div class="expression_list_buttons"> | 3 | <div class="expression_list_buttons"> |
| 3 | <div class="menu_button expression_list_upload" title="Upload image"> | 4 | <div class="menu_button expression_list_upload" title="Upload image"> |
| 4 | <i class="fa-solid fa-upload"></i> | 5 | <i class="fa-solid fa-upload"></i> |
| @@ -7,11 +8,14 @@ | |||
| 7 | <i class="fa-solid fa-trash"></i> | 8 | <i class="fa-solid fa-trash"></i> |
| 8 | </div> | 9 | </div> |
| 9 | </div> | 10 | </div> |
| 10 | <div class="expression_list_title {{textClass}}"> | 11 | <div class="expression_list_title"> |
| 11 | <span>{{item}}</span> | 12 | <span>{{../expression}}</span> |
| 12 | {{#if isCustom}} | 13 | {{#if ../isCustom}} |
| 13 | <small class="expression_list_custom">(custom)</small> | 14 | <small class="expression_list_custom">(custom)</small> |
| 14 | {{/if}} | 15 | {{/if}} |
| 15 | </div> | 16 | </div> |
| 16 | <img class="expression_list_image" src="{{imageSrc}}" /> | 17 | <div class="expression_list_image_container" title="{{this.title}}"> |
| 18 | <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" /> | ||
| 17 | </div> | 19 | </div> |
| 20 | </div> | ||
| 21 | {{/each}} | ||
| @@ -6,17 +6,17 @@ | |||
| 6 | </div> | 6 | </div> |
| 7 | 7 | ||
| 8 | <div class="inline-drawer-content"> | 8 | <div class="inline-drawer-content"> |
| 9 | <label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings."> | 9 | <label class="checkbox_label" for="expression_translate" title="Use the selected API from Chat Translation extension settings." data-i18n="[title]Use the selected API from Chat Translation extension settings."> |
| 10 | <input id="expression_translate" type="checkbox"> | 10 | <input id="expression_translate" type="checkbox"> |
| 11 | <span data-i18n="Translate text to English before classification">Translate text to English before classification</span> | 11 | <span data-i18n="Translate text to English before classification">Translate text to English before classification</span> |
| 12 | </label> | 12 | </label> |
| 13 | <label class="checkbox_label" for="expressions_show_default"> | 13 | <label class="checkbox_label" for="expressions_allow_multiple" title="A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected." data-i18n="[title]A single expression can have multiple sprites. Whenever the expression is chosen, a random sprite for this expression will be selected."> |
| 14 | <input id="expressions_show_default" type="checkbox"> | 14 | <input id="expressions_allow_multiple" type="checkbox"> |
| 15 | <span data-i18n="Show default images (emojis) if sprite missing">Show default images (emojis) if sprite missing</span> | 15 | <span data-i18n="Allow multiple sprites per expression">Allow multiple sprites per expression</span> |
| 16 | </label> | 16 | </label> |
| 17 | <label id="image_type_block" class="checkbox_label" for="image_type_toggle"> | 17 | <label class="checkbox_label" for="expressions_reroll_if_same" title="If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned." data-i18n="[title]If the same expression is used again, re-roll the sprite. This only applies to expressions that have multiple available sprites assigned."> |
| 18 | <input id="image_type_toggle" type="checkbox"> | 18 | <input id="expressions_reroll_if_same" type="checkbox"> |
| 19 | <span data-i18n="Image Type - talkinghead (extras)">Image Type - talkinghead (extras)</span> | 19 | <span data-i18n="Re-roll if same expression is used again">Re-roll if same sprite is used again</span> |
| 20 | </label> | 20 | </label> |
| 21 | <div class="expression_api_block m-b-1 m-t-1"> | 21 | <div class="expression_api_block m-b-1 m-t-1"> |
| 22 | <label for="expression_api" data-i18n="Classifier API">Classifier API</label> | 22 | <label for="expression_api" data-i18n="Classifier API">Classifier API</label> |
| @@ -75,8 +75,20 @@ | |||
| 75 | <span data-i18n="Remove all image overrides">Remove all image overrides</span> | 75 | <span data-i18n="Remove all image overrides">Remove all image overrides</span> |
| 76 | </div> | 76 | </div> |
| 77 | </div> | 77 | </div> |
| 78 | <p class="hint"><b data-i18n="Hint:">Hint:</b> <i><span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span> | 78 | <p class="hint"> |
| 79 | <span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt></i></p> | 79 | <b data-i18n="Hint:">Hint:</b> |
| 80 | <i> | ||
| 81 | <span data-i18n="Create new folder in the _space">Create new folder in the </span><b>/characters/</b> <span data-i18n="folder of your user data directory and name it as the name of the character.">folder of your user data directory and name it as the name of the character.</span> | ||
| 82 | <span data-i18n="Put images with expressions there. File names should follow the pattern:">Put images with expressions there. File names should follow the pattern: </span><tt data-i18n="expression_label_pattern">[expression_label].[image_format]</tt> | ||
| 83 | </i> | ||
| 84 | </p> | ||
| 85 | <p> | ||
| 86 | <i> | ||
| 87 | <span>In case of multiple files per expression, file names can contain a suffix, either separated by a dot or a | ||
| 88 | dash. | ||
| 89 | Examples: </span><tt>joy.png</tt>, <tt>joy-1.png</tt>, <tt>joy.expressive.png</tt> | ||
| 90 | </i> | ||
| 91 | </p> | ||
| 80 | <h3 id="image_list_header"> | 92 | <h3 id="image_list_header"> |
| 81 | <strong data-i18n="Sprite set:">Sprite set:</strong> <span id="image_list_header_name"></span> | 93 | <strong data-i18n="Sprite set:">Sprite set:</strong> <span id="image_list_header_name"></span> |
| 82 | </h3> | 94 | </h3> |
| @@ -111,6 +111,10 @@ img.expression.default { | |||
| 111 | justify-content: center; | 111 | justify-content: center; |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | .expression_list_image_container { | ||
| 115 | overflow: hidden; | ||
| 116 | } | ||
| 117 | |||
| 114 | .expression_list_title { | 118 | .expression_list_title { |
| 115 | position: absolute; | 119 | position: absolute; |
| 116 | bottom: 0; | 120 | bottom: 0; |
| @@ -126,6 +130,9 @@ img.expression.default { | |||
| 126 | flex-direction: column; | 130 | flex-direction: column; |
| 127 | line-height: 1; | 131 | line-height: 1; |
| 128 | } | 132 | } |
| 133 | .expression_list_custom { | ||
| 134 | font-size: 0.66rem; | ||
| 135 | } | ||
| 129 | 136 | ||
| 130 | .expression_list_buttons { | 137 | .expression_list_buttons { |
| 131 | position: absolute; | 138 | position: absolute; |
| @@ -162,11 +169,24 @@ img.expression.default { | |||
| 162 | row-gap: 1rem; | 169 | row-gap: 1rem; |
| 163 | } | 170 | } |
| 164 | 171 | ||
| 165 | #image_list .success { | 172 | #image_list .expression_list_item[data-expression-type="success"] .expression_list_title { |
| 166 | color: green; | 173 | color: green; |
| 167 | } | 174 | } |
| 168 | 175 | ||
| 169 | #image_list .failure { | 176 | #image_list .expression_list_item[data-expression-type="additional"] .expression_list_title { |
| 177 | color: darkolivegreen; | ||
| 178 | } | ||
| 179 | #image_list .expression_list_item[data-expression-type="additional"] .expression_list_title::before { | ||
| 180 | content: '➕'; | ||
| 181 | position: absolute; | ||
| 182 | top: -7px; | ||
| 183 | left: -9px; | ||
| 184 | font-size: 14px; | ||
| 185 | color: transparent; | ||
| 186 | text-shadow: 0 0 0 darkolivegreen; | ||
| 187 | } | ||
| 188 | |||
| 189 | #image_list .expression_list_item[data-expression-type="failure"] .expression_list_title { | ||
| 170 | color: red; | 190 | color: red; |
| 171 | } | 191 | } |
| 172 | 192 | ||
| @@ -189,3 +209,12 @@ img.expression.default { | |||
| 189 | flex-direction: row; | 209 | flex-direction: row; |
| 190 | } | 210 | } |
| 191 | 211 | ||
| 212 | #expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"], | ||
| 213 | #expressions_container:has(#expressions_allow_multiple:not(:checked)) label[for="expressions_reroll_if_same"] { | ||
| 214 | opacity: 0.3; | ||
| 215 | transition: opacity var(--animation-duration) ease; | ||
| 216 | } | ||
| 217 | #expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:hover, | ||
| 218 | #expressions_container:has(#expressions_allow_multiple:not(:checked)) #image_list .expression_list_item[data-expression-type="additional"]:focus { | ||
| 219 | opacity: unset; | ||
| 220 | } | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | <div class="m-b-1" data-i18n="upload_expression_request">Please enter a name for the sprite (without extension).</div> | ||
| 2 | <div class="m-b-1" data-i18n="upload_expression_naming_1"> | ||
| 3 | Sprite names must follow the naming schema for the selected expression: {{expression}} | ||
| 4 | </div> | ||
| 5 | <div data-i18n="upload_expression_naming_2"> | ||
| 6 | For multiple expressions, the name must follow the expression name and a valid suffix. Allowed separators are '-' or dot '.'. | ||
| 7 | </div> | ||
| 8 | <span class="m-b-1" data-i18n="Examples:">Examples:</span> <tt>{{expression}}.png</tt>, <tt>{{expression}}-1.png</tt>, <tt>{{expression}}.expressive.png</tt> | ||
| 9 | {{#if clickedFileName}} | ||
| 10 | <div class="m-t-1" data-i18n="upload_expression_replace">Click 'Replace' to replace the existing expression:</div> | ||
| 11 | <tt>{{clickedFileName}}</tt> | ||
| 12 | {{/if}} | ||
| @@ -605,7 +605,7 @@ const handleOutgoingMessage = createEventHandler(translateOutgoingMessage, () => | |||
| 605 | const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes)); | 605 | const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes)); |
| 606 | const handleMessageEdit = createEventHandler(translateMessageEdit, () => true); | 606 | const handleMessageEdit = createEventHandler(translateMessageEdit, () => true); |
| 607 | 607 | ||
| 608 | window['translate'] = translate; | 608 | globalThis.translate = translate; |
| 609 | 609 | ||
| 610 | jQuery(async () => { | 610 | jQuery(async () => { |
| 611 | const html = await renderExtensionTemplateAsync('translate', 'index'); | 611 | const html = await renderExtensionTemplateAsync('translate', 'index'); |
| @@ -27,14 +27,12 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm | |||
| 27 | import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; | 27 | import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 28 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; | 28 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; |
| 29 | import { GoogleTranslateTtsProvider } from './google-translate.js'; | 29 | import { GoogleTranslateTtsProvider } from './google-translate.js'; |
| 30 | export { talkingAnimation }; | ||
| 31 | 30 | ||
| 32 | const UPDATE_INTERVAL = 1000; | 31 | const UPDATE_INTERVAL = 1000; |
| 33 | const wrapper = new ModuleWorkerWrapper(moduleWorker); | 32 | const wrapper = new ModuleWorkerWrapper(moduleWorker); |
| 34 | 33 | ||
| 35 | let voiceMapEntries = []; | 34 | let voiceMapEntries = []; |
| 36 | let voiceMap = {}; // {charName:voiceid, charName2:voiceid2} | 35 | let voiceMap = {}; // {charName:voiceid, charName2:voiceid2} |
| 37 | let talkingHeadState = false; | ||
| 38 | let lastChatId = null; | 36 | let lastChatId = null; |
| 39 | let lastMessage = null; | 37 | let lastMessage = null; |
| 40 | let lastMessageHash = null; | 38 | let lastMessageHash = null; |
| @@ -166,27 +164,6 @@ async function moduleWorker() { | |||
| 166 | updateUiAudioPlayState(); | 164 | updateUiAudioPlayState(); |
| 167 | } | 165 | } |
| 168 | 166 | ||
| 169 | function talkingAnimation(switchValue) { | ||
| 170 | if (!modules.includes('talkinghead')) { | ||
| 171 | console.debug('Talking Animation module not loaded'); | ||
| 172 | return; | ||
| 173 | } | ||
| 174 | |||
| 175 | const apiUrl = getApiUrl(); | ||
| 176 | const animationType = switchValue ? 'start' : 'stop'; | ||
| 177 | |||
| 178 | if (switchValue !== talkingHeadState) { | ||
| 179 | try { | ||
| 180 | console.log(animationType + ' Talking Animation'); | ||
| 181 | doExtrasFetch(`${apiUrl}/api/talkinghead/${animationType}_talking`); | ||
| 182 | talkingHeadState = switchValue; | ||
| 183 | } catch (error) { | ||
| 184 | // Handle the error here or simply ignore it to prevent logging | ||
| 185 | } | ||
| 186 | } | ||
| 187 | updateUiAudioPlayState(); | ||
| 188 | } | ||
| 189 | |||
| 190 | function resetTtsPlayback() { | 167 | function resetTtsPlayback() { |
| 191 | // Stop system TTS utterance | 168 | // Stop system TTS utterance |
| 192 | cancelTtsPlay(); | 169 | cancelTtsPlay(); |
| @@ -378,7 +355,6 @@ function onAudioControlClicked() { | |||
| 378 | // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful | 355 | // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful |
| 379 | if (!audioElement.paused || isTtsProcessing()) { | 356 | if (!audioElement.paused || isTtsProcessing()) { |
| 380 | resetTtsPlayback(); | 357 | resetTtsPlayback(); |
| 381 | talkingAnimation(false); | ||
| 382 | } else { | 358 | } else { |
| 383 | // Default play behavior if not processing or playing is to play the last message. | 359 | // Default play behavior if not processing or playing is to play the last message. |
| 384 | processAndQueueTtsMessage(context.chat[context.chat.length - 1]); | 360 | processAndQueueTtsMessage(context.chat[context.chat.length - 1]); |
| @@ -405,7 +381,6 @@ function addAudioControl() { | |||
| 405 | function completeCurrentAudioJob() { | 381 | function completeCurrentAudioJob() { |
| 406 | audioQueueProcessorReady = true; | 382 | audioQueueProcessorReady = true; |
| 407 | currentAudioJob = null; | 383 | currentAudioJob = null; |
| 408 | talkingAnimation(false); //stop lip animation | ||
| 409 | // updateUiPlayState(); | 384 | // updateUiPlayState(); |
| 410 | wrapper.update(); | 385 | wrapper.update(); |
| 411 | } | 386 | } |
| @@ -436,7 +411,6 @@ async function processAudioJobQueue() { | |||
| 436 | audioQueueProcessorReady = false; | 411 | audioQueueProcessorReady = false; |
| 437 | currentAudioJob = audioJobQueue.shift(); | 412 | currentAudioJob = audioJobQueue.shift(); |
| 438 | playAudioData(currentAudioJob); | 413 | playAudioData(currentAudioJob); |
| 439 | talkingAnimation(true); | ||
| 440 | } catch (error) { | 414 | } catch (error) { |
| 441 | toastr.error(error.toString()); | 415 | toastr.error(error.toString()); |
| 442 | console.error(error); | 416 | console.error(error); |
| @@ -1,6 +1,5 @@ | |||
| 1 | import { isMobile } from '../../RossAscends-mods.js'; | 1 | import { isMobile } from '../../RossAscends-mods.js'; |
| 2 | import { getPreviewString } from './index.js'; | 2 | import { getPreviewString } from './index.js'; |
| 3 | import { talkingAnimation } from './index.js'; | ||
| 4 | import { saveTtsProviderSettings } from './index.js'; | 3 | import { saveTtsProviderSettings } from './index.js'; |
| 5 | export { SystemTtsProvider }; | 4 | export { SystemTtsProvider }; |
| 6 | 5 | ||
| @@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) { | |||
| 70 | //placing the speak invocation inside a callback fixes ordering and onend issues. | 69 | //placing the speak invocation inside a callback fixes ordering and onend issues. |
| 71 | setTimeout(function () { | 70 | setTimeout(function () { |
| 72 | speechSynthesis.speak(newUtt); | 71 | speechSynthesis.speak(newUtt); |
| 73 | talkingAnimation(true); | ||
| 74 | }, 0); | 72 | }, 0); |
| 75 | }; | 73 | }; |
| 76 | 74 | ||
| @@ -240,7 +238,6 @@ class SystemTtsProvider { | |||
| 240 | //some code to execute when done | 238 | //some code to execute when done |
| 241 | resolve(silence); | 239 | resolve(silence); |
| 242 | console.log('System TTS done'); | 240 | console.log('System TTS done'); |
| 243 | talkingAnimation(false); | ||
| 244 | }); | 241 | }); |
| 245 | }); | 242 | }); |
| 246 | } | 243 | } |
| @@ -561,9 +561,9 @@ async function retrieveFileChunks(queryText, collectionId) { | |||
| 561 | */ | 561 | */ |
| 562 | async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) { | 562 | async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) { |
| 563 | try { | 563 | try { |
| 564 | if (settings.translate_files && typeof window['translate'] === 'function') { | 564 | if (settings.translate_files && typeof globalThis.translate === 'function') { |
| 565 | console.log(`Vectors: Translating file ${fileName} to English...`); | 565 | console.log(`Vectors: Translating file ${fileName} to English...`); |
| 566 | const translatedText = await window['translate'](fileText, 'en'); | 566 | const translatedText = await globalThis.translate(fileText, 'en'); |
| 567 | fileText = translatedText; | 567 | fileText = translatedText; |
| 568 | } | 568 | } |
| 569 | 569 | ||
| @@ -1845,14 +1845,15 @@ async function loadContextSettings() { | |||
| 1845 | 1845 | ||
| 1846 | /** | 1846 | /** |
| 1847 | * Common function to perform fuzzy search with optional caching | 1847 | * Common function to perform fuzzy search with optional caching |
| 1848 | * @template T | ||
| 1848 | * @param {string} type - Type of search from fuzzySearchCategories | 1849 | * @param {string} type - Type of search from fuzzySearchCategories |
| 1849 | * @param {any[]} data - Data array to search in | 1850 | * @param {T[]} data - Data array to search in |
| 1850 | * @param {Array<{name: string, weight: number, getFn?: (obj: any) => string}>} keys - Fuse.js keys configuration | 1851 | * @param {Array<{name: string, weight: number, getFn?: (obj: T) => string}>} keys - Fuse.js keys configuration |
| 1851 | * @param {string} searchValue - The search term | 1852 | * @param {string} searchValue - The search term |
| 1852 | * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches | 1853 | * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches |
| 1853 | * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score | 1854 | * @returns {import('fuse.js').FuseResult<T>[]} Results as items with their score |
| 1854 | */ | 1855 | */ |
| 1855 | function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) { | 1856 | export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) { |
| 1856 | // Check cache if provided | 1857 | // Check cache if provided |
| 1857 | if (fuzzySearchCaches) { | 1858 | if (fuzzySearchCaches) { |
| 1858 | const cache = fuzzySearchCaches[type]; | 1859 | const cache = fuzzySearchCaches[type]; |
| @@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) { | |||
| 125 | .map((file) => { | 125 | .map((file) => { |
| 126 | const pathToSprite = path.join(spritesPath, file); | 126 | const pathToSprite = path.join(spritesPath, file); |
| 127 | const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); | 127 | const mtime = fs.statSync(pathToSprite).mtime?.toISOString().replace(/[^0-9]/g, '').slice(0, 14); |
| 128 | |||
| 129 | const fileName = path.parse(pathToSprite).name.toLowerCase(); | ||
| 130 | // Extract the label from the filename via regex, which can be suffixed with a sub-name, either connected with a dash or a dot. | ||
| 131 | // Examples: joy.png, joy-1.png, joy.expressive.png | ||
| 132 | const label = fileName.match(/^(.+?)(?:[-\\.].*?)?$/)?.[1] ?? fileName; | ||
| 133 | |||
| 128 | return { | 134 | return { |
| 129 | label: path.parse(pathToSprite).name.toLowerCase(), | 135 | label: label, |
| 130 | path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), | 136 | path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), |
| 131 | }; | 137 | }; |
| 132 | }); | 138 | }); |
| @@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) { | |||
| 141 | router.post('/delete', jsonParser, async (request, response) => { | 147 | router.post('/delete', jsonParser, async (request, response) => { |
| 142 | const label = request.body.label; | 148 | const label = request.body.label; |
| 143 | const name = request.body.name; | 149 | const name = request.body.name; |
| 150 | const spriteName = request.body.spriteName || label; | ||
| 144 | 151 | ||
| 145 | if (!label || !name) { | 152 | if (!spriteName || !name) { |
| 146 | return response.sendStatus(400); | 153 | return response.sendStatus(400); |
| 147 | } | 154 | } |
| 148 | 155 | ||
| @@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => { | |||
| 158 | 165 | ||
| 159 | // Remove existing sprite with the same label | 166 | // Remove existing sprite with the same label |
| 160 | for (const file of files) { | 167 | for (const file of files) { |
| 161 | if (path.parse(file).name === label) { | 168 | if (path.parse(file).name === spriteName) { |
| 162 | fs.rmSync(path.join(spritesPath, file)); | 169 | fs.rmSync(path.join(spritesPath, file)); |
| 163 | } | 170 | } |
| 164 | } | 171 | } |
| @@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { | |||
| 221 | const file = request.file; | 228 | const file = request.file; |
| 222 | const label = request.body.label; | 229 | const label = request.body.label; |
| 223 | const name = request.body.name; | 230 | const name = request.body.name; |
| 231 | const spriteName = request.body.spriteName || label; | ||
| 224 | 232 | ||
| 225 | if (!file || !label || !name) { | 233 | if (!file || !label || !name) { |
| 226 | return response.sendStatus(400); | 234 | return response.sendStatus(400); |
| @@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => { | |||
| 243 | 251 | ||
| 244 | // Remove existing sprite with the same label | 252 | // Remove existing sprite with the same label |
| 245 | for (const file of files) { | 253 | for (const file of files) { |
| 246 | if (path.parse(file).name === label) { | 254 | if (path.parse(file).name === spriteName) { |
| 247 | fs.rmSync(path.join(spritesPath, file)); | 255 | fs.rmSync(path.join(spritesPath, file)); |
| 248 | } | 256 | } |
| 249 | } | 257 | } |
| 250 | 258 | ||
| 251 | const filename = label + path.parse(file.originalname).ext; | 259 | const filename = spriteName + path.parse(file.originalname).ext; |
| 252 | const spritePath = path.join(file.destination, file.filename); | 260 | const spritePath = path.join(file.destination, file.filename); |
| 253 | const pathToFile = path.join(spritesPath, filename); | 261 | const pathToFile = path.join(spritesPath, filename); |
| 254 | // Copy uploaded file to sprites folder | 262 | // Copy uploaded file to sprites folder |