Commit 46b60bec by luoqi

feat(assistant): HTML artifact PoC — 模型按需产卡片/报表,沙箱 iframe 渲染

机制 B(Claude Artifacts 式,即用即焚):
- 后端加本地工具 render_artifact({title, html}),html 是 <body> 内部片段;
  SYSTEM_PROMPT 加"展示方式"skill:简短问答用文字;召回列表/画像卡/分析报表用 render_artifact,
  Tailwind + PAC teal 配色,图表用 Chart.js,禁外部网络、数据内联、手机号掩码。
- 前端 use-assistant-chat 加 artifact block(拦截 render_artifact 的 tool_call → html);
  assistant-chat 加 ArtifactView:沙箱 iframe 渲染。
- 安全:sandbox="allow-scripts"(无 allow-same-origin)→ 脚本在 null origin 碰不到父页
  cookie/凭证;注入 CSP default-src 'none' + 只放行 Tailwind/Chart.js CDN + connect-src 'none'
  堵死外传(患者数据不会被偷渡);postMessage 自适应高度;支持新窗口打开。

运行环境(iframe shell)由前端注入 Tailwind Play CDN + Chart.js,模型不写 <html>/<head>。
注:沙箱隔离,无法用父页的 Next.js/shadcn 组件;Tailwind+原生JS+Chart.js 已覆盖卡片+图表需求。

验证(deepseek):天气→纯文字不产卡;"卡片展示召回池TOP5"→ list_recall_queue→render_artifact,
HTML 含 canvas 图表、teal 配色、无 fetch。两端 tsc 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 19c9ddfd
...@@ -14,7 +14,14 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所 ...@@ -14,7 +14,14 @@ const SYSTEM_PROMPT = `你是一个通用智能助手,目前在为牙科诊所
当用工具回答患者相关问题时(数据正确性,务必遵守):只依据工具返回的真实数据,绝不编造;工具没返回的就如实说没有该信息;手机号只显示掩码。 当用工具回答患者相关问题时(数据正确性,务必遵守):只依据工具返回的真实数据,绝不编造;工具没返回的就如实说没有该信息;手机号只显示掩码。
用中文,简洁专业、友好。回答患者问题时可简述你查了哪些数据。`; 展示方式(重要):
- 简短问答、闲聊、解释 → 直接用文字(markdown)回答,不要产卡片。
- 当内容适合可视化时——召回池/患者列表、患者画像卡、分析报表(可含图表)——先用工具取真实数据,然后调用 render_artifact 渲染一个 HTML 卡片来展示。
- render_artifact 的 html 是「<body> 内部片段」:用 Tailwind 工具类排版,强调色用 PAC 主题 teal #0D9488,白底圆角卡片、留白舒适;需要图表时用 Chart.js(<canvas> + <script>new Chart(...)</script>)。
- 运行环境已注入 Tailwind 与 Chart.js,不要再写 <html>/<head>/<!DOCTYPE> 或自行引入它们;严禁 fetch / 访问任何外部网络——所有数据内联写进 HTML,手机号掩码,只用工具返回的真实值。
- 产出卡片后,再用一两句话点出要点即可(数据细节在卡片里)。
用中文,简洁专业、友好。`;
export interface AssistantChatInput { export interface AssistantChatInput {
userToken: string; userToken: string;
...@@ -53,6 +60,26 @@ export class AssistantService { ...@@ -53,6 +60,26 @@ export class AssistantService {
execute: async (args: unknown) => this.mcp.callTool(input.userToken, t.name, args), execute: async (args: unknown) => this.mcp.callTool(input.userToken, t.name, args),
}); });
} }
// 本地"渲染"工具(不走 MCP):模型把自包含 HTML 片段交给前端,在沙箱 iframe 里渲染成卡片/报表。
// html 通过 tool-call 入参流式到前端;execute 仅回执,模型据此继续给一句话总结。
tools.render_artifact = tool({
description:
'把一段自包含 HTML(<body> 内部片段)渲染成可视化卡片/报表展示给用户。适合召回池列表、患者画像卡、分析报表(可含图表)。用 Tailwind 工具类排版,可含 <canvas>+<script> 画 Chart.js 图表;运行环境已注入 Tailwind 与 Chart.js,勿自行引入,勿访问外部网络,数据内联、手机号掩码、只用真实数据。',
inputSchema: jsonSchema({
type: 'object',
properties: {
title: { type: 'string', description: '卡片标题(可选)' },
html: {
type: 'string',
description: '<body> 内部 HTML 片段(Tailwind 类;可含 <canvas>+<script> 图表)',
},
},
required: ['html'],
}),
execute: async () => '已在界面渲染该卡片。',
});
this.logger.log( this.logger.log(
`assistant chat: model=${input.modelId ?? 'deepseek'} tools=${Object.keys(tools).join(',')}`, `assistant chat: model=${input.modelId ?? 'deepseek'} tools=${Object.keys(tools).join(',')}`,
); );
......
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { Bot, Check, ChevronDown, ChevronRight, Loader2, Send, Sparkles, Square, Wrench } from 'lucide-react'; import {
Bot,
Check,
ChevronDown,
ChevronRight,
LayoutTemplate,
Loader2,
Maximize2,
Send,
Sparkles,
Square,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAssistantChat, type Block, type ChatMessage, type ToolStep } from './use-assistant-chat'; import {
useAssistantChat,
type Artifact,
type Block,
type ChatMessage,
type ToolStep,
} from './use-assistant-chat';
const MODELS = [ const MODELS = [
{ value: 'deepseek', label: 'DeepSeek' }, { value: 'deepseek', label: 'DeepSeek' },
...@@ -14,9 +32,9 @@ const MODELS = [ ...@@ -14,9 +32,9 @@ const MODELS = [
]; ];
const EXAMPLES = [ const EXAMPLES = [
'查一下患者孙柯的画像价值分群和当前召回计划', '用卡片展示召回池里优先级最高的 5 位患者',
'孙柯有哪些潜在治疗和应治未治的牙位?', '出一份召回池患者分析报表(含图表)',
'现在召回池里优先级最高的几位患者是谁?', '查一下患者孙柯的画像和召回计划',
]; ];
// ── 工具结果格式化 + 轻量摘要 ────────────────────────────── // ── 工具结果格式化 + 轻量摘要 ──────────────────────────────
...@@ -169,9 +187,86 @@ function Markdown({ text }: { text: string }) { ...@@ -169,9 +187,86 @@ function Markdown({ text }: { text: string }) {
function BlockView({ block }: { block: Block }) { function BlockView({ block }: { block: Block }) {
if (block.kind === 'tool') return <ToolCallView step={block.step} />; if (block.kind === 'tool') return <ToolCallView step={block.step} />;
if (block.kind === 'artifact') return <ArtifactView artifact={block.artifact} />;
return <Markdown text={block.text} />; return <Markdown text={block.text} />;
} }
// ── Artifact:模型产出的 HTML 在隔离沙箱 iframe 渲染(即用即焚)──────────────
// 安全:sandbox="allow-scripts" 不含 allow-same-origin → 脚本在 null origin,
// 碰不到父页 cookie/localStorage、无法带凭证调 PAC;CSP 只放行 Tailwind/Chart.js CDN
// 且 connect-src 'none' 堵死任何外传(患者数据不会被偷渡)。
function buildArtifactDoc(inner: string): string {
const csp = [
"default-src 'none'",
"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",
'font-src https://fonts.gstatic.com',
'img-src data:',
"connect-src 'none'",
"base-uri 'none'",
"form-action 'none'",
].join('; ');
return `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="https://cdn.tailwindcss.com"></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>
</head><body class="p-4">${inner}
<script>
(function(){function r(){parent.postMessage({__artifactHeight:document.documentElement.scrollHeight},'*');}
new ResizeObserver(r).observe(document.body);window.addEventListener('load',r);setTimeout(r,300);setTimeout(r,1200);})();
</script>
</body></html>`;
}
function ArtifactView({ artifact }: { artifact: Artifact }) {
const ref = useRef<HTMLIFrameElement>(null);
const [height, setHeight] = useState(220);
const doc = useMemo(() => buildArtifactDoc(artifact.html), [artifact.html]);
useEffect(() => {
const onMsg = (e: MessageEvent) => {
if (e.source !== ref.current?.contentWindow) return;
const h = (e.data as { __artifactHeight?: number })?.__artifactHeight;
if (typeof h === 'number') setHeight(Math.min(Math.max(h, 80), 1400));
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, []);
const openFull = () => {
const w = window.open('', '_blank');
w?.document.write(doc);
w?.document.close();
};
return (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-1.5 border-b border-slate-100 bg-slate-50/60 px-3 py-1.5">
<LayoutTemplate className="h-3.5 w-3.5 text-teal-600" />
<span className="text-[11.5px] font-medium text-slate-500">{artifact.title || '可视化卡片'}</span>
<button
type="button"
onClick={openFull}
title="新窗口打开"
className="ml-auto inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10.5px] text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
<Maximize2 className="h-3 w-3" />
</button>
</div>
<iframe
ref={ref}
title={artifact.title || 'artifact'}
sandbox="allow-scripts"
srcDoc={doc}
className="w-full"
style={{ height, border: 0 }}
/>
</div>
);
}
function MessageView({ message }: { message: ChatMessage }) { function MessageView({ message }: { message: ChatMessage }) {
if (message.role === 'user') { if (message.role === 'user') {
return ( return (
...@@ -296,7 +391,7 @@ export function AssistantChat() { ...@@ -296,7 +391,7 @@ export function AssistantChat() {
<span className="inline-flex h-7 w-7 flex-none items-center justify-center rounded-lg bg-teal-600 text-white"> <span className="inline-flex h-7 w-7 flex-none items-center justify-center rounded-lg bg-teal-600 text-white">
<Bot className="h-4 w-4" /> <Bot className="h-4 w-4" />
</span> </span>
<h1 className="text-[14px] font-semibold text-slate-900">PAC 助手</h1> <h1 className="text-[14px] font-semibold text-slate-900">外部助手</h1>
<div className="ml-auto"> <div className="ml-auto">
<ModelSelect model={model} setModel={setModel} disabled={status === 'streaming'} /> <ModelSelect model={model} setModel={setModel} disabled={status === 'streaming'} />
</div> </div>
...@@ -312,7 +407,7 @@ export function AssistantChat() { ...@@ -312,7 +407,7 @@ export function AssistantChat() {
<Bot className="h-6 w-6" /> <Bot className="h-6 w-6" />
</span> </span>
<div className="max-w-md text-[13.5px] text-slate-500"> <div className="max-w-md text-[13.5px] text-slate-500">
问我关于患者的问题,我会经 MCP 工具查询 PAC 实时数据,过程透明可见 问我关于患者的问题,我会查询 PAC 实时数据
</div> </div>
<div className="flex flex-wrap justify-center gap-1.5"> <div className="flex flex-wrap justify-center gap-1.5">
{EXAMPLES.map((ex) => ( {EXAMPLES.map((ex) => (
...@@ -380,7 +475,7 @@ export function AssistantChat() { ...@@ -380,7 +475,7 @@ export function AssistantChat() {
)} )}
</div> </div>
<p className="mt-1.5 text-center text-[10.5px] text-slate-400"> <p className="mt-1.5 text-center text-[10.5px] text-slate-400">
结果仅供参考,请核对后使用 结果仅供参考 请核对后使用
</p> </p>
</div> </div>
</div> </div>
......
...@@ -14,7 +14,20 @@ export interface ToolStep { ...@@ -14,7 +14,20 @@ export interface ToolStep {
error?: string; error?: string;
} }
export type Block = { kind: 'text'; text: string } | { kind: 'tool'; step: ToolStep }; /** 模型产出的 HTML 卡片(在输出区沙箱 iframe 渲染)。 */
export interface Artifact {
id: string;
title?: string;
html: string;
}
export type Block =
| { kind: 'text'; text: string }
| { kind: 'tool'; step: ToolStep }
| { kind: 'artifact'; artifact: Artifact };
/** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */
const ARTIFACT_TOOL = 'render_artifact';
export interface ChatMessage { export interface ChatMessage {
id: string; id: string;
...@@ -92,6 +105,15 @@ export function useAssistantChat() { ...@@ -92,6 +105,15 @@ export function useAssistantChat() {
appendText((evt.text as string) ?? ''); appendText((evt.text as string) ?? '');
break; break;
case 'tool_call': case 'tool_call':
if (evt.tool === ARTIFACT_TOOL) {
// 渲染工具:取 html/title 作为 artifact 卡片(不进工具步骤)。
const a = (evt.args ?? {}) as { title?: string; html?: string };
patch((blocks) => [
...blocks,
{ kind: 'artifact', artifact: { id: nextId(), title: a.title, html: a.html ?? '' } },
]);
break;
}
patch((blocks) => [ patch((blocks) => [
...blocks, ...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