Merge pull request #3403 from SillyTavern/support-multiple-expressions Support multiple expressions
Signed| @@ -40,4 +40,12 @@ declare global { | ||
| 40 | 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 | 1602 | "Character Expressions": "Expressions de personnages", |
| 1603 | 1603 | "Translate text to English before classification": "Traduire le texte en anglais avant de le classer", |
| 1604 | 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 | 1605 | "Classifier API": "API de classification", |
| 1607 | 1606 | "Select the API for classifying expressions.": "Sélectionnez l'API pour classer les expressions.", |
| 1608 | 1607 | "Main API": "API principale", |
| @@ -1467,7 +1467,6 @@ | ||
| 1467 | 1467 | "menu within": "내의 메뉴", |
| 1468 | 1468 | "Translate text to English before classification": "분류 전에 텍스트를 영어로 번역합니다.", |
| 1469 | 1469 | "Show default images (emojis) if sprite missing": "해당하는 스프라이트가 없으면 기본 이미지 (이모지들)을 표시합니다.", |
| 1470 | - "Image Type - talkinghead (extras)": "이미지 유형 - 토킹 헤드 (부가 사항)", | |
| 1471 | 1470 | "Classifier API": "분류를 위한 API", |
| 1472 | 1471 | "Select the API for classifying expressions.": "감정 이미지들을 분류할 API를 선택하세요.", |
| 1473 | 1472 | "Local": "로컬", |
| @@ -1349,7 +1349,6 @@ | ||
| 1349 | 1349 | "Character Expressions": "角色表情", |
| 1350 | 1350 | "Translate text to English before classification": "分类之前将文本翻译成英文", |
| 1351 | 1351 | "Show default images (emojis) if sprite missing": "如果表情包缺失,则显示默认图像(表情符号)", |
| 1352 | - "Image Type - talkinghead (extras)": "图像类型 - 说话头像(附加内容)", | |
| 1353 | 1352 | "Classifier API": "分类器 API", |
| 1354 | 1353 | "Select the API for classifying expressions.": "选择用于对表达式进行分类的API。", |
| 1355 | 1354 | "Main API": "主要 API", |
| @@ -1653,7 +1653,6 @@ | ||
| 1653 | 1653 | "HuggingFace Token": "HuggingFace 符元", |
| 1654 | 1654 | "Image Captioning": "圖片註解", |
| 1655 | 1655 | "Generate Caption": "產生圖片註解", |
| 1656 | - "Image Type - talkinghead (extras)": "圖片類型 - talkinghead(額外選項)", | |
| 1657 | 1656 | "Injection Position": "插入位置", |
| 1658 | 1657 | "Injection position. Relative (to other prompts in prompt manager) or In-chat @ Depth.": "插入位置(與提示詞管理器中的其他提示相比)或聊天中的深度位置。", |
| 1659 | 1658 | "Injection Template": "插入範本", |
| @@ -154,8 +154,18 @@ export const extension_settings = { | ||
| 154 | 154 | refine_mode: false, |
| 155 | 155 | }, |
| 156 | 156 | expressions: { |
| 157 | + /** @type {number} see `EXPRESSION_API` */ | |
| 158 | + api: undefined, | |
| 157 | 159 | /** @type {string[]} */ |
| 158 | 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 | 170 | connectionManager: { |
| 161 | 171 | selectedProfile: '', |
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | import { Fuse } from '../../../lib.js'; |
| 2 | 2 | |
| 3 | 3 | import { callPopupcharacters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js'; |
| 4 | 4 | import { dragElement, isMobile } from '../../RossAscends-mods.js'; |
| 5 | 5 | import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 6 | 6 | import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; |
| 7 | 7 | import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; |
| 8 | 8 | import { hideMutedSprites, selected_group } from '../../group-chats.js'; |
| 9 | 9 | import { isJsonSchemaSupported } from '../../textgen-settings.js'; |
| 10 | 10 | import { debounce_timeout } from '../../constants.js'; |
| 11 | 11 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| @@ -15,16 +15,32 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm | ||
| 15 | 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 16 | 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; |
| 17 | 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 18 | +import { Popup, POPUP_RESULT } from '../../popup.js'; | |
| 19 | +import { t } from '../../i18n.js'; | |
| 18 | 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 | 38 | const MODULE_NAME = 'expressions'; |
| 21 | 39 | const UPDATE_INTERVAL = 2000; |
| 22 | 40 | const STREAMING_UPDATE_INTERVAL = 10000; |
| 23 | -const TALKINGCHECK_UPDATE_INTERVAL = 500; | |
| 24 | 41 | const DEFAULT_FALLBACK_EXPRESSION = 'joy'; |
| 25 | 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 | 43 | const DEFAULT_EXPRESSIONS = [ |
| 27 | - 'talkinghead', | |
| 28 | 44 | 'admiration', |
| 29 | 45 | 'amusement', |
| 30 | 46 | 'anger', |
| @@ -54,6 +70,12 @@ const DEFAULT_EXPRESSIONS = [ | ||
| 54 | 70 | 'surprise', |
| 55 | 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 | 79 | /** @enum {number} */ |
| 58 | 80 | const EXPRESSION_API = { |
| 59 | 81 | local: 0, |
| @@ -65,35 +87,29 @@ const EXPRESSION_API = { | ||
| 65 | 87 | let expressionsList = null; |
| 66 | 88 | let lastCharacter = undefined; |
| 67 | 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 | 91 | let spriteCache = {}; |
| 71 | 92 | let inApiCall = false; |
| 72 | 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 | 104 | function toggleTalkingHeadCommandgetPlaceholderImage(_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 | 115 | function isVisualNovelMode() { |
| @@ -108,21 +124,21 @@ async function forceUpdateVisualNovelMode() { | ||
| 108 | 124 | |
| 109 | 125 | const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick); |
| 110 | 126 | |
| 111 | 127 | async function updateVisualNovelMode(namespriteFolderName, expression) { |
| 112 | 128 | const containervnContainer = $('#visual-novel-wrapper'); |
| 113 | 129 | |
| 114 | 130 | await visualNovelRemoveInactive(containervnContainer); |
| 115 | 131 | |
| 116 | 132 | const setSpritePromises = await visualNovelSetCharacterSprites(containervnContainer, namespriteFolderName, expression); |
| 117 | 133 | |
| 118 | 134 | // calculate layer indices based on recent messages |
| 119 | 135 | await visualNovelUpdateLayers(containervnContainer); |
| 120 | 136 | |
| 121 | 137 | await Promise.allSettled(setSpritePromises); |
| 122 | 138 | |
| 123 | 139 | // update again based on new sprites |
| 124 | 140 | if (setSpritePromises.length > 0) { |
| 125 | 141 | await visualNovelUpdateLayers(containervnContainer); |
| 126 | 142 | } |
| 127 | 143 | } |
| 128 | 144 | |
| @@ -153,52 +169,60 @@ async function visualNovelRemoveInactive(container) { | ||
| 153 | 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 | 182 | const context = getContext(); |
| 158 | 183 | const group = context.groups.find(x => x.id == context.groupId); |
| 159 | - const labels = await getExpressionsList(); | |
| 160 | 184 | |
| 161 | - const createCharacterPromises = []; | |
| 162 | 185 | const setSpritePromises = []; |
| 163 | 186 | |
| 164 | 187 | for (const avatar of group.members) { |
| 165 | - const isDisabled = group.disabled_members.includes(avatar); | |
| 166 | - | |
| 167 | 188 | // skip disabled characters |
| 189 | + const isDisabled = group.disabled_members.includes(avatar); | |
| 168 | 190 | if (isDisabled && hideMutedSprites) { |
| 169 | 191 | continue; |
| 170 | 192 | } |
| 171 | 193 | |
| 172 | 194 | const character = context.characters.find(x => x.avatar == avatar); |
| 173 | - | |
| 174 | 195 | if (!character) { |
| 175 | 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 | 205 | // download images if not downloaded yet |
| 181 | 206 | if (spriteCache[spriteFolderNamememberSpriteFolderName] === undefined) { |
| 182 | 207 | spriteCache[spriteFolderNamememberSpriteFolderName] = await getSpritesList(spriteFolderNamememberSpriteFolderName); |
| 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 | 212 | if (expressionImage!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 | 223 | await setImage(img, path); |
| 200 | 224 | } |
| 201 | 225 | expressionImage.toggleClass('hidden', noSprites!spriteFile); |
| 202 | 226 | } else { |
| 203 | 227 | const template = $('#expression-holder').clone(); |
| 204 | 228 | template.attr('id', `expression-${avatar}`); |
| @@ -206,21 +230,49 @@ async function visualNovelSetCharacterSprites(container, name, expression) { | ||
| 206 | 230 | template.find('.drag-grabber').attr('id', `expression-${avatar}header`); |
| 207 | 231 | $('#visual-novel-wrapper').append(template); |
| 208 | 232 | dragElement($(template[0])); |
| 209 | 233 | template.toggleClass('hidden', noSprites!spriteFile); |
| 210 | 234 | awaitimg setImage(= template.find('img'), defaultSpritePath || ''); |
| 235 | + await setImage(img, spriteFile?.imageSrc || ''); | |
| 211 | 236 | const fadeInPromise = new Promise(resolve => { |
| 212 | 237 | template.fadeIn(250, () => resolve()); |
| 213 | 238 | }); |
| 214 | 239 | createCharacterPromisessetSpritePromises.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 | 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 | 276 | async function visualNovelUpdateLayers(container) { |
| 225 | 277 | const context = getContext(); |
| 226 | 278 | const group = context.groups.find(x => x.id == context.groupId); |
| @@ -256,11 +308,17 @@ async function visualNovelUpdateLayers(container) { | ||
| 256 | 308 | const containerWidth = container.width(); |
| 257 | 309 | const pivotalPoint = containerWidth * 0.5; |
| 258 | 310 | |
| 259 | 311 | let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction); |
| 260 | 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 | 324 | let totalWidth = imagesWidth.reduce((a, b) => a + b, 0); |
| @@ -274,7 +332,7 @@ async function visualNovelUpdateLayers(container) { | ||
| 274 | 332 | currentPosition = 0; // Reset the initial position to 0 |
| 275 | 333 | } |
| 276 | 334 | |
| 277 | 335 | images.sort(sortFunction).eachforEach((indexcurrent, currentindex) => { |
| 278 | 336 | const element = $(current); |
| 279 | 337 | const elementID = element.attr('id'); |
| 280 | 338 | |
| @@ -294,9 +352,15 @@ async function visualNovelUpdateLayers(container) { | ||
| 294 | 352 | element.show(); |
| 295 | 353 | |
| 296 | 354 | const promise = new Promise(resolve => { |
| 297 | - element.animate({ left: currentPosition + 'px' }, 500, () => { | |
| 355 | + if (power_user.reduced_motion) { | |
| 298 | - resolve(); | |
| 356 | + element.css('left', currentPosition + 'px'); | |
| 299 | - }); | |
| 357 | + requestAnimationFrame(() => resolve()); | |
| 358 | + } | |
| 359 | + else { | |
| 360 | + element.animate({ left: currentPosition + 'px' }, 500, () => { | |
| 361 | + resolve(); | |
| 362 | + }); | |
| 363 | + } | |
| 300 | 364 | }); |
| 301 | 365 | |
| 302 | 366 | currentPosition += imagesWidth[index]; |
| @@ -307,23 +371,12 @@ async function visualNovelUpdateLayers(container) { | ||
| 307 | 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 | 380 | async function setImage(img, path) { |
| 328 | 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 | 393 | return new Promise(resolve => { |
| 341 | 394 | const prevExpressionSrc = img.attr('src'); |
| 342 | 395 | const expressionClone = img.clone(); |
| 343 | 396 | const originalId = img.attrdata('idfilename'); |
| 344 | 397 | |
| 345 | 398 | //only swap expressions when necessary |
| 346 | 399 | if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { |
| @@ -348,7 +401,7 @@ async function setImage(img, path) { | ||
| 348 | 401 | expressionClone.addClass('expression-clone'); |
| 349 | 402 | //make invisible and remove id to prevent double ids |
| 350 | 403 | //must be made invisible to start because they share the same Z-index |
| 351 | 404 | expressionClone.attrdata('idfilename', '').css({ opacity: 0 }); |
| 352 | 405 | //add new sprite path to clone src |
| 353 | 406 | expressionClone.attr('src', path); |
| 354 | 407 | //add invisible clone to html |
| @@ -384,14 +437,18 @@ async function setImage(img, path) { | ||
| 384 | 437 | //remove old expression |
| 385 | 438 | img.remove(); |
| 386 | 439 | //replace ID so it becomes the new 'original' expression for next change |
| 387 | 440 | expressionClone.attrdata('idfilename', originalId); |
| 388 | 441 | expressionClone.removeClass('expression-animating'); |
| 389 | 442 | |
| 390 | 443 | // Reset the expression holder min height and width |
| 391 | 444 | expressionHolder.css('min-width', 100); |
| 392 | 445 | expressionHolder.css('min-height', 100); |
| 393 | 446 | |
| 394 | - resolve(); | |
| 447 | + if (expressionClone.prop('complete')) { | |
| 448 | + resolve(); | |
| 449 | + } else { | |
| 450 | + expressionClone.one('load', () => resolve()); | |
| 451 | + } | |
| 395 | 452 | }); |
| 396 | 453 | |
| 397 | 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 | 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 | 473 | // non-characters not supported |
| 624 | 474 | if (!context.groupId && context.characterId === undefined) { |
| 625 | 475 | removeExpression(); |
| @@ -646,7 +496,7 @@ async function moduleWorker() { | ||
| 646 | 496 | } |
| 647 | 497 | |
| 648 | 498 | const currentLastMessage = getLastCharacterMessage(); |
| 649 | 499 | let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName(); |
| 650 | 500 | |
| 651 | 501 | // character has no expressions or it is not loaded |
| 652 | 502 | if (Object.keys(spriteCache).length === 0) { |
| @@ -686,6 +536,10 @@ async function moduleWorker() { | ||
| 686 | 536 | offlineMode.css('display', 'none'); |
| 687 | 537 | } |
| 688 | 538 | |
| 539 | + if (context.groupId && vnMode && newChat) { | |
| 540 | + await forceUpdateVisualNovelMode(); | |
| 541 | + } | |
| 542 | + | |
| 689 | 543 | // Don't bother classifying if current char has no sprites and no default expressions are enabled |
| 690 | 544 | if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) { |
| 691 | 545 | return; |
| @@ -732,11 +586,11 @@ async function moduleWorker() { | ||
| 732 | 586 | const force = !!context.groupId; |
| 733 | 587 | |
| 734 | 588 | // Character won't be angry on you for swiping |
| 735 | 589 | if (currentLastMessage.mes == '...' && expressionsList.includes(getFallbackExpression()extension_settings.expressions.fallback_expression)) { |
| 736 | 590 | expression = getFallbackExpression()extension_settings.expressions.fallback_expression; |
| 737 | 591 | } |
| 738 | 592 | |
| 739 | 593 | await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode }); |
| 740 | 594 | } |
| 741 | 595 | catch (error) { |
| 742 | 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 | 606 | function getSpriteFolderName(characterMessage = null, characterName = null) { |
| 838 | 607 | const context = getContext(); |
| 839 | 608 | let spriteFolderName = characterName ?? context.name2; |
| @@ -848,33 +617,6 @@ function getSpriteFolderName(characterMessage = null, characterName = null) { | ||
| 848 | 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 | 620 | function getFolderNameByMessage(message) { |
| 879 | 621 | const context = getContext(); |
| 880 | 622 | let avatarPath = ''; |
| @@ -894,48 +636,55 @@ function getFolderNameByMessage(message) { | ||
| 894 | 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 | 652 | vnMode = isVisualNovelMode(); |
| 901 | 653 | } |
| 902 | 654 | |
| 903 | 655 | if (vnMode) { |
| 904 | 656 | await updateVisualNovelMode(namespriteFolderName, expression); |
| 905 | 657 | } else { |
| 906 | 658 | setExpression(namespriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile }); |
| 907 | 659 | } |
| 908 | 660 | } |
| 909 | 661 | |
| 910 | 662 | async function setSpriteSetCommandsetSpriteFolderCommand(_, folder) { |
| 911 | 663 | if (!folder) { |
| 912 | 664 | console.log('Clearing sprite set'); |
| 913 | 665 | folder = ''; |
| 914 | 666 | } |
| 915 | 667 | |
| 916 | 668 | if (folder.startsWith('/') || folder.startsWith('\\')) { |
| 917 | - folder = folder.slice(1); | |
| 918 | - | |
| 919 | 669 | const currentLastMessage = getLastCharacterMessage(); |
| 670 | + folder = folder.slice(1); | |
| 920 | 671 | folder = `${currentLastMessage.name}/${folder}`; |
| 921 | 672 | } |
| 922 | 673 | |
| 923 | 674 | $('#expression_override').val(folder.trim()); |
| 924 | 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 | 678 | return ''; |
| 930 | 679 | } |
| 931 | 680 | |
| 932 | 681 | async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) { |
| 933 | 682 | if (!text) { |
| 934 | 683 | toastr.warningerror('No text provided'); |
| 935 | 684 | return ''; |
| 936 | 685 | } |
| 937 | 686 | if (api && !Object.keys(EXPRESSION_API).includes(api)) { |
| 938 | 687 | toastr.warningerror('Invalid API provided'); |
| 939 | 688 | return ''; |
| 940 | 689 | } |
| 941 | 690 | |
| @@ -951,37 +700,69 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { | ||
| 951 | 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 | 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); | |
| 961 | 714 | |
| 962 | - // In Talkinghead mode, don't check for the existence of the sprite | |
| 715 | + let label = searchTerm; | |
| 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); | |
| 969 | 716 | |
| 970 | - // Fuzzy search for sprite | |
| 717 | + /** @type {string?} */ | |
| 971 | - const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] }); | |
| 718 | + let spriteFile = null; | |
| 972 | - const results = fuse.search(spriteId); | |
| 973 | - const spriteItem = results[0]?.item; | |
| 974 | 719 | |
| 975 | - if (!spriteItem) { | |
| 720 | + await validateImages(spriteFolderName); | |
| 976 | - console.log('No sprite found for search term ' + spriteId); | |
| 977 | - return ''; | |
| 978 | - } | |
| 979 | 721 | |
| 980 | - label = spriteItem.label; | |
| 722 | + // Handle reset as a special term and just reset the sprite via expression call | |
| 723 | + if (searchTerm === RESET_SPRITE_LABEL) { | |
| 724 | + await sendExpressionCall(spriteFolderName, label, { force: true }); | |
| 725 | + return lastExpression[spriteFolderName] ?? ''; | |
| 981 | 726 | } |
| 982 | 727 | |
| 983 | - const vnMode = isVisualNovelMode(); | |
| 728 | + switch (type) { | |
| 984 | - await sendExpressionCall(spriteFolderName, label, true, vnMode); | |
| 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 | + } | |
| 740 | + | |
| 741 | + label = matchedExpression.label; | |
| 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`); | |
| 754 | + return ''; | |
| 755 | + } | |
| 756 | + | |
| 757 | + label = matchedSprite.expression; | |
| 758 | + spriteFile = matchedSprite.fileName; | |
| 759 | + break; | |
| 760 | + } | |
| 761 | + default: throw Error('Invalid sprite set type: ' + type); | |
| 762 | + } | |
| 763 | + | |
| 764 | + await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile }); | |
| 765 | + | |
| 985 | 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 | 798 | * Slash command callback for /uploadsprite |
| 1003 | 799 | * |
| 1004 | 800 | * label= is required |
| @@ -1011,16 +807,29 @@ function spriteFolderNameFromCharacter(char) { | ||
| 1011 | 807 | * @param {object} args |
| 1012 | 808 | * @param {string} args.name Character name or avatar key, passed through findChar |
| 1013 | 809 | * @param {string} args.label Expression label |
| 1014 | 810 | * @param {string} [args.folder=null] SpriteOptional sprite folder path, processed using backslash rules |
| 811 | + * @param {string?} [args.spriteName=null] Optional sprite name | |
| 1015 | 812 | * @param {string} imageUrl Image URI to fetch and upload |
| 1016 | 813 | * @returns {Promise<voidstring>} the sprite name |
| 1017 | 814 | */ |
| 1018 | 815 | async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) { |
| 1019 | 816 | if (!imageUrl) throw new Error('Image URL is required'); |
| 1020 | 817 | if (!label || typeof label !== 'string') throw new Error('Expression label is required');{ |
| 818 | + toastr.error(t`Expression label is required`, t`Error Uploading Sprite`); | |
| 819 | + return ''; | |
| 820 | + } | |
| 1021 | 821 | |
| 1022 | 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 | 834 | name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; |
| 1026 | 835 | const char = findChar({ name }); |
| @@ -1040,7 +849,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | ||
| 1040 | 849 | const formData = new FormData(); |
| 1041 | 850 | formData.append('name', folder); // this is the folder or character name |
| 1042 | 851 | formData.append('label', label); // this is the expression label |
| 1043 | 852 | formData.append('avatar', file); // this is the image file |
| 853 | + formData.append('spriteName', spriteName); // this is a redundant comment | |
| 1044 | 854 | |
| 1045 | 855 | await handleFileUpload('/api/sprites/upload', formData); |
| 1046 | 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 | 858 | console.error(`[${MODULE_NAME}] Error uploading file:`, error); |
| 1049 | 859 | throw error; |
| 1050 | 860 | } |
| 861 | + | |
| 862 | + return spriteName; | |
| 1051 | 863 | } |
| 1052 | 864 | |
| 1053 | 865 | /** |
| @@ -1159,7 +971,7 @@ function getJsonSchema(emotions) { | ||
| 1159 | 971 | function onTextGenSettingsReady(args) { |
| 1160 | 972 | // Only call if inside an API call |
| 1161 | 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 | 975 | Object.assign(args, { |
| 1164 | 976 | top_k: 1, |
| 1165 | 977 | stop: [], |
| @@ -1177,16 +989,16 @@ function onTextGenSettingsReady(args) { | ||
| 1177 | 989 | * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification. |
| 1178 | 990 | * @param {object} [options={}] - Optional arguments. |
| 1179 | 991 | * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification. |
| 1180 | 992 | * @returns {Promise<string?>} - The label of the expression. |
| 1181 | 993 | */ |
| 1182 | 994 | export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { customPrompt = null } = {}) { |
| 1183 | 995 | // Return if text is undefined, saving a costly fetch request |
| 1184 | 996 | if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) { |
| 1185 | 997 | return getFallbackExpression()extension_settings.expressions.fallback_expression; |
| 1186 | 998 | } |
| 1187 | 999 | |
| 1188 | 1000 | if (extension_settings.expressions.translate && typeof window['globalThis.translate'] === 'function') { |
| 1189 | 1001 | text = await window['globalThis.translate'](text, 'en'); |
| 1190 | 1002 | } |
| 1191 | 1003 | |
| 1192 | 1004 | text = sampleClassifyText(text); |
| @@ -1212,7 +1024,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | ||
| 1212 | 1024 | await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250); |
| 1213 | 1025 | } catch (error) { |
| 1214 | 1026 | console.warn('No LLM connection. Using fallback expression', error); |
| 1215 | 1027 | return getFallbackExpression()extension_settings.expressions.fallback_expression; |
| 1216 | 1028 | } |
| 1217 | 1029 | |
| 1218 | 1030 | const expressionsList = await getExpressionsList(); |
| @@ -1225,7 +1037,7 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | ||
| 1225 | 1037 | case EXPRESSION_API.webllm: { |
| 1226 | 1038 | if (!isWebLlmSupported()) { |
| 1227 | 1039 | console.warn('WebLLM is not supported. Using fallback expression'); |
| 1228 | 1040 | return getFallbackExpression()extension_settings.expressions.fallback_expression; |
| 1229 | 1041 | } |
| 1230 | 1042 | |
| 1231 | 1043 | const expressionsList = await getExpressionsList(); |
| @@ -1258,9 +1070,9 @@ export async function getExpressionLabel(text, expressionsApi = extension_settin | ||
| 1258 | 1070 | } break; |
| 1259 | 1071 | } |
| 1260 | 1072 | } catch (error) { |
| 1261 | 1073 | toastr.infoerror('Could not classify expression. Check the console or your backend for more information.'); |
| 1262 | 1074 | console.error(error); |
| 1263 | 1075 | return getFallbackExpression()extension_settings.expressions.fallback_expression; |
| 1264 | 1076 | } |
| 1265 | 1077 | } |
| 1266 | 1078 | |
| @@ -1288,75 +1100,155 @@ function removeExpression() { | ||
| 1288 | 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 | 1110 | return; |
| 1294 | 1111 | } |
| 1295 | 1112 | |
| 1296 | 1113 | const labels = await getExpressionsList(); |
| 1297 | 1114 | |
| 1298 | 1115 | if (spriteCache[characterspriteFolderName]) { |
| 1299 | 1116 | if (forceRedrawCached && $('#image_list').data('name') !== characterspriteFolderName) { |
| 1300 | 1117 | console.debug('force redrawing character sprites list'); |
| 1301 | 1118 | await drawSpritesList(characterspriteFolderName, labels, spriteCache[characterspriteFolderName]); |
| 1302 | 1119 | } |
| 1303 | 1120 | |
| 1304 | 1121 | return; |
| 1305 | 1122 | } |
| 1306 | 1123 | |
| 1307 | 1124 | const sprites = await getSpritesList(characterspriteFolderName); |
| 1308 | 1125 | let validExpressions = await drawSpritesList(characterspriteFolderName, labels, sprites); |
| 1309 | 1126 | spriteCache[characterspriteFolderName] = 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 | 1156 | let validExpressions = []; |
| 1157 | + | |
| 1314 | 1158 | $('#no_chat_expressions').hide(); |
| 1315 | 1159 | $('#open_chat_expressions').show(); |
| 1316 | 1160 | $('#image_list').empty(); |
| 1317 | 1161 | $('#image_list').data('name', characterspriteFolderName); |
| 1318 | 1162 | $('#image_list_header_name').text(characterspriteFolderName); |
| 1319 | 1163 | |
| 1320 | 1164 | if (!Array.isArray(labels)) { |
| 1321 | 1165 | return []; |
| 1322 | 1166 | } |
| 1323 | 1167 | |
| 1324 | 1168 | for (const itemexpression 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 | |
| 1171 | + .filter(s => s.label === expression) | |
| 1172 | + .map(s => s.files) | |
| 1173 | + .flat(); | |
| 1327 | 1174 | |
| 1328 | 1175 | if (spriteimages.length === 0) { |
| 1329 | - validExpressions.push(sprite); | |
| 1176 | + const listItem = await getListItem(expression, { | |
| 1330 | - const listItem = await getListItem(item, sprite.path, 'success', isCustom); | |
| 1177 | + isCustom, | |
| 1331 | - $('#image_list').append(listItem); | |
| 1178 | + images: [getPlaceholderImage(expression, isCustom)], | |
| 1332 | - } | |
| 1179 | + }); | |
| 1333 | - else { | |
| 1334 | - const listItem = await getListItem(item, '/img/No-Image-Placeholder.svg', 'failure', isCustom); | |
| 1335 | 1180 | $('#image_list').append(listItem); |
| 1181 | + continue; | |
| 1336 | 1182 | } |
| 1183 | + | |
| 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 | + }); | |
| 1191 | + $('#image_list').append(listItem); | |
| 1337 | 1192 | } |
| 1338 | 1193 | return validExpressions; |
| 1339 | 1194 | } |
| 1340 | 1195 | |
| 1341 | 1196 | /** |
| 1342 | 1197 | * Renders a list item template for the expressions list. |
| 1343 | 1198 | * @param {string} itemexpression Expression name |
| 1344 | 1199 | * @param {stringobject} imageSrc Pathargs toArguments imageobject |
| 1345 | - * @param {'success' | 'failure'} textClass 'success' or 'failure' | |
| 1200 | + * @param {ExpressionImage[]} [args.images] Array of image objects | |
| 1346 | 1201 | * @param {boolean} [args.isCustom=false] If expression is added by user |
| 1347 | 1202 | * @returns {Promise<string>} Rendered list item template |
| 1348 | 1203 | */ |
| 1349 | 1204 | async function getListItem(itemexpression, imageSrc,{ textClassimages, isCustom = false } = {}) { |
| 1350 | 1205 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { itemexpression, imageSrcimages, textClass,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 | 1216 | async function getSpritesList(name) { |
| 1354 | 1217 | console.debug('getting sprites list'); |
| 1355 | 1218 | |
| 1356 | 1219 | try { |
| 1357 | 1220 | const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`); |
| 1221 | + /** @type {{ label: string, path: string }[]} */ | |
| 1358 | 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 | 1253 | catch (err) { |
| 1362 | 1254 | console.log(err); |
| @@ -1395,17 +1287,31 @@ async function renderFallbackExpressionPicker() { | ||
| 1395 | 1287 | const defaultPicker = $('#expression_fallback'); |
| 1396 | 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 | 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 | 1300 | const option = document.createElement('option'); |
| 1402 | 1301 | option.value = expressionvalue; |
| 1403 | 1302 | option.text = expressionlabel; |
| 1404 | 1303 | option.selected = expression == fallbackExpressionisSelected; |
| 1405 | 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 | 1315 | function getCachedExpressions() { |
| 1410 | 1316 | if (!Array.isArray(expressionsList)) { |
| 1411 | 1317 | return []; |
| @@ -1463,7 +1369,7 @@ export async function getExpressionsList() { | ||
| 1463 | 1369 | } |
| 1464 | 1370 | |
| 1465 | 1371 | // If there was no specific list, or an error, just return the default expressions |
| 1466 | 1372 | expressionsList = DEFAULT_EXPRESSIONS.filter(e => e !== 'talkinghead').slice(); |
| 1467 | 1373 | return expressionsList; |
| 1468 | 1374 | } |
| 1469 | 1375 | |
| @@ -1471,161 +1377,221 @@ export async function getExpressionsList() { | ||
| 1471 | 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. | |
| 1478 | - const img = $('img.expression'); | |
| 1384 | + * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same. | |
| 1479 | - const prevExpressionSrc = img.attr('src'); | |
| 1385 | + * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one. | |
| 1480 | - const expressionClone = img.clone(); | |
| 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; | |
| 1481 | 1397 | |
| 1482 | - const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression)); | |
| 1398 | + // Search for sprites of that expression - or fallback expression sprites if enabled | |
| 1483 | - console.debug('checking for expression images to show..'); | |
| 1399 | + let sprite = spriteCache[spriteFolderName].find(x => x.label === expression); | |
| 1484 | - if (sprite) { | |
| 1400 | + if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) { | |
| 1485 | - console.debug('setting expression from character images folder'); | |
| 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; | |
| 1486 | 1405 | |
| 1487 | - if (force && isVisualNovelMode()) { | |
| 1406 | + let spriteFile = sprite.files[0]; | |
| 1488 | - const context = getContext(); | |
| 1489 | - const group = context.groups.find(x => x.id === context.groupId); | |
| 1490 | 1407 | |
| 1491 | - for (const member of group.members) { | |
| 1408 | + // If a specific sprite file should be set, we are looking it up here | |
| 1492 | - const groupMember = context.characters.find(x => x.avatar === member); | |
| 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 | + } | |
| 1493 | 1422 | |
| 1494 | - if (!groupMember) { | |
| 1423 | + return spriteFile; | |
| 1495 | - continue; | |
| 1496 | - } | |
| 1497 | 1424 | |
| 1498 | - if (groupMember.name == character) { | |
| 1425 | +} | |
| 1499 | - await setImage($(`.expression-holder[data-avatar="${member}"] img`), sprite.path); | |
| 1426 | + | |
| 1500 | - return; | |
| 1427 | +/** | |
| 1501 | - } | |
| 1428 | + * Set the expression of a character. | |
| 1502 | - } | |
| 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); | |
| 1438 | + const img = $('img.expression'); | |
| 1439 | + const prevExpressionSrc = img.attr('src'); | |
| 1440 | + const expressionClone = img.clone(); | |
| 1441 | + | |
| 1442 | + const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile }); | |
| 1443 | + if (spriteFile) { | |
| 1444 | + if (force && isVisualNovelMode()) { | |
| 1445 | + const context = getContext(); | |
| 1446 | + const group = context.groups.find(x => x.id === context.groupId); | |
| 1447 | + | |
| 1448 | + // If it's a folder, make sure we find the group member based on the actual name | |
| 1449 | + const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName; | |
| 1450 | + | |
| 1451 | + const groupMember = group.members | |
| 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); | |
| 1456 | + return; | |
| 1503 | 1457 | } |
| 1504 | - //only swap expressions when necessary | |
| 1458 | + } | |
| 1505 | - if (prevExpressionSrc !== sprite.path | |
| 1506 | - && !img.hasClass('expression-animating')) { | |
| 1507 | - //clone expression | |
| 1508 | - expressionClone.addClass('expression-clone'); | |
| 1509 | - //make invisible and remove id to prevent double ids | |
| 1510 | - //must be made invisible to start because they share the same Z-index | |
| 1511 | - expressionClone.attr('id', '').css({ opacity: 0 }); | |
| 1512 | - //add new sprite path to clone src | |
| 1513 | - expressionClone.attr('src', sprite.path); | |
| 1514 | - //add invisible clone to html | |
| 1515 | - expressionClone.appendTo($('#expression-holder')); | |
| 1516 | - | |
| 1517 | - const duration = 200; | |
| 1518 | - | |
| 1519 | - //add animation flags to both images | |
| 1520 | - //to prevent multiple expression changes happening simultaneously | |
| 1521 | - img.addClass('expression-animating'); | |
| 1522 | - | |
| 1523 | - // Set the parent container's min width and height before running the transition | |
| 1524 | - const imgWidth = img.width(); | |
| 1525 | - const imgHeight = img.height(); | |
| 1526 | - const expressionHolder = img.parent(); | |
| 1527 | - expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100); | |
| 1528 | - expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100); | |
| 1529 | - | |
| 1530 | - //position absolute prevent the original from jumping around during transition | |
| 1531 | - img.css('position', 'absolute').width(imgWidth).height(imgHeight); | |
| 1532 | - expressionClone.addClass('expression-animating'); | |
| 1533 | - //fade the clone in | |
| 1534 | - expressionClone.css({ | |
| 1535 | - opacity: 0, | |
| 1536 | - }).animate({ | |
| 1537 | - opacity: 1, | |
| 1538 | - }, duration) | |
| 1539 | - //when finshed fading in clone, fade out the original | |
| 1540 | - .promise().done(function () { | |
| 1541 | - img.animate({ | |
| 1542 | - opacity: 0, | |
| 1543 | - }, duration); | |
| 1544 | - //remove old expression | |
| 1545 | - img.remove(); | |
| 1546 | - //replace ID so it becomes the new 'original' expression for next change | |
| 1547 | - expressionClone.attr('id', 'expression-image'); | |
| 1548 | - expressionClone.removeClass('expression-animating'); | |
| 1549 | - | |
| 1550 | - // Reset the expression holder min height and width | |
| 1551 | - expressionHolder.css('min-width', 100); | |
| 1552 | - expressionHolder.css('min-height', 100); | |
| 1553 | - }); | |
| 1554 | 1459 | |
| 1460 | + //only swap expressions when necessary | |
| 1461 | + if (prevExpressionSrc !== spriteFile.imageSrc | |
| 1462 | + && !img.hasClass('expression-animating')) { | |
| 1463 | + //clone expression | |
| 1464 | + expressionClone.addClass('expression-clone'); | |
| 1465 | + //make invisible and remove id to prevent double ids | |
| 1466 | + //must be made invisible to start because they share the same Z-index | |
| 1467 | + expressionClone.attr('id', '').css({ opacity: 0 }); | |
| 1468 | + //add new sprite path to clone src | |
| 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); | |
| 1475 | + //add invisible clone to html | |
| 1476 | + expressionClone.appendTo($('#expression-holder')); | |
| 1555 | 1477 | |
| 1556 | - expressionClone.removeClass('expression-clone'); | |
| 1478 | + const duration = 200; | |
| 1557 | 1479 | |
| 1558 | - expressionClone.removeClass('default'); | |
| 1480 | + //add animation flags to both images | |
| 1559 | - expressionClone.off('error'); | |
| 1481 | + //to prevent multiple expression changes happening simultaneously | |
| 1560 | - expressionClone.on('error', function () { | |
| 1482 | + img.addClass('expression-animating'); | |
| 1561 | - console.debug('Expression image error', sprite.path); | |
| 1562 | - $(this).attr('src', ''); | |
| 1563 | - $(this).off('error'); | |
| 1564 | - if (force && extension_settings.expressions.showDefault) { | |
| 1565 | - setDefault(); | |
| 1566 | - } | |
| 1567 | - }); | |
| 1568 | - } | |
| 1569 | - } | |
| 1570 | - else { | |
| 1571 | - if (extension_settings.expressions.showDefault) { | |
| 1572 | - setDefault(); | |
| 1573 | - } | |
| 1574 | - } | |
| 1575 | 1483 | |
| 1576 | - function setDefault() { | |
| 1484 | + // Set the parent container's min width and height before running the transition | |
| 1577 | - console.debug('setting default'); | |
| 1485 | + const imgWidth = img.width(); | |
| 1578 | 1486 | const defImgUrlimgHeight = `/img/default-expressions/${expression}.png`height(); |
| 1579 | - //console.log(defImgUrl); | |
| 1487 | + const expressionHolder = img.parent(); | |
| 1580 | - img.attr('src', defImgUrl); | |
| 1488 | + expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100); | |
| 1581 | - img.addClass('default'); | |
| 1489 | + expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100); | |
| 1582 | - } | |
| 1583 | - document.getElementById('expression-holder').style.display = ''; | |
| 1584 | 1490 | |
| 1585 | - } else { | |
| 1491 | + //position absolute prevent the original from jumping around during transition | |
| 1586 | - // Set the Talkinghead emotion to the specified expression | |
| 1492 | + img.css('position', 'absolute').width(imgWidth).height(imgHeight); | |
| 1587 | - // TODO: For now, Talkinghead emote only supported when VN mode is off; see also updateVisualNovelMode. | |
| 1493 | + expressionClone.addClass('expression-animating'); | |
| 1588 | - try { | |
| 1494 | + //fade the clone in | |
| 1589 | - let result = await isTalkingHeadAvailable(); | |
| 1495 | + expressionClone.css({ | |
| 1590 | - if (result) { | |
| 1496 | + opacity: 0, | |
| 1591 | - const url = new URL(getApiUrl()); | |
| 1497 | + }).animate({ | |
| 1592 | - url.pathname = '/api/talkinghead/set_emotion'; | |
| 1498 | + opacity: 1, | |
| 1593 | - await doExtrasFetch(url, { | |
| 1499 | + }, duration) | |
| 1594 | - method: 'POST', | |
| 1500 | + //when finshed fading in clone, fade out the original | |
| 1595 | - headers: { | |
| 1501 | + .promise().done(function () { | |
| 1596 | - 'Content-Type': 'application/json', | |
| 1502 | + img.animate({ | |
| 1597 | - }, | |
| 1503 | + opacity: 0, | |
| 1598 | - body: JSON.stringify({ emotion_name: expression }), | |
| 1504 | + }, duration); | |
| 1505 | + //remove old expression | |
| 1506 | + img.remove(); | |
| 1507 | + //replace ID so it becomes the new 'original' expression for next change | |
| 1508 | + expressionClone.attr('id', 'expression-image'); | |
| 1509 | + expressionClone.removeClass('expression-animating'); | |
| 1510 | + | |
| 1511 | + // Reset the expression holder min height and width | |
| 1512 | + expressionHolder.css('min-width', 100); | |
| 1513 | + expressionHolder.css('min-height', 100); | |
| 1599 | 1514 | }); |
| 1600 | - } | |
| 1601 | - } | |
| 1602 | - catch (error) { | |
| 1603 | - // `set_emotion` is not present in old versions, so let it 404. | |
| 1604 | - } | |
| 1605 | 1515 | |
| 1606 | - try { | |
| 1516 | + expressionClone.removeClass('expression-clone'); | |
| 1607 | - // Find the <img> element with id="expression-image" and class="expression" | |
| 1517 | + | |
| 1608 | - const imgElement = document.querySelector('img#expression-image.expression'); | |
| 1518 | + expressionClone.removeClass('default'); | |
| 1609 | - //console.log("searching"); | |
| 1519 | + expressionClone.off('error'); | |
| 1610 | - if (imgElement && imgElement instanceof HTMLImageElement) { | |
| 1520 | + expressionClone.on('error', function (error) { | |
| 1611 | - //console.log("setting value"); | |
| 1521 | + console.debug('Expression image error', spriteFile.imageSrc, error); | |
| 1612 | - imgElement.src = getApiUrl() + '/api/talkinghead/result_feed'; | |
| 1522 | + $(this).attr('src', ''); | |
| 1613 | - } | |
| 1523 | + $(this).off('error'); | |
| 1524 | + if (force && extension_settings.expressions.showDefault) { | |
| 1525 | + setDefaultEmojiForImage(img, expression); | |
| 1526 | + } | |
| 1527 | + }); | |
| 1614 | 1528 | } |
| 1615 | - catch (error) { | |
| 1529 | + | |
| 1616 | - //console.log("The fetch failed!"); | |
| 1530 | + console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName }); | |
| 1531 | + } | |
| 1532 | + else { | |
| 1533 | + img.attr('data-sprite-folder-name', spriteFolderName); | |
| 1534 | + | |
| 1535 | + img.off('error'); | |
| 1536 | + | |
| 1537 | + if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) { | |
| 1538 | + setDefaultEmojiForImage(img, expression); | |
| 1539 | + } else { | |
| 1540 | + setNoneForImage(img, expression); | |
| 1617 | 1541 | } |
| 1542 | + console.debug('Expression unset - No sprite found', { expression: expression }); | |
| 1618 | 1543 | } |
| 1544 | + | |
| 1545 | + document.getElementById('expression-holder').style.display = ''; | |
| 1546 | +} | |
| 1547 | + | |
| 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; | |
| 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'); | |
| 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 | 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 | 1592 | async function onClickExpressionAddCustom() { |
| 1627 | 1593 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression'); |
| 1628 | 1594 | let expressionName = await callPopupPopup.show.input(templatenull, 'input'template); |
| 1629 | 1595 | |
| 1630 | 1596 | if (!expressionName) { |
| 1631 | 1597 | console.debug('No custom expression name provided'); |
| @@ -1636,19 +1602,15 @@ async function onClickExpressionAddCustom() { | ||
| 1636 | 1602 | |
| 1637 | 1603 | // a-z, 0-9, dashes and underscores only |
| 1638 | 1604 | if (!/^[a-z0-9-_]+$/.test(expressionName)) { |
| 1639 | 1605 | toastr.infowarning('Invalid custom expression name provided', 'Add Custom Expression'); |
| 1640 | 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 | 1610 | return; |
| 1647 | 1611 | } |
| 1648 | - | |
| 1649 | - // Check if expression name already exists in custom expressions | |
| 1650 | 1612 | if (extension_settings.expressions.custom.includes(expressionName)) { |
| 1651 | 1613 | toastr.infowarning('Custom expression already exists', 'Add Custom Expression'); |
| 1652 | 1614 | return; |
| 1653 | 1615 | } |
| 1654 | 1616 | |
| @@ -1665,14 +1627,15 @@ async function onClickExpressionAddCustom() { | ||
| 1665 | 1627 | |
| 1666 | 1628 | async function onClickExpressionRemoveCustom() { |
| 1667 | 1629 | const selectedExpression = String($('#expression_custom').val()); |
| 1630 | + const noCustomExpressions = extension_settings.expressions.custom.length === 0; | |
| 1668 | 1631 | |
| 1669 | 1632 | if (!selectedExpression || noCustomExpressions) { |
| 1670 | 1633 | console.debug('No custom expression selected'); |
| 1671 | 1634 | return; |
| 1672 | 1635 | } |
| 1673 | 1636 | |
| 1674 | 1637 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }); |
| 1675 | 1638 | const confirmation = await callPopupPopup.show.confirm(templatenull, 'confirm'template); |
| 1676 | 1639 | |
| 1677 | 1640 | if (!confirmation) { |
| 1678 | 1641 | console.debug('Custom expression removal cancelled'); |
| @@ -1682,8 +1645,8 @@ async function onClickExpressionRemoveCustom() { | ||
| 1682 | 1645 | // Remove custom expression from settings |
| 1683 | 1646 | const index = extension_settings.expressions.custom.indexOf(selectedExpression); |
| 1684 | 1647 | extension_settings.expressions.custom.splice(index, 1); |
| 1685 | 1648 | if (selectedExpression == getFallbackExpression()extension_settings.expressions.fallback_expression) { |
| 1686 | 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 | 1650 | extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION; |
| 1688 | 1651 | } |
| 1689 | 1652 | await renderAdditionalExpressionSettings(); |
| @@ -1707,12 +1670,35 @@ function onExpressionApiChanged() { | ||
| 1707 | 1670 | } |
| 1708 | 1671 | } |
| 1709 | 1672 | |
| 1710 | 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 | 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 | 1743 | async function onClickExpressionUpload(event) { |
| 1743 | 1744 | // Prevents the expression from being set |
| 1744 | 1745 | event.stopPropagation(); |
| 1745 | 1746 | |
| 1746 | 1747 | const idexpressionListItem = $(this).closest('.expression_list_item').attr('id'); |
| 1748 | + | |
| 1749 | + const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null; | |
| 1750 | + const expression = expressionListItem.data('expression'); | |
| 1747 | 1751 | const name = $('#image_list').data('name'); |
| 1748 | 1752 | |
| 1749 | 1753 | const handleExpressionUploadChange = async (e) => { |
| 1750 | 1754 | const file = e.target.files[0]; |
| 1751 | 1755 | |
| 1752 | 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 | 1820 | return; |
| 1754 | 1821 | } |
| 1755 | 1822 | |
| 1756 | 1823 | const formData = new FormData(); |
| 1757 | 1824 | formData.append('name', name); |
| 1758 | 1825 | formData.append('label', idexpression); |
| 1759 | 1826 | formData.append('avatar', file); |
| 1827 | + formData.append('spriteName', spriteName); | |
| 1760 | 1828 | |
| 1761 | 1829 | await handleFileUpload('/api/sprites/upload', formData); |
| 1762 | 1830 | |
| 1763 | 1831 | // Reset the input |
| 1764 | 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 | 1835 | $('#expression_upload') |
| @@ -1822,8 +1885,9 @@ async function onClickExpressionOverrideButton() { | ||
| 1822 | 1885 | inApiCall = true; |
| 1823 | 1886 | $('#visual-novel-wrapper').empty(); |
| 1824 | 1887 | await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true); |
| 1888 | + const name = overridePath.length === 0 ? currentLastMessage.name : overridePath; | |
| 1825 | 1889 | const expression = await getExpressionLabel(currentLastMessage.mes); |
| 1826 | 1890 | await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, { force: true }); |
| 1827 | 1891 | forceUpdateVisualNovelMode(); |
| 1828 | 1892 | } catch (error) { |
| 1829 | 1893 | console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`); |
| @@ -1849,7 +1913,7 @@ async function onClickExpressionOverrideRemoveAllButton() { | ||
| 1849 | 1913 | const currentLastMessage = getLastCharacterMessage(); |
| 1850 | 1914 | await validateImages(currentLastMessage.name, true); |
| 1851 | 1915 | const expression = await getExpressionLabel(currentLastMessage.mes); |
| 1852 | 1916 | await sendExpressionCall(currentLastMessage.name, expression, { force: true }); |
| 1853 | 1917 | forceUpdateVisualNovelMode(); |
| 1854 | 1918 | |
| 1855 | 1919 | console.debug(extension_settings.expressionOverrides); |
| @@ -1872,16 +1936,13 @@ async function onClickExpressionUploadPackButton() { | ||
| 1872 | 1936 | formData.append('name', name); |
| 1873 | 1937 | formData.append('avatar', file); |
| 1874 | 1938 | |
| 1939 | + const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 }); | |
| 1875 | 1940 | const { count } = await handleFileUpload('/api/sprites/upload-zip', formData); |
| 1941 | + toastr.clear(uploadToast); | |
| 1876 | 1942 | toastr.success(`Uploaded ${count} image(s) for ${name}`); |
| 1877 | 1943 | |
| 1878 | 1944 | // Reset the input |
| 1879 | 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 | 1948 | $('#expression_upload_pack') |
| @@ -1894,20 +1955,28 @@ async function onClickExpressionDelete(event) { | ||
| 1894 | 1955 | // Prevents the expression from being set |
| 1895 | 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 | 1968 | if (!confirmation) { |
| 1900 | 1969 | return; |
| 1901 | 1970 | } |
| 1902 | 1971 | |
| 1903 | 1972 | const idfileName = $(this).closestwithoutExtension('.expression_list_item')expressionListItem.attr('iddata-filename')); |
| 1904 | 1973 | const name = $('#image_list').data('name'); |
| 1905 | 1974 | |
| 1906 | 1975 | try { |
| 1907 | 1976 | await fetch('/api/sprites/delete', { |
| 1908 | 1977 | method: 'POST', |
| 1909 | 1978 | headers: getRequestHeaders(), |
| 1910 | 1979 | body: JSON.stringify({ name, label: idexpression, spriteName: fileName }), |
| 1911 | 1980 | }); |
| 1912 | 1981 | } catch (error) { |
| 1913 | 1982 | toastr.error('Failed to delete image. Try again later.'); |
| @@ -1984,6 +2053,16 @@ function migrateSettings() { | ||
| 1984 | 2053 | extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; |
| 1985 | 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 | 2068 | (async function () { |
| @@ -2010,13 +2089,19 @@ function migrateSettings() { | ||
| 2010 | 2089 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); |
| 2011 | 2090 | $('#expressions_container').append(template); |
| 2012 | 2091 | $('#expression_override_button').on('click', onClickExpressionOverrideButton); |
| 2013 | - $('#expressions_show_default').on('input', onExpressionsShowDefaultInput); | |
| 2014 | 2092 | $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton); |
| 2015 | - $('#expressions_show_default').prop('checked', extension_settings.expressions.showDefault).trigger('input'); | |
| 2016 | 2093 | $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () { |
| 2017 | 2094 | extension_settings.expressions.translate = !!$(this).prop('checked'); |
| 2018 | 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 | 2105 | $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton); |
| 2021 | 2106 | $(document).on('dragstart', '.expression', (e) => { |
| 2022 | 2107 | e.preventDefault(); |
| @@ -2025,21 +2110,15 @@ function migrateSettings() { | ||
| 2025 | 2110 | $(document).on('click', '.expression_list_item', onClickExpressionImage); |
| 2026 | 2111 | $(document).on('click', '.expression_list_upload', onClickExpressionUpload); |
| 2027 | 2112 | $(document).on('click', '.expression_list_delete', onClickExpressionDelete); |
| 2028 | 2113 | $(window).on('resize', () => updateVisualNovelModeDebounced()); |
| 2029 | 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 | 2116 | await renderAdditionalExpressionSettings(); |
| 2038 | 2117 | $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.extras); |
| 2039 | 2118 | $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); |
| 2040 | 2119 | $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? ''); |
| 2041 | 2120 | $('#expression_llm_prompt').on('input', function () { |
| 2042 | 2121 | extension_settings.expressions.llmPrompt = String($(this).val()); |
| 2043 | 2122 | saveSettingsDebounced(); |
| 2044 | 2123 | }); |
| 2045 | 2124 | $('#expression_llm_prompt_restore').on('click', function () { |
| @@ -2054,34 +2133,6 @@ function migrateSettings() { | ||
| 2054 | 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 | 2136 | addExpressionImage(); |
| 2086 | 2137 | addVisualNovelMode(); |
| 2087 | 2138 | migrateSettings(); |
| @@ -2090,11 +2141,6 @@ function migrateSettings() { | ||
| 2090 | 2141 | const updateFunction = wrapper.update.bind(wrapper); |
| 2091 | 2142 | setInterval(updateFunction, UPDATE_INTERVAL); |
| 2092 | 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 | 2144 | dragElement($('#expression-holder')); |
| 2099 | 2145 | eventSource.on(event_types.CHAT_CHANGED, () => { |
| 2100 | 2146 | // character changed |
| @@ -2108,110 +2154,137 @@ function migrateSettings() { | ||
| 2108 | 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 | 2157 | setExpressionOverrideHtml(); |
| 2118 | 2158 | |
| 2119 | 2159 | if (isVisualNovelMode()) { |
| 2120 | 2160 | $('#visual-novel-wrapper').empty(); |
| 2121 | 2161 | } |
| 2122 | 2162 | |
| 2123 | 2163 | updateFunction({ newChat: true }); |
| 2124 | 2164 | }); |
| 2125 | 2165 | eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced); |
| 2126 | 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 | 2168 | const localEnumProviders = { |
| 2134 | 2169 | expressions: () => getCachedExpressions().map(expression => { |
| 2135 | 2170 | const isCustomcurrentLastMessage = extension_settings.expressions.customselected_group ?.includes getLastCharacterMessage(expression) : null; |
| 2136 | - return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D'); | |
| 2171 | + const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); | |
| 2137 | - }), | |
| 2172 | + const expressions = getCachedExpressions(); | |
| 2173 | + return expressions.map(expression => { | |
| 2174 | + const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0; | |
| 2175 | + const isCustom = extension_settings.expressions.custom?.includes(expression); | |
| 2176 | + const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' : | |
| 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 | 2197 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2141 | 2198 | name: 'spriteexpression-set', |
| 2142 | 2199 | aliases: ['sprite', 'emote'], |
| 2143 | 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 | 2211 | unnamedArgumentList: [ |
| 2145 | 2212 | SlashCommandArgument.fromProps({ |
| 2146 | 2213 | description: 'spriteIdexpression label to set', |
| 2147 | 2214 | typeList: [ARGUMENT_TYPE.STRING], |
| 2148 | 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 | 2227 | helpString: 'Force sets the spriteexpression for the current character.', |
| 2153 | 2228 | returns: 'theThe currently set spriteexpression label after setting it.', |
| 2154 | 2229 | })); |
| 2155 | 2230 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2156 | 2231 | name: 'spriteoverrideexpression-folder-override', |
| 2157 | 2232 | aliases: ['spriteoverride', 'costume'], |
| 2158 | 2233 | callback: setSpriteSetCommandsetSpriteFolderCommand, |
| 2159 | 2234 | unnamedArgumentList: [ |
| 2160 | 2235 | new SlashCommandArgument( |
| 2161 | 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 | 2249 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2167 | 2250 | name: 'lastspriteexpression-last', |
| 2168 | - callback: (_, name) => { | |
| 2251 | + aliases: ['lastsprite'], | |
| 2252 | + /** @type {(args: object, name: string) => Promise<string>} */ | |
| 2253 | + callback: async (_, name) => { | |
| 2169 | 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 | 2263 | const char = findChar({ name: name }); |
| 2264 | + if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`); | |
| 2265 | + | |
| 2171 | 2266 | const sprite = lastExpression[char?.name ?? name] ?? ''; |
| 2172 | 2267 | return sprite; |
| 2173 | 2268 | }, |
| 2174 | 2269 | returns: 'the last set sprite / expression for the named character.', |
| 2175 | 2270 | unnamedArgumentList: [ |
| 2176 | 2271 | SlashCommandArgument.fromProps({ |
| 2177 | 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 | 2273 | typeList: [ARGUMENT_TYPE.STRING], |
| 2179 | - isRequired: true, | |
| 2180 | 2274 | enumProvider: commonEnumProviders.characters('character'), |
| 2181 | 2275 | }), |
| 2182 | 2276 | ], |
| 2183 | 2277 | helpString: 'Returns the last set sprite / 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 | 2279 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2193 | 2280 | name: 'classifyexpression-expressionslist', |
| 2194 | 2281 | aliases: ['expressions'], |
| 2282 | + /** @type {(args: {return: string}) => Promise<string>} */ | |
| 2195 | 2283 | callback: async (args) => { |
| 2196 | - /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ | |
| 2284 | + let returnType = | |
| 2197 | - // @ts-ignore | |
| 2285 | + /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ | |
| 2198 | - let returnType = args.return; | |
| 2286 | + (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 | 2288 | const list = await getExpressionsList(); |
| 2216 | 2289 | |
| 2217 | 2290 | return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); |
| @@ -2225,22 +2298,13 @@ function migrateSettings() { | ||
| 2225 | 2298 | enumList: slashCommandReturnHelper.enumList({ allowObject: true }), |
| 2226 | 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 | 2302 | returns: 'The comma-separated list of available expressions, including custom expressions.', |
| 2240 | 2303 | helpString: 'Returns a list of available expressions, including custom expressions.', |
| 2241 | 2304 | })); |
| 2242 | 2305 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2243 | 2306 | name: 'expression-classify', |
| 2307 | + aliases: ['classify'], | |
| 2244 | 2308 | callback: classifyCallback, |
| 2245 | 2309 | namedArgumentList: [ |
| 2246 | 2310 | SlashCommandNamedArgument.fromProps({ |
| @@ -2279,11 +2343,13 @@ function migrateSettings() { | ||
| 2279 | 2343 | `, |
| 2280 | 2344 | })); |
| 2281 | 2345 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2282 | 2346 | name: 'uploadspriteexpression-upload', |
| 2347 | + aliases: ['uploadsprite'], | |
| 2348 | + /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */ | |
| 2283 | 2349 | callback: async (args, url) => { |
| 2284 | 2350 | return await uploadSpriteCommand(args, url); |
| 2285 | - return ''; | |
| 2286 | 2351 | }, |
| 2352 | + returns: 'the resulting sprite name', | |
| 2287 | 2353 | unnamedArgumentList: [ |
| 2288 | 2354 | SlashCommandArgument.fromProps({ |
| 2289 | 2355 | description: 'URL of the image to upload', |
| @@ -2297,7 +2363,6 @@ function migrateSettings() { | ||
| 2297 | 2363 | description: 'Character name or avatar key (default is current character)', |
| 2298 | 2364 | typeList: [ARGUMENT_TYPE.STRING], |
| 2299 | 2365 | isRequired: false, |
| 2300 | - acceptsMultiple: false, | |
| 2301 | 2366 | }), |
| 2302 | 2367 | SlashCommandNamedArgument.fromProps({ |
| 2303 | 2368 | name: 'label', |
| @@ -2305,16 +2370,32 @@ function migrateSettings() { | ||
| 2305 | 2370 | typeList: [ARGUMENT_TYPE.STRING], |
| 2306 | 2371 | enumProvider: localEnumProviders.expressions, |
| 2307 | 2372 | isRequired: true, |
| 2308 | - acceptsMultiple: false, | |
| 2309 | 2373 | }), |
| 2310 | 2374 | SlashCommandNamedArgument.fromProps({ |
| 2311 | 2375 | name: 'folder', |
| 2312 | 2376 | description: 'Override folder to upload into', |
| 2313 | 2377 | typeList: [ARGUMENT_TYPE.STRING], |
| 2314 | 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 | 3 | <div class="expression_list_buttons"> |
| 3 | 4 | <div class="menu_button expression_list_upload" title="Upload image"> |
| 4 | 5 | <i class="fa-solid fa-upload"></i> |
| @@ -7,11 +8,14 @@ | ||
| 7 | 8 | <i class="fa-solid fa-trash"></i> |
| 8 | 9 | </div> |
| 9 | 10 | </div> |
| 10 | 11 | <div class="expression_list_title {{textClass}}"> |
| 11 | 12 | <span>{{item../expression}}</span> |
| 12 | 13 | {{#if ../isCustom}} |
| 13 | 14 | <small class="expression_list_custom">(custom)</small> |
| 14 | 15 | {{/if}} |
| 15 | 16 | </div> |
| 16 | 17 | <imgdiv class="expression_list_imageexpression_list_image_container" srctitle="{{imageSrcthis.title}}" /> |
| 18 | + <img class="expression_list_image" src="{{this.imageSrc}}" alt="{{this.title}}" data-epression="{{../expression}}" /> | |
| 19 | + </div> | |
| 17 | 20 | </div> |
| 21 | +{{/each}} | |
| @@ -6,17 +6,17 @@ | ||
| 6 | 6 | </div> |
| 7 | 7 | |
| 8 | 8 | <div class="inline-drawer-content"> |
| 9 | 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 | 10 | <input id="expression_translate" type="checkbox"> |
| 11 | 11 | <span data-i18n="Translate text to English before classification">Translate text to English before classification</span> |
| 12 | 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 | 14 | <input id="expressions_show_defaultexpressions_allow_multiple" type="checkbox"> |
| 15 | 15 | <span data-i18n="Show default imagesAllow (emojis)multiple ifsprites spriteper missingexpression">Show default imagesAllow (emojis)multiple ifsprites spriteper missingexpression</span> |
| 16 | 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 | 18 | <input id="image_type_toggleexpressions_reroll_if_same" type="checkbox"> |
| 19 | 19 | <span data-i18n="ImageRe-roll Typeif -same talkingheadexpression (extras)is used again">ImageRe-roll Typeif -same talkingheadsprite (extras)is used again</span> |
| 20 | 20 | </label> |
| 21 | 21 | <div class="expression_api_block m-b-1 m-t-1"> |
| 22 | 22 | <label for="expression_api" data-i18n="Classifier API">Classifier API</label> |
| @@ -75,8 +75,20 @@ | ||
| 75 | 75 | <span data-i18n="Remove all image overrides">Remove all image overrides</span> |
| 76 | 76 | </div> |
| 77 | 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 | 92 | <h3 id="image_list_header"> |
| 81 | 93 | <strong data-i18n="Sprite set:">Sprite set:</strong> <span id="image_list_header_name"></span> |
| 82 | 94 | </h3> |
| @@ -111,6 +111,10 @@ img.expression.default { | ||
| 111 | 111 | justify-content: center; |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | +.expression_list_image_container { | |
| 115 | + overflow: hidden; | |
| 116 | +} | |
| 117 | + | |
| 114 | 118 | .expression_list_title { |
| 115 | 119 | position: absolute; |
| 116 | 120 | bottom: 0; |
| @@ -126,6 +130,9 @@ img.expression.default { | ||
| 126 | 130 | flex-direction: column; |
| 127 | 131 | line-height: 1; |
| 128 | 132 | } |
| 133 | +.expression_list_custom { | |
| 134 | + font-size: 0.66rem; | |
| 135 | +} | |
| 129 | 136 | |
| 130 | 137 | .expression_list_buttons { |
| 131 | 138 | position: absolute; |
| @@ -162,11 +169,24 @@ img.expression.default { | ||
| 162 | 169 | row-gap: 1rem; |
| 163 | 170 | } |
| 164 | 171 | |
| 165 | 172 | #image_list .expression_list_item[data-expression-type="success"] .expression_list_title { |
| 166 | 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 | 190 | color: red; |
| 171 | 191 | } |
| 172 | 192 | |
| @@ -189,3 +209,12 @@ img.expression.default { | ||
| 189 | 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 | 605 | const handleImpersonateReady = createEventHandler(translateImpersonate, () => shouldTranslate(incomingTypes)); |
| 606 | 606 | const handleMessageEdit = createEventHandler(translateMessageEdit, () => true); |
| 607 | 607 | |
| 608 | 608 | window['globalThis.translate'] = translate; |
| 609 | 609 | |
| 610 | 610 | jQuery(async () => { |
| 611 | 611 | const html = await renderExtensionTemplateAsync('translate', 'index'); |
| @@ -27,14 +27,12 @@ import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashComm | ||
| 27 | 27 | import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 28 | 28 | import { POPUP_TYPE, callGenericPopup } from '../../popup.js'; |
| 29 | 29 | import { GoogleTranslateTtsProvider } from './google-translate.js'; |
| 30 | -export { talkingAnimation }; | |
| 31 | 30 | |
| 32 | 31 | const UPDATE_INTERVAL = 1000; |
| 33 | 32 | const wrapper = new ModuleWorkerWrapper(moduleWorker); |
| 34 | 33 | |
| 35 | 34 | let voiceMapEntries = []; |
| 36 | 35 | let voiceMap = {}; // {charName:voiceid, charName2:voiceid2} |
| 37 | -let talkingHeadState = false; | |
| 38 | 36 | let lastChatId = null; |
| 39 | 37 | let lastMessage = null; |
| 40 | 38 | let lastMessageHash = null; |
| @@ -166,27 +164,6 @@ async function moduleWorker() { | ||
| 166 | 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 | 167 | function resetTtsPlayback() { |
| 191 | 168 | // Stop system TTS utterance |
| 192 | 169 | cancelTtsPlay(); |
| @@ -378,7 +355,6 @@ function onAudioControlClicked() { | ||
| 378 | 355 | // Not pausing, doing a full stop to anything TTS is doing. Better UX as pause is not as useful |
| 379 | 356 | if (!audioElement.paused || isTtsProcessing()) { |
| 380 | 357 | resetTtsPlayback(); |
| 381 | - talkingAnimation(false); | |
| 382 | 358 | } else { |
| 383 | 359 | // Default play behavior if not processing or playing is to play the last message. |
| 384 | 360 | processAndQueueTtsMessage(context.chat[context.chat.length - 1]); |
| @@ -405,7 +381,6 @@ function addAudioControl() { | ||
| 405 | 381 | function completeCurrentAudioJob() { |
| 406 | 382 | audioQueueProcessorReady = true; |
| 407 | 383 | currentAudioJob = null; |
| 408 | - talkingAnimation(false); //stop lip animation | |
| 409 | 384 | // updateUiPlayState(); |
| 410 | 385 | wrapper.update(); |
| 411 | 386 | } |
| @@ -436,7 +411,6 @@ async function processAudioJobQueue() { | ||
| 436 | 411 | audioQueueProcessorReady = false; |
| 437 | 412 | currentAudioJob = audioJobQueue.shift(); |
| 438 | 413 | playAudioData(currentAudioJob); |
| 439 | - talkingAnimation(true); | |
| 440 | 414 | } catch (error) { |
| 441 | 415 | toastr.error(error.toString()); |
| 442 | 416 | console.error(error); |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | import { isMobile } from '../../RossAscends-mods.js'; |
| 2 | 2 | import { getPreviewString } from './index.js'; |
| 3 | -import { talkingAnimation } from './index.js'; | |
| 4 | 3 | import { saveTtsProviderSettings } from './index.js'; |
| 5 | 4 | export { SystemTtsProvider }; |
| 6 | 5 | |
| @@ -70,7 +69,6 @@ var speechUtteranceChunker = function (utt, settings, callback) { | ||
| 70 | 69 | //placing the speak invocation inside a callback fixes ordering and onend issues. |
| 71 | 70 | setTimeout(function () { |
| 72 | 71 | speechSynthesis.speak(newUtt); |
| 73 | - talkingAnimation(true); | |
| 74 | 72 | }, 0); |
| 75 | 73 | }; |
| 76 | 74 | |
| @@ -240,7 +238,6 @@ class SystemTtsProvider { | ||
| 240 | 238 | //some code to execute when done |
| 241 | 239 | resolve(silence); |
| 242 | 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 | 562 | async function vectorizeFile(fileText, fileName, collectionId, chunkSize, overlapPercent) { |
| 563 | 563 | try { |
| 564 | 564 | if (settings.translate_files && typeof window['globalThis.translate'] === 'function') { |
| 565 | 565 | console.log(`Vectors: Translating file ${fileName} to English...`); |
| 566 | 566 | const translatedText = await window['globalThis.translate'](fileText, 'en'); |
| 567 | 567 | fileText = translatedText; |
| 568 | 568 | } |
| 569 | 569 | |
| @@ -1845,14 +1845,15 @@ async function loadContextSettings() { | ||
| 1845 | 1845 | |
| 1846 | 1846 | /** |
| 1847 | 1847 | * Common function to perform fuzzy search with optional caching |
| 1848 | + * @template T | |
| 1848 | 1849 | * @param {string} type - Type of search from fuzzySearchCategories |
| 1849 | 1850 | * @param {anyT[]} data - Data array to search in |
| 1850 | 1851 | * @param {Array<{name: string, weight: number, getFn?: (obj: anyT) => string}>} keys - Fuse.js keys configuration |
| 1851 | 1852 | * @param {string} searchValue - The search term |
| 1852 | 1853 | * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches |
| 1853 | 1854 | * @returns {import('fuse.js').FuseResult<anyT>[]} Results as items with their score |
| 1854 | 1855 | */ |
| 1855 | 1856 | export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) { |
| 1856 | 1857 | // Check cache if provided |
| 1857 | 1858 | if (fuzzySearchCaches) { |
| 1858 | 1859 | const cache = fuzzySearchCaches[type]; |
| @@ -125,8 +125,14 @@ router.get('/get', jsonParser, function (request, response) { | ||
| 125 | 125 | .map((file) => { |
| 126 | 126 | const pathToSprite = path.join(spritesPath, file); |
| 127 | 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 | 134 | return { |
| 129 | - label: path.parse(pathToSprite).name.toLowerCase(), | |
| 135 | + label: label, | |
| 130 | 136 | path: `/characters/${name}/${file}` + (mtime ? `?t=${mtime}` : ''), |
| 131 | 137 | }; |
| 132 | 138 | }); |
| @@ -141,8 +147,9 @@ router.get('/get', jsonParser, function (request, response) { | ||
| 141 | 147 | router.post('/delete', jsonParser, async (request, response) => { |
| 142 | 148 | const label = request.body.label; |
| 143 | 149 | const name = request.body.name; |
| 150 | + const spriteName = request.body.spriteName || label; | |
| 144 | 151 | |
| 145 | 152 | if (!labelspriteName || !name) { |
| 146 | 153 | return response.sendStatus(400); |
| 147 | 154 | } |
| 148 | 155 | |
| @@ -158,7 +165,7 @@ router.post('/delete', jsonParser, async (request, response) => { | ||
| 158 | 165 | |
| 159 | 166 | // Remove existing sprite with the same label |
| 160 | 167 | for (const file of files) { |
| 161 | 168 | if (path.parse(file).name === labelspriteName) { |
| 162 | 169 | fs.rmSync(path.join(spritesPath, file)); |
| 163 | 170 | } |
| 164 | 171 | } |
| @@ -221,6 +228,7 @@ router.post('/upload', urlencodedParser, async (request, response) => { | ||
| 221 | 228 | const file = request.file; |
| 222 | 229 | const label = request.body.label; |
| 223 | 230 | const name = request.body.name; |
| 231 | + const spriteName = request.body.spriteName || label; | |
| 224 | 232 | |
| 225 | 233 | if (!file || !label || !name) { |
| 226 | 234 | return response.sendStatus(400); |
| @@ -243,12 +251,12 @@ router.post('/upload', urlencodedParser, async (request, response) => { | ||
| 243 | 251 | |
| 244 | 252 | // Remove existing sprite with the same label |
| 245 | 253 | for (const file of files) { |
| 246 | 254 | if (path.parse(file).name === labelspriteName) { |
| 247 | 255 | fs.rmSync(path.join(spritesPath, file)); |
| 248 | 256 | } |
| 249 | 257 | } |
| 250 | 258 | |
| 251 | 259 | const filename = labelspriteName + path.parse(file.originalname).ext; |
| 252 | 260 | const spritePath = path.join(file.destination, file.filename); |
| 253 | 261 | const pathToFile = path.join(spritesPath, filename); |
| 254 | 262 | // Copy uploaded file to sprites folder |