Merge pull request #2463 from SillyTavern/staging Staging

89dd4cbf3722dc12968b012e71c9021b09d070b2

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

Signed
33 files changed, +333 -136Showing whitespace changes
.dockerignore+2 -0
@@ -1,4 +1,6 @@
11.git
2+.github
3+.vscode
24node_modules
35npm-debug.log
46readme*
.npmignore+3 -0
@@ -8,3 +8,6 @@ secrets.json
88/data
99/cache
1010access.log
11+.github
12+.vscode
13+.git
default/config.yaml+2 -0
@@ -48,6 +48,8 @@ allowKeysExposure: false
4848skipContentCheck: false
4949# Disable automatic chats backup
5050disableChatBackup: false
51+# Number of backups to keep for each chat and settings file
52+numberOfBackups: 50
5153# Allowed hosts for card downloads
5254whitelistImportDomains:
5355 - localhost
public/css/loader.css+1 -1
@@ -1,4 +1,4 @@
11#loader, #preloader {
22 position: fixed;
33 margin: 0;
44 padding: 0;
public/css/popup-safari-fix.css+0 -2
@@ -1,8 +1,6 @@
11/* iPhone copium land */
2-@media screen and (max-width: 1000px) {
32.ios .popup .popup-body {
43 height: fit-content;
54 max-height: 90vh;
65 max-height: 90svh;
76}
8-}
public/css/popup.css+13 -4
@@ -27,8 +27,17 @@ dialog {
2727 backface-visibility: hidden;
2828 transform: translateZ(0);
2929 -webkit-font-smoothing: subpixel-antialiased;
30+
31+ /* Variables setup */
32+ --popup-animation-speed: var(--animation-duration-slow);
3033}
3134
35+/** Popup styles applied to the main popup */
36+.popup--animation-fast { --popup-animation-speed: var(--animation-duration); }
37+.popup--animation-slow { --popup-animation-speed: var(--animation-duration-slow); }
38+.popup--animation-none { --popup-animation-speed: 0ms; }
39+
40+/* Styling of main popup elements */
3241.popup .popup-body {
3342 display: flex;
3443 flex-direction: column;
@@ -60,11 +69,11 @@ dialog {
6069
6170/* Opening animation */
6271.popup[opening] {
6372 animation: pop-in var(--animationpopup-durationanimation-slowspeed) ease-in-out;
6473}
6574
6675.popup[opening]::backdrop {
6776 animation: fade-in var(--animationpopup-durationanimation-slowspeed) ease-in-out;
6877}
6978
7079/* Open state of the dialog */
@@ -85,11 +94,11 @@ body.no-blur .popup[open]::backdrop {
8594
8695/* Closing animation */
8796.popup[closing] {
8897 animation: pop-out var(--animationpopup-durationanimation-slowspeed) ease-in-out;
8998}
9099
91100.popup[closing]::backdrop {
92101 animation: fade-out var(--animationpopup-durationanimation-slowspeed) ease-in-out;
93102}
94103
95104.popup #toast-container {
public/index.html+3 -3
@@ -3903,8 +3903,8 @@
39033903 <label for="tag_import_setting"><small data-i18n="Import Card Tags">Import Card Tags</small></label>
39043904 <select id="tag_import_setting" class="widthNatural flex1 margin0">
39053905 <option data-i18n="Ask" value="1">Ask</option>
39063906 <option data-i18n="Nonetag_import_none" value="2">None</option>
39073907 <option data-i18n="Alltag_import_all" value="3">All</option>
39083908 <option data-i18n="Existing" value="4">Existing</option>
39093909 </select>
39103910 </div>
@@ -4796,7 +4796,7 @@
47964796 <input multiple type="file" id="character_import_file" accept=".json, image/png, .yaml, .yml, .charx" name="avatar">
47974797 <input id="character_import_file_type" name="file_type" class="text_pole" maxlength="999" size="2" value="" autocomplete="off">
47984798 </form>
47994799 <input type="file" id="character_replace_file" accept=".json, image/png, .yaml, .yml, .charx" name="replace_avatar" hidden>
48004800 </div>
48014801 <div name="Character List Panel" id="rm_characters_block" class="right_menu">
48024802 <div id="charListFixedTop">
public/locales/ru-ru.json+11 -6
@@ -163,9 +163,9 @@
163163 "View hidden API keys": "Посмотреть скрытые API-ключи",
164164 "Advanced Formatting": "Расширенное форматирование",
165165 "Context Template": "Шаблон контекста",
166166 "Replace Macro in Custom Stopping Strings": "ЗаменитьЗаменять макросы в пользовательских стоп-строках",
167167 "Story String": "Строка истории",
168168 "Example Separator": "ПримерРазделитель разделителяпримеров сообщений",
169169 "Chat Start": "Начало чата",
170170 "Activation Regex": "Regex для активации",
171171 "Instruct Mode": "Режим Instruct",
@@ -187,7 +187,7 @@
187187 "Misc. Settings": "Доп. настройки",
188188 "Auto-Continue": "Авто-продолжение",
189189 "Collapse Consecutive Newlines": "Сворачивать последовательные новые строки",
190190 "Allow for Chat Completion APIs": "Разрешить для API Chat Completion API",
191191 "Target length (tokens)": "Целевая длина (в токенах)",
192192 "World Info": "Информация о мире",
193193 "Scan Depth": "Глубина сканирования",
@@ -377,7 +377,7 @@
377377 "Log prompts to console": "Выводить промпты в консоль",
378378 "Never resize avatars": "Не менять размер аватарок",
379379 "Show avatar filenames": "Показывать названия файлов аватарок",
380380 "Import Card Tags": "ИмпортИмпортировать теговтеги карточки",
381381 "Confirm message deletion": "Подтверждение удаления сообщений",
382382 "Spoiler Free Mode": "Режим без спойлеров",
383383 "Auto-swipe": "Автоматические свайпы",
@@ -1274,7 +1274,7 @@
12741274 "openrouter_force_instruct": "This option is outdated and will be removed in the future. To use instruct formatting, please switch to OpenRouter under Text Completion API instead.",
12751275 "Force_Instruct_Mode_formatting_Description": "If both Instruct Mode and this are enabled, the prompt will be formatted by SillyTavern using the current\n advanced formatting settings (except instruct System Prompt). If disabled, the prompt will be formatted by OpenRouter.",
12761276 "Clear your cookie": "Clear your cookie",
12771277 "Add Chat Start and Example Separator to a list of stopping strings.": "Add Chat StartИспользовать andНачало Exampleчата Separatorи toРазделитель aпримеров listсообщений ofв stoppingкачестве stringsстоп-строк.",
12781278 "context_allow_jailbreak": "Если в карточке есть джейлбрейк И ПРИ ЭТОМ включена опция \"Приоритет джейлбрейку из карточки персонажа\", то этот джейлбрейк добавляется в конец промпта.\nНЕ РЕКОМЕНДУЕТСЯ ДЛЯ МОДЕЛЕЙ TEXT COMPLETION, МОЖЕТ ПОРТИТЬ ВЫХОДНОЙ ТЕКСТ.",
12791279 "Context Order": "Context Order",
12801280 "Summary": "Summary",
@@ -1636,5 +1636,10 @@
16361636 "Change Password": "Сменить пароль",
16371637 "Reset Code:": "Reset Code:",
16381638 "Click _space": "Нажмите ",
16391639 "Alternate Greeting #": "Вариант #",
1640+ "Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "Выберите, какие действия следует предпринять по отношению к тегам импортируемой карточки. При выборе опции \"Спрашивать\" вы будете решать это индивидуально для каждой карточки.",
1641+ "Ask": "Спрашивать",
1642+ "tag_import_all": "Все",
1643+ "Existing": "Только существующие",
1644+ "tag_import_none": "Не импортировать"
16401645}
public/locales/zh-cn.json+2 -1
@@ -217,6 +217,7 @@
217217 "Character Names Behavior": "角色名称行为",
218218 "Helps the model to associate messages with characters.": "有助于模型将消息与角色关联起来。",
219219 "None": "无",
220+ "tag_import_none": "无",
220221 "character_names_none": "群聊和过去的角色除外。否则,请确保在提示词中提供了姓名。",
221222 "Don't add character names.": "不添加角色名称。",
222223 "Completion": "补全对象",
@@ -653,7 +654,7 @@
653654 "Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "定义在导入卡片时应选择哪种操作来导入其列出的标签。“询问”将始终显示对话框。",
654655 "Import Card Tags": "导入卡片标签",
655656 "Ask": "询问",
656657 "Alltag_import_all": "全部",
657658 "Existing": "现存的",
658659 "Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "使用模糊匹配,在列表中通过所有数据字段搜索角色,而不仅仅是名称子字符串",
659660 "Advanced Character Search": "高级角色搜索",
public/script.js+40 -32
@@ -43,7 +43,6 @@ import {
4343 saveGroupChat,
4444 getGroups,
4545 generateGroupWrapper,
46- deleteGroup,
4746 is_group_generating,
4847 resetSelectedGroup,
4948 select_group_chats,
@@ -228,7 +227,7 @@ import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, de
228227import { initPresetManager } from './scripts/preset-manager.js';
229228import { MacrosParser, evaluateMacros } from './scripts/macros.js';
230229import { currentUser, setUserControls } from './scripts/user.js';
231230import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js';
232231import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
233232import { ScraperManager } from './scripts/scrapers.js';
234233import { SlashCommandParser } from './scripts/slash-commands/SlashCommandParser.js';
@@ -266,8 +265,6 @@ await new Promise((resolve) => {
266265});
267266
268267showLoader();
269-// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
270-document.getElementById('preloader').remove();
271268
272269// Configure toast library:
273270toastr.options.escapeHtml = true; // Prevent raw HTML inserts
@@ -7829,6 +7826,8 @@ window['SillyTavern'].getContext = function () {
78297826 registerDataBankScraper: ScraperManager.registerDataBankScraper,
78307827 callPopup: callPopup,
78317828 callGenericPopup: callGenericPopup,
7829+ showLoader: showLoader,
7830+ hideLoader: hideLoader,
78327831 mainApi: main_api,
78337832 extensionSettings: extension_settings,
78347833 ModuleWorkerWrapper: ModuleWorkerWrapper,
@@ -7899,7 +7898,7 @@ function swipe_left() { // when we swipe left..but no generation.
78997898 }
79007899 $(this).parent().children('.mes_block').transition({
79017900 x: swipe_range,
79027901 duration: animation_duration > 0 ? swipe_duration : 0,
79037902 easing: animation_easing,
79047903 queue: false,
79057904 complete: async function () {
@@ -7942,7 +7941,7 @@ function swipe_left() { // when we swipe left..but no generation.
79427941 complete: function () {
79437942 $(this).parent().children('.mes_block').transition({
79447943 x: '0px',
79457944 duration: animation_duration > 0 ? swipe_duration : 0,
79467945 easing: animation_easing,
79477946 queue: false,
79487947 complete: async function () {
@@ -7957,7 +7956,7 @@ function swipe_left() { // when we swipe left..but no generation.
79577956
79587957 $(this).parent().children('.avatar').transition({
79597958 x: swipe_range,
79607959 duration: animation_duration > 0 ? swipe_duration : 0,
79617960 easing: animation_easing,
79627961 queue: false,
79637962 complete: function () {
@@ -7969,7 +7968,7 @@ function swipe_left() { // when we swipe left..but no generation.
79697968 complete: function () {
79707969 $(this).parent().children('.avatar').transition({
79717970 x: '0px',
79727971 duration: animation_duration > 0 ? swipe_duration : 0,
79737972 easing: animation_easing,
79747973 queue: false,
79757974 complete: function () {
@@ -8076,7 +8075,7 @@ const swipe_right = () => {
80768075 this_mes_div.children('.swipe_left').css('display', 'flex');
80778076 this_mes_div.children('.mes_block').transition({ // this moves the div back and forth
80788077 x: '-' + swipe_range,
80798078 duration: animation_duration > 0 ? swipe_duration : 0,
80808079 easing: animation_easing,
80818080 queue: false,
80828081 complete: async function () {
@@ -8135,7 +8134,7 @@ const swipe_right = () => {
81358134 complete: function () {
81368135 this_mes_div.children('.mes_block').transition({
81378136 x: '0px',
81388137 duration: animation_duration > 0 ? swipe_duration : 0,
81398138 easing: animation_easing,
81408139 queue: false,
81418140 complete: async function () {
@@ -8158,7 +8157,7 @@ const swipe_right = () => {
81588157 });
81598158 this_mes_div.children('.avatar').transition({ // moves avatar along with swipe
81608159 x: '-' + swipe_range,
81618160 duration: animation_duration > 0 ? swipe_duration : 0,
81628161 easing: animation_easing,
81638162 queue: false,
81648163 complete: function () {
@@ -8170,7 +8169,7 @@ const swipe_right = () => {
81708169 complete: function () {
81718170 this_mes_div.children('.avatar').transition({
81728171 x: '0px',
81738172 duration: animation_duration > 0 ? swipe_duration : 0,
81748173 easing: animation_easing,
81758174 queue: false,
81768175 complete: function () {
@@ -8450,10 +8449,10 @@ async function connectAPISlash(_, text) {
84508449/**
84518450 * Imports supported files dropped into the app window.
84528451 * @param {File[]} files Array of files to process
84538452 * @param {boolean?Map<File, string>} preserveFileNames[data] WhetherExtra data to preservepass originalto filethe namesimport function
84548453 * @returns {Promise<void>}
84558454 */
84568455export async function processDroppedFiles(files, preserveFileNamesdata = falsenew Map()) {
84578456 const allowedMimeTypes = [
84588457 'application/json',
84598458 'image/png',
@@ -8470,7 +8469,8 @@ export async function processDroppedFiles(files, preserveFileNames = false) {
84708469 for (const file of files) {
84718470 const extension = file.name.split('.').pop().toLowerCase();
84728471 if (allowedMimeTypes.includes(file.type) || allowedExtensions.includes(extension)) {
8473- await importCharacter(file, preserveFileNames);
8472+ const preservedName = data instanceof Map && data.get(file);
8473+ await importCharacter(file, preservedName);
84748474 } else {
84758475 toastr.warning('Unsupported file type: ' + file.name);
84768476 }
@@ -8480,10 +8480,10 @@ export async function processDroppedFiles(files, preserveFileNames = false) {
84808480/**
84818481 * Imports a character from a file.
84828482 * @param {File} file File to import
84838483 * @param {booleanstring?} preserveFileName Whether to preserve original file name
84848484 * @returns {Promise<void>}
84858485 */
84868486async function importCharacter(file, preserveFileName = false'') {
84878487 if (is_group_generating || is_send_press) {
84888488 toastr.error('Cannot import characters while generating. Stop the request and try again.', 'Import aborted');
84898489 throw new Error('Cannot import character while generating');
@@ -8499,7 +8499,7 @@ async function importCharacter(file, preserveFileName = false) {
84998499 const formData = new FormData();
85008500 formData.append('avatar', file);
85018501 formData.append('file_type', format);
85028502 if (preserveFileName) formData.append('preserve_file_namepreserved_name', String(preserveFileName));
85038503
85048504 const data = await jQuery.ajax({
85058505 type: 'POST',
@@ -9170,14 +9170,26 @@ jQuery(async function () {
91709170 chooseBogusFolder($(this), tagId);
91719171 });
91729172
9173- $(document).on('input', '.edit_textarea', function () {
9173+ /**
9174- scroll_holder = $('#chat').scrollTop();
9174+ * Sets the scroll height of the edit textarea to fit the content.
9175- $(this).height(0).height(this.scrollHeight);
9175+ * @param {HTMLTextAreaElement} e Textarea element to auto-fit
9176+ */
9177+ function autoFitEditTextArea(e) {
9178+ scroll_holder = chatElement[0].scrollTop;
9179+ e.style.height = '0';
9180+ e.style.height = `${e.scrollHeight + 4}px`;
91769181 is_use_scroll_holder = true;
9182+ }
9183+ const autoFitEditTextAreaDebounced = debounce(autoFitEditTextArea, debounce_timeout.short);
9184+ document.addEventListener('input', e => {
9185+ if (e.target instanceof HTMLTextAreaElement && e.target.classList.contains('edit_textarea')) {
9186+ const immediately = e.target.scrollHeight > e.target.offsetHeight || e.target.value === '';
9187+ immediately ? autoFitEditTextArea(e.target) : autoFitEditTextAreaDebounced(e.target);
9188+ }
91779189 });
91789190 $document.getElementById('#chat').onaddEventListener('scroll', function () {
91799191 if (is_use_scroll_holder) {
9180- $('#chat').scrollTop(scroll_holder);
9192+ this.scrollTop = scroll_holder;
91819193 is_use_scroll_holder = false;
91829194 }
91839195 });
@@ -9709,14 +9721,8 @@ jQuery(async function () {
97099721 });
97109722
97119723 $('#newChatFromManageScreenButton').on('click', function () {
9712- setTimeout(() => {
9724+ doNewChat({ deleteCurrentChat: false });
9713- $('#option_start_new_chat').trigger('click');
9714- }, 1);
9715- setTimeout(() => {
9716- $('#dialogue_popup_ok').trigger('click');
9717- }, 1);
97189725 $('#select_chat_cross').trigger('click');
9719-
97209726 });
97219727
97229728 //////////////////////////////////////////////////////////////////////////////////////////////
@@ -10632,10 +10638,12 @@ jQuery(async function () {
1063210638 }
1063310639
1063410640 try {
10635- const cloneFile = new File([file], characters[this_chid].avatar, { type: file.type });
1063610641 const chatFile = characters[this_chid]['chat'];
10637- await processDroppedFiles([cloneFile], true);
10642+ const data = new Map();
10643+ data.set(file, characters[this_chid].avatar);
10644+ await processDroppedFiles([file], data);
1063810645 await openCharacterChat(chatFile);
10646+ await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'no-cache' });
1063910647 } catch {
1064010648 toastr.error('Failed to replace the character card.', 'Something went wrong');
1064110649 }
public/scripts/RossAscends-mods.js+3 -1
@@ -725,7 +725,9 @@ export function initRossMods() {
725725 RA_autoconnect();
726726 }
727727
728- if (getParsedUA()?.os?.name === 'iOS') {
728+ const isAppleDevice = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
729+
730+ if (getParsedUA()?.os?.name === 'iOS' || isAppleDevice) {
729731 document.body.classList.add('ios');
730732 }
731733
public/scripts/chats.js+7 -1
@@ -1526,7 +1526,13 @@ jQuery(function () {
15261526 const fileInput = document.getElementById('file_form_input');
15271527 if (!(fileInput instanceof HTMLInputElement)) return;
15281528
1529- fileInput.files = event.clipboardData.files;
1529+ // Workaround for Firefox: Use a DataTransfer object to indirectly set fileInput.files
1530+ const dataTransfer = new DataTransfer();
1531+ for (let i = 0; i < event.clipboardData.files.length; i++) {
1532+ dataTransfer.items.add(event.clipboardData.files[i]);
1533+ }
1534+
1535+ fileInput.files = dataTransfer.files;
15301536 await onFileAttach(fileInput.files[0]);
15311537 });
15321538});
public/scripts/constants.js+2 -0
@@ -5,6 +5,8 @@
55export const debounce_timeout = {
66 /** [100 ms] For ultra-fast responses, typically for keypresses or executions that might happen multiple times in a loop or recursion. */
77 quick: 100,
8+ /** [200 ms] Slightly slower than quick, but still very responsive. */
9+ short: 200,
810 /** [300 ms] Default time for general use, good balance between responsiveness and performance. */
911 standard: 300,
1012 /** [1.000 ms] For situations where the function triggers more intensive tasks. */
public/scripts/dynamic-styles.js+1 -1
@@ -154,7 +154,7 @@ export function initDynamicStyles() {
154154 // Process all stylesheets on initial load
155155 Array.from(document.styleSheets).forEach(sheet => {
156156 try {
157157 applyDynamicFocusStyles(sheet, { fromExtension: sheet.href?.toLowerCase().includes('scripts/extensions') == true });
158158 } catch (e) {
159159 console.warn('Failed to process stylesheet on initial load:', e);
160160 }
public/scripts/extensions.js+9 -2
@@ -374,7 +374,7 @@ async function addExtensionsButtonAndMenu() {
374374
375375 $('html').on('click', function (e) {
376376 const clickTarget = $(e.target);
377377 const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice'];
378378 if (dropdown.is(':visible') && !noCloseTargets.some(id => clickTarget.closest(id).length > 0)) {
379379 $(dropdown).fadeOut(animation_duration);
380380 }
@@ -641,9 +641,16 @@ async function showExtensionsDetails() {
641641 action: async () => {
642642 requiresReload = true;
643643 await autoUpdateExtensions(true);
644644 await popup.complete(POPUP_RESULT.AFFIRMATIVE);
645645 },
646646 };
647+
648+ // If we are updating an extension, the "old" popup is still active. We should close that.
649+ const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info'));
650+ if (oldPopup) {
651+ await oldPopup.complete(POPUP_RESULT.CANCELLED);
652+ }
653+
647654 const popup = new Popup(`<div class="extensions_info">${html}</div>`, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, customButtons: [updateAllButton], allowVerticalScrolling: true });
648655 popupPromise = popup.show();
649656 } catch (error) {
public/scripts/extensions/caption/index.js+5 -0
@@ -520,12 +520,17 @@ jQuery(async function () {
520520 const messageImg = messageBlock.find('.mes_img');
521521 if (messageImg.hasClass(animationClass)) return;
522522 messageImg.addClass(animationClass);
523+ try {
523524 const index = Number(messageBlock.attr('mesid'));
524525 const data = getContext().chat[index];
525526 await captionExistingMessage(data);
526527 appendMediaToMessage(data, messageBlock, false);
527528 await saveChatConditional();
529+ } catch(e) {
530+ console.error('Message image recaption failed', e);
531+ } finally {
528532 messageImg.removeClass(animationClass);
533+ }
529534 });
530535
531536 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'caption',
public/scripts/extensions/stable-diffusion/index.js+15 -17
@@ -3,7 +3,6 @@ import {
33 systemUserName,
44 hideSwipeButtons,
55 showSwipeButtons,
6- callPopup,
76 getRequestHeaders,
87 event_types,
98 eventSource,
@@ -29,10 +28,9 @@ import { getMultimodalCaption } from '../shared.js';
2928import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
3029import { SlashCommand } from '../../slash-commands/SlashCommand.js';
3130import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
32-import { resolveVariable } from '../../variables.js';
3331import { debounce_timeout } from '../../constants.js';
34-import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
3532import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
33+import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
3634export { MODULE_NAME };
3735
3836const MODULE_NAME = 'sd';
@@ -572,7 +570,7 @@ async function onDeleteStyleClick() {
572570 return;
573571 }
574572
575573 const confirmed = await callPopupcallGenericPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, 'confirm'POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
576574
577575 if (!confirmed) {
578576 return;
@@ -601,7 +599,7 @@ async function onDeleteStyleClick() {
601599}
602600
603601async function onSaveStyleClick() {
604602 const userInput = await callPopupcallGenericPopup('Enter style name:', 'input', '', { okButton: 'Save' }POPUP_TYPE.INPUT);
605603
606604 if (!userInput) {
607605 return;
@@ -670,7 +668,7 @@ async function refinePrompt(prompt, allowExpand, isNegative = false) {
670668
671669 if (extension_settings.sd.refine_mode) {
672670 const text = isNegative ? '<h3>Review and edit the <i>negative</i> prompt:</h3>' : '<h3>Review and edit the prompt:</h3>';
673671 const refinedPrompt = await callPopupcallGenericPopup(text + 'Press "Cancel" to abort the image generation.', 'input'POPUP_TYPE.INPUT, prompt.trim(), { rows: 5, okButton: 'Continue' });
674672
675673 if (refinedPrompt) {
676674 return refinedPrompt;
@@ -2918,25 +2916,25 @@ async function generateComfyImage(prompt, negativePrompt) {
29182916 const text = await workflowResponse.text();
29192917 toastr.error(`Failed to load workflow.\n\n${text}`);
29202918 }
29212919 let workflow = (await workflowResponse.json()).replacereplaceAll('"%prompt%"', JSON.stringify(prompt));
29222920 workflow = workflow.replacereplaceAll('"%negative_prompt%"', JSON.stringify(negativePrompt));
29232921
29242922 const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
29252923 workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));
29262924 placeholders.forEach(ph => {
29272925 workflow = workflow.replacereplaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
29282926 });
29292927 (extension_settings.sd.comfy_placeholders ?? []).forEach(ph => {
29302928 workflow = workflow.replacereplaceAll(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));
29312929 });
29322930 if (/%user_avatar%/gi.test(workflow)) {
29332931 const response = await fetch(getUserAvatarUrl());
29342932 if (response.ok) {
29352933 const avatarBlob = await response.blob();
29362934 const avatarBase64 = await getBase64Async(avatarBlob);
29372935 workflow = workflow.replacereplaceAll('"%user_avatar%"', JSON.stringify(avatarBase64));
29382936 } else {
29392937 workflow = workflow.replacereplaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
29402938 }
29412939 }
29422940 if (/%char_avatar%/gi.test(workflow)) {
@@ -2944,9 +2942,9 @@ async function generateComfyImage(prompt, negativePrompt) {
29442942 if (response.ok) {
29452943 const avatarBlob = await response.blob();
29462944 const avatarBase64 = await getBase64Async(avatarBlob);
29472945 workflow = workflow.replacereplaceAll('"%char_avatar%"', JSON.stringify(avatarBase64));
29482946 } else {
29492947 workflow = workflow.replacereplaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
29502948 }
29512949 }
29522950 console.log(`{
@@ -2978,7 +2976,7 @@ async function onComfyOpenWorkflowEditorClick() {
29782976 }),
29792977 })).json();
29802978 const editorHtml = $(await $.get('scripts/extensions/stable-diffusion/comfyWorkflowEditor.html'));
29812979 const popupResult = callPopupcallGenericPopup(editorHtml, 'confirm'POPUP_TYPE.CONFIRM, undefined'', { okButton: 'Save', widecancelButton: true'Cancel', largewide: true, rowslarge: 1true });
29822980 const checkPlaceholders = () => {
29832981 workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
29842982 $('.sd_comfy_workflow_editor_placeholder_list > li[data-placeholder]').each(function (idx) {
@@ -3058,7 +3056,7 @@ async function onComfyOpenWorkflowEditorClick() {
30583056}
30593057
30603058async function onComfyNewWorkflowClick() {
30613059 let name = await callPopupcallGenericPopup('<h3>Workflow name:</h3>', 'input'POPUP_TYPE.INPUT);
30623060 if (!name) {
30633061 return;
30643062 }
@@ -3085,7 +3083,7 @@ async function onComfyNewWorkflowClick() {
30853083}
30863084
30873085async function onComfyDeleteWorkflowClick() {
30883086 const confirm = await callPopupcallGenericPopup('Delete the workflow? This action is irreversible.', POPUP_TYPE.CONFIRM, 'confirm', { okButton: 'Delete', cancelButton: 'Cancel' });
30893087 if (!confirm) {
30903088 return;
30913089 }
public/scripts/extensions/tts/azure.js+4 -3
@@ -1,4 +1,5 @@
11import { callPopup, getRequestHeaders } from '../../../script.js';
2+import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
23import { SECRET_KEYS, findSecret, secret_state, writeSecret } from '../../secrets.js';
34import { getPreviewString, saveTtsProviderSettings } from './index.js';
45export { AzureTtsProvider };
@@ -69,13 +70,13 @@ class AzureTtsProvider {
6970 const popupText = 'Azure TTS API Key';
7071 const savedKey = secret_state[SECRET_KEYS.AZURE_TTS] ? await findSecret(SECRET_KEYS.AZURE_TTS) : '';
7172
7273 const key = await callPopupcallGenericPopup(popupText, 'input'POPUP_TYPE.INPUT, savedKey);
7374
7475 if (key == false || key == '') {
7576 return;
7677 }
7778
7879 await writeSecret(SECRET_KEYS.AZURE_TTS, String(key));
7980
8081 toastr.success('API Key saved');
8182 $('#azure_tts_key').addClass('success');
public/scripts/extensions/tts/coqui.js+2 -2
@@ -5,8 +5,8 @@ TODO:
55*/
66
77import { doExtrasFetch, extension_settings, getApiUrl, modules } from '../../extensions.js';
8-import { callPopup } from '../../../script.js';
98import { initVoiceMap } from './index.js';
9+import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
1010
1111export { CoquiTtsProvider };
1212
@@ -246,7 +246,7 @@ class CoquiTtsProvider {
246246 }
247247
248248 // Ask user for voiceId name to save voice
249249 const voiceName = await callPopupcallGenericPopup('<h3>Name of Coqui voice to add to voice select dropdown:</h3>', 'input'POPUP_TYPE.INPUT);
250250
251251 const model_origin = $('#coqui_model_origin').val();
252252 const model_language = $('#coqui_api_language').val();
public/scripts/extensions/tts/novel.js+3 -2
@@ -1,4 +1,5 @@
11import { getRequestHeaders, callPopup } from '../../../script.js';
2+import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
23import { splitRecursive } from '../../utils.js';
34import { getPreviewString, saveTtsProviderSettings } from './index.js';
45import { initVoiceMap } from './index.js';
@@ -56,7 +57,7 @@ class NovelTtsProvider {
5657
5758 // Add a new Novel custom voice to provider
5859 async addCustomVoice() {
5960 const voiceName = await callPopupcallGenericPopup('<h3>Custom Voice name:</h3>', 'input' POPUP_TYPE.INPUT);
6061 this.settings.customVoices.push(voiceName);
6162 this.populateCustomVoices();
6263 initVoiceMap(); // Update TTS extension voiceMap
public/scripts/group-chats.js+31 -5
@@ -178,8 +178,37 @@ async function loadGroupChat(chatId) {
178178 return [];
179179}
180180
181+async function validateGroup(group) {
182+ if (!group) return;
183+
184+ // Validate that all members exist as characters
185+ let dirty = false;
186+ group.members = group.members.filter(member => {
187+ const character = characters.find(x => x.avatar === member || x.name === member);
188+ if (!character) {
189+ const msg = `Warning: Listed member ${member} does not exist as a character. It will be removed from the group.`;
190+ toastr.warning(msg, 'Group Validation');
191+ console.warn(msg);
192+ dirty = true;
193+ }
194+ return character;
195+ });
196+
197+ if (dirty) {
198+ await editGroup(group.id, true, false);
199+ }
200+}
201+
181202export async function getGroupChat(groupId, reload = false) {
182203 const group = groups.find((x) => x.id === groupId);
204+ if (!group) {
205+ console.warn('Group not found', groupId);
206+ return;
207+ }
208+
209+ // Run validation before any loading
210+ validateGroup(group);
211+
183212 const chat_id = group.chat_id;
184213 const data = await loadGroupChat(chat_id);
185214 let freshChat = false;
@@ -197,7 +226,6 @@ export async function getGroupChat(groupId, reload = false) {
197226 if (group && Array.isArray(group.members)) {
198227 for (let member of group.members) {
199228 const character = characters.find(x => x.avatar === member || x.name === member);
200-
201229 if (!character) {
202230 continue;
203231 }
@@ -219,10 +247,8 @@ export async function getGroupChat(groupId, reload = false) {
219247 freshChat = true;
220248 }
221249
222- if (group) {
223250 let metadata = group.chat_metadata ?? {};
224251 updateChatMetadata(metadata, true);
225- }
226252
227253 if (reload) {
228254 select_group_chats(groupId, true);
@@ -1576,6 +1602,8 @@ export async function openGroupById(groupId) {
15761602 }
15771603
15781604 if (!is_send_press && !is_group_generating) {
1605+ select_group_chats(groupId);
1606+
15791607 if (selected_group !== groupId) {
15801608 await clearChat();
15811609 cancelTtsPlay();
@@ -1587,8 +1615,6 @@ export async function openGroupById(groupId) {
15871615 chat.length = 0;
15881616 await getGroupChat(groupId);
15891617 }
1590-
1591- select_group_chats(groupId);
15921618 }
15931619}
15941620
public/scripts/loader.js+40 -8
@@ -1,19 +1,44 @@
1+import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
2+
13const ELEMENT_ID = 'loader';
24
5+/** @type {Popup} */
6+let loaderPopup;
7+
8+let preloaderYoinked = false;
9+
310export function showLoader() {
4- const container = $('<div></div>').attr('id', ELEMENT_ID);
11+ // Two loaders don't make sense. Don't await, we can overlay the old loader while it closes
5- const loader = $('<div></div>').attr('id', 'load-spinner').addClass('fa-solid fa-gear fa-spin fa-3x');
12+ if (loaderPopup) loaderPopup.complete(POPUP_RESULT.CANCELLED);
6- container.append(loader);
13+
7- $('body').append(container);
14+ loaderPopup = new Popup(`
15+ <div id="loader">
16+ <div id="load-spinner" class="fa-solid fa-gear fa-spin fa-3x"></div>
17+ </div>`, POPUP_TYPE.DISPLAY, null, { transparent: true, animation: 'none' });
18+
19+ // No close button, loaders are not closable
20+ loaderPopup.closeButton.style.display = 'none';
21+
22+ loaderPopup.show();
823}
924
1025export async function hideLoader() {
11- //Sets up a 2-step animation. Spinner blurs/fades out, and then the loader shadow does the same.
26+ if (!loaderPopup) {
27+ console.warn('There is no loader showing to hide');
28+ return Promise.resolve();
29+ }
30+
31+ return new Promise((resolve) => {
32+ // Spinner blurs/fades out
1233 $('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
13- $(`#${ELEMENT_ID}`)
14- //only fade out the spinner and replace with login screen
15- .animate({ opacity: 0 }, 300, function () {
1634 $(`#${ELEMENT_ID}`).remove();
35+ // Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
36+ // If it's present, we remove it once and then it's gone.
37+ yoinkPreloader();
38+
39+ loaderPopup.complete(POPUP_RESULT.AFFIRMATIVE).then(() => {
40+ loaderPopup = null;
41+ resolve();
1742 });
1843 });
1944
@@ -22,4 +47,11 @@ export async function hideLoader() {
2247 'filter': 'blur(15px)',
2348 'opacity': '0',
2449 });
50+ });
51+}
52+
53+function yoinkPreloader() {
54+ if (preloaderYoinked) return;
55+ document.getElementById('preloader').remove();
56+ preloaderYoinked = true;
2557}
public/scripts/macros.js+2 -2
@@ -254,7 +254,7 @@ function getCurrentSwipeId() {
254254 // For swipe macro, we are accepting using the message that is currently being swiped
255255 const mid = getLastMessageId({ exclude_swipe_in_propress: false });
256256 const swipeId = chat[mid]?.swipe_id;
257257 return swipeId !== null ? swipeId + 1 : null;
258258}
259259
260260/**
@@ -401,7 +401,7 @@ function timeDiffReplace(input) {
401401 const time2 = moment(matchPart2);
402402
403403 const timeDifference = moment.duration(time1.diff(time2));
404404 return timeDifference.humanize(true);
405405 });
406406
407407 return output;
public/scripts/openai.js+1 -1
@@ -689,7 +689,7 @@ function formatWorldInfo(value) {
689689 return '';
690690 }
691691
692692 if (!oai_settings.wi_format.trim()) {
693693 return value;
694694 }
695695
public/scripts/popup.js+32 -12
@@ -1,3 +1,4 @@
1+import { shouldSendOnEnter } from './RossAscends-mods.js';
12import { power_user } from './power-user.js';
23import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
34
@@ -35,6 +36,7 @@ export const POPUP_RESULT = {
3536 * @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content)
3637 * @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup
3738 * @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup
39+ * @property {'slow'|'fast'|'none'?} [animation='slow'] - Animation speed for the popup (opening, closing, ...)
3840 * @property {POPUP_RESULT|number?} [defaultResult=POPUP_RESULT.AFFIRMATIVE] - The default result of this popup when Enter is pressed. Can be changed from `POPUP_RESULT.AFFIRMATIVE`.
3941 * @property {CustomPopupButton[]|string[]?} [customButtons=null] - Custom buttons to add to the popup. If only strings are provided, the buttons will be added with default options, and their result will be in order from `2` onward.
4042 * @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.
@@ -98,7 +100,7 @@ const showPopupHelper = {
98100 const result = await popup.show();
99101 if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);
100102 return result;
101103 },
102104};
103105
104106export class Popup {
@@ -142,7 +144,7 @@ export class Popup {
142144 * @param {string} [inputValue=''] - The initial value of the input field
143145 * @param {PopupOptions} [options={}] - Additional options for the popup
144146 */
145147 constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, animation = 'fast', defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {
146148 Popup.util.popups.push(this);
147149
148150 // Make this popup uniquely identifiable
@@ -175,6 +177,7 @@ export class Popup {
175177 if (transparent) this.dlg.classList.add('transparent_dialogue_popup');
176178 if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');
177179 if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup');
180+ if (animation) this.dlg.classList.add('popup--animation-' + animation);
178181
179182 // If custom button captions are provided, we set them beforehand
180183 this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK';
@@ -210,7 +213,7 @@ export class Popup {
210213 this.customInputs = customInputs;
211214 this.customInputs?.forEach(input => {
212215 if (!input.id || !(typeof input.id === 'string')) {
213216 console.warn('Given custom input does not have a valid id set');
214217 return;
215218 }
216219
@@ -318,20 +321,20 @@ export class Popup {
318321 if (String(undefined) === String(resultControl.dataset.result)) return;
319322 if (isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result);
320323 const type = resultControl.dataset.resultEvent || 'click';
321324 resultControl.addEventListener(type, async () => await this.complete(result));
322325 });
323326
324327 // Bind dialog listeners manually, so we can be sure context is preserved
325328 const cancelListener = async (evt) => {
326- this.complete(POPUP_RESULT.CANCELLED);
327329 evt.preventDefault();
328330 evt.stopPropagation();
331+ await this.complete(POPUP_RESULT.CANCELLED);
329332 window.removeEventListener('cancel', cancelListenerBound);
330333 };
331334 const cancelListenerBound = cancelListener.bind(this);
332335 this.dlg.addEventListener('cancel', cancelListenerBound);
333336
334337 const keyListener = async (evt) => {
335338 switch (evt.key) {
336339 case 'Enter': {
337340 // CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this
@@ -342,15 +345,23 @@ export class Popup {
342345 if (this.dlg != document.activeElement?.closest('.popup'))
343346 return;
344347
345348 // Check if the current focus is a result control. Only should we apply the compeletecomplete action
346349 const resultControl = document.activeElement?.closest('.result-control');
347350 if (!resultControl)
348351 return;
349352
350- const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
353+ // Check if we are inside an input type text or a textarea field and send on enter is disabled
351- this.complete(result);
354+ const textarea = document.activeElement?.closest('textarea');
355+ if (textarea instanceof HTMLTextAreaElement && !shouldSendOnEnter())
356+ return;
357+ const input = document.activeElement?.closest('input[type="text"]');
358+ if (input instanceof HTMLInputElement && !shouldSendOnEnter())
359+ return;
360+
352361 evt.preventDefault();
353362 evt.stopPropagation();
363+ const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
364+ await this.complete(result);
354365 window.removeEventListener('keydown', keyListenerBound);
355366
356367 break;
@@ -430,8 +441,10 @@ export class Popup {
430441 * - All other will return the result value as provided as `POPUP_RESULT` or a custom number value
431442 *
432443 * @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value)
444+ *
445+ * @returns {Promise<string|number|boolean?>} A promise that resolves with the value of the popup when it is completed.
433446 */
434447 async complete(result) {
435448 // In all cases besides INPUT the popup value should be the result
436449 /** @type {POPUP_RESULT|number|boolean|string?} */
437450 let value = result;
@@ -468,6 +481,8 @@ export class Popup {
468481
469482 Popup.util.lastResult = { value, result, inputResults: this.inputResults };
470483 this.#hide();
484+
485+ return this.#promise;
471486 }
472487
473488 /**
@@ -618,8 +633,13 @@ export function fixToastrForDialogs() {
618633 // Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists,
619634 // but we don't have an open dialog to move it into. It's just inside the existing one that will be gone in milliseconds.
620635 // To prevent new toasts from being showing up in there and then vanish in an instant,
621636 // we move the toastr back to the main body, or delete if its empty
622637 if (!dlg && isAlreadyPresent) {
638+ if (!toastContainer.childNodes.length) {
639+ toastContainer.remove();
640+ } else {
623641 document.body.appendChild(toastContainer);
642+ toastContainer.classList.add('toast-top-center');
643+ }
624644 }
625645}
public/scripts/slash-commands.js+4 -4
@@ -468,7 +468,7 @@ export function initDefaultSlashCommands() {
468468 description: 'display name',
469469 typeList: [ARGUMENT_TYPE.STRING],
470470 defaultValue: '{{user}}',
471471 enumProvider: commonEnumProviders.characters('character')personas,
472472 }),
473473 ],
474474 unnamedArgumentList: [
@@ -1653,8 +1653,8 @@ async function buttonsCallback(args, text) {
16531653 const buttonElement = document.createElement('div');
16541654 buttonElement.classList.add('menu_button', 'result-control', 'wide100p');
16551655 buttonElement.dataset.result = String(result);
16561656 buttonElement.addEventListener('click', async () => {
16571657 await popup?.complete(result);
16581658 });
16591659 buttonElement.innerText = button;
16601660 buttonContainer.appendChild(buttonElement);
@@ -2762,7 +2762,7 @@ export async function sendMessageAs(args, text) {
27622762 const isSystem = bias && !removeMacros(mesText).length;
27632763 const compact = isTrueBoolean(args?.compact);
27642764
27652765 const character = characters.find(x => x.avatar === name) ?? characters.find(x => x.name === name);
27662766 let force_avatar, original_avatar;
27672767
27682768 if (character && character.avatar !== 'none') {
public/scripts/sse-stream.js+1 -1
@@ -160,7 +160,7 @@ async function* parseStreamData(json) {
160160 return;
161161 }
162162 // llama.cpp?
163163 else if (typeof json.content === 'string' && json.content.length > 0 && json.object !== 'chat.completion.chunk') {
164164 for (let i = 0; i < json.content.length; i++) {
165165 const str = json.content[i];
166166 yield {
public/scripts/textgen-settings.js+13 -6
@@ -263,6 +263,8 @@ export const setting_names = [
263263 'bypass_status_check',
264264];
265265
266+const DYNATEMP_BLOCK = document.getElementById('dynatemp_block_ooba');
267+
266268export function validateTextGenUrl() {
267269 const selector = SERVER_INPUTS[settings.type];
268270
@@ -1045,6 +1047,10 @@ export function isJsonSchemaSupported() {
10451047 return [TABBY, LLAMACPP].includes(settings.type) && main_api === 'textgenerationwebui';
10461048}
10471049
1050+function isDynamicTemperatureSupported() {
1051+ return settings.dynatemp && DYNATEMP_BLOCK?.dataset?.tgType?.includes(settings.type);
1052+}
1053+
10481054function getLogprobsNumber() {
10491055 if (settings.type === VLLM || settings.type === INFERMATICAI) {
10501056 return 5;
@@ -1055,6 +1061,7 @@ function getLogprobsNumber() {
10551061
10561062export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) {
10571063 const canMultiSwipe = !isContinue && !isImpersonate && type !== 'quiet';
1064+ const dynatemp = isDynamicTemperatureSupported();
10581065 const { banned_tokens, banned_strings } = getCustomTokenBans();
10591066
10601067 let params = {
@@ -1063,7 +1070,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
10631070 'max_new_tokens': maxTokens,
10641071 'max_tokens': maxTokens,
10651072 'logprobs': power_user.request_token_probabilities ? getLogprobsNumber() : undefined,
10661073 'temperature': settings.dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp,
10671074 'top_p': settings.top_p,
10681075 'typical_p': settings.typical_p,
10691076 'typical': settings.typical_p,
@@ -1081,11 +1088,11 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
10811088 'length_penalty': settings.length_penalty,
10821089 'early_stopping': settings.early_stopping,
10831090 'add_bos_token': settings.add_bos_token,
10841091 'dynamic_temperature': settings.dynatemp ? true : undefined,
10851092 'dynatemp_low': settings.dynatemp ? settings.min_temp : undefined,
10861093 'dynatemp_high': settings.dynatemp ? settings.max_temp : undefined,
10871094 'dynatemp_range': settings.dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined,
10881095 'dynatemp_exponent': settings.dynatemp ? settings.dynatemp_exponent : undefined,
10891096 'smoothing_factor': settings.smoothing_factor,
10901097 'smoothing_curve': settings.smoothing_curve,
10911098 'dry_allowed_length': settings.dry_allowed_length,
public/scripts/utils.js+1 -1
@@ -803,7 +803,7 @@ export function getImageSizeFromDataURL(dataUrl) {
803803
804804export function getCharaFilename(chid) {
805805 const context = getContext();
806806 const fileName = context.characters[chid ?? context.characterId]?.avatar;
807807
808808 if (fileName) {
809809 return fileName.replace(/\.[^/.]+$/, '');
public/scripts/variables.js+5 -1
@@ -1358,7 +1358,11 @@ export function registerVariableCommands() {
13581358 name: 'times',
13591359 callback: timesCallback,
13601360 returns: 'result of the last executed command',
13611361 namedArgumentList: [],
1362+ new SlashCommandNamedArgument(
1363+ 'guard', 'disable loop iteration limit', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOff')(),
1364+ ),
1365+ ],
13621366 unnamedArgumentList: [
13631367 new SlashCommandArgument(
13641368 'repeats',
src/endpoints/characters.js+17 -10
@@ -499,15 +499,16 @@ function convertWorldInfoToCharacterBook(name, entries) {
499499 * Import a character from a YAML file.
500500 * @param {string} uploadPath Path to the uploaded file
501501 * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
502+ * @param {string|undefined} preservedFileName Preserved file name
502503 * @returns {Promise<string>} Internal name of the character
503504 */
504505async function importFromYaml(uploadPath, context, preservedFileName) {
505506 const fileText = fs.readFileSync(uploadPath, 'utf8');
506507 fs.rmSync(uploadPath);
507508 const yamlData = yaml.parse(fileText);
508509 console.log('Importing from YAML');
509510 yamlData.name = sanitize(yamlData.name);
510511 const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories);
511512 let char = convertToV2({
512513 'name': yamlData.name,
513514 'description': yamlData.context ?? '',
@@ -532,9 +533,10 @@ async function importFromYaml(uploadPath, context) {
532533 * @param {string} uploadPath
533534 * @param {object} params
534535 * @param {import('express').Request} params.request
536+ * @param {string|undefined} preservedFileName Preserved file name
535537 * @returns {Promise<string>} Internal name of the character
536538 */
537539async function importFromCharX(uploadPath, { request }, preservedFileName) {
538540 const data = fs.readFileSync(uploadPath);
539541 fs.rmSync(uploadPath);
540542 console.log('Importing from CharX');
@@ -567,7 +569,7 @@ async function importFromCharX(uploadPath, { request }) {
567569 unsetFavFlag(card);
568570 card['create_date'] = humanizedISO8601DateTime();
569571 card.name = sanitize(card.name);
570572 const fileName = preservedFileName || getPngName(card.name, request.user.directories);
571573 const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);
572574 return result ? fileName : '';
573575}
@@ -576,9 +578,10 @@ async function importFromCharX(uploadPath, { request }) {
576578 * Import a character from a JSON file.
577579 * @param {string} uploadPath Path to the uploaded file
578580 * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
581+ * @param {string|undefined} preservedFileName Preserved file name
579582 * @returns {Promise<string>} Internal name of the character
580583 */
581584async function importFromJson(uploadPath, { request }, preservedFileName) {
582585 const data = fs.readFileSync(uploadPath, 'utf8');
583586 fs.unlinkSync(uploadPath);
584587
@@ -590,7 +593,7 @@ async function importFromJson(uploadPath, { request }) {
590593 unsetFavFlag(jsonData);
591594 jsonData = readFromV2(jsonData);
592595 jsonData['create_date'] = humanizedISO8601DateTime();
593596 const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
594597 const char = JSON.stringify(jsonData);
595598 const result = await writeCharacterData(defaultAvatarPath, char, pngName, request);
596599 return result ? pngName : '';
@@ -600,7 +603,7 @@ async function importFromJson(uploadPath, { request }) {
600603 if (jsonData.creator_notes) {
601604 jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
602605 }
603606 const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
604607 let char = {
605608 'name': jsonData.name,
606609 'description': jsonData.description ?? '',
@@ -626,7 +629,7 @@ async function importFromJson(uploadPath, { request }) {
626629 if (jsonData.creator_notes) {
627630 jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
628631 }
629632 const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories);
630633 let char = {
631634 'name': jsonData.char_name,
632635 'description': jsonData.char_persona ?? '',
@@ -1089,8 +1092,8 @@ function getPngName(file, directories) {
10891092 * @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined
10901093 */
10911094function getPreservedName(request) {
10921095 return typeof request.body.file_typepreserved_name === 'pngstring' && request.body.preserve_file_name === 'true'preserved_name.length &&> request.file?.originalname0
10931096 ? path.parse(request.filebody.originalnamepreserved_name).name
10941097 : undefined;
10951098}
10961099
@@ -1123,6 +1126,10 @@ router.post('/import', urlencodedParser, async function (request, response) {
11231126 return response.sendStatus(400);
11241127 }
11251128
1129+ if (preservedFileName) {
1130+ invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`);
1131+ }
1132+
11261133 response.send({ file_name: fileName });
11271134 } catch (err) {
11281135 console.log(err);
src/endpoints/settings.js+57 -6
@@ -3,7 +3,7 @@ const path = require('path');
33const express = require('express');
44const _ = require('lodash');
55const writeFileAtomicSync = require('write-file-atomic').sync;
66const { PUBLIC_DIRECTORIES, SETTINGS_FILE } = require('../constants');
77const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util');
88const { jsonParser } = require('../express-common');
99const { getAllUserHandles, getUserDirectories } = require('../users');
@@ -27,12 +27,12 @@ const AUTOSAVE_FUNCTIONS = new Map();
2727 */
2828function triggerAutoSave(handle) {
2929 if (!AUTOSAVE_FUNCTIONS.has(handle)) {
3030 const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL);
3131 AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);
3232 }
3333
3434 const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);
3535 if (functionToCall && typeof functionToCall === 'function') {
3636 functionToCall();
3737 }
3838}
@@ -113,7 +113,7 @@ async function backupSettings() {
113113 const userHandles = await getAllUserHandles();
114114
115115 for (const handle of userHandles) {
116116 backupUserSettings(handle, true);
117117 }
118118 } catch (err) {
119119 console.log('Could not backup settings file', err);
@@ -123,13 +123,18 @@ async function backupSettings() {
123123/**
124124 * Makes a backup of the user's settings file.
125125 * @param {string} handle User handle
126+ * @param {boolean} preventDuplicates Prevent duplicate backups
126127 * @returns {void}
127128 */
128129function backupUserSettings(handle, preventDuplicates) {
129130 const userDirectories = getUserDirectories(handle);
130131 const backupFile = path.join(userDirectories.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`);
131132 const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
132133
134+ if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) {
135+ return;
136+ }
137+
133138 if (!fs.existsSync(sourceFile)) {
134139 return;
135140 }
@@ -138,6 +143,52 @@ function backupUserSettings(handle) {
138143 removeOldBackups(userDirectories.backups, `settings_${handle}`);
139144}
140145
146+/**
147+ * Checks if the backup would be a duplicate.
148+ * @param {string} handle User handle
149+ * @param {string} sourceFile Source file path
150+ * @returns {boolean} True if the backup is a duplicate
151+ */
152+function isDuplicateBackup(handle, sourceFile) {
153+ const latestBackup = getLatestBackup(handle);
154+ if (!latestBackup) {
155+ return false;
156+ }
157+ return areFilesEqual(latestBackup, sourceFile);
158+}
159+
160+/**
161+ * Returns true if the two files are equal.
162+ * @param {string} file1 File path
163+ * @param {string} file2 File path
164+ */
165+function areFilesEqual(file1, file2) {
166+ if (!fs.existsSync(file1) || !fs.existsSync(file2)) {
167+ return false;
168+ }
169+
170+ const content1 = fs.readFileSync(file1);
171+ const content2 = fs.readFileSync(file2);
172+ return content1.toString() === content2.toString();
173+}
174+
175+/**
176+ * Gets the latest backup file for a user.
177+ * @param {string} handle User handle
178+ * @returns {string|null} Latest backup file. Null if no backup exists.
179+ */
180+function getLatestBackup(handle) {
181+ const userDirectories = getUserDirectories(handle);
182+ const backupFiles = fs.readdirSync(userDirectories.backups)
183+ .filter(x => x.startsWith(getFilePrefix(handle)))
184+ .map(x => ({ name: x, ctime: fs.statSync(path.join(userDirectories.backups, x)).ctimeMs }));
185+ const latestBackup = backupFiles.sort((a, b) => b.ctime - a.ctime)[0]?.name;
186+ if (!latestBackup) {
187+ return null;
188+ }
189+ return path.join(userDirectories.backups, latestBackup);
190+}
191+
141192const router = express.Router();
142193
143194router.post('/save', jsonParser, function (request, response) {
@@ -265,7 +316,7 @@ router.post('/load-snapshot', jsonParser, async (request, response) => {
265316
266317router.post('/make-snapshot', jsonParser, async (request, response) => {
267318 try {
268319 backupUserSettings(request.user.profile.handle, false);
269320 response.sendStatus(204);
270321 } catch (error) {
271322 console.log(error);
src/util.js+1 -1
@@ -371,7 +371,7 @@ function generateTimestamp() {
371371 * @param {string} prefix File prefix to filter backups by.
372372 */
373373function removeOldBackups(directory, prefix) {
374374 const MAX_BACKUPS = Number(getConfigValue('numberOfBackups', 50));
375375
376376 let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix));
377377 if (files.length > MAX_BACKUPS) {