Blame Raw
Cohee · e3f41666 · · 1865 lines (64.1 KB)
1 contributor
1import { Fuse, localforage } from '../lib.js';
2import { characters, chat_metadata, eventSource, event_types, generateQuietPrompt, getCurrentChatId, getRequestHeaders, getThumbnailUrl, saveMetadata, saveSettingsDebounced, this_chid } from '../script.js';
3import { openThirdPartyExtensionMenu, saveMetadataDebounced } from './extensions.js';
4import { SlashCommand } from './slash-commands/SlashCommand.js';
5import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
6import { createThumbnail, flashHighlight, getBase64Async, stringFormat, debounce, setupScrollToTop, saveBase64AsFile, getFileExtension, sortIgnoreCaseAndAccents } from './utils.js';
7import { debounce_timeout } from './constants.js';
8import { t } from './i18n.js';
9import { callGenericPopup, Popup, POPUP_TYPE } from './popup.js';
10import { groups, selected_group } from './group-chats.js';
11import { humanizedDateTime } from './RossAscends-mods.js';
12import { deleteMediaFromServer } from './chats.js';
13
14const BG_METADATA_KEY = 'custom_background';
15const LIST_METADATA_KEY = 'chat_backgrounds';
16
17/** @type {Array<{id: string, name: string, thumbnailFile: string}>} */
18let folderList = [];
19/** @type {Object.<string, string[]>} filename → folderIds */
20let imageFolderMap = {};
21/** @type {string|null} Currently active folder drill-in, or null for root */
22let activeFolderId = null;
23/** @type {Set<string>} Selected system backgrounds for group folder actions */
24const selectedSystemBackgroundFiles = new Set();
25/** @type {boolean} Whether click-to-select mode is active for system backgrounds */
26let isBackgroundSelectionMode = false;
27
28// A single transparent PNG pixel used as a placeholder for errored backgrounds
29const PNG_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
30const PNG_PIXEL_BLOB = new Blob([Uint8Array.from(atob(PNG_PIXEL), c => c.charCodeAt(0))], { type: 'image/png' });
31const PLACEHOLDER_IMAGE = `url('data:image/png;base64,${PNG_PIXEL}')`;
32
33const THUMBNAIL_COLUMNS_MIN = 2;
34const THUMBNAIL_COLUMNS_MAX = 8;
35const THUMBNAIL_COLUMNS_DEFAULT_DESKTOP = 5;
36const THUMBNAIL_COLUMNS_DEFAULT_MOBILE = 3;
37
38/**
39 * Storage for frontend-generated background thumbnails.
40 * This is used to store thumbnails for backgrounds that cannot be generated on the server.
41 */
42const THUMBNAIL_STORAGE = localforage.createInstance({ name: 'SillyTavern_Thumbnails' });
43
44/**
45 * Cache for thumbnail blob URLs.
46 * @type {Map<string, string>}
47 */
48const THUMBNAIL_BLOBS = new Map();
49
50const THUMBNAIL_CONFIG = {
51 width: 160,
52 height: 90,
53};
54
55const ANIMATED_BACKGROUND_EXTENSIONS = ['mp4', 'webp', 'gif', 'apng'];
56
57/**
58 * Cache for image metadata.
59 * @type {Map<string, import('../../src/endpoints/image-metadata.js').ImageMetadata>}
60 */
61const METADATA_CACHE = new Map();
62
63/**
64 * Background source types.
65 * @readonly
66 * @enum {number}
67 */
68const BG_SOURCES = {
69 GLOBAL: 0,
70 CHAT: 1,
71};
72
73/**
74 * Background sorting options.
75 * @readonly
76 * @enum {string}
77 */
78const BG_SORT_OPTIONS = {
79 AZ: 'az',
80 ZA: 'za',
81 NEWEST: 'newest',
82 OLDEST: 'oldest',
83};
84
85/**
86 * Mapping of background sources to their corresponding tab IDs.
87 * @readonly
88 * @type {Record<string, string>}
89 */
90const BG_TABS = Object.freeze({
91 [BG_SOURCES.GLOBAL]: 'bg_global_tab',
92 [BG_SOURCES.CHAT]: 'bg_chat_tab',
93});
94
95/**
96 * Global IntersectionObserver instance for lazy loading backgrounds
97 * @type {IntersectionObserver|null}
98 */
99let lazyLoadObserver = null;
100
101/**
102 * Cache for the current list of system background filenames.
103 * Used to re-sort backgrounds without refetching from the server.
104 * @type {Array<{filename: string, isAnimated: boolean}>}
105 */
106let cachedSystemBackgrounds = [];
107
108export let background_settings = {
109 name: '__transparent.png',
110 url: generateUrlParameter('__transparent.png', false),
111 fitting: 'classic',
112 animation: false,
113 sortOrder: BG_SORT_OPTIONS.AZ,
114};
115
116/**
117 * Sorts an array of background filenames based on the current sort order.
118 * @param {string[]} backgrounds - Array of background filenames
119 * @param {boolean} isCustom - Whether these are custom (chat) backgrounds
120 * @returns {string[]} Sorted array of background filenames
121 */
122function sortBackgrounds(backgrounds, isCustom = false) {
123 const sortOrder = background_settings.sortOrder || BG_SORT_OPTIONS.AZ;
124
125 return [...backgrounds].sort((a, b) => {
126 switch (sortOrder) {
127 case BG_SORT_OPTIONS.AZ:
128 return sortIgnoreCaseAndAccents(a, b);
129 case BG_SORT_OPTIONS.ZA:
130 return sortIgnoreCaseAndAccents(b, a);
131 case BG_SORT_OPTIONS.NEWEST:
132 case BG_SORT_OPTIONS.OLDEST: {
133 const keyA = isCustom ? a : `backgrounds/${a}`;
134 const keyB = isCustom ? b : `backgrounds/${b}`;
135 const metaA = METADATA_CACHE.get(keyA);
136 const metaB = METADATA_CACHE.get(keyB);
137 const timestampA = metaA?.addedTimestamp ?? 0;
138 const timestampB = metaB?.addedTimestamp ?? 0;
139 // Newest first (descending) or oldest first (ascending)
140 return sortOrder === BG_SORT_OPTIONS.NEWEST
141 ? timestampB - timestampA
142 : timestampA - timestampB;
143 }
144 default:
145 return 0;
146 }
147 });
148}
149
150/**
151 * Creates a single thumbnail DOM element. The CSS now handles all sizing.
152 * @param {object} imageData - Data for the image (filename, isCustom, isAnimated).
153 * @returns {HTMLElement} The created thumbnail element.
154 */
155function createThumbnailElement(imageData) {
156 const bg = imageData.filename;
157 const isCustom = imageData.isCustom;
158 const isAnimated = imageData.isAnimated ?? false;
159
160 const thumbnail = $('#background_template .bg_example').clone();
161
162 const clipper = document.createElement('div');
163 clipper.className = 'thumbnail-clipper lazy-load-background';
164 clipper.style.backgroundImage = PLACEHOLDER_IMAGE;
165
166 // Apply dominant color and aspect ratio as placeholder if available
167 const metadataKey = isCustom ? bg : `backgrounds/${bg}`;
168 const metadata = METADATA_CACHE.get(metadataKey);
169 if (metadata) {
170 if (metadata.dominantColor) {
171 clipper.style.backgroundColor = metadata.dominantColor;
172 }
173 if (metadata.aspectRatio) {
174 thumbnail.css('aspect-ratio', metadata.aspectRatio);
175 }
176 }
177
178 const titleElement = thumbnail.find('.BGSampleTitle');
179 clipper.appendChild(titleElement.get(0));
180 thumbnail.append(clipper);
181
182 const url = generateUrlParameter(bg, isCustom);
183 const title = isCustom ? bg.split('/').pop() : bg;
184 const friendlyTitle = String(title || '').slice(0, title.lastIndexOf('.'));
185
186 thumbnail.attr('title', title);
187 thumbnail.attr('bgfile', bg);
188 thumbnail.attr('custom', String(isCustom));
189 thumbnail.attr('animated', String(isAnimated));
190 thumbnail.data('url', url);
191 titleElement.text(friendlyTitle);
192
193 return thumbnail.get(0);
194}
195
196/**
197 * Applies the thumbnail column count to the CSS and updates button states.
198 * @param {number} count - The number of columns to display.
199 */
200function applyThumbnailColumns(count) {
201 const newCount = Math.max(THUMBNAIL_COLUMNS_MIN, Math.min(count, THUMBNAIL_COLUMNS_MAX));
202 background_settings.thumbnailColumns = newCount;
203 document.documentElement.style.setProperty('--bg-thumb-columns', newCount.toString());
204
205 $('#bg_thumb_zoom_in').prop('disabled', newCount <= THUMBNAIL_COLUMNS_MIN);
206 $('#bg_thumb_zoom_out').prop('disabled', newCount >= THUMBNAIL_COLUMNS_MAX);
207
208 saveSettingsDebounced();
209}
210
211export function loadBackgroundSettings(settings) {
212 let backgroundSettings = settings.background;
213 if (!backgroundSettings || !backgroundSettings.name || !backgroundSettings.url) {
214 backgroundSettings = background_settings;
215 }
216 if (!backgroundSettings.fitting) {
217 backgroundSettings.fitting = 'classic';
218 }
219 if (!Object.hasOwn(backgroundSettings, 'animation')) {
220 backgroundSettings.animation = false;
221 }
222 if (!backgroundSettings.sortOrder) {
223 backgroundSettings.sortOrder = BG_SORT_OPTIONS.AZ;
224 }
225
226 // If a value is already saved, use it. Otherwise, determine default based on screen size.
227 let columns = backgroundSettings.thumbnailColumns;
228 if (!columns) {
229 const isNarrowScreen = window.matchMedia('(max-width: 480px)').matches;
230 columns = isNarrowScreen ? THUMBNAIL_COLUMNS_DEFAULT_MOBILE : THUMBNAIL_COLUMNS_DEFAULT_DESKTOP;
231 }
232 background_settings.thumbnailColumns = columns;
233 background_settings.sortOrder = backgroundSettings.sortOrder;
234 background_settings.animation = backgroundSettings.animation;
235 applyThumbnailColumns(background_settings.thumbnailColumns);
236
237 setBackground(backgroundSettings.name, backgroundSettings.url);
238 setFittingClass(backgroundSettings.fitting);
239 $('#background_fitting').val(backgroundSettings.fitting);
240 $('#background_thumbnails_animation').prop('checked', background_settings.animation);
241 $('#bg-sort').val(background_settings.sortOrder);
242 highlightSelectedBackground();
243}
244
245/**
246 * Sets the background for the current chat and adds it to the list of custom backgrounds.
247 * @param {{url: string, path:string}} backgroundInfo
248 */
249async function forceSetBackground(backgroundInfo) {
250 saveBackgroundMetadata(backgroundInfo.url);
251 $('#bg1').css('background-image', backgroundInfo.url);
252
253 const list = chat_metadata[LIST_METADATA_KEY] || [];
254 const bg = backgroundInfo.path;
255 list.push(bg);
256 chat_metadata[LIST_METADATA_KEY] = list;
257 saveMetadataDebounced();
258 renderChatBackgrounds();
259 highlightNewBackground(bg);
260 highlightLockedBackground();
261}
262
263async function onChatChanged() {
264 const lockedUrl = chat_metadata[BG_METADATA_KEY];
265
266 $('#bg1').css('background-image', lockedUrl || background_settings.url);
267
268 renderChatBackgrounds();
269 highlightLockedBackground();
270 highlightSelectedBackground();
271}
272
273/**
274 * Checks if a given URL corresponds to a custom background in the current chat's metadata.
275 * @param {string} fileUrl - The URL to check against the chat's custom backgrounds.
276 * @returns {boolean} True if the URL corresponds to a custom background, false otherwise.
277 */
278export function isCustomBackgroundUrl(fileUrl) {
279 const customBackgrounds = chat_metadata[LIST_METADATA_KEY] || [];
280 return customBackgrounds.some(bg => bg === fileUrl || generateUrlParameter(bg, true) === fileUrl);
281}
282
283/**
284 * Gets the client path for a background image, encoding the file name for safe URL usage.
285 * @param {string} fileUrl File name or URL of the background image
286 * @returns {string} Client path for the system backgroun
287 */
288export function getBackgroundPath(fileUrl) {
289 return `backgrounds/${encodeURIComponent(fileUrl)}`;
290}
291
292/**
293 * Gets the raw server-side relative path for a background image (no URL encoding).
294 * Used when communicating paths to the API (stored as plain strings in metadata).
295 * @param {string} file File name of the background image
296 * @returns {string} Raw relative path, e.g. "backgrounds/my file.jpg"
297 */
298function getBackgroundRelativePath(file) {
299 return `backgrounds/${file}`;
300}
301
302
303function highlightLockedBackground() {
304 $('.bg_example.locked-background').removeClass('locked-background');
305
306 const lockedBackgroundUrl = chat_metadata[BG_METADATA_KEY];
307
308 if (lockedBackgroundUrl) {
309 $('.bg_example').filter(function () {
310 return $(this).data('url') === lockedBackgroundUrl;
311 }).addClass('locked-background');
312 }
313}
314
315/**
316 * Locks the background for the current chat
317 * @param {Event|null} event
318 */
319function onLockBackgroundClick(event = null) {
320 if (!getCurrentChatId()) {
321 toastr.warning(t`Select a chat to lock the background for it`);
322 return;
323 }
324
325 // Take the global background's URL and save it to the chat's metadata.
326 const urlToLock = event ? $(event.target).closest('.bg_example').data('url') : background_settings.url;
327 saveBackgroundMetadata(urlToLock);
328 $('#bg1').css('background-image', urlToLock);
329
330 // Update UI states to reflect the new lock.
331 highlightLockedBackground();
332 highlightSelectedBackground();
333}
334
335/**
336 * Unlocks the background for the current chat
337 * @param {Event|null} _event
338 */
339function onUnlockBackgroundClick(_event = null) {
340 // Delete the lock from the chat's metadata.
341 removeBackgroundMetadata();
342
343 // Revert the view to the current global background.
344 $('#bg1').css('background-image', background_settings.url);
345
346 // Update UI states to reflect the removal of the lock.
347 highlightLockedBackground();
348 highlightSelectedBackground();
349}
350
351function isChatBackgroundLocked() {
352 return chat_metadata[BG_METADATA_KEY];
353}
354
355function saveBackgroundMetadata(file) {
356 chat_metadata[BG_METADATA_KEY] = file;
357 saveMetadataDebounced();
358}
359
360function removeBackgroundMetadata() {
361 delete chat_metadata[BG_METADATA_KEY];
362 saveMetadataDebounced();
363}
364
365/**
366 * Handles the click event for selecting a background.
367 * @param {JQuery.Event} e Event
368 */
369function onSelectBackgroundClick(e) {
370 const bgFile = $(this).attr('bgfile');
371 const isCustom = $(this).attr('custom') === 'true';
372 if (isBackgroundSelectionMode && !isCustom) {
373 toggleBackgroundGroupSelection(bgFile);
374 return;
375 }
376
377 const backgroundCssUrl = getUrlParameter(this);
378 const bypassGlobalLock = !isCustom && e.shiftKey;
379
380 if ((isChatBackgroundLocked() || isCustom) && !bypassGlobalLock) {
381 // If a background is locked, update the locked background directly
382 saveBackgroundMetadata(backgroundCssUrl);
383 $('#bg1').css('background-image', backgroundCssUrl);
384 } else {
385 // Otherwise, update the global background setting
386 setBackground(bgFile, backgroundCssUrl);
387 }
388
389 // Update UI highlights to reflect the changes.
390 highlightLockedBackground();
391 highlightSelectedBackground();
392}
393
394async function onCopyToSystemBackgroundClick(e) {
395 e.stopPropagation();
396 const bgNames = await getNewBackgroundName(this);
397
398 if (!bgNames) {
399 return;
400 }
401
402 const bgFile = await fetch(bgNames.oldBg);
403
404 if (!bgFile.ok) {
405 toastr.warning('Failed to copy background');
406 return;
407 }
408
409 const blob = await bgFile.blob();
410 const file = new File([blob], bgNames.newBg);
411 const formData = new FormData();
412 formData.set('avatar', file);
413
414 await uploadBackground(formData);
415
416 const list = chat_metadata[LIST_METADATA_KEY] || [];
417 const index = list.indexOf(bgNames.oldBg);
418 list.splice(index, 1);
419 saveMetadataDebounced();
420 renderChatBackgrounds();
421}
422
423/**
424 * Gets a thumbnail for the background from storage or fetches it if not available.
425 * It caches the thumbnail in local storage and returns a blob URL for the thumbnail.
426 * If the thumbnail cannot be fetched, it returns a transparent PNG pixel as a fallback.
427 * @param {string} bg Background URL
428 * @param {boolean} isCustom Is the background custom?
429 * @returns {Promise<string>} Blob URL of the thumbnail
430 */
431async function getThumbnailFromStorage(bg, isCustom) {
432 const cachedBlobUrl = THUMBNAIL_BLOBS.get(bg);
433 if (cachedBlobUrl) {
434 return cachedBlobUrl;
435 }
436
437 const savedBlob = await THUMBNAIL_STORAGE.getItem(bg);
438 if (savedBlob) {
439 const savedBlobUrl = URL.createObjectURL(savedBlob);
440 THUMBNAIL_BLOBS.set(bg, savedBlobUrl);
441 return savedBlobUrl;
442 }
443
444 try {
445 const url = isCustom ? bg : getBackgroundPath(bg);
446 const response = await fetch(url, { cache: 'force-cache' });
447 if (!response.ok) {
448 throw new Error('Fetch failed with status: ' + response.status);
449 }
450 const imageBlob = await response.blob();
451 const imageBase64 = await getBase64Async(imageBlob);
452 const thumbnailBase64 = await createThumbnail(imageBase64, THUMBNAIL_CONFIG.width, THUMBNAIL_CONFIG.height);
453 const thumbnailBlob = await fetch(thumbnailBase64).then(res => res.blob());
454 await THUMBNAIL_STORAGE.setItem(bg, thumbnailBlob);
455 const blobUrl = URL.createObjectURL(thumbnailBlob);
456 THUMBNAIL_BLOBS.set(bg, blobUrl);
457 return blobUrl;
458 } catch (error) {
459 console.error('Error fetching thumbnail, fallback image will be used:', error);
460 const fallbackBlob = PNG_PIXEL_BLOB;
461 const fallbackBlobUrl = URL.createObjectURL(fallbackBlob);
462 THUMBNAIL_BLOBS.set(bg, fallbackBlobUrl);
463 return fallbackBlobUrl;
464 }
465}
466
467/**
468 * Gets the new background name from the user.
469 * @param {Element} referenceElement
470 * @returns {Promise<{oldBg: string, newBg: string}>}
471 * */
472async function getNewBackgroundName(referenceElement) {
473 const exampleBlock = $(referenceElement).closest('.bg_example');
474 const isCustom = exampleBlock.attr('custom') === 'true';
475 const oldBg = exampleBlock.attr('bgfile');
476
477 if (!oldBg) {
478 console.debug('no bgfile');
479 return;
480 }
481
482 const fileExtension = oldBg.split('.').pop();
483 const fileNameBase = isCustom ? oldBg.split('/').pop() : oldBg;
484 const oldBgExtensionless = fileNameBase.replace(`.${fileExtension}`, '');
485 const newBgExtensionless = await Popup.show.input(t`Enter new background name:`, null, oldBgExtensionless);
486
487 if (!newBgExtensionless) {
488 console.debug('no new_bg_extensionless');
489 return;
490 }
491
492 const newBg = `${newBgExtensionless}.${fileExtension}`;
493
494 if (oldBgExtensionless === newBgExtensionless) {
495 console.debug('new_bg === old_bg');
496 return;
497 }
498
499 return { oldBg, newBg };
500}
501
502async function onRenameBackgroundClick(e) {
503 e.stopPropagation();
504
505 const bgNames = await getNewBackgroundName(this);
506
507 if (!bgNames) {
508 return;
509 }
510
511 const data = { old_bg: bgNames.oldBg, new_bg: bgNames.newBg };
512 const response = await fetch('/api/backgrounds/rename', {
513 method: 'POST',
514 headers: getRequestHeaders(),
515 body: JSON.stringify(data),
516 cache: 'no-cache',
517 });
518
519 if (response.ok) {
520 await getBackgrounds();
521 highlightNewBackground(bgNames.newBg);
522 } else {
523 toastr.warning('Failed to rename background');
524 }
525}
526
527async function onDeleteBackgroundClick(e) {
528 e.stopPropagation();
529 const bgToDelete = $(this).closest('.bg_example');
530 const url = bgToDelete.data('url');
531 const isCustom = bgToDelete.attr('custom') === 'true';
532 const deleteFromServerId = 'delete_bg_from_server';
533 /** @type {import('./popup.js').CustomPopupInput[]} */
534 const customInputs = [{
535 type: 'checkbox',
536 label: t`Also delete file from server`,
537 id: deleteFromServerId,
538 defaultState: true,
539 }];
540 let deleteFromServer = false;
541 const confirm = await Popup.show.confirm(t`Delete the background?`, null, {
542 customInputs: isCustom ? customInputs : [],
543 onClose: (popup) => {
544 if (isCustom) {
545 deleteFromServer = Boolean(popup?.inputResults?.get(deleteFromServerId) ?? false);
546 }
547 },
548 });
549 const bg = bgToDelete.attr('bgfile');
550
551 if (confirm) {
552 // If it's not custom, it's a built-in background. Delete it from the server
553 if (!isCustom) {
554 await delBackground(bg);
555 // Remove from cache to prevent reappearing on sort change
556 const cacheIndex = cachedSystemBackgrounds.findIndex(s => s.filename === bg);
557 if (cacheIndex !== -1) {
558 cachedSystemBackgrounds.splice(cacheIndex, 1);
559 }
560 } else {
561 const list = chat_metadata[LIST_METADATA_KEY] || [];
562 const index = list.indexOf(bg);
563 list.splice(index, 1);
564 }
565
566 if (bg === background_settings.name || url === chat_metadata[BG_METADATA_KEY]) {
567 const siblingSelector = '.bg_example';
568 const nextBg = bgToDelete.next(siblingSelector);
569 const prevBg = bgToDelete.prev(siblingSelector);
570
571 if (nextBg.length > 0) {
572 nextBg.trigger('click');
573 } else if (prevBg.length > 0) {
574 prevBg.trigger('click');
575 } else {
576 const anyOtherBg = $('.bg_example').not(bgToDelete).first();
577 if (anyOtherBg.length > 0) {
578 anyOtherBg.trigger('click');
579 }
580 }
581 }
582
583 // Remove from local image list so it doesn't reappear on re-render
584 const deletedBg = bgToDelete.attr('bgfile');
585 if (deletedBg) {
586 const cachedIdx = cachedSystemBackgrounds.findIndex(img => img.filename === deletedBg);
587 if (cachedIdx !== -1) cachedSystemBackgrounds.splice(cachedIdx, 1);
588 selectedSystemBackgroundFiles.delete(deletedBg);
589
590 // Update folder map and clear folder thumbnail if it referenced this image
591 if (imageFolderMap[deletedBg]) {
592 delete imageFolderMap[deletedBg];
593 }
594 for (const folder of folderList) {
595 if (folder.thumbnailFile === deletedBg) {
596 folder.thumbnailFile = '';
597 }
598 }
599 renderFolderGrid();
600 }
601
602 bgToDelete.remove();
603
604 if (url === chat_metadata[BG_METADATA_KEY]) {
605 removeBackgroundMetadata();
606 }
607
608 if (isCustom) {
609 if (deleteFromServer) {
610 await deleteMediaFromServer(bg);
611 }
612 renderChatBackgrounds();
613 await saveMetadata();
614 }
615
616 highlightLockedBackground();
617 highlightSelectedBackground();
618 syncGroupSelectionUi();
619 }
620}
621
622const autoBgPrompt = 'Ignore previous instructions and choose a location ONLY from the provided list that is the most suitable for the current scene. Do not output any other text:\n{0}';
623
624async function autoBackgroundCommand() {
625 /** @type {HTMLElement[]} */
626 const bgTitles = Array.from(document.querySelectorAll('#bg_menu_content .BGSampleTitle'));
627 const options = bgTitles.map(x => ({ element: x, text: x.innerText.trim() })).filter(x => x.text.length > 0);
628 if (options.length == 0) {
629 toastr.warning('No backgrounds to choose from. Please upload some images to the "backgrounds" folder.');
630 return '';
631 }
632
633 const list = options.map(option => `- ${option.text}`).join('\n');
634 const prompt = stringFormat(autoBgPrompt, list);
635 const reply = await generateQuietPrompt({ quietPrompt: prompt });
636 const fuse = new Fuse(options, { keys: ['text'] });
637 const bestMatch = fuse.search(reply, { limit: 1 });
638
639 if (bestMatch.length == 0) {
640 for (const option of options) {
641 if (String(reply).toLowerCase().includes(option.text.toLowerCase())) {
642 console.debug('Fallback choosing background:', option);
643 option.element.click();
644 return '';
645 }
646 }
647
648 toastr.warning('No match found. Please try again.');
649 return '';
650 }
651
652 console.debug('Automatically choosing background:', bestMatch);
653 bestMatch[0].item.element.click();
654 return '';
655}
656
657/**
658 * Renders the system backgrounds gallery.
659 * @param {Array<{filename: string, isAnimated: boolean}>} [backgrounds] - Optional filtered list of backgrounds with metadata.
660 */
661function renderSystemBackgrounds(backgrounds) {
662 const sourceList = backgrounds || [];
663 const container = $('#bg_menu_content');
664 container.empty();
665
666 if (sourceList.length === 0) {
667 syncGroupSelectionUi();
668 return;
669 }
670
671 const sortedList = sortBackgrounds(sourceList.map(bg => bg.filename), false);
672 const metadataByFilename = new Map(sourceList.map(bg => [bg.filename, bg]));
673 sortedList.forEach(filename => {
674 const bg = metadataByFilename.get(filename);
675 const imageData = { filename, isCustom: false, isAnimated: bg?.isAnimated ?? false };
676 const thumbnail = createThumbnailElement(imageData);
677 container.append(thumbnail);
678 });
679
680 syncGroupSelectionUi();
681 activateLazyLoader();
682}
683
684/**
685 * Renders the chat-specific (custom) backgrounds gallery.
686 * @param {string[]} [backgrounds] - Optional filtered list of backgrounds.
687 */
688function renderChatBackgrounds(backgrounds) {
689 const sourceList = backgrounds ?? (chat_metadata[LIST_METADATA_KEY] || []);
690 const container = $('#bg_custom_content');
691 container.empty();
692 $('#bg_chat_hint').toggle(!sourceList.length);
693
694 if (sourceList.length === 0) return;
695
696 const sortedList = sortBackgrounds(sourceList, true);
697 sortedList.forEach(bg => {
698 // For custom backgrounds, infer isAnimated from extension since we don't have server metadata
699 const isAnimated = isAnimatedBackgroundExtension(bg);
700 const imageData = { filename: bg, isCustom: true, isAnimated };
701 const thumbnail = createThumbnailElement(imageData);
702 container.append(thumbnail);
703 });
704
705 activateLazyLoader();
706}
707
708export async function getBackgrounds() {
709 const response = await fetch('/api/backgrounds/all', {
710 method: 'POST',
711 headers: getRequestHeaders(),
712 body: JSON.stringify({}),
713 });
714 if (response.ok) {
715 const { images, config } = await response.json();
716 Object.assign(THUMBNAIL_CONFIG, config);
717 cachedSystemBackgrounds = images;
718 const existingFiles = new Set(images.map(x => x.filename));
719 for (const selectedFile of selectedSystemBackgroundFiles) {
720 if (!existingFiles.has(selectedFile)) {
721 selectedSystemBackgroundFiles.delete(selectedFile);
722 }
723 }
724
725 // Load folders first so getFilteredImages() works correctly in folder view
726 await loadFolders();
727
728 await preloadImageMetadata();
729
730 // Render only filtered images if inside a folder, otherwise all
731 renderSystemBackgrounds(getFilteredImages());
732 highlightSelectedBackground();
733 }
734}
735
736/**
737 * Preloads all image metadata to use dominant colors as placeholders.
738 * @return {Promise<void>}
739 */
740async function preloadImageMetadata() {
741 try {
742 const response = await fetch('/api/image-metadata/all', {
743 method: 'POST',
744 headers: getRequestHeaders(),
745 body: JSON.stringify({ prefix: 'backgrounds/' }),
746 });
747 if (response.ok) {
748 const data = await response.json();
749 if (data?.images) {
750 METADATA_CACHE.clear();
751 for (const [path, metadata] of Object.entries(data.images)) {
752 METADATA_CACHE.set(path, metadata);
753 }
754 }
755 }
756 } catch (error) {
757 console.error('[ImageMetadata] Failed to preload metadata:', error);
758 }
759}
760
761/**
762 * Loads folder data from the server (separate from image loading).
763 */
764async function loadFolders() {
765 try {
766 const response = await fetch('/api/backgrounds/folders', {
767 method: 'POST',
768 headers: getRequestHeaders(),
769 body: JSON.stringify({}),
770 });
771 if (response.ok) {
772 const data = await response.json();
773 folderList = data.folders || [];
774 imageFolderMap = data.imageFolderMap || {};
775
776 // Auto-assign thumbnail for folders that don't have one, then persist
777 const allImages = cachedSystemBackgrounds.map(img => img.filename);
778 /** @type {{id: string, thumbnailFile: string}[]} */
779 const thumbnailUpdates = [];
780 for (const folder of folderList) {
781 if (!folder.thumbnailFile) {
782 const firstImage = allImages.find(img => {
783 const fids = imageFolderMap[img];
784 return fids && fids.includes(folder.id);
785 });
786 if (firstImage) {
787 folder.thumbnailFile = firstImage;
788 thumbnailUpdates.push({ id: folder.id, thumbnailFile: firstImage });
789 }
790 }
791 }
792 if (thumbnailUpdates.length > 0) {
793 await fetch('/api/image-metadata/folders/set-thumbnails', {
794 method: 'POST',
795 headers: getRequestHeaders(),
796 body: JSON.stringify({ updates: thumbnailUpdates }),
797 }).catch(err => console.debug('Auto-thumbnail save failed:', err));
798 }
799
800 renderFolderGrid();
801 }
802 } catch (error) {
803 console.error('Error loading folders:', error);
804 }
805}
806
807/**
808 * Renders the folder grid inside #bg_folder_grid.
809 */
810function renderFolderGrid() {
811 const container = $('#bg_folder_grid');
812 container.empty();
813
814 if (folderList.length === 0 && !activeFolderId) {
815 return;
816 }
817
818 for (const folder of folderList) {
819 const tile = createFolderTileElement(folder);
820 container.append(tile);
821 }
822}
823
824/**
825 * Creates a single folder tile DOM element.
826 * @param {{id: string, name: string, thumbnailFile: string}} folder
827 * @returns {HTMLElement}
828 */
829function createFolderTileElement(folder) {
830 const tile = $('#bg_folder_tile_template .bg_folder_tile').clone();
831 tile.attr('data-folder-id', folder.id);
832 tile.find('.bg_folder_tile_name').text(folder.name);
833
834 // Set cover image (async, update when resolved)
835 getFolderCoverUrl(folder).then(coverUrl => {
836 if (coverUrl) {
837 tile.find('.bg_folder_tile_cover').css('background-image', `url("${coverUrl}")`);
838 }
839 });
840
841 return tile.get(0);
842}
843
844/**
845 * Gets the cover image URL for a folder.
846 * Uses thumbnailFile if set, otherwise falls back to the first image in the folder.
847 * @param {{id: string, name: string, thumbnailFile: string}} folder
848 * @returns {Promise<string|null>}
849 */
850async function getFolderCoverUrl(folder) {
851 const file = folder.thumbnailFile || cachedSystemBackgrounds.find(img => {
852 const fids = imageFolderMap[img.filename];
853 return fids && fids.includes(folder.id);
854 })?.filename;
855 if (!file) return null;
856
857 if (isAnimatedBackgroundExtension(file) && !background_settings.animation) {
858 return getThumbnailFromStorage(file, false);
859 }
860 return getThumbnailUrl('bg', file);
861}
862
863/**
864 * Gets images filtered by the active folder.
865 * @returns {Array<{filename: string, isAnimated: boolean}>}
866 */
867function getFilteredImages() {
868 if (!activeFolderId) return cachedSystemBackgrounds;
869 return cachedSystemBackgrounds.filter(img => {
870 const fids = imageFolderMap[img.filename];
871 return fids && fids.includes(activeFolderId);
872 });
873}
874
875/**
876 * Drills into a folder — hides folder grid, shows breadcrumb, filters images.
877 * @param {string} folderId
878 */
879function onFolderDrillIn(folderId) {
880 const folder = folderList.find(f => f.id === folderId);
881 if (!folder) return;
882
883 clearBackgroundGroupSelection();
884 activeFolderId = folderId;
885 $('#Backgrounds').addClass('in-folder-view');
886
887 // Hide folder grid, show breadcrumb
888 $('#bg_folder_grid').hide();
889 $('#bg_folder_breadcrumb').show();
890 $('#bg_current_folder_name').text(folder.name);
891
892 // Render only this folder's images
893 renderSystemBackgrounds(getFilteredImages());
894 highlightSelectedBackground();
895}
896
897/**
898 * Returns to the root folder overview.
899 */
900function onBackToFolders() {
901 clearBackgroundGroupSelection();
902 activeFolderId = null;
903 $('#Backgrounds').removeClass('in-folder-view');
904
905 // Show folder grid, hide breadcrumb
906 $('#bg_folder_grid').show();
907 $('#bg_folder_breadcrumb').hide();
908 $('#bg_current_folder_name').text('');
909
910 // Show all images
911 renderSystemBackgrounds(getFilteredImages());
912 highlightSelectedBackground();
913}
914
915/**
916 * Refreshes click-to-select and group action UI state.
917 */
918function syncGroupSelectionUi() {
919 const selectedCount = selectedSystemBackgroundFiles.size;
920 const isGlobalTab = getActiveBackgroundTab() === BG_SOURCES.GLOBAL;
921 const showAddButton = isGlobalTab && isBackgroundSelectionMode && selectedCount > 0;
922 const showRemoveFromCurrentFolderButton = isGlobalTab && Boolean(activeFolderId) && isBackgroundSelectionMode && selectedCount > 0;
923
924 $('#Backgrounds').toggleClass('bg-selection-mode', isBackgroundSelectionMode);
925 $('#bg_selection_mode_button').toggleClass('active', isBackgroundSelectionMode);
926 $('#bg_group_select_count').text(selectedCount > 0 ? ` (${selectedCount})` : '').toggle(selectedCount > 0);
927
928 $('#bg_group_add_to_folder_button').toggle(showAddButton);
929 $('#bg_folder_remove_selected_button').toggle(showRemoveFromCurrentFolderButton);
930
931 $('#bg_menu_content .bg_example').each(function () {
932 const bgFile = String($(this).attr('bgfile') || '');
933 $(this).toggleClass('folder-group-selected', selectedSystemBackgroundFiles.has(bgFile));
934 });
935}
936
937/**
938 * Enables/disables click-to-select mode for system backgrounds.
939 * @param {boolean} enabled
940 */
941function setBackgroundSelectionMode(enabled) {
942 isBackgroundSelectionMode = enabled;
943 if (!enabled) {
944 selectedSystemBackgroundFiles.clear();
945 }
946 // Clear any open mobile menus
947 $('#bg_menu_content .bg_example.mobile-menu-open').removeClass('mobile-menu-open');
948 syncGroupSelectionUi();
949}
950
951/**
952 * Toggles selected state of a system background for group folder actions.
953 * @param {string} bgFile
954 */
955function toggleBackgroundGroupSelection(bgFile) {
956 if (!bgFile) return;
957 if (selectedSystemBackgroundFiles.has(bgFile)) {
958 selectedSystemBackgroundFiles.delete(bgFile);
959 } else {
960 selectedSystemBackgroundFiles.add(bgFile);
961 }
962 syncGroupSelectionUi();
963}
964
965/**
966 * Clears all selected system backgrounds for group folder actions.
967 */
968function clearBackgroundGroupSelection() {
969 selectedSystemBackgroundFiles.clear();
970 syncGroupSelectionUi();
971}
972
973/**
974 * Updates selection/folder action control visibility for the active tab.
975 */
976function updateGroupFolderControlsVisibility() {
977 const isGlobalTab = getActiveBackgroundTab() === BG_SOURCES.GLOBAL;
978 $('#bg_selection_mode_button').toggle(isGlobalTab);
979
980 if (!isGlobalTab && isBackgroundSelectionMode) {
981 setBackgroundSelectionMode(false);
982 return;
983 }
984 syncGroupSelectionUi();
985}
986
987/**
988 * Shows a folder selection popup and returns the selected folder id.
989 * @param {string} headingText
990 * @returns {Promise<string[]|null>} Array of selected folder IDs, or null if cancelled
991 */
992async function selectFoldersForGroupAction(headingText) {
993 if (folderList.length === 0) {
994 toastr.info(t`Create a folder first`);
995 return null;
996 }
997
998 const contentEl = document.createElement('div');
999 const heading = document.createElement('h3');
1000 heading.textContent = headingText;
1001 contentEl.appendChild(heading);
1002
1003 for (const folder of folderList) {
1004 const label = document.createElement('label');
1005 label.className = 'checkbox_label flexGap5';
1006 label.style.margin = '4px 0';
1007
1008 const checkbox = document.createElement('input');
1009 checkbox.type = 'checkbox';
1010 checkbox.dataset.folderId = folder.id;
1011
1012 const span = document.createElement('span');
1013 span.textContent = folder.name;
1014
1015 label.appendChild(checkbox);
1016 label.appendChild(span);
1017 contentEl.appendChild(label);
1018 }
1019
1020 const content = $(contentEl);
1021 const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', {
1022 okButton: t`Apply`,
1023 cancelButton: t`Cancel`,
1024 allowVerticalScrolling: true,
1025 leftAlign: true,
1026 });
1027 if (!result) return null;
1028
1029 const selectedIds = [];
1030 content.find('input[type="checkbox"]:checked').each(function () {
1031 selectedIds.push($(this).data('folderId'));
1032 });
1033 return selectedIds.length > 0 ? selectedIds : null;
1034}
1035
1036/**
1037 * Sends a folder assign/unassign request and updates local imageFolderMap state.
1038 * @param {string[]} bgFiles - Background filenames to update
1039 * @param {string} folderId - Target folder ID
1040 * @param {boolean} isRemove - Whether to remove (unassign) or add (assign)
1041 */
1042async function updateFolderAssignments(bgFiles, folderId, isRemove) {
1043 const paths = bgFiles.map(getBackgroundRelativePath);
1044 const endpoint = isRemove ? '/api/image-metadata/folders/unassign' : '/api/image-metadata/folders/assign';
1045
1046 const response = await fetch(endpoint, {
1047 method: 'POST',
1048 headers: getRequestHeaders(),
1049 body: JSON.stringify({ id: folderId, paths }),
1050 });
1051
1052 if (!response.ok) {
1053 throw new Error(`Folder ${isRemove ? 'unassign' : 'assign'} failed: ${response.status}`);
1054 }
1055
1056 for (const bgFile of bgFiles) {
1057 const currentFolderIds = imageFolderMap[bgFile] || [];
1058 if (isRemove) {
1059 const nextFolderIds = currentFolderIds.filter(id => id !== folderId);
1060 if (nextFolderIds.length > 0) {
1061 imageFolderMap[bgFile] = nextFolderIds;
1062 } else {
1063 delete imageFolderMap[bgFile];
1064 }
1065 } else if (!currentFolderIds.includes(folderId)) {
1066 imageFolderMap[bgFile] = [...currentFolderIds, folderId];
1067 }
1068 }
1069}
1070
1071/**
1072 * Adds selected system backgrounds to a chosen folder.
1073 */
1074async function onAddSelectedToFolder() {
1075 if (getActiveBackgroundTab() !== BG_SOURCES.GLOBAL) {
1076 toastr.warning(t`Folder actions are only available in the Global tab`);
1077 return;
1078 }
1079
1080 const bgFiles = Array.from(selectedSystemBackgroundFiles);
1081 if (bgFiles.length === 0) {
1082 toastr.info(t`Select one or more backgrounds first`);
1083 return;
1084 }
1085
1086 const folderIds = await selectFoldersForGroupAction(t`Add selected backgrounds to folders`);
1087 if (!folderIds) return;
1088
1089 try {
1090 let totalAdded = 0;
1091 for (const folderId of folderIds) {
1092 const actionableBgFiles = bgFiles.filter(bgFile => {
1093 const currentFolderIds = imageFolderMap[bgFile] || [];
1094 return !currentFolderIds.includes(folderId);
1095 });
1096 if (actionableBgFiles.length > 0) {
1097 await updateFolderAssignments(actionableBgFiles, folderId, false);
1098 totalAdded += actionableBgFiles.length;
1099 }
1100 }
1101
1102 renderFolderGrid();
1103
1104 if (activeFolderId) {
1105 renderSystemBackgrounds(getFilteredImages());
1106 highlightSelectedBackground();
1107 }
1108
1109 setBackgroundSelectionMode(false);
1110 if (totalAdded > 0) {
1111 toastr.success(t`Added backgrounds to ${folderIds.length} folder(s)`);
1112 } else {
1113 toastr.info(t`Selected backgrounds are already in the chosen folders`);
1114 }
1115 } catch (error) {
1116 console.error('Error adding selected backgrounds to folder:', error);
1117 toastr.error(t`Failed to update folder assignment`);
1118 }
1119}
1120
1121/**
1122 * Removes selected system backgrounds from the currently drilled-in folder.
1123 */
1124async function onRemoveSelectedFromCurrentFolder() {
1125 if (getActiveBackgroundTab() !== BG_SOURCES.GLOBAL) {
1126 toastr.warning(t`Folder actions are only available in the Global tab`);
1127 return;
1128 }
1129
1130 if (!activeFolderId) {
1131 toastr.info(t`Open a folder first`);
1132 return;
1133 }
1134
1135 const bgFiles = Array.from(selectedSystemBackgroundFiles);
1136 if (bgFiles.length === 0) {
1137 toastr.info(t`Select one or more backgrounds first`);
1138 return;
1139 }
1140
1141 try {
1142 await updateFolderAssignments(bgFiles, activeFolderId, true);
1143 renderFolderGrid();
1144 renderSystemBackgrounds(getFilteredImages());
1145 highlightSelectedBackground();
1146 setBackgroundSelectionMode(false);
1147 toastr.success(t`Removed ${bgFiles.length} background(s) from folder`);
1148 } catch (error) {
1149 console.error('Error removing selected backgrounds from current folder:', error);
1150 toastr.error(t`Failed to update folder assignment`);
1151 }
1152}
1153
1154/**
1155 * Creates a new folder via API.
1156 */
1157async function onCreateFolder() {
1158 const currentTab = getActiveBackgroundTab();
1159 if (currentTab !== BG_SOURCES.GLOBAL) {
1160 toastr.warning(t`Folders can only be created in the Global tab`);
1161 return;
1162 }
1163
1164 const name = await Popup.show.input(t`Enter folder name:`);
1165 if (!name || !name.trim()) return;
1166
1167 try {
1168 const response = await fetch('/api/image-metadata/folders/create', {
1169 method: 'POST',
1170 headers: getRequestHeaders(),
1171 body: JSON.stringify({ name: name.trim() }),
1172 });
1173 if (response.ok) {
1174 const folder = await response.json();
1175 folderList.push(folder);
1176 renderFolderGrid();
1177 toastr.success(t`Folder created: ${folder.name}`);
1178 }
1179 } catch (error) {
1180 console.error('Error creating folder:', error);
1181 toastr.error(t`Failed to create folder`);
1182 }
1183}
1184
1185/**
1186 * Renames a folder via API.
1187 * @param {string} folderId
1188 */
1189async function onRenameFolder(folderId) {
1190 const folder = folderList.find(f => f.id === folderId);
1191 if (!folder) return;
1192
1193 const newName = await Popup.show.input(t`Enter new folder name:`, null, folder.name);
1194 if (!newName || !newName.trim() || newName.trim() === folder.name) return;
1195
1196 try {
1197 const response = await fetch('/api/image-metadata/folders/update', {
1198 method: 'POST',
1199 headers: getRequestHeaders(),
1200 body: JSON.stringify({ id: folderId, name: newName.trim() }),
1201 });
1202 if (response.ok) {
1203 folder.name = newName.trim();
1204 renderFolderGrid();
1205 toastr.success(t`Folder renamed`);
1206 }
1207 } catch (error) {
1208 console.error('Error renaming folder:', error);
1209 toastr.error(t`Failed to rename folder`);
1210 }
1211}
1212
1213/**
1214 * Deletes a folder via API.
1215 * @param {string} folderId
1216 */
1217async function onDeleteFolder(folderId) {
1218 const folder = folderList.find(f => f.id === folderId);
1219 if (!folder) return;
1220
1221 const confirm = await Popup.show.confirm(t`Delete folder "${folder.name}"?`, t`Images will not be deleted, only the folder grouping.`);
1222 if (!confirm) return;
1223
1224 try {
1225 const response = await fetch('/api/image-metadata/folders/delete', {
1226 method: 'POST',
1227 headers: getRequestHeaders(),
1228 body: JSON.stringify({ id: folderId }),
1229 });
1230 if (response.ok) {
1231 folderList = folderList.filter(f => f.id !== folderId);
1232 // Clean imageFolderMap
1233 for (const fids of Object.values(imageFolderMap)) {
1234 const idx = fids.indexOf(folderId);
1235 if (idx !== -1) fids.splice(idx, 1);
1236 }
1237 // If we were inside this folder, go back
1238 if (activeFolderId === folderId) {
1239 onBackToFolders();
1240 }
1241 renderFolderGrid();
1242 toastr.success(t`Folder deleted`);
1243 }
1244 } catch (error) {
1245 console.error('Error deleting folder:', error);
1246 toastr.error(t`Failed to delete folder`);
1247 }
1248}
1249
1250/**
1251 * Shows a folder assignment popup for an image.
1252 * @param {string} bgFile - The background filename
1253 */
1254async function onAssignToFolder(bgFile) {
1255 if (folderList.length === 0) {
1256 toastr.info(t`Create a folder first`);
1257 return;
1258 }
1259
1260 const currentFolderIds = imageFolderMap[bgFile] || [];
1261
1262 // Build checkbox inputs for Popup using DOM construction (avoids HTML injection)
1263 const contentEl = document.createElement('div');
1264 const heading = document.createElement('h3');
1265 heading.textContent = t`Assign to folders`;
1266 contentEl.appendChild(heading);
1267
1268 for (const f of folderList) {
1269 const label = document.createElement('label');
1270 label.className = 'checkbox_label flexGap5';
1271 label.style.margin = '4px 0';
1272
1273 const checkbox = document.createElement('input');
1274 checkbox.type = 'checkbox';
1275 checkbox.dataset.folderId = f.id;
1276 checkbox.checked = currentFolderIds.includes(f.id);
1277
1278 const span = document.createElement('span');
1279 span.textContent = f.name;
1280
1281 label.appendChild(checkbox);
1282 label.appendChild(span);
1283 contentEl.appendChild(label);
1284 }
1285
1286 const content = $(contentEl);
1287
1288 const result = await callGenericPopup(content, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel` });
1289 if (!result) return;
1290
1291 // Determine which folders were toggled on/off
1292 const toAssign = [];
1293 const toUnassign = [];
1294 content.find('input[type="checkbox"]').each(function () {
1295 const fid = $(this).data('folder-id');
1296 const isChecked = $(this).prop('checked');
1297 const wasChecked = currentFolderIds.includes(fid);
1298 if (isChecked && !wasChecked) toAssign.push(fid);
1299 if (!isChecked && wasChecked) toUnassign.push(fid);
1300 });
1301
1302 try {
1303 for (const fid of toAssign) {
1304 await updateFolderAssignments([bgFile], fid, false);
1305 }
1306 for (const fid of toUnassign) {
1307 await updateFolderAssignments([bgFile], fid, true);
1308 }
1309
1310 renderFolderGrid();
1311
1312 // Re-render filtered image list if currently inside a folder view
1313 if (activeFolderId) {
1314 renderSystemBackgrounds(getFilteredImages());
1315 highlightSelectedBackground();
1316 }
1317
1318 toastr.success(t`Folder assignment updated`);
1319 } catch (error) {
1320 console.error('Error assigning to folder:', error);
1321 toastr.error(t`Failed to update folder assignment`);
1322 }
1323}
1324
1325/**
1326 * Sets an image as the folder cover.
1327 * @param {string} bgFile - The background filename
1328 */
1329async function onSetFolderCover(bgFile) {
1330 if (!activeFolderId) return;
1331
1332 try {
1333 const response = await fetch('/api/image-metadata/folders/update', {
1334 method: 'POST',
1335 headers: getRequestHeaders(),
1336 body: JSON.stringify({ id: activeFolderId, thumbnailFile: bgFile }),
1337 });
1338 if (response.ok) {
1339 const folder = folderList.find(f => f.id === activeFolderId);
1340 if (folder) {
1341 folder.thumbnailFile = bgFile;
1342 // Update the DOM tile cover image
1343 const coverUrl = await getFolderCoverUrl(folder);
1344 if (coverUrl) {
1345 $(`.bg_folder_tile[data-folder-id="${folder.id}"] .bg_folder_tile_cover`)
1346 .css('background-image', `url('${coverUrl}')`);
1347 }
1348 }
1349 toastr.success(t`Folder cover updated`);
1350 }
1351 } catch (error) {
1352 console.error('Error setting folder cover:', error);
1353 toastr.error(t`Failed to set folder cover`);
1354 }
1355}
1356
1357function activateLazyLoader() {
1358 // Disconnect previous observer to prevent memory leaks
1359 if (lazyLoadObserver) {
1360 lazyLoadObserver.disconnect();
1361 lazyLoadObserver = null;
1362 }
1363
1364 const lazyLoadElements = document.querySelectorAll('.lazy-load-background');
1365
1366 const options = {
1367 root: null,
1368 rootMargin: '200px',
1369 threshold: 0.01,
1370 };
1371
1372 lazyLoadObserver = new IntersectionObserver((entries, observer) => {
1373 entries.forEach(entry => {
1374 if (entry.target instanceof HTMLElement && entry.isIntersecting) {
1375 const clipper = entry.target;
1376 const parentThumbnail = clipper.closest('.bg_example');
1377
1378 if (parentThumbnail) {
1379 const bg = parentThumbnail.getAttribute('bgfile');
1380 const isCustom = parentThumbnail.getAttribute('custom') === 'true';
1381 const isAnimated = parentThumbnail.getAttribute('animated') === 'true';
1382 resolveImageUrl(bg, isCustom, isAnimated)
1383 .then(url => { clipper.style.backgroundImage = url; })
1384 .catch(() => { clipper.style.backgroundImage = PLACEHOLDER_IMAGE; });
1385 }
1386
1387 clipper.classList.remove('lazy-load-background');
1388 observer.unobserve(clipper);
1389 }
1390 });
1391 }, options);
1392
1393 lazyLoadElements.forEach(element => {
1394 lazyLoadObserver.observe(element);
1395 });
1396}
1397
1398/**
1399 * Gets the CSS URL of the background
1400 * @param {Element} block
1401 * @returns {string} URL of the background
1402 */
1403function getUrlParameter(block) {
1404 return $(block).closest('.bg_example').data('url');
1405}
1406
1407function generateUrlParameter(bg, isCustom) {
1408 return isCustom ? `url("${encodeURI(bg)}")` : `url("${getBackgroundPath(bg)}")`;
1409}
1410
1411function isAnimatedBackgroundExtension(fileName) {
1412 const fileExtension = fileName.split('.').pop().toLowerCase();
1413 return ANIMATED_BACKGROUND_EXTENSIONS.includes(fileExtension);
1414}
1415
1416/**
1417 * Resolves the image URL for the background.
1418 * @param {string} bg Background file name
1419 * @param {boolean} isCustom Is a custom background
1420 * @param {boolean|null} [isAnimated=null] Is the background animated (from metadata). If null, infers from extension.
1421 * @returns {Promise<string>} CSS URL of the background
1422 */
1423async function resolveImageUrl(bg, isCustom, isAnimated = null) {
1424 // If isAnimated is not provided (null), fall back to extension-based heuristic
1425 let animated = isAnimated;
1426 if (animated === null) {
1427 animated = isAnimatedBackgroundExtension(bg);
1428 }
1429
1430 const thumbnailUrl = animated && !background_settings.animation
1431 ? await getThumbnailFromStorage(bg, isCustom)
1432 : isCustom
1433 ? bg
1434 : getThumbnailUrl('bg', bg);
1435
1436 return `url("${thumbnailUrl}")`;
1437}
1438
1439async function setBackground(bg, url) {
1440 // Only change the visual background if one is not locked for the current chat.
1441 if (!isChatBackgroundLocked()) {
1442 $('#bg1').css('background-image', url);
1443 }
1444 background_settings.name = bg;
1445 background_settings.url = url;
1446 saveSettingsDebounced();
1447}
1448
1449async function delBackground(bg) {
1450 await fetch('/api/backgrounds/delete', {
1451 method: 'POST',
1452 headers: getRequestHeaders(),
1453 body: JSON.stringify({
1454 bg: bg,
1455 }),
1456 });
1457
1458 await THUMBNAIL_STORAGE.removeItem(bg);
1459 if (THUMBNAIL_BLOBS.has(bg)) {
1460 URL.revokeObjectURL(THUMBNAIL_BLOBS.get(bg));
1461 THUMBNAIL_BLOBS.delete(bg);
1462 }
1463}
1464
1465/**
1466 * Background upload handler.
1467 * @param {Event} e Event
1468 * @returns {Promise<void>}
1469 */
1470async function onBackgroundUploadSelected(e) {
1471 const input = e.currentTarget;
1472
1473 if (!(input instanceof HTMLInputElement)) {
1474 console.error('Invalid input element for background upload');
1475 return;
1476 }
1477
1478 for (const file of input.files) {
1479 if (file.size === 0) {
1480 continue;
1481 }
1482
1483 const formData = new FormData();
1484 formData.append('avatar', file);
1485
1486 await convertFileIfVideo(formData);
1487 switch (getActiveBackgroundTab()) {
1488 case BG_SOURCES.GLOBAL:
1489 await uploadBackground(formData);
1490 break;
1491 case BG_SOURCES.CHAT:
1492 await uploadChatBackground(formData);
1493 break;
1494 default:
1495 console.error('Unknown background source type');
1496 continue;
1497 }
1498 }
1499
1500 // Allow re-uploading the same file again by clearing the input value
1501 input.value = '';
1502}
1503
1504/**
1505 * Converts a video file to an animated webp format if the file is a video.
1506 * @param {FormData} formData
1507 * @returns {Promise<void>}
1508 */
1509async function convertFileIfVideo(formData) {
1510 const file = formData.get('avatar');
1511 if (!(file instanceof File)) {
1512 return;
1513 }
1514 if (!file.type.startsWith('video/')) {
1515 return;
1516 }
1517 if (typeof globalThis.convertVideoToAnimatedWebp !== 'function') {
1518 toastr.warning(t`Click here to install the Video Background Loader extension`, t`Video background uploads require a downloadable add-on`, {
1519 timeOut: 0,
1520 extendedTimeOut: 0,
1521 onclick: () => openThirdPartyExtensionMenu('https://github.com/SillyTavern/Extension-VideoBackgroundLoader'),
1522 });
1523 return;
1524 }
1525
1526 let toastMessage = jQuery();
1527 try {
1528 toastMessage = toastr.info(t`Preparing video for upload. This may take several minutes.`, t`Please wait`, { timeOut: 0, extendedTimeOut: 0 });
1529 const sourceBuffer = await file.arrayBuffer();
1530 const convertedBuffer = await globalThis.convertVideoToAnimatedWebp({ buffer: new Uint8Array(sourceBuffer), name: file.name });
1531 const convertedFileName = file.name.replace(/\.[^/.]+$/, '.webp');
1532 const convertedFile = new File([new Uint8Array(convertedBuffer)], convertedFileName, { type: 'image/webp' });
1533 formData.set('avatar', convertedFile);
1534 toastMessage.remove();
1535 } catch (error) {
1536 formData.delete('avatar');
1537 toastMessage.remove();
1538 console.error('Error converting video to animated webp:', error);
1539 toastr.error(t`Error converting video to animated webp`);
1540 }
1541}
1542
1543/**
1544 * Uploads a background to the server
1545 * @param {FormData} formData
1546 */
1547async function uploadBackground(formData) {
1548 try {
1549 if (!formData.has('avatar')) {
1550 console.log('No file provided. Background upload cancelled.');
1551 return;
1552 }
1553
1554 const response = await fetch('/api/backgrounds/upload', {
1555 method: 'POST',
1556 headers: getRequestHeaders({ omitContentType: true }),
1557 body: formData,
1558 cache: 'no-cache',
1559 });
1560
1561 if (!response.ok) {
1562 throw new Error('Failed to upload background');
1563 }
1564
1565 const bg = await response.text();
1566 setBackground(bg, generateUrlParameter(bg, false));
1567 await getBackgrounds();
1568 highlightNewBackground(bg);
1569 } catch (error) {
1570 console.error('Error uploading background:', error);
1571 }
1572}
1573
1574/**
1575 * Upload a chat background using a FormData object.
1576 * @param {FormData} formData FormData containing the background file
1577 * @returns {Promise<void>}
1578 */
1579async function uploadChatBackground(formData) {
1580 try {
1581 if (!getCurrentChatId()) {
1582 toastr.warning(t`Select a chat to upload a background for it`);
1583 return;
1584 }
1585 if (!formData.has('avatar')) {
1586 console.log('No file provided. Chat background upload cancelled.');
1587 return;
1588 }
1589
1590 const file = formData.get('avatar');
1591 if (!(file instanceof File)) {
1592 console.error('Invalid file type for chat background upload');
1593 return;
1594 }
1595
1596 const imageDataUri = await getBase64Async(file);
1597 const base64Data = imageDataUri.split(',')[1];
1598 const extension = getFileExtension(file);
1599 const characterName = selected_group
1600 ? groups.find(g => g.id === selected_group)?.id?.toString()
1601 : characters[this_chid]?.name;
1602 const filename = `${characterName}_${humanizedDateTime()}`;
1603 const imagePath = await saveBase64AsFile(base64Data, characterName, filename, extension);
1604
1605 const list = chat_metadata[LIST_METADATA_KEY] || [];
1606 list.push(imagePath);
1607 chat_metadata[LIST_METADATA_KEY] = list;
1608 await saveMetadata();
1609 renderChatBackgrounds();
1610 highlightNewBackground(imagePath);
1611 highlightLockedBackground();
1612 highlightSelectedBackground();
1613 } catch (error) {
1614 console.error('Error uploading chat background:', error);
1615 }
1616}
1617
1618/**
1619 * @param {string} bg
1620 */
1621function highlightNewBackground(bg) {
1622 const newBg = $(`.bg_example[bgfile="${bg}"]`);
1623 const scrollOffset = newBg.offset().top - newBg.parent().offset().top;
1624 $('#Backgrounds').scrollTop(scrollOffset);
1625 flashHighlight(newBg);
1626}
1627
1628/**
1629 * Sets the fitting class for the background element
1630 * @param {string} fitting Fitting type
1631 */
1632function setFittingClass(fitting) {
1633 const backgrounds = $('#bg1');
1634 for (const option of ['cover', 'contain', 'stretch', 'center']) {
1635 backgrounds.toggleClass(option, option === fitting);
1636 }
1637 background_settings.fitting = fitting;
1638}
1639
1640function highlightSelectedBackground() {
1641 $('.bg_example.selected-background').removeClass('selected-background');
1642
1643 // The "selected" highlight should always reflect the global background setting.
1644 const activeUrl = background_settings.url;
1645
1646 if (activeUrl) {
1647 // Find the thumbnail whose data-url attribute matches the active URL
1648 $('.bg_example').filter(function () {
1649 return $(this).data('url') === activeUrl;
1650 }).addClass('selected-background');
1651 }
1652}
1653
1654function onBackgroundFilterInput() {
1655 const filterValue = String($('#bg-filter').val()).toLowerCase();
1656 $('#bg_menu_content > .bg_example, #bg_custom_content > .bg_example').each(function () {
1657 const $bg = $(this);
1658 const title = $bg.attr('title') || '';
1659 const hasMatch = title.toLowerCase().includes(filterValue);
1660 $bg.toggle(hasMatch);
1661 });
1662
1663 // Show/hide folder tiles based on whether folder name matches the filter
1664 if (!activeFolderId) {
1665 $('#bg_folder_grid .bg_folder_tile').each(function () {
1666 const $tile = $(this);
1667 const folderId = $tile.attr('data-folder-id');
1668 if (!folderId || !filterValue) {
1669 $tile.show();
1670 return;
1671 }
1672 const folder = folderList.find(f => f.id === folderId);
1673 const folderName = folder ? folder.name.toLowerCase() : '';
1674 $tile.toggle(folderName.includes(filterValue));
1675 });
1676 }
1677}
1678
1679const debouncedOnBackgroundFilterInput = debounce(onBackgroundFilterInput, debounce_timeout.standard);
1680
1681/**
1682 * Gets the active background tab source.
1683 * @returns {BG_SOURCES} Active background tab source
1684 */
1685export function getActiveBackgroundTab() {
1686 const tabs = $('#bg_tabs');
1687 if (!tabs.length || !tabs.data('ui-tabs')) {
1688 return BG_SOURCES.GLOBAL;
1689 }
1690 return tabs.tabs('option', 'active');
1691}
1692
1693export function initBackgrounds() {
1694 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
1695 eventSource.on(event_types.FORCE_SET_BACKGROUND, forceSetBackground);
1696
1697 // Folder event handlers
1698 $(document)
1699 .on('click', '.bg_folder_tile:not(.bg_new_folder_tile)', function (e) {
1700 if ($(e.target).closest('.jg-button').length) return; // let button handler run
1701 const folderId = $(this).attr('data-folder-id');
1702 if (folderId) onFolderDrillIn(folderId);
1703 })
1704 .on('click', '#bg_add_folder_button', function () {
1705 onCreateFolder();
1706 })
1707 .on('click', '#bg_back_to_folders', function () {
1708 onBackToFolders();
1709 })
1710 .on('click', '.bg_folder_tile [data-action="rename-folder"]', function (e) {
1711 e.stopPropagation();
1712 const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
1713 if (folderId) onRenameFolder(folderId);
1714 })
1715 .on('click', '.bg_folder_tile [data-action="delete-folder"]', function (e) {
1716 e.stopPropagation();
1717 const folderId = $(this).closest('.bg_folder_tile').attr('data-folder-id');
1718 if (folderId) onDeleteFolder(folderId);
1719 })
1720 .on('click', '.bg_folder_tile .mobile-only-menu-toggle', function (e) {
1721 e.stopPropagation();
1722 const $context = $(this).closest('.bg_folder_tile');
1723 const wasOpen = $context.hasClass('mobile-menu-open');
1724 // Close all other open menus before opening a new one.
1725 $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
1726 $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
1727 if (!wasOpen) {
1728 $context.addClass('mobile-menu-open');
1729 }
1730 });
1731
1732 $(document)
1733 .off('click', '.bg_example').on('click', '.bg_example', onSelectBackgroundClick)
1734 .off('click', '.bg_example .mobile-only-menu-toggle').on('click', '.bg_example .mobile-only-menu-toggle', function (e) {
1735 e.stopPropagation();
1736 const $context = $(this).closest('.bg_example');
1737 const wasOpen = $context.hasClass('mobile-menu-open');
1738 // Close all other open menus before opening a new one.
1739 $('.bg_example.mobile-menu-open').removeClass('mobile-menu-open');
1740 $('.bg_folder_tile.mobile-menu-open').removeClass('mobile-menu-open');
1741 if (!wasOpen) {
1742 $context.addClass('mobile-menu-open');
1743 }
1744 })
1745 .off('blur', '.bg_example.mobile-menu-open').on('blur', '.bg_example.mobile-menu-open', function () {
1746 if (!$(this).is(':focus-within')) {
1747 $(this).removeClass('mobile-menu-open');
1748 }
1749 })
1750 .off('click', '.jg-button').on('click', '.jg-button', function (e) {
1751 e.stopPropagation();
1752 if (isBackgroundSelectionMode && $(this).closest('#bg_menu_content').length) {
1753 return;
1754 }
1755 const action = $(this).data('action');
1756
1757 switch (action) {
1758 case 'lock':
1759 onLockBackgroundClick.call(this, e.originalEvent);
1760 break;
1761 case 'unlock':
1762 onUnlockBackgroundClick.call(this, e.originalEvent);
1763 break;
1764 case 'edit':
1765 onRenameBackgroundClick.call(this, e.originalEvent);
1766 break;
1767 case 'delete':
1768 onDeleteBackgroundClick.call(this, e.originalEvent);
1769 break;
1770 case 'copy':
1771 onCopyToSystemBackgroundClick.call(this, e.originalEvent);
1772 break;
1773 case 'folder': {
1774 const bgEl = $(this).closest('.bg_example');
1775 if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
1776 const bgFile = bgEl.attr('bgfile');
1777 if (bgFile) onAssignToFolder(bgFile);
1778 break;
1779 }
1780 case 'set-cover': {
1781 const bgEl = $(this).closest('.bg_example');
1782 if (bgEl.attr('custom') === 'true') break; // Only system backgrounds
1783 const bgFile = bgEl.attr('bgfile');
1784 if (bgFile) onSetFolderCover(bgFile);
1785 break;
1786 }
1787 }
1788 });
1789
1790 $('#bg_thumb_zoom_in').on('click', () => {
1791 applyThumbnailColumns(background_settings.thumbnailColumns - 1);
1792 });
1793 $('#bg_thumb_zoom_out').on('click', () => {
1794 applyThumbnailColumns(background_settings.thumbnailColumns + 1);
1795 });
1796 $('#auto_background').on('click', autoBackgroundCommand);
1797 $('#bg_selection_mode_button').on('click', () => setBackgroundSelectionMode(!isBackgroundSelectionMode));
1798 $('#bg_group_add_to_folder_button').on('click', onAddSelectedToFolder);
1799 $('#bg_folder_remove_selected_button').on('click', onRemoveSelectedFromCurrentFolder);
1800 $('#add_bg_button').on('change', (e) => onBackgroundUploadSelected(e.originalEvent));
1801 $('#bg-filter').on('input', () => debouncedOnBackgroundFilterInput());
1802 $('#bg-sort').on('change', function () {
1803 background_settings.sortOrder = String($(this).val());
1804 saveSettingsDebounced();
1805 // Re-render both galleries with new sort order (respecting active folder filter)
1806 renderSystemBackgrounds(getFilteredImages());
1807 renderChatBackgrounds();
1808 highlightSelectedBackground();
1809 highlightLockedBackground();
1810 // Re-apply any active search filter
1811 onBackgroundFilterInput();
1812 });
1813 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1814 name: 'lockbg',
1815 callback: () => {
1816 onLockBackgroundClick();
1817 return '';
1818 },
1819 aliases: ['bglock'],
1820 helpString: 'Locks a background for the currently selected chat',
1821 }));
1822 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1823 name: 'unlockbg',
1824 callback: () => {
1825 onUnlockBackgroundClick();
1826 return '';
1827 },
1828 aliases: ['bgunlock'],
1829 helpString: 'Unlocks a background for the currently selected chat',
1830 }));
1831 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1832 name: 'autobg',
1833 callback: autoBackgroundCommand,
1834 aliases: ['bgauto'],
1835 helpString: 'Automatically changes the background based on the chat context using the AI request prompt',
1836 }));
1837
1838 $('#background_fitting').on('input', function () {
1839 background_settings.fitting = String($(this).val());
1840 setFittingClass(background_settings.fitting);
1841 saveSettingsDebounced();
1842 });
1843
1844 $('#background_thumbnails_animation').on('input', async function () {
1845 background_settings.animation = !!$(this).prop('checked');
1846 saveSettingsDebounced();
1847
1848 // Refresh background thumbnails
1849 await getBackgrounds();
1850 await onChatChanged();
1851 });
1852
1853 Object.values(BG_TABS).forEach(tabId => {
1854 setupScrollToTop({
1855 scrollContainerId: tabId,
1856 buttonId: 'bg-scroll-top',
1857 drawerId: 'Backgrounds',
1858 });
1859 });
1860
1861 $('#bg_tabs').tabs();
1862 $('#bg_tabs').on('tabsactivate', () => updateGroupFolderControlsVisibility());
1863 updateGroupFolderControlsVisibility();
1864 syncGroupSelectionUi();
1865}