自定义消息组件:图片、文件附件与图表

做智能体对话界面,一开始我们都觉得"不就是左边气泡右边气泡,加个 Markdown 渲染就完事了"。但随着智能体的能力越来越强,它不光会说话,还会发图片、发文件、画图表。用户的需求也从"帮我解释一下"变成了"直接给我生成一份报表""把那张图发给我"。

去年我们接了一个制造业的智能体项目,AI 能自动分析产线数据并生成趋势图。刚开始我们只支持纯文本和 Markdown,结果用户直接在群里开怼:"你们这 AI 说了半天,还不如一张图来得清楚。"我们连夜加班,把图表嵌入功能搞了上去。从那以后,我对"消息组件"这四个字有了全新的理解。

这篇文章,我就把图片、文件附件、图表这三种自定义消息组件的设计思路、实现方案和踩坑经验,一五一十地讲清楚。全部基于真实项目的 React + TypeScript 代码,你可以直接复制改造。

一、为什么需要扩展消息类型?

传统聊天界面里的消息就是纯文本。智能体聊天不是。AI 可能会在对话中发送一张流程图、一份 PDF 报告、一个交互式图表。用户也可能需要上传截图、文档让 AI 分析。这两种方向,都对消息组件的承载能力提出了新的要求。

我把扩展方向分成三个维度:

  1. 用户 → AI:用户上传图片(截图报错)、文件(PDF 合同、Excel 数据)、语音片段等
  2. AI → 用户:AI 生成分析图表(折线图、柱状图)、发送文件(报告导出)、展示可视化内容
  3. 系统 → 用户:工具调用的中间结果展示(比如"正在查询订单状态,返回了 3 条记录")

传统的 { role, content } 结构已经完全不够用了。我们需要一个能承载多种媒体类型的消息数据模型。

下面是扩展后的消息结构设计:

typescript 复制代码
type MessageContentType = 'text' | 'image' | 'file' | 'chart';

interface Message {
  id: string;
  role: 'user' | 'assistant' | 'system';
  timestamp: number;
  content: string; // 文本内容(可选,当有附件时可为空)
  attachments?: Attachment[];
  metadata?: Record<string, any>;
}

interface Attachment {
  id: string;
  type: 'image' | 'file' | 'chart';
  url?: string;           // 图片/文件的访问 URL
  name?: string;          // 文件名
  size?: number;          // 文件大小(字节)
  mimeType?: string;      // MIME 类型
  data?: any;             // 图表数据(前端直接渲染用)
  preview?: string;       // 缩略图(base64 或 blob)
  status?: 'uploading' | 'success' | 'error';
  progress?: number;      // 上传进度
}

这个结构既支持用户上传附件(发送时填充 attachments),也支持 AI 生成的附件(接收时展示)。下面我们分类型展开实现。

二、图片消息组件:预览、上传与画廊

2.1 AI 生成图片的展示

AI 在回答中可能直接返回图片 URL(例如调用绘图工具生成的图表、从文档中提取的截图)。我们需要支持点击预览、缩放、甚至画廊模式(多图轮播)。

基础组件代码:

tsx 复制代码
// components/ImageAttachment.tsx
import { useState } from 'react';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { ImageIcon, Download, ZoomIn } from 'lucide-react';

interface ImageAttachmentProps {
  url: string;
  alt?: string;
  thumbnail?: boolean; // 是否显示缩略图模式
}

export function ImageAttachment({ url, alt = '图片', thumbnail = false }: ImageAttachmentProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [isLoading, setIsLoading] = useState(true);

  return (
    <>
      <div
        className={`relative group cursor-pointer rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 ${
          thumbnail ? 'max-w-[200px]' : 'max-w-[300px]'
        }`}
        onClick={() => setIsOpen(true)}
      >
        {isLoading && (
          <div className="absolute inset-0 flex items-center justify-center">
            <div className="w-6 h-6 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
          </div>
        )}
        <img
          src={url}
          alt={alt}
          className={`w-full h-auto object-cover transition-opacity ${isLoading ? 'opacity-0' : 'opacity-100'}`}
          onLoad={() => setIsLoading(false)}
          onError={() => setIsLoading(false)}
        />
        <div className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition bg-black/50 rounded p-1">
          <ZoomIn className="w-4 h-4 text-white" />
        </div>
      </div>

      {/* 图片预览弹窗(画廊模式) */}
      <Dialog open={isOpen} onOpenChange={setIsOpen}>
        <DialogContent className="max-w-[90vw] max-h-[90vh] p-0 bg-black/90 border-none">
          <img src={url} alt={alt} className="w-full h-full object-contain" />
          <button
            onClick={() => window.open(url, '_blank')}
            className="absolute top-4 right-12 text-white bg-black/50 rounded p-2 hover:bg-black/70"
          >
            <Download className="w-5 h-5" />
          </button>
        </DialogContent>
      </Dialog>
    </>
  );
}

2.2 用户上传图片(带预览、压缩、进度)

用户发送图片前,需要在前端完成压缩、预览、上传进度显示。我们使用 react-dropzone 做拖拽上传,browser-image-compression 做压缩。

tsx 复制代码
// hooks/useImageUpload.ts
import { useState } from 'react';
import imageCompression from 'browser-image-compression';

export function useImageUpload(onUploadComplete: (url: string, file: File) => void) {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);

  const compressImage = async (file: File): Promise<File> => {
    const options = {
      maxSizeMB: 1,
      maxWidthOrHeight: 1920,
      useWebWorker: true,
    };
    try {
      return await imageCompression(file, options);
    } catch (error) {
      console.error('压缩失败', error);
      return file;
    }
  };

  const upload = async (file: File) => {
    setUploading(true);
    setProgress(0);
    const compressed = await compressImage(file);
    const formData = new FormData();
    formData.append('file', compressed);

    // 模拟上传进度(实际使用 XMLHttpRequest 或 axios onUploadProgress)
    const xhr = new XMLHttpRequest();
    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        setProgress(Math.round((e.loaded / e.total) * 100));
      }
    });
    xhr.addEventListener('load', () => {
      if (xhr.status === 200) {
        const data = JSON.parse(xhr.responseText);
        onUploadComplete(data.url, compressed);
        setUploading(false);
      } else {
        console.error('上传失败');
        setUploading(false);
      }
    });
    xhr.open('POST', '/api/upload');
    xhr.send(formData);
  };

  return { upload, uploading, progress };
}

在聊天输入框中集成拖拽上传区域(使用 react-dropzone):

tsx 复制代码
// components/ChatInputWithAttachments.tsx
import { useDropzone } from 'react-dropzone';
import { ImageAttachmentPreview } from './ImageAttachmentPreview';

export function ChatInputWithAttachments({ onSend }) {
  const [attachments, setAttachments] = useState<File[]>([]);
  const { getRootProps, getInputProps } = useDropzone({
    accept: { 'image/*': [] },
    onDrop: (acceptedFiles) => {
      setAttachments(prev => [...prev, ...acceptedFiles]);
    },
  });

  const handleSend = async () => {
    // 先上传图片获得 URL,再发送消息
    const uploadedUrls = await Promise.all(
      attachments.map(async (file) => {
        const url = await uploadImage(file);
        return { url, name: file.name, type: 'image' };
      })
    );
    onSend({ text: input, attachments: uploadedUrls });
    setAttachments([]);
  };

  return (
    <div {...getRootProps()} className="border rounded-lg p-2">
      <input {...getInputProps()} />
      <div className="flex flex-wrap gap-2 mb-2">
        {attachments.map((file, idx) => (
          <ImageAttachmentPreview key={idx} file={file} onRemove={() => setAttachments(prev => prev.filter((_, i) => i !== idx))} />
        ))}
      </div>
      <textarea value={input} onChange={...} />
      <button onClick={handleSend}>发送</button>
    </div>
  );
}

2.3 图片消息在聊天流中的展示

在消息循环中根据附件类型选择渲染组件:

tsx 复制代码
{messages.map(msg => (
  <div key={msg.id} className="message-bubble">
    {msg.content && <Markdown>{msg.content}</Markdown>}
    {msg.attachments?.map(att => {
      if (att.type === 'image') {
        return <ImageAttachment key={att.id} url={att.url} thumbnail />;
      }
      // 其他类型...
    })}
  </div>
))}

三、文件附件组件:下载、预览与图标

3.1 文件消息的数据结构与展示

AI 可以生成 PDF、Excel、Word 等文件让用户下载。展示时用文件图标 + 文件名 + 大小,点击即可下载。

tsx 复制代码
// components/FileAttachment.tsx
import { FileIcon, Download, FileText, FileSpreadsheet, FileImage } from 'lucide-react';

const fileIconMap: Record<string, React.ElementType> = {
  'application/pdf': FileText,
  'application/vnd.ms-excel': FileSpreadsheet,
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': FileSpreadsheet,
  'image/': FileImage,
};

export function FileAttachment({ file }: { file: Attachment }) {
  const Icon = fileIconMap[file.mimeType?.split('/')[0] + '/'] || FileIcon;
  const formattedSize = file.size ? `${(file.size / 1024).toFixed(1)} KB` : '';

  return (
    <div className="flex items-center gap-3 p-3 border rounded-lg bg-gray-50 dark:bg-gray-800 max-w-[280px]">
      <Icon className="w-8 h-8 text-blue-500" />
      <div className="flex-1 min-w-0">
        <div className="text-sm font-medium truncate">{file.name}</div>
        <div className="text-xs text-gray-500">{formattedSize}</div>
      </div>
      <a href={file.url} download={file.name} className="p-1 hover:bg-gray-200 rounded">
        <Download className="w-4 h-4" />
      </a>
    </div>
  );
}

3.2 文件上传(支持任意类型)

用户可以上传文档让 AI 分析。后端需要提供文件上传接口,前端使用相同的 useFileUpload Hook,支持分片上传(大文件)。

tsx 复制代码
// 支持分片上传的简单实现(使用 tus 协议)
import * as tus from 'tus-js-client';

export function useFileUpload() {
  const uploadFile = (file: File, onProgress: (percent: number) => void): Promise<string> => {
    return new Promise((resolve, reject) => {
      const upload = new tus.Upload(file, {
        endpoint: '/api/upload',
        retryDelays: [0, 3000, 5000, 10000],
        onError: reject,
        onProgress: (bytesUploaded, bytesTotal) => {
          onProgress(Math.round((bytesUploaded / bytesTotal) * 100));
        },
        onSuccess: () => {
          resolve(upload.url);
        },
      });
      upload.start();
    });
  };
  return { uploadFile };
}

在聊天组件中使用:

tsx 复制代码
const handleFileSelect = async (files: FileList) => {
  for (const file of Array.from(files)) {
    setUploadProgress(0);
    const url = await uploadFile(file, (p) => setUploadProgress(p));
    setAttachments(prev => [...prev, { name: file.name, url, type: 'file', size: file.size }]);
  }
};

3.3 文件预览(PDF、图片内嵌预览)

对于 PDF 文件,可以内嵌 <iframe> 或使用 react-pdf 库实现轻量预览。我们选择在文件附件下方增加一个"预览"按钮,点击打开模态框。

tsx 复制代码
import { Document, Page } from 'react-pdf';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';

function PdfPreview({ url }: { url: string }) {
  const [numPages, setNumPages] = useState(null);
  return (
    <Document file={url} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
      <Page pageNumber={1} width={300} />
    </Document>
  );
}

四、图表组件:动态数据可视化

4.1 AI 生成图表的场景与数据格式

AI 在分析数据后,可能会要求前端渲染图表。后端只需返回标准化的图表数据(如 ECharts 配置),前端用 echarts-for-react 渲染。

定义图表消息的数据结构:

typescript 复制代码
interface ChartAttachment extends Attachment {
  type: 'chart';
  chartType: 'line' | 'bar' | 'pie' | 'scatter';
  data: {
    xAxis?: string[];
    series: { name: string; data: number[] }[];
  };
  options?: any; // 自定义 ECharts 配置
}

4.2 ECharts 封装组件

tsx 复制代码
// components/ChartMessage.tsx
import ReactECharts from 'echarts-for-react';
import { useTheme } from 'next-themes';

export function ChartMessage({ data, chartType, options }: ChartAttachment) {
  const { theme } = useTheme();
  const isDark = theme === 'dark';

  const getOption = () => {
    const baseOption = {
      tooltip: { trigger: 'axis' },
      legend: { data: data.series.map(s => s.name), textStyle: { color: isDark ? '#fff' : '#000' } },
      grid: { containLabel: true },
      xAxis: { type: 'category', data: data.xAxis, axisLabel: { rotate: 30 } },
      yAxis: { type: 'value' },
      series: data.series.map(s => ({
        name: s.name,
        type: chartType,
        data: s.data,
        smooth: true,
      })),
    };
    return { ...baseOption, ...options };
  };

  return <ReactECharts option={getOption()} style={{ height: 350, width: '100%' }} theme={isDark ? 'dark' : 'light'} />;
}

4.3 与流式输出的配合

如果 AI 在流式输出过程中逐步返回图表数据,建议等收到完整的 chart 附件后再渲染,避免配置不完整导致 ECharts 报错。

tsx 复制代码
// 在流式消息聚合时判断
if (chunk.attachments?.some(a => a.type === 'chart')) {
  // 等待完整的图表数据到达后才显示
  if (isComplete) {
    setMessages(prev => [...prev, finalMessage]);
  } else {
    // 显示加载占位符
    setMessages(prev => [...prev, { ...incompleteMessage, content: '正在生成图表...' }]);
  }
}

4.4 简单 SVG 图表(轻量级)

如果不引入 ECharts 这种重量级库,也可以用 recharts 或自己画 SVG。下面是一个简单的折线图示例:

tsx 复制代码
import { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';

const data = data.xAxis.map((label, idx) => ({
  name: label,
  值: data.series[0].data[idx],
}));

<LineChart width={400} height={250} data={data}>
  <XAxis dataKey="name" />
  <YAxis />
  <Tooltip />
  <Line type="monotone" dataKey="值" stroke="#8884d8" />
</LineChart>

五、整体消息渲染器:路由与组合

最终,我们需要一个统一的消息组件,根据 attachments 中的类型渲染不同的子组件。

tsx 复制代码
// components/MessageBubble.tsx
import { ImageAttachment } from './ImageAttachment';
import { FileAttachment } from './FileAttachment';
import { ChartMessage } from './ChartMessage';
import Markdown from './Markdown';

export function MessageBubble({ message }: { message: Message }) {
  const isUser = message.role === 'user';

  return (
    <div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
      <div className={`max-w-[80%] rounded-2xl px-4 py-2 ${isUser ? 'bg-blue-500 text-white' : 'bg-gray-100 dark:bg-gray-800'}`}>
        {message.content && <Markdown>{message.content}</Markdown>}
        {message.attachments?.map(att => {
          switch (att.type) {
            case 'image':
              return <ImageAttachment key={att.id} url={att.url} thumbnail />;
            case 'file':
              return <FileAttachment key={att.id} file={att} />;
            case 'chart':
              return <ChartMessage key={att.id} {...att} />;
            default:
              return null;
          }
        })}
      </div>
    </div>
  );
}

下面用一张图展示整个消息类型架构:

六、实战:一个包含图表+文件下载的完整对话示例

下面是一个完整的对话流,展示了用户上传 Excel、AI 分析后生成图表并发送 PDF 报告的场景。

复制代码
用户: [上传 sales_data.xlsx] 帮我分析一下 Q1 各区域销售额趋势
系统: 正在分析文件...
AI: 根据您提供的销售数据,以下是各区域 Q1 销售额趋势图:
[图表组件显示折线图]
同时,我生成了详细的分析报告,请下载:
[文件附件: Q1销售分析报告.pdf]
AI: 华东区增长最快,环比增长 12.3%,建议加大营销投入。

要实现这样的对话,前端需要支持:

  1. 拖拽/选择文件上传,显示上传进度
  2. 发送消息时携带文件 ID
  3. 后端调用数据处理 Agent,生成图表配置和 PDF 文件
  4. 流式返回中同时包含文本、图表配置、文件 URL
  5. 前端解析多种附件类型并正确渲染

七、性能与体验优化

懒加载图片ImageAttachment 组件使用 loading="lazy",并在进入视口后才加载,减少首屏请求。

图片缓存:对相同 URL 的图片使用浏览器缓存,避免重复加载。

文件下载进度 :使用 fetchResponse.body 读取流可以获取下载进度,但通常下载本身是浏览器行为,用户可感知。对于大文件,可以考虑显示"文件正在生成"状态。

图表防抖 :图表组件在流式更新时可能反复重绘,使用 useMemo 缓存配置。

移动端适配 :图片预览模态框在移动端需要支持手势缩放,可以集成 yet-another-react-lightbox 库。

附件占位符:上传过程中显示缩略图占位符和进度条,上传完成后替换为正常附件。

tsx 复制代码
{att.status === 'uploading' && (
  <div className="relative">
    <div className="w-16 h-16 bg-gray-200 rounded animate-pulse" />
    <div className="absolute inset-0 flex items-center justify-center text-xs bg-black/50 text-white">
      {att.progress}%
    </div>
  </div>
)}

八、总结与扩展思考

通过扩展消息组件的类型,我们可以让智能体对话界面承载更丰富的信息形式。这不仅仅是炫技,而是真实业务场景的要求:用户需要看图、下载文档、直观地看到数据分析结果。

下一步可以扩展的方向还有很多:

  • 语音消息:用户发送语音,AI 语音回复,集成语音识别和合成。
  • 视频消息:AI 生成的教学视频或录屏,支持在线播放。
  • 交互式组件:表单、按钮、日历选择器等,让 AI 能收集用户输入。
  • 实时数据流:股票 K 线图、监控仪表盘等动态刷新图表。

这些都可以通过相同的 Attachment 机制扩展,只需要增加新的 type 和对应的渲染组件即可。

相关推荐
芯芯点灯8 小时前
gd32f303烧录提示Flash Timeout. Reset the Target and try it again.;
开发语言·前端·javascript
2601_958492558 小时前
7 Best WordPress Tools to Help Your News Site Actually Make Money
前端·word
施努卡机器视觉8 小时前
SNK施努卡3C锂电池全自动生产线:从极片到成品,如何实现高精度与柔性生产
人工智能·自动化
多年小白8 小时前
英伟达VR200机柜PCB价值量同比+233%:AI硬件主线如何被引爆?
大数据·人工智能·科技·深度学习·ai
放下华子我只抽RuiKe58 小时前
React 从入门到生产(七):性能优化实战
前端·javascript·人工智能·react.js·性能优化·前端框架·github
南屹川8 小时前
【网络】TCP/IP协议深度解析:从连接建立到数据传输
人工智能
糯米团子7498 小时前
vue知识点复习
前端·vue.js
月诸清酒8 小时前
66-260522 AI 科技日报 (谷歌永久提高Antigravity平台的Gemini使用限额到3倍)
人工智能
龙腾AI白云8 小时前
【无标题】知识图谱:AI的超级大脑
人工智能·知识图谱·tornado