Compare commits

..

2 Commits

Author SHA1 Message Date
2db77fe663 feat(chat): show_html 卡片工具栏改为 ⋯ 菜单并新增全屏预览 2026-08-05 01:03:03 +08:00
79b916f44c 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 裁切
2026-08-05 00:10:47 +08:00
4 changed files with 717 additions and 215 deletions

View File

@ -168,14 +168,8 @@ const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
'4:3': 4 / 3, '4:3': 4 / 3,
'3:4': 3 / 4 '3:4': 3 / 4
}; };
// show_html 统一按比例对应预设基准像素渲染(前端仍会按可用空间自适应) // 卡片尺寸的基准像素与上限约束由 CSS 表达(见 _chat-area.scss 的 show_html[data-rendered] 规则),
const SHOW_HTML_RATIO_BASE_SIZE: Record<string, { width: number; height: number }> = { // 此处仅保留比例数值用于写入 --show-html-ar CSS 变量。
'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 }
};
function normalizeShowHtmlRatio(raw: string | null) { function normalizeShowHtmlRatio(raw: string | null) {
if (!raw) return '1:1'; if (!raw) return '1:1';
@ -224,34 +218,7 @@ function readShowHtmlJsMode(node: Element) {
return normalizeShowHtmlJsMode(node.getAttribute('js')); 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) { function decodeBase64Utf8(input: string) {
if (!input) return ''; if (!input) return '';
@ -326,15 +293,171 @@ function sanitizeShowHtmlContent(rawHtml: string) {
return doc.body.innerHTML; 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) { function buildShowHtmlIframeSrcdoc(rawHtml: string) {
const content = (rawHtml || '').trim(); const content = (rawHtml || '').trim();
if (!content) { if (!content) {
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>'; return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
} }
// 若模型已经输出完整 HTML 文档,直接使用;否则包裹成最小可运行文档。 // 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用
if (/<html[\s>]/i.test(content)) { if (/<html[\s>]/i.test(content)) {
return content; return injectShowHtmlSandboxGuards(content);
} }
return `<!doctype html> return `<!doctype html>
@ -342,6 +465,7 @@ function buildShowHtmlIframeSrcdoc(rawHtml: string) {
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
${buildShowHtmlSandboxInjection()}
<style> <style>
html, body { html, body {
margin: 0; margin: 0;
@ -381,42 +505,309 @@ function escapeHtml(input: string) {
.replace(/'/g, '&#39;'); .replace(/'/g, '&#39;');
} }
function bindShowHtmlRefreshControl(wrapper: HTMLElement, onRefresh: () => void) { /**
* show_html
* - js="on" CSP/ srcdoc
* - js="off" sanitize HTML srcdoc
*/
interface ShowHtmlFullscreenPayload {
srcdoc: string;
allowScripts: boolean;
}
interface ShowHtmlCardControls {
onRefresh: () => void;
getFullscreenPayload: () => ShowHtmlFullscreenPayload | null;
}
const SHOW_HTML_CONTROLS_KEY = '__showHtmlCardControls';
function getShowHtmlCardControls(wrapper: HTMLElement): ShowHtmlCardControls | null {
return ((wrapper as any)[SHOW_HTML_CONTROLS_KEY] as ShowHtmlCardControls | undefined) || null;
}
function triggerShowHtmlCardRefresh(wrapper: HTMLElement, btn: HTMLElement | null) {
const controls = getShowHtmlCardControls(wrapper);
if (!controls) return;
markShowTagDrawingActive(1200);
if (btn) {
btn.classList.remove('is-refreshing');
// 触发重放动画
void btn.offsetWidth;
btn.classList.add('is-refreshing');
window.setTimeout(() => btn.classList.remove('is-refreshing'), 720);
}
try {
controls.onRefresh();
} catch (error) {
debugShowHtmlLog('refresh:error', {
message: error instanceof Error ? error.message : String(error || '')
});
}
}
// ===== 卡片 ⋯ 菜单(全局 fixed 单例,同一时间只存在一个) =====
let showHtmlCardMenuEl: HTMLElement | null = null;
let showHtmlCardMenuAnchor: HTMLElement | null = null;
let showHtmlCardMenuEventsBound = false;
function closeShowHtmlCardMenu() {
if (showHtmlCardMenuEl) {
showHtmlCardMenuEl.remove();
showHtmlCardMenuEl = null;
showHtmlCardMenuAnchor = null;
}
}
function bindShowHtmlCardMenuGlobalEvents() {
if (showHtmlCardMenuEventsBound || typeof document === 'undefined') return;
showHtmlCardMenuEventsBound = true;
document.addEventListener(
'click',
(event) => {
if (!showHtmlCardMenuEl) return;
const target = event.target as Node | null;
if (target && showHtmlCardMenuEl.contains(target)) return;
// 锚点按钮自身走 toggle 逻辑,不在全局监听里关闭
if (target && showHtmlCardMenuAnchor?.contains(target)) return;
closeShowHtmlCardMenu();
},
true
);
document.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape' && showHtmlCardMenuEl) {
event.stopPropagation();
closeShowHtmlCardMenu();
}
},
true
);
window.addEventListener('resize', closeShowHtmlCardMenu);
// 聊天区滚动时菜单位置即失效直接关闭capture 以捕获任意滚动容器)
document.addEventListener(
'scroll',
() => {
if (showHtmlCardMenuEl) closeShowHtmlCardMenu();
},
true
);
}
function buildShowHtmlCardMenuItem(label: string): HTMLButtonElement {
const item = document.createElement('button');
item.type = 'button';
item.className = 'show-html-card-menu__item';
item.textContent = label;
return item;
}
function openShowHtmlCardMenu(anchor: HTMLElement, wrapper: HTMLElement) {
bindShowHtmlCardMenuGlobalEvents();
if (showHtmlCardMenuEl && showHtmlCardMenuAnchor === anchor) {
closeShowHtmlCardMenu();
return;
}
closeShowHtmlCardMenu();
const menu = document.createElement('div');
menu.className = 'show-html-card-menu';
menu.setAttribute('role', 'menu');
const refreshItem = buildShowHtmlCardMenuItem('刷新');
refreshItem.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
closeShowHtmlCardMenu();
triggerShowHtmlCardRefresh(wrapper, anchor);
};
const fullscreenItem = buildShowHtmlCardMenuItem('全屏');
fullscreenItem.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const controls = getShowHtmlCardControls(wrapper);
closeShowHtmlCardMenu();
const payload = controls?.getFullscreenPayload?.();
if (!payload || !payload.srcdoc) return;
openShowHtmlFullscreen(payload);
};
menu.appendChild(refreshItem);
menu.appendChild(fullscreenItem);
document.body.appendChild(menu);
// 定位:按钮下方、右对齐,视口边缘留 8px 安全距离
const rect = anchor.getBoundingClientRect();
const menuWidth = menu.offsetWidth;
let left = Math.round(rect.right - menuWidth);
left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8));
menu.style.left = `${left}px`;
menu.style.top = `${Math.round(rect.bottom + 6)}px`;
showHtmlCardMenuEl = menu;
showHtmlCardMenuAnchor = anchor;
}
// ===== show_html 全屏预览(应用内 overlay复用沙箱 srcdoc不走路由 =====
interface ShowHtmlFullscreenState {
root: HTMLElement;
iframe: HTMLIFrameElement;
payload: ShowHtmlFullscreenPayload | null;
}
const SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC =
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
let showHtmlFullscreenState: ShowHtmlFullscreenState | null = null;
let showHtmlFullscreenEventsBound = false;
function isShowHtmlFullscreenOpen(): boolean {
return !!showHtmlFullscreenState?.root.classList.contains('is-open');
}
function bindShowHtmlFullscreenGlobalEvents() {
if (showHtmlFullscreenEventsBound || typeof document === 'undefined') return;
showHtmlFullscreenEventsBound = true;
// 桌面端 Esc 退出;移动端没有 Esc依赖顶栏 ✕ 按钮(触屏主路径)
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !isShowHtmlFullscreenOpen()) return;
event.stopPropagation();
closeShowHtmlFullscreen();
});
}
function ensureShowHtmlFullscreenDom(): ShowHtmlFullscreenState {
if (showHtmlFullscreenState) return showHtmlFullscreenState;
bindShowHtmlFullscreenGlobalEvents();
const root = document.createElement('div');
root.className = 'show-html-fullscreen';
root.setAttribute('role', 'dialog');
root.setAttribute('aria-modal', 'true');
root.setAttribute('aria-label', '卡片全屏预览');
const bar = document.createElement('div');
bar.className = 'show-html-fullscreen__bar';
const title = document.createElement('span');
title.className = 'show-html-fullscreen__title';
title.textContent = '全屏预览';
const actions = document.createElement('div');
actions.className = 'show-html-fullscreen__actions';
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'show-html-fullscreen__btn';
refreshBtn.title = '刷新';
refreshBtn.setAttribute('aria-label', '刷新');
refreshBtn.textContent = '↻';
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'show-html-fullscreen__btn show-html-fullscreen__btn--close';
closeBtn.title = '关闭Esc';
closeBtn.setAttribute('aria-label', '关闭全屏预览');
closeBtn.textContent = '✕';
actions.appendChild(refreshBtn);
actions.appendChild(closeBtn);
bar.appendChild(title);
bar.appendChild(actions);
const body = document.createElement('div');
body.className = 'show-html-fullscreen__body';
const iframe = document.createElement('iframe');
iframe.className = 'show-html-fullscreen__iframe';
iframe.setAttribute('referrerpolicy', 'no-referrer');
// 与内联卡片一致overlay 用 CSS fixed 占满视口,不需要浏览器 Fullscreen API
iframe.setAttribute('allow', "fullscreen 'none'");
body.appendChild(iframe);
root.appendChild(bar);
root.appendChild(body);
refreshBtn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
const state = showHtmlFullscreenState;
if (!state?.payload) return;
// 与内联卡片刷新同策略:先挂空文档再重挂,强制 iframe 内页面重载
const keep = state.payload.srcdoc;
state.iframe.srcdoc = SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC;
requestAnimationFrame(() => {
if (showHtmlFullscreenState?.payload) {
showHtmlFullscreenState.iframe.srcdoc = keep;
}
});
debugShowHtmlLog('fullscreen:refresh', { srcdocLength: keep.length });
};
closeBtn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
closeShowHtmlFullscreen();
};
document.body.appendChild(root);
showHtmlFullscreenState = { root, iframe, payload: null };
return showHtmlFullscreenState;
}
function openShowHtmlFullscreen(payload: ShowHtmlFullscreenPayload) {
if (typeof document === 'undefined') return;
const state = ensureShowHtmlFullscreenDom();
state.payload = payload;
// js=off 卡片内容已 sanitize全屏时连脚本执行权也不给语义与卡片内一致
state.iframe.setAttribute('sandbox', payload.allowScripts ? 'allow-scripts' : '');
state.iframe.srcdoc = payload.srcdoc;
state.root.classList.add('is-open');
document.documentElement.classList.add('show-html-fullscreen-open');
debugShowHtmlLog('fullscreen:open', {
allowScripts: payload.allowScripts,
srcdocLength: payload.srcdoc.length
});
}
function closeShowHtmlFullscreen() {
const state = showHtmlFullscreenState;
if (!state || !isShowHtmlFullscreenOpen()) return;
state.root.classList.remove('is-open');
document.documentElement.classList.remove('show-html-fullscreen-open');
state.payload = null;
// 清空 srcdoc停掉 iframe 内脚本/定时器并释放内存
state.iframe.srcdoc = SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC;
debugShowHtmlLog('fullscreen:close', {});
}
/**
* show_html /
* wrapper
*/
function bindShowHtmlCardControls(wrapper: HTMLElement, controls: ShowHtmlCardControls) {
(wrapper as any)[SHOW_HTML_CONTROLS_KEY] = controls;
let toolbar = wrapper.querySelector(':scope > .chat-inline-card__toolbar') as HTMLElement | null; let toolbar = wrapper.querySelector(':scope > .chat-inline-card__toolbar') as HTMLElement | null;
if (!toolbar) { if (!toolbar) {
toolbar = document.createElement('div'); toolbar = document.createElement('div');
toolbar.className = 'chat-inline-card__toolbar'; toolbar.className = 'chat-inline-card__toolbar';
wrapper.appendChild(toolbar); wrapper.appendChild(toolbar);
} }
let refreshBtn = toolbar.querySelector( let menuBtn = toolbar.querySelector(
':scope > .chat-inline-card__refresh-btn' ':scope > .chat-inline-card__menu-btn'
) as HTMLButtonElement | null; ) as HTMLButtonElement | null;
if (!refreshBtn) { if (!menuBtn) {
refreshBtn = document.createElement('button'); menuBtn = document.createElement('button');
refreshBtn.type = 'button'; menuBtn.type = 'button';
refreshBtn.className = 'chat-inline-card__refresh-btn'; menuBtn.className = 'chat-inline-card__menu-btn';
refreshBtn.title = '刷新卡片'; menuBtn.title = '卡片操作';
refreshBtn.setAttribute('aria-label', '刷新卡片'); menuBtn.setAttribute('aria-label', '卡片操作');
refreshBtn.textContent = '↻'; menuBtn.setAttribute('aria-haspopup', 'menu');
toolbar.appendChild(refreshBtn); menuBtn.textContent = '⋯';
toolbar.appendChild(menuBtn);
} }
refreshBtn.onclick = (event) => { menuBtn.onclick = (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
markShowTagDrawingActive(1200); openShowHtmlCardMenu(menuBtn!, wrapper);
refreshBtn!.classList.remove('is-refreshing');
// 触发重放动画
void refreshBtn!.offsetWidth;
refreshBtn!.classList.add('is-refreshing');
try {
onRefresh();
} catch (error) {
debugShowHtmlLog('refresh:error', {
message: error instanceof Error ? error.message : String(error || '')
});
} finally {
window.setTimeout(() => refreshBtn?.classList.remove('is-refreshing'), 720);
}
}; };
} }
@ -552,7 +943,6 @@ function renderShowImages(root: ParentNode | null = document) {
const jsMode = readShowHtmlJsMode(node); const jsMode = readShowHtmlJsMode(node);
const computedRatioKey = readShowHtmlRatio(node); const computedRatioKey = readShowHtmlRatio(node);
let ratioKey = computedRatioKey; let ratioKey = computedRatioKey;
let { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
// 流式阶段:一旦 show_html 已经出现完整闭合(非 partial冻结该 path 的已完成快照; // 流式阶段:一旦 show_html 已经出现完整闭合(非 partial冻结该 path 的已完成快照;
// 后续即使继续输出其他普通文本,也复用该快照,避免已完成 show_html 反复刷新。 // 后续即使继续输出其他普通文本,也复用该快照,避免已完成 show_html 反复刷新。
@ -571,47 +961,31 @@ function renderShowImages(root: ParentNode | null = document) {
const effectiveEncoded = frozenSnapshot?.encoded || encoded; const effectiveEncoded = frozenSnapshot?.encoded || encoded;
if (frozenSnapshot?.ratioKey) { if (frozenSnapshot?.ratioKey) {
ratioKey = frozenSnapshot.ratioKey; ratioKey = frozenSnapshot.ratioKey;
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
} }
// 流式期间锁定卡片比例(尺寸由 CSS 自适应,无需锁像素):
// 流式初期 ratio 属性可能未输出完整而先落到 1:1后续允许升级到真实比例
if (inStreaming) { if (inStreaming) {
const locked = showHtmlStreamingSizeLockByPath.get(pathKey); const locked = showHtmlStreamingRatioLockByPath.get(pathKey);
if (locked) { if (locked) {
// 修复:流式初期若 ratio 解析不完整会先落到 1:1后续必须允许升级到真实比例
const shouldUpgradeRatio = const shouldUpgradeRatio =
ratioKey !== locked.ratioKey && (locked.ratioKey === '1:1' || ratioKey !== '1:1'); ratioKey !== locked && (locked === '1:1' || ratioKey !== '1:1');
if (shouldUpgradeRatio) { if (shouldUpgradeRatio) {
ratioKey = computedRatioKey; ratioKey = computedRatioKey;
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey)); showHtmlStreamingRatioLockByPath.set(pathKey, ratioKey);
showHtmlStreamingSizeLockByPath.set(pathKey, {
ratioKey,
widthPx,
heightPx
});
} else { } else {
ratioKey = locked.ratioKey; ratioKey = locked;
widthPx = locked.widthPx;
heightPx = locked.heightPx;
} }
} else { } else {
showHtmlStreamingSizeLockByPath.set(pathKey, { showHtmlStreamingRatioLockByPath.set(pathKey, ratioKey);
ratioKey,
widthPx,
heightPx
});
} }
} else { } else {
showHtmlStreamingSizeLockByPath.delete(pathKey); showHtmlStreamingRatioLockByPath.delete(pathKey);
} }
// streaming 阶段 v-html 会反复重建 show_html 节点,这里先锁定占位尺寸,避免高度掉 0 // 卡片尺寸由 CSS 接管width: min(...) + aspect-ratio见 _chat-area.scss
node.style.display = 'block'; // 这里只写入比例变量;窗口/容器尺寸变化时浏览器自动重算,无需 JS 监听 resize
node.style.width = `${widthPx}px`; node.style.setProperty('--show-html-ar', String(SHOW_HTML_RATIO_VALUES[ratioKey] || 1));
node.style.maxWidth = '100%';
node.style.minHeight = `${heightPx}px`;
node.style.margin = '14px auto';
node.style.boxSizing = 'border-box';
node.style.overflow = 'hidden';
debugShowHtmlLog('render:node-seen', { debugShowHtmlLog('render:node-seen', {
renderId, renderId,
@ -624,9 +998,7 @@ function renderShowImages(root: ParentNode | null = document) {
partial: node.getAttribute('data-partial') || '', partial: node.getAttribute('data-partial') || '',
encodedLength: encoded.length, encodedLength: encoded.length,
effectiveEncodedLength: effectiveEncoded.length, effectiveEncodedLength: effectiveEncoded.length,
innerLength: (node.innerHTML || '').length, innerLength: (node.innerHTML || '').length
reservedWidth: `${widthPx}px`,
reservedMinHeight: `${heightPx}px`
}); });
// js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁 // js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁
@ -643,29 +1015,15 @@ function renderShowImages(root: ParentNode | null = document) {
tip.textContent = '正在渲染内容...'; tip.textContent = '正在渲染内容...';
host.appendChild(tip); host.appendChild(tip);
wrapper.appendChild(host); wrapper.appendChild(host);
pending = { pending = { wrapper, host, tip };
wrapper,
host,
tip,
widthPx: 0,
heightPx: 0,
ratioKey: '1:1'
};
showHtmlJsPendingRenderByPath.set(pathKey, pending); 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.replaceChildren(pending.wrapper);
node.setAttribute('data-rendered', '1'); node.setAttribute('data-rendered', '1');
debugShowHtmlLog('render:node-js-pending', { debugShowHtmlLog('render:node-js-pending', {
renderId, renderId,
pathKey, pathKey,
ratioKey, ratioKey
widthPx,
heightPx
}); });
return; return;
} }
@ -684,19 +1042,20 @@ function renderShowImages(root: ParentNode | null = document) {
iframe.setAttribute('sandbox', 'allow-scripts'); iframe.setAttribute('sandbox', 'allow-scripts');
iframe.setAttribute('referrerpolicy', 'no-referrer'); iframe.setAttribute('referrerpolicy', 'no-referrer');
iframe.setAttribute('loading', 'lazy'); iframe.setAttribute('loading', 'lazy');
// 空 allow 以外的特性显式拒绝:卡片是固定尺寸展示区,无全屏等合理需求
iframe.setAttribute('allow', "fullscreen 'none'");
wrapper.appendChild(iframe); wrapper.appendChild(iframe);
persistent = { persistent = {
encoded: '', encoded: '',
ratioKey: '1:1', ratioKey: '1:1',
widthPx: 0,
heightPx: 0,
srcdoc: '', srcdoc: '',
wrapper, wrapper,
iframe iframe
}; };
showHtmlJsIframeRenderByPath.set(pathKey, persistent); showHtmlJsIframeRenderByPath.set(pathKey, persistent);
} }
bindShowHtmlRefreshControl(persistent.wrapper, () => { bindShowHtmlCardControls(persistent.wrapper, {
onRefresh: () => {
// 强制重新挂载同一份 srcdoc触发 iframe 内页面重载 // 强制重新挂载同一份 srcdoc触发 iframe 内页面重载
const keep = persistent?.srcdoc || srcdoc; const keep = persistent?.srcdoc || srcdoc;
persistent.iframe.srcdoc = persistent.iframe.srcdoc =
@ -709,24 +1068,23 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowHtmlLog('refresh:iframe', { debugShowHtmlLog('refresh:iframe', {
pathKey, pathKey,
ratioKey: persistent?.ratioKey || ratioKey, ratioKey: persistent?.ratioKey || ratioKey,
widthPx: persistent?.widthPx || widthPx,
heightPx: persistent?.heightPx || heightPx,
srcdocLength: keep.length srcdocLength: keep.length
}); });
},
// 全屏直接复用当前 srcdoc已含 CSP meta 与守卫脚本),沙箱保持 allow-scripts
getFullscreenPayload: () => {
const current = persistent?.srcdoc || '';
if (!current) return null;
return { srcdoc: current, allowScripts: true };
}
}); });
persistent.wrapper.style.width = `${widthPx}px`;
persistent.iframe.style.height = `${heightPx}px`;
const needsUpdate = const needsUpdate =
persistent.encoded !== effectiveEncoded || persistent.encoded !== effectiveEncoded ||
persistent.widthPx !== widthPx ||
persistent.heightPx !== heightPx ||
persistent.ratioKey !== ratioKey || persistent.ratioKey !== ratioKey ||
persistent.srcdoc !== srcdoc; persistent.srcdoc !== srcdoc;
if (needsUpdate) { if (needsUpdate) {
persistent.iframe.srcdoc = srcdoc; persistent.iframe.srcdoc = srcdoc;
persistent.encoded = effectiveEncoded; persistent.encoded = effectiveEncoded;
persistent.widthPx = widthPx;
persistent.heightPx = heightPx;
persistent.ratioKey = ratioKey; persistent.ratioKey = ratioKey;
persistent.srcdoc = srcdoc; persistent.srcdoc = srcdoc;
} }
@ -739,8 +1097,6 @@ function renderShowImages(root: ParentNode | null = document) {
renderId, renderId,
pathKey, pathKey,
ratioKey, ratioKey,
widthPx,
heightPx,
encodedLength: effectiveEncoded.length, encodedLength: effectiveEncoded.length,
srcdocLength: srcdoc.length, srcdocLength: srcdoc.length,
needsUpdate needsUpdate
@ -754,16 +1110,12 @@ function renderShowImages(root: ParentNode | null = document) {
if (now - lastTs < SHOW_HTML_STREAMING_RENDER_INTERVAL_MS) { if (now - lastTs < SHOW_HTML_STREAMING_RENDER_INTERVAL_MS) {
const persistent = showHtmlPersistentRenderByPath.get(pathKey); const persistent = showHtmlPersistentRenderByPath.get(pathKey);
if (persistent) { if (persistent) {
persistent.wrapper.style.width = `${widthPx}px`;
persistent.host.style.height = `${heightPx}px`;
node.replaceChildren(persistent.wrapper); node.replaceChildren(persistent.wrapper);
node.setAttribute('data-rendered', '1'); node.setAttribute('data-rendered', '1');
debugShowHtmlLog('render:node-throttled-rehydrate', { debugShowHtmlLog('render:node-throttled-rehydrate', {
renderId, renderId,
pathKey, pathKey,
ratioKey, ratioKey
widthPx,
heightPx
}); });
} }
debugShowHtmlLog('render:node-throttled', { debugShowHtmlLog('render:node-throttled', {
@ -790,9 +1142,7 @@ function renderShowImages(root: ParentNode | null = document) {
effectiveEncodedLength: effectiveEncoded.length, effectiveEncodedLength: effectiveEncoded.length,
rawLength: rawHtml.length, rawLength: rawHtml.length,
safeLength: safeHtml.length, safeLength: safeHtml.length,
ratioKey, ratioKey
widthPx,
heightPx
}); });
let persistent = showHtmlPersistentRenderByPath.get(pathKey); let persistent = showHtmlPersistentRenderByPath.get(pathKey);
@ -852,15 +1202,14 @@ function renderShowImages(root: ParentNode | null = document) {
encoded: '', encoded: '',
safeHtml: '', safeHtml: '',
ratioKey: '1:1', ratioKey: '1:1',
widthPx: 0,
heightPx: 0,
wrapper, wrapper,
host, host,
root root
}; };
showHtmlPersistentRenderByPath.set(pathKey, persistent); showHtmlPersistentRenderByPath.set(pathKey, persistent);
} }
bindShowHtmlRefreshControl(persistent.wrapper, () => { bindShowHtmlCardControls(persistent.wrapper, {
onRefresh: () => {
const keep = persistent?.safeHtml || ''; const keep = persistent?.safeHtml || '';
persistent.root.innerHTML = ''; persistent.root.innerHTML = '';
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -871,33 +1220,30 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowHtmlLog('refresh:shadow-root', { debugShowHtmlLog('refresh:shadow-root', {
pathKey, pathKey,
ratioKey: persistent?.ratioKey || ratioKey, ratioKey: persistent?.ratioKey || ratioKey,
widthPx: persistent?.widthPx || widthPx,
heightPx: persistent?.heightPx || heightPx,
safeLength: keep.length safeLength: keep.length
}); });
},
// js=off 内容已 sanitize全屏走禁脚本沙箱 iframe渲染效果与 Shadow DOM 一致
getFullscreenPayload: () => {
const current = persistent?.safeHtml || '';
if (!current.trim()) return null;
return { srcdoc: buildShowHtmlIframeSrcdoc(current), allowScripts: false };
}
}); });
persistent.wrapper.style.width = `${widthPx}px`;
persistent.host.style.height = `${heightPx}px`;
const needsUpdate = const needsUpdate =
persistent.encoded !== effectiveEncoded || persistent.encoded !== effectiveEncoded ||
persistent.widthPx !== widthPx ||
persistent.heightPx !== heightPx ||
persistent.ratioKey !== ratioKey; persistent.ratioKey !== ratioKey;
if (needsUpdate) { if (needsUpdate) {
persistent.root.innerHTML = safeHtml; persistent.root.innerHTML = safeHtml;
persistent.encoded = effectiveEncoded; persistent.encoded = effectiveEncoded;
persistent.safeHtml = safeHtml; persistent.safeHtml = safeHtml;
persistent.widthPx = widthPx;
persistent.heightPx = heightPx;
persistent.ratioKey = ratioKey; persistent.ratioKey = ratioKey;
debugShowHtmlLog('render:node-persistent-update', { debugShowHtmlLog('render:node-persistent-update', {
renderId, renderId,
pathKey, pathKey,
encodedLength: effectiveEncoded.length, encodedLength: effectiveEncoded.length,
ratioKey, ratioKey
widthPx,
heightPx
}); });
} else { } else {
debugShowHtmlLog('render:node-persistent-reuse', { debugShowHtmlLog('render:node-persistent-reuse', {
@ -917,8 +1263,6 @@ function renderShowImages(root: ParentNode | null = document) {
pathKey, pathKey,
jsMode, jsMode,
ratioKey, ratioKey,
widthPx,
heightPx,
rect: { rect: {
x: Math.round(rect.x), x: Math.round(rect.x),
y: Math.round(rect.y), y: Math.round(rect.y),
@ -933,8 +1277,6 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowImageLog('render:show-html-done', { debugShowImageLog('render:show-html-done', {
renderId, renderId,
ratioKey, ratioKey,
widthPx,
heightPx,
contentLength: rawHtml.length contentLength: rawHtml.length
}); });
} }
@ -970,22 +1312,14 @@ const showHtmlCompletedSnapshotByPath = new Map<
ratioKey: string ratioKey: string
} }
>(); >();
const showHtmlStreamingSizeLockByPath = new Map< // 流式期间只锁定比例;具体尺寸由 CSS min()+aspect-ratio 自适应计算
string, const showHtmlStreamingRatioLockByPath = new Map<string, string>();
{
ratioKey: string,
widthPx: number,
heightPx: number
}
>();
const showHtmlPersistentRenderByPath = new Map< const showHtmlPersistentRenderByPath = new Map<
string, string,
{ {
encoded: string, encoded: string,
safeHtml: string, safeHtml: string,
ratioKey: string, ratioKey: string,
widthPx: number,
heightPx: number,
wrapper: HTMLElement, wrapper: HTMLElement,
host: HTMLElement, host: HTMLElement,
root: HTMLElement root: HTMLElement
@ -996,10 +1330,7 @@ const showHtmlJsPendingRenderByPath = new Map<
{ {
wrapper: HTMLElement, wrapper: HTMLElement,
host: HTMLElement, host: HTMLElement,
tip: HTMLElement, tip: HTMLElement
widthPx: number,
heightPx: number,
ratioKey: string
} }
>(); >();
const showHtmlJsIframeRenderByPath = new Map< const showHtmlJsIframeRenderByPath = new Map<
@ -1007,8 +1338,6 @@ const showHtmlJsIframeRenderByPath = new Map<
{ {
encoded: string, encoded: string,
ratioKey: string, ratioKey: string,
widthPx: number,
heightPx: number,
srcdoc: string, srcdoc: string,
wrapper: HTMLElement, wrapper: HTMLElement,
iframe: HTMLIFrameElement iframe: HTMLIFrameElement
@ -1318,11 +1647,14 @@ export function teardownShowImageObserver() {
layoutDebugLastTsByKey.clear(); layoutDebugLastTsByKey.clear();
showHtmlStreamingRenderTsByPath.clear(); showHtmlStreamingRenderTsByPath.clear();
showHtmlCompletedSnapshotByPath.clear(); showHtmlCompletedSnapshotByPath.clear();
showHtmlStreamingSizeLockByPath.clear(); showHtmlStreamingRatioLockByPath.clear();
showHtmlPersistentRenderByPath.clear(); showHtmlPersistentRenderByPath.clear();
showHtmlJsPendingRenderByPath.clear(); showHtmlJsPendingRenderByPath.clear();
showHtmlJsIframeRenderByPath.clear(); showHtmlJsIframeRenderByPath.clear();
showTagObservedContainer = null; showTagObservedContainer = null;
// 菜单/全屏层是全局单例,卡片 Map 清空后其引用的 wrapper 已卸载,必须一并关闭
closeShowHtmlCardMenu();
closeShowHtmlFullscreen();
} }
function updateViewportHeightVar() { function updateViewportHeightVar() {

View File

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

View File

@ -715,12 +715,14 @@ export function parseMarkdownSegments(text: string, isStreaming = false): Markdo
return segments; return segments;
} }
export function renderMarkdownText(text: string): string { export function renderMarkdownText(text: string, isStreaming = false): string {
if (!text) return ''; if (!text) return '';
// isStreaming 必须透传:流式期间未闭合的 show_html 需要编码成 data-partial 占位
// js=off 实时渲染 / js=on 显示"渲染中"),否则原始标签文本会直接散落到消息里
const safeText = transformMathBlocks( const safeText = transformMathBlocks(
transformShowFileBlocks( 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 { .chat-inline-card--html {
display: block; display: block;
position: relative; 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 { .chat-inline-card__toolbar {
@ -1749,42 +1758,42 @@ body[data-theme='dark'] .more-icon {
pointer-events: none; pointer-events: none;
} }
.chat-inline-card__refresh-btn { /* ⋯ 菜单按钮纯文字风格无边框无背景hover 仅变色;保留 26px 点击热区 */
.chat-inline-card__menu-btn {
pointer-events: auto; pointer-events: auto;
width: 26px; width: 26px;
height: 26px; height: 26px;
border-radius: 999px; border: 0;
border: 1px solid var(--border-default); background: transparent;
background: color-mix(in srgb, var(--surface-card) 78%, transparent);
color: var(--text-secondary); color: var(--text-secondary);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 15px; font-size: 16px;
line-height: 1; line-height: 1;
/* ⋯ 字形视觉重心偏下,上抬补偵使其看起来居中 */
padding-bottom: 5px;
cursor: pointer; cursor: pointer;
transition: transition: color 0.18s ease;
color 0.18s ease,
border-color 0.18s ease,
background 0.18s ease;
} }
.chat-inline-card__refresh-btn:hover { .chat-inline-card__menu-btn:hover {
color: var(--text-primary); color: var(--text-primary);
border-color: var(--accent);
background: color-mix(in srgb, var(--highlight) 72%, transparent);
} }
.chat-inline-card__refresh-btn.is-refreshing { .chat-inline-card__menu-btn.is-refreshing {
animation: chat-inline-refresh-spin 0.6s linear 1; animation: chat-inline-menu-pulse 0.6s ease 1;
} }
@keyframes chat-inline-refresh-spin { @keyframes chat-inline-menu-pulse {
from { 0% {
transform: rotate(0deg); opacity: 1;
} }
to { 40% {
transform: rotate(360deg); opacity: 0.35;
}
100% {
opacity: 1;
} }
} }
@ -1809,6 +1818,148 @@ body[data-theme='dark'] .more-icon {
background: transparent; background: transparent;
} }
/* ---------------- show_html 卡片 ⋯ 菜单(全局 fixed 单例) ---------------- */
.show-html-card-menu {
position: fixed;
z-index: 3050;
min-width: 112px;
padding: 4px;
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: 8px;
box-shadow: var(--shadow-card);
animation: show-html-card-menu-in 0.16s ease-out;
}
@keyframes show-html-card-menu-in {
from {
opacity: 0;
transform: translateY(-3px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.show-html-card-menu__item {
display: flex;
align-items: center;
width: 100%;
height: 32px;
padding: 0 12px;
background: none;
border: none;
border-radius: 5px;
font-size: 13px;
text-align: left;
color: var(--text-primary);
cursor: pointer;
transition: background 0.12s ease;
}
.show-html-card-menu__item:hover {
background: var(--surface-soft);
}
/* ---------------- show_html 全屏预览 overlay ---------------- */
/* 打开期间锁定底层页面滚动(聊天区滚动容器在 body 下) */
html.show-html-fullscreen-open,
html.show-html-fullscreen-open body {
overflow: hidden;
}
.show-html-fullscreen {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
/* 与聊天区同一套视口高度变量,规避移动端 100vh 地址栏问题 */
height: var(--app-viewport, 100vh);
z-index: 3000;
background: var(--surface-base);
flex-direction: column;
}
.show-html-fullscreen.is-open {
display: flex;
}
.show-html-fullscreen__bar {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
/* 手机刘海/状态栏安全区;移动端无法 Esc关闭按钮必须永远可见可达 */
padding: calc(8px + env(safe-area-inset-top, 0px))
calc(10px + env(safe-area-inset-right, 0px)) 8px
calc(16px + env(safe-area-inset-left, 0px));
border-bottom: 1px solid var(--border-default);
}
.show-html-fullscreen__title {
font-size: 13px;
color: var(--text-secondary);
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.show-html-fullscreen__actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
/* 与卡片 ⋯ 按钮一致的纯文字风格无边框无背景hover 仅变色 */
.show-html-fullscreen__btn {
width: 32px;
height: 32px;
border: 0;
background: transparent;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: color 0.18s ease;
}
.show-html-fullscreen__btn:hover {
color: var(--text-primary);
}
.show-html-fullscreen__body {
flex: 1 1 auto;
min-height: 0;
position: relative;
}
.show-html-fullscreen__iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: transparent;
}
/* 移动端加大热区,保证触屏可可靠点中关闭按钮 */
@media (max-width: 768px) {
.show-html-fullscreen__btn {
width: 36px;
height: 36px;
font-size: 16px;
}
}
/* 兼容旧类名(可逐步移除) */ /* 兼容旧类名(可逐步移除) */
.chat-inline-image__error { .chat-inline-image__error {
@extend .chat-inline-card__error; @extend .chat-inline-card__error;
@ -1854,6 +2005,22 @@ show-html:not([data-rendered='1'])[ratio='3:4'] {
aspect-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 { .text-output .text-content {
padding: 0 var(--chat-content-x); padding: 0 var(--chat-content-x);
max-width: 100%; max-width: 100%;