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 -14Showing whitespace changes
public/index.html+3 -2
@@ -5451,9 +5451,10 @@
54515451 </div>
54525452 <hr>
54535453 <div id="spoiler_free_desc" class="flex-container flexFlowColumn flex1 flexNoGap">
54545454 <div id="creators_notes_div" class="title_restorable flexGap5">
54555455 <span class="flex1" data-i18n="Creator's Notes">Creator's Notes</span>
54565456 <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>
54575458 <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>
54585459 </div>
54595460 <div id="creator_notes_spoiler" class="flex1"></div>
public/script.js+6 -5
@@ -251,7 +251,7 @@ import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_set
251251import { hideLoader, showLoader } from './scripts/loader.js';
252252import { BulkEditOverlay, CharacterContextMenu } from './scripts/BulkEditOverlay.js';
253253import { loadFeatherlessModels, loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels, initTextGenModels, loadTabbyModels, loadGenericModels } from './scripts/textgen-models.js';
254254import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities } from './scripts/chats.js';
255255import { getPresetManager, initPresetManager } from './scripts/preset-manager.js';
256256import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js';
257257import { currentUser, setUserControls } from './scripts/user.js';
@@ -544,7 +544,7 @@ console.debug('Character context menu initialized', characterContextMenu);
544544// Markdown converter
545545export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against
546546/** @type {import('showdown').Converter} */
547547export let converter;
548548
549549// array for prompt token calculations
550550console.debug('initializing Prompt Itemization Array on Startup');
@@ -978,6 +978,7 @@ async function firstLoadInit() {
978978 await getClientVersion();
979979 await readSecretState();
980980 await initLocales();
981+ initChatUtilities();
981982 initDefaultSlashCommands();
982983 initTextGenModels();
983984 initOpenAI();
@@ -2232,7 +2233,7 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
22322233 };
22332234 mes = encodeStyleTags(mes);
22342235 mes = DOMPurify.sanitize(mes, config);
22352236 mes = decodeStyleTags(mes, { prefix: '.mes_text ' });
22362237
22372238 return mes;
22382239}
@@ -8248,7 +8249,7 @@ export function select_selected_character(chid, { switchMenu = true } = {}) {
82488249 $('#description_textarea').val(characters[chid].description);
82498250 $('#character_world').val(characters[chid].data?.extensions?.world || '');
82508251 $('#creator_notes_textarea').val(characters[chid].data?.creator_notes || characters[chid].creatorcomment);
82518252 $('#creator_notes_spoiler').html(DOMPurify.sanitize(converter.makeHtml(substituteParamsformatCreatorNotes(characters[chid].data?.creator_notes) || characters[chid].creatorcomment), { MESSAGE_SANITIZE: true }characters[chid].avatar));
82528253 $('#character_version_textarea').val(characters[chid].data?.character_version || '');
82538254 $('#system_prompt_textarea').val(characters[chid].data?.system_prompt || '');
82548255 $('#post_history_instructions_textarea').val(characters[chid].data?.post_history_instructions || '');
@@ -8329,7 +8330,7 @@ function select_rm_create({ switchMenu = true } = {}) {
83298330 $('#description_textarea').val(create_save.description);
83308331 $('#character_world').val(create_save.world);
83318332 $('#creator_notes_textarea').val(create_save.creator_notes);
83328333 $('#creator_notes_spoiler').html(DOMPurify.sanitize(converter.makeHtmlformatCreatorNotes(create_save.creator_notes), { MESSAGE_SANITIZE: true }''));
83338334 $('#post_history_instructions_textarea').val(create_save.post_history_instructions);
83348335 $('#system_prompt_textarea').val(create_save.system_prompt);
83358336 $('#tags_textarea').val(create_save.tags);
public/scripts/chats.js+211 -7
@@ -1,6 +1,6 @@
11// Move chat functions here from script.js (eventually)
22
33import { Popper, css, DOMPurify } from '../lib.js';
44import {
55 addCopyToCodeBlocks,
66 appendMediaToMessage,
@@ -23,6 +23,8 @@ import {
2323 neutralCharacterName,
2424 updateChatMetadata,
2525 system_message_types,
26+ converter,
27+ substituteParams,
2628 getSystemMessageByType,
2729 printMessages,
2830 clearChat,
@@ -475,10 +477,12 @@ export function encodeStyleTags(text) {
475477/**
476478 * Sanitizes custom style tags in the message text to prevent DOM pollution.
477479 * @param {string} text Message text
480+ * @param {object} options Options object
481+ * @param {string} options.prefix Prefix the selectors with this value
478482 * @returns {string} Sanitized message text
479483 * @copyright https://github.com/kwaroran/risuAI
480484 */
481-export function decodeStyleTags(text) {
485+export function decodeStyleTags(text, { prefix } = { prefix: '.mes_text ' }) {
482486 const styleDecodeRegex = /<custom-style>(.+?)<\/custom-style>/gms;
483487 const mediaAllowed = isExternalMediaAllowed();
484488
@@ -494,7 +498,7 @@ export function decodeStyleTags(text) {
494498 return v;
495499 }).join(' ');
496500
497501 rule.selectors[i] = '.mes_text 'prefix + selectors;
498502 }
499503 }
500504 }
@@ -532,6 +536,200 @@ export function decodeStyleTags(text) {
532536 });
533537}
534538
539+/**
540+ * Class to manage style preferences for characters.
541+ */
542+class 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+ */
595+export 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+
616+async 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+
656+async 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+ */
706+function 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+ */
718+function 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+
535733async function openExternalMediaOverridesDialog() {
536734 const entityId = getCurrentEntityId();
537735
@@ -1037,12 +1235,12 @@ async function openAttachmentManager() {
10371235 popper.update();
10381236 });
10391237
10401238 return [{ popper, bodyListener] };
10411239 }).filter(Boolean);
10421240
10431241 return () => {
10441242 modalButtonData.forEach(p => {
10451243 const [{ popper, bodyListener] } = p;
10461244 popper.destroy();
10471245 document.body.removeEventListener('click', bodyListener);
10481246 });
@@ -1466,7 +1664,7 @@ export function registerFileConverter(mimeType, converter) {
14661664 converters[mimeType] = converter;
14671665}
14681666
14691667jQuery(export function initChatUtilities() {
14701668 $(document).on('click', '.mes_hide', async function () {
14711669 const messageBlock = $(this).closest('.mes');
14721670 const messageId = Number(messageBlock.attr('mesid'));
@@ -1645,6 +1843,10 @@ jQuery(function () {
16451843 reloadCurrentChat();
16461844 });
16471845
1846+ $('#creators_note_styles_button').on('click', function () {
1847+ openGlobalStylesPreferenceDialog();
1848+ });
1849+
16481850 $(document).on('click', '.mes_img', expandMessageImage);
16491851 $(document).on('click', '.mes_img_enlarge', expandAndZoomMessageImage);
16501852 $(document).on('click', '.mes_img_delete', deleteMessageImage);
@@ -1679,4 +1881,6 @@ jQuery(function () {
16791881 fileInput.files = dataTransfer.files;
16801882 await onFileAttach(fileInput.files[0]);
16811883 });
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>