diff --git a/src/script.js b/src/script.js index e523332a..e1b4e74e 100644 --- a/src/script.js +++ b/src/script.js @@ -248,7 +248,7 @@ import { } from './scripts/utils.js'; import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, OVERSWIPE_BEHAVIOR, SCROLL_BEHAVIOR, SWIPE_DIRECTION, SWIPE_SOURCE, SWIPE_STATE } from './scripts/constants.js'; -import { activateDeferredThirdPartyExtensions, activateStartupSystemExtensions, applyExtensionSettings, cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, runGenerationInterceptors, startOfflineExtensionsDiscovery } from './scripts/extensions.js'; +import { activateDeferredThirdPartyExtensions, activateStartupSystemExtensions, applyExtensionSettings, cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, isCodeRenderDelegatedToThirdPartyRenderer, runGenerationInterceptors, startOfflineExtensionsDiscovery } from './scripts/extensions.js'; import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js'; import { initMacroAutoComplete } from './scripts/autocomplete/MacroAutoComplete.js'; import { @@ -315,6 +315,7 @@ import { renderInteractiveHtmlCodeBlocks, setHtmlCodeRenderEnabled, setHtmlCodeRenderReplaceLastMessageByDefault, + setHtmlCodeRenderSuppressedByExternalRenderer, } from './scripts/html-code-preview.js'; import { getPresetManager, initPresetManager } from './scripts/preset-manager.js'; import { evaluateMacros, getLastMessageId, initMacros } from './scripts/macros.js'; @@ -2964,8 +2965,10 @@ export function appendMediaToMessage(mes, messageElement, scrollBehavior = SCROL } export function addCopyToCodeBlocks(messageElement) { - setHtmlCodeRenderEnabled(extension_settings.code_render?.enabled === true); + const shouldRunHtmlCodeRender = extension_settings.code_render?.enabled === true; + setHtmlCodeRenderEnabled(shouldRunHtmlCodeRender); setHtmlCodeRenderReplaceLastMessageByDefault(extension_settings.code_render?.replace_last_message_by_default === true); + setHtmlCodeRenderSuppressedByExternalRenderer(shouldRunHtmlCodeRender && isCodeRenderDelegatedToThirdPartyRenderer()); renderInteractiveHtmlCodeBlocks(messageElement); const coordinator = getCodeHighlightCoordinator(); diff --git a/src/scripts/extensions.js b/src/scripts/extensions.js index 7255659b..6b6077d6 100644 --- a/src/scripts/extensions.js +++ b/src/scripts/extensions.js @@ -616,6 +616,19 @@ export function findExtension(name) { return { name: internalExtensionName, enabled: isEnabled }; } +const CODE_RENDER_DELEGATE_EXTENSION_NAMES = Object.freeze([ + 'JS-Slash-Runner', + 'LittleWhiteBox', +]); + +/** + * Returns true when a known third-party message code renderer should own HTML/script code blocks. + * @returns {boolean} + */ +export function isCodeRenderDelegatedToThirdPartyRenderer() { + return CODE_RENDER_DELEGATE_EXTENSION_NAMES.some(name => findExtension(name)?.enabled === true); +} + /** * Returns a deep clone of the manifest for the given extension name. * Accepts either the short name (e.g. `SillyTavern-MyExtension`) or the full internal key diff --git a/src/scripts/extensions/code-render/index.js b/src/scripts/extensions/code-render/index.js index 03ddbb4b..87a353f2 100644 --- a/src/scripts/extensions/code-render/index.js +++ b/src/scripts/extensions/code-render/index.js @@ -2,10 +2,15 @@ import { reloadCurrentChat, saveSettingsDebounced, } from '../../../script.js'; -import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; +import { + extension_settings, + isCodeRenderDelegatedToThirdPartyRenderer, + renderExtensionTemplateAsync, +} from '../../extensions.js'; import { setHtmlCodeRenderEnabled, setHtmlCodeRenderReplaceLastMessageByDefault, + setHtmlCodeRenderSuppressedByExternalRenderer, } from '../../html-code-preview.js'; const MODULE_NAME = 'code-render'; @@ -14,6 +19,18 @@ const defaultSettings = { replace_last_message_by_default: false, }; +function syncHtmlCodeRenderState() { + const delegated = isCodeRenderDelegatedToThirdPartyRenderer(); + + setHtmlCodeRenderEnabled(extension_settings.code_render.enabled); + setHtmlCodeRenderReplaceLastMessageByDefault(extension_settings.code_render.replace_last_message_by_default); + setHtmlCodeRenderSuppressedByExternalRenderer(delegated); + + $('#code_render_enabled').prop('checked', extension_settings.code_render.enabled); + $('#code_render_replace_last_message_by_default').prop('checked', extension_settings.code_render.replace_last_message_by_default); + $('#code_render_delegated_notice').toggle(delegated); +} + function loadSettings() { if (!extension_settings.code_render || typeof extension_settings.code_render !== 'object') { extension_settings.code_render = { ...defaultSettings }; @@ -27,17 +44,13 @@ function loadSettings() { extension_settings.code_render.replace_last_message_by_default = defaultSettings.replace_last_message_by_default; } - setHtmlCodeRenderEnabled(extension_settings.code_render.enabled); - setHtmlCodeRenderReplaceLastMessageByDefault(extension_settings.code_render.replace_last_message_by_default); - $('#code_render_enabled').prop('checked', extension_settings.code_render.enabled); - $('#code_render_replace_last_message_by_default').prop('checked', extension_settings.code_render.replace_last_message_by_default); + syncHtmlCodeRenderState(); } async function onSettingsInput() { extension_settings.code_render.enabled = !!$('#code_render_enabled').prop('checked'); extension_settings.code_render.replace_last_message_by_default = !!$('#code_render_replace_last_message_by_default').prop('checked'); - setHtmlCodeRenderEnabled(extension_settings.code_render.enabled); - setHtmlCodeRenderReplaceLastMessageByDefault(extension_settings.code_render.replace_last_message_by_default); + syncHtmlCodeRenderState(); saveSettingsDebounced(); await reloadCurrentChat(); } diff --git a/src/scripts/extensions/code-render/settings.html b/src/scripts/extensions/code-render/settings.html index 1fcef568..6699a552 100644 --- a/src/scripts/extensions/code-render/settings.html +++ b/src/scripts/extensions/code-render/settings.html @@ -21,6 +21,10 @@ data-i18n="When enabled, previews in the newest message automatically replace the current last message view."> When enabled, previews in the newest message automatically replace the current last message view. + - \ No newline at end of file + diff --git a/src/scripts/html-code-preview.js b/src/scripts/html-code-preview.js index 58ed6181..c69476fe 100644 --- a/src/scripts/html-code-preview.js +++ b/src/scripts/html-code-preview.js @@ -15,6 +15,7 @@ const HTML_ROOT_PATTERN = /<\s*html[\s>]/i; const DOCTYPE_PATTERN = / ({ + length: elements.length, + get(index) { + return elements[index]; + }, + is(selector) { + return Boolean(elements[0]?.matches?.(selector)); + }, + closest(selector) { + return createCollection(elements.map(el => el?.closest?.(selector)).filter(Boolean)); + }, + find(selector) { + const found = []; + for (const el of elements) { + if (!el?.querySelectorAll) { + continue; + } + + if (selector === 'pre > code') { + found.push(...el.querySelectorAll('code').filter(code => code.parentElement?.matches('pre'))); + continue; + } + + found.push(...el.querySelectorAll(selector)); + } + return createCollection(found); + }, + }); + + const shim = (input) => createCollection(input ? [input] : []); + Object.defineProperty(globalThis, '$', { value: shim, configurable: true }); + Object.defineProperty(globalThis, 'jQuery', { value: shim, configurable: true }); + + return () => { + if (previousDollar) { + Object.defineProperty(globalThis, '$', previousDollar); + } else { + delete globalThis.$; + } + + if (previousJquery) { + Object.defineProperty(globalThis, 'jQuery', previousJquery); + } else { + delete globalThis.jQuery; + } + }; +} + +function installButtonElementAlias() { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'HTMLButtonElement'); + Object.defineProperty(globalThis, 'HTMLButtonElement', { + value: globalThis.HTMLElement, + configurable: true, + }); + + return () => { + if (previous) { + Object.defineProperty(globalThis, 'HTMLButtonElement', previous); + } else { + delete globalThis.HTMLButtonElement; + } + }; +} + +function createMessageWithFrontendCode() { + const message = document.createElement('div'); + message.classList.add('mes'); + + const mesText = document.createElement('div'); + mesText.classList.add('mes_text'); + + const pre = document.createElement('pre'); + const code = document.createElement('code'); + code.textContent = 'preview'; + pre.append(code); + mesText.append(pre); + message.append(mesText); + document.body.append(message); + + return { message, pre }; +} + +test('html code preview suppression preserves code blocks for delegated renderers', async () => { + const dom = installFakeDom(); + const cleanupJquery = installJqueryShim(); + const cleanupButtonAlias = installButtonElementAlias(); + + try { + const { + renderInteractiveHtmlCodeBlocks, + setHtmlCodeRenderEnabled, + setHtmlCodeRenderReplaceLastMessageByDefault, + setHtmlCodeRenderSuppressedByExternalRenderer, + } = await importFresh(path.join(REPO_ROOT, 'src/scripts/html-code-preview.js')); + + const suppressed = createMessageWithFrontendCode(); + setHtmlCodeRenderEnabled(true); + setHtmlCodeRenderReplaceLastMessageByDefault(false); + setHtmlCodeRenderSuppressedByExternalRenderer(true); + renderInteractiveHtmlCodeBlocks(suppressed.message); + + assert.equal(suppressed.pre.isConnected, true); + assert.equal(suppressed.message.querySelector('.mes-code-preview'), null); + + const fallback = createMessageWithFrontendCode(); + setHtmlCodeRenderSuppressedByExternalRenderer(false); + renderInteractiveHtmlCodeBlocks(fallback.message); + + assert.equal(fallback.pre.isConnected, false); + assert.ok(fallback.message.querySelector('.mes-code-preview')); + } finally { + cleanupButtonAlias(); + cleanupJquery(); + dom.cleanup(); + } +}); + +test('message render path delegates code-render to known third-party renderers', async () => { + const extensionsSource = await readFile(path.join(REPO_ROOT, 'src/scripts/extensions.js'), 'utf8'); + assert.match(extensionsSource, /'JS-Slash-Runner'/); + assert.match(extensionsSource, /'LittleWhiteBox'/); + assert.match(extensionsSource, /export function isCodeRenderDelegatedToThirdPartyRenderer\(\)/); + assert.match(extensionsSource, /findExtension\(name\)\?\.enabled === true/); + + const scriptSource = await readFile(path.join(REPO_ROOT, 'src/script.js'), 'utf8'); + const start = scriptSource.indexOf('export function addCopyToCodeBlocks'); + assert.ok(start >= 0, 'addCopyToCodeBlocks must exist'); + const end = scriptSource.indexOf('const coordinator = getCodeHighlightCoordinator();', start); + assert.ok(end > start, 'addCopyToCodeBlocks setup section must contain code highlight coordinator'); + const section = scriptSource.slice(start, end); + + const suppressionIndex = section.indexOf('setHtmlCodeRenderSuppressedByExternalRenderer('); + const delegationIndex = section.indexOf('isCodeRenderDelegatedToThirdPartyRenderer()', suppressionIndex); + const renderIndex = section.indexOf('renderInteractiveHtmlCodeBlocks(messageElement);'); + assert.ok(suppressionIndex >= 0, 'message render path must sync code-render delegation'); + assert.ok(delegationIndex > suppressionIndex, 'delegation sync must consult known third-party renderers'); + assert.ok(renderIndex > suppressionIndex, 'delegation state must be synced before rendering previews'); +});