Add generation type triggers to world info (#4286)

* Add generation type triggers to world info

* Simplify includes check

* Refactor getEntryField validation and default value handling

* Remove invalid attribute

* Check for a valid trigger
This commit is contained in:
Cohee
2025-07-20 00:22:54 +03:00
committed by GitHub
parent 786d8f43db
commit c292f64163
6 changed files with 132 additions and 26 deletions
+7
View File
@@ -286,6 +286,13 @@ select.keyselect+span.select2-container .select2-selection--multiple {
display: none; display: none;
} }
.world_entry label[for="__invisible"] {
visibility: hidden;
pointer-events: none;
opacity: 0;
width: 0;
}
#WIMultiSelector .select2-container .select2-selection--multiple { #WIMultiSelector .select2-container .select2-selection--multiple {
max-height: 25vh; max-height: 25vh;
overflow-y: auto; overflow-y: auto;
+24
View File
@@ -6494,6 +6494,30 @@
</select> </select>
</div> </div>
</div> </div>
<div class="flex4">
<div class="flex-container justifySpaceBetween">
<small>
<span data-i18n="Filter to Generation Triggers">
Filter to Generation Triggers
</span>
</small>
<!-- Not a real control. Used to make label heights even. -->
<label class="checkbox_label" for="__invisible">
<input type="checkbox" name="__invisible">
<span><small>&nbsp;</small></span>
</label>
</div>
<div class="range-block-range">
<select name="triggers" multiple>
<option data-i18n="Normal" value="normal">Normal</option>
<option data-i18n="Continue" value="continue">Continue</option>
<option data-i18n="Impersonate" value="impersonate">Impersonate</option>
<option data-i18n="Swipe" value="swipe">Swipe</option>
<option data-i18n="Regenerate" value="regenerate">Regenerate</option>
<option data-i18n="Quiet" value="quiet">Quiet</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">
+3 -1
View File
@@ -176,7 +176,7 @@ import {
renderPaginationDropdown, renderPaginationDropdown,
paginationDropdownChangeHandler, paginationDropdownChangeHandler,
} from './scripts/utils.js'; } from './scripts/utils.js';
import { debounce_timeout, IGNORE_SYMBOL } from './scripts/constants.js'; import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL } from './scripts/constants.js';
import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js'; import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js';
import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js'; import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
@@ -3721,6 +3721,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
// Make quiet prompt available for WIAN // Make quiet prompt available for WIAN
setExtensionPrompt('QUIET_PROMPT', quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true); setExtensionPrompt('QUIET_PROMPT', quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true);
const chatForWI = coreChat.map(x => world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse(); const chatForWI = coreChat.map(x => world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse();
/** @type {import('./scripts/world-info.js').WIGlobalScanData} */
const globalScanData = { const globalScanData = {
personaDescription: persona, personaDescription: persona,
characterDescription: description, characterDescription: description,
@@ -3728,6 +3729,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
characterDepthPrompt: charDepthPrompt, characterDepthPrompt: charDepthPrompt,
scenario: scenario, scenario: scenario,
creatorNotes: creatorNotes, creatorNotes: creatorNotes,
trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',
}; };
const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData); const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);
setExtensionPrompt('QUIET_PROMPT', '', extension_prompt_types.IN_PROMPT, 0, true); setExtensionPrompt('QUIET_PROMPT', '', extension_prompt_types.IN_PROMPT, 0, true);
+12
View File
@@ -28,3 +28,15 @@ export const IGNORE_SYMBOL = Symbol.for('ignore');
* https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats
*/ */
export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg']; export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg'];
/**
* Known generation triggers that can be passed to Generate function.
*/
export const GENERATION_TYPE_TRIGGERS = [
'normal',
'continue',
'impersonate',
'swipe',
'regenerate',
'quiet',
];
+85 -25
View File
@@ -9,7 +9,7 @@ import { FILTER_TYPES, FilterHelper } from './filters.js';
import { getTokenCountAsync } from './tokenizers.js'; import { getTokenCountAsync } from './tokenizers.js';
import { power_user } from './power-user.js'; import { power_user } from './power-user.js';
import { getTagKeyForEntity } from './tags.js'; import { getTagKeyForEntity } from './tags.js';
import { debounce_timeout } from './constants.js'; import { debounce_timeout, GENERATION_TYPE_TRIGGERS } from './constants.js';
import { getRegexedString, regex_placement } from './extensions/regex/engine.js'; import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
import { SlashCommand } from './slash-commands/SlashCommand.js'; import { SlashCommand } from './slash-commands/SlashCommand.js';
@@ -107,6 +107,7 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
* @property {string} characterDepthPrompt Character depth prompt (sometimes referred to as character notes) * @property {string} characterDepthPrompt Character depth prompt (sometimes referred to as character notes)
* @property {string} scenario Character defined scenario * @property {string} scenario Character defined scenario
* @property {string} creatorNotes Character creator notes * @property {string} creatorNotes Character creator notes
* @property {string} trigger The type that triggered the scan, e.g. 'normal', 'continue', etc.
*/ */
/** /**
@@ -145,6 +146,36 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
* @typedef TimedEffectType Type of timed effect * @typedef TimedEffectType Type of timed effect
* @type {'sticky'|'cooldown'|'delay'} * @type {'sticky'|'cooldown'|'delay'}
*/ */
/**
* @typedef {object} WIPromptResult
* @property {string} worldInfoString - Complete world info string
* @property {string} worldInfoBefore - World info that goes before the prompt
* @property {string} worldInfoAfter - World info that goes after the prompt
* @property {Array} worldInfoExamples - Array of example entries
* @property {Array} worldInfoDepth - Array of depth entries
* @property {Array} anBefore - Array of entries before Author's Note
* @property {Array} anAfter - Array of entries after Author's Note
*/
/**
* @typedef {object} WIActivated
* @property {string} worldInfoBefore The world info before the chat.
* @property {string} worldInfoAfter The world info after the chat.
* @property {any[]} EMEntries The entries for examples.
* @property {any[]} WIDepthEntries The depth entries.
* @property {any[]} ANBeforeEntries The entries before Author's Note.
* @property {any[]} ANAfterEntries The entries after Author's Note.
* @property {Set<any>} allActivatedEntries All entries.
*/
/**
* @typedef {object} WIEntryFieldDefinition
* @property {any} default - Default value for the field
* @property {string} type - Type of the field, can be 'string', 'number', 'boolean', 'array', 'enum'
* @property {boolean} [excludeFromTemplate=false] - Whether to exclude this field from the template
* @property {(value: any) => boolean} [arrayFilter] - Optional filter function for array fields to filter out unwanted values
*/
// End typedef area // End typedef area
/** /**
@@ -801,14 +832,6 @@ export const worldInfoCache = new StructuredCloneMap({ cloneOnGet: true, cloneOn
* @param {number} maxContext - The maximum context size of the generation. * @param {number} maxContext - The maximum context size of the generation.
* @param {boolean} isDryRun - If true, the function will not emit any events. * @param {boolean} isDryRun - If true, the function will not emit any events.
* @param {WIGlobalScanData} globalScanData Chat independent context to be scanned * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned
* @typedef {object} WIPromptResult
* @property {string} worldInfoString - Complete world info string
* @property {string} worldInfoBefore - World info that goes before the prompt
* @property {string} worldInfoAfter - World info that goes after the prompt
* @property {Array} worldInfoExamples - Array of example entries
* @property {Array} worldInfoDepth - Array of depth entries
* @property {Array} anBefore - Array of entries before Author's Note
* @property {Array} anAfter - Array of entries after Author's Note
* @returns {Promise<WIPromptResult>} The world info string and depth. * @returns {Promise<WIPromptResult>} The world info string and depth.
*/ */
export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanData) { export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanData) {
@@ -1139,7 +1162,7 @@ function registerWorldInfoSlashCommands() {
return ''; return '';
} }
if (newWorldInfoEntryTemplate[field] === undefined) { if (!Object.hasOwn(newWorldInfoEntryDefinition, field)) {
toastr.warning('Valid field name is required'); toastr.warning('Valid field name is required');
return ''; return '';
} }
@@ -1167,7 +1190,7 @@ function registerWorldInfoSlashCommands() {
} }
break; break;
default: default:
fieldValue = entry[field]; fieldValue = entry[field] ?? newWorldInfoEntryDefinition[field]?.default;
} }
if (fieldValue === undefined) { if (fieldValue === undefined) {
@@ -1253,11 +1276,19 @@ function registerWorldInfoSlashCommands() {
return ''; return '';
} }
if (newWorldInfoEntryTemplate[field] === undefined) { if (!Object.hasOwn(newWorldInfoEntryDefinition, field)) {
toastr.warning('Valid field name is required'); toastr.warning('Valid field name is required');
return ''; return '';
} }
// Init a default value for the field if it does not exist
if (!Object.hasOwn(entry, field)) {
entry[field] = newWorldInfoEntryDefinition[field].default;
}
// Use an array filter if it exists for the field
const arrayFilter = newWorldInfoEntryDefinition[field]?.arrayFilter || (() => true);
// handle special cases, otherwise execute default logic // handle special cases, otherwise execute default logic
let tagNames; let tagNames;
let charNames; let charNames;
@@ -1285,7 +1316,7 @@ function registerWorldInfoSlashCommands() {
break; break;
default: default:
if (Array.isArray(entry[field])) { if (Array.isArray(entry[field])) {
entry[field] = parseStringArray(value); entry[field] = parseStringArray(value).filter(arrayFilter);
} else if (typeof entry[field] === 'boolean') { } else if (typeof entry[field] === 'boolean') {
entry[field] = isTrueBoolean(value); entry[field] = isTrueBoolean(value);
} else if (typeof entry[field] === 'number') { } else if (typeof entry[field] === 'number') {
@@ -2438,6 +2469,7 @@ export const originalWIDataKeyMap = {
'sticky': 'extensions.sticky', 'sticky': 'extensions.sticky',
'cooldown': 'extensions.cooldown', 'cooldown': 'extensions.cooldown',
'delay': 'extensions.delay', 'delay': 'extensions.delay',
'triggers': 'extensions.triggers',
}; };
/** 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 */
@@ -3496,6 +3528,29 @@ export async function getWorldEntry(name, data, entry) {
automationIdInput.val(entry.automationId ?? '').trigger('input', { noSave: true }); automationIdInput.val(entry.automationId ?? '').trigger('input', { noSave: true });
setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1); setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1);
// Generation Type Triggers
const generationTypeTriggers = editTemplate.find('select[name="triggers"]');
generationTypeTriggers.data('uid', entry.uid);
generationTypeTriggers.on('input', async function (_, { noSave = false } = {}) {
const uid = $(this).data('uid');
const value = $(this).val();
data.entries[uid].triggers = Array.isArray(value) ? value : [];
setWIOriginalDataValue(data, uid, 'extensions.triggers', data.entries[uid].triggers);
!noSave && await saveWorldInfo(name, data);
});
if (!isMobile()) {
generationTypeTriggers.select2({
placeholder: t`All types (default)`,
width: '100%',
closeOnSelect: false,
allowClear: true,
});
}
generationTypeTriggers
.val(Array.isArray(entry.triggers) ? entry.triggers : [])
.trigger('input', { noSave: true })
.trigger('change');
countTokensDebounced(counter, contentInput.val()); countTokensDebounced(counter, contentInput.val());
editTemplate.find('.inline-drawer-content').css('display', 'none'); editTemplate.find('.inline-drawer-content').css('display', 'none');
@@ -3652,7 +3707,7 @@ export async function deleteWorldInfoEntry(data, uid, { silent = false } = {}) {
* *
* Use `newEntryTemplate` if you just need the template that contains default values * Use `newEntryTemplate` if you just need the template that contains default values
* *
* @type {{[key: string]: { default: any, type: string, excludeFromTemplate?: boolean }}} * @type {{[key: string]: WIEntryFieldDefinition}}
*/ */
export const newWorldInfoEntryDefinition = { export const newWorldInfoEntryDefinition = {
key: { default: [], type: 'array' }, key: { default: [], type: 'array' },
@@ -3694,6 +3749,7 @@ export const newWorldInfoEntryDefinition = {
characterFilterNames: { default: [], type: 'array', excludeFromTemplate: true }, characterFilterNames: { default: [], type: 'array', excludeFromTemplate: true },
characterFilterTags: { default: [], type: 'array', excludeFromTemplate: true }, characterFilterTags: { default: [], type: 'array', excludeFromTemplate: true },
characterFilterExclude: { default: false, type: 'boolean', excludeFromTemplate: true }, characterFilterExclude: { default: false, type: 'boolean', excludeFromTemplate: true },
triggers: { default: [], type: 'array', arrayFilter: (value) => GENERATION_TYPE_TRIGGERS.includes(value) },
}; };
export const newWorldInfoEntryTemplate = Object.fromEntries( export const newWorldInfoEntryTemplate = Object.fromEntries(
@@ -4143,23 +4199,14 @@ function parseDecorators(content) {
* @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. * @param {boolean} isDryRun Whether to perform a dry run.
* @param {WIGlobalScanData} globalScanData Chat independent context to be scanned * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned
* @typedef {object} WIActivated
* @property {string} worldInfoBefore The world info before the chat.
* @property {string} worldInfoAfter The world info after the chat.
* @property {any[]} EMEntries The entries for examples.
* @property {any[]} WIDepthEntries The depth entries.
* @property {any[]} ANBeforeEntries The entries before Author's Note.
* @property {any[]} ANAfterEntries The entries after Author's Note.
* @property {Set<any>} allActivatedEntries All entries.
* @returns {Promise<WIActivated>} The world info activated. * @returns {Promise<WIActivated>} The world info activated.
*/ */
//MARK: checkWorldInfo //MARK: checkWorldInfo
export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData) { export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData) {
const context = getContext(); const context = getContext();
const buffer = new WorldInfoBuffer(chat, globalScanData); const buffer = new WorldInfoBuffer(chat, globalScanData);
console.debug(`[WI] --- START WI SCAN (on ${chat.length} messages)${isDryRun ? ' (DRY RUN)' : ''} ---`); console.debug(`[WI] --- START WI SCAN (on ${chat.length} messages, trigger = ${globalScanData.trigger})${isDryRun ? ' (DRY RUN)' : ''} ---`);
// Combine the chat // Combine the chat
@@ -4231,7 +4278,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData)
// Loop and find all entries that can activate here // Loop and find all entries that can activate here
let activatedNow = new Set(); let activatedNow = new Set();
for (let entry of sortedEntries) { for (const entry of sortedEntries) {
// Logging preparation // Logging preparation
let headerLogged = false; let headerLogged = false;
function log(...args) { function log(...args) {
@@ -4252,6 +4299,15 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData)
continue; continue;
} }
// Check for generation type trigger filter
if (Array.isArray(entry.triggers) && entry.triggers.length > 0) {
const isTriggered = entry.triggers.includes(globalScanData.trigger);
if (!isTriggered) {
log(`skipped by generation type trigger filter (${globalScanData.trigger}${entry.triggers})`);
continue;
}
}
// Check if this entry applies to the character or if it's excluded // Check if this entry applies to the character or if it's excluded
if (entry.characterFilter && entry.characterFilter?.names?.length > 0) { if (entry.characterFilter && entry.characterFilter?.names?.length > 0) {
const nameIncluded = entry.characterFilter.names.includes(getCharaFilename()); const nameIncluded = entry.characterFilter.names.includes(getCharaFilename());
@@ -4877,6 +4933,7 @@ function convertAgnaiMemoryBook(inputObj) {
sticky: null, sticky: null,
cooldown: null, cooldown: null,
delay: null, delay: null,
triggers: [],
}; };
}); });
@@ -4919,6 +4976,7 @@ function convertRisuLorebook(inputObj) {
sticky: null, sticky: null,
cooldown: null, cooldown: null,
delay: null, delay: null,
triggers: [],
}; };
}); });
@@ -4966,6 +5024,7 @@ function convertNovelLorebook(inputObj) {
sticky: null, sticky: null,
cooldown: null, cooldown: null,
delay: null, delay: null,
triggers: [],
}; };
}); });
@@ -5022,6 +5081,7 @@ export function convertCharacterBook(characterBook) {
matchScenario: entry.extensions?.match_scenario ?? false, matchScenario: entry.extensions?.match_scenario ?? false,
matchCreatorNotes: entry.extensions?.match_creator_notes ?? false, matchCreatorNotes: entry.extensions?.match_creator_notes ?? false,
extensions: entry.extensions ?? {}, extensions: entry.extensions ?? {},
triggers: entry.extensions?.triggers || [],
}; };
}); });
+1
View File
@@ -707,6 +707,7 @@ function convertWorldInfoToCharacterBook(name, entries) {
match_character_depth_prompt: entry.matchCharacterDepthPrompt ?? false, match_character_depth_prompt: entry.matchCharacterDepthPrompt ?? false,
match_scenario: entry.matchScenario ?? false, match_scenario: entry.matchScenario ?? false,
match_creator_notes: entry.matchCreatorNotes ?? false, match_creator_notes: entry.matchCreatorNotes ?? false,
triggers: entry.triggers ?? [],
}, },
}; };