fix(chat): 修复 show_html 流式渲染回归并加固卡片沙箱

- renderMarkdownText 透传 isStreaming:恢复 js=off 实时渲染与 js=on 渲染中占位(回归根因:MarkdownRenderer 未透传流式标志)

- js=on 沙箱加固:srcdoc 强制注入 CSP meta(connect-src 'none' 等)与滚动守卫脚本(scrollIntoView/focus 内部化),iframe 加 allow="fullscreen 'none'"

- 卡片尺寸从 JS 内联像素改为 CSS min()+aspect-ratio 自适应:流式/历史宽度一致,resize 自动跟随

- 修复卡片 wrapper 叠加 margin 导致底部圆角被 overflow:hidden 裁切
This commit is contained in:
JOJO 2026-08-05 00:10:47 +08:00
parent 677604b538
commit 79b916f44c
4 changed files with 216 additions and 142 deletions

View File

@ -168,14 +168,8 @@ const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
'4:3': 4 / 3,
'3:4': 3 / 4
};
// show_html 统一按比例对应预设基准像素渲染(前端仍会按可用空间自适应)
const SHOW_HTML_RATIO_BASE_SIZE: Record<string, { width: number; height: number }> = {
'1:1': { width: 620, height: 620 },
'16:9': { width: 620, height: 349 },
'9:16': { width: 620, height: 1102 },
'4:3': { width: 620, height: 465 },
'3:4': { width: 620, height: 827 }
};
// 卡片尺寸的基准像素与上限约束由 CSS 表达(见 _chat-area.scss 的 show_html[data-rendered] 规则),
// 此处仅保留比例数值用于写入 --show-html-ar CSS 变量。
function normalizeShowHtmlRatio(raw: string | null) {
if (!raw) return '1:1';
@ -224,34 +218,7 @@ 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'];
const windowW = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 320);
const windowH = Math.max(360, window.innerHeight || document.documentElement.clientHeight || 360);
const parentRect = node.parentElement?.getBoundingClientRect();
const flowRect =
node.closest('.messages-flow')?.getBoundingClientRect() ||
node.closest('.message-text')?.getBoundingClientRect() ||
node.closest('.text-content')?.getBoundingClientRect() ||
node.closest('.messages-area')?.getBoundingClientRect() ||
null;
const availableWidth = Math.max(
220,
Math.floor(Math.min(flowRect?.width || windowW, parentRect?.width || windowW) - 8)
);
// 移除“二次约束”后:窗口/容器足够大时,严格使用 prompt 约定的基准像素;
// 仅在物理空间不足时按比例等比缩小(不再做额外的最小/最大高度钳制)。
// 最大卡片约束:上限按 1200x900窗口不足时仍按可用空间缩放
const limitWidth = Math.max(220, Math.min(availableWidth, Math.floor(windowW * 0.96), 1200));
const limitHeight = Math.max(180, Math.min(Math.floor(windowH * 0.9), 900));
const widthScale = limitWidth / base.width;
const heightScale = limitHeight / base.height;
const scale = Math.min(1, widthScale, heightScale);
const widthPx = Math.max(220, Math.round(base.width * scale));
const heightPx = Math.round(widthPx / ratio);
return { widthPx, heightPx, ratio };
}
function decodeBase64Utf8(input: string) {
if (!input) return '';
@ -326,15 +293,171 @@ function sanitizeShowHtmlContent(rawHtml: string) {
return doc.body.innerHTML;
}
/**
* show_html js=on CSP srcdoc <meta>
* - connect-src 'none' fetch/XHR/WebSocket/EventSource
* - script/style/img/font/media https: CDN
* GET
* - frame/child/worker-src 'none' Workerobject/form/base
* iframe csp srcdoc w3c/webappsec-csp#492 meta
*/
const SHOW_HTML_IFRAME_CSP = [
"default-src 'none'",
"script-src 'unsafe-inline' https:",
"style-src 'unsafe-inline' https:",
"img-src data: blob: https:",
"font-src data: https:",
"media-src data: blob: https:",
"connect-src 'none'",
"form-action 'none'",
"base-uri 'none'",
"frame-src 'none'",
"child-src 'none'",
"worker-src 'none'",
"object-src 'none'"
].join('; ');
/**
* show_html js=on srcdoc
* iframe scrollIntoView()/focus() 穿 iframe 宿
* CSSOM View sandbox CSS w3c/csswg-drafts#7134
* API使 iframe
* frame
*
*/
const SHOW_HTML_IFRAME_GUARD_SCRIPT = `(function () {
'use strict';
if (window.__astrionSandboxGuard) return;
window.__astrionSandboxGuard = true;
function scrollingContainerList(el) {
var list = [];
var node = el.parentElement;
while (node && node !== document.body && node !== document.documentElement) {
var s = window.getComputedStyle(node);
if (/(auto|scroll|overlay)/.test(String(s.overflow) + String(s.overflowY) + String(s.overflowX))) {
list.push(node);
}
node = node.parentElement;
}
list.push(document.scrollingElement || document.documentElement);
return list;
}
function scrollOneContainer(container, el, block) {
var isRoot = container === (document.scrollingElement || document.documentElement);
var eRect = el.getBoundingClientRect();
var viewH = isRoot ? window.innerHeight : container.clientHeight;
var top = isRoot ? eRect.top : eRect.top - container.getBoundingClientRect().top;
var bottom = top + eRect.height;
var delta = 0;
if (block === 'start') delta = top;
else if (block === 'end') delta = bottom - viewH;
else if (block === 'center') delta = top - (viewH - eRect.height) / 2;
else if (top < 0) delta = top;
else if (bottom > viewH) delta = Math.min(bottom - viewH, top);
if (delta) container.scrollTop += delta;
}
function localScrollIntoView(el, arg) {
var block = 'start';
if (arg === false) block = 'end';
else if (arg && typeof arg === 'object' && typeof arg.block === 'string') block = arg.block;
var containers = scrollingContainerList(el);
for (var i = 0; i < containers.length; i++) {
scrollOneContainer(containers[i], el, block);
}
}
Element.prototype.scrollIntoView = function (arg) { localScrollIntoView(this, arg); };
if ('scrollIntoViewIfNeeded' in Element.prototype) {
Element.prototype.scrollIntoViewIfNeeded = function () { localScrollIntoView(this, { block: 'nearest' }); };
}
var origFocus = HTMLElement.prototype.focus;
HTMLElement.prototype.focus = function (options) {
var opts = options && typeof options === 'object'
? Object.assign({}, options, { preventScroll: true })
: { preventScroll: true };
origFocus.call(this, opts);
localScrollIntoView(this, { block: 'nearest' });
};
document.addEventListener('click', function (ev) {
var t = ev.target;
var anchor = t && t.closest ? t.closest('a[href^="#"]') : null;
if (!anchor) return;
var id = (anchor.getAttribute('href') || '').slice(1);
ev.preventDefault();
if (!id) return;
var target = document.getElementById(id);
if (target) {
// 先摘掉 id 再更新 hash避免浏览器默认锚点滚动穿透到宿主页面
target.removeAttribute('id');
try { window.location.hash = id; } catch (err) { /* opaque origin 下忽略 */ }
target.setAttribute('id', id);
localScrollIntoView(target, { block: 'start' });
} else {
try { window.location.hash = id; } catch (err) { /* ignore */ }
}
}, true);
function patchFrame(win) {
try {
win.Element.prototype.scrollIntoView = Element.prototype.scrollIntoView;
win.HTMLElement.prototype.focus = HTMLElement.prototype.focus;
} catch (err) { /* 跨源子 frame 跳过 */ }
}
new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var added = mutations[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (!n || n.nodeType !== 1) continue;
var frames = [];
if (n.tagName === 'IFRAME' || n.tagName === 'FRAME') frames.push(n);
if (n.querySelectorAll) {
var found = n.querySelectorAll('iframe,frame');
for (var k = 0; k < found.length; k++) frames.push(found[k]);
}
for (var m = 0; m < frames.length; m++) {
if (frames[m].contentWindow) patchFrame(frames[m].contentWindow);
}
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
})();`;
/** 生成注入内容CSP meta 必须早于任何资源引用,守卫脚本必须早于任何卡片脚本 */
function buildShowHtmlSandboxInjection() {
return `<meta http-equiv="Content-Security-Policy" content="${SHOW_HTML_IFRAME_CSP}" /><script>${SHOW_HTML_IFRAME_GUARD_SCRIPT}</script>`;
}
/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 <head> 最前(无 head 则补一个) */
function injectShowHtmlSandboxGuards(doc: string) {
const injection = buildShowHtmlSandboxInjection();
const headMatch = /<head\b[^>]*>/i.exec(doc);
if (headMatch) {
const idx = headMatch.index + headMatch[0].length;
return doc.slice(0, idx) + injection + doc.slice(idx);
}
const htmlMatch = /<html\b[^>]*>/i.exec(doc);
if (htmlMatch) {
const idx = htmlMatch.index + htmlMatch[0].length;
return doc.slice(0, idx) + `<head>${injection}</head>` + doc.slice(idx);
}
return injection + doc;
}
function buildShowHtmlIframeSrcdoc(rawHtml: string) {
const content = (rawHtml || '').trim();
if (!content) {
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
}
// 若模型已经输出完整 HTML 文档,直接使用;否则包裹成最小可运行文档。
// 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用
if (/<html[\s>]/i.test(content)) {
return content;
return injectShowHtmlSandboxGuards(content);
}
return `<!doctype html>
@ -342,6 +465,7 @@ function buildShowHtmlIframeSrcdoc(rawHtml: string) {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${buildShowHtmlSandboxInjection()}
<style>
html, body {
margin: 0;
@ -552,7 +676,6 @@ function renderShowImages(root: ParentNode | null = document) {
const jsMode = readShowHtmlJsMode(node);
const computedRatioKey = readShowHtmlRatio(node);
let ratioKey = computedRatioKey;
let { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
// 流式阶段:一旦 show_html 已经出现完整闭合(非 partial冻结该 path 的已完成快照;
// 后续即使继续输出其他普通文本,也复用该快照,避免已完成 show_html 反复刷新。
@ -571,47 +694,31 @@ function renderShowImages(root: ParentNode | null = document) {
const effectiveEncoded = frozenSnapshot?.encoded || encoded;
if (frozenSnapshot?.ratioKey) {
ratioKey = frozenSnapshot.ratioKey;
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
}
// 流式期间锁定卡片比例(尺寸由 CSS 自适应,无需锁像素):
// 流式初期 ratio 属性可能未输出完整而先落到 1:1后续允许升级到真实比例
if (inStreaming) {
const locked = showHtmlStreamingSizeLockByPath.get(pathKey);
const locked = showHtmlStreamingRatioLockByPath.get(pathKey);
if (locked) {
// 修复:流式初期若 ratio 解析不完整会先落到 1:1后续必须允许升级到真实比例
const shouldUpgradeRatio =
ratioKey !== locked.ratioKey && (locked.ratioKey === '1:1' || ratioKey !== '1:1');
ratioKey !== locked && (locked === '1:1' || ratioKey !== '1:1');
if (shouldUpgradeRatio) {
ratioKey = computedRatioKey;
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
showHtmlStreamingSizeLockByPath.set(pathKey, {
ratioKey,
widthPx,
heightPx
});
showHtmlStreamingRatioLockByPath.set(pathKey, ratioKey);
} else {
ratioKey = locked.ratioKey;
widthPx = locked.widthPx;
heightPx = locked.heightPx;
ratioKey = locked;
}
} else {
showHtmlStreamingSizeLockByPath.set(pathKey, {
ratioKey,
widthPx,
heightPx
});
showHtmlStreamingRatioLockByPath.set(pathKey, ratioKey);
}
} else {
showHtmlStreamingSizeLockByPath.delete(pathKey);
showHtmlStreamingRatioLockByPath.delete(pathKey);
}
// streaming 阶段 v-html 会反复重建 show_html 节点,这里先锁定占位尺寸,避免高度掉 0
node.style.display = 'block';
node.style.width = `${widthPx}px`;
node.style.maxWidth = '100%';
node.style.minHeight = `${heightPx}px`;
node.style.margin = '14px auto';
node.style.boxSizing = 'border-box';
node.style.overflow = 'hidden';
// 卡片尺寸由 CSS 接管width: min(...) + aspect-ratio见 _chat-area.scss
// 这里只写入比例变量;窗口/容器尺寸变化时浏览器自动重算,无需 JS 监听 resize
node.style.setProperty('--show-html-ar', String(SHOW_HTML_RATIO_VALUES[ratioKey] || 1));
debugShowHtmlLog('render:node-seen', {
renderId,
@ -624,9 +731,7 @@ function renderShowImages(root: ParentNode | null = document) {
partial: node.getAttribute('data-partial') || '',
encodedLength: encoded.length,
effectiveEncodedLength: effectiveEncoded.length,
innerLength: (node.innerHTML || '').length,
reservedWidth: `${widthPx}px`,
reservedMinHeight: `${heightPx}px`
innerLength: (node.innerHTML || '').length
});
// js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁
@ -643,29 +748,15 @@ function renderShowImages(root: ParentNode | null = document) {
tip.textContent = '正在渲染内容...';
host.appendChild(tip);
wrapper.appendChild(host);
pending = {
wrapper,
host,
tip,
widthPx: 0,
heightPx: 0,
ratioKey: '1:1'
};
pending = { wrapper, host, tip };
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
ratioKey
});
return;
}
@ -684,12 +775,12 @@ function renderShowImages(root: ParentNode | null = document) {
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.setAttribute('referrerpolicy', 'no-referrer');
iframe.setAttribute('loading', 'lazy');
// 空 allow 以外的特性显式拒绝:卡片是固定尺寸展示区,无全屏等合理需求
iframe.setAttribute('allow', "fullscreen 'none'");
wrapper.appendChild(iframe);
persistent = {
encoded: '',
ratioKey: '1:1',
widthPx: 0,
heightPx: 0,
srcdoc: '',
wrapper,
iframe
@ -709,24 +800,16 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowHtmlLog('refresh:iframe', {
pathKey,
ratioKey: persistent?.ratioKey || ratioKey,
widthPx: persistent?.widthPx || widthPx,
heightPx: persistent?.heightPx || heightPx,
srcdocLength: keep.length
});
});
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;
}
@ -739,8 +822,6 @@ function renderShowImages(root: ParentNode | null = document) {
renderId,
pathKey,
ratioKey,
widthPx,
heightPx,
encodedLength: effectiveEncoded.length,
srcdocLength: srcdoc.length,
needsUpdate
@ -754,16 +835,12 @@ function renderShowImages(root: ParentNode | null = document) {
if (now - lastTs < SHOW_HTML_STREAMING_RENDER_INTERVAL_MS) {
const persistent = showHtmlPersistentRenderByPath.get(pathKey);
if (persistent) {
persistent.wrapper.style.width = `${widthPx}px`;
persistent.host.style.height = `${heightPx}px`;
node.replaceChildren(persistent.wrapper);
node.setAttribute('data-rendered', '1');
debugShowHtmlLog('render:node-throttled-rehydrate', {
renderId,
pathKey,
ratioKey,
widthPx,
heightPx
ratioKey
});
}
debugShowHtmlLog('render:node-throttled', {
@ -790,9 +867,7 @@ function renderShowImages(root: ParentNode | null = document) {
effectiveEncodedLength: effectiveEncoded.length,
rawLength: rawHtml.length,
safeLength: safeHtml.length,
ratioKey,
widthPx,
heightPx
ratioKey
});
let persistent = showHtmlPersistentRenderByPath.get(pathKey);
@ -852,8 +927,6 @@ function renderShowImages(root: ParentNode | null = document) {
encoded: '',
safeHtml: '',
ratioKey: '1:1',
widthPx: 0,
heightPx: 0,
wrapper,
host,
root
@ -871,33 +944,23 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowHtmlLog('refresh:shadow-root', {
pathKey,
ratioKey: persistent?.ratioKey || ratioKey,
widthPx: persistent?.widthPx || widthPx,
heightPx: persistent?.heightPx || heightPx,
safeLength: keep.length
});
});
persistent.wrapper.style.width = `${widthPx}px`;
persistent.host.style.height = `${heightPx}px`;
const needsUpdate =
persistent.encoded !== effectiveEncoded ||
persistent.widthPx !== widthPx ||
persistent.heightPx !== heightPx ||
persistent.ratioKey !== ratioKey;
if (needsUpdate) {
persistent.root.innerHTML = safeHtml;
persistent.encoded = effectiveEncoded;
persistent.safeHtml = safeHtml;
persistent.widthPx = widthPx;
persistent.heightPx = heightPx;
persistent.ratioKey = ratioKey;
debugShowHtmlLog('render:node-persistent-update', {
renderId,
pathKey,
encodedLength: effectiveEncoded.length,
ratioKey,
widthPx,
heightPx
ratioKey
});
} else {
debugShowHtmlLog('render:node-persistent-reuse', {
@ -917,8 +980,6 @@ function renderShowImages(root: ParentNode | null = document) {
pathKey,
jsMode,
ratioKey,
widthPx,
heightPx,
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
@ -933,8 +994,6 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowImageLog('render:show-html-done', {
renderId,
ratioKey,
widthPx,
heightPx,
contentLength: rawHtml.length
});
}
@ -970,22 +1029,14 @@ const showHtmlCompletedSnapshotByPath = new Map<
ratioKey: string
}
>();
const showHtmlStreamingSizeLockByPath = new Map<
string,
{
ratioKey: string,
widthPx: number,
heightPx: number
}
>();
// 流式期间只锁定比例;具体尺寸由 CSS min()+aspect-ratio 自适应计算
const showHtmlStreamingRatioLockByPath = new Map<string, string>();
const showHtmlPersistentRenderByPath = new Map<
string,
{
encoded: string,
safeHtml: string,
ratioKey: string,
widthPx: number,
heightPx: number,
wrapper: HTMLElement,
host: HTMLElement,
root: HTMLElement
@ -996,10 +1047,7 @@ const showHtmlJsPendingRenderByPath = new Map<
{
wrapper: HTMLElement,
host: HTMLElement,
tip: HTMLElement,
widthPx: number,
heightPx: number,
ratioKey: string
tip: HTMLElement
}
>();
const showHtmlJsIframeRenderByPath = new Map<
@ -1007,8 +1055,6 @@ const showHtmlJsIframeRenderByPath = new Map<
{
encoded: string,
ratioKey: string,
widthPx: number,
heightPx: number,
srcdoc: string,
wrapper: HTMLElement,
iframe: HTMLIFrameElement
@ -1318,7 +1364,7 @@ export function teardownShowImageObserver() {
layoutDebugLastTsByKey.clear();
showHtmlStreamingRenderTsByPath.clear();
showHtmlCompletedSnapshotByPath.clear();
showHtmlStreamingSizeLockByPath.clear();
showHtmlStreamingRatioLockByPath.clear();
showHtmlPersistentRenderByPath.clear();
showHtmlJsPendingRenderByPath.clear();
showHtmlJsIframeRenderByPath.clear();

View File

@ -38,7 +38,8 @@ const onMathRendered = inject<() => void>('mathRenderedCallback', () => {});
const segments = computed(() => parseMarkdownSegments(props.content || '', props.isStreaming));
function renderText(text: string) {
return renderMarkdownText(text);
// show_html partial /
return renderMarkdownText(text, props.isStreaming);
}
function renderMath() {

View File

@ -715,12 +715,14 @@ export function parseMarkdownSegments(text: string, isStreaming = false): Markdo
return segments;
}
export function renderMarkdownText(text: string): string {
export function renderMarkdownText(text: string, isStreaming = false): string {
if (!text) return '';
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
// js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
const safeText = transformMathBlocks(
transformShowFileBlocks(
transformShowImageBlocks(transformShowHtmlBlocks(text, false))
transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming))
)
);

View File

@ -1736,6 +1736,15 @@ body[data-theme='dark'] .more-icon {
.chat-inline-card--html {
display: block;
position: relative;
width: 100%;
height: 100%;
/* 外层 show_html 元素已携带 margin aspect-ratio 尺寸wrapper 必须撑满父容器
若叠加基类 margin 会超出父高度 overflow:hidden 裁掉底部边框与圆角 */
margin: 0;
}
.chat-inline-html__host {
height: 100%;
}
.chat-inline-card__toolbar {
@ -1854,6 +1863,22 @@ show-html:not([data-rendered='1'])[ratio='3:4'] {
aspect-ratio: 3 / 4;
}
/* 已渲染enhance show_html 卡片尺寸完全由 CSS 计算替代原 JS 内联像素方案
宽度基准 620px同时受容器宽度与高度上限900px / 90dvh按比例折算约束
比例由 enhance 写入的 --show-html-ar 变量参与计算窗口/容器变化时浏览器自动重算 */
show_html[data-rendered='1'],
show-html[data-rendered='1'] {
display: block;
box-sizing: border-box;
overflow: hidden;
margin: 14px auto;
max-width: 100%;
--show-html-ar: 1;
width: min(620px, calc(100% - 8px), calc(min(90vh, 900px) * var(--show-html-ar)));
width: min(620px, calc(100% - 8px), calc(min(90dvh, 900px) * var(--show-html-ar)));
aspect-ratio: var(--show-html-ar);
}
.text-output .text-content {
padding: 0 var(--chat-content-x);
max-width: 100%;