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

c292f6416322ba39892c580f4901a28566b6b9ad

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
6 files changed, +132 -26Ignore whitespace
public/css/world-info.css+7 -0
@@ -286,6 +286,13 @@ select.keyselect+span.select2-container .select2-selection--multiple {
286 display: none;286 display: none;
287}287}
288288
289.world_entry label[for="__invisible"] {
290 visibility: hidden;
291 pointer-events: none;
292 opacity: 0;
293 width: 0;
294}
295
289#WIMultiSelector .select2-container .select2-selection--multiple {296#WIMultiSelector .select2-container .select2-selection--multiple {
290 max-height: 25vh;297 max-height: 25vh;
291 overflow-y: auto;298 overflow-y: auto;
public/index.html+24 -0
@@ -6494,6 +6494,30 @@
6494 </select>6494 </select>
6495 </div>6495 </div>
6496 </div>6496 </div>
6497 <div class="flex4">
6498 <div class="flex-container justifySpaceBetween">
6499 <small>
6500 <span data-i18n="Filter to Generation Triggers">
6501 Filter to Generation Triggers
6502 </span>
6503 </small>
6504 <!-- Not a real control. Used to make label heights even. -->
6505 <label class="checkbox_label" for="__invisible">
6506 <input type="checkbox" name="__invisible">
6507 <span><small>&nbsp;</small></span>
6508 </label>
6509 </div>
6510 <div class="range-block-range">
6511 <select name="triggers" multiple>
6512 <option data-i18n="Normal" value="normal">Normal</option>
6513 <option data-i18n="Continue" value="continue">Continue</option>
6514 <option data-i18n="Impersonate" value="impersonate">Impersonate</option>
6515 <option data-i18n="Swipe" value="swipe">Swipe</option>
6516 <option data-i18n="Regenerate" value="regenerate">Regenerate</option>
6517 <option data-i18n="Quiet" value="quiet">Quiet</option>
6518 </select>
6519 </div>
6520 </div>
6497 </div>6521 </div>
6498 <div name="WIEntryBottomControls" class="flex-container flex1 justifySpaceBetween world_entry_form_horizontal">6522 <div name="WIEntryBottomControls" class="flex-container flex1 justifySpaceBetween world_entry_form_horizontal">
6499 <div class="flex-container flexFlowColumn flexNoGap wi-enter-footer-text">6523 <div class="flex-container flexFlowColumn flexNoGap wi-enter-footer-text">
public/script.js+3 -1
@@ -176,7 +176,7 @@ import {
176 renderPaginationDropdown,176 renderPaginationDropdown,
177 paginationDropdownChangeHandler,177 paginationDropdownChangeHandler,
178} from './scripts/utils.js';178} from './scripts/utils.js';
179import { debounce_timeout, IGNORE_SYMBOL } from './scripts/constants.js';179import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL } from './scripts/constants.js';
180180
181import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js';181import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors, saveMetadataDebounced } from './scripts/extensions.js';
182import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';182import { 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
3721 // Make quiet prompt available for WIAN3721 // Make quiet prompt available for WIAN
3722 setExtensionPrompt('QUIET_PROMPT', quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true);3722 setExtensionPrompt('QUIET_PROMPT', quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true);
3723 const chatForWI = coreChat.map(x => world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse();3723 const chatForWI = coreChat.map(x => world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse();
3724 /** @type {import('./scripts/world-info.js').WIGlobalScanData} */
3724 const globalScanData = {3725 const globalScanData = {
3725 personaDescription: persona,3726 personaDescription: persona,
3726 characterDescription: description,3727 characterDescription: description,
@@ -3728,6 +3729,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3728 characterDepthPrompt: charDepthPrompt,3729 characterDepthPrompt: charDepthPrompt,
3729 scenario: scenario,3730 scenario: scenario,
3730 creatorNotes: creatorNotes,3731 creatorNotes: creatorNotes,
3732 trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',
3731 };3733 };
3732 const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);3734 const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);
3733 setExtensionPrompt('QUIET_PROMPT', '', extension_prompt_types.IN_PROMPT, 0, true);3735 setExtensionPrompt('QUIET_PROMPT', '', extension_prompt_types.IN_PROMPT, 0, true);
public/scripts/constants.js+12 -0
@@ -28,3 +28,15 @@ export const IGNORE_SYMBOL = Symbol.for('ignore');
28 * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats28 * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats
29 */29 */
30export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg'];30export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg'];
31
32/**
33 * Known generation triggers that can be passed to Generate function.
34 */
35export const GENERATION_TYPE_TRIGGERS = [
36 'normal',
37 'continue',
38 'impersonate',
39 'swipe',
40 'regenerate',
41 'quiet',
42];
public/scripts/world-info.js+85 -25
@@ -9,7 +9,7 @@ import { FILTER_TYPES, FilterHelper } from './filters.js';
9import { getTokenCountAsync } from './tokenizers.js';9import { getTokenCountAsync } from './tokenizers.js';
10import { power_user } from './power-user.js';10import { power_user } from './power-user.js';
11import { getTagKeyForEntity } from './tags.js';11import { getTagKeyForEntity } from './tags.js';
12import { debounce_timeout } from './constants.js';12import { debounce_timeout, GENERATION_TYPE_TRIGGERS } from './constants.js';
13import { getRegexedString, regex_placement } from './extensions/regex/engine.js';13import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
14import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';14import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
15import { SlashCommand } from './slash-commands/SlashCommand.js';15import { SlashCommand } from './slash-commands/SlashCommand.js';
@@ -107,6 +107,7 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
107 * @property {string} characterDepthPrompt Character depth prompt (sometimes referred to as character notes)107 * @property {string} characterDepthPrompt Character depth prompt (sometimes referred to as character notes)
108 * @property {string} scenario Character defined scenario108 * @property {string} scenario Character defined scenario
109 * @property {string} creatorNotes Character creator notes109 * @property {string} creatorNotes Character creator notes
110 * @property {string} trigger The type that triggered the scan, e.g. 'normal', 'continue', etc.
110 */111 */
111112
112/**113/**
@@ -145,6 +146,36 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
145 * @typedef TimedEffectType Type of timed effect146 * @typedef TimedEffectType Type of timed effect
146 * @type {'sticky'|'cooldown'|'delay'}147 * @type {'sticky'|'cooldown'|'delay'}
147 */148 */
149
150/**
151 * @typedef {object} WIPromptResult
152 * @property {string} worldInfoString - Complete world info string
153 * @property {string} worldInfoBefore - World info that goes before the prompt
154 * @property {string} worldInfoAfter - World info that goes after the prompt
155 * @property {Array} worldInfoExamples - Array of example entries
156 * @property {Array} worldInfoDepth - Array of depth entries
157 * @property {Array} anBefore - Array of entries before Author's Note
158 * @property {Array} anAfter - Array of entries after Author's Note
159 */
160
161/**
162 * @typedef {object} WIActivated
163 * @property {string} worldInfoBefore The world info before the chat.
164 * @property {string} worldInfoAfter The world info after the chat.
165 * @property {any[]} EMEntries The entries for examples.
166 * @property {any[]} WIDepthEntries The depth entries.
167 * @property {any[]} ANBeforeEntries The entries before Author's Note.
168 * @property {any[]} ANAfterEntries The entries after Author's Note.
169 * @property {Set<any>} allActivatedEntries All entries.
170 */
171
172/**
173 * @typedef {object} WIEntryFieldDefinition
174 * @property {any} default - Default value for the field
175 * @property {string} type - Type of the field, can be 'string', 'number', 'boolean', 'array', 'enum'
176 * @property {boolean} [excludeFromTemplate=false] - Whether to exclude this field from the template
177 * @property {(value: any) => boolean} [arrayFilter] - Optional filter function for array fields to filter out unwanted values
178 */
148// End typedef area179// End typedef area
149180
150/**181/**
@@ -801,14 +832,6 @@ export const worldInfoCache = new StructuredCloneMap({ cloneOnGet: true, cloneOn
801 * @param {number} maxContext - The maximum context size of the generation.832 * @param {number} maxContext - The maximum context size of the generation.
802 * @param {boolean} isDryRun - If true, the function will not emit any events.833 * @param {boolean} isDryRun - If true, the function will not emit any events.
803 * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned834 * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned
804 * @typedef {object} WIPromptResult
805 * @property {string} worldInfoString - Complete world info string
806 * @property {string} worldInfoBefore - World info that goes before the prompt
807 * @property {string} worldInfoAfter - World info that goes after the prompt
808 * @property {Array} worldInfoExamples - Array of example entries
809 * @property {Array} worldInfoDepth - Array of depth entries
810 * @property {Array} anBefore - Array of entries before Author's Note
811 * @property {Array} anAfter - Array of entries after Author's Note
812 * @returns {Promise<WIPromptResult>} The world info string and depth.835 * @returns {Promise<WIPromptResult>} The world info string and depth.
813 */836 */
814export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanData) {837export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanData) {
@@ -1139,7 +1162,7 @@ function registerWorldInfoSlashCommands() {
1139 return '';1162 return '';
1140 }1163 }
11411164
1142 if (newWorldInfoEntryTemplate[field] === undefined) {1165 if (!Object.hasOwn(newWorldInfoEntryDefinition, field)) {
1143 toastr.warning('Valid field name is required');1166 toastr.warning('Valid field name is required');
1144 return '';1167 return '';
1145 }1168 }
@@ -1167,7 +1190,7 @@ function registerWorldInfoSlashCommands() {
1167 }1190 }
1168 break;1191 break;
1169 default:1192 default:
1170 fieldValue = entry[field];1193 fieldValue = entry[field] ?? newWorldInfoEntryDefinition[field]?.default;
1171 }1194 }
11721195
1173 if (fieldValue === undefined) {1196 if (fieldValue === undefined) {
@@ -1253,11 +1276,19 @@ function registerWorldInfoSlashCommands() {
1253 return '';1276 return '';
1254 }1277 }
12551278
1256 if (newWorldInfoEntryTemplate[field] === undefined) {1279 if (!Object.hasOwn(newWorldInfoEntryDefinition, field)) {
1257 toastr.warning('Valid field name is required');1280 toastr.warning('Valid field name is required');
1258 return '';1281 return '';
1259 }1282 }
12601283
1284 // Init a default value for the field if it does not exist
1285 if (!Object.hasOwn(entry, field)) {
1286 entry[field] = newWorldInfoEntryDefinition[field].default;
1287 }
1288
1289 // Use an array filter if it exists for the field
1290 const arrayFilter = newWorldInfoEntryDefinition[field]?.arrayFilter || (() => true);
1291
1261 // handle special cases, otherwise execute default logic1292 // handle special cases, otherwise execute default logic
1262 let tagNames;1293 let tagNames;
1263 let charNames;1294 let charNames;
@@ -1285,7 +1316,7 @@ function registerWorldInfoSlashCommands() {
1285 break;1316 break;
1286 default:1317 default:
1287 if (Array.isArray(entry[field])) {1318 if (Array.isArray(entry[field])) {
1288 entry[field] = parseStringArray(value);1319 entry[field] = parseStringArray(value).filter(arrayFilter);
1289 } else if (typeof entry[field] === 'boolean') {1320 } else if (typeof entry[field] === 'boolean') {
1290 entry[field] = isTrueBoolean(value);1321 entry[field] = isTrueBoolean(value);
1291 } else if (typeof entry[field] === 'number') {1322 } else if (typeof entry[field] === 'number') {
@@ -2438,6 +2469,7 @@ export const originalWIDataKeyMap = {
2438 'sticky': 'extensions.sticky',2469 'sticky': 'extensions.sticky',
2439 'cooldown': 'extensions.cooldown',2470 'cooldown': 'extensions.cooldown',
2440 'delay': 'extensions.delay',2471 'delay': 'extensions.delay',
2472 'triggers': 'extensions.triggers',
2441};2473};
24422474
2443/** Checks the state of the current search, and adds/removes the search sorting option accordingly */2475/** 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) {
3496 automationIdInput.val(entry.automationId ?? '').trigger('input', { noSave: true });3528 automationIdInput.val(entry.automationId ?? '').trigger('input', { noSave: true });
3497 setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1);3529 setTimeout(() => createEntryInputAutocomplete(automationIdInput, getAutomationIdCallback(data)), 1);
34983530
3531 // Generation Type Triggers
3532 const generationTypeTriggers = editTemplate.find('select[name="triggers"]');
3533 generationTypeTriggers.data('uid', entry.uid);
3534 generationTypeTriggers.on('input', async function (_, { noSave = false } = {}) {
3535 const uid = $(this).data('uid');
3536 const value = $(this).val();
3537 data.entries[uid].triggers = Array.isArray(value) ? value : [];
3538 setWIOriginalDataValue(data, uid, 'extensions.triggers', data.entries[uid].triggers);
3539 !noSave && await saveWorldInfo(name, data);
3540 });
3541 if (!isMobile()) {
3542 generationTypeTriggers.select2({
3543 placeholder: t`All types (default)`,
3544 width: '100%',
3545 closeOnSelect: false,
3546 allowClear: true,
3547 });
3548 }
3549 generationTypeTriggers
3550 .val(Array.isArray(entry.triggers) ? entry.triggers : [])
3551 .trigger('input', { noSave: true })
3552 .trigger('change');
3553
3499 countTokensDebounced(counter, contentInput.val());3554 countTokensDebounced(counter, contentInput.val());
35003555
3501 editTemplate.find('.inline-drawer-content').css('display', 'none');3556 editTemplate.find('.inline-drawer-content').css('display', 'none');
@@ -3652,7 +3707,7 @@ export async function deleteWorldInfoEntry(data, uid, { silent = false } = {}) {
3652 *3707 *
3653 * Use `newEntryTemplate` if you just need the template that contains default values3708 * Use `newEntryTemplate` if you just need the template that contains default values
3654 *3709 *
3655 * @type {{[key: string]: { default: any, type: string, excludeFromTemplate?: boolean }}}3710 * @type {{[key: string]: WIEntryFieldDefinition}}
3656 */3711 */
3657export const newWorldInfoEntryDefinition = {3712export const newWorldInfoEntryDefinition = {
3658 key: { default: [], type: 'array' },3713 key: { default: [], type: 'array' },
@@ -3694,6 +3749,7 @@ export const newWorldInfoEntryDefinition = {
3694 characterFilterNames: { default: [], type: 'array', excludeFromTemplate: true },3749 characterFilterNames: { default: [], type: 'array', excludeFromTemplate: true },
3695 characterFilterTags: { default: [], type: 'array', excludeFromTemplate: true },3750 characterFilterTags: { default: [], type: 'array', excludeFromTemplate: true },
3696 characterFilterExclude: { default: false, type: 'boolean', excludeFromTemplate: true },3751 characterFilterExclude: { default: false, type: 'boolean', excludeFromTemplate: true },
3752 triggers: { default: [], type: 'array', arrayFilter: (value) => GENERATION_TYPE_TRIGGERS.includes(value) },
3697};3753};
36983754
3699export const newWorldInfoEntryTemplate = Object.fromEntries(3755export const newWorldInfoEntryTemplate = Object.fromEntries(
@@ -4143,23 +4199,14 @@ function parseDecorators(content) {
4143 * @param {number} maxContext The maximum context size of the generation.4199 * @param {number} maxContext The maximum context size of the generation.
4144 * @param {boolean} isDryRun Whether to perform a dry run.4200 * @param {boolean} isDryRun Whether to perform a dry run.
4145 * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned4201 * @param {WIGlobalScanData} globalScanData Chat independent context to be scanned
4146 * @typedef {object} WIActivated
4147 * @property {string} worldInfoBefore The world info before the chat.
4148 * @property {string} worldInfoAfter The world info after the chat.
4149 * @property {any[]} EMEntries The entries for examples.
4150 * @property {any[]} WIDepthEntries The depth entries.
4151 * @property {any[]} ANBeforeEntries The entries before Author's Note.
4152 * @property {any[]} ANAfterEntries The entries after Author's Note.
4153 * @property {Set<any>} allActivatedEntries All entries.
4154 * @returns {Promise<WIActivated>} The world info activated.4202 * @returns {Promise<WIActivated>} The world info activated.
4155 */4203 */
4156
4157//MARK: checkWorldInfo4204//MARK: checkWorldInfo
4158export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData) {4205export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData) {
4159 const context = getContext();4206 const context = getContext();
4160 const buffer = new WorldInfoBuffer(chat, globalScanData);4207 const buffer = new WorldInfoBuffer(chat, globalScanData);
41614208
4162 console.debug(`[WI] --- START WI SCAN (on ${chat.length} messages)${isDryRun ? ' (DRY RUN)' : ''} ---`);4209 console.debug(`[WI] --- START WI SCAN (on ${chat.length} messages, trigger = ${globalScanData.trigger})${isDryRun ? ' (DRY RUN)' : ''} ---`);
41634210
4164 // Combine the chat4211 // Combine the chat
41654212
@@ -4231,7 +4278,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData)
4231 // Loop and find all entries that can activate here4278 // Loop and find all entries that can activate here
4232 let activatedNow = new Set();4279 let activatedNow = new Set();
42334280
4234 for (let entry of sortedEntries) {4281 for (const entry of sortedEntries) {
4235 // Logging preparation4282 // Logging preparation
4236 let headerLogged = false;4283 let headerLogged = false;
4237 function log(...args) {4284 function log(...args) {
@@ -4252,6 +4299,15 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData)
4252 continue;4299 continue;
4253 }4300 }
42544301
4302 // Check for generation type trigger filter
4303 if (Array.isArray(entry.triggers) && entry.triggers.length > 0) {
4304 const isTriggered = entry.triggers.includes(globalScanData.trigger);
4305 if (!isTriggered) {
4306 log(`skipped by generation type trigger filter (${globalScanData.trigger} ∉ ${entry.triggers})`);
4307 continue;
4308 }
4309 }
4310
4255 // Check if this entry applies to the character or if it's excluded4311 // Check if this entry applies to the character or if it's excluded
4256 if (entry.characterFilter && entry.characterFilter?.names?.length > 0) {4312 if (entry.characterFilter && entry.characterFilter?.names?.length > 0) {
4257 const nameIncluded = entry.characterFilter.names.includes(getCharaFilename());4313 const nameIncluded = entry.characterFilter.names.includes(getCharaFilename());
@@ -4877,6 +4933,7 @@ function convertAgnaiMemoryBook(inputObj) {
4877 sticky: null,4933 sticky: null,
4878 cooldown: null,4934 cooldown: null,
4879 delay: null,4935 delay: null,
4936 triggers: [],
4880 };4937 };
4881 });4938 });
48824939
@@ -4919,6 +4976,7 @@ function convertRisuLorebook(inputObj) {
4919 sticky: null,4976 sticky: null,
4920 cooldown: null,4977 cooldown: null,
4921 delay: null,4978 delay: null,
4979 triggers: [],
4922 };4980 };
4923 });4981 });
49244982
@@ -4966,6 +5024,7 @@ function convertNovelLorebook(inputObj) {
4966 sticky: null,5024 sticky: null,
4967 cooldown: null,5025 cooldown: null,
4968 delay: null,5026 delay: null,
5027 triggers: [],
4969 };5028 };
4970 });5029 });
49715030
@@ -5022,6 +5081,7 @@ export function convertCharacterBook(characterBook) {
5022 matchScenario: entry.extensions?.match_scenario ?? false,5081 matchScenario: entry.extensions?.match_scenario ?? false,
5023 matchCreatorNotes: entry.extensions?.match_creator_notes ?? false,5082 matchCreatorNotes: entry.extensions?.match_creator_notes ?? false,
5024 extensions: entry.extensions ?? {},5083 extensions: entry.extensions ?? {},
5084 triggers: entry.extensions?.triggers || [],
5025 };5085 };
5026 });5086 });
50275087
src/endpoints/characters.js+1 -0
@@ -707,6 +707,7 @@ function convertWorldInfoToCharacterBook(name, entries) {
707 match_character_depth_prompt: entry.matchCharacterDepthPrompt ?? false,707 match_character_depth_prompt: entry.matchCharacterDepthPrompt ?? false,
708 match_scenario: entry.matchScenario ?? false,708 match_scenario: entry.matchScenario ?? false,
709 match_creator_notes: entry.matchCreatorNotes ?? false,709 match_creator_notes: entry.matchCreatorNotes ?? false,
710 triggers: entry.triggers ?? [],
710 },711 },
711 };712 };
712713