dify智能体输出AI会话千问效果流式渲染卡片实现方案
产品卡片流式渲染实现方案
一、功能概述
1.1 功能定位
商品卡片(Product Card)功能是一个在智能体对话中展示商品信息的组件,支持通过自定义 HTML 标签 <product-card> 在 Markdown 内容中渲染美观的商品卡片,实现类似千问等大模型产品的商品推荐体验。
1.2 核心能力
| 能力 | 说明 |
|---|---|
| Markdown 自定义组件 | 通过 <product-card> 标签在 Markdown 中渲染商品卡片 |
| 流式输出支持 | 卡片标签缓冲等待完整后一次性渲染,避免未闭合标签显示为乱码 |
| 缓存写入 | 点击卡片前自动写入商城系统所需的 localStorage 缓存 |
| 跳转链接 | 支持生成包含必要参数的跳转链接 |
| 响应式布局 | 适配手机、平板、桌面等不同屏幕尺寸 |
1.3 完整数据流
用户输入 → 大模型处理 → 流式输出 → ProductCardProcessor 缓冲 → ReactMarkdown 渲染 → 用户看到商品卡片
↓
点击卡片 → 写入缓存 → 跳转商城页面
二、问题背景
2.1 业务场景
在智能体对话场景中,大模型会流式输出包含产品推荐的内容,其中产品卡片通过自定义 HTML 标签 <product-card> 标记,例如:
推荐商品:
<product-card data-name="五丰寒地东北大米" data-price="156.00" data-image="..."></product-card>
2.2 流式传输特点
大模型的流式输出是逐字符/逐词传输的,卡片标签会被分割成多个小块:
< → product → -card → data-name=" → 五丰寒地 → 东北大米 → " → ... → </ → product → -card → >
2.3 核心问题
直接渲染会导致以下问题:
| 问题 | 现象描述 | 影响 |
|---|---|---|
| 标签文本泄露 |
<product-card 等未闭合标签作为普通文本显示在页面上 |
用户看到乱码,体验极差 |
| 白屏闪烁 | 第一次遇到卡片标签时,已输出的文本消失,等待卡片缓冲完成后才重新显示 | 用户以为页面崩溃 |
| 位置错乱 | 卡片显示位置可能不正确,与周围文本重叠或错位 | 布局混乱,影响阅读 |
| 状态冲突 | 多个 Markdown 组件共享全局状态导致相互干扰 | 聊天列表中多条消息相互影响 |
2.4 实际案例
问题场景(用户反馈):
- 流式输出时,
<product-card data-name="黄河边金典原阳大米5KG"等标签内容直接以纯文本形式显示在页面上 - 用户看到的是未解析的标签代码,而非美观的商品卡片
- 等待一段时间后,卡片才会完整渲染出来,但之前显示的标签代码仍残留
期望效果:
- 非卡片文本实时流式显示
- 卡片标签在传输过程中不显示,等待完整后一次性渲染为卡片
- 卡片位置正确,不会与周围文本重叠
三、解决方案设计
2.1 设计目标
针对流式输出场景,实现以下目标:
- 非卡片内容实时渲染:普通文本在收到后立即显示,保持流式体验
-
卡片内容缓冲等待:
<product-card>标签在传输过程中不显示,等待完整后一次性渲染为卡片 - 位置保持正确:卡片渲染在其原始位置,不会与周围文本重叠或错位
- 状态隔离:每个 Markdown 组件拥有独立状态,互不干扰
2.2 核心策略:双字段分离
采用接收内容与渲染内容分离的策略:
| 字段 | 作用 | 更新时机 |
|---|---|---|
rawContent |
接收完整的原始内容 | 每次调用时累积,用于增量检测 |
renderContent |
用于渲染的内容 | 非卡片内容立即更新,卡片内容缓冲完成后更新 |
核心思想:
-
非卡片内容:实时追加到
renderContent,立即渲染 -
卡片内容:先缓冲到
productCardBuffer,等待闭合标签到达后一次性追加到renderContent
2.3 状态机设计
使用有限状态机管理处理逻辑:
┌─────────────────────────────────────────────────────────────────────┐
│ 状态转换图 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ 检测到 '<product-card' ┌──────────────┐
│ │ 普通文本 │ ────────────────────────────────────────► │ 卡片缓冲 │
│ │ 模式 │ │ 模式 │
│ └──────────────┘ ◄─────────────────────────────────────── └──────────────┘
│ ▲ 检测到 '</product-card>' │
│ │ │
│ │ 输出普通文本 │ 累积卡片内容
│ ▼ ▼
│ ┌──────────────┐ ┌──────────────┐
│ │ renderContent│ │ productCardBuffer│
│ │ (实时渲染) │ │ (等待完整) │
│ └──────────────┘ └──────────────┘
│ │
│ 检测到闭合标签后 │
│ 将 buffer 追加到 │
│ renderContent │
└─────────────────────────────────────────────────────────────────────┘
2.4 关键设计决策
| 设计点 | 决策 | 原因 |
|---|---|---|
| 卡片处理方式 | 缓冲等待完整后一次性渲染 | 避免未闭合标签显示为乱码,确保卡片完整性 |
| 状态隔离 | 每个组件独立实例 | 避免聊天列表中多消息相互干扰 |
| 增量处理 | 直接接收 delta,不依赖前缀比对 | 不受网络波动、SSE重连等影响,达到100%成功率 |
| 兼容模式 |
preprocess 方法仍支持全量内容传入 |
兼容现有调用方式 |
三、核心代码实现
3.1 ProductCardProcessor 类
核心架构:采用增量 Delta 直接传入模式,与千问等成熟方案一致。
const OPEN_TAG = '<product-card'
const CLOSE_TAG = '</product-card>'
export class ProductCardProcessor {
// 用于渲染的内容(非卡片立即追加,卡片缓冲后追加)
private renderContent = ''
// 当前缓冲区(待处理的字符)
private productCardBuffer = ''
// 卡片标签嵌套计数
private productCardOpenCount = 0
reset(): void {
this.renderContent = ''
this.productCardBuffer = ''
this.productCardOpenCount = 0
}
/**
* 处理增量内容(推荐使用,与千问方案一致)
* @param delta 本次新增的内容片段
* @returns 用于渲染的内容
*/
processDelta(delta: string): string {
if (typeof delta !== 'string')
return this.renderContent
for (const char of delta) {
if (this.productCardOpenCount === 0) {
// 状态:不在卡片内部
this.productCardBuffer += char
const openTagIndex = this.productCardBuffer.indexOf(OPEN_TAG)
if (openTagIndex !== -1) {
// 检测到卡片开始标签
this.renderContent += this.productCardBuffer.substring(0, openTagIndex)
this.productCardBuffer = OPEN_TAG
this.productCardOpenCount = 1
}
else {
// 未检测到标签,检查是否可以输出缓冲区内容
if (this.productCardBuffer.length >= OPEN_TAG.length) {
const lastPart = this.productCardBuffer.slice(-OPEN_TAG.length)
if (!OPEN_TAG.startsWith(lastPart)) {
this.renderContent += this.productCardBuffer[0]
this.productCardBuffer = this.productCardBuffer.substring(1)
}
}
}
}
else {
// 状态:在卡片内部,持续累积直到找到闭合标签
this.productCardBuffer += char
const closeTagIndex = this.productCardBuffer.indexOf(CLOSE_TAG)
if (closeTagIndex !== -1) {
// 检测到卡片闭合标签
this.renderContent += this.productCardBuffer.substring(0, closeTagIndex + CLOSE_TAG.length)
this.productCardBuffer = this.productCardBuffer.substring(closeTagIndex + CLOSE_TAG.length)
this.productCardOpenCount = 0
}
}
}
// 处理循环结束后缓冲区中剩余的非卡片内容
if (this.productCardOpenCount === 0 && this.productCardBuffer) {
if (this.productCardBuffer.length < OPEN_TAG.length || !OPEN_TAG.startsWith(this.productCardBuffer)) {
this.renderContent += this.productCardBuffer
this.productCardBuffer = ''
}
}
return this.renderContent
}
/**
* 兼容旧接口:处理全量内容(内部自动计算增量)
* @param content 完整内容
* @returns 用于渲染的内容
*/
preprocess(content: string): string {
if (typeof content !== 'string')
return content
// 通过 renderContent 长度推断增量部分
const delta = content.substring(this.renderContent.length)
return this.processDelta(delta)
}
}
3.2 Markdown 组件集成
import { ProductCardProcessor } from './markdown-utils'
import { useEffect, useRef, useCallback } from 'react'
export const Markdown = (props: MarkdownProps) => {
// 关键:使用 useRef 确保每个组件实例拥有独立的处理器
// 懒初始化模式,避免每次渲染创建新实例
const processorRef = useRef<ProductCardProcessor | null>(null)
if (!processorRef.current) {
processorRef.current = new ProductCardProcessor()
}
// 组件卸载时重置状态,避免内存泄漏
useEffect(() => {
return () => {
processorRef.current?.reset()
}
}, [])
// 包装处理器实例方法,保持引用稳定
const preprocessProductCardInstance = useCallback((content: string) => {
return processorRef.current?.preprocess(content) ?? content
}, [])
// 内容预处理流程:产品卡片 → Think标签 → LaTeX
const latexContent = flow([
preprocessProductCardInstance,
preprocessThinkTag,
preprocessLaTeX,
])(props.content)
return (
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
<ReactMarkdown latexContent={latexContent} {...} />
</div>
)
}
四、优化点说明
4.1 增量 Delta 直接传入(核心优化)
问题:原始方案依赖 content.startsWith(rawContent) 前缀比对推断增量,在 iOS Safari 的 SSE 重连等场景下会失败。
解决方案:采用与千问一致的增量 Delta 直接传入模式。processDelta 方法只接收本次新增的内容片段,不需要知道历史内容。
// 优化前:依赖前缀比对
preprocess(content: string): string {
if (!content.startsWith(this.rawContent)) {
this.reset() // 前缀不匹配时重置,导致状态丢失
}
const delta = content.substring(this.rawContent.length)
// ...
}
// 优化后:直接处理增量
processDelta(delta: string): string {
for (const char of delta) {
// ...直接处理,不受网络影响
}
}
4.2 状态封装(避免全局变量冲突)
问题:使用全局变量会导致多个 Markdown 组件共享状态,在聊天列表等场景下互相干扰。
解决方案:将状态封装到 ProductCardProcessor 类中,每个 Markdown 组件通过 useRef 持有独立实例。
4.3 useRef 懒初始化(避免重复创建)
问题:useRef(new ProductCardProcessor()) 每次渲染都会创建新实例。
解决方案:使用懒初始化模式,只在首次渲染时创建实例。
// 优化前:每次渲染创建新实例
const processorRef = useRef<ProductCardProcessor>(new ProductCardProcessor())
// 优化后:懒初始化,只创建一次
const processorRef = useRef<ProductCardProcessor | null>(null)
if (!processorRef.current) {
processorRef.current = new ProductCardProcessor()
}
五、处理流程示意图
┌─────────────────────────────────────────────────────────────────┐
│ 流式内容输入(增量 delta) │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 逐字符处理(for 循环) │
│ │
│ productCardOpenCount === 0: │
│ ├─ 检测到 '<product-card' → 进入卡片缓冲状态 │
│ └─ 未检测到标签 → 输出非卡片字符到 renderContent │
│ │
│ productCardOpenCount > 0: │
│ ├─ 检测到 '</product-card>' → 输出完整卡片到 renderContent │
│ └─ 未检测到闭合标签 → 继续累积到 buffer │
└──────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 返回 renderContent │
│ (用于 ReactMarkdown 渲染) │
└─────────────────────────────────────────────────────────────────┘
六、使用示例
6.1 基本使用(推荐:processDelta)
// 创建处理器实例
const processor = new ProductCardProcessor()
// 模拟流式输入(增量 delta)
processor.processDelta('你好') // → '你好'(普通文本实时输出)
processor.processDelta('<') // → '你好'(缓冲 '<',等待更多字符)
processor.processDelta('product') // → '你好'(缓冲 '<product')
processor.processDelta('-card data-name="商品"></product-card>')
// → '你好<product-card data-name="商品"></product-card>'(卡片完整后输出)
6.2 实际流式场景模拟
const processor = new ProductCardProcessor()
// 模拟大模型流式输出,逐块传输
const chunks = [
'推荐商品:',
'<product-card data-name="五丰寒地东北大米" ',
'data-price="156.00" data-image="https://example.com/rice.jpg"',
'></product-card>',
'\n这是一款优质大米。'
]
chunks.forEach(chunk => {
const result = processor.preprocess(chunk)
console.log(result)
})
// 输出过程:
// 1. '推荐商品:'
// 2. '推荐商品:'(卡片开始,不输出)
// 3. '推荐商品:'(卡片继续,不输出)
// 4. '推荐商品:<product-card data-name="五丰寒地东北大米" data-price="156.00" data-image="https://example.com/rice.jpg"></product-card>'(卡片完整,输出)
// 5. '推荐商品:<product-card ...></product-card>\n这是一款优质大米。'(后续文本实时输出)
6.3 新会话场景(非增量内容)
const processor = new ProductCardProcessor()
// 第一次调用
processor.preprocess('你好,这是第一条消息') // → '你好,这是第一条消息'
// 新会话,内容不是增量的
processor.preprocess('你好,这是新会话') // → '你好,这是新会话'(自动 reset,重新开始)
七、常见问题排查
7.1 卡片标签文本泄露
现象:页面上显示 <product-card data-name="xxx" 等标签代码
可能原因:
- 卡片闭合标签
</product-card>未正确传输 - 处理器状态未正确维护
- 超时时间过短导致提前输出
排查方法:
// 在 preprocess 方法中添加调试日志
console.log('productCardOpenCount:', this.productCardOpenCount)
console.log('productCardBuffer:', this.productCardBuffer.substring(0, 50))
7.2 卡片不渲染
现象:卡片标签完整但未渲染为卡片组件
可能原因:
- ReactMarkdown 未配置正确的自定义组件
- 标签属性格式不正确
排查方法:
- 检查
react-markdown-wrapper.tsx中是否注册了product-card组件 - 验证标签属性是否符合组件要求
7.3 状态串扰
现象:聊天列表中多条消息的卡片状态相互影响
可能原因:
- 使用了全局共享的处理器实例
排查方法:
- 确认每个 Markdown 组件使用独立的
ProductCardProcessor实例(通过useRef)
八、HTML Div 格式支持
8.1 功能概述
除了 <product-card> 标签格式外,系统还支持大模型直接输出完整样式的 HTML <div> 格式。这种格式允许模型自行控制卡片的视觉呈现,适合需要更灵活样式的场景。
8.2 两种格式对比
| 特性 |
<product-card> 标签格式 |
HTML Div 格式 |
|---|---|---|
| 样式控制 | 由 ProductCard 组件统一控制 | 模型自行定义 CSS |
| 点击事件 | React 组件内联绑定 | 事件委托机制 |
| 流式缓冲 | 自动缓冲等待完整标签 | 不缓冲,直接渲染 |
| 适用场景 | 样式统一、快速开发 | 高度定制化需求 |
8.3 HTML Div 格式规范
8.3.1 必须属性
外层 div 必须包含以下 data-* 属性,用于点击事件处理:
| 属性 | 必填 | 说明 |
|---|---|---|
data-product-card |
✅ | 标识为商品卡(值可为空或任意值) |
data-name |
✅ | 商品名称 |
data-price |
✅ | 商品价格(如 156.00) |
data-product-id |
✅ | 商品ID |
data-shop-id |
✅ | 店铺ID |
data-shop-name |
✅ | 店铺名称 |
data-image |
✅ | 商品图片URL |
data-spec |
❌ | 规格型号 |
data-category |
❌ | 商品类目 |
data-unit |
❌ | 计量单位 |
data-tags |
❌ | 标签(热销、新品等) |
8.3.2 完整示例
<div data-product-card data-name="五丰寒地东北大米" data-price="156.00" data-image="https://www.eadd1.cn/api/Edfs/GetImage?url=http://182.92.5.197:8999/AppStoreService/image/2022120813420940574.jpg" data-spec="1*4*5KG" data-category="米面粮油" data-unit="" data-tags="" data-product-id="3823" data-shop-id="13523" data-shop-name="宏帝商贸" class="mt-3 flex w-full cursor-pointer items-start gap-3 rounded-lg bg-gray-50 p-3 border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all duration-200 ease-in-out">
<div class="relative h-20 w-20 flex-shrink-0">
<img src="https://www.eadd1.cn/api/Edfs/GetImage?url=http://182.92.5.197:8999/AppStoreService/image/2022120813420940574.jpg" alt="五丰寒地东北大米" class="h-full w-full rounded-lg object-cover" loading="lazy">
</div>
<div class="min-w-0 flex-1">
<div class="mt-1 flex items-center gap-1 line-clamp-2 text-[12px] text-gray-500">
<img src="https://www.eadd1.cn/wechat/img/jkg_01.png" alt="店铺" class="h-2 w-2 flex-shrink-0" style="width: 12px; height: 12px;">
小李商贸
</div>
<h4 class="line-clamp-2 font-medium text-gray-800" style="font-size: 16px; padding-top: 0.2rem; margin-bottom: 8px; white-space: normal;">五丰寒地东北大米</h4>
<div class="mt-1 flex flex-wrap gap-2">
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">1*4*5KG</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">米面粮油</span>
</div>
<div class="mt-0 flex items-baseline gap-1">
<span class="text-xs font-semibold text-orange-600">¥</span>
<span class="text-base font-bold text-orange-600">156.00</span>
</div>
</div>
</div>
8.4 点击事件实现机制
由于 HTML Div 格式是通过 rehype-raw 渲染的静态 DOM,React 的内联事件无法直接绑定。系统采用事件委托机制:
8.4.1 事件委托流程
用户点击卡片 → 事件冒泡到 markdown-body 容器 → closest('[data-product-card]') 检测 → 读取 dataset 属性 → 调用 handleProductCardClick
8.4.2 核心代码
在 app/components/base/markdown/index.tsx 中:
const handleMarkdownClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
const cardElement = target.closest('[data-product-card]') as HTMLElement | null
if (cardElement) {
const dataset = cardElement.dataset
handleProductCardClick({
dataName: dataset.name || '',
dataPrice: dataset.price || '',
dataImage: dataset.image || '',
dataSpec: dataset.spec,
dataCategory: dataset.category,
dataStock: dataset.stock,
dataLink: 'https://www.xxxx.cn/wechat/view/shop_goods_details.html',
dataTags: dataset.tags,
dataProductId: dataset.productId,
dataUnit: dataset.unit,
dataShopId: dataset.shopId,
dataShopName: dataset.shopName,
})
}
}, [])
8.5 样式支持
8.5.1 动态 CSS 类支持
由于模型输出的 HTML 不在 Tailwind 的扫描范围内,系统在 app/styles/globals.css 中手动添加了常用的 Tailwind 类:
| 类别 | 支持的类名 |
|---|---|
| 尺寸 |
h-2, w-2, h-3, w-3, h-4, w-4, h-5, w-5, h-6, w-6, h-20, w-20, h-24, w-24, h-26, w-26
|
| 间距 |
mt-0, mt-1, mt-3, gap-1, gap-2, gap-3
|
| 圆角 |
rounded, rounded-lg, rounded-br-lg
|
| 布局 |
flex, flex-shrink-0, flex-wrap, flex-1, items-center, items-baseline, items-start
|
| 字体 |
text-xs, text-base, text-[10px], text-[12px], text-[16px], font-medium, font-semibold, font-bold
|
| 颜色 |
text-gray-500/600/800, text-orange-600, text-white, bg-gray-50/100, bg-orange-500
|
| 边框 |
border, border-gray-100/200
|
| 其他 |
line-clamp-2, object-cover, cursor-pointer, transition-all, duration-200, ease-in-out
|
8.5.2 自定义样式
推荐使用内联 style 属性来确保样式生效,例如:
<img src="..." style="width: 12px; height: 12px;" />
<h4 style="font-size: 16px; padding-top: 0.2rem; margin-bottom: 8px; white-space: normal;">商品名称</h4>
8.6 大模型提示词建议
你是一个智能导购助手。请根据用户的查询和提供的商品数据,直接输出包含商品卡的文本内容。
## 输出规则
1. 使用 HTML div 格式输出商品卡,不要用代码块包裹
2. 商品卡外层 div 必须包含 data-product-card 属性和所有必要的 data-* 属性
3. 店铺名称必须显示在商品名称上方,使用店铺图标:https://www.eadd1.cn/wechat/img/jkg_01.png
4. 店铺图标尺寸必须为 12px × 12px
5. 在商品卡前后可以添加自然语言描述
## 完整示例
您好!这是为您推荐的商品:
<div data-product-card data-name="五丰寒地东北大米" data-price="156.00" data-image="https://www.eadd1.cn/api/Edfs/GetImage?url=http://182.92.5.197:8999/AppStoreService/image/2022120813420940574.jpg" data-spec="1*4*5KG" data-category="米面粮油" data-unit="" data-tags="" data-product-id="3823" data-shop-id="13523" data-shop-name="小李商贸" class="mt-3 flex w-full cursor-pointer items-start gap-3 rounded-lg bg-gray-50 p-3 border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all duration-200 ease-in-out">
<div class="relative h-20 w-20 flex-shrink-0">
<img src="https://www.eadd1.cn/api/Edfs/GetImage?url=http://182.92.5.197:8999/AppStoreService/image/2022120813420940574.jpg" alt="五丰寒地东北大米" class="h-full w-full rounded-lg object-cover" loading="lazy">
</div>
<div class="min-w-0 flex-1">
<div class="mt-1 flex items-center gap-1 line-clamp-2 text-[12px] text-gray-500">
<img src="https://www.eadd1.cn/wechat/img/jkg_01.png" alt="店铺" class="h-2 w-2 flex-shrink-0" style="width: 12px; height: 12px;">
宏帝商贸
</div>
<h4 class="line-clamp-2 font-medium text-gray-800" style="font-size: 16px; padding-top: 0.2rem; margin-bottom: 8px; white-space: normal;">五丰寒地东北大米</h4>
<div class="mt-1 flex flex-wrap gap-2">
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">1*4*5KG</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">米面粮油</span>
</div>
<div class="mt-0 flex items-baseline gap-1">
<span class="text-xs font-semibold text-orange-600">¥</span>
<span class="text-base font-bold text-orange-600">156.00</span>
</div>
</div>
</div>
请问您想了解更多吗?
九、关键文件
| 文件 | 说明 |
|---|---|
app/components/base/markdown/markdown-utils.ts |
ProductCardProcessor 类实现、handleProductCardClick 公共函数 |
app/components/base/markdown/index.tsx |
Markdown 组件集成,事件委托实现 |
app/components/base/markdown/react-markdown-wrapper.tsx |
ReactMarkdown 封装,注册自定义组件和块级元素提取 |
app/components/base/markdown-blocks/product-card.tsx |
ProductCard React 组件实现 |
app/styles/globals.css |
全局 CSS,包含动态 HTML 所需的 Tailwind 类定义 |