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

@@ -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;
}
}