Implement creator's note style tag preferences (#3979) * Implement creator's note style tag preferences * Decouple external media preference from style preference * Allow explicitly empty prefixes in decodeStyleTags * Fix Copilot comments * Refactor global styles management into StylesPreference class * Refactor openAttachmentManager to return an object instead of an array * Unify header structure * Re-render characters panel on setting initial preference * Add note about classname prefixing * Rename event handler

5ac472fbac2e1f3cf755697fec4d2eb79d28956a

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

Signed
5 files changed, +263 -14Ignore whitespace
public/index.html+3 -2
@@ -5451,9 +5451,10 @@
5451 </div>5451 </div>
5452 <hr>5452 <hr>
5453 <div id="spoiler_free_desc" class="flex-container flexFlowColumn flex1 flexNoGap">5453 <div id="spoiler_free_desc" class="flex-container flexFlowColumn flex1 flexNoGap">
5454 <div id="creators_notes_div" class="title_restorable">5454 <div id="creators_notes_div" class="title_restorable flexGap5">
5455 <span data-i18n="Creator's Notes">Creator's Notes</span>5455 <span class="flex1" data-i18n="Creator's Notes">Creator's Notes</span>
5456 <small id="creators_note_desc_hidden" data-i18n="Character details are hidden.">Character details are hidden.</small>5456 <small id="creators_note_desc_hidden" data-i18n="Character details are hidden.">Character details are hidden.</small>
5457 <div id="creators_note_styles_button" class="margin0 menu_button fa-solid fa-palette fa-fw" title="Allow / Forbid the use of global styles for this character." data-i18n="[title]Allow / Forbid the use of global styles for this character."></div>
5457 <div id="spoiler_free_desc_button" class="margin0 menu_button fa-solid fa-eye fa-fw" title="Show / Hide Description and First Message" data-i18n="[title]Show / Hide Description and First Message"></div>5458 <div id="spoiler_free_desc_button" class="margin0 menu_button fa-solid fa-eye fa-fw" title="Show / Hide Description and First Message" data-i18n="[title]Show / Hide Description and First Message"></div>
5458 </div>5459 </div>
5459 <div id="creator_notes_spoiler" class="flex1"></div>5460 <div id="creator_notes_spoiler" class="flex1"></div>
public/script.js+6 -5
@@ -251,7 +251,7 @@ import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_set
251import { hideLoader, showLoader } from './scripts/loader.js';251import { hideLoader, showLoader } from './scripts/loader.js';
252import { BulkEditOverlay, CharacterContextMenu } from './scripts/BulkEditOverlay.js';252import { BulkEditOverlay, CharacterContextMenu } from './scripts/BulkEditOverlay.js';
253import { loadFeatherlessModels, loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels, initTextGenModels, loadTabbyModels, loadGenericModels } from './scripts/textgen-models.js';253import { loadFeatherlessModels, loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels, initTextGenModels, loadTabbyModels, loadGenericModels } from './scripts/textgen-models.js';
254import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat } from './scripts/chats.js';254import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities } from './scripts/chats.js';
255import { getPresetManager, initPresetManager } from './scripts/preset-manager.js';255import { getPresetManager, initPresetManager } from './scripts/preset-manager.js';
256import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js';256import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js';
257import { currentUser, setUserControls } from './scripts/user.js';257import { currentUser, setUserControls } from './scripts/user.js';
@@ -544,7 +544,7 @@ console.debug('Character context menu initialized', characterContextMenu);
544// Markdown converter544// Markdown converter
545export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against545export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against
546/** @type {import('showdown').Converter} */546/** @type {import('showdown').Converter} */
547let converter;547export let converter;
548548
549// array for prompt token calculations549// array for prompt token calculations
550console.debug('initializing Prompt Itemization Array on Startup');550console.debug('initializing Prompt Itemization Array on Startup');
@@ -978,6 +978,7 @@ async function firstLoadInit() {
978 await getClientVersion();978 await getClientVersion();
979 await readSecretState();979 await readSecretState();
980 await initLocales();980 await initLocales();
981 initChatUtilities();
981 initDefaultSlashCommands();982 initDefaultSlashCommands();
982 initTextGenModels();983 initTextGenModels();
983 initOpenAI();984 initOpenAI();
@@ -2232,7 +2233,7 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
2232 };2233 };
2233 mes = encodeStyleTags(mes);2234 mes = encodeStyleTags(mes);
2234 mes = DOMPurify.sanitize(mes, config);2235 mes = DOMPurify.sanitize(mes, config);
2235 mes = decodeStyleTags(mes);2236 mes = decodeStyleTags(mes, { prefix: '.mes_text ' });
22362237
2237 return mes;2238 return mes;
2238}2239}
@@ -8248,7 +8249,7 @@ export function select_selected_character(chid, { switchMenu = true } = {}) {
8248 $('#description_textarea').val(characters[chid].description);8249 $('#description_textarea').val(characters[chid].description);
8249 $('#character_world').val(characters[chid].data?.extensions?.world || '');8250 $('#character_world').val(characters[chid].data?.extensions?.world || '');
8250 $('#creator_notes_textarea').val(characters[chid].data?.creator_notes || characters[chid].creatorcomment);8251 $('#creator_notes_textarea').val(characters[chid].data?.creator_notes || characters[chid].creatorcomment);
8251 $('#creator_notes_spoiler').html(DOMPurify.sanitize(converter.makeHtml(substituteParams(characters[chid].data?.creator_notes) || characters[chid].creatorcomment), { MESSAGE_SANITIZE: true }));8252 $('#creator_notes_spoiler').html(formatCreatorNotes(characters[chid].data?.creator_notes || characters[chid].creatorcomment, characters[chid].avatar));
8252 $('#character_version_textarea').val(characters[chid].data?.character_version || '');8253 $('#character_version_textarea').val(characters[chid].data?.character_version || '');
8253 $('#system_prompt_textarea').val(characters[chid].data?.system_prompt || '');8254 $('#system_prompt_textarea').val(characters[chid].data?.system_prompt || '');
8254 $('#post_history_instructions_textarea').val(characters[chid].data?.post_history_instructions || '');8255 $('#post_history_instructions_textarea').val(characters[chid].data?.post_history_instructions || '');
@@ -8329,7 +8330,7 @@ function select_rm_create({ switchMenu = true } = {}) {
8329 $('#description_textarea').val(create_save.description);8330 $('#description_textarea').val(create_save.description);
8330 $('#character_world').val(create_save.world);8331 $('#character_world').val(create_save.world);
8331 $('#creator_notes_textarea').val(create_save.creator_notes);8332 $('#creator_notes_textarea').val(create_save.creator_notes);
8332 $('#creator_notes_spoiler').html(DOMPurify.sanitize(converter.makeHtml(create_save.creator_notes), { MESSAGE_SANITIZE: true }));8333 $('#creator_notes_spoiler').html(formatCreatorNotes(create_save.creator_notes, ''));
8333 $('#post_history_instructions_textarea').val(create_save.post_history_instructions);8334 $('#post_history_instructions_textarea').val(create_save.post_history_instructions);
8334 $('#system_prompt_textarea').val(create_save.system_prompt);8335 $('#system_prompt_textarea').val(create_save.system_prompt);
8335 $('#tags_textarea').val(create_save.tags);8336 $('#tags_textarea').val(create_save.tags);
public/scripts/chats.js+211 -7
@@ -1,6 +1,6 @@
1// Move chat functions here from script.js (eventually)1// Move chat functions here from script.js (eventually)
22
3import { Popper, css } from '../lib.js';3import { Popper, css, DOMPurify } from '../lib.js';
4import {4import {
5 addCopyToCodeBlocks,5 addCopyToCodeBlocks,
6 appendMediaToMessage,6 appendMediaToMessage,
@@ -23,6 +23,8 @@ import {
23 neutralCharacterName,23 neutralCharacterName,
24 updateChatMetadata,24 updateChatMetadata,
25 system_message_types,25 system_message_types,
26 converter,
27 substituteParams,
26 getSystemMessageByType,28 getSystemMessageByType,
27 printMessages,29 printMessages,
28 clearChat,30 clearChat,
@@ -475,10 +477,12 @@ export function encodeStyleTags(text) {
475/**477/**
476 * Sanitizes custom style tags in the message text to prevent DOM pollution.478 * Sanitizes custom style tags in the message text to prevent DOM pollution.
477 * @param {string} text Message text479 * @param {string} text Message text
480 * @param {object} options Options object
481 * @param {string} options.prefix Prefix the selectors with this value
478 * @returns {string} Sanitized message text482 * @returns {string} Sanitized message text
479 * @copyright https://github.com/kwaroran/risuAI483 * @copyright https://github.com/kwaroran/risuAI
480 */484 */
481export function decodeStyleTags(text) {485export function decodeStyleTags(text, { prefix } = { prefix: '.mes_text ' }) {
482 const styleDecodeRegex = /<custom-style>(.+?)<\/custom-style>/gms;486 const styleDecodeRegex = /<custom-style>(.+?)<\/custom-style>/gms;
483 const mediaAllowed = isExternalMediaAllowed();487 const mediaAllowed = isExternalMediaAllowed();
484488
@@ -494,7 +498,7 @@ export function decodeStyleTags(text) {
494 return v;498 return v;
495 }).join(' ');499 }).join(' ');
496500
497 rule.selectors[i] = '.mes_text ' + selectors;501 rule.selectors[i] = prefix + selectors;
498 }502 }
499 }503 }
500 }504 }
@@ -532,6 +536,200 @@ export function decodeStyleTags(text) {
532 });536 });
533}537}
534538
539/**
540 * Class to manage style preferences for characters.
541 */
542class StylesPreference {
543 /**
544 * Creates a new StylesPreference instance.
545 * @param {string|null} avatarId - The avatar ID of the character
546 */
547 constructor(avatarId) {
548 this.avatarId = avatarId;
549 }
550
551 /**
552 * Gets the account storage key for the style preference.
553 */
554 get key() {
555 return `AllowGlobalStyles-${this.avatarId}`;
556 }
557
558 /**
559 * Checks if a preference exists for this character.
560 * @returns {boolean} True if preference exists, false otherwise
561 */
562 exists() {
563 return this.avatarId
564 ? accountStorage.getItem(this.key) !== null
565 : true; // No character == assume preference is set
566 }
567
568 /**
569 * Gets the current style preference.
570 * @returns {boolean} True if global styles are allowed, false otherwise
571 */
572 get() {
573 return this.avatarId
574 ? accountStorage.getItem(this.key) === 'true'
575 : false; // Always disabled when creating a new character
576 }
577
578 /**
579 * Sets the global styles preference.
580 * @param {boolean} allowed - Whether global styles are allowed
581 */
582 set(allowed) {
583 if (this.avatarId) {
584 accountStorage.setItem(this.key, String(allowed));
585 }
586 }
587}
588
589/**
590 * Formats creator notes in the message text.
591 * @param {string} text Raw Markdown text
592 * @param {string} avatarId Avatar ID
593 * @returns {string} Formatted HTML text
594 */
595export function formatCreatorNotes(text, avatarId) {
596 const preference = new StylesPreference(avatarId);
597 const sanitizeStyles = !preference.get();
598 const decodeStyleParam = { prefix: sanitizeStyles ? '#creator_notes_spoiler ' : '' };
599 /** @type {import('dompurify').Config & { MESSAGE_SANITIZE: boolean }} */
600 const config = {
601 RETURN_DOM: false,
602 RETURN_DOM_FRAGMENT: false,
603 RETURN_TRUSTED_TYPE: false,
604 MESSAGE_SANITIZE: true,
605 ADD_TAGS: ['custom-style'],
606 };
607
608 let html = converter.makeHtml(substituteParams(text));
609 html = encodeStyleTags(html);
610 html = DOMPurify.sanitize(html, config);
611 html = decodeStyleTags(html, decodeStyleParam);
612
613 return html;
614}
615
616async function openGlobalStylesPreferenceDialog() {
617 if (selected_group) {
618 toastr.info(t`To change the global styles preference, please select a character individually.`);
619 return;
620 }
621
622 const entityId = getCurrentEntityId();
623 const preference = new StylesPreference(entityId);
624 const currentValue = preference.get();
625
626 const template = $(await renderTemplateAsync('globalStylesPreference'));
627
628 const allowedRadio = template.find('#global_styles_allowed');
629 const forbiddenRadio = template.find('#global_styles_forbidden');
630
631 allowedRadio.on('change', () => {
632 preference.set(true);
633 allowedRadio.prop('checked', true);
634 forbiddenRadio.prop('checked', false);
635 });
636
637 forbiddenRadio.on('change', () => {
638 preference.set(false);
639 allowedRadio.prop('checked', false);
640 forbiddenRadio.prop('checked', true);
641 });
642
643 const currentPreferenceRadio = currentValue ? allowedRadio : forbiddenRadio;
644 template.find(currentPreferenceRadio).prop('checked', true);
645
646 await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: false, large: false });
647
648 // Re-render the notes if the preference changed
649 const newValue = preference.get();
650 if (newValue !== currentValue) {
651 $('#rm_button_selected_ch').trigger('click');
652 setGlobalStylesButtonClass(newValue);
653 }
654}
655
656async function checkForCreatorNotesStyles() {
657 // Don't do anything if in group chat or not in a chat
658 if (selected_group || this_chid === undefined) {
659 return;
660 }
661
662 const notes = characters[this_chid].data?.creator_notes || characters[this_chid].creatorcomment;
663 const avatarId = characters[this_chid].avatar;
664 const styleContents = getStyleContentsFromMarkdown(notes);
665
666 if (!styleContents) {
667 setGlobalStylesButtonClass(null);
668 return;
669 }
670
671 const preference = new StylesPreference(avatarId);
672 const hasPreference = preference.exists();
673 if (!hasPreference) {
674 const template = $(await renderTemplateAsync('globalStylesPopup'));
675 template.find('textarea').val(styleContents);
676 const confirmResult = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', {
677 wide: false,
678 large: false,
679 okButton: t`Just to Creator's Notes`,
680 cancelButton: t`Apply to the entire app`,
681 });
682
683 switch (confirmResult) {
684 case POPUP_RESULT.AFFIRMATIVE:
685 preference.set(false);
686 break;
687 case POPUP_RESULT.NEGATIVE:
688 preference.set(true);
689 break;
690 case POPUP_RESULT.CANCELLED:
691 preference.set(false);
692 break;
693 }
694
695 $('#rm_button_selected_ch').trigger('click');
696 }
697
698 const currentPreference = preference.get();
699 setGlobalStylesButtonClass(currentPreference);
700}
701
702/**
703 * Sets the class of the global styles button based on the state.
704 * @param {boolean|null} state State of the button
705 */
706function setGlobalStylesButtonClass(state) {
707 const button = $('#creators_note_styles_button');
708 button.toggleClass('empty', state === null);
709 button.toggleClass('allowed', state === true);
710 button.toggleClass('forbidden', state === false);
711}
712
713/**
714 * Extracts the contents of all style elements from the Markdown text.
715 * @param {string} text Markdown text
716 * @returns {string} The joined contents of all style elements
717 */
718function getStyleContentsFromMarkdown(text) {
719 if (!text) {
720 return '';
721 }
722
723 const div = document.createElement('div');
724 const html = converter.makeHtml(substituteParams(text));
725 div.innerHTML = html;
726 const styleElements = Array.from(div.querySelectorAll('style'));
727 return styleElements
728 .filter(s => s.textContent.trim().length > 0)
729 .map(s => s.textContent.trim())
730 .join('\n\n');
731}
732
535async function openExternalMediaOverridesDialog() {733async function openExternalMediaOverridesDialog() {
536 const entityId = getCurrentEntityId();734 const entityId = getCurrentEntityId();
537735
@@ -1037,12 +1235,12 @@ async function openAttachmentManager() {
1037 popper.update();1235 popper.update();
1038 });1236 });
10391237
1040 return [popper, bodyListener];1238 return { popper, bodyListener };
1041 }).filter(Boolean);1239 }).filter(Boolean);
10421240
1043 return () => {1241 return () => {
1044 modalButtonData.forEach(p => {1242 modalButtonData.forEach(p => {
1045 const [popper, bodyListener] = p;1243 const { popper,bodyListener } = p;
1046 popper.destroy();1244 popper.destroy();
1047 document.body.removeEventListener('click', bodyListener);1245 document.body.removeEventListener('click', bodyListener);
1048 });1246 });
@@ -1466,7 +1664,7 @@ export function registerFileConverter(mimeType, converter) {
1466 converters[mimeType] = converter;1664 converters[mimeType] = converter;
1467}1665}
14681666
1469jQuery(function () {1667export function initChatUtilities() {
1470 $(document).on('click', '.mes_hide', async function () {1668 $(document).on('click', '.mes_hide', async function () {
1471 const messageBlock = $(this).closest('.mes');1669 const messageBlock = $(this).closest('.mes');
1472 const messageId = Number(messageBlock.attr('mesid'));1670 const messageId = Number(messageBlock.attr('mesid'));
@@ -1645,6 +1843,10 @@ jQuery(function () {
1645 reloadCurrentChat();1843 reloadCurrentChat();
1646 });1844 });
16471845
1846 $('#creators_note_styles_button').on('click', function () {
1847 openGlobalStylesPreferenceDialog();
1848 });
1849
1648 $(document).on('click', '.mes_img', expandMessageImage);1850 $(document).on('click', '.mes_img', expandMessageImage);
1649 $(document).on('click', '.mes_img_enlarge', expandAndZoomMessageImage);1851 $(document).on('click', '.mes_img_enlarge', expandAndZoomMessageImage);
1650 $(document).on('click', '.mes_img_delete', deleteMessageImage);1852 $(document).on('click', '.mes_img_delete', deleteMessageImage);
@@ -1679,4 +1881,6 @@ jQuery(function () {
1679 fileInput.files = dataTransfer.files;1881 fileInput.files = dataTransfer.files;
1680 await onFileAttach(fileInput.files[0]);1882 await onFileAttach(fileInput.files[0]);
1681 });1883 });
1682});1884
1885 eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles);
1886}
public/scripts/templates/globalStylesPopup.html+27 -0
@@ -0,0 +1,27 @@
1<div class="flex-container flexFlowColumn">
2 <h3 data-i18n="Creator's Notes contain CSS style tags. Do you want to apply them just to Creator's Notes or to the entire application?" class="margin0">
3 Creator's Notes contain CSS style tags. Do you want to apply them just to Creator's Notes or to the entire application?
4 </h3>
5 <h4 data-i18n="CAUTION: Malformed styles may cause issues." class="neutral_warning">
6 CAUTION: Malformed styles may cause issues.
7 </h4>
8 <hr>
9 <small>
10 <span data-i18n="To change the preference later, use the">
11 To change the preference later, use the
12 </span>
13 <code class="fa-solid fa-palette"></code>
14 <span data-i18n="button in the Creator's Notes block.">
15 button in the Creator's Notes block.
16 </span>
17 </small>
18 <textarea class="text_pole textarea_compact monospace" rows="8" readonly></textarea>
19 <small class="justifyLeft">
20 <b data-i18n="Note:">
21 Note:
22 </b>
23 <span data-i18n="Class names will be automatically prefixed with 'custom-'.">
24 Class names will be automatically prefixed with 'custom-'.
25 </span>
26 </small>
27</div>
public/scripts/templates/globalStylesPreference.html+16 -0
@@ -0,0 +1,16 @@
1<div class="flex-container flexFlowColumn">
2 <h3 data-i18n="Choose how to apply CSS style tags if they are defined in Creator's Notes of this character:" class="margin0">
3 Choose how to apply CSS style tags if they are defined in Creator's Notes of this character:
4 </h3>
5 <h4 data-i18n="CAUTION: Malformed styles may cause issues." class="neutral_warning">
6 CAUTION: Malformed styles may cause issues.
7 </h4>
8 <label class="checkbox_label" for="global_styles_forbidden">
9 <input type="radio" id="global_styles_forbidden" name="global_styles_preference" />
10 <span data-i18n="Just to Creator's Notes">Just to Creator's Notes</span>
11 </label>
12 <label class="checkbox_label" for="global_styles_allowed">
13 <input type="radio" id="global_styles_allowed" name="global_styles_preference" />
14 <span data-i18n="Apply to the entire app">Apply to the entire app</span>
15 </label>
16</div>