fix(show_html): add js mode with sandbox iframe rendering
This commit is contained in:
parent
ac788daa03
commit
24c702c0af
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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, '&')
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user