WI key dropdown templating shows all keys

- Cache all keys for the loaded lorebook
- Key selection dropdown shows all keys and how often they are used already
- More templating changes
This commit is contained in:
Wolfsblvt 2024-05-08 20:34:53 +02:00
parent fda0e886e4
commit eb273a1873
4 changed files with 108 additions and 19 deletions

View File

@ -198,7 +198,7 @@
}
span.select2-container .select2-selection__choice__display:has(> .regex_item),
span.select2-container .select2-results__option:has(> .regex_item) {
span.select2-container .select2-results__option:has(> .result_block .regex_item) {
background-color: #D27D2D30;
}
@ -213,4 +213,13 @@ span.select2-container .select2-results__option:has(> .regex_item) {
position: relative;
top: -1px;
margin-right: 3px;
}
.select2-results__option .regex_item .regex_icon {
margin-right: 6px;
}
.select2-results__option .item_count {
margin-left: 10px;
float: right;
}

View File

@ -5109,7 +5109,7 @@
</span>
</small>
<small class="textAlignCenter" data-i18n="Primary Keywords">Primary Keywords</small>
<select class="keyprimaryselect select2_multi_sameline" name="key" data-i18n="[placeholder]Keywords or Regexes" placeholder="Keywords or Regexes" multiple="multiple"></select>
<select class="keyprimaryselect keyselect select2_multi_sameline" name="key" data-i18n="[placeholder]Keywords or Regexes" placeholder="Keywords or Regexes" multiple="multiple"></select>
<textarea class="text_pole keyprimarytextpole mobile" name="key" rows="1" data-i18n="[placeholder]Comma separated list" placeholder="Comma separated list" maxlength="2000" style="display: none;"></textarea>
</div>
<div class="world_entry_form_control">
@ -5128,7 +5128,7 @@
</span>
</small>
<small class="textAlignCenter" data-i18n="Optional Filter">Optional Filter</small>
<select class="keypsecondaryselect select2_multi_sameline" name="keysecondary" data-i18n="[placeholder]Keywords or Regexes (ignored if empty)" placeholder="Keywords or Regexes (ignored if empty)" multiple="multiple"></select>
<select class="keysecondaryselect keyselect select2_multi_sameline" name="keysecondary" data-i18n="[placeholder]Keywords or Regexes (ignored if empty)" placeholder="Keywords or Regexes (ignored if empty)" multiple="multiple"></select>
<textarea class="text_pole keysecondarytextpole mobile" name="keysecondary" rows="1" data-i18n="[placeholder]Comma separated list (ignored if empty)" placeholder="Comma separated list (ignored if empty)" maxlength="2000" style="display: none;"></textarea>
</div>
</div>

View File

@ -1450,7 +1450,12 @@ export function includesIgnoreCaseAndAccents(text, searchTerm) {
return normalizedText.includes(normalizedSearchTerm);
}
/**
* @typedef {object} Select2Option The option object for select2 controls
* @property {string} id - The unique ID inside this select
* @property {string} text - The text for this option
* @property {number?} [count] - Optionally show the count how often that option was chosen already
*/
/**
* Returns a unique hash as ID for a select2 option text
@ -1466,20 +1471,21 @@ export function getSelect2OptionId(option) {
* Modifies the select2 options by adding not existing one and optionally selecting them
*
* @param {JQuery<HTMLElement>} element - The "select" element to add the options to
* @param {string[]|{id: string, text: string}[]} items - The option items to build, add or select
* @param {string[]|Select2Option[]} items - The option items to build, add or select
* @param {object} [options] - Optional arguments
* @param {boolean} [options.select=false] - Whether the options should be selected right away
* @param {object} [options.changeEventArgs=null] - Optional event args being passed into the "change" event when its triggered because a new options is selected
*/
export function select2ModifyOptions(element, items, { select = false, changeEventArgs = null } = {}) {
if (!items.length) return;
/** @type {{id: string, text: string}[]} */
/** @type {Select2Option[]} */
const dataItems = items.map(x => typeof x === 'string' ? { id: getSelect2OptionId(x), text: x } : x);
const existingValues = [];
dataItems.forEach(item => {
// Set the value, creating a new option if necessary
if (element.find("option[value='" + item.id + "']").length) {
if (select) element.val(item.id).trigger('change', changeEventArgs);
if (select) existingValues.push(item.id);
} else {
// Create a DOM Option and optionally pre-select by default
var newOption = new Option(item.text, item.id, select, select);
@ -1487,5 +1493,34 @@ export function select2ModifyOptions(element, items, { select = false, changeEve
element.append(newOption);
if (select) element.trigger('change', changeEventArgs);
}
if (existingValues.length) element.val(existingValues).trigger('change', changeEventArgs);
});
}
/**
* Returns the ajax settings that can be used on the select2 ajax property to dynamically get the data.
* Can be used on a single global array, querying data from the server or anything similar.
*
* @param {function():Select2Option[]} dataProvider - The provider/function to retrieve the data - can be as simple as "() => myData" for arrays
* @return {object} The ajax object with the transport function to use on the select2 ajax property
*/
export function getDynamicSelect2DataViaAjax(dataProvider) {
function dynamicSelect2DataTransport(params, success, failure) {
var items = dataProvider();
// fitering if params.data.q available
if (params.data && params.data.q) {
items = items.filter(function (item) {
return new RegExp(params.data.q).test(item.text);
});
}
var promise = new Promise(function (resolve, reject) {
resolve({ results: items });
});
promise.then(success);
promise.catch(failure);
};
const ajax = {
transport: dynamicSelect2DataTransport
};
return ajax;
}

View File

@ -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, select2ModifyOptions, getStringHash, getSelect2OptionId } from './utils.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getStringHash, getSelect2OptionId, getDynamicSelect2DataViaAjax } from './utils.js';
import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { registerSlashCommand } from './slash-commands.js';
@ -831,6 +831,37 @@ function nullWorldInfo() {
toastr.info('Create or import a new World Info file first.', 'World Info is not set', { timeOut: 10000, preventDuplicates: true });
}
/** @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) {
updateEditor = (navigation) => displayWorldEntries(name, data, navigation);
@ -867,6 +898,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;
@ -1146,11 +1181,7 @@ function deleteOriginalDataValue(data, uid) {
}
}
/**
* @typedef {object} Select2Option The option object for select2 controls
* @property {string} id - The unique ID inside this select
* @property {string} text - The text for this option
*/
/** @typedef {import('./utils.js').Select2Option} Select2Option */
/**
* Splits a given input string that contains one or more keywords or regexes, separated by commas.
@ -1297,21 +1328,33 @@ function getWorldEntry(name, data, entry) {
event.stopPropagation();
});
function templateStyling(/** @type {Select2Option} */ item) {
function templateStyling(/** @type {Select2Option} */ item, { searchStyle = false } = {}) {
const content = $('<span>').addClass('item').text(item.text);
const isRegex = isValidRegex(item.text);
if (!isRegex) return item.text;
return $('<span>').addClass('regex_item').text(item.text)
.prepend($('<span>').addClass('regex_icon').text("•*").attr('title', 'Regex'));
if (isRegex) {
content.addClass('regex_item').prepend($('<span>').addClass('regex_icon').text("•*").attr('title', 'Regex'));
}
if (searchStyle && item.count) {
// Build a wrapping element
const wrapper = $('<span>').addClass('result_block')
.append(content);
wrapper.append($('<span>').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 (!isMobile()) {
input.select2({
ajax: getDynamicSelect2DataViaAjax(() => worldEntryKeyOptionsCache),
tags: true,
tokenSeparators: [','],
tokenizer: customTokenizer,
placeholder: input.attr('placeholder'),
templateResult: templateStyling,
templateSelection: templateStyling,
templateResult: item => templateStyling(item, { searchStyle: true }),
templateSelection: item => templateStyling(item),
});
input.on('change', function (_, { skipReset, noSave } = {}) {
const uid = $(this).data('uid');
@ -1325,6 +1368,8 @@ function getWorldEntry(name, data, entry) {
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 }));
select2ModifyOptions(input, entry[entryPropName], { select: true, changeEventArgs: { skipReset: true, noSave: true } });
}