diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts
index a37eeed3..537df08b 100644
--- a/static/src/app/bootstrap.ts
+++ b/static/src/app/bootstrap.ts
@@ -3,6 +3,12 @@ import katex from 'katex';
import DOMPurify from 'dompurify';
import { createApp } from 'vue';
import ShowFileCard from '../components/chat/ShowFileCard.vue';
+import { buildShowHtmlIframeSrcdoc } from '../utils/showHtmlSandbox';
+import {
+ openShowHtmlFullscreen,
+ closeShowHtmlFullscreen,
+ type ShowHtmlFullscreenPayload
+} from '../utils/showHtmlFullscreen';
let showImageRenderSeq = 0;
let showImageDebugLogCount = 0;
@@ -293,209 +299,6 @@ function sanitizeShowHtmlContent(rawHtml: string) {
return doc.body.innerHTML;
}
-/**
- * show_html js=on 沙箱 CSP(通过 srcdoc 内 交付,必须位于任何资源引用之前)。
- * - 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 ``;
-}
-
-/** 模型自带完整 HTML 文档时,强制把沙箱注入插到
最前(无 head 则补一个) */
-function injectShowHtmlSandboxGuards(doc: string) {
- const injection = buildShowHtmlSandboxInjection();
- const headMatch = /]*>/i.exec(doc);
- if (headMatch) {
- const idx = headMatch.index + headMatch[0].length;
- return doc.slice(0, idx) + injection + doc.slice(idx);
- }
- const htmlMatch = /]*>/i.exec(doc);
- if (htmlMatch) {
- const idx = htmlMatch.index + htmlMatch[0].length;
- return doc.slice(0, idx) + `${injection}` + doc.slice(idx);
- }
- return injection + doc;
-}
-
-function buildShowHtmlIframeSrcdoc(rawHtml: string) {
- const content = (rawHtml || '').trim();
- if (!content) {
- return '';
- }
-
- // 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。
- if (/]/i.test(content)) {
- return injectShowHtmlSandboxGuards(content);
- }
-
- return `
-
-
-
-
- ${buildShowHtmlSandboxInjection()}
-
-
- ${content}
-`;
-}
-
function escapeHtml(input: string) {
return input
.replace(/&/g, '&')
@@ -505,16 +308,6 @@ 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;
@@ -650,135 +443,6 @@ function openShowHtmlCardMenu(anchor: HTMLElement, wrapper: HTMLElement) {
showHtmlCardMenuAnchor = anchor;
}
-// ===== show_html 全屏预览(应用内 overlay,复用沙箱 srcdoc,不走路由) =====
-interface ShowHtmlFullscreenState {
- root: HTMLElement;
- iframe: HTMLIFrameElement;
- payload: ShowHtmlFullscreenPayload | null;
-}
-
-const SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC =
- '';
-
-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 上,按钮与菜单只创建一次。
diff --git a/static/src/components/chat/ShowFileCard.vue b/static/src/components/chat/ShowFileCard.vue
index ba44907e..6b330be4 100644
--- a/static/src/components/chat/ShowFileCard.vue
+++ b/static/src/components/chat/ShowFileCard.vue
@@ -4,6 +4,15 @@
{{ displayName }}
+
@@ -69,6 +78,8 @@
import { ref, computed, onMounted } from 'vue';
import { highlightCode, prismLangForPath } from '@/utils/prismHighlight';
import { renderMarkdown } from '../../composables/useMarkdownRenderer';
+import { buildShowHtmlIframeSrcdoc } from '@/utils/showHtmlSandbox';
+import { openShowHtmlFullscreen } from '@/utils/showHtmlFullscreen';
import PdfPreview from './PdfPreview.vue';
// 文本类文件内容缓存,避免父组件反复重建卡片时重复 fetch 导致闪烁
@@ -105,6 +116,7 @@ const error = ref('');
const rawContent = ref('');
const copied = ref(false);
const contentUrl = ref('');
+const previewLoading = ref(false);
// ---- 计算属性 ----
const fileType = computed(() => props.type || inferShowFileType(props.path));
@@ -119,6 +131,9 @@ const canCopy = computed(() =>
['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value)
);
+// HTML 文件额外提供全屏预览入口(卡片内仍按代码高亮展示)
+const isHtmlFile = computed(() => /\.html?$/i.test(props.path));
+
const isAndroidApp = computed(() => {
return (
typeof (window as any).AndroidDownloadBridge !== 'undefined' ||
@@ -251,6 +266,72 @@ function buildContentUrl() {
return `/api/file/content?path=${encodeURIComponent(props.path)}`;
}
+/**
+ * 检测 HTML 中的外部资源引用(src/href)。
+ * srcdoc iframe 无基础 URL,相对路径与根路径引用都会解析失败;
+ * 绝对 URL(含协议相对)与 data/blob/锚点不受影响。
+ */
+function detectExternalResourceRefs(html: string): boolean {
+ const attrPattern = /\b(?:src|href)\s*=\s*["']([^"']*)["']/gi;
+ let match: RegExpExecArray | null;
+ while ((match = attrPattern.exec(html))) {
+ const url = (match[1] || '').trim();
+ if (!url || url.startsWith('#')) continue;
+ if (/^(https?:)?\/\//i.test(url)) continue;
+ if (/^(data|blob|mailto|tel|javascript):/i.test(url)) continue;
+ return true;
+ }
+ return false;
+}
+
+/**
+ * 全屏预览 HTML 文件(V1:面向自包含单文件)。
+ * 与 show_html js=on 卡片同策略:注入 CSP/守卫脚本,沙箱 allow-scripts。
+ * 检测到外部资源引用时在顶栏提示「可能显示不完整」。
+ */
+async function openHtmlPreview() {
+ if (previewLoading.value) return;
+ previewLoading.value = true;
+ error.value = '';
+ try {
+ let content = rawContent.value || getCachedShowFileContent(props.path) || '';
+ if (!content) {
+ const resp = await fetch(buildContentUrl());
+ if (!resp.ok) {
+ let msg = resp.statusText;
+ try {
+ const j = await resp.json();
+ msg = j.error || msg;
+ } catch {
+ // 响应体非 JSON 时沿用 statusText
+ }
+ error.value = msg || '加载失败';
+ return;
+ }
+ content = await resp.text();
+ setCachedShowFileContent(props.path, content);
+ // 顺手填充卡片代码区内容,保持两处一致
+ rawContent.value = content;
+ }
+ if (!content.trim()) {
+ error.value = '文件内容为空';
+ return;
+ }
+ openShowHtmlFullscreen({
+ srcdoc: buildShowHtmlIframeSrcdoc(content),
+ allowScripts: true,
+ title: displayName.value,
+ notice: detectExternalResourceRefs(content)
+ ? '含外部资源引用,预览可能不完整'
+ : undefined
+ });
+ } catch (e) {
+ error.value = (e as Error).message || '网络错误';
+ } finally {
+ previewLoading.value = false;
+ }
+}
+
async function loadContent() {
if (!canPreview.value) return;
error.value = '';
@@ -428,6 +509,16 @@ onMounted(() => {
background: var(--surface-muted);
border-color: var(--border-strong);
}
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: default;
+
+ &:hover {
+ background: var(--surface-base);
+ border-color: var(--border-default);
+ }
+ }
}
.sfc-btn-download {
diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss
index f3fa58c9..40f5a997 100644
--- a/static/src/styles/components/chat/_chat-area.scss
+++ b/static/src/styles/components/chat/_chat-area.scss
@@ -1901,6 +1901,15 @@ html.show-html-fullscreen-open body {
border-bottom: 1px solid var(--border-default);
}
+/* 标题 + 提示小字容器:标题优先收缩省略,提示文字更弱一级 */
+.show-html-fullscreen__heading {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+}
+
.show-html-fullscreen__title {
font-size: 13px;
color: var(--text-secondary);
@@ -1908,6 +1917,17 @@ html.show-html-fullscreen-open body {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.show-html-fullscreen__notice {
+ flex: 0 1 auto;
+ font-size: 12px;
+ color: var(--text-tertiary);
+ line-height: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.show-html-fullscreen__actions {
diff --git a/static/src/utils/showHtmlFullscreen.ts b/static/src/utils/showHtmlFullscreen.ts
new file mode 100644
index 00000000..260ce8a4
--- /dev/null
+++ b/static/src/utils/showHtmlFullscreen.ts
@@ -0,0 +1,185 @@
+/**
+ * show_html 全屏预览 overlay(应用内 fixed 层,不走浏览器 Fullscreen API)。
+ * 从 app/bootstrap.ts 抽取为全局单例,供 HTML 卡片与文件卡片(ShowFileCard)共用:
+ * - js="on" 卡片:直接复用已注入 CSP/守卫的 srcdoc,沙箱允许脚本
+ * - js="off" 卡片:用 sanitize 后的 HTML 现场构建 srcdoc,沙箱禁脚本(与卡片内语义一致)
+ * - HTML 文件预览:与 js="on" 卡片同策略(注入 CSP/守卫,allow-scripts)
+ */
+import { EMPTY_SHOW_HTML_SRCDOC } from './showHtmlSandbox';
+
+export interface ShowHtmlFullscreenPayload {
+ srcdoc: string;
+ allowScripts: boolean;
+ /** 顶栏标题(如文件名),缺省显示「全屏预览」 */
+ title?: string;
+ /** 顶栏标题旁的提示小字(如「含外部资源引用,预览可能不完整」),缺省隐藏 */
+ notice?: string;
+}
+
+interface ShowHtmlFullscreenState {
+ root: HTMLElement;
+ iframe: HTMLIFrameElement;
+ titleEl: HTMLElement;
+ noticeEl: HTMLElement;
+ payload: ShowHtmlFullscreenPayload | null;
+}
+
+// 与 bootstrap.ts 的 showHtml 调试通道一致:window.__SHOW_HTML_DEBUG__ 或
+// localStorage.showHtmlDebug = '1' 开启,统一 [SHOW_HTML_DEBUG] 前缀便于一次性筛选
+const SHOW_HTML_DEBUG_MAX_LOGS = 500;
+let debugLogCount = 0;
+
+function isDebugEnabled(): boolean {
+ if (typeof window === 'undefined') return false;
+ try {
+ const explicitFlag = (window as any).__SHOW_HTML_DEBUG__;
+ if (explicitFlag === true || explicitFlag === '1') return true;
+ if (explicitFlag === false || explicitFlag === '0') return false;
+ const localFlag = window.localStorage?.getItem('showHtmlDebug');
+ return localFlag === '1' || localFlag === 'true';
+ } catch {
+ return false;
+ }
+}
+
+function debugLog(event: string, payload: Record = {}) {
+ if (!isDebugEnabled()) return;
+ if (debugLogCount >= SHOW_HTML_DEBUG_MAX_LOGS) return;
+ debugLogCount += 1;
+ if (debugLogCount === 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);
+}
+
+let showHtmlFullscreenState: ShowHtmlFullscreenState | null = null;
+let showHtmlFullscreenEventsBound = false;
+
+export 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 heading = document.createElement('div');
+ heading.className = 'show-html-fullscreen__heading';
+ const title = document.createElement('span');
+ title.className = 'show-html-fullscreen__title';
+ title.textContent = '全屏预览';
+ const notice = document.createElement('span');
+ notice.className = 'show-html-fullscreen__notice';
+ notice.style.display = 'none';
+ heading.appendChild(title);
+ heading.appendChild(notice);
+ 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(heading);
+ 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 = EMPTY_SHOW_HTML_SRCDOC;
+ requestAnimationFrame(() => {
+ if (showHtmlFullscreenState?.payload) {
+ showHtmlFullscreenState.iframe.srcdoc = keep;
+ }
+ });
+ debugLog('fullscreen:refresh', { srcdocLength: keep.length });
+ };
+ closeBtn.onclick = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ closeShowHtmlFullscreen();
+ };
+
+ document.body.appendChild(root);
+ showHtmlFullscreenState = { root, iframe, titleEl: title, noticeEl: notice, payload: null };
+ return showHtmlFullscreenState;
+}
+
+export function openShowHtmlFullscreen(payload: ShowHtmlFullscreenPayload) {
+ if (typeof document === 'undefined') return;
+ const state = ensureShowHtmlFullscreenDom();
+ state.payload = payload;
+ state.titleEl.textContent = payload.title || '全屏预览';
+ const noticeText = (payload.notice || '').trim();
+ state.noticeEl.textContent = noticeText;
+ state.noticeEl.style.display = noticeText ? '' : 'none';
+ // 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');
+ debugLog('fullscreen:open', {
+ allowScripts: payload.allowScripts,
+ srcdocLength: payload.srcdoc.length,
+ hasNotice: !!noticeText
+ });
+}
+
+export 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 = EMPTY_SHOW_HTML_SRCDOC;
+ debugLog('fullscreen:close', {});
+}
diff --git a/static/src/utils/showHtmlSandbox.ts b/static/src/utils/showHtmlSandbox.ts
new file mode 100644
index 00000000..0d1dea97
--- /dev/null
+++ b/static/src/utils/showHtmlSandbox.ts
@@ -0,0 +1,211 @@
+/**
+ * show_html 沙箱与 srcdoc 构建工具(纯函数,无 DOM 依赖)。
+ * 从 app/bootstrap.ts 抽取,供 HTML 卡片渲染管线与文件卡片(ShowFileCard)全屏预览共用。
+ */
+
+/**
+ * show_html js=on 沙箱 CSP(通过 srcdoc 内 交付,必须位于任何资源引用之前)。
+ * - 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 交付。
+ */
+export 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 递归修补以收窄“干净原型”绕过窗口。
+ * 已知边界:不防“针对本守卫的确定性绕过”(威胁模型为提示注入生成的一般恶意内容)。
+ */
+export 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 必须早于任何资源引用,守卫脚本必须早于任何卡片脚本 */
+export function buildShowHtmlSandboxInjection(): string {
+ return ``;
+}
+
+/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 最前(无 head 则补一个) */
+export function injectShowHtmlSandboxGuards(doc: string): string {
+ const injection = buildShowHtmlSandboxInjection();
+ const headMatch = /]*>/i.exec(doc);
+ if (headMatch) {
+ const idx = headMatch.index + headMatch[0].length;
+ return doc.slice(0, idx) + injection + doc.slice(idx);
+ }
+ const htmlMatch = /]*>/i.exec(doc);
+ if (htmlMatch) {
+ const idx = htmlMatch.index + htmlMatch[0].length;
+ return doc.slice(0, idx) + `${injection}` + doc.slice(idx);
+ }
+ return injection + doc;
+}
+
+/** 空 srcdoc:刷新/关闭全屏时挂载,强制 iframe 内页面重载并释放脚本与内存 */
+export const EMPTY_SHOW_HTML_SRCDOC =
+ '';
+
+export function buildShowHtmlIframeSrcdoc(rawHtml: string): string {
+ const content = (rawHtml || '').trim();
+ if (!content) {
+ return EMPTY_SHOW_HTML_SRCDOC;
+ }
+
+ // 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。
+ if (/]/i.test(content)) {
+ return injectShowHtmlSandboxGuards(content);
+ }
+
+ return `
+
+
+
+
+ ${buildShowHtmlSandboxInjection()}
+
+
+ ${content}
+`;
+}