18 KiB
多模态输入支持 — 设计规格
1. 概述
为 Agent 对话输入框增加图片上传能力,使消息支持「文本 + 图片」混合内容。后端将图片以 URL 引用方式传递给 LLM(Vercel AI SDK 的 image content part),实现多模态对话。
范围
- 支持模态:仅图片(PNG / JPG / WebP)
- 传输方式:先上传到服务器再引用 URL(复用已有
server/api/file/upload.post.ts) - 模型能力检测:前端根据
llmModels.type字段(text | vision | multimodal)拦截,非 vision/multimodal 模型时禁用图片上传 - 限制:单条消息最多 4 张图片,单张 ≤ 5MB(已有上传限制)
不在范围内
- 音频、视频、文件附件
- Base64 内嵌传输
- 新建上传 API(复用已有)
- 重连/刷新恢复机制改造(现有
stream.get.ts+ stream buffer 已够用) - 数据库 schema 迁移(
agentMessages.content+agentMessages.partsJSON 字段已足够)
2. 核心数据结构
2.1 ContentPart — 统一多模态消息内容
// app/types/chat.ts (新增)
export interface ContentTextPart {
type: "text";
text: string;
}
export interface ContentImagePart {
type: "image";
image: string; // 服务器返回的 URL,如 /static/upload/xxx.png
mimeType: string; // image/png | image/jpeg | image/webp
name?: string; // 原始文件名(用于编辑时展示)
}
export type ContentPart = ContentTextPart | ContentImagePart;
2.2 MessagePart 扩展
// app/types/chat.ts (修改)
export type MessagePartType =
| "text"
| "reasoning"
| "tool-call"
| "tool-result"
| "tool-approval"
| "image"; // ← 新增
export interface MessagePart {
id: string;
type: MessagePartType;
text?: string;
toolName?: string;
toolCallId?: string;
args?: unknown;
result?: unknown;
state?: string;
// ← 新增
image?: {
url: string;
mimeType: string;
name?: string;
};
}
2.3 ModelOption 统一扩展
在 AgentChatArea.vue、AgentModelSelector.vue、AgentToolbar.vue、app/pages/chat/[agentSlug]/index.vue 中各自定义的 ModelOption 接口统一新增 type 字段:
export interface ModelOption {
id: number;
name: string;
modelId: string;
providerName: string;
supportsTools: number;
type?: "text" | "vision" | "multimodal"; // ← 新增
}
设计决策:
type为可选字段,因为 guest models API 当前未返回该字段(需同步修复)。前端用type === 'vision' || type === 'multimodal'判断是否支持图片。
3. 前端改造
3.1 AgentInput.vue — 输入框组件
当前状态:仅 inputText: ref<string> + textarea,emit send: [content: string]。
改造点:
-
新增图片状态:
const images = ref<{ url: string; mimeType: string; name: string }[]>([]); const isUploading = ref(false); -
模型能力检测:接收
supportsVision: booleanprop,为 false 时隐藏上传按钮、禁用粘贴/拖拽图片。 -
上传交互:
- 工具栏新增图片上传按钮(
<input type="file" accept="image/*" multiple>) - 支持粘贴图片(
paste事件检测clipboardData.items中的 image) - 支持拖拽图片(
dragover+drop事件) - 上传时调用
POST /api/file/upload(FormData),成功后 push 到images images.length >= 4时禁用上传入口
- 工具栏新增图片上传按钮(
-
图片预览区:textarea 上方显示已添加图片的缩略图列表,每张带删除按钮。
-
emit 签名变更:
// 从 emit: { send: [content: string] } // 改为 emit: { send: [parts: ContentPart[]] }发送时构建
ContentPart[]:const parts: ContentPart[] = []; if (inputText.value.trim()) { parts.push({ type: "text", text: inputText.value.trim() }); } for (const img of images.value) { parts.push({ type: "image", image: img.url, mimeType: img.mimeType, name: img.name }); } emit("send", parts); // 清空 inputText.value = ""; images.value = []; -
编辑模式:接收
editParts?: ContentPart[]prop,填充 textarea 文本 + 图片列表。
3.2 AgentChatArea.vue — 聊天区域父组件
改造点:
ModelOption接口新增type字段。sendemit 签名从[content: string]改为[parts: ContentPart[]]。editemit 签名从[messageId, content: string]改为[messageId, parts: ContentPart[]]。- 新增
supportsVisioncomputed,传给AgentInput:const supportsVision = computed(() => { const mid = props.modelId; if (mid === null) return false; const model = props.models.find((m) => m.id === mid); return model?.type === "vision" || model?.type === "multimodal"; }); - 将
supportsVision传给AgentInput。
3.3 AgentMessageItem.vue — 消息项组件
当前状态:用户消息渲染 <div class="user-bubble">{{ message.content }}</div>。
改造点:
-
用户消息渲染改为遍历
message.parts(或message.contentParts):<div class="user-bubble"> <template v-for="part in userContentParts" :key="part.id ?? part.text"> <span v-if="part.type === 'text'">{{ part.text }}</span> <img v-else-if="part.type === 'image'" :src="part.image.url" :alt="part.image.name ?? ''" class="user-image" @click="previewImage(part.image.url)" /> </template> </div> -
编辑按钮 emit 改为传
ContentPart[]:// 从 emit("edit", messageId, message.content) // 改为 emit("edit", messageId, userContentParts) -
新增图片预览(点击放大,可用简单的 modal/overlay)。
3.4 AgentMessageParts.vue — assistant 消息 parts 渲染
改造点:新增 image 类型处理(虽然 assistant 消息一般不含图片,但保持一致性):
<template v-if="part.type === 'image'">
<img :src="part.image?.url" class="assistant-image" />
</template>
3.5 useAgentChat.ts — 前端 composable
当前状态:send(content: string, opts?) → fetch POST { content }。
改造点:
-
send方法签名改为send(parts: ContentPart[], opts?)。 -
buildRequestBody改为传parts:body: { parts, // ← 替代 content modelId, enableThinking, enableTools, ...opts } -
兼容映射 —
loadMessages时旧消息content: string自动映射:// 加载历史消息时 function normalizeMessage(m: RawMessage): ChatMessage { const contentParts: ContentPart[] = m.parts ? (JSON.parse(m.parts).filter((p: any) => p.type === "text" || p.type === "image")) : [{ type: "text", text: m.content }]; // ... } -
编辑消息:
send(parts, { editMessageId })时,将parts发送给后端,后端更新消息内容。 -
重生成:
send(parts, { regenerate: true })时,parts从最后一条用户消息的contentParts提取,沿用原图片。
3.6 app/pages/chat/[agentSlug]/index.vue — 页面入口
改造点:
ModelOption接口新增type字段。loadModels()中 map 时保留type:models.value = list.map((m) => ({ id: m.id, name: m.name, modelId: m.modelId, providerName: m.providerName ?? "—", supportsTools: m.supportsTools ?? 1, type: m.type ?? "text", // ← 新增 }));handleSend签名从(content: string)改为(parts: ContentPart[])。handleEdit签名从(messageId, content: string)改为(messageId, parts: ContentPart[])。handleRegenerate改为从最后一条用户消息提取contentParts:function handleRegenerate() { const lastUserMsg = [...chat.messages.value].reverse().find((m) => m.role === "user"); if (lastUserMsg && lastUserMsg.contentParts) { chat.send(lastUserMsg.contentParts, { regenerate: true }); } }
3.7 ChatMessage 类型扩展
在 useAgentChat.ts 或 app/types/chat.ts 中的 ChatMessage 接口新增:
export interface ChatMessage {
// ... 现有字段
content: string; // 保留,纯文本提取(用于标题生成等)
contentParts?: ContentPart[]; // ← 新增,多模态内容
parts?: MessagePart[]; // 现有,assistant 消息的 reasoning/tool 等
}
4. 后端改造
4.1 server/api/agents/[agentSlug]/chat/index.post.ts — Agent chat POST API
改造点:
- body 从
content: string改为parts: ContentPart[]。 - 兼容处理:若
body.content为 string(旧客户端),自动包装为[{ type: "text", text: content }]。const parts: ContentPart[] = body.parts ?? (body.content ? [{ type: "text", text: body.content }] : []); - 将
parts传给ChatEngine。
4.2 server/service/agent/chat-engine.ts — 核心聊天引擎
改造点:
-
ChatEngineParams.body从content: string改为parts: ContentPart[]。 -
executeChat中saveMessage调用改为:// 从 parts 提取纯文本 content const textContent = parts .filter((p) => p.type === "text") .map((p) => p.text) .join("\n"); // parts JSON 包含 image part const partsJson = JSON.stringify(parts); await saveMessage(sessionId, "user", textContent, partsJson); -
buildModelMessages中 user 消息处理改为:// 当前:{ type: "text", text: m.content } // 改为:解析 m.parts JSON,映射为 AI SDK UserContent const userParts = JSON.parse(m.parts ?? "[]") as ContentPart[]; if (userParts.length === 0) { // 兼容旧数据 return { type: "text", text: m.content }; } return userParts.map((p) => { if (p.type === "text") return { type: "text", text: p.text }; if (p.type === "image") return { type: "image", image: p.image }; // AI SDK 接受 URL });AI SDK 的
streamText的messages参数中,user 消息的content可以是UserContent数组,包含{ type: "image", image: url | URL }。
4.3 server/service/agent/session.ts — 消息持久化
当前状态:saveMessage(sessionId, role, content, parts?) — content: string, parts?: string | null。
改造点:无需修改签名。调用方(chat-engine.ts)负责从 ContentPart[] 提取纯文本 content 和序列化 parts JSON。
4.4 server/api/llm/chat/index.post.ts — 独立 chat API
改造点:
- body 新增
parts?: ContentPart[]字段(与content: string二选一)。 - 若
parts存在,使用parts构建消息;否则用content。 messages参数中 user 消息支持ContentPart[]格式。
4.5 server/api/agents/[agentSlug]/models/index.get.ts — Guest models API
改造点:map 时保留 type 字段:
const list = systemModels.map((m) => ({
id: m.id,
name: m.name,
modelId: m.modelId,
providerName: "—",
supportsTools: m.supportsTools,
type: m.type, // ← 新增
}));
同样在 defaultModelId 补充逻辑中也加上 type: row.model.type。
5. 数据流
5.1 发送消息(含图片)
用户输入文本 + 选择/粘贴/拖拽图片
→ AgentInput.vue: 上传图片到 /api/file/upload,获得 URL
→ 构建 ContentPart[] = [{type:"text",text}, {type:"image",image:url,mimeType}, ...]
→ emit("send", parts)
→ AgentChatArea → index.vue handleSend(parts)
→ useAgentChat.send(parts)
→ POST /api/agents/[slug]/chat { parts, modelId, ... }
→ chat-engine.executeChat:
- 提取纯文本 → saveMessage(sessionId, "user", textContent, partsJson)
- buildModelMessages: 解析 parts JSON → AI SDK UserContent[]
- streamText({ messages, model, ... })
→ SSE 流式返回
5.2 加载历史消息
loadMessages(sessionId)
→ GET /api/agents/[slug]/sessions/[id]/messages
→ 返回 { content: string, parts: string | null, ... }
→ normalizeMessage:
- 若 parts JSON 含 image part → contentParts = parsed parts
- 若 parts 为空 → contentParts = [{type:"text", text: content}]
→ ChatMessage { content, contentParts, parts (assistant reasoning/tools), ... }
→ 渲染: AgentMessageItem 遍历 contentParts 显示文本 + 图片
5.3 编辑消息
用户点击编辑 → AgentMessageItem emit("edit", messageId, contentParts)
→ AgentChatArea → index.vue handleEdit(messageId, parts)
→ useAgentChat.send(parts, { editMessageId: messageId })
→ POST /api/agents/[slug]/chat { parts, editMessageId, ... }
→ 后端更新消息: saveMessage(..., textContent, partsJson)
→ 重新生成 assistant 回复
5.4 重生成
用户点击重生成
→ index.vue handleRegenerate()
→ 找到最后一条 user 消息,提取 contentParts
→ useAgentChat.send(contentParts, { regenerate: true })
→ 后端: 删除最后一条 assistant 消息,用原 user parts 重新生成
6. 模型能力检测
前端拦截逻辑
const supportsVision = computed(() => {
const model = models.value.find((m) => m.id === currentModelId.value);
return model?.type === "vision" || model?.type === "multimodal";
});
supportsVision = false时:AgentInput隐藏图片上传按钮- 禁用粘贴图片(paste 事件中忽略 image items)
- 禁用拖拽图片(drop 事件中忽略 image files)
- 若用户切换到非 vision 模型且当前已有图片,toast 提示「当前模型不支持图片,请切换到支持视觉的模型」
后端无拦截
后端不拦截,直接将 parts 传给 AI SDK。若模型不支持图片,AI SDK / LLM provider 会返回错误,前端展示错误消息即可。
7. 兼容性处理
| 场景 | 旧格式 | 新格式 | 兼容策略 |
|---|---|---|---|
| 前端发送 | { content: "text" } |
{ parts: [{type:"text",text}] } |
后端:body.parts ?? (body.content ? [{type:"text",text:content}] : []) |
| 数据库存储 | content: "text", parts: null |
content: "text", parts: "[{type:\"text\",...},{type:\"image\",...}]" |
buildModelMessages 解析 parts,为空时 fallback 到 content |
| 前端加载 | content: "text" |
contentParts: ContentPart[] |
normalizeMessage:parts 为空时映射为 [{type:"text",text:content}] |
| 编辑消息 | emit("edit", id, "text") |
emit("edit", id, parts) |
handleEdit 签名变更,旧消息 contentParts 仅含 text |
| 重生成 | send(content, {regenerate}) |
send(contentParts, {regenerate}) |
从 ChatMessage.contentParts 提取 |
8. 文件变更清单
前端
| 文件 | 变更类型 | 说明 |
|---|---|---|
app/types/chat.ts |
修改 | 新增 ContentPart 类型,扩展 MessagePartType + MessagePart |
app/components/agent/AgentInput.vue |
修改 | 图片上传、预览、粘贴/拖拽,emit 改为 ContentPart[] |
app/components/agent/AgentChatArea.vue |
修改 | ModelOption 加 type,send/edit emit 签名变更,传 supportsVision |
app/components/agent/AgentModelSelector.vue |
修改 | ModelOption 加 type |
app/components/agent/AgentToolbar.vue |
修改 | ModelOption 加 type |
app/components/agent/AgentMessageItem.vue |
修改 | 用户消息渲染遍历 contentParts,编辑 emit 传 ContentPart[] |
app/components/agent/AgentMessageParts.vue |
修改 | 新增 image part 渲染 |
app/composables/useAgentChat.ts |
修改 | send 接收 ContentPart[],buildRequestBody 传 parts,normalizeMessage 兼容映射 |
app/pages/chat/[agentSlug]/index.vue |
修改 | ModelOption 加 type,loadModels 保留 type,handleSend/handleEdit/handleRegenerate 签名变更 |
后端
| 文件 | 变更类型 | 说明 |
|---|---|---|
server/api/agents/[agentSlug]/chat/index.post.ts |
修改 | body 从 content 改为 parts,兼容 string |
server/service/agent/chat-engine.ts |
修改 | ChatEngineParams.body 改为 parts,executeChat 提取文本 + 存 parts JSON,buildModelMessages 解析 parts 映射为 AI SDK UserContent |
server/api/llm/chat/index.post.ts |
修改 | 新增 parts 字段支持 |
server/api/agents/[agentSlug]/models/index.get.ts |
修改 | map 时保留 type 字段 |
无需变更
| 文件 | 原因 |
|---|---|
server/api/file/upload.post.ts |
已有上传 API,直接复用 |
server/constants/upload.ts |
常量已定义 |
packages/drizzle-pkg/lib/schema/agent.ts |
content + parts 字段已足够,无需迁移 |
packages/drizzle-pkg/lib/schema/llm.ts |
type 字段已存在 |
server/service/agent/session.ts |
saveMessage 签名不变,调用方负责提取 |
server/api/agents/[agentSlug]/chat/stream.get.ts |
SSE 恢复机制不变 |
server/service/llm/model-resolver.ts |
模型解析逻辑不变 |
9. 边界情况
- 空消息:
parts仅含图片无文本 → 允许发送(LLM 可纯图片理解) - 仅文本:
parts仅含一个 text part → 正常发送 - 图片上传失败:toast 提示,不阻塞文本发送
- 切换模型:从 vision 模型切到 text 模型,已有图片时 toast 提示,不自动清除图片(用户可选择切换回去或手动删除图片)
- 编辑旧消息:旧消息
contentParts仅含 text,编辑时可添加图片 - 重生成:沿用原 user 消息的
contentParts(含图片) - 超过 4 张图片:禁用上传入口,toast 提示「最多 4 张图片」
- 非图片文件:粘贴/拖拽时过滤非 image/* MIME 类型
10. 测试要点
- 文本 + 图片混合发送,LLM 正确接收并回复
- 纯文本发送(向后兼容)
- 纯图片发送(无文本)
- 粘贴图片(clipboard)
- 拖拽图片
- 文件选择器上传
- 图片预览 + 删除
- 最多 4 张限制
- 非 vision 模型时上传入口禁用
- 切换模型时已有图片的提示
- 编辑消息:增删图片后重新发送
- 重生成:沿用原图片
- 加载历史消息:旧消息(无 parts)正确映射
- 加载历史消息:新消息(含 image parts)正确渲染图片
- 独立 chat API(
/api/llm/chat)支持 parts - Guest 用户 models API 返回 type 字段