Implement persona thumbnails (#4210) * Implement persona thumbnails * Dear Firefox, fix your overzealous image cache * Add cache busting for avatar uploads when overwriting existing files

cdefddef6c0f7fe4655619204429f0276b9a2b28

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
9 files changed, +94 -24Ignore whitespace
default/config.yaml+1 -1
@@ -139,7 +139,7 @@ thumbnails:
139139 # JPG thumbnail quality (0-100)
140140 quality: 95
141141 # Maximum thumbnail dimensions per type [width, height]
142142 dimensions: { 'bg': [160, 90], 'avatar': [96, 144], 'persona': [96, 144] }
143143
144144# PERFORMANCE-RELATED CONFIGURATION
145145performance:
public/script.js+24 -9
@@ -2598,7 +2598,7 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
25982598 mes.swipes = [mes.mes];
25992599 }
26002600
26012601 let avatarImg = getUserAvatargetThumbnailUrl('persona', user_avatar);
26022602 const isSystem = mes.is_system;
26032603 const title = mes.title;
26042604 generatedPromptCache = '';
@@ -5575,7 +5575,7 @@ export async function sendMessageAsUser(messageText, messageBias, insertAt = nul
55755575
55765576 // Lock user avatar to a persona.
55775577 if (avatar in power_user.personas) {
55785578 message.force_avatar = getUserAvatargetThumbnailUrl('persona', avatar);
55795579 }
55805580
55815581 if (messageBias) {
@@ -7204,7 +7204,7 @@ async function read_avatar_load(input) {
72047204 await delay(DEFAULT_SAVE_EDIT_TIMEOUT);
72057205
72067206 const formData = new FormData(/** @type {HTMLFormElement} */($('#form_create').get(0)));
72077207 await fetch(getThumbnailUrl('avatar', formData.get('avatar_url').toString()), {
72087208 method: 'GET',
72097209 cache: 'no-cache',
72107210 headers: {
@@ -7233,8 +7233,15 @@ async function read_avatar_load(input) {
72337233 }
72347234}
72357235
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()}` : ''}`;
72387245}
72397246
72407247export 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
72747281 }
72757282 else if (entity.type === 'persona') {
72767283 avatarTemplate.attr({ 'data-pid': id, 'data-chid': null });
72777284 avatarTemplate.find('img').attr('src', getUserAvatargetThumbnailUrl('persona', entity.item.avatar));
72787285 avatarTemplate.attr('title', `[Persona] ${entity.item.name}\nFile: ${entity.item.avatar}`);
72797286 }
72807287
@@ -12147,9 +12154,17 @@ jQuery(async function () {
1214712154 $('body').append(newElement);
1214812155 newElement.fadeIn(animation_duration);
1214912156 const zoomedAvatarImgElement = $(`.zoomed_avatar[forChar="${charname}"] img`);
1215012157 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+ }
1215312168 } else if (messageElement.attr('is_user') == 'false') { //handle char avatars
1215412169 zoomedAvatarImgElement.attr('src', avatarSrc);
1215512170 zoomedAvatarImgElement.attr('data-izoomify-url', avatarSrc);
public/scripts/data-maid.js+4 -0
@@ -45,6 +45,10 @@ class DataMaidDialog {
4545 name: t`Background Thumbnails`,
4646 description: t`Thumbnails for missing or deleted backgrounds.`,
4747 },
48+ personaThumbnails: {
49+ name: t`Persona Thumbnails`,
50+ description: t`Thumbnails for missing or deleted personas.`,
51+ },
4852 chatBackups: {
4953 name: t`Chat Backups`,
5054 description: t`Automatically generated chat backups.`,
public/scripts/personas.js+5 -7
@@ -37,6 +37,7 @@ import { SlashCommandNamedArgument, ARGUMENT_TYPE, SlashCommandArgument } from '
3737import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
3838import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
3939import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
40+import { isFirefox } from './browser-fixes.js';
4041
4142/**
4243 * @typedef {object} PersonaConnection A connection between a character and a character or group entity
@@ -124,7 +125,7 @@ function reloadUserAvatar(force = false) {
124125 }
125126
126127 if ($(this).attr('is_user') == 'true' && $(this).attr('force_avatar') == 'false') {
127128 avatarImg.attr('src', getUserAvatargetThumbnailUrl('persona', user_avatar));
128129 }
129130 });
130131}
@@ -179,7 +180,6 @@ function verifyPersonaSearchSortRule() {
179180 * @returns {JQuery<HTMLElement>} Avatar block
180181 */
181182function getUserAvatarBlock(avatarId) {
182- const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
183183 const template = $('#user_avatar_template .avatar-container').clone();
184184 const personaName = power_user.personas[avatarId];
185185 const personaDescription = power_user.persona_descriptions[avatarId]?.description;
@@ -189,10 +189,7 @@ function getUserAvatarBlock(avatarId) {
189189 template.attr('data-avatar-id', avatarId);
190190 template.find('.avatar').attr('data-avatar-id', avatarId).attr('title', avatarId);
191191 template.toggleClass('default_persona', avatarId === power_user.default_persona);
192192 letconst avatarUrl = getUserAvatargetThumbnailUrl('persona', avatarId, isFirefox());
193- if (isFirefox) {
194- avatarUrl += '?t=' + Date.now();
195- }
196193 template.find('img').attr('src', avatarUrl);
197194
198195 // 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) {
386383 const name = formData.get('overwrite_name');
387384 if (name) {
388385 await fetch(getUserAvatar(String(name)), { cache: 'no-cache' });
386+ await fetch(getThumbnailUrl('persona', String(name)), { cache: 'no-cache' });
389387 reloadUserAvatar(true);
390388 }
391389
@@ -1657,7 +1655,7 @@ async function syncUserNameToPersona() {
16571655 for (const mes of chat) {
16581656 if (mes.is_user) {
16591657 mes.name = name1;
16601658 mes.force_avatar = getUserAvatargetThumbnailUrl('persona', user_avatar);
16611659 }
16621660 }
16631661
src/constants.js+1 -0
@@ -18,6 +18,7 @@ export const USER_DIRECTORY_TEMPLATE = Object.freeze({
1818 thumbnails: 'thumbnails',
1919 thumbnailsBg: 'thumbnails/bg',
2020 thumbnailsAvatar: 'thumbnails/avatar',
21+ thumbnailsPersona: 'thumbnails/persona',
2122 worlds: 'worlds',
2223 user: 'user',
2324 avatars: 'User Avatars',
src/endpoints/avatars.js+10 -2
@@ -9,12 +9,13 @@ import { sync as writeFileAtomicSync } from 'write-file-atomic';
99import { getImages, tryParse } from '../util.js';
1010import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1111import { applyAvatarCropResize } from './characters.js';
12+import { invalidateThumbnail } from './thumbnails.js';
1213
1314export const router = express.Router();
1415
1516router.post('/get', function (request, response) {
1617 varconst images = getImages(request.user.directories.avatars);
1718 response.send(JSON.stringify(images));
1819});
1920
2021router.post('/delete', getFileNameValidationFunction('avatar'), function (request, response) {
@@ -29,6 +30,7 @@ router.post('/delete', getFileNameValidationFunction('avatar'), function (reques
2930
3031 if (fs.existsSync(fileName)) {
3132 fs.unlinkSync(fileName);
33+ invalidateThumbnail(request.user.directories, 'persona', sanitize(request.body.avatar));
3234 return response.send({ result: 'ok' });
3335 }
3436
@@ -44,6 +46,12 @@ router.post('/upload', getFileNameValidationFunction('overwrite_name'), async (r
4446 const rawImg = await Jimp.read(pathToUpload);
4547 const image = await applyAvatarCropResize(rawImg, crop);
4648
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+
4755 const filename = request.body.overwrite_name || `${Date.now()}.png`;
4856 const pathToNewFile = path.join(request.user.directories.avatars, filename);
4957 writeFileAtomicSync(pathToNewFile, image);
src/endpoints/data-maid.js+32 -0
@@ -18,6 +18,7 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex');
1818 * @property {string[]} groupChats - List of loose group chats
1919 * @property {string[]} avatarThumbnails - List of loose avatar thumbnails
2020 * @property {string[]} backgroundThumbnails - List of loose background thumbnails
21+ * @property {string[]} personaThumbnails - List of loose persona thumbnails
2122 * @property {string[]} chatBackups - List of chat backups
2223 * @property {string[]} settingsBackups - List of settings backups
2324 */
@@ -39,6 +40,7 @@ const sha256 = str => crypto.createHash('sha256').update(str).digest('hex');
3940 * @property {DataMaidSanitizedRecord[]} groupChats - List of sanitized loose group chats
4041 * @property {DataMaidSanitizedRecord[]} avatarThumbnails - List of sanitized loose avatar thumbnails
4142 * @property {DataMaidSanitizedRecord[]} backgroundThumbnails - List of sanitized loose background thumbnails
43+ * @property {DataMaidSanitizedRecord[]} personaThumbnails - List of sanitized loose persona thumbnails
4244 * @property {DataMaidSanitizedRecord[]} chatBackups - List of sanitized chat backups
4345 * @property {DataMaidSanitizedRecord[]} settingsBackups - List of sanitized settings backups
4446 */
@@ -106,6 +108,7 @@ export class DataMaidService {
106108 groupChats: await this.#collectGroupChats(),
107109 avatarThumbnails: await this.#collectAvatarThumbnails(),
108110 backgroundThumbnails: await this.#collectBackgroundThumbnails(),
111+ personaThumbnails: await this.#collectPersonaThumbnails(),
109112 chatBackups: await this.#collectChatBackups(),
110113 settingsBackups: await this.#collectSettingsBackups(),
111114 };
@@ -145,6 +148,7 @@ export class DataMaidService {
145148 groupChats: await Promise.all(report.groupChats.map(i => this.#sanitizeRecord(i, false))),
146149 avatarThumbnails: await Promise.all(report.avatarThumbnails.map(i => this.#sanitizeRecord(i, false))),
147150 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))),
148152 chatBackups: await Promise.all(report.chatBackups.map(i => this.#sanitizeRecord(i, false))),
149153 settingsBackups: await Promise.all(report.settingsBackups.map(i => this.#sanitizeRecord(i, false))),
150154 };
@@ -416,6 +420,34 @@ export class DataMaidService {
416420 }
417421
418422 /**
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+ /**
419451 * Collects chat backups from the provided directories.
420452 * @returns {Promise<string[]>} List of paths to chat backups
421453 */
src/endpoints/thumbnails.js+16 -5
@@ -14,16 +14,21 @@ const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean'
1414const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number'))));
1515const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png';
1616
17+/**
18+ * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType
19+ */
20+
1721/** @type {Record<string, number[]>} */
1822export const dimensions = {
1923 'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
2024 'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
25+ 'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]),
2126};
2227
2328/**
2429 * Gets a path to thumbnail folder based on the type.
2530 * @param {import('../users.js').UserDirectoryList} directories User directories
2631 * @param {'bg' | 'avatar'ThumbnailType} type Thumbnail type
2732 * @returns {string} Path to the thumbnails folder
2833 */
2934function getThumbnailFolder(directories, type) {
@@ -36,6 +41,9 @@ function getThumbnailFolder(directories, type) {
3641 case 'avatar':
3742 thumbnailFolder = directories.thumbnailsAvatar;
3843 break;
44+ case 'persona':
45+ thumbnailFolder = directories.thumbnailsPersona;
46+ break;
3947 }
4048
4149 return thumbnailFolder;
@@ -44,7 +52,7 @@ function getThumbnailFolder(directories, type) {
4452/**
4553 * Gets a path to the original images folder based on the type.
4654 * @param {import('../users.js').UserDirectoryList} directories User directories
4755 * @param {'bg' | 'avatar'ThumbnailType} type Thumbnail type
4856 * @returns {string} Path to the original images folder
4957 */
5058function getOriginalFolder(directories, type) {
@@ -57,6 +65,9 @@ function getOriginalFolder(directories, type) {
5765 case 'avatar':
5866 originalFolder = directories.characters;
5967 break;
68+ case 'persona':
69+ originalFolder = directories.avatars;
70+ break;
6071 }
6172
6273 return originalFolder;
@@ -65,7 +76,7 @@ function getOriginalFolder(directories, type) {
6576/**
6677 * Removes the generated thumbnail from the disk.
6778 * @param {import('../users.js').UserDirectoryList} directories User directories
6879 * @param {'bg' | 'avatar'ThumbnailType} type Type of the thumbnail
6980 * @param {string} file Name of the file
7081 */
7182export function invalidateThumbnail(directories, type, file) {
@@ -82,7 +93,7 @@ export function invalidateThumbnail(directories, type, file) {
8293/**
8394 * Generates a thumbnail for the given file.
8495 * @param {import('../users.js').UserDirectoryList} directories User directories
8596 * @param {'bg' | 'avatar'ThumbnailType} type Type of the thumbnail
8697 * @param {string} file Name of the file
8798 * @returns
8899 */
@@ -188,7 +199,7 @@ router.get('/', async function (request, response) {
188199 return response.sendStatus(400);
189200 }
190201
191202 if (!(type === 'bg' || type === 'avatar' || type === 'persona')) {
192203 return response.sendStatus(400);
193204 }
194205
src/users.js+1 -0
@@ -71,6 +71,7 @@ const STORAGE_KEYS = {
7171 * @property {string} thumbnails - The directory where the thumbnails are stored
7272 * @property {string} thumbnailsBg - The directory where the background thumbnails are stored
7373 * @property {string} thumbnailsAvatar - The directory where the avatar thumbnails are stored
74+ * @property {string} thumbnailsPersona - The directory where the persona thumbnails are stored
7475 * @property {string} worlds - The directory where the WI are stored
7576 * @property {string} user - The directory where the user's public data is stored
7677 * @property {string} avatars - The directory where the avatars are stored