diff --git a/public/script.js b/public/script.js
index c91bff5d7..b8a47cf17 100644
--- a/public/script.js
+++ b/public/script.js
@@ -251,7 +251,7 @@ import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_set
import { hideLoader, showLoader } from './scripts/loader.js';
import { BulkEditOverlay, CharacterContextMenu } from './scripts/BulkEditOverlay.js';
import { loadFeatherlessModels, loadMancerModels, loadOllamaModels, loadTogetherAIModels, loadInfermaticAIModels, loadOpenRouterModels, loadVllmModels, loadAphroditeModels, loadDreamGenModels, initTextGenModels, loadTabbyModels, loadGenericModels } from './scripts/textgen-models.js';
-import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat } from './scripts/chats.js';
+import { appendFileContent, hasPendingFileAttachment, populateFileAttachment, decodeStyleTags, encodeStyleTags, isExternalMediaAllowed, getCurrentEntityId, preserveNeutralChat, restoreNeutralChat, formatCreatorNotes, initChatUtilities } from './scripts/chats.js';
import { getPresetManager, initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js';
@@ -544,7 +544,7 @@ console.debug('Character context menu initialized', characterContextMenu);
// Markdown converter
export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against
/** @type {import('showdown').Converter} */
-let converter;
+export let converter;
// array for prompt token calculations
console.debug('initializing Prompt Itemization Array on Startup');
@@ -978,6 +978,7 @@ async function firstLoadInit() {
await getClientVersion();
await readSecretState();
await initLocales();
+ initChatUtilities();
initDefaultSlashCommands();
initTextGenModels();
initOpenAI();
@@ -2232,7 +2233,7 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
};
mes = encodeStyleTags(mes);
mes = DOMPurify.sanitize(mes, config);
- mes = decodeStyleTags(mes);
+ mes = decodeStyleTags(mes, { prefix: '.mes_text ' });
return mes;
}
@@ -8248,7 +8249,7 @@ export function select_selected_character(chid, { switchMenu = true } = {}) {
$('#description_textarea').val(characters[chid].description);
$('#character_world').val(characters[chid].data?.extensions?.world || '');
$('#creator_notes_textarea').val(characters[chid].data?.creator_notes || characters[chid].creatorcomment);
- $('#creator_notes_spoiler').html(DOMPurify.sanitize(converter.makeHtml(substituteParams(characters[chid].data?.creator_notes) || characters[chid].creatorcomment), { MESSAGE_SANITIZE: true }));
+ $('#creator_notes_spoiler').html(formatCreatorNotes(characters[chid].data?.creator_notes || characters[chid].creatorcomment, characters[chid].avatar));
$('#character_version_textarea').val(characters[chid].data?.character_version || '');
$('#system_prompt_textarea').val(characters[chid].data?.system_prompt || '');
$('#post_history_instructions_textarea').val(characters[chid].data?.post_history_instructions || '');
@@ -8329,7 +8330,7 @@ function select_rm_create({ switchMenu = true } = {}) {
$('#description_textarea').val(create_save.description);
$('#character_world').val(create_save.world);
$('#creator_notes_textarea').val(create_save.creator_notes);
- $('#creator_notes_spoiler').html(DOMPurify.sanitize(converter.makeHtml(create_save.creator_notes), { MESSAGE_SANITIZE: true }));
+ $('#creator_notes_spoiler').html(formatCreatorNotes(create_save.creator_notes, ''));
$('#post_history_instructions_textarea').val(create_save.post_history_instructions);
$('#system_prompt_textarea').val(create_save.system_prompt);
$('#tags_textarea').val(create_save.tags);
diff --git a/public/scripts/chats.js b/public/scripts/chats.js
index 212c09653..0b361f92a 100644
--- a/public/scripts/chats.js
+++ b/public/scripts/chats.js
@@ -1,6 +1,6 @@
// Move chat functions here from script.js (eventually)
-import { Popper, css } from '../lib.js';
+import { Popper, css, DOMPurify } from '../lib.js';
import {
addCopyToCodeBlocks,
appendMediaToMessage,
@@ -23,6 +23,8 @@ import {
neutralCharacterName,
updateChatMetadata,
system_message_types,
+ converter,
+ substituteParams,
getSystemMessageByType,
printMessages,
clearChat,
@@ -475,10 +477,12 @@ export function encodeStyleTags(text) {
/**
* Sanitizes custom style tags in the message text to prevent DOM pollution.
* @param {string} text Message text
+ * @param {object} options Options object
+ * @param {string} options.prefix Prefix the selectors with this value
* @returns {string} Sanitized message text
* @copyright https://github.com/kwaroran/risuAI
*/
-export function decodeStyleTags(text) {
+export function decodeStyleTags(text, { prefix } = { prefix: '.mes_text ' }) {
const styleDecodeRegex = /(.+?)<\/custom-style>/gms;
const mediaAllowed = isExternalMediaAllowed();
@@ -494,7 +498,7 @@ export function decodeStyleTags(text) {
return v;
}).join(' ');
- rule.selectors[i] = '.mes_text ' + selectors;
+ rule.selectors[i] = prefix + selectors;
}
}
}
@@ -532,6 +536,200 @@ export function decodeStyleTags(text) {
});
}
+/**
+ * Class to manage style preferences for characters.
+ */
+class StylesPreference {
+ /**
+ * Creates a new StylesPreference instance.
+ * @param {string|null} avatarId - The avatar ID of the character
+ */
+ constructor(avatarId) {
+ this.avatarId = avatarId;
+ }
+
+ /**
+ * Gets the account storage key for the style preference.
+ */
+ get key() {
+ return `AllowGlobalStyles-${this.avatarId}`;
+ }
+
+ /**
+ * Checks if a preference exists for this character.
+ * @returns {boolean} True if preference exists, false otherwise
+ */
+ exists() {
+ return this.avatarId
+ ? accountStorage.getItem(this.key) !== null
+ : true; // No character == assume preference is set
+ }
+
+ /**
+ * Gets the current style preference.
+ * @returns {boolean} True if global styles are allowed, false otherwise
+ */
+ get() {
+ return this.avatarId
+ ? accountStorage.getItem(this.key) === 'true'
+ : false; // Always disabled when creating a new character
+ }
+
+ /**
+ * Sets the global styles preference.
+ * @param {boolean} allowed - Whether global styles are allowed
+ */
+ set(allowed) {
+ if (this.avatarId) {
+ accountStorage.setItem(this.key, String(allowed));
+ }
+ }
+}
+
+/**
+ * Formats creator notes in the message text.
+ * @param {string} text Raw Markdown text
+ * @param {string} avatarId Avatar ID
+ * @returns {string} Formatted HTML text
+ */
+export function formatCreatorNotes(text, avatarId) {
+ const preference = new StylesPreference(avatarId);
+ const sanitizeStyles = !preference.get();
+ const decodeStyleParam = { prefix: sanitizeStyles ? '#creator_notes_spoiler ' : '' };
+ /** @type {import('dompurify').Config & { MESSAGE_SANITIZE: boolean }} */
+ const config = {
+ RETURN_DOM: false,
+ RETURN_DOM_FRAGMENT: false,
+ RETURN_TRUSTED_TYPE: false,
+ MESSAGE_SANITIZE: true,
+ ADD_TAGS: ['custom-style'],
+ };
+
+ let html = converter.makeHtml(substituteParams(text));
+ html = encodeStyleTags(html);
+ html = DOMPurify.sanitize(html, config);
+ html = decodeStyleTags(html, decodeStyleParam);
+
+ return html;
+}
+
+async function openGlobalStylesPreferenceDialog() {
+ if (selected_group) {
+ toastr.info(t`To change the global styles preference, please select a character individually.`);
+ return;
+ }
+
+ const entityId = getCurrentEntityId();
+ const preference = new StylesPreference(entityId);
+ const currentValue = preference.get();
+
+ const template = $(await renderTemplateAsync('globalStylesPreference'));
+
+ const allowedRadio = template.find('#global_styles_allowed');
+ const forbiddenRadio = template.find('#global_styles_forbidden');
+
+ allowedRadio.on('change', () => {
+ preference.set(true);
+ allowedRadio.prop('checked', true);
+ forbiddenRadio.prop('checked', false);
+ });
+
+ forbiddenRadio.on('change', () => {
+ preference.set(false);
+ allowedRadio.prop('checked', false);
+ forbiddenRadio.prop('checked', true);
+ });
+
+ const currentPreferenceRadio = currentValue ? allowedRadio : forbiddenRadio;
+ template.find(currentPreferenceRadio).prop('checked', true);
+
+ await callGenericPopup(template, POPUP_TYPE.TEXT, '', { wide: false, large: false });
+
+ // Re-render the notes if the preference changed
+ const newValue = preference.get();
+ if (newValue !== currentValue) {
+ $('#rm_button_selected_ch').trigger('click');
+ setGlobalStylesButtonClass(newValue);
+ }
+}
+
+async function checkForCreatorNotesStyles() {
+ // Don't do anything if in group chat or not in a chat
+ if (selected_group || this_chid === undefined) {
+ return;
+ }
+
+ const notes = characters[this_chid].data?.creator_notes || characters[this_chid].creatorcomment;
+ const avatarId = characters[this_chid].avatar;
+ const styleContents = getStyleContentsFromMarkdown(notes);
+
+ if (!styleContents) {
+ setGlobalStylesButtonClass(null);
+ return;
+ }
+
+ const preference = new StylesPreference(avatarId);
+ const hasPreference = preference.exists();
+ if (!hasPreference) {
+ const template = $(await renderTemplateAsync('globalStylesPopup'));
+ template.find('textarea').val(styleContents);
+ const confirmResult = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '', {
+ wide: false,
+ large: false,
+ okButton: t`Just to Creator's Notes`,
+ cancelButton: t`Apply to the entire app`,
+ });
+
+ switch (confirmResult) {
+ case POPUP_RESULT.AFFIRMATIVE:
+ preference.set(false);
+ break;
+ case POPUP_RESULT.NEGATIVE:
+ preference.set(true);
+ break;
+ case POPUP_RESULT.CANCELLED:
+ preference.set(false);
+ break;
+ }
+
+ $('#rm_button_selected_ch').trigger('click');
+ }
+
+ const currentPreference = preference.get();
+ setGlobalStylesButtonClass(currentPreference);
+}
+
+/**
+ * Sets the class of the global styles button based on the state.
+ * @param {boolean|null} state State of the button
+ */
+function setGlobalStylesButtonClass(state) {
+ const button = $('#creators_note_styles_button');
+ button.toggleClass('empty', state === null);
+ button.toggleClass('allowed', state === true);
+ button.toggleClass('forbidden', state === false);
+}
+
+/**
+ * Extracts the contents of all style elements from the Markdown text.
+ * @param {string} text Markdown text
+ * @returns {string} The joined contents of all style elements
+ */
+function getStyleContentsFromMarkdown(text) {
+ if (!text) {
+ return '';
+ }
+
+ const div = document.createElement('div');
+ const html = converter.makeHtml(substituteParams(text));
+ div.innerHTML = html;
+ const styleElements = Array.from(div.querySelectorAll('style'));
+ return styleElements
+ .filter(s => s.textContent.trim().length > 0)
+ .map(s => s.textContent.trim())
+ .join('\n\n');
+}
+
async function openExternalMediaOverridesDialog() {
const entityId = getCurrentEntityId();
@@ -1037,12 +1235,12 @@ async function openAttachmentManager() {
popper.update();
});
- return [popper, bodyListener];
+ return { popper, bodyListener };
}).filter(Boolean);
return () => {
modalButtonData.forEach(p => {
- const [popper, bodyListener] = p;
+ const { popper,bodyListener } = p;
popper.destroy();
document.body.removeEventListener('click', bodyListener);
});
@@ -1466,7 +1664,7 @@ export function registerFileConverter(mimeType, converter) {
converters[mimeType] = converter;
}
-jQuery(function () {
+export function initChatUtilities() {
$(document).on('click', '.mes_hide', async function () {
const messageBlock = $(this).closest('.mes');
const messageId = Number(messageBlock.attr('mesid'));
@@ -1645,6 +1843,10 @@ jQuery(function () {
reloadCurrentChat();
});
+ $('#creators_note_styles_button').on('click', function () {
+ openGlobalStylesPreferenceDialog();
+ });
+
$(document).on('click', '.mes_img', expandMessageImage);
$(document).on('click', '.mes_img_enlarge', expandAndZoomMessageImage);
$(document).on('click', '.mes_img_delete', deleteMessageImage);
@@ -1679,4 +1881,6 @@ jQuery(function () {
fileInput.files = dataTransfer.files;
await onFileAttach(fileInput.files[0]);
});
-});
+
+ eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles);
+}
diff --git a/public/scripts/templates/globalStylesPopup.html b/public/scripts/templates/globalStylesPopup.html
new file mode 100644
index 000000000..06a9e4ce1
--- /dev/null
+++ b/public/scripts/templates/globalStylesPopup.html
@@ -0,0 +1,27 @@
+
+
+ Creator's Notes contain CSS style tags. Do you want to apply them just to Creator's Notes or to the entire application?
+
+
+ CAUTION: Malformed styles may cause issues.
+
+
+
+
+ To change the preference later, use the
+
+
+
+ button in the Creator's Notes block.
+
+
+
+
+
+ Note:
+
+
+ Class names will be automatically prefixed with 'custom-'.
+
+
+
diff --git a/public/scripts/templates/globalStylesPreference.html b/public/scripts/templates/globalStylesPreference.html
new file mode 100644
index 000000000..ff766a199
--- /dev/null
+++ b/public/scripts/templates/globalStylesPreference.html
@@ -0,0 +1,16 @@
+
+
+ Choose how to apply CSS style tags if they are defined in Creator's Notes of this character:
+