Merge pull request #2463 from SillyTavern/staging Staging

89dd4cbf3722dc12968b012e71c9021b09d070b2

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

Signed
33 files changed, +353 -156Ignore whitespace
.dockerignore+2 -0
@@ -1,4 +1,6 @@
1.git1.git
2.github
3.vscode
2node_modules4node_modules
3npm-debug.log5npm-debug.log
4readme*6readme*
.npmignore+3 -0
@@ -8,3 +8,6 @@ secrets.json
8/data8/data
9/cache9/cache
10access.log10access.log
11.github
12.vscode
13.git
default/config.yaml+2 -0
@@ -48,6 +48,8 @@ allowKeysExposure: false
48skipContentCheck: false48skipContentCheck: false
49# Disable automatic chats backup49# Disable automatic chats backup
50disableChatBackup: false50disableChatBackup: false
51# Number of backups to keep for each chat and settings file
52numberOfBackups: 50
51# Allowed hosts for card downloads53# Allowed hosts for card downloads
52whitelistImportDomains:54whitelistImportDomains:
53 - localhost55 - localhost
public/css/loader.css+1 -1
@@ -1,4 +1,4 @@
1#loader, #preloader {1#preloader {
2 position: fixed;2 position: fixed;
3 margin: 0;3 margin: 0;
4 padding: 0;4 padding: 0;
public/css/popup-safari-fix.css+4 -6
@@ -1,8 +1,6 @@
1/* iPhone copium land */1/* iPhone copium land */
2@media screen and (max-width: 1000px) {2.ios .popup .popup-body {
3 .ios .popup .popup-body {3 height: fit-content;
4 height: fit-content;4 max-height: 90vh;
5 max-height: 90vh;5 max-height: 90svh;
6 max-height: 90svh;
7 }
8}6}
public/css/popup.css+13 -4
@@ -27,8 +27,17 @@ dialog {
27 backface-visibility: hidden;27 backface-visibility: hidden;
28 transform: translateZ(0);28 transform: translateZ(0);
29 -webkit-font-smoothing: subpixel-antialiased;29 -webkit-font-smoothing: subpixel-antialiased;
30
31 /* Variables setup */
32 --popup-animation-speed: var(--animation-duration-slow);
30}33}
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 */
32.popup .popup-body {41.popup .popup-body {
33 display: flex;42 display: flex;
34 flex-direction: column;43 flex-direction: column;
@@ -60,11 +69,11 @@ dialog {
6069
61/* Opening animation */70/* Opening animation */
62.popup[opening] {71.popup[opening] {
63 animation: pop-in var(--animation-duration-slow) ease-in-out;72 animation: pop-in var(--popup-animation-speed) ease-in-out;
64}73}
6574
66.popup[opening]::backdrop {75.popup[opening]::backdrop {
67 animation: fade-in var(--animation-duration-slow) ease-in-out;76 animation: fade-in var(--popup-animation-speed) ease-in-out;
68}77}
6978
70/* Open state of the dialog */79/* Open state of the dialog */
@@ -85,11 +94,11 @@ body.no-blur .popup[open]::backdrop {
8594
86/* Closing animation */95/* Closing animation */
87.popup[closing] {96.popup[closing] {
88 animation: pop-out var(--animation-duration-slow) ease-in-out;97 animation: pop-out var(--popup-animation-speed) ease-in-out;
89}98}
9099
91.popup[closing]::backdrop {100.popup[closing]::backdrop {
92 animation: fade-out var(--animation-duration-slow) ease-in-out;101 animation: fade-out var(--popup-animation-speed) ease-in-out;
93}102}
94103
95.popup #toast-container {104.popup #toast-container {
public/index.html+3 -3
@@ -3903,8 +3903,8 @@
3903 <label for="tag_import_setting"><small data-i18n="Import Card Tags">Import Card Tags</small></label>3903 <label for="tag_import_setting"><small data-i18n="Import Card Tags">Import Card Tags</small></label>
3904 <select id="tag_import_setting" class="widthNatural flex1 margin0">3904 <select id="tag_import_setting" class="widthNatural flex1 margin0">
3905 <option data-i18n="Ask" value="1">Ask</option>3905 <option data-i18n="Ask" value="1">Ask</option>
3906 <option data-i18n="None" value="2">None</option>3906 <option data-i18n="tag_import_none" value="2">None</option>
3907 <option data-i18n="All" value="3">All</option>3907 <option data-i18n="tag_import_all" value="3">All</option>
3908 <option data-i18n="Existing" value="4">Existing</option>3908 <option data-i18n="Existing" value="4">Existing</option>
3909 </select>3909 </select>
3910 </div>3910 </div>
@@ -4796,7 +4796,7 @@
4796 <input multiple type="file" id="character_import_file" accept=".json, image/png, .yaml, .yml, .charx" name="avatar">4796 <input multiple type="file" id="character_import_file" accept=".json, image/png, .yaml, .yml, .charx" name="avatar">
4797 <input id="character_import_file_type" name="file_type" class="text_pole" maxlength="999" size="2" value="" autocomplete="off">4797 <input id="character_import_file_type" name="file_type" class="text_pole" maxlength="999" size="2" value="" autocomplete="off">
4798 </form>4798 </form>
4799 <input type="file" id="character_replace_file" accept="image/png" name="replace_avatar" hidden>4799 <input type="file" id="character_replace_file" accept=".json, image/png, .yaml, .yml, .charx" name="replace_avatar" hidden>
4800 </div>4800 </div>
4801 <div name="Character List Panel" id="rm_characters_block" class="right_menu">4801 <div name="Character List Panel" id="rm_characters_block" class="right_menu">
4802 <div id="charListFixedTop">4802 <div id="charListFixedTop">
public/locales/ru-ru.json+11 -6
@@ -163,9 +163,9 @@
163 "View hidden API keys": "Посмотреть скрытые API-ключи",163 "View hidden API keys": "Посмотреть скрытые API-ключи",
164 "Advanced Formatting": "Расширенное форматирование",164 "Advanced Formatting": "Расширенное форматирование",
165 "Context Template": "Шаблон контекста",165 "Context Template": "Шаблон контекста",
166 "Replace Macro in Custom Stopping Strings": "Заменить макросы в пользовательских стоп-строках",166 "Replace Macro in Custom Stopping Strings": "Заменять макросы в пользовательских стоп-строках",
167 "Story String": "Строка истории",167 "Story String": "Строка истории",
168 "Example Separator": "Пример разделителя",168 "Example Separator": "Разделитель примеров сообщений",
169 "Chat Start": "Начало чата",169 "Chat Start": "Начало чата",
170 "Activation Regex": "Regex для активации",170 "Activation Regex": "Regex для активации",
171 "Instruct Mode": "Режим Instruct",171 "Instruct Mode": "Режим Instruct",
@@ -187,7 +187,7 @@
187 "Misc. Settings": "Доп. настройки",187 "Misc. Settings": "Доп. настройки",
188 "Auto-Continue": "Авто-продолжение",188 "Auto-Continue": "Авто-продолжение",
189 "Collapse Consecutive Newlines": "Сворачивать последовательные новые строки",189 "Collapse Consecutive Newlines": "Сворачивать последовательные новые строки",
190 "Allow for Chat Completion APIs": "Разрешить для API Chat Completion",190 "Allow for Chat Completion APIs": "Разрешить для Chat Completion API",
191 "Target length (tokens)": "Целевая длина (в токенах)",191 "Target length (tokens)": "Целевая длина (в токенах)",
192 "World Info": "Информация о мире",192 "World Info": "Информация о мире",
193 "Scan Depth": "Глубина сканирования",193 "Scan Depth": "Глубина сканирования",
@@ -377,7 +377,7 @@
377 "Log prompts to console": "Выводить промпты в консоль",377 "Log prompts to console": "Выводить промпты в консоль",
378 "Never resize avatars": "Не менять размер аватарок",378 "Never resize avatars": "Не менять размер аватарок",
379 "Show avatar filenames": "Показывать названия файлов аватарок",379 "Show avatar filenames": "Показывать названия файлов аватарок",
380 "Import Card Tags": "Импорт тегов карточки",380 "Import Card Tags": "Импортировать теги карточки",
381 "Confirm message deletion": "Подтверждение удаления сообщений",381 "Confirm message deletion": "Подтверждение удаления сообщений",
382 "Spoiler Free Mode": "Режим без спойлеров",382 "Spoiler Free Mode": "Режим без спойлеров",
383 "Auto-swipe": "Автоматические свайпы",383 "Auto-swipe": "Автоматические свайпы",
@@ -1274,7 +1274,7 @@
1274 "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.",1274 "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.",
1275 "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.",1275 "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.",
1276 "Clear your cookie": "Clear your cookie",1276 "Clear your cookie": "Clear your cookie",
1277 "Add Chat Start and Example Separator to a list of stopping strings.": "Add Chat Start and Example Separator to a list of stopping strings.",1277 "Add Chat Start and Example Separator to a list of stopping strings.": "Использовать Начало чата и Разделитель примеров сообщений в качестве стоп-строк.",
1278 "context_allow_jailbreak": "Если в карточке есть джейлбрейк И ПРИ ЭТОМ включена опция \"Приоритет джейлбрейку из карточки персонажа\", то этот джейлбрейк добавляется в конец промпта.\nНЕ РЕКОМЕНДУЕТСЯ ДЛЯ МОДЕЛЕЙ TEXT COMPLETION, МОЖЕТ ПОРТИТЬ ВЫХОДНОЙ ТЕКСТ.",1278 "context_allow_jailbreak": "Если в карточке есть джейлбрейк И ПРИ ЭТОМ включена опция \"Приоритет джейлбрейку из карточки персонажа\", то этот джейлбрейк добавляется в конец промпта.\nНЕ РЕКОМЕНДУЕТСЯ ДЛЯ МОДЕЛЕЙ TEXT COMPLETION, МОЖЕТ ПОРТИТЬ ВЫХОДНОЙ ТЕКСТ.",
1279 "Context Order": "Context Order",1279 "Context Order": "Context Order",
1280 "Summary": "Summary",1280 "Summary": "Summary",
@@ -1636,5 +1636,10 @@
1636 "Change Password": "Сменить пароль",1636 "Change Password": "Сменить пароль",
1637 "Reset Code:": "Reset Code:",1637 "Reset Code:": "Reset Code:",
1638 "Click _space": "Нажмите ",1638 "Click _space": "Нажмите ",
1639 "Alternate Greeting #": "Вариант #"1639 "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": "Не импортировать"
1640}1645}
public/locales/zh-cn.json+2 -1
@@ -217,6 +217,7 @@
217 "Character Names Behavior": "角色名称行为",217 "Character Names Behavior": "角色名称行为",
218 "Helps the model to associate messages with characters.": "有助于模型将消息与角色关联起来。",218 "Helps the model to associate messages with characters.": "有助于模型将消息与角色关联起来。",
219 "None": "无",219 "None": "无",
220 "tag_import_none": "无",
220 "character_names_none": "群聊和过去的角色除外。否则,请确保在提示词中提供了姓名。",221 "character_names_none": "群聊和过去的角色除外。否则,请确保在提示词中提供了姓名。",
221 "Don't add character names.": "不添加角色名称。",222 "Don't add character names.": "不添加角色名称。",
222 "Completion": "补全对象",223 "Completion": "补全对象",
@@ -653,7 +654,7 @@
653 "Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "定义在导入卡片时应选择哪种操作来导入其列出的标签。“询问”将始终显示对话框。",654 "Defines on importing cards which action should be chosen for importing its listed tags. 'Ask' will always display the dialog.": "定义在导入卡片时应选择哪种操作来导入其列出的标签。“询问”将始终显示对话框。",
654 "Import Card Tags": "导入卡片标签",655 "Import Card Tags": "导入卡片标签",
655 "Ask": "询问",656 "Ask": "询问",
656 "All": "全部",657 "tag_import_all": "全部",
657 "Existing": "现存的",658 "Existing": "现存的",
658 "Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "使用模糊匹配,在列表中通过所有数据字段搜索角色,而不仅仅是名称子字符串",659 "Use fuzzy matching, and search characters in the list by all data fields, not just by a name substring": "使用模糊匹配,在列表中通过所有数据字段搜索角色,而不仅仅是名称子字符串",
659 "Advanced Character Search": "高级角色搜索",660 "Advanced Character Search": "高级角色搜索",
public/script.js+40 -32
@@ -43,7 +43,6 @@ import {
43 saveGroupChat,43 saveGroupChat,
44 getGroups,44 getGroups,
45 generateGroupWrapper,45 generateGroupWrapper,
46 deleteGroup,
47 is_group_generating,46 is_group_generating,
48 resetSelectedGroup,47 resetSelectedGroup,
49 select_group_chats,48 select_group_chats,
@@ -228,7 +227,7 @@ import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, de
228import { initPresetManager } from './scripts/preset-manager.js';227import { initPresetManager } from './scripts/preset-manager.js';
229import { MacrosParser, evaluateMacros } from './scripts/macros.js';228import { MacrosParser, evaluateMacros } from './scripts/macros.js';
230import { currentUser, setUserControls } from './scripts/user.js';229import { currentUser, setUserControls } from './scripts/user.js';
231import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js';230import { POPUP_TYPE, Popup, callGenericPopup, fixToastrForDialogs } from './scripts/popup.js';
232import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';231import { renderTemplate, renderTemplateAsync } from './scripts/templates.js';
233import { ScraperManager } from './scripts/scrapers.js';232import { ScraperManager } from './scripts/scrapers.js';
234import { SlashCommandParser } from './scripts/slash-commands/SlashCommandParser.js';233import { SlashCommandParser } from './scripts/slash-commands/SlashCommandParser.js';
@@ -266,8 +265,6 @@ await new Promise((resolve) => {
266});265});
267266
268showLoader();267showLoader();
269// Yoink preloader entirely; it only exists to cover up unstyled content while loading JS
270document.getElementById('preloader').remove();
271268
272// Configure toast library:269// Configure toast library:
273toastr.options.escapeHtml = true; // Prevent raw HTML inserts270toastr.options.escapeHtml = true; // Prevent raw HTML inserts
@@ -7829,6 +7826,8 @@ window['SillyTavern'].getContext = function () {
7829 registerDataBankScraper: ScraperManager.registerDataBankScraper,7826 registerDataBankScraper: ScraperManager.registerDataBankScraper,
7830 callPopup: callPopup,7827 callPopup: callPopup,
7831 callGenericPopup: callGenericPopup,7828 callGenericPopup: callGenericPopup,
7829 showLoader: showLoader,
7830 hideLoader: hideLoader,
7832 mainApi: main_api,7831 mainApi: main_api,
7833 extensionSettings: extension_settings,7832 extensionSettings: extension_settings,
7834 ModuleWorkerWrapper: ModuleWorkerWrapper,7833 ModuleWorkerWrapper: ModuleWorkerWrapper,
@@ -7899,7 +7898,7 @@ function swipe_left() { // when we swipe left..but no generation.
7899 }7898 }
7900 $(this).parent().children('.mes_block').transition({7899 $(this).parent().children('.mes_block').transition({
7901 x: swipe_range,7900 x: swipe_range,
7902 duration: swipe_duration,7901 duration: animation_duration > 0 ? swipe_duration : 0,
7903 easing: animation_easing,7902 easing: animation_easing,
7904 queue: false,7903 queue: false,
7905 complete: async function () {7904 complete: async function () {
@@ -7942,7 +7941,7 @@ function swipe_left() { // when we swipe left..but no generation.
7942 complete: function () {7941 complete: function () {
7943 $(this).parent().children('.mes_block').transition({7942 $(this).parent().children('.mes_block').transition({
7944 x: '0px',7943 x: '0px',
7945 duration: swipe_duration,7944 duration: animation_duration > 0 ? swipe_duration : 0,
7946 easing: animation_easing,7945 easing: animation_easing,
7947 queue: false,7946 queue: false,
7948 complete: async function () {7947 complete: async function () {
@@ -7957,7 +7956,7 @@ function swipe_left() { // when we swipe left..but no generation.
79577956
7958 $(this).parent().children('.avatar').transition({7957 $(this).parent().children('.avatar').transition({
7959 x: swipe_range,7958 x: swipe_range,
7960 duration: swipe_duration,7959 duration: animation_duration > 0 ? swipe_duration : 0,
7961 easing: animation_easing,7960 easing: animation_easing,
7962 queue: false,7961 queue: false,
7963 complete: function () {7962 complete: function () {
@@ -7969,7 +7968,7 @@ function swipe_left() { // when we swipe left..but no generation.
7969 complete: function () {7968 complete: function () {
7970 $(this).parent().children('.avatar').transition({7969 $(this).parent().children('.avatar').transition({
7971 x: '0px',7970 x: '0px',
7972 duration: swipe_duration,7971 duration: animation_duration > 0 ? swipe_duration : 0,
7973 easing: animation_easing,7972 easing: animation_easing,
7974 queue: false,7973 queue: false,
7975 complete: function () {7974 complete: function () {
@@ -8076,7 +8075,7 @@ const swipe_right = () => {
8076 this_mes_div.children('.swipe_left').css('display', 'flex');8075 this_mes_div.children('.swipe_left').css('display', 'flex');
8077 this_mes_div.children('.mes_block').transition({ // this moves the div back and forth8076 this_mes_div.children('.mes_block').transition({ // this moves the div back and forth
8078 x: '-' + swipe_range,8077 x: '-' + swipe_range,
8079 duration: swipe_duration,8078 duration: animation_duration > 0 ? swipe_duration : 0,
8080 easing: animation_easing,8079 easing: animation_easing,
8081 queue: false,8080 queue: false,
8082 complete: async function () {8081 complete: async function () {
@@ -8135,7 +8134,7 @@ const swipe_right = () => {
8135 complete: function () {8134 complete: function () {
8136 this_mes_div.children('.mes_block').transition({8135 this_mes_div.children('.mes_block').transition({
8137 x: '0px',8136 x: '0px',
8138 duration: swipe_duration,8137 duration: animation_duration > 0 ? swipe_duration : 0,
8139 easing: animation_easing,8138 easing: animation_easing,
8140 queue: false,8139 queue: false,
8141 complete: async function () {8140 complete: async function () {
@@ -8158,7 +8157,7 @@ const swipe_right = () => {
8158 });8157 });
8159 this_mes_div.children('.avatar').transition({ // moves avatar along with swipe8158 this_mes_div.children('.avatar').transition({ // moves avatar along with swipe
8160 x: '-' + swipe_range,8159 x: '-' + swipe_range,
8161 duration: swipe_duration,8160 duration: animation_duration > 0 ? swipe_duration : 0,
8162 easing: animation_easing,8161 easing: animation_easing,
8163 queue: false,8162 queue: false,
8164 complete: function () {8163 complete: function () {
@@ -8170,7 +8169,7 @@ const swipe_right = () => {
8170 complete: function () {8169 complete: function () {
8171 this_mes_div.children('.avatar').transition({8170 this_mes_div.children('.avatar').transition({
8172 x: '0px',8171 x: '0px',
8173 duration: swipe_duration,8172 duration: animation_duration > 0 ? swipe_duration : 0,
8174 easing: animation_easing,8173 easing: animation_easing,
8175 queue: false,8174 queue: false,
8176 complete: function () {8175 complete: function () {
@@ -8450,10 +8449,10 @@ async function connectAPISlash(_, text) {
8450/**8449/**
8451 * Imports supported files dropped into the app window.8450 * Imports supported files dropped into the app window.
8452 * @param {File[]} files Array of files to process8451 * @param {File[]} files Array of files to process
8453 * @param {boolean?} preserveFileNames Whether to preserve original file names8452 * @param {Map<File, string>} [data] Extra data to pass to the import function
8454 * @returns {Promise<void>}8453 * @returns {Promise<void>}
8455 */8454 */
8456export async function processDroppedFiles(files, preserveFileNames = false) {8455export async function processDroppedFiles(files, data = new Map()) {
8457 const allowedMimeTypes = [8456 const allowedMimeTypes = [
8458 'application/json',8457 'application/json',
8459 'image/png',8458 'image/png',
@@ -8470,7 +8469,8 @@ export async function processDroppedFiles(files, preserveFileNames = false) {
8470 for (const file of files) {8469 for (const file of files) {
8471 const extension = file.name.split('.').pop().toLowerCase();8470 const extension = file.name.split('.').pop().toLowerCase();
8472 if (allowedMimeTypes.includes(file.type) || allowedExtensions.includes(extension)) {8471 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);
8474 } else {8474 } else {
8475 toastr.warning('Unsupported file type: ' + file.name);8475 toastr.warning('Unsupported file type: ' + file.name);
8476 }8476 }
@@ -8480,10 +8480,10 @@ export async function processDroppedFiles(files, preserveFileNames = false) {
8480/**8480/**
8481 * Imports a character from a file.8481 * Imports a character from a file.
8482 * @param {File} file File to import8482 * @param {File} file File to import
8483 * @param {boolean?} preserveFileName Whether to preserve original file name8483 * @param {string?} preserveFileName Whether to preserve original file name
8484 * @returns {Promise<void>}8484 * @returns {Promise<void>}
8485 */8485 */
8486async function importCharacter(file, preserveFileName = false) {8486async function importCharacter(file, preserveFileName = '') {
8487 if (is_group_generating || is_send_press) {8487 if (is_group_generating || is_send_press) {
8488 toastr.error('Cannot import characters while generating. Stop the request and try again.', 'Import aborted');8488 toastr.error('Cannot import characters while generating. Stop the request and try again.', 'Import aborted');
8489 throw new Error('Cannot import character while generating');8489 throw new Error('Cannot import character while generating');
@@ -8499,7 +8499,7 @@ async function importCharacter(file, preserveFileName = false) {
8499 const formData = new FormData();8499 const formData = new FormData();
8500 formData.append('avatar', file);8500 formData.append('avatar', file);
8501 formData.append('file_type', format);8501 formData.append('file_type', format);
8502 formData.append('preserve_file_name', String(preserveFileName));8502 if (preserveFileName) formData.append('preserved_name', preserveFileName);
85038503
8504 const data = await jQuery.ajax({8504 const data = await jQuery.ajax({
8505 type: 'POST',8505 type: 'POST',
@@ -9170,14 +9170,26 @@ jQuery(async function () {
9170 chooseBogusFolder($(this), tagId);9170 chooseBogusFolder($(this), tagId);
9171 });9171 });
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`;
9176 is_use_scroll_holder = true;9181 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 }
9177 });9189 });
9178 $('#chat').on('scroll', function () {9190 document.getElementById('chat').addEventListener('scroll', function () {
9179 if (is_use_scroll_holder) {9191 if (is_use_scroll_holder) {
9180 $('#chat').scrollTop(scroll_holder);9192 this.scrollTop = scroll_holder;
9181 is_use_scroll_holder = false;9193 is_use_scroll_holder = false;
9182 }9194 }
9183 });9195 });
@@ -9709,14 +9721,8 @@ jQuery(async function () {
9709 });9721 });
97109722
9711 $('#newChatFromManageScreenButton').on('click', function () {9723 $('#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);
9718 $('#select_chat_cross').trigger('click');9725 $('#select_chat_cross').trigger('click');
9719
9720 });9726 });
97219727
9722 //////////////////////////////////////////////////////////////////////////////////////////////9728 //////////////////////////////////////////////////////////////////////////////////////////////
@@ -10632,10 +10638,12 @@ jQuery(async function () {
10632 }10638 }
1063310639
10634 try {10640 try {
10635 const cloneFile = new File([file], characters[this_chid].avatar, { type: file.type });
10636 const chatFile = characters[this_chid]['chat'];10641 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);
10638 await openCharacterChat(chatFile);10645 await openCharacterChat(chatFile);
10646 await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'no-cache' });
10639 } catch {10647 } catch {
10640 toastr.error('Failed to replace the character card.', 'Something went wrong');10648 toastr.error('Failed to replace the character card.', 'Something went wrong');
10641 }10649 }
public/scripts/RossAscends-mods.js+3 -1
@@ -725,7 +725,9 @@ export function initRossMods() {
725 RA_autoconnect();725 RA_autoconnect();
726 }726 }
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) {
729 document.body.classList.add('ios');731 document.body.classList.add('ios');
730 }732 }
731733
public/scripts/chats.js+7 -1
@@ -1526,7 +1526,13 @@ jQuery(function () {
1526 const fileInput = document.getElementById('file_form_input');1526 const fileInput = document.getElementById('file_form_input');
1527 if (!(fileInput instanceof HTMLInputElement)) return;1527 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;
1530 await onFileAttach(fileInput.files[0]);1536 await onFileAttach(fileInput.files[0]);
1531 });1537 });
1532});1538});
public/scripts/constants.js+2 -0
@@ -5,6 +5,8 @@
5export const debounce_timeout = {5export const debounce_timeout = {
6 /** [100 ms] For ultra-fast responses, typically for keypresses or executions that might happen multiple times in a loop or recursion. */6 /** [100 ms] For ultra-fast responses, typically for keypresses or executions that might happen multiple times in a loop or recursion. */
7 quick: 100,7 quick: 100,
8 /** [200 ms] Slightly slower than quick, but still very responsive. */
9 short: 200,
8 /** [300 ms] Default time for general use, good balance between responsiveness and performance. */10 /** [300 ms] Default time for general use, good balance between responsiveness and performance. */
9 standard: 300,11 standard: 300,
10 /** [1.000 ms] For situations where the function triggers more intensive tasks. */12 /** [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() {
154 // Process all stylesheets on initial load154 // Process all stylesheets on initial load
155 Array.from(document.styleSheets).forEach(sheet => {155 Array.from(document.styleSheets).forEach(sheet => {
156 try {156 try {
157 applyDynamicFocusStyles(sheet, { fromExtension: sheet.href.toLowerCase().includes('scripts/extensions') });157 applyDynamicFocusStyles(sheet, { fromExtension: sheet.href?.toLowerCase().includes('scripts/extensions') == true });
158 } catch (e) {158 } catch (e) {
159 console.warn('Failed to process stylesheet on initial load:', e);159 console.warn('Failed to process stylesheet on initial load:', e);
160 }160 }
public/scripts/extensions.js+9 -2
@@ -374,7 +374,7 @@ async function addExtensionsButtonAndMenu() {
374374
375 $('html').on('click', function (e) {375 $('html').on('click', function (e) {
376 const clickTarget = $(e.target);376 const clickTarget = $(e.target);
377 const noCloseTargets = ['#sd_gen', '#extensionsMenuButton'];377 const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice'];
378 if (dropdown.is(':visible') && !noCloseTargets.some(id => clickTarget.closest(id).length > 0)) {378 if (dropdown.is(':visible') && !noCloseTargets.some(id => clickTarget.closest(id).length > 0)) {
379 $(dropdown).fadeOut(animation_duration);379 $(dropdown).fadeOut(animation_duration);
380 }380 }
@@ -641,9 +641,16 @@ async function showExtensionsDetails() {
641 action: async () => {641 action: async () => {
642 requiresReload = true;642 requiresReload = true;
643 await autoUpdateExtensions(true);643 await autoUpdateExtensions(true);
644 popup.complete(POPUP_RESULT.AFFIRMATIVE);644 await popup.complete(POPUP_RESULT.AFFIRMATIVE);
645 },645 },
646 };646 };
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
647 const popup = new Popup(`<div class="extensions_info">${html}</div>`, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, customButtons: [updateAllButton], allowVerticalScrolling: true });654 const popup = new Popup(`<div class="extensions_info">${html}</div>`, POPUP_TYPE.TEXT, '', { okButton: 'Close', wide: true, large: true, customButtons: [updateAllButton], allowVerticalScrolling: true });
648 popupPromise = popup.show();655 popupPromise = popup.show();
649 } catch (error) {656 } catch (error) {
public/scripts/extensions/caption/index.js+11 -6
@@ -520,12 +520,17 @@ jQuery(async function () {
520 const messageImg = messageBlock.find('.mes_img');520 const messageImg = messageBlock.find('.mes_img');
521 if (messageImg.hasClass(animationClass)) return;521 if (messageImg.hasClass(animationClass)) return;
522 messageImg.addClass(animationClass);522 messageImg.addClass(animationClass);
523 const index = Number(messageBlock.attr('mesid'));523 try {
524 const data = getContext().chat[index];524 const index = Number(messageBlock.attr('mesid'));
525 await captionExistingMessage(data);525 const data = getContext().chat[index];
526 appendMediaToMessage(data, messageBlock, false);526 await captionExistingMessage(data);
527 await saveChatConditional();527 appendMediaToMessage(data, messageBlock, false);
528 messageImg.removeClass(animationClass);528 await saveChatConditional();
529 } catch(e) {
530 console.error('Message image recaption failed', e);
531 } finally {
532 messageImg.removeClass(animationClass);
533 }
529 });534 });
530535
531 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'caption',536 SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'caption',
public/scripts/extensions/stable-diffusion/index.js+15 -17
@@ -3,7 +3,6 @@ import {
3 systemUserName,3 systemUserName,
4 hideSwipeButtons,4 hideSwipeButtons,
5 showSwipeButtons,5 showSwipeButtons,
6 callPopup,
7 getRequestHeaders,6 getRequestHeaders,
8 event_types,7 event_types,
9 eventSource,8 eventSource,
@@ -29,10 +28,9 @@ import { getMultimodalCaption } from '../shared.js';
29import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';28import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
30import { SlashCommand } from '../../slash-commands/SlashCommand.js';29import { SlashCommand } from '../../slash-commands/SlashCommand.js';
31import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';30import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
32import { resolveVariable } from '../../variables.js';
33import { debounce_timeout } from '../../constants.js';31import { debounce_timeout } from '../../constants.js';
34import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
35import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';32import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValue.js';
33import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
36export { MODULE_NAME };34export { MODULE_NAME };
3735
38const MODULE_NAME = 'sd';36const MODULE_NAME = 'sd';
@@ -572,7 +570,7 @@ async function onDeleteStyleClick() {
572 return;570 return;
573 }571 }
574572
575 const confirmed = await callPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, 'confirm', '', { okButton: 'Delete' });573 const confirmed = await callGenericPopup(`Are you sure you want to delete the style "${selectedStyle}"?`, POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
576574
577 if (!confirmed) {575 if (!confirmed) {
578 return;576 return;
@@ -601,7 +599,7 @@ async function onDeleteStyleClick() {
601}599}
602600
603async function onSaveStyleClick() {601async function onSaveStyleClick() {
604 const userInput = await callPopup('Enter style name:', 'input', '', { okButton: 'Save' });602 const userInput = await callGenericPopup('Enter style name:', POPUP_TYPE.INPUT);
605603
606 if (!userInput) {604 if (!userInput) {
607 return;605 return;
@@ -670,7 +668,7 @@ async function refinePrompt(prompt, allowExpand, isNegative = false) {
670668
671 if (extension_settings.sd.refine_mode) {669 if (extension_settings.sd.refine_mode) {
672 const text = isNegative ? '<h3>Review and edit the <i>negative</i> prompt:</h3>' : '<h3>Review and edit the prompt:</h3>';670 const text = isNegative ? '<h3>Review and edit the <i>negative</i> prompt:</h3>' : '<h3>Review and edit the prompt:</h3>';
673 const refinedPrompt = await callPopup(text + 'Press "Cancel" to abort the image generation.', 'input', prompt.trim(), { rows: 5, okButton: 'Continue' });671 const refinedPrompt = await callGenericPopup(text + 'Press "Cancel" to abort the image generation.', POPUP_TYPE.INPUT, prompt.trim(), { rows: 5, okButton: 'Continue' });
674672
675 if (refinedPrompt) {673 if (refinedPrompt) {
676 return refinedPrompt;674 return refinedPrompt;
@@ -2918,25 +2916,25 @@ async function generateComfyImage(prompt, negativePrompt) {
2918 const text = await workflowResponse.text();2916 const text = await workflowResponse.text();
2919 toastr.error(`Failed to load workflow.\n\n${text}`);2917 toastr.error(`Failed to load workflow.\n\n${text}`);
2920 }2918 }
2921 let workflow = (await workflowResponse.json()).replace('"%prompt%"', JSON.stringify(prompt));2919 let workflow = (await workflowResponse.json()).replaceAll('"%prompt%"', JSON.stringify(prompt));
2922 workflow = workflow.replace('"%negative_prompt%"', JSON.stringify(negativePrompt));2920 workflow = workflow.replaceAll('"%negative_prompt%"', JSON.stringify(negativePrompt));
29232921
2924 const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);2922 const seed = extension_settings.sd.seed >= 0 ? extension_settings.sd.seed : Math.round(Math.random() * Number.MAX_SAFE_INTEGER);
2925 workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));2923 workflow = workflow.replaceAll('"%seed%"', JSON.stringify(seed));
2926 placeholders.forEach(ph => {2924 placeholders.forEach(ph => {
2927 workflow = workflow.replace(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));2925 workflow = workflow.replaceAll(`"%${ph}%"`, JSON.stringify(extension_settings.sd[ph]));
2928 });2926 });
2929 (extension_settings.sd.comfy_placeholders ?? []).forEach(ph => {2927 (extension_settings.sd.comfy_placeholders ?? []).forEach(ph => {
2930 workflow = workflow.replace(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));2928 workflow = workflow.replaceAll(`"%${ph.find}%"`, JSON.stringify(substituteParams(ph.replace)));
2931 });2929 });
2932 if (/%user_avatar%/gi.test(workflow)) {2930 if (/%user_avatar%/gi.test(workflow)) {
2933 const response = await fetch(getUserAvatarUrl());2931 const response = await fetch(getUserAvatarUrl());
2934 if (response.ok) {2932 if (response.ok) {
2935 const avatarBlob = await response.blob();2933 const avatarBlob = await response.blob();
2936 const avatarBase64 = await getBase64Async(avatarBlob);2934 const avatarBase64 = await getBase64Async(avatarBlob);
2937 workflow = workflow.replace('"%user_avatar%"', JSON.stringify(avatarBase64));2935 workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(avatarBase64));
2938 } else {2936 } else {
2939 workflow = workflow.replace('"%user_avatar%"', JSON.stringify(PNG_PIXEL));2937 workflow = workflow.replaceAll('"%user_avatar%"', JSON.stringify(PNG_PIXEL));
2940 }2938 }
2941 }2939 }
2942 if (/%char_avatar%/gi.test(workflow)) {2940 if (/%char_avatar%/gi.test(workflow)) {
@@ -2944,9 +2942,9 @@ async function generateComfyImage(prompt, negativePrompt) {
2944 if (response.ok) {2942 if (response.ok) {
2945 const avatarBlob = await response.blob();2943 const avatarBlob = await response.blob();
2946 const avatarBase64 = await getBase64Async(avatarBlob);2944 const avatarBase64 = await getBase64Async(avatarBlob);
2947 workflow = workflow.replace('"%char_avatar%"', JSON.stringify(avatarBase64));2945 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(avatarBase64));
2948 } else {2946 } else {
2949 workflow = workflow.replace('"%char_avatar%"', JSON.stringify(PNG_PIXEL));2947 workflow = workflow.replaceAll('"%char_avatar%"', JSON.stringify(PNG_PIXEL));
2950 }2948 }
2951 }2949 }
2952 console.log(`{2950 console.log(`{
@@ -2978,7 +2976,7 @@ async function onComfyOpenWorkflowEditorClick() {
2978 }),2976 }),
2979 })).json();2977 })).json();
2980 const editorHtml = $(await $.get('scripts/extensions/stable-diffusion/comfyWorkflowEditor.html'));2978 const editorHtml = $(await $.get('scripts/extensions/stable-diffusion/comfyWorkflowEditor.html'));
2981 const popupResult = callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save', wide: true, large: true, rows: 1 });2979 const popupResult = callGenericPopup(editorHtml, POPUP_TYPE.CONFIRM, '', { okButton: 'Save', cancelButton: 'Cancel', wide: true, large: true });
2982 const checkPlaceholders = () => {2980 const checkPlaceholders = () => {
2983 workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();2981 workflow = $('#sd_comfy_workflow_editor_workflow').val().toString();
2984 $('.sd_comfy_workflow_editor_placeholder_list > li[data-placeholder]').each(function (idx) {2982 $('.sd_comfy_workflow_editor_placeholder_list > li[data-placeholder]').each(function (idx) {
@@ -3058,7 +3056,7 @@ async function onComfyOpenWorkflowEditorClick() {
3058}3056}
30593057
3060async function onComfyNewWorkflowClick() {3058async function onComfyNewWorkflowClick() {
3061 let name = await callPopup('<h3>Workflow name:</h3>', 'input');3059 let name = await callGenericPopup('Workflow name:', POPUP_TYPE.INPUT);
3062 if (!name) {3060 if (!name) {
3063 return;3061 return;
3064 }3062 }
@@ -3085,7 +3083,7 @@ async function onComfyNewWorkflowClick() {
3085}3083}
30863084
3087async function onComfyDeleteWorkflowClick() {3085async function onComfyDeleteWorkflowClick() {
3088 const confirm = await callPopup('Delete the workflow? This action is irreversible.', 'confirm');3086 const confirm = await callGenericPopup('Delete the workflow? This action is irreversible.', POPUP_TYPE.CONFIRM, '', { okButton: 'Delete', cancelButton: 'Cancel' });
3089 if (!confirm) {3087 if (!confirm) {
3090 return;3088 return;
3091 }3089 }
public/scripts/extensions/tts/azure.js+4 -3
@@ -1,4 +1,5 @@
1import { callPopup, getRequestHeaders } from '../../../script.js';1import { getRequestHeaders } from '../../../script.js';
2import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
2import { SECRET_KEYS, findSecret, secret_state, writeSecret } from '../../secrets.js';3import { SECRET_KEYS, findSecret, secret_state, writeSecret } from '../../secrets.js';
3import { getPreviewString, saveTtsProviderSettings } from './index.js';4import { getPreviewString, saveTtsProviderSettings } from './index.js';
4export { AzureTtsProvider };5export { AzureTtsProvider };
@@ -69,13 +70,13 @@ class AzureTtsProvider {
69 const popupText = 'Azure TTS API Key';70 const popupText = 'Azure TTS API Key';
70 const savedKey = secret_state[SECRET_KEYS.AZURE_TTS] ? await findSecret(SECRET_KEYS.AZURE_TTS) : '';71 const savedKey = secret_state[SECRET_KEYS.AZURE_TTS] ? await findSecret(SECRET_KEYS.AZURE_TTS) : '';
7172
72 const key = await callPopup(popupText, 'input', savedKey);73 const key = await callGenericPopup(popupText, POPUP_TYPE.INPUT, savedKey);
7374
74 if (key == false || key == '') {75 if (key == false || key == '') {
75 return;76 return;
76 }77 }
7778
78 await writeSecret(SECRET_KEYS.AZURE_TTS, key);79 await writeSecret(SECRET_KEYS.AZURE_TTS, String(key));
7980
80 toastr.success('API Key saved');81 toastr.success('API Key saved');
81 $('#azure_tts_key').addClass('success');82 $('#azure_tts_key').addClass('success');
public/scripts/extensions/tts/coqui.js+2 -2
@@ -5,8 +5,8 @@ TODO:
5*/5*/
66
7import { doExtrasFetch, extension_settings, getApiUrl, modules } from '../../extensions.js';7import { doExtrasFetch, extension_settings, getApiUrl, modules } from '../../extensions.js';
8import { callPopup } from '../../../script.js';
9import { initVoiceMap } from './index.js';8import { initVoiceMap } from './index.js';
9import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
1010
11export { CoquiTtsProvider };11export { CoquiTtsProvider };
1212
@@ -246,7 +246,7 @@ class CoquiTtsProvider {
246 }246 }
247247
248 // Ask user for voiceId name to save voice248 // Ask user for voiceId name to save voice
249 const voiceName = await callPopup('<h3>Name of Coqui voice to add to voice select dropdown:</h3>', 'input');249 const voiceName = await callGenericPopup('Name of Coqui voice to add to voice select dropdown:', POPUP_TYPE.INPUT);
250250
251 const model_origin = $('#coqui_model_origin').val();251 const model_origin = $('#coqui_model_origin').val();
252 const model_language = $('#coqui_api_language').val();252 const model_language = $('#coqui_api_language').val();
public/scripts/extensions/tts/novel.js+3 -2
@@ -1,4 +1,5 @@
1import { getRequestHeaders, callPopup } from '../../../script.js';1import { getRequestHeaders } from '../../../script.js';
2import { POPUP_TYPE, callGenericPopup } from '../../popup.js';
2import { splitRecursive } from '../../utils.js';3import { splitRecursive } from '../../utils.js';
3import { getPreviewString, saveTtsProviderSettings } from './index.js';4import { getPreviewString, saveTtsProviderSettings } from './index.js';
4import { initVoiceMap } from './index.js';5import { initVoiceMap } from './index.js';
@@ -56,7 +57,7 @@ class NovelTtsProvider {
5657
57 // Add a new Novel custom voice to provider58 // Add a new Novel custom voice to provider
58 async addCustomVoice() {59 async addCustomVoice() {
59 const voiceName = await callPopup('<h3>Custom Voice name:</h3>', 'input');60 const voiceName = await callGenericPopup('Custom Voice name:', POPUP_TYPE.INPUT);
60 this.settings.customVoices.push(voiceName);61 this.settings.customVoices.push(voiceName);
61 this.populateCustomVoices();62 this.populateCustomVoices();
62 initVoiceMap(); // Update TTS extension voiceMap63 initVoiceMap(); // Update TTS extension voiceMap
public/scripts/group-chats.js+33 -7
@@ -178,8 +178,37 @@ async function loadGroupChat(chatId) {
178 return [];178 return [];
179}179}
180180
181async 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
181export async function getGroupChat(groupId, reload = false) {202export async function getGroupChat(groupId, reload = false) {
182 const group = groups.find((x) => x.id === groupId);203 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
183 const chat_id = group.chat_id;212 const chat_id = group.chat_id;
184 const data = await loadGroupChat(chat_id);213 const data = await loadGroupChat(chat_id);
185 let freshChat = false;214 let freshChat = false;
@@ -197,7 +226,6 @@ export async function getGroupChat(groupId, reload = false) {
197 if (group && Array.isArray(group.members)) {226 if (group && Array.isArray(group.members)) {
198 for (let member of group.members) {227 for (let member of group.members) {
199 const character = characters.find(x => x.avatar === member || x.name === member);228 const character = characters.find(x => x.avatar === member || x.name === member);
200
201 if (!character) {229 if (!character) {
202 continue;230 continue;
203 }231 }
@@ -219,10 +247,8 @@ export async function getGroupChat(groupId, reload = false) {
219 freshChat = true;247 freshChat = true;
220 }248 }
221249
222 if (group) {250 let metadata = group.chat_metadata ?? {};
223 let metadata = group.chat_metadata ?? {};251 updateChatMetadata(metadata, true);
224 updateChatMetadata(metadata, true);
225 }
226252
227 if (reload) {253 if (reload) {
228 select_group_chats(groupId, true);254 select_group_chats(groupId, true);
@@ -1576,6 +1602,8 @@ export async function openGroupById(groupId) {
1576 }1602 }
15771603
1578 if (!is_send_press && !is_group_generating) {1604 if (!is_send_press && !is_group_generating) {
1605 select_group_chats(groupId);
1606
1579 if (selected_group !== groupId) {1607 if (selected_group !== groupId) {
1580 await clearChat();1608 await clearChat();
1581 cancelTtsPlay();1609 cancelTtsPlay();
@@ -1587,8 +1615,6 @@ export async function openGroupById(groupId) {
1587 chat.length = 0;1615 chat.length = 0;
1588 await getGroupChat(groupId);1616 await getGroupChat(groupId);
1589 }1617 }
1590
1591 select_group_chats(groupId);
1592 }1618 }
1593}1619}
15941620
public/scripts/loader.js+47 -15
@@ -1,25 +1,57 @@
1import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
2
1const ELEMENT_ID = 'loader';3const ELEMENT_ID = 'loader';
24
5/** @type {Popup} */
6let loaderPopup;
7
8let preloaderYoinked = false;
9
3export function showLoader() {10export 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();
8}23}
924
10export async function hideLoader() {25export 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) {
12 $('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {27 console.warn('There is no loader showing to hide');
13 $(`#${ELEMENT_ID}`)28 return Promise.resolve();
14 //only fade out the spinner and replace with login screen29 }
15 .animate({ opacity: 0 }, 300, function () {30
16 $(`#${ELEMENT_ID}`).remove();31 return new Promise((resolve) => {
32 // Spinner blurs/fades out
33 $('#load-spinner').on('transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd', function () {
34 $(`#${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();
42 });
43 });
44
45 $('#load-spinner')
46 .css({
47 'filter': 'blur(15px)',
48 'opacity': '0',
17 });49 });
18 });50 });
51}
1952
20 $('#load-spinner')53function yoinkPreloader() {
21 .css({54 if (preloaderYoinked) return;
22 'filter': 'blur(15px)',55 document.getElementById('preloader').remove();
23 'opacity': '0',56 preloaderYoinked = true;
24 });
25}57}
public/scripts/macros.js+2 -2
@@ -254,7 +254,7 @@ function getCurrentSwipeId() {
254 // For swipe macro, we are accepting using the message that is currently being swiped254 // For swipe macro, we are accepting using the message that is currently being swiped
255 const mid = getLastMessageId({ exclude_swipe_in_propress: false });255 const mid = getLastMessageId({ exclude_swipe_in_propress: false });
256 const swipeId = chat[mid]?.swipe_id;256 const swipeId = chat[mid]?.swipe_id;
257 return swipeId ? swipeId + 1 : null;257 return swipeId !== null ? swipeId + 1 : null;
258}258}
259259
260/**260/**
@@ -401,7 +401,7 @@ function timeDiffReplace(input) {
401 const time2 = moment(matchPart2);401 const time2 = moment(matchPart2);
402402
403 const timeDifference = moment.duration(time1.diff(time2));403 const timeDifference = moment.duration(time1.diff(time2));
404 return timeDifference.humanize();404 return timeDifference.humanize(true);
405 });405 });
406406
407 return output;407 return output;
public/scripts/openai.js+1 -1
@@ -689,7 +689,7 @@ function formatWorldInfo(value) {
689 return '';689 return '';
690 }690 }
691691
692 if (!oai_settings.wi_format) {692 if (!oai_settings.wi_format.trim()) {
693 return value;693 return value;
694 }694 }
695695
public/scripts/popup.js+33 -13
@@ -1,3 +1,4 @@
1import { shouldSendOnEnter } from './RossAscends-mods.js';
1import { power_user } from './power-user.js';2import { power_user } from './power-user.js';
2import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';3import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
34
@@ -35,6 +36,7 @@ export const POPUP_RESULT = {
35 * @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content)36 * @property {boolean?} [transparent=false] - Whether to display the popup in transparent mode (no background, border, shadow or anything, only its content)
36 * @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup37 * @property {boolean?} [allowHorizontalScrolling=false] - Whether to allow horizontal scrolling in the popup
37 * @property {boolean?} [allowVerticalScrolling=false] - Whether to allow vertical scrolling in the popup38 * @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, ...)
38 * @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`.40 * @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`.
39 * @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.41 * @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.
40 * @property {CustomPopupInput[]?} [customInputs=null] - Custom inputs to add to the popup. The display below the content and the input box, one by one.42 * @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 = {
98 const result = await popup.show();100 const result = await popup.show();
99 if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);101 if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);
100 return result;102 return result;
101 }103 },
102};104};
103105
104export class Popup {106export class Popup {
@@ -142,7 +144,7 @@ export class Popup {
142 * @param {string} [inputValue=''] - The initial value of the input field144 * @param {string} [inputValue=''] - The initial value of the input field
143 * @param {PopupOptions} [options={}] - Additional options for the popup145 * @param {PopupOptions} [options={}] - Additional options for the popup
144 */146 */
145 constructor(content, type, inputValue = '', { okButton = null, cancelButton = null, rows = 1, wide = false, wider = false, large = false, transparent = false, allowHorizontalScrolling = false, allowVerticalScrolling = false, defaultResult = POPUP_RESULT.AFFIRMATIVE, customButtons = null, customInputs = null, onClosing = null, onClose = null, cropAspect = null, cropImage = null } = {}) {147 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 } = {}) {
146 Popup.util.popups.push(this);148 Popup.util.popups.push(this);
147149
148 // Make this popup uniquely identifiable150 // Make this popup uniquely identifiable
@@ -175,6 +177,7 @@ export class Popup {
175 if (transparent) this.dlg.classList.add('transparent_dialogue_popup');177 if (transparent) this.dlg.classList.add('transparent_dialogue_popup');
176 if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');178 if (allowHorizontalScrolling) this.dlg.classList.add('horizontal_scrolling_dialogue_popup');
177 if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup');179 if (allowVerticalScrolling) this.dlg.classList.add('vertical_scrolling_dialogue_popup');
180 if (animation) this.dlg.classList.add('popup--animation-' + animation);
178181
179 // If custom button captions are provided, we set them beforehand182 // If custom button captions are provided, we set them beforehand
180 this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK';183 this.okButton.textContent = typeof okButton === 'string' ? okButton : 'OK';
@@ -210,7 +213,7 @@ export class Popup {
210 this.customInputs = customInputs;213 this.customInputs = customInputs;
211 this.customInputs?.forEach(input => {214 this.customInputs?.forEach(input => {
212 if (!input.id || !(typeof input.id === 'string')) {215 if (!input.id || !(typeof input.id === 'string')) {
213 console.warn('Given custom input does not have a valid id set')216 console.warn('Given custom input does not have a valid id set');
214 return;217 return;
215 }218 }
216219
@@ -318,20 +321,20 @@ export class Popup {
318 if (String(undefined) === String(resultControl.dataset.result)) return;321 if (String(undefined) === String(resultControl.dataset.result)) return;
319 if (isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result);322 if (isNaN(result)) throw new Error('Invalid result control. Result must be a number. ' + resultControl.dataset.result);
320 const type = resultControl.dataset.resultEvent || 'click';323 const type = resultControl.dataset.resultEvent || 'click';
321 resultControl.addEventListener(type, () => this.complete(result));324 resultControl.addEventListener(type, async () => await this.complete(result));
322 });325 });
323326
324 // Bind dialog listeners manually, so we can be sure context is preserved327 // Bind dialog listeners manually, so we can be sure context is preserved
325 const cancelListener = (evt) => {328 const cancelListener = async (evt) => {
326 this.complete(POPUP_RESULT.CANCELLED);
327 evt.preventDefault();329 evt.preventDefault();
328 evt.stopPropagation();330 evt.stopPropagation();
331 await this.complete(POPUP_RESULT.CANCELLED);
329 window.removeEventListener('cancel', cancelListenerBound);332 window.removeEventListener('cancel', cancelListenerBound);
330 };333 };
331 const cancelListenerBound = cancelListener.bind(this);334 const cancelListenerBound = cancelListener.bind(this);
332 this.dlg.addEventListener('cancel', cancelListenerBound);335 this.dlg.addEventListener('cancel', cancelListenerBound);
333336
334 const keyListener = (evt) => {337 const keyListener = async (evt) => {
335 switch (evt.key) {338 switch (evt.key) {
336 case 'Enter': {339 case 'Enter': {
337 // CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this340 // CTRL+Enter counts as a closing action, but all other modifiers (ALT, SHIFT) should not trigger this
@@ -342,15 +345,23 @@ export class Popup {
342 if (this.dlg != document.activeElement?.closest('.popup'))345 if (this.dlg != document.activeElement?.closest('.popup'))
343 return;346 return;
344347
345 // Check if the current focus is a result control. Only should we apply the compelete action348 // Check if the current focus is a result control. Only should we apply the complete action
346 const resultControl = document.activeElement?.closest('.result-control');349 const resultControl = document.activeElement?.closest('.result-control');
347 if (!resultControl)350 if (!resultControl)
348 return;351 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
352 evt.preventDefault();361 evt.preventDefault();
353 evt.stopPropagation();362 evt.stopPropagation();
363 const result = Number(document.activeElement.getAttribute('data-result') ?? this.defaultResult);
364 await this.complete(result);
354 window.removeEventListener('keydown', keyListenerBound);365 window.removeEventListener('keydown', keyListenerBound);
355366
356 break;367 break;
@@ -430,8 +441,10 @@ export class Popup {
430 * - All other will return the result value as provided as `POPUP_RESULT` or a custom number value441 * - All other will return the result value as provided as `POPUP_RESULT` or a custom number value
431 *442 *
432 * @param {POPUP_RESULT|number} result - The result of the popup (either an existing `POPUP_RESULT` or a custom result value)443 * @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.
433 */446 */
434 complete(result) {447 async complete(result) {
435 // In all cases besides INPUT the popup value should be the result448 // In all cases besides INPUT the popup value should be the result
436 /** @type {POPUP_RESULT|number|boolean|string?} */449 /** @type {POPUP_RESULT|number|boolean|string?} */
437 let value = result;450 let value = result;
@@ -468,6 +481,8 @@ export class Popup {
468481
469 Popup.util.lastResult = { value, result, inputResults: this.inputResults };482 Popup.util.lastResult = { value, result, inputResults: this.inputResults };
470 this.#hide();483 this.#hide();
484
485 return this.#promise;
471 }486 }
472487
473 /**488 /**
@@ -618,8 +633,13 @@ export function fixToastrForDialogs() {
618 // Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists,633 // Now another case is if we only have one popup and that is currently closing. In that case the toastr container exists,
619 // 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.634 // 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.
620 // To prevent new toasts from being showing up in there and then vanish in an instant,635 // To prevent new toasts from being showing up in there and then vanish in an instant,
621 // we move the toastr back to the main body636 // we move the toastr back to the main body, or delete if its empty
622 if (!dlg && isAlreadyPresent) {637 if (!dlg && isAlreadyPresent) {
623 document.body.appendChild(toastContainer);638 if (!toastContainer.childNodes.length) {
639 toastContainer.remove();
640 } else {
641 document.body.appendChild(toastContainer);
642 toastContainer.classList.add('toast-top-center');
643 }
624 }644 }
625}645}
public/scripts/slash-commands.js+4 -4
@@ -468,7 +468,7 @@ export function initDefaultSlashCommands() {
468 description: 'display name',468 description: 'display name',
469 typeList: [ARGUMENT_TYPE.STRING],469 typeList: [ARGUMENT_TYPE.STRING],
470 defaultValue: '{{user}}',470 defaultValue: '{{user}}',
471 enumProvider: commonEnumProviders.characters('character'),471 enumProvider: commonEnumProviders.personas,
472 }),472 }),
473 ],473 ],
474 unnamedArgumentList: [474 unnamedArgumentList: [
@@ -1653,8 +1653,8 @@ async function buttonsCallback(args, text) {
1653 const buttonElement = document.createElement('div');1653 const buttonElement = document.createElement('div');
1654 buttonElement.classList.add('menu_button', 'result-control', 'wide100p');1654 buttonElement.classList.add('menu_button', 'result-control', 'wide100p');
1655 buttonElement.dataset.result = String(result);1655 buttonElement.dataset.result = String(result);
1656 buttonElement.addEventListener('click', () => {1656 buttonElement.addEventListener('click', async () => {
1657 popup?.complete(result);1657 await popup.complete(result);
1658 });1658 });
1659 buttonElement.innerText = button;1659 buttonElement.innerText = button;
1660 buttonContainer.appendChild(buttonElement);1660 buttonContainer.appendChild(buttonElement);
@@ -2762,7 +2762,7 @@ export async function sendMessageAs(args, text) {
2762 const isSystem = bias && !removeMacros(mesText).length;2762 const isSystem = bias && !removeMacros(mesText).length;
2763 const compact = isTrueBoolean(args?.compact);2763 const compact = isTrueBoolean(args?.compact);
27642764
2765 const character = characters.find(x => x.name === name);2765 const character = characters.find(x => x.avatar === name) ?? characters.find(x => x.name === name);
2766 let force_avatar, original_avatar;2766 let force_avatar, original_avatar;
27672767
2768 if (character && character.avatar !== 'none') {2768 if (character && character.avatar !== 'none') {
public/scripts/sse-stream.js+1 -1
@@ -160,7 +160,7 @@ async function* parseStreamData(json) {
160 return;160 return;
161 }161 }
162 // llama.cpp?162 // llama.cpp?
163 else if (typeof json.content === 'string' && json.content.length > 0) {163 else if (typeof json.content === 'string' && json.content.length > 0 && json.object !== 'chat.completion.chunk') {
164 for (let i = 0; i < json.content.length; i++) {164 for (let i = 0; i < json.content.length; i++) {
165 const str = json.content[i];165 const str = json.content[i];
166 yield {166 yield {
public/scripts/textgen-settings.js+13 -6
@@ -263,6 +263,8 @@ export const setting_names = [
263 'bypass_status_check',263 'bypass_status_check',
264];264];
265265
266const DYNATEMP_BLOCK = document.getElementById('dynatemp_block_ooba');
267
266export function validateTextGenUrl() {268export function validateTextGenUrl() {
267 const selector = SERVER_INPUTS[settings.type];269 const selector = SERVER_INPUTS[settings.type];
268270
@@ -1045,6 +1047,10 @@ export function isJsonSchemaSupported() {
1045 return [TABBY, LLAMACPP].includes(settings.type) && main_api === 'textgenerationwebui';1047 return [TABBY, LLAMACPP].includes(settings.type) && main_api === 'textgenerationwebui';
1046}1048}
10471049
1050function isDynamicTemperatureSupported() {
1051 return settings.dynatemp && DYNATEMP_BLOCK?.dataset?.tgType?.includes(settings.type);
1052}
1053
1048function getLogprobsNumber() {1054function getLogprobsNumber() {
1049 if (settings.type === VLLM || settings.type === INFERMATICAI) {1055 if (settings.type === VLLM || settings.type === INFERMATICAI) {
1050 return 5;1056 return 5;
@@ -1055,6 +1061,7 @@ function getLogprobsNumber() {
10551061
1056export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) {1062export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) {
1057 const canMultiSwipe = !isContinue && !isImpersonate && type !== 'quiet';1063 const canMultiSwipe = !isContinue && !isImpersonate && type !== 'quiet';
1064 const dynatemp = isDynamicTemperatureSupported();
1058 const { banned_tokens, banned_strings } = getCustomTokenBans();1065 const { banned_tokens, banned_strings } = getCustomTokenBans();
10591066
1060 let params = {1067 let params = {
@@ -1063,7 +1070,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
1063 'max_new_tokens': maxTokens,1070 'max_new_tokens': maxTokens,
1064 'max_tokens': maxTokens,1071 'max_tokens': maxTokens,
1065 'logprobs': power_user.request_token_probabilities ? getLogprobsNumber() : undefined,1072 'logprobs': power_user.request_token_probabilities ? getLogprobsNumber() : undefined,
1066 'temperature': settings.dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp,1073 'temperature': dynatemp ? (settings.min_temp + settings.max_temp) / 2 : settings.temp,
1067 'top_p': settings.top_p,1074 'top_p': settings.top_p,
1068 'typical_p': settings.typical_p,1075 'typical_p': settings.typical_p,
1069 'typical': settings.typical_p,1076 'typical': settings.typical_p,
@@ -1081,11 +1088,11 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
1081 'length_penalty': settings.length_penalty,1088 'length_penalty': settings.length_penalty,
1082 'early_stopping': settings.early_stopping,1089 'early_stopping': settings.early_stopping,
1083 'add_bos_token': settings.add_bos_token,1090 'add_bos_token': settings.add_bos_token,
1084 'dynamic_temperature': settings.dynatemp ? true : undefined,1091 'dynamic_temperature': dynatemp ? true : undefined,
1085 'dynatemp_low': settings.dynatemp ? settings.min_temp : undefined,1092 'dynatemp_low': dynatemp ? settings.min_temp : undefined,
1086 'dynatemp_high': settings.dynatemp ? settings.max_temp : undefined,1093 'dynatemp_high': dynatemp ? settings.max_temp : undefined,
1087 'dynatemp_range': settings.dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined,1094 'dynatemp_range': dynatemp ? (settings.max_temp - settings.min_temp) / 2 : undefined,
1088 'dynatemp_exponent': settings.dynatemp ? settings.dynatemp_exponent : undefined,1095 'dynatemp_exponent': dynatemp ? settings.dynatemp_exponent : undefined,
1089 'smoothing_factor': settings.smoothing_factor,1096 'smoothing_factor': settings.smoothing_factor,
1090 'smoothing_curve': settings.smoothing_curve,1097 'smoothing_curve': settings.smoothing_curve,
1091 'dry_allowed_length': settings.dry_allowed_length,1098 'dry_allowed_length': settings.dry_allowed_length,
public/scripts/utils.js+1 -1
@@ -803,7 +803,7 @@ export function getImageSizeFromDataURL(dataUrl) {
803803
804export function getCharaFilename(chid) {804export function getCharaFilename(chid) {
805 const context = getContext();805 const context = getContext();
806 const fileName = context.characters[chid ?? context.characterId].avatar;806 const fileName = context.characters[chid ?? context.characterId]?.avatar;
807807
808 if (fileName) {808 if (fileName) {
809 return fileName.replace(/\.[^/.]+$/, '');809 return fileName.replace(/\.[^/.]+$/, '');
public/scripts/variables.js+5 -1
@@ -1358,7 +1358,11 @@ export function registerVariableCommands() {
1358 name: 'times',1358 name: 'times',
1359 callback: timesCallback,1359 callback: timesCallback,
1360 returns: 'result of the last executed command',1360 returns: 'result of the last executed command',
1361 namedArgumentList: [],1361 namedArgumentList: [
1362 new SlashCommandNamedArgument(
1363 'guard', 'disable loop iteration limit', [ARGUMENT_TYPE.STRING], false, false, null, commonEnumProviders.boolean('onOff')(),
1364 ),
1365 ],
1362 unnamedArgumentList: [1366 unnamedArgumentList: [
1363 new SlashCommandArgument(1367 new SlashCommandArgument(
1364 'repeats',1368 'repeats',
src/endpoints/characters.js+17 -10
@@ -499,15 +499,16 @@ function convertWorldInfoToCharacterBook(name, entries) {
499 * Import a character from a YAML file.499 * Import a character from a YAML file.
500 * @param {string} uploadPath Path to the uploaded file500 * @param {string} uploadPath Path to the uploaded file
501 * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects501 * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
502 * @param {string|undefined} preservedFileName Preserved file name
502 * @returns {Promise<string>} Internal name of the character503 * @returns {Promise<string>} Internal name of the character
503 */504 */
504async function importFromYaml(uploadPath, context) {505async function importFromYaml(uploadPath, context, preservedFileName) {
505 const fileText = fs.readFileSync(uploadPath, 'utf8');506 const fileText = fs.readFileSync(uploadPath, 'utf8');
506 fs.rmSync(uploadPath);507 fs.rmSync(uploadPath);
507 const yamlData = yaml.parse(fileText);508 const yamlData = yaml.parse(fileText);
508 console.log('Importing from YAML');509 console.log('Importing from YAML');
509 yamlData.name = sanitize(yamlData.name);510 yamlData.name = sanitize(yamlData.name);
510 const fileName = getPngName(yamlData.name, context.request.user.directories);511 const fileName = preservedFileName || getPngName(yamlData.name, context.request.user.directories);
511 let char = convertToV2({512 let char = convertToV2({
512 'name': yamlData.name,513 'name': yamlData.name,
513 'description': yamlData.context ?? '',514 'description': yamlData.context ?? '',
@@ -532,9 +533,10 @@ async function importFromYaml(uploadPath, context) {
532 * @param {string} uploadPath533 * @param {string} uploadPath
533 * @param {object} params534 * @param {object} params
534 * @param {import('express').Request} params.request535 * @param {import('express').Request} params.request
536 * @param {string|undefined} preservedFileName Preserved file name
535 * @returns {Promise<string>} Internal name of the character537 * @returns {Promise<string>} Internal name of the character
536 */538 */
537async function importFromCharX(uploadPath, { request }) {539async function importFromCharX(uploadPath, { request }, preservedFileName) {
538 const data = fs.readFileSync(uploadPath);540 const data = fs.readFileSync(uploadPath);
539 fs.rmSync(uploadPath);541 fs.rmSync(uploadPath);
540 console.log('Importing from CharX');542 console.log('Importing from CharX');
@@ -567,7 +569,7 @@ async function importFromCharX(uploadPath, { request }) {
567 unsetFavFlag(card);569 unsetFavFlag(card);
568 card['create_date'] = humanizedISO8601DateTime();570 card['create_date'] = humanizedISO8601DateTime();
569 card.name = sanitize(card.name);571 card.name = sanitize(card.name);
570 const fileName = getPngName(card.name, request.user.directories);572 const fileName = preservedFileName || getPngName(card.name, request.user.directories);
571 const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);573 const result = await writeCharacterData(avatar, JSON.stringify(card), fileName, request);
572 return result ? fileName : '';574 return result ? fileName : '';
573}575}
@@ -576,9 +578,10 @@ async function importFromCharX(uploadPath, { request }) {
576 * Import a character from a JSON file.578 * Import a character from a JSON file.
577 * @param {string} uploadPath Path to the uploaded file579 * @param {string} uploadPath Path to the uploaded file
578 * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects580 * @param {{ request: import('express').Request, response: import('express').Response }} context Express request and response objects
581 * @param {string|undefined} preservedFileName Preserved file name
579 * @returns {Promise<string>} Internal name of the character582 * @returns {Promise<string>} Internal name of the character
580 */583 */
581async function importFromJson(uploadPath, { request }) {584async function importFromJson(uploadPath, { request }, preservedFileName) {
582 const data = fs.readFileSync(uploadPath, 'utf8');585 const data = fs.readFileSync(uploadPath, 'utf8');
583 fs.unlinkSync(uploadPath);586 fs.unlinkSync(uploadPath);
584587
@@ -590,7 +593,7 @@ async function importFromJson(uploadPath, { request }) {
590 unsetFavFlag(jsonData);593 unsetFavFlag(jsonData);
591 jsonData = readFromV2(jsonData);594 jsonData = readFromV2(jsonData);
592 jsonData['create_date'] = humanizedISO8601DateTime();595 jsonData['create_date'] = humanizedISO8601DateTime();
593 const pngName = getPngName(jsonData.data?.name || jsonData.name, request.user.directories);596 const pngName = preservedFileName || getPngName(jsonData.data?.name || jsonData.name, request.user.directories);
594 const char = JSON.stringify(jsonData);597 const char = JSON.stringify(jsonData);
595 const result = await writeCharacterData(defaultAvatarPath, char, pngName, request);598 const result = await writeCharacterData(defaultAvatarPath, char, pngName, request);
596 return result ? pngName : '';599 return result ? pngName : '';
@@ -600,7 +603,7 @@ async function importFromJson(uploadPath, { request }) {
600 if (jsonData.creator_notes) {603 if (jsonData.creator_notes) {
601 jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');604 jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
602 }605 }
603 const pngName = getPngName(jsonData.name, request.user.directories);606 const pngName = preservedFileName || getPngName(jsonData.name, request.user.directories);
604 let char = {607 let char = {
605 'name': jsonData.name,608 'name': jsonData.name,
606 'description': jsonData.description ?? '',609 'description': jsonData.description ?? '',
@@ -626,7 +629,7 @@ async function importFromJson(uploadPath, { request }) {
626 if (jsonData.creator_notes) {629 if (jsonData.creator_notes) {
627 jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');630 jsonData.creator_notes = jsonData.creator_notes.replace('Creator\'s notes go here.', '');
628 }631 }
629 const pngName = getPngName(jsonData.char_name, request.user.directories);632 const pngName = preservedFileName || getPngName(jsonData.char_name, request.user.directories);
630 let char = {633 let char = {
631 'name': jsonData.char_name,634 'name': jsonData.char_name,
632 'description': jsonData.char_persona ?? '',635 'description': jsonData.char_persona ?? '',
@@ -1089,8 +1092,8 @@ function getPngName(file, directories) {
1089 * @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined1092 * @returns {string | undefined} - The preserved name if the request is valid, otherwise undefined
1090 */1093 */
1091function getPreservedName(request) {1094function getPreservedName(request) {
1092 return request.body.file_type === 'png' && request.body.preserve_file_name === 'true' && request.file?.originalname1095 return typeof request.body.preserved_name === 'string' && request.body.preserved_name.length > 0
1093 ? path.parse(request.file.originalname).name1096 ? path.parse(request.body.preserved_name).name
1094 : undefined;1097 : undefined;
1095}1098}
10961099
@@ -1123,6 +1126,10 @@ router.post('/import', urlencodedParser, async function (request, response) {
1123 return response.sendStatus(400);1126 return response.sendStatus(400);
1124 }1127 }
11251128
1129 if (preservedFileName) {
1130 invalidateThumbnail(request.user.directories, 'avatar', `${preservedFileName}.png`);
1131 }
1132
1126 response.send({ file_name: fileName });1133 response.send({ file_name: fileName });
1127 } catch (err) {1134 } catch (err) {
1128 console.log(err);1135 console.log(err);
src/endpoints/settings.js+57 -6
@@ -3,7 +3,7 @@ const path = require('path');
3const express = require('express');3const express = require('express');
4const _ = require('lodash');4const _ = require('lodash');
5const writeFileAtomicSync = require('write-file-atomic').sync;5const writeFileAtomicSync = require('write-file-atomic').sync;
6const { PUBLIC_DIRECTORIES, SETTINGS_FILE } = require('../constants');6const { SETTINGS_FILE } = require('../constants');
7const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util');7const { getConfigValue, generateTimestamp, removeOldBackups } = require('../util');
8const { jsonParser } = require('../express-common');8const { jsonParser } = require('../express-common');
9const { getAllUserHandles, getUserDirectories } = require('../users');9const { getAllUserHandles, getUserDirectories } = require('../users');
@@ -27,12 +27,12 @@ const AUTOSAVE_FUNCTIONS = new Map();
27 */27 */
28function triggerAutoSave(handle) {28function triggerAutoSave(handle) {
29 if (!AUTOSAVE_FUNCTIONS.has(handle)) {29 if (!AUTOSAVE_FUNCTIONS.has(handle)) {
30 const throttledAutoSave = _.throttle(() => backupUserSettings(handle), AUTOSAVE_INTERVAL);30 const throttledAutoSave = _.throttle(() => backupUserSettings(handle, true), AUTOSAVE_INTERVAL);
31 AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);31 AUTOSAVE_FUNCTIONS.set(handle, throttledAutoSave);
32 }32 }
3333
34 const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);34 const functionToCall = AUTOSAVE_FUNCTIONS.get(handle);
35 if (functionToCall) {35 if (functionToCall && typeof functionToCall === 'function') {
36 functionToCall();36 functionToCall();
37 }37 }
38}38}
@@ -113,7 +113,7 @@ async function backupSettings() {
113 const userHandles = await getAllUserHandles();113 const userHandles = await getAllUserHandles();
114114
115 for (const handle of userHandles) {115 for (const handle of userHandles) {
116 backupUserSettings(handle);116 backupUserSettings(handle, true);
117 }117 }
118 } catch (err) {118 } catch (err) {
119 console.log('Could not backup settings file', err);119 console.log('Could not backup settings file', err);
@@ -123,13 +123,18 @@ async function backupSettings() {
123/**123/**
124 * Makes a backup of the user's settings file.124 * Makes a backup of the user's settings file.
125 * @param {string} handle User handle125 * @param {string} handle User handle
126 * @param {boolean} preventDuplicates Prevent duplicate backups
126 * @returns {void}127 * @returns {void}
127 */128 */
128function backupUserSettings(handle) {129function backupUserSettings(handle, preventDuplicates) {
129 const userDirectories = getUserDirectories(handle);130 const userDirectories = getUserDirectories(handle);
130 const backupFile = path.join(userDirectories.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`);131 const backupFile = path.join(userDirectories.backups, `${getFilePrefix(handle)}${generateTimestamp()}.json`);
131 const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);132 const sourceFile = path.join(userDirectories.root, SETTINGS_FILE);
132133
134 if (preventDuplicates && isDuplicateBackup(handle, sourceFile)) {
135 return;
136 }
137
133 if (!fs.existsSync(sourceFile)) {138 if (!fs.existsSync(sourceFile)) {
134 return;139 return;
135 }140 }
@@ -138,6 +143,52 @@ function backupUserSettings(handle) {
138 removeOldBackups(userDirectories.backups, `settings_${handle}`);143 removeOldBackups(userDirectories.backups, `settings_${handle}`);
139}144}
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 */
152function 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 */
165function 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 */
180function 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
141const router = express.Router();192const router = express.Router();
142193
143router.post('/save', jsonParser, function (request, response) {194router.post('/save', jsonParser, function (request, response) {
@@ -265,7 +316,7 @@ router.post('/load-snapshot', jsonParser, async (request, response) => {
265316
266router.post('/make-snapshot', jsonParser, async (request, response) => {317router.post('/make-snapshot', jsonParser, async (request, response) => {
267 try {318 try {
268 backupUserSettings(request.user.profile.handle);319 backupUserSettings(request.user.profile.handle, false);
269 response.sendStatus(204);320 response.sendStatus(204);
270 } catch (error) {321 } catch (error) {
271 console.log(error);322 console.log(error);
src/util.js+1 -1
@@ -371,7 +371,7 @@ function generateTimestamp() {
371 * @param {string} prefix File prefix to filter backups by.371 * @param {string} prefix File prefix to filter backups by.
372 */372 */
373function removeOldBackups(directory, prefix) {373function removeOldBackups(directory, prefix) {
374 const MAX_BACKUPS = 50;374 const MAX_BACKUPS = Number(getConfigValue('numberOfBackups', 50));
375375
376 let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix));376 let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix));
377 if (files.length > MAX_BACKUPS) {377 if (files.length > MAX_BACKUPS) {