| 1 | import { Fuse } from '../../../lib.js'; |
| 2 | |
| 3 | import { characters, eventSource, event_types, generateQuietPrompt, generateRaw, getRequestHeaders, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js'; |
| 4 | import { dragElement, isMobile } from '../../RossAscends-mods.js'; |
| 5 | import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 6 | import { loadMovingUIState, performFuzzySearch, power_user } from '../../power-user.js'; |
| 7 | import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar, isFalseBoolean, includesIgnoreCaseAndAccents } from '../../utils.js'; |
| 8 | import { hideMutedSprites, selected_group } from '../../group-chats.js'; |
| 9 | import { isJsonSchemaSupported } from '../../textgen-settings.js'; |
| 10 | import { debounce_timeout } from '../../constants.js'; |
| 11 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| 12 | import { SlashCommand } from '../../slash-commands/SlashCommand.js'; |
| 13 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 14 | import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 15 | import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 16 | import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js'; |
| 17 | import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js'; |
| 18 | import { Popup, POPUP_RESULT } from '../../popup.js'; |
| 19 | import { t } from '../../i18n.js'; |
| 20 | import { removeReasoningFromString } from '../../reasoning.js'; |
| 21 | export { MODULE_NAME }; |
| 22 | |
| 23 | /** |
| 24 | * @typedef {object} Expression Expression definition with label and file path |
| 25 | * @property {string} label The label of the expression |
| 26 | * @property {ExpressionImage[]} files One or more images to represent this expression |
| 27 | */ |
| 28 | |
| 29 | /** |
| 30 | * @typedef {object} ExpressionImage An expression image |
| 31 | * @property {string} expression - The expression |
| 32 | * @property {boolean} [isCustom=false] - If the expression is added by user |
| 33 | * @property {string} fileName - The filename with extension |
| 34 | * @property {string} title - The title for the image |
| 35 | * @property {string} imageSrc - The image source / full path |
| 36 | * @property {'success' | 'additional' | 'failure'} type - The type of the image |
| 37 | */ |
| 38 | |
| 39 | const MODULE_NAME = 'expressions'; |
| 40 | const UPDATE_INTERVAL = 2000; |
| 41 | const STREAMING_UPDATE_INTERVAL = 10000; |
| 42 | const DEFAULT_FALLBACK_EXPRESSION = 'joy'; |
| 43 | 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}}'; |
| 44 | const DEFAULT_EXPRESSIONS = [ |
| 45 | 'admiration', |
| 46 | 'amusement', |
| 47 | 'anger', |
| 48 | 'annoyance', |
| 49 | 'approval', |
| 50 | 'caring', |
| 51 | 'confusion', |
| 52 | 'curiosity', |
| 53 | 'desire', |
| 54 | 'disappointment', |
| 55 | 'disapproval', |
| 56 | 'disgust', |
| 57 | 'embarrassment', |
| 58 | 'excitement', |
| 59 | 'fear', |
| 60 | 'gratitude', |
| 61 | 'grief', |
| 62 | 'joy', |
| 63 | 'love', |
| 64 | 'nervousness', |
| 65 | 'optimism', |
| 66 | 'pride', |
| 67 | 'realization', |
| 68 | 'relief', |
| 69 | 'remorse', |
| 70 | 'sadness', |
| 71 | 'surprise', |
| 72 | 'neutral', |
| 73 | ]; |
| 74 | |
| 75 | const OPTION_NO_FALLBACK = '#none'; |
| 76 | const OPTION_EMOJI_FALLBACK = '#emoji'; |
| 77 | const RESET_SPRITE_LABEL = '#reset'; |
| 78 | |
| 79 | |
| 80 | /** @enum {number} */ |
| 81 | const EXPRESSION_API = { |
| 82 | local: 0, |
| 83 | extras: 1, |
| 84 | llm: 2, |
| 85 | webllm: 3, |
| 86 | none: 99, |
| 87 | }; |
| 88 | |
| 89 | /** @enum {string} */ |
| 90 | const PROMPT_TYPE = { |
| 91 | raw: 'raw', |
| 92 | full: 'full', |
| 93 | }; |
| 94 | |
| 95 | let expressionsList = null; |
| 96 | let lastCharacter = undefined; |
| 97 | let lastMessage = null; |
| 98 | /** @type {{[characterKey: string]: Expression[]}} */ |
| 99 | let spriteCache = {}; |
| 100 | let inApiCall = false; |
| 101 | let lastServerResponseTime = 0; |
| 102 | |
| 103 | /** @type {{[characterName: string]: string}} */ |
| 104 | export let lastExpression = {}; |
| 105 | |
| 106 | /** |
| 107 | * Returns a placeholder image object for a given expression |
| 108 | * @param {string} expression - The expression label |
| 109 | * @param {boolean} [isCustom=false] - Whether the expression is custom |
| 110 | * @returns {ExpressionImage} The placeholder image object |
| 111 | */ |
| 112 | function getPlaceholderImage(expression, isCustom = false) { |
| 113 | return { |
| 114 | expression: expression, |
| 115 | isCustom: isCustom, |
| 116 | title: 'No Image', |
| 117 | type: 'failure', |
| 118 | fileName: 'No-Image-Placeholder.svg', |
| 119 | imageSrc: '/img/No-Image-Placeholder.svg', |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | function isVisualNovelMode() { |
| 124 | return Boolean(!isMobile() && power_user.waifuMode && getContext().groupId); |
| 125 | } |
| 126 | |
| 127 | async function forceUpdateVisualNovelMode() { |
| 128 | if (isVisualNovelMode()) { |
| 129 | await updateVisualNovelMode(); |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | const updateVisualNovelModeDebounced = debounce(forceUpdateVisualNovelMode, debounce_timeout.quick); |
| 134 | |
| 135 | async function updateVisualNovelMode(spriteFolderName, expression) { |
| 136 | const vnContainer = $('#visual-novel-wrapper'); |
| 137 | |
| 138 | await visualNovelRemoveInactive(vnContainer); |
| 139 | |
| 140 | const setSpritePromises = await visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression); |
| 141 | |
| 142 | // calculate layer indices based on recent messages |
| 143 | await visualNovelUpdateLayers(vnContainer); |
| 144 | |
| 145 | await Promise.allSettled(setSpritePromises); |
| 146 | |
| 147 | // update again based on new sprites |
| 148 | if (setSpritePromises.length > 0) { |
| 149 | await visualNovelUpdateLayers(vnContainer); |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | async function visualNovelRemoveInactive(container) { |
| 154 | const context = getContext(); |
| 155 | const group = context.groups.find(x => x.id == context.groupId); |
| 156 | const removeInactiveCharactersPromises = []; |
| 157 | |
| 158 | // remove inactive characters after 1 second |
| 159 | container.find('.expression-holder').each((_, current) => { |
| 160 | const promise = new Promise(resolve => { |
| 161 | const element = $(current); |
| 162 | const avatar = element.data('avatar'); |
| 163 | |
| 164 | if (!group.members.includes(avatar) || group.disabled_members.includes(avatar)) { |
| 165 | element.fadeOut(250, () => { |
| 166 | element.remove(); |
| 167 | resolve(); |
| 168 | }); |
| 169 | } else { |
| 170 | resolve(); |
| 171 | } |
| 172 | }); |
| 173 | |
| 174 | removeInactiveCharactersPromises.push(promise); |
| 175 | }); |
| 176 | |
| 177 | await Promise.allSettled(removeInactiveCharactersPromises); |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Sets the character sprites for visual novel mode based on the provided container, name, and expression. |
| 182 | * |
| 183 | * @param {JQuery<HTMLElement>} vnContainer - The container element where the sprites will be set |
| 184 | * @param {string} spriteFolderName - The name of the sprite folder |
| 185 | * @param {string} expression - The expression to set for the characters |
| 186 | * @returns {Promise<Array>} - An array of promises that resolve when the sprites are set |
| 187 | */ |
| 188 | async function visualNovelSetCharacterSprites(vnContainer, spriteFolderName, expression) { |
| 189 | const originalExpression = expression; |
| 190 | const context = getContext(); |
| 191 | const group = context.groups.find(x => x.id == context.groupId); |
| 192 | |
| 193 | const setSpritePromises = []; |
| 194 | |
| 195 | for (const avatar of group.members) { |
| 196 | // skip disabled characters |
| 197 | const isDisabled = group.disabled_members.includes(avatar); |
| 198 | if (isDisabled && hideMutedSprites) { |
| 199 | continue; |
| 200 | } |
| 201 | |
| 202 | const character = context.characters.find(x => x.avatar == avatar); |
| 203 | if (!character) { |
| 204 | continue; |
| 205 | } |
| 206 | |
| 207 | const expressionImage = vnContainer.find(`.expression-holder[data-avatar="${avatar}"]`); |
| 208 | /** @type {JQuery<HTMLElement>} */ |
| 209 | let img; |
| 210 | |
| 211 | const memberSpriteFolderName = getSpriteFolderName({ original_avatar: character.avatar }, character.name); |
| 212 | |
| 213 | // download images if not downloaded yet |
| 214 | if (spriteCache[memberSpriteFolderName] === undefined) { |
| 215 | spriteCache[memberSpriteFolderName] = await getSpritesList(memberSpriteFolderName); |
| 216 | } |
| 217 | |
| 218 | const prevExpressionSrc = expressionImage.find('img').attr('src') || null; |
| 219 | |
| 220 | if (!originalExpression && Array.isArray(spriteCache[memberSpriteFolderName]) && spriteCache[memberSpriteFolderName].length > 0) { |
| 221 | expression = await getLastMessageSprite(avatar); |
| 222 | } |
| 223 | |
| 224 | const spriteFile = chooseSpriteForExpression(memberSpriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc }); |
| 225 | if (expressionImage.length) { |
| 226 | if (!spriteFolderName || spriteFolderName == memberSpriteFolderName) { |
| 227 | await validateImages(memberSpriteFolderName, true); |
| 228 | setExpressionOverrideHtml(true); // <= force clear expression override input |
| 229 | const path = spriteFile?.imageSrc || ''; |
| 230 | img = expressionImage.find('img'); |
| 231 | await setImage(img, path); |
| 232 | } |
| 233 | expressionImage.toggleClass('hidden', !spriteFile); |
| 234 | } else { |
| 235 | const template = $('#expression-holder').clone(); |
| 236 | template.attr('id', `expression-${avatar}`); |
| 237 | template.attr('data-avatar', avatar); |
| 238 | template.find('.drag-grabber').attr('id', `expression-${avatar}header`); |
| 239 | $('#visual-novel-wrapper').append(template); |
| 240 | dragElement($(template[0])); |
| 241 | template.toggleClass('hidden', !spriteFile); |
| 242 | img = template.find('img'); |
| 243 | await setImage(img, spriteFile?.imageSrc || ''); |
| 244 | const fadeInPromise = new Promise(resolve => { |
| 245 | template.fadeIn(250, () => resolve()); |
| 246 | }); |
| 247 | setSpritePromises.push(fadeInPromise); |
| 248 | } |
| 249 | |
| 250 | if (!img) { |
| 251 | continue; |
| 252 | } |
| 253 | |
| 254 | img.attr('data-sprite-folder-name', spriteFolderName); |
| 255 | img.attr('data-expression', expression); |
| 256 | img.attr('data-sprite-filename', spriteFile?.fileName || null); |
| 257 | img.attr('title', expression); |
| 258 | |
| 259 | if (spriteFile) console.info(`Expression set for group member ${character.name}`, { expression: spriteFile.expression, file: spriteFile.fileName }); |
| 260 | else if (expressionImage.length) console.info(`Expression unset for group member ${character.name} - No sprite found`, { expression: expression }); |
| 261 | else console.info(`Expression not available for group member ${character.name}`, { expression: expression }); |
| 262 | } |
| 263 | |
| 264 | return setSpritePromises; |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * Classifies the text of the latest message and returns the expression label. |
| 269 | * @param {string} avatar - The avatar of the character to get the last message for |
| 270 | * @returns {Promise<string>} - The expression label |
| 271 | */ |
| 272 | async function getLastMessageSprite(avatar) { |
| 273 | const context = getContext(); |
| 274 | const lastMessage = context.chat.slice().reverse().find(x => x.original_avatar == avatar || (x.force_avatar && x.force_avatar.includes(encodeURIComponent(avatar)))); |
| 275 | |
| 276 | if (lastMessage) { |
| 277 | const text = lastMessage.mes || ''; |
| 278 | return await getExpressionLabel(text); |
| 279 | } |
| 280 | |
| 281 | return null; |
| 282 | } |
| 283 | |
| 284 | export async function visualNovelUpdateLayers(container) { |
| 285 | const context = getContext(); |
| 286 | const group = context.groups.find(x => x.id == context.groupId); |
| 287 | const recentMessages = context.chat.map(x => x.original_avatar).filter(x => x).reverse().filter(onlyUnique); |
| 288 | const filteredMembers = group.members.filter(x => !group.disabled_members.includes(x)); |
| 289 | const layerIndices = filteredMembers.slice().sort((a, b) => { |
| 290 | const aRecentIndex = recentMessages.indexOf(a); |
| 291 | const bRecentIndex = recentMessages.indexOf(b); |
| 292 | const aFilteredIndex = filteredMembers.indexOf(a); |
| 293 | const bFilteredIndex = filteredMembers.indexOf(b); |
| 294 | |
| 295 | if (aRecentIndex !== -1 && bRecentIndex !== -1) { |
| 296 | return bRecentIndex - aRecentIndex; |
| 297 | } else if (aRecentIndex !== -1) { |
| 298 | return 1; |
| 299 | } else if (bRecentIndex !== -1) { |
| 300 | return -1; |
| 301 | } else { |
| 302 | return aFilteredIndex - bFilteredIndex; |
| 303 | } |
| 304 | }); |
| 305 | |
| 306 | const setLayerIndicesPromises = []; |
| 307 | |
| 308 | const sortFunction = (a, b) => { |
| 309 | const avatarA = $(a).data('avatar'); |
| 310 | const avatarB = $(b).data('avatar'); |
| 311 | const indexA = filteredMembers.indexOf(avatarA); |
| 312 | const indexB = filteredMembers.indexOf(avatarB); |
| 313 | return indexA - indexB; |
| 314 | }; |
| 315 | |
| 316 | const containerWidth = container.width(); |
| 317 | const pivotalPoint = containerWidth * 0.5; |
| 318 | |
| 319 | let images = Array.from($('#visual-novel-wrapper .expression-holder')).sort(sortFunction); |
| 320 | let imagesWidth = []; |
| 321 | |
| 322 | for (const image of images) { |
| 323 | if (image instanceof HTMLImageElement && !image.complete) { |
| 324 | await new Promise(resolve => image.addEventListener('load', resolve, { once: true })); |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | images.forEach(image => { |
| 329 | imagesWidth.push($(image).width()); |
| 330 | }); |
| 331 | |
| 332 | let totalWidth = imagesWidth.reduce((a, b) => a + b, 0); |
| 333 | let currentPosition = pivotalPoint - (totalWidth / 2); |
| 334 | |
| 335 | if (totalWidth > containerWidth) { |
| 336 | let totalOverlap = totalWidth - containerWidth; |
| 337 | let totalWidthWithoutWidest = imagesWidth.reduce((a, b) => a + b, 0) - Math.max(...imagesWidth); |
| 338 | let overlaps = imagesWidth.map(width => (width / totalWidthWithoutWidest) * totalOverlap); |
| 339 | imagesWidth = imagesWidth.map((width, index) => width - overlaps[index]); |
| 340 | currentPosition = 0; // Reset the initial position to 0 |
| 341 | } |
| 342 | |
| 343 | images.forEach((current, index) => { |
| 344 | const element = $(current); |
| 345 | const elementID = element.attr('id'); |
| 346 | |
| 347 | // skip repositioning of dragged elements |
| 348 | if (element.data('dragged') |
| 349 | || (power_user.movingUIState[elementID] |
| 350 | && (typeof power_user.movingUIState[elementID] === 'object') |
| 351 | && Object.keys(power_user.movingUIState[elementID]).length > 0)) { |
| 352 | loadMovingUIState(); |
| 353 | //currentPosition += imagesWidth[index]; |
| 354 | return; |
| 355 | } |
| 356 | |
| 357 | const avatar = element.data('avatar'); |
| 358 | const layerIndex = layerIndices.indexOf(avatar); |
| 359 | element.css('z-index', layerIndex); |
| 360 | element.show(); |
| 361 | |
| 362 | const promise = new Promise(resolve => { |
| 363 | if (power_user.reduced_motion) { |
| 364 | element.css('left', currentPosition + 'px'); |
| 365 | requestAnimationFrame(() => resolve()); |
| 366 | } else { |
| 367 | element.animate({ left: currentPosition + 'px' }, 500, () => { |
| 368 | resolve(); |
| 369 | }); |
| 370 | } |
| 371 | }); |
| 372 | |
| 373 | currentPosition += imagesWidth[index]; |
| 374 | |
| 375 | setLayerIndicesPromises.push(promise); |
| 376 | }); |
| 377 | |
| 378 | await Promise.allSettled(setLayerIndicesPromises); |
| 379 | } |
| 380 | |
| 381 | /** |
| 382 | * Sets the expression for the given character image. |
| 383 | * @param {JQuery<HTMLElement>} img - The image element to set the image on |
| 384 | * @param {string} path - The path to the image |
| 385 | * @returns {Promise<void>} - A promise that resolves when the image is set |
| 386 | */ |
| 387 | async function setImage(img, path) { |
| 388 | // Cohee: If something goes wrong, uncomment this to return to the old behavior |
| 389 | /* |
| 390 | img.attr('src', path); |
| 391 | img.removeClass('default'); |
| 392 | img.off('error'); |
| 393 | img.on('error', function () { |
| 394 | console.debug('Error loading image', path); |
| 395 | $(this).off('error'); |
| 396 | $(this).attr('src', ''); |
| 397 | }); |
| 398 | */ |
| 399 | |
| 400 | return new Promise(resolve => { |
| 401 | const prevExpressionSrc = img.attr('src'); |
| 402 | const expressionClone = img.clone(); |
| 403 | const originalId = img.data('filename'); |
| 404 | |
| 405 | //only swap expressions when necessary |
| 406 | if (prevExpressionSrc !== path && !img.hasClass('expression-animating')) { |
| 407 | //clone expression |
| 408 | expressionClone.addClass('expression-clone'); |
| 409 | //make invisible and remove id to prevent double ids |
| 410 | //must be made invisible to start because they share the same Z-index |
| 411 | expressionClone.data('filename', '').css({ opacity: 0 }); |
| 412 | //add new sprite path to clone src |
| 413 | expressionClone.attr('src', path); |
| 414 | //add invisible clone to html |
| 415 | expressionClone.appendTo(img.parent()); |
| 416 | |
| 417 | const duration = 200; |
| 418 | |
| 419 | //add animation flags to both images |
| 420 | //to prevent multiple expression changes happening simultaneously |
| 421 | img.addClass('expression-animating'); |
| 422 | |
| 423 | // Set the parent container's min width and height before running the transition |
| 424 | const imgWidth = img.width(); |
| 425 | const imgHeight = img.height(); |
| 426 | const expressionHolder = img.parent(); |
| 427 | expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100); |
| 428 | expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100); |
| 429 | |
| 430 | //position absolute prevent the original from jumping around during transition |
| 431 | img.css('position', 'absolute').width(imgWidth).height(imgHeight); |
| 432 | expressionClone.addClass('expression-animating'); |
| 433 | //fade the clone in |
| 434 | expressionClone.css({ |
| 435 | opacity: 0, |
| 436 | }).animate({ |
| 437 | opacity: 1, |
| 438 | }, duration) |
| 439 | //when finshed fading in clone, fade out the original |
| 440 | .promise().done(function () { |
| 441 | img.animate({ |
| 442 | opacity: 0, |
| 443 | }, duration); |
| 444 | //remove old expression |
| 445 | img.remove(); |
| 446 | //replace ID so it becomes the new 'original' expression for next change |
| 447 | expressionClone.data('filename', originalId); |
| 448 | expressionClone.removeClass('expression-animating'); |
| 449 | |
| 450 | // Reset the expression holder min height and width |
| 451 | expressionHolder.css('min-width', 100); |
| 452 | expressionHolder.css('min-height', 100); |
| 453 | |
| 454 | if (expressionClone.prop('complete')) { |
| 455 | resolve(); |
| 456 | } else { |
| 457 | expressionClone.one('load', () => resolve()); |
| 458 | } |
| 459 | }); |
| 460 | |
| 461 | expressionClone.removeClass('expression-clone'); |
| 462 | |
| 463 | expressionClone.removeClass('default'); |
| 464 | expressionClone.off('error'); |
| 465 | expressionClone.on('error', function () { |
| 466 | console.debug('Expression image error', path); |
| 467 | $(this).attr('src', ''); |
| 468 | $(this).off('error'); |
| 469 | resolve(); |
| 470 | }); |
| 471 | } else { |
| 472 | resolve(); |
| 473 | } |
| 474 | }); |
| 475 | } |
| 476 | |
| 477 | async function moduleWorker({ newChat = false } = {}) { |
| 478 | const context = getContext(); |
| 479 | |
| 480 | // non-characters not supported |
| 481 | if (!context.groupId && context.characterId === undefined) { |
| 482 | removeExpression(); |
| 483 | return; |
| 484 | } |
| 485 | |
| 486 | const vnMode = isVisualNovelMode(); |
| 487 | const vnWrapperVisible = $('#visual-novel-wrapper').is(':visible'); |
| 488 | |
| 489 | if (vnMode) { |
| 490 | $('#expression-wrapper').hide(); |
| 491 | $('#visual-novel-wrapper').show(); |
| 492 | } else { |
| 493 | $('#expression-wrapper').show(); |
| 494 | $('#visual-novel-wrapper').hide(); |
| 495 | } |
| 496 | |
| 497 | const vnStateChanged = vnMode !== vnWrapperVisible; |
| 498 | |
| 499 | if (vnStateChanged) { |
| 500 | lastMessage = null; |
| 501 | $('#visual-novel-wrapper').empty(); |
| 502 | $('#expression-holder').css({ top: '', left: '', right: '', bottom: '', height: '', width: '', margin: '' }); |
| 503 | } |
| 504 | |
| 505 | const currentLastMessage = getLastCharacterMessage(); |
| 506 | let spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name); |
| 507 | |
| 508 | // character has no expressions or it is not loaded |
| 509 | if (Object.keys(spriteCache).length === 0) { |
| 510 | await validateImages(spriteFolderName); |
| 511 | lastCharacter = context.groupId || context.characterId; |
| 512 | } |
| 513 | |
| 514 | const offlineMode = $('.expression_settings .offline_mode'); |
| 515 | if (!modules.includes('classify') && extension_settings.expressions.api == EXPRESSION_API.extras) { |
| 516 | $('#open_chat_expressions').show(); |
| 517 | $('#no_chat_expressions').hide(); |
| 518 | offlineMode.css('display', 'block'); |
| 519 | lastCharacter = context.groupId || context.characterId; |
| 520 | |
| 521 | if (context.groupId) { |
| 522 | await validateImages(spriteFolderName, true); |
| 523 | await forceUpdateVisualNovelMode(); |
| 524 | } |
| 525 | |
| 526 | return; |
| 527 | } else { |
| 528 | // force reload expressions list on connect to API |
| 529 | if (offlineMode.is(':visible')) { |
| 530 | expressionsList = null; |
| 531 | spriteCache = {}; |
| 532 | expressionsList = await getExpressionsList(); |
| 533 | await validateImages(spriteFolderName, true); |
| 534 | await forceUpdateVisualNovelMode(); |
| 535 | } |
| 536 | |
| 537 | if (context.groupId && !Array.isArray(spriteCache[spriteFolderName])) { |
| 538 | await validateImages(spriteFolderName, true); |
| 539 | await forceUpdateVisualNovelMode(); |
| 540 | } |
| 541 | |
| 542 | offlineMode.css('display', 'none'); |
| 543 | } |
| 544 | |
| 545 | if (context.groupId && vnMode && newChat) { |
| 546 | await forceUpdateVisualNovelMode(); |
| 547 | } |
| 548 | |
| 549 | // Don't bother classifying if current char has no sprites and no default expressions are enabled |
| 550 | if ((!Array.isArray(spriteCache[spriteFolderName]) || spriteCache[spriteFolderName].length === 0) && !extension_settings.expressions.showDefault) { |
| 551 | return; |
| 552 | } |
| 553 | |
| 554 | const lastMessageChanged = !((lastCharacter === context.characterId || lastCharacter === context.groupId) && lastMessage === currentLastMessage.mes); |
| 555 | |
| 556 | // check if last message changed |
| 557 | if (!lastMessageChanged) { |
| 558 | return; |
| 559 | } |
| 560 | |
| 561 | // If using LLM api then check if streamingProcessor is finished to avoid sending multiple requests to the API |
| 562 | if (extension_settings.expressions.api === EXPRESSION_API.llm && context.streamingProcessor && !context.streamingProcessor.isFinished) { |
| 563 | return; |
| 564 | } |
| 565 | |
| 566 | // API is busy |
| 567 | if (inApiCall) { |
| 568 | console.debug('Classification API is busy'); |
| 569 | return; |
| 570 | } |
| 571 | |
| 572 | // Throttle classification requests during streaming |
| 573 | if (!context.groupId && context.streamingProcessor && !context.streamingProcessor.isFinished) { |
| 574 | const now = Date.now(); |
| 575 | const timeSinceLastServerResponse = now - lastServerResponseTime; |
| 576 | |
| 577 | if (timeSinceLastServerResponse < STREAMING_UPDATE_INTERVAL) { |
| 578 | console.log('Streaming in progress: throttling expression update. Next update at ' + new Date(lastServerResponseTime + STREAMING_UPDATE_INTERVAL)); |
| 579 | return; |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | try { |
| 584 | inApiCall = true; |
| 585 | let expression = await getExpressionLabel(currentLastMessage.mes); |
| 586 | |
| 587 | // If we're not already overriding the folder name, account for group chats. |
| 588 | if (spriteFolderName === currentLastMessage.name && !context.groupId) { |
| 589 | spriteFolderName = context.name2; |
| 590 | } |
| 591 | |
| 592 | const force = !!context.groupId; |
| 593 | |
| 594 | // Character won't be angry on you for swiping |
| 595 | if (currentLastMessage.mes == '...' && expressionsList.includes(extension_settings.expressions.fallback_expression)) { |
| 596 | expression = extension_settings.expressions.fallback_expression; |
| 597 | } |
| 598 | |
| 599 | await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode }); |
| 600 | } catch (error) { |
| 601 | console.log(error); |
| 602 | } finally { |
| 603 | inApiCall = false; |
| 604 | lastCharacter = context.groupId || context.characterId; |
| 605 | lastMessage = currentLastMessage.mes; |
| 606 | lastServerResponseTime = Date.now(); |
| 607 | } |
| 608 | } |
| 609 | |
| 610 | function getSpriteFolderName(characterMessage = null, characterName = null) { |
| 611 | const context = getContext(); |
| 612 | let spriteFolderName = characterName ?? context.name2; |
| 613 | const message = characterMessage ?? getLastCharacterMessage(); |
| 614 | const avatarFileName = getFolderNameByMessage(message); |
| 615 | const expressionOverride = extension_settings.expressionOverrides.find(e => e.name == avatarFileName); |
| 616 | |
| 617 | if (expressionOverride && expressionOverride.path) { |
| 618 | spriteFolderName = expressionOverride.path; |
| 619 | } |
| 620 | |
| 621 | return spriteFolderName; |
| 622 | } |
| 623 | |
| 624 | function getFolderNameByMessage(message) { |
| 625 | const context = getContext(); |
| 626 | let avatarPath = ''; |
| 627 | |
| 628 | if (context.groupId) { |
| 629 | avatarPath = message.original_avatar || context.characters.find(x => message.force_avatar && message.force_avatar.includes(encodeURIComponent(x.avatar)))?.avatar; |
| 630 | } else if (context.characterId !== undefined) { |
| 631 | avatarPath = getCharaFilename(); |
| 632 | } |
| 633 | |
| 634 | if (!avatarPath) { |
| 635 | return ''; |
| 636 | } |
| 637 | |
| 638 | const folderName = avatarPath.replace(/\.[^/.]+$/, ''); |
| 639 | return folderName; |
| 640 | } |
| 641 | |
| 642 | /** |
| 643 | * Update the expression for the given character. |
| 644 | * |
| 645 | * @param {string} spriteFolderName The character name, optionally with a sprite folder override, e.g. "folder/expression". |
| 646 | * @param {string} expression The expression label, e.g. "amusement", "joy", etc. |
| 647 | * @param {Object} [options] Additional options |
| 648 | * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression. |
| 649 | * @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. |
| 650 | * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name. |
| 651 | */ |
| 652 | export async function sendExpressionCall(spriteFolderName, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) { |
| 653 | lastExpression[spriteFolderName.split('/')[0]] = expression; |
| 654 | if (vnMode === null) { |
| 655 | vnMode = isVisualNovelMode(); |
| 656 | } |
| 657 | |
| 658 | if (vnMode) { |
| 659 | await updateVisualNovelMode(spriteFolderName, expression); |
| 660 | } else { |
| 661 | setExpression(spriteFolderName, expression, { force: force, overrideSpriteFile: overrideSpriteFile }); |
| 662 | } |
| 663 | } |
| 664 | |
| 665 | /** |
| 666 | * Slash command callback for /setspritefolder |
| 667 | * @param {object} param Command parameters |
| 668 | * @param {string} param.name Character name override |
| 669 | * @param {string} folder Folder path, can be full or partial with leading slash |
| 670 | * @returns {Promise<string>} Empty string |
| 671 | */ |
| 672 | async function setSpriteFolderCommand({ name }, folder) { |
| 673 | if (!folder) { |
| 674 | console.log('Clearing sprite set'); |
| 675 | folder = ''; |
| 676 | } |
| 677 | |
| 678 | if (folder.startsWith('/') || folder.startsWith('\\')) { |
| 679 | const currentLastMessage = getLastCharacterMessage(); |
| 680 | if (currentLastMessage.name === null && !name) { |
| 681 | toastr.error('At least one character message is required to set a sprites subfolder.', 'Provide the name with "name=" argument.'); |
| 682 | return ''; |
| 683 | } |
| 684 | folder = folder.slice(1); |
| 685 | folder = `${name || currentLastMessage.name}/${folder}`; |
| 686 | } |
| 687 | |
| 688 | $('#expression_override').val(folder.trim()); |
| 689 | onClickExpressionOverrideButton(); |
| 690 | |
| 691 | // No need to resend the expression, the folder override will automatically update the currently displayed one. |
| 692 | return ''; |
| 693 | } |
| 694 | |
| 695 | async function classifyCallback(/** @type {{api: string?, filter: string?, prompt: string?}} */ { api = null, filter = null, prompt = null }, text) { |
| 696 | if (!text) { |
| 697 | toastr.error('No text provided'); |
| 698 | return ''; |
| 699 | } |
| 700 | if (api && !Object.keys(EXPRESSION_API).includes(api)) { |
| 701 | toastr.error('Invalid API provided'); |
| 702 | return ''; |
| 703 | } |
| 704 | |
| 705 | const expressionApi = EXPRESSION_API[api] || extension_settings.expressions.api; |
| 706 | const filterAvailable = !isFalseBoolean(filter); |
| 707 | |
| 708 | if (expressionApi === EXPRESSION_API.none) { |
| 709 | toastr.warning('No classifier API selected'); |
| 710 | return ''; |
| 711 | } |
| 712 | |
| 713 | if (!modules.includes('classify') && expressionApi == EXPRESSION_API.extras) { |
| 714 | toastr.warning('Text classification is disabled or not available'); |
| 715 | return ''; |
| 716 | } |
| 717 | |
| 718 | const label = await getExpressionLabel(text, expressionApi, { filterAvailable: filterAvailable, customPrompt: prompt }); |
| 719 | console.debug(`Classification result for "${text}": ${label}`); |
| 720 | return label; |
| 721 | } |
| 722 | |
| 723 | /** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */ |
| 724 | async function setSpriteSlashCommand({ type }, searchTerm) { |
| 725 | type ??= 'expression'; |
| 726 | searchTerm = searchTerm.trim().toLowerCase(); |
| 727 | if (!searchTerm) { |
| 728 | toastr.error(t`No expression or sprite name provided`, t`Set Sprite`); |
| 729 | return ''; |
| 730 | } |
| 731 | |
| 732 | const currentLastMessage = selected_group ? getLastCharacterMessage() : null; |
| 733 | const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); |
| 734 | |
| 735 | let label = searchTerm; |
| 736 | |
| 737 | /** @type {string?} */ |
| 738 | let spriteFile = null; |
| 739 | |
| 740 | await validateImages(spriteFolderName); |
| 741 | |
| 742 | // Handle reset as a special term and just reset the sprite via expression call |
| 743 | if (searchTerm === RESET_SPRITE_LABEL) { |
| 744 | await sendExpressionCall(spriteFolderName, label, { force: true }); |
| 745 | return lastExpression[spriteFolderName] ?? ''; |
| 746 | } |
| 747 | |
| 748 | switch (type) { |
| 749 | case 'expression': { |
| 750 | // Fuzzy search for expression |
| 751 | const existingExpressions = getCachedExpressions().map(x => ({ label: x })); |
| 752 | const results = performFuzzySearch('expression-expressions', existingExpressions, [ |
| 753 | { name: 'label', weight: 1 }, |
| 754 | ], searchTerm); |
| 755 | const matchedExpression = results[0]?.item; |
| 756 | if (!matchedExpression) { |
| 757 | toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`); |
| 758 | return ''; |
| 759 | } |
| 760 | |
| 761 | label = matchedExpression.label; |
| 762 | break; |
| 763 | } |
| 764 | case 'sprite': { |
| 765 | // Fuzzy search for sprite file |
| 766 | const sprites = spriteCache[spriteFolderName].map(x => x.files).flat(); |
| 767 | const results = performFuzzySearch('expression-expressions', sprites, [ |
| 768 | { name: 'title', weight: 1 }, |
| 769 | { name: 'fileName', weight: 1 }, |
| 770 | ], searchTerm); |
| 771 | const matchedSprite = results[0]?.item; |
| 772 | if (!matchedSprite) { |
| 773 | toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`); |
| 774 | return ''; |
| 775 | } |
| 776 | |
| 777 | label = matchedSprite.expression; |
| 778 | spriteFile = matchedSprite.fileName; |
| 779 | break; |
| 780 | } |
| 781 | default: throw Error('Invalid sprite set type: ' + type); |
| 782 | } |
| 783 | |
| 784 | await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile }); |
| 785 | |
| 786 | return label; |
| 787 | } |
| 788 | |
| 789 | /** |
| 790 | * @param {string} expressionName - Label of the expression to set as fallback |
| 791 | */ |
| 792 | function setFallBackExpressionSlashCommand(args, expressionName) { |
| 793 | expressionName = expressionName.trim().toLowerCase(); |
| 794 | |
| 795 | if (!expressionName) return extension_settings?.expressions?.fallback_expression || ''; |
| 796 | |
| 797 | const select = /** @type {HTMLSelectElement} */(document.getElementById('expression_fallback')); |
| 798 | const fallbackExpressions = Array |
| 799 | .from(select?.options || []) |
| 800 | .map(option => option.value) |
| 801 | .filter(expression => expression?.length > 0); |
| 802 | |
| 803 | const expressionMatch = fallbackExpressions.find(expression => includesIgnoreCaseAndAccents(expression, expressionName)); |
| 804 | |
| 805 | if (!expressionMatch) { |
| 806 | toastr.warning(t`No expression found for search term ${expressionName}`, t`Set Fallback Expression`); |
| 807 | return ''; |
| 808 | } |
| 809 | |
| 810 | $(select).val(expressionMatch).trigger('change'); |
| 811 | |
| 812 | return expressionMatch; |
| 813 | } |
| 814 | |
| 815 | /** |
| 816 | * Returns the sprite folder name (including override) for a character. |
| 817 | * @param {object} char Character object |
| 818 | * @param {string} char.avatar Avatar filename with extension |
| 819 | * @returns {string} Sprite folder name |
| 820 | * @throws {Error} If character not found or avatar not set |
| 821 | */ |
| 822 | function spriteFolderNameFromCharacter(char) { |
| 823 | const avatarFileName = char.avatar.replace(/\.[^/.]+$/, ''); |
| 824 | const expressionOverride = extension_settings.expressionOverrides.find(e => e.name === avatarFileName); |
| 825 | return expressionOverride?.path ? expressionOverride.path : avatarFileName; |
| 826 | } |
| 827 | |
| 828 | /** |
| 829 | * Generates a unique sprite name by appending an index to the given expression. * |
| 830 | * @param {string} expression - The base expression to be used as the prefix for the sprite name. |
| 831 | * @param {ExpressionImage[]} existingFiles - An array of existing file objects, each containing a fileName property. |
| 832 | * @returns {string} - A unique sprite name with the format "expression-index". |
| 833 | */ |
| 834 | function generateUniqueSpriteName(expression, existingFiles) { |
| 835 | let index = existingFiles.length; |
| 836 | let newSpriteName; |
| 837 | do { |
| 838 | newSpriteName = `${expression}-${index++}`; |
| 839 | } while (existingFiles.some(file => withoutExtension(file.fileName) === newSpriteName)); |
| 840 | return newSpriteName; |
| 841 | } |
| 842 | |
| 843 | /** |
| 844 | * Slash command callback for /uploadsprite |
| 845 | * |
| 846 | * label= is required |
| 847 | * if name= is provided, it will be used as a findChar lookup |
| 848 | * if name= is not provided, the last character's name will be used |
| 849 | * if folder= is a full path, it will be used as the folder |
| 850 | * if folder= is a partial path, it will be appended to the character's name |
| 851 | * if folder= is not provided, the character's override folder will be used, if set |
| 852 | * |
| 853 | * @param {object} args |
| 854 | * @param {string} args.name Character name or avatar key, passed through findChar |
| 855 | * @param {string} args.label Expression label |
| 856 | * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules |
| 857 | * @param {string?} [args.spriteName=null] Optional sprite name |
| 858 | * @param {string} imageUrl Image URI to fetch and upload |
| 859 | * @returns {Promise<string>} the sprite name |
| 860 | */ |
| 861 | async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) { |
| 862 | if (!imageUrl) throw new Error('Image URL is required'); |
| 863 | if (!label || typeof label !== 'string') { |
| 864 | toastr.error(t`Expression label is required`, t`Error Uploading Sprite`); |
| 865 | return ''; |
| 866 | } |
| 867 | |
| 868 | label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); |
| 869 | if (!label) { |
| 870 | toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`); |
| 871 | return ''; |
| 872 | } |
| 873 | |
| 874 | spriteName = spriteName || label; |
| 875 | if (!validateExpressionSpriteName(label, spriteName)) { |
| 876 | toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`); |
| 877 | return ''; |
| 878 | } |
| 879 | |
| 880 | name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; |
| 881 | const char = findChar({ name }); |
| 882 | |
| 883 | if (!folder) { |
| 884 | folder = spriteFolderNameFromCharacter(char); |
| 885 | } else if (folder.startsWith('/') || folder.startsWith('\\')) { |
| 886 | const subfolder = folder.slice(1); |
| 887 | folder = `${char.name}/${subfolder}`; |
| 888 | } |
| 889 | |
| 890 | try { |
| 891 | const response = await fetch(imageUrl); |
| 892 | const blob = await response.blob(); |
| 893 | const file = new File([blob], 'image.png', { type: 'image/png' }); |
| 894 | |
| 895 | const formData = new FormData(); |
| 896 | formData.append('name', folder); // this is the folder or character name |
| 897 | formData.append('label', label); // this is the expression label |
| 898 | formData.append('avatar', file); // this is the image file |
| 899 | formData.append('spriteName', spriteName); // this is a redundant comment |
| 900 | |
| 901 | await handleFileUpload('/api/sprites/upload', formData); |
| 902 | console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`); |
| 903 | } catch (error) { |
| 904 | console.error(`[${MODULE_NAME}] Error uploading file:`, error); |
| 905 | throw error; |
| 906 | } |
| 907 | |
| 908 | return spriteName; |
| 909 | } |
| 910 | |
| 911 | /** |
| 912 | * Processes the classification text to reduce the amount of text sent to the API. |
| 913 | * Quotes and asterisks are to be removed. If the text is less than 300 characters, it is returned as is. |
| 914 | * If the text is more than 300 characters, the first and last 150 characters are returned. |
| 915 | * The result is trimmed to the end of sentence. |
| 916 | * @param {string} text The text to process. |
| 917 | * @returns {string} |
| 918 | */ |
| 919 | function sampleClassifyText(text) { |
| 920 | if (!text) { |
| 921 | return text; |
| 922 | } |
| 923 | |
| 924 | // Replace macros, remove asterisks and quotes |
| 925 | let result = substituteParams(text).replace(/[*"]/g, ''); |
| 926 | |
| 927 | // If using LLM api there is no need to check length of characters |
| 928 | if (extension_settings.expressions.api === EXPRESSION_API.llm) { |
| 929 | return result.trim(); |
| 930 | } |
| 931 | |
| 932 | const SAMPLE_THRESHOLD = 500; |
| 933 | const HALF_SAMPLE_THRESHOLD = SAMPLE_THRESHOLD / 2; |
| 934 | |
| 935 | if (text.length < SAMPLE_THRESHOLD) { |
| 936 | result = trimToEndSentence(result); |
| 937 | } else { |
| 938 | result = trimToEndSentence(result.slice(0, HALF_SAMPLE_THRESHOLD)) + ' ' + trimToStartSentence(result.slice(-HALF_SAMPLE_THRESHOLD)); |
| 939 | } |
| 940 | |
| 941 | return result.trim(); |
| 942 | } |
| 943 | |
| 944 | /** |
| 945 | * Gets the classification prompt for the LLM API. |
| 946 | * @param {string[]} labels A list of labels to search for. |
| 947 | * @returns {Promise<string>} Prompt for the LLM API. |
| 948 | */ |
| 949 | async function getLlmPrompt(labels) { |
| 950 | const labelsString = labels.map(x => `"${x}"`).join(', '); |
| 951 | const prompt = substituteParamsExtended(String(extension_settings.expressions.llmPrompt), { labels: labelsString }); |
| 952 | return prompt; |
| 953 | } |
| 954 | |
| 955 | /** |
| 956 | * Parses the emotion response from the LLM API. |
| 957 | * @param {string} emotionResponse The response from the LLM API. |
| 958 | * @param {string[]} labels A list of labels to search for. |
| 959 | * @returns {string} The parsed emotion or the fallback expression. |
| 960 | */ |
| 961 | function parseLlmResponse(emotionResponse, labels) { |
| 962 | try { |
| 963 | const parsedEmotion = JSON.parse(emotionResponse); |
| 964 | const response = parsedEmotion?.emotion?.trim()?.toLowerCase(); |
| 965 | |
| 966 | if (!response || !labels.includes(response)) { |
| 967 | console.debug(`Parsed emotion response: ${response} not in labels: ${labels}`); |
| 968 | throw new Error('Emotion not in labels'); |
| 969 | } |
| 970 | |
| 971 | return response; |
| 972 | } catch { |
| 973 | // Clean possible reasoning from response |
| 974 | emotionResponse = removeReasoningFromString(emotionResponse); |
| 975 | |
| 976 | const fuse = new Fuse(labels, { includeScore: true }); |
| 977 | console.debug('Using fuzzy search in labels:', labels); |
| 978 | const result = fuse.search(emotionResponse); |
| 979 | if (result.length > 0) { |
| 980 | console.debug(`fuzzy search found: ${result[0].item} as closest for the LLM response:`, emotionResponse); |
| 981 | return result[0].item; |
| 982 | } |
| 983 | const lowerCaseResponse = String(emotionResponse || '').toLowerCase(); |
| 984 | for (const label of labels) { |
| 985 | if (lowerCaseResponse.includes(label.toLowerCase())) { |
| 986 | console.debug(`Found label ${label} in the LLM response:`, emotionResponse); |
| 987 | return label; |
| 988 | } |
| 989 | } |
| 990 | } |
| 991 | |
| 992 | throw new Error('Could not parse emotion response ' + emotionResponse); |
| 993 | } |
| 994 | |
| 995 | /** |
| 996 | * Gets the JSON schema for the LLM API. |
| 997 | * @param {string[]} emotions A list of emotions to search for. |
| 998 | * @returns {object} The JSON schema for the LLM API. |
| 999 | */ |
| 1000 | function getJsonSchema(emotions) { |
| 1001 | return { |
| 1002 | $schema: 'http://json-schema.org/draft-04/schema#', |
| 1003 | type: 'object', |
| 1004 | properties: { |
| 1005 | emotion: { |
| 1006 | type: 'string', |
| 1007 | enum: emotions, |
| 1008 | }, |
| 1009 | }, |
| 1010 | required: [ |
| 1011 | 'emotion', |
| 1012 | ], |
| 1013 | additionalProperties: false, |
| 1014 | }; |
| 1015 | } |
| 1016 | |
| 1017 | function onTextGenSettingsReady(args) { |
| 1018 | // Only call if inside an API call |
| 1019 | if (inApiCall && extension_settings.expressions.api === EXPRESSION_API.llm && isJsonSchemaSupported()) { |
| 1020 | const emotions = DEFAULT_EXPRESSIONS; |
| 1021 | Object.assign(args, { |
| 1022 | top_k: 1, |
| 1023 | stop: [], |
| 1024 | stopping_strings: [], |
| 1025 | custom_token_bans: [], |
| 1026 | json_schema: getJsonSchema(emotions), |
| 1027 | }); |
| 1028 | } |
| 1029 | } |
| 1030 | |
| 1031 | /** |
| 1032 | * Retrieves the label of an expression via classification based on the provided text. |
| 1033 | * Optionally allows to override the expressions API being used. |
| 1034 | * @param {string} text - The text to classify and retrieve the expression label for. |
| 1035 | * @param {EXPRESSION_API} [expressionsApi=extension_settings.expressions.api] - The expressions API to use for classification. |
| 1036 | * @param {object} [options={}] - Optional arguments. |
| 1037 | * @param {boolean?} [options.filterAvailable=null] - Whether to filter available expressions. If not specified, uses the extension setting. |
| 1038 | * @param {string?} [options.customPrompt=null] - The custom prompt to use for classification. |
| 1039 | * @returns {Promise<string?>} - The label of the expression. |
| 1040 | */ |
| 1041 | export async function getExpressionLabel(text, expressionsApi = extension_settings.expressions.api, { filterAvailable = null, customPrompt = null } = {}) { |
| 1042 | // Return if text is undefined, saving a costly fetch request |
| 1043 | if ((!modules.includes('classify') && expressionsApi == EXPRESSION_API.extras) || !text) { |
| 1044 | return extension_settings.expressions.fallback_expression; |
| 1045 | } |
| 1046 | |
| 1047 | if (extension_settings.expressions.translate && typeof globalThis.translate === 'function') { |
| 1048 | text = await globalThis.translate(text, 'en'); |
| 1049 | } |
| 1050 | |
| 1051 | text = sampleClassifyText(text); |
| 1052 | |
| 1053 | filterAvailable ??= extension_settings.expressions.filterAvailable; |
| 1054 | if (filterAvailable && ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(expressionsApi)) { |
| 1055 | console.debug('Filter available is only supported for LLM and WebLLM expressions'); |
| 1056 | } |
| 1057 | |
| 1058 | try { |
| 1059 | switch (expressionsApi) { |
| 1060 | // Local BERT pipeline |
| 1061 | case EXPRESSION_API.local: { |
| 1062 | const localResult = await fetch('/api/extra/classify', { |
| 1063 | method: 'POST', |
| 1064 | headers: getRequestHeaders(), |
| 1065 | body: JSON.stringify({ text: text }), |
| 1066 | }); |
| 1067 | |
| 1068 | if (localResult.ok) { |
| 1069 | const data = await localResult.json(); |
| 1070 | return data.classification[0].label; |
| 1071 | } |
| 1072 | } break; |
| 1073 | // Using LLM |
| 1074 | case EXPRESSION_API.llm: { |
| 1075 | try { |
| 1076 | await waitUntilCondition(() => online_status !== 'no_connection', 3000, 250); |
| 1077 | } catch (error) { |
| 1078 | console.warn('No LLM connection. Using fallback expression', error); |
| 1079 | return extension_settings.expressions.fallback_expression; |
| 1080 | } |
| 1081 | |
| 1082 | const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable }); |
| 1083 | const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList); |
| 1084 | eventSource.once(event_types.TEXT_COMPLETION_SETTINGS_READY, onTextGenSettingsReady); |
| 1085 | |
| 1086 | let emotionResponse; |
| 1087 | try { |
| 1088 | inApiCall = true; |
| 1089 | switch (extension_settings.expressions.promptType) { |
| 1090 | case PROMPT_TYPE.raw: |
| 1091 | emotionResponse = await generateRaw({ prompt: text, systemPrompt: prompt }); |
| 1092 | break; |
| 1093 | case PROMPT_TYPE.full: |
| 1094 | emotionResponse = await generateQuietPrompt({ quietPrompt: prompt }); |
| 1095 | break; |
| 1096 | } |
| 1097 | } finally { |
| 1098 | inApiCall = false; |
| 1099 | } |
| 1100 | return parseLlmResponse(emotionResponse, expressionsList); |
| 1101 | } |
| 1102 | // Using WebLLM |
| 1103 | case EXPRESSION_API.webllm: { |
| 1104 | if (!isWebLlmSupported()) { |
| 1105 | console.warn('WebLLM is not supported. Using fallback expression'); |
| 1106 | return extension_settings.expressions.fallback_expression; |
| 1107 | } |
| 1108 | |
| 1109 | const expressionsList = await getExpressionsList({ filterAvailable: filterAvailable }); |
| 1110 | const prompt = substituteParamsExtended(customPrompt, { labels: expressionsList }) || await getLlmPrompt(expressionsList); |
| 1111 | const messages = [ |
| 1112 | { role: 'user', content: text + '\n\n' + prompt }, |
| 1113 | ]; |
| 1114 | |
| 1115 | const emotionResponse = await generateWebLlmChatPrompt(messages); |
| 1116 | return parseLlmResponse(emotionResponse, expressionsList); |
| 1117 | } |
| 1118 | // Extras |
| 1119 | case EXPRESSION_API.extras: { |
| 1120 | const url = new URL(getApiUrl()); |
| 1121 | url.pathname = '/api/classify'; |
| 1122 | |
| 1123 | const extrasResult = await doExtrasFetch(url, { |
| 1124 | method: 'POST', |
| 1125 | headers: { |
| 1126 | 'Content-Type': 'application/json', |
| 1127 | 'Bypass-Tunnel-Reminder': 'bypass', |
| 1128 | }, |
| 1129 | body: JSON.stringify({ text: text }), |
| 1130 | }); |
| 1131 | |
| 1132 | if (extrasResult.ok) { |
| 1133 | const data = await extrasResult.json(); |
| 1134 | return data.classification[0].label; |
| 1135 | } |
| 1136 | } break; |
| 1137 | // None |
| 1138 | case EXPRESSION_API.none: { |
| 1139 | // Return empty, the fallback expression will be used |
| 1140 | return ''; |
| 1141 | } |
| 1142 | default: { |
| 1143 | toastr.error('Invalid API selected'); |
| 1144 | return ''; |
| 1145 | } |
| 1146 | } |
| 1147 | } catch (error) { |
| 1148 | toastr.error('Could not classify expression. Check the console or your backend for more information.'); |
| 1149 | console.error(error); |
| 1150 | return extension_settings.expressions.fallback_expression; |
| 1151 | } |
| 1152 | } |
| 1153 | |
| 1154 | function getLastCharacterMessage() { |
| 1155 | const context = getContext(); |
| 1156 | const reversedChat = context.chat.slice().reverse(); |
| 1157 | |
| 1158 | for (let mes of reversedChat) { |
| 1159 | if (mes.is_user || mes.is_system || mes.extra?.type === system_message_types.NARRATOR) { |
| 1160 | continue; |
| 1161 | } |
| 1162 | |
| 1163 | return { mes: mes.mes, name: mes.name, original_avatar: mes.original_avatar, force_avatar: mes.force_avatar }; |
| 1164 | } |
| 1165 | |
| 1166 | return { mes: '', name: null, original_avatar: null, force_avatar: null }; |
| 1167 | } |
| 1168 | |
| 1169 | function removeExpression() { |
| 1170 | lastMessage = null; |
| 1171 | $('img.expression').off('error'); |
| 1172 | $('img.expression').prop('src', ''); |
| 1173 | $('img.expression').removeClass('default'); |
| 1174 | $('#open_chat_expressions').hide(); |
| 1175 | $('#no_chat_expressions').show(); |
| 1176 | } |
| 1177 | |
| 1178 | /** |
| 1179 | * Validate a character's sprites, and redraw the sprites list if not done before or forced to redraw. |
| 1180 | * @param {string} spriteFolderName - The character sprite folder to validate |
| 1181 | * @param {boolean} [forceRedrawCached=false] - Whether to force redrawing the sprites list even if it's already been drawn before |
| 1182 | */ |
| 1183 | async function validateImages(spriteFolderName, forceRedrawCached = false) { |
| 1184 | if (!spriteFolderName) { |
| 1185 | return; |
| 1186 | } |
| 1187 | |
| 1188 | const labels = await getExpressionsList(); |
| 1189 | |
| 1190 | if (spriteCache[spriteFolderName]) { |
| 1191 | if (forceRedrawCached && $('#image_list').data('name') !== spriteFolderName) { |
| 1192 | console.debug('force redrawing character sprites list'); |
| 1193 | await drawSpritesList(spriteFolderName, labels, spriteCache[spriteFolderName]); |
| 1194 | } |
| 1195 | |
| 1196 | return; |
| 1197 | } |
| 1198 | |
| 1199 | const sprites = await getSpritesList(spriteFolderName); |
| 1200 | let validExpressions = await drawSpritesList(spriteFolderName, labels, sprites); |
| 1201 | spriteCache[spriteFolderName] = validExpressions; |
| 1202 | } |
| 1203 | |
| 1204 | /** |
| 1205 | * Takes a given sprite as returned from the server, and enriches it with additional data for display/sorting |
| 1206 | * @param {{ path: string, label: string }} sprite |
| 1207 | * @returns {ExpressionImage} |
| 1208 | */ |
| 1209 | function getExpressionImageData(sprite) { |
| 1210 | const fileName = sprite.path.split('/').pop().split('?')[0]; |
| 1211 | const fileNameWithoutExtension = fileName.replace(/\.[^/.]+$/, ''); |
| 1212 | return { |
| 1213 | expression: sprite.label, |
| 1214 | fileName: fileName, |
| 1215 | title: fileNameWithoutExtension, |
| 1216 | imageSrc: sprite.path, |
| 1217 | type: 'success', |
| 1218 | isCustom: extension_settings.expressions.custom?.includes(sprite.label), |
| 1219 | }; |
| 1220 | } |
| 1221 | |
| 1222 | /** |
| 1223 | * Populate the character expression list with sprites for the given character. |
| 1224 | * @param {string} spriteFolderName - The name of the character to populate the list for |
| 1225 | * @param {string[]} labels - An array of expression labels that are valid |
| 1226 | * @param {Expression[]} sprites - An array of sprites |
| 1227 | * @returns {Promise<Expression[]>} An array of valid expression labels |
| 1228 | */ |
| 1229 | async function drawSpritesList(spriteFolderName, labels, sprites) { |
| 1230 | /** @type {Expression[]} */ |
| 1231 | let validExpressions = []; |
| 1232 | |
| 1233 | $('#no_chat_expressions').hide(); |
| 1234 | $('#open_chat_expressions').show(); |
| 1235 | $('#image_list').empty(); |
| 1236 | $('#image_list').data('name', spriteFolderName); |
| 1237 | $('#image_list_header_name').text(spriteFolderName); |
| 1238 | |
| 1239 | if (!Array.isArray(labels)) { |
| 1240 | return []; |
| 1241 | } |
| 1242 | |
| 1243 | for (const expression of labels.sort()) { |
| 1244 | const isCustom = extension_settings.expressions.custom?.includes(expression); |
| 1245 | const images = sprites |
| 1246 | .filter(s => s.label === expression) |
| 1247 | .map(s => s.files) |
| 1248 | .flat(); |
| 1249 | |
| 1250 | if (images.length === 0) { |
| 1251 | const listItem = await getListItem(expression, { |
| 1252 | isCustom, |
| 1253 | images: [getPlaceholderImage(expression, isCustom)], |
| 1254 | }); |
| 1255 | $('#image_list').append(listItem); |
| 1256 | continue; |
| 1257 | } |
| 1258 | |
| 1259 | validExpressions.push({ label: expression, files: images }); |
| 1260 | |
| 1261 | // Render main = first file, additional = rest |
| 1262 | let listItem = await getListItem(expression, { |
| 1263 | isCustom, |
| 1264 | images, |
| 1265 | }); |
| 1266 | $('#image_list').append(listItem); |
| 1267 | } |
| 1268 | return validExpressions; |
| 1269 | } |
| 1270 | |
| 1271 | /** |
| 1272 | * Renders a list item template for the expressions list. |
| 1273 | * @param {string} expression Expression name |
| 1274 | * @param {object} args Arguments object |
| 1275 | * @param {ExpressionImage[]} [args.images] Array of image objects |
| 1276 | * @param {boolean} [args.isCustom=false] If expression is added by user |
| 1277 | * @returns {Promise<string>} Rendered list item template |
| 1278 | */ |
| 1279 | async function getListItem(expression, { images, isCustom = false } = {}) { |
| 1280 | return renderExtensionTemplateAsync(MODULE_NAME, 'list-item', { expression, images, isCustom: isCustom ?? false }); |
| 1281 | } |
| 1282 | |
| 1283 | /** |
| 1284 | * Fetches and processes the list of sprites for a given character name. |
| 1285 | * Retrieves sprite data from the server and organizes it into labeled groups. |
| 1286 | * |
| 1287 | * @param {string} name - The character name to fetch sprites for |
| 1288 | * @returns {Promise<Expression[]>} A promise that resolves to an array of grouped expression objects, each containing a label and associated image data |
| 1289 | */ |
| 1290 | |
| 1291 | async function getSpritesList(name) { |
| 1292 | console.debug('getting sprites list'); |
| 1293 | |
| 1294 | try { |
| 1295 | const result = await fetch(`/api/sprites/get?name=${encodeURIComponent(name)}`); |
| 1296 | /** @type {{ label: string, path: string }[]} */ |
| 1297 | let sprites = result.ok ? (await result.json()) : []; |
| 1298 | |
| 1299 | /** @type {Expression[]} */ |
| 1300 | const grouped = sprites.reduce((acc, sprite) => { |
| 1301 | const imageData = getExpressionImageData(sprite); |
| 1302 | let existingExpression = acc.find(exp => exp.label === sprite.label); |
| 1303 | if (existingExpression) { |
| 1304 | existingExpression.files.push(imageData); |
| 1305 | } else { |
| 1306 | acc.push({ label: sprite.label, files: [imageData] }); |
| 1307 | } |
| 1308 | |
| 1309 | return acc; |
| 1310 | }, []); |
| 1311 | |
| 1312 | // Sort the sprites for each expression alphabetically, but keep the main expression file at the front |
| 1313 | for (const expression of grouped) { |
| 1314 | expression.files.sort((a, b) => { |
| 1315 | if (a.title === expression.label) return -1; |
| 1316 | if (b.title === expression.label) return 1; |
| 1317 | return a.title.localeCompare(b.title); |
| 1318 | }); |
| 1319 | |
| 1320 | // Mark all besides the first sprite as 'additional' |
| 1321 | for (let i = 1; i < expression.files.length; i++) { |
| 1322 | expression.files[i].type = 'additional'; |
| 1323 | } |
| 1324 | } |
| 1325 | |
| 1326 | return grouped; |
| 1327 | } catch (err) { |
| 1328 | console.log(err); |
| 1329 | return []; |
| 1330 | } |
| 1331 | } |
| 1332 | |
| 1333 | async function renderAdditionalExpressionSettings() { |
| 1334 | renderCustomExpressions(); |
| 1335 | await renderFallbackExpressionPicker(); |
| 1336 | } |
| 1337 | |
| 1338 | function renderCustomExpressions() { |
| 1339 | if (!Array.isArray(extension_settings.expressions.custom)) { |
| 1340 | extension_settings.expressions.custom = []; |
| 1341 | } |
| 1342 | |
| 1343 | const customExpressions = extension_settings.expressions.custom.sort((a, b) => a.localeCompare(b)); |
| 1344 | $('#expression_custom').empty(); |
| 1345 | |
| 1346 | for (const expression of customExpressions) { |
| 1347 | const option = document.createElement('option'); |
| 1348 | option.value = expression; |
| 1349 | option.text = expression; |
| 1350 | $('#expression_custom').append(option); |
| 1351 | } |
| 1352 | |
| 1353 | if (customExpressions.length === 0) { |
| 1354 | $('#expression_custom').append('<option value="" disabled selected>[ No custom expressions ]</option>'); |
| 1355 | } |
| 1356 | } |
| 1357 | |
| 1358 | async function renderFallbackExpressionPicker() { |
| 1359 | const expressions = await getExpressionsList(); |
| 1360 | |
| 1361 | const defaultPicker = $('#expression_fallback'); |
| 1362 | defaultPicker.empty(); |
| 1363 | |
| 1364 | |
| 1365 | addOption(OPTION_NO_FALLBACK, '[ No fallback ]', !extension_settings.expressions.fallback_expression && !extension_settings.expressions.showDefault); |
| 1366 | addOption(OPTION_EMOJI_FALLBACK, '[ Default emojis ]', !!extension_settings.expressions.showDefault); |
| 1367 | |
| 1368 | for (const expression of expressions) { |
| 1369 | addOption(expression, expression, expression == extension_settings.expressions.fallback_expression); |
| 1370 | } |
| 1371 | |
| 1372 | /** @type {(value: string, label: string, isSelected: boolean) => void} */ |
| 1373 | function addOption(value, label, isSelected) { |
| 1374 | const option = document.createElement('option'); |
| 1375 | option.value = value; |
| 1376 | option.text = label; |
| 1377 | option.selected = isSelected; |
| 1378 | defaultPicker.append(option); |
| 1379 | } |
| 1380 | } |
| 1381 | |
| 1382 | /** |
| 1383 | * Retrieves a unique list of cached expressions. |
| 1384 | * Combines the default expressions list with custom user-defined expressions. |
| 1385 | * |
| 1386 | * @returns {string[]} An array of unique expression labels |
| 1387 | */ |
| 1388 | |
| 1389 | function getCachedExpressions() { |
| 1390 | if (!Array.isArray(expressionsList)) { |
| 1391 | return []; |
| 1392 | } |
| 1393 | |
| 1394 | return [...expressionsList, ...extension_settings.expressions.custom].filter(onlyUnique); |
| 1395 | } |
| 1396 | |
| 1397 | export async function getExpressionsList({ filterAvailable = false } = {}) { |
| 1398 | // If there is no cached list, load and cache it |
| 1399 | if (!Array.isArray(expressionsList)) { |
| 1400 | expressionsList = await resolveExpressionsList(); |
| 1401 | } |
| 1402 | |
| 1403 | const expressions = getCachedExpressions(); |
| 1404 | |
| 1405 | // Filtering is only available for llm and webllm APIs |
| 1406 | if (!filterAvailable || ![EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)) { |
| 1407 | return expressions; |
| 1408 | } |
| 1409 | |
| 1410 | // Get expressions with available sprites |
| 1411 | const currentLastMessage = selected_group ? getLastCharacterMessage() : null; |
| 1412 | const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); |
| 1413 | |
| 1414 | return expressions.filter(label => { |
| 1415 | const expression = spriteCache[spriteFolderName]?.find(x => x.label === label); |
| 1416 | return (expression?.files.length ?? 0) > 0; |
| 1417 | }); |
| 1418 | |
| 1419 | /** |
| 1420 | * Returns the list of expressions from the API or fallback in offline mode. |
| 1421 | * @returns {Promise<string[]>} |
| 1422 | */ |
| 1423 | async function resolveExpressionsList() { |
| 1424 | // See if we can retrieve a specific expression list from the API |
| 1425 | try { |
| 1426 | // Check Extras api first, if enabled and that module active |
| 1427 | if (extension_settings.expressions.api == EXPRESSION_API.extras && modules.includes('classify')) { |
| 1428 | const url = new URL(getApiUrl()); |
| 1429 | url.pathname = '/api/classify/labels'; |
| 1430 | |
| 1431 | const apiResult = await doExtrasFetch(url, { |
| 1432 | method: 'GET', |
| 1433 | headers: { 'Bypass-Tunnel-Reminder': 'bypass' }, |
| 1434 | }); |
| 1435 | |
| 1436 | if (apiResult.ok) { |
| 1437 | const data = await apiResult.json(); |
| 1438 | expressionsList = data.labels; |
| 1439 | return expressionsList; |
| 1440 | } |
| 1441 | } |
| 1442 | |
| 1443 | // If running the local classify model (not using the LLM), we ask that one |
| 1444 | if (extension_settings.expressions.api == EXPRESSION_API.local) { |
| 1445 | const apiResult = await fetch('/api/extra/classify/labels', { |
| 1446 | method: 'POST', |
| 1447 | headers: getRequestHeaders({ omitContentType: true }), |
| 1448 | }); |
| 1449 | |
| 1450 | if (apiResult.ok) { |
| 1451 | const data = await apiResult.json(); |
| 1452 | expressionsList = data.labels; |
| 1453 | return expressionsList; |
| 1454 | } |
| 1455 | } |
| 1456 | } catch (error) { |
| 1457 | console.log(error); |
| 1458 | } |
| 1459 | |
| 1460 | // If there was no specific list, or an error, just return the default expressions |
| 1461 | expressionsList = DEFAULT_EXPRESSIONS.slice(); |
| 1462 | return expressionsList; |
| 1463 | } |
| 1464 | } |
| 1465 | |
| 1466 | /** |
| 1467 | * Selects a sprite from the given sprite folder for the given expression. |
| 1468 | * |
| 1469 | * If multiple sprites are allowed for the expression, it will randomly select one. |
| 1470 | * If the rerollIfSame option is enabled, it will only select a different sprite if the previous sprite was the same. |
| 1471 | * If the overrideSpriteFile option is set, it will look for the sprite with the given file name instead of randomly selecting one. |
| 1472 | * |
| 1473 | * @param {string} spriteFolderName - The name of the sprite folder |
| 1474 | * @param {string} expression - The expression to find the sprite for |
| 1475 | * @param {object} [options] - Options to select the sprite |
| 1476 | * @param {string} [options.prevExpressionSrc=null] - The source of the previous expression |
| 1477 | * @param {string} [options.overrideSpriteFile=null] - The file name of the sprite to select |
| 1478 | * @returns {ExpressionImage?} - The selected sprite |
| 1479 | */ |
| 1480 | function chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc = null, overrideSpriteFile = null } = {}) { |
| 1481 | if (!spriteCache[spriteFolderName]) return null; |
| 1482 | if (expression === RESET_SPRITE_LABEL) return null; |
| 1483 | |
| 1484 | // Search for sprites of that expression - or fallback expression sprites if enabled |
| 1485 | let sprite = spriteCache[spriteFolderName].find(x => x.label === expression); |
| 1486 | if (!(sprite?.files.length > 0) && extension_settings.expressions.fallback_expression) { |
| 1487 | sprite = spriteCache[spriteFolderName].find(x => x.label === extension_settings.expressions.fallback_expression); |
| 1488 | console.debug('Expression', expression, 'not found. Using fallback expression', extension_settings.expressions.fallback_expression); |
| 1489 | } |
| 1490 | if (!(sprite?.files.length > 0)) return null; |
| 1491 | |
| 1492 | let spriteFile = sprite.files[0]; |
| 1493 | |
| 1494 | // If a specific sprite file should be set, we are looking it up here |
| 1495 | if (overrideSpriteFile) { |
| 1496 | const searched = sprite.files.find(x => x.fileName === overrideSpriteFile); |
| 1497 | if (searched) spriteFile = searched; |
| 1498 | else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`); |
| 1499 | } else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) { |
| 1500 | // Else calculate next expression, if multiple are allowed |
| 1501 | let possibleFiles = sprite.files; |
| 1502 | if (extension_settings.expressions.rerollIfSame) { |
| 1503 | possibleFiles = possibleFiles.filter(x => !prevExpressionSrc || x.imageSrc !== prevExpressionSrc); |
| 1504 | } |
| 1505 | spriteFile = possibleFiles[Math.floor(Math.random() * possibleFiles.length)]; |
| 1506 | } |
| 1507 | |
| 1508 | return spriteFile; |
| 1509 | } |
| 1510 | |
| 1511 | /** |
| 1512 | * Set the expression of a character. |
| 1513 | * @param {string} spriteFolderName - The name of the character (folder name - can also be a costume override) |
| 1514 | * @param {string} expression - The expression or sprite name to set |
| 1515 | * @param {Object} options - Optional parameters |
| 1516 | * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on |
| 1517 | * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name. |
| 1518 | * @returns {Promise<void>} A promise that resolves when the expression has been set. |
| 1519 | */ |
| 1520 | async function setExpression(spriteFolderName, expression, { force = false, overrideSpriteFile = null } = {}) { |
| 1521 | await validateImages(spriteFolderName); |
| 1522 | const img = $('img.expression'); |
| 1523 | const prevExpressionSrc = img.attr('src'); |
| 1524 | const expressionClone = img.clone(); |
| 1525 | |
| 1526 | const spriteFile = chooseSpriteForExpression(spriteFolderName, expression, { prevExpressionSrc: prevExpressionSrc, overrideSpriteFile: overrideSpriteFile }); |
| 1527 | if (spriteFile) { |
| 1528 | if (force && isVisualNovelMode()) { |
| 1529 | const context = getContext(); |
| 1530 | const group = context.groups.find(x => x.id === context.groupId); |
| 1531 | |
| 1532 | // If it's a folder, make sure we find the group member based on the actual name |
| 1533 | const memberName = spriteFolderName.split('/')[0] ?? spriteFolderName; |
| 1534 | |
| 1535 | const groupMember = group.members |
| 1536 | .map(member => context.characters.find(x => x.avatar === member)) |
| 1537 | .find(groupMember => groupMember && groupMember.name === memberName); |
| 1538 | if (groupMember) { |
| 1539 | await setImage($(`.expression-holder[data-avatar="${groupMember.avatar}"] img`), spriteFile.imageSrc); |
| 1540 | return; |
| 1541 | } |
| 1542 | } |
| 1543 | |
| 1544 | //only swap expressions when necessary |
| 1545 | if (prevExpressionSrc !== spriteFile.imageSrc |
| 1546 | && !img.hasClass('expression-animating')) { |
| 1547 | //clone expression |
| 1548 | expressionClone.addClass('expression-clone'); |
| 1549 | //make invisible and remove id to prevent double ids |
| 1550 | //must be made invisible to start because they share the same Z-index |
| 1551 | expressionClone.attr('id', '').css({ opacity: 0 }); |
| 1552 | //add new sprite path to clone src |
| 1553 | expressionClone.attr('src', spriteFile.imageSrc); |
| 1554 | //set relevant data tags |
| 1555 | expressionClone.attr('data-sprite-folder-name', spriteFolderName); |
| 1556 | expressionClone.attr('data-expression', expression); |
| 1557 | expressionClone.attr('data-sprite-filename', spriteFile.fileName); |
| 1558 | expressionClone.attr('title', expression); |
| 1559 | //add invisible clone to html |
| 1560 | expressionClone.appendTo($('#expression-holder')); |
| 1561 | |
| 1562 | const duration = 200; |
| 1563 | |
| 1564 | //add animation flags to both images |
| 1565 | //to prevent multiple expression changes happening simultaneously |
| 1566 | img.addClass('expression-animating'); |
| 1567 | |
| 1568 | // Set the parent container's min width and height before running the transition |
| 1569 | const imgWidth = img.width(); |
| 1570 | const imgHeight = img.height(); |
| 1571 | const expressionHolder = img.parent(); |
| 1572 | expressionHolder.css('min-width', imgWidth > 100 ? imgWidth : 100); |
| 1573 | expressionHolder.css('min-height', imgHeight > 100 ? imgHeight : 100); |
| 1574 | |
| 1575 | //position absolute prevent the original from jumping around during transition |
| 1576 | img.css('position', 'absolute').width(imgWidth).height(imgHeight); |
| 1577 | expressionClone.addClass('expression-animating'); |
| 1578 | //fade the clone in |
| 1579 | expressionClone.css({ |
| 1580 | opacity: 0, |
| 1581 | }).animate({ |
| 1582 | opacity: 1, |
| 1583 | }, duration) |
| 1584 | //when finshed fading in clone, fade out the original |
| 1585 | .promise().done(function () { |
| 1586 | img.animate({ |
| 1587 | opacity: 0, |
| 1588 | }, duration); |
| 1589 | //remove old expression |
| 1590 | img.remove(); |
| 1591 | //replace ID so it becomes the new 'original' expression for next change |
| 1592 | expressionClone.attr('id', 'expression-image'); |
| 1593 | expressionClone.removeClass('expression-animating'); |
| 1594 | |
| 1595 | // Reset the expression holder min height and width |
| 1596 | expressionHolder.css('min-width', 100); |
| 1597 | expressionHolder.css('min-height', 100); |
| 1598 | }); |
| 1599 | |
| 1600 | expressionClone.removeClass('expression-clone'); |
| 1601 | |
| 1602 | expressionClone.removeClass('default'); |
| 1603 | expressionClone.off('error'); |
| 1604 | expressionClone.on('error', function (error) { |
| 1605 | console.debug('Expression image error', spriteFile.imageSrc, error); |
| 1606 | $(this).attr('src', ''); |
| 1607 | $(this).off('error'); |
| 1608 | if (force && extension_settings.expressions.showDefault) { |
| 1609 | setDefaultEmojiForImage(img, expression); |
| 1610 | } |
| 1611 | }); |
| 1612 | } |
| 1613 | |
| 1614 | console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName }); |
| 1615 | } else { |
| 1616 | img.attr('data-sprite-folder-name', spriteFolderName); |
| 1617 | |
| 1618 | img.off('error'); |
| 1619 | |
| 1620 | if (extension_settings.expressions.showDefault && expression !== RESET_SPRITE_LABEL) { |
| 1621 | setDefaultEmojiForImage(img, expression); |
| 1622 | } else { |
| 1623 | setNoneForImage(img, expression); |
| 1624 | } |
| 1625 | console.debug('Expression unset - No sprite found', { expression: expression }); |
| 1626 | } |
| 1627 | |
| 1628 | document.getElementById('expression-holder').style.display = ''; |
| 1629 | } |
| 1630 | |
| 1631 | /** |
| 1632 | * Sets the default expression image for the given image element and expression |
| 1633 | * @param {JQuery<HTMLElement>} img - The image element to set the default expression for |
| 1634 | * @param {string} expression - The expression label to use for the default image |
| 1635 | */ |
| 1636 | function setDefaultEmojiForImage(img, expression) { |
| 1637 | if (extension_settings.expressions.custom?.includes(expression)) { |
| 1638 | console.debug(`Can't set default emoji for a custom expression (${expression}). setting to ${DEFAULT_FALLBACK_EXPRESSION} instead.`); |
| 1639 | expression = DEFAULT_FALLBACK_EXPRESSION; |
| 1640 | } |
| 1641 | |
| 1642 | const defImgUrl = `/img/default-expressions/${expression}.png`; |
| 1643 | img.attr('src', defImgUrl); |
| 1644 | img.attr('data-expression', expression); |
| 1645 | img.attr('data-sprite-filename', null); |
| 1646 | img.attr('title', expression); |
| 1647 | img.addClass('default'); |
| 1648 | } |
| 1649 | |
| 1650 | /** |
| 1651 | * Sets the image element to display no expression by clearing its source attribute. |
| 1652 | * @param {JQuery<HTMLElement>} img - The image element to clear the expression for |
| 1653 | * @param {string} expression - The expression label to use |
| 1654 | */ |
| 1655 | function setNoneForImage(img, expression) { |
| 1656 | img.attr('src', ''); |
| 1657 | img.attr('data-expression', expression); |
| 1658 | img.attr('data-sprite-filename', null); |
| 1659 | img.attr('title', expression); |
| 1660 | img.removeClass('default'); |
| 1661 | } |
| 1662 | |
| 1663 | function onClickExpressionImage() { |
| 1664 | // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label |
| 1665 | if ($(this).attr('data-expression-type') === 'failure') { |
| 1666 | const label = $(this).attr('data-expression'); |
| 1667 | setSpriteSlashCommand({ type: 'expression' }, label); |
| 1668 | return; |
| 1669 | } |
| 1670 | |
| 1671 | const spriteFile = $(this).attr('data-filename'); |
| 1672 | setSpriteSlashCommand({ type: 'sprite' }, spriteFile); |
| 1673 | } |
| 1674 | |
| 1675 | async function onClickExpressionAddCustom() { |
| 1676 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'add-custom-expression'); |
| 1677 | let expressionName = await Popup.show.input(null, template); |
| 1678 | |
| 1679 | if (!expressionName) { |
| 1680 | console.debug('No custom expression name provided'); |
| 1681 | return; |
| 1682 | } |
| 1683 | |
| 1684 | expressionName = expressionName.trim().toLowerCase(); |
| 1685 | |
| 1686 | // a-z, 0-9, dashes and underscores only |
| 1687 | if (!/^[a-z0-9-_]+$/.test(expressionName)) { |
| 1688 | toastr.warning('Invalid custom expression name provided', 'Add Custom Expression'); |
| 1689 | return; |
| 1690 | } |
| 1691 | if (DEFAULT_EXPRESSIONS.includes(expressionName) || DEFAULT_EXPRESSIONS.some(x => expressionName.startsWith(x))) { |
| 1692 | toastr.warning('Expression name already exists', 'Add Custom Expression'); |
| 1693 | return; |
| 1694 | } |
| 1695 | if (extension_settings.expressions.custom.includes(expressionName)) { |
| 1696 | toastr.warning('Custom expression already exists', 'Add Custom Expression'); |
| 1697 | return; |
| 1698 | } |
| 1699 | |
| 1700 | // Add custom expression into settings |
| 1701 | extension_settings.expressions.custom.push(expressionName); |
| 1702 | await renderAdditionalExpressionSettings(); |
| 1703 | saveSettingsDebounced(); |
| 1704 | |
| 1705 | // Force refresh sprites list |
| 1706 | expressionsList = null; |
| 1707 | spriteCache = {}; |
| 1708 | moduleWorker(); |
| 1709 | } |
| 1710 | |
| 1711 | async function onClickExpressionRemoveCustom() { |
| 1712 | const selectedExpression = String($('#expression_custom').val()); |
| 1713 | const noCustomExpressions = extension_settings.expressions.custom.length === 0; |
| 1714 | |
| 1715 | if (!selectedExpression || noCustomExpressions) { |
| 1716 | console.debug('No custom expression selected'); |
| 1717 | return; |
| 1718 | } |
| 1719 | |
| 1720 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'remove-custom-expression', { expression: selectedExpression }); |
| 1721 | const confirmation = await Popup.show.confirm(null, template); |
| 1722 | |
| 1723 | if (!confirmation) { |
| 1724 | console.debug('Custom expression removal cancelled'); |
| 1725 | return; |
| 1726 | } |
| 1727 | |
| 1728 | // Remove custom expression from settings |
| 1729 | const index = extension_settings.expressions.custom.indexOf(selectedExpression); |
| 1730 | extension_settings.expressions.custom.splice(index, 1); |
| 1731 | if (selectedExpression == extension_settings.expressions.fallback_expression) { |
| 1732 | 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'); |
| 1733 | extension_settings.expressions.fallback_expression = DEFAULT_FALLBACK_EXPRESSION; |
| 1734 | } |
| 1735 | await renderAdditionalExpressionSettings(); |
| 1736 | saveSettingsDebounced(); |
| 1737 | |
| 1738 | // Force refresh sprites list |
| 1739 | expressionsList = null; |
| 1740 | spriteCache = {}; |
| 1741 | moduleWorker(); |
| 1742 | } |
| 1743 | |
| 1744 | function onExpressionApiChanged() { |
| 1745 | const tempApi = this.value; |
| 1746 | if (tempApi) { |
| 1747 | extension_settings.expressions.api = Number(tempApi); |
| 1748 | $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); |
| 1749 | $('.expression_prompt_type_block').toggle(extension_settings.expressions.api === EXPRESSION_API.llm); |
| 1750 | expressionsList = null; |
| 1751 | spriteCache = {}; |
| 1752 | moduleWorker(); |
| 1753 | saveSettingsDebounced(); |
| 1754 | } |
| 1755 | } |
| 1756 | |
| 1757 | async function onExpressionFallbackChanged() { |
| 1758 | /** @type {HTMLSelectElement} */ |
| 1759 | const select = this; |
| 1760 | const selectedValue = select.value; |
| 1761 | |
| 1762 | switch (selectedValue) { |
| 1763 | case OPTION_NO_FALLBACK: |
| 1764 | extension_settings.expressions.fallback_expression = null; |
| 1765 | extension_settings.expressions.showDefault = false; |
| 1766 | break; |
| 1767 | case OPTION_EMOJI_FALLBACK: |
| 1768 | extension_settings.expressions.fallback_expression = null; |
| 1769 | extension_settings.expressions.showDefault = true; |
| 1770 | break; |
| 1771 | default: |
| 1772 | extension_settings.expressions.fallback_expression = selectedValue; |
| 1773 | extension_settings.expressions.showDefault = false; |
| 1774 | break; |
| 1775 | } |
| 1776 | |
| 1777 | const img = $('img.expression'); |
| 1778 | const spriteFolderName = img.attr('data-sprite-folder-name'); |
| 1779 | const expression = img.attr('data-expression'); |
| 1780 | |
| 1781 | if (spriteFolderName && expression) { |
| 1782 | await sendExpressionCall(spriteFolderName, expression, { force: true }); |
| 1783 | } |
| 1784 | |
| 1785 | saveSettingsDebounced(); |
| 1786 | } |
| 1787 | |
| 1788 | /** |
| 1789 | * Handles the file upload process for a sprite image. |
| 1790 | * @param {string} url URL to upload the file to |
| 1791 | * @param {FormData} formData FormData object containing the file and other data to upload |
| 1792 | * @returns {Promise<any>} - The response data from the server |
| 1793 | */ |
| 1794 | async function handleFileUpload(url, formData) { |
| 1795 | try { |
| 1796 | const result = await fetch(url, { |
| 1797 | method: 'POST', |
| 1798 | headers: getRequestHeaders({ omitContentType: true }), |
| 1799 | body: formData, |
| 1800 | cache: 'no-cache', |
| 1801 | }); |
| 1802 | |
| 1803 | if (!result.ok) { |
| 1804 | throw new Error(`Upload failed with status ${result.status}`); |
| 1805 | } |
| 1806 | |
| 1807 | const data = await result.json(); |
| 1808 | |
| 1809 | // Refresh sprites list |
| 1810 | const name = formData.get('name').toString(); |
| 1811 | delete spriteCache[name]; |
| 1812 | await fetchImagesNoCache(); |
| 1813 | await validateImages(name); |
| 1814 | |
| 1815 | return data ?? {}; |
| 1816 | } catch (error) { |
| 1817 | console.error('Error uploading image:', error); |
| 1818 | toastr.error('Failed to upload image'); |
| 1819 | return {}; |
| 1820 | } |
| 1821 | } |
| 1822 | |
| 1823 | /** |
| 1824 | * Removes the file extension from a file name |
| 1825 | * @param {string} fileName The file name to remove the extension from |
| 1826 | * @returns {string} The file name without the extension |
| 1827 | */ |
| 1828 | function withoutExtension(fileName) { |
| 1829 | return fileName.replace(/\.[^/.]+$/, ''); |
| 1830 | } |
| 1831 | |
| 1832 | function validateExpressionSpriteName(expression, spriteName) { |
| 1833 | const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`); |
| 1834 | const validFileName = filenameValidationRegex.test(spriteName); |
| 1835 | return validFileName; |
| 1836 | } |
| 1837 | |
| 1838 | async function onClickExpressionUpload(event) { |
| 1839 | // Prevents the expression from being set |
| 1840 | event.stopPropagation(); |
| 1841 | |
| 1842 | const expressionListItem = $(this).closest('.expression_list_item'); |
| 1843 | |
| 1844 | const clickedFileName = expressionListItem.attr('data-expression-type') !== 'failure' ? expressionListItem.attr('data-filename') : null; |
| 1845 | const expression = expressionListItem.data('expression'); |
| 1846 | const name = $('#image_list').data('name'); |
| 1847 | |
| 1848 | const handleExpressionUploadChange = async (e) => { |
| 1849 | const file = e.target.files[0]; |
| 1850 | |
| 1851 | if (!file || !file.name) { |
| 1852 | console.debug('No valid file selected'); |
| 1853 | return; |
| 1854 | } |
| 1855 | |
| 1856 | const existingFiles = spriteCache[name]?.find(x => x.label === expression)?.files || []; |
| 1857 | |
| 1858 | let spriteName = expression; |
| 1859 | |
| 1860 | if (extension_settings.expressions.allowMultiple) { |
| 1861 | const matchesExisting = existingFiles.some(x => x.fileName === file.name); |
| 1862 | const fileNameWithoutExtension = withoutExtension(file.name); |
| 1863 | const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension); |
| 1864 | |
| 1865 | if (!clickedFileName && validFileName) { |
| 1866 | // If there is no expression yet and it's a valid expression, we just take it |
| 1867 | spriteName = fileNameWithoutExtension; |
| 1868 | } else if (clickedFileName === file.name) { |
| 1869 | // If the filename matches the one that was clicked, we just take it and replace it |
| 1870 | spriteName = fileNameWithoutExtension; |
| 1871 | } else if (!matchesExisting && validFileName) { |
| 1872 | // If it's a valid filename and there's no existing file with the same name, we just take it |
| 1873 | spriteName = fileNameWithoutExtension; |
| 1874 | } else { |
| 1875 | /** @type {import('../../popup.js').CustomPopupButton[]} */ |
| 1876 | const customButtons = []; |
| 1877 | if (clickedFileName) { |
| 1878 | customButtons.push({ |
| 1879 | text: t`Replace Existing`, |
| 1880 | result: POPUP_RESULT.NEGATIVE, |
| 1881 | action: () => { |
| 1882 | console.debug('Replacing existing sprite'); |
| 1883 | spriteName = withoutExtension(clickedFileName); |
| 1884 | }, |
| 1885 | }); |
| 1886 | } |
| 1887 | |
| 1888 | spriteName = null; |
| 1889 | const suggestedSpriteName = generateUniqueSpriteName(expression, existingFiles); |
| 1890 | |
| 1891 | const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); |
| 1892 | |
| 1893 | const input = await Popup.show.input(t`Upload Expression Sprite`, message, |
| 1894 | suggestedSpriteName, { customButtons: customButtons }); |
| 1895 | |
| 1896 | if (input) { |
| 1897 | if (!validateExpressionSpriteName(expression, input)) { |
| 1898 | toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`); |
| 1899 | return; |
| 1900 | } |
| 1901 | spriteName = input; |
| 1902 | } |
| 1903 | } |
| 1904 | } else { |
| 1905 | spriteName = withoutExtension(expression); |
| 1906 | } |
| 1907 | |
| 1908 | if (!spriteName) { |
| 1909 | toastr.warning(t`Cancelled uploading sprite.`, t`Upload Cancelled`); |
| 1910 | // Reset the input |
| 1911 | e.target.form.reset(); |
| 1912 | return; |
| 1913 | } |
| 1914 | |
| 1915 | const formData = new FormData(); |
| 1916 | formData.append('name', name); |
| 1917 | formData.append('label', expression); |
| 1918 | formData.append('avatar', file); |
| 1919 | formData.append('spriteName', spriteName); |
| 1920 | |
| 1921 | await handleFileUpload('/api/sprites/upload', formData); |
| 1922 | |
| 1923 | // Reset the input |
| 1924 | e.target.form.reset(); |
| 1925 | }; |
| 1926 | |
| 1927 | $('#expression_upload') |
| 1928 | .off('change') |
| 1929 | .on('change', handleExpressionUploadChange) |
| 1930 | .trigger('click'); |
| 1931 | } |
| 1932 | |
| 1933 | async function onClickExpressionOverrideButton() { |
| 1934 | const context = getContext(); |
| 1935 | const currentLastMessage = getLastCharacterMessage(); |
| 1936 | const avatarFileName = getFolderNameByMessage(currentLastMessage); |
| 1937 | |
| 1938 | // If the avatar name couldn't be found, abort. |
| 1939 | if (!avatarFileName) { |
| 1940 | console.debug(`Could not find filename for character with name ${currentLastMessage.name} and ID ${context.characterId}`); |
| 1941 | |
| 1942 | return; |
| 1943 | } |
| 1944 | |
| 1945 | const overridePath = String($('#expression_override').val()); |
| 1946 | const existingOverrideIndex = extension_settings.expressionOverrides.findIndex((e) => |
| 1947 | e.name == avatarFileName, |
| 1948 | ); |
| 1949 | |
| 1950 | // If the path is empty, delete the entry from overrides |
| 1951 | if (overridePath === undefined || overridePath.length === 0) { |
| 1952 | if (existingOverrideIndex === -1) { |
| 1953 | return; |
| 1954 | } |
| 1955 | |
| 1956 | extension_settings.expressionOverrides.splice(existingOverrideIndex, 1); |
| 1957 | console.debug(`Removed existing override for ${avatarFileName}`); |
| 1958 | } else { |
| 1959 | // Properly override objects and clear the sprite cache of the previously set names |
| 1960 | const existingOverride = extension_settings.expressionOverrides[existingOverrideIndex]; |
| 1961 | if (existingOverride) { |
| 1962 | Object.assign(existingOverride, { path: overridePath }); |
| 1963 | delete spriteCache[existingOverride.name]; |
| 1964 | } else { |
| 1965 | const characterOverride = { name: avatarFileName, path: overridePath }; |
| 1966 | extension_settings.expressionOverrides.push(characterOverride); |
| 1967 | delete spriteCache[currentLastMessage.name]; |
| 1968 | } |
| 1969 | |
| 1970 | console.debug(`Added/edited expression override for character with filename ${avatarFileName} to folder ${overridePath}`); |
| 1971 | } |
| 1972 | |
| 1973 | saveSettingsDebounced(); |
| 1974 | |
| 1975 | // Refresh sprites list. Assume the override path has been properly handled. |
| 1976 | try { |
| 1977 | inApiCall = true; |
| 1978 | $('#visual-novel-wrapper').empty(); |
| 1979 | await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true); |
| 1980 | const name = overridePath.length === 0 ? currentLastMessage.name : overridePath; |
| 1981 | const expression = await getExpressionLabel(currentLastMessage.mes); |
| 1982 | await sendExpressionCall(name, expression, { force: true }); |
| 1983 | forceUpdateVisualNovelMode(); |
| 1984 | } catch (error) { |
| 1985 | console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`); |
| 1986 | } finally { |
| 1987 | inApiCall = false; |
| 1988 | } |
| 1989 | } |
| 1990 | |
| 1991 | async function onClickExpressionOverrideRemoveAllButton() { |
| 1992 | // Remove all the overrided entries from sprite cache |
| 1993 | for (const element of extension_settings.expressionOverrides) { |
| 1994 | delete spriteCache[element.name]; |
| 1995 | } |
| 1996 | |
| 1997 | extension_settings.expressionOverrides = []; |
| 1998 | saveSettingsDebounced(); |
| 1999 | |
| 2000 | console.debug('All expression image overrides have been cleared.'); |
| 2001 | |
| 2002 | // Refresh sprites list to use the default name if applicable |
| 2003 | try { |
| 2004 | $('#visual-novel-wrapper').empty(); |
| 2005 | const currentLastMessage = getLastCharacterMessage(); |
| 2006 | await validateImages(currentLastMessage.name, true); |
| 2007 | const expression = await getExpressionLabel(currentLastMessage.mes); |
| 2008 | await sendExpressionCall(currentLastMessage.name, expression, { force: true }); |
| 2009 | forceUpdateVisualNovelMode(); |
| 2010 | |
| 2011 | console.debug(extension_settings.expressionOverrides); |
| 2012 | } catch (error) { |
| 2013 | console.debug(`The current expression could not be set because of error: ${error}`); |
| 2014 | } |
| 2015 | } |
| 2016 | |
| 2017 | async function onClickExpressionUploadPackButton() { |
| 2018 | const name = $('#image_list').data('name'); |
| 2019 | |
| 2020 | const handleFileUploadChange = async (e) => { |
| 2021 | const file = e.target.files[0]; |
| 2022 | |
| 2023 | if (!file) { |
| 2024 | return; |
| 2025 | } |
| 2026 | |
| 2027 | const formData = new FormData(); |
| 2028 | formData.append('name', name); |
| 2029 | formData.append('avatar', file); |
| 2030 | |
| 2031 | const uploadToast = toastr.info('Please wait...', 'Upload is processing', { timeOut: 0, extendedTimeOut: 0 }); |
| 2032 | const { count } = await handleFileUpload('/api/sprites/upload-zip', formData); |
| 2033 | toastr.clear(uploadToast); |
| 2034 | |
| 2035 | // Only show success message if at least one image was uploaded |
| 2036 | if (count) { |
| 2037 | toastr.success(`Uploaded ${count} image(s) for ${name}`); |
| 2038 | } |
| 2039 | |
| 2040 | // Reset the input |
| 2041 | e.target.form.reset(); |
| 2042 | }; |
| 2043 | |
| 2044 | $('#expression_upload_pack') |
| 2045 | .off('change') |
| 2046 | .on('change', handleFileUploadChange) |
| 2047 | .trigger('click'); |
| 2048 | } |
| 2049 | |
| 2050 | async function onClickExpressionDelete(event) { |
| 2051 | // Prevents the expression from being set |
| 2052 | event.stopPropagation(); |
| 2053 | |
| 2054 | const expressionListItem = $(this).closest('.expression_list_item'); |
| 2055 | const expression = expressionListItem.data('expression'); |
| 2056 | |
| 2057 | if (expressionListItem.attr('data-expression-type') === 'failure') { |
| 2058 | return; |
| 2059 | } |
| 2060 | |
| 2061 | 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!` |
| 2062 | + '<br /><br />' |
| 2063 | + t`Expression:` + ' <tt>' + expressionListItem.attr('data-filename') + '</tt>'); |
| 2064 | if (!confirmation) { |
| 2065 | return; |
| 2066 | } |
| 2067 | |
| 2068 | const fileName = withoutExtension(expressionListItem.attr('data-filename')); |
| 2069 | const name = $('#image_list').data('name'); |
| 2070 | |
| 2071 | try { |
| 2072 | await fetch('/api/sprites/delete', { |
| 2073 | method: 'POST', |
| 2074 | headers: getRequestHeaders(), |
| 2075 | body: JSON.stringify({ name, label: expression, spriteName: fileName }), |
| 2076 | }); |
| 2077 | } catch (error) { |
| 2078 | toastr.error('Failed to delete image. Try again later.'); |
| 2079 | } |
| 2080 | |
| 2081 | // Refresh sprites list |
| 2082 | delete spriteCache[name]; |
| 2083 | await fetchImagesNoCache(); |
| 2084 | await validateImages(name); |
| 2085 | } |
| 2086 | |
| 2087 | function setExpressionOverrideHtml(forceClear = false) { |
| 2088 | const currentLastMessage = getLastCharacterMessage(); |
| 2089 | const avatarFileName = getFolderNameByMessage(currentLastMessage); |
| 2090 | if (!avatarFileName) { |
| 2091 | return; |
| 2092 | } |
| 2093 | |
| 2094 | const expressionOverride = extension_settings.expressionOverrides.find((e) => |
| 2095 | e.name == avatarFileName, |
| 2096 | ); |
| 2097 | |
| 2098 | if (expressionOverride && expressionOverride.path) { |
| 2099 | $('#expression_override').val(expressionOverride.path); |
| 2100 | } else if (expressionOverride) { |
| 2101 | delete extension_settings.expressionOverrides[expressionOverride.name]; |
| 2102 | } |
| 2103 | |
| 2104 | if (forceClear && !expressionOverride) { |
| 2105 | $('#expression_override').val(''); |
| 2106 | } |
| 2107 | } |
| 2108 | |
| 2109 | async function fetchImagesNoCache() { |
| 2110 | const promises = []; |
| 2111 | $('#image_list img').each(function () { |
| 2112 | const src = $(this).attr('src'); |
| 2113 | |
| 2114 | if (!src) { |
| 2115 | return; |
| 2116 | } |
| 2117 | |
| 2118 | const promise = fetch(src, { |
| 2119 | method: 'GET', |
| 2120 | cache: 'no-cache', |
| 2121 | headers: { |
| 2122 | 'Cache-Control': 'no-cache', |
| 2123 | 'Pragma': 'no-cache', |
| 2124 | 'Expires': '0', |
| 2125 | }, |
| 2126 | }); |
| 2127 | promises.push(promise); |
| 2128 | }); |
| 2129 | |
| 2130 | return await Promise.allSettled(promises); |
| 2131 | } |
| 2132 | |
| 2133 | function migrateSettings() { |
| 2134 | if (extension_settings.expressions.api === undefined) { |
| 2135 | extension_settings.expressions.api = EXPRESSION_API.none; |
| 2136 | saveSettingsDebounced(); |
| 2137 | } |
| 2138 | |
| 2139 | if (Object.keys(extension_settings.expressions).includes('local')) { |
| 2140 | if (extension_settings.expressions.local) { |
| 2141 | extension_settings.expressions.api = EXPRESSION_API.local; |
| 2142 | } |
| 2143 | |
| 2144 | delete extension_settings.expressions.local; |
| 2145 | saveSettingsDebounced(); |
| 2146 | } |
| 2147 | |
| 2148 | if (extension_settings.expressions.llmPrompt === undefined) { |
| 2149 | extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; |
| 2150 | saveSettingsDebounced(); |
| 2151 | } |
| 2152 | |
| 2153 | if (extension_settings.expressions.allowMultiple === undefined) { |
| 2154 | extension_settings.expressions.allowMultiple = true; |
| 2155 | saveSettingsDebounced(); |
| 2156 | } |
| 2157 | |
| 2158 | if (extension_settings.expressions.showDefault && extension_settings.expressions.fallback_expression) { |
| 2159 | extension_settings.expressions.showDefault = false; |
| 2160 | saveSettingsDebounced(); |
| 2161 | } |
| 2162 | |
| 2163 | if (extension_settings.expressions.promptType === undefined) { |
| 2164 | extension_settings.expressions.promptType = PROMPT_TYPE.raw; |
| 2165 | saveSettingsDebounced(); |
| 2166 | } |
| 2167 | } |
| 2168 | |
| 2169 | export async function init() { |
| 2170 | function addExpressionImage() { |
| 2171 | const html = ` |
| 2172 | <div id="expression-wrapper"> |
| 2173 | <div id="expression-holder" class="expression-holder" style="display:none;"> |
| 2174 | <div id="expression-holderheader" class="fa-solid fa-grip drag-grabber"></div> |
| 2175 | <img id="expression-image" class="expression"> |
| 2176 | </div> |
| 2177 | </div>`; |
| 2178 | $('body').append(html); |
| 2179 | loadMovingUIState(); |
| 2180 | } |
| 2181 | function addVisualNovelMode() { |
| 2182 | const html = ` |
| 2183 | <div id="visual-novel-wrapper"> |
| 2184 | </div>`; |
| 2185 | const element = $(html); |
| 2186 | element.hide(); |
| 2187 | $('body').append(element); |
| 2188 | } |
| 2189 | async function addSettings() { |
| 2190 | const template = await renderExtensionTemplateAsync(MODULE_NAME, 'settings'); |
| 2191 | $('#expressions_container').append(template); |
| 2192 | $('#expression_override_button').on('click', onClickExpressionOverrideButton); |
| 2193 | $('#expression_upload_pack_button').on('click', onClickExpressionUploadPackButton); |
| 2194 | $('#expression_translate').prop('checked', extension_settings.expressions.translate).on('input', function () { |
| 2195 | extension_settings.expressions.translate = !!$(this).prop('checked'); |
| 2196 | saveSettingsDebounced(); |
| 2197 | }); |
| 2198 | $('#expressions_allow_multiple').prop('checked', extension_settings.expressions.allowMultiple).on('input', function () { |
| 2199 | extension_settings.expressions.allowMultiple = !!$(this).prop('checked'); |
| 2200 | saveSettingsDebounced(); |
| 2201 | }); |
| 2202 | $('#expressions_reroll_if_same').prop('checked', extension_settings.expressions.rerollIfSame).on('input', function () { |
| 2203 | extension_settings.expressions.rerollIfSame = !!$(this).prop('checked'); |
| 2204 | saveSettingsDebounced(); |
| 2205 | }); |
| 2206 | $('#expressions_filter_available').prop('checked', extension_settings.expressions.filterAvailable).on('input', function () { |
| 2207 | extension_settings.expressions.filterAvailable = !!$(this).prop('checked'); |
| 2208 | saveSettingsDebounced(); |
| 2209 | }); |
| 2210 | $('#expression_override_cleanup_button').on('click', onClickExpressionOverrideRemoveAllButton); |
| 2211 | $(document).on('dragstart', '.expression', (e) => { |
| 2212 | e.preventDefault(); |
| 2213 | return false; |
| 2214 | }); |
| 2215 | $(document).on('click', '.expression_list_item', onClickExpressionImage); |
| 2216 | $(document).on('click', '.expression_list_upload', onClickExpressionUpload); |
| 2217 | $(document).on('click', '.expression_list_delete', onClickExpressionDelete); |
| 2218 | $(window).on('resize', () => updateVisualNovelModeDebounced()); |
| 2219 | $('#open_chat_expressions').hide(); |
| 2220 | |
| 2221 | await renderAdditionalExpressionSettings(); |
| 2222 | $('#expression_api').val(extension_settings.expressions.api ?? EXPRESSION_API.none); |
| 2223 | $('.expression_llm_prompt_block').toggle([EXPRESSION_API.llm, EXPRESSION_API.webllm].includes(extension_settings.expressions.api)); |
| 2224 | $('#expression_llm_prompt').val(extension_settings.expressions.llmPrompt ?? ''); |
| 2225 | $('#expression_llm_prompt').on('input', function () { |
| 2226 | extension_settings.expressions.llmPrompt = String($(this).val()); |
| 2227 | saveSettingsDebounced(); |
| 2228 | }); |
| 2229 | $('#expression_llm_prompt_restore').on('click', function () { |
| 2230 | $('#expression_llm_prompt').val(DEFAULT_LLM_PROMPT); |
| 2231 | extension_settings.expressions.llmPrompt = DEFAULT_LLM_PROMPT; |
| 2232 | saveSettingsDebounced(); |
| 2233 | }); |
| 2234 | $('#expression_prompt_raw').on('input', function () { |
| 2235 | extension_settings.expressions.promptType = PROMPT_TYPE.raw; |
| 2236 | saveSettingsDebounced(); |
| 2237 | }); |
| 2238 | $('#expression_prompt_full').on('input', function () { |
| 2239 | extension_settings.expressions.promptType = PROMPT_TYPE.full; |
| 2240 | saveSettingsDebounced(); |
| 2241 | }); |
| 2242 | $(`input[name="expression_prompt_type"][value="${extension_settings.expressions.promptType}"]`).prop('checked', true); |
| 2243 | $('.expression_prompt_type_block').toggle(extension_settings.expressions.api === EXPRESSION_API.llm); |
| 2244 | |
| 2245 | $('#expression_custom_add').on('click', onClickExpressionAddCustom); |
| 2246 | $('#expression_custom_remove').on('click', onClickExpressionRemoveCustom); |
| 2247 | $('#expression_fallback').on('change', onExpressionFallbackChanged); |
| 2248 | $('#expression_api').on('change', onExpressionApiChanged); |
| 2249 | } |
| 2250 | |
| 2251 | addExpressionImage(); |
| 2252 | addVisualNovelMode(); |
| 2253 | migrateSettings(); |
| 2254 | await addSettings(); |
| 2255 | const wrapper = new ModuleWorkerWrapper(moduleWorker); |
| 2256 | const updateFunction = wrapper.update.bind(wrapper); |
| 2257 | setInterval(updateFunction, UPDATE_INTERVAL); |
| 2258 | moduleWorker(); |
| 2259 | dragElement($('#expression-holder')); |
| 2260 | eventSource.on(event_types.CHAT_CHANGED, () => { |
| 2261 | // character changed |
| 2262 | removeExpression(); |
| 2263 | spriteCache = {}; |
| 2264 | lastExpression = {}; |
| 2265 | |
| 2266 | //clear expression |
| 2267 | let imgElement = document.getElementById('expression-image'); |
| 2268 | if (imgElement && imgElement instanceof HTMLImageElement) { |
| 2269 | imgElement.src = ''; |
| 2270 | } |
| 2271 | |
| 2272 | setExpressionOverrideHtml(true); // force-clear, as the character might not have an override defined |
| 2273 | |
| 2274 | if (isVisualNovelMode()) { |
| 2275 | $('#visual-novel-wrapper').empty(); |
| 2276 | } |
| 2277 | |
| 2278 | updateFunction({ newChat: true }); |
| 2279 | }); |
| 2280 | eventSource.on(event_types.MOVABLE_PANELS_RESET, updateVisualNovelModeDebounced); |
| 2281 | eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced); |
| 2282 | |
| 2283 | const localEnumProviders = { |
| 2284 | expressions: () => { |
| 2285 | const currentLastMessage = selected_group ? getLastCharacterMessage() : null; |
| 2286 | const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); |
| 2287 | const expressions = getCachedExpressions(); |
| 2288 | return expressions.map(expression => { |
| 2289 | const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0; |
| 2290 | const isCustom = extension_settings.expressions.custom?.includes(expression); |
| 2291 | const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' : |
| 2292 | spriteCount > 1 ? `${spriteCount} sprites` : null; |
| 2293 | return new SlashCommandEnumValue(expression, |
| 2294 | subtitle, |
| 2295 | isCustom ? enumTypes.name : enumTypes.enum, |
| 2296 | isCustom ? 'C' : 'D'); |
| 2297 | }); |
| 2298 | }, |
| 2299 | sprites: () => { |
| 2300 | const currentLastMessage = selected_group ? getLastCharacterMessage() : null; |
| 2301 | const spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage?.name); |
| 2302 | const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? []; |
| 2303 | return sprites.map(x => { |
| 2304 | return new SlashCommandEnumValue(x.title, |
| 2305 | x.title !== x.expression ? x.expression : null, |
| 2306 | x.isCustom ? enumTypes.name : enumTypes.enum, |
| 2307 | x.isCustom ? 'C' : 'D'); |
| 2308 | }); |
| 2309 | }, |
| 2310 | }; |
| 2311 | |
| 2312 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2313 | name: 'expression-set', |
| 2314 | aliases: ['sprite', 'emote'], |
| 2315 | callback: setSpriteSlashCommand, |
| 2316 | namedArgumentList: [ |
| 2317 | SlashCommandNamedArgument.fromProps({ |
| 2318 | name: 'type', |
| 2319 | description: 'Whether to set an expression or a specific sprite.', |
| 2320 | typeList: [ARGUMENT_TYPE.STRING], |
| 2321 | isRequired: false, |
| 2322 | defaultValue: 'expression', |
| 2323 | enumList: ['expression', 'sprite'], |
| 2324 | }), |
| 2325 | ], |
| 2326 | unnamedArgumentList: [ |
| 2327 | SlashCommandArgument.fromProps({ |
| 2328 | description: 'expression label to set', |
| 2329 | typeList: [ARGUMENT_TYPE.STRING], |
| 2330 | isRequired: true, |
| 2331 | enumProvider: (executor, _) => { |
| 2332 | // Check if command is used to set a sprite, then use those enums |
| 2333 | const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression'; |
| 2334 | if (type == 'sprite') return localEnumProviders.sprites(); |
| 2335 | else return [ |
| 2336 | ...localEnumProviders.expressions(), |
| 2337 | new SlashCommandEnumValue(RESET_SPRITE_LABEL, 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'), |
| 2338 | ]; |
| 2339 | }, |
| 2340 | }), |
| 2341 | ], |
| 2342 | helpString: 'Force sets the expression for the current character.', |
| 2343 | returns: 'The currently set expression label after setting it.', |
| 2344 | })); |
| 2345 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2346 | name: 'expression-fallback', |
| 2347 | callback: setFallBackExpressionSlashCommand, |
| 2348 | unnamedArgumentList: [ |
| 2349 | SlashCommandArgument.fromProps({ |
| 2350 | description: 'expression label to set', |
| 2351 | typeList: [ARGUMENT_TYPE.STRING], |
| 2352 | isRequired: false, |
| 2353 | enumProvider: () => [ |
| 2354 | new SlashCommandEnumValue('#none', 'Sets the fallback expression to no image'), |
| 2355 | new SlashCommandEnumValue('#emoji', 'Sets the fallback expression to emojis'), |
| 2356 | ...localEnumProviders.expressions(), |
| 2357 | ], |
| 2358 | }), |
| 2359 | ], |
| 2360 | helpString: ` |
| 2361 | <div> |
| 2362 | Gets the currently selected expression fallback for all characters.<br /> |
| 2363 | If a valid expression label is sent, it will be set as the new fallback. |
| 2364 | </div> |
| 2365 | <div> |
| 2366 | <strong>Example:</strong> |
| 2367 | <ul> |
| 2368 | <li> |
| 2369 | <pre><code>/expression-fallback | /echo</code></pre> |
| 2370 | <small>Returns the currently selected fallback.</small> |
| 2371 | </li> |
| 2372 | <li> |
| 2373 | <pre><code>/expression-fallback admiration</code></pre> |
| 2374 | <small>Sets a new expression as fallback.</small> |
| 2375 | </li> |
| 2376 | </ul> |
| 2377 | </div> |
| 2378 | `, |
| 2379 | returns: 'The currently set expression label after setting it.', |
| 2380 | })); |
| 2381 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2382 | name: 'expression-folder-override', |
| 2383 | aliases: ['spriteoverride', 'costume'], |
| 2384 | callback: setSpriteFolderCommand, |
| 2385 | namedArgumentList: [ |
| 2386 | SlashCommandNamedArgument.fromProps({ |
| 2387 | name: 'name', |
| 2388 | description: 'Character name to set a subfolder for. If not provided, the character who last sent a message will be used.', |
| 2389 | typeList: [ARGUMENT_TYPE.STRING], |
| 2390 | enumProvider: commonEnumProviders.characters('character'), |
| 2391 | isRequired: false, |
| 2392 | acceptsMultiple: false, |
| 2393 | }), |
| 2394 | ], |
| 2395 | unnamedArgumentList: [ |
| 2396 | new SlashCommandArgument( |
| 2397 | 'optional folder', [ARGUMENT_TYPE.STRING], false, |
| 2398 | ), |
| 2399 | ], |
| 2400 | helpString: ` |
| 2401 | <div> |
| 2402 | Sets an override sprite folder for the current character.<br /> |
| 2403 | In groups, this will apply to the character who last sent a message. |
| 2404 | </div> |
| 2405 | <div> |
| 2406 | 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. |
| 2407 | </div> |
| 2408 | `, |
| 2409 | })); |
| 2410 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2411 | name: 'expression-last', |
| 2412 | aliases: ['lastsprite'], |
| 2413 | /** @type {(args: object, name: string) => Promise<string>} */ |
| 2414 | callback: async (_, name) => { |
| 2415 | if (typeof name !== 'string') throw new Error('name must be a string'); |
| 2416 | if (!name) { |
| 2417 | if (selected_group) { |
| 2418 | toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`); |
| 2419 | return ''; |
| 2420 | } |
| 2421 | name = characters[this_chid]?.avatar; |
| 2422 | } |
| 2423 | |
| 2424 | const char = findChar({ name: name }); |
| 2425 | if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`); |
| 2426 | |
| 2427 | const sprite = lastExpression[char?.name ?? name] ?? ''; |
| 2428 | return sprite; |
| 2429 | }, |
| 2430 | returns: 'the last set expression for the named character.', |
| 2431 | unnamedArgumentList: [ |
| 2432 | SlashCommandArgument.fromProps({ |
| 2433 | 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)', |
| 2434 | typeList: [ARGUMENT_TYPE.STRING], |
| 2435 | enumProvider: commonEnumProviders.characters('character'), |
| 2436 | }), |
| 2437 | ], |
| 2438 | helpString: 'Returns the last set expression for the named character.', |
| 2439 | })); |
| 2440 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2441 | name: 'expression-list', |
| 2442 | aliases: ['expressions'], |
| 2443 | /** @type {(args: {return: string, filter: string}) => Promise<string>} */ |
| 2444 | callback: async (args) => { |
| 2445 | let returnType = |
| 2446 | /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ |
| 2447 | (args.return); |
| 2448 | |
| 2449 | const list = await getExpressionsList({ filterAvailable: !isFalseBoolean(args.filter) }); |
| 2450 | |
| 2451 | return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); |
| 2452 | }, |
| 2453 | namedArgumentList: [ |
| 2454 | SlashCommandNamedArgument.fromProps({ |
| 2455 | name: 'return', |
| 2456 | description: 'The way how you want the return value to be provided', |
| 2457 | typeList: [ARGUMENT_TYPE.STRING], |
| 2458 | defaultValue: 'pipe', |
| 2459 | enumList: slashCommandReturnHelper.enumList({ allowObject: true }), |
| 2460 | forceEnum: true, |
| 2461 | }), |
| 2462 | SlashCommandNamedArgument.fromProps({ |
| 2463 | name: 'filter', |
| 2464 | description: 'Filter the list to only include expressions that have available sprites for the current character.', |
| 2465 | typeList: [ARGUMENT_TYPE.BOOLEAN], |
| 2466 | enumList: commonEnumProviders.boolean('trueFalse')(), |
| 2467 | defaultValue: 'true', |
| 2468 | }), |
| 2469 | ], |
| 2470 | returns: 'The comma-separated list of available expressions, including custom expressions.', |
| 2471 | helpString: 'Returns a list of available expressions, including custom expressions.', |
| 2472 | })); |
| 2473 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2474 | name: 'expression-classify', |
| 2475 | aliases: ['classify'], |
| 2476 | callback: classifyCallback, |
| 2477 | namedArgumentList: [ |
| 2478 | SlashCommandNamedArgument.fromProps({ |
| 2479 | name: 'api', |
| 2480 | description: 'The Classifier API to classify with. If not specified, the configured one will be used.', |
| 2481 | typeList: [ARGUMENT_TYPE.STRING], |
| 2482 | enumList: Object.keys(EXPRESSION_API).map(api => new SlashCommandEnumValue(api, null, enumTypes.enum)), |
| 2483 | }), |
| 2484 | SlashCommandNamedArgument.fromProps({ |
| 2485 | name: 'filter', |
| 2486 | description: 'Filter the list to only include expressions that have available sprites for the current character.', |
| 2487 | typeList: [ARGUMENT_TYPE.BOOLEAN], |
| 2488 | enumList: commonEnumProviders.boolean('trueFalse')(), |
| 2489 | defaultValue: 'true', |
| 2490 | }), |
| 2491 | SlashCommandNamedArgument.fromProps({ |
| 2492 | name: 'prompt', |
| 2493 | description: 'Custom prompt for classification. Only relevant if Classifier API is set to LLM.', |
| 2494 | typeList: [ARGUMENT_TYPE.STRING], |
| 2495 | }), |
| 2496 | ], |
| 2497 | unnamedArgumentList: [ |
| 2498 | new SlashCommandArgument( |
| 2499 | 'text', [ARGUMENT_TYPE.STRING], true, |
| 2500 | ), |
| 2501 | ], |
| 2502 | returns: 'emotion classification label for the given text', |
| 2503 | helpString: ` |
| 2504 | <div> |
| 2505 | Performs an emotion classification of the given text and returns a label. |
| 2506 | </div> |
| 2507 | <div> |
| 2508 | Allows to specify which Classifier API to perform the classification with. |
| 2509 | </div> |
| 2510 | <div> |
| 2511 | <strong>Example:</strong> |
| 2512 | <ul> |
| 2513 | <li> |
| 2514 | <pre><code>/classify I am so happy today!</code></pre> |
| 2515 | </li> |
| 2516 | </ul> |
| 2517 | </div> |
| 2518 | `, |
| 2519 | })); |
| 2520 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2521 | name: 'expression-upload', |
| 2522 | aliases: ['uploadsprite'], |
| 2523 | /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */ |
| 2524 | callback: async (args, url) => { |
| 2525 | return await uploadSpriteCommand(args, url); |
| 2526 | }, |
| 2527 | returns: 'the resulting sprite name', |
| 2528 | unnamedArgumentList: [ |
| 2529 | SlashCommandArgument.fromProps({ |
| 2530 | description: 'URL of the image to upload', |
| 2531 | typeList: [ARGUMENT_TYPE.STRING], |
| 2532 | isRequired: true, |
| 2533 | }), |
| 2534 | ], |
| 2535 | namedArgumentList: [ |
| 2536 | SlashCommandNamedArgument.fromProps({ |
| 2537 | name: 'name', |
| 2538 | description: 'Character name or avatar key (default is current character)', |
| 2539 | typeList: [ARGUMENT_TYPE.STRING], |
| 2540 | isRequired: false, |
| 2541 | }), |
| 2542 | SlashCommandNamedArgument.fromProps({ |
| 2543 | name: 'label', |
| 2544 | description: 'Sprite label/expression name', |
| 2545 | typeList: [ARGUMENT_TYPE.STRING], |
| 2546 | enumProvider: localEnumProviders.expressions, |
| 2547 | isRequired: true, |
| 2548 | }), |
| 2549 | SlashCommandNamedArgument.fromProps({ |
| 2550 | name: 'folder', |
| 2551 | description: 'Override folder to upload into', |
| 2552 | typeList: [ARGUMENT_TYPE.STRING], |
| 2553 | isRequired: false, |
| 2554 | }), |
| 2555 | SlashCommandNamedArgument.fromProps({ |
| 2556 | name: 'spriteName', |
| 2557 | 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.', |
| 2558 | typeList: [ARGUMENT_TYPE.STRING], |
| 2559 | isRequired: false, |
| 2560 | }), |
| 2561 | ], |
| 2562 | helpString: ` |
| 2563 | <div> |
| 2564 | Upload a sprite from a URL. |
| 2565 | </div> |
| 2566 | <div> |
| 2567 | <strong>Example:</strong> |
| 2568 | <ul> |
| 2569 | <li> |
| 2570 | <pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre> |
| 2571 | </li> |
| 2572 | </ul> |
| 2573 | </div> |
| 2574 | `, |
| 2575 | })); |
| 2576 | } |