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
Signed| @@ -5451,9 +5451,10 @@ | ||
| 5451 | 5451 | </div> |
| 5452 | 5452 | <hr> |
| 5453 | 5453 | <div id="spoiler_free_desc" class="flex-container flexFlowColumn flex1 flexNoGap"> |
| 5454 | 5454 | <div id="creators_notes_div" class="title_restorable flexGap5"> |
| 5455 | 5455 | <span class="flex1" data-i18n="Creator's Notes">Creator's Notes</span> |
| 5456 | 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 | 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 | 5459 | </div> |
| 5459 | 5460 | <div id="creator_notes_spoiler" class="flex1"></div> |
| @@ -251,7 +251,7 @@ import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_set | ||
| 251 | 251 | import { hideLoader, showLoader } from './scripts/loader.js'; |
| 252 | 252 | import { BulkEditOverlay, CharacterContextMenu } from './scripts/BulkEditOverlay.js'; |
| 253 | 253 | import { loadFeatherlessModels, loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels, initTextGenModels, loadTabbyModels, loadGenericModels } from './scripts/textgen-models.js'; |
| 254 | 254 | import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities } from './scripts/chats.js'; |
| 255 | 255 | import { getPresetManager, initPresetManager } from './scripts/preset-manager.js'; |
| 256 | 256 | import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js'; |
| 257 | 257 | import { currentUser, setUserControls } from './scripts/user.js'; |
| @@ -544,7 +544,7 @@ console.debug('Character context menu initialized', characterContextMenu); | ||
| 544 | 544 | // Markdown converter |
| 545 | 545 | export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against |
| 546 | 546 | /** @type {import('showdown').Converter} */ |
| 547 | 547 | export let converter; |
| 548 | 548 | |
| 549 | 549 | // array for prompt token calculations |
| 550 | 550 | console.debug('initializing Prompt Itemization Array on Startup'); |
| @@ -978,6 +978,7 @@ async function firstLoadInit() { | ||
| 978 | 978 | await getClientVersion(); |
| 979 | 979 | await readSecretState(); |
| 980 | 980 | await initLocales(); |
| 981 | + initChatUtilities(); | |
| 981 | 982 | initDefaultSlashCommands(); |
| 982 | 983 | initTextGenModels(); |
| 983 | 984 | initOpenAI(); |
| @@ -2232,7 +2233,7 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san | ||
| 2232 | 2233 | }; |
| 2233 | 2234 | mes = encodeStyleTags(mes); |
| 2234 | 2235 | mes = DOMPurify.sanitize(mes, config); |
| 2235 | 2236 | mes = decodeStyleTags(mes, { prefix: '.mes_text ' }); |
| 2236 | 2237 | |
| 2237 | 2238 | return mes; |
| 2238 | 2239 | } |
| @@ -8248,7 +8249,7 @@ export function select_selected_character(chid, { switchMenu = true } = {}) { | ||
| 8248 | 8249 | $('#description_textarea').val(characters[chid].description); |
| 8249 | 8250 | $('#character_world').val(characters[chid].data?.extensions?.world || ''); |
| 8250 | 8251 | $('#creator_notes_textarea').val(characters[chid].data?.creator_notes || characters[chid].creatorcomment); |
| 8251 | 8252 | $('#creator_notes_spoiler').html(DOMPurify.sanitize(converter.makeHtml(substituteParamsformatCreatorNotes(characters[chid].data?.creator_notes) || characters[chid].creatorcomment), { MESSAGE_SANITIZE: true }characters[chid].avatar)); |
| 8252 | 8253 | $('#character_version_textarea').val(characters[chid].data?.character_version || ''); |
| 8253 | 8254 | $('#system_prompt_textarea').val(characters[chid].data?.system_prompt || ''); |
| 8254 | 8255 | $('#post_history_instructions_textarea').val(characters[chid].data?.post_history_instructions || ''); |
| @@ -8329,7 +8330,7 @@ function select_rm_create({ switchMenu = true } = {}) { | ||
| 8329 | 8330 | $('#description_textarea').val(create_save.description); |
| 8330 | 8331 | $('#character_world').val(create_save.world); |
| 8331 | 8332 | $('#creator_notes_textarea').val(create_save.creator_notes); |
| 8332 | 8333 | $('#creator_notes_spoiler').html(DOMPurify.sanitize(converter.makeHtmlformatCreatorNotes(create_save.creator_notes), { MESSAGE_SANITIZE: true }'')); |
| 8333 | 8334 | $('#post_history_instructions_textarea').val(create_save.post_history_instructions); |
| 8334 | 8335 | $('#system_prompt_textarea').val(create_save.system_prompt); |
| 8335 | 8336 | $('#tags_textarea').val(create_save.tags); |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | // Move chat functions here from script.js (eventually) |
| 2 | 2 | |
| 3 | 3 | import { Popper, css, DOMPurify } from '../lib.js'; |
| 4 | 4 | import { |
| 5 | 5 | addCopyToCodeBlocks, |
| 6 | 6 | appendMediaToMessage, |
| @@ -23,6 +23,8 @@ import { | ||
| 23 | 23 | neutralCharacterName, |
| 24 | 24 | updateChatMetadata, |
| 25 | 25 | system_message_types, |
| 26 | + converter, | |
| 27 | + substituteParams, | |
| 26 | 28 | getSystemMessageByType, |
| 27 | 29 | printMessages, |
| 28 | 30 | clearChat, |
| @@ -475,10 +477,12 @@ export function encodeStyleTags(text) { | ||
| 475 | 477 | /** |
| 476 | 478 | * Sanitizes custom style tags in the message text to prevent DOM pollution. |
| 477 | 479 | * @param {string} text Message text |
| 480 | + * @param {object} options Options object | |
| 481 | + * @param {string} options.prefix Prefix the selectors with this value | |
| 478 | 482 | * @returns {string} Sanitized message text |
| 479 | 483 | * @copyright https://github.com/kwaroran/risuAI |
| 480 | 484 | */ |
| 481 | -export function decodeStyleTags(text) { | |
| 485 | +export function decodeStyleTags(text, { prefix } = { prefix: '.mes_text ' }) { | |
| 482 | 486 | const styleDecodeRegex = /<custom-style>(.+?)<\/custom-style>/gms; |
| 483 | 487 | const mediaAllowed = isExternalMediaAllowed(); |
| 484 | 488 | |
| @@ -494,7 +498,7 @@ export function decodeStyleTags(text) { | ||
| 494 | 498 | return v; |
| 495 | 499 | }).join(' '); |
| 496 | 500 | |
| 497 | 501 | rule.selectors[i] = '.mes_text 'prefix + selectors; |
| 498 | 502 | } |
| 499 | 503 | } |
| 500 | 504 | } |
| @@ -532,6 +536,200 @@ export function decodeStyleTags(text) { | ||
| 532 | 536 | }); |
| 533 | 537 | } |
| 534 | 538 | |
| 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 | + | |
| 535 | 733 | async function openExternalMediaOverridesDialog() { |
| 536 | 734 | const entityId = getCurrentEntityId(); |
| 537 | 735 | |
| @@ -1037,12 +1235,12 @@ async function openAttachmentManager() { | ||
| 1037 | 1235 | popper.update(); |
| 1038 | 1236 | }); |
| 1039 | 1237 | |
| 1040 | 1238 | return [{ popper, bodyListener] }; |
| 1041 | 1239 | }).filter(Boolean); |
| 1042 | 1240 | |
| 1043 | 1241 | return () => { |
| 1044 | 1242 | modalButtonData.forEach(p => { |
| 1045 | 1243 | const [{ popper, bodyListener] } = p; |
| 1046 | 1244 | popper.destroy(); |
| 1047 | 1245 | document.body.removeEventListener('click', bodyListener); |
| 1048 | 1246 | }); |
| @@ -1466,7 +1664,7 @@ export function registerFileConverter(mimeType, converter) { | ||
| 1466 | 1664 | converters[mimeType] = converter; |
| 1467 | 1665 | } |
| 1468 | 1666 | |
| 1469 | 1667 | jQuery(export function initChatUtilities() { |
| 1470 | 1668 | $(document).on('click', '.mes_hide', async function () { |
| 1471 | 1669 | const messageBlock = $(this).closest('.mes'); |
| 1472 | 1670 | const messageId = Number(messageBlock.attr('mesid')); |
| @@ -1645,6 +1843,10 @@ jQuery(function () { | ||
| 1645 | 1843 | reloadCurrentChat(); |
| 1646 | 1844 | }); |
| 1647 | 1845 | |
| 1846 | + $('#creators_note_styles_button').on('click', function () { | |
| 1847 | + openGlobalStylesPreferenceDialog(); | |
| 1848 | + }); | |
| 1849 | + | |
| 1648 | 1850 | $(document).on('click', '.mes_img', expandMessageImage); |
| 1649 | 1851 | $(document).on('click', '.mes_img_enlarge', expandAndZoomMessageImage); |
| 1650 | 1852 | $(document).on('click', '.mes_img_delete', deleteMessageImage); |
| @@ -1679,4 +1881,6 @@ jQuery(function () { | ||
| 1679 | 1881 | fileInput.files = dataTransfer.files; |
| 1680 | 1882 | await onFileAttach(fileInput.files[0]); |
| 1681 | 1883 | }); |
| 1682 | -}); | |
| 1884 | + | |
| 1885 | + eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles); | |
| 1886 | +} | |
| @@ -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> | |
| @@ -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> | |