-
-
+
`;
return html;
}
diff --git a/public/scripts/group-chats.js b/public/scripts/group-chats.js
index 57d54a036..7108a9914 100644
--- a/public/scripts/group-chats.js
+++ b/public/scripts/group-chats.js
@@ -110,10 +110,18 @@ export const group_generation_mode = {
APPEND: 1,
};
+const DEFAULT_AUTO_MODE_DELAY = 5;
+
export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, 100));
-setInterval(groupChatAutoModeWorker, 5000);
+let autoModeWorker = null;
const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), 500);
+function setAutoModeWorker() {
+ clearInterval(autoModeWorker);
+ const autoModeDelay = groups.find(x => x.id === selected_group)?.auto_mode_delay ?? DEFAULT_AUTO_MODE_DELAY;
+ autoModeWorker = setInterval(groupChatAutoModeWorker, autoModeDelay * 1000);
+}
+
async function _save(group, reload = true) {
await fetch('/api/groups/edit', {
method: 'POST',
@@ -1035,6 +1043,15 @@ async function onGroupGenerationModeInput(e) {
}
}
+async function onGroupAutoModeDelayInput(e) {
+ if (openGroupId) {
+ let _thisGroup = groups.find((x) => x.id == openGroupId);
+ _thisGroup.auto_mode_delay = Number(e.target.value);
+ await editGroup(openGroupId, false, false);
+ setAutoModeWorker();
+ }
+}
+
async function onGroupNameInput() {
if (openGroupId) {
let _thisGroup = groups.find((x) => x.id == openGroupId);
@@ -1231,6 +1248,7 @@ function select_group_chats(groupId, skipAnimation) {
$('#rm_group_submit').prop('disabled', !groupHasMembers);
$('#rm_group_allow_self_responses').prop('checked', group && group.allow_self_responses);
$('#rm_group_hidemutedsprites').prop('checked', group && group.hideMutedSprites);
+ $('#rm_group_automode_delay').val(group?.auto_mode_delay ?? DEFAULT_AUTO_MODE_DELAY);
// bottom buttons
if (openGroupId) {
@@ -1249,6 +1267,7 @@ function select_group_chats(groupId, skipAnimation) {
}
updateFavButtonState(group?.fav ?? false);
+ setAutoModeWorker();
// top bar
if (group) {
@@ -1441,6 +1460,7 @@ async function createGroup() {
let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');
let activationStrategy = Number($('#rm_group_activation_strategy').find(':selected').val()) ?? group_activation_strategy.NATURAL;
let generationMode = Number($('#rm_group_generation_mode').find(':selected').val()) ?? group_generation_mode.SWAP;
+ let autoModeDelay = Number($('#rm_group_automode_delay').val()) ?? DEFAULT_AUTO_MODE_DELAY;
const members = newGroupMembers;
const memberNames = characters.filter(x => members.includes(x.avatar)).map(x => x.name).join(', ');
@@ -1469,6 +1489,7 @@ async function createGroup() {
fav: fav_grp_checked,
chat_id: chatName,
chats: chats,
+ auto_mode_delay: autoModeDelay,
}),
});
@@ -1741,6 +1762,7 @@ jQuery(() => {
$('#rm_group_allow_self_responses').on('input', onGroupSelfResponsesClick);
$('#rm_group_activation_strategy').on('change', onGroupActivationStrategyInput);
$('#rm_group_generation_mode').on('change', onGroupGenerationModeInput);
+ $('#rm_group_automode_delay').on('input', onGroupAutoModeDelayInput);
$('#group_avatar_button').on('input', uploadGroupAvatar);
$('#rm_group_restore_avatar').on('click', restoreGroupAvatar);
$(document).on('click', '.group_member .right_menu_button', onGroupActionClick);
diff --git a/public/scripts/power-user.js b/public/scripts/power-user.js
index b5ac27f9d..7153bfd2d 100644
--- a/public/scripts/power-user.js
+++ b/public/scripts/power-user.js
@@ -232,6 +232,7 @@ let power_user = {
aux_field: 'character_version',
restore_user_input: true,
reduced_motion: false,
+ compact_input_area: true,
};
let themes = [];
@@ -273,6 +274,7 @@ const storage_keys = {
enableZenSliders: 'enableZenSliders',
enableLabMode: 'enableLabMode',
reduced_motion: 'reduced_motion',
+ compact_input_area: 'compact_input_area',
};
const contextControls = [
@@ -450,6 +452,13 @@ function switchReducedMotion() {
$('#reduced_motion').prop('checked', power_user.reduced_motion);
}
+function switchCompactInputArea() {
+ const value = localStorage.getItem(storage_keys.compact_input_area);
+ power_user.compact_input_area = value === null ? true : value == 'true';
+ $('#send_form').toggleClass('compact', power_user.compact_input_area);
+ $('#compact_input_area').prop('checked', power_user.compact_input_area);
+}
+
var originalSliderValues = [];
async function switchLabMode() {
@@ -1246,6 +1255,15 @@ async function applyTheme(name) {
action: async () => {
localStorage.setItem(storage_keys.reduced_motion, String(power_user.reduced_motion));
$('#reduced_motion').prop('checked', power_user.reduced_motion);
+ switchReducedMotion();
+ },
+ },
+ {
+ key: 'compact_input_area',
+ action: async () => {
+ localStorage.setItem(storage_keys.compact_input_area, String(power_user.compact_input_area));
+ $('#compact_input_area').prop('checked', power_user.compact_input_area);
+ switchCompactInputArea();
},
},
];
@@ -1504,6 +1522,7 @@ function loadPowerUserSettings(settings, data) {
$(`#character_sort_order option[data-order="${power_user.sort_order}"][data-field="${power_user.sort_field}"]`).prop('selected', true);
switchReducedMotion();
+ switchCompactInputArea();
reloadMarkdownProcessor(power_user.render_formulas);
loadInstructMode(data);
loadContextSettings();
@@ -1893,11 +1912,26 @@ function sortEntitiesList(entities) {
});
}
-async function saveTheme() {
- const name = await callPopup('Enter a theme preset name:', 'input');
+/**
+ * Updates the current UI theme file.
+ */
+async function updateTheme() {
+ await saveTheme(power_user.theme);
+ toastr.success('Theme saved.');
+}
- if (!name) {
- return;
+/**
+ * Saves the current theme to the server.
+ * @param {string|undefined} name Theme name. If undefined, a popup will be shown to enter a name.
+ * @returns {Promise
} A promise that resolves when the theme is saved.
+ */
+async function saveTheme(name = undefined) {
+ if (typeof name !== 'string') {
+ name = await callPopup('Enter a theme preset name:', 'input', power_user.theme);
+
+ if (!name) {
+ return;
+ }
}
const theme = {
@@ -1932,6 +1966,8 @@ async function saveTheme() {
hotswap_enabled: power_user.hotswap_enabled,
custom_css: power_user.custom_css,
bogus_folders: power_user.bogus_folders,
+ reduced_motion: power_user.reduced_motion,
+ compact_input_area: power_user.compact_input_area,
};
const response = await fetch('/savetheme', {
@@ -2804,7 +2840,8 @@ $(document).ready(() => {
saveSettingsDebounced();
});
- $('#ui-preset-save-button').on('click', saveTheme);
+ $('#ui-preset-save-button').on('click', () => saveTheme());
+ $('#ui-preset-update-button').on('click', () => updateTheme());
$('#movingui-preset-save-button').on('click', saveMovingUI);
$('#never_resize_avatars').on('input', function () {
@@ -3151,6 +3188,13 @@ $(document).ready(() => {
saveSettingsDebounced();
});
+ $('#compact_input_area').on('input', function () {
+ power_user.compact_input_area = !!$(this).prop('checked');
+ localStorage.setItem(storage_keys.compact_input_area, String(power_user.compact_input_area));
+ switchCompactInputArea();
+ saveSettingsDebounced();
+ });
+
$(document).on('click', '#debug_table [data-debug-function]', function () {
const functionId = $(this).data('debug-function');
const functionRecord = debug_functions.find(f => f.functionId === functionId);
diff --git a/server.js b/server.js
index 090dcdacd..153cb908f 100644
--- a/server.js
+++ b/server.js
@@ -93,9 +93,9 @@ const cliArguments = yargs(hideBin(process.argv))
}).parseSync();
// change all relative paths
-const directory = process['pkg'] ? path.dirname(process.execPath) : __dirname;
+const serverDirectory = process['pkg'] ? path.dirname(process.execPath) : __dirname;
console.log(process['pkg'] ? 'Running from binary' : 'Running from source');
-process.chdir(directory);
+process.chdir(serverDirectory);
const app = express();
app.use(compression());
@@ -621,6 +621,8 @@ const setupTasks = async function () {
exitProcess();
});
+ await loadPlugins();
+
console.log('Launching...');
if (autorun) open(autorunUrl.toString());
@@ -632,6 +634,16 @@ const setupTasks = async function () {
}
};
+async function loadPlugins() {
+ try {
+ const pluginDirectory = path.join(serverDirectory, 'plugins');
+ const loader = require('./src/plugin-loader');
+ await loader.loadPlugins(app, pluginDirectory);
+ } catch {
+ console.log('Plugin loading failed.');
+ }
+}
+
if (listen && !getConfigValue('whitelistMode', true) && !getConfigValue('basicAuthMode', false)) {
if (getConfigValue('securityOverride', false)) {
console.warn(color.red('Security has been overridden. If it\'s not a trusted network, change the settings.'));
diff --git a/src/endpoints/groups.js b/src/endpoints/groups.js
index d7341ff04..f8a117e84 100644
--- a/src/endpoints/groups.js
+++ b/src/endpoints/groups.js
@@ -73,6 +73,7 @@ router.post('/create', jsonParser, (request, response) => {
fav: request.body.fav,
chat_id: request.body.chat_id ?? id,
chats: request.body.chats ?? [id],
+ auto_mode_delay: request.body.auto_mode_delay ?? 5,
};
const pathToFile = path.join(DIRECTORIES.groups, `${id}.json`);
const fileData = JSON.stringify(groupMetadata);
diff --git a/src/plugin-loader.js b/src/plugin-loader.js
new file mode 100644
index 000000000..ba19fa603
--- /dev/null
+++ b/src/plugin-loader.js
@@ -0,0 +1,162 @@
+const fs = require('fs');
+const path = require('path');
+const { getConfigValue } = require('./util');
+const enableServerPlugins = getConfigValue('enableServerPlugins', false);
+
+/**
+ * Determine if a file is a CommonJS module.
+ * @param {string} file Path to file
+ * @returns {boolean} True if file is a CommonJS module
+ */
+const isCommonJS = (file) => path.extname(file) === '.js';
+
+/**
+ * Determine if a file is an ECMAScript module.
+ * @param {string} file Path to file
+ * @returns {boolean} True if file is an ECMAScript module
+ */
+const isESModule = (file) => path.extname(file) === '.mjs';
+
+/**
+ * Load and initialize server plugins from a directory if they are enabled.
+ * @param {import('express').Express} app Express app
+ * @param {string} pluginsPath Path to plugins directory
+ * @returns {Promise} Promise that resolves when all plugins are loaded
+ */
+async function loadPlugins(app, pluginsPath) {
+ // Server plugins are disabled.
+ if (!enableServerPlugins) {
+ return;
+ }
+
+ // Plugins directory does not exist.
+ if (!fs.existsSync(pluginsPath)) {
+ return;
+ }
+
+ const files = fs.readdirSync(pluginsPath);
+
+ // No plugins to load.
+ if (files.length === 0) {
+ return;
+ }
+
+ for (const file of files) {
+ const pluginFilePath = path.join(pluginsPath, file);
+
+ if (fs.statSync(pluginFilePath).isDirectory()) {
+ await loadFromDirectory(app, pluginFilePath);
+ continue;
+ }
+
+ // Not a JavaScript file.
+ if (!isCommonJS(file) && !isESModule(file)) {
+ continue;
+ }
+
+ await loadFromFile(app, pluginFilePath);
+ }
+}
+
+async function loadFromDirectory(app, pluginDirectoryPath) {
+ const files = fs.readdirSync(pluginDirectoryPath);
+
+ // No plugins to load.
+ if (files.length === 0) {
+ return;
+ }
+
+ // Plugin is an npm package.
+ const packageJsonFilePath = path.join(pluginDirectoryPath, 'package.json');
+ if (fs.existsSync(packageJsonFilePath)) {
+ if (await loadFromPackage(app, packageJsonFilePath)) {
+ return;
+ }
+ }
+
+ // Plugin is a CommonJS module.
+ const cjsFilePath = path.join(pluginDirectoryPath, 'index.js');
+ if (fs.existsSync(cjsFilePath)) {
+ if (await loadFromFile(app, cjsFilePath)) {
+ return;
+ }
+ }
+
+ // Plugin is an ECMAScript module.
+ const esmFilePath = path.join(pluginDirectoryPath, 'index.mjs');
+ if (fs.existsSync(esmFilePath)) {
+ if (await loadFromFile(app, esmFilePath)) {
+ return;
+ }
+ }
+}
+
+/**
+ * Loads and initializes a plugin from an npm package.
+ * @param {import('express').Express} app Express app
+ * @param {string} packageJsonPath Path to package.json file
+ * @returns {Promise} Promise that resolves to true if plugin was loaded successfully
+ */
+async function loadFromPackage(app, packageJsonPath) {
+ try {
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
+ if (packageJson.main) {
+ const pluginFilePath = path.join(path.dirname(packageJsonPath), packageJson.main);
+ return await loadFromFile(app, pluginFilePath);
+ }
+ } catch (error) {
+ console.error(`Failed to load plugin from ${packageJsonPath}: ${error}`);
+ }
+ return false;
+}
+
+/**
+ * Loads and initializes a plugin from a file.
+ * @param {import('express').Express} app Express app
+ * @param {string} pluginFilePath Path to plugin directory
+ * @returns {Promise} Promise that resolves to true if plugin was loaded successfully
+ */
+async function loadFromFile(app, pluginFilePath) {
+ try {
+ const plugin = await getPluginModule(pluginFilePath);
+ console.log(`Initializing plugin from ${pluginFilePath}`);
+ return await initPlugin(app, plugin);
+ } catch (error) {
+ console.error(`Failed to load plugin from ${pluginFilePath}: ${error}`);
+ return false;
+ }
+}
+
+/**
+ * Initializes a plugin module.
+ * @param {import('express').Express} app Express app
+ * @param {any} plugin Plugin module
+ * @returns {Promise} Promise that resolves to true if plugin was initialized successfully
+ */
+async function initPlugin(app, plugin) {
+ if (typeof plugin.init === 'function') {
+ await plugin.init(app);
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Loads a module from a file depending on the module type.
+ * @param {string} pluginFilePath Path to plugin file
+ * @returns {Promise} Promise that resolves to plugin module
+ */
+async function getPluginModule(pluginFilePath) {
+ if (isCommonJS(pluginFilePath)) {
+ return require(pluginFilePath);
+ }
+ if (isESModule(pluginFilePath)) {
+ return await import(pluginFilePath);
+ }
+ throw new Error(`Unsupported module type in ${pluginFilePath}`);
+}
+
+module.exports = {
+ loadPlugins,
+};