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

自定义消息组件:图片、文件附件与图表 做智能体对话界面一开始我们都觉得“不就是左边气泡右边气泡加个 Markdown 渲染就完事了”。但随着智能体的能力越来越强它不光会说话还会发图片、发文件、画图表。用户的需求也从“帮我解释一下”变成了“直接给我生成一份报表”“把那张图发给我”。去年我们接了一个制造业的智能体项目AI 能自动分析产线数据并生成趋势图。刚开始我们只支持纯文本和 Markdown结果用户直接在群里开怼“你们这 AI 说了半天还不如一张图来得清楚。”我们连夜加班把图表嵌入功能搞了上去。从那以后我对“消息组件”这四个字有了全新的理解。这篇文章我就把图片、文件附件、图表这三种自定义消息组件的设计思路、实现方案和踩坑经验一五一十地讲清楚。全部基于真实项目的 React TypeScript 代码你可以直接复制改造。一、为什么需要扩展消息类型传统聊天界面里的消息就是纯文本。智能体聊天不是。AI 可能会在对话中发送一张流程图、一份 PDF 报告、一个交互式图表。用户也可能需要上传截图、文档让 AI 分析。这两种方向都对消息组件的承载能力提出了新的要求。我把扩展方向分成三个维度用户 → AI用户上传图片截图报错、文件PDF 合同、Excel 数据、语音片段等AI → 用户AI 生成分析图表折线图、柱状图、发送文件报告导出、展示可视化内容系统 → 用户工具调用的中间结果展示比如“正在查询订单状态返回了 3 条记录”传统的{ role, content }结构已经完全不够用了。我们需要一个能承载多种媒体类型的消息数据模型。下面是扩展后的消息结构设计typeMessageContentTypetext|image|file|chart;interfaceMessage{id:string;role:user|assistant|system;timestamp:number;content:string;// 文本内容可选当有附件时可为空attachments?:Attachment[];metadata?:Recordstring,any;}interfaceAttachment{id:string;type:image|file|chart;url?:string;// 图片/文件的访问 URLname?:string;// 文件名size?:number;// 文件大小字节mimeType?:string;// MIME 类型data?:any;// 图表数据前端直接渲染用preview?:string;// 缩略图base64 或 blobstatus?:uploading|success|error;progress?:number;// 上传进度}这个结构既支持用户上传附件发送时填充attachments也支持 AI 生成的附件接收时展示。下面我们分类型展开实现。二、图片消息组件预览、上传与画廊2.1 AI 生成图片的展示AI 在回答中可能直接返回图片 URL例如调用绘图工具生成的图表、从文档中提取的截图。我们需要支持点击预览、缩放、甚至画廊模式多图轮播。基础组件代码// 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 classNameabsolute inset-0 flex items-center justify-center div classNamew-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 classNameabsolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition bg-black/50 rounded p-1 ZoomIn classNamew-4 h-4 text-white / /div /div {/* 图片预览弹窗画廊模式 */} Dialog open{isOpen} onOpenChange{setIsOpen} DialogContent classNamemax-w-[90vw] max-h-[90vh] p-0 bg-black/90 border-none img src{url} alt{alt} classNamew-full h-full object-contain / button onClick{() window.open(url, _blank)} classNameabsolute top-4 right-12 text-white bg-black/50 rounded p-2 hover:bg-black/70 Download classNamew-5 h-5 / /button /DialogContent /Dialog / ); }2.2 用户上传图片带预览、压缩、进度用户发送图片前需要在前端完成压缩、预览、上传进度显示。我们使用react-dropzone做拖拽上传browser-image-compression做压缩。// 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): PromiseFile { 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// components/ChatInputWithAttachments.tsx import { useDropzone } from react-dropzone; import { ImageAttachmentPreview } from ./ImageAttachmentPreview; export function ChatInputWithAttachments({ onSend }) { const [attachments, setAttachments] useStateFile[]([]); 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()} classNameborder rounded-lg p-2 input {...getInputProps()} / div classNameflex 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 图片消息在聊天流中的展示在消息循环中根据附件类型选择渲染组件{messages.map(msg ( div key{msg.id} classNamemessage-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 等文件让用户下载。展示时用文件图标 文件名 大小点击即可下载。// components/FileAttachment.tsx import { FileIcon, Download, FileText, FileSpreadsheet, FileImage } from lucide-react; const fileIconMap: Recordstring, 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 classNameflex items-center gap-3 p-3 border rounded-lg bg-gray-50 dark:bg-gray-800 max-w-[280px] Icon classNamew-8 h-8 text-blue-500 / div classNameflex-1 min-w-0 div classNametext-sm font-medium truncate{file.name}/div div classNametext-xs text-gray-500{formattedSize}/div /div a href{file.url} download{file.name} classNamep-1 hover:bg-gray-200 rounded Download classNamew-4 h-4 / /a /div ); }3.2 文件上传支持任意类型用户可以上传文档让 AI 分析。后端需要提供文件上传接口前端使用相同的useFileUploadHook支持分片上传大文件。// 支持分片上传的简单实现使用 tus 协议 import * as tus from tus-js-client; export function useFileUpload() { const uploadFile (file: File, onProgress: (percent: number) void): Promisestring { 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 }; }在聊天组件中使用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库实现轻量预览。我们选择在文件附件下方增加一个“预览”按钮点击打开模态框。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渲染。定义图表消息的数据结构interfaceChartAttachmentextendsAttachment{type:chart;chartType:line|bar|pie|scatter;data:{xAxis?:string[];series:{name:string;data:number[]}[];};options?:any;// 自定义 ECharts 配置}4.2 ECharts 封装组件// 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 报错。// 在流式消息聚合时判断 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。下面是一个简单的折线图示例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 dataKeyname / YAxis / Tooltip / Line typemonotone dataKey值 stroke#8884d8 / /LineChart五、整体消息渲染器路由与组合最终我们需要一个统一的消息组件根据attachments中的类型渲染不同的子组件。// 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%建议加大营销投入。要实现这样的对话前端需要支持拖拽/选择文件上传显示上传进度发送消息时携带文件 ID后端调用数据处理 Agent生成图表配置和 PDF 文件流式返回中同时包含文本、图表配置、文件 URL前端解析多种附件类型并正确渲染七、性能与体验优化懒加载图片ImageAttachment组件使用loadinglazy并在进入视口后才加载减少首屏请求。图片缓存对相同 URL 的图片使用浏览器缓存避免重复加载。文件下载进度使用fetch的Response.body读取流可以获取下载进度但通常下载本身是浏览器行为用户可感知。对于大文件可以考虑显示“文件正在生成”状态。图表防抖图表组件在流式更新时可能反复重绘使用useMemo缓存配置。移动端适配图片预览模态框在移动端需要支持手势缩放可以集成yet-another-react-lightbox库。附件占位符上传过程中显示缩略图占位符和进度条上传完成后替换为正常附件。{att.status uploading ( div classNamerelative div classNamew-16 h-16 bg-gray-200 rounded animate-pulse / div classNameabsolute inset-0 flex items-center justify-center text-xs bg-black/50 text-white {att.progress}% /div /div )}八、总结与扩展思考通过扩展消息组件的类型我们可以让智能体对话界面承载更丰富的信息形式。这不仅仅是炫技而是真实业务场景的要求用户需要看图、下载文档、直观地看到数据分析结果。下一步可以扩展的方向还有很多语音消息用户发送语音AI 语音回复集成语音识别和合成。视频消息AI 生成的教学视频或录屏支持在线播放。交互式组件表单、按钮、日历选择器等让 AI 能收集用户输入。实时数据流股票 K 线图、监控仪表盘等动态刷新图表。这些都可以通过相同的Attachment机制扩展只需要增加新的type和对应的渲染组件即可。