Commit 5737df69 by luoqi

perf(assistant): artifact 流式渲染 — 卡片边生成边"长出",不再干等整段

诊断:render_artifact 慢点全在"模型逐字吐 ~5.5KB HTML"(~32s),而 tool-call 要等整段
生成完才触发 → 用户对着"生成中"干等 30s+。MCP/工具往返仅 0.2s,非瓶颈;默认模型已是
deepseek-v4-flash(已是快的)。

优化(感知为主):
- 后端:消费 AI SDK 的 tool-input-delta,按 toolCallId 累积入参 JSON,增量提取 html 字段
  (extractHtmlField 容忍半截转义),250ms 节流推 artifact_html 事件。
- 前端:artifact_html 按 callId upsert 到 artifact 块(实时增长),最终 tool_call 覆盖完整 html+title;
  ArtifactView 对 srcDoc 重建做 600ms 节流(避免每增量都重载 iframe 白闪 / Tailwind 反复重扫)。
- 提示词加"HTML 力求紧凑"以略减 token。

实测:卡片 html 从 ~8s 起流式到达(35→5537 字符,65 次增量),界面从第 ~8s 起"长出"卡片,
而非 ~35s 整张蹦出。两端 tsc 0。

注:总时长仍受模型出字速度限制(~40s);若要"秒开"常见列表,需走机制 A(预制组件直渲工具结果)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 6ce494ae
...@@ -10,6 +10,39 @@ interface ChatBody { ...@@ -10,6 +10,39 @@ interface ChatBody {
} }
/** /**
* 从「正在流式生成的工具入参 JSON」里增量提取 html 字段值(用于 artifact 实时渲染)。
* 入参形如 {"title":"...","html":"<div>... ← 还没结束。容忍末尾不完整的转义,返回已解出的部分。
*/
function extractHtmlField(jsonText: string): string | null {
const m = jsonText.match(/"html"\s*:\s*"/);
if (!m || m.index === undefined) return null;
const s = jsonText.slice(m.index + m[0].length);
const esc: Record<string, string> = {
n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f',
};
let out = '';
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '\\') {
const n = s[i + 1];
if (n === undefined) break; // 末尾半截转义 → 停
if (n === 'u') {
if (i + 6 > s.length) break;
out += String.fromCharCode(parseInt(s.slice(i + 2, i + 6), 16));
i += 5;
continue;
}
out += esc[n] ?? n;
i += 1;
continue;
}
if (c === '"') break; // html 值结束
out += c;
}
return out;
}
/**
* AssistantController — "外部 agent" 模拟器聊天端点(SSE 流式)。 * AssistantController — "外部 agent" 模拟器聊天端点(SSE 流式)。
* *
* POST /pac/v1/assistant/chat —— 普通 JWT 鉴权(全局 guard);转发用户 token 给 MCP, * POST /pac/v1/assistant/chat —— 普通 JWT 鉴权(全局 guard);转发用户 token 给 MCP,
...@@ -42,6 +75,12 @@ export class AssistantController { ...@@ -42,6 +75,12 @@ export class AssistantController {
const ac = new AbortController(); const ac = new AbortController();
req.on('close', () => ac.abort()); req.on('close', () => ac.abort());
// 流式 artifact:render_artifact 的入参(HTML)逐字生成,边生成边推 → 前端实时"长出"卡片,
// 不必等整段(~30s)生成完。按 toolCallId 累积 JSON 入参,增量提取 html 字段,250ms 节流发送。
const artToolIds = new Set<string>();
const artBuf = new Map<string, string>();
const artLastSent = new Map<string, number>();
try { try {
const result = await this.assistant.chat({ const result = await this.assistant.chat({
userToken: token, userToken: token,
...@@ -57,6 +96,22 @@ export class AssistantController { ...@@ -57,6 +96,22 @@ export class AssistantController {
case 'text': case 'text':
send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' }); send({ type: 'text', text: (p.text as string) ?? (p.delta as string) ?? '' });
break; break;
case 'tool-input-start':
if (p.toolName === 'render_artifact') artToolIds.add(String(p.id));
break;
case 'tool-input-delta': {
const id = String(p.id);
if (!artToolIds.has(id)) break;
const acc = (artBuf.get(id) ?? '') + String(p.delta ?? '');
artBuf.set(id, acc);
const now = Date.now();
if (now - (artLastSent.get(id) ?? 0) > 250) {
artLastSent.set(id, now);
const html = extractHtmlField(acc);
if (html) send({ type: 'artifact_html', id, html });
}
break;
}
case 'tool-call': case 'tool-call':
send({ type: 'tool_call', id: p.toolCallId, tool: p.toolName, args: p.input }); send({ type: 'tool_call', id: p.toolCallId, tool: p.toolName, args: p.input });
break; break;
......
...@@ -19,6 +19,7 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所 ...@@ -19,6 +19,7 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所
- 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表(可含图表)——先用工具取真实数据,然后调用 render_artifact 渲染一个 HTML 卡片来展示。 - 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表(可含图表)——先用工具取真实数据,然后调用 render_artifact 渲染一个 HTML 卡片来展示。
- render_artifact 的 html 是「<body> 内部片段」:用 Tailwind 工具类排版,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适;需要图表时用 Chart.js(<canvas> + <script>new Chart(...)</script>)。 - render_artifact 的 html 是「<body> 内部片段」:用 Tailwind 工具类排版,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适;需要图表时用 Chart.js(<canvas> + <script>new Chart(...)</script>)。
- 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们;严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码,只用工具返回的真实值。 - 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们;严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码,只用工具返回的真实值。
- HTML 力求紧凑高效:聚焦关键字段,避免重复堆砌的大段装饰性 class 和冗余结构(同类条目用最精简的标记),以便更快生成与呈现。
- 产出卡片后,再用一两句话点出要点即可(数据细节在卡片里)。 - 产出卡片后,再用一两句话点出要点即可(数据细节在卡片里)。
用中文,简洁专业、友好。`; 用中文,简洁专业、友好。`;
......
...@@ -223,7 +223,23 @@ new ResizeObserver(r).observe(document.body);window.addEventListener('load',r);s ...@@ -223,7 +223,23 @@ new ResizeObserver(r).observe(document.body);window.addEventListener('load',r);s
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(220);
const doc = useMemo(() => buildArtifactDoc(artifact.html), [artifact.html]); // 流式更新节流:每个增量都重载 iframe 会白闪 + Tailwind 反复重扫。最多 ~600ms 渲一次,末值兜底。
const [renderHtml, setRenderHtml] = useState(artifact.html);
const lastRef = useRef(0);
useEffect(() => {
const since = Date.now() - lastRef.current;
if (since >= 600) {
lastRef.current = Date.now();
setRenderHtml(artifact.html);
return;
}
const t = setTimeout(() => {
lastRef.current = Date.now();
setRenderHtml(artifact.html);
}, 600 - since);
return () => clearTimeout(t);
}, [artifact.html]);
const doc = useMemo(() => buildArtifactDoc(renderHtml), [renderHtml]);
useEffect(() => { useEffect(() => {
const onMsg = (e: MessageEvent) => { const onMsg = (e: MessageEvent) => {
...@@ -237,7 +253,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) { ...@@ -237,7 +253,7 @@ function ArtifactView({ artifact }: { artifact: Artifact }) {
const openFull = () => { const openFull = () => {
const w = window.open('', '_blank'); const w = window.open('', '_blank');
w?.document.write(doc); w?.document.write(buildArtifactDoc(artifact.html));
w?.document.close(); w?.document.close();
}; };
......
...@@ -55,6 +55,25 @@ function findToolIdx(blocks: Block[], callId: string | undefined): number { ...@@ -55,6 +55,25 @@ function findToolIdx(blocks: Block[], callId: string | undefined): number {
return -1; return -1;
} }
/** 按 callId upsert artifact 块:流式 html 增量更新,最终 tool_call 覆盖(title+完整 html)。 */
function upsertArtifact(blocks: Block[], id: string, patch: { title?: string; html?: string }): Block[] {
const i = blocks.findIndex((b) => b.kind === 'artifact' && b.artifact.id === id);
if (i === -1) {
return [...blocks, { kind: 'artifact', artifact: { id, title: patch.title, html: patch.html ?? '' } }];
}
return blocks.map((b, j) => {
if (j !== i || b.kind !== 'artifact') return b;
return {
kind: 'artifact',
artifact: {
id,
title: patch.title ?? b.artifact.title,
html: patch.html ?? b.artifact.html,
},
};
});
}
/** 把一条消息压平成后端要的 {role, content} 文本(工具块不回传,模型自行重新决策)。 */ /** 把一条消息压平成后端要的 {role, content} 文本(工具块不回传,模型自行重新决策)。 */
function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null { function toApiMessage(m: ChatMessage): { role: 'user' | 'assistant'; content: string } | null {
const text = m.blocks const text = m.blocks
...@@ -119,14 +138,19 @@ export function useAssistantChat() { ...@@ -119,14 +138,19 @@ export function useAssistantChat() {
case 'text': case 'text':
appendText((evt.text as string) ?? ''); appendText((evt.text as string) ?? '');
break; break;
case 'artifact_html':
// 流式:render_artifact 的 html 边生成边到(按 callId upsert,实时"长出"卡片)。
patch((blocks) =>
upsertArtifact(blocks, String(evt.id ?? ''), { html: String(evt.html ?? '') }),
);
break;
case 'tool_call': case 'tool_call':
if (evt.tool === ARTIFACT_TOOL) { if (evt.tool === ARTIFACT_TOOL) {
// 渲染工具:取 html/title 作为 artifact 卡片(不进工具步骤)。 // 渲染工具最终入参:覆盖为完整 html + title(与流式同一 callId 合并)。
const a = (evt.args ?? {}) as { title?: string; html?: string }; const a = (evt.args ?? {}) as { title?: string; html?: string };
patch((blocks) => [ patch((blocks) =>
...blocks, upsertArtifact(blocks, String(evt.id ?? nextId()), { title: a.title, html: a.html }),
{ kind: 'artifact', artifact: { id: nextId(), title: a.title, html: a.html ?? '' } }, );
]);
break; break;
} }
patch((blocks) => [ patch((blocks) => [
......
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