diff --git a/.gitignore b/.gitignore index d6a5061bb..fbef3d33f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ access.log public/css/user.css /plugins/ /data +/default/scaffold diff --git a/default/content/presets/openai/Default.json b/default/content/presets/openai/Default.json index dbf3b9619..132abeea0 100644 --- a/default/content/presets/openai/Default.json +++ b/default/content/presets/openai/Default.json @@ -231,6 +231,7 @@ "api_url_scale": "", "show_external_models": false, "assistant_prefill": "", + "assistant_impersonation": "", "human_sysprompt_message": "Let's get started. Please generate your response based on the information and instructions provided above.", "use_ai21_tokenizer": false, "use_google_tokenizer": false, diff --git a/default/content/settings.json b/default/content/settings.json index 3f4c25865..c04b88bfb 100644 --- a/default/content/settings.json +++ b/default/content/settings.json @@ -624,6 +624,7 @@ "show_external_models": false, "proxy_password": "", "assistant_prefill": "", + "assistant_impersonation": "", "use_ai21_tokenizer": false } } diff --git a/default/scaffold/README.md b/default/scaffold/README.md new file mode 100644 index 000000000..b1272cf8f --- /dev/null +++ b/default/scaffold/README.md @@ -0,0 +1,26 @@ +# Content Scaffolding + +Content files in this folder will be copied for all users (old and new) on the server startup. + +1. You **must** create an `index.json` file in `/default/scaffold` for it to work. The syntax is the same as for default content. +2. All file paths should be relative to `/default/scaffold`, the use of subdirectories is allowed. +3. Scaffolded files are copied first, so they override any of the default files (presets/settings/etc.) that have the same file name. + +## Example + +```json +[ + { + "filename": "themes/Midnight.json", + "type": "theme" + }, + { + "filename": "backgrounds/city.png", + "type": "background" + }, + { + "filename": "characters/Charlie.png", + "type": "character" + } +] +``` diff --git a/package-lock.json b/package-lock.json index 93c8ed8ee..2a99a2099 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "sillytavern", - "version": "1.12.0-preview", + "version": "1.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sillytavern", - "version": "1.12.0-preview", + "version": "1.12.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { "@agnai/sentencepiece-js": "^1.1.1", "@agnai/web-tokenizers": "^0.1.3", - "@dqbd/tiktoken": "^1.0.13", "@zeldafan0225/ai_horde": "^4.0.1", "archiver": "^7.0.1", "bing-translate-api": "^2.9.1", @@ -46,6 +45,7 @@ "sanitize-filename": "^1.6.3", "sillytavern-transformers": "^2.14.6", "simple-git": "^3.19.1", + "tiktoken": "^1.0.15", "vectra": "^0.2.2", "wavefile": "^11.0.0", "write-file-atomic": "^5.0.1", @@ -82,10 +82,6 @@ "version": "0.1.3", "license": "Apache-2.0" }, - "node_modules/@dqbd/tiktoken": { - "version": "1.0.13", - "license": "MIT" - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "dev": true, @@ -4403,6 +4399,11 @@ "dev": true, "license": "MIT" }, + "node_modules/tiktoken": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.15.tgz", + "integrity": "sha512-sCsrq/vMWUSEW29CJLNmPvWxlVp7yh2tlkAjpJltIKqp5CKf98ZNpdeHRmAlPVFlGEbswDc6SmI8vz64W/qErw==" + }, "node_modules/timm": { "version": "1.7.1", "license": "MIT" diff --git a/package.json b/package.json index 278b63283..e8cbda243 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "dependencies": { "@agnai/sentencepiece-js": "^1.1.1", "@agnai/web-tokenizers": "^0.1.3", - "@dqbd/tiktoken": "^1.0.13", "@zeldafan0225/ai_horde": "^4.0.1", "archiver": "^7.0.1", "bing-translate-api": "^2.9.1", @@ -36,6 +35,7 @@ "sanitize-filename": "^1.6.3", "sillytavern-transformers": "^2.14.6", "simple-git": "^3.19.1", + "tiktoken": "^1.0.15", "vectra": "^0.2.2", "wavefile": "^11.0.0", "write-file-atomic": "^5.0.1", @@ -68,14 +68,15 @@ "type": "git", "url": "https://github.com/SillyTavern/SillyTavern.git" }, - "version": "1.12.0-preview", + "version": "1.12.0", "scripts": { "start": "node server.js", "start:no-csrf": "node server.js --disableCsrf", "postinstall": "node post-install.js", "lint": "eslint \"src/**/*.js\" \"public/**/*.js\" ./*.js", "lint:fix": "eslint \"src/**/*.js\" \"public/**/*.js\" ./*.js --fix", - "plugins:update": "node plugins update" + "plugins:update": "node plugins update", + "plugins:install": "node plugins install" }, "bin": { "sillytavern": "./server.js" diff --git a/plugins.js b/plugins.js index 4615951c6..63d21778a 100644 --- a/plugins.js +++ b/plugins.js @@ -15,6 +15,12 @@ if (command === 'update') { updatePlugins(); } +if (command === 'install') { + const pluginName = process.argv[3]; + console.log('Installing a new plugin', color.green(pluginName)); + installPlugin(pluginName); +} + async function updatePlugins() { const directories = fs.readdirSync(pluginsPath) .filter(file => !file.startsWith('.')) @@ -51,3 +57,19 @@ async function updatePlugins() { console.log(color.magenta('All plugins updated!')); } + +async function installPlugin(pluginName) { + try { + const pluginPath = path.join(pluginsPath, path.basename(pluginName, '.git')); + + if (fs.existsSync(pluginPath)) { + return console.log(color.yellow(`Directory already exists at ${pluginPath}`)); + } + + await git().clone(pluginName, pluginPath, { '--depth': 1 }); + console.log(`Plugin ${color.green(pluginName)} installed to ${color.cyan(pluginPath)}`); + } + catch (error) { + console.error(color.red(`Failed to install plugin ${pluginName}`), error); + } +} diff --git a/public/css/select2-overrides.css b/public/css/select2-overrides.css index cdab229d8..776bf48ed 100644 --- a/public/css/select2-overrides.css +++ b/public/css/select2-overrides.css @@ -171,3 +171,78 @@ .select2-results__option.select2-results__message::before { display: none; } + +.select2-selection__choice__display { + /* Fix weird alignment on the left side */ + margin-left: 1px; +} + +/* Styling for choice remove icon */ +span.select2.select2-container .select2-selection__choice__remove { + cursor: pointer; + transition: background-color 0.3s; + color: var(--SmartThemeBodyColor); + background-color: var(--black50a); +} + +span.select2.select2-container .select2-selection__choice__remove:hover { + color: var(--SmartThemeBodyColor); + background-color: var(--white30a); +} + +/* Custom class to support styling to show clickable choice options */ +.select2_choice_clickable+span.select2-container .select2-selection__choice__display { + cursor: pointer; +} +.select2_choice_clickable_buttonstyle+span.select2-container .select2-selection__choice__display { + cursor: pointer; + transition: background-color 0.3s; + color: var(--SmartThemeBodyColor); + background-color: var(--black50a); +} + +.select2_choice_clickable_buttonstyle+span.select2-container .select2-selection__choice__display:hover { + background-color: var(--white30a); +} + +/* Custom class to support same line multi inputs of select2 controls */ +.select2_multi_sameline+span.select2-container .select2-selection--multiple { + display: flex; + flex-wrap: wrap; +}.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-search--inline { + /* Allow search placeholder to take up all space if needed */ + flex-grow: 1; +} + +.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-selection__rendered { + /* Fix weird styling choice or huge margin around selected options */ + margin-block-start: 2px; + margin-block-end: 2px; +} + +.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-search__field { + /* Min height to reserve spacing */ + min-height: calc(var(--mainFontSize) + 13px); + /* Min width to be clickable */ + min-width: 4em; + align-content: center; + /* Fix search textarea alignment issue with UL elements */ + margin-top: 0px; + height: unset; + /* Prevent height from jumping around when input is focused */ + line-height: 1; +} + +.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-selection__rendered { + /* Min height to reserve spacing */ + min-height: calc(var(--mainFontSize) + 13px); +} + +/* Make search bar invisible unless the select2 is active, to save space */ +.select2_multi_sameline+span.select2-container .select2-selection--multiple .select2-search--inline { + height: 1px; +} + +.select2_multi_sameline+span.select2-container.select2-container--focus .select2-selection--multiple .select2-search--inline { + height: unset; +} diff --git a/public/css/tags.css b/public/css/tags.css index 11806c69a..f9896d992 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -103,7 +103,8 @@ } #bulkTagsList, -#tagList .tag { +#tagList .tag, +#groupTagList .tag { opacity: 0.6; } @@ -193,7 +194,8 @@ filter: brightness(75%) saturate(0.6); } -.tag_as_folder:hover { +.tag_as_folder:hover, +.tag_as_folder.flash { filter: brightness(150%) saturate(0.6) !important; } diff --git a/public/css/world-info.css b/public/css/world-info.css index ef814a104..78af36950 100644 --- a/public/css/world-info.css +++ b/public/css/world-info.css @@ -76,6 +76,12 @@ .world_entry_form_control { display: flex; flex-direction: column; + position: relative; +} + +.world_entry_form_control .keyprimarytextpole, +.world_entry_form_control .keysecondarytextpole { + padding-right: 25px; } .world_entry_thin_controls { @@ -101,7 +107,7 @@ height: auto; margin-top: 0; margin-bottom: 0; - min-height: calc(var(--mainFontSize) + 13px); + min-height: calc(var(--mainFontSize) + 14px); } .delete_entry_button { @@ -197,20 +203,57 @@ display: none; } -#world_info+span.select2-container .select2-selection__choice__remove, -#world_info+span.select2-container .select2-selection__choice__display { - cursor: pointer; - transition: background-color 0.3s; +span.select2-container .select2-selection__choice__display:has(> .regex_item), +span.select2-container .select2-results__option:has(> .result_block .regex_item) { + background-color: #D27D2D30; +} + +.regex_item .regex_icon { + background-color: var(--black30a); color: var(--SmartThemeBodyColor); - background-color: var(--black50a); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 7px; + font-weight: bold; + font-size: calc(var(--mainFontSize) * 0.75); + padding: 0px 3px; + position: relative; + top: -1px; + margin-right: 3px; } -#world_info+span.select2-container .select2-selection__choice__display { - /* Fix weird alignment on the left side */ - margin-left: 1px; +.select2-results__option .regex_item .regex_icon { + margin-right: 6px; } -#world_info+span.select2-container .select2-selection__choice__remove:hover, -#world_info+span.select2-container .select2-selection__choice__display:hover { - background-color: var(--white30a); +.select2-results__option .item_count { + margin-left: 10px; + float: right; +} + +select.keyselect+span.select2-container .select2-selection--multiple { + padding-right: 30px; +} + +.switch_input_type_icon { + cursor: pointer; + font-weight: bold; + height: 20px; + width: fit-content; + margin-right: 5px; + margin-top: calc(5px + var(--mainFontSize)); + position: absolute; + right: 0; + padding: 1px; + + background-color: transparent; + border: none; + font-size: 1em; + + opacity: 0.5; + color: var(--SmartThemeBodyColor); + transition: opacity 0.3s; +} + +.switch_input_type_icon:hover { + opacity: 1; } diff --git a/public/index.html b/public/index.html index ae0ba93de..5c2f065d7 100644 --- a/public/index.html +++ b/public/index.html @@ -116,7 +116,7 @@
/memberadd John Doe
+ /member-add John Doe
/memberremove 2
- /memberremove John Doe
+ /member-remove 2
+ /member-remove John Doe
hidden=off
argument to exclude hidden messages.
+ role
argument to filter messages by role. Possible values are: system, assistant, user.
+ {{char}}
).
+ If the tag doesn't exist, it is created.
+ /tag-add name="Chloe" scenario
+ will add the tag "scenario" to the character named Chloe.
+ {{char}}
).
+ /tag-remove name="Chloe" scenario
+ will remove the tag "scenario" from the character named Chloe.
+ {{char}}
).
+ /tag-exists name="Chloe" scenario
+ will return true if the character named Chloe has the tag "scenario".
+ {{char}}
).
+ /tag-list name="Chloe"
+ could return something like OC, scenario, edited, funny
+
and select a Chat API.
and pick a character
+ Click
and pick a character.
.
a
with the value of the right operand b
,
@@ -1132,6 +1196,7 @@ export function registerVariableCommands() {
'command to execute while true', [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND], true,
),
],
+ splitUnnamedArgument: true,
helpString: `
a
with the value of the right operand b
,
@@ -1158,7 +1223,7 @@ export function registerVariableCommands() {
Examples:
/setvar key=i 0 | /while left=i right=10 rule=let "/addvar key=i 1"
+ /setvar key=i 0 | /while left=i right=10 rule=lte "/addvar key=i 1"
adds 1 to the value of "i" until it reaches 10.
repeats
number of times.
@@ -1592,7 +1658,7 @@ export function registerVariableCommands() {
returns: 'length of the provided value',
unnamedArgumentList: [
new SlashCommandArgument(
- 'value', [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], true
+ 'value', [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], true,
),
],
helpString: `
diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js
index af102a45c..10a9cc47e 100644
--- a/public/scripts/world-info.js
+++ b/public/scripts/world-info.js
@@ -1,5 +1,5 @@
import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
-import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight } from './utils.js';
+import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { isMobile } from './RossAscends-mods.js';
@@ -69,7 +69,7 @@ const saveSettingsDebounced = debounce(() => {
saveSettings();
}, debounce_timeout.relaxed);
const sortFn = (a, b) => b.order - a.order;
-let updateEditor = (navigation) => { console.debug('Triggered WI navigation', navigation); };
+let updateEditor = (navigation, flashOnNav = true) => { console.debug('Triggered WI navigation', navigation, flashOnNav); };
// Do not optimize. updateEditor is a function that is updated by the displayWorldEntries with new data.
const worldInfoFilter = new FilterHelper(() => updateEditor());
@@ -197,6 +197,13 @@ class WorldInfoBuffer {
* @returns {boolean} True if the string was found in the buffer
*/
matchKeys(haystack, needle, entry) {
+ // If the needle is a regex, we do regex pattern matching and override all the other options
+ const keyRegex = parseRegexFromString(needle);
+ if (keyRegex) {
+ return keyRegex.test(haystack);
+ }
+
+ // Otherwise we do normal matching of plaintext with the chosen entry settings
const transformedString = this.#transformString(needle, entry);
const matchWholeWords = entry.matchWholeWords ?? world_info_match_whole_words;
@@ -541,6 +548,19 @@ function registerWorldInfoSlashCommands() {
return '';
}
+ if (typeof newEntryTemplate[field] === 'boolean') {
+ const isTrue = isTrueBoolean(value);
+ const isFalse = isFalseBoolean(value);
+
+ if (isTrue) {
+ value = String(true);
+ }
+
+ if (isFalse) {
+ value = String(false);
+ }
+ }
+
const fuse = new Fuse(entries, {
keys: [{ name: field, weight: 1 }],
includeScore: true,
@@ -984,8 +1004,39 @@ function nullWorldInfo() {
toastr.info('Create or import a new World Info file first.', 'World Info is not set', { timeOut: 10000, preventDuplicates: true });
}
-function displayWorldEntries(name, data, navigation = navigation_option.none) {
- updateEditor = (navigation) => displayWorldEntries(name, data, navigation);
+/** @type {Select2Option[]} Cache all keys as selectable dropdown option */
+const worldEntryKeyOptionsCache = [];
+
+/**
+ * Update the cache and all select options for the keys with new values to display
+ * @param {string[]|Select2Option[]} keyOptions - An array of options to update
+ * @param {object} options - Optional arguments
+ * @param {boolean?} [options.remove=false] - Whether the option was removed, so the count should be reduced - otherwise it'll be increased
+ * @param {boolean?} [options.reset=false] - Whether the cache should be reset. Reset will also not trigger update of the controls, as we expect them to be redrawn anyway
+ */
+function updateWorldEntryKeyOptionsCache(keyOptions, { remove = false, reset = false } = {}) {
+ if (!keyOptions.length) return;
+ /** @type {Select2Option[]} */
+ const options = keyOptions.map(x => typeof x === 'string' ? { id: getSelect2OptionId(x), text: x } : x);
+ if (reset) worldEntryKeyOptionsCache.length = 0;
+ options.forEach(option => {
+ // Update the cache list
+ let cachedEntry = worldEntryKeyOptionsCache.find(x => x.id == option.id);
+ if (cachedEntry) {
+ cachedEntry.count += !remove ? 1 : -1;
+ } else if (!remove) {
+ worldEntryKeyOptionsCache.push(option);
+ cachedEntry = option;
+ cachedEntry.count = 1;
+ }
+ });
+
+ // Sort by count DESC and then alphabetically
+ worldEntryKeyOptionsCache.sort((a, b) => b.count - a.count || a.text.localeCompare(b.text));
+}
+
+function displayWorldEntries(name, data, navigation = navigation_option.none, flashOnNav = true) {
+ updateEditor = (navigation, flashOnNav = true) => displayWorldEntries(name, data, navigation, flashOnNav);
const worldEntriesList = $('#world_popup_entries_list');
@@ -1020,6 +1071,10 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
entriesArray = worldInfoFilter.applyFilters(entriesArray);
entriesArray = sortEntries(entriesArray);
+ // Cache keys
+ const keys = entriesArray.flatMap(entry => [...entry.key, ...entry.keysecondary]);
+ updateWorldEntryKeyOptionsCache(keys, { reset: true });
+
// Run the callback for printing this
typeof callback === 'function' && callback(entriesArray);
return entriesArray;
@@ -1036,7 +1091,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
$('#world_info_pagination').pagination({
dataSource: getDataArray,
pageSize: Number(localStorage.getItem(storageKey)) || perPageDefault,
- sizeChangerOptions: [10, 25, 50, 100],
+ sizeChangerOptions: [10, 25, 50, 100, 500, 1000],
showSizeChanger: true,
pageRange: 1,
pageNumber: startPage,
@@ -1114,7 +1169,7 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
const parentOffset = element.parent().offset();
const scrollOffset = elementOffset.top - parentOffset.top;
$('#WorldInfo').scrollTop(scrollOffset);
- flashHighlight(element);
+ if (flashOnNav) flashHighlight(element);
});
}
@@ -1202,9 +1257,10 @@ function displayWorldEntries(name, data, navigation = navigation_option.none) {
}
worldEntriesList.sortable({
+ items: '.world_entry',
delay: getSortableDelay(),
handle: '.drag-handle',
- stop: async function (event, ui) {
+ stop: async function (_event, _ui) {
const firstEntryUid = $('#world_popup_entries_list .world_entry').first().data('uid');
const minDisplayIndex = data?.entries[firstEntryUid]?.displayIndex ?? 0;
$('#world_popup_entries_list .world_entry').each(function (index) {
@@ -1234,6 +1290,7 @@ const originalDataKeyMap = {
'displayIndex': 'extensions.display_index',
'excludeRecursion': 'extensions.exclude_recursion',
'preventRecursion': 'extensions.prevent_recursion',
+ 'delayUntilRecursion': 'extensions.delay_until_recursion',
'selectiveLogic': 'selectiveLogic',
'comment': 'comment',
'constant': 'constant',
@@ -1299,6 +1356,139 @@ function deleteOriginalDataValue(data, uid) {
}
}
+/** @typedef {import('./utils.js').Select2Option} Select2Option */
+
+/**
+ * Splits a given input string that contains one or more keywords or regexes, separated by commas.
+ *
+ * Each part can be a valid regex following the pattern `/myregex/flags` with optional flags. Commmas inside the regex are allowed, slashes have to be escaped like this: `\/`
+ * If a regex doesn't stand alone, it is not treated as a regex.
+ *
+ * @param {string} input - One or multiple keywords or regexes, separated by commas
+ * @returns {string[]} An array of keywords and regexes
+ */
+function splitKeywordsAndRegexes(input) {
+ /** @type {string[]} */
+ let keywordsAndRegexes = [];
+
+ // We can make this easy. Instead of writing another function to find and parse regexes,
+ // we gonna utilize the custom tokenizer that also handles the input.
+ // No need for validation here
+ const addFindCallback = (/** @type {Select2Option} */ item) => {
+ keywordsAndRegexes.push(item.text);
+ };
+
+ const { term } = customTokenizer({ _type: 'custom_call', term: input }, undefined, addFindCallback);
+ const finalTerm = term.trim();
+ if (finalTerm) {
+ addFindCallback({ id: getSelect2OptionId(finalTerm), text: finalTerm });
+ }
+
+ return keywordsAndRegexes;
+}
+
+/**
+ * Tokenizer parsing input and splitting it into keywords and regexes
+ *
+ * @param {{_type: string, term: string}} input - The typed input
+ * @param {{options: object}} _selection - The selection even object (?)
+ * @param {function(Select2Option):void} callback - The original callback function to call if an item should be inserted
+ * @returns {{term: string}} - The remaining part that is untokenized in the textbox
+ */
+function customTokenizer(input, _selection, callback) {
+ let current = input.term;
+
+ // Go over the input and check the current state, if we can get a token
+ for (let i = 0; i < current.length; i++) {
+ let char = current[i];
+
+ // If a comma is typed, we tokenize the input.
+ // unless we are inside a possible regex, which would allow commas inside
+ if (char === ',') {
+ // We take everything up till now and consider this a token
+ const token = current.slice(0, i).trim();
+
+ // Now how we test if this is a valid regex? And not a finished one, but a half-finished one?
+ // Easy, if someone typed a comma it can't be a delimiter escape.
+ // So we just check if this opening with a slash, and if so, we "close" the regex and try to parse it.
+ // So if we are inside a valid regex, we can't take the token now, we continue processing until the regex is closed,
+ // or this is not a valid regex anymore
+ if (token.startsWith('/') && isValidRegex(token + '/')) {
+ continue;
+ }
+
+ // So now the comma really means the token is done.
+ // We take the token up till now, and insert it. Empty will be skipped.
+ if (token) {
+ const isRegex = isValidRegex(token);
+
+ // Last chance to check for valid regex again. Because it might have been valid while typing, but now is not valid anymore and contains commas we need to split.
+ if (token.startsWith('/') && !isRegex) {
+ const tokens = token.split(',').map(x => x.trim());
+ tokens.forEach(x => callback({ id: getSelect2OptionId(x), text: x }));
+ } else {
+ callback({ id: getSelect2OptionId(token), text: token });
+ }
+ }
+
+ // Now remove the token from the current input, and the comma too
+ current = current.slice(i + 1);
+ i = 0;
+ }
+ }
+
+ // At the end, just return the left-over input
+ return { term: current };
+}
+
+/**
+ * Validates if a string is a valid slash-delimited regex, that can be parsed and executed
+ *
+ * This is a wrapper around `parseRegexFromString`
+ *
+ * @param {string} input - A delimited regex string
+ * @returns {boolean} Whether this would be a valid regex that can be parsed and executed
+ */
+function isValidRegex(input) {
+ return parseRegexFromString(input) !== null;
+}
+
+/**
+ * Gets a real regex object from a slash-delimited regex string
+ *
+ * This function works with `/` as delimiter, and each occurance of it inside the regex has to be escaped.
+ * Flags are optional, but can only be valid flags supported by JavaScript's `RegExp` (`g`, `i`, `m`, `s`, `u`, `y`).
+ *
+ * @param {string} input - A delimited regex string
+ * @returns {RegExp|null} The regex object, or null if not a valid regex
+ */
+function parseRegexFromString(input) {
+ // Extracting the regex pattern and flags
+ let match = input.match(/^\/([\w\W]+?)\/([gimsuy]*)$/);
+ if (!match) {
+ return null; // Not a valid regex format
+ }
+
+ let [, pattern, flags] = match;
+
+ // If we find any unescaped slash delimiter, we also exit out.
+ // JS doesn't care about delimiters inside regex patterns, but for this to be a valid regex outside of our implementation,
+ // we have to make sure that our delimiter is correctly escaped. Or every other engine would fail.
+ if (pattern.match(/(^|[^\\])\//)) {
+ return null;
+ }
+
+ // Now we need to actually unescape the slash delimiters, because JS doesn't care about delimiters
+ pattern = pattern.replace('\\/', '/');
+
+ // Then we return the regex. If it fails, it was invalid syntax.
+ try {
+ return new RegExp(pattern, flags);
+ } catch (e) {
+ return null;
+ }
+}
+
function getWorldEntry(name, data, entry) {
if (!data.entries[entry.uid]) {
return;
@@ -1308,28 +1498,125 @@ function getWorldEntry(name, data, entry) {
template.data('uid', entry.uid);
template.attr('uid', entry.uid);
+ // Init default state of WI Key toggle (=> true)
+ if (typeof power_user.wi_key_input_plaintext === 'undefined') power_user.wi_key_input_plaintext = true;
+
+ /** Function to build the keys input controls @param {string} entryPropName @param {string} originalDataValueName */
+ function enableKeysInput(entryPropName, originalDataValueName) {
+ const isFancyInput = !isMobile() && !power_user.wi_key_input_plaintext;
+ const input = isFancyInput ? template.find(`select[name="${entryPropName}"]`) : template.find(`textarea[name="${entryPropName}"]`);
+ input.data('uid', entry.uid);
+ input.on('click', function (event) {
+ // Prevent closing the drawer on clicking the input
+ event.stopPropagation();
+ });
+
+ function templateStyling(/** @type {Select2Option} */ item, { searchStyle = false } = {}) {
+ const content = $('').addClass('item').text(item.text).attr('title', `${item.text}\n\nClick to edit`);
+ const isRegex = isValidRegex(item.text);
+ if (isRegex) {
+ content.html(highlightRegex(item.text));
+ content.addClass('regex_item').prepend($('').addClass('regex_icon').text('•*').attr('title', 'Regex'));
+ }
+
+ if (searchStyle && item.count) {
+ // Build a wrapping element
+ const wrapper = $('').addClass('result_block')
+ .append(content);
+ wrapper.append($('').addClass('item_count').text(item.count).attr('title', `Used as a key ${item.count} ${item.count != 1 ? 'times' : 'time'} in this lorebook`));
+ return wrapper;
+ }
+
+ return content;
+ }
+
+ if (isFancyInput) {
+ input.select2({
+ ajax: dynamicSelect2DataViaAjax(() => worldEntryKeyOptionsCache),
+ tags: true,
+ tokenSeparators: [','],
+ tokenizer: customTokenizer,
+ placeholder: input.attr('placeholder'),
+ templateResult: item => templateStyling(item, { searchStyle: true }),
+ templateSelection: item => templateStyling(item),
+ });
+ input.on('change', function (_, { skipReset, noSave } = {}) {
+ const uid = $(this).data('uid');
+ /** @type {string[]} */
+ const keys = ($(this).select2('data')).map(x => x.text);
+
+ !skipReset && resetScrollHeight(this);
+ if (!noSave) {
+ data.entries[uid][entryPropName] = keys;
+ setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
+ saveWorldInfo(name, data);
+ }
+ });
+ input.on('select2:select', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data]));
+ input.on('select2:unselect', /** @type {function(*):void} */ event => updateWorldEntryKeyOptionsCache([event.params.data], { remove: true }));
+
+ select2ChoiceClickSubscribe(input, target => {
+ const key = $(target).text();
+ console.debug('Editing WI key', key);
+
+ // Remove the current key from the actual selection
+ const selected = input.val();
+ if (!Array.isArray(selected)) return;
+ var index = selected.indexOf(getSelect2OptionId(key));
+ if (index > -1) selected.splice(index, 1);
+ input.val(selected).trigger('change');
+ // Manually update the cache, that change event is not gonna trigger it
+ updateWorldEntryKeyOptionsCache([key], { remove: true });
+
+ // We need to "hack" the actual text input into the currently open textarea
+ input.next('span.select2-container').find('textarea')
+ .val(key).trigger('input');
+ }, { openDrawer: true });
+
+ select2ModifyOptions(input, entry[entryPropName], { select: true, changeEventArgs: { skipReset: true, noSave: true } });
+ }
+ else {
+ // Compatibility with mobile devices. On mobile we need a text input field, not a select option control, so we need its own event handlers
+ template.find(`select[name="${entryPropName}"]`).hide();
+ input.show();
+
+ input.on('input', function (_, { skipReset, noSave } = {}) {
+ const uid = $(this).data('uid');
+ const value = String($(this).val());
+ !skipReset && resetScrollHeight(this);
+ if (!noSave) {
+ data.entries[uid][entryPropName] = splitKeywordsAndRegexes(value);
+ setOriginalDataValue(data, uid, originalDataValueName, data.entries[uid][entryPropName]);
+ saveWorldInfo(name, data);
+ }
+ });
+ input.val(entry[entryPropName].join(', ')).trigger('input', { skipReset: true });
+ }
+ return { isFancy: isFancyInput, control: input };
+ }
+
// key
- const keyInput = template.find('textarea[name="key"]');
- keyInput.data('uid', entry.uid);
- keyInput.on('click', function (event) {
- // Prevent closing the drawer on clicking the input
- event.stopPropagation();
- });
+ const keyInput = enableKeysInput('key', 'keys');
- keyInput.on('input', function (_, { skipReset } = {}) {
- const uid = $(this).data('uid');
- const value = String($(this).val());
- !skipReset && resetScrollHeight(this);
- data.entries[uid].key = value
- .split(',')
- .map((x) => x.trim())
- .filter((x) => x);
+ // keysecondary
+ const keySecondaryInput = enableKeysInput('keysecondary', 'secondary_keys');
- setOriginalDataValue(data, uid, 'keys', data.entries[uid].key);
- saveWorldInfo(name, data);
+ // draw key input switch button
+ template.find('.switch_input_type_icon').on('click', function () {
+ power_user.wi_key_input_plaintext = !power_user.wi_key_input_plaintext;
+ saveSettingsDebounced();
+
+ // Just redraw the panel
+ const uid = ($(this).parents('.world_entry')).data('uid');
+ updateEditor(uid, false);
+
+ $(`.world_entry[uid="${uid}"] .inline-drawer-icon`).trigger('click');
+ // setTimeout(() => {
+ // }, debounce_timeout.standard);
+ }).each((_, icon) => {
+ $(icon).attr('title', $(icon).data(power_user.wi_key_input_plaintext ? 'tooltip-on' : 'tooltip-off'));
+ $(icon).text($(icon).data(power_user.wi_key_input_plaintext ? 'icon-on' : 'icon-off'));
});
- keyInput.val(entry.key.join(', ')).trigger('input', { skipReset: true });
- //initScrollHeight(keyInput);
// logic AND/NOT
const selectiveLogicDropdown = template.find('select[name="entryLogicType"]');
@@ -1458,25 +1745,6 @@ function getWorldEntry(name, data, entry) {
saveWorldInfo(name, data);
});
- // keysecondary
- const keySecondaryInput = template.find('textarea[name="keysecondary"]');
- keySecondaryInput.data('uid', entry.uid);
- keySecondaryInput.on('input', function (_, { skipReset } = {}) {
- const uid = $(this).data('uid');
- const value = String($(this).val());
- !skipReset && resetScrollHeight(this);
- data.entries[uid].keysecondary = value
- .split(',')
- .map((x) => x.trim())
- .filter((x) => x);
-
- setOriginalDataValue(data, uid, 'secondary_keys', data.entries[uid].keysecondary);
- saveWorldInfo(name, data);
- });
-
- keySecondaryInput.val(entry.keysecondary.join(', ')).trigger('input', { skipReset: true });
- //initScrollHeight(keySecondaryInput);
-
// comment
const commentInput = template.find('textarea[name="comment"]');
const commentToggle = template.find('input[name="addMemo"]');
@@ -1539,8 +1807,8 @@ function getWorldEntry(name, data, entry) {
if (counter.data('first-run')) {
counter.data('first-run', false);
countTokensDebounced(counter, contentInput.val());
- initScrollHeight(keyInput);
- initScrollHeight(keySecondaryInput);
+ if (!keyInput.isFancy) initScrollHeight(keyInput.control);
+ if (!keySecondaryInput.isFancy) initScrollHeight(keySecondaryInput.control);
}
});
@@ -1563,11 +1831,11 @@ function getWorldEntry(name, data, entry) {
.closest('.world_entry')
.find('.keysecondarytextpole');
- const keyprimarytextpole = $(this)
+ const keyprimaryselect = $(this)
.closest('.world_entry')
- .find('.keyprimarytextpole');
+ .find('.keyprimaryselect');
- const keyprimaryHeight = keyprimarytextpole.outerHeight();
+ const keyprimaryHeight = keyprimaryselect.outerHeight();
keysecondarytextpole.css('height', keyprimaryHeight + 'px');
value ? keysecondary.show() : keysecondary.hide();
@@ -1619,7 +1887,7 @@ function getWorldEntry(name, data, entry) {
saveWorldInfo(name, data);
});
groupInput.val(entry.group ?? '').trigger('input');
- setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data)), 1);
+ setTimeout(() => createEntryInputAutocomplete(groupInput, getInclusionGroupCallback(data), { allowMultiple: true }), 1);
// inclusion priority
const groupOverrideInput = template.find('input[name="groupOverride"]');
@@ -1891,6 +2159,18 @@ function getWorldEntry(name, data, entry) {
});
preventRecursionInput.prop('checked', entry.preventRecursion).trigger('input');
+ // delay until recursion
+ const delayUntilRecursionInput = template.find('input[name="delay_until_recursion"]');
+ delayUntilRecursionInput.data('uid', entry.uid);
+ delayUntilRecursionInput.on('input', function () {
+ const uid = $(this).data('uid');
+ const value = $(this).prop('checked');
+ data.entries[uid].delayUntilRecursion = value;
+ setOriginalDataValue(data, uid, 'extensions.delay_until_recursion', data.entries[uid].delayUntilRecursion);
+ saveWorldInfo(name, data);
+ });
+ delayUntilRecursionInput.prop('checked', entry.delayUntilRecursion).trigger('input');
+
// duplicate button
const duplicateButton = template.find('.duplicate_entry_button');
duplicateButton.data('uid', entry.uid);
@@ -2029,11 +2309,15 @@ function getWorldEntry(name, data, entry) {
* @returns {(input: any, output: any) => any} Callback function for the autocomplete
*/
function getInclusionGroupCallback(data) {
- return function (input, output) {
+ return function (control, input, output) {
+ const uid = $(control).data('uid');
+ const thisGroups = String($(control).val()).split(/,\s*/).filter(x => x).map(x => x.toLowerCase());
const groups = new Set();
for (const entry of Object.values(data.entries)) {
+ // Skip the groups of this entry, because auto-complete should only suggest the ones that are already available on other entries
+ if (entry.uid == uid) continue;
if (entry.group) {
- groups.add(String(entry.group));
+ entry.group.split(/,\s*/).filter(x => x).forEach(x => groups.add(x));
}
}
@@ -2041,20 +2325,19 @@ function getInclusionGroupCallback(data) {
haystack.sort((a, b) => a.localeCompare(b));
const needle = input.term.toLowerCase();
const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1;
- const result = haystack.filter(x => x.toLowerCase().includes(needle));
-
- if (input.term && !hasExactMatch) {
- result.unshift(input.term);
- }
+ const result = haystack.filter(x => x.toLowerCase().includes(needle) && (!thisGroups.includes(x) || hasExactMatch && thisGroups.filter(g => g == x).length == 1));
output(result);
};
}
function getAutomationIdCallback(data) {
- return function (input, output) {
+ return function (control, input, output) {
+ const uid = $(control).data('uid');
const ids = new Set();
for (const entry of Object.values(data.entries)) {
+ // Skip automation id of this entry, because auto-complete should only suggest the ones that are already available on other entries
+ if (entry.uid == uid) continue;
if (entry.automationId) {
ids.add(String(entry.automationId));
}
@@ -2070,36 +2353,53 @@ function getAutomationIdCallback(data) {
const haystack = Array.from(ids);
haystack.sort((a, b) => a.localeCompare(b));
const needle = input.term.toLowerCase();
- const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1;
const result = haystack.filter(x => x.toLowerCase().includes(needle));
- if (input.term && !hasExactMatch) {
- result.unshift(input.term);
- }
-
output(result);
};
}
/**
* Create an autocomplete for the inclusion group.
- * @param {JQuery