docs/plan/2026-06-01-image-upload-tech-design.md
Rama main · réplica de lectura del repositorio original.
# Agent 任务图片上传技术方案
> 状态:方案设计阶段,暂不进入开发
## 1. 背景与目标
当前 Agent 任务的用户消息仅支持纯文本,前端虽然有文件选择入口(ChatInput 的 "+" 按钮),但上传的文件仅作为附件元数据展示,不会传递给 LLM。本次需求:**支持用户在发送任务时附带图片,图片通过 LLM 视觉能力被 Agent 理解和处理**。
图片存储采用阿里云 OSS,前端通过 STS 临时凭证直传 OSS(不经过后端代理,节省服务器带宽)。
## 2. 现状分析
### 2.1 消息链路(当前)
```
用户输入 → ChatInput.emit('send', text, files[])
→ useChat.sendMessage(text, files)
→ uploadFiles(files) // POST /v1/files/upload 存到本地磁盘
→ wsSendMessage(sid, text) // WS 只发纯文本,图片不参与
→ StreamingWsHandler.handleSendMessage()
→ sessionService.saveMessage() // content = 纯文本
→ HarnessService.execute()
→ buildContext() 加载历史消息
→ PromptEngine.buildRequest() // ChatRequest.Message.content = String
→ OpenAiLlmAdapter.stream() // 序列化为 {"role":"user","content":"纯文本"}
```
### 2.2 关键断点
| 层级 | 当前状态 | 需要改动 |
|------|---------|---------|
| DB `message.content` | MEDIUMTEXT 纯文本 | 支持 JSON content parts 或新增列 |
| `ChatRequest.Message.content` | `String` | 改为 `Object`(兼容 String 和 List) |
| `PromptEngine` | 透传纯文本 content | 透传多模态 content |
| `OpenAiLlmAdapter` | 序列化 content 为 string | 序列化为 string 或 content_parts 数组 |
| `HarnessService.buildContext()` | 从 DB 读取纯文本 | 读取多模态结构并还原 |
| 前端 `useChat.uploadFiles()` | 上传到后端本地磁盘 | 改为 OSS 直传 |
| 前端 `MessageBubble` | 展示文件名 tag | 渲染图片预览 |
| 前端 WS `send_message` | 只发 text | 携带图片 OSS URL 列表 |
## 3. 整体方案设计
### 3.1 上传方案:STS 直传 OSS
```
┌──────────┐ ①请求STS凭证 ┌──────────┐
│ 前端 │ ──────────────→ │ 后端 │
│ Vue3 │ ←────────────── │ Spring │
│ │ ②返回临时凭证 │ Boot │
│ │ │ │
│ │ ③直传图片 ┌──────────┐
│ │ ──────────────→ │ 阿里云OSS │
│ │ ←────────────── │ │
│ │ ④返回OSS URL │ │
└──────────┘ └──────────┘
```
- **步骤 ①②**:前端调用后端 `POST /api/v1/oss/sts-token`,后端通过 RAM AssumeRole 生成 STS 临时凭证(AccessKeyId / AccessKeySecret / SecurityToken / Expiration)
- **步骤 ③④**:前端使用阿里云 OSS JS SDK(`ali-oss`)+ STS 凭证直传图片到 OSS Bucket
- **步骤 ⑤**:前端拿到 OSS URL 后,随 WS `send_message` 一起发送
### 3.2 消息内容格式:OpenAI 兼容的 Content Parts
采用 OpenAI Vision API 的 content parts 格式,与现有纯文本向后兼容:
```json
// 纯文本消息(现有)
{ "role": "user", "content": "请分析这张图片" }
// 带图片消息(新增)
{
"role": "user",
"content": [
{ "type": "text", "text": "请分析这张图片" },
{ "type": "image_url", "image_url": { "url": "https://etfs.oss-cn-hangzhou.aliyuncs.com/session/123/abc.jpg" } }
]
}
```
这种格式被 OpenAI / Claude / 通义千问 / DeepSeek 等主流模型兼容。
### 3.3 数据流全景
```
前端选图 → 获取STS凭证 → 直传OSS → 拿到URL
↓
WS send_message 携带:
{ content: "文本", images: ["oss_url_1", ...] }
↓
后端 StreamingWsHandler
↓
saveMessage() 存入DB:
content = JSON([{type:"text",...},{type:"image_url",...}])
↓
HarnessService.buildContext()
读取DB → 还原 ChatRequest.Message
↓
PromptEngine.buildRequest()
透传 content parts
↓
OpenAiLlmAdapter.stream()
序列化为 OpenAI 格式发给 LLM
```
## 4. 详细设计
### 4.1 后端:OSS STS 模块
#### 4.1.1 新增 `OssProperties` 配置类
```java
@Data
@Component
@ConfigurationProperties(prefix = "oss")
public class OssProperties {
private String region = "oss-cn-hangzhou";
private String accessKeyId = "xxxxxxx";
private String accessKeySecret = "xxxxxxx";
private String bucket = "etfs";
private int maxKeys = 100;
private Sts sts = new Sts();
private Cdn cdn = new Cdn();
@Data
public static class Sts {
private String regionId = "cn-hangzhou";
private String endpoint = "sts.cn-hangzhou.aliyuncs.com";
private String accessKeyId = "xxxxxxx";
private String accessKeySecret = "xxxxxxx";
private String roleArn = "acs:ram::xxxxxxx:role/ramosstest";
private String roleSessionName = "OssSession"; // 运行时覆盖为 "User_{userId}"
private long expire = 3600;
private DataSize maxSize = DataSize.ofMegabytes(20); // STS policy 单文件上限 20MB
}
@Data
public static class Cdn {
private String domain; // CDN 域名,为空时使用 OSS 默认域名
}
}
```
#### 4.1.2 新增 STS 服务
```java
@Service
public class OssStsService {
// AssumeRole 生成临时凭证
// roleSessionName = "User_{userId}"(便于审计追踪)
// 限制 policy: 只允许 putObject,限定 bucket 和路径前缀
// 路径前缀: /session/{sessionId}/
// 单文件大小限制: 20MB
public StsTokenVO generateStsToken(Long userId, Long sessionId);
}
```
#### 4.1.3 新增 STS 接口
```
POST /api/v1/oss/sts-token
Body: { "sessionId": 123 }
Response: {
"accessKeyId": "...",
"accessKeySecret": "...",
"securityToken": "...",
"expiration": "2026-06-01T12:00:00Z",
"bucket": "etfs",
"region": "oss-cn-hangzhou",
"uploadDir": "session/123/"
}
```
#### 4.1.4 Maven 依赖
```xml
<!-- 阿里云 STS SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-sts</artifactId>
<version>3.1.1</version>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.4</version>
</dependency>
```
### 4.2 后端:消息多模态改造
#### 4.2.1 `ChatRequest.Message.content` 类型改造
```java
@Data
@Builder
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Message {
private String role;
private Object content; // String 或 List<ContentPart>
private String name;
@JsonProperty("tool_call_id")
private String toolCallId;
@JsonProperty("tool_calls")
private List<ToolCall> toolCalls;
}
// 新增 ContentPart 抽象
@Data
@Builder
public static class ContentPart {
private String type; // "text" 或 "image_url"
private String text; // type=text 时
@JsonProperty("image_url")
private ImageUrl imageUrl; // type=image_url 时
}
@Data
@Builder
public static class ImageUrl {
private String url;
}
```
**序列化兼容性**:当 `content` 是 `String` 时 Jackson 序列化为 JSON string,当是 `List` 时序列化为 JSON array。无需额外适配。
#### 4.2.2 DB 存储方案:直接存 JSON 到 `message.content`
```sql
-- 现有纯文本
content = "请分析这张图片"
-- 多模态
content = '[{"type":"text","text":"请分析这张图片"},{"type":"image_url","image_url":{"url":"https://..."}}]'
```
无需改表结构,MEDIUMTEXT 天然支持 JSON。`HarnessService.buildContext()` 读取时,先尝试 JSON 解析为 `List<ContentPart>`,失败则视为纯文本字符串。
不采用新增列方案,避免不必要的表结构变更。
#### 4.2.3 `HarnessService.buildContext()` 改造
```java
// 原来
var msgBuilder = ChatRequest.Message.builder()
.role(msg.getRole().toLowerCase())
.content(msg.getContent());
// 改造后
Object parsedContent = parseContent(msg.getContent());
var msgBuilder = ChatRequest.Message.builder()
.role(msg.getRole().toLowerCase())
.content(parsedContent);
// 解析逻辑
private Object parseContent(String raw) {
if (raw == null) return null;
raw = raw.trim();
if (raw.startsWith("[")) {
try {
return objectMapper.readValue(raw, new TypeReference<List<ChatRequest.ContentPart>>() {});
} catch (Exception e) {
return raw; // fallback to plain text
}
}
return raw;
}
```
#### 4.2.4 `SessionService.saveMessage()` 改造
新增重载方法,支持保存多模态 content:
```java
public Message saveMessage(Long sessionId, String role, Object content,
String toolCallId, String toolCalls,
Integer tokenCount, Long modelId) {
Message message = new Message();
// ...
if (content instanceof String str) {
message.setContent(str);
} else {
message.setContent(objectMapper.writeValueAsString(content));
}
// ...
}
```
#### 4.2.5 `AgentExecutionContext` 改造
```java
// 新增多模态消息添加方法
public void addUserMessage(List<ChatRequest.ContentPart> parts) {
messages.add(ChatRequest.Message.builder()
.role("user")
.content(parts)
.build());
}
```
#### 4.2.6 `OpenAiLlmAdapter.buildRequest()` 改造
```java
// 原来
body.put("messages", request.getMessages());
// 不需要改动!Jackson 会自动将 Message.content (Object)
// 序列化为 string 或 array。
// 但需要确保 messages 的 content 字段在序列化时正确输出。
// 已有的 @JsonIgnoreProperties(ignoreUnknown = true) 足够。
```
实际上 `buildRequest()` 把 `request.getMessages()` 直接放入 HashMap 然后 Jackson 序列化,`content` 是 `Object` 类型,Jackson 会根据实际类型(String / List)输出正确的 JSON。**此层无需改动**。
### 4.3 后端:WebSocket 消息扩展
#### 4.3.1 `send_message` 协议扩展
```json
// 现有
{ "type": "send_message", "sessionId": 123,
"data": { "content": "请分析", "eventId": "uuid" } }
// 扩展
{ "type": "send_message", "sessionId": 123,
"data": { "content": "请分析", "eventId": "uuid",
"images": ["https://etfs.oss-cn-hangzhou.aliyuncs.com/session/123/abc.jpg"] } }
```
#### 4.3.2 `StreamingWsHandler.handleSendMessage()` 改造
```java
String content = data.get("content").asText();
List<String> images = new ArrayList<>();
if (data.has("images") && data.get("images").isArray()) {
for (JsonNode img : data.get("images")) {
images.add(img.asText());
}
}
// 非 vision 模型 + 有图片 → 报错中断
if (!images.isEmpty()) {
Agent agent = agentMapper.selectById(session.getAgentId());
LlmModel model = llmModelMapper.selectById(agent.getModelId());
if (!model.isSupportsVision()) { // 需在 LlmModel 实体新增 supportsVision 字段
registry.send(userId, WsEvent.of("error", sessionId,
Map.of("message", "当前模型不支持图片输入,请切换支持视觉的模型")));
return;
}
}
// 前端限制最多 10 张图片
if (images.size() > 10) {
registry.send(userId, WsEvent.of("error", sessionId,
Map.of("message", "单条消息最多支持 10 张图片")));
return;
}
// 构建多模态 content
Object messageContent;
if (images.isEmpty()) {
messageContent = content;
} else {
List<ChatRequest.ContentPart> parts = new ArrayList<>();
if (content != null && !content.isBlank()) {
parts.add(ChatRequest.ContentPart.builder().type("text").text(content).build());
}
for (String imageUrl : images) {
parts.add(ChatRequest.ContentPart.builder()
.type("image_url")
.imageUrl(ChatRequest.ImageUrl.builder().url(imageUrl).build())
.build());
}
messageContent = parts;
}
// 保存到 DB
sessionService.saveMessage(sessionId, "USER", messageContent, null, null, 0, null);
// 传递给 HarnessService(需要同步改造 prepareMessage)
harnessService.prepareMessage(sessionId, messageContent);
```
#### 4.3.3 `HarnessService.prepareMessage()` 改造
```java
// 原来
public String prepareMessage(Long sessionId, String userContent) {
String eventId = UUID.randomUUID().toString();
eventContentStore.put(eventId, userContent);
return eventId;
}
// 改造:cache value 改为 Object
private final Cache<String, Object> eventContentStore = ...;
public String prepareMessage(Long sessionId, Object userContent) {
String eventId = UUID.randomUUID().toString();
eventContentStore.put(eventId, userContent);
return eventId;
}
```
### 4.4 前端改造
#### 4.4.1 新增 OSS 上传工具
```typescript
// desktop/src/utils/ossUpload.ts
import OSS from 'ali-oss'
interface StsToken {
accessKeyId: string
accessKeySecret: string
securityToken: string
bucket: string
region: string
uploadDir: string
expiration: string
}
export async function uploadToOss(
file: File,
stsToken: StsToken
): Promise<string> {
const client = new OSS({
region: stsToken.region,
accessKeyId: stsToken.accessKeyId,
accessKeySecret: stsToken.accessKeySecret,
stsToken: stsToken.securityToken,
bucket: stsToken.bucket,
})
const key = `${stsToken.uploadDir}${Date.now()}_${file.name}`
const result = await client.put(key, file)
return result.url
}
```
新增 npm 依赖:`ali-oss`
#### 4.4.2 `useChat.ts` 改造
```typescript
// uploadFiles 改为 uploadImages,返回 OSS URL 列表
async function uploadImages(files: File[]): Promise<string[]> {
// 1. 获取 STS 凭证
const { data: stsToken } = await api.post('/oss/sts-token', {
sessionId: sessionId.value
})
// 2. 逐个上传到 OSS
const urls: string[] = []
for (const file of files) {
try {
const url = await uploadToOss(file, stsToken)
urls.push(url)
} catch {
ElMessage.error(`图片 ${file.name} 上传失败`)
}
}
return urls
}
// sendMessage 改造
async function sendMessage(text: string, files?: File[]) {
// ...
const imageUrls = await uploadImages(files || [])
// 构建 displayContent(用于本地展示)
let displayContent = text
if (imageUrls.length > 0) {
displayContent += imageUrls.map(url => `\n[图片: ${url}]`).join('')
}
// WS 发送时携带 images
wsSendMessage(sid, text || '', eventId, imageUrls)
// 本地消息存储携带 images
sessionStore.addUserMessage(sid, {
id: `msg_${Date.now()}_user`,
role: 'user',
content: text,
createdAt: new Date().toLocaleString(),
images: imageUrls // 新增
})
}
```
#### 4.4.3 `useStreamWS.ts` 改造
```typescript
// sendMessage 扩展 images 参数
function sendMessage(sessionId: string, content: string, eventId: string, images?: string[]) {
ws.value?.send(JSON.stringify({
type: 'send_message',
sessionId,
data: { content, eventId, images: images || [] }
}))
}
```
#### 4.4.4 `ChatInput.vue` 改造
```typescript
// 文件选择增加图片类型过滤 + 数量限制
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
for (const file of Array.from(input.files)) {
// 限制最多 10 张图片
if (pendingFiles.value.length >= 10) {
ElMessage.warning('最多上传 10 张图片')
break
}
// 限制单图 10MB
if (file.size > 10 * 1024 * 1024) {
ElMessage.warning(`图片 ${file.name} 超过 10MB 限制`)
continue
}
// 图片预览
if (file.type.startsWith('image/')) {
file.previewUrl = URL.createObjectURL(file)
}
pendingFiles.value.push(file)
}
}
input.value = ''
}
// 文件 chips 增加图片缩略图
// <img v-if="file.previewUrl" :src="file.previewUrl" class="file-preview" />
```
#### 4.4.5 `MessageBubble.vue` 改造
```typescript
// ChatMessage 类型扩展
interface ChatMessage {
// ...existing
images?: string[] // OSS 图片 URL 列表
}
// 用户消息渲染:图片以缩略图展示
// <div v-if="message.images?.length" class="message-images">
// <img v-for="url in message.images" :key="url" :src="url"
// class="message-image" @click="previewImage(url)" />
// </div>
```
#### 4.4.6 `chatMessage.ts` 改造
`mapApiMessagesToChat()` 需要从 API 返回的消息中解析多模态 content:
```typescript
// API 返回的 content 可能是 string 或 ContentPart[]
const rawContent = m.content
let textContent = ''
let imageUrls: string[] = []
if (typeof rawContent === 'string') {
textContent = rawContent
} else if (Array.isArray(rawContent)) {
for (const part of rawContent) {
if (part.type === 'text') textContent += part.text
if (part.type === 'image_url') imageUrls.push(part.image_url?.url)
}
}
```
### 4.5 后端:消息 API 返回改造
`GET /v1/sessions/{id}/messages` 返回的 `MessageVO` 需要携带图片信息:
```java
// SessionController 中的 toVO 方法改造
// 当 content 是 JSON array 时,解析出 text 和 images
@Data
public static class MessageVO {
private Long id;
private String role;
private String content; // 纯文本部分
private List<String> images; // 图片 URL 列表
private String toolCallId;
private String toolCalls;
private Integer tokenCount;
private Long modelId;
private String metadata;
private String createdAt;
}
```
## 5. OSS Bucket 配置要求
### 5.1 CORS 配置
前端直传 OSS 需要 Bucket 开启 CORS:
```xml
<CORSRule>
<AllowedOrigin>*</AllowedOrigin>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<MaxAgeSeconds>3600</MaxAgeSeconds>
</CORSRule>
```
### 5.2 Bucket 权限
- 图片上传后需要 LLM 能通过 URL 访问 → Bucket 需要**公共读**权限
- 或者使用 CDN 域名 + 防盗链(推荐生产方案)
### 5.3 路径规划
```
etfs/
└── session/
└── {sessionId}/
├── 1717200000_screenshot.png
└── 1717200001_photo.jpg
```
## 6. 已确认决策
| # | 问题 | 决策 |
|---|------|------|
| 6.1 | 图片传给 LLM 的方式 | **URL 方式**。模型支持 vision,URL 与 Base64 均可,优先用 URL(节省 token 和后端带宽)。需确保 OSS Bucket 公共读或 LLM 服务可访问。 |
| 6.2 | 图片大小与格式限制 | 前端限制单图 10MB,后端 STS policy 限制单文件 20MB。格式暂不限制,由 LLM 侧兜底。 |
| 6.3 | 图片 Token 消耗 | 初版不在前端做 token 提示,后续可加。 |
| 6.4 | 多图支持 | **单条消息最多 10 张图片**。前端硬限制,后端 STS policy 不限数量。 |
| 6.5 | 历史消息中的图片 | OSS 图片不过期,长期保留。后续如需清理,走离线脚本。 |
| 6.6 | 非 Vision 模型的处理 | **后端报错,任务中断**。在 `StreamingWsHandler.handleSendMessage()` 中检测 Agent 绑定的模型是否支持 vision,不支持时返回 error 事件,前端弹出提示。 |
| 6.7 | STS 凭证安全 | `roleSessionName` 改为 `"User_{userId}"`,STS policy 限制 putObject + 指定 bucket/路径前缀。 |
| 6.8 | 图片压缩与处理 | 初版不做压缩。后续可利用 OSS 图片处理服务(`?x-oss-process=image/resize,w_1024`)在 LLM 请求时动态缩放。 |
| 6.9 | 现有文件上传系统 | **仅任务图片走 OSS,现有本地文件上传系统不动**。不做存量数据迁移。 |
## 7. 改动范围总结
| 文件/模块 | 改动类型 | 说明 |
|-----------|---------|------|
| `pom.xml` | 修改 | 新增阿里云 STS SDK 依赖 |
| `application.yml` | 修改 | 新增 `oss.*` 配置 |
| **新增** `OssProperties.java` | 新增 | OSS 配置属性类 |
| **新增** `OssStsService.java` | 新增 | STS 临时凭证生成 |
| **新增** `OssController.java` | 新增 | `POST /v1/oss/sts-token` 接口 |
| `ChatRequest.java` | 修改 | `Message.content` 改为 `Object`,新增 `ContentPart` / `ImageUrl` |
| `AgentExecutionContext.java` | 修改 | 新增 `addUserMessage(List<ContentPart>)` |
| `HarnessService.java` | 修改 | `prepareMessage()` 参数改为 `Object`,`buildContext()` 解析多模态 content |
| `SessionService.java` | 修改 | `saveMessage()` 支持 `Object content` |
| `StreamingWsHandler.java` | 修改 | `handleSendMessage()` 解析 images 参数、校验 vision 模型、构建多模态 content |
| `LlmModel.java` | 修改 | 新增 `supportsVision` 字段 |
| `SessionController.java` | 修改 | `MessageVO` 增加 `images` 字段 |
| **新增** `ossUpload.ts` | 新增 | OSS 直传工具函数 |
| `useChat.ts` | 修改 | `uploadFiles` → `uploadImages`,WS 消息携带 images |
| `useStreamWS.ts` | 修改 | `sendMessage()` 增加 images 参数 |
| `ChatInput.vue` | 修改 | 图片预览缩略图、10 张/10MB 限制 |
| `MessageBubble.vue` | 修改 | 消息中渲染图片缩略图 |
| `chat.ts` | 修改 | `ChatMessage` 增加 `images` 字段 |
| `chatMessage.ts` | 修改 | `mapApiMessagesToChat()` 解析多模态 content |
| **新增** `V021__message_image_support.sql` | 新增 | `llm_model` 表新增 `supports_vision` 列 |
| `package.json` | 修改 | 新增 `ali-oss` npm 依赖 |
## 8. 开发阶段建议
| 阶段 | 内容 | 预估工时 |
|------|------|---------|
| P1 | OSS STS 模块(后端配置 + STS 接口 + 前端直传) | 1d |
| P2 | 消息多模态改造(ChatRequest + 存储 + 上下文构建 + LlmModel.supportsVision) | 1.5d |
| P3 | WebSocket 协议扩展 + vision 模型校验 + 消息发送链路打通 | 0.5d |
| P4 | 前端消息展示(图片缩略图 + 预览)+ 10 张/10MB 限制 | 0.5d |
| P5 | 联调测试 + 边界处理(非 vision 模型报错中断) | 1d |
| **合计** | | **~4.5d** |