agent-Specialization/static/src/app/bootstrap.ts
JOJO e4a663594d feat: Android App 原生 PDF 预览与前端 show_file 卡片
- 集成 AndroidPdfViewer (mhiew fork) 实现不依赖浏览器的 PDF 预览
- 新增 PdfPreviewActivity 与 JS Bridge (AndroidPdfBridge)
- ShowFileCard 在 Android App 内对 PDF 显示「预览」按钮,不内嵌 iframe
- 新增 /api/file/content 后端接口用于文件内容 inline 预览
- 前端支持 <show_file> 标签与 download:// 链接渲染
- 更新 Android 版本号至 1.0.35
2026-06-24 00:06:10 +08:00

1448 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-nocheck
import katex from 'katex';
import DOMPurify from 'dompurify';
import { createApp } from 'vue';
import ShowFileCard from '../components/chat/ShowFileCard.vue';
let showImageRenderSeq = 0;
let showImageDebugLogCount = 0;
const SHOW_IMAGE_DEBUG_VERSION = 'v2-lite-throttled';
const SHOW_IMAGE_DEBUG_MAX_LOGS = 200;
let showHtmlDebugLogCount = 0;
const SHOW_HTML_DEBUG_MAX_LOGS = 500;
function getShowImageDebugMode(): 'off' | 'lite' | 'verbose' {
if (typeof window === 'undefined') return 'off';
try {
const explicitFlag = (window as any).__SHOW_IMAGE_DEBUG__;
if (
explicitFlag === false ||
explicitFlag === '0' ||
explicitFlag === 'off' ||
explicitFlag === 'false'
) {
return 'off';
}
if (explicitFlag === 'verbose') return 'verbose';
if (explicitFlag === true || explicitFlag === '1' || explicitFlag === 'lite') return 'lite';
const localFlag = window.localStorage?.getItem('showImageDebug');
if (localFlag === '0' || localFlag === 'off' || localFlag === 'false' || localFlag === 'none') {
return 'off';
}
if (localFlag === 'verbose') return 'verbose';
if (localFlag === '1' || localFlag === 'true' || localFlag === 'lite') return 'lite';
return 'off';
} catch {
return 'off';
}
}
function debugShowImageLog(event: string, payload: Record<string, any> = {}) {
const mode = getShowImageDebugMode();
if (mode === 'off') return;
if (
mode === 'lite' &&
!['observer:setup', 'observer:show-image-mutation', 'render:start', 'render:end'].includes(
event
)
) {
return;
}
if (showImageDebugLogCount >= SHOW_IMAGE_DEBUG_MAX_LOGS) return;
showImageDebugLogCount += 1;
if (showImageDebugLogCount === SHOW_IMAGE_DEBUG_MAX_LOGS) {
console.warn('[SHOW_IMAGE_DEBUG]', 'log-limit-reached', {
max: SHOW_IMAGE_DEBUG_MAX_LOGS,
version: SHOW_IMAGE_DEBUG_VERSION
});
return;
}
console.log('[SHOW_IMAGE_DEBUG]', event, payload);
}
function isShowHtmlDebugEnabled() {
if (typeof window === 'undefined') return true;
try {
const explicitFlag = (window as any).__SHOW_HTML_DEBUG__;
if (explicitFlag === false || explicitFlag === '0') return false;
if (explicitFlag === true || explicitFlag === '1') return true;
const localFlag = window.localStorage?.getItem('showHtmlDebug');
if (localFlag === '0' || localFlag === 'false') return false;
if (localFlag === '1' || localFlag === 'true') return true;
return true;
} catch {
return true;
}
}
function debugShowHtmlLog(event: string, payload: Record<string, any> = {}) {
if (!isShowHtmlDebugEnabled()) return;
if (showHtmlDebugLogCount >= SHOW_HTML_DEBUG_MAX_LOGS) return;
showHtmlDebugLogCount += 1;
if (showHtmlDebugLogCount === SHOW_HTML_DEBUG_MAX_LOGS) {
console.warn('[SHOW_HTML_DEBUG]', 'log-limit-reached', {
max: SHOW_HTML_DEBUG_MAX_LOGS
});
return;
}
console.log('[SHOW_HTML_DEBUG]', event, payload);
}
function markShowTagDrawingActive(holdMs = 1600) {
if (typeof window === 'undefined') return;
try {
(window as any).__SHOW_TAG_DRAWING_UNTIL__ = Date.now() + holdMs;
} catch {
// ignore
}
}
function summarizeNodeForDebug(node: Node | null) {
if (!node) return 'null';
if (node.nodeType === Node.TEXT_NODE) {
const text = (node.textContent || '').replace(/\s+/g, ' ').trim();
return `TEXT(${text.slice(0, 80)})`;
}
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
const tag = el.tagName.toLowerCase();
if (tag === 'show_image' || tag === 'show-image') {
return `SHOW_IMAGE(src=${el.getAttribute('src') || ''})`;
}
return `${tag.toUpperCase()}`;
}
return `NODE(${node.nodeType})`;
}
function collectShowImageOrder(root: ParentNode | null = document) {
if (!root || !(root instanceof Node)) return [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ALL);
const tokens: string[] = [];
let current = walker.currentNode;
while (current) {
if (current.nodeType === Node.TEXT_NODE) {
const text = (current.textContent || '').replace(/\s+/g, ' ').trim();
if (text) tokens.push(`TEXT(${text.slice(0, 40)})`);
} else if (current.nodeType === Node.ELEMENT_NODE) {
const el = current as Element;
const tag = el.tagName.toLowerCase();
if (tag === 'show_image' || tag === 'show-image') {
tokens.push(`SHOW_IMAGE(${el.getAttribute('src') || ''})`);
} else if (tag === 'show_html' || tag === 'show-html') {
tokens.push(`SHOW_HTML(ratio=${readShowHtmlRatio(el)},js=${readShowHtmlJsMode(el)})`);
}
}
current = walker.nextNode();
}
return tokens.slice(0, 200);
}
function normalizeShowImageSrc(src: string) {
if (!src) return '';
const trimmed = src.trim();
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (trimmed.startsWith('/user_upload/')) return trimmed;
// 兼容容器内部路径:/workspace/.../user_upload/xxx.png 或 /workspace/user_upload/xxx
const idx = trimmed.toLowerCase().indexOf('/user_upload/');
if (idx >= 0) {
return '/user_upload/' + trimmed.slice(idx + '/user_upload/'.length);
}
if (trimmed.startsWith('/') || trimmed.startsWith('./') || trimmed.startsWith('../')) {
return trimmed;
}
return '';
}
function parsePixelSize(raw: string | null, fallback: number) {
if (!raw) return fallback;
const n = Number.parseInt(raw.trim(), 10);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.min(4096, n);
}
const SHOW_HTML_ALLOWED_RATIOS = ['1:1', '16:9', '9:16', '4:3', '3:4'] as const;
const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
'1:1': 1,
'16:9': 16 / 9,
'9:16': 9 / 16,
'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 }
};
function normalizeShowHtmlRatio(raw: string | null) {
if (!raw) return '1:1';
const cleaned = raw.trim().toLowerCase().replace(/[x/]/g, ':').replace(/\s+/g, '');
if ((SHOW_HTML_ALLOWED_RATIOS as readonly string[]).includes(cleaned)) return cleaned;
return '1:1';
}
function pickNearestAllowedRatio(width: number, height: number) {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return '1:1';
}
const target = width / height;
let best = '1:1';
let bestDiff = Number.POSITIVE_INFINITY;
for (const ratio of SHOW_HTML_ALLOWED_RATIOS) {
const diff = Math.abs(SHOW_HTML_RATIO_VALUES[ratio] - target);
if (diff < bestDiff) {
bestDiff = diff;
best = ratio;
}
}
return best;
}
function readShowHtmlRatio(node: Element) {
const ratioAttr = node.getAttribute('ratio');
if (ratioAttr) return normalizeShowHtmlRatio(ratioAttr);
// 兼容旧格式:模型输出了 width/height 时,自动映射到允许的比例
const width = parsePixelSize(node.getAttribute('width'), 0);
const height = parsePixelSize(node.getAttribute('height'), 0);
if (width > 0 && height > 0) return pickNearestAllowedRatio(width, height);
return '1:1';
}
function normalizeShowHtmlJsMode(raw: string | null) {
if (!raw) return 'off';
const cleaned = raw.trim().toLowerCase();
if (['on', '1', 'true', 'yes', 'enabled', 'enable'].includes(cleaned)) {
return 'on';
}
return 'off';
}
function readShowHtmlJsMode(node: Element) {
return normalizeShowHtmlJsMode(node.getAttribute('js'));
}
function computeAdaptiveShowHtmlSize(node: Element, ratioKey: string) {
const ratio = SHOW_HTML_RATIO_VALUES[ratioKey] || 1;
const base = SHOW_HTML_RATIO_BASE_SIZE[ratioKey] || SHOW_HTML_RATIO_BASE_SIZE['1:1'];
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 '';
try {
return decodeURIComponent(escape(atob(input)));
} catch {
return '';
}
}
function sanitizeInlineCss(css: string) {
if (!css) return '';
return css
.replace(/expression\s*\([^)]*\)/gi, '')
.replace(/url\s*\(\s*['"]?\s*javascript:[^)]+\)/gi, 'url(about:blank)')
.replace(/@import[^;]+;/gi, '');
}
function rewriteCssSelectorsForShadowDom(css: string) {
if (!css) return '';
// 注意:不能直接把 html/body 连续替换成 .show-html-root
// 否则后续 /\bhtml\b/ 会再次命中 ".show-html-root" 里的 "html",产生
// 类似 ".show-.show-html-root-root" 的错误选择器,导致布局样式失效(元素偏移)。
const placeholder = '__SHOW_HTML_ROOT_SELECTOR__';
return css
.replace(/\bhtml\s*,\s*body\b/gi, placeholder)
.replace(/\bbody\s*,\s*html\b/gi, placeholder)
.replace(/\bhtml\b/gi, placeholder)
.replace(/\bbody\b/gi, placeholder)
.replace(new RegExp(placeholder, 'g'), '.show-html-root');
}
function sanitizeShowHtmlContent(rawHtml: string) {
const purified = DOMPurify.sanitize(rawHtml || '', {
USE_PROFILES: { html: true, svg: true, svgFilters: true, mathMl: true },
FORBID_TAGS: ['script', 'noscript', 'iframe', 'object', 'embed', 'link'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: true
}) as string;
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${purified}</body>`, 'text/html');
const elements = Array.from(doc.body.querySelectorAll('*'));
elements.forEach((el) => {
Array.from(el.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (name.startsWith('on')) {
el.removeAttribute(attr.name);
return;
}
if (
(name === 'src' || name === 'href' || name === 'xlink:href') &&
/^\s*javascript:/i.test(value)
) {
el.removeAttribute(attr.name);
return;
}
if (name === 'style') {
el.setAttribute('style', sanitizeInlineCss(value));
}
});
});
doc.querySelectorAll('style').forEach((styleEl) => {
styleEl.textContent = rewriteCssSelectorsForShadowDom(
sanitizeInlineCss(styleEl.textContent || '')
);
});
return doc.body.innerHTML;
}
function buildShowHtmlIframeSrcdoc(rawHtml: string) {
const content = (rawHtml || '').trim();
if (!content) {
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
}
// 若模型已经输出完整 HTML 文档,直接使用;否则包裹成最小可运行文档。
if (/<html[\s>]/i.test(content)) {
return content;
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
min-height: 100%;
background: transparent;
scrollbar-width: thin;
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
}
html::-webkit-scrollbar, body::-webkit-scrollbar {
width: 8px;
height: 8px;
}
html::-webkit-scrollbar-track, body::-webkit-scrollbar-track {
background: transparent;
}
html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb {
background: rgba(121, 109, 94, 0.48);
border-radius: 8px;
}
html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover {
background: rgba(121, 109, 94, 0.62);
}
</style>
</head>
<body>${content}</body>
</html>`;
}
function escapeHtml(input: string) {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 refreshBtn = toolbar.querySelector(
':scope > .chat-inline-card__refresh-btn'
) as HTMLButtonElement | null;
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);
}
refreshBtn.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
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);
}
};
}
function renderShowImages(root: ParentNode | null = document) {
if (!root) return;
const debugMode = getShowImageDebugMode();
const verbose = debugMode === 'verbose';
const renderId = ++showImageRenderSeq;
const imageNodes = Array.from(
root.querySelectorAll('show_image:not([data-rendered]), show-image:not([data-rendered])')
);
const htmlNodes = Array.from(
root.querySelectorAll('show_html:not([data-rendered]), show-html:not([data-rendered])')
);
const fileNodes = Array.from(
root.querySelectorAll('show_file:not([data-rendered]), show-file:not([data-rendered])')
);
// download:// 链接也需要绑定,即使没有 show 标签
const hasDownloadLinks = !!root.querySelector(
'a.md-download-link:not([data-download-bound]), a[data-download="1"]:not([data-download-bound])'
);
if (htmlNodes.length > 0 || fileNodes.length > 0) {
markShowTagDrawingActive();
}
if (!verbose && imageNodes.length === 0 && htmlNodes.length === 0 && fileNodes.length === 0 && !hasDownloadLinks) return;
const rootNode = root as Node;
debugShowImageLog('render:start', {
renderId,
rootType:
rootNode?.nodeType === Node.ELEMENT_NODE
? (rootNode as Element).tagName.toLowerCase()
: `nodeType:${rootNode?.nodeType}`,
pendingImageCount: imageNodes.length,
pendingHtmlCount: htmlNodes.length,
pendingFileCount: fileNodes.length,
orderBefore: collectShowImageOrder(root)
});
if (verbose) {
debugShowImageLog('render:pending-show-images', {
renderId,
count: imageNodes.length,
nodes: imageNodes.map((n) => n.getAttribute('src') || '')
});
}
imageNodes.forEach((node) => {
if (!node.isConnected || node.hasAttribute('data-rendered')) return;
if (verbose) {
debugShowImageLog('render:node-start', {
renderId,
src: node.getAttribute('src') || '',
parent: summarizeNodeForDebug(node.parentNode),
prevSibling: summarizeNodeForDebug(node.previousSibling),
nextSibling: summarizeNodeForDebug(node.nextSibling),
childCount: node.childNodes.length,
childPreview: Array.from(node.childNodes)
.slice(0, 6)
.map((child) => summarizeNodeForDebug(child))
});
}
// 将 show_image 内误被包裹的内容移动到当前节点之后,保持原有顺序
if (node.parentNode && node.firstChild) {
const parent = node.parentNode;
const ref = node.nextSibling; // 可能为 nullinsertBefore 会当 append
const children = Array.from(node.childNodes);
if (verbose) {
debugShowImageLog('render:unwrap-children', {
renderId,
src: node.getAttribute('src') || '',
moveCount: children.length,
ref: summarizeNodeForDebug(ref),
children: children.slice(0, 10).map((child) => summarizeNodeForDebug(child))
});
}
children.forEach((child) => parent.insertBefore(child, ref));
}
const rawSrc = node.getAttribute('src') || '';
const mappedSrc = normalizeShowImageSrc(rawSrc);
if (!mappedSrc) {
node.setAttribute('data-rendered', '1');
node.setAttribute('data-rendered-error', 'invalid-src');
if (verbose) debugShowImageLog('render:invalid-src', { renderId, rawSrc });
return;
}
const alt = node.getAttribute('alt') || '';
const safeAlt = escapeHtml(alt.trim());
const figure = document.createElement('figure');
figure.className = 'chat-inline-card chat-inline-card--image chat-inline-image';
const img = document.createElement('img');
img.loading = 'lazy';
img.src = mappedSrc;
img.alt = safeAlt;
img.onerror = () => {
figure.classList.add('chat-inline-card--error', 'chat-inline-image--error');
const tip = document.createElement('div');
tip.className = 'chat-inline-card__error chat-inline-image__error';
tip.textContent = '图片加载失败';
figure.appendChild(tip);
};
figure.appendChild(img);
if (safeAlt) {
const caption = document.createElement('figcaption');
caption.className = 'chat-inline-card__caption';
caption.innerHTML = safeAlt;
figure.appendChild(caption);
}
node.replaceChildren(figure);
node.setAttribute('data-rendered', '1');
if (verbose) {
debugShowImageLog('render:node-done', {
renderId,
rawSrc,
mappedSrc,
alt
});
}
});
htmlNodes.forEach((node) => {
markShowTagDrawingActive();
if (!node.isConnected) return;
const inStreaming = !!node.closest('.streaming-text');
const pathKey = buildNodePathKey(node);
const isPartial = node.getAttribute('data-partial') === '1';
const encoded = node.getAttribute('data-encoded') || '';
const jsMode = readShowHtmlJsMode(node);
const computedRatioKey = readShowHtmlRatio(node);
let ratioKey = computedRatioKey;
let { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
// 流式阶段:一旦 show_html 已经出现完整闭合(非 partial冻结该 path 的已完成快照;
// 后续即使继续输出其他普通文本,也复用该快照,避免已完成 show_html 反复刷新。
if (inStreaming && !isPartial && encoded) {
const frozen = showHtmlCompletedSnapshotByPath.get(pathKey);
if (!frozen || frozen.encoded !== encoded || frozen.ratioKey !== ratioKey) {
showHtmlCompletedSnapshotByPath.set(pathKey, {
encoded,
ratioKey
});
}
} else if (!inStreaming) {
showHtmlCompletedSnapshotByPath.delete(pathKey);
}
const frozenSnapshot = inStreaming ? showHtmlCompletedSnapshotByPath.get(pathKey) : null;
const effectiveEncoded = frozenSnapshot?.encoded || encoded;
if (frozenSnapshot?.ratioKey) {
ratioKey = frozenSnapshot.ratioKey;
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
}
if (inStreaming) {
const locked = showHtmlStreamingSizeLockByPath.get(pathKey);
if (locked) {
// 修复:流式初期若 ratio 解析不完整会先落到 1:1后续必须允许升级到真实比例
const shouldUpgradeRatio =
ratioKey !== locked.ratioKey && (locked.ratioKey === '1:1' || ratioKey !== '1:1');
if (shouldUpgradeRatio) {
ratioKey = computedRatioKey;
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
showHtmlStreamingSizeLockByPath.set(pathKey, {
ratioKey,
widthPx,
heightPx
});
} else {
ratioKey = locked.ratioKey;
widthPx = locked.widthPx;
heightPx = locked.heightPx;
}
} else {
showHtmlStreamingSizeLockByPath.set(pathKey, {
ratioKey,
widthPx,
heightPx
});
}
} else {
showHtmlStreamingSizeLockByPath.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';
debugShowHtmlLog('render:node-seen', {
renderId,
inStreaming,
pathKey,
hasRendered: node.hasAttribute('data-rendered'),
ratioAttr: node.getAttribute('ratio') || '',
jsMode,
ratioKey,
partial: node.getAttribute('data-partial') || '',
encodedLength: encoded.length,
effectiveEncodedLength: effectiveEncoded.length,
innerLength: (node.innerHTML || '').length,
reservedWidth: `${widthPx}px`,
reservedMinHeight: `${heightPx}px`
});
// js="on":在闭合前只显示占位,不做实时渲染,避免 iframe/srcdoc 反复重建与闪烁
if (jsMode === 'on' && isPartial) {
let pending = showHtmlJsPendingRenderByPath.get(pathKey);
if (!pending) {
const wrapper = document.createElement('div');
wrapper.className =
'chat-inline-card chat-inline-card--html chat-inline-card--pending chat-inline-html chat-inline-html--pending';
const host = document.createElement('div');
host.className = 'chat-inline-card__body chat-inline-html__host chat-inline-html__host--pending';
const tip = document.createElement('div');
tip.className = 'chat-inline-card__pending-tip chat-inline-html__pending-tip';
tip.textContent = '正在渲染内容...';
host.appendChild(tip);
wrapper.appendChild(host);
pending = {
wrapper,
host,
tip,
widthPx: 0,
heightPx: 0,
ratioKey: '1:1'
};
showHtmlJsPendingRenderByPath.set(pathKey, pending);
}
pending.wrapper.style.width = `${widthPx}px`;
pending.host.style.height = `${heightPx}px`;
pending.widthPx = widthPx;
pending.heightPx = heightPx;
pending.ratioKey = ratioKey;
node.replaceChildren(pending.wrapper);
node.setAttribute('data-rendered', '1');
debugShowHtmlLog('render:node-js-pending', {
renderId,
pathKey,
ratioKey,
widthPx,
heightPx
});
return;
}
// js="on":闭合后使用 sandbox iframe 隔离执行(不依赖 shadow root 直渲染)。
if (jsMode === 'on') {
const rawHtml = effectiveEncoded ? decodeBase64Utf8(effectiveEncoded) : node.innerHTML || '';
const srcdoc = buildShowHtmlIframeSrcdoc(rawHtml);
let persistent = showHtmlJsIframeRenderByPath.get(pathKey);
if (!persistent) {
const wrapper = document.createElement('div');
wrapper.className =
'chat-inline-card chat-inline-card--html chat-inline-card--iframe chat-inline-html chat-inline-html--iframe';
const iframe = document.createElement('iframe');
iframe.className = 'chat-inline-html__iframe';
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.setAttribute('referrerpolicy', 'no-referrer');
iframe.setAttribute('loading', 'lazy');
wrapper.appendChild(iframe);
persistent = {
encoded: '',
ratioKey: '1:1',
widthPx: 0,
heightPx: 0,
srcdoc: '',
wrapper,
iframe
};
showHtmlJsIframeRenderByPath.set(pathKey, persistent);
}
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;
}
if (!inStreaming || isPartial || needsUpdate || !node.hasAttribute('data-rendered')) {
node.replaceChildren(persistent.wrapper);
}
node.setAttribute('data-rendered', '1');
showHtmlJsPendingRenderByPath.delete(pathKey);
debugShowHtmlLog('render:node-js-iframe', {
renderId,
pathKey,
ratioKey,
widthPx,
heightPx,
encodedLength: effectiveEncoded.length,
srcdocLength: srcdoc.length,
needsUpdate
});
return;
}
if (inStreaming) {
const now = Date.now();
const lastTs = showHtmlStreamingRenderTsByPath.get(pathKey) || 0;
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
});
}
debugShowHtmlLog('render:node-throttled', {
renderId,
pathKey,
now,
lastTs,
interval: SHOW_HTML_STREAMING_RENDER_INTERVAL_MS
});
return;
}
showHtmlStreamingRenderTsByPath.set(pathKey, now);
} else {
showHtmlStreamingRenderTsByPath.delete(pathKey);
}
const rawHtml = effectiveEncoded ? decodeBase64Utf8(effectiveEncoded) : node.innerHTML || '';
const safeHtml = sanitizeShowHtmlContent(rawHtml);
debugShowHtmlLog('render:node-content', {
renderId,
pathKey,
jsMode,
encodedLength: encoded.length,
effectiveEncodedLength: effectiveEncoded.length,
rawLength: rawHtml.length,
safeLength: safeHtml.length,
ratioKey,
widthPx,
heightPx
});
let persistent = showHtmlPersistentRenderByPath.get(pathKey);
if (!persistent) {
const wrapper = document.createElement('div');
wrapper.className = 'chat-inline-card chat-inline-card--html chat-inline-html';
const host = document.createElement('div');
host.className = 'chat-inline-card__body chat-inline-html__host';
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: auto;
background: transparent;
color: inherit;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
}
*, *::before, *::after {
box-sizing: border-box;
}
.show-html-root {
width: 100%;
height: 100%;
min-height: 100%;
overflow: auto;
background: transparent;
scrollbar-width: thin;
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
}
.show-html-root::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.show-html-root::-webkit-scrollbar-track {
background: transparent;
}
.show-html-root::-webkit-scrollbar-thumb {
background: rgba(121, 109, 94, 0.48);
border-radius: 8px;
}
.show-html-root::-webkit-scrollbar-thumb:hover {
background: rgba(121, 109, 94, 0.62);
}
`;
const root = document.createElement('div');
root.className = 'show-html-root';
shadow.appendChild(style);
shadow.appendChild(root);
wrapper.appendChild(host);
persistent = {
encoded: '',
safeHtml: '',
ratioKey: '1:1',
widthPx: 0,
heightPx: 0,
wrapper,
host,
root
};
showHtmlPersistentRenderByPath.set(pathKey, persistent);
}
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,
widthPx,
heightPx
});
} else {
debugShowHtmlLog('render:node-persistent-reuse', {
renderId,
pathKey,
encodedLength: effectiveEncoded.length
});
}
if (!inStreaming || isPartial || needsUpdate || !node.hasAttribute('data-rendered')) {
node.replaceChildren(persistent.wrapper);
}
node.setAttribute('data-rendered', '1');
const rect = persistent.wrapper.getBoundingClientRect();
debugShowHtmlLog('render:node-done', {
renderId,
pathKey,
jsMode,
ratioKey,
widthPx,
heightPx,
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
w: Math.round(rect.width),
h: Math.round(rect.height)
},
partial: node.getAttribute('data-partial') || '',
inStreaming
});
if (verbose) {
debugShowImageLog('render:show-html-done', {
renderId,
ratioKey,
widthPx,
heightPx,
contentLength: rawHtml.length
});
}
});
// ===== show_file 预览卡片 =====
fileNodes.forEach((node) => {
if (!node.isConnected || node.hasAttribute('data-rendered')) return;
renderShowFileCard(node);
node.setAttribute('data-rendered', '1');
});
// ===== download:// 链接事件绑定 =====
bindDownloadLinks(root);
debugShowImageLog('render:end', {
renderId,
orderAfter: collectShowImageOrder(root)
});
}
let showImageObserver: MutationObserver | null = null;
let showContainerObserver: MutationObserver | null = null;
let showTagRenderScheduled = false;
let showTagBindRetryTimer: number | null = null;
let showTagObservedContainer: Element | null = null;
const showHtmlStreamingRenderTsByPath = new Map<string, number>();
const SHOW_HTML_STREAMING_RENDER_INTERVAL_MS = 180;
const showHtmlCompletedSnapshotByPath = new Map<
string,
{
encoded: string,
ratioKey: 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
}
>();
const showHtmlJsPendingRenderByPath = new Map<
string,
{
wrapper: HTMLElement,
host: HTMLElement,
tip: HTMLElement,
widthPx: number,
heightPx: number,
ratioKey: string
}
>();
const showHtmlJsIframeRenderByPath = new Map<
string,
{
encoded: string,
ratioKey: string,
widthPx: number,
heightPx: number,
srcdoc: string,
wrapper: HTMLElement,
iframe: HTMLIFrameElement
}
>();
let layoutDebugObserver: MutationObserver | null = null;
let layoutDebugResizeObserver: ResizeObserver | null = null;
let layoutDebugStarted = false;
let layoutDebugBindRetryTimer: number | null = null;
let layoutDebugCount = 0;
const LAYOUT_DEBUG_MAX = 300;
const layoutDebugLastTsByKey = new Map<string, number>();
function scheduleShowTagRender(container: Element) {
if (showTagRenderScheduled) return;
showTagRenderScheduled = true;
const run = () => {
showTagRenderScheduled = false;
renderShowImages(container);
};
if (typeof queueMicrotask === 'function') {
queueMicrotask(run);
return;
}
Promise.resolve().then(run);
}
function getShowTagContainer() {
return document.querySelector('.messages-area');
}
function isLayoutDebugEnabled() {
if (typeof window === 'undefined') return true;
try {
const explicitFlag = (window as any).__LAYOUT_DEBUG__;
if (explicitFlag === false || explicitFlag === '0') return false;
if (explicitFlag === true || explicitFlag === '1') return true;
const localFlag = window.localStorage?.getItem('layoutDebug');
if (localFlag === '0' || localFlag === 'false') return false;
if (localFlag === '1' || localFlag === 'true') return true;
return true;
} catch {
return true;
}
}
function layoutDebugLog(event: string, payload: Record<string, any> = {}, key = event) {
if (!isLayoutDebugEnabled()) return;
if (layoutDebugCount >= LAYOUT_DEBUG_MAX) return;
const now = Date.now();
const lastTs = layoutDebugLastTsByKey.get(key) || 0;
if (now - lastTs < 120) return;
layoutDebugLastTsByKey.set(key, now);
layoutDebugCount += 1;
if (layoutDebugCount === LAYOUT_DEBUG_MAX) {
console.warn('[LAYOUT_DEBUG]', 'log-limit-reached', { max: LAYOUT_DEBUG_MAX });
return;
}
console.log('[LAYOUT_DEBUG]', event, payload);
}
function elementRectSummary(el: Element | null) {
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
className: (el as HTMLElement).className || '',
x: Math.round(rect.x),
y: Math.round(rect.y),
width: Math.round(rect.width),
height: Math.round(rect.height)
};
}
function setupLayoutDebugObservers() {
if (layoutDebugStarted || !isLayoutDebugEnabled()) return;
const trackedSelectors = [
'.main-container',
'.workspace-region',
'.workspace-panel',
'.chat-container',
'.messages-area'
];
const tracked = trackedSelectors
.map((selector) => document.querySelector(selector))
.filter((el): el is Element => !!el);
if (tracked.length === 0) {
layoutDebugLog('layout:setup-wait', { reason: 'targets-not-mounted' }, 'layout:setup-wait');
if (layoutDebugBindRetryTimer) {
window.clearTimeout(layoutDebugBindRetryTimer);
}
layoutDebugBindRetryTimer = window.setTimeout(() => {
layoutDebugBindRetryTimer = null;
setupLayoutDebugObservers();
}, 250);
return;
}
layoutDebugStarted = true;
layoutDebugLog('layout:setup', {
trackedCount: tracked.length,
tracked: tracked.map((el) => (el as HTMLElement).className || el.tagName.toLowerCase())
});
const snapshot = () => {
layoutDebugLog(
'layout:snapshot',
{
window: {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight
},
main: elementRectSummary(document.querySelector('.main-container')),
workspace: elementRectSummary(document.querySelector('.workspace-region')),
panel: elementRectSummary(document.querySelector('.workspace-panel')),
chat: elementRectSummary(document.querySelector('.chat-container')),
messages: elementRectSummary(document.querySelector('.messages-area'))
},
'layout:snapshot'
);
};
snapshot();
layoutDebugObserver = new MutationObserver((mutations) => {
const interesting = mutations.filter((m) => {
if (!(m.target instanceof Element)) return false;
const className = (m.target as HTMLElement).className || '';
return (
m.type === 'attributes' &&
(tracked.some((el) => el === m.target) ||
className.includes('chat-container') ||
className.includes('workspace-panel') ||
className.includes('main-container'))
);
});
if (!interesting.length) return;
layoutDebugLog('layout:mutation', {
count: interesting.length,
items: interesting.slice(0, 8).map((m) => ({
attr: m.attributeName,
target: summarizeNodeForDebug(m.target)
}))
});
snapshot();
});
layoutDebugObserver.observe(document.body, {
subtree: true,
attributes: true,
attributeFilter: ['class', 'style']
});
layoutDebugResizeObserver = new ResizeObserver((entries) => {
if (!entries.length) return;
layoutDebugLog('layout:resize', {
items: entries.slice(0, 8).map((entry) => ({
target: summarizeNodeForDebug(entry.target),
width: Math.round(entry.contentRect.width),
height: Math.round(entry.contentRect.height)
}))
});
snapshot();
});
tracked.forEach((el) => layoutDebugResizeObserver?.observe(el));
window.addEventListener('resize', snapshot);
}
function buildNodePathKey(node: Element) {
const getIndexWithin = (el: Element, selector: string) => {
const list = Array.from(document.querySelectorAll(selector));
const idx = list.indexOf(el);
return idx >= 0 ? idx : -1;
};
const parts: string[] = [];
const messageBlock = node.closest('.message-block');
if (messageBlock) {
parts.push(`msg:${getIndexWithin(messageBlock, '.message-block')}`);
}
const actionItem = node.closest('.action-item');
if (actionItem) {
parts.push(`action:${getIndexWithin(actionItem, '.action-item')}`);
}
const textContent = node.closest('.text-content');
if (textContent) {
parts.push(`text:${getIndexWithin(textContent, '.text-content')}`);
}
let cur: Element | null = node;
while (cur && cur !== document.body && parts.length < 12) {
const parent = cur.parentElement;
if (!parent) break;
const index = Array.prototype.indexOf.call(parent.children, cur);
parts.push(`${cur.tagName.toLowerCase()}:${index}`);
if (cur.classList.contains('text-content')) break;
cur = parent;
}
return parts.reverse().join('/');
}
export function setupShowImageObserver() {
if (showImageObserver || showContainerObserver) return;
setupLayoutDebugObservers();
const bind = () => {
const container = getShowTagContainer();
if (!container) {
if (showTagBindRetryTimer) {
window.clearTimeout(showTagBindRetryTimer);
}
showTagBindRetryTimer = window.setTimeout(bind, 250);
return;
}
if (showTagBindRetryTimer) {
window.clearTimeout(showTagBindRetryTimer);
showTagBindRetryTimer = null;
}
if (showTagObservedContainer === container && showImageObserver) return;
if (showImageObserver) {
showImageObserver.disconnect();
showImageObserver = null;
}
showTagObservedContainer = container;
debugShowImageLog('observer:setup', {
container: summarizeNodeForDebug(container),
debugMode: getShowImageDebugMode(),
version: SHOW_IMAGE_DEBUG_VERSION
});
renderShowImages(container);
showImageObserver = new MutationObserver((mutations) => {
const debugMode = getShowImageDebugMode();
const verbose = debugMode === 'verbose';
const hasShowImageRelatedMutation = mutations.some((mutation) => {
const targetIsShowImage =
mutation.target instanceof Element &&
['show_image', 'show_html', 'show_file', 'show-image', 'show-html', 'show-file'].includes(
mutation.target.tagName.toLowerCase()
);
const addedHasShowTag = Array.from(mutation.addedNodes).some(
(n) =>
n instanceof Element &&
(['show_image', 'show_html', 'show_file', 'show-image', 'show-html', 'show-file'].includes(
n.tagName.toLowerCase()
) || !!n.querySelector?.('show_image,show_html,show_file,show-image,show-html,show-file,a.md-download-link,a[data-download="1"]'))
);
return targetIsShowImage || addedHasShowTag;
});
if (!hasShowImageRelatedMutation) return;
debugShowImageLog('observer:show-image-mutation', {
mutationCount: mutations.length,
mutations: verbose
? mutations.slice(0, 20).map((m) => ({
type: m.type,
target: summarizeNodeForDebug(m.target),
added: Array.from(m.addedNodes)
.slice(0, 6)
.map((n) => summarizeNodeForDebug(n)),
removed: Array.from(m.removedNodes)
.slice(0, 6)
.map((n) => summarizeNodeForDebug(n))
}))
: undefined
});
scheduleShowTagRender(container);
});
showImageObserver.observe(container, { childList: true, subtree: true });
};
bind();
showContainerObserver = new MutationObserver(() => {
const latest = getShowTagContainer();
if (!latest) return;
if (latest !== showTagObservedContainer) {
bind();
}
});
showContainerObserver.observe(document.body, { childList: true, subtree: true });
}
export function teardownShowImageObserver() {
if (showTagBindRetryTimer) {
window.clearTimeout(showTagBindRetryTimer);
showTagBindRetryTimer = null;
}
if (showImageObserver) {
showImageObserver.disconnect();
showImageObserver = null;
}
if (showContainerObserver) {
showContainerObserver.disconnect();
showContainerObserver = null;
}
if (layoutDebugObserver) {
layoutDebugObserver.disconnect();
layoutDebugObserver = null;
}
if (layoutDebugResizeObserver) {
layoutDebugResizeObserver.disconnect();
layoutDebugResizeObserver = null;
}
if (layoutDebugBindRetryTimer) {
window.clearTimeout(layoutDebugBindRetryTimer);
layoutDebugBindRetryTimer = null;
}
layoutDebugStarted = false;
layoutDebugCount = 0;
layoutDebugLastTsByKey.clear();
showHtmlStreamingRenderTsByPath.clear();
showHtmlCompletedSnapshotByPath.clear();
showHtmlStreamingSizeLockByPath.clear();
showHtmlPersistentRenderByPath.clear();
showHtmlJsPendingRenderByPath.clear();
showHtmlJsIframeRenderByPath.clear();
showTagObservedContainer = null;
}
function updateViewportHeightVar() {
const docEl = document.documentElement;
const visualViewport = window.visualViewport;
if (visualViewport) {
const vh = visualViewport.height;
const bottomInset = Math.max(
0,
(window.innerHeight || docEl.clientHeight || vh) -
visualViewport.height -
visualViewport.offsetTop
);
docEl.style.setProperty('--app-viewport', `${vh}px`);
docEl.style.setProperty('--app-bottom-inset', `${bottomInset}px`);
} else {
const height = window.innerHeight || docEl.clientHeight;
if (height) {
docEl.style.setProperty('--app-viewport', `${height}px`);
}
docEl.style.setProperty('--app-bottom-inset', 'env(safe-area-inset-bottom, 0px)');
}
}
// ===== show_file 预览卡片渲染 =====
function dispatchShowFileDownload(path: string) {
// 直接 fetch 下载接口,不走 Vue 组件链
const url = `/api/download/file?path=${encodeURIComponent(path)}`;
const name = path.split('/').pop() || 'file';
fetch(url)
.then((resp) => {
if (!resp.ok) {
return resp.json().then((j) => {
throw new Error(j.error || j.message || resp.statusText);
}).catch(() => {
throw new Error(resp.statusText || '下载失败');
});
}
return resp.blob();
})
.then((blob) => {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
})
.catch((err) => {
console.warn('[show_file] 下载失败:', err);
// 兜底:直接跳转
window.open(url, '_blank');
});
}
// 跟踪已挂载的 show_file Vue 应用,便于卸载
const showFileAppMap = new WeakMap<Element, any>();
/**
* 将 <show_file> 节点渲染为 Vue 预览卡片组件。
* 只读取 path 属性,预览内容直接在卡片内部展开。
*/
function renderShowFileCard(node: Element) {
const path = (node.getAttribute('path') || '').trim().replace(/^\/+/, '');
if (!path) {
node.replaceChildren(document.createTextNode('[show_file: 缺少 path 属性]'));
return;
}
// 清理旧实例
const oldApp = showFileAppMap.get(node);
if (oldApp) {
try { oldApp.unmount(); } catch {}
showFileAppMap.delete(node);
}
const mountEl = document.createElement('div');
mountEl.className = 'show-file-card-root';
node.replaceChildren(mountEl);
const app = createApp(ShowFileCard, { path });
app.mount(mountEl);
showFileAppMap.set(node, app);
}
/**
* 绑定 download:// 链接的点击事件。
* markdown 里的 [文件](download:///path) 会被渲染为 <a class="md-download-link" href="download://..." data-path="/path">。
*/
function bindDownloadLinks(root: ParentNode) {
// 优先用 data-download 属性选择(不被 sanitize 影响),
// md-download-link class 作为兼容回退
const links = root.querySelectorAll<HTMLAnchorElement>(
'a[data-download="1"]:not([data-download-bound]), a.md-download-link:not([data-download-bound])'
);
links.forEach((link) => {
link.setAttribute('data-download-bound', '1');
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const path = link.getAttribute('data-path') || '';
if (path) {
dispatchShowFileDownload(path);
}
});
});
}
if (typeof window !== 'undefined') {
window.katex = katex;
updateViewportHeightVar();
window.addEventListener('resize', updateViewportHeightVar);
window.addEventListener('orientationchange', updateViewportHeightVar);
window.addEventListener('pageshow', updateViewportHeightVar);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateViewportHeightVar);
window.visualViewport.addEventListener('scroll', updateViewportHeightVar);
}
}