dify智能体输出AI会话千问效果流式渲染卡片实现方案

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 设计目标

针对流式输出场景,实现以下目标:

  1. 非卡片内容实时渲染:普通文本在收到后立即显示,保持流式体验
  2. 卡片内容缓冲等待<product-card> 标签在传输过程中不显示,等待完整后一次性渲染为卡片
  3. 位置保持正确:卡片渲染在其原始位置,不会与周围文本重叠或错位
  4. 状态隔离:每个 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" 等标签代码

可能原因

  1. 卡片闭合标签 </product-card> 未正确传输
  2. 处理器状态未正确维护
  3. 超时时间过短导致提前输出

排查方法

// 在 preprocess 方法中添加调试日志
console.log('productCardOpenCount:', this.productCardOpenCount)
console.log('productCardBuffer:', this.productCardBuffer.substring(0, 50))

7.2 卡片不渲染

现象:卡片标签完整但未渲染为卡片组件

可能原因

  1. ReactMarkdown 未配置正确的自定义组件
  2. 标签属性格式不正确

排查方法

  • 检查 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 类定义
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容