第四阶段 · 模型与多模态扩展

10 · 图片上传与多模态消息

支持图片选择、预览、发送与消息气泡渲染,并把图片附件真正组装成模型可用的多模态输入。

课时资源

一、学习目标

本课所在阶段:第四阶段 · 模型与多模态扩展

学完这一课后,你应该能够:

  • 看懂图片文件是怎样从输入框进入聊天请求的
  • 理解为什么前端要先把图片转成 dataUrl
  • 说清楚 AttachmentMetaimage_url 和消息气泡图片渲染分别负责什么
  • 区分"当前轮图片展示"和"完整附件持久化"这两件事

二、问题背景

到了第九课,应用已经支持真实模型、工具面板和模型切换。但它仍然有一个明显缺口:输入框只能发文本,用户没法把图片一起发出去,聊天区也不会显示自己刚刚上传了什么。

真实多模态聊天至少要先解决三个问题:

  • 选择图片后,用户要能立刻看到预览
  • 发请求时,图片不能还是浏览器里的 File 对象,而要变成可序列化的数据
  • 模型输入不能只保留文件名,而要把图片真正放进多模态消息内容里

这一课真正补上的,就是这三段链路。

三、核心概念

这一课最重要的不是"多一个上传按钮",而是图片在系统里有了三种不同形态:

  1. File 浏览器原生文件对象,只适合本地读取,不适合直接放进聊天请求体

  2. AttachmentMeta 前端和接口之间使用的轻量附件结构,里面保存了 nametypepreviewdataUrl

  3. 多模态消息内容 后端在 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、显示上传预览,并把附件和文本一起提交。

关键代码:为什么要同时保存 previewdataUrl

return {
  name: file.name,
  type: file.type,
  preview: dataUrl,
  dataUrl,
};

代码解析:

这一课里这两个字段虽然值相同,但职责不同:

  1. preview 面向界面显示,语义上表示"这张图怎样在前端预览"
  2. dataUrl 面向请求链路,语义上表示"这张图怎样进入模型输入"
  3. 先分开字段,后面如果你要把预览图换成压缩版、把发送图换成原图,就不用重构整个数据结构

app/page.tsx - 把 attachments 挂到用户消息上,同时把它们和 modelIdtoolIds 一起发给后端。

关键代码:

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/')
);

代码解析:

这里不是随便过滤一下,而是在做输入边界控制:

  1. 当前课只支持图片附件
  2. 只有 data:image/... 才会被组装成 image_url
  3. 这给后面扩展 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 没有被单独持久化和重建。这是下一步工程化扩展要解决的问题,不是这一课的重点。

八、练习题

  1. 解释 AttachmentMeta 里为什么既有 preview,又有 dataUrl
  2. 如果后面要支持 PDF 上传,你会改 ChatInput.tsxbuildUserContent(...) 还是 MessageBubble.tsx?为什么?
  3. 说明"当前轮图片渲染"和"历史图片回放"分别依赖哪些代码。

九、总结

这一课真正完成的是:让图片从输入框进入正式聊天请求,并在两个地方同时生效。

  • 在后端,它被组装成 image_url,成为模型的多模态输入
  • 在前端,它被挂到用户消息上,成为聊天区可见的图片气泡

如果你已经能说清 ChatInput -> page.tsx -> chat.service.ts -> chatbot.ts -> MessageBubble 这条链路,以及当前为什么还没有做完整附件持久化,这一课就掌握了。

登录以继续阅读

解锁完整文档、代码示例及更多高级功能。

立即登录

On this page