You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

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.parts JSON 字段已足够)

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]。

改造点:

  1. 新增图片状态:

    const images = ref<{ url: string; mimeType: string; name: string }[]>([]);
    const isUploading = ref(false);
    
  2. 模型能力检测:接收 supportsVision: boolean prop,为 false 时隐藏上传按钮、禁用粘贴/拖拽图片。

  3. 上传交互:

    • 工具栏新增图片上传按钮(<input type="file" accept="image/*" multiple>)
    • 支持粘贴图片(paste 事件检测 clipboardData.items 中的 image)
    • 支持拖拽图片(dragover + drop 事件)
    • 上传时调用 POST /api/file/upload(FormData),成功后 push 到 images
    • images.length >= 4 时禁用上传入口
  4. 图片预览区:textarea 上方显示已添加图片的缩略图列表,每张带删除按钮。

  5. 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 = [];
    
  6. 编辑模式:接收 editParts?: ContentPart[] prop,填充 textarea 文本 + 图片列表。

3.2 AgentChatArea.vue — 聊天区域父组件

改造点:

  1. ModelOption 接口新增 type 字段。
  2. send emit 签名从 [content: string] 改为 [parts: ContentPart[]]。
  3. edit emit 签名从 [messageId, content: string] 改为 [messageId, parts: ContentPart[]]。
  4. 新增 supportsVision computed,传给 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";
    });
    
  5. 将 supportsVision 传给 AgentInput。

3.3 AgentMessageItem.vue — 消息项组件

当前状态:用户消息渲染 <div class="user-bubble">{{ message.content }}</div>。

改造点:

  1. 用户消息渲染改为遍历 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>
    
  2. 编辑按钮 emit 改为传 ContentPart[]:

    // 从
    emit("edit", messageId, message.content)
    // 改为
    emit("edit", messageId, userContentParts)
    
  3. 新增图片预览(点击放大,可用简单的 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 }。

改造点:

  1. send 方法签名改为 send(parts: ContentPart[], opts?)。

  2. buildRequestBody 改为传 parts:

    body: {
      parts,           // ← 替代 content
      modelId,
      enableThinking,
      enableTools,
      ...opts
    }
    
  3. 兼容映射 — 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 }];
      // ...
    }
    
  4. 编辑消息:send(parts, { editMessageId }) 时,将 parts 发送给后端,后端更新消息内容。

  5. 重生成:send(parts, { regenerate: true }) 时,parts 从最后一条用户消息的 contentParts 提取,沿用原图片。

3.6 app/pages/chat/[agentSlug]/index.vue — 页面入口

改造点:

  1. ModelOption 接口新增 type 字段。
  2. 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",           // ← 新增
    }));
    
  3. handleSend 签名从 (content: string) 改为 (parts: ContentPart[])。
  4. handleEdit 签名从 (messageId, content: string) 改为 (messageId, parts: ContentPart[])。
  5. 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

改造点:

  1. body 从 content: string 改为 parts: ContentPart[]。
  2. 兼容处理:若 body.content 为 string(旧客户端),自动包装为 [{ type: "text", text: content }]。
    const parts: ContentPart[] = body.parts
      ?? (body.content ? [{ type: "text", text: body.content }] : []);
    
  3. 将 parts 传给 ChatEngine。

4.2 server/service/agent/chat-engine.ts — 核心聊天引擎

改造点:

  1. ChatEngineParams.body 从 content: string 改为 parts: ContentPart[]。

  2. 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);
    
  3. 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

改造点:

  1. body 新增 parts?: ContentPart[] 字段(与 content: string 二选一)。
  2. 若 parts 存在,使用 parts 构建消息;否则用 content。
  3. 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. 边界情况

  1. 空消息:parts 仅含图片无文本 → 允许发送(LLM 可纯图片理解)
  2. 仅文本:parts 仅含一个 text part → 正常发送
  3. 图片上传失败:toast 提示,不阻塞文本发送
  4. 切换模型:从 vision 模型切到 text 模型,已有图片时 toast 提示,不自动清除图片(用户可选择切换回去或手动删除图片)
  5. 编辑旧消息:旧消息 contentParts 仅含 text,编辑时可添加图片
  6. 重生成:沿用原 user 消息的 contentParts(含图片)
  7. 超过 4 张图片:禁用上传入口,toast 提示「最多 4 张图片」
  8. 非图片文件:粘贴/拖拽时过滤非 image/* MIME 类型

10. 测试要点

  • 文本 + 图片混合发送,LLM 正确接收并回复
  • 纯文本发送(向后兼容)
  • 纯图片发送(无文本)
  • 粘贴图片(clipboard)
  • 拖拽图片
  • 文件选择器上传
  • 图片预览 + 删除
  • 最多 4 张限制
  • 非 vision 模型时上传入口禁用
  • 切换模型时已有图片的提示
  • 编辑消息:增删图片后重新发送
  • 重生成:沿用原图片
  • 加载历史消息:旧消息(无 parts)正确映射
  • 加载历史消息:新消息(含 image parts)正确渲染图片
  • 独立 chat API(/api/llm/chat)支持 parts
  • Guest 用户 models API 返回 type 字段