10 · 图片上传与多模态消息
支持图片选择、预览、发送与消息气泡渲染,并把图片附件真正组装成模型可用的多模态输入。
课时资源
一、学习目标
本课所在阶段:第四阶段 · 模型与多模态扩展。
学完这一课后,你应该能够:
- 看懂图片文件是怎样从输入框进入聊天请求的
- 理解为什么前端要先把图片转成
dataUrl - 说清楚
AttachmentMeta、image_url和消息气泡图片渲染分别负责什么 - 区分"当前轮图片展示"和"完整附件持久化"这两件事
二、问题背景
到了第九课,应用已经支持真实模型、工具面板和模型切换。但它仍然有一个明显缺口:输入框只能发文本,用户没法把图片一起发出去,聊天区也不会显示自己刚刚上传了什么。
真实多模态聊天至少要先解决三个问题:
- 选择图片后,用户要能立刻看到预览
- 发请求时,图片不能还是浏览器里的
File对象,而要变成可序列化的数据 - 模型输入不能只保留文件名,而要把图片真正放进多模态消息内容里
这一课真正补上的,就是这三段链路。
三、核心概念
这一课最重要的不是"多一个上传按钮",而是图片在系统里有了三种不同形态:
-
File浏览器原生文件对象,只适合本地读取,不适合直接放进聊天请求体 -
AttachmentMeta前端和接口之间使用的轻量附件结构,里面保存了name、type、preview、dataUrl -
多模态消息内容 后端在
buildUserContent(...)里把图片附件转成image_url,让模型真正收到图片输入
这里还有一个很重要的边界要先讲清楚:
- 这一课已经支持"图片发送"和"当前界面里的图片渲染"
- 但还没有做"附件历史持久化和会话重放"
也就是说,图片现在已经进了本轮请求链路,但历史恢复仍然主要依赖文本消息。
四、整体流程
五、运行过程
1. 前端先把图片从 File 变成可发送的数据
这一课的第一步不是发请求,而是先在浏览器里把图片读出来:
const dataUrl = await readFileAsDataUrl(file);
return {
name: file.name,
type: file.type,
preview: dataUrl,
dataUrl,
};这里同一份 dataUrl 做了两件事:
preview给前端界面渲染图片dataUrl给后端组装多模态消息
2. 用户消息本身也会带上附件
发送时,page.tsx 先把用户消息放进本地消息列表:
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content,
attachments,
loading: false,
};这一步很重要,因为它决定了聊天区能立即渲染用户刚选的图片,而不用等服务端返回。
3. 请求体把文本、图片、模型、工具一起发到后端
body: JSON.stringify({
message: content,
messages: history,
threadId,
modelId,
attachments,
toolIds: selectedToolIds,
})从这一课开始,一条聊天请求已经不只是文本,而是同时带着:
- 文本内容
- 图片附件
- 当前线程
- 当前模型
- 当前工具集合
4. 后端把图片附件真正转换成多模态输入
本课最关键的升级发生在 buildUserContent(...):
return [
{ type: 'text', text: message },
...imageAttachments.map((attachment) => ({
type: 'image_url',
image_url: {
url: attachment.dataUrl as string,
},
})),
];这说明 lesson 10 已经不是"只告诉模型有附件",而是把图片真的塞进了模型输入。
也正因为这样,这一课和前几课的本质差别是:
- 前几课主要是文本消息
- 这一课开始出现真实多模态内容块
5. 聊天气泡会把图片附件渲染出来
MessageBubble.tsx 里会读取消息上的 attachments:
{attachment.preview ? (
<img src={attachment.preview} alt={attachment.name} className="attachment-preview" />
) : null}所以用户发送图片后,聊天区会直接显示缩略图,而不是只有文件名。
六、关键代码解析
app/components/ChatInput.tsx - 负责读取图片文件、生成 dataUrl、显示上传预览,并把附件和文本一起提交。
关键代码:为什么要同时保存 preview 和 dataUrl
return {
name: file.name,
type: file.type,
preview: dataUrl,
dataUrl,
};代码解析:
这一课里这两个字段虽然值相同,但职责不同:
preview面向界面显示,语义上表示"这张图怎样在前端预览"dataUrl面向请求链路,语义上表示"这张图怎样进入模型输入"- 先分开字段,后面如果你要把预览图换成压缩版、把发送图换成原图,就不用重构整个数据结构
app/page.tsx - 把 attachments 挂到用户消息上,同时把它们和 modelId、toolIds 一起发给后端。
关键代码:
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content,
attachments,
loading: false,
};代码解析:
这一步很重要,因为它决定了聊天区能立即渲染用户刚选的图片,而不用等服务端返回。
app/agent/chatbot.ts - 本课最关键的后端文件。它会筛出图片附件,并把它们组装成模型能消费的 image_url 内容块。
关键代码 1:为什么 buildUserContent(...) 要先筛图片
const imageAttachments = (attachments ?? []).filter(
(attachment) =>
typeof attachment.dataUrl === 'string' &&
attachment.dataUrl.startsWith('data:image/')
);代码解析:
这里不是随便过滤一下,而是在做输入边界控制:
- 当前课只支持图片附件
- 只有
data:image/...才会被组装成image_url - 这给后面扩展 PDF、音频或视频留下了清晰入口
关键代码 2:把图片组装成多模态输入
return [
{ type: 'text', text: message },
...imageAttachments.map((attachment) => ({
type: 'image_url',
image_url: {
url: attachment.dataUrl as string,
},
})),
];代码解析:
这说明 lesson 10 已经不是"只告诉模型有附件",而是把图片真的塞进了模型输入。也正因为这样,这一课和前几课的本质差别是:前几课主要是文本消息,这一课开始出现真实多模态内容块。
app/components/MessageBubble.tsx - 负责把消息里的 attachments.preview 渲染成图片卡片,让用户在聊天区看到自己刚刚发出的图片。
关键代码:
{attachment.preview ? (
<img src={attachment.preview} alt={attachment.name} className="attachment-preview" />
) : null}代码解析:
用户发送图片后,聊天区会直接显示缩略图,而不是只有文件名。
app/types/chat.ts - 定义 AttachmentMeta 和带附件的 ChatMessage,是前后端共享的数据边界。
七、常见问题
为什么不直接把 File 发给后端?
因为 File 是浏览器对象,不适合直接进入 JSON 请求体。先转成 dataUrl,这一课就能用最直白的方式打通链路。
这一课到底是"附件元数据透传",还是"真实图片输入"?
严格按当前代码说,这一课已经支持真实图片输入。因为 chatbot.ts 里确实把图片组装成了 image_url 内容块。
为什么历史会话里看不到完整图片回放?
因为当前历史恢复逻辑仍然以文本消息为主,attachments 没有被单独持久化和重建。这是下一步工程化扩展要解决的问题,不是这一课的重点。
八、练习题
- 解释
AttachmentMeta里为什么既有preview,又有dataUrl。 - 如果后面要支持 PDF 上传,你会改
ChatInput.tsx、buildUserContent(...)还是MessageBubble.tsx?为什么? - 说明"当前轮图片渲染"和"历史图片回放"分别依赖哪些代码。
九、总结
这一课真正完成的是:让图片从输入框进入正式聊天请求,并在两个地方同时生效。
- 在后端,它被组装成
image_url,成为模型的多模态输入 - 在前端,它被挂到用户消息上,成为聊天区可见的图片气泡
如果你已经能说清 ChatInput -> page.tsx -> chat.service.ts -> chatbot.ts -> MessageBubble 这条链路,以及当前为什么还没有做完整附件持久化,这一课就掌握了。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。