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
This commit is contained in:
Cohee
2025-05-22 22:32:53 +03:00
committed by GitHub
parent 62c2c88a79
commit 5ac472fbac
5 changed files with 263 additions and 14 deletions

View File

@@ -5451,9 +5451,10 @@
</div> </div>
<hr> <hr>
<div id="spoiler_free_desc" class="flex-container flexFlowColumn flex1 flexNoGap"> <div id="spoiler_free_desc" class="flex-container flexFlowColumn flex1 flexNoGap">
<div id="creators_notes_div" class="title_restorable"> <div id="creators_notes_div" class="title_restorable flexGap5">
<span data-i18n="Creator's Notes">Creator's Notes</span> <span class="flex1" data-i18n="Creator's Notes">Creator's Notes</span>
<small id="creators_note_desc_hidden" data-i18n="Character details are hidden.">Character details are hidden.</small> <small id="creators_note_desc_hidden" data-i18n="Character details are hidden.">Character details are hidden.</small>
<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>
<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> <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>
</div> </div>
<div id="creator_notes_spoiler" class="flex1"></div> <div id="creator_notes_spoiler" class="flex1"></div>

View File

@@ -251,7 +251,7 @@ import { getBackgrounds, initBackgrounds, loadBackgroundSettings, background_set
import { hideLoader, showLoader } from './scripts/loader.js'; import { hideLoader, showLoader } from './scripts/loader.js';
import { BulkEditOverlay, CharacterContextMenu } from './scripts/BulkEditOverlay.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 { 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 { getPresetManager, initPresetManager } from './scripts/preset-manager.js';
import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js'; import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js';
import { currentUser, setUserControls } from './scripts/user.js'; import { currentUser, setUserControls } from './scripts/user.js';
@@ -544,7 +544,7 @@ console.debug('Character context menu initialized', characterContextMenu);
// Markdown converter // Markdown converter
export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against export let mesForShowdownParse; //intended to be used as a context to compare showdown strings against
/** @type {import('showdown').Converter} */ /** @type {import('showdown').Converter} */
let converter; export let converter;
// array for prompt token calculations // array for prompt token calculations
console.debug('initializing Prompt Itemization Array on Startup'); console.debug('initializing Prompt Itemization Array on Startup');
@@ -978,6 +978,7 @@ async function firstLoadInit() {
await getClientVersion(); await getClientVersion();
await readSecretState(); await readSecretState();
await initLocales(); await initLocales();
initChatUtilities();
initDefaultSlashCommands(); initDefaultSlashCommands();
initTextGenModels(); initTextGenModels();
initOpenAI(); initOpenAI();
@@ -2232,7 +2233,7 @@ export function messageFormatting(mes, ch_name, isSystem, isUser, messageId, san
}; };
mes = encodeStyleTags(mes); mes = encodeStyleTags(mes);
mes = DOMPurify.sanitize(mes, config); mes = DOMPurify.sanitize(mes, config);
mes = decodeStyleTags(mes); mes = decodeStyleTags(mes, { prefix: '.mes_text ' });
return mes; return mes;
} }
@@ -8248,7 +8249,7 @@ export function select_selected_character(chid, { switchMenu = true } = {}) {
$('#description_textarea').val(characters[chid].description); $('#description_textarea').val(characters[chid].description);
$('#character_world').val(characters[chid].data?.extensions?.world || ''); $('#character_world').val(characters[chid].data?.extensions?.world || '');
$('#creator_notes_textarea').val(characters[chid].data?.creator_notes || characters[chid].creatorcomment); $('#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 || ''); $('#character_version_textarea').val(characters[chid].data?.character_version || '');
$('#system_prompt_textarea').val(characters[chid].data?.system_prompt || ''); $('#system_prompt_textarea').val(characters[chid].data?.system_prompt || '');
$('#post_history_instructions_textarea').val(characters[chid].data?.post_history_instructions || ''); $('#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); $('#description_textarea').val(create_save.description);
$('#character_world').val(create_save.world); $('#character_world').val(create_save.world);
$('#creator_notes_textarea').val(create_save.creator_notes); $('#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); $('#post_history_instructions_textarea').val(create_save.post_history_instructions);
$('#system_prompt_textarea').val(create_save.system_prompt); $('#system_prompt_textarea').val(create_save.system_prompt);
$('#tags_textarea').val(create_save.tags); $('#tags_textarea').val(create_save.tags);

View File

@@ -1,6 +1,6 @@
// Move chat functions here from script.js (eventually) // Move chat functions here from script.js (eventually)
import { Popper, css } from '../lib.js'; import { Popper, css, DOMPurify } from '../lib.js';
import { import {
addCopyToCodeBlocks, addCopyToCodeBlocks,
appendMediaToMessage, appendMediaToMessage,
@@ -23,6 +23,8 @@ import {
neutralCharacterName, neutralCharacterName,
updateChatMetadata, updateChatMetadata,
system_message_types, system_message_types,
converter,
substituteParams,
getSystemMessageByType, getSystemMessageByType,
printMessages, printMessages,
clearChat, clearChat,
@@ -475,10 +477,12 @@ export function encodeStyleTags(text) {
/** /**
* Sanitizes custom style tags in the message text to prevent DOM pollution. * Sanitizes custom style tags in the message text to prevent DOM pollution.
* @param {string} text Message text * @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 * @returns {string} Sanitized message text
* @copyright https://github.com/kwaroran/risuAI * @copyright https://github.com/kwaroran/risuAI
*/ */
export function decodeStyleTags(text) { export function decodeStyleTags(text, { prefix } = { prefix: '.mes_text ' }) {
const styleDecodeRegex = /<custom-style>(.+?)<\/custom-style>/gms; const styleDecodeRegex = /<custom-style>(.+?)<\/custom-style>/gms;
const mediaAllowed = isExternalMediaAllowed(); const mediaAllowed = isExternalMediaAllowed();
@@ -494,7 +498,7 @@ export function decodeStyleTags(text) {
return v; return v;
}).join(' '); }).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() { async function openExternalMediaOverridesDialog() {
const entityId = getCurrentEntityId(); const entityId = getCurrentEntityId();
@@ -1037,12 +1235,12 @@ async function openAttachmentManager() {
popper.update(); popper.update();
}); });
return [popper, bodyListener]; return { popper, bodyListener };
}).filter(Boolean); }).filter(Boolean);
return () => { return () => {
modalButtonData.forEach(p => { modalButtonData.forEach(p => {
const [popper, bodyListener] = p; const { popper,bodyListener } = p;
popper.destroy(); popper.destroy();
document.body.removeEventListener('click', bodyListener); document.body.removeEventListener('click', bodyListener);
}); });
@@ -1466,7 +1664,7 @@ export function registerFileConverter(mimeType, converter) {
converters[mimeType] = converter; converters[mimeType] = converter;
} }
jQuery(function () { export function initChatUtilities() {
$(document).on('click', '.mes_hide', async function () { $(document).on('click', '.mes_hide', async function () {
const messageBlock = $(this).closest('.mes'); const messageBlock = $(this).closest('.mes');
const messageId = Number(messageBlock.attr('mesid')); const messageId = Number(messageBlock.attr('mesid'));
@@ -1645,6 +1843,10 @@ jQuery(function () {
reloadCurrentChat(); reloadCurrentChat();
}); });
$('#creators_note_styles_button').on('click', function () {
openGlobalStylesPreferenceDialog();
});
$(document).on('click', '.mes_img', expandMessageImage); $(document).on('click', '.mes_img', expandMessageImage);
$(document).on('click', '.mes_img_enlarge', expandAndZoomMessageImage); $(document).on('click', '.mes_img_enlarge', expandAndZoomMessageImage);
$(document).on('click', '.mes_img_delete', deleteMessageImage); $(document).on('click', '.mes_img_delete', deleteMessageImage);
@@ -1679,4 +1881,6 @@ jQuery(function () {
fileInput.files = dataTransfer.files; fileInput.files = dataTransfer.files;
await onFileAttach(fileInput.files[0]); await onFileAttach(fileInput.files[0]);
}); });
});
eventSource.on(event_types.CHAT_CHANGED, checkForCreatorNotesStyles);
}

View File

@@ -0,0 +1,27 @@
<div class="flex-container flexFlowColumn">
<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">
Creator's Notes contain CSS style tags. Do you want to apply them just to Creator's Notes or to the entire application?
</h3>
<h4 data-i18n="CAUTION: Malformed styles may cause issues." class="neutral_warning">
CAUTION: Malformed styles may cause issues.
</h4>
<hr>
<small>
<span data-i18n="To change the preference later, use the">
To change the preference later, use the
</span>
<code class="fa-solid fa-palette"></code>
<span data-i18n="button in the Creator's Notes block.">
button in the Creator's Notes block.
</span>
</small>
<textarea class="text_pole textarea_compact monospace" rows="8" readonly></textarea>
<small class="justifyLeft">
<b data-i18n="Note:">
Note:
</b>
<span data-i18n="Class names will be automatically prefixed with 'custom-'.">
Class names will be automatically prefixed with 'custom-'.
</span>
</small>
</div>

View File

@@ -0,0 +1,16 @@
<div class="flex-container flexFlowColumn">
<h3 data-i18n="Choose how to apply CSS style tags if they are defined in Creator's Notes of this character:" class="margin0">
Choose how to apply CSS style tags if they are defined in Creator's Notes of this character:
</h3>
<h4 data-i18n="CAUTION: Malformed styles may cause issues." class="neutral_warning">
CAUTION: Malformed styles may cause issues.
</h4>
<label class="checkbox_label" for="global_styles_forbidden">
<input type="radio" id="global_styles_forbidden" name="global_styles_preference" />
<span data-i18n="Just to Creator's Notes">Just to Creator's Notes</span>
</label>
<label class="checkbox_label" for="global_styles_allowed">
<input type="radio" id="global_styles_allowed" name="global_styles_preference" />
<span data-i18n="Apply to the entire app">Apply to the entire app</span>
</label>
</div>