Merge pull request #3146 from SillyTavern/persona-lorebook Persona lorebook

3698f407094ec4af75c277a3d6c0f7db200b7017

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

Signed
7 files changed, +184 -36Ignore whitespace
public/index.html+3 -21
@@ -4846,6 +4846,8 @@
4846 </div>4846 </div>
4847 <div id="sync_name_button" class="menu_button fa-solid fa-sync" title="Click to set user name for all messages" data-i18n="[title]Click to set user name for all messages">4847 <div id="sync_name_button" class="menu_button fa-solid fa-sync" title="Click to set user name for all messages" data-i18n="[title]Click to set user name for all messages">
4848 </div>4848 </div>
4849 <div id="persona_lore_button" class="menu_button fa-solid fa-globe" title="Persona Lore&#10;Alt+Click to open the lorebook" data-i18n="[title]Persona Lore Alt+Click to open the lorebook">
4850 </div>
4849 </div>4851 </div>
4850 <div>4852 <div>
4851 <h4 data-i18n="Persona Description">Persona Description</h4>4853 <h4 data-i18n="Persona Description">Persona Description</h4>
@@ -4962,7 +4964,7 @@
4962 <input type="hidden" id="fav_checkbox" name="fav" />4964 <input type="hidden" id="fav_checkbox" name="fav" />
4963 <div id="advanced_div" class="menu_button fa-solid fa-book " title="Advanced Definitions" data-i18n="[title]Advanced Definition"></div>4965 <div id="advanced_div" class="menu_button fa-solid fa-book " title="Advanced Definitions" data-i18n="[title]Advanced Definition"></div>
4964 <div id="world_button" class="menu_button fa-solid fa-globe" title="Character Lore&#10;&#10;Click to load&#10;Shift-click to open 'Link to World Info' popup" data-i18n="[title]world_button_title"></div>4966 <div id="world_button" class="menu_button fa-solid fa-globe" title="Character Lore&#10;&#10;Click to load&#10;Shift-click to open 'Link to World Info' popup" data-i18n="[title]world_button_title"></div>
4965 <div class="chat_lorebook_button menu_button fa-solid fa-passport" title="Chat Lore" data-i18n="[title]Chat Lore"></div>4967 <div class="chat_lorebook_button menu_button fa-solid fa-passport" title="Chat Lore&#10;Alt+Click to open the lorebook" data-i18n="[title]Chat Lore Alt+Click to open the lorebook"></div>
4966 <div id="export_button" class="menu_button fa-solid fa-file-export " title="Export and Download" data-i18n="[title]Export and Download"></div>4968 <div id="export_button" class="menu_button fa-solid fa-file-export " title="Export and Download" data-i18n="[title]Export and Download"></div>
4967 <!-- <div id="set_chat_scenario" class="menu_button fa-solid fa-scroll" title="Set a chat scenario override"></div> -->4969 <!-- <div id="set_chat_scenario" class="menu_button fa-solid fa-scroll" title="Set a chat scenario override"></div> -->
4968 <!-- <div id="set_character_world" class="menu_button fa-solid fa-globe" title="Set a character World Info / Lorebook"></div> -->4970 <!-- <div id="set_character_world" class="menu_button fa-solid fa-globe" title="Set a character World Info / Lorebook"></div> -->
@@ -5572,26 +5574,6 @@
5572 </div>5574 </div>
5573 </div>5575 </div>
5574 <!-- templates for JS to reuse when needed -->5576 <!-- templates for JS to reuse when needed -->
5575 <div id="chat_world_template" class="template_element">
5576 <div class="chat_world range-block flexFlowColumn flex-container">
5577 <div class="range-block-title">
5578 <h4 data-i18n="Chat Lorebook"><!-- This data-i18n attribute is kept for backward compatibility, use the ones below when translating -->
5579 <span data-i18n="Chat Lorebook for">Chat Lorebook for</span> <span class="chat_name"></span>
5580 </h4>
5581 </div>
5582 <div class="range-block-counter justifyLeft flex-container flexFlowColumn margin-bot-10px">
5583 <span data-i18n="chat_world_template_txt">
5584 A selected World Info will be bound to this chat. When generating an AI reply,
5585 it will be combined with the entries from global and character lorebooks.
5586 </span>
5587 </div>
5588 <div class="range-block-range wide100p">
5589 <select class="chat_world_info_selector wide100p">
5590 <option value="">--- None ---</option>
5591 </select>
5592 </div>
5593 </div>
5594 </div>
5595 <div id="character_world_template" class="template_element">5577 <div id="character_world_template" class="template_element">
5596 <div class="character_world range-block flexFlowColumn flex-container">5578 <div class="character_world range-block flexFlowColumn flex-container">
5597 <div class="range-block-title">5579 <div class="range-block-title">
public/scripts/personas.js+68 -2
@@ -21,8 +21,10 @@ import { PAGINATION_TEMPLATE, debounce, delay, download, ensureImageFormatSuppor
21import { debounce_timeout } from './constants.js';21import { debounce_timeout } from './constants.js';
22import { FILTER_TYPES, FilterHelper } from './filters.js';22import { FILTER_TYPES, FilterHelper } from './filters.js';
23import { selected_group } from './group-chats.js';23import { selected_group } from './group-chats.js';
24import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';24import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
25import { t } from './i18n.js';25import { t } from './i18n.js';
26import { openWorldInfoEditor, world_names } from './world-info.js';
27import { renderTemplateAsync } from './templates.js';
2628
27let savePersonasPage = 0;29let savePersonasPage = 0;
28const GRID_STORAGE_KEY = 'Personas_GridView';30const GRID_STORAGE_KEY = 'Personas_GridView';
@@ -375,6 +377,7 @@ export function initPersona(avatarId, personaName, personaDescription) {
375 position: persona_description_positions.IN_PROMPT,377 position: persona_description_positions.IN_PROMPT,
376 depth: DEFAULT_DEPTH,378 depth: DEFAULT_DEPTH,
377 role: DEFAULT_ROLE,379 role: DEFAULT_ROLE,
380 lorebook: '',
378 };381 };
379382
380 saveSettingsDebounced();383 saveSettingsDebounced();
@@ -418,6 +421,7 @@ export async function convertCharacterToPersona(characterId = null) {
418 position: persona_description_positions.IN_PROMPT,421 position: persona_description_positions.IN_PROMPT,
419 depth: DEFAULT_DEPTH,422 depth: DEFAULT_DEPTH,
420 role: DEFAULT_ROLE,423 role: DEFAULT_ROLE,
424 lorebook: '',
421 };425 };
422426
423 // If the user is currently using this persona, update the description427 // If the user is currently using this persona, update the description
@@ -461,6 +465,7 @@ export function setPersonaDescription() {
461 .val(power_user.persona_description_role)465 .val(power_user.persona_description_role)
462 .find(`option[value="${power_user.persona_description_role}"]`)466 .find(`option[value="${power_user.persona_description_role}"]`)
463 .prop('selected', String(true));467 .prop('selected', String(true));
468 $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);
464 countPersonaDescriptionTokens();469 countPersonaDescriptionTokens();
465}470}
466471
@@ -490,6 +495,7 @@ async function updatePersonaNameIfExists(avatarId, newName) {
490 position: persona_description_positions.IN_PROMPT,495 position: persona_description_positions.IN_PROMPT,
491 depth: DEFAULT_DEPTH,496 depth: DEFAULT_DEPTH,
492 role: DEFAULT_ROLE,497 role: DEFAULT_ROLE,
498 lorebook: '',
493 };499 };
494 console.log(`Created persona name for ${avatarId} as ${newName}`);500 console.log(`Created persona name for ${avatarId} as ${newName}`);
495 }501 }
@@ -535,6 +541,7 @@ async function bindUserNameToPersona(e) {
535 position: isCurrentPersona ? power_user.persona_description_position : persona_description_positions.IN_PROMPT,541 position: isCurrentPersona ? power_user.persona_description_position : persona_description_positions.IN_PROMPT,
536 depth: isCurrentPersona ? power_user.persona_description_depth : DEFAULT_DEPTH,542 depth: isCurrentPersona ? power_user.persona_description_depth : DEFAULT_DEPTH,
537 role: isCurrentPersona ? power_user.persona_description_role : DEFAULT_ROLE,543 role: isCurrentPersona ? power_user.persona_description_role : DEFAULT_ROLE,
544 lorebook: isCurrentPersona ? power_user.persona_description_lorebook : '',
538 };545 };
539 }546 }
540547
@@ -579,12 +586,20 @@ function selectCurrentPersona() {
579 power_user.persona_description_position = descriptor.position ?? persona_description_positions.IN_PROMPT;586 power_user.persona_description_position = descriptor.position ?? persona_description_positions.IN_PROMPT;
580 power_user.persona_description_depth = descriptor.depth ?? DEFAULT_DEPTH;587 power_user.persona_description_depth = descriptor.depth ?? DEFAULT_DEPTH;
581 power_user.persona_description_role = descriptor.role ?? DEFAULT_ROLE;588 power_user.persona_description_role = descriptor.role ?? DEFAULT_ROLE;
589 power_user.persona_description_lorebook = descriptor.lorebook ?? '';
582 } else {590 } else {
583 power_user.persona_description = '';591 power_user.persona_description = '';
584 power_user.persona_description_position = persona_description_positions.IN_PROMPT;592 power_user.persona_description_position = persona_description_positions.IN_PROMPT;
585 power_user.persona_description_depth = DEFAULT_DEPTH;593 power_user.persona_description_depth = DEFAULT_DEPTH;
586 power_user.persona_description_role = DEFAULT_ROLE;594 power_user.persona_description_role = DEFAULT_ROLE;
587 power_user.persona_descriptions[user_avatar] = { description: '', position: persona_description_positions.IN_PROMPT, depth: DEFAULT_DEPTH, role: DEFAULT_ROLE };595 power_user.persona_description_lorebook = '';
596 power_user.persona_descriptions[user_avatar] = {
597 description: '',
598 position: persona_description_positions.IN_PROMPT,
599 depth: DEFAULT_DEPTH,
600 role: DEFAULT_ROLE,
601 lorebook: '',
602 };
588 }603 }
589604
590 setPersonaDescription();605 setPersonaDescription();
@@ -652,6 +667,7 @@ async function lockPersona() {
652 position: persona_description_positions.IN_PROMPT,667 position: persona_description_positions.IN_PROMPT,
653 depth: DEFAULT_DEPTH,668 depth: DEFAULT_DEPTH,
654 role: DEFAULT_ROLE,669 role: DEFAULT_ROLE,
670 lorebook: '',
655 };671 };
656 }672 }
657673
@@ -731,6 +747,7 @@ function onPersonaDescriptionInput() {
731 position: Number($('#persona_description_position').find(':selected').val()),747 position: Number($('#persona_description_position').find(':selected').val()),
732 depth: Number($('#persona_depth_value').val()),748 depth: Number($('#persona_depth_value').val()),
733 role: Number($('#persona_depth_role').find(':selected').val()),749 role: Number($('#persona_depth_role').find(':selected').val()),
750 lorebook: '',
734 };751 };
735 power_user.persona_descriptions[user_avatar] = object;752 power_user.persona_descriptions[user_avatar] = object;
736 }753 }
@@ -766,6 +783,52 @@ function onPersonaDescriptionDepthRoleInput() {
766 saveSettingsDebounced();783 saveSettingsDebounced();
767}784}
768785
786/**
787 * Opens a popup to set the lorebook for the current persona.
788 * @param {PointerEvent} event Click event
789 */
790async function onPersonaLoreButtonClick(event) {
791 const personaName = power_user.personas[user_avatar];
792 const selectedLorebook = power_user.persona_description_lorebook;
793
794 if (!personaName) {
795 toastr.warning(t`You must bind a name to this persona before you can set a lorebook.`, t`Persona name not set`);
796 return;
797 }
798
799 if (event.altKey && selectedLorebook) {
800 openWorldInfoEditor(selectedLorebook);
801 return;
802 }
803
804 const template = $(await renderTemplateAsync('personaLorebook'));
805
806 const worldSelect = template.find('select');
807 template.find('.persona_name').text(personaName);
808
809 for (const worldName of world_names) {
810 const option = document.createElement('option');
811 option.value = worldName;
812 option.innerText = worldName;
813 option.selected = selectedLorebook === worldName;
814 worldSelect.append(option);
815 }
816
817 worldSelect.on('change', function () {
818 power_user.persona_description_lorebook = String($(this).val());
819
820 if (power_user.personas[user_avatar]) {
821 const object = getOrCreatePersonaDescriptor();
822 object.lorebook = power_user.persona_description_lorebook;
823 }
824
825 $('#persona_lore_button').toggleClass('world_set', !!power_user.persona_description_lorebook);
826 saveSettingsDebounced();
827 });
828
829 await callGenericPopup(template, POPUP_TYPE.TEXT);
830}
831
769function onPersonaDescriptionPositionInput() {832function onPersonaDescriptionPositionInput() {
770 power_user.persona_description_position = Number(833 power_user.persona_description_position = Number(
771 $('#persona_description_position').find(':selected').val(),834 $('#persona_description_position').find(':selected').val(),
@@ -789,6 +852,7 @@ function getOrCreatePersonaDescriptor() {
789 position: power_user.persona_description_position,852 position: power_user.persona_description_position,
790 depth: power_user.persona_description_depth,853 depth: power_user.persona_description_depth,
791 role: power_user.persona_description_role,854 role: power_user.persona_description_role,
855 lorebook: power_user.persona_description_lorebook,
792 };856 };
793 power_user.persona_descriptions[user_avatar] = object;857 power_user.persona_descriptions[user_avatar] = object;
794 }858 }
@@ -1038,6 +1102,7 @@ async function duplicatePersona(avatarId) {
1038 position: descriptor?.position ?? persona_description_positions.IN_PROMPT,1102 position: descriptor?.position ?? persona_description_positions.IN_PROMPT,
1039 depth: descriptor?.depth ?? DEFAULT_DEPTH,1103 depth: descriptor?.depth ?? DEFAULT_DEPTH,
1040 role: descriptor?.role ?? DEFAULT_ROLE,1104 role: descriptor?.role ?? DEFAULT_ROLE,
1105 lorebook: descriptor?.lorebook ?? '',
1041 };1106 };
10421107
1043 await uploadUserAvatar(getUserAvatar(avatarId), newAvatarId);1108 await uploadUserAvatar(getUserAvatar(avatarId), newAvatarId);
@@ -1055,6 +1120,7 @@ export function initPersonas() {
1055 $('#persona_description_position').on('input', onPersonaDescriptionPositionInput);1120 $('#persona_description_position').on('input', onPersonaDescriptionPositionInput);
1056 $('#persona_depth_value').on('input', onPersonaDescriptionDepthValueInput);1121 $('#persona_depth_value').on('input', onPersonaDescriptionDepthValueInput);
1057 $('#persona_depth_role').on('input', onPersonaDescriptionDepthRoleInput);1122 $('#persona_depth_role').on('input', onPersonaDescriptionDepthRoleInput);
1123 $('#persona_lore_button').on('click', onPersonaLoreButtonClick);
1058 $('#personas_backup').on('click', onBackupPersonas);1124 $('#personas_backup').on('click', onBackupPersonas);
1059 $('#personas_restore').on('click', () => $('#personas_restore_input').trigger('click'));1125 $('#personas_restore').on('click', () => $('#personas_restore_input').trigger('click'));
1060 $('#personas_restore_input').on('change', onPersonasRestoreInput);1126 $('#personas_restore_input').on('change', onPersonasRestoreInput);
public/scripts/power-user.js+1 -0
@@ -261,6 +261,7 @@ let power_user = {
261 persona_description_position: persona_description_positions.IN_PROMPT,261 persona_description_position: persona_description_positions.IN_PROMPT,
262 persona_description_role: 0,262 persona_description_role: 0,
263 persona_description_depth: 2,263 persona_description_depth: 2,
264 persona_description_lorebook: '',
264 persona_show_notifications: true,265 persona_show_notifications: true,
265 persona_sort_order: 'asc',266 persona_sort_order: 'asc',
266267
public/scripts/templates/chatLorebook.html+18 -0
@@ -0,0 +1,18 @@
1<div class="chat_world range-block flexFlowColumn flex-container">
2 <div class="range-block-title">
3 <h4 data-i18n="Chat Lorebook"><!-- This data-i18n attribute is kept for backward compatibility, use the ones below when translating -->
4 <span data-i18n="Chat Lorebook for">Chat Lorebook for</span> <span class="chat_name"></span>
5 </h4>
6 </div>
7 <div class="range-block-counter justifyLeft flex-container flexFlowColumn margin-bot-10px">
8 <span data-i18n="chat_world_template_txt">
9 A selected World Info will be bound to this chat. When generating an AI reply,
10 it will be combined with the entries from global and character lorebooks.
11 </span>
12 </div>
13 <div class="range-block-range wide100p">
14 <select class="chat_world_info_selector wide100p">
15 <option value="">--- None ---</option>
16 </select>
17 </div>
18</div>
public/scripts/templates/personaLorebook.html+18 -0
@@ -0,0 +1,18 @@
1<div class="persona_world range-block flexFlowColumn flex-container">
2 <div class="range-block-title">
3 <h4>
4 <span data-i18n="PErsona Lorebook for">Persona Lorebook for</span> <span class="persona_name"></span>
5 </h4>
6 </div>
7 <div class="range-block-counter justifyLeft flex-container flexFlowColumn margin-bot-10px">
8 <span data-i18n="persona_world_template_txt">
9 A selected World Info will be bound to this persona. When generating an AI reply,
10 it will be combined with the entries from global, character and chat lorebooks.
11 </span>
12 </div>
13 <div class="range-block-range wide100p">
14 <select class="persona_world_info_selector wide100p">
15 <option value="">--- None ---</option>
16 </select>
17 </div>
18</div>
public/scripts/world-info.js+72 -13
@@ -3548,6 +3548,11 @@ async function getCharacterLore() {
3548 continue;3548 continue;
3549 }3549 }
35503550
3551 if (power_user.persona_description_lorebook === worldName) {
3552 console.debug(`[WI] Character ${name}'s world ${worldName} is already activated in persona lore! Skipping...`);
3553 continue;
3554 }
3555
3551 const data = await loadWorldInfo(worldName);3556 const data = await loadWorldInfo(worldName);
3552 const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: worldName, ...rest })) : [];3557 const newEntries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: worldName, ...rest })) : [];
3553 entries = entries.concat(newEntries);3558 entries = entries.concat(newEntries);
@@ -3598,11 +3603,45 @@ async function getChatLore() {
3598 return entries;3603 return entries;
3599}3604}
36003605
3606async function getPersonaLore() {
3607 const chatWorld = chat_metadata[METADATA_KEY];
3608 const personaWorld = power_user.persona_description_lorebook;
3609
3610 if (!personaWorld) {
3611 return [];
3612 }
3613
3614 if (chatWorld === personaWorld) {
3615 console.debug(`[WI] Persona world ${personaWorld} is already activated in chat world! Skipping...`);
3616 return [];
3617 }
3618
3619 if (selected_world_info.includes(personaWorld)) {
3620 console.debug(`[WI] Persona world ${personaWorld} is already activated in global world info! Skipping...`);
3621 return [];
3622 }
3623
3624 const data = await loadWorldInfo(personaWorld);
3625 const entries = data ? Object.keys(data.entries).map((x) => data.entries[x]).map(({ uid, ...rest }) => ({ uid, world: personaWorld, ...rest })) : [];
3626
3627 console.debug(`[WI] Persona lore has ${entries.length} entries`, [personaWorld]);
3628
3629 return entries;
3630}
3631
3601export async function getSortedEntries() {3632export async function getSortedEntries() {
3602 try {3633 try {
3603 const globalLore = await getGlobalLore();3634 const [
3604 const characterLore = await getCharacterLore();3635 globalLore,
3605 const chatLore = await getChatLore();3636 characterLore,
3637 chatLore,
3638 personaLore,
3639 ] = await Promise.all([
3640 getGlobalLore(),
3641 getCharacterLore(),
3642 getChatLore(),
3643 getPersonaLore(),
3644 ]);
36063645
3607 let entries;3646 let entries;
36083647
@@ -3622,8 +3661,8 @@ export async function getSortedEntries() {
3622 break;3661 break;
3623 }3662 }
36243663
3625 // Chat lore always goes first3664 // Chat lore always goes first, then persona lore, then the rest
3626 entries = [...chatLore.sort(sortFn), ...entries];3665 entries = [...chatLore.sort(sortFn), ...personaLore.sort(sortFn), ...entries];
36273666
3628 // Calculate hash and parse decorators. Split maps to preserve old hashes.3667 // Calculate hash and parse decorators. Split maps to preserve old hashes.
3629 entries = entries.map((entry) => {3668 entries = entries.map((entry) => {
@@ -4816,9 +4855,33 @@ export async function importWorldInfo(file) {
4816 });4855 });
4817}4856}
48184857
4819export function assignLorebookToChat() {4858/**
4859 * Forces the world info editor to open on a specific world.
4860 * @param {string} worldName The name of the world to open
4861 */
4862export function openWorldInfoEditor(worldName) {
4863 console.log(`Opening lorebook for ${worldName}`);
4864 if (!$('#WorldInfo').is(':visible')) {
4865 $('#WIDrawerIcon').trigger('click');
4866 }
4867 const index = world_names.indexOf(worldName);
4868 $('#world_editor_select').val(index).trigger('change');
4869}
4870
4871/**
4872 * Assigns a lorebook to the current chat.
4873 * @param {PointerEvent} event Pointer event
4874 * @returns {Promise<void>}
4875 */
4876export async function assignLorebookToChat(event) {
4820 const selectedName = chat_metadata[METADATA_KEY];4877 const selectedName = chat_metadata[METADATA_KEY];
4821 const template = $('#chat_world_template .chat_world').clone();4878
4879 if (selectedName && event.altKey) {
4880 openWorldInfoEditor(selectedName);
4881 return;
4882 }
4883
4884 const template = $(await renderTemplateAsync('chatLorebook'));
48224885
4823 const worldSelect = template.find('select');4886 const worldSelect = template.find('select');
4824 const chatName = template.find('.chat_name');4887 const chatName = template.find('.chat_name');
@@ -4846,7 +4909,7 @@ export function assignLorebookToChat() {
4846 saveMetadata();4909 saveMetadata();
4847 });4910 });
48484911
4849 callPopup(template, 'text');4912 return callGenericPopup(template, POPUP_TYPE.TEXT);
4850}4913}
48514914
4852jQuery(() => {4915jQuery(() => {
@@ -4997,11 +5060,7 @@ jQuery(() => {
4997 const worldName = characters[chid]?.data?.extensions?.world;5060 const worldName = characters[chid]?.data?.extensions?.world;
4998 const hasEmbed = checkEmbeddedWorld(chid);5061 const hasEmbed = checkEmbeddedWorld(chid);
4999 if (worldName && world_names.includes(worldName) && !event.shiftKey) {5062 if (worldName && world_names.includes(worldName) && !event.shiftKey) {
5000 if (!$('#WorldInfo').is(':visible')) {5063 openWorldInfoEditor(worldName);
5001 $('#WIDrawerIcon').trigger('click');
5002 }
5003 const index = world_names.indexOf(worldName);
5004 $('#world_editor_select').val(index).trigger('change');
5005 } else if (hasEmbed && !event.shiftKey) {5064 } else if (hasEmbed && !event.shiftKey) {
5006 await importEmbeddedWorldInfo();5065 await importEmbeddedWorldInfo();
5007 saveCharacterDebounced();5066 saveCharacterDebounced();
public/style.css+4 -0
@@ -2788,6 +2788,10 @@ select option:not(:checked) {
2788 width: max-content;2788 width: max-content;
2789}2789}
27902790
2791#persona-management-block .menu_button {
2792 filter: grayscale(0.5);
2793}
2794
2791input[type=search]::-webkit-search-cancel-button {2795input[type=search]::-webkit-search-cancel-button {
2792 -webkit-appearance: none;2796 -webkit-appearance: none;
2793 height: 1em;2797 height: 1em;