Commit d4a8fbfd by luoqi

fix(assistant): artifact 流式空白 — 改持久壳 iframe + postMessage 注入(不重载)

上版每次流式增量都换 srcDoc → iframe 整段重载 → 反复重新下载 Tailwind/Chart.js CDN,
还没加载完下一帧又来 → 整个流式期间一直"重载中"=空白(用户实测空白)。

改为持久壳方案:
- iframe 只加载一次(srcDoc=固定壳:CSP+Tailwind+Chart.js+#root+消息监听),CDN 只下一次;
- 内容通过 postMessage 注入 #root.innerHTML(Tailwind 的 MutationObserver 自动给新内容上样式)→
  流式内容实时"长出",不重载、不白闪;
- Artifact 加 streaming 标志:流式中只 set innerHTML(图表脚本不执行,避免半截脚本报错);
  最终 tool_call(streaming=false)时重建 <script> 节点 → Chart.js 执行、图表渲染。
- 高度由 iframe 内 ResizeObserver 实时回传(initial 120,随内容增长),不再是空大白框。
- openFull 用自包含整页(buildStandaloneDoc)。

web tsc 0。纯前端改动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 5737df69
...@@ -195,65 +195,103 @@ function BlockView({ block }: { block: Block }) { ...@@ -195,65 +195,103 @@ function BlockView({ block }: { block: Block }) {
// 安全:sandbox="allow-scripts" 不含 allow-same-origin → 脚本在 null origin, // 安全:sandbox="allow-scripts" 不含 allow-same-origin → 脚本在 null origin,
// 碰不到父页 cookie/localStorage、无法带凭证调 PAC;CSP 只放行 Tailwind/Chart.js CDN // 碰不到父页 cookie/localStorage、无法带凭证调 PAC;CSP 只放行 Tailwind/Chart.js CDN
// 且 connect-src 'none' 堵死任何外传(患者数据不会被偷渡)。 // 且 connect-src 'none' 堵死任何外传(患者数据不会被偷渡)。
function buildArtifactDoc(inner: string): string { const ARTIFACT_CSP = [
const csp = [ "default-src 'none'",
"default-src 'none'", "script-src 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net",
"script-src 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net", "style-src 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com",
"style-src 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com", 'font-src https://fonts.gstatic.com',
'font-src https://fonts.gstatic.com', 'img-src data:',
'img-src data:', "connect-src 'none'",
"connect-src 'none'", "base-uri 'none'",
"base-uri 'none'", "form-action 'none'",
"form-action 'none'", ].join('; ');
].join('; ');
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"> const ARTIFACT_HEAD = `<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${csp}"> <meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CSP}">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>body{margin:0;font-family:"PingFang SC","Noto Sans CJK SC",system-ui,sans-serif;background:#f8fafc;color:#0f172a}</style> <style>body{margin:0;font-family:"PingFang SC","Noto Sans CJK SC",system-ui,sans-serif;background:#f8fafc;color:#0f172a}</style>`;
</head><body class="p-4">${inner}
// 持久壳:iframe 只加载一次(Tailwind/Chart.js 不重载),内容经 postMessage 注入 #root。
// 流式中只 set innerHTML(Tailwind 的 MutationObserver 自动上样式);final 时重建 <script> 让 Chart.js 执行。
function artifactShell(): string {
return `<!DOCTYPE html><html lang="zh"><head>${ARTIFACT_HEAD}</head><body class="p-4"><div id="root"></div>
<script> <script>
(function(){function r(){parent.postMessage({__artifactHeight:document.documentElement.scrollHeight},'*');} (function(){
new ResizeObserver(r).observe(document.body);window.addEventListener('load',r);setTimeout(r,300);setTimeout(r,1200);})(); var root=document.getElementById('root');
function h(){parent.postMessage({__artifactHeight:document.documentElement.scrollHeight},'*');}
new ResizeObserver(h).observe(document.body);
window.addEventListener('message',function(e){
var d=e.data||{}; if(!d.__artifact)return;
root.innerHTML=d.html||'';
if(d.final){root.querySelectorAll('script').forEach(function(o){var s=document.createElement('script');for(var i=0;i<o.attributes.length;i++){s.setAttribute(o.attributes[i].name,o.attributes[i].value);}s.textContent=o.textContent;o.parentNode.replaceChild(s,o);});}
setTimeout(h, d.final?150:0);
});
parent.postMessage({__ready:true},'*');
})();
</script> </script>
</body></html>`; </body></html>`;
} }
// openFull / 兜底:自包含整页(内容内联,脚本随页面加载执行)。
function buildStandaloneDoc(inner: string): string {
return `<!DOCTYPE html><html lang="zh"><head>${ARTIFACT_HEAD}</head><body class="p-4">${inner}</body></html>`;
}
function ArtifactView({ artifact }: { artifact: Artifact }) { function ArtifactView({ artifact }: { artifact: Artifact }) {
const ref = useRef<HTMLIFrameElement>(null); const ref = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(220); const [height, setHeight] = useState(120);
// 流式更新节流:每个增量都重载 iframe 会白闪 + Tailwind 反复重扫。最多 ~600ms 渲一次,末值兜底。 const shell = useMemo(() => artifactShell(), []);
const [renderHtml, setRenderHtml] = useState(artifact.html); const readyRef = useRef(false);
const lastRef = useRef(0); const htmlRef = useRef(artifact.html);
const finalRef = useRef(!artifact.streaming);
const lastPostRef = useRef(0);
const post = () => {
const win = ref.current?.contentWindow;
if (!win || !readyRef.current) return;
win.postMessage({ __artifact: true, html: htmlRef.current, final: finalRef.current }, '*');
lastPostRef.current = Date.now();
};
// 内容/状态变化 → postMessage 注入(流式 500ms 节流;final 立即,触发图表脚本)。
useEffect(() => { useEffect(() => {
const since = Date.now() - lastRef.current; htmlRef.current = artifact.html;
if (since >= 600) { finalRef.current = !artifact.streaming;
lastRef.current = Date.now(); if (!readyRef.current) return;
setRenderHtml(artifact.html); if (finalRef.current) {
post();
return;
}
const since = Date.now() - lastPostRef.current;
if (since >= 500) {
post();
return; return;
} }
const t = setTimeout(() => { const t = setTimeout(post, 500 - since);
lastRef.current = Date.now();
setRenderHtml(artifact.html);
}, 600 - since);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [artifact.html]); // eslint-disable-next-line react-hooks/exhaustive-deps
const doc = useMemo(() => buildArtifactDoc(renderHtml), [renderHtml]); }, [artifact.html, artifact.streaming]);
useEffect(() => { useEffect(() => {
const onMsg = (e: MessageEvent) => { const onMsg = (e: MessageEvent) => {
if (e.source !== ref.current?.contentWindow) return; if (e.source !== ref.current?.contentWindow) return;
const h = (e.data as { __artifactHeight?: number })?.__artifactHeight; const d = e.data as { __ready?: boolean; __artifactHeight?: number };
if (typeof h === 'number') setHeight(Math.min(Math.max(h, 80), 1400)); if (d?.__ready) {
readyRef.current = true;
post();
}
if (typeof d?.__artifactHeight === 'number') setHeight(Math.min(Math.max(d.__artifactHeight, 60), 1600));
}; };
window.addEventListener('message', onMsg); window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg); return () => window.removeEventListener('message', onMsg);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const openFull = () => { const openFull = () => {
const w = window.open('', '_blank'); const w = window.open('', '_blank');
w?.document.write(buildArtifactDoc(artifact.html)); w?.document.write(buildStandaloneDoc(artifact.html));
w?.document.close(); w?.document.close();
}; };
...@@ -275,7 +313,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) { ...@@ -275,7 +313,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
ref={ref} ref={ref}
title={artifact.title || 'artifact'} title={artifact.title || 'artifact'}
sandbox="allow-scripts" sandbox="allow-scripts"
srcDoc={doc} srcDoc={shell}
className="w-full" className="w-full"
style={{ height, border: 0 }} style={{ height, border: 0 }}
/> />
......
...@@ -21,6 +21,8 @@ export interface Artifact { ...@@ -21,6 +21,8 @@ export interface Artifact {
id: string; id: string;
title?: string; title?: string;
html: string; html: string;
/** true = 还在流式生成中(只注入内容,图表脚本不执行);false/缺省 = 最终(执行图表脚本)。 */
streaming?: boolean;
} }
export type Block = export type Block =
...@@ -56,10 +58,17 @@ function findToolIdx(blocks: Block[], callId: string | undefined): number { ...@@ -56,10 +58,17 @@ function findToolIdx(blocks: Block[], callId: string | undefined): number {
} }
/** 按 callId upsert artifact 块:流式 html 增量更新,最终 tool_call 覆盖(title+完整 html)。 */ /** 按 callId upsert artifact 块:流式 html 增量更新,最终 tool_call 覆盖(title+完整 html)。 */
function upsertArtifact(blocks: Block[], id: string, patch: { title?: string; html?: string }): Block[] { function upsertArtifact(
blocks: Block[],
id: string,
patch: { title?: string; html?: string; streaming?: boolean },
): Block[] {
const i = blocks.findIndex((b) => b.kind === 'artifact' && b.artifact.id === id); const i = blocks.findIndex((b) => b.kind === 'artifact' && b.artifact.id === id);
if (i === -1) { if (i === -1) {
return [...blocks, { kind: 'artifact', artifact: { id, title: patch.title, html: patch.html ?? '' } }]; return [
...blocks,
{ kind: 'artifact', artifact: { id, title: patch.title, html: patch.html ?? '', streaming: patch.streaming } },
];
} }
return blocks.map((b, j) => { return blocks.map((b, j) => {
if (j !== i || b.kind !== 'artifact') return b; if (j !== i || b.kind !== 'artifact') return b;
...@@ -69,6 +78,7 @@ function upsertArtifact(blocks: Block[], id: string, patch: { title?: string; ht ...@@ -69,6 +78,7 @@ function upsertArtifact(blocks: Block[], id: string, patch: { title?: string; ht
id, id,
title: patch.title ?? b.artifact.title, title: patch.title ?? b.artifact.title,
html: patch.html ?? b.artifact.html, html: patch.html ?? b.artifact.html,
streaming: patch.streaming ?? b.artifact.streaming,
}, },
}; };
}); });
...@@ -141,15 +151,19 @@ export function useAssistantChat() { ...@@ -141,15 +151,19 @@ export function useAssistantChat() {
case 'artifact_html': case 'artifact_html':
// 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。 // 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。
patch((blocks) => patch((blocks) =>
upsertArtifact(blocks, String(evt.id ?? ''), { html: String(evt.html ?? '') }), upsertArtifact(blocks, String(evt.id ?? ''), { html: String(evt.html ?? ''), streaming: true }),
); );
break; break;
case 'tool_call': case 'tool_call':
if (evt.tool === ARTIFACT_TOOL) { if (evt.tool === ARTIFACT_TOOL) {
// 渲染工具最终入参:覆盖为完整 html + title(与流式同一 callId 合并)。 // 渲染工具最终入参:覆盖为完整 html + title,标记 streaming=false(触发图表脚本执行)。
const a = (evt.args ?? {}) as { title?: string; html?: string }; const a = (evt.args ?? {}) as { title?: string; html?: string };
patch((blocks) => patch((blocks) =>
upsertArtifact(blocks, String(evt.id ?? nextId()), { title: a.title, html: a.html }), upsertArtifact(blocks, String(evt.id ?? nextId()), {
title: a.title,
html: a.html,
streaming: false,
}),
); );
break; break;
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment