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:
parent
677604b538
commit
79b916f44c
@ -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':禁嵌套远程框架与 Worker;object/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();
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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))
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@ -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%;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user