fix(code-render): defer to JSR and LittleWhiteBox renderers

This commit is contained in:
Darkatse 2026-06-08 18:56:24 +08:00
parent 98f81238e9
commit 4bec253704
6 changed files with 211 additions and 11 deletions

View file

@ -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();

View file

@ -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

View file

@ -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();
}

View file

@ -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.
</small>
<small id="code_render_delegated_notice" class="extensions_info" style="display: none;"
data-i18n="Code Render is disabled while JS-Slash-Runner or LittleWhiteBox is enabled.">
Code Render is disabled while JS-Slash-Runner or LittleWhiteBox is enabled.
</small>
</div>
</div>
</div>
</div>

View file

@ -15,6 +15,7 @@ const HTML_ROOT_PATTERN = /<\s*html[\s>]/i;
const DOCTYPE_PATTERN = /<!doctype\b/i;
const SCRIPT_PATTERN = /<\s*script\b/i;
let htmlCodeRenderEnabled = false;
let htmlCodeRenderSuppressedByExternalRenderer = false;
let replaceLastMessageByDefault = false;
let previewCounter = 0;
let isPreviewMessageListenerBound = false;
@ -599,6 +600,15 @@ export function setHtmlCodeRenderEnabled(enabled) {
htmlCodeRenderEnabled = !!enabled;
}
/**
* Suppresses the built-in renderer while a richer third-party renderer owns code blocks.
* @param {boolean} suppressed
* @returns {void}
*/
export function setHtmlCodeRenderSuppressedByExternalRenderer(suppressed) {
htmlCodeRenderSuppressedByExternalRenderer = !!suppressed;
}
/**
* Configures whether the newest rendered preview should replace the last message by default.
* @param {boolean} enabled
@ -614,7 +624,7 @@ export function setHtmlCodeRenderReplaceLastMessageByDefault(enabled) {
* @returns {void}
*/
export function renderInteractiveHtmlCodeBlocks(messageElement) {
if (!htmlCodeRenderEnabled) {
if (!htmlCodeRenderEnabled || htmlCodeRenderSuppressedByExternalRenderer) {
return;
}

View file

@ -0,0 +1,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { installFakeDom } from './helpers/fake-dom.mjs';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
async function importFresh(modulePath) {
const url = `${pathToFileURL(modulePath).href}?t=${Date.now()}-${Math.random()}`;
return import(url);
}
function installJqueryShim() {
const previousDollar = Object.getOwnPropertyDescriptor(globalThis, '$');
const previousJquery = Object.getOwnPropertyDescriptor(globalThis, 'jQuery');
const createCollection = (elements) => ({
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 = '<html><body>preview</body></html>';
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');
});