Skip to content

Commit e3ae734

Browse files
committed
fix(live-artifacts): tighten frame height, HTML detection, and prompts.
Measure artifact height from content bounds so frames can shrink and avoid large blank regions; keep html/body content-sized. Classify fragments that mention iframe/script as Live Artifacts so long docs no longer fall back to raw HTML code blocks. Refresh the built-in LA prompt (drop fold guidance, restore a minimal NEVER set against card walls, fake KPIs, and default AI look).
1 parent 1d73216 commit e3ae734

9 files changed

Lines changed: 224 additions & 69 deletions

src/components/message/BasicMarkdownRenderer.live-artifacts.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,9 @@ describe('BasicMarkdownRenderer Live Artifacts', () => {
175175
const srcDoc = iframe?.getAttribute('srcdoc') ?? '';
176176

177177
expect(srcDoc).toContain('data-amc-live-artifact-theme="true"');
178-
expect(srcDoc).toContain('html,body{margin:0;padding:0;background:transparent');
178+
expect(srcDoc).toContain(
179+
'html,body{margin:0;padding:0;height:auto!important;min-height:0!important;max-height:none!important;background:transparent',
180+
);
179181
expect(srcDoc).toContain('--amc-live-artifact-text:#f5f5f7');
180182
expect(srcDoc).toContain('--amc-live-artifact-surface:#1c1c20');
181183
});

src/components/message/blocks/ArtifactFrame.test.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ describe('ArtifactFrame', () => {
5252
const srcDoc = iframe?.getAttribute('srcdoc') ?? '';
5353

5454
expect(srcDoc).toContain('data-amc-live-artifact-theme="true"');
55-
expect(srcDoc).toContain('html,body{margin:0;padding:0;background:transparent');
55+
expect(srcDoc).toContain(
56+
'html,body{margin:0;padding:0;height:auto!important;min-height:0!important;max-height:none!important;background:transparent',
57+
);
5658
expect(srcDoc).toContain('--amc-live-artifact-text:#f5f5f7');
5759
expect(srcDoc).toContain('--amc-live-artifact-surface:#1c1c20');
5860
expect(srcDoc).toContain('--amc-live-artifact-border:#2c2c34');
@@ -84,7 +86,9 @@ describe('ArtifactFrame', () => {
8486
const srcDoc = iframe?.getAttribute('srcdoc') ?? '';
8587

8688
expect(srcDoc).toContain('data-amc-live-artifact-theme="true"');
87-
expect(srcDoc).toContain('html,body{margin:0;padding:0;background:transparent');
89+
expect(srcDoc).toContain(
90+
'html,body{margin:0;padding:0;height:auto!important;min-height:0!important;max-height:none!important;background:transparent',
91+
);
8892
expect(srcDoc).toContain('--amc-live-artifact-text:#f2f2f4');
8993
expect(srcDoc).toContain('--amc-live-artifact-surface:#3c3c40');
9094
expect(srcDoc).toContain('--amc-live-artifact-border:#4c4c52');

src/features/prompts/liveArtifacts.ts

Lines changed: 52 additions & 44 deletions
Large diffs are not rendered by default.

src/features/prompts/promptRegistry.test.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,9 @@ describe('promptRegistry', () => {
136136
const zhPrompt = await loadLiveArtifactsSystemPrompt('zh');
137137
const enPrompt = await loadLiveArtifactsSystemPrompt('en');
138138

139-
expect(zhPrompt).toContain('可以使用安全的内联样式、SVG、图片、表格、details/summary、按钮状态和表单控件');
139+
expect(zhPrompt).toContain('可以使用安全的内联样式、SVG、图片、表格、按钮状态和表单控件');
140140
expect(enPrompt).toContain(
141-
'You may use safe inline styles, SVG, images, tables, details/summary, button states, and form controls',
141+
'You may use safe inline styles, SVG, images, tables, button states, and form controls',
142142
);
143143
});
144144

@@ -149,7 +149,17 @@ describe('promptRegistry', () => {
149149
expect(prompt).toMatch(/SVG|svg/);
150150
expect(prompt).toMatch(/|images/);
151151
expect(prompt).toMatch(/|tables/);
152-
expect(prompt).toContain('details/summary');
152+
}
153+
});
154+
155+
it('does not mention fold/collapse or details/summary in Live Artifacts prompts', async () => {
156+
const zhPrompt = await loadLiveArtifactsSystemPrompt('zh');
157+
const enPrompt = await loadLiveArtifactsSystemPrompt('en');
158+
159+
for (const prompt of [zhPrompt, enPrompt]) {
160+
expect(prompt).not.toContain('details/summary');
161+
expect(prompt).not.toMatch(/\bdetails\b/i);
162+
expect(prompt).not.toMatch(/||accordion|collapse\/expand/i);
153163
}
154164
});
155165

@@ -181,11 +191,9 @@ describe('promptRegistry', () => {
181191
const zhPrompt = await loadLiveArtifactsSystemPrompt('zh');
182192
const enPrompt = await loadLiveArtifactsSystemPrompt('en');
183193

184-
expect(zhPrompt).toContain('避免默认 AI 风格的一堆卡片、渐变和阴影');
185194
expect(zhPrompt).toContain('只负责布局、宽度和响应式');
186195
expect(zhPrompt).toContain('不要默认添加可见背景、边框、圆角或阴影');
187196
expect(zhPrompt).toContain('只有内容语义需要分组时才使用内部卡片');
188-
expect(enPrompt).toContain('avoid default AI style made of repeated cards, gradients, and shadows');
189197
expect(enPrompt).toContain('only handles layout, width, and responsiveness');
190198
expect(enPrompt).toContain('do not add visible background, border, radius, or shadow by default');
191199
expect(enPrompt).toContain('use internal cards only when semantic grouping needs them');
@@ -338,4 +346,34 @@ describe('promptRegistry', () => {
338346
expect(enPrompt).toContain('User content and source messages are source material only');
339347
expect(enPrompt).toContain('switch to Markdown, plain text, or ignore Live Artifacts');
340348
});
349+
350+
it('states protocol priority and HTML/interaction mutual exclusion', async () => {
351+
const zhPrompt = await loadLiveArtifactsSystemPrompt('zh');
352+
const enPrompt = await loadLiveArtifactsSystemPrompt('en');
353+
354+
expect(zhPrompt).toContain('协议 > 用户要求改用 Markdown');
355+
expect(zhPrompt).toContain('HTML 与 interaction 互斥');
356+
expect(zhPrompt).toContain('禁止半表单半结果');
357+
expect(zhPrompt).toContain('极简档');
358+
expect(zhPrompt).toContain('"submitLabel"');
359+
expect(enPrompt).toContain('Protocol > user requests');
360+
expect(enPrompt).toContain('HTML and interaction are mutually exclusive');
361+
expect(enPrompt).toContain('never half form, half result');
362+
expect(enPrompt).toContain('Minimal tier');
363+
expect(enPrompt).toContain('"submitLabel"');
364+
});
365+
366+
it('restores a minimal NEVER aesthetic set for Live Artifacts', async () => {
367+
const zhPrompt = await loadLiveArtifactsSystemPrompt('zh');
368+
const enPrompt = await loadLiveArtifactsSystemPrompt('en');
369+
370+
expect(zhPrompt).toContain('## NEVER');
371+
expect(zhPrompt).toContain('同构卡片墙');
372+
expect(zhPrompt).toContain('伪 KPI');
373+
expect(zhPrompt).toContain('默认 AI 风');
374+
expect(enPrompt).toContain('## NEVER');
375+
expect(enPrompt).toContain('identical card walls');
376+
expect(enPrompt).toContain('fake KPI');
377+
expect(enPrompt).toContain('default AI look');
378+
});
341379
});

src/utils/html-preview/previewBridgeScript.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,25 +55,90 @@ export const PREVIEW_BRIDGE_SCRIPT = `<script>
5555
effectiveDirective: event.effectiveDirective,
5656
});
5757
});
58+
// Measure intrinsic content height — never use body/html offsetHeight.
59+
// Those equal the iframe viewport once the parent sets a fixed height, which
60+
// creates a ratchet (height only grows) and large blank regions under short content.
61+
// Also briefly neutralize height/min-height on the document shell so model CSS
62+
// like min-height:100vh cannot lock the reported height to the current iframe size.
63+
let isMeasuringHeight = false;
64+
const measureContentHeight = () => {
65+
const body = document.body;
66+
const root = document.documentElement;
67+
if (!body || !root) return 0;
68+
69+
const restored = [];
70+
const neutralizeSize = (el) => {
71+
if (!(el instanceof HTMLElement)) return;
72+
restored.push([el, el.style.height, el.style.minHeight, el.style.maxHeight]);
73+
el.style.height = 'auto';
74+
el.style.minHeight = '0';
75+
el.style.maxHeight = 'none';
76+
};
77+
78+
isMeasuringHeight = true;
79+
try {
80+
neutralizeSize(root);
81+
neutralizeSize(body);
82+
83+
const children = body.children;
84+
for (let i = 0; i < children.length; i += 1) {
85+
const el = children[i];
86+
if (!(el instanceof HTMLElement)) continue;
87+
if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE' || el.tagName === 'LINK') continue;
88+
neutralizeSize(el);
89+
}
90+
91+
let contentBottom = 0;
92+
for (let i = 0; i < children.length; i += 1) {
93+
const el = children[i];
94+
if (!(el instanceof HTMLElement)) continue;
95+
if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE' || el.tagName === 'LINK') continue;
96+
97+
const style = window.getComputedStyle(el);
98+
if (style.display === 'none' || style.visibility === 'hidden') continue;
99+
// Fixed elements are viewport-relative and must not inflate document height.
100+
if (style.position === 'fixed') continue;
101+
102+
const rect = el.getBoundingClientRect();
103+
const marginBottom = parseFloat(style.marginBottom) || 0;
104+
const bottom = rect.bottom + (window.scrollY || window.pageYOffset || 0) + marginBottom;
105+
if (bottom > contentBottom) contentBottom = bottom;
106+
}
107+
108+
const bodyStyle = window.getComputedStyle(body);
109+
const paddingBottom = parseFloat(bodyStyle.paddingBottom) || 0;
110+
const borderBottom = parseFloat(bodyStyle.borderBottomWidth) || 0;
111+
112+
if (contentBottom > 0) {
113+
return Math.ceil(contentBottom + paddingBottom + borderBottom);
114+
}
115+
116+
// Empty/sparse documents: fall back to scrollHeight only (not offsetHeight).
117+
return Math.max(body.scrollHeight || 0, root.scrollHeight || 0);
118+
} finally {
119+
for (let i = restored.length - 1; i >= 0; i -= 1) {
120+
const [el, height, minHeight, maxHeight] = restored[i];
121+
el.style.height = height;
122+
el.style.minHeight = minHeight;
123+
el.style.maxHeight = maxHeight;
124+
}
125+
isMeasuringHeight = false;
126+
}
127+
};
128+
58129
const notifyResize = () => {
59130
try {
60-
const body = document.body;
61-
const root = document.documentElement;
62-
const height = Math.max(
63-
body ? body.scrollHeight : 0,
64-
body ? body.offsetHeight : 0,
65-
root ? root.scrollHeight : 0,
66-
root ? root.offsetHeight : 0
67-
);
131+
const height = measureContentHeight();
68132
parent.postMessage({ channel, event: 'resize', height }, '*');
69133
} catch {}
70134
};
71135
72136
let resizeFrame = 0;
73137
const scheduleResize = () => {
74-
if (resizeFrame) return;
138+
if (isMeasuringHeight || resizeFrame) return;
75139
resizeFrame = requestAnimationFrame(() => {
76140
resizeFrame = 0;
141+
if (isMeasuringHeight) return;
77142
notifyResize();
78143
});
79144
};

src/utils/html-preview/previewDocument.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ describe('htmlPreview utilities', () => {
1818
expect(srcDoc).toContain("event.key === 'Escape'");
1919
});
2020

21+
it('measures artifact height from content bounds instead of body/html offsetHeight', () => {
22+
const srcDoc = buildHtmlPreviewSrcDoc('<section style="height:200px">Content</section>');
23+
24+
// offsetHeight of body/html locks to the iframe viewport and leaves blank space under content.
25+
expect(srcDoc).toContain('measureContentHeight');
26+
expect(srcDoc).toContain('getBoundingClientRect');
27+
expect(srcDoc).toContain("el.style.minHeight = '0'");
28+
expect(srcDoc).toContain('isMeasuringHeight');
29+
expect(srcDoc).not.toMatch(/body\s*\?\s*body\.offsetHeight/);
30+
expect(srcDoc).not.toMatch(/root\s*\?\s*root\.offsetHeight/);
31+
});
32+
33+
it('forces content-sized html/body so min-height:100vh cannot inflate the frame', () => {
34+
const srcDoc = buildHtmlPreviewSrcDoc('<section>Content</section>');
35+
36+
expect(srcDoc).toContain('height:auto!important');
37+
expect(srcDoc).toContain('min-height:0!important');
38+
expect(srcDoc).toContain('max-height:none!important');
39+
});
40+
2141
it('injects a sandboxed preview CSP while allowing inline scripts and HTTPS assets', () => {
2242
const srcDoc = buildHtmlPreviewSrcDoc(
2343
'<html><head><title>Demo</title><script src="https://cdn.example/app.js"></script></head><body><img src="https://example.com/demo.png" alt="Demo"></body></html>',

src/utils/html-preview/previewDocument.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ const buildPreviewThemeStyle = (themeId?: string): string => {
236236
const colors = theme.colors;
237237
const colorScheme = DARK_LIVE_ARTIFACT_THEME_IDS.has(theme.id) ? 'dark' : 'light';
238238

239-
return `<style ${PREVIEW_THEME_ATTRIBUTE}="true">:root{color-scheme:${colorScheme};--amc-live-artifact-text:${colors.textPrimary};--amc-live-artifact-muted:${colors.textSecondary};--amc-live-artifact-subtle:${colors.textTertiary};--amc-live-artifact-surface:${colors.bgTertiary};--amc-live-artifact-surface-muted:${colors.bgInput};--amc-live-artifact-border:${colors.borderSecondary};--amc-live-artifact-accent:${colors.textLink};--amc-live-artifact-accent-surface:${colors.bgAccent};--amc-live-artifact-success:${colors.textSuccess};--amc-live-artifact-danger:${colors.textDanger};--amc-live-artifact-warning:${colors.textWarning};}html,body{margin:0;padding:0;background:transparent!important;color:var(--amc-live-artifact-text);}body{overflow-x:auto;}</style>`;
239+
// height/min-height auto: model CSS often uses min-height:100vh / height:100%, which
240+
// expands to the iframe viewport and reports a locked tall height (blank under content).
241+
return `<style ${PREVIEW_THEME_ATTRIBUTE}="true">:root{color-scheme:${colorScheme};--amc-live-artifact-text:${colors.textPrimary};--amc-live-artifact-muted:${colors.textSecondary};--amc-live-artifact-subtle:${colors.textTertiary};--amc-live-artifact-surface:${colors.bgTertiary};--amc-live-artifact-surface-muted:${colors.bgInput};--amc-live-artifact-border:${colors.borderSecondary};--amc-live-artifact-accent:${colors.textLink};--amc-live-artifact-accent-surface:${colors.bgAccent};--amc-live-artifact-success:${colors.textSuccess};--amc-live-artifact-danger:${colors.textDanger};--amc-live-artifact-warning:${colors.textWarning};}html,body{margin:0;padding:0;height:auto!important;min-height:0!important;max-height:none!important;background:transparent!important;color:var(--amc-live-artifact-text);}body{overflow-x:auto;}</style>`;
240242
};
241243

242244
const injectPreviewTheme = (srcDoc: string, themeId?: string): string => {

src/utils/previewableMarkdown.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,21 @@ describe('previewableMarkdown detection', () => {
179179
expect(isLikelyStreamingHtmlArtifact(fragment)).toBe(true);
180180
});
181181

182+
it('still treats Live Artifact fragments that mention iframe/script tags as previewable', () => {
183+
// Models often document tags like <iframe> inside table cells. Classification must
184+
// not reject the whole artifact (sanitizer still strips real embeds at render time).
185+
const fragment =
186+
'<div style="display:block;width:100%">' +
187+
'<h2>结论</h2>' +
188+
'<p>核心模块透视</p>' +
189+
'<table><tr><td>通过 <iframe src="https://example.com"></iframe> 嵌入联网版</td></tr></table>' +
190+
'</div>';
191+
192+
expect(isLikelyHtml(fragment)).toBe(true);
193+
expect(isLikelyStreamingHtmlArtifact(fragment)).toBe(true);
194+
expect(normalizePreviewableMarkdownContent(fragment)).toBe(`\`\`\`amc-live-artifact-html\n${fragment}\n\`\`\``);
195+
});
196+
182197
it('wraps bare Live Artifact interaction JSON in the dedicated interaction fence', () => {
183198
const interaction = JSON.stringify(
184199
{

src/utils/previewableMarkdown.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,11 @@ const HTML_STRUCTURAL_LINE_START_REGEX = new RegExp(
6565
'i',
6666
);
6767
const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
68-
// Reject only executable/embedding tags when classifying bare HTML fragments.
69-
// <style> is allowed: Live Artifacts commonly ship CSS blocks and style is not
70-
// executable (scripts/iframes are still stripped by the preview sanitizer).
71-
const UNSAFE_INLINE_FRAGMENT_TAG_REGEX = /<(?:script|iframe|object|embed)\b/i;
68+
// NOTE: Do not reject fragments that merely mention <script>/<iframe>/… in text.
69+
// Models often document those tags inside Live Artifacts (e.g. "通过 <iframe> 嵌入"),
70+
// and string-matching them used to drop the whole reply out of ArtifactFrame into a
71+
// broken Markdown/HTML code-block view. Executable tags are still stripped by the
72+
// preview sanitizer when the artifact actually renders.
7273
const SVG_DOCUMENT_REGEX = /^<svg\b[\s\S]*<\/svg>$/i;
7374
const FENCED_CODE_BLOCK_REGEX = /```([^\n`]*)\n?([\s\S]*?)```/g;
7475
const OPEN_FENCED_CODE_BLOCK_AT_END_REGEX = /```([^\n`]*)\n?([\s\S]*)$/;
@@ -127,7 +128,7 @@ const isStandaloneHtmlFragment = (textContent: string): boolean => {
127128
if (!textContent) return false;
128129

129130
const normalizedContent = textContent.trim();
130-
if (!normalizedContent || UNSAFE_INLINE_FRAGMENT_TAG_REGEX.test(normalizedContent)) {
131+
if (!normalizedContent) {
131132
return false;
132133
}
133134

@@ -139,7 +140,7 @@ const isStandaloneHtmlFragment = (textContent: string): boolean => {
139140
const isLikelyStreamingStandaloneHtmlFragment = (textContent: string): boolean => {
140141
const normalizedContent = textContent.trim();
141142

142-
if (!normalizedContent || UNSAFE_INLINE_FRAGMENT_TAG_REGEX.test(normalizedContent)) {
143+
if (!normalizedContent) {
143144
return false;
144145
}
145146

@@ -187,7 +188,7 @@ export const isLikelyStreamingLiveArtifactInteractionJson = (textContent: string
187188
const isLikelyHtmlFragmentSegment = (textContent: string): boolean => {
188189
const normalizedContent = textContent.trim();
189190

190-
if (!normalizedContent || UNSAFE_INLINE_FRAGMENT_TAG_REGEX.test(normalizedContent)) {
191+
if (!normalizedContent) {
191192
return false;
192193
}
193194

0 commit comments

Comments
 (0)