Add new WI position 'outlet' to expose WI entries triggered to be placed as macros literally anywhere (#4523) * feat: add outlet prompt support for world info entries * feat: add outlet name field with autocomplete for world info entries * feat: add outlet entries for world info support to WI parsing * feat: add tooltip and validation for WI outlet name entries * Trim macro outlet name Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: consolidate WI injection cleanup and fix strict equality comparison * fix: update outlet macro regex pattern to use double colon delimiter * fix: replace carriage returns with newlines in WI entry outlet tooltip * fix: add null checks and array validation for WI outlet entries handling * feat: add outletName field to world info entry definition schema (supports /setwientryfield command) * fix: add outletName field to export of character lore books * do not remove outlet name on position change, but filter out for auto complete * Match WI extensions field name Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> * outlet name being used in char WI import too * docs: add outlet macro documentation to help template * be explicit about risu/novel/agnai wi not supporting outlets * Inject A/N and TC sysprompt after populating outlets --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

7cba4e88ab4c79257be3438138ceac327dda0aeb

Wolfsblvt <wolfsblvt@gmail.com>

Signed
8 files changed, +181 -66Showing whitespace changes
public/css/world-info.css+4 -0
@@ -301,6 +301,10 @@ select.keyselect+span.select2-container .select2-selection--multiple {
301 display: none;301 display: none;
302}302}
303303
304.world_entry:not(:has(select[name="position"] option[value="7"]:checked)) .world_entry_form_control:has(input[name="outletName"]) {
305 display: none;
306}
307
304.world_entry label[for="__invisible"] {308.world_entry label[for="__invisible"] {
305 visibility: hidden;309 visibility: hidden;
306 pointer-events: none;310 pointer-events: none;
public/index.html+10 -0
@@ -6476,6 +6476,13 @@
6476 </div>6476 </div>
6477 </div>6477 </div>
6478 <div name="perEntryOverridesBlock" class="flex-container wide100p alignitemscenter">6478 <div name="perEntryOverridesBlock" class="flex-container wide100p alignitemscenter">
6479 <div class="world_entry_form_control flex1" title="Set the outlet name for this WI entry.&#10;&#10;WI entries with position 'outlet' will not be added to the prompt automatically. Instead, they will be collected and can be used as a macro in the prompt.&#10;Add {{outlet::YourName}} to the prompt in any place you want to add all WI entries this specific outlet." data-i18n="[title]wi_outlet_name">
6480 <small class="textAlignCenter">
6481 <span data-i18n="Outlet Name">Outlet Name</span>
6482 <div class="fa-solid fa-circle-info opacity50p"></div>
6483 </small>
6484 <input class="text_pole margin0" name="outletName" type="text" placeholder="Outlet Name" data-i18n="[placeholder]Outlet Name">
6485 </div>
6479 <div class="world_entry_form_control flex1">6486 <div class="world_entry_form_control flex1">
6480 <small class="textAlignCenter" data-i18n="Scan Depth">Scan Depth</small>6487 <small class="textAlignCenter" data-i18n="Scan Depth">Scan Depth</small>
6481 <input class="text_pole margin0" name="scanDepth" type="number" placeholder="Use global setting" data-i18n="[placeholder]Use global setting" max="1000">6488 <input class="text_pole margin0" name="scanDepth" type="number" placeholder="Use global setting" data-i18n="[placeholder]Use global setting" max="1000">
@@ -6813,6 +6820,9 @@
6813 <option value="4" data-role="2" data-i18n="at Depth AI">6820 <option value="4" data-role="2" data-i18n="at Depth AI">
6814 @D 🤖6821 @D 🤖
6815 </option>6822 </option>
6823 <option value="7" data-role="" data-i18n="Outlet">
6824 ➡️ Outlet
6825 </option>
6816 </select>6826 </select>
6817 </div>6827 </div>
6818 <div class="world_entry_form_control wi-enter-footer-text flex-container flexNoGap">6828 <div class="world_entry_form_control wi-enter-footer-text flex-container flexNoGap">
public/script.js+32 -25
@@ -3575,18 +3575,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3575 creatorNotes,3575 creatorNotes,
3576 } = getCharacterCardFields();3576 } = getCharacterCardFields();
35773577
3578 if (main_api !== 'openai') {
3579 if (power_user.sysprompt.enabled) {
3580 system = power_user.prefer_character_prompt && system
3581 ? substituteParams(system, name1, name2, (power_user.sysprompt.content ?? ''))
3582 : baseChatReplace(power_user.sysprompt.content, name1, name2);
3583 system = isInstruct ? substituteParams(system, name1, name2, power_user.sysprompt.content) : system;
3584 } else {
3585 // Nullify if it's not enabled
3586 system = '';
3587 }
3588 }
3589
3590 // Depth prompt (character-specific A/N)3578 // Depth prompt (character-specific A/N)
3591 removeDepthPrompts();3579 removeDepthPrompts();
3592 const groupDepthPrompts = getGroupDepthPrompts(selected_group, Number(this_chid));3580 const groupDepthPrompts = getGroupDepthPrompts(selected_group, Number(this_chid));
@@ -3717,13 +3705,6 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
37173705
3718 let mesExamplesArray = parseMesExamples(mesExamples, isInstruct);3706 let mesExamplesArray = parseMesExamples(mesExamples, isInstruct);
37193707
3720 //////////////////////////////////
3721 // Extension added strings
3722 // Set non-WI AN
3723 setFloatingPrompt();
3724 // Add persona description to prompt
3725 addPersonaDescriptionExtensionPrompt();
3726
3727 // Add WI to prompt (and also inject WI to AN value via hijack)3708 // Add WI to prompt (and also inject WI to AN value via hijack)
3728 // Make quiet prompt available for WIAN3709 // Make quiet prompt available for WIAN
3729 setExtensionPrompt(inject_ids.QUIET_PROMPT, quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true);3710 setExtensionPrompt(inject_ids.QUIET_PROMPT, quiet_prompt || '', extension_prompt_types.IN_PROMPT, 0, true);
@@ -3738,7 +3719,7 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3738 creatorNotes: creatorNotes,3719 creatorNotes: creatorNotes,
3739 trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',3720 trigger: GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',
3740 };3721 };
3741 const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);3722 const { worldInfoString, worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth, outletEntries } = await getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);
3742 setExtensionPrompt(inject_ids.QUIET_PROMPT, '', extension_prompt_types.IN_PROMPT, 0, true);3723 setExtensionPrompt(inject_ids.QUIET_PROMPT, '', extension_prompt_types.IN_PROMPT, 0, true);
37433724
3744 // Add message example WI3725 // Add message example WI
@@ -3770,17 +3751,41 @@ export async function Generate(type, { automatic_trigger, force_name2, quiet_pro
3770 if (skipWIAN !== true) {3751 if (skipWIAN !== true) {
3771 console.log('skipWIAN not active, adding WIAN');3752 console.log('skipWIAN not active, adding WIAN');
3772 // Add all depth WI entries to prompt3753 // Add all depth WI entries to prompt
3773 flushWIDepthInjections();3754 flushWIInjections();
3774 if (Array.isArray(worldInfoDepth)) {3755 if (Array.isArray(worldInfoDepth)) {
3775 worldInfoDepth.forEach((e) => {3756 worldInfoDepth.forEach((e) => {
3776 const joinedEntries = e.entries.join('\n');3757 const joinedEntries = e.entries.join('\n');
3777 setExtensionPrompt(inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, extension_prompt_types.IN_CHAT, e.depth, false, e.role);3758 setExtensionPrompt(inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, extension_prompt_types.IN_CHAT, e.depth, false, e.role);
3778 });3759 });
3779 }3760 }
3761 if (outletEntries && typeof outletEntries === 'object' && Object.keys(outletEntries).length > 0) {
3762 Object.entries(outletEntries).forEach(([key, value]) => {
3763 setExtensionPrompt(inject_ids.CUSTOM_WI_OUTLET(key), value.join('\n'), extension_prompt_types.NONE, 0);
3764 });
3765 }
3780 } else {3766 } else {
3781 console.log('skipping WIAN');3767 console.log('skipping WIAN');
3782 }3768 }
37833769
3770 // Set non-WI AN
3771 setFloatingPrompt();
3772
3773 // Add persona description to prompt
3774 addPersonaDescriptionExtensionPrompt();
3775
3776 // Prepare the system prompt for Text Completion APIs
3777 if (main_api !== 'openai') {
3778 if (power_user.sysprompt.enabled) {
3779 system = power_user.prefer_character_prompt && system
3780 ? substituteParams(system, name1, name2, (power_user.sysprompt.content ?? ''))
3781 : baseChatReplace(power_user.sysprompt.content, name1, name2);
3782 system = isInstruct ? substituteParams(system, name1, name2, power_user.sysprompt.content) : system;
3783 } else {
3784 // Nullify if it's not enabled
3785 system = '';
3786 }
3787 }
3788
3784 // Collect before / after story string injections3789 // Collect before / after story string injections
3785 const beforeScenarioAnchor = await getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT);3790 const beforeScenarioAnchor = await getExtensionPrompt(extension_prompt_types.BEFORE_PROMPT);
3786 const afterScenarioAnchor = await getExtensionPrompt(extension_prompt_types.IN_PROMPT);3791 const afterScenarioAnchor = await getExtensionPrompt(extension_prompt_types.IN_PROMPT);
@@ -4751,10 +4756,12 @@ async function doChatInject(messages, isContinue) {
4751 return injectedIndices;4756 return injectedIndices;
4752}4757}
47534758
4754function flushWIDepthInjections() {4759function flushWIInjections() {
4755 //prevent custom depth WI entries (which have unique random key names) from duplicating4760 const depthPrefix = inject_ids.CUSTOM_WI_DEPTH;
4761 const outletPrefix = inject_ids.CUSTOM_WI_OUTLET('');
4762
4756 for (const key of Object.keys(extension_prompts)) {4763 for (const key of Object.keys(extension_prompts)) {
4757 if (key.startsWith(inject_ids.CUSTOM_WI_DEPTH)) {4764 if (key.startsWith(depthPrefix) || key.startsWith(outletPrefix)) {
4758 delete extension_prompts[key];4765 delete extension_prompts[key];
4759 }4766 }
4760 }4767 }
@@ -4775,7 +4782,7 @@ function unblockGeneration(type) {
4775 showSwipeButtons();4782 showSwipeButtons();
4776 setGenerationProgress(0);4783 setGenerationProgress(0);
4777 flushEphemeralStoppingStrings();4784 flushEphemeralStoppingStrings();
4778 flushWIDepthInjections();4785 flushWIInjections();
4779}4786}
47804787
4781export function getNextMessageId(type) {4788export function getNextMessageId(type) {
public/scripts/constants.js+1 -0
@@ -51,6 +51,7 @@ export const inject_ids = {
51 DEPTH_PROMPT_INDEX: (index) => `DEPTH_PROMPT_${index}`,51 DEPTH_PROMPT_INDEX: (index) => `DEPTH_PROMPT_${index}`,
52 CUSTOM_WI_DEPTH: 'customDepthWI',52 CUSTOM_WI_DEPTH: 'customDepthWI',
53 CUSTOM_WI_DEPTH_ROLE: (depth, role) => `customDepthWI_${depth}_${role}`,53 CUSTOM_WI_DEPTH_ROLE: (depth, role) => `customDepthWI_${depth}_${role}`,
54 CUSTOM_WI_OUTLET: (key) => `customWIOutlet_${key}`,
54};55};
5556
56export const COMETAPI_IGNORE_PATTERNS = [57export const COMETAPI_IGNORE_PATTERNS = [
public/scripts/macros.js+14 -1
@@ -1,10 +1,11 @@
1import { Handlebars, moment, seedrandom, droll } from '../lib.js';1import { Handlebars, moment, seedrandom, droll } from '../lib.js';
2import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams, eventSource, event_types } from '../script.js';2import { chat, chat_metadata, main_api, getMaxContextSize, getCurrentChatId, substituteParams, eventSource, event_types, extension_prompts } from '../script.js';
3import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js';3import { timestampToMoment, isDigitsOnly, getStringHash, escapeRegex, uuidv4 } from './utils.js';
4import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';4import { textgenerationwebui_banned_in_macros } from './textgen-settings.js';
5import { getInstructMacros } from './instruct-mode.js';5import { getInstructMacros } from './instruct-mode.js';
6import { getVariableMacros } from './variables.js';6import { getVariableMacros } from './variables.js';
7import { isMobile } from './RossAscends-mods.js';7import { isMobile } from './RossAscends-mods.js';
8import { inject_ids } from './constants.js';
89
9/**10/**
10 * @typedef Macro11 * @typedef Macro
@@ -459,6 +460,17 @@ function getTimeDiffMacro() {
459}460}
460461
461/**462/**
463 * Returns the outlet prompt for a given outlet key.
464 * @param {string} key - The outlet key
465 * @returns {string} The outlet prompt
466 */
467function getOutletPrompt(key) {
468 const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(key)]?.value;
469 // Macros should already be parsed, but we'll do it again just in case
470 return substituteParams(value);
471}
472
473/**
462 * Substitutes {{macro}} parameters in a string.474 * Substitutes {{macro}} parameters in a string.
463 * @param {string} content - The string to substitute parameters in.475 * @param {string} content - The string to substitute parameters in.
464 * @param {EnvObject} env - Map of macro names to the values they'll be substituted with. If the param476 * @param {EnvObject} env - Map of macro names to the values they'll be substituted with. If the param
@@ -518,6 +530,7 @@ export function evaluateMacros(content, env, postProcessFn) {
518 { regex: /{{datetimeformat +([^}]*)}}/gi, replace: (_, format) => moment().format(format) },530 { regex: /{{datetimeformat +([^}]*)}}/gi, replace: (_, format) => moment().format(format) },
519 { regex: /{{idle_duration}}/gi, replace: () => getTimeSinceLastMessage() },531 { regex: /{{idle_duration}}/gi, replace: () => getTimeSinceLastMessage() },
520 { regex: /{{time_UTC([-+]\d+)}}/gi, replace: (_, offset) => moment().utc().utcOffset(parseInt(offset, 10)).format('LT') },532 { regex: /{{time_UTC([-+]\d+)}}/gi, replace: (_, offset) => moment().utc().utcOffset(parseInt(offset, 10)).format('LT') },
533 { regex: /{{outlet::(.+?)}}/gi, replace: (_, key) => getOutletPrompt(key.trim()) || '' },
521 getTimeDiffMacro(),534 getTimeDiffMacro(),
522 getBannedWordsMacro(),535 getBannedWordsMacro(),
523 getRandomReplaceMacro(),536 getRandomReplaceMacro(),
public/scripts/templates/macros.html+1 -0
@@ -22,6 +22,7 @@
22 <li><tt>&lcub;&lcub;char&rcub;&rcub;</tt> – <span data-i18n="help_macros_16">the Character's name</span></li>22 <li><tt>&lcub;&lcub;char&rcub;&rcub;</tt> – <span data-i18n="help_macros_16">the Character's name</span></li>
23 <li><tt>&lcub;&lcub;version&rcub;&rcub;</tt> – <span data-i18n="help_macros_17">the Character's version number</span></li>23 <li><tt>&lcub;&lcub;version&rcub;&rcub;</tt> – <span data-i18n="help_macros_17">the Character's version number</span></li>
24 <li><tt>&lcub;&lcub;charDepthPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_charDepthPrompt">the Character's @ Depth Note</span></li>24 <li><tt>&lcub;&lcub;charDepthPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_charDepthPrompt">the Character's @ Depth Note</span></li>
25 <li><tt>&lcub;&lcub;outlet::(name)&rcub;&rcub;</tt> – <span data-i18n="help_macros_outletName">the WI entry content for the outlet with the specified name</span></li>
25 <li><tt>&lcub;&lcub;group&rcub;&rcub;</tt> – <span data-i18n="help_macros_18">a comma-separated list of group member names (including muted) or the character name in solo chats. Alias: &lcub;&lcub;charIfNotGroup&rcub;&rcub;</span></li>26 <li><tt>&lcub;&lcub;group&rcub;&rcub;</tt> – <span data-i18n="help_macros_18">a comma-separated list of group member names (including muted) or the character name in solo chats. Alias: &lcub;&lcub;charIfNotGroup&rcub;&rcub;</span></li>
26 <li><tt>&lcub;&lcub;groupNotMuted&rcub;&rcub;</tt> – <span data-i18n="help_groupNotMuted">the same as &lcub;&lcub;group&rcub;&rcub;, but excludes muted members</span></li>27 <li><tt>&lcub;&lcub;groupNotMuted&rcub;&rcub;</tt> – <span data-i18n="help_groupNotMuted">the same as &lcub;&lcub;group&rcub;&rcub;, but excludes muted members</span></li>
27 <li><tt>&lcub;&lcub;model&rcub;&rcub;</tt> – <span data-i18n="help_macros_19">a text generation model name for the currently selected API. </span><b data-i18n="Can be inaccurate!">Can be inaccurate!</b></li>28 <li><tt>&lcub;&lcub;model&rcub;&rcub;</tt> – <span data-i18n="help_macros_19">a text generation model name for the currently selected API. </span><b data-i18n="Can be inaccurate!">Can be inaccurate!</b></li>
public/scripts/world-info.js+118 -40
@@ -156,6 +156,7 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
156 * @property {Array} worldInfoDepth - Array of depth entries156 * @property {Array} worldInfoDepth - Array of depth entries
157 * @property {Array} anBefore - Array of entries before Author's Note157 * @property {Array} anBefore - Array of entries before Author's Note
158 * @property {Array} anAfter - Array of entries after Author's Note158 * @property {Array} anAfter - Array of entries after Author's Note
159 * @property {{[key: string]: string[]}} outletEntries - Array of entries to be added to an outlet
159 */160 */
160161
161/**162/**
@@ -166,6 +167,7 @@ const KNOWN_DECORATORS = ['@@activate', '@@dont_activate'];
166 * @property {any[]} WIDepthEntries The depth entries.167 * @property {any[]} WIDepthEntries The depth entries.
167 * @property {any[]} ANBeforeEntries The entries before Author's Note.168 * @property {any[]} ANBeforeEntries The entries before Author's Note.
168 * @property {any[]} ANAfterEntries The entries after Author's Note.169 * @property {any[]} ANAfterEntries The entries after Author's Note.
170 * @property {{[key: string]: string[]}} outletEntries - Array of entries to be added to an outlet
169 * @property {Set<any>} allActivatedEntries All entries.171 * @property {Set<any>} allActivatedEntries All entries.
170 */172 */
171173
@@ -817,6 +819,7 @@ export const world_info_position = {
817 atDepth: 4,819 atDepth: 4,
818 EMTop: 5,820 EMTop: 5,
819 EMBottom: 6,821 EMBottom: 6,
822 outlet: 7,
820};823};
821824
822export const wi_anchor_position = {825export const wi_anchor_position = {
@@ -866,6 +869,7 @@ export async function getWorldInfoPrompt(chat, maxContext, isDryRun, globalScanD
866 worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [],869 worldInfoDepth: activatedWorldInfo.WIDepthEntries ?? [],
867 anBefore: activatedWorldInfo.ANBeforeEntries ?? [],870 anBefore: activatedWorldInfo.ANBeforeEntries ?? [],
868 anAfter: activatedWorldInfo.ANAfterEntries ?? [],871 anAfter: activatedWorldInfo.ANAfterEntries ?? [],
872 outletEntries: activatedWorldInfo.outletEntries ?? {},
869 };873 };
870}874}
871875
@@ -3425,6 +3429,19 @@ export async function getWorldEntry(name, data, entry) {
3425 contentInput.val(entry.content).trigger('input', { skipCount: true, noSave: true });3429 contentInput.val(entry.content).trigger('input', { skipCount: true, noSave: true });
3426 editTemplate.find('.editor_maximize').attr('data-for', contentInputId);3430 editTemplate.find('.editor_maximize').attr('data-for', contentInputId);
34273431
3432 // Outlet name
3433 const outletNameInput = editTemplate.find('input[name="outletName"]');
3434 outletNameInput.data('uid', entry.uid);
3435 outletNameInput.on('input', async function (_, { noSave = false } = {}) {
3436 const uid = $(this).data('uid');
3437 const value = $(this).val();
3438 data.entries[uid].outletName = value;
3439 setWIOriginalDataValue(data, uid, 'extensions.outlet_name', data.entries[uid].outletName);
3440 !noSave && await saveWorldInfo(name, data);
3441 });
3442 outletNameInput.val(entry.outletName ?? '').trigger('input', { noSave: true });
3443 setTimeout(() => createEntryInputAutocomplete(outletNameInput, getOutletNameCallback(data), { allowMultiple: true }), 1);
3444
3428 // Scan depth3445 // Scan depth
3429 const scanDepthInput = editTemplate.find('input[name="scanDepth"]');3446 const scanDepthInput = editTemplate.find('input[name="scanDepth"]');
3430 scanDepthInput.data('uid', entry.uid);3447 scanDepthInput.data('uid', entry.uid);
@@ -3597,63 +3614,105 @@ export async function getWorldEntry(name, data, entry) {
3597 return headerTemplate;3614 return headerTemplate;
3598}3615}
35993616
3617
3600/**3618/**
3601 * Get the inclusion groups for the autocomplete.3619 * Builds a jQuery UI autocomplete callback: (control, request, response) => void
3602 * @param {any} data WI data3620 * @param {object} [opt={}] - Optional arguments
3603 * @returns {(input: any, output: any) => any} Callback function for the autocomplete3621 * @param {{entries: Record<string, any>}} [opt.data] - Your WI data
3622 * @param {(entry:any)=>string|string[]|null|undefined} [opt.collectValues] - Extract values from one entry
3623 * @param {() => Iterable<string>} [opt.includeExtras] - Optional global extras to include
3624 * @param {(ctx:{result:string[], control:JQuery, input:any, haystack:string[]})=>string[]} [opt.postFilter] - Optional final filter step (for special rules like your "group" de-dupe logic)
3604 */3625 */
3605function getInclusionGroupCallback(data) {3626function buildAutocompleteCallback({ data, collectValues, includeExtras = () => [], postFilter } = {}) {
3606 return function (control, input, output) {3627 return function (control, input, output) {
3607 const uid = $(control).data('uid');3628 const uid = $(control).data('uid');
3608 const thisGroups = String($(control).val()).split(/,\s*/).filter(x => x).map(x => x.toLowerCase());3629
3609 const groups = new Set();3630 // Collect unique values from all *other* entries
3610 for (const entry of Object.values(data.entries)) {3631 const values = new Set();
3611 // Skip the groups of this entry, because auto-complete should only suggest the ones that are already available on other entries3632 for (const entry of Object.values(data.entries ?? {})) {
3612 if (entry.uid == uid) continue;3633 if (entry?.uid == uid) continue;
3613 if (entry.group) {3634 const raw = collectValues(entry);
3614 entry.group.split(/,\s*/).filter(x => x).forEach(x => groups.add(x));3635 if (raw == null) continue;
3636 const arr = Array.isArray(raw) ? raw : [raw];
3637 for (const v of arr) {
3638 const s = String(v).trim();
3639 if (s) values.add(s);
3615 }3640 }
3616 }3641 }
36173642
3618 const haystack = Array.from(groups);3643 // Add optional global extras
3619 haystack.sort((a, b) => a.localeCompare(b));3644 for (const v of includeExtras()) {
3620 const needle = input.term.toLowerCase();3645 const s = String(v).trim();
3621 const hasExactMatch = haystack.findIndex(x => x.toLowerCase() == needle) !== -1;3646 if (s) values.add(s);
3622 const result = haystack.filter(x => x.toLowerCase().includes(needle) && (!thisGroups.includes(x) || hasExactMatch && thisGroups.filter(g => g == x).length == 1));3647 }
3648
3649 // Sort stable & locale-aware
3650 const haystack = Array.from(values).sort((a, b) => a.localeCompare(b));
3651
3652 // Case-insensitive contains
3653 const needle = String(input.term ?? '').toLowerCase();
3654 let result = haystack.filter(x => x.toLowerCase().includes(needle));
3655
3656 // Optional final-pass semantics
3657 if (postFilter) {
3658 result = postFilter({ result, control: $(control), input, haystack });
3659 }
36233660
3624 output(result);3661 output(result);
3625 };3662 };
3626}3663}
36273664
3628function getAutomationIdCallback(data) {3665/**
3629 return function (control, input, output) {3666 * Splits a string into an array of strings, separated by commas and trimmed
3630 const uid = $(control).data('uid');3667 * @param {string} s - The string to split
3631 const ids = new Set();3668 * @returns {string[]} An array of strings, separated by commas and trimmed
3632 for (const entry of Object.values(data.entries)) {3669 */
3633 // Skip automation id of this entry, because auto-complete should only suggest the ones that are already available on other entries3670const splitCsv = s => String(s ?? '').split(/,\s*/).filter(Boolean);
3634 if (entry.uid == uid) continue;
3635 if (entry.automationId) {
3636 ids.add(String(entry.automationId));
3637 }
3638 }
36393671
3640 if ('quickReplyApi' in globalThis) {3672/**
3641 for (const automationId of globalThis.quickReplyApi.listAutomationIds()) {3673 * Get the inclusion groups for the autocomplete.
3642 ids.add(String(automationId));3674 * @param {any} data WI data
3643 }3675 * @returns {(input: any, output: any) => any} Callback function for the autocomplete
3676 */
3677function getInclusionGroupCallback(data) {
3678 return buildAutocompleteCallback({
3679 data,
3680 collectValues: entry => entry.group ? splitCsv(entry.group) : [],
3681 postFilter: ({ result, control, input, haystack }) => {
3682 const thisGroups = splitCsv(String($(control).val()));
3683 const needle = String(input.term ?? '').toLowerCase();
3684 const hasExactMatch = haystack.some(x => x.toLowerCase() === needle);
3685
3686 // include suggestion if it contains the needle AND
3687 // (not already present OR (exact match typed && appears only once))
3688 return result.filter(x =>
3689 !thisGroups.includes(x) ||
3690 (hasExactMatch && thisGroups.filter(g => g === x).length === 1),
3691 );
3692 },
3693 });
3644}3694}
36453695
3646 const haystack = Array.from(ids);3696function getAutomationIdCallback(data) {
3647 haystack.sort((a, b) => a.localeCompare(b));3697 return buildAutocompleteCallback({
3648 const needle = input.term.toLowerCase();3698 data,
3649 const result = haystack.filter(x => x.toLowerCase().includes(needle));3699 collectValues: entry => entry.automationId != null ? [String(entry.automationId)] : [],
3700 includeExtras: () =>
3701 ('quickReplyApi' in globalThis && globalThis.quickReplyApi?.listAutomationIds)
3702 ? globalThis.quickReplyApi.listAutomationIds()
3703 : [],
3704 });
3705}
36503706
3651 output(result);3707function getOutletNameCallback(data) {
3652 };3708 return buildAutocompleteCallback({
3709 data,
3710 collectValues: entry => entry.position === world_info_position.outlet && entry.outletName ? [entry.outletName] : [],
3711 });
3653}3712}
36543713
3655/**3714/**
3656 * Create an autocomplete for the inclusion group.3715 * Create an autocomplete for an input element.
3657 * @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to3716 * @param {JQuery<HTMLElement>} input - Input element to attach the autocomplete to
3658 * @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks3717 * @param {(control: JQuery<HTMLElement>, input: any, output: any) => any} callback - Source data callbacks
3659 * @param {object} [options={}] - Optional arguments3718 * @param {object} [options={}] - Optional arguments
@@ -3770,6 +3829,7 @@ export const newWorldInfoEntryDefinition = {
3770 probability: { default: 100, type: 'number' },3829 probability: { default: 100, type: 'number' },
3771 useProbability: { default: true, type: 'boolean' },3830 useProbability: { default: true, type: 'boolean' },
3772 depth: { default: DEFAULT_DEPTH, type: 'number' },3831 depth: { default: DEFAULT_DEPTH, type: 'number' },
3832 outletName: { default: '', type: 'string' },
3773 group: { default: '', type: 'string' },3833 group: { default: '', type: 'string' },
3774 groupOverride: { default: false, type: 'boolean' },3834 groupOverride: { default: false, type: 'boolean' },
3775 groupWeight: { default: DEFAULT_WEIGHT, type: 'number' },3835 groupWeight: { default: DEFAULT_WEIGHT, type: 'number' },
@@ -4281,7 +4341,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
4281 timedEffects.checkTimedEffects();4341 timedEffects.checkTimedEffects();
42824342
4283 if (sortedEntries.length === 0) {4343 if (sortedEntries.length === 0) {
4284 return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], ANBeforeEntries: [], ANAfterEntries: [], allActivatedEntries: new Set() };4344 return { worldInfoBefore: '', worldInfoAfter: '', WIDepthEntries: [], EMEntries: [], ANBeforeEntries: [], ANAfterEntries: [], outletEntries: {}, allActivatedEntries: new Set() };
4285 }4345 }
42864346
4287 /** @type {number[]} Represents the delay levels for entries that are delayed until recursion */4347 /** @type {number[]} Represents the delay levels for entries that are delayed until recursion */
@@ -4682,6 +4742,8 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
4682 const ANTopEntries = [];4742 const ANTopEntries = [];
4683 const ANBottomEntries = [];4743 const ANBottomEntries = [];
4684 const WIDepthEntries = [];4744 const WIDepthEntries = [];
4745 /** @type {{[key: string]: string[]}} */
4746 const WIOutletEntries = {};
46854747
4686 // Appends from insertion order 999 to 1. Use unshift for this purpose4748 // Appends from insertion order 999 to 1. Use unshift for this purpose
4687 // TODO (kingbri): Change to use WI Anchor positioning instead of separate top/bottom arrays4749 // TODO (kingbri): Change to use WI Anchor positioning instead of separate top/bottom arrays
@@ -4730,6 +4792,18 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
4730 }4792 }
4731 break;4793 break;
4732 }4794 }
4795 case world_info_position.outlet: {
4796 if (!entry.outletName) {
4797 console.warn(`[WI] Entry ${entry.uid} has position 'outlet' but no outlet name. Skipping.`);
4798 break;
4799 }
4800 if (Array.isArray(WIOutletEntries[entry.outletName])) {
4801 WIOutletEntries[entry.outletName].push(content);
4802 } else {
4803 WIOutletEntries[entry.outletName] = [content];
4804 }
4805 break;
4806 }
4733 default:4807 default:
4734 break;4808 break;
4735 }4809 }
@@ -4751,7 +4825,7 @@ export async function checkWorldInfo(chat, maxContext, isDryRun, globalScanData
4751 console.log(`[WI] ${isDryRun ? 'Hypothetically adding' : 'Adding'} ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values()));4825 console.log(`[WI] ${isDryRun ? 'Hypothetically adding' : 'Adding'} ${allActivatedEntries.size} entries to prompt`, Array.from(allActivatedEntries.values()));
4752 console.debug(`[WI] --- DONE${isDryRun ? ' (DRY RUN)' : ''} ---`);4826 console.debug(`[WI] --- DONE${isDryRun ? ' (DRY RUN)' : ''} ---`);
47534827
4754 return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, ANBeforeEntries: ANTopEntries, ANAfterEntries: ANBottomEntries, allActivatedEntries: new Set(allActivatedEntries.values()) };4828 return { worldInfoBefore, worldInfoAfter, EMEntries, WIDepthEntries, ANBeforeEntries: ANTopEntries, ANAfterEntries: ANBottomEntries, outletEntries: WIOutletEntries, allActivatedEntries: new Set(allActivatedEntries.values()) };
4755}4829}
47564830
4757/**4831/**
@@ -4971,6 +5045,7 @@ function convertAgnaiMemoryBook(inputObj) {
4971 displayIndex: index,5045 displayIndex: index,
4972 probability: 100,5046 probability: 100,
4973 useProbability: true,5047 useProbability: true,
5048 outletName: '',
4974 group: '',5049 group: '',
4975 groupOverride: false,5050 groupOverride: false,
4976 groupWeight: DEFAULT_WEIGHT,5051 groupWeight: DEFAULT_WEIGHT,
@@ -5015,6 +5090,7 @@ function convertRisuLorebook(inputObj) {
5015 displayIndex: index,5090 displayIndex: index,
5016 probability: entry.activationPercent ?? 100,5091 probability: entry.activationPercent ?? 100,
5017 useProbability: entry.activationPercent ?? true,5092 useProbability: entry.activationPercent ?? true,
5093 outletName: '',
5018 group: '',5094 group: '',
5019 groupOverride: false,5095 groupOverride: false,
5020 groupWeight: DEFAULT_WEIGHT,5096 groupWeight: DEFAULT_WEIGHT,
@@ -5064,6 +5140,7 @@ function convertNovelLorebook(inputObj) {
5064 displayIndex: index,5140 displayIndex: index,
5065 probability: 100,5141 probability: 100,
5066 useProbability: true,5142 useProbability: true,
5143 outletName: '',
5067 group: '',5144 group: '',
5068 groupOverride: false,5145 groupOverride: false,
5069 groupWeight: DEFAULT_WEIGHT,5146 groupWeight: DEFAULT_WEIGHT,
@@ -5114,6 +5191,7 @@ export function convertCharacterBook(characterBook) {
5114 useProbability: entry.extensions?.useProbability ?? true,5191 useProbability: entry.extensions?.useProbability ?? true,
5115 depth: entry.extensions?.depth ?? DEFAULT_DEPTH,5192 depth: entry.extensions?.depth ?? DEFAULT_DEPTH,
5116 selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,5193 selectiveLogic: entry.extensions?.selectiveLogic ?? world_info_logic.AND_ANY,
5194 outletName: entry.extensions?.outlet_name ?? '',
5117 group: entry.extensions?.group ?? '',5195 group: entry.extensions?.group ?? '',
5118 groupOverride: entry.extensions?.group_override ?? false,5196 groupOverride: entry.extensions?.group_override ?? false,
5119 groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT,5197 groupWeight: entry.extensions?.group_weight ?? DEFAULT_WEIGHT,
src/endpoints/characters.js+1 -0
@@ -689,6 +689,7 @@ function convertWorldInfoToCharacterBook(name, entries) {
689 useProbability: entry.useProbability ?? false,689 useProbability: entry.useProbability ?? false,
690 depth: entry.depth ?? 4,690 depth: entry.depth ?? 4,
691 selectiveLogic: entry.selectiveLogic ?? 0,691 selectiveLogic: entry.selectiveLogic ?? 0,
692 outlet_name: entry.outletName ?? '',
692 group: entry.group ?? '',693 group: entry.group ?? '',
693 group_override: entry.groupOverride ?? false,694 group_override: entry.groupOverride ?? false,
694 group_weight: entry.groupWeight ?? null,695 group_weight: entry.groupWeight ?? null,