Implement persona thumbnails (#4210) * Implement persona thumbnails * Dear Firefox, fix your overzealous image cache * Add cache busting for avatar uploads when overwriting existing files
Signed| @@ -139,7 +139,7 @@ thumbnails: | ||
| 139 | 139 | # JPG thumbnail quality (0-100) |
| 140 | 140 | quality: 95 |
| 141 | 141 | # Maximum thumbnail dimensions per type [width, height] |
| 142 | 142 | dimensions: { 'bg': [160, 90], 'avatar': [96, 144], 'persona': [96, 144] } |
| 143 | 143 | |
| 144 | 144 | # PERFORMANCE-RELATED CONFIGURATION |
| 145 | 145 | performance: |
| @@ -2598,7 +2598,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll | ||
| 2598 | 2598 | mes.swipes = [mes.mes]; |
| 2599 | 2599 | } |
| 2600 | 2600 | |
| 2601 | 2601 | let avatarImg = getUserAvatargetThumbnailUrl('persona', user_avatar); |
| 2602 | 2602 | const isSystem = mes.is_system; |
| 2603 | 2603 | const title = mes.title; |
| 2604 | 2604 | generatedPromptCache = ''; |
| @@ -5575,7 +5575,7 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul | ||
| 5575 | 5575 | |
| 5576 | 5576 | // Lock user avatar to a persona. |
| 5577 | 5577 | if (avatar in power_user.personas) { |
| 5578 | 5578 | message.force_avatar = getUserAvatargetThumbnailUrl('persona', avatar); |
| 5579 | 5579 | } |
| 5580 | 5580 | |
| 5581 | 5581 | if (messageBias) { |
| @@ -7204,7 +7204,7 @@ async function read_avatar_load(input) { | ||
| 7204 | 7204 | await delay(DEFAULT_SAVE_EDIT_TIMEOUT); |
| 7205 | 7205 | |
| 7206 | 7206 | const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0))); |
| 7207 | 7207 | await fetch(getThumbnailUrl('avatar', formData.get('avatar_url').toString()), { |
| 7208 | 7208 | method: 'GET', |
| 7209 | 7209 | cache: 'no-cache', |
| 7210 | 7210 | headers: { |
| @@ -7233,8 +7233,15 @@ async function read_avatar_load(input) { | ||
| 7233 | 7233 | } |
| 7234 | 7234 | } |
| 7235 | 7235 | |
| 7236 | -export function getThumbnailUrl(type, file) { | |
| 7236 | +/** | |
| 7237 | - return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}`; | |
| 7237 | + * Gets the URL for a thumbnail of a specific type and file. | |
| 7238 | + * @param {import('../src/endpoints/thumbnails.js').ThumbnailType} type The type of the thumbnail to get | |
| 7239 | + * @param {string} file The file name or path for which to get the thumbnail URL | |
| 7240 | + * @param {boolean} [t=false] Whether to add a cache-busting timestamp to the URL | |
| 7241 | + * @returns {string} The URL for the thumbnail | |
| 7242 | + */ | |
| 7243 | +export function getThumbnailUrl(type, file, t = false) { | |
| 7244 | + return `/thumbnail?type=${type}&file=${encodeURIComponent(file)}${t ? `&t=${Date.now()}` : ''}`; | |
| 7238 | 7245 | } |
| 7239 | 7246 | |
| 7240 | 7247 | export function buildAvatarList(block, entities, { templateId = 'inline_avatar_template', empty = true, interactable = false, highlightFavs = true } = {}) { |
| @@ -7274,7 +7281,7 @@ export function buildAvatarList(block, entities, { templateId = 'inline_avatar_t | ||
| 7274 | 7281 | } |
| 7275 | 7282 | else if (entity.type === 'persona') { |
| 7276 | 7283 | avatarTemplate.attr({ 'data-pid': id, 'data-chid': null }); |
| 7277 | 7284 | avatarTemplate.find('img').attr('src', getUserAvatargetThumbnailUrl('persona', entity.item.avatar)); |
| 7278 | 7285 | avatarTemplate.attr('title', `[Persona] ${entity.item.name}\nFile: ${entity.item.avatar}`); |
| 7279 | 7286 | } |
| 7280 | 7287 | |
| @@ -12147,9 +12154,17 @@ jQuery(async function () { | ||
| 12147 | 12154 | $('body').append(newElement); |
| 12148 | 12155 | newElement.fadeIn(animation_duration); |
| 12149 | 12156 | const zoomedAvatarImgElement = $(`.zoomed_avatar[forChar="${charname}"] img`); |
| 12150 | 12157 | if (messageElement.attr('is_user') == 'true' || (messageElement.attr('is_system') == 'true' && !isValidCharacter)) { //handle user and system avatars |
| 12151 | - zoomedAvatarImgElement.attr('src', thumbURL); | |
| 12158 | + //handle user and system avatars | |
| 12152 | - zoomedAvatarImgElement.attr('data-izoomify-url', thumbURL); | |
| 12159 | + const isValidPersona = decodeURIComponent(targetAvatarImg) in power_user.personas; | |
| 12160 | + if (isValidPersona) { | |
| 12161 | + const personaSrc = getUserAvatar(targetAvatarImg); | |
| 12162 | + zoomedAvatarImgElement.attr('src', personaSrc); | |
| 12163 | + zoomedAvatarImgElement.attr('data-izoomify-url', personaSrc); | |
| 12164 | + } else { | |
| 12165 | + zoomedAvatarImgElement.attr('src', thumbURL); | |
| 12166 | + zoomedAvatarImgElement.attr('data-izoomify-url', thumbURL); | |
| 12167 | + } | |
| 12153 | 12168 | } else if (messageElement.attr('is_user') == 'false') { //handle char avatars |
| 12154 | 12169 | zoomedAvatarImgElement.attr('src', avatarSrc); |
| 12155 | 12170 | zoomedAvatarImgElement.attr('data-izoomify-url', avatarSrc); |
| @@ -45,6 +45,10 @@ class DataMaidDialog { | ||
| 45 | 45 | name: t`Background Thumbnails`, |
| 46 | 46 | description: t`Thumbnails for missing or deleted backgrounds.`, |
| 47 | 47 | }, |
| 48 | + personaThumbnails: { | |
| 49 | + name: t`Persona Thumbnails`, | |
| 50 | + description: t`Thumbnails for missing or deleted personas.`, | |
| 51 | + }, | |
| 48 | 52 | chatBackups: { |
| 49 | 53 | name: t`Chat Backups`, |
| 50 | 54 | description: t`Automatically generated chat backups.`, |
| @@ -37,6 +37,7 @@ import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from ' | ||
| 37 | 37 | import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 38 | 38 | import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; |
| 39 | 39 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| 40 | +import { isFirefox } from './browser-fixes.js'; | |
| 40 | 41 | |
| 41 | 42 | /** |
| 42 | 43 | * @typedef {object} PersonaConnection A connection between a character and a character or group entity |
| @@ -124,7 +125,7 @@ function reloadUserAvatar(force = false) { | ||
| 124 | 125 | } |
| 125 | 126 | |
| 126 | 127 | if ($(this).attr('is_user') == 'true' && $(this).attr('force_avatar') == 'false') { |
| 127 | 128 | avatarImg.attr('src', getUserAvatargetThumbnailUrl('persona', user_avatar)); |
| 128 | 129 | } |
| 129 | 130 | }); |
| 130 | 131 | } |
| @@ -179,7 +180,6 @@ function verifyPersonaSearchSortRule() { | ||
| 179 | 180 | * @returns {JQuery<HTMLElement>} Avatar block |
| 180 | 181 | */ |
| 181 | 182 | function getUserAvatarBlock(avatarId) { |
| 182 | - const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; | |
| 183 | 183 | const template = $('#user_avatar_template .avatar-container').clone(); |
| 184 | 184 | const personaName = power_user.personas[avatarId]; |
| 185 | 185 | const personaDescription = power_user.persona_descriptions[avatarId]?.description; |
| @@ -189,10 +189,7 @@ function getUserAvatarBlock(avatarId) { | ||
| 189 | 189 | template.attr('data-avatar-id', avatarId); |
| 190 | 190 | template.find('.avatar').attr('data-avatar-id', avatarId).attr('title', avatarId); |
| 191 | 191 | template.toggleClass('default_persona', avatarId === power_user.default_persona); |
| 192 | 192 | letconst avatarUrl = getUserAvatargetThumbnailUrl('persona', avatarId, isFirefox()); |
| 193 | - if (isFirefox) { | |
| 194 | - avatarUrl += '?t=' + Date.now(); | |
| 195 | - } | |
| 196 | 193 | template.find('img').attr('src', avatarUrl); |
| 197 | 194 | |
| 198 | 195 | // Make sure description block has at least three rows. Otherwise height looks inconsistent. I don't have a better idea for this. |
| @@ -386,6 +383,7 @@ async function changeUserAvatar(e) { | ||
| 386 | 383 | const name = formData.get('overwrite_name'); |
| 387 | 384 | if (name) { |
| 388 | 385 | await fetch(getUserAvatar(String(name)), { cache: 'no-cache' }); |
| 386 | + await fetch(getThumbnailUrl('persona', String(name)), { cache: 'no-cache' }); | |
| 389 | 387 | reloadUserAvatar(true); |
| 390 | 388 | } |
| 391 | 389 | |
| @@ -1657,7 +1655,7 @@ async function syncUserNameToPersona() { | ||
| 1657 | 1655 | for (const mes of chat) { |
| 1658 | 1656 | if (mes.is_user) { |
| 1659 | 1657 | mes.name = name1; |
| 1660 | 1658 | mes.force_avatar = getUserAvatargetThumbnailUrl('persona', user_avatar); |
| 1661 | 1659 | } |
| 1662 | 1660 | } |
| 1663 | 1661 | |
| @@ -18,6 +18,7 @@ export const USER_DIRECTORY_TEMPLATE = Object.freeze({ | ||
| 18 | 18 | thumbnails: 'thumbnails', |
| 19 | 19 | thumbnailsBg: 'thumbnails/bg', |
| 20 | 20 | thumbnailsAvatar: 'thumbnails/avatar', |
| 21 | + thumbnailsPersona: 'thumbnails/persona', | |
| 21 | 22 | worlds: 'worlds', |
| 22 | 23 | user: 'user', |
| 23 | 24 | avatars: 'User Avatars', |
| @@ -9,12 +9,13 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic'; | ||
| 9 | 9 | import { getImages, tryParse } from '../util.js'; |
| 10 | 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 11 | 11 | import { applyAvatarCropResize } from './characters.js'; |
| 12 | +import { invalidateThumbnail } from './thumbnails.js'; | |
| 12 | 13 | |
| 13 | 14 | export const router = express.Router(); |
| 14 | 15 | |
| 15 | 16 | router.post('/get', function (request, response) { |
| 16 | 17 | varconst images = getImages(request.user.directories.avatars); |
| 17 | 18 | response.send(JSON.stringify(images)); |
| 18 | 19 | }); |
| 19 | 20 | |
| 20 | 21 | router.post('/delete', getFileNameValidationFunction('avatar'), function (request, response) { |
| @@ -29,6 +30,7 @@ router.post('/delete', getFileNameValidationFunction('avatar'), function (reques | ||
| 29 | 30 | |
| 30 | 31 | if (fs.existsSync(fileName)) { |
| 31 | 32 | fs.unlinkSync(fileName); |
| 33 | + invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.avatar)); | |
| 32 | 34 | return response.send({ result: 'ok' }); |
| 33 | 35 | } |
| 34 | 36 | |
| @@ -44,6 +46,12 @@ router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (r | ||
| 44 | 46 | const rawImg = await Jimp.read(pathToUpload); |
| 45 | 47 | const image = await applyAvatarCropResize(rawImg, crop); |
| 46 | 48 | |
| 49 | + // Remove previous thumbnail and bust cache if overwriting | |
| 50 | + if (request.body.overwrite_name) { | |
| 51 | + invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.overwrite_name)); | |
| 52 | + response.setHeader('Clear-Site-Data', '"cache"'); | |
| 53 | + } | |
| 54 | + | |
| 47 | 55 | const filename = request.body.overwrite_name || `${Date.now()}.png`; |
| 48 | 56 | const pathToNewFile = path.join(request.user.directories.avatars, filename); |
| 49 | 57 | writeFileAtomicSync(pathToNewFile, image); |
| @@ -18,6 +18,7 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex'); | ||
| 18 | 18 | * @property {string[]} groupChats - List of loose group chats |
| 19 | 19 | * @property {string[]} avatarThumbnails - List of loose avatar thumbnails |
| 20 | 20 | * @property {string[]} backgroundThumbnails - List of loose background thumbnails |
| 21 | + * @property {string[]} personaThumbnails - List of loose persona thumbnails | |
| 21 | 22 | * @property {string[]} chatBackups - List of chat backups |
| 22 | 23 | * @property {string[]} settingsBackups - List of settings backups |
| 23 | 24 | */ |
| @@ -39,6 +40,7 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex'); | ||
| 39 | 40 | * @property {DataMaidSanitizedRecord[]} groupChats - List of sanitized loose group chats |
| 40 | 41 | * @property {DataMaidSanitizedRecord[]} avatarThumbnails - List of sanitized loose avatar thumbnails |
| 41 | 42 | * @property {DataMaidSanitizedRecord[]} backgroundThumbnails - List of sanitized loose background thumbnails |
| 43 | + * @property {DataMaidSanitizedRecord[]} personaThumbnails - List of sanitized loose persona thumbnails | |
| 42 | 44 | * @property {DataMaidSanitizedRecord[]} chatBackups - List of sanitized chat backups |
| 43 | 45 | * @property {DataMaidSanitizedRecord[]} settingsBackups - List of sanitized settings backups |
| 44 | 46 | */ |
| @@ -106,6 +108,7 @@ export class DataMaidService { | ||
| 106 | 108 | groupChats: await this.#collectGroupChats(), |
| 107 | 109 | avatarThumbnails: await this.#collectAvatarThumbnails(), |
| 108 | 110 | backgroundThumbnails: await this.#collectBackgroundThumbnails(), |
| 111 | + personaThumbnails: await this.#collectPersonaThumbnails(), | |
| 109 | 112 | chatBackups: await this.#collectChatBackups(), |
| 110 | 113 | settingsBackups: await this.#collectSettingsBackups(), |
| 111 | 114 | }; |
| @@ -145,6 +148,7 @@ export class DataMaidService { | ||
| 145 | 148 | groupChats: await Promise.all(report.groupChats.map(i => this.#sanitizeRecord(i, false))), |
| 146 | 149 | avatarThumbnails: await Promise.all(report.avatarThumbnails.map(i => this.#sanitizeRecord(i, false))), |
| 147 | 150 | backgroundThumbnails: await Promise.all(report.backgroundThumbnails.map(i => this.#sanitizeRecord(i, false))), |
| 151 | + personaThumbnails: await Promise.all(report.personaThumbnails.map(i => this.#sanitizeRecord(i, false))), | |
| 148 | 152 | chatBackups: await Promise.all(report.chatBackups.map(i => this.#sanitizeRecord(i, false))), |
| 149 | 153 | settingsBackups: await Promise.all(report.settingsBackups.map(i => this.#sanitizeRecord(i, false))), |
| 150 | 154 | }; |
| @@ -416,6 +420,34 @@ export class DataMaidService { | ||
| 416 | 420 | } |
| 417 | 421 | |
| 418 | 422 | /** |
| 423 | + * Collects loose persona thumbnails from the provided directories. | |
| 424 | + * @returns {Promise<string[]>} List of paths to loose persona thumbnails | |
| 425 | + */ | |
| 426 | + async #collectPersonaThumbnails() { | |
| 427 | + const result = []; | |
| 428 | + | |
| 429 | + try { | |
| 430 | + const knownPersonas = new Set(); | |
| 431 | + const personas = await fs.promises.readdir(this.directories.avatars, { withFileTypes: true }); | |
| 432 | + for (const file of personas) { | |
| 433 | + if (file.isFile()) { | |
| 434 | + knownPersonas.add(file.name); | |
| 435 | + } | |
| 436 | + } | |
| 437 | + const personaThumbnails = await fs.promises.readdir(this.directories.thumbnailsPersona, { withFileTypes: true }); | |
| 438 | + for (const file of personaThumbnails) { | |
| 439 | + if (file.isFile() && !knownPersonas.has(file.name)) { | |
| 440 | + result.push(path.join(this.directories.thumbnailsPersona, file.name)); | |
| 441 | + } | |
| 442 | + } | |
| 443 | + } catch (error) { | |
| 444 | + console.error('[Data Maid] Error collecting persona thumbnails:', error); | |
| 445 | + } | |
| 446 | + | |
| 447 | + return result; | |
| 448 | + } | |
| 449 | + | |
| 450 | + /** | |
| 419 | 451 | * Collects chat backups from the provided directories. |
| 420 | 452 | * @returns {Promise<string[]>} List of paths to chat backups |
| 421 | 453 | */ |
| @@ -14,16 +14,21 @@ const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean' | ||
| 14 | 14 | const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number')))); |
| 15 | 15 | const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png'; |
| 16 | 16 | |
| 17 | +/** | |
| 18 | + * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType | |
| 19 | + */ | |
| 20 | + | |
| 17 | 21 | /** @type {Record<string, number[]>} */ |
| 18 | 22 | export const dimensions = { |
| 19 | 23 | 'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]), |
| 20 | 24 | 'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]), |
| 25 | + 'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]), | |
| 21 | 26 | }; |
| 22 | 27 | |
| 23 | 28 | /** |
| 24 | 29 | * Gets a path to thumbnail folder based on the type. |
| 25 | 30 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 26 | 31 | * @param {'bg' | 'avatar'ThumbnailType} type Thumbnail type |
| 27 | 32 | * @returns {string} Path to the thumbnails folder |
| 28 | 33 | */ |
| 29 | 34 | function getThumbnailFolder(directories, type) { |
| @@ -36,6 +41,9 @@ function getThumbnailFolder(directories, type) { | ||
| 36 | 41 | case 'avatar': |
| 37 | 42 | thumbnailFolder = directories.thumbnailsAvatar; |
| 38 | 43 | break; |
| 44 | + case 'persona': | |
| 45 | + thumbnailFolder = directories.thumbnailsPersona; | |
| 46 | + break; | |
| 39 | 47 | } |
| 40 | 48 | |
| 41 | 49 | return thumbnailFolder; |
| @@ -44,7 +52,7 @@ function getThumbnailFolder(directories, type) { | ||
| 44 | 52 | /** |
| 45 | 53 | * Gets a path to the original images folder based on the type. |
| 46 | 54 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 47 | 55 | * @param {'bg' | 'avatar'ThumbnailType} type Thumbnail type |
| 48 | 56 | * @returns {string} Path to the original images folder |
| 49 | 57 | */ |
| 50 | 58 | function getOriginalFolder(directories, type) { |
| @@ -57,6 +65,9 @@ function getOriginalFolder(directories, type) { | ||
| 57 | 65 | case 'avatar': |
| 58 | 66 | originalFolder = directories.characters; |
| 59 | 67 | break; |
| 68 | + case 'persona': | |
| 69 | + originalFolder = directories.avatars; | |
| 70 | + break; | |
| 60 | 71 | } |
| 61 | 72 | |
| 62 | 73 | return originalFolder; |
| @@ -65,7 +76,7 @@ function getOriginalFolder(directories, type) { | ||
| 65 | 76 | /** |
| 66 | 77 | * Removes the generated thumbnail from the disk. |
| 67 | 78 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 68 | 79 | * @param {'bg' | 'avatar'ThumbnailType} type Type of the thumbnail |
| 69 | 80 | * @param {string} file Name of the file |
| 70 | 81 | */ |
| 71 | 82 | export function invalidateThumbnail(directories, type, file) { |
| @@ -82,7 +93,7 @@ export function invalidateThumbnail(directories, type, file) { | ||
| 82 | 93 | /** |
| 83 | 94 | * Generates a thumbnail for the given file. |
| 84 | 95 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 85 | 96 | * @param {'bg' | 'avatar'ThumbnailType} type Type of the thumbnail |
| 86 | 97 | * @param {string} file Name of the file |
| 87 | 98 | * @returns |
| 88 | 99 | */ |
| @@ -188,7 +199,7 @@ router.get('/', async function (request, response) { | ||
| 188 | 199 | return response.sendStatus(400); |
| 189 | 200 | } |
| 190 | 201 | |
| 191 | 202 | if (!(type === 'bg' || type === 'avatar' || type === 'persona')) { |
| 192 | 203 | return response.sendStatus(400); |
| 193 | 204 | } |
| 194 | 205 | |
| @@ -71,6 +71,7 @@ const STORAGE_KEYS = { | ||
| 71 | 71 | * @property {string} thumbnails - The directory where the thumbnails are stored |
| 72 | 72 | * @property {string} thumbnailsBg - The directory where the background thumbnails are stored |
| 73 | 73 | * @property {string} thumbnailsAvatar - The directory where the avatar thumbnails are stored |
| 74 | + * @property {string} thumbnailsPersona - The directory where the persona thumbnails are stored | |
| 74 | 75 | * @property {string} worlds - The directory where the WI are stored |
| 75 | 76 | * @property {string} user - The directory where the user's public data is stored |
| 76 | 77 | * @property {string} avatars - The directory where the avatars are stored |