1. 为什么要在FastGPT智能体里专门做对话框和HTML渲染
前阵子接了个内部项目:在FastGPT上搭一个智能体,用户问销售数据的时候,不能只回一堆Markdown文字,而是要在对话框里直接渲染出一张带进度条、图表、跳转链接和申请按钮的报表卡片。打开需求文档的瞬间我就知道,这事儿的核心根本不是“怎么让模型回答得更准”,而是“怎么让回答内容在对话框里以HTML形式变成可交互的组件”。
FastGPT默认的对话框输出链路很清晰:模型生成Markdown -> 前端解析渲染。这个链路应付普通问答、知识库引用、列表展示完全够用。但一旦业务方想要更多——按钮触发下一步、卡片里嵌表单、点击明细跳转新页面——Markdown就彻底不够了。Markdown可以表达结构和样式,但表达不了“事件”和“状态”。按钮点击之后干什么、表单提交之后怎么回传、图表数据从哪里加载,这些都需要一个HTML渲染层,而不是简单的文本渲染。
还有一个容易被忽视的问题:对话框本身不只是“输入框+消息列表”。在FastGPT智能体开发里,对话框就是用户和系统之间的完整交互界面,它要负责会话状态的延续、历史消息的展示、流式输出的中间态、以及富内容的最终呈现。如果只把HTML渲染做成“把字符串塞进innerHTML”,后面每一步都会踩坑。
这个项目最终的目标我拆成了三条:
- 用户问题进入FastGPT,智能体根据意图决定回复普通文字还是渲染HTML卡片。
- 对话框前端能识别消息类型,对HTML卡片做安全的、隔离的渲染。
- 卡片里的按钮、表单能和对话框主页面通信,再把用户操作作为新的对话消息发回给FastGPT,让上下文保持连贯。
说白了,HTML渲染不是给对话框“美化一下”,而是给智能体扩展出一种新的表达能力。下面我会按照方案选型、渲染层实现、工作流设计和踩坑记录的顺序,把整条链路完整讲一遍。
1.1 当Chatbot不只是一问一答
很多团队用FastGPT做智能体,一开始就是“问答机器人”。用户问,机器人答,答完结束。但实际业务场景里,问答往往是一个动作的开始。比如员工问“我这个月还剩几天年假”,真正的需求可能不仅是要一个数字,而是想要一个“申请休假”的入口。如果智能体的回答是纯文本,用户还得自己去OA系统里找入口;如果回答是一张带剩余天数、申请按钮、最近请假记录的HTML卡片,这个问题就被完整闭环了。
所以智能体开发的思考起点,应该从“回答一个问题”转变成“完成一次交互”。对话框就是承载交互的容器。HTML渲染就是让交互变得可落地的技术手段。
1.2 项目需求与最终效果
我们这个智能体定位是“经营数据分析助手”。使用场景有三种:一,销售问“华东区域上个月业绩完成率”,智能体返回一张带进度条的汇总卡片,点击卡片上的“查看明细”能展开表格;二,运营问“本周用户反馈最多的三个问题”,智能体返回分类列表,每条后面有“生成工单”按钮;三,普通聊天,比如“你是谁”“你能做什么”,智能体用Markdown返回就行。
这个设计从一开始就把“内容形态”和“内容场景”绑定了。不是所有回答都适合HTML卡片,卡片化是重表达,只用在数据密度高、操作意图强的场景。
1.3 对话框要承担的三个职责
在开发过程中,我把对话框拆成三个层次:
- 会话层:维护历史消息,把多轮上下文传给FastGPT。
- 消息层:负责消息的增删改、流式状态、错误处理。一条消息在发送前是loading,发送中是流式文本,发送完成后可能是文本、Markdown、HTML卡片或错误提示。
- 渲染层:根据消息类型选择渲染器。纯文本走普通文本渲染,Markdown走markdown-it,HTML卡片走iframe沙箱渲染器。
这三个层次分开之后,HTML渲染就成了一个独立的渲染器,不会污染原有的聊天逻辑。FastGPT的对话接口只要返回结构化消息,前端就能自动选择渲染方式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 对话框方案选型:在自研页面里接入FastGPT的消息流
先选对话框的实现方式。FastGPT本身有二开能力,但直接把它的开源前端页面拿来改,维护成本和升级成本都比较高。我更倾向于在自研的管理系统里,用FastGPT的API通道做一个独立对话框组件。
2.1 三种接入路径的对比
我把方案比了一圈,列在下面:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 直接使用FastGPT自带聊天页并修改源码 | 开发量小,功能全 | 升级要merge代码,二次封装受限于上游结构 | 只做轻量定制,不做多系统嵌入 |
| 在自研前端调FastGPT API自建对话框 | 可控性强,容易接入业务系统,方便做HTML渲染 | 要自己维护对话状态和会话列表 | 需要和其他业务页面联动,适合本次项目 |
| 在第三方系统里用iframe嵌入FastGPT聊天窗 | 接入最快,完全隔离 | 跨域通信受限,自定义渲染能力弱 | 演示项目或轻量接入 |
我最终选了第二个方案。理由很简单:HTML渲染层需要和业务系统里的登录态、权限、菜单深度联动,iframe方式会把通信变成一个绕不开的坎,自带页面二开又会受限于上游结构。
2.2 定义消息协议:让前端知道“这段内容怎么渲染”
自建对话框最大的好处,是可以自己定义消息协议。FastGPT的对话返回内容到了后端之后,我加了一个轻量处理层,把它转换成前端能够明确渲染的消息结构。结构大致是这样的:
json复制{
"id": "msg_123456",
"type": "html",
"content": "<div class=\"report-card\">...</div>",
"meta": {
"title": "华东区域业绩汇总",
"timestamp": "2025-06-01 10:00:00",
"source": "FastGPT"
}
}
这里的type字段是关键。它有几个枚举值:
text:纯文本,走默认消息渲染。markdown:走Markdown渲染。html:走HTML卡片渲染器,也就是沙箱iframe。error:展示错误状态和重试按钮。loading:展示加载中的占位动画。
FastGPT的原始返回值一般是字符串或流式文本,所以在接入层做一次“内容转译”是必须的。比如让模型输出一个标准JSON,前端解析后把JSON里的html字段放进消息体。后面我会详细说怎么让FastGPT稳定输出这种结构。
2.3 API调用方式:为什么最终没有用逐字流式渲染HTML
FastGPT的会话接口支持流式和非流式两种。普通文本问答用流式体验很好,逐字出来像真人打字。但HTML卡片不能用流式渲染。原因很直接:流式返回会把HTML标签切成碎片,比如<div class="card">可能会先返回<div clas,下一段再返回s="card">。前端如果边接收边渲染,页面就会闪烁、错乱,甚至因为标签不闭合而崩溃。
我的处理方式是把消息分成两类:普通文本类用流式,HTML卡片类用非流式。具体来说,FastGPT返回的内容就是一个JSON对象,里面有一个renderType字段。前端拿到完整JSON后再决定渲染方式。如果renderType是html,就一次性渲染整段HTML;如果是text,就继续走流式逻辑。
这个“先判定再流式”的转折点,是在智能体工作流里完成的。后面第4部分会细讲。
3. 前端HTML渲染层:安全、隔离与交互
HTML渲染的核心问题不是“能不能显示”,而是“能不能安全地显示、可控地交互”。FastGPT的智能体可以接入外部数据,模型也可能被提示词注入或返回不可信的HTML。如果在主页面里直接渲染这些内容,等于把整个系统的Cookie、本地存储和操作权限都暴露给了不可信代码。
3.1 直接用v-html或innerHTML会出事
很多同学第一次做HTML渲染,会用Vue的v-html或者React的dangerouslySetInnerHTML。如果HTML内容完全由后端可信代码生成,那没问题;但如果内容里有模型生成部分,或者外部API返回的部分,风险就会放大。
举个例子:模型在生成HTML卡片时,如果HTML里夹带了一段<script>fetch('/api/deleteUser?id=123')</script>,而这段脚本又在主页面上下文里执行,问题就很严重。即便你不信模型会有恶意,也要防着用户通过对话内容注入恶意代码。业务里已经出现过“用户故意在问题里塞一段HTML,诱导智能体把这段HTML原样返回”的攻击方式。
所以我的第一原则是:任何来自模型或用户内容的HTML,都不能直接在主页面的DOM上下文里执行脚本。
3.2 首选方案:iframe沙箱渲染HTML卡片
我采用的方案是把HTML卡片塞进一个iframe里,用sandbox属性限制脚本权限。
js复制function renderHtmlCard(html, container) {
const iframe = document.createElement('iframe');
iframe.setAttribute('sandbox', 'allow-scripts');
iframe.style.width = '100%';
iframe.style.border = 'none';
iframe.srcdoc = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; }
.report-card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; }
.report-card .btn { background: #2563eb; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; }
</style>
</head>
<body>${html}</body>
</html>`;
container.appendChild(iframe);
}
这里sandbox="allow-scripts"很关键。它允许HTML里的脚本运行,但脚本没有allow-same-origin权限,因此拿不到父页面的localStorage、Cookie,也不能操作父页面DOM。这样即使HTML里真有恶意脚本,它也只是一个“关在笼子里”的脚本。
对可信度较高的内部智能体,我会再加一个allow-popups,允许卡片里的“新窗口打开”链接。但不会加allow-top-navigation,避免iframe里的链接直接把整个业务系统页面跳走。
3.3 高度自适应与移动端适配
iframe最让人头疼的是高度。HTML卡片内容不固定,iframe不能用固定高度,否则会出现双滚动条。我的做法是等iframe加载完,读取内容高度,再动态设置iframe的height。
js复制iframe.addEventListener('load', () => {
try {
const height = iframe.contentDocument.body.scrollHeight;
iframe.style.height = height + 'px';
} catch (e) {
// 跨域场景下会抛错,用 postMessage 方案
}
});
由于是用srcdoc加载,同源情况下可以直接读取contentDocument。如果以后改成加载外链HTML,就需要iframe内部页面通过parent.postMessage把高度发出来。
移动端适配这里有个坑:如果HTML卡片里的内容宽度超出屏幕,iframe会出现横向滚动条。常规做法是在iframe内部给body设置min-width: 0,同时给卡片最外层加max-width: 100%; box-sizing: border-box。如果嵌入了table,建议在生成HTML时就给table加上固定布局或横向滚动容器。
3.4 iframe与父页面通信:让卡片里的按钮“能说话”
HTML卡片不只是展示,还要能交互。比如卡片里有一个“生成工单”按钮,点击后应该把工单信息发送给父页面,再由父页面调用后端接口创建工单。
iframe内部按钮点击后,通过postMessage把事件发给父页面:
js复制// iframe 内部代码
document.getElementById('createTicketBtn').addEventListener('click', () => {
parent.postMessage({
source: 'fastgpt-html-card',
event: 'createTicket',
payload: { customerId: 'C12345', content: '用户反馈网络超时' }
}, '*');
});
父页面监听message事件:
js复制window.addEventListener('message', (e) => {
if (e.data && e.data.source === 'fastgpt-html-card') {
if (e.data.event === 'createTicket') {
// 调用业务系统API创建工单
createTicket(e.data.payload);
}
}
});
注意,postMessage的第二个参数可以用'*',但更好的是限定父页面源。如果iframe内部是可执行脚本,建议在脚本里把源校验写上。这里是内部的,问题不大。但安全习惯还是要有。
这一步跑通之后,HTML卡片就从一个“展示壳”变成了真正能参与业务流转的交互组件。
4. 让FastGPT按需生成HTML内容:工作流的设计方法
前端渲染层做好了,下一个问题是:FastGPT怎么知道什么时候返回HTML,什么时候返回普通Markdown?一开始我以为靠Prompt就能解决,后来发现不行。模型对HTML标签的掌控力是被高估的,让它自由生成HTML,经常会生成不完整标签、错误嵌套、甚至把CSS写飞。真正可靠的路径是:让模型返回结构化数据,HTML的拼接交给代码节点。
4.1 不要指望模型每次都能输出正确HTML
我试过在系统提示词里写:“当你需要展示报表时,请输出HTML代码。”结果模型时好时坏。好的时候能输出漂亮的卡片,坏的时候会输出半个<div>就戛然而止,或者把按钮事件写成实时JS,前端没法直接复用。
更尴尬的是,模型生成的HTML里经常带有内联事件处理属性(onclick)。在sandbox环境下,内联事件能不能用要看浏览器策略,而且CSP严格一点就会被直接拦截。所以让模型自由生成的HTML,既不稳定,也不安全。
4.2 用代码节点把结构化数据翻译成HTML
正确的做法是在FastGPT工作流里加一个代码节点。这个节点的作用是:输入结构化数据,输出HTML字符串。
FastGPT的工作流节点支持Python或JavaScript代码。我这次用的是JavaScript代码节点。节点输入是一个JSON对象,比如:
json复制{
"title": "华东区域6月业绩",
"total": 8200000,
"target": 10000000,
"completionRate": 82,
"detail": [
{ "city": "上海", "amount": 3200000 },
{ "city": "杭州", "amount": 2100000 },
{ "city": "南京", "amount": 2900000 }
]
}
代码节点里写一个纯函数,把这些数据拼成HTML字符串:
javascript复制function buildReportCard(data) {
const items = data.detail.map(item => `
<tr>
<td>${item.city}</td>
<td>${item.amount}</td>
</tr>
`).join('');
const rate = data.completionRate;
const barColor = rate >= 80 ? '#22c55e' : '#f59e0b';
const html = `
<div class="report-card">
<h3>${data.title}</h3>
<div class="rate-bar">
<div style="width:${rate}%;background:${barColor};">${rate}%</div>
</div>
<table>${items}</table>
<button data-event="viewDetail" data-city="all">查看明细</button>
</div>
`;
return html;
}
然后在工作流里把结果作为最终输出,返回给前端。这样FastGPT只负责判断意图和数据提取,HTML的结构完全由可控代码节点决定。稳定性和安全性都大幅提升。
4.3 判断节点:什么时候才需要卡片化
为了让智能体“按需”渲染,我在工作流里设计了一个分类判断。用户问题进来后,先过一个AI对话节点,让模型输出一个JSON,包含两个字段:intent和needHtml。
intent:是“报表查询”“工单申请”还是“闲聊”。needHtml:是否需要HTML卡片。
然后工作流里用条件判断节点,如果needHtml为true,才走代码节点生成HTML;否则直接走普通回复节点。这样做有几个好处:减少对代码节点的无意义调用,普通问答的响应速度更快;也很容易统计“卡片化”的占比,方便后续调优。
4.4 从外部API拉取数据的场景
如果智能体要展示的数据来自外部系统,比如CRM、ERP,工作流里会多一个HTTP请求节点。数据拉回来之后先做字段校验和清洗,再传给代码节点生成HTML。强烈建议不要在代码节点里把业务数据直接拼进HTML而不做转义,尤其是来自外部系统的字符串内容。
我的做法是在代码节点里统一做HTML实体转义:
javascript复制function escapeHtml(str) {
return String(str)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
所有动态字段都经过escapeHtml,保证用户输入的引号或尖括号不会被解析成HTML标签。这一步踩过一次坑:有个客户的备注里写了一行<img src=x onerror=alert(1)>,如果没有转义,这个字符串会被直接渲染成真实标签,触发XSS告警。
5. 实测踩坑:HTML渲染在对话框里的七个坑
整个项目从联调到上线,踩了不少坑。挑几个让我印象最深的,按排查链路写出来,希望你能绕开。
5.1 流式输出把标签切碎了
这是第一个遇到的坑。FastGPT默认开启SSE流式输出,前端那边为了体验好,一直把输出拼到消息框里。结果HTML卡片在拼接过程中,经常出现半个标签。我一开始在渲染层做了“标签闭合检测”,看起来简单,实际很不可靠。HTML是一个上下文相关的语言,只看标签头尾闭合根本判断不了最终效果,一个<script>标签里的字符串也能影响判断。
最终方案是回到第2.3节说的:工作流里把HTML卡片作为独立消息类型,前端检测到needHtml=true后,就停止流式拼接,等FastGPT返回完整JSON之后一次性渲染。也就是说,HTML卡片不走逐字流,普通文本才走流式。
5.2 全局样式污染
最开始图省事,把HTML卡片直接插入到页面主DOM里。结果卡片样式被项目里引入的Tailwind Preflight重置了:标题没了、列表点没了、按钮背景被覆盖。我加了一堆!important补救,越补越乱。
后来全部改成iframe沙箱渲染,才彻底解决了全局样式污染问题。iframe内部是一个独立的文档,不受父页面任何CSS影响。同时我故意不在iframe里引入任何全局UI框架,只用每张卡片自己的内联样式,保证可预测。
5.3 CSP和sandbox双重拦截
项目里本身配了CSP(内容安全策略),script-src限制得很严。iframe里如果加载内联脚本,会被CSP拦截。解决方法是给iframe单独放宽安全策略,或者确保生成的HTML不依赖内联脚本。我选择了第二种:卡片里的按钮不写onclick,而是统一监听click事件,然后用data-event属性区分动作。
html复制<button data-event="createTicket" data-id="T001">生成工单</button>
iframe内部的脚本统一用事件委托:
js复制document.body.addEventListener('click', (e) => {
const btn = e.target.closest('[data-event]');
if (!btn) return;
const eventName = btn.dataset.event;
const payload = { id: btn.dataset.id };
parent.postMessage({ source: 'fastgpt-html-card', event: eventName, payload }, '*');
});
这样避免内联脚本被CSP拦截,也符合sandbox对代码执行的要求。
5.4 iframe高度反复横跳
iframe的高度自适应,在内容里有图片时特别容易出问题。图片加载慢,载入时高度还只是文字高度,等图片加载完,高度突然变大,iframe就出现了内滚动条。
解决方法是监听iframe内部图片的load事件,每次都重新计算高度。更稳妥的做法是用ResizeObserver观察body尺寸变化:
js复制const observer = new ResizeObserver(() => {
parent.postMessage({ source: 'fastgpt-html-card', event: 'resize', height: document.body.scrollHeight }, '*');
});
observer.observe(document.body);
父页面收到resize消息后更新iframe高度。这个方案比等load事件更稳健,尤其在卡片里有折叠面板、动态展开内容的时候。
5.5 图片资源加载失败
FastGPT返回HTML卡片里的图片,如果引用的是内部系统相对路径,iframe的srcdoc会以about:srcdoc作为基准地址,相对路径全部失效,图片直接裂掉。
解决方法是让代码节点在生成HTML时,把相对路径统一转换成绝对路径。或者在父页面收到消息后,对HTML字符串做一次正则替换。推荐前者:生成端保证数据完整性,渲染端只做渲染。
另外不要用Base64存大图。Base64会让HTML字符串膨胀30%以上,几个图表下去,消息体直接几兆,前端渲染明显变卡。小图标可以用内联SVG或Base64,报表图片建议先传到对象存储,再用URL引用。
5.6 移动端触控和滚动
移动端的问题集中在两点。一是卡片内部如果有横向内容,很容易和浏览器边缘手势冲突。二是iframe内的滚动事件会“吞”掉触摸手势,用户上下滑的时候,先滑的是iframe内部,而不是整个聊天列表。
我的处理是:html卡片里的内容尽量减少固定高度,完全由内容撑开,iframe始终和内容一样高,这样内部就不会出现滚动条,触摸手势会自然穿透到父页面。如果确实有超高卡片,比如一屏放不下的报表,就不要在卡片里做内滚动,改成“展开/收起”交互。
5.7 数据脱敏与日志记录
最后这个坑和渲染无关,但很重要。HTML卡片里包含的数据往往是业务敏感的,比如销售额、客户工单明细。智能体在生成HTML时,如果不做权限判断,任何有权限打开对话框的用户都可能看到完整数据。我们在工作流里加了数据脱敏节点,对非管理员用户隐藏手机号、金额等敏感字段。同时在对话框前端,对postMessage的事件做了源校验和参数校验,确保从iframe里传出来的数据不是伪造的。
日志方面,我记录了每条HTML卡片的渲染时间、消息Id、卡片类型和渲染失败原因。排查问题的时候,这些日志比“用户说页面白屏”有用得多。
这几轮踩下来,我最大的感受是:HTML渲染本身不难,难的是把它放进智能体对话这个上下文里,还能保持稳定和安全。FastGPT只帮你解决模型编排和对话管理,渲染侧的事情还得自己扛。如果以后再做类似项目,我的默认选择仍然会是“模型出数据、工作流出结构、前端出渲染”,这条链路也许不是最快的,但一定是最不容易失控的。
