Merge branch 'staging' into parser-followup-2

This commit is contained in:
LenAnderson
2024-06-24 07:29:37 -04:00
6 changed files with 724 additions and 67 deletions

View File

@ -5397,26 +5397,6 @@
</div> </div>
</div> </div>
<div class="flex-container wide100p flexGap10"> <div class="flex-container wide100p flexGap10">
<div class="flex4 flex-container flexFlowColumn flexNoGap">
<div class="flex-container justifySpaceBetween">
<small for="characterFilter" data-i18n="Filter to Character(s)">
Filter to Character(s)
</small>
<label class="checkbox_label flexNoGap margin-r5" for="character_exclusion">
<input type="checkbox" name="character_exclusion" />
<span>
<small data-i18n="Character Exclusion">Character Exclusion</small>
</span>
</label>
</div>
<div class="range-block-range">
<select name="characterFilter" class="select2_multi_sameline" multiple>
<option value="">
<span data-i18n="-- Characters not found --">-- Characters not found --</span>
</option>
</select>
</div>
</div>
<div class="flex3 flex-container flexFlowColumn flexNoGap"> <div class="flex3 flex-container flexFlowColumn flexNoGap">
<div class="flex-container justifySpaceBetween"> <div class="flex-container justifySpaceBetween">
<small for="group"> <small for="group">
@ -5449,6 +5429,54 @@
<input type="number" class="text_pole margin0" name="groupWeight" rows="1" placeholder="100" min="1" max="999999"> <input type="number" class="text_pole margin0" name="groupWeight" rows="1" placeholder="100" min="1" max="999999">
</div> </div>
</div> </div>
<div class="flex2 flex-container flexFlowColumn flexNoGap" data-i18n="[title]Sticky entries will stay active for N messages after being triggered." title="Sticky entries will stay active for N messages after being triggered.">
<div class="flex-container justifySpaceBetween marginBot5">
<small class="flex-container alignItemsBaseline" for="sticky" data-i18n="Sticky">
<span data-i18n="Sticky">
Sticky
</span>
<i class="fa-solid fa-comments fa-xs"></i>
</small>
</div>
<div class="range-block-range">
<input class="text_pole margin0" name="sticky" type="number" placeholder="Non-sticky" min="0" max="999999">
</div>
</div>
<div class="flex2 flex-container flexFlowColumn flexNoGap" data-i18n="[title]Entries with a cooldown can't be activated N messages after being triggered." title="Entries with a cooldown can't be activated N messages after being triggered.">
<div class="flex-container justifySpaceBetween marginBot5">
<small class="flex-container alignItemsBaseline" for="cooldown" data-i18n="Cooldown">
<span data-i18n="Cooldown">
Cooldown
</span>
<i class="fa-solid fa-comments fa-xs"></i>
</small>
</div>
<div class="range-block-range">
<input class="text_pole margin0" name="cooldown" type="number" placeholder="No cooldown" min="0" max="999999">
</div>
</div>
</div>
<div class="flex-container wide100p flexGap10">
<div class="flex4 flex-container flexFlowColumn flexNoGap">
<div class="flex-container justifySpaceBetween">
<small for="characterFilter" data-i18n="Filter to Character(s)">
Filter to Character(s)
</small>
<label class="checkbox_label flexNoGap margin-r5" for="character_exclusion">
<input type="checkbox" name="character_exclusion" />
<span>
<small data-i18n="Character Exclusion">Character Exclusion</small>
</span>
</label>
</div>
<div class="range-block-range">
<select name="characterFilter" class="select2_multi_sameline" multiple>
<option value="">
<span data-i18n="-- Characters not found --">-- Characters not found --</span>
</option>
</select>
</div>
</div>
</div> </div>
<div name="WIEntryBottomControls" class="flex-container flex1 justifySpaceBetween world_entry_form_horizontal"> <div name="WIEntryBottomControls" class="flex-container flex1 justifySpaceBetween world_entry_form_horizontal">
<div class="flex-container flexFlowColumn flexNoGap wi-enter-footer-text "> <div class="flex-container flexFlowColumn flexNoGap wi-enter-footer-text ">

View File

@ -1941,8 +1941,8 @@ async function generateCallback(args, value) {
} }
async function echoCallback(args, value) { async function echoCallback(args, value) {
const safeValue = DOMPurify.sanitize(String(value) || ''); // Note: We don't need to sanitize input, as toastr is set up by default to escape HTML via toastr options
if (safeValue === '') { if (value === '') {
console.warn('WARN: No argument provided for /echo command'); console.warn('WARN: No argument provided for /echo command');
return; return;
} }
@ -1950,17 +1950,17 @@ async function echoCallback(args, value) {
const severity = args?.severity !== undefined && typeof args?.severity === 'string' ? args.severity : 'info'; const severity = args?.severity !== undefined && typeof args?.severity === 'string' ? args.severity : 'info';
switch (severity) { switch (severity) {
case 'error': case 'error':
toastr.error(safeValue, title); toastr.error(value, title);
break; break;
case 'warning': case 'warning':
toastr.warning(safeValue, title); toastr.warning(value, title);
break; break;
case 'success': case 'success':
toastr.success(safeValue, title); toastr.success(value, title);
break; break;
case 'info': case 'info':
default: default:
toastr.info(safeValue, title); toastr.info(value, title);
break; break;
} }
return value; return value;

View File

@ -861,7 +861,7 @@ async function generateTextGenWithStreaming(generate_data, signal) {
return async function* streamData() { return async function* streamData() {
let text = ''; let text = '';
/** @type {import('logprobs.js').TokenLogprobs | null} */ /** @type {import('./logprobs.js').TokenLogprobs | null} */
let logprobs = null; let logprobs = null;
const swipes = []; const swipes = [];
while (true) { while (true) {
@ -893,7 +893,7 @@ async function generateTextGenWithStreaming(generate_data, signal) {
* Probabilities feature. * Probabilities feature.
* @param {string} token - the text of the token that the logprobs are for * @param {string} token - the text of the token that the logprobs are for
* @param {Object} logprobs - logprobs object returned from the API * @param {Object} logprobs - logprobs object returned from the API
* @returns {import('logprobs.js').TokenLogprobs | null} - converted logprobs * @returns {import('./logprobs.js').TokenLogprobs | null} - converted logprobs
*/ */
export function parseTextgenLogprobs(token, logprobs) { export function parseTextgenLogprobs(token, logprobs) {
if (!logprobs) { if (!logprobs) {
@ -1112,6 +1112,8 @@ export function getTextGenGenerationData(finalPrompt, maxTokens, isImpersonate,
'tfs_z': settings.tfs, 'tfs_z': settings.tfs,
'repeat_last_n': settings.rep_pen_range, 'repeat_last_n': settings.rep_pen_range,
'n_predict': maxTokens, 'n_predict': maxTokens,
'num_predict': maxTokens,
'num_ctx': max_context,
'mirostat': settings.mirostat_mode, 'mirostat': settings.mirostat_mode,
'ignore_eos': settings.ban_eos_token, 'ignore_eos': settings.ban_eos_token,
'n_probs': power_user.request_token_probabilities ? 10 : undefined, 'n_probs': power_user.request_token_probabilities ? 10 : undefined,

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 { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js';
import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, equalsIgnoreCaseAndAccents, getSanitizedFilename, checkOverwriteExistingData, parseStringArray } 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, getSanitizedFilename, checkOverwriteExistingData, getStringHash, parseStringArray } from './utils.js';
import { extension_settings, getContext } from './extensions.js'; import { extension_settings, getContext } from './extensions.js';
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js'; import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js';
import { isMobile } from './RossAscends-mods.js'; import { isMobile } from './RossAscends-mods.js';
@ -84,24 +84,39 @@ const DEFAULT_DEPTH = 4;
const DEFAULT_WEIGHT = 100; const DEFAULT_WEIGHT = 100;
const MAX_SCAN_DEPTH = 1000; const MAX_SCAN_DEPTH = 1000;
// Typedef area
/** /**
* Represents a scanning buffer for one evaluation of World Info.
*/
class WorldInfoBuffer {
// Typedef area
/**
* @typedef {object} WIScanEntry The entry that triggered the scan * @typedef {object} WIScanEntry The entry that triggered the scan
* @property {number} [scanDepth] The depth of the scan * @property {number} [scanDepth] The depth of the scan
* @property {boolean} [caseSensitive] If the scan is case sensitive * @property {boolean} [caseSensitive] If the scan is case sensitive
* @property {boolean} [matchWholeWords] If the scan should match whole words * @property {boolean} [matchWholeWords] If the scan should match whole words
* @property {boolean} [useGroupScoring] If the scan should use group scoring * @property {boolean} [useGroupScoring] If the scan should use group scoring
* @property {number} [uid] The UID of the entry that triggered the scan * @property {number} [uid] The UID of the entry that triggered the scan
* @property {string} [world] The world info book of origin of the entry
* @property {string[]} [key] The primary keys to scan for * @property {string[]} [key] The primary keys to scan for
* @property {string[]} [keysecondary] The secondary keys to scan for * @property {string[]} [keysecondary] The secondary keys to scan for
* @property {number} [selectiveLogic] The logic to use for selective activation * @property {number} [selectiveLogic] The logic to use for selective activation
* @property {number} [sticky] The sticky value of the entry
* @property {number} [cooldown] The cooldown of the entry
*/ */
// End typedef area
/**
* @typedef {object} WITimedEffect Timed effect for world info
* @property {number} hash Hash of the entry that triggered the effect
* @property {number} start The chat index where the effect starts
* @property {number} end The chat index where the effect ends
*/
/**
* @typedef TimedEffectType Type of timed effect
* @type {'sticky'|'cooldown'}
*/
// End typedef area
/**
* Represents a scanning buffer for one evaluation of World Info.
*/
class WorldInfoBuffer {
/** /**
* @type {object[]} Array of entries that need to be activated no matter what * @type {object[]} Array of entries that need to be activated no matter what
*/ */
@ -266,9 +281,9 @@ class WorldInfoBuffer {
} }
/** /**
* Clears the force activations buffer. * Clean-up the external effects for entries.
*/ */
cleanExternalActivations() { resetExternalEffects() {
WorldInfoBuffer.externalActivations.splice(0, WorldInfoBuffer.externalActivations.length); WorldInfoBuffer.externalActivations.splice(0, WorldInfoBuffer.externalActivations.length);
} }
@ -325,6 +340,316 @@ class WorldInfoBuffer {
} }
} }
/**
* Represents a timed effects manager for World Info.
*/
class WorldInfoTimedEffects {
/**
* Cache for entry hashes. Uses weak map to avoid memory leaks.
* @type {WeakMap<WIScanEntry, number>}
*/
#entryHashCache = new WeakMap();
/**
* Array of chat messages.
* @type {string[]}
*/
#chat = [];
/**
* Array of entries.
* @type {WIScanEntry[]}
*/
#entries = [];
/**
* Set of entries to ignore chat advancement requirement for.
*/
#entryIgnoreAdvancement = [];
/**
* Buffer for active timed effects.
* @type {Record<TimedEffectType, WIScanEntry[]>}
*/
#buffer = {
'sticky': [],
'cooldown': [],
};
/**
* Callbacks for effect types ending.
* @type {Record<TimedEffectType, (entry: WIScanEntry) => void>}
*/
#onEnded = {
/**
* Callback for when a sticky entry ends.
* Sets an entry on cooldown immediately if it has a cooldown.
* @param {WIScanEntry} entry Entry that ended sticky
*/
'sticky': (entry) => {
if (!entry.cooldown) {
return;
}
const key = this.#getEntryKey(entry);
const effect = this.#getEntryTimedEffect(entry, 'cooldown');
chat_metadata.timedWorldInfo.cooldown[key] = effect;
console.log(`Adding cooldown entry ${key} on ended sticky: start=${effect.start}, end=${effect.end}`);
// Set the cooldown immediately for this evaluation
this.#buffer['cooldown'].push(entry);
this.#entryIgnoreAdvancement.push(entry);
},
/**
* Callback for when a cooldown entry ends.
* No-op, essentially.
* @param {WIScanEntry} entry Entry that ended cooldown
*/
'cooldown': (entry) => {
console.debug('Cooldown ended for entry', entry.uid);
},
};
/**
* Initialize the timed effects with the given messages.
* @param {string[]} chat Array of chat messages
* @param {WIScanEntry[]} entries Array of entries
*/
constructor(chat, entries) {
this.#chat = chat;
this.#entries = entries;
this.#ensureChatMetadata();
}
/**
* Verify correct structure of chat metadata.
*/
#ensureChatMetadata() {
if (!chat_metadata.timedWorldInfo) {
chat_metadata.timedWorldInfo = {};
}
['sticky', 'cooldown'].forEach(type => {
// Ensure the property exists and is an object
if (!chat_metadata.timedWorldInfo[type] || typeof chat_metadata.timedWorldInfo[type] !== 'object') {
chat_metadata.timedWorldInfo[type] = {};
}
// Clean up invalid entries
Object.entries(chat_metadata.timedWorldInfo[type]).forEach(([key, value]) => {
if (!value || typeof value !== 'object') {
delete chat_metadata.timedWorldInfo[type][key];
}
});
});
}
/**
* Gets a hash for a WI entry.
* @param {WIScanEntry} entry WI entry
* @returns {number} String hash
*/
#getEntryHash(entry) {
if (this.#entryHashCache.has(entry)) {
return this.#entryHashCache.get(entry);
}
const hash = getStringHash(JSON.stringify(entry));
this.#entryHashCache.set(entry, hash);
return hash;
}
/**
* Gets a unique-ish key for a WI entry.
* @param {WIScanEntry} entry WI entry
* @returns {string} String key for the entry
*/
#getEntryKey(entry) {
return `${entry.world}.${entry.uid}`;
}
/**
* Gets a timed effect for a WI entry.
* @param {WIScanEntry} entry WI entry
* @param {TimedEffectType} type Type of timed effect
* @returns {WITimedEffect} Timed effect for the entry
*/
#getEntryTimedEffect(entry, type) {
return {
hash: this.#getEntryHash(entry),
start: this.#chat.length,
end: this.#chat.length + Number(entry[type]),
};
}
/**
* Gets if the entry should be ignored for chat advancement requirement.
* @param {WIScanEntry} entry WI entry
* @returns {boolean} True if the entry should be ignored
*/
#isChatAdvancementIgnored(entry) {
return entry && this.#entryIgnoreAdvancement.some(x => this.#getEntryHash(x) === this.#getEntryHash(entry));
}
/**
* Processes entries for a given type of timed effect.
* @param {TimedEffectType} type Identifier for the type of timed effect
* @param {WIScanEntry[]} buffer Buffer to store the entries
* @param {(entry: WIScanEntry) => void} onEnded Callback for when a timed effect ends
*/
#checkTimedEffectOfType(type, buffer, onEnded) {
/** @type {[string, WITimedEffect][]} */
const effects = Object.entries(chat_metadata.timedWorldInfo[type]);
for (const [key, value] of effects) {
console.log(`Processing ${type} entry ${key}`, value);
const entry = this.#entries.find(x => String(this.#getEntryHash(x)) === String(value.hash));
if (this.#chat.length <= Number(value.start) && !this.#isChatAdvancementIgnored(entry)) {
console.log(`Removing ${type} entry ${key} from timedWorldInfo: chat not advanced`, value);
delete chat_metadata.timedWorldInfo[type][key];
continue;
}
// Missing entries (they could be from another character's lorebook)
if (!entry) {
if (this.#chat.length > Number(value.end)) {
console.log(`Removing ${type} entry from timedWorldInfo: entry not found and interval passed`, entry);
delete chat_metadata.timedWorldInfo[type][key];
}
continue;
}
// Ignore invalid entries (not configured for timed effects)
if (!entry[type]) {
console.log(`Removing ${type} entry from timedWorldInfo: entry not ${type}`, entry);
delete chat_metadata.timedWorldInfo[type][key];
continue;
}
if (this.#chat.length > Number(value.end)) {
console.log(`Removing ${type} entry from timedWorldInfo: ${type} interval passed`, entry);
delete chat_metadata.timedWorldInfo[type][key];
if (typeof onEnded === 'function') {
onEnded(entry);
}
continue;
}
buffer.push(entry);
console.log(`Timed effect "${type}" applied to entry`, entry);
}
}
/**
* Checks for timed effects on chat messages.
*/
checkTimedEffects() {
this.#checkTimedEffectOfType('sticky', this.#buffer.sticky, this.#onEnded.sticky.bind(this));
this.#checkTimedEffectOfType('cooldown', this.#buffer.cooldown, this.#onEnded.cooldown.bind(this));
}
/**
* Gets raw timed effect metadatum for a WI entry.
* @param {TimedEffectType} type Type of timed effect
* @param {WIScanEntry} entry WI entry
* @returns {WITimedEffect} Timed effect for the entry
*/
getEffectMetadata(type, entry) {
if (!this.isValidEffectType(type)) {
return null;
}
const key = this.#getEntryKey(entry);
return chat_metadata.timedWorldInfo[type][key];
}
/**
* Sets a timed effect for a WI entry.
* @param {TimedEffectType} type Type of timed effect
* @param {WIScanEntry} entry WI entry to check
*/
#setTimedEffectOfType(type, entry) {
// Skip if entry does not have the type (sticky or cooldown)
if (!entry[type]) {
return;
}
const key = this.#getEntryKey(entry);
if (!chat_metadata.timedWorldInfo[type][key]) {
const effect = this.#getEntryTimedEffect(entry, type);
chat_metadata.timedWorldInfo[type][key] = effect;
console.log(`Adding ${type} entry ${key}: start=${effect.start}, end=${effect.end}`);
}
}
/**
* Sets timed effects on chat messages.
* @param {WIScanEntry[]} activatedEntries Entries that were activated
*/
setTimedEffects(activatedEntries) {
for (const entry of activatedEntries) {
this.#setTimedEffectOfType('sticky', entry);
this.#setTimedEffectOfType('cooldown', entry);
}
}
/**
* Force set a timed effect for a WI entry.
* @param {TimedEffectType} type Type of timed effect
* @param {WIScanEntry} entry WI entry
* @param {boolean} newState The state of the effect
*/
setTimedEffect(type, entry, newState) {
if (!this.isValidEffectType(type)) {
return;
}
const key = this.#getEntryKey(entry);
delete chat_metadata.timedWorldInfo[type][key];
if (newState) {
const effect = this.#getEntryTimedEffect(entry, type);
chat_metadata.timedWorldInfo[type][key] = effect;
console.log(`Adding ${type} entry ${key}: start=${effect.start}, end=${effect.end}`);
}
}
/**
* Check if the string is a valid timed effect type.
* @param {string} type Name of the timed effect
* @returns {boolean} Is recognized type
*/
isValidEffectType(type) {
return typeof type === 'string' && ['sticky', 'cooldown'].includes(type.trim().toLowerCase());
}
/**
* Check if the current entry is sticky activated.
* @param {TimedEffectType} type Type of timed effect
* @param {WIScanEntry} entry WI entry to check
* @returns {boolean} True if the entry is active
*/
isEffectActive(type, entry) {
if (!this.isValidEffectType(type)) {
return false;
}
return this.#buffer[type]?.some(x => this.#getEntryHash(x) === this.#getEntryHash(entry)) ?? false;
}
/**
* Clean-up previously set timed effects.
*/
cleanUp() {
for (const buffer of Object.values(this.#buffer)) {
buffer.splice(0, buffer.length);
}
this.#entryIgnoreAdvancement.splice(0, this.#entryIgnoreAdvancement.length);
}
}
export function getWorldInfoSettings() { export function getWorldInfoSettings() {
return { return {
world_info, world_info,
@ -357,7 +682,7 @@ export const wi_anchor_position = {
after: 1, after: 1,
}; };
const worldInfoCache = {}; const worldInfoCache = new Map();
/** /**
* Gets the world info based on chat messages. * Gets the world info based on chat messages.
@ -370,7 +695,7 @@ const worldInfoCache = {};
async function getWorldInfoPrompt(chat, maxContext, isDryRun) { async function getWorldInfoPrompt(chat, maxContext, isDryRun) {
let worldInfoString = '', worldInfoBefore = '', worldInfoAfter = ''; let worldInfoString = '', worldInfoBefore = '', worldInfoAfter = '';
const activatedWorldInfo = await checkWorldInfo(chat, maxContext); const activatedWorldInfo = await checkWorldInfo(chat, maxContext, isDryRun);
worldInfoBefore = activatedWorldInfo.worldInfoBefore; worldInfoBefore = activatedWorldInfo.worldInfoBefore;
worldInfoAfter = activatedWorldInfo.worldInfoAfter; worldInfoAfter = activatedWorldInfo.worldInfoAfter;
worldInfoString = worldInfoBefore + worldInfoAfter; worldInfoString = worldInfoBefore + worldInfoAfter;
@ -477,9 +802,11 @@ function setWorldInfoSettings(settings, data) {
$('#world_info').trigger('change'); $('#world_info').trigger('change');
$('#world_editor_select').trigger('change'); $('#world_editor_select').trigger('change');
eventSource.on(event_types.CHAT_CHANGED, () => { eventSource.on(event_types.CHAT_CHANGED, async () => {
const hasWorldInfo = !!chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY]); const hasWorldInfo = !!chat_metadata[METADATA_KEY] && world_names.includes(chat_metadata[METADATA_KEY]);
$('.chat_lorebook_button').toggleClass('world_set', hasWorldInfo); $('.chat_lorebook_button').toggleClass('world_set', hasWorldInfo);
// Pre-cache the world info data for the chat for quicker first prompt generation
await getSortedEntries();
}); });
eventSource.on(event_types.WORLDINFO_FORCE_ACTIVATE, (entries) => { eventSource.on(event_types.WORLDINFO_FORCE_ACTIVATE, (entries) => {
@ -498,6 +825,16 @@ function registerWorldInfoSlashCommands() {
} }
} }
/**
* Gets a *rough* approximation of the current chat context.
* Normally, it is provided externally by the prompt builder.
* Don't use for anything critical!
* @returns {string[]}
*/
function getScanningChat() {
return getContext().chat.filter(x => !x.is_system).map(x => x.mes);
}
async function getEntriesFromFile(file) { async function getEntriesFromFile(file) {
if (!file || !world_names.includes(file)) { if (!file || !world_names.includes(file)) {
toastr.warning('Valid World Info file name is required'); toastr.warning('Valid World Info file name is required');
@ -701,6 +1038,116 @@ function registerWorldInfoSlashCommands() {
return ''; return '';
} }
async function getTimedEffectCallback(args, value) {
if (!getCurrentChatId()) {
throw new Error('This command can only be used in chat');
}
const file = args.file;
const uid = value;
const effect = args.effect;
const entries = await getEntriesFromFile(file);
if (!entries) {
return '';
}
/** @type {WIScanEntry} */
const entry = structuredClone(entries.find(x => String(x.uid) === String(uid)));
if (!entry) {
toastr.warning('Valid UID is required');
return '';
}
entry.world = file; // Required by the timed effects manager
const chat = getScanningChat();
const timedEffects = new WorldInfoTimedEffects(chat, [entry]);
if (!timedEffects.isValidEffectType(effect)) {
toastr.warning('Valid effect type is required');
return '';
}
const data = timedEffects.getEffectMetadata(effect, entry);
if (String(args.format).trim().toLowerCase() === ARGUMENT_TYPE.NUMBER) {
return String(data ? (data.end - chat.length) : 0);
}
return String(!!data);
}
async function setTimedEffectCallback(args, value) {
if (!getCurrentChatId()) {
throw new Error('This command can only be used in chat');
}
const file = args.file;
const uid = args.uid;
const effect = args.effect;
if (value === undefined) {
toastr.warning('New state is required');
return '';
}
const entries = await getEntriesFromFile(file);
if (!entries) {
return '';
}
/** @type {WIScanEntry} */
const entry = structuredClone(entries.find(x => String(x.uid) === String(uid)));
if (!entry) {
toastr.warning('Valid UID is required');
return '';
}
entry.world = file; // Required by the timed effects manager
const chat = getScanningChat();
const timedEffects = new WorldInfoTimedEffects(chat, [entry]);
if (!timedEffects.isValidEffectType(effect)) {
toastr.warning('Valid effect type is required');
return '';
}
if (!entry[effect]) {
toastr.warning('This entry does not have the selected effect. Configure it in the editor first.');
return '';
}
const getNewEffectState = () => {
const currentState = !!timedEffects.getEffectMetadata(effect, entry);
if (['toggle', 't', ''].includes(value.trim().toLowerCase())) {
return !currentState;
}
if (isTrueBoolean(value)) {
return true;
}
if (isFalseBoolean(value)) {
return false;
}
return currentState;
};
const newEffectState = getNewEffectState();
timedEffects.setTimedEffect(effect, entry, newEffectState);
await saveMetadata();
toastr.success(`Timed effect "${effect}" for entry ${entry.uid} is now ${newEffectState ? 'active' : 'inactive'}`);
return '';
}
/** A collection of local enum providers for this context of world info */ /** A collection of local enum providers for this context of world info */
const localEnumProviders = { const localEnumProviders = {
/** All possible fields that can be set in a WI entry */ /** All possible fields that can be set in a WI entry */
@ -713,12 +1160,18 @@ function registerWorldInfoSlashCommands() {
const file = executor.namedArgumentList.find(it => it.name == 'file')?.value; const file = executor.namedArgumentList.find(it => it.name == 'file')?.value;
if (file instanceof SlashCommandClosure) throw new Error('Argument \'file\' does not support closures'); if (file instanceof SlashCommandClosure) throw new Error('Argument \'file\' does not support closures');
// Try find world from cache // Try find world from cache
const world = worldInfoCache[file]; if (!worldInfoCache.has(file)) return [];
const world = worldInfoCache.get(file);
if (!world) return []; if (!world) return [];
return Object.entries(world.entries).map(([uid, data]) => return Object.entries(world.entries).map(([uid, data]) =>
new SlashCommandEnumValue(uid, `${data.comment ? `${data.comment}: ` : ''}${data.key.join(', ')}${data.keysecondary?.length ? ` [${Object.entries(world_info_logic).find(([_, value]) => value == data.selectiveLogic)[0]}] ${data.keysecondary.join(', ')}` : ''} [${getWiPositionString(data)}]`, new SlashCommandEnumValue(uid, `${data.comment ? `${data.comment}: ` : ''}${data.key.join(', ')}${data.keysecondary?.length ? ` [${Object.entries(world_info_logic).find(([_, value]) => value == data.selectiveLogic)[0]}] ${data.keysecondary.join(', ')}` : ''} [${getWiPositionString(data)}]`,
enumTypes.enum, enumIcons.getWiStatusIcon(data))); enumTypes.enum, enumIcons.getWiStatusIcon(data)));
}, },
timedEffects: () => [
new SlashCommandEnumValue('sticky', 'Stays active for N messages', enumTypes.enum, '📌'),
new SlashCommandEnumValue('cooldown', 'Cooldown for N messages', enumTypes.enum, '⌛'),
],
}; };
function getWiPositionString(entry) { function getWiPositionString(entry) {
@ -734,7 +1187,8 @@ function registerWorldInfoSlashCommands() {
} }
} }
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'world', SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'world',
callback: onWorldInfoChange, callback: onWorldInfoChange,
namedArgumentList: [ namedArgumentList: [
new SlashCommandNamedArgument( new SlashCommandNamedArgument(
@ -758,14 +1212,16 @@ function registerWorldInfoSlashCommands() {
`, `,
aliases: [], aliases: [],
})); }));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getchatbook', SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'getchatbook',
callback: getChatBookCallback, callback: getChatBookCallback,
returns: 'lorebook name', returns: 'lorebook name',
helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.', helpString: 'Get a name of the chat-bound lorebook or create a new one if was unbound, and pass it down the pipe.',
aliases: ['getchatlore', 'getchatwi'], aliases: ['getchatlore', 'getchatwi'],
})); }));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'findentry', SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'findentry',
aliases: ['findlore', 'findwi'], aliases: ['findlore', 'findwi'],
returns: 'UID', returns: 'UID',
callback: findBookEntryCallback, callback: findBookEntryCallback,
@ -804,7 +1260,8 @@ function registerWorldInfoSlashCommands() {
</div> </div>
`, `,
})); }));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'getentryfield', SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'getentryfield',
aliases: ['getlorefield', 'getwifield'], aliases: ['getlorefield', 'getwifield'],
callback: getEntryFieldCallback, callback: getEntryFieldCallback,
returns: 'field value', returns: 'field value',
@ -846,7 +1303,8 @@ function registerWorldInfoSlashCommands() {
</div> </div>
`, `,
})); }));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'createentry', SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'createentry',
callback: createEntryCallback, callback: createEntryCallback,
aliases: ['createlore', 'createwi'], aliases: ['createlore', 'createwi'],
returns: 'UID of the new record', returns: 'UID of the new record',
@ -881,7 +1339,8 @@ function registerWorldInfoSlashCommands() {
</div> </div>
`, `,
})); }));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'setentryfield', SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'setentryfield',
callback: setEntryFieldCallback, callback: setEntryFieldCallback,
aliases: ['setlorefield', 'setwifield'], aliases: ['setlorefield', 'setwifield'],
namedArgumentList: [ namedArgumentList: [
@ -926,7 +1385,110 @@ function registerWorldInfoSlashCommands() {
</div> </div>
`, `,
})); }));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'wi-set-timed-effect',
callback: setTimedEffectCallback,
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'file',
description: 'book name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: commonEnumProviders.worlds,
}),
SlashCommandNamedArgument.fromProps({
name: 'uid',
description: 'record UID',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.wiUids,
}),
SlashCommandNamedArgument.fromProps({
name: 'effect',
description: 'effect name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.timedEffects,
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'new state of the effect',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
acceptsMultiple: false,
enumList: commonEnumProviders.boolean('onOffToggle')(),
}),
],
helpString: `
<div>
Set a timed effect for the record with the UID from the specified book. The duration must be set in the entry itself.
Will only be applied for the current chat. Enabling an effect that was already active refreshes the duration.
If the last chat message is swiped or deleted, the effect will be removed.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/wi-set-timed-effect file=chatLore uid=123 effect=sticky on</code></pre>
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'wi-get-timed-effect',
callback: getTimedEffectCallback,
helpString: `
<div>
Get the current state of the timed effect for the record with the UID from the specified book.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<code>/wi-get-timed-effect file=chatLore format=bool effect=sticky 123</code> - returns true or false if the effect is active or not
</li>
<li>
<code>/wi-get-timed-effect file=chatLore format=number effect=sticky 123</code> - returns the remaining duration of the effect, or 0 if inactive
</li>
</ul>
</div>
`,
returns: 'state of the effect',
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'file',
description: 'book name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: commonEnumProviders.worlds,
}),
SlashCommandNamedArgument.fromProps({
name: 'effect',
description: 'effect name',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.timedEffects,
}),
SlashCommandNamedArgument.fromProps({
name: 'format',
description: 'output format',
isRequired: false,
typeList: [ARGUMENT_TYPE.STRING],
defaultValue: ARGUMENT_TYPE.BOOLEAN,
enumList: [ARGUMENT_TYPE.BOOLEAN, ARGUMENT_TYPE.NUMBER],
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'record UID',
typeList: [ARGUMENT_TYPE.STRING],
isRequired: true,
enumProvider: localEnumProviders.wiUids,
}),
],
}));
} }
// World Info Editor // World Info Editor
@ -945,8 +1507,8 @@ async function loadWorldInfoData(name) {
return; return;
} }
if (worldInfoCache[name]) { if (worldInfoCache.has(name)) {
return worldInfoCache[name]; return worldInfoCache.get(name);
} }
const response = await fetch('/api/worldinfo/get', { const response = await fetch('/api/worldinfo/get', {
@ -958,7 +1520,7 @@ async function loadWorldInfoData(name) {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
worldInfoCache[name] = data; worldInfoCache.set(name, data);
return data; return data;
} }
@ -1390,6 +1952,8 @@ const originalDataKeyMap = {
'vectorized': 'extensions.vectorized', 'vectorized': 'extensions.vectorized',
'groupOverride': 'extensions.group_override', 'groupOverride': 'extensions.group_override',
'groupWeight': 'extensions.group_weight', 'groupWeight': 'extensions.group_weight',
'sticky': 'extensions.sticky',
'cooldown': 'extensions.cooldown',
}; };
/** Checks the state of the current search, and adds/removes the search sorting option accordingly */ /** Checks the state of the current search, and adds/removes the search sorting option accordingly */
@ -1518,7 +2082,8 @@ function customTokenizer(input, _selection, callback) {
// Now remove the token from the current input, and the comma too // Now remove the token from the current input, and the comma too
current = current.slice(i + 1); current = current.slice(i + 1);
insideRegex = false, regexClosed = false; insideRegex = false;
regexClosed = false;
i = 0; i = 0;
} }
} }
@ -2011,6 +2576,32 @@ function getWorldEntry(name, data, entry) {
}); });
groupWeightInput.val(entry.groupWeight ?? DEFAULT_WEIGHT).trigger('input'); groupWeightInput.val(entry.groupWeight ?? DEFAULT_WEIGHT).trigger('input');
// sticky
const sticky = template.find('input[name="sticky"]');
sticky.data('uid', entry.uid);
sticky.on('input', function () {
const uid = $(this).data('uid');
const value = Number($(this).val());
data.entries[uid].sticky = !isNaN(value) ? value : null;
setOriginalDataValue(data, uid, 'extensions.sticky', data.entries[uid].sticky);
saveWorldInfo(name, data);
});
sticky.val(entry.sticky > 0 ? entry.sticky : '').trigger('input');
// cooldown
const cooldown = template.find('input[name="cooldown"]');
cooldown.data('uid', entry.uid);
cooldown.on('input', function () {
const uid = $(this).data('uid');
const value = Number($(this).val());
data.entries[uid].cooldown = !isNaN(value) ? value : null;
setOriginalDataValue(data, uid, 'extensions.cooldown', data.entries[uid].cooldown);
saveWorldInfo(name, data);
});
cooldown.val(entry.cooldown > 0 ? entry.cooldown : '').trigger('input');
// probability // probability
if (entry.probability === undefined) { if (entry.probability === undefined) {
entry.probability = null; entry.probability = null;
@ -2559,12 +3150,20 @@ const newEntryDefinition = {
useGroupScoring: { default: null, type: 'boolean?' }, useGroupScoring: { default: null, type: 'boolean?' },
automationId: { default: '', type: 'string' }, automationId: { default: '', type: 'string' },
role: { default: 0, type: 'enum' }, role: { default: 0, type: 'enum' },
sticky: { default: null, type: 'number?' },
cooldown: { default: null, type: 'number?' },
}; };
const newEntryTemplate = Object.fromEntries( const newEntryTemplate = Object.fromEntries(
Object.entries(newEntryDefinition).map(([key, value]) => [key, value.default]), Object.entries(newEntryDefinition).map(([key, value]) => [key, value.default]),
); );
/**
* Creates a new world info entry from template.
* @param {string} _name Name of the WI (unused)
* @param {any} data WI data
* @returns {object | undefined} New entry object or undefined if failed
*/
function createWorldInfoEntry(_name, data) { function createWorldInfoEntry(_name, data) {
const newUid = getFreeWorldEntryUid(data); const newUid = getFreeWorldEntryUid(data);
@ -2593,7 +3192,7 @@ async function saveWorldInfo(name, data, immediately) {
return; return;
} }
delete worldInfoCache[name]; worldInfoCache.delete(name);
if (immediately) { if (immediately) {
return await _save(name, data); return await _save(name, data);
@ -2863,10 +3462,11 @@ export async function getSortedEntries() {
* Performs a scan on the chat and returns the world info activated. * Performs a scan on the chat and returns the world info activated.
* @param {string[]} chat The chat messages to scan. * @param {string[]} chat The chat messages to scan.
* @param {number} maxContext The maximum context size of the generation. * @param {number} maxContext The maximum context size of the generation.
* @param {boolean} isDryRun Whether to perform a dry run.
* @typedef {{ worldInfoBefore: string, worldInfoAfter: string, EMEntries: any[], WIDepthEntries: any[], allActivatedEntries: Set<any> }} WIActivated * @typedef {{ worldInfoBefore: string, worldInfoAfter: string, EMEntries: any[], WIDepthEntries: any[], allActivatedEntries: Set<any> }} WIActivated
* @returns {Promise<WIActivated>} The world info activated. * @returns {Promise<WIActivated>} The world info activated.
*/ */
async function checkWorldInfo(chat, maxContext) { async function checkWorldInfo(chat, maxContext, isDryRun) {
const context = getContext(); const context = getContext();
const buffer = new WorldInfoBuffer(chat); const buffer = new WorldInfoBuffer(chat);
@ -2899,6 +3499,9 @@ async function checkWorldInfo(chat, maxContext) {
console.debug(`Context size: ${maxContext}; WI budget: ${budget} (max% = ${world_info_budget}%, cap = ${world_info_budget_cap})`); console.debug(`Context size: ${maxContext}; WI budget: ${budget} (max% = ${world_info_budget}%, cap = ${world_info_budget_cap})`);
const sortedEntries = await getSortedEntries(); const sortedEntries = await getSortedEntries();
const timedEffects = new WorldInfoTimedEffects(chat, sortedEntries);
!isDryRun && timedEffects.checkTimedEffects();
if (sortedEntries.length === 0) { if (sortedEntries.length === 0) {
return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], allActivatedEntries: new Set() }; return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], allActivatedEntries: new Set() };
@ -2941,6 +3544,14 @@ async function checkWorldInfo(chat, maxContext) {
} }
} }
const isSticky = timedEffects.isEffectActive('sticky', entry);
const isCooldown = timedEffects.isEffectActive('cooldown', entry);
if (isCooldown && !isSticky) {
console.debug(`WI entry ${entry.uid} suppressed by cooldown`);
continue;
}
if (failedProbabilityChecks.has(entry)) { if (failedProbabilityChecks.has(entry)) {
continue; continue;
} }
@ -2949,7 +3560,7 @@ async function checkWorldInfo(chat, maxContext) {
continue; continue;
} }
if (entry.constant || buffer.isExternallyActivated(entry)) { if (entry.constant || buffer.isExternallyActivated(entry) || isSticky) {
activatedNow.add(entry); activatedNow.add(entry);
continue; continue;
} }
@ -3037,9 +3648,12 @@ async function checkWorldInfo(chat, maxContext) {
const rollValue = Math.random() * 100; const rollValue = Math.random() * 100;
if (entry.useProbability && rollValue > entry.probability) { if (entry.useProbability && rollValue > entry.probability) {
const isSticky = timedEffects.isEffectActive('sticky', entry);
if (!isSticky) {
console.debug(`WI entry ${entry.uid} ${entry.key} failed probability check, skipping`); console.debug(`WI entry ${entry.uid} ${entry.key} failed probability check, skipping`);
failedProbabilityChecks.add(entry); failedProbabilityChecks.add(entry);
continue; continue;
}
} else { console.debug(`uid:${entry.uid} passed probability check, inserting to prompt`); } } else { console.debug(`uid:${entry.uid} passed probability check, inserting to prompt`); }
// Substitute macros inline, for both this checking and also future processing // Substitute macros inline, for both this checking and also future processing
@ -3167,7 +3781,9 @@ async function checkWorldInfo(chat, maxContext) {
context.setExtensionPrompt(NOTE_MODULE_NAME, ANWithWI, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]); context.setExtensionPrompt(NOTE_MODULE_NAME, ANWithWI, chat_metadata[metadata_keys.position], chat_metadata[metadata_keys.depth], extension_settings.note.allowWIScan, chat_metadata[metadata_keys.role]);
} }
buffer.cleanExternalActivations(); !isDryRun && timedEffects.setTimedEffects(Array.from(allActivatedEntries));
buffer.resetExternalEffects();
timedEffects.cleanUp();
return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, allActivatedEntries }; return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, allActivatedEntries };
} }
@ -3328,6 +3944,8 @@ function convertAgnaiMemoryBook(inputObj) {
useGroupScoring: null, useGroupScoring: null,
automationId: '', automationId: '',
role: extension_prompt_roles.SYSTEM, role: extension_prompt_roles.SYSTEM,
sticky: null,
cooldown: null,
}; };
}); });
@ -3367,6 +3985,8 @@ function convertRisuLorebook(inputObj) {
useGroupScoring: null, useGroupScoring: null,
automationId: '', automationId: '',
role: extension_prompt_roles.SYSTEM, role: extension_prompt_roles.SYSTEM,
sticky: null,
cooldown: null,
}; };
}); });
@ -3411,6 +4031,8 @@ function convertNovelLorebook(inputObj) {
useGroupScoring: null, useGroupScoring: null,
automationId: '', automationId: '',
role: extension_prompt_roles.SYSTEM, role: extension_prompt_roles.SYSTEM,
sticky: null,
cooldown: null,
}; };
}); });
@ -3457,6 +4079,8 @@ function convertCharacterBook(characterBook) {
automationId: entry.extensions?.automation_id ?? '', automationId: entry.extensions?.automation_id ?? '',
role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM, role: entry.extensions?.role ?? extension_prompt_roles.SYSTEM,
vectorized: entry.extensions?.vectorized ?? false, vectorized: entry.extensions?.vectorized ?? false,
sticky: entry.extensions?.sticky ?? null,
cooldown: entry.extensions?.cooldown ?? null,
}; };
}); });

View File

@ -259,6 +259,7 @@ const TOGETHERAI_KEYS = [
// https://github.com/jmorganca/ollama/blob/main/docs/api.md#request-with-options // https://github.com/jmorganca/ollama/blob/main/docs/api.md#request-with-options
const OLLAMA_KEYS = [ const OLLAMA_KEYS = [
'num_predict', 'num_predict',
'num_ctx',
'stop', 'stop',
'temperature', 'temperature',
'repeat_penalty', 'repeat_penalty',

View File

@ -483,6 +483,8 @@ function convertWorldInfoToCharacterBook(name, entries) {
automation_id: entry.automationId ?? '', automation_id: entry.automationId ?? '',
role: entry.role ?? 0, role: entry.role ?? 0,
vectorized: entry.vectorized ?? false, vectorized: entry.vectorized ?? false,
sticky: entry.sticky ?? null,
cooldown: entry.cooldown ?? null,
}, },
}; };