Merge branch 'staging' into ru-l10n

This commit is contained in:
Cohee
2024-05-19 19:48:38 +03:00
79 changed files with 3736 additions and 559 deletions

View File

@@ -702,7 +702,7 @@ const isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
*/
function autoFitSendTextArea() {
const originalScrollBottom = chatBlock.scrollHeight - (chatBlock.scrollTop + chatBlock.offsetHeight);
if (sendTextArea.scrollHeight + 3 == sendTextArea.offsetHeight) {
if (Math.ceil(sendTextArea.scrollHeight + 3) >= Math.floor(sendTextArea.offsetHeight)) {
// Needs to be pulled dynamically because it is affected by font size changes
const sendTextAreaMinHeight = window.getComputedStyle(sendTextArea).getPropertyValue('min-height');
sendTextArea.style.height = sendTextAreaMinHeight;
@@ -1133,6 +1133,11 @@ export function initRossMods() {
return;
}
if ($('#dialogue_del_mes_cancel').is(':visible')) {
$('#dialogue_del_mes_cancel').trigger('click');
return;
}
if ($('.drawer-content')
.not('#WorldInfo')
.not('#left-nav-panel')

View File

@@ -25,6 +25,8 @@ export class AutoComplete {
/**@type {boolean}*/ isReplaceable = false;
/**@type {boolean}*/ isShowingDetails = false;
/**@type {boolean}*/ wasForced = false;
/**@type {boolean}*/ isForceHidden = false;
/**@type {boolean}*/ canBeAutoHidden = false;
/**@type {string}*/ text;
/**@type {AutoCompleteNameResult}*/ parserResult;
@@ -57,6 +59,10 @@ export class AutoComplete {
return power_user.stscript.matching ?? 'fuzzy';
}
get autoHide() {
return power_user.stscript.autocomplete.autoHide ?? false;
}
@@ -224,6 +230,16 @@ export class AutoComplete {
return a.name.localeCompare(b.name);
}
basicAutoHideCheck() {
// auto hide only if at least one char has been typed after the name + space
return this.textarea.selectionStart > this.parserResult.start
+ this.parserResult.name.length
+ (this.startQuote ? 1 : 0)
+ (this.endQuote ? 1 : 0)
+ 1
;
}
/**
* Show the autocomplete.
* @param {boolean} isInput Whether triggered by input.
@@ -244,6 +260,9 @@ export class AutoComplete {
return this.hide();
}
// disable force-hide if trigger was forced
if (isForced) this.isForceHidden = false;
// request provider to get name result (potentially "incomplete", i.e. not an actual existing name) for
// cursor position
this.parserResult = await this.getNameAt(this.text, this.textarea.selectionStart);
@@ -275,12 +294,16 @@ export class AutoComplete {
this.name = this.name.slice(0, this.textarea.selectionStart - (this.parserResult.start) - (this.startQuote ? 1 : 0));
this.parserResult.name = this.name;
this.isReplaceable = true;
this.isForceHidden = false;
this.canBeAutoHidden = false;
} else {
this.isReplaceable = false;
this.canBeAutoHidden = this.basicAutoHideCheck();
}
} else {
// if not forced and no user input -> just show details
this.isReplaceable = false;
this.canBeAutoHidden = this.basicAutoHideCheck();
}
if (isForced || isInput || isSelect) {
@@ -292,8 +315,11 @@ export class AutoComplete {
this.secondaryParserResult = result;
this.name = this.secondaryParserResult.name;
this.isReplaceable = isForced || this.secondaryParserResult.isRequired;
this.isForceHidden = false;
this.canBeAutoHidden = false;
} else {
this.isReplaceable = false;
this.canBeAutoHidden = this.basicAutoHideCheck();
}
}
}
@@ -314,7 +340,17 @@ export class AutoComplete {
// filter the list of options by the partial name according to the matching type
.filter(it => this.isReplaceable || it.name == '' ? matchers[this.matchType](it.name) : it.name.toLowerCase() == this.name)
// remove aliases
.filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx)
.filter((it,idx,list) => list.findIndex(opt=>opt.value == it.value) == idx);
if (this.result.length == 0 && this.effectiveParserResult != this.parserResult && isForced) {
// no matching secondary results and forced trigger -> show current command details
this.secondaryParserResult = null;
this.result = [this.effectiveParserResult.optionList.find(it=>it.name == this.effectiveParserResult.name)];
this.name = this.effectiveParserResult.name;
this.fuzzyRegex = /(.*)(.*)(.*)/;
}
this.result = this.result
// update remaining options
.map(option => {
// build element
@@ -336,6 +372,15 @@ export class AutoComplete {
;
if (this.isForceHidden) {
// hidden with escape
return this.hide();
}
if (this.autoHide && this.canBeAutoHidden && !isForced && this.effectiveParserResult == this.parserResult && this.result.length == 1) {
// auto hide user setting enabled and somewhere after name part and would usually show command details
return this.hide();
}
if (this.result.length == 0) {
if (!isInput) {
// no result and no input? hide autocomplete
@@ -683,6 +728,8 @@ export class AutoComplete {
if (evt.ctrlKey || evt.altKey || evt.shiftKey) return;
evt.preventDefault();
evt.stopPropagation();
this.isForceHidden = true;
this.wasForced = false;
this.hide();
return;
}

View File

@@ -24,6 +24,7 @@
* @property {boolean} group_override - Overrides any existing group assignment for the extension.
* @property {number} group_weight - A value used for prioritizing extensions within the same group.
* @property {boolean} prevent_recursion - Completely disallows recursive application of the extension.
* @property {boolean} delay_until_recursion - Will only be checked during recursion.
* @property {number} scan_depth - The maximum depth to search for matches when applying the extension.
* @property {boolean} match_whole_words - Specifies if only entire words should be matched during extension application.
* @property {boolean} use_group_scoring - Indicates if group weight is considered when selecting extensions.
@@ -69,6 +70,8 @@
* @property {"system" | "user" | "assistant"} depth_prompt.role - The role the character takes on during the prompted interaction (system, user, or assistant).
* // Non-standard extensions added by external tools
* @property {string} [pygmalion_id] - The unique identifier assigned to the character by the Pygmalion.chat.
* @property {string} [github_repo] - The gitHub repository associated with the character.
* @property {string} [source_url] - The source URL associated with the character.
* @property {{full_path: string}} [chub] - The Chub-specific data associated with the character.
*/

View File

@@ -109,7 +109,7 @@ function downloadAssetsList(url) {
</div>`);
}
for (const i in availableAssets[assetType]) {
for (const i in availableAssets[assetType].sort((a, b) => a?.name && b?.name && a['name'].localeCompare(b['name']))) {
const asset = availableAssets[assetType][i];
const elemId = `assets_install_${assetType}_${i}`;
let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
@@ -200,6 +200,9 @@ function downloadAssetsList(url) {
</div>`);
if (assetType === 'character') {
if (asset.highlight) {
assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>');
}
assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset['url']}" alt="${displayName}"></div>`);
}

View File

@@ -4,7 +4,7 @@
Enter a URL or the ID of a Fandom wiki page to scrape:
</label>
<small>
<span data-i18n=Examples:">Examples:</span>
<span data-i18n="Examples:">Examples:</span>
<code>https://harrypotter.fandom.com/</code>
<span data-i18n="or">or</span>
<code>harrypotter</code>

View File

@@ -7,7 +7,7 @@
Don't include the page name!
</i>
<small>
<span data-i18n=Examples:">Examples:</span>
<span data-i18n="Examples:">Examples:</span>
<code>https://streetcat.wiki/index.php</code>
<span data-i18n="or">or</span>
<code>https://tcrf.net</code>

View File

@@ -435,12 +435,17 @@ jQuery(function () {
<select id="caption_multimodal_model" class="flex1 text_pole">
<option data-type="openai" value="gpt-4-vision-preview">gpt-4-vision-preview</option>
<option data-type="openai" value="gpt-4-turbo">gpt-4-turbo</option>
<option data-type="openai" value="gpt-4o">gpt-4o</option>
<option data-type="anthropic" value="claude-3-opus-20240229">claude-3-opus-20240229</option>
<option data-type="anthropic" value="claude-3-sonnet-20240229">claude-3-sonnet-20240229</option>
<option data-type="anthropic" value="claude-3-haiku-20240307">claude-3-haiku-20240307</option>
<option data-type="google" value="gemini-pro-vision">gemini-pro-vision</option>
<option data-type="google" value="gemini-1.5-flash-latest">gemini-1.5-flash-latest</option>
<option data-type="openrouter" value="openai/gpt-4-vision-preview">openai/gpt-4-vision-preview</option>
<option data-type="openrouter" value="openai/gpt-4o">openai/gpt-4o</option>
<option data-type="openrouter" value="openai/gpt-4-turbo">openai/gpt-4-turbo</option>
<option data-type="openrouter" value="haotian-liu/llava-13b">haotian-liu/llava-13b</option>
<option data-type="openrouter" value="fireworks/firellava-13b">fireworks/firellava-13b</option>
<option data-type="openrouter" value="anthropic/claude-3-haiku">anthropic/claude-3-haiku</option>
<option data-type="openrouter" value="anthropic/claude-3-sonnet">anthropic/claude-3-sonnet</option>
<option data-type="openrouter" value="anthropic/claude-3-opus">anthropic/claude-3-opus</option>
@@ -449,6 +454,8 @@ jQuery(function () {
<option data-type="openrouter" value="anthropic/claude-3-opus:beta">anthropic/claude-3-opus:beta</option>
<option data-type="openrouter" value="nousresearch/nous-hermes-2-vision-7b">nousresearch/nous-hermes-2-vision-7b</option>
<option data-type="openrouter" value="google/gemini-pro-vision">google/gemini-pro-vision</option>
<option data-type="openrouter" value="google/gemini-flash-1.5">google/gemini-flash-1.5</option>
<option data-type="openrouter" value="liuhaotian/llava-yi-34b">liuhaotian/llava-yi-34b</option>
<option data-type="ollama" value="ollama_current">[Currently selected]</option>
<option data-type="ollama" value="bakllava:latest">bakllava:latest</option>
<option data-type="ollama" value="llava:latest">llava:latest</option>

View File

@@ -1020,12 +1020,12 @@ function parseLlmResponse(emotionResponse, labels) {
const parsedEmotion = JSON.parse(emotionResponse);
return parsedEmotion?.emotion ?? fallbackExpression;
} catch {
const fuse = new Fuse([emotionResponse]);
for (const label of labels) {
const result = fuse.search(label);
if (result.length > 0) {
return label;
}
const fuse = new Fuse(labels, { includeScore: true });
console.debug('Using fuzzy search in labels:', labels);
const result = fuse.search(emotionResponse);
if (result.length > 0) {
console.debug(`fuzzy search found: ${result[0].item} as closest for the LLM response:`, emotionResponse);
return result[0].item;
}
}

View File

@@ -804,10 +804,7 @@ function setMemoryContext(value, saveToMessage, index = null) {
const context = getContext();
context.setExtensionPrompt(MODULE_NAME, formatMemoryValue(value), extension_settings.memory.position, extension_settings.memory.depth, false, extension_settings.memory.role);
$('#memory_contents').val(value);
console.log('Summary set to: ' + value);
console.debug('Position: ' + extension_settings.memory.position);
console.debug('Depth: ' + extension_settings.memory.depth);
console.debug('Role: ' + extension_settings.memory.role);
console.log('Summary set to: ' + value, 'Position: ' + extension_settings.memory.position, 'Depth: ' + extension_settings.memory.depth, 'Role: ' + extension_settings.memory.role);
if (saveToMessage && context.chat.length) {
const idx = index ?? context.chat.length - 2;

View File

@@ -342,6 +342,16 @@ export class QuickReply {
message.addEventListener('scroll', (evt)=>{
updateScrollDebounced();
});
/** @type {any} */
const resizeListener = debounce((evt) => {
updateSyntax();
updateScrollDebounced(evt);
if (document.activeElement == message) {
message.blur();
message.focus();
}
});
window.addEventListener('resize', resizeListener);
message.style.color = 'transparent';
message.style.background = 'transparent';
message.style.setProperty('text-shadow', 'none', 'important');
@@ -514,6 +524,8 @@ export class QuickReply {
});
await popupResult;
window.removeEventListener('resize', resizeListener);
} else {
warn('failed to fetch qrEditor template');
}

View File

@@ -209,6 +209,10 @@
justify-content: center;
padding-bottom: 0.5em;
}
#qr--qrOptions {
display: flex;
flex-direction: column;
}
#qr--qrOptions > #qr--ctxEditor .qr--ctxItem {
display: flex;
flex-direction: row;
@@ -218,12 +222,17 @@
@media screen and (max-width: 750px) {
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor {
flex-direction: column;
overflow: auto;
}
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main {
flex: 0 0 auto;
}
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--labels {
flex-direction: column;
}
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-message {
min-height: 90svh;
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor > #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder {
min-height: 50svh;
height: 50svh;
}
}
.dialogue_popup:has(#qr--modalEditor) {

View File

@@ -229,6 +229,8 @@
#qr--qrOptions {
display: flex;
flex-direction: column;
> #qr--ctxEditor {
.qr--ctxItem {
display: flex;
@@ -244,11 +246,16 @@
@media screen and (max-width: 750px) {
body .dialogue_popup:has(#qr--modalEditor) .dialogue_popup_text > #qr--modalEditor {
flex-direction: column;
overflow: auto;
> #qr--main {
flex: 0 0 auto;
}
> #qr--main > .qr--labels {
flex-direction: column;
}
> #qr--main > .qr--modal-messageContainer > #qr--modal-message {
min-height: 90svh;
> #qr--main > .qr--modal-messageContainer > #qr--modal-messageHolder {
min-height: 50svh;
height: 50svh;
}
}
}

View File

@@ -14,7 +14,7 @@
<i class="fa-solid fa-file-import"></i>
<span data-i18n="ext_regex_import_script">Import Script</span>
</div>
<input type="file" id="import_regex_file" hidden accept="*.json" />
<input type="file" id="import_regex_file" hidden accept="*.json" multiple />
</div>
<hr />
<label data-i18n="ext_regex_saved_scripts">Saved Scripts</label>

View File

@@ -1,5 +1,6 @@
import { substituteParams } from '../../../script.js';
import { extension_settings } from '../../extensions.js';
import { regexFromString } from '../../utils.js';
export {
regex_placement,
getRegexedString,
@@ -21,29 +22,6 @@ const regex_placement = {
WORLD_INFO: 5,
};
/**
* Instantiates a regular expression from a string.
* @param {string} input The input string.
* @returns {RegExp} The regular expression instance.
* @copyright Originally from: https://github.com/IonicaBizau/regex-parser.js/blob/master/lib/index.js
*/
function regexFromString(input) {
try {
// Parse input
var m = input.match(/(\/?)(.+)\1([a-z]*)/i);
// Invalid flags
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3])) {
return RegExp(input);
}
// Create the regular expression
return new RegExp(m[2], m[3]);
} catch {
return;
}
}
/**
* Parent function to fetch a regexed version of a raw string
* @param {string} rawString The raw string to be regexed

View File

@@ -325,7 +325,9 @@ jQuery(async () => {
});
$('#import_regex_file').on('change', async function () {
const inputElement = this instanceof HTMLInputElement && this;
await onRegexImportFileChange(inputElement.files[0]);
for (const file of inputElement.files) {
await onRegexImportFileChange(file);
}
inputElement.value = '';
});
$('#import_regex').on('click', function () {

View File

@@ -1948,7 +1948,7 @@ async function generatePicture(args, trigger, message, callback) {
}
if (!isValidState()) {
toastr.warning('Extensions API is not connected or doesn\'t provide SD module. Enable Stable Horde to generate images.');
toastr.warning('Image generation is not available. Check your settings and try again.');
return;
}

View File

@@ -642,9 +642,9 @@ jQuery(() => {
loadSettings();
eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, handleIncomingMessage);
eventSource.makeFirst(event_types.CHARACTER_MESSAGE_RENDERED, handleIncomingMessage);
eventSource.makeFirst(event_types.USER_MESSAGE_RENDERED, handleOutgoingMessage);
eventSource.on(event_types.MESSAGE_SWIPED, handleIncomingMessage);
eventSource.on(event_types.USER_MESSAGE_RENDERED, handleOutgoingMessage);
eventSource.on(event_types.IMPERSONATE_READY, handleImpersonateReady);
eventSource.on(event_types.MESSAGE_EDITED, handleMessageEdit);

View File

@@ -261,6 +261,7 @@ async function playAudioData(audioJob) {
audioElement.addEventListener('ended', completeCurrentAudioJob);
audioElement.addEventListener('canplay', () => {
console.debug('Starting TTS playback');
audioElement.playbackRate = extension_settings.tts.playback_rate;
audioElement.play();
});
}
@@ -529,6 +530,10 @@ function loadSettings() {
$('#tts_pass_asterisks').prop('checked', extension_settings.tts.pass_asterisks);
$('#tts_skip_codeblocks').prop('checked', extension_settings.tts.skip_codeblocks);
$('#tts_skip_tags').prop('checked', extension_settings.tts.skip_tags);
$('#playback_rate').val(extension_settings.tts.playback_rate);
$('#playback_rate_counter').val(Number(extension_settings.tts.playback_rate).toFixed(2));
$('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System');
$('body').toggleClass('tts', extension_settings.tts.enabled);
}
@@ -538,6 +543,7 @@ const defaultSettings = {
currentProvider: 'ElevenLabs',
auto_generation: true,
narrate_user: false,
playback_rate: 1,
};
function setTtsStatus(status, success) {
@@ -649,6 +655,7 @@ async function loadTtsProvider(provider) {
function onTtsProviderChange() {
const ttsProviderSelection = $('#tts_provider').val();
extension_settings.tts.currentProvider = ttsProviderSelection;
$('#playback_rate_block').toggle(extension_settings.tts.currentProvider !== 'System');
loadTtsProvider(ttsProviderSelection);
}
@@ -1022,6 +1029,20 @@ $(document).ready(function () {
<small>Pass Asterisks to TTS Engine</small>
</label>
</div>
<div id="playback_rate_block" class="range-block">
<hr>
<div class="range-block-title justifyLeft" data-i18n="Audio Playback Speed">
<small>Audio Playback Speed</small>
</div>
<div class="range-block-range-and-counter">
<div class="range-block-range">
<input type="range" id="playback_rate" name="volume" min="0" max="3" step="0.05">
</div>
<div class="range-block-counter">
<input type="number" min="0" max="3" step="0.05" data-for="playback_rate" id="playback_rate_counter">
</div>
</div>
</div>
<div id="tts_voicemap_block">
</div>
<hr>
@@ -1046,6 +1067,15 @@ $(document).ready(function () {
$('#tts_pass_asterisks').on('click', onPassAsterisksClick);
$('#tts_auto_generation').on('click', onAutoGenerationClick);
$('#tts_narrate_user').on('click', onNarrateUserClick);
$('#playback_rate').on('input', function () {
const value = $(this).val();
const formattedValue = Number(value).toFixed(2);
extension_settings.tts.playback_rate = value;
$('#playback_rate_counter').val(formattedValue);
saveSettingsDebounced();
});
$('#tts_voices').on('click', onTtsVoicesClick);
for (const provider in ttsProviders) {
$('#tts_provider').append($('<option />').val(provider).text(provider));
@@ -1063,8 +1093,8 @@ $(document).ready(function () {
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
eventSource.on(event_types.MESSAGE_DELETED, onMessageDeleted);
eventSource.on(event_types.GROUP_UPDATED, onChatChanged);
eventSource.on(event_types.MESSAGE_SENT, onMessageEvent);
eventSource.on(event_types.MESSAGE_RECEIVED, onMessageEvent);
eventSource.makeLast(event_types.CHARACTER_MESSAGE_RENDERED, onMessageEvent);
eventSource.makeLast(event_types.USER_MESSAGE_RENDERED, onMessageEvent);
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'speak',
callback: onNarrateText,
aliases: ['narrate', 'tts'],

View File

@@ -258,9 +258,8 @@ export class FilterHelper {
*/
folderFilter(data) {
const state = this.filterData[FILTER_TYPES.FOLDER];
// Slightly different than the other filters, as a positive folder filter means it doesn't filter anything (folders get "not hidden" at another place),
// while a negative state should then filter out all folders.
const isFolder = entity => isFilterState(state, FILTER_STATES.SELECTED) ? true : entity.type === 'tag';
// Filter directly on folder. Special rules on still displaying characters with active folder filter are implemented in 'getEntitiesList' directly.
const isFolder = entity => entity.type === 'tag';
return this.filterDataByState(data, state, isFolder);
}
@@ -342,15 +341,40 @@ export class FilterHelper {
* Applies all filters to the given data.
* @param {any[]} data - The data to filter.
* @param {object} options - Optional call parameters
* @param {boolean|FilterType} [options.clearScoreCache=true] - Whether the score
* @param {boolean} [options.clearScoreCache=true] - Whether the score cache should be cleared.
* @param {Object.<FilterType, any>} [options.tempOverrides={}] - Temporarily override specific filters for this filter application
* @returns {any[]} The filtered data.
*/
applyFilters(data, { clearScoreCache = true } = {}) {
applyFilters(data, { clearScoreCache = true, tempOverrides = {} } = {}) {
if (clearScoreCache) this.clearScoreCache();
return Object.values(this.filterFunctions)
.reduce((data, fn) => fn(data), data);
// Save original filter states
const originalStates = {};
for (const key in tempOverrides) {
originalStates[key] = this.filterData[key];
this.filterData[key] = tempOverrides[key];
}
try {
const result = Object.values(this.filterFunctions)
.reduce((data, fn) => fn(data), data);
// Restore original filter states
for (const key in originalStates) {
this.filterData[key] = originalStates[key];
}
return result;
} catch (error) {
// Restore original filter states in case of an error
for (const key in originalStates) {
this.filterData[key] = originalStates[key];
}
throw error;
}
}
/**
* Cache scores for a specific filter type
* @param {FilterType} type - The type of data being cached

View File

@@ -637,7 +637,7 @@ function isValidImageUrl(url) {
if (Object.keys(url).length === 0) {
return false;
}
return isDataURL(url) || (url && url.startsWith('user'));
return isDataURL(url) || (url && (url.startsWith('user') || url.startsWith('/user')));
}
function getGroupAvatar(group) {
@@ -1418,6 +1418,10 @@ function select_group_chats(groupId, skipAnimation) {
* @returns {Promise<void>} - A promise that resolves when the processing and upload is complete.
*/
async function uploadGroupAvatar(event) {
if (!(event.target instanceof HTMLInputElement) || !event.target.files.length) {
return;
}
const file = event.target.files[0];
if (!file) {

View File

@@ -5,7 +5,9 @@ const storageKey = 'language';
const overrideLanguage = localStorage.getItem(storageKey);
const localeFile = String(overrideLanguage || navigator.language || navigator.userLanguage || 'en').toLowerCase();
const langs = await fetch('/locales/lang.json').then(response => response.json());
const localeData = await getLocaleData(localeFile);
// Don't change to let/const! It will break module loading.
// eslint-disable-next-line prefer-const
var localeData = await getLocaleData(localeFile);
/**
* Fetches the locale data for the given language.

View File

@@ -7,7 +7,7 @@ import {
power_user,
context_presets,
} from './power-user.js';
import { resetScrollHeight } from './utils.js';
import { regexFromString, resetScrollHeight } from './utils.js';
/**
* @type {any[]} Instruct mode presets.
@@ -189,10 +189,10 @@ export function autoSelectInstructPreset(modelId) {
// If activation regex is set, check if it matches the model id
if (preset.activation_regex) {
try {
const regex = new RegExp(preset.activation_regex, 'i');
const regex = regexFromString(preset.activation_regex);
// Stop on first match so it won't cycle back and forth between presets if multiple regexes match
if (regex.test(modelId)) {
if (regex instanceof RegExp && regex.test(modelId)) {
selectInstructPreset(preset.name);
return true;

View File

@@ -50,6 +50,7 @@ import {
download,
getBase64Async,
getFileText,
getImageSizeFromDataURL,
getSortableDelay,
isDataURL,
parseJsonFile,
@@ -265,6 +266,7 @@ const default_settings = {
show_external_models: false,
proxy_password: '',
assistant_prefill: '',
assistant_impersonation: '',
human_sysprompt_message: default_claude_human_sysprompt_message,
use_ai21_tokenizer: false,
use_google_tokenizer: false,
@@ -273,6 +275,7 @@ const default_settings = {
use_alt_scale: false,
squash_system_messages: false,
image_inlining: false,
inline_image_quality: 'low',
bypass_status_check: false,
continue_prefill: false,
names_behavior: character_names_behavior.NONE,
@@ -340,6 +343,7 @@ const oai_settings = {
show_external_models: false,
proxy_password: '',
assistant_prefill: '',
assistant_impersonation: '',
human_sysprompt_message: default_claude_human_sysprompt_message,
use_ai21_tokenizer: false,
use_google_tokenizer: false,
@@ -348,6 +352,7 @@ const oai_settings = {
use_alt_scale: false,
squash_system_messages: false,
image_inlining: false,
inline_image_quality: 'low',
bypass_status_check: false,
continue_prefill: false,
names_behavior: character_names_behavior.NONE,
@@ -1764,7 +1769,7 @@ async function sendOpenAIRequest(type, messages, signal) {
generate_data['human_sysprompt_message'] = substituteParams(oai_settings.human_sysprompt_message);
// Don't add a prefill on quiet gens (summarization)
if (!isQuiet) {
generate_data['assistant_prefill'] = substituteParams(oai_settings.assistant_prefill);
generate_data['assistant_prefill'] = isImpersonate ? substituteParams(oai_settings.assistant_impersonation) : substituteParams(oai_settings.assistant_prefill);
}
}
@@ -1844,6 +1849,8 @@ async function sendOpenAIRequest(type, messages, signal) {
generate_data['seed'] = oai_settings.seed;
}
await eventSource.emit(event_types.CHAT_COMPLETION_SETTINGS_READY, generate_data);
const generate_url = '/api/backends/chat-completions/generate';
const response = await fetch(generate_url, {
method: 'POST',
@@ -2188,12 +2195,47 @@ class Message {
}
}
const quality = oai_settings.inline_image_quality || default_settings.inline_image_quality;
this.content = [
{ type: 'text', text: textContent },
{ type: 'image_url', image_url: { 'url': image, 'detail': 'low' } },
{ type: 'image_url', image_url: { 'url': image, 'detail': quality } },
];
this.tokens += Message.tokensPerImage;
const tokens = await this.getImageTokenCost(image, quality);
this.tokens += tokens;
}
async getImageTokenCost(dataUrl, quality) {
if (quality === 'low') {
return Message.tokensPerImage;
}
const size = await getImageSizeFromDataURL(dataUrl);
// If the image is small enough, we can use the low quality token cost
if (quality === 'auto' && size.width <= 512 && size.height <= 512) {
return Message.tokensPerImage;
}
/*
* Images are first scaled to fit within a 2048 x 2048 square, maintaining their aspect ratio.
* Then, they are scaled such that the shortest side of the image is 768px long.
* Finally, we count how many 512px squares the image consists of.
* Each of those squares costs 170 tokens. Another 85 tokens are always added to the final total.
* https://platform.openai.com/docs/guides/vision/calculating-costs
*/
const scale = 2048 / Math.min(size.width, size.height);
const scaledWidth = Math.round(size.width * scale);
const scaledHeight = Math.round(size.height * scale);
const finalScale = 768 / Math.min(scaledWidth, scaledHeight);
const finalWidth = Math.round(scaledWidth * finalScale);
const finalHeight = Math.round(scaledHeight * finalScale);
const squares = Math.ceil(finalWidth / 512) * Math.ceil(finalHeight / 512);
const tokens = squares * 170 + 85;
return tokens;
}
/**
@@ -2720,8 +2762,10 @@ function loadOpenAISettings(data, settings) {
oai_settings.show_external_models = settings.show_external_models ?? default_settings.show_external_models;
oai_settings.proxy_password = settings.proxy_password ?? default_settings.proxy_password;
oai_settings.assistant_prefill = settings.assistant_prefill ?? default_settings.assistant_prefill;
oai_settings.assistant_impersonation = settings.assistant_impersonation ?? default_settings.assistant_impersonation;
oai_settings.human_sysprompt_message = settings.human_sysprompt_message ?? default_settings.human_sysprompt_message;
oai_settings.image_inlining = settings.image_inlining ?? default_settings.image_inlining;
oai_settings.inline_image_quality = settings.inline_image_quality ?? default_settings.inline_image_quality;
oai_settings.bypass_status_check = settings.bypass_status_check ?? default_settings.bypass_status_check;
oai_settings.seed = settings.seed ?? default_settings.seed;
oai_settings.n = settings.n ?? default_settings.n;
@@ -2755,10 +2799,14 @@ function loadOpenAISettings(data, settings) {
$('#api_url_scale').val(oai_settings.api_url_scale);
$('#openai_proxy_password').val(oai_settings.proxy_password);
$('#claude_assistant_prefill').val(oai_settings.assistant_prefill);
$('#claude_assistant_impersonation').val(oai_settings.assistant_impersonation);
$('#claude_human_sysprompt_textarea').val(oai_settings.human_sysprompt_message);
$('#openai_image_inlining').prop('checked', oai_settings.image_inlining);
$('#openai_bypass_status_check').prop('checked', oai_settings.bypass_status_check);
$('#openai_inline_image_quality').val(oai_settings.inline_image_quality);
$(`#openai_inline_image_quality option[value="${oai_settings.inline_image_quality}"]`).prop('selected', true);
$('#model_openai_select').val(oai_settings.openai_model);
$(`#model_openai_select option[value="${oai_settings.openai_model}"`).attr('selected', true);
$('#model_claude_select').val(oai_settings.claude_model);
@@ -3071,6 +3119,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
api_url_scale: settings.api_url_scale,
show_external_models: settings.show_external_models,
assistant_prefill: settings.assistant_prefill,
assistant_impersonation: settings.assistant_impersonation,
human_sysprompt_message: settings.human_sysprompt_message,
use_ai21_tokenizer: settings.use_ai21_tokenizer,
use_google_tokenizer: settings.use_google_tokenizer,
@@ -3079,6 +3128,7 @@ async function saveOpenAIPreset(name, settings, triggerUi = true) {
use_alt_scale: settings.use_alt_scale,
squash_system_messages: settings.squash_system_messages,
image_inlining: settings.image_inlining,
inline_image_quality: settings.inline_image_quality,
bypass_status_check: settings.bypass_status_check,
continue_prefill: settings.continue_prefill,
continue_postfix: settings.continue_postfix,
@@ -3456,6 +3506,7 @@ function onSettingsPresetChange() {
show_external_models: ['#openai_show_external_models', 'show_external_models', true],
proxy_password: ['#openai_proxy_password', 'proxy_password', false],
assistant_prefill: ['#claude_assistant_prefill', 'assistant_prefill', false],
assistant_impersonation: ['#claude_assistant_impersonation', 'assistant_impersonation', false],
human_sysprompt_message: ['#claude_human_sysprompt_textarea', 'human_sysprompt_message', false],
use_ai21_tokenizer: ['#use_ai21_tokenizer', 'use_ai21_tokenizer', true],
use_google_tokenizer: ['#use_google_tokenizer', 'use_google_tokenizer', true],
@@ -3464,6 +3515,7 @@ function onSettingsPresetChange() {
use_alt_scale: ['#use_alt_scale', 'use_alt_scale', true],
squash_system_messages: ['#squash_system_messages', 'squash_system_messages', true],
image_inlining: ['#openai_image_inlining', 'image_inlining', true],
inline_image_quality: ['#openai_inline_image_quality', 'inline_image_quality', false],
continue_prefill: ['#continue_prefill', 'continue_prefill', true],
continue_postfix: ['#continue_postfix', 'continue_postfix', false],
seed: ['#seed_openai', 'seed', false],
@@ -3480,6 +3532,11 @@ function onSettingsPresetChange() {
preset.names_behavior = character_names_behavior.COMPLETION;
}
// Claude: Assistant Impersonation Prefill = Inherit from Assistant Prefill
if (preset.assistant_prefill !== undefined && preset.assistant_impersonation === undefined) {
preset.assistant_impersonation = preset.assistant_prefill;
}
const updateInput = (selector, value) => $(selector).val(value).trigger('input');
const updateCheckbox = (selector, value) => $(selector).prop('checked', value).trigger('input');
@@ -3515,7 +3572,7 @@ function getMaxContextOpenAI(value) {
if (oai_settings.max_context_unlocked) {
return unlocked_max;
}
else if (value.includes('gpt-4-turbo') || value.includes('gpt-4-1106') || value.includes('gpt-4-0125') || value.includes('gpt-4-vision')) {
else if (value.includes('gpt-4-turbo') || value.includes('gpt-4o') || value.includes('gpt-4-1106') || value.includes('gpt-4-0125') || value.includes('gpt-4-vision')) {
return max_128k;
}
else if (value.includes('gpt-3.5-turbo-1106')) {
@@ -3679,7 +3736,7 @@ async function onModelChange() {
if (oai_settings.chat_completion_source == chat_completion_sources.MAKERSUITE) {
if (oai_settings.max_context_unlocked) {
$('#openai_max_context').attr('max', max_1mil);
} else if (value === 'gemini-1.5-pro-latest') {
} else if (value === 'gemini-1.5-pro-latest' || value.includes('gemini-1.5-flash')) {
$('#openai_max_context').attr('max', max_1mil);
} else if (value === 'gemini-ultra' || value === 'gemini-1.0-pro-latest' || value === 'gemini-pro' || value === 'gemini-1.0-ultra-latest') {
$('#openai_max_context').attr('max', max_32k);
@@ -4239,11 +4296,14 @@ export function isImageInliningSupported() {
// gultra just isn't being offered as multimodal, thanks google.
const visionSupportedModels = [
'gpt-4-vision',
'gemini-1.5-flash-latest',
'gemini-1.5-flash',
'gemini-1.0-pro-vision-latest',
'gemini-1.5-pro-latest',
'gemini-pro-vision',
'claude-3',
'gpt-4-turbo',
'gpt-4o',
];
switch (oai_settings.chat_completion_source) {
@@ -4672,6 +4732,11 @@ $(document).ready(async function () {
saveSettingsDebounced();
});
$('#claude_assistant_impersonation').on('input', function () {
oai_settings.assistant_impersonation = String($(this).val());
saveSettingsDebounced();
});
$('#claude_human_sysprompt_textarea').on('input', function () {
oai_settings.human_sysprompt_message = String($('#claude_human_sysprompt_textarea').val());
saveSettingsDebounced();
@@ -4707,6 +4772,11 @@ $(document).ready(async function () {
saveSettingsDebounced();
});
$('#openai_inline_image_quality').on('input', function () {
oai_settings.inline_image_quality = String($(this).val());
saveSettingsDebounced();
});
$('#continue_prefill').on('input', function () {
oai_settings.continue_prefill = !!$(this).prop('checked');
saveSettingsDebounced();

View File

@@ -259,6 +259,7 @@ let power_user = {
stscript: {
matching: 'fuzzy',
autocomplete: {
autoHide: false,
style: 'theme',
font: {
scale: 0.8,
@@ -1618,6 +1619,7 @@ function loadPowerUserSettings(settings, data) {
$('#token_padding').val(power_user.token_padding);
$('#aux_field').val(power_user.aux_field);
$('#stscript_autocomplete_autoHide').prop('checked', power_user.stscript.autocomplete.autoHide ?? false).trigger('input');
$('#stscript_matching').val(power_user.stscript.matching ?? 'fuzzy');
$('#stscript_autocomplete_style').val(power_user.stscript.autocomplete_style ?? 'theme');
document.body.setAttribute('data-stscript-style', power_user.stscript.autocomplete_style);
@@ -3646,6 +3648,11 @@ $(document).ready(() => {
saveSettingsDebounced();
});
$('#stscript_autocomplete_autoHide').on('input', function () {
power_user.stscript.autocomplete.autoHide = !!$(this).prop('checked');
saveSettingsDebounced();
});
$('#stscript_matching').on('change', function () {
const value = $(this).find(':selected').val();
power_user.stscript.matching = String(value);

View File

@@ -114,7 +114,9 @@ class PresetManager {
* @returns {any} Preset value
*/
findPreset(name) {
return $(this.select).find(`option:contains(${name})`).val();
return $(this.select).find('option').filter(function() {
return $(this).text() === name;
}).val();
}
/**

View File

@@ -0,0 +1,441 @@
import {
main_api,
saveSettingsDebounced,
novelai_setting_names,
callPopup,
settings,
} from '../script.js';
import { power_user } from './power-user.js';
//import { BIAS_CACHE, displayLogitBias, getLogitBiasListResult } from './logit-bias.js';
//import { getEventSourceStream } from './sse-stream.js';
//import { getSortableDelay, onlyUnique } from './utils.js';
//import { getCfgPrompt } from './cfg-scale.js';
import { setting_names } from './textgen-settings.js';
const TGsamplerNames = setting_names;
const forcedOnColoring = 'filter: sepia(1) hue-rotate(59deg) contrast(1.5) saturate(3.5)';
const forcedOffColoring = 'filter: sepia(1) hue-rotate(308deg) contrast(0.7) saturate(10)';
let userDisabledSamplers, userShownSamplers;
/*
for reference purposes:
//NAI
const nai_settings = {
temperature: 1.5,
repetition_penalty: 2.25,
repetition_penalty_range: 2048,
repetition_penalty_slope: 0.09,
repetition_penalty_frequency: 0,
repetition_penalty_presence: 0.005,
tail_free_sampling: 0.975,
top_k: 10,
top_p: 0.75,
top_a: 0.08,
typical_p: 0.975,
min_length: 1,
model_novel: 'clio-v1',
preset_settings_novel: 'Talker-Chat-Clio',
streaming_novel: false,
preamble: default_preamble,
prefix: '',
cfg_uc: '',
banned_tokens: '',
order: default_order,
logit_bias: [],
};
// TG Types
export const textgen_types = {
OOBA: 'ooba',
MANCER: 'mancer',
VLLM: 'vllm',
APHRODITE: 'aphrodite',
TABBY: 'tabby',
KOBOLDCPP: 'koboldcpp',
TOGETHERAI: 'togetherai',
LLAMACPP: 'llamacpp',
OLLAMA: 'ollama',
INFERMATICAI: 'infermaticai',
DREAMGEN: 'dreamgen',
OPENROUTER: 'openrouter',
};
//KAI and TextGen
const setting_names = [
'temp',
'temperature_last',
'rep_pen',
'rep_pen_range',
'no_repeat_ngram_size',
'top_k',
'top_p',
'top_a',
'tfs',
'epsilon_cutoff',
'eta_cutoff',
'typical_p',
'min_p',
'penalty_alpha',
'num_beams',
'length_penalty',
'min_length',
'dynatemp',
'min_temp',
'max_temp',
'dynatemp_exponent',
'smoothing_factor',
'smoothing_curve',
'max_tokens_second',
'encoder_rep_pen',
'freq_pen',
'presence_pen',
'do_sample',
'early_stopping',
'seed',
'add_bos_token',
'ban_eos_token',
'skip_special_tokens',
'streaming',
'mirostat_mode',
'mirostat_tau',
'mirostat_eta',
'guidance_scale',
'negative_prompt',
'grammar_string',
'json_schema',
'banned_tokens',
'legacy_api',
//'n_aphrodite',
//'best_of_aphrodite',
'ignore_eos_token',
'spaces_between_special_tokens',
//'logits_processors_aphrodite',
//'log_probs_aphrodite',
//'prompt_log_probs_aphrodite'
'sampler_order',
'sampler_priority',
'samplers',
'n',
'logit_bias',
'custom_model',
'bypass_status_check',
];
//OAI settings
const default_settings = {
preset_settings_openai: 'Default',
temp_openai: 1.0,
freq_pen_openai: 0,
pres_pen_openai: 0,
count_pen: 0.0,
top_p_openai: 1.0,
top_k_openai: 0,
min_p_openai: 0,
top_a_openai: 1,
repetition_penalty_openai: 1,
stream_openai: false,
//...
}
*/
// Goal 1: show popup with all samplers for active API
async function showSamplerSelectPopup() {
const popup = $('#dialogue_popup');
popup.addClass('large_dialogue_popup');
const html = $(document.createElement('div'));
html.attr('id', 'sampler_view_list')
.addClass('flex-container flexFlowColumn');
html.append(`
<div class="title_restorable flexFlowColumn alignItemsBaseline">
<div class="flex-container justifyCenter">
<h3>Sampler Select</h3>
<div class="flex-container alignItemsBaseline">
<div id="resetSelectedSamplers" class="menu_button menu_button_icon tag_view_create" title="Reset custom sampler selection">
<i class="fa-solid fa-recycle"></i>
</div>
</div>
<!--<div class="flex-container alignItemsBaseline">
<div class="menu_button menu_button_icon tag_view_create" title="Create a new sampler">
<i class="fa-solid fa-plus"></i>
<span data-i18n="Create">Create</span>
</div>
</div>-->
</div>
<small>Here you can toggle the display of individual samplers. (WIP)</small>
</div>
<hr>`);
const listContainer = $('<div id="apiSamplersList" class="flex-container flexNoGap"></div>');
const APISamplers = await listSamplers(main_api);
listContainer.append(APISamplers);
html.append(listContainer);
callPopup(html, 'text', null, { allowVerticalScrolling: true });
setSamplerListListeners();
$('#resetSelectedSamplers').off('click').on('click', async function () {
console.log('saw sampler select reset click');
userDisabledSamplers = [];
userShownSamplers = [];
power_user.selectSamplers.forceShown = [];
power_user.selectSamplers.forceHidden = [];
await validateDisabledSamplers(true);
});
}
function setSamplerListListeners() {
// Goal 2: hide unchecked samplers from DOM
let listContainer = $('#apiSamplersList');
listContainer.find('input').off('change').on('change', async function () {
const samplerName = this.name.replace('_checkbox', '');
let relatedDOMElement = $(`#${samplerName}_${main_api}`).parent();
let targetDisplayType = 'flex';
if (samplerName === 'json_schema') {
relatedDOMElement = $('#json_schema_block');
targetDisplayType = 'block';
}
if (samplerName === 'grammar_string') {
relatedDOMElement = $('#grammar_block_ooba');
targetDisplayType = 'block';
}
if (samplerName === 'guidance_scale') {
relatedDOMElement = $('#cfg_block_ooba');
targetDisplayType = 'block';
}
if (samplerName === 'mirostat_mode') {
relatedDOMElement = $('#mirostat_block_ooba');
targetDisplayType = 'block';
}
if (samplerName === 'dynatemp') {
relatedDOMElement = $('#dynatemp_block_ooba');
targetDisplayType = 'block';
}
if (samplerName === 'banned_tokens') {
relatedDOMElement = $('#banned_tokens_block_ooba');
targetDisplayType = 'block';
}
if (samplerName === 'sampler_order') {
relatedDOMElement = $('#sampler_order_block');
targetDisplayType = 'flex';
}
// Get the current state of the custom data attribute
const previousState = relatedDOMElement.data('selectsampler');
if ($(this).prop('checked') === false) {
//console.log('saw clicking checkbox from on to off...');
if (previousState === 'shown') {
console.log('saw previously custom shown sampler');
//console.log('removing from custom force show list');
relatedDOMElement.removeData('selectsampler');
$(this).parent().find('.sampler_name').removeAttr('style');
power_user?.selectSamplers?.forceShown.splice(power_user?.selectSamplers?.forceShown.indexOf(samplerName), 1);
console.log(power_user?.selectSamplers?.forceShown);
} else {
console.log('saw previous untouched sampler');
//console.log(`adding ${samplerName} to force hide list`);
relatedDOMElement.data('selectsampler', 'hidden');
console.log(relatedDOMElement.data('selectsampler'));
power_user.selectSamplers.forceHidden.push(samplerName);
$(this).parent().find('.sampler_name').attr('style', forcedOffColoring);
console.log(power_user.selectSamplers.forceHidden);
}
} else { // going from unchecked to checked
//console.log('saw clicking checkbox from off to on...');
if (previousState === 'hidden') {
console.log('saw previously custom hidden sampler');
//console.log('removing from custom force hide list');
relatedDOMElement.removeData('selectsampler');
$(this).parent().find('.sampler_name').removeAttr('style');
power_user?.selectSamplers?.forceHidden.splice(power_user?.selectSamplers?.forceHidden.indexOf(samplerName), 1);
console.log(power_user?.selectSamplers?.forceHidden);
} else {
console.log('saw previous untouched sampler');
//console.log(`adding ${samplerName} to force shown list`);
relatedDOMElement.data('selectsampler', 'shown');
console.log(relatedDOMElement.data('selectsampler'));
power_user.selectSamplers.forceShown.push(samplerName);
$(this).parent().find('.sampler_name').attr('style', forcedOnColoring);
console.log(power_user.selectSamplers.forceShown);
}
}
await saveSettingsDebounced();
const shouldDisplay = $(this).prop('checked') ? targetDisplayType : 'none';
relatedDOMElement.css('display', shouldDisplay);
console.log(samplerName, relatedDOMElement.data('selectsampler'), shouldDisplay);
});
}
function isElementVisibleInDOM(element) {
while (element && element !== document.body) {
if (window.getComputedStyle(element).display === 'none') {
return false;
}
element = element.parentElement;
}
return true;
}
async function listSamplers(main_api, arrayOnly = false) {
let availableSamplers;
if (main_api === 'textgenerationwebui') {
availableSamplers = TGsamplerNames;
const valuesToRemove = new Set(['streaming', 'seed', 'bypass_status_check', 'custom_model', 'legacy_api', 'samplers']);
availableSamplers = availableSamplers.filter(sampler => !valuesToRemove.has(sampler));
availableSamplers.sort();
}
if (arrayOnly) {
console.log('returning full samplers array');
return availableSamplers;
}
const samplersListHTML = availableSamplers.reduce((html, sampler) => {
let customColor;
const targetDOMelement = $(`#${sampler}_${main_api}`);
const isInForceHiddenArray = userDisabledSamplers.includes(sampler);
const isInForceShownArray = userShownSamplers.includes(sampler);
let isVisibleInDOM = isElementVisibleInDOM(targetDOMelement[0]);
const isInDefaultState = () => {
if (isVisibleInDOM && isInForceShownArray) { return false; }
else if (!isVisibleInDOM && isInForceHiddenArray) { return false; }
else { return true; }
};
const shouldBeChecked = () => {
if (isInForceHiddenArray) {
customColor = forcedOffColoring;
return false;
}
else if (isInForceShownArray) {
customColor = forcedOnColoring;
return true;
}
else { return isVisibleInDOM; }
};
console.log(sampler, isInDefaultState(), isInForceHiddenArray, shouldBeChecked());
return html + `
<div class="sampler_view_list_item wide50p flex-container">
<input type="checkbox" name="${sampler}_checkbox" ${shouldBeChecked() ? 'checked' : ''}>
<small class="sampler_name" style="${customColor}">${sampler}</small>
</div>
`;
}, '');
return samplersListHTML;
}
// Goal 3: make "sampler is hidden/disabled" status persistent (save settings)
// this runs on initial getSettings as well as after API changes
export async function validateDisabledSamplers(redraw = false) {
const APISamplers = await listSamplers(main_api, true);
if (!Array.isArray(APISamplers)) {
return;
}
for (const sampler of APISamplers) {
let relatedDOMElement = $(`#${sampler}_${main_api}`).parent();
let targetDisplayType = 'flex';
if (sampler === 'json_schema') {
relatedDOMElement = $('#json_schema_block');
targetDisplayType = 'block';
}
if (sampler === 'grammar_string') {
relatedDOMElement = $('#grammar_block_ooba');
targetDisplayType = 'block';
}
if (sampler === 'guidance_scale') {
relatedDOMElement = $('#cfg_block_ooba');
targetDisplayType = 'block';
}
if (sampler === 'mirostat_mode') {
relatedDOMElement = $('#mirostat_block_ooba');
targetDisplayType = 'block';
}
if (sampler === 'dynatemp') {
relatedDOMElement = $('#dynatemp_block_ooba');
targetDisplayType = 'block';
}
if (sampler === 'banned_tokens') {
relatedDOMElement = $('#banned_tokens_block_ooba');
targetDisplayType = 'block';
}
if (sampler === 'sampler_order') {
relatedDOMElement = $('#sampler_order_block');
}
if (power_user?.selectSamplers?.forceHidden.includes(sampler)) {
//default handling for standard sliders
relatedDOMElement.data('selectsampler', 'hidden');
relatedDOMElement.css('display', 'none');
} else if (power_user?.selectSamplers?.forceShown.includes(sampler)) {
relatedDOMElement.data('selectsampler', 'shown');
relatedDOMElement.css('display', targetDisplayType);
} else {
if (relatedDOMElement.data('selectsampler') === 'hidden') {
relatedDOMElement.removeAttr('selectsampler');
relatedDOMElement.css('display', targetDisplayType);
}
if (relatedDOMElement.data('selectsampler') === 'shown') {
relatedDOMElement.removeAttr('selectsampler');
relatedDOMElement.css('display', 'none');
}
}
if (redraw) {
let samplersHTML = await listSamplers(main_api);
$('#apiSamplersList').empty().append(samplersHTML);
setSamplerListListeners();
}
}
}
export async function initCustomSelectedSamplers() {
userDisabledSamplers = power_user?.selectSamplers?.forceHidden || [];
userShownSamplers = power_user?.selectSamplers?.forceShown || [];
power_user.selectSamplers = {};
power_user.selectSamplers.forceHidden = userDisabledSamplers;
power_user.selectSamplers.forceShown = userShownSamplers;
await saveSettingsDebounced();
$('#samplerSelectButton').off('click').on('click', showSamplerSelectPopup);
}
// Goal 4: filter hidden samplers from API output
// Goal 5: allow addition of custom samplers to be displayed
// Goal 6: send custom sampler values into prompt

View File

@@ -447,8 +447,9 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'unhide',
],
helpString: 'Unhides a message from the prompt.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'disable',
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-disable',
callback: disableGroupMemberCallback,
aliases: ['disable', 'disablemember', 'memberdisable'],
unnamedArgumentList: [
new SlashCommandArgument(
'member index or name', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], true,
@@ -456,7 +457,8 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'disable',
],
helpString: 'Disables a group member from being drafted for replies.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'enable',
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-enable',
aliases: ['enable', 'enablemember', 'memberenable'],
callback: enableGroupMemberCallback,
unnamedArgumentList: [
new SlashCommandArgument(
@@ -465,9 +467,9 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'enable',
],
helpString: 'Enables a group member to be drafted for replies.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberadd',
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-add',
callback: addGroupMemberCallback,
aliases: ['addmember'],
aliases: ['addmember', 'memberadd'],
unnamedArgumentList: [
new SlashCommandArgument(
'character name', [ARGUMENT_TYPE.STRING], true,
@@ -481,15 +483,15 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberadd',
<strong>Example:</strong>
<ul>
<li>
<pre><code>/memberadd John Doe</code></pre>
<pre><code>/member-add John Doe</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberremove',
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-remove',
callback: removeGroupMemberCallback,
aliases: ['removemember'],
aliases: ['removemember', 'memberremove'],
unnamedArgumentList: [
new SlashCommandArgument(
'member index or name', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], true,
@@ -503,16 +505,16 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberremove
<strong>Example:</strong>
<ul>
<li>
<pre><code>/memberremove 2</code></pre>
<pre><code>/memberremove John Doe</code></pre>
<pre><code>/member-remove 2</code></pre>
<pre><code>/member-remove John Doe</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberup',
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-up',
callback: moveGroupMemberUpCallback,
aliases: ['upmember'],
aliases: ['upmember', 'memberup'],
unnamedArgumentList: [
new SlashCommandArgument(
'member index or name', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], true,
@@ -520,9 +522,9 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberup',
],
helpString: 'Moves a group member up in the group chat list.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'memberdown',
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'member-down',
callback: moveGroupMemberDownCallback,
aliases: ['downmember'],
aliases: ['downmember', 'memberdown'],
unnamedArgumentList: [
new SlashCommandArgument(
'member index or name', [ARGUMENT_TYPE.NUMBER, ARGUMENT_TYPE.STRING], true,
@@ -702,6 +704,19 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'addswipe',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'abort',
callback: abortCallback,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({ name: 'quiet',
description: 'Whether to suppress the toast message notifying about the /abort call.',
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
enumList: ['true', 'false'],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({ description: 'The reason for aborting command execution. Shown when quiet=false',
typeList: [ARGUMENT_TYPE.STRING],
}),
],
helpString: 'Aborts the slash command batch execution.',
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'fuzzy',
@@ -847,6 +862,12 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'messages',
new SlashCommandNamedArgument(
'names', 'show message author names', [ARGUMENT_TYPE.BOOLEAN], false, false, 'off', ['off', 'on'],
),
new SlashCommandNamedArgument(
'hidden', 'include hidden messages', [ARGUMENT_TYPE.BOOLEAN], false, false, 'on', ['off', 'on'],
),
new SlashCommandNamedArgument(
'role', 'filter messages by role' , [ARGUMENT_TYPE.STRING], false, false, null, ['system', 'assistant', 'user'],
),
],
unnamedArgumentList: [
new SlashCommandArgument(
@@ -858,6 +879,12 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'messages',
<div>
Returns the specified message or range of messages as a string.
</div>
<div>
Use the <code>hidden=off</code> argument to exclude hidden messages.
</div>
<div>
Use the <code>role</code> argument to filter messages by role. Possible values are: system, assistant, user.
</div>
<div>
<strong>Examples:</strong>
<ul>
@@ -1310,13 +1337,37 @@ async function popupCallback(args, value) {
function getMessagesCallback(args, value) {
const includeNames = !isFalseBoolean(args?.names);
const includeHidden = isTrueBoolean(args?.hidden);
const role = args?.role;
const range = stringToRange(value, 0, chat.length - 1);
if (!range) {
console.warn(`WARN: Invalid range provided for /getmessages command: ${value}`);
console.warn(`WARN: Invalid range provided for /messages command: ${value}`);
return '';
}
const filterByRole = (mes) => {
if (!role) {
return true;
}
const isNarrator = mes.extra?.type === system_message_types.NARRATOR;
if (role === 'system') {
return isNarrator && !mes.is_user;
}
if (role === 'assistant') {
return !isNarrator && !mes.is_user;
}
if (role === 'user') {
return !isNarrator && mes.is_user;
}
throw new Error(`Invalid role provided. Expected one of: system, assistant, user. Got: ${role}`);
};
const messages = [];
for (let messageId = range.start; messageId <= range.end; messageId++) {
@@ -1326,7 +1377,13 @@ function getMessagesCallback(args, value) {
continue;
}
if (message.is_system) {
if (role && !filterByRole(message)) {
console.debug(`/messages: Skipping message with ID ${messageId} due to role filter`);
continue;
}
if (!includeHidden && message.is_system) {
console.debug(`/messages: Skipping hidden message with ID ${messageId}`);
continue;
}
@@ -1377,9 +1434,15 @@ async function runCallback(args, name) {
}
}
function abortCallback() {
$('#send_textarea').val('')[0].dispatchEvent(new Event('input', { bubbles:true }));
throw new Error('/abort command executed');
/**
*
* @param {object} param0
* @param {SlashCommandAbortController} param0._abortController
* @param {string} [param0.quiet]
* @param {string} [reason]
*/
function abortCallback({ _abortController, quiet }, reason) {
_abortController.abort((reason ?? '').toString().length == 0 ? '/abort command executed' : reason, !isFalseBoolean(quiet ?? 'true'));
}
async function delayCallback(_, amount) {
@@ -2645,7 +2708,7 @@ const clearCommandProgressDebounced = debounce(clearCommandProgress);
* @prop {boolean} [handleParserErrors] (true) Whether to handle parser errors (show toast on error) or throw.
* @prop {SlashCommandScope} [scope] (null) The scope to be used when executing the commands.
* @prop {boolean} [handleExecutionErrors] (false) Whether to handle execution errors (show toast on error) or throw
* @prop {PARSER_FLAG[]} [parserFlags] (null) Parser flags to apply
* @prop {{[id:PARSER_FLAG]:boolean}} [parserFlags] (null) Parser flags to apply
* @prop {SlashCommandAbortController} [abortController] (null) Controller used to abort or pause command execution
* @prop {(done:number, total:number)=>void} [onProgress] (null) Callback to handle progress events
*/
@@ -2653,7 +2716,7 @@ const clearCommandProgressDebounced = debounce(clearCommandProgress);
/**
* @typedef ExecuteSlashCommandsOnChatInputOptions
* @prop {SlashCommandScope} [scope] (null) The scope to be used when executing the commands.
* @prop {PARSER_FLAG[]} [parserFlags] (null) Parser flags to apply
* @prop {{[id:PARSER_FLAG]:boolean}} [parserFlags] (null) Parser flags to apply
* @prop {boolean} [clearChatInput] (false) Whether to clear the chat input textarea
*/
@@ -2705,10 +2768,12 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) {
}
} catch (e) {
document.querySelector('#form_sheld').classList.add('script_error');
toastr.error(e.message);
result = new SlashCommandClosureResult();
result.isError = true;
result.errorMessage = e.message;
if (e.cause !== 'abort') {
toastr.error(e.message);
}
} finally {
delay(1000).then(()=>clearCommandProgressDebounced());
@@ -2740,7 +2805,7 @@ async function executeSlashCommandsWithOptions(text, options = {}) {
let closure;
try {
closure = parser.parse(text, true, options.parserFlags, options.abortController);
closure = parser.parse(text, true, options.parserFlags, options.abortController ?? new SlashCommandAbortController());
closure.scope.parent = options.scope;
closure.onProgress = options.onProgress;
} catch (e) {
@@ -2767,8 +2832,9 @@ async function executeSlashCommandsWithOptions(text, options = {}) {
try {
const result = await closure.execute();
if (result.isAborted) {
if (result.isAborted && !result.isQuietlyAborted) {
toastr.warning(result.abortReason, 'Command execution aborted');
closure.abortController.signal.isQuiet = true;
}
return result;
} catch (e) {

View File

@@ -1,5 +1,25 @@
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
import { SlashCommandArgument, SlashCommandNamedArgument } from './SlashCommandArgument.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { PARSER_FLAG } from './SlashCommandParser.js';
import { SlashCommandScope } from './SlashCommandScope.js';
/**
* @typedef {{
* _pipe:string|SlashCommandClosure,
* _scope:SlashCommandScope,
* _parserFlags:{[id:PARSER_FLAG]:boolean},
* _abortController:SlashCommandAbortController,
* [id:string]:string|SlashCommandClosure,
* }} NamedArguments
*/
/**
* @typedef {string|SlashCommandClosure|(string|SlashCommandClosure)[]} UnnamedArguments
*/
@@ -8,7 +28,7 @@ export class SlashCommand {
* Creates a SlashCommand from a properties object.
* @param {Object} props
* @param {string} [props.name]
* @param {(namedArguments:Object.<string,string|SlashCommandClosure>, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|void|Promise<string|SlashCommandClosure|void>} [props.callback]
* @param {(namedArguments:NamedArguments, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|void|Promise<string|SlashCommandClosure|void>} [props.callback]
* @param {string} [props.helpString]
* @param {boolean} [props.splitUnnamedArgument]
* @param {string[]} [props.aliases]
@@ -25,7 +45,7 @@ export class SlashCommand {
/**@type {string}*/ name;
/**@type {(namedArguments:Object<string, string|SlashCommandClosure>, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
/**@type {(namedArguments:{_pipe:string|SlashCommandClosure, _scope:SlashCommandScope, _abortController:SlashCommandAbortController, [id:string]:string|SlashCommandClosure}, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
/**@type {string}*/ helpString;
/**@type {boolean}*/ splitUnnamedArgument = false;
/**@type {string[]}*/ aliases = [];

View File

@@ -5,7 +5,8 @@ export class SlashCommandAbortController {
constructor() {
this.signal = new SlashCommandAbortSignal();
}
abort(reason = 'No reason.') {
abort(reason = 'No reason.', isQuiet = false) {
this.signal.isQuiet = isQuiet;
this.signal.aborted = true;
this.signal.reason = reason;
}
@@ -20,8 +21,8 @@ export class SlashCommandAbortController {
}
export class SlashCommandAbortSignal {
/**@type {boolean}*/ isQuiet = false;
/**@type {boolean}*/ paused = false;
/**@type {boolean}*/ aborted = false;
/**@type {string}*/ reason = null;
}

View File

@@ -50,6 +50,17 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
}
getNamedArgumentAt(text, index, isSelect) {
function getSplitRegex() {
try {
return new RegExp('(?<==)');
} catch {
// For browsers that don't support lookbehind
return new RegExp('=(.*)');
}
}
if (!Array.isArray(this.executor.command?.namedArgumentList)) {
return null;
}
const notProvidedNamedArguments = this.executor.command.namedArgumentList.filter(arg=>!this.executor.namedArgumentList.find(it=>it.name == arg.name));
let name;
let value;
@@ -62,7 +73,7 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
// cursor is somewhere within the named arguments (including final space)
argAssign = this.executor.namedArgumentList.find(it=>it.start <= index && it.end >= index);
if (argAssign) {
const [argName, ...v] = text.slice(argAssign.start, index).split(/(?<==)/);
const [argName, ...v] = text.slice(argAssign.start, index).split(getSplitRegex());
name = argName;
value = v.join('');
start = argAssign.start;
@@ -99,7 +110,7 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
const result = new AutoCompleteSecondaryNameResult(
value,
start + name.length,
cmdArg.enumList.map(it=>new SlashCommandEnumAutoCompleteOption(it)),
cmdArg.enumList.map(it=>new SlashCommandEnumAutoCompleteOption(this.executor.command, it)),
true,
);
result.isRequired = true;
@@ -122,6 +133,9 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
}
getUnnamedArgumentAt(text, index, isSelect) {
if (!Array.isArray(this.executor.command?.unnamedArgumentList)) {
return null;
}
const lastArgIsBlank = this.executor.unnamedArgumentList.slice(-1)[0]?.value == '';
const notProvidedArguments = this.executor.command.unnamedArgumentList.slice(this.executor.unnamedArgumentList.length - (lastArgIsBlank ? 1 : 0));
let value;
@@ -154,7 +168,7 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
const result = new AutoCompleteSecondaryNameResult(
value,
start,
cmdArg.enumList.map(it=>new SlashCommandEnumAutoCompleteOption(it)),
cmdArg.enumList.map(it=>new SlashCommandEnumAutoCompleteOption(this.executor.command, it)),
false,
);
const isCompleteValue = cmdArg.enumList.find(it=>it.value == value);

View File

@@ -161,6 +161,7 @@ export class SlashCommandClosure {
let args = {
_scope: this.scope,
_parserFlags: executor.parserFlags,
_abortController: this.abortController,
};
let value;
// substitute named arguments
@@ -223,6 +224,15 @@ export class SlashCommandClosure {
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}')
;
} else if (Array.isArray(value)) {
value = value.map(v=>{
if (typeof v == 'string') {
return v
?.replace(/\\\{/g, '{')
?.replace(/\\\}/g, '}');
}
return v;
});
}
let abortResult = await this.testAbortController();
@@ -254,6 +264,7 @@ export class SlashCommandClosure {
if (this.abortController?.signal?.aborted) {
const result = new SlashCommandClosureResult();
result.isAborted = true;
result.isQuietlyAborted = this.abortController.signal.isQuiet;
result.abortReason = this.abortController.signal.reason.toString();
return result;
}

View File

@@ -2,6 +2,7 @@ export class SlashCommandClosureResult {
/**@type {boolean}*/ interrupt = false;
/**@type {string}*/ pipe;
/**@type {boolean}*/ isAborted = false;
/**@type {boolean}*/ isQuietlyAborted = false;
/**@type {string}*/ abortReason;
/**@type {boolean}*/ isError = false;
/**@type {string}*/ errorMessage;

View File

@@ -1,16 +1,20 @@
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
import { SlashCommand } from './SlashCommand.js';
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption {
/**@type {SlashCommand}*/ cmd;
/**@type {SlashCommandEnumValue}*/ enumValue;
/**
* @param {SlashCommand} cmd
* @param {SlashCommandEnumValue} enumValue
*/
constructor(enumValue) {
constructor(cmd, enumValue) {
super(enumValue.value, '◊');
this.cmd = cmd;
this.enumValue = enumValue;
}
@@ -25,22 +29,6 @@ export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption {
renderDetails() {
const frag = document.createDocumentFragment();
const specs = document.createElement('div'); {
specs.classList.add('specs');
const name = document.createElement('div'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = this.name;
specs.append(name);
}
frag.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
help.textContent = this.enumValue.description;
frag.append(help);
}
return frag;
return this.cmd.renderHelpDetails();
}
}

View File

@@ -20,7 +20,7 @@ export class SlashCommandExecutor {
// @ts-ignore
/**@type {SlashCommandNamedArgumentAssignment[]}*/ namedArgumentList = [];
/**@type {SlashCommandUnnamedArgumentAssignment[]}*/ unnamedArgumentList = [];
/**@type {Object<PARSER_FLAG,boolean>} */ parserFlags;
/**@type {{[id:PARSER_FLAG]:boolean}} */ parserFlags;
get commandCount() {
return 1

View File

@@ -27,22 +27,6 @@ export class SlashCommandNamedArgumentAutoCompleteOption extends AutoCompleteOpt
renderDetails() {
const frag = document.createDocumentFragment();
const specs = document.createElement('div'); {
specs.classList.add('specs');
const name = document.createElement('div'); {
name.classList.add('name');
name.classList.add('monospace');
name.textContent = this.name;
specs.append(name);
}
frag.append(specs);
}
const help = document.createElement('span'); {
help.classList.add('help');
help.innerHTML = `${this.arg.isRequired ? '' : '(optional) '}${this.arg.description ?? ''}`;
frag.append(help);
}
return frag;
return this.cmd.renderHelpDetails();
}
}

View File

@@ -114,7 +114,6 @@ export class SlashCommandParser {
constructor() {
//TODO should not be re-registered from every instance
// add dummy commands for help strings / autocomplete
if (!Object.keys(this.commands).includes('parser-flag')) {
const help = {};
@@ -186,6 +185,15 @@ export class SlashCommandParser {
relevance: 0,
};
function getQuotedRunRegex() {
try {
return new RegExp('(".+?(?<!\\\\)")|(\\S+?)');
} catch {
// fallback for browsers that don't support lookbehind
return /(".+?")|(\S+?)/;
}
}
const COMMENT = {
scope: 'comment',
begin: /\/[/#]/,
@@ -225,7 +233,7 @@ export class SlashCommandParser {
const RUN = {
match: [
/\/:/,
/(".+?(?<!\\)") |(\S+?) /,
getQuotedRunRegex(),
],
className: {
1: 'variable.language',
@@ -362,7 +370,6 @@ export class SlashCommandParser {
;
if (childClosure !== null) return null;
const macro = this.macroIndex.findLast(it=>it.start <= index && it.end >= index);
console.log(macro);
if (macro) {
const frag = document.createRange().createContextualFragment(await (await fetch('/scripts/templates/macros.html')).text());
const options = [...frag.querySelectorAll('ul:nth-of-type(2n+1) > li')].map(li=>new MacroAutoCompleteOption(
@@ -821,6 +828,7 @@ export class SlashCommandParser {
assignment.start = this.index;
value = '';
}
assignment.start = this.index;
assignment.value = this.parseClosure();
assignment.end = this.index;
listValues.push(assignment);

View File

@@ -174,7 +174,7 @@ async function* parseStreamData(json) {
else if (Array.isArray(json.choices)) {
const isNotPrimary = json?.choices?.[0]?.index > 0;
if (isNotPrimary || json.choices.length === 0) {
return null;
throw new Error('Not a primary swipe');
}
if (typeof json.choices[0].text === 'string' && json.choices[0].text.length > 0) {
@@ -271,7 +271,7 @@ export class SmoothEventSourceStream extends EventSourceStream {
hasFocus && await eventSource.emit(event_types.SMOOTH_STREAM_TOKEN_RECEIVED, parsed.chunk);
}
} catch (error) {
console.error('Smooth Streaming parsing error', error);
console.debug('Smooth Streaming parsing error', error);
controller.enqueue(event);
}
},

View File

@@ -14,9 +14,12 @@ import {
// eslint-disable-next-line no-unused-vars
import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
import { groupCandidatesFilter, groups, selected_group } from './group-chats.js';
import { groupCandidatesFilter, groups, select_group_chats, selected_group } from './group-chats.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight } from './utils.js';
import { power_user } from './power-user.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
export {
TAG_FOLDER_TYPES,
@@ -63,7 +66,7 @@ export const tag_filter_types = {
const ACTIONABLE_TAGS = {
FAV: { id: '1', sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' },
GROUP: { id: '0', sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' },
FOLDER: { id: '4', sort_order: 3, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' },
FOLDER: { id: '4', sort_order: 3, name: 'Show only folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' },
VIEW: { id: '2', sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' },
HINT: { id: '3', sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' },
UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' },
@@ -174,9 +177,8 @@ function filterByTagState(entities, { globalDisplayFilters = false, subForEntity
}
// Hide folders that have 0 visible sub entities after the first filtering round
const alwaysFolder = isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED);
if (entity.type === 'tag') {
return alwaysFolder || entity.entities.length > 0;
return entity.entities.length > 0;
}
return true;
@@ -322,6 +324,13 @@ function filterByGroups(filterHelper) {
* @param {FilterHelper} filterHelper Instance of FilterHelper class.
*/
function filterByFolder(filterHelper) {
if (!power_user.bogus_folders) {
$('#bogus_folders').prop('checked', true).trigger('input');
onViewTagsListClick();
flashHighlight($('#dialogue_popup .tag_as_folder, #dialogue_popup .tag_folder_indicator'));
return;
}
const state = toggleTagThreeState($(this));
ACTIONABLE_TAGS.FOLDER.filter_state = state;
filterHelper.setFilterData(FILTER_TYPES.FOLDER, state);
@@ -350,7 +359,7 @@ function createTagMapFromList(listElement, key) {
* If you have an entity, you can get it's key easily via `getTagKeyForEntity(entity)`.
*
* @param {string} key - The key for which to get tags via the tag map
* @param {boolean} [sort=true] -
* @param {boolean} [sort=true] - Whether the tag list should be sorted
* @returns {Tag[]} A list of tags
*/
function getTagsList(key, sort = true) {
@@ -456,35 +465,122 @@ export function getTagKeyForEntityElement(element) {
return undefined;
}
/**
* Adds a tag to a given entity
* @param {Tag} tag - The tag to add
* @param {string|string[]} entityId - The entity to add this tag to. Has to be the entity key (e.g. `addTagToEntity`). (Also allows multiple entities to be passed in)
* @param {object} [options={}] - Optional arguments
* @param {JQuery<HTMLElement>|string?} [options.tagListSelector=null] - An optional selector if a specific list should be updated with the new tag too (for example because the add was triggered for that function)
* @param {PrintTagListOptions} [options.tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before.
* @returns {boolean} Whether at least one tag was added
*/
export function addTagToEntity(tag, entityId, { tagListSelector = null, tagListOptions = {} } = {}) {
let result = false;
// Add tags to the map
if (Array.isArray(entityId)) {
entityId.forEach((id) => result = addTagToMap(tag.id, id) || result);
} else {
result = addTagToMap(tag.id, entityId);
}
// Save and redraw
printCharactersDebounced();
saveSettingsDebounced();
// We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it
tagListOptions.addTag = tag;
// add tag to the UI and internal map - we reprint so sorting and new markup is done correctly
if (tagListSelector) printTagList(tagListSelector, tagListOptions);
const inlineSelector = getInlineListSelector();
if (inlineSelector) {
printTagList($(inlineSelector), tagListOptions);
}
return result;
}
/**
* Removes a tag from a given entity
* @param {Tag} tag - The tag to remove
* @param {string|string[]} entityId - The entity to remove this tag from. Has to be the entity key (e.g. `addTagToEntity`). (Also allows multiple entities to be passed in)
* @param {object} [options={}] - Optional arguments
* @param {JQuery<HTMLElement>|string?} [options.tagListSelector=null] - An optional selector if a specific list should be updated with the tag removed too (for example because the add was triggered for that function)
* @param {JQuery<HTMLElement>?} [options.tagElement=null] - Optionally a direct html element of the tag to be removed, so it can be removed from the UI
* @returns {boolean} Whether at least one tag was removed
*/
export function removeTagFromEntity(tag, entityId, { tagListSelector = null, tagElement = null } = {}) {
let result = false;
// Remove tag from the map
if (Array.isArray(entityId)) {
entityId.forEach((id) => result = removeTagFromMap(tag.id, id) || result);
} else {
result = removeTagFromMap(tag.id, entityId);
}
// Save and redraw
printCharactersDebounced();
saveSettingsDebounced();
// We don't reprint the lists, we can just remove the html elements from them.
if (tagListSelector) {
const $selector = (typeof tagListSelector === 'string') ? $(tagListSelector) : tagListSelector;
$selector.find(`.tag[id="${tag.id}"]`).remove();
}
if (tagElement) tagElement.remove();
$(`${getInlineListSelector()} .tag[id="${tag.id}"]`).remove();
return result;
}
/**
* Adds a tag from a given character. If no character is provided, adds it from the currently active one.
* @param {string} tagId - The id of the tag
* @param {string} characterId - The id/key of the character or group
* @returns {boolean} Whether the tag was added or not
*/
function addTagToMap(tagId, characterId = null) {
const key = characterId !== null && characterId !== undefined ? getTagKeyForEntity(characterId) : getTagKey();
if (!key) {
return;
return false;
}
if (!Array.isArray(tag_map[key])) {
tag_map[key] = [tagId];
return true;
}
else {
if (tag_map[key].includes(tagId))
return false;
tag_map[key].push(tagId);
tag_map[key] = tag_map[key].filter(onlyUnique);
return true;
}
}
/**
* Removes a tag from a given character. If no character is provided, removes it from the currently active one.
* @param {string} tagId - The id of the tag
* @param {string} characterId - The id/key of the character or group
* @returns {boolean} Whether the tag was removed or not
*/
function removeTagFromMap(tagId, characterId = null) {
const key = characterId !== null && characterId !== undefined ? getTagKeyForEntity(characterId) : getTagKey();
if (!key) {
return;
return false;
}
if (!Array.isArray(tag_map[key])) {
tag_map[key] = [];
return false;
}
else {
const indexOf = tag_map[key].indexOf(tagId);
tag_map[key].splice(indexOf, 1);
return indexOf !== -1;
}
}
@@ -528,24 +624,7 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) {
const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters;
const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
if (characterIds) {
characterIds.forEach((characterId) => addTagToMap(tag.id, characterId));
} else {
addTagToMap(tag.id);
}
printCharactersDebounced();
saveSettingsDebounced();
// We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it
tagListOptions.addTag = tag;
// add tag to the UI and internal map - we reprint so sorting and new markup is done correctly
printTagList(listSelector, tagListOptions);
const inlineSelector = getInlineListSelector();
if (inlineSelector) {
printTagList($(inlineSelector), tagListOptions);
}
addTagToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions });
// need to return false to keep the input clear
return false;
@@ -628,6 +707,7 @@ function createNewTag(tagName) {
create_date: Date.now(),
};
tags.push(tag);
console.debug('Created new tag', tag.name, 'with id', tag.id);
return tag;
}
@@ -883,8 +963,9 @@ function printTagFilters(type = tag_filter_types.character) {
const FILTER_SELECTOR = type === tag_filter_types.character ? CHARACTER_FILTER_SELECTOR : GROUP_FILTER_SELECTOR;
$(FILTER_SELECTOR).empty();
// Print all action tags. (Exclude folder if that setting isn't chosen)
const actionTags = Object.values(ACTIONABLE_TAGS).filter(tag => power_user.bogus_folders || tag.id != ACTIONABLE_TAGS.FOLDER.id);
// Print all action tags. (Rework 'Folder' button to some kind of onboarding if no folders are enabled yet)
const actionTags = Object.values(ACTIONABLE_TAGS);
actionTags.find(x => x == ACTIONABLE_TAGS.FOLDER).name = power_user.bogus_folders ? 'Show only folders' : 'Enable \'Tags as Folder\'\n\nAllows characters to be grouped in folders by their assigned tags.\nTags have to be explicitly chosen as folder to show up.\n\nClick here to start';
printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
const inListActionTags = Object.values(InListActionable);
@@ -924,8 +1005,8 @@ function updateTagFilterIndicator() {
function onTagRemoveClick(event) {
event.stopPropagation();
const tag = $(this).closest('.tag');
const tagId = tag.attr('id');
const tagElement = $(this).closest('.tag');
const tagId = tagElement.attr('id');
// Check if we are inside the drilldown. If so, we call remove on the bogus folder
if ($(this).closest('.rm_tag_bogus_drilldown').length > 0) {
@@ -934,24 +1015,13 @@ function onTagRemoveClick(event) {
return;
}
const tag = tags.find(t => t.id === tagId);
// Optional, check for multiple character ids being present.
const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters;
const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
tag.remove();
if (characterIds) {
characterIds.forEach((characterId) => removeTagFromMap(tagId, characterId));
} else {
removeTagFromMap(tagId);
}
$(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove();
printCharactersDebounced();
saveSettingsDebounced();
removeTagFromEntity(tag, characterIds, { tagElement: tagElement });
}
// @ts-ignore
@@ -977,7 +1047,7 @@ function onGroupCreateClick() {
export function applyTagsOnCharacterSelect() {
//clearTagsFilter();
const chid = Number($(this).attr('chid'));
const chid = Number(this_chid);
printTagList($('#tagList'), { forEntityOrKey: chid, tagOptions: { removable: true } });
}
@@ -1453,14 +1523,200 @@ function printViewTagList(empty = true) {
}
}
function registerTagsSlashCommands() {
/**
* Gets the key for char/group for a slash command. If none can be found, a toastr will be shown and null returned.
* @param {string?} [charName] The optionally provided char name
* @returns {string?} - The char/group key, or null if none found
*/
function paraGetCharKey(charName) {
const entity = charName
? (characters.find(x => x.name === charName) || groups.find(x => x.name == charName))
: (selected_group ? groups.find(x => x.id == selected_group) : characters[this_chid]);
const key = getTagKeyForEntity(entity);
if (!key) {
toastr.warning(`Character ${charName} not found.`);
return null;
}
return key;
}
/**
* Gets a tag by its name. Optionally can create the tag if it does not exist.
* @param {string} tagName - The name of the tag
* @param {object} options - Optional arguments
* @param {boolean} [options.allowCreate=false] - Whether a new tag should be created if no tag with the name exists
* @returns {Tag?} The tag, or null if not found
*/
function paraGetTag(tagName, { allowCreate = false } = {}) {
if (!tagName) {
toastr.warning('Tag name must be provided.');
return null;
}
let tag = tags.find(t => t.name === tagName);
if (allowCreate && !tag) {
tag = createNewTag(tagName);
}
if (!tag) {
toastr.warning(`Tag ${tagName} not found.`);
return null;
}
return tag;
}
function updateTagsList() {
switch (menu_type) {
case 'characters':
printTagFilters(tag_filter_types.character);
printTagFilters(tag_filter_types.group_member);
break;
case 'character_edit':
applyTagsOnCharacterSelect();
break;
case 'group_edit':
select_group_chats(selected_group, true);
break;
}
}
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tag-add',
returns: 'true/false - Whether the tag was added or was assigned already',
/** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */
callback: ({ name }, tagName) => {
const key = paraGetCharKey(name);
if (!key) return 'false';
const tag = paraGetTag(tagName, { allowCreate: true });
if (!tag) return 'false';
const result = addTagToEntity(tag, key);
updateTagsList();
return String(result);
},
namedArgumentList: [
new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'),
],
unnamedArgumentList: [
new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true),
],
helpString: `
<div>
Adds a tag to the character. If no character is provided, it adds it to the current character (<code>{{char}}</code>).
If the tag doesn't exist, it is created.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/tag-add name="Chloe" scenario</code></pre>
will add the tag "scenario" to the character named Chloe.
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tag-remove',
returns: 'true/false - Whether the tag was removed or wasn\'t assigned already',
/** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */
callback: ({ name }, tagName) => {
const key = paraGetCharKey(name);
if (!key) return 'false';
const tag = paraGetTag(tagName);
if (!tag) return 'false';
const result = removeTagFromEntity(tag, key);
updateTagsList();
return String(result);
},
namedArgumentList: [
new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'),
],
unnamedArgumentList: [
new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true),
],
helpString: `
<div>
Removes a tag from the character. If no character is provided, it removes it from the current character (<code>{{char}}</code>).
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/tag-remove name="Chloe" scenario</code></pre>
will remove the tag "scenario" from the character named Chloe.
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tag-exists',
returns: 'true/false - Whether the given tag name is assigned to the character',
/** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */
callback: ({ name }, tagName) => {
const key = paraGetCharKey(name);
if (!key) return 'false';
const tag = paraGetTag(tagName);
if (!tag) return 'false';
return String(tag_map[key].includes(tag.id));
},
namedArgumentList: [
new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'),
],
unnamedArgumentList: [
new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true),
],
helpString: `
<div>
Checks whether the given tag is assigned to the character. If no character is provided, it checks the current character (<code>{{char}}</code>).
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/tag-exists name="Chloe" scenario</code></pre>
will return true if the character named Chloe has the tag "scenario".
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'tag-list',
returns: 'Comma-separated list of all assigned tags',
/** @param {{name: string}} namedArgs @returns {string} */
callback: ({ name }) => {
const key = paraGetCharKey(name);
if (!key) return '';
const tags = getTagsList(key);
return tags.map(x => x.name).join(', ');
},
namedArgumentList: [
new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'),
],
helpString: `
<div>
Lists all assigned tags of the character. If no character is provided, it uses the current character (<code>{{char}}</code>).
<br />
Note that there is no special handling for tags containing commas, they will be printed as-is.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/tag-list name="Chloe"</code></pre>
could return something like <code>OC, scenario, edited, funny</code>
</li>
</ul>
</div>
`,
}));
}
export function initTags() {
createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } });
createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } });
$(document).on('click', '#rm_button_create', onCharacterCreateClick);
$(document).on('click', '#rm_button_group_chats', onGroupCreateClick);
$(document).on('click', '.character_select', applyTagsOnCharacterSelect);
$(document).on('click', '.group_select', applyTagsOnGroupSelect);
$(document).on('click', '.tag_remove', onTagRemoveClick);
$(document).on('input', '.tag_input', onTagInput);
$(document).on('click', '.tags_view', onViewTagsListClick);
@@ -1471,6 +1727,7 @@ export function initTags() {
$(document).on('click', '.tag_view_backup', onTagsBackupClick);
$(document).on('click', '.tag_view_restore', onBackupRestoreClick);
eventSource.on(event_types.CHARACTER_DUPLICATED, copyTags);
eventSource.makeFirst(event_types.CHAT_CHANGED, () => selected_group ? applyTagsOnGroupSelect() : applyTagsOnCharacterSelect());
$(document).on('input', '#dialogue_popup input[name="auto_sort_tags"]', (evt) => {
const toggle = $(evt.target).is(':checked');
@@ -1498,4 +1755,6 @@ export function initTags() {
printCharactersDebounced();
}
}
registerTagsSlashCommands();
}

View File

@@ -11,9 +11,10 @@
<span data-i18n="Click ">Click </span><code><i class="fa-solid fa-plug"></i></code><span data-i18n="and select a"> and select a </span><a href="https://docs.sillytavern.app/usage/api-connections/" target="_blank" data-i18n="Chat API">Chat API</a>.</span>
</li>
<li>
<span data-i18n="Click ">Click </span><code><i class="fa-solid fa-address-card"></i></code><span data-i18n="and pick a character"> and pick a character</span>
<span data-i18n="Click ">Click </span><code><i class="fa-solid fa-address-card"></i></code><span data-i18n="and pick a character."> and pick a character.</span>
</li>
</ol>
<span data-i18n="You can browse a list of bundled characters in the Download Extensions & Assets menu within">You can browse a list of bundled characters in the <i>Download Extensions & Assets</i> menu within </span><code><i class="fa-solid fa-cubes"></i></code><span>.</span>
<hr>
<h3 data-i18n="Confused or lost?">Confused or lost?</h3>
<ul>

View File

@@ -237,7 +237,7 @@ function onMancerModelSelect() {
$('#api_button_textgenerationwebui').trigger('click');
const limits = mancerModels.find(x => x.id === modelId)?.limits;
setGenerationParamsFromPreset({ max_length: limits.context, genamt: limits.completion }, true);
setGenerationParamsFromPreset({ max_length: limits.context });
}
function onTogetherModelSelect() {

View File

@@ -166,7 +166,7 @@ export let textgenerationwebui_banned_in_macros = [];
export let textgenerationwebui_presets = [];
export let textgenerationwebui_preset_names = [];
const setting_names = [
export const setting_names = [
'temp',
'temperature_last',
'rep_pen',
@@ -659,7 +659,7 @@ jQuery(function () {
'no_repeat_ngram_size_textgenerationwebui': 0,
'min_length_textgenerationwebui': 0,
'num_beams_textgenerationwebui': 1,
'length_penalty_textgenerationwebui': 0,
'length_penalty_textgenerationwebui': 1,
'penalty_alpha_textgenerationwebui': 0,
'typical_p_textgenerationwebui': 1, // Added entry
'guidance_scale_textgenerationwebui': 1,
@@ -991,7 +991,7 @@ export function getTextGenModel() {
}
export function isJsonSchemaSupported() {
return settings.type === TABBY && main_api === 'textgenerationwebui';
return [TABBY, LLAMACPP].includes(settings.type) && main_api === 'textgenerationwebui';
}
export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate, isContinue, cfgValues, type) {
@@ -1065,7 +1065,7 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
'guidance_scale': cfgValues?.guidanceScale?.value ?? settings.guidance_scale ?? 1,
'negative_prompt': cfgValues?.negativePrompt ?? substituteParams(settings.negative_prompt) ?? '',
'grammar_string': settings.grammar_string,
'json_schema': settings.type === TABBY ? settings.json_schema : undefined,
'json_schema': [TABBY, LLAMACPP].includes(settings.type) ? settings.json_schema : undefined,
// llama.cpp aliases. In case someone wants to use LM Studio as Text Completion API
'repeat_penalty': settings.rep_pen,
'tfs_z': settings.tfs,
@@ -1150,5 +1150,15 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
eventSource.emitAndWait(event_types.TEXT_COMPLETION_SETTINGS_READY, params);
// Grammar conflicts with with json_schema
if (settings.type === LLAMACPP) {
if (params.json_schema && Object.keys(params.json_schema).length > 0) {
delete params.grammar_string;
delete params.grammar;
} else {
delete params.json_schema;
}
}
return params;
}

View File

@@ -732,6 +732,24 @@ export function isDataURL(str) {
return regex.test(str);
}
/**
* Gets the size of an image from a data URL.
* @param {string} dataUrl Image data URL
* @returns {Promise<{ width: number, height: number }>} Image size
*/
export function getImageSizeFromDataURL(dataUrl) {
const image = new Image();
image.src = dataUrl;
return new Promise((resolve, reject) => {
image.onload = function () {
resolve({ width: image.width, height: image.height });
};
image.onerror = function () {
reject(new Error('Failed to load image'));
};
});
}
export function getCharaFilename(chid) {
const context = getContext();
const fileName = context.characters[chid ?? context.characterId].avatar;
@@ -773,6 +791,29 @@ export function escapeRegex(string) {
return string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
}
/**
* Instantiates a regular expression from a string.
* @param {string} input The input string.
* @returns {RegExp} The regular expression instance.
* @copyright Originally from: https://github.com/IonicaBizau/regex-parser.js/blob/master/lib/index.js
*/
export function regexFromString(input) {
try {
// Parse input
var m = input.match(/(\/?)(.+)\1([a-z]*)/i);
// Invalid flags
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3])) {
return RegExp(input);
}
// Create the regular expression
return new RegExp(m[2], m[3]);
} catch {
return;
}
}
export class Stopwatch {
/**
* Initializes a Stopwatch class.
@@ -1449,3 +1490,166 @@ export function includesIgnoreCaseAndAccents(text, searchTerm) {
// Check if the normalized text includes the normalized search term
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
*
* @param {string} option - The option
* @returns {string} A hashed version of that option
*/
export function getSelect2OptionId(option) {
return String(getStringHash(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[]|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 {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) 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);
// Append it to the select
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 {{transport: (params, success, failure) => any}} The ajax object with the transport function to use on the select2 ajax property
*/
export function dynamicSelect2DataViaAjax(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 includesIgnoreCaseAndAccents(item.text, params.data.q);
});
}
var promise = new Promise(function (resolve, reject) {
resolve({ results: items });
});
promise.then(success);
promise.catch(failure);
}
const ajax = {
transport: dynamicSelect2DataTransport,
};
return ajax;
}
/**
* Checks whether a given control is a select2 choice element - meaning one of the results being displayed in the select multi select box
* @param {JQuery<HTMLElement>|HTMLElement} element - The element to check
* @returns {boolean} Whether this is a choice element
*/
export function isSelect2ChoiceElement(element) {
const $element = $(element);
return ($element.hasClass('select2-selection__choice__display') || $element.parents('.select2-selection__choice__display').length > 0);
}
/**
* Subscribes a 'click' event handler to the choice elements of a select2 multi-select control
*
* @param {JQuery<HTMLElement>} control The original control the select2 was applied to
* @param {function(HTMLElement):void} action - The action to execute when a choice element is clicked
* @param {object} options - Optional parameters
* @param {boolean} [options.buttonStyle=false] - Whether the choices should be styles as a clickable button with color and hover transition, instead of just changed cursor
* @param {boolean} [options.closeDrawer=false] - Whether the drawer should be closed and focus removed after the choice item was clicked
* @param {boolean} [options.openDrawer=false] - Whether the drawer should be opened, even if this click would normally close it
*/
export function select2ChoiceClickSubscribe(control, action, { buttonStyle = false, closeDrawer = false, openDrawer = false } = {}) {
// Add class for styling (hover color, changed cursor, etc)
control.addClass('select2_choice_clickable');
if (buttonStyle) control.addClass('select2_choice_clickable_buttonstyle');
// Get the real container below and create a click handler on that one
const select2Container = control.next('span.select2-container');
select2Container.on('click', function (event) {
const isChoice = isSelect2ChoiceElement(event.target);
if (isChoice) {
event.preventDefault();
// select2 still bubbles the event to open the dropdown. So we close it here and remove focus if we want that
if (closeDrawer) {
control.select2('close');
setTimeout(() => select2Container.find('textarea').trigger('blur'), debounce_timeout.quick);
}
if (openDrawer) {
control.select2('open');
}
// Now execute the actual action that was subscribed
action(event.target);
}
});
}
/**
* Applies syntax highlighting to a given regex string by generating HTML with classes
*
* @param {string} regexStr - The javascript compatible regex string
* @returns {string} The html representation of the highlighted regex
*/
export function highlightRegex(regexStr) {
// Function to escape HTML special characters for safety
const escapeHtml = (str) => str.replace(/[&<>"']/g, match => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;',
})[match]);
// Replace special characters with their HTML-escaped forms
regexStr = escapeHtml(regexStr);
// Patterns that we want to highlight only if they are not escaped
const patterns = {
brackets: /(?<!\\)\[.*?\]/g, // Non-escaped squary brackets
quantifiers: /(?<!\\)[*+?{}]/g, // Non-escaped quantifiers
operators: /(?<!\\)[|.^$()]/g, // Non-escaped operators like | and ()
specialChars: /\\./g,
flags: /(?<=\/)([gimsuy]*)$/g, // Match trailing flags
delimiters: /^\/|(?<![\\<])\//g, // Match leading or trailing delimiters
};
// Function to replace each pattern with a highlighted HTML span
const wrapPattern = (pattern, className) => {
regexStr = regexStr.replace(pattern, match => `<span class="${className}">${match}</span>`);
};
// Apply highlighting patterns
wrapPattern(patterns.brackets, 'regex-brackets');
wrapPattern(patterns.quantifiers, 'regex-quantifier');
wrapPattern(patterns.operators, 'regex-operator');
wrapPattern(patterns.specialChars, 'regex-special');
wrapPattern(patterns.flags, 'regex-flags');
wrapPattern(patterns.delimiters, 'regex-delimiter');
return `<span class="regex-highlight">${regexStr}</span>`;
}

View File

@@ -1,11 +1,13 @@
import { chat_metadata, getCurrentChatId, saveSettingsDebounced, sendSystemMessage, system_message_types } from '../script.js';
import { extension_settings, saveMetadataDebounced } from './extensions.js';
import { executeSlashCommands } from './slash-commands.js';
import { executeSlashCommands, executeSlashCommandsWithOptions } from './slash-commands.js';
import { SlashCommand } from './slash-commands/SlashCommand.js';
import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js';
import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { PARSER_FLAG, SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
import { isFalseBoolean } from './utils.js';
@@ -314,61 +316,117 @@ function listVariablesCallback() {
sendSystemMessage(system_message_types.GENERIC, htmlMessage);
}
async function whileCallback(args, command) {
/**
*
* @param {import('./slash-commands/SlashCommand.js').NamedArguments} args
* @param {(string|SlashCommandClosure)[]} value
*/
async function whileCallback(args, value) {
const isGuardOff = isFalseBoolean(args.guard);
const iterations = isGuardOff ? Number.MAX_SAFE_INTEGER : MAX_LOOPS;
/**@type {string|SlashCommandClosure} */
let command;
if (value) {
if (value[0] instanceof SlashCommandClosure) {
command = value[0];
} else {
command = value.join(' ');
}
}
let commandResult;
for (let i = 0; i < iterations; i++) {
const { a, b, rule } = parseBooleanOperands(args);
const result = evalBoolean(rule, a, b);
if (result && command) {
if (command instanceof SlashCommandClosure) await command.execute();
else await executeSubCommands(command, args._scope, args._parserFlags);
if (command instanceof SlashCommandClosure) {
commandResult = await command.execute();
} else {
commandResult = await executeSubCommands(command, args._scope, args._parserFlags, args._abortController);
}
if (commandResult.isAborted) break;
} else {
break;
}
}
if (commandResult) {
return commandResult.pipe;
}
return '';
}
/**
*
* @param {import('./slash-commands/SlashCommand.js').NamedArguments} args
* @param {import('./slash-commands/SlashCommand.js').UnnamedArguments} value
* @returns
*/
async function timesCallback(args, value) {
let repeats;
let command;
if (Array.isArray(value)) {
[repeats, command] = value;
[repeats, ...command] = value;
if (command[0] instanceof SlashCommandClosure) {
command = command[0];
} else {
command = command.join(' ');
}
} else {
[repeats, ...command] = value.split(' ');
[repeats, ...command] = /**@type {string}*/(value).split(' ');
command = command.join(' ');
}
const isGuardOff = isFalseBoolean(args.guard);
const iterations = Math.min(Number(repeats), isGuardOff ? Number.MAX_SAFE_INTEGER : MAX_LOOPS);
let result;
for (let i = 0; i < iterations; i++) {
/**@type {SlashCommandClosureResult}*/
if (command instanceof SlashCommandClosure) {
command.scope.setMacro('timesIndex', i);
await command.execute();
result = await command.execute();
}
else {
await executeSubCommands(command.replace(/\{\{timesIndex\}\}/g, i), args._scope, args._parserFlags);
result = await executeSubCommands(command.replace(/\{\{timesIndex\}\}/g, i.toString()), args._scope, args._parserFlags, args._abortController);
}
if (result.isAborted) break;
}
return '';
return result?.pipe ?? '';
}
async function ifCallback(args, command) {
/**
*
* @param {import('./slash-commands/SlashCommand.js').NamedArguments} args
* @param {(string|SlashCommandClosure)[]} value
*/
async function ifCallback(args, value) {
const { a, b, rule } = parseBooleanOperands(args);
const result = evalBoolean(rule, a, b);
if (result && command) {
if (command instanceof SlashCommandClosure) return (await command.execute()).pipe;
return await executeSubCommands(command, args._scope, args._parserFlags);
} else if (!result && args.else && ((typeof args.else === 'string' && args.else !== '') || args.else instanceof SlashCommandClosure)) {
if (args.else instanceof SlashCommandClosure) return (await args.else.execute(args._scope)).pipe;
return await executeSubCommands(args.else, args._scope, args._parserFlags);
/**@type {string|SlashCommandClosure} */
let command;
if (value) {
if (value[0] instanceof SlashCommandClosure) {
command = value[0];
} else {
command = value.join(' ');
}
}
let commandResult;
if (result && command) {
if (command instanceof SlashCommandClosure) return (await command.execute()).pipe;
commandResult = await executeSubCommands(command, args._scope, args._parserFlags, args._abortController);
} else if (!result && args.else && ((typeof args.else === 'string' && args.else !== '') || args.else instanceof SlashCommandClosure)) {
if (args.else instanceof SlashCommandClosure) return (await args.else.execute()).pipe;
commandResult = await executeSubCommands(args.else, args._scope, args._parserFlags, args._abortController);
}
if (commandResult) {
return commandResult.pipe;
}
return '';
}
@@ -511,20 +569,25 @@ function evalBoolean(rule, a, b) {
/**
* Executes a slash command from a string (may be enclosed in quotes) and returns the result.
* @param {string} command Command to execute. May contain escaped macro and batch separators.
* @returns {Promise<string>} Pipe result
* @param {SlashCommandScope} [scope] The scope to use.
* @param {{[id:PARSER_FLAG]:boolean}} [parserFlags] The parser flags to use.
* @param {SlashCommandAbortController} [abortController] The abort controller to use.
* @returns {Promise<SlashCommandClosureResult>} Closure execution result
*/
async function executeSubCommands(command, scope = null, parserFlags = null) {
async function executeSubCommands(command, scope = null, parserFlags = null, abortController = null) {
if (command.startsWith('"') && command.endsWith('"')) {
command = command.slice(1, -1);
}
const result = await executeSlashCommands(command, true, scope, true, parserFlags);
const result = await executeSlashCommandsWithOptions(command, {
handleExecutionErrors: false,
handleParserErrors: false,
parserFlags,
scope,
abortController: abortController ?? new SlashCommandAbortController(),
});
if (!result || typeof result !== 'object') {
return '';
}
return result?.pipe || '';
return result;
}
/**
@@ -1066,6 +1129,7 @@ export function registerVariableCommands() {
'command to execute if true', [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND], true,
),
],
splitUnnamedArgument: true,
helpString: `
<div>
Compares the value of the left operand <code>a</code> with the value of the right operand <code>b</code>,
@@ -1132,6 +1196,7 @@ export function registerVariableCommands() {
'command to execute while true', [ARGUMENT_TYPE.CLOSURE, ARGUMENT_TYPE.SUBCOMMAND], true,
),
],
splitUnnamedArgument: true,
helpString: `
<div>
Compares the value of the left operand <code>a</code> with the value of the right operand <code>b</code>,
@@ -1158,7 +1223,7 @@ export function registerVariableCommands() {
<strong>Examples:</strong>
<ul>
<li>
<pre><code class="language-stscript">/setvar key=i 0 | /while left=i right=10 rule=let "/addvar key=i 1"</code></pre>
<pre><code class="language-stscript">/setvar key=i 0 | /while left=i right=10 rule=lte "/addvar key=i 1"</code></pre>
adds 1 to the value of "i" until it reaches 10.
</li>
</ul>
@@ -1184,6 +1249,7 @@ export function registerVariableCommands() {
true,
),
],
splitUnnamedArgument: true,
helpString: `
<div>
Execute any valid slash command enclosed in quotes <code>repeats</code> 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: `

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 } 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 = $('<span>').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($('<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 (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<HTMLElement>} input Input element to attach the autocomplete to
* @param {(input: any, output: any) => any} callback Source data callbacks
* @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to
* @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks
* @param {object} [options={}] - Optional arguments
* @param {boolean} [options.allowMultiple=false] - Whether to allow multiple comma-separated values
*/
function createEntryInputAutocomplete(input, callback) {
function createEntryInputAutocomplete(input, callback, { allowMultiple = false } = {}) {
const handleSelect = (event, ui) => {
// Prevent default autocomplete select, so we can manually set the value
event.preventDefault();
if (!allowMultiple) {
$(input).val(ui.item.value).trigger('input').trigger('blur');
} else {
var terms = String($(input).val()).split(/,\s*/);
terms.pop(); // remove the current input
terms.push(ui.item.value); // add the selected item
$(input).val(terms.filter(x => x).join(', ')).trigger('input').trigger('blur');
}
};
$(input).autocomplete({
minLength: 0,
source: callback,
select: function (event, ui) {
$(input).val(ui.item.value).trigger('input').trigger('blur');
source: function (request, response) {
if (!allowMultiple) {
callback(input, request, response);
} else {
const term = request.term.split(/,\s*/).pop();
request.term = term;
callback(input, request, response);
}
},
select: handleSelect,
});
$(input).on('focus click', function () {
$(input).autocomplete('search', String($(input).val()));
$(input).autocomplete('search', allowMultiple ? String($(input).val()).split(/,\s*/).pop() : $(input).val());
});
}
/**
* Duplicated a WI entry by copying all of its properties and assigning a new uid
* @param {*} data - The data of the book
@@ -2152,6 +2452,8 @@ const newEntryTemplate = {
position: 0,
disable: false,
excludeRecursion: false,
preventRecursion: false,
delayUntilRecursion: false,
probability: 100,
useProbability: true,
depth: DEFAULT_DEPTH,
@@ -2166,7 +2468,7 @@ const newEntryTemplate = {
role: 0,
};
function createWorldInfoEntry(name, data) {
function createWorldInfoEntry(_name, data) {
const newUid = getFreeWorldEntryUid(data);
if (!Number.isInteger(newUid)) {
@@ -2519,7 +2821,7 @@ async function checkWorldInfo(chat, maxContext) {
continue;
}
if (allActivatedEntries.has(entry) || entry.disable == true || (count > 1 && world_info_recursive && entry.excludeRecursion)) {
if (allActivatedEntries.has(entry) || entry.disable == true || (count > 1 && world_info_recursive && entry.excludeRecursion) || (count == 1 && entry.delayUntilRecursion)) {
continue;
}
@@ -2792,10 +3094,12 @@ function filterGroupsByScoring(groups, buffer, removeEntry) {
function filterByInclusionGroups(newEntries, allActivatedEntries, buffer) {
console.debug('-- INCLUSION GROUP CHECKS BEGIN --');
const grouped = newEntries.filter(x => x.group).reduce((acc, item) => {
if (!acc[item.group]) {
acc[item.group] = [];
}
acc[item.group].push(item);
item.group.split(/,\s*/).filter(x => x).forEach(group => {
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(item);
});
return acc;
}, {});
@@ -2887,9 +3191,10 @@ function convertAgnaiMemoryBook(inputObj) {
disable: !entry.enabled,
addMemo: !!entry.name,
excludeRecursion: false,
delayUntilRecursion: false,
displayIndex: index,
probability: null,
useProbability: false,
probability: 100,
useProbability: true,
group: '',
groupOverride: false,
groupWeight: DEFAULT_WEIGHT,
@@ -2925,9 +3230,10 @@ function convertRisuLorebook(inputObj) {
disable: false,
addMemo: true,
excludeRecursion: false,
delayUntilRecursion: false,
displayIndex: index,
probability: entry.activationPercent ?? null,
useProbability: entry.activationPercent ?? false,
probability: entry.activationPercent ?? 100,
useProbability: entry.activationPercent ?? true,
group: '',
groupOverride: false,
groupWeight: DEFAULT_WEIGHT,
@@ -2968,9 +3274,10 @@ function convertNovelLorebook(inputObj) {
disable: !entry.enabled,
addMemo: addMemo,
excludeRecursion: false,
delayUntilRecursion: false,
displayIndex: index,
probability: null,
useProbability: false,
probability: 100,
useProbability: true,
group: '',
groupOverride: false,
groupWeight: DEFAULT_WEIGHT,
@@ -3008,11 +3315,12 @@ function convertCharacterBook(characterBook) {
position: entry.extensions?.position ?? (entry.position === 'before_char' ? world_info_position.before : world_info_position.after),
excludeRecursion: entry.extensions?.exclude_recursion ?? false,
preventRecursion: entry.extensions?.prevent_recursion ?? false,
delayUntilRecursion: entry.extensions?.delay_until_recursion ?? false,
disable: !entry.enabled,
addMemo: entry.comment ? true : false,
displayIndex: entry.extensions?.display_index ?? index,
probability: entry.extensions?.probability ?? null,
useProbability: entry.extensions?.useProbability ?? false,
probability: entry.extensions?.probability ?? 100,
useProbability: entry.extensions?.useProbability ?? true,
depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
group: entry.extensions?.group ?? '',
@@ -3261,7 +3569,7 @@ export async function importWorldInfo(file) {
toastr.info(`World Info "${data.name}" imported successfully!`);
}
},
error: (jqXHR, exception) => { },
error: (_jqXHR, _exception) => { },
});
}
@@ -3468,21 +3776,14 @@ jQuery(() => {
});
// Subscribe world loading to the select2 multiselect items (We need to target the specific select2 control)
$('#world_info + span.select2-container').on('click', function (event) {
if ($(event.target).hasClass('select2-selection__choice__display')) {
event.preventDefault();
// select2 still bubbles the event to open the dropdown. So we close it here
$('#world_info').select2('close');
const name = $(event.target).text();
const selectedIndex = world_names.indexOf(name);
if (selectedIndex !== -1) {
$('#world_editor_select').val(selectedIndex).trigger('change');
console.log('Quick selection of world', name);
}
select2ChoiceClickSubscribe($('#world_info'), target => {
const name = $(target).text();
const selectedIndex = world_names.indexOf(name);
if (selectedIndex !== -1) {
$('#world_editor_select').val(selectedIndex).trigger('change');
console.log('Quick selection of world', name);
}
});
}, { buttonStyle: true, closeDrawer: true });
}
$('#WorldInfo').on('scroll', () => {