feat: 完善智能体图片聊天与会话恢复

- 增加私有图片上传、绑定、历史回显与生命周期清理

- 支持输入草稿恢复、图片交互和模型图片能力约束

- 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
2026-07-17 19:54:26 +08:00
parent 62d763199f
commit 1e6158be77
62 changed files with 5333 additions and 189 deletions

View File

@@ -49,6 +49,14 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-satoken</artifactId>
</dependency>
<dependency>
<groupId>org.dromara.x-file-storage</groupId>
<artifactId>x-file-storage-spring</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
</dependency>
<dependency>
<groupId>com.mybatis-flex</groupId>
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>

View File

@@ -0,0 +1,56 @@
package tech.easyflow.agent.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Agent 图片上传、草稿和对象存储配置。
*/
@ConfigurationProperties(prefix = "easyflow.agent.media")
public class AgentMediaProperties {
/** x-file-storage 中的私有 Agent 媒体平台。 */
private String platform = "minio-agent-media";
/** 单次对话最多图片数。 */
private int maxImageCount = 5;
/** 单张图片最大字节数。 */
private long maxImageBytes = 10L * 1024L * 1024L;
/** 单张图片最大像素数。 */
private long maxImagePixels = 40_000_000L;
/** 临时图片有效期。 */
private Duration uploadTtl = Duration.ofHours(24);
/** 输入草稿有效期。 */
private Duration composerDraftTtl = Duration.ofHours(24);
/** 过期对象清理周期。 */
private Duration cleanupInterval = Duration.ofMinutes(10);
/** @return 私有媒体平台名称 */
public String getPlatform() { return platform; }
/** @param platform 私有媒体平台名称 */
public void setPlatform(String platform) { this.platform = platform; }
/** @return 单次图片上限 */
public int getMaxImageCount() { return maxImageCount; }
/** @param maxImageCount 单次图片上限 */
public void setMaxImageCount(int maxImageCount) { this.maxImageCount = maxImageCount; }
/** @return 单图字节上限 */
public long getMaxImageBytes() { return maxImageBytes; }
/** @param maxImageBytes 单图字节上限 */
public void setMaxImageBytes(long maxImageBytes) { this.maxImageBytes = maxImageBytes; }
/** @return 单图像素上限 */
public long getMaxImagePixels() { return maxImagePixels; }
/** @param maxImagePixels 单图像素上限 */
public void setMaxImagePixels(long maxImagePixels) { this.maxImagePixels = maxImagePixels; }
/** @return 临时图片 TTL */
public Duration getUploadTtl() { return uploadTtl; }
/** @param uploadTtl 临时图片 TTL */
public void setUploadTtl(Duration uploadTtl) { this.uploadTtl = uploadTtl; }
/** @return 草稿 TTL */
public Duration getComposerDraftTtl() { return composerDraftTtl; }
/** @param composerDraftTtl 草稿 TTL */
public void setComposerDraftTtl(Duration composerDraftTtl) { this.composerDraftTtl = composerDraftTtl; }
/** @return 清理周期 */
public Duration getCleanupInterval() { return cleanupInterval; }
/** @param cleanupInterval 清理周期 */
public void setCleanupInterval(Duration cleanupInterval) { this.cleanupInterval = cleanupInterval; }
}

View File

@@ -4,6 +4,7 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Agent 模块自动配置。
@@ -11,6 +12,7 @@ import org.springframework.context.annotation.ComponentScan;
@AutoConfiguration
@MapperScan("tech.easyflow.agent.mapper")
@ComponentScan("tech.easyflow.agent")
@EnableConfigurationProperties(AgentRuntimeProperties.class)
@EnableScheduling
@EnableConfigurationProperties({AgentRuntimeProperties.class, AgentMediaProperties.class})
public class AgentModuleConfig {
}

View File

@@ -12,6 +12,7 @@ public class AgentChatRequest {
private BigInteger agentId;
private BigInteger sessionId;
private String prompt;
private List<String> imageUploadIds = new ArrayList<>();
private List<AgentChatCapability> capabilities = new ArrayList<>();
/**
@@ -56,6 +57,22 @@ public class AgentChatRequest {
*/
public void setPrompt(String prompt) { this.prompt = prompt; }
/**
* 获取本轮临时图片上传 ID。
*
* @return 图片上传 ID
*/
public List<String> getImageUploadIds() { return imageUploadIds; }
/**
* 设置本轮临时图片上传 ID。
*
* @param imageUploadIds 图片上传 ID
*/
public void setImageUploadIds(List<String> imageUploadIds) {
this.imageUploadIds = imageUploadIds == null ? new ArrayList<>() : new ArrayList<>(imageUploadIds);
}
/**
* 获取本次聊天启用的临时能力。
*

View File

@@ -5,6 +5,7 @@ import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import java.util.List;
import java.util.ArrayList;
/**
* Agent 草稿态纯文本试用请求。
@@ -16,6 +17,7 @@ public class AgentDraftChatRequest {
private List<AgentKnowledgeBinding> knowledgeBindings;
private String sessionId;
private String prompt;
private List<String> imageUploadIds = new ArrayList<>();
/**
* 获取 Agent 草稿快照。
@@ -106,4 +108,22 @@ public class AgentDraftChatRequest {
public void setPrompt(String prompt) {
this.prompt = prompt;
}
/**
* 获取本轮临时图片上传 ID。
*
* @return 图片上传 ID
*/
public List<String> getImageUploadIds() {
return imageUploadIds;
}
/**
* 设置本轮临时图片上传 ID。
*
* @param imageUploadIds 图片上传 ID
*/
public void setImageUploadIds(List<String> imageUploadIds) {
this.imageUploadIds = imageUploadIds == null ? new ArrayList<>() : new ArrayList<>(imageUploadIds);
}
}

View File

@@ -8,6 +8,8 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.message.AgentMediaBlock;
import com.easyagents.agent.runtime.message.AgentTextBlock;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
import org.slf4j.Logger;
@@ -27,6 +29,9 @@ import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.agent.runtime.session.EasyFlowAgentSessionStore;
import tech.easyflow.agent.runtime.media.AgentBoundMedia;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.agent.runtime.media.AgentMediaUploadRecord;
import tech.easyflow.agent.service.AgentService;
import tech.easyflow.ai.entity.DocumentCollection;
import tech.easyflow.ai.entity.Mcp;
@@ -36,6 +41,7 @@ import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
import tech.easyflow.ai.service.DocumentCollectionService;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.ModelService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
@@ -56,6 +62,8 @@ import javax.annotation.Resource;
import java.math.BigInteger;
import java.util.*;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
/**
* Agent 管理端运行服务。
@@ -107,6 +115,10 @@ public class AgentRunService {
private McpService mcpService;
@Resource
private DocumentCollectionService documentCollectionService;
@Resource
private ModelService modelService;
@Resource
private AgentMediaService agentMediaService;
/**
* 启动 Agent 聊天。
@@ -134,6 +146,10 @@ public class AgentRunService {
AgentChatCapabilityService.AgentChatCapabilityResolution capabilityResolution =
agentChatCapabilityService.apply(agent, chatRequest.getCapabilities(), account);
agent = capabilityResolution.agent();
assertImageCapability(agent, chatRequest.getImageUploadIds());
List<AgentMediaUploadRecord> mediaUploads = agentMediaService.requireUploads(
chatRequest.getImageUploadIds(), AgentMediaService.MODE_FORMAL,
chatRequest.getAgentId().toString(), sessionId.toString(), account);
String requestId = UUID.randomUUID().toString();
String traceId = UUID.randomUUID().toString();
// 组建会话上下文必要信息
@@ -143,7 +159,7 @@ public class AgentRunService {
}
applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession);
// 执行对话
return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(),
return run(agent, chatRequest.getPrompt(), mediaUploads, account, requestId, traceId, sessionId.toString(),
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore);
}
@@ -163,16 +179,22 @@ public class AgentRunService {
if (runtimeSessionId == null || runtimeSessionId.isBlank()) {
runtimeSessionId = "agent-draft-" + new SnowFlakeIDKeyGenerator().nextId();
}
assertImageCapability(agent, draftRequest.getImageUploadIds());
List<AgentMediaUploadRecord> mediaUploads = agentMediaService.requireUploads(
draftRequest.getImageUploadIds(), AgentMediaService.MODE_DRAFT,
agent.getId().toString(), runtimeSessionId, account);
BigInteger chatSessionId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId());
String requestId = UUID.randomUUID().toString();
String traceId = UUID.randomUUID().toString();
ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, chatSessionId, draftRequest.getPrompt(), account, DRAFT_ASSISTANT_CODE);
return run(agent, draftRequest.getPrompt(), requestId, traceId, runtimeSessionId,
return run(agent, draftRequest.getPrompt(), mediaUploads, account, requestId, traceId, runtimeSessionId,
DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore);
}
private SseEmitter run(Agent agent,
String prompt,
List<AgentMediaUploadRecord> mediaUploads,
LoginAccount account,
String requestId,
String traceId,
String runtimeSessionId,
@@ -185,6 +207,7 @@ public class AgentRunService {
AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId);
boolean submitted = false;
try {
List<AgentBoundMedia> boundMedia;
if (persistChatlog) {
// 持久化会话初始信息
chatRuntimeManager.prepareSession(chatContext);
@@ -192,9 +215,23 @@ public class AgentRunService {
chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开Agent 运行已取消"));
return chatSseEmitter.getEmitter();
}
chatRuntimeManager.recordUserMessage(chatContext, buildUserRuntimeMessage(chatContext, prompt));
BigInteger messageId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId());
boundMedia = agentMediaService.bindFormal(mediaUploads, chatContext.getSessionId().toString(),
messageId.toString(), account);
chatRuntimeManager.recordUserMessage(chatContext,
buildUserRuntimeMessage(chatContext, messageId, prompt, boundMedia));
if (!sendInputAccepted(chatSseEmitter, chatContext.getSessionId(), messageId, boundMedia)) {
chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开Agent 运行已取消"));
return chatSseEmitter.getEmitter();
}
} else {
boundMedia = agentMediaService.bindDraft(mediaUploads);
if (!sendInputAccepted(chatSseEmitter, null, null, boundMedia)) {
return chatSseEmitter.getEmitter();
}
}
threadPoolTaskExecutor.execute(() -> startRuntime(agent, prompt, requestId, traceId, runtimeSessionId,
AgentMessage userMessage = buildAgentMessage(prompt, boundMedia);
threadPoolTaskExecutor.execute(() -> startRuntime(agent, userMessage, account, requestId, traceId, runtimeSessionId,
assistantCode, chatContext, chatSseEmitter, persistChatlog, runtimeSessionStore, lockHandle));
submitted = true;
return chatSseEmitter.getEmitter();
@@ -342,7 +379,8 @@ public class AgentRunService {
}
private void startRuntime(Agent agent,
String prompt,
AgentMessage userMessage,
LoginAccount account,
String requestId,
String traceId,
String runtimeSessionId,
@@ -374,6 +412,7 @@ public class AgentRunService {
request.setToolInvokers(bundle.getToolInvokers());
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
request.setSessionStore(runtimeSessionStore);
request.setMediaResolver(agentMediaService.runtimeResolver(account));
request.getMetadata().put("assistantCode", assistantCode);
runtime.init(request);
// 注册会话运行时管理
@@ -409,7 +448,7 @@ public class AgentRunService {
return;
}
agentRunRegistry.bindSubscription(requestId,
runtime.stream(AgentMessage.text(AgentMessageRole.USER, prompt)).subscribe(
runtime.stream(userMessage).subscribe(
runContext.eventConsumer(),
runContext.errorConsumer(),
runContext.completionHandler()
@@ -1012,8 +1051,8 @@ public class AgentRunService {
* @return 最长 200 字符的会话标题
*/
private String toSessionTitle(String prompt) {
if (prompt == null) {
return null;
if (prompt == null || prompt.isBlank()) {
return "图片对话";
}
return prompt.length() > 200 ? prompt.substring(0, 200) : prompt;
}
@@ -1028,17 +1067,43 @@ public class AgentRunService {
return context;
}
private ChatRuntimeMessage buildUserRuntimeMessage(ChatRuntimeContext context, String prompt) {
private ChatRuntimeMessage buildUserRuntimeMessage(ChatRuntimeContext context,
BigInteger messageId,
String prompt,
List<AgentBoundMedia> media) {
ChatRuntimeMessage message = new ChatRuntimeMessage();
message.setMessageId(messageId);
message.setRole("user");
message.setContentType("TEXT");
message.setContentType(media == null || media.isEmpty() ? "TEXT" : "MULTIMODAL");
message.setContentText(prompt);
if (media != null && !media.isEmpty()) {
message.getContentPayload().put("images", media.stream().map(AgentBoundMedia::payload).toList());
}
message.setCreatedAt(new Date());
message.setSenderId(context.getUserId());
message.setSenderName(context.getUserName());
return message;
}
private AgentMessage buildAgentMessage(String prompt, List<AgentBoundMedia> media) {
AgentMessage message = new AgentMessage();
message.setRole(AgentMessageRole.USER);
List<com.easyagents.agent.runtime.message.AgentContentBlock> blocks = new ArrayList<>();
if (prompt != null && !prompt.isBlank()) {
blocks.add(new AgentTextBlock(prompt));
}
if (media != null) {
for (AgentBoundMedia item : media) {
AgentMediaBlock image = new AgentMediaBlock("image");
image.setReference(item.reference());
image.setMimeType(item.mimeType());
blocks.add(image);
}
}
message.setContentBlocks(blocks);
return message;
}
private ChatRuntimeMessage buildAssistantRuntimeMessage(ChatRuntimeContext context, String content) {
return buildAssistantRuntimeMessage(context, content, new ChatAssistantAccumulator(), List.of());
}
@@ -1092,11 +1157,29 @@ public class AgentRunService {
Map.of("sessionId", sessionId.toString()));
}
private boolean sendInputAccepted(ChatSseEmitter chatSseEmitter,
BigInteger sessionId,
BigInteger messageId,
List<AgentBoundMedia> boundMedia) {
Map<String, Object> payload = new LinkedHashMap<>();
if (sessionId != null) {
payload.put("sessionId", sessionId.toString());
}
if (messageId != null) {
payload.put("messageId", messageId.toString());
}
if (boundMedia != null && !boundMedia.isEmpty()) {
payload.put("images", boundMedia.stream().map(AgentBoundMedia::payload).toList());
}
return sendEnvelope(chatSseEmitter, ChatDomain.SYSTEM, ChatType.INPUT_ACCEPTED, payload);
}
private void validateChatRequest(AgentChatRequest request) {
if (request == null || request.getAgentId() == null) {
throw new BusinessException("Agent ID 不能为空");
}
if (request.getPrompt() == null || request.getPrompt().isBlank()) {
if ((request.getPrompt() == null || request.getPrompt().isBlank())
&& (request.getImageUploadIds() == null || request.getImageUploadIds().isEmpty())) {
throw new BusinessException("Agent 输入不能为空");
}
}
@@ -1108,11 +1191,24 @@ public class AgentRunService {
if (request.getAgent().getModelId() == null) {
throw new BusinessException("Agent 模型不能为空");
}
if (request.getPrompt() == null || request.getPrompt().isBlank()) {
if ((request.getPrompt() == null || request.getPrompt().isBlank())
&& (request.getImageUploadIds() == null || request.getImageUploadIds().isEmpty())) {
throw new BusinessException("Agent 输入不能为空");
}
}
private void assertImageCapability(Agent agent, List<String> imageUploadIds) {
if (imageUploadIds == null || imageUploadIds.isEmpty()) {
return;
}
tech.easyflow.ai.entity.Model model = agent == null || agent.getModelId() == null
? null
: modelService.getModelInstance(agent.getModelId());
if (model == null || !Boolean.TRUE.equals(model.getSupportImage())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "当前 Agent 模型未启用多模态图片能力");
}
}
private LoginAccount requireCurrentLoginAccount() {
try {
return SaTokenUtil.getLoginAccount();

View File

@@ -112,6 +112,8 @@ public class AgentRuntimeCompiler {
spec.setBaseUrl(stringValue(config, "baseUrl", model.getEndpoint()));
spec.setEndpointPath(stringValue(config, "endpointPath", model.getRequestPath()));
spec.setApiKey(stringValue(config, "apiKey", model.getApiKey()));
spec.setSupportImage(Boolean.TRUE.equals(model.getSupportImage()));
spec.setSupportImageBase64Only(Boolean.TRUE.equals(model.getSupportImageB64Only()));
spec.getMetadata().put("modelId", model.getId());
return spec;
}

View File

@@ -0,0 +1,59 @@
package tech.easyflow.agent.runtime.composer;
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* 可跨刷新恢复的 Agent 聊天输入草稿。
*/
public class AgentComposerDraft {
private String mode;
private String agentId;
private String sessionId;
private String text;
private List<String> imageUploadIds = new ArrayList<>();
private List<AgentMediaUploadView> images = new ArrayList<>();
private long revision;
private Instant expiresAt;
/** @return 聊天模式 */
public String getMode() { return mode; }
/** @param mode 聊天模式 */
public void setMode(String mode) { this.mode = mode; }
/** @return Agent ID */
public String getAgentId() { return agentId; }
/** @param agentId Agent ID */
public void setAgentId(String agentId) { this.agentId = agentId; }
/** @return 会话 ID */
public String getSessionId() { return sessionId; }
/** @param sessionId 会话 ID */
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
/** @return 输入文本 */
public String getText() { return text; }
/** @param text 输入文本 */
public void setText(String text) { this.text = text; }
/** @return 图片上传 ID */
public List<String> getImageUploadIds() { return imageUploadIds; }
/** @param imageUploadIds 图片上传 ID */
public void setImageUploadIds(List<String> imageUploadIds) {
this.imageUploadIds = imageUploadIds == null ? new ArrayList<>() : new ArrayList<>(imageUploadIds);
}
/** @return 图片展示信息 */
public List<AgentMediaUploadView> getImages() { return images; }
/** @param images 图片展示信息 */
public void setImages(List<AgentMediaUploadView> images) {
this.images = images == null ? new ArrayList<>() : new ArrayList<>(images);
}
/** @return 草稿修订号 */
public long getRevision() { return revision; }
/** @param revision 草稿修订号 */
public void setRevision(long revision) { this.revision = revision; }
/** @return 过期时间 */
public Instant getExpiresAt() { return expiresAt; }
/** @param expiresAt 过期时间 */
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
}

View File

@@ -0,0 +1,330 @@
package tech.easyflow.agent.runtime.composer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ResponseStatusException;
import tech.easyflow.agent.config.AgentMediaProperties;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.agent.runtime.media.AgentMediaUploadRecord;
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
import tech.easyflow.common.entity.LoginAccount;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* 基于 Redis 的 Agent 输入草稿与当前未发送会话管理服务。
*/
@Service
public class AgentComposerDraftService {
private static final String DRAFT_PREFIX = "easyflow:agent:composer:draft:";
private static final String REVISION_PREFIX = "easyflow:agent:composer:revision:";
private static final String ACTIVE_PREFIX = "easyflow:agent:composer:active:";
private static final DefaultRedisScript<Long> SAVE_SCRIPT = new DefaultRedisScript<>("""
local current = redis.call('GET', KEYS[2])
if current and tonumber(current) ~= tonumber(ARGV[1]) then
return -1
end
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[4])
redis.call('SET', KEYS[2], ARGV[3], 'EX', ARGV[4])
redis.call('SET', KEYS[3], ARGV[5], 'EX', ARGV[4])
return tonumber(ARGV[3])
""", Long.class);
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final AgentMediaProperties properties;
private final AgentMediaService mediaService;
/**
* 创建输入草稿服务。
*
* @param redisTemplate Redis 模板
* @param objectMapper JSON 映射器
* @param properties Agent 媒体配置
* @param mediaService Agent 媒体服务
*/
public AgentComposerDraftService(StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
AgentMediaProperties properties,
AgentMediaService mediaService) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.properties = properties;
this.mediaService = mediaService;
}
/**
* 为正式或草稿聊天预分配稳定会话 ID。
*
* @param mode 聊天模式
* @return 会话信息
*/
public AgentComposerSession allocateSession(String mode) {
String safeMode = mode(mode);
long id = new SnowFlakeIDKeyGenerator().nextId();
return new AgentComposerSession(safeMode,
AgentMediaService.MODE_DRAFT.equals(safeMode) ? "agent-draft-" + id : String.valueOf(id));
}
/**
* 保存输入草稿并对相关图片续期。
*
* @param draft 草稿内容
* @param account 当前账号
* @return 保存后的草稿
*/
public AgentComposerDraft save(AgentComposerDraft draft, LoginAccount account) {
if (draft == null) {
throw badRequest("聊天草稿不能为空");
}
Identity identity = identity(account);
String safeMode = mode(draft.getMode());
String agentId = text(draft.getAgentId(), "Agent ID 不能为空");
String sessionId = text(draft.getSessionId(), "会话 ID 不能为空");
validateSessionId(safeMode, sessionId);
List<AgentMediaUploadRecord> uploads = mediaService.requireUploads(draft.getImageUploadIds(), safeMode,
agentId, sessionId, account);
for (AgentMediaUploadRecord upload : uploads) {
mediaService.bindDraft(List.of(upload));
}
draft.setMode(safeMode);
draft.setAgentId(agentId);
draft.setSessionId(sessionId);
draft.setText(draft.getText() == null ? "" : draft.getText());
if (draft.getText().length() > 100_000) {
throw badRequest("输入内容过长");
}
draft.setImages(uploads.stream().map(this::toView).toList());
long expectedRevision = Math.max(0L, draft.getRevision());
long nextRevision = expectedRevision + 1L;
draft.setRevision(nextRevision);
Duration ttl = draftTtl();
draft.setExpiresAt(Instant.now().plus(ttl));
String scope = scope(identity, safeMode, agentId, sessionId);
try {
Long result = redisTemplate.execute(SAVE_SCRIPT,
List.of(DRAFT_PREFIX + scope, REVISION_PREFIX + scope, activeKey(identity, safeMode, agentId)),
String.valueOf(expectedRevision), objectMapper.writeValueAsString(draft),
String.valueOf(nextRevision), String.valueOf(Math.max(1L, ttl.toSeconds())), sessionId);
if (result == null || result < 0L) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "聊天草稿已在其他窗口更新,请刷新后重试");
}
return draft;
} catch (ResponseStatusException error) {
throw error;
} catch (Exception error) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "聊天草稿保存失败", error);
}
}
/**
* 获取指定会话或最近未发送会话的草稿,读取不延长 TTL。
*
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID可为空
* @param account 当前账号
* @return 草稿
*/
public Optional<AgentComposerDraft> get(String mode,
String agentId,
String sessionId,
LoginAccount account) {
Identity identity = identity(account);
String safeMode = mode(mode);
String safeAgentId = text(agentId, "Agent ID 不能为空");
String resolvedSessionId = sessionId;
if (!StringUtils.hasText(resolvedSessionId)) {
resolvedSessionId = redisTemplate.opsForValue().get(activeKey(identity, safeMode, safeAgentId));
}
if (!StringUtils.hasText(resolvedSessionId)) {
return Optional.empty();
}
validateSessionId(safeMode, resolvedSessionId);
String value = redisTemplate.opsForValue().get(DRAFT_PREFIX
+ scope(identity, safeMode, safeAgentId, resolvedSessionId));
if (!StringUtils.hasText(value)) {
return Optional.empty();
}
try {
AgentComposerDraft draft = objectMapper.readValue(value, AgentComposerDraft.class);
List<AgentMediaUploadRecord> uploads = availableUploads(draft.getImageUploadIds(), safeMode,
safeAgentId, resolvedSessionId, account);
draft.setImageUploadIds(uploads.stream().map(AgentMediaUploadRecord::getUploadId).toList());
draft.setImages(uploads.stream().map(this::toView).toList());
return Optional.of(draft);
} catch (Exception error) {
if (error instanceof ResponseStatusException responseError) {
throw responseError;
}
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "聊天草稿读取失败", error);
}
}
/**
* 读取仍有效的草稿图片,跳过自然过期项并保留权限或归属异常。
*
* @param uploadIds 图片上传 ID
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param account 当前账号
* @return 仍有效且保持原顺序的图片
*/
private List<AgentMediaUploadRecord> availableUploads(List<String> uploadIds,
String mode,
String agentId,
String sessionId,
LoginAccount account) {
List<AgentMediaUploadRecord> available = new ArrayList<>();
for (String uploadId : uploadIds == null ? List.<String>of() : uploadIds) {
try {
available.addAll(mediaService.requireUploads(List.of(uploadId), mode, agentId, sessionId, account));
} catch (ResponseStatusException error) {
if (error.getStatusCode() == HttpStatus.BAD_REQUEST
&& "图片已过期,请重新上传".equals(error.getReason())) {
continue;
}
throw error;
}
}
return available;
}
/**
* 删除已发送或主动清空的输入草稿。
*
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param account 当前账号
*/
public void delete(String mode, String agentId, String sessionId, LoginAccount account) {
delete(mode, agentId, sessionId, List.of(), true, account);
}
/**
* 删除输入草稿,并按调用语义选择是否清理临时图片。
*
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param requestedUploadIds 调用方当前持有的上传 ID
* @param deleteUploads 是否删除临时图片
* @param account 当前账号
*/
public void delete(String mode,
String agentId,
String sessionId,
List<String> requestedUploadIds,
boolean deleteUploads,
LoginAccount account) {
Identity identity = identity(account);
String safeMode = mode(mode);
String safeAgentId = text(agentId, "Agent ID 不能为空");
String safeSessionId = text(sessionId, "会话 ID 不能为空");
validateSessionId(safeMode, safeSessionId);
String scope = scope(identity, safeMode, safeAgentId, safeSessionId);
String draftKey = DRAFT_PREFIX + scope;
if (deleteUploads) {
LinkedHashSet<String> uploadIds = new LinkedHashSet<>();
String serializedDraft = redisTemplate.opsForValue().get(draftKey);
if (StringUtils.hasText(serializedDraft)) {
try {
AgentComposerDraft storedDraft = objectMapper.readValue(serializedDraft, AgentComposerDraft.class);
if (storedDraft.getImageUploadIds() != null) {
uploadIds.addAll(storedDraft.getImageUploadIds());
}
} catch (Exception error) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "聊天草稿删除失败", error);
}
}
if (requestedUploadIds != null) {
uploadIds.addAll(requestedUploadIds);
}
mediaService.deleteUploadsForScope(new ArrayList<>(uploadIds), safeMode,
safeAgentId, safeSessionId, account);
}
redisTemplate.delete(List.of(draftKey, REVISION_PREFIX + scope));
String activeKey = activeKey(identity, safeMode, safeAgentId);
String activeSession = redisTemplate.opsForValue().get(activeKey);
if (safeSessionId.equals(activeSession)) {
redisTemplate.delete(activeKey);
}
}
private AgentMediaUploadView toView(AgentMediaUploadRecord record) {
AgentMediaUploadView view = new AgentMediaUploadView();
view.setUploadId(record.getUploadId());
view.setName(record.getOriginalName());
view.setMimeType(record.getMimeType());
view.setSize(record.getSize());
view.setWidth(record.getWidth());
view.setHeight(record.getHeight());
view.setExpiresAt(record.getExpiresAt());
view.setPreviewUrl("/api/v1/agent/media/content?reference=draft%3A" + record.getUploadId());
return view;
}
private void validateSessionId(String mode, String sessionId) {
boolean valid = AgentMediaService.MODE_DRAFT.equals(mode)
? sessionId.matches("agent-draft-\\d+")
: sessionId.matches("\\d+");
if (!valid) {
throw badRequest("会话 ID 无效");
}
}
private String scope(Identity identity, String mode, String agentId, String sessionId) {
return identity.tenantId + ":" + identity.userId + ":" + mode + ":" + agentId + ":" + sessionId;
}
private String activeKey(Identity identity, String mode, String agentId) {
return ACTIVE_PREFIX + identity.tenantId + ":" + identity.userId + ":" + mode + ":" + agentId;
}
private String mode(String value) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
if (!AgentMediaService.MODE_FORMAL.equals(normalized) && !AgentMediaService.MODE_DRAFT.equals(normalized)) {
throw badRequest("聊天模式无效");
}
return normalized;
}
private String text(String value, String message) {
if (!StringUtils.hasText(value) || value.length() > 200) {
throw badRequest(message);
}
return value.trim();
}
private Duration draftTtl() {
Duration ttl = properties.getComposerDraftTtl();
return ttl == null || ttl.isZero() || ttl.isNegative() ? Duration.ofHours(24) : ttl;
}
private Identity identity(LoginAccount account) {
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "当前登录状态失效");
}
return new Identity(account.getTenantId().toString(), account.getId().toString());
}
private ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private record Identity(String tenantId, String userId) { }
}

View File

@@ -0,0 +1,10 @@
package tech.easyflow.agent.runtime.composer;
/**
* 输入框使用的预分配会话标识。
*
* @param mode 聊天模式
* @param sessionId 会话 ID
*/
public record AgentComposerSession(String mode, String sessionId) {
}

View File

@@ -0,0 +1,13 @@
package tech.easyflow.agent.runtime.media;
import java.util.Map;
/**
* 已绑定到聊天消息的图片引用与展示载荷。
*
* @param reference 运行时稳定引用
* @param mimeType MIME 类型
* @param payload 聊天历史展示载荷
*/
public record AgentBoundMedia(String reference, String mimeType, Map<String, Object> payload) {
}

View File

@@ -0,0 +1,32 @@
package tech.easyflow.agent.runtime.media;
import java.util.Arrays;
/**
* 校验并规范化后的 Agent 图片。
*
* @param bytes 图片字节
* @param mimeType MIME 类型
* @param extension 规范化扩展名
* @param width 宽度
* @param height 高度
* @param sha256 SHA-256 摘要
*/
public record AgentImageData(byte[] bytes,
String mimeType,
String extension,
int width,
int height,
String sha256) {
/**
* 创建不可变图片数据。
*/
public AgentImageData {
bytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
}
/** @return 图片字节副本 */
@Override
public byte[] bytes() { return Arrays.copyOf(bytes, bytes.length); }
}

View File

@@ -0,0 +1,213 @@
package tech.easyflow.agent.runtime.media;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import tech.easyflow.agent.config.AgentMediaProperties;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.stream.ImageInputStream;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Iterator;
/**
* Agent 聊天图片格式识别、尺寸校验与规范化处理器。
*/
@Component
public class AgentImageProcessor {
private final AgentMediaProperties properties;
/**
* 创建图片处理器。
*
* @param properties Agent 媒体配置
*/
public AgentImageProcessor(AgentMediaProperties properties) {
this.properties = properties;
}
/**
* 校验并规范化上传图片。
*
* @param file 上传文件
* @return 规范化图片
*/
public AgentImageData process(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw badRequest("请选择要上传的图片");
}
if (file.getSize() > properties.getMaxImageBytes()) {
throw badRequest("单张图片不能超过 10 MiB");
}
try {
byte[] source = file.getBytes();
ImageFormat format = detectFormat(source);
Dimensions dimensions = format == ImageFormat.WEBP
? webpDimensions(source)
: imageIoDimensions(source);
validateDimensions(dimensions);
byte[] normalized = source;
String mimeType = format.mimeType;
String extension = format.extension;
// 动图仅保留首帧BMP 转 PNG避免模型端格式兼容差异。
if (format == ImageFormat.GIF || format == ImageFormat.BMP) {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(source));
if (image == null) {
throw badRequest("图片内容无法解析");
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
if (!ImageIO.write(image, "png", output)) {
throw badRequest("图片格式转换失败");
}
normalized = output.toByteArray();
mimeType = "image/png";
extension = "png";
}
if (normalized.length > properties.getMaxImageBytes()) {
throw badRequest("处理后的图片不能超过 10 MiB");
}
return new AgentImageData(normalized, mimeType, extension,
dimensions.width, dimensions.height, sha256(normalized));
} catch (ResponseStatusException error) {
throw error;
} catch (Exception error) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "图片处理失败", error);
}
}
private ImageFormat detectFormat(byte[] bytes) {
if (bytes.length >= 8
&& bytes[0] == (byte) 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) {
return ImageFormat.PNG;
}
if (bytes.length >= 3 && bytes[0] == (byte) 0xff && bytes[1] == (byte) 0xd8 && bytes[2] == (byte) 0xff) {
return ImageFormat.JPEG;
}
if (bytes.length >= 6) {
String header = new String(bytes, 0, 6, StandardCharsets.US_ASCII);
if ("GIF87a".equals(header) || "GIF89a".equals(header)) {
return ImageFormat.GIF;
}
}
if (bytes.length >= 2 && bytes[0] == 'B' && bytes[1] == 'M') {
return ImageFormat.BMP;
}
if (bytes.length >= 12
&& "RIFF".equals(ascii(bytes, 0, 4))
&& "WEBP".equals(ascii(bytes, 8, 4))) {
return ImageFormat.WEBP;
}
throw badRequest("仅支持 PNG、JPG、JPEG、WebP、GIF、BMP 图片");
}
private Dimensions imageIoDimensions(byte[] bytes) throws Exception {
try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
Iterator<ImageReader> readers = ImageIO.getImageReaders(input);
if (!readers.hasNext()) {
throw badRequest("图片内容无法解析");
}
ImageReader reader = readers.next();
try {
reader.setInput(input, true, true);
return new Dimensions(reader.getWidth(0), reader.getHeight(0));
} finally {
reader.dispose();
}
}
}
private Dimensions webpDimensions(byte[] bytes) {
int offset = 12;
while (offset + 8 <= bytes.length) {
String chunk = ascii(bytes, offset, 4);
int size = littleEndianInt(bytes, offset + 4);
int data = offset + 8;
if (size < 0 || data + size > bytes.length) {
break;
}
if ("VP8X".equals(chunk) && size >= 10) {
return new Dimensions(1 + littleEndian24(bytes, data + 4),
1 + littleEndian24(bytes, data + 7));
}
if ("VP8 ".equals(chunk) && size >= 10
&& bytes[data + 3] == (byte) 0x9d && bytes[data + 4] == 0x01 && bytes[data + 5] == 0x2a) {
return new Dimensions(littleEndian16(bytes, data + 6) & 0x3fff,
littleEndian16(bytes, data + 8) & 0x3fff);
}
if ("VP8L".equals(chunk) && size >= 5 && bytes[data] == 0x2f) {
int b1 = unsigned(bytes[data + 1]);
int b2 = unsigned(bytes[data + 2]);
int b3 = unsigned(bytes[data + 3]);
int b4 = unsigned(bytes[data + 4]);
int width = 1 + ((b1 | (b2 << 8)) & 0x3fff);
int height = 1 + (((b2 >> 6) | (b3 << 2) | (b4 << 10)) & 0x3fff);
return new Dimensions(width, height);
}
offset = data + size + (size & 1);
}
throw badRequest("WebP 图片内容无法解析");
}
private void validateDimensions(Dimensions dimensions) {
if (dimensions.width <= 0 || dimensions.height <= 0) {
throw badRequest("图片尺寸无效");
}
long pixels = (long) dimensions.width * dimensions.height;
if (pixels > properties.getMaxImagePixels()) {
throw badRequest("图片像素过大,请压缩后重试");
}
}
private String sha256(byte[] bytes) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
}
private String ascii(byte[] bytes, int offset, int length) {
return new String(bytes, offset, length, StandardCharsets.US_ASCII);
}
private int littleEndian16(byte[] bytes, int offset) {
return unsigned(bytes[offset]) | (unsigned(bytes[offset + 1]) << 8);
}
private int littleEndian24(byte[] bytes, int offset) {
return unsigned(bytes[offset]) | (unsigned(bytes[offset + 1]) << 8) | (unsigned(bytes[offset + 2]) << 16);
}
private int littleEndianInt(byte[] bytes, int offset) {
return unsigned(bytes[offset]) | (unsigned(bytes[offset + 1]) << 8)
| (unsigned(bytes[offset + 2]) << 16) | (unsigned(bytes[offset + 3]) << 24);
}
private int unsigned(byte value) { return value & 0xff; }
private ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private record Dimensions(int width, int height) { }
private enum ImageFormat {
PNG("image/png", "png"),
JPEG("image/jpeg", "jpg"),
WEBP("image/webp", "webp"),
GIF("image/gif", "gif"),
BMP("image/bmp", "bmp");
private final String mimeType;
private final String extension;
ImageFormat(String mimeType, String extension) {
this.mimeType = mimeType;
this.extension = extension;
}
}
}

View File

@@ -0,0 +1,40 @@
package tech.easyflow.agent.runtime.media;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 定期删除 Redis 凭据已过期的 MinIO 临时图片。
*/
@Component
public class AgentMediaCleanupScheduler {
private static final Logger LOG = LoggerFactory.getLogger(AgentMediaCleanupScheduler.class);
private final AgentMediaService mediaService;
/**
* 创建清理任务。
*
* @param mediaService Agent 媒体服务
*/
public AgentMediaCleanupScheduler(AgentMediaService mediaService) {
this.mediaService = mediaService;
}
/**
* 清理一批过期临时图片。
*/
@Scheduled(fixedDelayString = "${easyflow.agent.media.cleanup-interval:10m}")
public void cleanup() {
try {
int cleaned = mediaService.cleanupExpired(200);
if (cleaned > 0) {
LOG.info("Cleaned {} expired Agent media objects", cleaned);
}
} catch (Exception error) {
LOG.error("Agent media cleanup failed", error);
}
}
}

View File

@@ -0,0 +1,233 @@
package tech.easyflow.agent.runtime.media;
import io.minio.*;
import io.minio.errors.ErrorResponseException;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import org.dromara.x.file.storage.core.FileStorageService;
import org.dromara.x.file.storage.core.platform.MinioFileStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.web.server.ResponseStatusException;
import tech.easyflow.agent.config.AgentMediaProperties;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 基于独立私有 MinIO 桶的 Agent 媒体对象存储。
*/
@Service
public class AgentMediaObjectStorage {
private static final Logger LOG = LoggerFactory.getLogger(AgentMediaObjectStorage.class);
private final FileStorageService fileStorageService;
private final AgentMediaProperties properties;
private final AtomicBoolean bucketReady = new AtomicBoolean(false);
/**
* 创建 Agent 媒体对象存储。
*
* @param fileStorageService x-file-storage 服务
* @param properties Agent 媒体配置
*/
public AgentMediaObjectStorage(FileStorageService fileStorageService, AgentMediaProperties properties) {
this.fileStorageService = fileStorageService;
this.properties = properties;
}
/**
* 应用启动后预创建私有桶;失败时保留完整日志,首次使用会再次尝试。
*/
@EventListener(ApplicationReadyEvent.class)
public void initializeBucket() {
try {
ensureBucket();
} catch (Exception error) {
LOG.error("Agent media bucket initialization failed", error);
}
}
/**
* 写入对象。
*
* @param objectKey 对象键
* @param data 对象字节
* @param mimeType MIME 类型
*/
public void put(String objectKey, byte[] data, String mimeType) {
try {
MinioFileStorage storage = storage();
ensureBucket();
storage.getClient().putObject(PutObjectArgs.builder()
.bucket(storage.getBucketName())
.object(fullKey(storage, objectKey))
.stream(new java.io.ByteArrayInputStream(data), data.length, -1)
.contentType(mimeType)
.build());
} catch (Exception error) {
throw storageError("图片上传到对象存储失败", error);
}
}
/**
* 读取对象并限制最大内存占用。
*
* @param objectKey 对象键
* @param maxBytes 最大读取字节数
* @return 对象字节
*/
public byte[] get(String objectKey, long maxBytes) {
try {
MinioFileStorage storage = storage();
ensureBucket();
try (InputStream input = storage.getClient().getObject(GetObjectArgs.builder()
.bucket(storage.getBucketName()).object(fullKey(storage, objectKey)).build());
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
long total = 0L;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > maxBytes) {
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "图片对象超过允许大小");
}
output.write(buffer, 0, read);
}
return output.toByteArray();
}
} catch (ResponseStatusException error) {
throw error;
} catch (ErrorResponseException error) {
String code = error.errorResponse() == null ? null : error.errorResponse().code();
if ("NoSuchKey".equals(code) || "NoSuchObject".equals(code)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "图片不存在或已删除", error);
}
throw storageError("图片对象读取失败", error);
} catch (Exception error) {
throw storageError("图片对象读取失败", error);
}
}
/**
* 在私有桶内复制对象。
*
* @param sourceKey 源对象键
* @param targetKey 目标对象键
*/
public void copy(String sourceKey, String targetKey) {
try {
MinioFileStorage storage = storage();
ensureBucket();
storage.getClient().copyObject(CopyObjectArgs.builder()
.bucket(storage.getBucketName())
.object(fullKey(storage, targetKey))
.source(CopySource.builder().bucket(storage.getBucketName())
.object(fullKey(storage, sourceKey)).build())
.build());
} catch (Exception error) {
throw storageError("图片对象归档失败", error);
}
}
/**
* 删除对象,重复删除视为成功。
*
* @param objectKey 对象键
*/
public void delete(String objectKey) {
try {
MinioFileStorage storage = storage();
ensureBucket();
storage.getClient().removeObject(RemoveObjectArgs.builder()
.bucket(storage.getBucketName()).object(fullKey(storage, objectKey)).build());
} catch (Exception error) {
throw storageError("图片对象删除失败", error);
}
}
/**
* 删除指定前缀下的全部对象。
*
* @param objectPrefix 对象键前缀
*/
public void deletePrefix(String objectPrefix) {
try {
MinioFileStorage storage = storage();
ensureBucket();
String fullPrefix = fullKey(storage, objectPrefix);
List<DeleteObject> batch = new ArrayList<>(1000);
for (Result<Item> result : storage.getClient().listObjects(ListObjectsArgs.builder()
.bucket(storage.getBucketName())
.prefix(fullPrefix)
.recursive(true)
.build())) {
batch.add(new DeleteObject(result.get().objectName()));
if (batch.size() == 1000) {
removeBatch(storage, batch);
batch.clear();
}
}
removeBatch(storage, batch);
} catch (Exception error) {
throw storageError("图片对象目录删除失败", error);
}
}
private synchronized void ensureBucket() throws Exception {
if (bucketReady.get()) {
return;
}
MinioFileStorage storage = storage();
boolean exists = storage.getClient().bucketExists(
BucketExistsArgs.builder().bucket(storage.getBucketName()).build());
if (!exists) {
storage.getClient().makeBucket(MakeBucketArgs.builder().bucket(storage.getBucketName()).build());
}
bucketReady.set(true);
}
private MinioFileStorage storage() {
MinioFileStorage storage = fileStorageService.getFileStorage(properties.getPlatform());
if (storage == null) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"Agent 私有媒体存储平台未配置");
}
return storage;
}
private void removeBatch(MinioFileStorage storage, List<DeleteObject> objects) throws Exception {
if (objects.isEmpty()) {
return;
}
Iterable<Result<DeleteError>> errors = storage.getClient().removeObjects(RemoveObjectsArgs.builder()
.bucket(storage.getBucketName())
.objects(List.copyOf(objects))
.build());
for (Result<DeleteError> result : errors) {
DeleteError error = result.get();
throw new IllegalStateException("删除 MinIO 对象失败: " + error.objectName() + ", " + error.message());
}
}
private String fullKey(MinioFileStorage storage, String objectKey) {
String basePath = storage.getBasePath();
if (basePath == null || basePath.isBlank()) {
return objectKey;
}
return basePath.replaceAll("/+$", "") + "/" + objectKey.replaceAll("^/+", "");
}
private ResponseStatusException storageError(String message, Exception error) {
LOG.error(message, error);
return new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, message, error);
}
}

View File

@@ -0,0 +1,428 @@
package tech.easyflow.agent.runtime.media;
import com.easyagents.agent.runtime.media.AgentMediaResolver;
import com.easyagents.agent.runtime.media.AgentMediaResource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.util.UriUtils;
import tech.easyflow.agent.config.AgentMediaProperties;
import tech.easyflow.common.entity.LoginAccount;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.*;
/**
* Agent 图片上传、归档、鉴权读取和运行时解析服务。
*/
@Service
public class AgentMediaService {
public static final String MODE_FORMAL = "FORMAL";
public static final String MODE_DRAFT = "DRAFT";
private final AgentImageProcessor imageProcessor;
private final AgentMediaObjectStorage objectStorage;
private final RedisAgentMediaUploadStore uploadStore;
private final AgentMediaProperties properties;
/**
* 创建 Agent 媒体服务。
*
* @param imageProcessor 图片处理器
* @param objectStorage 私有对象存储
* @param uploadStore 临时凭据存储
* @param properties Agent 媒体配置
*/
public AgentMediaService(AgentImageProcessor imageProcessor,
AgentMediaObjectStorage objectStorage,
RedisAgentMediaUploadStore uploadStore,
AgentMediaProperties properties) {
this.imageProcessor = imageProcessor;
this.objectStorage = objectStorage;
this.uploadStore = uploadStore;
this.properties = properties;
}
/**
* 上传一张聊天临时图片。
*
* @param file 图片文件
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param account 当前账号
* @return 上传结果
*/
public AgentMediaUploadView upload(MultipartFile file,
String mode,
String agentId,
String sessionId,
LoginAccount account) {
String safeMode = requireMode(mode);
String safeAgentId = requireText(agentId, "Agent ID 不能为空");
String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
Identity identity = identity(account);
AgentImageData image = imageProcessor.process(file);
String uploadId = UUID.randomUUID().toString().replace("-", "");
String objectKey = "temp/%s/%s/%s.%s".formatted(identity.tenantId, identity.userId,
uploadId, image.extension());
objectStorage.put(objectKey, image.bytes(), image.mimeType());
AgentMediaUploadRecord record = new AgentMediaUploadRecord();
record.setUploadId(uploadId);
record.setTenantId(identity.tenantId);
record.setUserId(identity.userId);
record.setMode(safeMode);
record.setAgentId(safeAgentId);
record.setSessionId(safeSessionId);
record.setObjectKey(objectKey);
record.setOriginalName(safeOriginalName(file.getOriginalFilename(), image.extension()));
record.setMimeType(image.mimeType());
record.setExtension(image.extension());
record.setSize(image.bytes().length);
record.setWidth(image.width());
record.setHeight(image.height());
record.setSha256(image.sha256());
record.setCreatedAt(Instant.now());
try {
uploadStore.create(record);
} catch (RuntimeException error) {
objectStorage.delete(objectKey);
throw error;
}
return toView(record);
}
/**
* 删除当前账号尚未绑定的临时图片。
*
* @param uploadId 上传 ID
* @param account 当前账号
*/
public void deleteUpload(String uploadId, LoginAccount account) {
AgentMediaUploadRecord record = uploadStore.find(uploadId).orElse(null);
if (record == null) {
return;
}
assertOwner(record, identity(account));
objectStorage.delete(record.getObjectKey());
uploadStore.delete(record);
}
/**
* 幂等删除指定草稿作用域内的临时图片。
*
* @param uploadIds 上传 ID
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param account 当前账号
*/
public void deleteUploadsForScope(List<String> uploadIds,
String mode,
String agentId,
String sessionId,
LoginAccount account) {
Identity identity = identity(account);
String safeMode = requireMode(mode);
String safeAgentId = requireText(agentId, "Agent ID 不能为空");
String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
for (String uploadId : uploadIds == null ? List.<String>of() : uploadIds.stream()
.filter(StringUtils::hasText).map(String::trim).distinct().toList()) {
AgentMediaUploadRecord record = uploadStore.find(uploadId).orElse(null);
if (record == null) {
continue;
}
assertOwner(record, identity);
if (!safeMode.equals(record.getMode())
|| !safeAgentId.equals(record.getAgentId())
|| !safeSessionId.equals(record.getSessionId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "图片不属于当前聊天会话");
}
objectStorage.delete(record.getObjectKey());
uploadStore.delete(record);
}
}
/**
* 校验并取得本轮图片上传凭据。
*
* @param uploadIds 上传 ID
* @param mode 聊天模式
* @param agentId Agent ID
* @param sessionId 会话 ID
* @param account 当前账号
* @return 有序上传凭据
*/
public List<AgentMediaUploadRecord> requireUploads(List<String> uploadIds,
String mode,
String agentId,
String sessionId,
LoginAccount account) {
List<String> ids = uploadIds == null ? List.of() : uploadIds.stream()
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
if (ids.size() > properties.getMaxImageCount()) {
throw badRequest("每次最多发送 " + properties.getMaxImageCount() + " 张图片");
}
if (ids.isEmpty()) {
return List.of();
}
Identity identity = identity(account);
String safeMode = requireMode(mode);
String safeAgentId = requireText(agentId, "Agent ID 不能为空");
String safeSessionId = requireText(sessionId, "会话 ID 不能为空");
List<AgentMediaUploadRecord> records = new ArrayList<>(ids.size());
for (String uploadId : ids) {
AgentMediaUploadRecord record = uploadStore.find(uploadId)
.orElseThrow(() -> badRequest("图片已过期,请重新上传"));
assertOwner(record, identity);
if (!safeMode.equals(record.getMode())
|| !safeAgentId.equals(record.getAgentId())
|| !safeSessionId.equals(record.getSessionId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "图片不属于当前聊天会话");
}
records.add(record);
}
return records;
}
/**
* 将正式聊天图片从临时区归档到消息目录。
*
* @param uploads 临时图片
* @param sessionId 会话 ID
* @param messageId 消息 ID
* @param account 当前账号
* @return 已绑定图片
*/
public List<AgentBoundMedia> bindFormal(List<AgentMediaUploadRecord> uploads,
String sessionId,
String messageId,
LoginAccount account) {
if (uploads == null || uploads.isEmpty()) {
return List.of();
}
Identity identity = identity(account);
List<AgentBoundMedia> result = new ArrayList<>(uploads.size());
for (int index = 0; index < uploads.size(); index++) {
AgentMediaUploadRecord upload = uploads.get(index);
assertOwner(upload, identity);
String reference = upload.getFormalReference();
if (StringUtils.hasText(reference)) {
FormalReference formal = parseFormalReference(reference);
if (!digits(sessionId).equals(formal.sessionId)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "图片已绑定到其他聊天会话");
}
result.add(new AgentBoundMedia(reference, upload.getMimeType(), displayPayload(upload, reference)));
continue;
}
reference = formalReference(sessionId, messageId, index, upload.getExtension());
String targetKey = formalObjectKey(identity, sessionId, messageId, index, upload.getExtension());
objectStorage.copy(upload.getObjectKey(), targetKey);
uploadStore.markFormalBinding(upload, reference);
result.add(new AgentBoundMedia(reference, upload.getMimeType(), displayPayload(upload, reference)));
}
return result;
}
/**
* 删除当前账号一个正式会话归档的全部图片。
*
* @param sessionId 会话 ID
* @param account 当前账号
*/
public void deleteFormalSession(String sessionId, LoginAccount account) {
Identity identity = identity(account);
objectStorage.deletePrefix("formal/%s/%s/%s/".formatted(
identity.tenantId, identity.userId, digits(sessionId)));
}
/**
* 为草稿试运行保留临时图片引用并续期。
*
* @param uploads 临时图片
* @return 已绑定图片
*/
public List<AgentBoundMedia> bindDraft(List<AgentMediaUploadRecord> uploads) {
if (uploads == null || uploads.isEmpty()) {
return List.of();
}
List<AgentBoundMedia> result = new ArrayList<>(uploads.size());
for (AgentMediaUploadRecord upload : uploads) {
uploadStore.renew(upload);
String reference = "draft:" + upload.getUploadId();
result.add(new AgentBoundMedia(reference, upload.getMimeType(), displayPayload(upload, reference)));
}
return result;
}
/**
* 读取当前用户可访问的图片引用。
*
* @param reference 稳定图片引用
* @param account 当前账号
* @return 图片资源
*/
public AgentMediaResource load(String reference, LoginAccount account) {
Identity identity = identity(account);
if (reference != null && reference.startsWith("draft:")) {
String uploadId = reference.substring("draft:".length());
AgentMediaUploadRecord record = uploadStore.find(uploadId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "图片已过期"));
assertOwner(record, identity);
return new AgentMediaResource(record.getMimeType(),
objectStorage.get(record.getObjectKey(), properties.getMaxImageBytes()));
}
FormalReference formal = parseFormalReference(reference);
String key = formalObjectKey(identity, formal.sessionId, formal.messageId, formal.index, formal.extension);
return new AgentMediaResource(mimeType(formal.extension),
objectStorage.get(key, properties.getMaxImageBytes()));
}
/**
* 创建仅能访问当前账号媒体的 easy-agents 解析器。
*
* @param account 当前账号
* @return 运行时媒体解析器
*/
public AgentMediaResolver runtimeResolver(LoginAccount account) {
return reference -> {
AgentMediaResource resource = load(reference, account);
if (reference != null && reference.startsWith("draft:")) {
uploadStore.find(reference.substring("draft:".length())).ifPresent(uploadStore::renew);
}
return resource;
};
}
/**
* 清理过期临时对象。
*
* @param limit 单次清理上限
* @return 清理数量
*/
public int cleanupExpired(int limit) {
int cleaned = 0;
for (AgentMediaUploadRecord indexed : uploadStore.expired(limit)) {
Optional<AgentMediaUploadRecord> current = uploadStore.find(indexed.getUploadId());
if (current.isPresent() && current.get().getExpiresAt() != null
&& current.get().getExpiresAt().isAfter(Instant.now())) {
uploadStore.removeExpiryIndex(indexed);
continue;
}
objectStorage.delete(indexed.getObjectKey());
current.ifPresent(uploadStore::delete);
uploadStore.removeExpiryIndex(indexed);
cleaned++;
}
return cleaned;
}
private AgentMediaUploadView toView(AgentMediaUploadRecord record) {
AgentMediaUploadView view = new AgentMediaUploadView();
view.setUploadId(record.getUploadId());
view.setName(record.getOriginalName());
view.setMimeType(record.getMimeType());
view.setSize(record.getSize());
view.setWidth(record.getWidth());
view.setHeight(record.getHeight());
view.setExpiresAt(record.getExpiresAt());
view.setPreviewUrl("/api/v1/agent/media/content?reference="
+ UriUtils.encodeQueryParam("draft:" + record.getUploadId(), StandardCharsets.UTF_8));
return view;
}
private Map<String, Object> displayPayload(AgentMediaUploadRecord upload, String reference) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("imageRef", reference);
payload.put("name", upload.getOriginalName());
payload.put("mimeType", upload.getMimeType());
payload.put("size", upload.getSize());
payload.put("width", upload.getWidth());
payload.put("height", upload.getHeight());
payload.put("previewUrl", "/api/v1/agent/media/content?reference="
+ UriUtils.encodeQueryParam(reference, StandardCharsets.UTF_8));
return payload;
}
private String formalReference(String sessionId, String messageId, int index, String extension) {
return "formal:" + sessionId + ":" + messageId + ":" + index + ":" + extension;
}
private FormalReference parseFormalReference(String reference) {
if (reference == null || !reference.startsWith("formal:")) {
throw badRequest("图片引用无效");
}
String[] parts = reference.split(":", -1);
if (parts.length != 5 || !parts[1].matches("\\d+") || !parts[2].matches("\\d+")
|| !parts[3].matches("\\d+") || !Set.of("png", "jpg", "webp").contains(parts[4])) {
throw badRequest("图片引用无效");
}
return new FormalReference(parts[1], parts[2], Integer.parseInt(parts[3]), parts[4]);
}
private String formalObjectKey(Identity identity, String sessionId, String messageId, int index, String extension) {
return "formal/%s/%s/%s/%s/%d.%s".formatted(identity.tenantId, identity.userId,
digits(sessionId), digits(messageId), index, extension);
}
private String digits(String value) {
if (value == null || !value.matches("\\d+")) {
throw badRequest("聊天消息标识无效");
}
return value;
}
private String mimeType(String extension) {
return switch (extension) {
case "jpg" -> "image/jpeg";
case "webp" -> "image/webp";
default -> "image/png";
};
}
private void assertOwner(AgentMediaUploadRecord record, Identity identity) {
if (record == null || !identity.tenantId.equals(record.getTenantId())
|| !identity.userId.equals(record.getUserId())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该图片");
}
}
private Identity identity(LoginAccount account) {
if (account == null || account.getId() == null || account.getTenantId() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "当前登录状态失效");
}
return new Identity(account.getTenantId().toString(), account.getId().toString());
}
private String requireMode(String mode) {
String normalized = mode == null ? "" : mode.trim().toUpperCase(Locale.ROOT);
if (!MODE_FORMAL.equals(normalized) && !MODE_DRAFT.equals(normalized)) {
throw badRequest("聊天模式无效");
}
return normalized;
}
private String requireText(String value, String message) {
if (!StringUtils.hasText(value) || value.length() > 200) {
throw badRequest(message);
}
return value.trim();
}
private String safeOriginalName(String value, String extension) {
String name = StringUtils.hasText(value) ? value.replaceAll("[\\r\\n\\t]", " ").trim() : "image." + extension;
return name.length() > 200 ? name.substring(0, 200) : name;
}
private ResponseStatusException badRequest(String message) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
}
private record Identity(String tenantId, String userId) { }
private record FormalReference(String sessionId, String messageId, int index, String extension) { }
}

View File

@@ -0,0 +1,96 @@
package tech.easyflow.agent.runtime.media;
import java.time.Instant;
/**
* Redis 中保存的 Agent 临时图片上传凭据。
*/
public class AgentMediaUploadRecord {
private String uploadId;
private String tenantId;
private String userId;
private String mode;
private String agentId;
private String sessionId;
private String objectKey;
private String originalName;
private String mimeType;
private String extension;
private long size;
private int width;
private int height;
private String sha256;
private String formalReference;
private Instant createdAt;
private Instant expiresAt;
/** @return 上传 ID */
public String getUploadId() { return uploadId; }
/** @param uploadId 上传 ID */
public void setUploadId(String uploadId) { this.uploadId = uploadId; }
/** @return 租户 ID */
public String getTenantId() { return tenantId; }
/** @param tenantId 租户 ID */
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
/** @return 用户 ID */
public String getUserId() { return userId; }
/** @param userId 用户 ID */
public void setUserId(String userId) { this.userId = userId; }
/** @return 聊天模式 */
public String getMode() { return mode; }
/** @param mode 聊天模式 */
public void setMode(String mode) { this.mode = mode; }
/** @return Agent ID */
public String getAgentId() { return agentId; }
/** @param agentId Agent ID */
public void setAgentId(String agentId) { this.agentId = agentId; }
/** @return 会话 ID */
public String getSessionId() { return sessionId; }
/** @param sessionId 会话 ID */
public void setSessionId(String sessionId) { this.sessionId = sessionId; }
/** @return 对象键 */
public String getObjectKey() { return objectKey; }
/** @param objectKey 对象键 */
public void setObjectKey(String objectKey) { this.objectKey = objectKey; }
/** @return 原文件名 */
public String getOriginalName() { return originalName; }
/** @param originalName 原文件名 */
public void setOriginalName(String originalName) { this.originalName = originalName; }
/** @return MIME 类型 */
public String getMimeType() { return mimeType; }
/** @param mimeType MIME 类型 */
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
/** @return 扩展名 */
public String getExtension() { return extension; }
/** @param extension 扩展名 */
public void setExtension(String extension) { this.extension = extension; }
/** @return 文件大小 */
public long getSize() { return size; }
/** @param size 文件大小 */
public void setSize(long size) { this.size = size; }
/** @return 图片宽度 */
public int getWidth() { return width; }
/** @param width 图片宽度 */
public void setWidth(int width) { this.width = width; }
/** @return 图片高度 */
public int getHeight() { return height; }
/** @param height 图片高度 */
public void setHeight(int height) { this.height = height; }
/** @return SHA-256 */
public String getSha256() { return sha256; }
/** @param sha256 SHA-256 */
public void setSha256(String sha256) { this.sha256 = sha256; }
/** @return 已完成归档的正式图片引用 */
public String getFormalReference() { return formalReference; }
/** @param formalReference 已完成归档的正式图片引用 */
public void setFormalReference(String formalReference) { this.formalReference = formalReference; }
/** @return 创建时间 */
public Instant getCreatedAt() { return createdAt; }
/** @param createdAt 创建时间 */
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
/** @return 过期时间 */
public Instant getExpiresAt() { return expiresAt; }
/** @param expiresAt 过期时间 */
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
}

View File

@@ -0,0 +1,51 @@
package tech.easyflow.agent.runtime.media;
import java.time.Instant;
/**
* 返回给聊天输入框的临时图片信息。
*/
public class AgentMediaUploadView {
private String uploadId;
private String name;
private String mimeType;
private long size;
private int width;
private int height;
private String previewUrl;
private Instant expiresAt;
/** @return 上传 ID */
public String getUploadId() { return uploadId; }
/** @param uploadId 上传 ID */
public void setUploadId(String uploadId) { this.uploadId = uploadId; }
/** @return 文件名 */
public String getName() { return name; }
/** @param name 文件名 */
public void setName(String name) { this.name = name; }
/** @return MIME 类型 */
public String getMimeType() { return mimeType; }
/** @param mimeType MIME 类型 */
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
/** @return 文件大小 */
public long getSize() { return size; }
/** @param size 文件大小 */
public void setSize(long size) { this.size = size; }
/** @return 图片宽度 */
public int getWidth() { return width; }
/** @param width 图片宽度 */
public void setWidth(int width) { this.width = width; }
/** @return 图片高度 */
public int getHeight() { return height; }
/** @param height 图片高度 */
public void setHeight(int height) { this.height = height; }
/** @return 鉴权预览地址 */
public String getPreviewUrl() { return previewUrl; }
/** @param previewUrl 鉴权预览地址 */
public void setPreviewUrl(String previewUrl) { this.previewUrl = previewUrl; }
/** @return 过期时间 */
public Instant getExpiresAt() { return expiresAt; }
/** @param expiresAt 过期时间 */
public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; }
}

View File

@@ -0,0 +1,189 @@
package tech.easyflow.agent.runtime.media;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import tech.easyflow.agent.config.AgentMediaProperties;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Agent 临时图片凭据与过期对象索引的 Redis 存储。
*/
@Service
public class RedisAgentMediaUploadStore {
private static final String KEY_PREFIX = "easyflow:agent:image-upload:";
private static final String EXPIRY_INDEX = "easyflow:agent:image-upload:expiry";
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private final AgentMediaProperties properties;
/**
* 创建临时图片凭据存储。
*
* @param redisTemplate Redis 模板
* @param objectMapper JSON 映射器
* @param properties Agent 媒体配置
*/
public RedisAgentMediaUploadStore(StringRedisTemplate redisTemplate,
ObjectMapper objectMapper,
AgentMediaProperties properties) {
this.redisTemplate = redisTemplate;
this.objectMapper = objectMapper;
this.properties = properties;
}
/**
* 创建临时图片凭据和清理索引。
*
* @param record 上传凭据
*/
public void create(AgentMediaUploadRecord record) {
Duration ttl = uploadTtl();
Instant expiresAt = Instant.now().plus(ttl);
record.setExpiresAt(expiresAt);
write(record, ttl);
redisTemplate.opsForZSet().add(EXPIRY_INDEX, cleanupMember(record), expiresAt.toEpochMilli());
}
/**
* 查找上传凭据,读取操作不延长 TTL。
*
* @param uploadId 上传 ID
* @return 上传凭据
*/
public Optional<AgentMediaUploadRecord> find(String uploadId) {
if (!StringUtils.hasText(uploadId)) {
return Optional.empty();
}
String value = redisTemplate.opsForValue().get(key(uploadId));
return StringUtils.hasText(value) ? Optional.of(read(value)) : Optional.empty();
}
/**
* 续期仍被草稿或试运行会话使用的图片。
*
* @param record 上传凭据
*/
public void renew(AgentMediaUploadRecord record) {
Duration ttl = uploadTtl();
String previousMember = cleanupMember(record);
record.setExpiresAt(Instant.now().plus(ttl));
write(record, ttl);
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, previousMember);
redisTemplate.opsForZSet().add(EXPIRY_INDEX, cleanupMember(record), record.getExpiresAt().toEpochMilli());
}
/**
* 记录临时上传已归档到正式图片,且不延长原有 TTL。
*
* @param record 上传凭据
* @param formalReference 正式图片引用
*/
public void markFormalBinding(AgentMediaUploadRecord record, String formalReference) {
if (record == null || !StringUtils.hasText(formalReference)) {
throw new IllegalArgumentException("正式图片绑定信息不能为空");
}
String previousMember = cleanupMember(record);
Instant expiresAt = record.getExpiresAt();
if (expiresAt == null) {
expiresAt = Instant.now().plus(uploadTtl());
record.setExpiresAt(expiresAt);
}
Duration remaining = Duration.between(Instant.now(), expiresAt);
if (remaining.isZero() || remaining.isNegative()) {
throw new IllegalStateException("图片上传凭据已过期");
}
record.setFormalReference(formalReference);
write(record, remaining);
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, previousMember);
redisTemplate.opsForZSet().add(EXPIRY_INDEX, cleanupMember(record),
record.getExpiresAt().toEpochMilli());
}
/**
* 删除已归档的临时凭据和清理索引。
*
* @param record 上传凭据
*/
public void delete(AgentMediaUploadRecord record) {
if (record == null) {
return;
}
redisTemplate.delete(key(record.getUploadId()));
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, cleanupMember(record));
}
/**
* 获取已到期的清理索引成员。
*
* @param limit 最大数量
* @return 到期上传凭据
*/
public List<AgentMediaUploadRecord> expired(int limit) {
Set<String> members = redisTemplate.opsForZSet()
.rangeByScore(EXPIRY_INDEX, 0, Instant.now().toEpochMilli(), 0, Math.max(1, limit));
List<AgentMediaUploadRecord> records = new ArrayList<>();
if (members == null) {
return records;
}
for (String member : members) {
try {
records.add(objectMapper.readValue(member, AgentMediaUploadRecord.class));
} catch (Exception error) {
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, member);
}
}
return records;
}
/**
* 删除一个清理索引成员。
*
* @param record 上传凭据
*/
public void removeExpiryIndex(AgentMediaUploadRecord record) {
redisTemplate.opsForZSet().remove(EXPIRY_INDEX, cleanupMember(record));
}
private void write(AgentMediaUploadRecord record, Duration ttl) {
try {
redisTemplate.opsForValue().set(key(record.getUploadId()), objectMapper.writeValueAsString(record),
Math.max(1L, ttl.toSeconds()), TimeUnit.SECONDS);
} catch (Exception error) {
throw new IllegalStateException("写入 Agent 图片上传凭据失败", error);
}
}
private AgentMediaUploadRecord read(String value) {
try {
return objectMapper.readValue(value, AgentMediaUploadRecord.class);
} catch (Exception error) {
throw new IllegalStateException("读取 Agent 图片上传凭据失败", error);
}
}
private String cleanupMember(AgentMediaUploadRecord record) {
try {
return objectMapper.writeValueAsString(record);
} catch (Exception error) {
throw new IllegalStateException("写入 Agent 图片清理索引失败", error);
}
}
private String key(String uploadId) { return KEY_PREFIX + uploadId; }
private Duration uploadTtl() {
Duration ttl = properties.getUploadTtl();
return ttl == null || ttl.isNegative() || ttl.isZero() ? Duration.ofHours(24) : ttl;
}
}

View File

@@ -249,6 +249,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
summary.put("title", model.getTitle());
summary.put("modelName", model.getModelName());
summary.put("providerType", model.getModelProvider() == null ? null : model.getModelProvider().getProviderType());
summary.put("supportImage", Boolean.TRUE.equals(model.getSupportImage()));
summary.put("supportImageB64Only", Boolean.TRUE.equals(model.getSupportImageB64Only()));
return summary;
}

View File

@@ -11,6 +11,7 @@ import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.agent.entity.AgentHitlPending;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
@@ -21,6 +22,8 @@ import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.agent.runtime.media.AgentBoundMedia;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -399,6 +402,33 @@ public class AgentRunServiceDraftAndHitlTest {
Assert.assertEquals(Boolean.TRUE, payload.get(0).get("faqCollection"));
}
/**
* 验证输入接收事件会回传正式图片展示信息。
*
* @throws Exception 反射调用失败时抛出
*/
@Test
public void sendInputAcceptedShouldExposeBoundImages() throws Exception {
AgentRunService service = new AgentRunService();
RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
AgentBoundMedia image = new AgentBoundMedia("formal:101:201:0:png", "image/png",
Map.of("imageRef", "formal:101:201:0:png",
"previewUrl", "/api/v1/agent/media/content?reference=formal:101:201:0:png"));
boolean sent = invoke(service, "sendInputAccepted",
new Class<?>[]{ChatSseEmitter.class, BigInteger.class, BigInteger.class, List.class},
emitter, BigInteger.valueOf(101), BigInteger.valueOf(201), List.of(image));
Assert.assertTrue(sent);
Assert.assertEquals(1, emitter.envelopes.size());
Assert.assertEquals(ChatType.INPUT_ACCEPTED, emitter.envelopes.get(0).getType());
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
Assert.assertEquals("101", payload.get("sessionId"));
Assert.assertEquals("201", payload.get("messageId"));
Assert.assertEquals(List.of(image.payload()), payload.get("images"));
}
/**
* 验证未保存草稿会生成临时 Agent ID并把绑定指向该运行 ID。
*
@@ -463,12 +493,16 @@ public class AgentRunServiceDraftAndHitlTest {
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(100));
ChatRuntimeContext context = chatContext();
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.ONE);
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
String.class, ChatRuntimeContext.class, boolean.class, AgentSessionStore.class},
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true,
new InMemoryAgentSessionStore()));
new Class<?>[]{Agent.class, String.class, List.class, LoginAccount.class, String.class,
String.class, String.class, String.class, ChatRuntimeContext.class, boolean.class,
AgentSessionStore.class},
agent, "你好", List.of(), account, "request-lock", "trace-lock", "session-lock", "AGENT",
context, true, new InMemoryAgentSessionStore()));
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
@@ -490,14 +524,21 @@ public class AgentRunServiceDraftAndHitlTest {
setField(service, "agentRuntimeCompiler", compiler);
setField(service, "agentRuntimeFactory", runtimeFactory);
setField(service, "agentRunRegistry", new AgentRunRegistry());
AgentMediaService mediaService = Mockito.mock(AgentMediaService.class);
Mockito.when(mediaService.runtimeResolver(Mockito.any())).thenReturn(reference -> null);
setField(service, "agentMediaService", mediaService);
Agent agent = new Agent();
agent.setId(BigInteger.valueOf(100));
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.ONE);
invoke(service, "startRuntime",
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class, String.class,
ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class, AgentSessionStore.class,
AgentRunLock.Handle.class},
agent, "你好", "request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
new Class<?>[]{Agent.class, AgentMessage.class, LoginAccount.class, String.class, String.class,
String.class, String.class, ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class,
AgentSessionStore.class, AgentRunLock.Handle.class},
agent, AgentMessage.text(AgentMessageRole.USER, "你好"), account,
"request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());

View File

@@ -0,0 +1,101 @@
package tech.easyflow.agent.runtime.composer;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import tech.easyflow.agent.config.AgentMediaProperties;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.common.entity.LoginAccount;
import java.math.BigInteger;
import java.util.List;
/**
* {@link AgentComposerDraftService} 草稿清理测试。
*/
public class AgentComposerDraftServiceTest {
private static final String DRAFT_KEY = "easyflow:agent:composer:draft:2:7:FORMAL:9:101";
private static final String ACTIVE_KEY = "easyflow:agent:composer:active:2:7:FORMAL:9";
private StringRedisTemplate redisTemplate;
private ValueOperations<String, String> valueOperations;
private AgentMediaService mediaService;
private AgentComposerDraftService service;
private LoginAccount account;
/**
* 初始化草稿服务测试依赖。
*/
@Before
@SuppressWarnings("unchecked")
public void setUp() {
redisTemplate = Mockito.mock(StringRedisTemplate.class);
valueOperations = Mockito.mock(ValueOperations.class);
Mockito.when(redisTemplate.opsForValue()).thenReturn(valueOperations);
mediaService = Mockito.mock(AgentMediaService.class);
service = new AgentComposerDraftService(redisTemplate, new ObjectMapper(),
new AgentMediaProperties(), mediaService);
account = new LoginAccount();
account.setTenantId(BigInteger.valueOf(2));
account.setId(BigInteger.valueOf(7));
}
/**
* 验证删除草稿时合并服务端与调用方上传 ID 并清理临时图片。
*
* @throws Exception JSON 编码失败时抛出
*/
@Test
public void deleteShouldCleanupDraftUploadsBeforeRedisKeys() throws Exception {
AgentComposerDraft draft = new AgentComposerDraft();
draft.setImageUploadIds(List.of("upload-1", "upload-2"));
Mockito.when(valueOperations.get(DRAFT_KEY))
.thenReturn(new ObjectMapper().writeValueAsString(draft));
Mockito.when(valueOperations.get(ACTIVE_KEY)).thenReturn("101");
service.delete("FORMAL", "9", "101", List.of("upload-2", "upload-3"), true, account);
Mockito.verify(mediaService).deleteUploadsForScope(
List.of("upload-1", "upload-2", "upload-3"), "FORMAL", "9", "101", account);
Mockito.verify(redisTemplate).delete(List.of(DRAFT_KEY,
"easyflow:agent:composer:revision:2:7:FORMAL:9:101"));
Mockito.verify(redisTemplate).delete(ACTIVE_KEY);
}
/**
* 验证关闭上传清理时只删除草稿缓存。
*/
@Test
public void deleteShouldKeepUploadsWhenCleanupIsDisabled() {
Mockito.when(valueOperations.get(ACTIVE_KEY)).thenReturn("101");
service.delete("FORMAL", "9", "101", List.of("upload-1"), false, account);
Mockito.verifyNoInteractions(mediaService);
Mockito.verify(redisTemplate).delete(List.of(DRAFT_KEY,
"easyflow:agent:composer:revision:2:7:FORMAL:9:101"));
}
/**
* 验证旧版草稿未保存图片 ID 字段时仍能完成清理。
*
* @throws Exception JSON 编码失败时抛出
*/
@Test
public void deleteShouldHandleLegacyDraftWithoutImageUploadIds() throws Exception {
AgentComposerDraft draft = new AgentComposerDraft();
Mockito.when(valueOperations.get(DRAFT_KEY))
.thenReturn(new ObjectMapper().writeValueAsString(draft));
service.delete("FORMAL", "9", "101", List.of(), true, account);
Mockito.verify(mediaService).deleteUploadsForScope(
List.of(), "FORMAL", "9", "101", account);
Mockito.verify(redisTemplate).delete(List.of(DRAFT_KEY,
"easyflow:agent:composer:revision:2:7:FORMAL:9:101"));
}
}

View File

@@ -0,0 +1,141 @@
package tech.easyflow.agent.runtime.media;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import tech.easyflow.agent.config.AgentMediaProperties;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
/**
* Agent 图片格式识别、规范化和资源限制测试。
*/
public class AgentImageProcessorTest {
/**
* 验证 PNG 会保留格式、尺寸和稳定摘要。
*
* @throws Exception 图片生成失败时抛出
*/
@Test
public void processShouldAcceptPngAndExposeMetadata() throws Exception {
AgentImageProcessor processor = new AgentImageProcessor(new AgentMediaProperties());
AgentImageData image = processor.process(file("sample.png", "image/png", imageBytes("png", 3, 2)));
Assert.assertEquals("image/png", image.mimeType());
Assert.assertEquals("png", image.extension());
Assert.assertEquals(3, image.width());
Assert.assertEquals(2, image.height());
Assert.assertEquals(64, image.sha256().length());
}
/**
* 验证 GIF 首帧会转换为模型兼容的 PNG。
*
* @throws Exception 图片生成失败时抛出
*/
@Test
public void processShouldNormalizeGifToPng() throws Exception {
AgentImageProcessor processor = new AgentImageProcessor(new AgentMediaProperties());
AgentImageData image = processor.process(file("sample.gif", "image/gif", imageBytes("gif", 4, 3)));
Assert.assertEquals("image/png", image.mimeType());
Assert.assertEquals("png", image.extension());
Assert.assertEquals(4, image.width());
Assert.assertEquals(3, image.height());
Assert.assertArrayEquals(new byte[]{(byte) 0x89, 0x50, 0x4e, 0x47},
java.util.Arrays.copyOf(image.bytes(), 4));
}
/**
* 验证扩展名无法绕过真实图片内容校验。
*/
@Test
public void processShouldRejectUnsupportedContent() {
AgentImageProcessor processor = new AgentImageProcessor(new AgentMediaProperties());
ResponseStatusException error = Assert.assertThrows(ResponseStatusException.class,
() -> processor.process(file("fake.png", "image/png", "not-an-image".getBytes())));
Assert.assertEquals(400, error.getStatusCode().value());
}
/**
* 验证像素上限会在图片解码前置阶段生效。
*
* @throws Exception 图片生成失败时抛出
*/
@Test
public void processShouldRejectImageOverPixelLimit() throws Exception {
AgentMediaProperties properties = new AgentMediaProperties();
properties.setMaxImagePixels(3);
AgentImageProcessor processor = new AgentImageProcessor(properties);
ResponseStatusException error = Assert.assertThrows(ResponseStatusException.class,
() -> processor.process(file("large.png", "image/png", imageBytes("png", 2, 2))));
Assert.assertEquals(400, error.getStatusCode().value());
Assert.assertTrue(error.getReason().contains("像素过大"));
}
private byte[] imageBytes(String format, int width, int height) throws IOException {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream output = new ByteArrayOutputStream();
Assert.assertTrue(ImageIO.write(image, format, output));
return output.toByteArray();
}
private MultipartFile file(String name, String contentType, byte[] bytes) {
return new ByteArrayMultipartFile(name, contentType, bytes);
}
/**
* 无磁盘依赖的测试 MultipartFile。
*/
private static final class ByteArrayMultipartFile implements MultipartFile {
private final String name;
private final String contentType;
private final byte[] bytes;
private ByteArrayMultipartFile(String name, String contentType, byte[] bytes) {
this.name = name;
this.contentType = contentType;
this.bytes = bytes;
}
@Override
public String getName() { return "file"; }
@Override
public String getOriginalFilename() { return name; }
@Override
public String getContentType() { return contentType; }
@Override
public boolean isEmpty() { return bytes.length == 0; }
@Override
public long getSize() { return bytes.length; }
@Override
public byte[] getBytes() { return bytes.clone(); }
@Override
public InputStream getInputStream() { return new ByteArrayInputStream(bytes); }
@Override
public void transferTo(File destination) throws IOException {
java.nio.file.Files.write(destination.toPath(), bytes);
}
}
}

View File

@@ -0,0 +1,103 @@
package tech.easyflow.agent.runtime.media;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import tech.easyflow.agent.config.AgentMediaProperties;
import tech.easyflow.common.entity.LoginAccount;
import java.math.BigInteger;
import java.time.Instant;
import java.util.List;
/**
* {@link AgentMediaService} 正式图片绑定测试。
*/
public class AgentMediaServiceTest {
private AgentMediaObjectStorage objectStorage;
private RedisAgentMediaUploadStore uploadStore;
private AgentMediaService service;
private LoginAccount account;
private AgentMediaUploadRecord upload;
/**
* 初始化媒体服务测试依赖。
*/
@Before
public void setUp() {
objectStorage = Mockito.mock(AgentMediaObjectStorage.class);
uploadStore = Mockito.mock(RedisAgentMediaUploadStore.class);
service = new AgentMediaService(Mockito.mock(AgentImageProcessor.class), objectStorage,
uploadStore, new AgentMediaProperties());
account = new LoginAccount();
account.setTenantId(BigInteger.valueOf(2));
account.setId(BigInteger.valueOf(7));
upload = buildUpload();
Mockito.doAnswer(invocation -> {
upload.setFormalReference(invocation.getArgument(1));
return null;
}).when(uploadStore).markFormalBinding(Mockito.same(upload), Mockito.anyString());
}
/**
* 验证重复绑定同一上传凭据时复用首次正式引用。
*/
@Test
public void bindFormalShouldReuseExistingBinding() {
List<AgentBoundMedia> first = service.bindFormal(List.of(upload), "101", "201", account);
List<AgentBoundMedia> second = service.bindFormal(List.of(upload), "101", "202", account);
Assert.assertEquals("formal:101:201:0:png", first.get(0).reference());
Assert.assertEquals(first.get(0).reference(), second.get(0).reference());
Assert.assertEquals("/api/v1/agent/media/content?reference=formal:101:201:0:png",
second.get(0).payload().get("previewUrl"));
Mockito.verify(objectStorage, Mockito.times(1))
.copy("temp/2/7/upload-1.png", "formal/2/7/101/201/0.png");
Mockito.verify(uploadStore, Mockito.times(1))
.markFormalBinding(upload, "formal:101:201:0:png");
Mockito.verify(objectStorage, Mockito.never()).delete(Mockito.anyString());
Mockito.verify(uploadStore, Mockito.never()).delete(Mockito.any());
}
/**
* 验证已绑定图片不能被复用到其他正式会话。
*/
@Test
public void bindFormalShouldRejectAnotherSession() {
upload.setFormalReference("formal:101:201:0:png");
ResponseStatusException error = Assert.assertThrows(ResponseStatusException.class,
() -> service.bindFormal(List.of(upload), "102", "202", account));
Assert.assertEquals(HttpStatus.CONFLICT, error.getStatusCode());
Mockito.verify(objectStorage, Mockito.never()).copy(Mockito.anyString(), Mockito.anyString());
}
/**
* 构造一条有效的临时图片凭据。
*
* @return 临时图片凭据
*/
private AgentMediaUploadRecord buildUpload() {
AgentMediaUploadRecord record = new AgentMediaUploadRecord();
record.setUploadId("upload-1");
record.setTenantId("2");
record.setUserId("7");
record.setMode(AgentMediaService.MODE_FORMAL);
record.setAgentId("9");
record.setSessionId("101");
record.setObjectKey("temp/2/7/upload-1.png");
record.setOriginalName("sample.png");
record.setMimeType("image/png");
record.setExtension("png");
record.setSize(128L);
record.setWidth(32);
record.setHeight(32);
record.setExpiresAt(Instant.now().plusSeconds(3600));
return record;
}
}