@ -168,8 +168,14 @@ const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
'4:3' : 4 / 3 ,
'3:4' : 3 / 4
} ;
// 卡片尺寸的基准像素与上限约束由 CSS 表达(见 _chat-area.scss 的 show_html[data-rendered] 规则),
// 此处仅保留比例数值用于写入 --show-html-ar CSS 变量。
// 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 }
} ;
function normalizeShowHtmlRatio ( raw : string | null ) {
if ( ! raw ) return '1:1' ;
@ -218,7 +224,34 @@ 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 '' ;
@ -293,171 +326,15 @@ 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 文档, 强制注入沙箱 CSP 与守卫脚本后使用 。
// 若模型已经输出完整 HTML 文档,直接使用;否则包裹成最小可运行文档。
if ( /<html[\s>]/i . test ( content ) ) {
return injectShowHtmlSandboxGuards( content) ;
return content ;
}
return ` <!doctype html>
@ -465,7 +342,6 @@ 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 ;
@ -505,309 +381,42 @@ function escapeHtml(input: string) {
. replace ( /'/g , ''' ) ;
}
/ * *
* 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 ;
function bindShowHtmlRefreshControl ( wrapper : HTMLElement , onRefresh : ( ) = > void ) {
let toolbar = wrapper . querySelector ( ':scope > .chat-inline-card__toolbar' ) as HTMLElement | null ;
if ( ! toolbar ) {
toolbar = document . createElement ( 'div' ) ;
toolbar . className = 'chat-inline-card__toolbar' ;
wrapper . appendChild ( toolbar ) ;
}
let menu Btn = toolbar . querySelector (
':scope > .chat-inline-card__ menu -btn'
let refreshBtn = toolbar . querySelector (
':scope > .chat-inline-card__refresh-btn'
) as HTMLButtonElement | null ;
if ( ! menuBtn ) {
menuBtn = document . createElement ( 'button' ) ;
menuBtn . type = 'button' ;
menuBtn . className = 'chat-inline-card__menu-btn' ;
menuBtn . title = '卡片操作' ;
menuBtn . setAttribute ( 'aria-label' , '卡片操作' ) ;
menuBtn . setAttribute ( 'aria-haspopup' , 'menu' ) ;
menuBtn . textContent = '⋯' ;
toolbar . appendChild ( menuBtn ) ;
if ( ! refreshBtn ) {
refreshBtn = document . createElement ( 'button' ) ;
refreshBtn . type = 'button' ;
refreshBtn . className = 'chat-inline-card__refresh-btn' ;
refreshBtn . title = '刷新卡片' ;
refreshBtn . setAttribute ( 'aria-label' , '刷新卡片' ) ;
refreshBtn . textContent = '↻' ;
toolbar . appendChild ( refreshBtn ) ;
}
menu Btn. onclick = ( event ) = > {
refreshBtn . onclick = ( event ) = > {
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
openShowHtmlCardMenu ( menuBtn ! , wrapper ) ;
markShowTagDrawingActive ( 1200 ) ;
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 ) ;
}
} ;
}
@ -943,6 +552,7 @@ 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 反复刷新。
@ -961,31 +571,47 @@ 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 = showHtmlStreaming Ratio LockByPath. get ( pathKey ) ;
const locked = showHtmlStreamingSizeLockByPath . get ( pathKey ) ;
if ( locked ) {
// 修复:流式初期若 ratio 解析不完整会先落到 1:1, 后续必须允许升级到真实比例
const shouldUpgradeRatio =
ratioKey !== locked && ( locked === '1:1' || ratioKey !== '1:1' ) ;
ratioKey !== locked . ratioKey && ( locked . ratioKey === '1:1' || ratioKey !== '1:1' ) ;
if ( shouldUpgradeRatio ) {
ratioKey = computedRatioKey ;
showHtmlStreamingRatioLockByPath . set ( pathKey , ratioKey ) ;
( { widthPx , heightPx } = computeAdaptiveShowHtmlSize ( node , ratioKey ) ) ;
showHtmlStreamingSizeLockByPath . set ( pathKey , {
ratioKey ,
widthPx ,
heightPx
} ) ;
} else {
ratioKey = locked ;
ratioKey = locked . ratioKey ;
widthPx = locked . widthPx ;
heightPx = locked . heightPx ;
}
} else {
showHtmlStreamingRatioLockByPath . set ( pathKey , ratioKey ) ;
showHtmlStreamingSizeLockByPath . set ( pathKey , {
ratioKey ,
widthPx ,
heightPx
} ) ;
}
} else {
showHtmlStreamingRatioLockByPath . delete ( pathKey ) ;
showHtmlStreaming Size LockByPath. delete ( pathKey ) ;
}
// 卡片尺寸由 CSS 接管( width: min(...) + aspect-ratio, 见 _chat-area.scss) ,
// 这里只写入比例变量;窗口/容器尺寸变化时浏览器自动重算,无需 JS 监听 resize
node . style . setProperty ( '--show-html-ar' , String ( SHOW_HTML_RATIO_VALUES [ ratioKey ] || 1 ) ) ;
// 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' ;
debugShowHtmlLog ( 'render:node-seen' , {
renderId ,
@ -998,7 +624,9 @@ function renderShowImages(root: ParentNode | null = document) {
partial : node.getAttribute ( 'data-partial' ) || '' ,
encodedLength : encoded.length ,
effectiveEncodedLength : effectiveEncoded.length ,
innerLength : ( node . innerHTML || '' ) . length
innerLength : ( node . innerHTML || '' ) . length ,
reservedWidth : ` ${ widthPx } px ` ,
reservedMinHeight : ` ${ heightPx } px `
} ) ;
// js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁
@ -1015,15 +643,29 @@ function renderShowImages(root: ParentNode | null = document) {
tip . textContent = '正在渲染内容...' ;
host . appendChild ( tip ) ;
wrapper . appendChild ( host ) ;
pending = { wrapper , host , tip } ;
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
ratioKey ,
widthPx ,
heightPx
} ) ;
return ;
}
@ -1042,49 +684,49 @@ 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
} ;
showHtmlJsIframeRenderByPath . set ( pathKey , persistent ) ;
}
bindShowHtmlCardControls ( persistent . wrapper , {
onRefresh : ( ) = > {
// 强制重新挂载同一份 srcdoc, 触发 iframe 内页面重载
const keep = persistent ? . srcdoc || srcdoc ;
persistent . iframe . srcdoc =
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>' ;
requestAnimationFrame ( ( ) = > {
if ( persistent ? . iframe ) {
persistent . iframe . srcdoc = keep ;
}
} ) ;
debugShowHtmlLog ( 'refresh:iframe' , {
pathKey ,
ratioKey : persistent?.ratioKey || ratioKey ,
srcdocLength : keep.length
} ) ;
} ,
// 全屏直接复用当前 srcdoc( 已含 CSP meta 与守卫脚本),沙箱保持 allow-scripts
getFullscreenPayload : ( ) = > {
const current = persistent ? . srcdoc || '' ;
if ( ! current ) return null ;
return { srcdoc : current , allowScripts : true } ;
}
bindShowHtmlRefreshControl ( persistent . wrapper , ( ) = > {
// 强制重新挂载同一份 srcdoc, 触发 iframe 内页面重载
const keep = persistent ? . srcdoc || srcdoc ;
persistent . iframe . srcdoc =
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>' ;
requestAnimationFrame ( ( ) = > {
if ( persistent ? . iframe ) {
persistent . iframe . srcdoc = keep ;
}
} ) ;
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 ;
}
@ -1097,6 +739,8 @@ function renderShowImages(root: ParentNode | null = document) {
renderId ,
pathKey ,
ratioKey ,
widthPx ,
heightPx ,
encodedLength : effectiveEncoded.length ,
srcdocLength : srcdoc.length ,
needsUpdate
@ -1110,12 +754,16 @@ 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
ratioKey ,
widthPx ,
heightPx
} ) ;
}
debugShowHtmlLog ( 'render:node-throttled' , {
@ -1142,7 +790,9 @@ function renderShowImages(root: ParentNode | null = document) {
effectiveEncodedLength : effectiveEncoded.length ,
rawLength : rawHtml.length ,
safeLength : safeHtml.length ,
ratioKey
ratioKey ,
widthPx ,
heightPx
} ) ;
let persistent = showHtmlPersistentRenderByPath . get ( pathKey ) ;
@ -1202,48 +852,52 @@ function renderShowImages(root: ParentNode | null = document) {
encoded : '' ,
safeHtml : '' ,
ratioKey : '1:1' ,
widthPx : 0 ,
heightPx : 0 ,
wrapper ,
host ,
root
} ;
showHtmlPersistentRenderByPath . set ( pathKey , persistent ) ;
}
bindShowHtmlCardControls ( persistent . wrapper , {
onRefresh : ( ) = > {
const keep = persistent ? . safeHtml || '' ;
persistent . root . innerHTML = '' ;
requestAnimationFrame ( ( ) = > {
if ( persistent ? . root ) {
persistent . root . innerHTML = keep ;
}
} ) ;
debugShowHtmlLog ( 'refresh:shadow-root' , {
pathKey ,
ratioKey : persistent?.ratioKey || ratioKey ,
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 } ;
}
bindShowHtmlRefreshControl ( persistent . wrapper , ( ) = > {
const keep = persistent ? . safeHtml || '' ;
persistent . root . innerHTML = '' ;
requestAnimationFrame ( ( ) = > {
if ( persistent ? . root ) {
persistent . root . innerHTML = keep ;
}
} ) ;
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
ratioKey ,
widthPx ,
heightPx
} ) ;
} else {
debugShowHtmlLog ( 'render:node-persistent-reuse' , {
@ -1263,6 +917,8 @@ function renderShowImages(root: ParentNode | null = document) {
pathKey ,
jsMode ,
ratioKey ,
widthPx ,
heightPx ,
rect : {
x : Math.round ( rect . x ) ,
y : Math.round ( rect . y ) ,
@ -1277,6 +933,8 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowImageLog ( 'render:show-html-done' , {
renderId ,
ratioKey ,
widthPx ,
heightPx ,
contentLength : rawHtml.length
} ) ;
}
@ -1312,14 +970,22 @@ const showHtmlCompletedSnapshotByPath = new Map<
ratioKey : string
}
> ( ) ;
// 流式期间只锁定比例;具体尺寸由 CSS min()+aspect-ratio 自适应计算
const showHtmlStreamingRatioLockByPath = new Map < string , string > ( ) ;
const showHtmlStreamingSizeLockByPath = new Map <
string ,
{
ratioKey : string ,
widthPx : number ,
heightPx : number
}
> ( ) ;
const showHtmlPersistentRenderByPath = new Map <
string ,
{
encoded : string ,
safeHtml : string ,
ratioKey : string ,
widthPx : number ,
heightPx : number ,
wrapper : HTMLElement ,
host : HTMLElement ,
root : HTMLElement
@ -1330,7 +996,10 @@ const showHtmlJsPendingRenderByPath = new Map<
{
wrapper : HTMLElement ,
host : HTMLElement ,
tip : HTMLElement
tip : HTMLElement ,
widthPx : number ,
heightPx : number ,
ratioKey : string
}
> ( ) ;
const showHtmlJsIframeRenderByPath = new Map <
@ -1338,6 +1007,8 @@ const showHtmlJsIframeRenderByPath = new Map<
{
encoded : string ,
ratioKey : string ,
widthPx : number ,
heightPx : number ,
srcdoc : string ,
wrapper : HTMLElement ,
iframe : HTMLIFrameElement
@ -1647,14 +1318,11 @@ export function teardownShowImageObserver() {
layoutDebugLastTsByKey . clear ( ) ;
showHtmlStreamingRenderTsByPath . clear ( ) ;
showHtmlCompletedSnapshotByPath . clear ( ) ;
showHtmlStreaming Ratio LockByPath. clear ( ) ;
showHtmlStreaming Size LockByPath. clear ( ) ;
showHtmlPersistentRenderByPath . clear ( ) ;
showHtmlJsPendingRenderByPath . clear ( ) ;
showHtmlJsIframeRenderByPath . clear ( ) ;
showTagObservedContainer = null ;
// 菜单/全屏层是全局单例,卡片 Map 清空后其引用的 wrapper 已卸载,必须一并关闭
closeShowHtmlCardMenu ( ) ;
closeShowHtmlFullscreen ( ) ;
}
function updateViewportHeightVar() {