fix(show_html): add js mode with sandbox iframe rendering

This commit is contained in:
JOJO 2026-04-26 17:13:08 +08:00
parent ac788daa03
commit 24c702c0af
5 changed files with 239 additions and 11 deletions

View File

@ -51,8 +51,11 @@
如需在界面直接展示图片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。
如需在界面直接渲染 HTML 预览,使用 `<show_html ratio="比例">...</show_html>`。
如需在界面直接渲染 HTML 预览,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`
- `js` 必填:
- `js="off"`:默认模式(建议优先使用),仅 HTML + CSS支持流式实时渲染
- `js="on"`:脚本模式(仅在确实需要 JS 交互/动画时使用),前端会切换到 iframe sandbox 隔离渲染;在标签未闭合前不会实时渲染,只显示“正在渲染内容...”
- 各比例对应的前端基准渲染尺寸(宽 x 高)如下,请按这些尺寸去设计元素大小与间距:
- `1:1` → `620x620`
- `16:9` → `620x349`
@ -62,12 +65,12 @@
- 说明:以上为基准像素,最终仍会按窗口与容器可用空间自适应裁剪
- **禁止**输出 `width` / `height` 属性,最终像素尺寸由前端根据窗口自适应
- 实际展示给用户的渲染后内容长宽都很小,可能只有几百 px需主动使用合适的间距调整、合适的元素大小和距离如 `padding`/`gap`/`line-height`)保证内容可读、不过度留白
- 标签内容只写 **HTML + CSS**,不要写 JavaScript
- 禁止输出 `<script>`、`onclick` 等事件脚本;前端会禁用脚本执行
- 当 `js="off"` 时:标签内容只写 **HTML + CSS**,不要写 JavaScript;禁止输出 `<script>`、`onclick` 等事件脚本
- 当 `js="on"` 时:允许输出 JavaScript含 `<script>`),但应只包含与当前展示直接相关的最小脚本逻辑
- 当用户说“给我画一个xxx”这类可视化诉求时优先使用 `<show_html>` 标签直接实现,而不是先写一个 html 文件(除非用户明确要求落盘)
简单示例(正确格式):
<show_html ratio="16:9">
<show_html ratio="16:9" js="off">
<div class="card">Hello</div>
<style>.card{{padding:16px;border:1px solid #ddd;border-radius:10px}}</style>
</show_html>

View File

@ -51,8 +51,11 @@
如需在界面直接展示图片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。
如需在界面直接渲染 HTML 预览,使用 `<show_html ratio="比例">...</show_html>`。
如需在界面直接渲染 HTML 预览,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`
- `js` 必填:
- `js="off"`:默认模式(建议优先使用),仅 HTML + CSS支持流式实时渲染
- `js="on"`:脚本模式(仅在确实需要 JS 交互/动画时使用),前端会切换到 iframe sandbox 隔离渲染;在标签未闭合前不会实时渲染,只显示“正在渲染内容...”
- 各比例对应的前端基准渲染尺寸(宽 x 高)如下,请按这些尺寸去设计元素大小与间距:
- `1:1` → `620x620`
- `16:9` → `620x349`
@ -62,12 +65,12 @@
- 说明:以上为基准像素,最终仍会按窗口与容器可用空间自适应裁剪
- **禁止**输出 `width` / `height` 属性,最终像素尺寸由前端根据窗口自适应
- 实际展示给用户的渲染后内容长宽都很小,可能只有几百 px需主动使用合适的间距调整、合适的元素大小和距离如 `padding`/`gap`/`line-height`)保证内容可读、不过度留白
- 标签内容只写 **HTML + CSS**,不要写 JavaScript
- 禁止输出 `<script>`、`onclick` 等事件脚本;前端会禁用脚本执行
- 当 `js="off"` 时:标签内容只写 **HTML + CSS**,不要写 JavaScript;禁止输出 `<script>`、`onclick` 等事件脚本
- 当 `js="on"` 时:允许输出 JavaScript含 `<script>`),但应只包含与当前展示直接相关的最小脚本逻辑
- 当用户说“给我画一个xxx”这类可视化诉求时优先使用 `<show_html>` 标签直接实现,而不是先写一个 html 文件(除非用户明确要求落盘)
简单示例(正确格式):
<show_html ratio="16:9">
<show_html ratio="16:9" js="off">
<div class="card">Hello</div>
<style>.card{{padding:16px;border:1px solid #ddd;border-radius:10px}}</style>
</show_html>

View File

@ -127,7 +127,7 @@ function collectShowImageOrder(root: ParentNode | null = document) {
if (tag === 'show_image') {
tokens.push(`SHOW_IMAGE(${el.getAttribute('src') || ''})`);
} else if (tag === 'show_html') {
tokens.push(`SHOW_HTML(ratio=${readShowHtmlRatio(el)})`);
tokens.push(`SHOW_HTML(ratio=${readShowHtmlRatio(el)},js=${readShowHtmlJsMode(el)})`);
}
}
current = walker.nextNode();
@ -209,6 +209,19 @@ function readShowHtmlRatio(node: Element) {
return '1:1';
}
function normalizeShowHtmlJsMode(raw: string | null) {
if (!raw) return 'off';
const cleaned = raw.trim().toLowerCase();
if (['on', '1', 'true', 'yes', 'enabled', 'enable'].includes(cleaned)) {
return 'on';
}
return 'off';
}
function readShowHtmlJsMode(node: Element) {
return normalizeShowHtmlJsMode(node.getAttribute('js'));
}
function computeAdaptiveShowHtmlSize(node: Element, ratioKey: string) {
const ratio = SHOW_HTML_RATIO_VALUES[ratioKey] || 1;
const base = SHOW_HTML_RATIO_BASE_SIZE[ratioKey] || SHOW_HTML_RATIO_BASE_SIZE['1:1'];
@ -316,6 +329,36 @@ function sanitizeShowHtmlContent(rawHtml: string) {
return doc.body.innerHTML;
}
function buildShowHtmlIframeSrcdoc(rawHtml: string) {
const content = (rawHtml || '').trim();
if (!content) {
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
}
// 若模型已经输出完整 HTML 文档,直接使用;否则包裹成最小可运行文档。
if (/<html[\s>]/i.test(content)) {
return content;
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100%;
background: transparent;
}
</style>
</head>
<body>${content}</body>
</html>`;
}
function escapeHtml(input: string) {
return input
.replace(/&/g, '&amp;')
@ -441,6 +484,7 @@ function renderShowImages(root: ParentNode | null = document) {
const pathKey = buildNodePathKey(node);
const isPartial = node.getAttribute('data-partial') === '1';
const encoded = node.getAttribute('data-encoded') || '';
const jsMode = readShowHtmlJsMode(node);
const computedRatioKey = readShowHtmlRatio(node);
let ratioKey = computedRatioKey;
let { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
@ -510,6 +554,7 @@ function renderShowImages(root: ParentNode | null = document) {
pathKey,
hasRendered: node.hasAttribute('data-rendered'),
ratioAttr: node.getAttribute('ratio') || '',
jsMode,
ratioKey,
partial: node.getAttribute('data-partial') || '',
encodedLength: encoded.length,
@ -518,6 +563,106 @@ function renderShowImages(root: ParentNode | null = document) {
reservedWidth: `${widthPx}px`,
reservedMinHeight: `${heightPx}px`
});
// js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁
if (jsMode === 'on' && isPartial) {
let pending = showHtmlJsPendingRenderByPath.get(pathKey);
if (!pending) {
const wrapper = document.createElement('div');
wrapper.className = 'chat-inline-html chat-inline-html--pending';
const host = document.createElement('div');
host.className = 'chat-inline-html__host chat-inline-html__host--pending';
const tip = document.createElement('div');
tip.className = 'chat-inline-html__pending-tip';
tip.textContent = '正在渲染内容...';
host.appendChild(tip);
wrapper.appendChild(host);
pending = {
wrapper,
host,
tip,
widthPx: 0,
heightPx: 0,
ratioKey: '1:1'
};
showHtmlJsPendingRenderByPath.set(pathKey, pending);
}
pending.wrapper.style.width = `${widthPx}px`;
pending.host.style.height = `${heightPx}px`;
pending.widthPx = widthPx;
pending.heightPx = heightPx;
pending.ratioKey = ratioKey;
node.replaceChildren(pending.wrapper);
node.setAttribute('data-rendered', '1');
debugShowHtmlLog('render:node-js-pending', {
renderId,
pathKey,
ratioKey,
widthPx,
heightPx
});
return;
}
// js="on":闭合后使用 sandbox iframe 隔离执行(不依赖 shadow root 直渲染)。
if (jsMode === 'on') {
const rawHtml = effectiveEncoded ? decodeBase64Utf8(effectiveEncoded) : node.innerHTML || '';
const srcdoc = buildShowHtmlIframeSrcdoc(rawHtml);
let persistent = showHtmlJsIframeRenderByPath.get(pathKey);
if (!persistent) {
const wrapper = document.createElement('div');
wrapper.className = 'chat-inline-html chat-inline-html--iframe';
const iframe = document.createElement('iframe');
iframe.className = 'chat-inline-html__iframe';
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.setAttribute('referrerpolicy', 'no-referrer');
iframe.setAttribute('loading', 'lazy');
wrapper.appendChild(iframe);
persistent = {
encoded: '',
ratioKey: '1:1',
widthPx: 0,
heightPx: 0,
srcdoc: '',
wrapper,
iframe
};
showHtmlJsIframeRenderByPath.set(pathKey, persistent);
}
persistent.wrapper.style.width = `${widthPx}px`;
persistent.iframe.style.height = `${heightPx}px`;
const needsUpdate =
persistent.encoded !== effectiveEncoded ||
persistent.widthPx !== widthPx ||
persistent.heightPx !== heightPx ||
persistent.ratioKey !== ratioKey ||
persistent.srcdoc !== srcdoc;
if (needsUpdate) {
persistent.iframe.srcdoc = srcdoc;
persistent.encoded = effectiveEncoded;
persistent.widthPx = widthPx;
persistent.heightPx = heightPx;
persistent.ratioKey = ratioKey;
persistent.srcdoc = srcdoc;
}
if (!inStreaming || isPartial || needsUpdate || !node.hasAttribute('data-rendered')) {
node.replaceChildren(persistent.wrapper);
}
node.setAttribute('data-rendered', '1');
showHtmlJsPendingRenderByPath.delete(pathKey);
debugShowHtmlLog('render:node-js-iframe', {
renderId,
pathKey,
ratioKey,
widthPx,
heightPx,
encodedLength: effectiveEncoded.length,
srcdocLength: srcdoc.length,
needsUpdate
});
return;
}
if (inStreaming) {
const now = Date.now();
const lastTs = showHtmlStreamingRenderTsByPath.get(pathKey) || 0;
@ -555,6 +700,7 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowHtmlLog('render:node-content', {
renderId,
pathKey,
jsMode,
encodedLength: encoded.length,
effectiveEncodedLength: effectiveEncoded.length,
rawLength: rawHtml.length,
@ -673,6 +819,7 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowHtmlLog('render:node-done', {
renderId,
pathKey,
jsMode,
ratioKey,
widthPx,
heightPx,
@ -741,6 +888,29 @@ const showHtmlPersistentByEncoded = new Map<string, {
wrapper: HTMLElement, host: HTMLElement, root: HTMLElement,
encoded: string, ratioKey: string, widthPx: number, heightPx: number
}>();
const showHtmlJsPendingRenderByPath = new Map<
string,
{
wrapper: HTMLElement,
host: HTMLElement,
tip: HTMLElement,
widthPx: number,
heightPx: number,
ratioKey: string
}
>();
const showHtmlJsIframeRenderByPath = new Map<
string,
{
encoded: string,
ratioKey: string,
widthPx: number,
heightPx: number,
srcdoc: string,
wrapper: HTMLElement,
iframe: HTMLIFrameElement
}
>();
let layoutDebugObserver: MutationObserver | null = null;
let layoutDebugResizeObserver: ResizeObserver | null = null;
let layoutDebugStarted = false;
@ -1041,6 +1211,8 @@ export function teardownShowImageObserver() {
showHtmlStreamingSizeLockByPath.clear();
showHtmlPersistentRenderByPath.clear();
showHtmlPersistentByEncoded.clear();
showHtmlJsPendingRenderByPath.clear();
showHtmlJsIframeRenderByPath.clear();
showTagObservedContainer = null;
}

View File

@ -89,6 +89,23 @@ function extractShowHtmlRatio(tagSource: string) {
return '1:1';
}
function normalizeShowHtmlJsMode(raw: string | null | undefined) {
if (!raw) return 'off';
const cleaned = raw.trim().toLowerCase();
if (['on', '1', 'true', 'yes', 'enabled', 'enable'].includes(cleaned)) {
return 'on';
}
return 'off';
}
function extractShowHtmlJsMode(tagSource: string) {
const jsMatch = tagSource.match(/js\s*=\s*["']?\s*([a-zA-Z0-9_-]+)/i);
if (jsMatch?.[1]) {
return normalizeShowHtmlJsMode(jsMatch[1]);
}
return 'off';
}
function isShowHtmlTagInsideMarkdownCode(raw: string, tagIndex: number) {
if (!raw || tagIndex <= 0) return false;
let i = 0;
@ -151,20 +168,24 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
}
const partialOpen = raw.slice(start);
const ratio = extractShowHtmlRatio(partialOpen);
const jsMode = extractShowHtmlJsMode(partialOpen);
const ratioAttr = ratio ? ` ratio="${ratio}"` : '';
const jsAttr = ` js="${jsMode}"`;
showHtmlDebugLog('md:transform:open-tag-partial', {
blockCount,
isStreaming,
ratio,
jsMode,
partialLength: partialOpen.length
});
output += `<show_html${ratioAttr} data-partial="1">${closeTag}`;
output += `<show_html${ratioAttr}${jsAttr} data-partial="1">${closeTag}`;
break;
}
const openTag = raw.slice(start, openEnd + 1);
const ratio = extractShowHtmlRatio(openTag);
const canonicalOpenTag = `<show_html ratio="${ratio}">`;
const jsMode = extractShowHtmlJsMode(openTag);
const canonicalOpenTag = `<show_html ratio="${ratio}" js="${jsMode}">`;
const closeStart = raw.indexOf(closeTag, openEnd + 1);
if (closeStart === -1) {
if (!isStreaming) {
@ -177,6 +198,7 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
blockCount,
isStreaming,
ratio,
jsMode,
innerPartialLength: innerPartial.length,
encodedLength: encoded.length
});
@ -194,6 +216,7 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
blockCount,
isStreaming,
ratio,
jsMode,
innerLength: inner.length,
encodedLength: encoded.length
});

View File

@ -950,6 +950,33 @@ body[data-theme='dark'] .more-icon {
background: transparent;
}
.chat-inline-html--pending {
display: flex;
align-items: center;
justify-content: center;
}
.chat-inline-html__host--pending {
display: flex;
align-items: center;
justify-content: center;
min-height: 100%;
padding: 12px 14px;
}
.chat-inline-html__pending-tip {
font-size: 13px;
color: var(--claude-text-secondary);
}
.chat-inline-html__iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
/* show_html 在 streaming 重建期间会短暂回到原始标签,需提供稳定占位避免 scrollHeight 抖动 */
show_html:not([data-rendered="1"]) {
display: block;