feat: 完善智能体图片聊天与会话恢复
- 增加私有图片上传、绑定、历史回显与生命周期清理 - 支持输入草稿恢复、图片交互和模型图片能力约束 - 修复旧脏会话幂等删除与前端会话恢复
This commit is contained in:
@@ -4,10 +4,15 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import com.mybatisflex.core.paginate.Page;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
@@ -19,6 +24,12 @@ import tech.easyflow.agent.publish.AgentPublishAppService;
|
||||
import tech.easyflow.agent.runtime.AgentChatRequest;
|
||||
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
||||
import tech.easyflow.agent.runtime.AgentRunService;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraft;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerSession;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaUploadView;
|
||||
import com.easyagents.agent.runtime.media.AgentMediaResource;
|
||||
import tech.easyflow.agent.service.AgentApprovalStateService;
|
||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
@@ -28,6 +39,8 @@ import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.log.annotation.LogReporterDisabled;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.enums.ResourceAction;
|
||||
@@ -66,6 +79,10 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
private AgentApprovalStateService agentApprovalStateService;
|
||||
@Resource
|
||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||
@Resource
|
||||
private AgentMediaService agentMediaService;
|
||||
@Resource
|
||||
private AgentComposerDraftService agentComposerDraftService;
|
||||
|
||||
/**
|
||||
* 创建 Agent 控制器。
|
||||
@@ -162,6 +179,113 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
||||
return agentRunService.chatDraft(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传一张 Agent 聊天临时图片。
|
||||
*
|
||||
* @param file 图片文件
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @return 上传结果
|
||||
*/
|
||||
@PostMapping(value = "/media/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public Result<AgentMediaUploadView> uploadMedia(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("mode") String mode,
|
||||
@RequestParam("agentId") String agentId,
|
||||
@RequestParam("sessionId") String sessionId) {
|
||||
return Result.ok(agentMediaService.upload(file, mode, agentId, sessionId, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前账号尚未发送的临时图片。
|
||||
*
|
||||
* @param uploadId 上传 ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/media/delete")
|
||||
public Result<Void> deleteMedia(@JsonBody(value = "uploadId", required = true) String uploadId) {
|
||||
agentMediaService.deleteUpload(uploadId, SaTokenUtil.getLoginAccount());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过鉴权代理读取 Agent 私有聊天图片。
|
||||
*
|
||||
* @param reference 稳定图片引用
|
||||
* @return 图片响应
|
||||
*/
|
||||
@GetMapping("/media/content")
|
||||
@LogReporterDisabled
|
||||
public ResponseEntity<byte[]> mediaContent(@RequestParam("reference") String reference) {
|
||||
AgentMediaResource resource = agentMediaService.load(reference, SaTokenUtil.getLoginAccount());
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CACHE_CONTROL, "private, no-store")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "inline")
|
||||
.contentType(MediaType.parseMediaType(resource.mimeType()))
|
||||
.contentLength(resource.bytes().length)
|
||||
.body(resource.bytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* 为输入框预分配稳定会话 ID。
|
||||
*
|
||||
* @param mode 聊天模式
|
||||
* @return 会话信息
|
||||
*/
|
||||
@PostMapping("/composer/session")
|
||||
public Result<AgentComposerSession> allocateComposerSession(
|
||||
@JsonBody(value = "mode", required = true) String mode) {
|
||||
return Result.ok(agentComposerDraftService.allocateSession(mode));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Agent 输入草稿。
|
||||
*
|
||||
* @param draft 输入草稿
|
||||
* @return 保存后的草稿
|
||||
*/
|
||||
@PostMapping("/composer/draft/persist")
|
||||
public Result<AgentComposerDraft> saveComposerDraft(@JsonBody AgentComposerDraft draft) {
|
||||
return Result.ok(agentComposerDraftService.save(draft, SaTokenUtil.getLoginAccount()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话或最近未发送会话的输入草稿。
|
||||
*
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID,可为空
|
||||
* @return 输入草稿
|
||||
*/
|
||||
@GetMapping("/composer/draft")
|
||||
public Result<AgentComposerDraft> getComposerDraft(@RequestParam("mode") String mode,
|
||||
@RequestParam("agentId") String agentId,
|
||||
@RequestParam(value = "sessionId", required = false) String sessionId) {
|
||||
return Result.ok(agentComposerDraftService.get(mode, agentId, sessionId, SaTokenUtil.getLoginAccount())
|
||||
.orElse(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除已发送或主动清空的输入草稿。
|
||||
*
|
||||
* @param mode 聊天模式
|
||||
* @param agentId Agent ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param imageUploadIds 调用方仍持有的上传 ID
|
||||
* @param deleteUploads 是否同时删除临时图片
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping("/composer/draft/delete")
|
||||
public Result<Void> deleteComposerDraft(@JsonBody(value = "mode", required = true) String mode,
|
||||
@JsonBody(value = "agentId", required = true) String agentId,
|
||||
@JsonBody(value = "sessionId", required = true) String sessionId,
|
||||
@JsonBody(value = "imageUploadIds") List<String> imageUploadIds,
|
||||
@JsonBody(value = "deleteUploads") Boolean deleteUploads) {
|
||||
agentComposerDraftService.delete(mode, agentId, sessionId, imageUploadIds,
|
||||
!Boolean.FALSE.equals(deleteUploads), SaTokenUtil.getLoginAccount());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理 Agent 草稿试运行会话。
|
||||
*
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.admin.dto.chatworkspace.*;
|
||||
import tech.easyflow.agent.entity.Agent;
|
||||
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
@@ -39,6 +41,8 @@ public class AgentSessionService {
|
||||
private final DocumentCollectionService documentCollectionService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
|
||||
private final AgentMediaService agentMediaService;
|
||||
private final AgentComposerDraftService agentComposerDraftService;
|
||||
private final ChatJsonSupport chatJsonSupport;
|
||||
|
||||
/**
|
||||
@@ -50,6 +54,8 @@ public class AgentSessionService {
|
||||
* @param documentCollectionService 知识库服务
|
||||
* @param resourceAccessService 资源访问服务
|
||||
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
|
||||
* @param agentMediaService Agent 媒体服务
|
||||
* @param agentComposerDraftService Agent 输入草稿服务
|
||||
* @param chatJsonSupport 聊天 JSON 工具
|
||||
*/
|
||||
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
|
||||
@@ -58,6 +64,8 @@ public class AgentSessionService {
|
||||
DocumentCollectionService documentCollectionService,
|
||||
ResourceAccessService resourceAccessService,
|
||||
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
|
||||
AgentMediaService agentMediaService,
|
||||
AgentComposerDraftService agentComposerDraftService,
|
||||
ChatJsonSupport chatJsonSupport) {
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.chatSessionCommandService = chatSessionCommandService;
|
||||
@@ -65,6 +73,8 @@ public class AgentSessionService {
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.resourceAccessService = resourceAccessService;
|
||||
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
|
||||
this.agentMediaService = agentMediaService;
|
||||
this.agentComposerDraftService = agentComposerDraftService;
|
||||
this.chatJsonSupport = chatJsonSupport;
|
||||
}
|
||||
|
||||
@@ -186,21 +196,58 @@ public class AgentSessionService {
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
requireUserAgentSession(account, sessionId);
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||
// 上一次删除可能已写入删除标记但媒体清理失败,重试时继续清理当前用户目录。
|
||||
deleteComposerDraft(summary, account, sessionId);
|
||||
agentMediaService.deleteFormalSession(sessionId.toString(), account);
|
||||
return;
|
||||
}
|
||||
requireUserAgentSession(account, summary);
|
||||
agentRuntimeStateCleanupService.clearChatSession(sessionId, account.getId());
|
||||
chatSessionCommandService.deleteSession(sessionId, account.getId(), account.getId());
|
||||
deleteComposerDraft(summary, account, sessionId);
|
||||
agentMediaService.deleteFormalSession(sessionId.toString(), account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话对应的未发送草稿和临时图片。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param account 当前登录账号
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
private void deleteComposerDraft(ChatSessionSummary summary, LoginAccount account, BigInteger sessionId) {
|
||||
if (summary == null || summary.getAssistantId() == null) {
|
||||
return;
|
||||
}
|
||||
agentComposerDraftService.delete(AgentMediaService.MODE_FORMAL,
|
||||
summary.getAssistantId().toString(), sessionId.toString(), account);
|
||||
}
|
||||
|
||||
private ChatSessionSummary requireUserAgentSession(LoginAccount account, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())
|
||||
|| !ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||
throw new BusinessException("Agent 会话不存在");
|
||||
}
|
||||
requireUserAgentSession(account, summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验会话属于当前用户且类型为 Agent。
|
||||
*
|
||||
* @param account 当前登录账号
|
||||
* @param summary 会话摘要
|
||||
* @throws BusinessException 会话类型不匹配或不属于当前用户时抛出
|
||||
*/
|
||||
private void requireUserAgentSession(LoginAccount account, ChatSessionSummary summary) {
|
||||
if (!ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
||||
throw new BusinessException("Agent 会话不存在");
|
||||
}
|
||||
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
||||
throw new BusinessException("无权访问该 Agent 会话");
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package tech.easyflow.admin.service.agent;
|
||||
|
||||
import org.mockito.Mockito;
|
||||
import org.testng.Assert;
|
||||
import org.testng.annotations.BeforeMethod;
|
||||
import org.testng.annotations.Test;
|
||||
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
|
||||
import tech.easyflow.agent.runtime.composer.AgentComposerDraftService;
|
||||
import tech.easyflow.agent.runtime.media.AgentMediaService;
|
||||
import tech.easyflow.agent.service.AgentService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.service.ResourceAccessService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link AgentSessionService} 会话删除测试。
|
||||
*/
|
||||
public class AgentSessionServiceTest {
|
||||
|
||||
private static final BigInteger ACCOUNT_ID = BigInteger.valueOf(7);
|
||||
private static final BigInteger SESSION_ID = BigInteger.valueOf(101);
|
||||
|
||||
private ChatSessionQueryService chatSessionQueryService;
|
||||
private ChatSessionCommandService chatSessionCommandService;
|
||||
private AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
|
||||
private AgentMediaService agentMediaService;
|
||||
private AgentComposerDraftService agentComposerDraftService;
|
||||
private AgentSessionService service;
|
||||
private LoginAccount account;
|
||||
|
||||
/**
|
||||
* 初始化测试依赖。
|
||||
*/
|
||||
@BeforeMethod
|
||||
public void setUp() {
|
||||
chatSessionQueryService = mock(ChatSessionQueryService.class);
|
||||
chatSessionCommandService = mock(ChatSessionCommandService.class);
|
||||
agentRuntimeStateCleanupService = mock(AgentRuntimeStateCleanupService.class);
|
||||
agentMediaService = mock(AgentMediaService.class);
|
||||
agentComposerDraftService = mock(AgentComposerDraftService.class);
|
||||
service = new AgentSessionService(
|
||||
chatSessionQueryService,
|
||||
chatSessionCommandService,
|
||||
mock(AgentService.class),
|
||||
mock(DocumentCollectionService.class),
|
||||
mock(ResourceAccessService.class),
|
||||
agentRuntimeStateCleanupService,
|
||||
agentMediaService,
|
||||
agentComposerDraftService,
|
||||
mock(ChatJsonSupport.class)
|
||||
);
|
||||
account = new LoginAccount();
|
||||
account.setId(ACCOUNT_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证正常删除会清理运行态、写入删除命令并删除媒体目录。
|
||||
*/
|
||||
@Test
|
||||
public void shouldDeleteActiveOwnedAgentSession() {
|
||||
when(chatSessionQueryService.getSessionSummary(SESSION_ID))
|
||||
.thenReturn(buildSession(ACCOUNT_ID, 0, "AGENT"));
|
||||
|
||||
service.deleteCurrentUserSession(account, SESSION_ID);
|
||||
|
||||
verify(agentRuntimeStateCleanupService).clearChatSession(SESSION_ID, ACCOUNT_ID);
|
||||
verify(chatSessionCommandService).deleteSession(SESSION_ID, ACCOUNT_ID, ACCOUNT_ID);
|
||||
verify(agentComposerDraftService).delete(AgentMediaService.MODE_FORMAL, "9", "101", account);
|
||||
verify(agentMediaService).deleteFormalSession(SESSION_ID.toString(), account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证会话已删除时重复请求仍会重试媒体目录清理并成功返回。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRetryMediaCleanupForDeletedSession() {
|
||||
when(chatSessionQueryService.getSessionSummary(SESSION_ID))
|
||||
.thenReturn(buildSession(ACCOUNT_ID, 1, "AGENT"));
|
||||
|
||||
service.deleteCurrentUserSession(account, SESSION_ID);
|
||||
|
||||
verify(agentRuntimeStateCleanupService, never()).clearChatSession(Mockito.any(), Mockito.any());
|
||||
verify(chatSessionCommandService, never()).deleteSession(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentComposerDraftService).delete(AgentMediaService.MODE_FORMAL, "9", "101", account);
|
||||
verify(agentMediaService).deleteFormalSession(SESSION_ID.toString(), account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证查询不到会话时删除保持幂等,并按当前用户目录重试媒体清理。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRetryMediaCleanupWhenSessionIsMissing() {
|
||||
when(chatSessionQueryService.getSessionSummary(SESSION_ID)).thenReturn(null);
|
||||
|
||||
service.deleteCurrentUserSession(account, SESSION_ID);
|
||||
|
||||
verify(agentRuntimeStateCleanupService, never()).clearChatSession(Mockito.any(), Mockito.any());
|
||||
verify(chatSessionCommandService, never()).deleteSession(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentComposerDraftService, never()).delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentMediaService).deleteFormalSession(SESSION_ID.toString(), account);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证活动会话属于其他用户时仍拒绝删除,且不执行任何清理。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRejectActiveSessionOwnedByAnotherUser() {
|
||||
when(chatSessionQueryService.getSessionSummary(SESSION_ID))
|
||||
.thenReturn(buildSession(BigInteger.valueOf(8), 0, "AGENT"));
|
||||
|
||||
BusinessException exception = Assert.expectThrows(
|
||||
BusinessException.class,
|
||||
() -> service.deleteCurrentUserSession(account, SESSION_ID)
|
||||
);
|
||||
|
||||
Assert.assertEquals(exception.getMessage(), "无权访问该 Agent 会话");
|
||||
verify(agentRuntimeStateCleanupService, never()).clearChatSession(Mockito.any(), Mockito.any());
|
||||
verify(chatSessionCommandService, never()).deleteSession(Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentComposerDraftService, never()).delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any());
|
||||
verify(agentMediaService, never()).deleteFormalSession(Mockito.any(), Mockito.any());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造会话摘要。
|
||||
*
|
||||
* @param userId 用户 ID
|
||||
* @param isDeleted 删除标记
|
||||
* @param assistantCode 助手类型
|
||||
* @return 会话摘要
|
||||
*/
|
||||
private ChatSessionSummary buildSession(BigInteger userId, Integer isDeleted, String assistantCode) {
|
||||
ChatSessionSummary summary = new ChatSessionSummary();
|
||||
summary.setId(SESSION_ID);
|
||||
summary.setUserId(userId);
|
||||
summary.setIsDeleted(isDeleted);
|
||||
summary.setAssistantCode(assistantCode);
|
||||
summary.setAssistantId(BigInteger.valueOf(9));
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user