发布 v1.10 #5
@@ -72,6 +72,8 @@ services:
|
|||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
MINIO_ROOT_USER: easyflowadmin
|
MINIO_ROOT_USER: easyflowadmin
|
||||||
MINIO_ROOT_PASSWORD: easyflowadmin123
|
MINIO_ROOT_PASSWORD: easyflowadmin123
|
||||||
|
MINIO_API_STALE_UPLOADS_EXPIRY: 24h
|
||||||
|
MINIO_API_STALE_UPLOADS_CLEANUP_INTERVAL: 6h
|
||||||
ports:
|
ports:
|
||||||
- "9000:9000"
|
- "9000:9000"
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
@@ -88,7 +90,7 @@ services:
|
|||||||
MINIO_ROOT_USER: easyflowadmin
|
MINIO_ROOT_USER: easyflowadmin
|
||||||
MINIO_ROOT_PASSWORD: easyflowadmin123
|
MINIO_ROOT_PASSWORD: easyflowadmin123
|
||||||
MINIO_ENDPOINT: http://minio:9000
|
MINIO_ENDPOINT: http://minio:9000
|
||||||
MINIO_BUCKETS: easyflow,milvus
|
MINIO_BUCKETS: easyflow,milvus,easyflow-agent-media
|
||||||
MINIO_PUBLIC_BUCKETS: easyflow
|
MINIO_PUBLIC_BUCKETS: easyflow
|
||||||
MINIO_ALIAS: local
|
MINIO_ALIAS: local
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
|||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
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.RequestContextHolder;
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
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.AgentChatRequest;
|
||||||
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
import tech.easyflow.agent.runtime.AgentDraftChatRequest;
|
||||||
import tech.easyflow.agent.runtime.AgentRunService;
|
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.AgentApprovalStateService;
|
||||||
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
import tech.easyflow.agent.service.AgentKnowledgeBindingService;
|
||||||
import tech.easyflow.agent.service.AgentService;
|
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.domain.Result;
|
||||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
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.entity.vo.RoleCategoryAccessSnapshot;
|
||||||
import tech.easyflow.system.enums.CategoryResourceType;
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
import tech.easyflow.system.enums.ResourceAction;
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
@@ -66,6 +79,10 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
private AgentApprovalStateService agentApprovalStateService;
|
private AgentApprovalStateService agentApprovalStateService;
|
||||||
@Resource
|
@Resource
|
||||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||||
|
@Resource
|
||||||
|
private AgentMediaService agentMediaService;
|
||||||
|
@Resource
|
||||||
|
private AgentComposerDraftService agentComposerDraftService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Agent 控制器。
|
* 创建 Agent 控制器。
|
||||||
@@ -162,6 +179,113 @@ public class AgentController extends BaseCurdController<AgentService, Agent> {
|
|||||||
return agentRunService.chatDraft(request);
|
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 草稿试运行会话。
|
* 清理 Agent 草稿试运行会话。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import org.springframework.util.StringUtils;
|
|||||||
import tech.easyflow.admin.dto.chatworkspace.*;
|
import tech.easyflow.admin.dto.chatworkspace.*;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.runtime.AgentRuntimeStateCleanupService;
|
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.agent.service.AgentService;
|
||||||
import tech.easyflow.ai.entity.DocumentCollection;
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
@@ -39,6 +41,8 @@ public class AgentSessionService {
|
|||||||
private final DocumentCollectionService documentCollectionService;
|
private final DocumentCollectionService documentCollectionService;
|
||||||
private final ResourceAccessService resourceAccessService;
|
private final ResourceAccessService resourceAccessService;
|
||||||
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
|
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
|
||||||
|
private final AgentMediaService agentMediaService;
|
||||||
|
private final AgentComposerDraftService agentComposerDraftService;
|
||||||
private final ChatJsonSupport chatJsonSupport;
|
private final ChatJsonSupport chatJsonSupport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +54,8 @@ public class AgentSessionService {
|
|||||||
* @param documentCollectionService 知识库服务
|
* @param documentCollectionService 知识库服务
|
||||||
* @param resourceAccessService 资源访问服务
|
* @param resourceAccessService 资源访问服务
|
||||||
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
|
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
|
||||||
|
* @param agentMediaService Agent 媒体服务
|
||||||
|
* @param agentComposerDraftService Agent 输入草稿服务
|
||||||
* @param chatJsonSupport 聊天 JSON 工具
|
* @param chatJsonSupport 聊天 JSON 工具
|
||||||
*/
|
*/
|
||||||
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
|
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
|
||||||
@@ -58,6 +64,8 @@ public class AgentSessionService {
|
|||||||
DocumentCollectionService documentCollectionService,
|
DocumentCollectionService documentCollectionService,
|
||||||
ResourceAccessService resourceAccessService,
|
ResourceAccessService resourceAccessService,
|
||||||
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
|
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
|
||||||
|
AgentMediaService agentMediaService,
|
||||||
|
AgentComposerDraftService agentComposerDraftService,
|
||||||
ChatJsonSupport chatJsonSupport) {
|
ChatJsonSupport chatJsonSupport) {
|
||||||
this.chatSessionQueryService = chatSessionQueryService;
|
this.chatSessionQueryService = chatSessionQueryService;
|
||||||
this.chatSessionCommandService = chatSessionCommandService;
|
this.chatSessionCommandService = chatSessionCommandService;
|
||||||
@@ -65,6 +73,8 @@ public class AgentSessionService {
|
|||||||
this.documentCollectionService = documentCollectionService;
|
this.documentCollectionService = documentCollectionService;
|
||||||
this.resourceAccessService = resourceAccessService;
|
this.resourceAccessService = resourceAccessService;
|
||||||
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
|
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
|
||||||
|
this.agentMediaService = agentMediaService;
|
||||||
|
this.agentComposerDraftService = agentComposerDraftService;
|
||||||
this.chatJsonSupport = chatJsonSupport;
|
this.chatJsonSupport = chatJsonSupport;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,21 +196,58 @@ public class AgentSessionService {
|
|||||||
* @param sessionId 会话 ID
|
* @param sessionId 会话 ID
|
||||||
*/
|
*/
|
||||||
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
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());
|
agentRuntimeStateCleanupService.clearChatSession(sessionId, account.getId());
|
||||||
chatSessionCommandService.deleteSession(sessionId, account.getId(), 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) {
|
private ChatSessionSummary requireUserAgentSession(LoginAccount account, BigInteger sessionId) {
|
||||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())
|
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||||
|| !ASSISTANT_CODE.equals(summary.getAssistantCode())) {
|
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 会话不存在");
|
throw new BusinessException("Agent 会话不存在");
|
||||||
}
|
}
|
||||||
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
||||||
throw new BusinessException("无权访问该 Agent 会话");
|
throw new BusinessException("无权访问该 Agent 会话");
|
||||||
}
|
}
|
||||||
return summary;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<BigInteger, AgentAvailability> resolveAgentAvailability(List<ChatSessionSummary> sessions) {
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ public enum ChatType {
|
|||||||
STATUS,
|
STATUS,
|
||||||
CITATIONS,
|
CITATIONS,
|
||||||
SESSION_CREATED,
|
SESSION_CREATED,
|
||||||
|
INPUT_ACCEPTED,
|
||||||
ERROR,
|
ERROR,
|
||||||
FORM_REQUEST,
|
FORM_REQUEST,
|
||||||
FORM_CANCEL,
|
FORM_CANCEL,
|
||||||
|
|||||||
@@ -49,6 +49,14 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-satoken</artifactId>
|
<artifactId>easyflow-common-satoken</artifactId>
|
||||||
</dependency>
|
</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>
|
<dependency>
|
||||||
<groupId>com.mybatis-flex</groupId>
|
<groupId>com.mybatis-flex</groupId>
|
||||||
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
<artifactId>mybatis-flex-spring-boot3-starter</artifactId>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import org.mybatis.spring.annotation.MapperScan;
|
|||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.ComponentScan;
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 模块自动配置。
|
* Agent 模块自动配置。
|
||||||
@@ -11,6 +12,7 @@ import org.springframework.context.annotation.ComponentScan;
|
|||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
@MapperScan("tech.easyflow.agent.mapper")
|
@MapperScan("tech.easyflow.agent.mapper")
|
||||||
@ComponentScan("tech.easyflow.agent")
|
@ComponentScan("tech.easyflow.agent")
|
||||||
@EnableConfigurationProperties(AgentRuntimeProperties.class)
|
@EnableScheduling
|
||||||
|
@EnableConfigurationProperties({AgentRuntimeProperties.class, AgentMediaProperties.class})
|
||||||
public class AgentModuleConfig {
|
public class AgentModuleConfig {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class AgentChatRequest {
|
|||||||
private BigInteger agentId;
|
private BigInteger agentId;
|
||||||
private BigInteger sessionId;
|
private BigInteger sessionId;
|
||||||
private String prompt;
|
private String prompt;
|
||||||
|
private List<String> imageUploadIds = new ArrayList<>();
|
||||||
private List<AgentChatCapability> capabilities = new ArrayList<>();
|
private List<AgentChatCapability> capabilities = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,22 @@ public class AgentChatRequest {
|
|||||||
*/
|
*/
|
||||||
public void setPrompt(String prompt) { this.prompt = prompt; }
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取本次聊天启用的临时能力。
|
* 获取本次聊天启用的临时能力。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
|||||||
import tech.easyflow.agent.entity.AgentToolBinding;
|
import tech.easyflow.agent.entity.AgentToolBinding;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 草稿态纯文本试用请求。
|
* Agent 草稿态纯文本试用请求。
|
||||||
@@ -16,6 +17,7 @@ public class AgentDraftChatRequest {
|
|||||||
private List<AgentKnowledgeBinding> knowledgeBindings;
|
private List<AgentKnowledgeBinding> knowledgeBindings;
|
||||||
private String sessionId;
|
private String sessionId;
|
||||||
private String prompt;
|
private String prompt;
|
||||||
|
private List<String> imageUploadIds = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Agent 草稿快照。
|
* 获取 Agent 草稿快照。
|
||||||
@@ -106,4 +108,22 @@ public class AgentDraftChatRequest {
|
|||||||
public void setPrompt(String prompt) {
|
public void setPrompt(String prompt) {
|
||||||
this.prompt = 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
|
|||||||
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
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.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||||
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
|
import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
|
||||||
import org.slf4j.Logger;
|
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.hitl.AgentHitlPendingService;
|
||||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
||||||
import tech.easyflow.agent.runtime.session.EasyFlowAgentSessionStore;
|
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.agent.service.AgentService;
|
||||||
import tech.easyflow.ai.entity.DocumentCollection;
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
import tech.easyflow.ai.entity.Mcp;
|
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.rag.KnowledgeRetrievalModes;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
import tech.easyflow.ai.service.McpService;
|
import tech.easyflow.ai.service.McpService;
|
||||||
|
import tech.easyflow.ai.service.ModelService;
|
||||||
import tech.easyflow.ai.service.PluginItemService;
|
import tech.easyflow.ai.service.PluginItemService;
|
||||||
import tech.easyflow.ai.service.WorkflowService;
|
import tech.easyflow.ai.service.WorkflowService;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
@@ -56,6 +62,8 @@ import javax.annotation.Resource;
|
|||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 管理端运行服务。
|
* Agent 管理端运行服务。
|
||||||
@@ -107,6 +115,10 @@ public class AgentRunService {
|
|||||||
private McpService mcpService;
|
private McpService mcpService;
|
||||||
@Resource
|
@Resource
|
||||||
private DocumentCollectionService documentCollectionService;
|
private DocumentCollectionService documentCollectionService;
|
||||||
|
@Resource
|
||||||
|
private ModelService modelService;
|
||||||
|
@Resource
|
||||||
|
private AgentMediaService agentMediaService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动 Agent 聊天。
|
* 启动 Agent 聊天。
|
||||||
@@ -134,6 +146,10 @@ public class AgentRunService {
|
|||||||
AgentChatCapabilityService.AgentChatCapabilityResolution capabilityResolution =
|
AgentChatCapabilityService.AgentChatCapabilityResolution capabilityResolution =
|
||||||
agentChatCapabilityService.apply(agent, chatRequest.getCapabilities(), account);
|
agentChatCapabilityService.apply(agent, chatRequest.getCapabilities(), account);
|
||||||
agent = capabilityResolution.agent();
|
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 requestId = UUID.randomUUID().toString();
|
||||||
String traceId = UUID.randomUUID().toString();
|
String traceId = UUID.randomUUID().toString();
|
||||||
// 组建会话上下文必要信息
|
// 组建会话上下文必要信息
|
||||||
@@ -143,7 +159,7 @@ public class AgentRunService {
|
|||||||
}
|
}
|
||||||
applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession);
|
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);
|
ASSISTANT_CODE, chatContext, true, easyFlowAgentSessionStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,16 +179,22 @@ public class AgentRunService {
|
|||||||
if (runtimeSessionId == null || runtimeSessionId.isBlank()) {
|
if (runtimeSessionId == null || runtimeSessionId.isBlank()) {
|
||||||
runtimeSessionId = "agent-draft-" + new SnowFlakeIDKeyGenerator().nextId();
|
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());
|
BigInteger chatSessionId = BigInteger.valueOf(new SnowFlakeIDKeyGenerator().nextId());
|
||||||
String requestId = UUID.randomUUID().toString();
|
String requestId = UUID.randomUUID().toString();
|
||||||
String traceId = UUID.randomUUID().toString();
|
String traceId = UUID.randomUUID().toString();
|
||||||
ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, chatSessionId, draftRequest.getPrompt(), account, DRAFT_ASSISTANT_CODE);
|
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);
|
DRAFT_ASSISTANT_CODE, chatContext, false, draftAgentSessionStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
private SseEmitter run(Agent agent,
|
private SseEmitter run(Agent agent,
|
||||||
String prompt,
|
String prompt,
|
||||||
|
List<AgentMediaUploadRecord> mediaUploads,
|
||||||
|
LoginAccount account,
|
||||||
String requestId,
|
String requestId,
|
||||||
String traceId,
|
String traceId,
|
||||||
String runtimeSessionId,
|
String runtimeSessionId,
|
||||||
@@ -185,6 +207,7 @@ public class AgentRunService {
|
|||||||
AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId);
|
AgentRunLock.Handle lockHandle = acquireRunLock(agent, runtimeSessionId);
|
||||||
boolean submitted = false;
|
boolean submitted = false;
|
||||||
try {
|
try {
|
||||||
|
List<AgentBoundMedia> boundMedia;
|
||||||
if (persistChatlog) {
|
if (persistChatlog) {
|
||||||
// 持久化会话初始信息
|
// 持久化会话初始信息
|
||||||
chatRuntimeManager.prepareSession(chatContext);
|
chatRuntimeManager.prepareSession(chatContext);
|
||||||
@@ -192,9 +215,23 @@ public class AgentRunService {
|
|||||||
chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开,Agent 运行已取消"));
|
chatRuntimeManager.recordFailure(chatContext, new BusinessException("客户端连接已断开,Agent 运行已取消"));
|
||||||
return chatSseEmitter.getEmitter();
|
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));
|
assistantCode, chatContext, chatSseEmitter, persistChatlog, runtimeSessionStore, lockHandle));
|
||||||
submitted = true;
|
submitted = true;
|
||||||
return chatSseEmitter.getEmitter();
|
return chatSseEmitter.getEmitter();
|
||||||
@@ -342,7 +379,8 @@ public class AgentRunService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void startRuntime(Agent agent,
|
private void startRuntime(Agent agent,
|
||||||
String prompt,
|
AgentMessage userMessage,
|
||||||
|
LoginAccount account,
|
||||||
String requestId,
|
String requestId,
|
||||||
String traceId,
|
String traceId,
|
||||||
String runtimeSessionId,
|
String runtimeSessionId,
|
||||||
@@ -374,6 +412,7 @@ public class AgentRunService {
|
|||||||
request.setToolInvokers(bundle.getToolInvokers());
|
request.setToolInvokers(bundle.getToolInvokers());
|
||||||
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
|
request.setKnowledgeRetrievers(bundle.getKnowledgeRetrievers());
|
||||||
request.setSessionStore(runtimeSessionStore);
|
request.setSessionStore(runtimeSessionStore);
|
||||||
|
request.setMediaResolver(agentMediaService.runtimeResolver(account));
|
||||||
request.getMetadata().put("assistantCode", assistantCode);
|
request.getMetadata().put("assistantCode", assistantCode);
|
||||||
runtime.init(request);
|
runtime.init(request);
|
||||||
// 注册会话运行时管理
|
// 注册会话运行时管理
|
||||||
@@ -409,7 +448,7 @@ public class AgentRunService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
agentRunRegistry.bindSubscription(requestId,
|
agentRunRegistry.bindSubscription(requestId,
|
||||||
runtime.stream(AgentMessage.text(AgentMessageRole.USER, prompt)).subscribe(
|
runtime.stream(userMessage).subscribe(
|
||||||
runContext.eventConsumer(),
|
runContext.eventConsumer(),
|
||||||
runContext.errorConsumer(),
|
runContext.errorConsumer(),
|
||||||
runContext.completionHandler()
|
runContext.completionHandler()
|
||||||
@@ -1012,8 +1051,8 @@ public class AgentRunService {
|
|||||||
* @return 最长 200 字符的会话标题
|
* @return 最长 200 字符的会话标题
|
||||||
*/
|
*/
|
||||||
private String toSessionTitle(String prompt) {
|
private String toSessionTitle(String prompt) {
|
||||||
if (prompt == null) {
|
if (prompt == null || prompt.isBlank()) {
|
||||||
return null;
|
return "图片对话";
|
||||||
}
|
}
|
||||||
return prompt.length() > 200 ? prompt.substring(0, 200) : prompt;
|
return prompt.length() > 200 ? prompt.substring(0, 200) : prompt;
|
||||||
}
|
}
|
||||||
@@ -1028,17 +1067,43 @@ public class AgentRunService {
|
|||||||
return context;
|
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();
|
ChatRuntimeMessage message = new ChatRuntimeMessage();
|
||||||
|
message.setMessageId(messageId);
|
||||||
message.setRole("user");
|
message.setRole("user");
|
||||||
message.setContentType("TEXT");
|
message.setContentType(media == null || media.isEmpty() ? "TEXT" : "MULTIMODAL");
|
||||||
message.setContentText(prompt);
|
message.setContentText(prompt);
|
||||||
|
if (media != null && !media.isEmpty()) {
|
||||||
|
message.getContentPayload().put("images", media.stream().map(AgentBoundMedia::payload).toList());
|
||||||
|
}
|
||||||
message.setCreatedAt(new Date());
|
message.setCreatedAt(new Date());
|
||||||
message.setSenderId(context.getUserId());
|
message.setSenderId(context.getUserId());
|
||||||
message.setSenderName(context.getUserName());
|
message.setSenderName(context.getUserName());
|
||||||
return message;
|
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) {
|
private ChatRuntimeMessage buildAssistantRuntimeMessage(ChatRuntimeContext context, String content) {
|
||||||
return buildAssistantRuntimeMessage(context, content, new ChatAssistantAccumulator(), List.of());
|
return buildAssistantRuntimeMessage(context, content, new ChatAssistantAccumulator(), List.of());
|
||||||
}
|
}
|
||||||
@@ -1092,11 +1157,29 @@ public class AgentRunService {
|
|||||||
Map.of("sessionId", sessionId.toString()));
|
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) {
|
private void validateChatRequest(AgentChatRequest request) {
|
||||||
if (request == null || request.getAgentId() == null) {
|
if (request == null || request.getAgentId() == null) {
|
||||||
throw new BusinessException("Agent ID 不能为空");
|
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 输入不能为空");
|
throw new BusinessException("Agent 输入不能为空");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1108,11 +1191,24 @@ public class AgentRunService {
|
|||||||
if (request.getAgent().getModelId() == null) {
|
if (request.getAgent().getModelId() == null) {
|
||||||
throw new BusinessException("Agent 模型不能为空");
|
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 输入不能为空");
|
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() {
|
private LoginAccount requireCurrentLoginAccount() {
|
||||||
try {
|
try {
|
||||||
return SaTokenUtil.getLoginAccount();
|
return SaTokenUtil.getLoginAccount();
|
||||||
|
|||||||
@@ -112,6 +112,8 @@ public class AgentRuntimeCompiler {
|
|||||||
spec.setBaseUrl(stringValue(config, "baseUrl", model.getEndpoint()));
|
spec.setBaseUrl(stringValue(config, "baseUrl", model.getEndpoint()));
|
||||||
spec.setEndpointPath(stringValue(config, "endpointPath", model.getRequestPath()));
|
spec.setEndpointPath(stringValue(config, "endpointPath", model.getRequestPath()));
|
||||||
spec.setApiKey(stringValue(config, "apiKey", model.getApiKey()));
|
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());
|
spec.getMetadata().put("modelId", model.getId());
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package tech.easyflow.agent.runtime.composer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入框使用的预分配会话标识。
|
||||||
|
*
|
||||||
|
* @param mode 聊天模式
|
||||||
|
* @param sessionId 会话 ID
|
||||||
|
*/
|
||||||
|
public record AgentComposerSession(String mode, String sessionId) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) { }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,6 +249,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
|
|||||||
summary.put("title", model.getTitle());
|
summary.put("title", model.getTitle());
|
||||||
summary.put("modelName", model.getModelName());
|
summary.put("modelName", model.getModelName());
|
||||||
summary.put("providerType", model.getModelProvider() == null ? null : model.getModelProvider().getProviderType());
|
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;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
|||||||
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
||||||
import org.junit.Assert;
|
import org.junit.Assert;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.mockito.Mockito;
|
||||||
import tech.easyflow.agent.entity.AgentHitlPending;
|
import tech.easyflow.agent.entity.AgentHitlPending;
|
||||||
import tech.easyflow.agent.entity.Agent;
|
import tech.easyflow.agent.entity.Agent;
|
||||||
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
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.event.AgentRunEventRecorder;
|
||||||
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
|
||||||
import tech.easyflow.agent.runtime.lock.AgentRunLock;
|
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.chatlog.domain.dto.ChatSessionSummary;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
@@ -399,6 +402,33 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
Assert.assertEquals(Boolean.TRUE, payload.get(0).get("faqCollection"));
|
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。
|
* 验证未保存草稿会生成临时 Agent ID,并把绑定指向该运行 ID。
|
||||||
*
|
*
|
||||||
@@ -463,12 +493,16 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
Agent agent = new Agent();
|
Agent agent = new Agent();
|
||||||
agent.setId(BigInteger.valueOf(100));
|
agent.setId(BigInteger.valueOf(100));
|
||||||
ChatRuntimeContext context = chatContext();
|
ChatRuntimeContext context = chatContext();
|
||||||
|
LoginAccount account = new LoginAccount();
|
||||||
|
account.setId(BigInteger.ONE);
|
||||||
|
account.setTenantId(BigInteger.ONE);
|
||||||
|
|
||||||
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
|
Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
|
||||||
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class,
|
new Class<?>[]{Agent.class, String.class, List.class, LoginAccount.class, String.class,
|
||||||
String.class, ChatRuntimeContext.class, boolean.class, AgentSessionStore.class},
|
String.class, String.class, String.class, ChatRuntimeContext.class, boolean.class,
|
||||||
agent, "你好", "request-lock", "trace-lock", "session-lock", "AGENT", context, true,
|
AgentSessionStore.class},
|
||||||
new InMemoryAgentSessionStore()));
|
agent, "你好", List.of(), account, "request-lock", "trace-lock", "session-lock", "AGENT",
|
||||||
|
context, true, new InMemoryAgentSessionStore()));
|
||||||
|
|
||||||
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
|
Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
|
||||||
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
|
Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
|
||||||
@@ -490,14 +524,21 @@ public class AgentRunServiceDraftAndHitlTest {
|
|||||||
setField(service, "agentRuntimeCompiler", compiler);
|
setField(service, "agentRuntimeCompiler", compiler);
|
||||||
setField(service, "agentRuntimeFactory", runtimeFactory);
|
setField(service, "agentRuntimeFactory", runtimeFactory);
|
||||||
setField(service, "agentRunRegistry", new AgentRunRegistry());
|
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 agent = new Agent();
|
||||||
agent.setId(BigInteger.valueOf(100));
|
agent.setId(BigInteger.valueOf(100));
|
||||||
|
LoginAccount account = new LoginAccount();
|
||||||
|
account.setId(BigInteger.ONE);
|
||||||
|
account.setTenantId(BigInteger.ONE);
|
||||||
invoke(service, "startRuntime",
|
invoke(service, "startRuntime",
|
||||||
new Class<?>[]{Agent.class, String.class, String.class, String.class, String.class, String.class,
|
new Class<?>[]{Agent.class, AgentMessage.class, LoginAccount.class, String.class, String.class,
|
||||||
ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class, AgentSessionStore.class,
|
String.class, String.class, ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class,
|
||||||
AgentRunLock.Handle.class},
|
AgentSessionStore.class, AgentRunLock.Handle.class},
|
||||||
agent, "你好", "request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
|
agent, AgentMessage.text(AgentMessageRole.USER, "你好"), account,
|
||||||
|
"request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
|
||||||
chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);
|
chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);
|
||||||
|
|
||||||
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
|
Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ public class Model extends ModelBase {
|
|||||||
}
|
}
|
||||||
ollamaChatConfig.setModel(checkAndGetModelName());
|
ollamaChatConfig.setModel(checkAndGetModelName());
|
||||||
ollamaChatConfig.setProvider(getModelProvider().getProviderName());
|
ollamaChatConfig.setProvider(getModelProvider().getProviderName());
|
||||||
|
ollamaChatConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
||||||
return new OllamaChatModel(ollamaChatConfig);
|
return new OllamaChatModel(ollamaChatConfig);
|
||||||
case "deepseek":
|
case "deepseek":
|
||||||
DeepseekConfig deepseekConfig = new DeepseekConfig();
|
DeepseekConfig deepseekConfig = new DeepseekConfig();
|
||||||
@@ -78,6 +79,7 @@ public class Model extends ModelBase {
|
|||||||
deepseekConfig.setSupportThinking(Boolean.TRUE);
|
deepseekConfig.setSupportThinking(Boolean.TRUE);
|
||||||
deepseekConfig.setThinkingProtocol("deepseek");
|
deepseekConfig.setThinkingProtocol("deepseek");
|
||||||
deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE);
|
deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE);
|
||||||
|
deepseekConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
||||||
if (getSupportToolMessage() != null) {
|
if (getSupportToolMessage() != null) {
|
||||||
deepseekConfig.setSupportToolMessage(getSupportToolMessage());
|
deepseekConfig.setSupportToolMessage(getSupportToolMessage());
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,7 @@ public class Model extends ModelBase {
|
|||||||
openAIChatConfig.setApiKey(checkAndGetApiKey());
|
openAIChatConfig.setApiKey(checkAndGetApiKey());
|
||||||
openAIChatConfig.setModel(checkAndGetModelName());
|
openAIChatConfig.setModel(checkAndGetModelName());
|
||||||
openAIChatConfig.setRequestPath(checkAndGetRequestPath());
|
openAIChatConfig.setRequestPath(checkAndGetRequestPath());
|
||||||
|
openAIChatConfig.setSupportImageBase64Only(getSupportImageB64Only());
|
||||||
if (getSupportToolMessage() != null) {
|
if (getSupportToolMessage() != null) {
|
||||||
openAIChatConfig.setSupportToolMessage(getSupportToolMessage());
|
openAIChatConfig.setSupportToolMessage(getSupportToolMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ easyflow:
|
|||||||
command-topic-prefix: easyflow:agent-runtime-command
|
command-topic-prefix: easyflow:agent-runtime-command
|
||||||
command-result-timeout: 5s
|
command-result-timeout: 5s
|
||||||
command-result-ttl: 5m
|
command-result-ttl: 5m
|
||||||
|
media:
|
||||||
|
platform: minio-agent-media
|
||||||
|
max-image-count: 5
|
||||||
|
max-image-bytes: 10485760
|
||||||
|
max-image-pixels: 40000000
|
||||||
|
upload-ttl: 24h
|
||||||
|
composer-draft-ttl: 24h
|
||||||
|
cleanup-interval: 10m
|
||||||
login:
|
login:
|
||||||
# 放行接口路径
|
# 放行接口路径
|
||||||
excludes: /api/v1/auth/**, /static/**, /userCenter/auth/**, /userCenter/public/**
|
excludes: /api/v1/auth/**, /static/**, /userCenter/auth/**, /userCenter/public/**
|
||||||
@@ -213,6 +221,13 @@ dromara:
|
|||||||
# minio 对象对外访问链接
|
# minio 对象对外访问链接
|
||||||
domain: http://127.0.0.1:39000/easyflow/
|
domain: http://127.0.0.1:39000/easyflow/
|
||||||
base-path: attachment
|
base-path: attachment
|
||||||
|
- platform: minio-agent-media
|
||||||
|
enable-storage: true
|
||||||
|
access-key: easyflowadmin
|
||||||
|
secret-key: easyflowadmin123
|
||||||
|
end-point: http://127.0.0.1:39000
|
||||||
|
bucket-name: easyflow-agent-media
|
||||||
|
base-path: agent-chat
|
||||||
|
|
||||||
# easy-agents 文档解析统一配置
|
# easy-agents 文档解析统一配置
|
||||||
easy-agents:
|
easy-agents:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AiChatMessage, AiToolApprovalPayload } from './types';
|
import type { AiChatMessage, AiToolApprovalPayload } from './types';
|
||||||
|
import type { ChatImageAttachment, ChatImageLoader } from '@easyflow/common-ui';
|
||||||
|
|
||||||
import { Close } from '@element-plus/icons-vue';
|
import { Close } from '@element-plus/icons-vue';
|
||||||
import { ElButton } from 'element-plus';
|
import { ElButton } from 'element-plus';
|
||||||
@@ -7,23 +8,34 @@ import { ElButton } from 'element-plus';
|
|||||||
import AiConversation from './AiConversation.vue';
|
import AiConversation from './AiConversation.vue';
|
||||||
import AiPromptInput from './AiPromptInput.vue';
|
import AiPromptInput from './AiPromptInput.vue';
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(
|
||||||
approvalLoading?: boolean;
|
defineProps<{
|
||||||
closable?: boolean;
|
approvalLoading?: boolean;
|
||||||
emptyText?: string;
|
closable?: boolean;
|
||||||
loading?: boolean;
|
emptyText?: string;
|
||||||
messages: AiChatMessage[];
|
loading?: boolean;
|
||||||
placeholder?: string;
|
images?: ChatImageAttachment[];
|
||||||
subtitle?: string;
|
imageEnabled?: boolean;
|
||||||
title: string;
|
imageLoader?: ChatImageLoader;
|
||||||
}>();
|
modelValue?: string;
|
||||||
|
messages: AiChatMessage[];
|
||||||
|
placeholder?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
title: string;
|
||||||
|
}>(),
|
||||||
|
{ imageEnabled: true },
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
addFiles: [files: File[]];
|
||||||
approve: [payload: AiToolApprovalPayload];
|
approve: [payload: AiToolApprovalPayload];
|
||||||
close: [];
|
close: [];
|
||||||
reject: [payload: AiToolApprovalPayload];
|
reject: [payload: AiToolApprovalPayload];
|
||||||
send: [text: string];
|
send: [text: string];
|
||||||
|
removeImage: [item: ChatImageAttachment];
|
||||||
|
retryImage: [item: ChatImageAttachment];
|
||||||
stop: [];
|
stop: [];
|
||||||
|
'update:modelValue': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
@@ -63,10 +75,18 @@ defineSlots<{
|
|||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
<AiPromptInput
|
<AiPromptInput
|
||||||
|
:model-value="modelValue"
|
||||||
|
:images="images"
|
||||||
|
:image-enabled="imageEnabled"
|
||||||
|
:image-loader="imageLoader"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
@send="emit('send', $event)"
|
@send="emit('send', $event)"
|
||||||
|
@add-files="emit('addFiles', $event)"
|
||||||
|
@remove-image="emit('removeImage', $event)"
|
||||||
|
@retry-image="emit('retryImage', $event)"
|
||||||
@stop="emit('stop')"
|
@stop="emit('stop')"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {mount} from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import {describe, expect, it} from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import AiPromptInput from './AiPromptInput.vue';
|
import AiPromptInput from './AiPromptInput.vue';
|
||||||
|
|
||||||
@@ -9,10 +9,12 @@ describe('AiPromptInput', () => {
|
|||||||
const wrapper = mount(AiPromptInput, {
|
const wrapper = mount(AiPromptInput, {
|
||||||
props: {
|
props: {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
modelValue: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await wrapper.find('textarea').setValue('你好');
|
await wrapper.find('textarea').setValue('你好');
|
||||||
|
await wrapper.setProps({ modelValue: '你好' });
|
||||||
await wrapper.find('[aria-label="发送"]').trigger('click');
|
await wrapper.find('[aria-label="发送"]').trigger('click');
|
||||||
|
|
||||||
expect(wrapper.emitted('send')?.[0]?.[0]).toBe('你好');
|
expect(wrapper.emitted('send')?.[0]?.[0]).toBe('你好');
|
||||||
@@ -30,4 +32,62 @@ describe('AiPromptInput', () => {
|
|||||||
|
|
||||||
expect(wrapper.emitted('stop')).toBeTruthy();
|
expect(wrapper.emitted('stop')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports sending a ready image without text', async () => {
|
||||||
|
const wrapper = mount(AiPromptInput, {
|
||||||
|
props: {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
localId: 'image-1',
|
||||||
|
mimeType: 'image/png',
|
||||||
|
name: 'test.png',
|
||||||
|
previewUrl: 'data:image/png;base64,AA==',
|
||||||
|
size: 1,
|
||||||
|
status: 'ready',
|
||||||
|
uploadId: 'upload-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.find('[aria-label="发送"]').trigger('click');
|
||||||
|
|
||||||
|
expect(wrapper.emitted('send')?.[0]?.[0]).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts image files from clipboard paste', async () => {
|
||||||
|
const wrapper = mount(AiPromptInput, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const image = new File(['image'], 'pasted.png', { type: 'image/png' });
|
||||||
|
const event = new Event('paste', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(event, 'clipboardData', {
|
||||||
|
value: { files: [image] },
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.find('textarea').element.dispatchEvent(event);
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(wrapper.emitted('addFiles')?.[0]?.[0]).toEqual([image]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts image files from drop', async () => {
|
||||||
|
const wrapper = mount(AiPromptInput, {
|
||||||
|
props: {
|
||||||
|
loading: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const image = new File(['image'], 'dropped.jpg', { type: 'image/jpeg' });
|
||||||
|
const text = new File(['text'], 'note.txt', { type: 'text/plain' });
|
||||||
|
|
||||||
|
await wrapper.find('.ai-prompt-input').trigger('drop', {
|
||||||
|
dataTransfer: { files: [image, text] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.emitted('addFiles')?.[0]?.[0]).toEqual([image]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +1,122 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, ref} from 'vue';
|
import type { ChatImageAttachment, ChatImageLoader } from '@easyflow/common-ui';
|
||||||
|
|
||||||
import {Promotion} from '@element-plus/icons-vue';
|
import { computed, ref } from 'vue';
|
||||||
import {ElButton, ElInput} from 'element-plus';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
import { ChatImageAttachments } from '@easyflow/common-ui';
|
||||||
loading?: boolean;
|
import { Paperclip, Promotion } from '@element-plus/icons-vue';
|
||||||
placeholder?: string;
|
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
||||||
}>();
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
loading?: boolean;
|
||||||
|
images?: ChatImageAttachment[];
|
||||||
|
imageEnabled?: boolean;
|
||||||
|
imageLoader?: ChatImageLoader;
|
||||||
|
modelValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}>(),
|
||||||
|
{ imageEnabled: true },
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
addFiles: [files: File[]];
|
||||||
|
removeImage: [item: ChatImageAttachment];
|
||||||
|
retryImage: [item: ChatImageAttachment];
|
||||||
send: [text: string];
|
send: [text: string];
|
||||||
stop: [];
|
stop: [];
|
||||||
|
'update:modelValue': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const text = ref('');
|
const text = computed({
|
||||||
const canSend = computed(() => text.value.trim().length > 0 && !props.loading);
|
get: () => props.modelValue || '',
|
||||||
|
set: (value: string) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
const fileInput = ref<HTMLInputElement>();
|
||||||
|
const dragActive = ref(false);
|
||||||
|
const hasReadyImage = computed(() =>
|
||||||
|
(props.images || []).some((item) => item.status === 'ready'),
|
||||||
|
);
|
||||||
|
const hasPendingImage = computed(() =>
|
||||||
|
(props.images || []).some((item) => item.status !== 'ready'),
|
||||||
|
);
|
||||||
|
const canSend = computed(
|
||||||
|
() =>
|
||||||
|
(text.value.trim().length > 0 || hasReadyImage.value) &&
|
||||||
|
(!hasReadyImage.value || props.imageEnabled !== false) &&
|
||||||
|
!hasPendingImage.value &&
|
||||||
|
!props.loading,
|
||||||
|
);
|
||||||
|
|
||||||
function send() {
|
function send() {
|
||||||
const value = text.value.trim();
|
const value = text.value.trim();
|
||||||
if (!value || props.loading) return;
|
if (
|
||||||
|
(!value && !hasReadyImage.value) ||
|
||||||
|
props.loading ||
|
||||||
|
hasPendingImage.value
|
||||||
|
)
|
||||||
|
return;
|
||||||
emit('send', value);
|
emit('send', value);
|
||||||
text.value = '';
|
}
|
||||||
|
|
||||||
|
function chooseFiles() {
|
||||||
|
if (props.imageEnabled === false) return;
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFiles(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const files = [...(target.files || [])];
|
||||||
|
if (files.length) emit('addFiles', files);
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePaste(event: ClipboardEvent) {
|
||||||
|
if (props.imageEnabled === false) return;
|
||||||
|
const files = [...(event.clipboardData?.files || [])].filter((file) =>
|
||||||
|
file.type.startsWith('image/'),
|
||||||
|
);
|
||||||
|
if (!files.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
emit('addFiles', files);
|
||||||
|
ElMessage.success(
|
||||||
|
files.length === 1 ? '已粘贴图片' : `已粘贴 ${files.length} 张图片`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnter(event: DragEvent) {
|
||||||
|
if (
|
||||||
|
!props.loading &&
|
||||||
|
props.imageEnabled !== false &&
|
||||||
|
(props.images?.length || 0) < 5 &&
|
||||||
|
[...(event.dataTransfer?.types || [])].includes('Files')
|
||||||
|
) {
|
||||||
|
dragActive.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(event: DragEvent) {
|
||||||
|
const container = event.currentTarget as HTMLElement;
|
||||||
|
if (
|
||||||
|
!(event.relatedTarget instanceof Node) ||
|
||||||
|
!container.contains(event.relatedTarget)
|
||||||
|
) {
|
||||||
|
dragActive.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent) {
|
||||||
|
dragActive.value = false;
|
||||||
|
if (
|
||||||
|
props.loading ||
|
||||||
|
props.imageEnabled === false ||
|
||||||
|
(props.images?.length || 0) >= 5
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const files = [...(event.dataTransfer?.files || [])].filter((file) =>
|
||||||
|
file.type.startsWith('image/'),
|
||||||
|
);
|
||||||
|
if (files.length) emit('addFiles', files);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stop() {
|
function stop() {
|
||||||
@@ -38,7 +133,24 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ai-prompt-input">
|
<div
|
||||||
|
class="ai-prompt-input"
|
||||||
|
:class="{ 'is-dragging': dragActive }"
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<ChatImageAttachments
|
||||||
|
v-if="images?.length"
|
||||||
|
class="ai-prompt-input__images"
|
||||||
|
:items="images"
|
||||||
|
:image-loader="imageLoader"
|
||||||
|
removable
|
||||||
|
retryable
|
||||||
|
@remove="emit('removeImage', $event)"
|
||||||
|
@retry="emit('retryImage', $event)"
|
||||||
|
/>
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="text"
|
v-model="text"
|
||||||
class="ai-prompt-input__textarea"
|
class="ai-prompt-input__textarea"
|
||||||
@@ -47,8 +159,29 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
|||||||
:autosize="{ minRows: 1, maxRows: 5 }"
|
:autosize="{ minRows: 1, maxRows: 5 }"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
:placeholder="placeholder || '输入消息'"
|
:placeholder="placeholder || '输入消息'"
|
||||||
|
@paste="handlePaste"
|
||||||
@keydown="handleKeydown"
|
@keydown="handleKeydown"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
v-if="imageEnabled !== false"
|
||||||
|
ref="fileInput"
|
||||||
|
class="ai-prompt-input__file"
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp"
|
||||||
|
multiple
|
||||||
|
@change="handleFiles"
|
||||||
|
/>
|
||||||
|
<ElButton
|
||||||
|
v-if="imageEnabled !== false"
|
||||||
|
:icon="Paperclip"
|
||||||
|
circle
|
||||||
|
text
|
||||||
|
:disabled="loading || (images?.length || 0) >= 5"
|
||||||
|
aria-label="添加图片"
|
||||||
|
title="添加图片"
|
||||||
|
class="ai-prompt-input__attach"
|
||||||
|
@click="chooseFiles"
|
||||||
|
/>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="loading"
|
v-if="loading"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -73,10 +206,12 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ai-prompt-input {
|
.ai-prompt-input {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
margin: 0 16px 16px;
|
margin: 0 16px 16px;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
background: color-mix(in srgb, var(--el-bg-color) 92%, transparent);
|
background: color-mix(in srgb, var(--el-bg-color) 92%, transparent);
|
||||||
@@ -85,6 +220,24 @@ function handleKeydown(event: Event | KeyboardEvent) {
|
|||||||
box-shadow: var(--el-box-shadow-lighter);
|
box-shadow: var(--el-box-shadow-lighter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-prompt-input.is-dragging {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
border-color: var(--el-color-primary-light-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-prompt-input__images {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-prompt-input__file {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-prompt-input__attach {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-prompt-input__textarea {
|
.ai-prompt-input__textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
101
easyflow-ui-admin/app/src/components/ai-chat/mediaApi.ts
Normal file
101
easyflow-ui-admin/app/src/components/ai-chat/mediaApi.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
|
||||||
|
export type AgentComposerMode = 'DRAFT' | 'FORMAL';
|
||||||
|
|
||||||
|
export interface AgentMediaUpload extends ChatImageAttachment {
|
||||||
|
expiresAt?: string;
|
||||||
|
height: number;
|
||||||
|
mimeType: string;
|
||||||
|
name: string;
|
||||||
|
previewUrl: string;
|
||||||
|
size: number;
|
||||||
|
uploadId: string;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentComposerDraftPayload {
|
||||||
|
agentId: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
imageUploadIds: string[];
|
||||||
|
images?: AgentMediaUpload[];
|
||||||
|
mode: AgentComposerMode;
|
||||||
|
revision: number;
|
||||||
|
sessionId: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestResult<T = any> {
|
||||||
|
data: T;
|
||||||
|
errorCode: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allocateAgentComposerSession(mode: AgentComposerMode) {
|
||||||
|
return api.post<
|
||||||
|
RequestResult<{ mode: AgentComposerMode; sessionId: string }>
|
||||||
|
>('/api/v1/agent/composer/session', { mode });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadAgentChatImage(
|
||||||
|
file: File,
|
||||||
|
context: {
|
||||||
|
agentId: string;
|
||||||
|
mode: AgentComposerMode;
|
||||||
|
sessionId: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('file', file);
|
||||||
|
body.append('mode', context.mode);
|
||||||
|
body.append('agentId', context.agentId);
|
||||||
|
body.append('sessionId', context.sessionId);
|
||||||
|
return api.postFile<RequestResult<AgentMediaUpload>>(
|
||||||
|
'/api/v1/agent/media/upload',
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAgentChatImage(uploadId: string) {
|
||||||
|
return api.post<RequestResult<void>>('/api/v1/agent/media/delete', {
|
||||||
|
uploadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentComposerDraft(params: {
|
||||||
|
agentId: string;
|
||||||
|
mode: AgentComposerMode;
|
||||||
|
sessionId?: string;
|
||||||
|
}) {
|
||||||
|
return api.get<RequestResult<AgentComposerDraftPayload | null>>(
|
||||||
|
'/api/v1/agent/composer/draft',
|
||||||
|
{ params },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAgentComposerDraft(data: AgentComposerDraftPayload) {
|
||||||
|
return api.post<RequestResult<AgentComposerDraftPayload>>(
|
||||||
|
'/api/v1/agent/composer/draft/persist',
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAgentComposerDraft(data: {
|
||||||
|
agentId: string;
|
||||||
|
deleteUploads?: boolean;
|
||||||
|
imageUploadIds?: string[];
|
||||||
|
mode: AgentComposerMode;
|
||||||
|
sessionId: string;
|
||||||
|
}) {
|
||||||
|
return api.post<RequestResult<void>>(
|
||||||
|
'/api/v1/agent/composer/draft/delete',
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAgentChatImage(previewUrl: string) {
|
||||||
|
if (!previewUrl || /^(blob:|data:)/i.test(previewUrl)) return previewUrl;
|
||||||
|
const blob = await api.download<Blob>(previewUrl);
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
|
import {
|
||||||
|
allocateAgentComposerSession,
|
||||||
|
deleteAgentComposerDraft,
|
||||||
|
getAgentComposerDraft,
|
||||||
|
saveAgentComposerDraft,
|
||||||
|
} from './mediaApi';
|
||||||
|
import { useAgentComposerDraft } from './useAgentComposerDraft';
|
||||||
|
|
||||||
|
vi.mock('./mediaApi', () => ({
|
||||||
|
allocateAgentComposerSession: vi.fn(),
|
||||||
|
deleteAgentChatImage: vi.fn(),
|
||||||
|
deleteAgentComposerDraft: vi.fn(),
|
||||||
|
getAgentComposerDraft: vi.fn(),
|
||||||
|
loadAgentChatImage: vi.fn(),
|
||||||
|
saveAgentComposerDraft: vi.fn(),
|
||||||
|
uploadAgentChatImage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAgentComposerDraft', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
useUserStore().setUserInfo({
|
||||||
|
avatar: '',
|
||||||
|
id: 'user-1',
|
||||||
|
loginName: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
});
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(allocateAgentComposerSession).mockResolvedValue({
|
||||||
|
data: { mode: 'FORMAL', sessionId: '100' },
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
vi.mocked(getAgentComposerDraft).mockResolvedValue({
|
||||||
|
data: null,
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
vi.mocked(deleteAgentComposerDraft).mockResolvedValue({
|
||||||
|
data: undefined,
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
vi.mocked(saveAgentComposerDraft).mockImplementation(async (draft) => ({
|
||||||
|
data: {
|
||||||
|
...draft,
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
revision: draft.revision + 1,
|
||||||
|
},
|
||||||
|
errorCode: 0,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores an unsynced local shadow and writes it back to Redis', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = useAgentComposerDraft('FORMAL');
|
||||||
|
await first.activate('agent-1');
|
||||||
|
first.text.value = '刷新前尚未发送的内容';
|
||||||
|
first.scheduleSave();
|
||||||
|
|
||||||
|
const restored = useAgentComposerDraft('FORMAL');
|
||||||
|
await restored.activate('agent-1');
|
||||||
|
|
||||||
|
expect(restored.sessionId.value).toBe('100');
|
||||||
|
expect(restored.text.value).toBe('刷新前尚未发送的内容');
|
||||||
|
expect(saveAgentComposerDraft).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
agentId: 'agent-1',
|
||||||
|
sessionId: '100',
|
||||||
|
text: '刷新前尚未发送的内容',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps local shadows isolated by the logged-in account', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = useAgentComposerDraft('FORMAL');
|
||||||
|
await first.activate('agent-1');
|
||||||
|
first.text.value = '账号一的草稿';
|
||||||
|
first.scheduleSave();
|
||||||
|
|
||||||
|
useUserStore().setUserInfo({
|
||||||
|
avatar: '',
|
||||||
|
id: 'user-2',
|
||||||
|
loginName: 'other',
|
||||||
|
nickname: '其他用户',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
});
|
||||||
|
vi.mocked(allocateAgentComposerSession).mockResolvedValueOnce({
|
||||||
|
data: { mode: 'FORMAL', sessionId: '200' },
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
const second = useAgentComposerDraft('FORMAL');
|
||||||
|
await second.activate('agent-1');
|
||||||
|
|
||||||
|
expect(second.sessionId.value).toBe('200');
|
||||||
|
expect(second.text.value).toBe('');
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes a debounced save and the send-time flush', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let resolveFirstSave: (() => void) | undefined;
|
||||||
|
vi.mocked(saveAgentComposerDraft).mockImplementation((draft) => {
|
||||||
|
if (!resolveFirstSave) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveFirstSave = () =>
|
||||||
|
resolve({
|
||||||
|
data: { ...draft, revision: 1 },
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
data: { ...draft, revision: draft.revision + 1 },
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const composer = useAgentComposerDraft('FORMAL');
|
||||||
|
await composer.activate('agent-1');
|
||||||
|
composer.text.value = '并发保存内容';
|
||||||
|
composer.scheduleSave();
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const flushPromise = composer.flush();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(saveAgentComposerDraft).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveFirstSave?.();
|
||||||
|
await flushPromise;
|
||||||
|
|
||||||
|
expect(saveAgentComposerDraft).toHaveBeenCalledTimes(2);
|
||||||
|
expect(saveAgentComposerDraft).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ revision: 1, text: '并发保存内容' }),
|
||||||
|
);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for an in-flight save before clearing an accepted draft', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let resolveSave: (() => void) | undefined;
|
||||||
|
vi.mocked(saveAgentComposerDraft).mockImplementation(
|
||||||
|
(draft) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveSave = () =>
|
||||||
|
resolve({
|
||||||
|
data: { ...draft, revision: 1 },
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const composer = useAgentComposerDraft('DRAFT');
|
||||||
|
await composer.activate('agent-1', 'agent-draft-100');
|
||||||
|
composer.text.value = '试运行内容';
|
||||||
|
composer.scheduleSave();
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const accepted = composer.markAccepted();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(deleteAgentComposerDraft).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
resolveSave?.();
|
||||||
|
await accepted;
|
||||||
|
|
||||||
|
expect(deleteAgentComposerDraft).toHaveBeenCalledWith({
|
||||||
|
agentId: 'agent-1',
|
||||||
|
deleteUploads: false,
|
||||||
|
imageUploadIds: [],
|
||||||
|
mode: 'DRAFT',
|
||||||
|
sessionId: 'agent-draft-100',
|
||||||
|
});
|
||||||
|
expect(composer.text.value).toBe('');
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
|
import {
|
||||||
|
COMPOSER_SHADOW_PREFIX,
|
||||||
|
resolveAgentChatIdentity,
|
||||||
|
} from '#/utils/agent-chat-cache';
|
||||||
|
|
||||||
|
import {
|
||||||
|
allocateAgentComposerSession,
|
||||||
|
deleteAgentComposerDraft,
|
||||||
|
getAgentComposerDraft,
|
||||||
|
saveAgentComposerDraft,
|
||||||
|
} from './mediaApi';
|
||||||
|
import type { AgentComposerDraftPayload, AgentComposerMode } from './mediaApi';
|
||||||
|
import { useChatImageUploads } from './useChatImageUploads';
|
||||||
|
|
||||||
|
const SHADOW_TTL = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
interface ShadowDraft extends AgentComposerDraftPayload {
|
||||||
|
pendingSync: boolean;
|
||||||
|
shadowExpiresAt: number;
|
||||||
|
shadowUpdatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentComposerDraft(mode: AgentComposerMode) {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const agentId = ref('');
|
||||||
|
const sessionId = ref('');
|
||||||
|
const text = ref('');
|
||||||
|
const revision = ref(0);
|
||||||
|
const images = useChatImageUploads();
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let activation = 0;
|
||||||
|
let changeSequence = 0;
|
||||||
|
let pendingOperations = 0;
|
||||||
|
let operationChain = Promise.resolve();
|
||||||
|
|
||||||
|
function identityScope() {
|
||||||
|
return resolveAgentChatIdentity(userStore.userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shadowKey(
|
||||||
|
targetAgentId = agentId.value,
|
||||||
|
targetSessionId = sessionId.value,
|
||||||
|
targetIdentity = identityScope(),
|
||||||
|
) {
|
||||||
|
return `${COMPOSER_SHADOW_PREFIX}:${targetIdentity}:${mode}:${targetAgentId}:${targetSessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeShadowKey(
|
||||||
|
targetAgentId = agentId.value,
|
||||||
|
targetIdentity = identityScope(),
|
||||||
|
) {
|
||||||
|
return `${COMPOSER_SHADOW_PREFIX}:${targetIdentity}:active:${mode}:${targetAgentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readShadow(targetAgentId: string, preferredSessionId?: string) {
|
||||||
|
try {
|
||||||
|
if (!identityScope()) return undefined;
|
||||||
|
const activeSessionId =
|
||||||
|
preferredSessionId ||
|
||||||
|
localStorage.getItem(activeShadowKey(targetAgentId)) ||
|
||||||
|
'';
|
||||||
|
if (!activeSessionId) return undefined;
|
||||||
|
const raw = localStorage.getItem(
|
||||||
|
shadowKey(targetAgentId, activeSessionId),
|
||||||
|
);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const draft = JSON.parse(raw) as ShadowDraft;
|
||||||
|
if (draft.shadowExpiresAt <= Date.now()) {
|
||||||
|
localStorage.removeItem(shadowKey(targetAgentId, activeSessionId));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return draft;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeShadowPayload(
|
||||||
|
payload: AgentComposerDraftPayload,
|
||||||
|
pendingSync = true,
|
||||||
|
serverExpiresAt?: string,
|
||||||
|
targetIdentity = identityScope(),
|
||||||
|
) {
|
||||||
|
if (!targetIdentity || !payload.agentId || !payload.sessionId) return;
|
||||||
|
const parsedServerExpiry = serverExpiresAt
|
||||||
|
? Date.parse(serverExpiresAt)
|
||||||
|
: Number.NaN;
|
||||||
|
const shadow: ShadowDraft = {
|
||||||
|
...payload,
|
||||||
|
pendingSync,
|
||||||
|
shadowExpiresAt:
|
||||||
|
!pendingSync && Number.isFinite(parsedServerExpiry)
|
||||||
|
? parsedServerExpiry
|
||||||
|
: Date.now() + SHADOW_TTL,
|
||||||
|
shadowUpdatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
shadowKey(payload.agentId, payload.sessionId, targetIdentity),
|
||||||
|
JSON.stringify(shadow),
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
activeShadowKey(payload.agentId, targetIdentity),
|
||||||
|
payload.sessionId,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Redis 仍是权威草稿源,本地存储不足不阻断输入。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeShadow(pendingSync = true, serverExpiresAt?: string) {
|
||||||
|
writeShadowPayload(shadowPayload(), pendingSync, serverExpiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearShadow(
|
||||||
|
targetAgentId = agentId.value,
|
||||||
|
targetSessionId = sessionId.value,
|
||||||
|
targetIdentity = identityScope(),
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!targetIdentity) return;
|
||||||
|
localStorage.removeItem(
|
||||||
|
shadowKey(targetAgentId, targetSessionId, targetIdentity),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
localStorage.getItem(activeShadowKey(targetAgentId, targetIdentity)) ===
|
||||||
|
targetSessionId
|
||||||
|
) {
|
||||||
|
localStorage.removeItem(activeShadowKey(targetAgentId, targetIdentity));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略不可用的本地存储。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadOf(): AgentComposerDraftPayload {
|
||||||
|
return {
|
||||||
|
agentId: agentId.value,
|
||||||
|
imageUploadIds: [...images.uploadIds.value],
|
||||||
|
mode,
|
||||||
|
revision: revision.value,
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
text: text.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shadowPayload(): AgentComposerDraftPayload {
|
||||||
|
return {
|
||||||
|
...payloadOf(),
|
||||||
|
images: images.readyItems.value.map((item) => ({ ...item })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDraft(draft: AgentComposerDraftPayload) {
|
||||||
|
sessionId.value = String(draft.sessionId);
|
||||||
|
text.value = draft.text || '';
|
||||||
|
revision.value = Number(draft.revision || 0);
|
||||||
|
images.restore(draft.images || []);
|
||||||
|
changeSequence++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(operation: () => Promise<void>) {
|
||||||
|
pendingOperations++;
|
||||||
|
const queued = operationChain.catch(() => undefined).then(operation);
|
||||||
|
operationChain = queued
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
pendingOperations--;
|
||||||
|
});
|
||||||
|
return queued;
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureSaveContext() {
|
||||||
|
return {
|
||||||
|
activation,
|
||||||
|
agentId: agentId.value,
|
||||||
|
identity: identityScope(),
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentContext(context: ReturnType<typeof captureSaveContext>) {
|
||||||
|
return (
|
||||||
|
context.activation === activation &&
|
||||||
|
context.agentId === agentId.value &&
|
||||||
|
context.identity === identityScope() &&
|
||||||
|
(!context.sessionId || context.sessionId === sessionId.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSession() {
|
||||||
|
if (sessionId.value) return sessionId.value;
|
||||||
|
const response = await allocateAgentComposerSession(mode);
|
||||||
|
if (response.errorCode !== 0 || !response.data?.sessionId) {
|
||||||
|
throw new Error(response.message || '会话创建失败');
|
||||||
|
}
|
||||||
|
sessionId.value = String(response.data.sessionId);
|
||||||
|
return sessionId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activate(targetAgentId: string, preferredSessionId?: string) {
|
||||||
|
const switchingScope =
|
||||||
|
Boolean(agentId.value && sessionId.value) &&
|
||||||
|
(targetAgentId !== agentId.value ||
|
||||||
|
Boolean(preferredSessionId && preferredSessionId !== sessionId.value));
|
||||||
|
if (
|
||||||
|
switchingScope &&
|
||||||
|
(Boolean(saveTimer) ||
|
||||||
|
pendingOperations > 0 ||
|
||||||
|
Boolean(text.value.trim()) ||
|
||||||
|
images.uploadIds.value.length > 0)
|
||||||
|
) {
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
const currentActivation = ++activation;
|
||||||
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = undefined;
|
||||||
|
}
|
||||||
|
agentId.value = targetAgentId;
|
||||||
|
sessionId.value = preferredSessionId || '';
|
||||||
|
text.value = '';
|
||||||
|
revision.value = 0;
|
||||||
|
images.clear();
|
||||||
|
if (!targetAgentId) return;
|
||||||
|
const shadow = readShadow(targetAgentId, preferredSessionId);
|
||||||
|
try {
|
||||||
|
const response = await getAgentComposerDraft({
|
||||||
|
agentId: targetAgentId,
|
||||||
|
mode,
|
||||||
|
...(preferredSessionId ? { sessionId: preferredSessionId } : {}),
|
||||||
|
});
|
||||||
|
if (currentActivation !== activation) return;
|
||||||
|
if (response.errorCode !== 0) {
|
||||||
|
throw new Error(response.message || '聊天草稿读取失败');
|
||||||
|
}
|
||||||
|
if (shadow?.pendingSync) {
|
||||||
|
shadow.revision = Number(response.data?.revision || 0);
|
||||||
|
applyDraft(shadow);
|
||||||
|
await save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.data) {
|
||||||
|
applyDraft(response.data);
|
||||||
|
writeShadow(false, response.data.expiresAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shadow) {
|
||||||
|
applyDraft(shadow);
|
||||||
|
await save();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureSession();
|
||||||
|
} catch (error) {
|
||||||
|
if (currentActivation !== activation) return;
|
||||||
|
if (shadow) {
|
||||||
|
applyDraft(shadow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureSession();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSave(context: ReturnType<typeof captureSaveContext>) {
|
||||||
|
if (!context.agentId || !context.identity || !isCurrentContext(context)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureSession();
|
||||||
|
if (!isCurrentContext(context)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const request = shadowPayload();
|
||||||
|
const savedSequence = changeSequence;
|
||||||
|
if (!request.text.trim() && request.imageUploadIds.length === 0) {
|
||||||
|
clearShadow(request.agentId, request.sessionId, context.identity);
|
||||||
|
const response = await deleteAgentComposerDraft({
|
||||||
|
agentId: request.agentId,
|
||||||
|
deleteUploads: true,
|
||||||
|
mode,
|
||||||
|
sessionId: request.sessionId,
|
||||||
|
});
|
||||||
|
if (response.errorCode !== 0) {
|
||||||
|
throw new Error(response.message || '聊天草稿删除失败');
|
||||||
|
}
|
||||||
|
if (isCurrentContext(context)) {
|
||||||
|
revision.value = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeShadowPayload(request, true, undefined, context.identity);
|
||||||
|
const response = await saveAgentComposerDraft(request);
|
||||||
|
if (response.errorCode !== 0 || !response.data) {
|
||||||
|
throw new Error(response.message || '聊天草稿保存失败');
|
||||||
|
}
|
||||||
|
if (!isCurrentContext(context)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
revision.value = Number(response.data.revision || revision.value);
|
||||||
|
writeShadow(changeSequence !== savedSequence, response.data.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
const context = captureSaveContext();
|
||||||
|
return enqueue(() => performSave(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
changeSequence++;
|
||||||
|
writeShadow();
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
saveTimer = undefined;
|
||||||
|
void save().catch(() => undefined);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = undefined;
|
||||||
|
}
|
||||||
|
await save();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear(deleteRemote = true, deleteUploads = true) {
|
||||||
|
if (saveTimer) {
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = undefined;
|
||||||
|
}
|
||||||
|
const scope = {
|
||||||
|
identity: identityScope(),
|
||||||
|
agentId: agentId.value,
|
||||||
|
imageUploadIds: [...images.uploadIds.value],
|
||||||
|
mode,
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
};
|
||||||
|
activation++;
|
||||||
|
changeSequence++;
|
||||||
|
clearShadow(scope.agentId, scope.sessionId, scope.identity);
|
||||||
|
text.value = '';
|
||||||
|
revision.value = 0;
|
||||||
|
images.clear();
|
||||||
|
if (deleteRemote && scope.agentId && scope.sessionId) {
|
||||||
|
await enqueue(async () => {
|
||||||
|
const response = await deleteAgentComposerDraft({
|
||||||
|
agentId: scope.agentId,
|
||||||
|
deleteUploads,
|
||||||
|
imageUploadIds: scope.imageUploadIds,
|
||||||
|
mode: scope.mode,
|
||||||
|
sessionId: scope.sessionId,
|
||||||
|
});
|
||||||
|
if (response.errorCode !== 0) {
|
||||||
|
throw new Error(response.message || '聊天草稿删除失败');
|
||||||
|
}
|
||||||
|
clearShadow(scope.agentId, scope.sessionId, scope.identity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAccepted() {
|
||||||
|
await clear(true, mode === 'FORMAL');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNew(targetAgentId: string) {
|
||||||
|
if (
|
||||||
|
agentId.value &&
|
||||||
|
sessionId.value &&
|
||||||
|
(saveTimer ||
|
||||||
|
pendingOperations > 0 ||
|
||||||
|
text.value.trim() ||
|
||||||
|
images.uploadIds.value.length > 0)
|
||||||
|
) {
|
||||||
|
await flush();
|
||||||
|
text.value = '';
|
||||||
|
images.clear();
|
||||||
|
}
|
||||||
|
const response = await allocateAgentComposerSession(mode);
|
||||||
|
if (response.errorCode !== 0 || !response.data?.sessionId) {
|
||||||
|
throw new Error(response.message || '会话创建失败');
|
||||||
|
}
|
||||||
|
await activate(targetAgentId, String(response.data.sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activate,
|
||||||
|
agentId,
|
||||||
|
clear,
|
||||||
|
ensureSession,
|
||||||
|
flush,
|
||||||
|
images,
|
||||||
|
markAccepted,
|
||||||
|
revision,
|
||||||
|
scheduleSave,
|
||||||
|
sessionId,
|
||||||
|
startNew,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { nextTick, watchEffect } from 'vue';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useChatImageUploads } from './useChatImageUploads';
|
||||||
|
|
||||||
|
const mediaApi = vi.hoisted(() => ({
|
||||||
|
deleteAgentChatImage: vi.fn(),
|
||||||
|
uploadAgentChatImage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./mediaApi', () => mediaApi);
|
||||||
|
|
||||||
|
describe('useChatImageUploads', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
mediaApi.deleteAgentChatImage.mockReset();
|
||||||
|
mediaApi.uploadAgentChatImage.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reactively exposes the ready state after upload', async () => {
|
||||||
|
let resolveUpload: (value: any) => void = () => undefined;
|
||||||
|
mediaApi.uploadAgentChatImage.mockReturnValue(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveUpload = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-image');
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||||
|
|
||||||
|
const uploads = useChatImageUploads();
|
||||||
|
const observedStatuses: (string | undefined)[] = [];
|
||||||
|
const stop = watchEffect(() => {
|
||||||
|
observedStatuses.push(uploads.items.value[0]?.status);
|
||||||
|
});
|
||||||
|
const pending = uploads.addFiles(
|
||||||
|
[new File(['image'], 'test.png', { type: 'image/png' })],
|
||||||
|
{ agentId: 'agent-1', mode: 'FORMAL', sessionId: 'session-1' },
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
resolveUpload({
|
||||||
|
data: {
|
||||||
|
height: 1,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
name: 'test.png',
|
||||||
|
previewUrl: '/api/v1/agent/media/content?reference=draft%3Atest',
|
||||||
|
size: 5,
|
||||||
|
uploadId: 'upload-1',
|
||||||
|
width: 1,
|
||||||
|
},
|
||||||
|
errorCode: 0,
|
||||||
|
});
|
||||||
|
await pending;
|
||||||
|
await nextTick();
|
||||||
|
stop();
|
||||||
|
|
||||||
|
expect(observedStatuses).toContain('uploading');
|
||||||
|
expect(observedStatuses.at(-1)).toBe('ready');
|
||||||
|
expect(uploads.uploadIds.value).toEqual(['upload-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { ChatImageAttachment } from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { deleteAgentChatImage, uploadAgentChatImage } from './mediaApi';
|
||||||
|
import type { AgentComposerMode, AgentMediaUpload } from './mediaApi';
|
||||||
|
|
||||||
|
const MAX_IMAGES = 5;
|
||||||
|
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const ACCEPTED_EXTENSIONS = new Set([
|
||||||
|
'bmp',
|
||||||
|
'gif',
|
||||||
|
'jpeg',
|
||||||
|
'jpg',
|
||||||
|
'png',
|
||||||
|
'webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface UploadContext {
|
||||||
|
agentId: string;
|
||||||
|
mode: AgentComposerMode;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalAttachment extends ChatImageAttachment {
|
||||||
|
file?: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalId() {
|
||||||
|
return `agent-image-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionOf(file: File) {
|
||||||
|
const extension = file.name.includes('.')
|
||||||
|
? file.name.split('.').pop()?.toLowerCase() || ''
|
||||||
|
: '';
|
||||||
|
if (extension) return extension;
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
'image/bmp': 'bmp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
}[file.type.toLowerCase()] || ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
const candidate = error as any;
|
||||||
|
return (
|
||||||
|
candidate?.response?.data?.message || candidate?.message || '图片上传失败'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatImageUploads() {
|
||||||
|
const items = ref<LocalAttachment[]>([]);
|
||||||
|
const readyItems = computed(() =>
|
||||||
|
items.value.filter(
|
||||||
|
(item): item is LocalAttachment & AgentMediaUpload =>
|
||||||
|
item.status === 'ready' && Boolean(item.uploadId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const uploadIds = computed(() =>
|
||||||
|
readyItems.value.map((item) => item.uploadId),
|
||||||
|
);
|
||||||
|
const uploading = computed(() =>
|
||||||
|
items.value.some((item) => item.status === 'uploading'),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function addFiles(files: File[], context: UploadContext) {
|
||||||
|
const available = Math.max(0, MAX_IMAGES - items.value.length);
|
||||||
|
const accepted = files.slice(0, available);
|
||||||
|
const rejectedCount = Math.max(0, files.length - accepted.length);
|
||||||
|
const uploads: Promise<void>[] = [];
|
||||||
|
for (const file of accepted) {
|
||||||
|
const extension = extensionOf(file);
|
||||||
|
const local: LocalAttachment = {
|
||||||
|
file,
|
||||||
|
localId: createLocalId(),
|
||||||
|
mimeType: file.type,
|
||||||
|
name: file.name || '粘贴的图片',
|
||||||
|
previewUrl: URL.createObjectURL(file),
|
||||||
|
size: file.size,
|
||||||
|
status: 'uploading',
|
||||||
|
};
|
||||||
|
items.value.push(local);
|
||||||
|
if (!ACCEPTED_EXTENSIONS.has(extension)) {
|
||||||
|
local.status = 'error';
|
||||||
|
local.error = '仅支持 PNG、JPG、JPEG、WebP、GIF、BMP';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_BYTES) {
|
||||||
|
local.status = 'error';
|
||||||
|
local.error = '单张图片不能超过 10 MiB';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
uploads.push(upload(local, context));
|
||||||
|
}
|
||||||
|
await Promise.all(uploads);
|
||||||
|
return rejectedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(item: LocalAttachment, context: UploadContext) {
|
||||||
|
if (!item.file) return;
|
||||||
|
const current = items.value.find(
|
||||||
|
(candidate) => candidate.localId === item.localId,
|
||||||
|
);
|
||||||
|
if (!current) return;
|
||||||
|
current.status = 'uploading';
|
||||||
|
current.error = undefined;
|
||||||
|
try {
|
||||||
|
const response = await uploadAgentChatImage(current.file!, context);
|
||||||
|
if (response.errorCode !== 0 || !response.data) {
|
||||||
|
throw new Error(response.message || '图片上传失败');
|
||||||
|
}
|
||||||
|
const latest = items.value.find(
|
||||||
|
(candidate) => candidate.localId === item.localId,
|
||||||
|
);
|
||||||
|
if (!latest) {
|
||||||
|
await deleteAgentChatImage(response.data.uploadId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const localPreview = latest.previewUrl;
|
||||||
|
Object.assign(latest, response.data, {
|
||||||
|
file: latest.file,
|
||||||
|
localId: latest.localId,
|
||||||
|
status: 'ready',
|
||||||
|
});
|
||||||
|
if (localPreview.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(localPreview);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const latest = items.value.find(
|
||||||
|
(candidate) => candidate.localId === item.localId,
|
||||||
|
);
|
||||||
|
if (latest) {
|
||||||
|
latest.status = 'error';
|
||||||
|
latest.error = errorMessage(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(item: ChatImageAttachment) {
|
||||||
|
const index = items.value.findIndex(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.localId === item.localId ||
|
||||||
|
(candidate.uploadId && candidate.uploadId === item.uploadId),
|
||||||
|
);
|
||||||
|
if (index < 0) return;
|
||||||
|
const selected = items.value[index];
|
||||||
|
if (selected?.uploadId) {
|
||||||
|
const response = await deleteAgentChatImage(selected.uploadId);
|
||||||
|
if (response.errorCode !== 0) {
|
||||||
|
throw new Error(response.message || '图片删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [removed] = items.value.splice(index, 1);
|
||||||
|
if (removed?.previewUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(removed.previewUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retry(item: ChatImageAttachment, context: UploadContext) {
|
||||||
|
const found = items.value.find(
|
||||||
|
(candidate) => candidate.localId === item.localId,
|
||||||
|
);
|
||||||
|
if (found?.file) {
|
||||||
|
await upload(found, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore(restored: AgentMediaUpload[] = []) {
|
||||||
|
clear();
|
||||||
|
items.value = restored.slice(0, MAX_IMAGES).map((item) => ({
|
||||||
|
...item,
|
||||||
|
status: 'ready',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
for (const item of items.value) {
|
||||||
|
if (item.previewUrl?.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(item.previewUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addFiles,
|
||||||
|
clear,
|
||||||
|
items,
|
||||||
|
readyItems,
|
||||||
|
remove,
|
||||||
|
restore,
|
||||||
|
retry,
|
||||||
|
uploadIds,
|
||||||
|
uploading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -65,11 +65,11 @@
|
|||||||
"modelAbility": {
|
"modelAbility": {
|
||||||
"supportThinking": "Thinking",
|
"supportThinking": "Thinking",
|
||||||
"supportTool": "Tool",
|
"supportTool": "Tool",
|
||||||
"SupportAudio": "Audio",
|
"supportAudio": "Audio",
|
||||||
"SupportVideo": "Video",
|
"supportVideo": "Video",
|
||||||
"SupportImage": "Image",
|
"supportImage": "Multimodal",
|
||||||
"supportFree": "Free",
|
"supportFree": "Free",
|
||||||
"supportImageB64Only": "ImageB64Only",
|
"supportImageB64Only": "Base64 images only",
|
||||||
"supportToolMessage": "SupportToolMessage"
|
"supportToolMessage": "SupportToolMessage"
|
||||||
},
|
},
|
||||||
"requestPath": "RequestPath",
|
"requestPath": "RequestPath",
|
||||||
|
|||||||
@@ -64,9 +64,9 @@
|
|||||||
"supportTool": "工具",
|
"supportTool": "工具",
|
||||||
"supportAudio": "音频",
|
"supportAudio": "音频",
|
||||||
"supportVideo": "视频",
|
"supportVideo": "视频",
|
||||||
"supportImage": "图片",
|
"supportImage": "多模态",
|
||||||
"supportFree": "免费",
|
"supportFree": "免费",
|
||||||
"supportImageB64Only": "仅支持Base64图片",
|
"supportImageB64Only": "仅接受 Base64 图片",
|
||||||
"supportToolMessage": "支持Tool消息"
|
"supportToolMessage": "支持Tool消息"
|
||||||
},
|
},
|
||||||
"requestPath": "请求路径",
|
"requestPath": "请求路径",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
buildForcePasswordRoute,
|
buildForcePasswordRoute,
|
||||||
shouldForcePasswordChange,
|
shouldForcePasswordChange,
|
||||||
} from '#/utils/password-reset';
|
} from '#/utils/password-reset';
|
||||||
|
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
@@ -133,6 +134,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// 不做任何处理
|
// 不做任何处理
|
||||||
}
|
}
|
||||||
|
clearAgentChatBrowserCache(userStore.userInfo);
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
accessStore.setLoginExpired(false);
|
accessStore.setLoginExpired(false);
|
||||||
|
|
||||||
|
|||||||
83
easyflow-ui-admin/app/src/utils/agent-chat-cache.ts
Normal file
83
easyflow-ui-admin/app/src/utils/agent-chat-cache.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const COMPOSER_SHADOW_PREFIX = 'easyflow:agent-composer-shadow';
|
||||||
|
const RUNTIME_STORAGE_PREFIX = 'easyflow:agent-chat-runtime';
|
||||||
|
|
||||||
|
type AccountIdentity =
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
| {
|
||||||
|
id?: number | string;
|
||||||
|
tenantId?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearListeners = new Set<(identity: string) => void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成聊天本地缓存使用的账号隔离标识。
|
||||||
|
*/
|
||||||
|
export function resolveAgentChatIdentity(account: AccountIdentity) {
|
||||||
|
if (!account?.id) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${String(account.tenantId || 'default')}:${String(account.id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册账号聊天缓存清理监听器。
|
||||||
|
*/
|
||||||
|
export function onAgentChatCacheClear(listener: (identity: string) => void) {
|
||||||
|
clearListeners.add(listener);
|
||||||
|
return () => clearListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理指定账号的草稿影子和运行快照。
|
||||||
|
*/
|
||||||
|
export function clearAgentChatBrowserCache(account: AccountIdentity) {
|
||||||
|
const identity = resolveAgentChatIdentity(account);
|
||||||
|
if (!identity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeStorageEntries(safeStorage('localStorage'), [
|
||||||
|
`${COMPOSER_SHADOW_PREFIX}:${identity}:`,
|
||||||
|
]);
|
||||||
|
removeStorageEntries(safeStorage('sessionStorage'), [
|
||||||
|
`${RUNTIME_STORAGE_PREFIX}:${identity}:`,
|
||||||
|
]);
|
||||||
|
for (const listener of clearListeners) {
|
||||||
|
listener(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 草稿影子存储前缀。 */
|
||||||
|
export { COMPOSER_SHADOW_PREFIX, RUNTIME_STORAGE_PREFIX };
|
||||||
|
|
||||||
|
function removeStorageEntries(
|
||||||
|
storage: Storage | undefined,
|
||||||
|
prefixes: string[],
|
||||||
|
) {
|
||||||
|
if (!storage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const keys: string[] = [];
|
||||||
|
for (let index = 0; index < storage.length; index++) {
|
||||||
|
const key = storage.key(index);
|
||||||
|
if (key && prefixes.some((prefix) => key.startsWith(prefix))) {
|
||||||
|
keys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
storage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 浏览器禁用存储时,登录退出流程仍应继续。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeStorage(type: 'localStorage' | 'sessionStorage') {
|
||||||
|
try {
|
||||||
|
return globalThis[type];
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ChatImageAttachment,
|
||||||
ChatTimelineItem,
|
ChatTimelineItem,
|
||||||
ChatTimelineKnowledgeHit,
|
ChatTimelineKnowledgeHit,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineToolApprovalPayload,
|
ChatTimelineToolApprovalPayload,
|
||||||
ChatTimelineToolStatus,
|
ChatTimelineToolStatus,
|
||||||
} from '@easyflow/common-ui';
|
} from '@easyflow/common-ui';
|
||||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||||
|
|
||||||
import type {AgentChatMessageRecord} from '../api';
|
import type { AgentChatMessageRecord } from '../api';
|
||||||
|
|
||||||
export interface AgentSseEnvelope {
|
export interface AgentSseEnvelope {
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -73,8 +74,7 @@ function isBlankToolName(value: unknown) {
|
|||||||
function shouldSkipToolProjection(value: unknown) {
|
function shouldSkipToolProjection(value: unknown) {
|
||||||
const normalizedName = normalizeToolName(value).toLowerCase();
|
const normalizedName = normalizeToolName(value).toLowerCase();
|
||||||
return (
|
return (
|
||||||
normalizedName === 'context_reload' ||
|
normalizedName === 'context_reload' || normalizedName === '__fragment__'
|
||||||
normalizedName === '__fragment__'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +160,26 @@ function normalizeKnowledgeItems(payload: Record<string, any>) {
|
|||||||
.filter((item) => item.chunkContent || item.title || item.documentName);
|
.filter((item) => item.chunkContent || item.title || item.documentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeImages(payload: Record<string, any>) {
|
||||||
|
return asArray(payload.images)
|
||||||
|
.map((value): ChatImageAttachment | undefined => {
|
||||||
|
const image = asRecord(value);
|
||||||
|
const previewUrl = asText(image.previewUrl);
|
||||||
|
if (!previewUrl) return undefined;
|
||||||
|
return {
|
||||||
|
height: Number(image.height || 0) || undefined,
|
||||||
|
imageRef: asText(image.imageRef),
|
||||||
|
mimeType: asText(image.mimeType),
|
||||||
|
name: asText(image.name) || '图片',
|
||||||
|
previewUrl,
|
||||||
|
size: Number(image.size || 0) || undefined,
|
||||||
|
status: 'ready',
|
||||||
|
width: Number(image.width || 0) || undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is ChatImageAttachment => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
function buildApprovalPayload(payload: Record<string, any>) {
|
function buildApprovalPayload(payload: Record<string, any>) {
|
||||||
return {
|
return {
|
||||||
expiresAt: asText(payload.expiresAt),
|
expiresAt: asText(payload.expiresAt),
|
||||||
@@ -318,7 +338,10 @@ function appendHistoryRecord(
|
|||||||
const role = normalizeRole(record.senderRole);
|
const role = normalizeRole(record.senderRole);
|
||||||
const metadata = normalizeMetadata(record);
|
const metadata = normalizeMetadata(record);
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
ChatTimelineBuilder.appendUserMessage(items, record.contentText, metadata);
|
ChatTimelineBuilder.appendUserMessage(items, record.contentText, {
|
||||||
|
...metadata,
|
||||||
|
images: normalizeImages(asRecord(record.contentPayload)),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (role === 'system') {
|
if (role === 'system') {
|
||||||
@@ -446,8 +469,12 @@ export function applyAgentSseEnvelope(
|
|||||||
ChatTimelineBuilder.upsertToolCall(items, {
|
ChatTimelineBuilder.upsertToolCall(items, {
|
||||||
input: payload.input ?? payload.toolInput,
|
input: payload.input ?? payload.toolInput,
|
||||||
output: asyncTool
|
output: asyncTool
|
||||||
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
|
? (payload.summary ??
|
||||||
: payload.output ?? payload.result ?? payload.text,
|
payload.label ??
|
||||||
|
payload.output ??
|
||||||
|
payload.result ??
|
||||||
|
payload.text)
|
||||||
|
: (payload.output ?? payload.result ?? payload.text),
|
||||||
status: asyncTool
|
status: asyncTool
|
||||||
? asyncToolTimelineStatus(payload)
|
? asyncToolTimelineStatus(payload)
|
||||||
: type === 'TOOL_RESULT'
|
: type === 'TOOL_RESULT'
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
|
import { clearAgentChatBrowserCache } from '#/utils/agent-chat-cache';
|
||||||
|
|
||||||
|
import { sendAgentChat } from './api';
|
||||||
|
import { agentChatRuntimeManager } from './agentChatRuntimeManager';
|
||||||
|
|
||||||
|
vi.mock('./api', () => ({
|
||||||
|
generateAgentSessionId: vi.fn(),
|
||||||
|
sendAgentChat: vi.fn(),
|
||||||
|
stopAgentChatStream: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('agentChatRuntimeManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
sessionStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces draft image URLs and isolates snapshots by account', async () => {
|
||||||
|
let callbacks: any;
|
||||||
|
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
|
||||||
|
callbacks = options;
|
||||||
|
return Promise.resolve() as any;
|
||||||
|
});
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const firstAccount = {
|
||||||
|
avatar: '',
|
||||||
|
id: 'user-1',
|
||||||
|
loginName: 'admin',
|
||||||
|
nickname: '管理员',
|
||||||
|
tenantId: 'tenant-1',
|
||||||
|
};
|
||||||
|
userStore.setUserInfo(firstAccount);
|
||||||
|
|
||||||
|
await agentChatRuntimeManager.start({
|
||||||
|
agentId: 'agent-1',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
name: 'draft.png',
|
||||||
|
previewUrl: '/api/v1/agent/media/content?reference=draft%3Aupload-1',
|
||||||
|
status: 'ready',
|
||||||
|
uploadId: 'upload-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prompt: '识别图片',
|
||||||
|
sessionId: '101',
|
||||||
|
});
|
||||||
|
callbacks.onMessage({
|
||||||
|
data: JSON.stringify({
|
||||||
|
domain: 'SYSTEM',
|
||||||
|
payload: {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
imageRef: 'formal:101:201:0:png',
|
||||||
|
name: 'draft.png',
|
||||||
|
previewUrl:
|
||||||
|
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
type: 'INPUT_ACCEPTED',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const accepted = agentChatRuntimeManager.getSnapshot('101');
|
||||||
|
const userMessage = accepted?.items.find(
|
||||||
|
(item) => item.type === 'message' && item.role === 'user',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
userMessage?.type === 'message' ? userMessage.images?.[0] : null,
|
||||||
|
).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
imageRef: 'formal:101:201:0:png',
|
||||||
|
previewUrl:
|
||||||
|
'/api/v1/agent/media/content?reference=formal:101:201:0:png',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
userStore.setUserInfo({
|
||||||
|
...firstAccount,
|
||||||
|
id: 'user-2',
|
||||||
|
loginName: 'other',
|
||||||
|
nickname: '其他用户',
|
||||||
|
});
|
||||||
|
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||||
|
|
||||||
|
clearAgentChatBrowserCache(firstAccount);
|
||||||
|
userStore.setUserInfo(firstAccount);
|
||||||
|
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,35 @@
|
|||||||
import type {ChatTimelineItem} from '@easyflow/common-ui';
|
import type {
|
||||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
ChatImageAttachment,
|
||||||
|
ChatTimelineItem,
|
||||||
|
ChatTimelineMessageItem,
|
||||||
|
} from '@easyflow/common-ui';
|
||||||
|
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||||
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import type {AgentChatCapabilityPayload} from './api';
|
import {
|
||||||
import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api';
|
onAgentChatCacheClear,
|
||||||
|
resolveAgentChatIdentity,
|
||||||
|
RUNTIME_STORAGE_PREFIX,
|
||||||
|
} from '#/utils/agent-chat-cache';
|
||||||
|
|
||||||
import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter';
|
import type { AgentChatCapabilityPayload } from './api';
|
||||||
|
import {
|
||||||
|
generateAgentSessionId,
|
||||||
|
sendAgentChat,
|
||||||
|
stopAgentChatStream,
|
||||||
|
} from './api';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyAgentSseEnvelope,
|
||||||
|
parseAgentSseMessage,
|
||||||
|
} from './adapters/agentTimelineAdapter';
|
||||||
|
|
||||||
interface RuntimeSessionState {
|
interface RuntimeSessionState {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
identity: string;
|
||||||
items: ChatTimelineItem[];
|
items: ChatTimelineItem[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
roundId: string;
|
roundId: string;
|
||||||
@@ -37,17 +56,18 @@ interface StartOptions {
|
|||||||
agentName?: string;
|
agentName?: string;
|
||||||
baseItems?: ChatTimelineItem[];
|
baseItems?: ChatTimelineItem[];
|
||||||
capabilities?: AgentChatCapabilityPayload[];
|
capabilities?: AgentChatCapabilityPayload[];
|
||||||
|
imageUploadIds?: string[];
|
||||||
|
images?: ChatImageAttachment[];
|
||||||
|
onInputAccepted?: () => void | Promise<void>;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_PREFIX = 'easyflow:agent-chat-runtime';
|
const STORAGE_VERSION = 2;
|
||||||
const LATEST_STORAGE_KEY = `${STORAGE_PREFIX}:latest`;
|
|
||||||
const STORAGE_VERSION = 1;
|
|
||||||
|
|
||||||
const sessions = new Map<string, RuntimeSessionState>();
|
const sessions = new Map<string, RuntimeSessionState>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
let latestSessionId = '';
|
const latestSessionIds = new Map<string, string>();
|
||||||
|
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
const serialized = JSON.stringify(value);
|
const serialized = JSON.stringify(value);
|
||||||
@@ -58,8 +78,20 @@ function createRoundId() {
|
|||||||
return `agent-chat-round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
return `agent-chat-round-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function storageKey(sessionId: string) {
|
function identityScope() {
|
||||||
return `${STORAGE_PREFIX}:${sessionId}`;
|
return resolveAgentChatIdentity(useUserStore().userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionKey(identity: string, sessionId: string) {
|
||||||
|
return `${identity}:${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageKey(identity: string, sessionId: string) {
|
||||||
|
return `${RUNTIME_STORAGE_PREFIX}:${identity}:${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestStorageKey(identity: string) {
|
||||||
|
return `${RUNTIME_STORAGE_PREFIX}:${identity}:latest`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeSessionStorage() {
|
function safeSessionStorage() {
|
||||||
@@ -94,15 +126,22 @@ function persistSession(state: RuntimeSessionState) {
|
|||||||
version: STORAGE_VERSION,
|
version: STORAGE_VERSION,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
storage.setItem(storageKey(state.sessionId), JSON.stringify(snapshot));
|
storage.setItem(
|
||||||
storage.setItem(LATEST_STORAGE_KEY, state.sessionId);
|
storageKey(state.identity, state.sessionId),
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
);
|
||||||
|
storage.setItem(latestStorageKey(state.identity), state.sessionId);
|
||||||
} catch {
|
} catch {
|
||||||
// 缓存失败不影响正式聊天主流程。
|
// 缓存失败不影响正式聊天主流程。
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreSession(sessionId: string) {
|
function restoreSession(identity: string, sessionId: string) {
|
||||||
const existing = sessions.get(sessionId);
|
if (!identity) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const scopedSessionKey = sessionKey(identity, sessionId);
|
||||||
|
const existing = sessions.get(scopedSessionKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
@@ -111,7 +150,7 @@ function restoreSession(sessionId: string) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const raw = storage.getItem(storageKey(sessionId));
|
const raw = storage.getItem(storageKey(identity, sessionId));
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -124,6 +163,7 @@ function restoreSession(sessionId: string) {
|
|||||||
agentName: parsed.agentName,
|
agentName: parsed.agentName,
|
||||||
completed: parsed.completed,
|
completed: parsed.completed,
|
||||||
error: parsed.error,
|
error: parsed.error,
|
||||||
|
identity,
|
||||||
items: Array.isArray(parsed.items) ? parsed.items : [],
|
items: Array.isArray(parsed.items) ? parsed.items : [],
|
||||||
prompt: parsed.prompt,
|
prompt: parsed.prompt,
|
||||||
roundId: parsed.roundId,
|
roundId: parsed.roundId,
|
||||||
@@ -131,7 +171,7 @@ function restoreSession(sessionId: string) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
updatedAt: parsed.updatedAt,
|
updatedAt: parsed.updatedAt,
|
||||||
};
|
};
|
||||||
sessions.set(sessionId, restored);
|
sessions.set(scopedSessionKey, restored);
|
||||||
return restored;
|
return restored;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -140,25 +180,30 @@ function restoreSession(sessionId: string) {
|
|||||||
|
|
||||||
function upsertState(state: RuntimeSessionState) {
|
function upsertState(state: RuntimeSessionState) {
|
||||||
state.updatedAt = Date.now();
|
state.updatedAt = Date.now();
|
||||||
latestSessionId = state.sessionId;
|
latestSessionIds.set(state.identity, state.sessionId);
|
||||||
sessions.set(state.sessionId, state);
|
sessions.set(sessionKey(state.identity, state.sessionId), state);
|
||||||
persistSession(state);
|
persistSession(state);
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
function runningSession() {
|
function runningSession(identity = identityScope()) {
|
||||||
return [...sessions.values()].find((session) => session.sending);
|
return [...sessions.values()].find(
|
||||||
|
(session) => session.identity === identity && session.sending,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreLatestSession() {
|
function restoreLatestSession(identity = identityScope()) {
|
||||||
const running = runningSession();
|
if (!identity) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const running = runningSession(identity);
|
||||||
if (running) {
|
if (running) {
|
||||||
return running;
|
return running;
|
||||||
}
|
}
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
const storedSessionId = storage?.getItem(LATEST_STORAGE_KEY) || '';
|
const storedSessionId = storage?.getItem(latestStorageKey(identity)) || '';
|
||||||
const sessionId = latestSessionId || storedSessionId;
|
const sessionId = latestSessionIds.get(identity) || storedSessionId;
|
||||||
return sessionId ? restoreSession(sessionId) : undefined;
|
return sessionId ? restoreSession(identity, sessionId) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSessionId(sessionId?: string) {
|
async function resolveSessionId(sessionId?: string) {
|
||||||
@@ -176,12 +221,70 @@ function errorMessage(error: unknown) {
|
|||||||
return error instanceof Error ? error.message : '发送失败,请稍后再试';
|
return error instanceof Error ? error.message : '发送失败,请稍后再试';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAcceptedImages(payload: Record<string, any>) {
|
||||||
|
if (!Array.isArray(payload.images)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return payload.images
|
||||||
|
.map((value): ChatImageAttachment | undefined => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const image = value as Record<string, any>;
|
||||||
|
const previewUrl = String(image.previewUrl || '');
|
||||||
|
if (!previewUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
height: Number(image.height || 0) || undefined,
|
||||||
|
imageRef: String(image.imageRef || ''),
|
||||||
|
mimeType: String(image.mimeType || ''),
|
||||||
|
name: String(image.name || '图片'),
|
||||||
|
previewUrl,
|
||||||
|
size: Number(image.size || 0) || undefined,
|
||||||
|
status: 'ready',
|
||||||
|
width: Number(image.width || 0) || undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((image): image is ChatImageAttachment => Boolean(image));
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceAcceptedImages(
|
||||||
|
items: ChatTimelineItem[],
|
||||||
|
roundId: string,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const acceptedImages = normalizeAcceptedImages(payload);
|
||||||
|
if (acceptedImages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userMessage = items.find(
|
||||||
|
(item): item is ChatTimelineMessageItem =>
|
||||||
|
item.type === 'message' &&
|
||||||
|
item.role === 'user' &&
|
||||||
|
item.roundId === roundId,
|
||||||
|
);
|
||||||
|
if (userMessage) {
|
||||||
|
userMessage.images = acceptedImages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAgentChatCacheClear((identity) => {
|
||||||
|
for (const [key, session] of sessions) {
|
||||||
|
if (session.identity === identity) {
|
||||||
|
sessions.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latestSessionIds.delete(identity);
|
||||||
|
notify();
|
||||||
|
});
|
||||||
|
|
||||||
export const agentChatRuntimeManager = {
|
export const agentChatRuntimeManager = {
|
||||||
getSnapshot(sessionId?: string) {
|
getSnapshot(sessionId?: string) {
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const state = restoreSession(sessionId);
|
const state = restoreSession(identityScope(), sessionId);
|
||||||
return state ? clone(state) : undefined;
|
return state ? clone(state) : undefined;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -195,7 +298,7 @@ export const agentChatRuntimeManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
replaceItems(sessionId: string, items: ChatTimelineItem[]) {
|
replaceItems(sessionId: string, items: ChatTimelineItem[]) {
|
||||||
const state = restoreSession(sessionId);
|
const state = restoreSession(identityScope(), sessionId);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,7 +307,11 @@ export const agentChatRuntimeManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async start(options: StartOptions) {
|
async start(options: StartOptions) {
|
||||||
const active = runningSession();
|
const identity = identityScope();
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error('当前登录状态失效');
|
||||||
|
}
|
||||||
|
const active = runningSession(identity);
|
||||||
if (active) {
|
if (active) {
|
||||||
throw new Error('当前回复完成后再发送新消息');
|
throw new Error('当前回复完成后再发送新消息');
|
||||||
}
|
}
|
||||||
@@ -214,6 +321,7 @@ export const agentChatRuntimeManager = {
|
|||||||
agentId: options.agentId,
|
agentId: options.agentId,
|
||||||
agentName: options.agentName,
|
agentName: options.agentName,
|
||||||
completed: false,
|
completed: false,
|
||||||
|
identity,
|
||||||
items: clone(options.baseItems || []),
|
items: clone(options.baseItems || []),
|
||||||
prompt: options.prompt,
|
prompt: options.prompt,
|
||||||
roundId,
|
roundId,
|
||||||
@@ -222,6 +330,7 @@ export const agentChatRuntimeManager = {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
|
ChatTimelineBuilder.appendUserMessage(state.items, options.prompt, {
|
||||||
|
images: options.images,
|
||||||
roundId,
|
roundId,
|
||||||
});
|
});
|
||||||
upsertState(state);
|
upsertState(state);
|
||||||
@@ -230,12 +339,13 @@ export const agentChatRuntimeManager = {
|
|||||||
{
|
{
|
||||||
agentId: options.agentId,
|
agentId: options.agentId,
|
||||||
capabilities: options.capabilities,
|
capabilities: options.capabilities,
|
||||||
|
imageUploadIds: options.imageUploadIds,
|
||||||
prompt: options.prompt,
|
prompt: options.prompt,
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onError(error) {
|
onError(error) {
|
||||||
const current = sessions.get(sessionId);
|
const current = sessions.get(sessionKey(identity, sessionId));
|
||||||
if (!current || !current.sending) {
|
if (!current || !current.sending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -247,7 +357,7 @@ export const agentChatRuntimeManager = {
|
|||||||
upsertState(current);
|
upsertState(current);
|
||||||
},
|
},
|
||||||
onFinished() {
|
onFinished() {
|
||||||
const current = sessions.get(sessionId);
|
const current = sessions.get(sessionKey(identity, sessionId));
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -257,7 +367,7 @@ export const agentChatRuntimeManager = {
|
|||||||
upsertState(current);
|
upsertState(current);
|
||||||
},
|
},
|
||||||
onMessage(message) {
|
onMessage(message) {
|
||||||
const current = sessions.get(sessionId);
|
const current = sessions.get(sessionKey(identity, sessionId));
|
||||||
if (!current || !current.sending) {
|
if (!current || !current.sending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -265,6 +375,13 @@ export const agentChatRuntimeManager = {
|
|||||||
if (!envelope) {
|
if (!envelope) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
envelope.domain === 'SYSTEM' &&
|
||||||
|
envelope.type === 'INPUT_ACCEPTED'
|
||||||
|
) {
|
||||||
|
replaceAcceptedImages(current.items, roundId, envelope.payload);
|
||||||
|
void options.onInputAccepted?.();
|
||||||
|
}
|
||||||
applyAgentSseEnvelope(current.items, envelope, { roundId });
|
applyAgentSseEnvelope(current.items, envelope, { roundId });
|
||||||
upsertState(current);
|
upsertState(current);
|
||||||
},
|
},
|
||||||
@@ -275,7 +392,10 @@ export const agentChatRuntimeManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
stop(sessionId?: string) {
|
stop(sessionId?: string) {
|
||||||
const state = sessionId ? restoreSession(sessionId) : runningSession();
|
const identity = identityScope();
|
||||||
|
const state = sessionId
|
||||||
|
? restoreSession(identity, sessionId)
|
||||||
|
: runningSession(identity);
|
||||||
if (!state || !state.sending) {
|
if (!state || !state.sending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||||
|
|
||||||
import type {AgentInfo} from '../agents/types';
|
import type { AgentInfo } from '../agents/types';
|
||||||
|
|
||||||
import {api, SseClient} from '#/api/request';
|
import { api, SseClient } from '#/api/request';
|
||||||
|
|
||||||
const agentChatSseClient = new SseClient();
|
const agentChatSseClient = new SseClient();
|
||||||
|
|
||||||
@@ -170,6 +170,7 @@ export function sendAgentChat(
|
|||||||
data: {
|
data: {
|
||||||
agentId: number | string;
|
agentId: number | string;
|
||||||
capabilities?: AgentChatCapabilityPayload[];
|
capabilities?: AgentChatCapabilityPayload[];
|
||||||
|
imageUploadIds?: string[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
sessionId?: number | string;
|
sessionId?: number | string;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,12 +16,17 @@ import type {
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ChatTimeline, ChatTimelineBuilder } from '@easyflow/common-ui';
|
import {
|
||||||
|
ChatImageAttachments,
|
||||||
|
ChatTimeline,
|
||||||
|
ChatTimelineBuilder,
|
||||||
|
} from '@easyflow/common-ui';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Delete,
|
Delete,
|
||||||
EditPen,
|
EditPen,
|
||||||
MoreFilled,
|
MoreFilled,
|
||||||
|
Paperclip,
|
||||||
Plus,
|
Plus,
|
||||||
Promotion,
|
Promotion,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
@@ -41,6 +46,8 @@ import {
|
|||||||
import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue';
|
import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue';
|
||||||
import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue';
|
import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue';
|
||||||
import { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger';
|
import { useChatInputTrigger } from '#/components/chat-workspace/input-triggers/useChatInputTrigger';
|
||||||
|
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
|
||||||
|
import { loadAgentChatImage } from '#/components/ai-chat/mediaApi';
|
||||||
|
|
||||||
import AgentWelcomeState from '../agents/components/AgentWelcomeState.vue';
|
import AgentWelcomeState from '../agents/components/AgentWelcomeState.vue';
|
||||||
import { resolveInteractionDisplay } from '../agents/interaction-config';
|
import { resolveInteractionDisplay } from '../agents/interaction-config';
|
||||||
@@ -58,6 +65,10 @@ import {
|
|||||||
renameAgentSession,
|
renameAgentSession,
|
||||||
saveAgentSessionExtraKnowledges,
|
saveAgentSessionExtraKnowledges,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
import {
|
||||||
|
isMissingAgentSessionError,
|
||||||
|
resolveAgentSessionErrorMessage,
|
||||||
|
} from './sessionRecovery';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -67,8 +78,11 @@ const sessions = ref<AgentChatSessionView[]>([]);
|
|||||||
const timelineItems = ref<ChatTimelineItem[]>([]);
|
const timelineItems = ref<ChatTimelineItem[]>([]);
|
||||||
const selectedAgentId = ref('');
|
const selectedAgentId = ref('');
|
||||||
const currentSessionId = ref('');
|
const currentSessionId = ref('');
|
||||||
const promptText = ref('');
|
const composer = useAgentComposerDraft('FORMAL');
|
||||||
|
const promptText = composer.text;
|
||||||
const promptInputRef = ref();
|
const promptInputRef = ref();
|
||||||
|
const imageFileInputRef = ref<HTMLInputElement>();
|
||||||
|
const composerDragActive = ref(false);
|
||||||
const loadingAgents = ref(false);
|
const loadingAgents = ref(false);
|
||||||
const agentLoadError = ref('');
|
const agentLoadError = ref('');
|
||||||
const loadingSessions = ref(false);
|
const loadingSessions = ref(false);
|
||||||
@@ -88,6 +102,11 @@ let runtimeUnsubscribe: (() => void) | undefined;
|
|||||||
const selectedAgent = computed(() =>
|
const selectedAgent = computed(() =>
|
||||||
agents.value.find((agent) => String(agent.id) === selectedAgentId.value),
|
agents.value.find((agent) => String(agent.id) === selectedAgentId.value),
|
||||||
);
|
);
|
||||||
|
const selectedAgentImageSupport = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
selectedAgent.value?.publishedSnapshotJson?.modelSummary?.supportImage,
|
||||||
|
),
|
||||||
|
);
|
||||||
const interactionDisplay = computed(() =>
|
const interactionDisplay = computed(() =>
|
||||||
resolveInteractionDisplay(selectedAgent.value),
|
resolveInteractionDisplay(selectedAgent.value),
|
||||||
);
|
);
|
||||||
@@ -99,7 +118,12 @@ const currentSession = computed(() =>
|
|||||||
const canStopRuntime = computed(() => sending.value || runtimeRunning.value);
|
const canStopRuntime = computed(() => sending.value || runtimeRunning.value);
|
||||||
const canSend = computed(
|
const canSend = computed(
|
||||||
() =>
|
() =>
|
||||||
Boolean(promptText.value.trim()) &&
|
(Boolean(promptText.value.trim()) ||
|
||||||
|
composer.images.readyItems.value.length > 0) &&
|
||||||
|
!composer.images.uploading.value &&
|
||||||
|
!composer.images.items.value.some((item) => item.status === 'error') &&
|
||||||
|
(selectedAgentImageSupport.value !== false ||
|
||||||
|
composer.images.readyItems.value.length === 0) &&
|
||||||
Boolean(selectedAgentId.value) &&
|
Boolean(selectedAgentId.value) &&
|
||||||
!sending.value &&
|
!sending.value &&
|
||||||
!runtimeRunning.value,
|
!runtimeRunning.value,
|
||||||
@@ -217,6 +241,25 @@ async function syncSessionRoute(sessionId?: string) {
|
|||||||
await router.replace({ query: nextQuery });
|
await router.replace({ query: nextQuery });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeSessionFromPage(sessionId: string) {
|
||||||
|
sessions.value = sessions.value.filter(
|
||||||
|
(item) => String(item.sessionId) !== sessionId,
|
||||||
|
);
|
||||||
|
const isCurrentSession =
|
||||||
|
currentSessionId.value === sessionId ||
|
||||||
|
String(route.query.sessionId || '') === sessionId;
|
||||||
|
if (isCurrentSession) {
|
||||||
|
try {
|
||||||
|
await composer.clear();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.warning(
|
||||||
|
error instanceof Error ? error.message : '会话草稿清理失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await createNewSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAgents() {
|
async function loadAgents() {
|
||||||
loadingAgents.value = true;
|
loadingAgents.value = true;
|
||||||
agentLoadError.value = '';
|
agentLoadError.value = '';
|
||||||
@@ -381,10 +424,10 @@ function buildOptimisticSession(
|
|||||||
assistantName: selectedAgent.value?.name,
|
assistantName: selectedAgent.value?.name,
|
||||||
continuable: true,
|
continuable: true,
|
||||||
lastMessageAt: new Date().toISOString(),
|
lastMessageAt: new Date().toISOString(),
|
||||||
lastMessagePreview: prompt,
|
lastMessagePreview: prompt || '发送了图片',
|
||||||
messageCount: 1,
|
messageCount: 1,
|
||||||
sessionId,
|
sessionId,
|
||||||
title: prompt.slice(0, 48) || '新对话',
|
title: prompt.slice(0, 48) || '图片对话',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,10 +516,17 @@ async function loadConversation(sessionId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentSessionId.value = sessionId;
|
currentSessionId.value = sessionId;
|
||||||
|
if (selectedAgentId.value) {
|
||||||
|
await activateComposer(selectedAgentId.value, sessionId);
|
||||||
|
}
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
await syncSessionRoute(sessionId);
|
await syncSessionRoute(sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '会话加载失败');
|
if (isMissingAgentSessionError(error)) {
|
||||||
|
await removeSessionFromPage(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.error(resolveAgentSessionErrorMessage(error) || '会话加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
loadingConversation.value = false;
|
loadingConversation.value = false;
|
||||||
}
|
}
|
||||||
@@ -485,9 +535,17 @@ async function loadConversation(sessionId: string) {
|
|||||||
async function createNewSession() {
|
async function createNewSession() {
|
||||||
currentSessionId.value = '';
|
currentSessionId.value = '';
|
||||||
timelineItems.value = [];
|
timelineItems.value = [];
|
||||||
promptText.value = '';
|
|
||||||
extraKnowledgeIds.value = [];
|
extraKnowledgeIds.value = [];
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
|
if (selectedAgentId.value) {
|
||||||
|
try {
|
||||||
|
await composer.startNew(selectedAgentId.value);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.warning(
|
||||||
|
error instanceof Error ? error.message : '新会话创建失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
await syncSessionRoute();
|
await syncSessionRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,10 +566,18 @@ async function bindCreatedSession(sessionId: string, prompt: string) {
|
|||||||
await syncSessionRoute(sessionId);
|
await syncSessionRoute(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAgentChange() {
|
async function handleAgentChange() {
|
||||||
extraKnowledgeIds.value = [];
|
extraKnowledgeIds.value = [];
|
||||||
if (timelineItems.value.length > 0 || currentSessionId.value) {
|
if (timelineItems.value.length > 0 || currentSessionId.value) {
|
||||||
void createNewSession();
|
await createNewSession();
|
||||||
|
} else {
|
||||||
|
await activateComposer(selectedAgentId.value);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
selectedAgentImageSupport.value === false &&
|
||||||
|
composer.images.items.value.length > 0
|
||||||
|
) {
|
||||||
|
ElMessage.warning('当前智能体不支持图片,请先移除图片');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,14 +639,29 @@ function buildCapabilities() {
|
|||||||
|
|
||||||
async function sendContent(rawContent: string) {
|
async function sendContent(rawContent: string) {
|
||||||
const content = rawContent.trim();
|
const content = rawContent.trim();
|
||||||
if (!content || !selectedAgentId.value || sending.value) {
|
if (
|
||||||
|
(!content && composer.images.readyItems.value.length === 0) ||
|
||||||
|
!selectedAgentId.value ||
|
||||||
|
sending.value
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (runtimeRunning.value) {
|
if (runtimeRunning.value) {
|
||||||
ElMessage.warning('当前回复完成后再发送新消息');
|
ElMessage.warning('当前回复完成后再发送新消息');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
promptText.value = '';
|
if (composer.images.uploading.value) {
|
||||||
|
ElMessage.warning('图片上传完成后再发送');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const failedImage = composer.images.items.value.find(
|
||||||
|
(item) => item.status === 'error',
|
||||||
|
);
|
||||||
|
if (failedImage) {
|
||||||
|
ElMessage.error(failedImage.error || '请处理上传失败的图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await composer.flush();
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
try {
|
try {
|
||||||
const sessionId = await agentChatRuntimeManager.start({
|
const sessionId = await agentChatRuntimeManager.start({
|
||||||
@@ -588,14 +669,19 @@ async function sendContent(rawContent: string) {
|
|||||||
agentName: selectedAgent.value?.name,
|
agentName: selectedAgent.value?.name,
|
||||||
baseItems: timelineItems.value,
|
baseItems: timelineItems.value,
|
||||||
capabilities: buildCapabilities(),
|
capabilities: buildCapabilities(),
|
||||||
|
imageUploadIds: composer.images.uploadIds.value,
|
||||||
|
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
||||||
|
onInputAccepted: () =>
|
||||||
|
composer.markAccepted().catch(() => {
|
||||||
|
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||||
|
}),
|
||||||
prompt: content,
|
prompt: content,
|
||||||
sessionId: currentSessionId.value,
|
sessionId: composer.sessionId.value,
|
||||||
});
|
});
|
||||||
await bindCreatedSession(sessionId, content);
|
await bindCreatedSession(sessionId, content);
|
||||||
syncRuntimeSnapshot(sessionId);
|
syncRuntimeSnapshot(sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
promptText.value = content;
|
|
||||||
ElMessage.error(
|
ElMessage.error(
|
||||||
error instanceof Error ? error.message : '发送失败,请稍后再试',
|
error instanceof Error ? error.message : '发送失败,请稍后再试',
|
||||||
);
|
);
|
||||||
@@ -612,6 +698,112 @@ function handleSuggestedQuestion(question: string) {
|
|||||||
|
|
||||||
function handlePromptInput() {
|
function handlePromptInput() {
|
||||||
chatInputTrigger.sync();
|
chatInputTrigger.sync();
|
||||||
|
composer.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateComposer(agentId: string, sessionId?: string) {
|
||||||
|
if (!agentId) return;
|
||||||
|
try {
|
||||||
|
await composer.activate(agentId, sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.warning(
|
||||||
|
error instanceof Error ? error.message : '输入草稿恢复失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseImageFiles() {
|
||||||
|
imageFileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addImageFiles(files: File[]) {
|
||||||
|
if (!selectedAgentId.value) {
|
||||||
|
ElMessage.warning('请先选择智能体');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedAgentImageSupport.value === false) {
|
||||||
|
ElMessage.warning('当前智能体不支持图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await composer.ensureSession();
|
||||||
|
const rejected = await composer.images.addFiles(files, {
|
||||||
|
agentId: selectedAgentId.value,
|
||||||
|
mode: 'FORMAL',
|
||||||
|
sessionId: composer.sessionId.value,
|
||||||
|
});
|
||||||
|
composer.scheduleSave();
|
||||||
|
if (rejected > 0) {
|
||||||
|
ElMessage.warning('每次最多添加 5 张图片');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageFiles(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const files = [...(target.files || [])];
|
||||||
|
if (files.length) void addImageFiles(files);
|
||||||
|
target.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImagePaste(event: ClipboardEvent) {
|
||||||
|
const files = [...(event.clipboardData?.files || [])].filter((file) =>
|
||||||
|
file.type.startsWith('image/'),
|
||||||
|
);
|
||||||
|
if (!files.length) return;
|
||||||
|
event.preventDefault();
|
||||||
|
void addImageFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageDragEnter(event: DragEvent) {
|
||||||
|
if (
|
||||||
|
selectedAgentImageSupport.value !== false &&
|
||||||
|
!capabilityDisabled.value &&
|
||||||
|
composer.images.items.value.length < 5 &&
|
||||||
|
[...(event.dataTransfer?.types || [])].includes('Files')
|
||||||
|
) {
|
||||||
|
composerDragActive.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageDragLeave(event: DragEvent) {
|
||||||
|
const container = event.currentTarget as HTMLElement;
|
||||||
|
if (
|
||||||
|
!(event.relatedTarget instanceof Node) ||
|
||||||
|
!container.contains(event.relatedTarget)
|
||||||
|
) {
|
||||||
|
composerDragActive.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageDrop(event: DragEvent) {
|
||||||
|
composerDragActive.value = false;
|
||||||
|
if (
|
||||||
|
selectedAgentImageSupport.value === false ||
|
||||||
|
capabilityDisabled.value ||
|
||||||
|
composer.images.items.value.length >= 5
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const files = [...(event.dataTransfer?.files || [])].filter((file) =>
|
||||||
|
file.type.startsWith('image/'),
|
||||||
|
);
|
||||||
|
if (files.length) void addImageFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryImage(item: any) {
|
||||||
|
await composer.images.retry(item, {
|
||||||
|
agentId: selectedAgentId.value,
|
||||||
|
mode: 'FORMAL',
|
||||||
|
sessionId: composer.sessionId.value,
|
||||||
|
});
|
||||||
|
composer.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeImage(item: any) {
|
||||||
|
try {
|
||||||
|
await composer.images.remove(item);
|
||||||
|
composer.scheduleSave();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '图片删除失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePromptKeyup() {
|
function handlePromptKeyup() {
|
||||||
@@ -731,16 +923,15 @@ async function handleDeleteSession(session: AgentChatSessionView) {
|
|||||||
if (res.errorCode !== 0) {
|
if (res.errorCode !== 0) {
|
||||||
throw new Error(res.message || '删除失败');
|
throw new Error(res.message || '删除失败');
|
||||||
}
|
}
|
||||||
sessions.value = sessions.value.filter(
|
await removeSessionFromPage(sessionId);
|
||||||
(item) => String(item.sessionId) !== sessionId,
|
|
||||||
);
|
|
||||||
if (currentSessionId.value === sessionId) {
|
|
||||||
await createNewSession();
|
|
||||||
}
|
|
||||||
ElMessage.success('已删除');
|
ElMessage.success('已删除');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error !== 'cancel') {
|
if (error !== 'cancel') {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '删除失败');
|
if (isMissingAgentSessionError(error)) {
|
||||||
|
await removeSessionFromPage(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ElMessage.error(resolveAgentSessionErrorMessage(error) || '删除失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -801,7 +992,10 @@ async function bootstrap() {
|
|||||||
const latestSnapshot = agentChatRuntimeManager.getLatestSnapshot();
|
const latestSnapshot = agentChatRuntimeManager.getLatestSnapshot();
|
||||||
if (latestSnapshot?.items.length) {
|
if (latestSnapshot?.items.length) {
|
||||||
syncRuntimeSnapshot(latestSnapshot.sessionId);
|
syncRuntimeSnapshot(latestSnapshot.sessionId);
|
||||||
|
await activateComposer(selectedAgentId.value, latestSnapshot.sessionId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
await activateComposer(selectedAgentId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -936,6 +1130,7 @@ onBeforeUnmount(() => {
|
|||||||
<ChatTimeline
|
<ChatTimeline
|
||||||
v-else
|
v-else
|
||||||
:items="timelineItems"
|
:items="timelineItems"
|
||||||
|
:image-loader="loadAgentChatImage"
|
||||||
empty-text="选择智能体后开始对话"
|
empty-text="选择智能体后开始对话"
|
||||||
:approval-loading="Boolean(approvalLoadingKey)"
|
:approval-loading="Boolean(approvalLoadingKey)"
|
||||||
:copyable="canCopyMessage"
|
:copyable="canCopyMessage"
|
||||||
@@ -947,7 +1142,14 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="agent-chat__composer">
|
<div
|
||||||
|
class="agent-chat__composer"
|
||||||
|
:class="{ 'is-dragging': composerDragActive }"
|
||||||
|
@dragenter.prevent="handleImageDragEnter"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragleave.prevent="handleImageDragLeave"
|
||||||
|
@drop.prevent="handleImageDrop"
|
||||||
|
>
|
||||||
<ChatCapabilityMenu
|
<ChatCapabilityMenu
|
||||||
:disabled="capabilityDisabled"
|
:disabled="capabilityDisabled"
|
||||||
:extra-knowledge-ids="extraKnowledgeIds"
|
:extra-knowledge-ids="extraKnowledgeIds"
|
||||||
@@ -967,6 +1169,15 @@ onBeforeUnmount(() => {
|
|||||||
@select="handleTriggerSelect"
|
@select="handleTriggerSelect"
|
||||||
@set-active="chatInputTrigger.setActiveIndex"
|
@set-active="chatInputTrigger.setActiveIndex"
|
||||||
/>
|
/>
|
||||||
|
<ChatImageAttachments
|
||||||
|
v-if="composer.images.items.value.length"
|
||||||
|
:items="composer.images.items.value"
|
||||||
|
:image-loader="loadAgentChatImage"
|
||||||
|
removable
|
||||||
|
retryable
|
||||||
|
@remove="removeImage"
|
||||||
|
@retry="retryImage"
|
||||||
|
/>
|
||||||
<ElInput
|
<ElInput
|
||||||
ref="promptInputRef"
|
ref="promptInputRef"
|
||||||
v-model="promptText"
|
v-model="promptText"
|
||||||
@@ -980,9 +1191,31 @@ onBeforeUnmount(() => {
|
|||||||
@input="handlePromptInput"
|
@input="handlePromptInput"
|
||||||
@keydown="handlePromptKeydown"
|
@keydown="handlePromptKeydown"
|
||||||
@keyup="handlePromptKeyup"
|
@keyup="handlePromptKeyup"
|
||||||
|
@paste="handleImagePaste"
|
||||||
/>
|
/>
|
||||||
<div class="agent-chat__composer-footer">
|
<div class="agent-chat__composer-footer">
|
||||||
<div class="agent-chat__composer-tools">
|
<div class="agent-chat__composer-tools">
|
||||||
|
<template v-if="selectedAgentImageSupport !== false">
|
||||||
|
<input
|
||||||
|
ref="imageFileInputRef"
|
||||||
|
class="agent-chat__image-file-input"
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.webp,.gif,.bmp,image/png,image/jpeg,image/webp,image/gif,image/bmp"
|
||||||
|
multiple
|
||||||
|
@change="handleImageFiles"
|
||||||
|
/>
|
||||||
|
<ElButton
|
||||||
|
:icon="Paperclip"
|
||||||
|
circle
|
||||||
|
text
|
||||||
|
:disabled="
|
||||||
|
capabilityDisabled || composer.images.items.value.length >= 5
|
||||||
|
"
|
||||||
|
aria-label="添加图片"
|
||||||
|
title="添加图片"
|
||||||
|
@click="chooseImageFiles"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<ChatCapabilityMenu
|
<ChatCapabilityMenu
|
||||||
class="agent-chat__capability-entry"
|
class="agent-chat__capability-entry"
|
||||||
:disabled="capabilityDisabled"
|
:disabled="capabilityDisabled"
|
||||||
@@ -1177,12 +1410,11 @@ onBeforeUnmount(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding-bottom: 176px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__timeline-wrap.is-welcome {
|
.agent-chat__timeline-wrap.is-welcome {
|
||||||
padding: 0 min(8vw, 96px) 190px;
|
padding: 0 min(8vw, 96px);
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,20 +1427,24 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__composer {
|
.agent-chat__composer {
|
||||||
position: absolute;
|
position: relative;
|
||||||
right: min(8vw, 96px);
|
|
||||||
bottom: 24px;
|
|
||||||
left: min(8vw, 96px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
margin: 0 min(8vw, 96px) 24px;
|
||||||
background: var(--el-bg-color);
|
background: var(--el-bg-color);
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
box-shadow: var(--el-box-shadow-light);
|
box-shadow: var(--el-box-shadow-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__composer.is-dragging {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
border-color: var(--el-color-primary-light-5);
|
||||||
|
}
|
||||||
|
|
||||||
.agent-chat__trigger-panel {
|
.agent-chat__trigger-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + 10px);
|
bottom: calc(100% + 10px);
|
||||||
@@ -1241,6 +1477,10 @@ onBeforeUnmount(() => {
|
|||||||
max-width: calc(100% - 64px);
|
max-width: calc(100% - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__image-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-chat__capability-entry {
|
.agent-chat__capability-entry {
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
@@ -1343,7 +1583,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__timeline-wrap {
|
.agent-chat__timeline-wrap {
|
||||||
padding-bottom: 184px;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__timeline-wrap.is-welcome {
|
.agent-chat__timeline-wrap.is-welcome {
|
||||||
@@ -1353,7 +1593,7 @@ onBeforeUnmount(() => {
|
|||||||
.agent-chat__timeline-wrap.is-welcome :deep(.agent-welcome) {
|
.agent-chat__timeline-wrap.is-welcome :deep(.agent-welcome) {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
padding-bottom: 206px;
|
padding-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__timeline-wrap :deep(.chat-timeline) {
|
.agent-chat__timeline-wrap :deep(.chat-timeline) {
|
||||||
@@ -1361,9 +1601,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__composer {
|
.agent-chat__composer {
|
||||||
right: 16px;
|
margin: 0 16px 16px;
|
||||||
bottom: 16px;
|
|
||||||
left: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__composer-footer {
|
.agent-chat__composer-footer {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isMissingAgentSessionError,
|
||||||
|
resolveAgentSessionErrorMessage,
|
||||||
|
} from './sessionRecovery';
|
||||||
|
|
||||||
|
describe('agent chat session recovery', () => {
|
||||||
|
it('识别请求层直接抛出的业务响应', () => {
|
||||||
|
const error = {
|
||||||
|
errorCode: 400,
|
||||||
|
message: 'Agent 会话不存在',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isMissingAgentSessionError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('识别包含响应体的网络错误', () => {
|
||||||
|
const error = {
|
||||||
|
response: {
|
||||||
|
data: {
|
||||||
|
message: 'Agent 会话不存在',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isMissingAgentSessionError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保留其他错误消息供页面展示', () => {
|
||||||
|
const error = new Error('服务暂时不可用');
|
||||||
|
|
||||||
|
expect(isMissingAgentSessionError(error)).toBe(false);
|
||||||
|
expect(resolveAgentSessionErrorMessage(error)).toBe('服务暂时不可用');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const MISSING_AGENT_SESSION_MESSAGE = 'Agent 会话不存在';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取请求层抛出的错误消息。
|
||||||
|
*/
|
||||||
|
export function resolveAgentSessionErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const payload = error as Record<string, unknown>;
|
||||||
|
if (typeof payload.message === 'string') {
|
||||||
|
return payload.message;
|
||||||
|
}
|
||||||
|
const response = payload.response;
|
||||||
|
if (!response || typeof response !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const data = (response as Record<string, unknown>).data;
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const message = (data as Record<string, unknown>).message;
|
||||||
|
return typeof message === 'string' ? message : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断请求是否因 Agent 会话已不存在而失败。
|
||||||
|
*/
|
||||||
|
export function isMissingAgentSessionError(error: unknown): boolean {
|
||||||
|
return resolveAgentSessionErrorMessage(error).includes(
|
||||||
|
MISSING_AGENT_SESSION_MESSAGE,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,6 +42,12 @@ const selectedKnowledge = computed(() => {
|
|||||||
|
|
||||||
const activeBaseTab = ref<'basic' | 'interaction'>('basic');
|
const activeBaseTab = ref<'basic' | 'interaction'>('basic');
|
||||||
const interactionForm = ref<InstanceType<typeof AgentInteractionForm>>();
|
const interactionForm = ref<InstanceType<typeof AgentInteractionForm>>();
|
||||||
|
const selectedModel = computed(() =>
|
||||||
|
props.models.find((item) => item.value === String(props.state.agent.modelId)),
|
||||||
|
);
|
||||||
|
const tryoutImageEnabled = computed(() =>
|
||||||
|
Boolean(selectedModel.value?.raw?.supportImage),
|
||||||
|
);
|
||||||
|
|
||||||
function isInteractionIssue(issue?: AgentValidationIssue) {
|
function isInteractionIssue(issue?: AgentValidationIssue) {
|
||||||
return issue?.field?.startsWith('interaction.');
|
return issue?.field?.startsWith('interaction.');
|
||||||
@@ -95,6 +101,7 @@ const selectedToolOptions = computed(() => {
|
|||||||
<template v-if="state.panelMode === 'tryout'">
|
<template v-if="state.panelMode === 'tryout'">
|
||||||
<AgentTryoutPanel
|
<AgentTryoutPanel
|
||||||
:agent="state.agent"
|
:agent="state.agent"
|
||||||
|
:image-enabled="tryoutImageEnabled"
|
||||||
:tool-bindings="state.toolBindings"
|
:tool-bindings="state.toolBindings"
|
||||||
:knowledge-bindings="state.knowledgeBindings"
|
:knowledge-bindings="state.knowledgeBindings"
|
||||||
@close="emit('closeTryout')"
|
@close="emit('closeTryout')"
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { BrushCleaning } from '@easyflow/icons';
|
|||||||
import { ElButton, ElMessage } from 'element-plus';
|
import { ElButton, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
|
import AiChatPanel from '#/components/ai-chat/AiChatPanel.vue';
|
||||||
|
import { loadAgentChatImage } from '#/components/ai-chat/mediaApi';
|
||||||
|
import { useAgentComposerDraft } from '#/components/ai-chat/useAgentComposerDraft';
|
||||||
|
|
||||||
import { approveAgentRun, rejectAgentRun } from '../api';
|
import { approveAgentRun, rejectAgentRun } from '../api';
|
||||||
import { useAgentTryoutStream } from '../composables/useAgentTryoutStream';
|
import { useAgentTryoutStream } from '../composables/useAgentTryoutStream';
|
||||||
@@ -26,6 +28,7 @@ import AgentWelcomeState from './AgentWelcomeState.vue';
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
agent: AgentInfo;
|
agent: AgentInfo;
|
||||||
|
imageEnabled?: boolean;
|
||||||
knowledgeBindings: AgentKnowledgeBinding[];
|
knowledgeBindings: AgentKnowledgeBinding[];
|
||||||
toolBindings: AgentToolBinding[];
|
toolBindings: AgentToolBinding[];
|
||||||
}>();
|
}>();
|
||||||
@@ -45,6 +48,7 @@ const {
|
|||||||
stop,
|
stop,
|
||||||
} = useAgentTryoutStream();
|
} = useAgentTryoutStream();
|
||||||
const approvalLoading = ref(false);
|
const approvalLoading = ref(false);
|
||||||
|
const composer = useAgentComposerDraft('DRAFT');
|
||||||
const interactionDisplay = computed(() =>
|
const interactionDisplay = computed(() =>
|
||||||
resolveInteractionDisplay(props.agent),
|
resolveInteractionDisplay(props.agent),
|
||||||
);
|
);
|
||||||
@@ -58,18 +62,27 @@ function getDraftContext() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncCurrentDraftContext(restore = false) {
|
function syncCurrentDraftContext(restore = false) {
|
||||||
syncDraftContext(getDraftContext(), restore);
|
syncDraftContext(getDraftContext(), restore, composer.sessionId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
async function activateComposer() {
|
||||||
syncCurrentDraftContext(true);
|
const agentId = String(props.agent.id || '');
|
||||||
});
|
if (!agentId) return;
|
||||||
|
try {
|
||||||
|
await composer.activate(agentId, `agent-draft-${agentId}`);
|
||||||
|
syncCurrentDraftContext(true);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.warning(
|
||||||
|
error instanceof Error ? error.message : '输入草稿恢复失败',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => void activateComposer());
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.agent.id, props.agent.localId],
|
() => [props.agent.id, props.agent.localId],
|
||||||
() => {
|
() => void activateComposer(),
|
||||||
syncCurrentDraftContext(true);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -82,12 +95,35 @@ watch(
|
|||||||
|
|
||||||
async function handleSend(prompt: string) {
|
async function handleSend(prompt: string) {
|
||||||
if (loading.value || approvalLoading.value) return;
|
if (loading.value || approvalLoading.value) return;
|
||||||
|
if (composer.images.uploading.value) {
|
||||||
|
ElMessage.warning('图片上传完成后再发送');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const failedImage = composer.images.items.value.find(
|
||||||
|
(item) => item.status === 'error',
|
||||||
|
);
|
||||||
|
if (failedImage) {
|
||||||
|
ElMessage.error(failedImage.error || '请处理上传失败的图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await composer.flush();
|
||||||
await sendDraft({
|
await sendDraft({
|
||||||
...getDraftContext(),
|
...getDraftContext(),
|
||||||
prompt,
|
prompt,
|
||||||
|
imageUploadIds: composer.images.uploadIds.value,
|
||||||
|
images: composer.images.readyItems.value.map((item) => ({ ...item })),
|
||||||
|
sessionId: composer.sessionId.value,
|
||||||
|
onAccepted: () =>
|
||||||
|
composer.markAccepted().catch(() => {
|
||||||
|
ElMessage.warning('消息已发送,草稿将在过期后自动清理');
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleDraftTextInput() {
|
||||||
|
composer.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
function handleSuggestedQuestion(question: string) {
|
function handleSuggestedQuestion(question: string) {
|
||||||
void handleSend(question);
|
void handleSend(question);
|
||||||
}
|
}
|
||||||
@@ -137,12 +173,49 @@ function handleSelectNextVariant(item: ChatTimelineMessageItem) {
|
|||||||
async function handleClearSession() {
|
async function handleClearSession() {
|
||||||
try {
|
try {
|
||||||
await clearDraftSession();
|
await clearDraftSession();
|
||||||
|
await composer.clear();
|
||||||
|
await activateComposer();
|
||||||
ElMessage.success('已清理会话');
|
ElMessage.success('已清理会话');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '清理会话失败');
|
ElMessage.error(error instanceof Error ? error.message : '清理会话失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAddFiles(files: File[]) {
|
||||||
|
if (!props.agent.id) {
|
||||||
|
ElMessage.warning('请先保存智能体');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await composer.ensureSession();
|
||||||
|
const rejected = await composer.images.addFiles(files, {
|
||||||
|
agentId: String(props.agent.id),
|
||||||
|
mode: 'DRAFT',
|
||||||
|
sessionId: composer.sessionId.value,
|
||||||
|
});
|
||||||
|
if (rejected > 0) {
|
||||||
|
ElMessage.warning('每次最多添加 5 张图片');
|
||||||
|
}
|
||||||
|
composer.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRetryImage(item: any) {
|
||||||
|
await composer.images.retry(item, {
|
||||||
|
agentId: String(props.agent.id),
|
||||||
|
mode: 'DRAFT',
|
||||||
|
sessionId: composer.sessionId.value,
|
||||||
|
});
|
||||||
|
composer.scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveImage(item: any) {
|
||||||
|
try {
|
||||||
|
await composer.images.remove(item);
|
||||||
|
composer.scheduleSave();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '图片删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
if (!loading.value) {
|
if (!loading.value) {
|
||||||
return;
|
return;
|
||||||
@@ -189,14 +262,22 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AiChatPanel
|
<AiChatPanel
|
||||||
|
v-model="composer.text.value"
|
||||||
:title="agent.name || '草稿试运行'"
|
:title="agent.name || '草稿试运行'"
|
||||||
empty-text="输入问题试运行当前智能体"
|
empty-text="输入问题试运行当前智能体"
|
||||||
closable
|
closable
|
||||||
:messages="[]"
|
:messages="[]"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
:images="composer.images.items.value"
|
||||||
|
:image-enabled="imageEnabled"
|
||||||
|
:image-loader="loadAgentChatImage"
|
||||||
:placeholder="interactionDisplay.inputPlaceholder"
|
:placeholder="interactionDisplay.inputPlaceholder"
|
||||||
:approval-loading="approvalLoading"
|
:approval-loading="approvalLoading"
|
||||||
@send="handleSend"
|
@send="handleSend"
|
||||||
|
@update:model-value="handleDraftTextInput"
|
||||||
|
@add-files="handleAddFiles"
|
||||||
|
@remove-image="handleRemoveImage"
|
||||||
|
@retry-image="handleRetryImage"
|
||||||
@stop="handleStop"
|
@stop="handleStop"
|
||||||
@approve="handleApprove"
|
@approve="handleApprove"
|
||||||
@reject="handleReject"
|
@reject="handleReject"
|
||||||
@@ -226,6 +307,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
|
|||||||
<ChatTimeline
|
<ChatTimeline
|
||||||
v-else
|
v-else
|
||||||
:items="timelineItems"
|
:items="timelineItems"
|
||||||
|
:image-loader="loadAgentChatImage"
|
||||||
empty-text="输入问题试运行当前智能体"
|
empty-text="输入问题试运行当前智能体"
|
||||||
:approval-loading="approvalLoading"
|
:approval-loading="approvalLoading"
|
||||||
:copyable="canCopyMessage"
|
:copyable="canCopyMessage"
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ChatImageAttachment,
|
||||||
ChatTimelineItem,
|
ChatTimelineItem,
|
||||||
ChatTimelineKnowledgeHit,
|
ChatTimelineKnowledgeHit,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineToolStatus,
|
ChatTimelineToolStatus,
|
||||||
} from '@easyflow/common-ui';
|
} from '@easyflow/common-ui';
|
||||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||||
|
|
||||||
interface AgentTryoutRuntimeEvent {
|
interface AgentTryoutRuntimeEvent {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
@@ -25,6 +26,7 @@ interface AgentTryoutRawVariant {
|
|||||||
|
|
||||||
interface AgentTryoutRawRound {
|
interface AgentTryoutRawRound {
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
images?: ChatImageAttachment[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
roundId: string;
|
roundId: string;
|
||||||
selectedVariantIndex: number;
|
selectedVariantIndex: number;
|
||||||
@@ -110,7 +112,9 @@ function createVariant(variantIndex: number): AgentTryoutRawVariant {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRuntimeEvent(value: any): AgentTryoutRuntimeEvent | undefined {
|
function normalizeRuntimeEvent(
|
||||||
|
value: any,
|
||||||
|
): AgentTryoutRuntimeEvent | undefined {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -135,8 +139,9 @@ function normalizeVariant(value: any, index: number) {
|
|||||||
? value.runtimeEvents
|
? value.runtimeEvents
|
||||||
.map((item: any) => normalizeRuntimeEvent(item))
|
.map((item: any) => normalizeRuntimeEvent(item))
|
||||||
.filter(
|
.filter(
|
||||||
(item: AgentTryoutRuntimeEvent | undefined): item is AgentTryoutRuntimeEvent =>
|
(
|
||||||
Boolean(item),
|
item: AgentTryoutRuntimeEvent | undefined,
|
||||||
|
): item is AgentTryoutRuntimeEvent => Boolean(item),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
@@ -157,7 +162,8 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
|
|||||||
}
|
}
|
||||||
const prompt = asText(value.prompt);
|
const prompt = asText(value.prompt);
|
||||||
const roundId = asText(value.roundId);
|
const roundId = asText(value.roundId);
|
||||||
if (!prompt || !roundId) {
|
const images = Array.isArray(value.images) ? value.images.slice(0, 5) : [];
|
||||||
|
if ((!prompt && images.length === 0) || !roundId) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const variants = Array.isArray(value.variants)
|
const variants = Array.isArray(value.variants)
|
||||||
@@ -174,6 +180,7 @@ function normalizeRound(value: any): AgentTryoutRawRound | undefined {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
createdAt: Number(value.createdAt || Date.now()),
|
createdAt: Number(value.createdAt || Date.now()),
|
||||||
|
images,
|
||||||
prompt,
|
prompt,
|
||||||
roundId,
|
roundId,
|
||||||
selectedVariantIndex,
|
selectedVariantIndex,
|
||||||
@@ -210,7 +217,10 @@ function restoreSession(mode: string, sessionId: string) {
|
|||||||
.map((item) => normalizeRound(item))
|
.map((item) => normalizeRound(item))
|
||||||
.filter((item): item is AgentTryoutRawRound => Boolean(item))
|
.filter((item): item is AgentTryoutRawRound => Boolean(item))
|
||||||
: [];
|
: [];
|
||||||
memorySessions.set(key, rounds.map((item) => clone(item)));
|
memorySessions.set(
|
||||||
|
key,
|
||||||
|
rounds.map((item) => clone(item)),
|
||||||
|
);
|
||||||
return rounds;
|
return rounds;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
@@ -228,7 +238,10 @@ function persistSession(
|
|||||||
sessionId,
|
sessionId,
|
||||||
version: STORAGE_VERSION,
|
version: STORAGE_VERSION,
|
||||||
};
|
};
|
||||||
memorySessions.set(key, snapshot.rounds.map((item) => clone(item)));
|
memorySessions.set(
|
||||||
|
key,
|
||||||
|
snapshot.rounds.map((item) => clone(item)),
|
||||||
|
);
|
||||||
const storage = safeSessionStorage();
|
const storage = safeSessionStorage();
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
return;
|
return;
|
||||||
@@ -269,7 +282,9 @@ function visibleText(item: ChatTimelineMessageItem) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUserMessage(item: ChatTimelineItem): item is ChatTimelineMessageItem {
|
function isUserMessage(
|
||||||
|
item: ChatTimelineItem,
|
||||||
|
): item is ChatTimelineMessageItem {
|
||||||
return item.type === 'message' && item.role === 'user';
|
return item.type === 'message' && item.role === 'user';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +341,10 @@ function normalizeAssistantPartIds(
|
|||||||
const segment = assistantSegmentIndex(items, roundId);
|
const segment = assistantSegmentIndex(items, roundId);
|
||||||
const latest = [...items]
|
const latest = [...items]
|
||||||
.reverse()
|
.reverse()
|
||||||
.find((item): item is ChatTimelineMessageItem => isAssistantMessage(item) && item.roundId === roundId);
|
.find(
|
||||||
|
(item): item is ChatTimelineMessageItem =>
|
||||||
|
isAssistantMessage(item) && item.roundId === roundId,
|
||||||
|
);
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -468,7 +486,9 @@ function projectEventToTimeline(
|
|||||||
metadata: payload.metadata,
|
metadata: payload.metadata,
|
||||||
requestId: asText(payload.requestId),
|
requestId: asText(payload.requestId),
|
||||||
resumeToken: asText(payload.resumeToken),
|
resumeToken: asText(payload.resumeToken),
|
||||||
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
|
toolCallId: asText(
|
||||||
|
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||||
|
),
|
||||||
toolDisplayName: asText(payload.toolDisplayName),
|
toolDisplayName: asText(payload.toolDisplayName),
|
||||||
toolName: asText(payload.toolName),
|
toolName: asText(payload.toolName),
|
||||||
toolType: asText(payload.toolType),
|
toolType: asText(payload.toolType),
|
||||||
@@ -482,7 +502,9 @@ function projectEventToTimeline(
|
|||||||
ChatTimelineBuilder.markToolApproving(items, {
|
ChatTimelineBuilder.markToolApproving(items, {
|
||||||
requestId: asText(payload.requestId),
|
requestId: asText(payload.requestId),
|
||||||
resumeToken: asText(payload.resumeToken),
|
resumeToken: asText(payload.resumeToken),
|
||||||
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
|
toolCallId: asText(
|
||||||
|
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -491,7 +513,9 @@ function projectEventToTimeline(
|
|||||||
reason: asText(payload.reason),
|
reason: asText(payload.reason),
|
||||||
requestId: asText(payload.requestId),
|
requestId: asText(payload.requestId),
|
||||||
resumeToken: asText(payload.resumeToken),
|
resumeToken: asText(payload.resumeToken),
|
||||||
toolCallId: asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id),
|
toolCallId: asText(
|
||||||
|
payload.toolCallId ?? payload.tool_call_id ?? payload.id,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -508,8 +532,12 @@ function projectEventToTimeline(
|
|||||||
ChatTimelineBuilder.upsertToolCall(items, {
|
ChatTimelineBuilder.upsertToolCall(items, {
|
||||||
input: payload.input ?? payload.toolInput,
|
input: payload.input ?? payload.toolInput,
|
||||||
output: asyncTool
|
output: asyncTool
|
||||||
? payload.summary ?? payload.label ?? payload.output ?? payload.result ?? payload.text
|
? (payload.summary ??
|
||||||
: payload.output ?? payload.result ?? payload.text,
|
payload.label ??
|
||||||
|
payload.output ??
|
||||||
|
payload.result ??
|
||||||
|
payload.text)
|
||||||
|
: (payload.output ?? payload.result ?? payload.text),
|
||||||
status: asyncTool
|
status: asyncTool
|
||||||
? asyncToolTimelineStatus(payload)
|
? asyncToolTimelineStatus(payload)
|
||||||
: type === 'TOOL_RESULT'
|
: type === 'TOOL_RESULT'
|
||||||
@@ -521,8 +549,17 @@ function projectEventToTimeline(
|
|||||||
variantIndex,
|
variantIndex,
|
||||||
'knowledge-retrieval',
|
'knowledge-retrieval',
|
||||||
),
|
),
|
||||||
toolCallId: asText(payload.toolCallId ?? payload.taskId ?? payload.tool_call_id ?? payload.id),
|
toolCallId: asText(
|
||||||
toolName: asyncTool ? displayToolName : isHiddenToolName(rawToolName) ? rawToolName : displayToolName,
|
payload.toolCallId ??
|
||||||
|
payload.taskId ??
|
||||||
|
payload.tool_call_id ??
|
||||||
|
payload.id,
|
||||||
|
),
|
||||||
|
toolName: asyncTool
|
||||||
|
? displayToolName
|
||||||
|
: isHiddenToolName(rawToolName)
|
||||||
|
? rawToolName
|
||||||
|
: displayToolName,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -568,7 +605,9 @@ function projectEventToTimeline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function asyncToolTimelineStatus(payload: Record<string, unknown>): ChatTimelineToolStatus {
|
function asyncToolTimelineStatus(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): ChatTimelineToolStatus {
|
||||||
const status = asText(payload.status).toUpperCase();
|
const status = asText(payload.status).toUpperCase();
|
||||||
if (status === 'SUCCEEDED') return 'success';
|
if (status === 'SUCCEEDED') return 'success';
|
||||||
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
|
if (status === 'FAILED' || status === 'TIMEOUT' || status === 'CANCELLED') {
|
||||||
@@ -612,7 +651,10 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
|
|
||||||
function schedulePersist() {
|
function schedulePersist() {
|
||||||
const key = storageKey(options.mode, options.sessionId);
|
const key = storageKey(options.mode, options.sessionId);
|
||||||
memorySessions.set(key, [...rounds.values()].map((item) => clone(item)));
|
memorySessions.set(
|
||||||
|
key,
|
||||||
|
[...rounds.values()].map((item) => clone(item)),
|
||||||
|
);
|
||||||
if (persistTimer) {
|
if (persistTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -630,11 +672,12 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
removeStoredSession(options.mode, options.sessionId);
|
removeStoredSession(options.mode, options.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRound(prompt: string) {
|
function createRound(prompt: string, images: ChatImageAttachment[] = []) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const roundId = createRoundId();
|
const roundId = createRoundId();
|
||||||
rounds.set(roundId, {
|
rounds.set(roundId, {
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
|
images: clone(images),
|
||||||
prompt,
|
prompt,
|
||||||
roundId,
|
roundId,
|
||||||
selectedVariantIndex: 1,
|
selectedVariantIndex: 1,
|
||||||
@@ -738,6 +781,7 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
for (const round of sortedRounds(rounds)) {
|
for (const round of sortedRounds(rounds)) {
|
||||||
ChatTimelineBuilder.appendUserMessage(items, round.prompt, {
|
ChatTimelineBuilder.appendUserMessage(items, round.prompt, {
|
||||||
id: `user-${round.roundId}`,
|
id: `user-${round.roundId}`,
|
||||||
|
images: round.images,
|
||||||
roundId: round.roundId,
|
roundId: round.roundId,
|
||||||
});
|
});
|
||||||
const variant = selectedVariant(round);
|
const variant = selectedVariant(round);
|
||||||
@@ -745,7 +789,12 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const event of variant.runtimeEvents) {
|
for (const event of variant.runtimeEvents) {
|
||||||
projectEventToTimeline(items, event, round.roundId, variant.variantIndex);
|
projectEventToTimeline(
|
||||||
|
items,
|
||||||
|
event,
|
||||||
|
round.roundId,
|
||||||
|
variant.variantIndex,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (variant.status === 'completed' || variant.status === 'error') {
|
if (variant.status === 'completed' || variant.status === 'error') {
|
||||||
ChatTimelineBuilder.finalize(items);
|
ChatTimelineBuilder.finalize(items);
|
||||||
@@ -761,10 +810,7 @@ export function useAgentTryoutRawRounds(options: {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectVariant(
|
function selectVariant(roundId: string, direction: 'next' | 'previous') {
|
||||||
roundId: string,
|
|
||||||
direction: 'next' | 'previous',
|
|
||||||
) {
|
|
||||||
const round = rounds.get(roundId);
|
const round = rounds.get(roundId);
|
||||||
if (!round) {
|
if (!round) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import type {ServerSentEventMessage} from 'fetch-event-stream';
|
import type { ServerSentEventMessage } from 'fetch-event-stream';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
ChatImageAttachment,
|
||||||
ChatTimelineItem as ChatTimelineItemType,
|
ChatTimelineItem as ChatTimelineItemType,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
} from '@easyflow/common-ui';
|
} from '@easyflow/common-ui';
|
||||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
import { ChatTimelineBuilder } from '@easyflow/common-ui';
|
||||||
|
|
||||||
import type {AgentInfo, AgentKnowledgeBinding, AgentToolBinding,} from '../types';
|
import type {
|
||||||
|
AgentInfo,
|
||||||
|
AgentKnowledgeBinding,
|
||||||
|
AgentToolBinding,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
import {ref} from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import {sseClient} from '#/api/request';
|
import { sseClient } from '#/api/request';
|
||||||
|
|
||||||
import {clearAgentDraftSession} from '../api';
|
import { clearAgentDraftSession } from '../api';
|
||||||
import {useAgentTryoutRawRounds} from './useAgentTryoutRawRounds';
|
import { useAgentTryoutRawRounds } from './useAgentTryoutRawRounds';
|
||||||
|
|
||||||
function resolveDraftSessionId(agent: AgentInfo) {
|
function resolveDraftSessionId(agent: AgentInfo) {
|
||||||
return `agent-draft-${agent.id || agent.localId || 'unsaved'}`;
|
return `agent-draft-${agent.id || agent.localId || 'unsaved'}`;
|
||||||
@@ -99,8 +104,13 @@ export function useAgentTryoutStream() {
|
|||||||
timelineItems.value = rawRounds?.buildTimelineItems() || [];
|
timelineItems.value = rawRounds?.buildTimelineItems() || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDraftContext(payload: DraftRuntimeContext, restore = false) {
|
function syncDraftContext(
|
||||||
const sessionId = resolveDraftSessionId(payload.agent);
|
payload: DraftRuntimeContext,
|
||||||
|
restore = false,
|
||||||
|
requestedSessionId?: string,
|
||||||
|
) {
|
||||||
|
const sessionId =
|
||||||
|
requestedSessionId || resolveDraftSessionId(payload.agent);
|
||||||
const sessionChanged = activeSessionId !== sessionId;
|
const sessionChanged = activeSessionId !== sessionId;
|
||||||
activeSessionId = sessionId;
|
activeSessionId = sessionId;
|
||||||
if (!rawRounds || sessionChanged) {
|
if (!rawRounds || sessionChanged) {
|
||||||
@@ -206,26 +216,44 @@ export function useAgentTryoutStream() {
|
|||||||
|
|
||||||
async function runDraft(payload: {
|
async function runDraft(payload: {
|
||||||
agent: AgentInfo;
|
agent: AgentInfo;
|
||||||
|
images?: ChatImageAttachment[];
|
||||||
|
imageUploadIds?: string[];
|
||||||
knowledgeBindings: AgentKnowledgeBinding[];
|
knowledgeBindings: AgentKnowledgeBinding[];
|
||||||
|
onAccepted?: () => void | Promise<void>;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
sessionId?: string;
|
||||||
toolBindings: AgentToolBinding[];
|
toolBindings: AgentToolBinding[];
|
||||||
}) {
|
}) {
|
||||||
syncDraftContext(payload);
|
syncDraftContext(payload, false, payload.sessionId);
|
||||||
if (!rawRounds) {
|
if (!rawRounds) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activeRoundId = rawRounds.createRound(payload.prompt);
|
activeRoundId = rawRounds.createRound(payload.prompt, payload.images);
|
||||||
rebuildTimeline();
|
rebuildTimeline();
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
userStopped = false;
|
userStopped = false;
|
||||||
|
let accepted = false;
|
||||||
await sseClient.post(
|
await sseClient.post(
|
||||||
'/api/v1/agent/chat/draft',
|
'/api/v1/agent/chat/draft',
|
||||||
{
|
{
|
||||||
...payload,
|
agent: payload.agent,
|
||||||
|
imageUploadIds: payload.imageUploadIds,
|
||||||
|
knowledgeBindings: payload.knowledgeBindings,
|
||||||
|
prompt: payload.prompt,
|
||||||
sessionId: activeSessionId,
|
sessionId: activeSessionId,
|
||||||
|
toolBindings: payload.toolBindings,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onMessage: handleMessage,
|
onMessage: (message) => {
|
||||||
|
const envelope = resolveEnvelope(parseEventData(message));
|
||||||
|
const domain = String(envelope.domain || '').toUpperCase();
|
||||||
|
const type = String(envelope.type || '').toUpperCase();
|
||||||
|
if (!accepted && domain === 'SYSTEM' && type === 'INPUT_ACCEPTED') {
|
||||||
|
accepted = true;
|
||||||
|
void payload.onAccepted?.();
|
||||||
|
}
|
||||||
|
handleMessage(message);
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
if (shouldIgnoreStoppedError(error)) {
|
if (shouldIgnoreStoppedError(error)) {
|
||||||
return;
|
return;
|
||||||
@@ -257,8 +285,12 @@ export function useAgentTryoutStream() {
|
|||||||
|
|
||||||
async function sendDraft(payload: {
|
async function sendDraft(payload: {
|
||||||
agent: AgentInfo;
|
agent: AgentInfo;
|
||||||
|
images?: ChatImageAttachment[];
|
||||||
|
imageUploadIds?: string[];
|
||||||
knowledgeBindings: AgentKnowledgeBinding[];
|
knowledgeBindings: AgentKnowledgeBinding[];
|
||||||
|
onAccepted?: () => void | Promise<void>;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
sessionId?: string;
|
||||||
toolBindings: AgentToolBinding[];
|
toolBindings: AgentToolBinding[];
|
||||||
}) {
|
}) {
|
||||||
await runDraft(payload);
|
await runDraft(payload);
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ const formData = reactive<FormData>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
|
const modelAbility = ref<ModelAbilityItem[]>(getDefaultModelAbility());
|
||||||
|
const visibleModelAbility = computed(() =>
|
||||||
|
modelAbility.value.filter(
|
||||||
|
(item) => item.field !== 'supportImageB64Only' || formData.supportImage,
|
||||||
|
),
|
||||||
|
);
|
||||||
type SelectableModelType = '' | 'embeddingModel' | 'rerankModel';
|
type SelectableModelType = '' | 'embeddingModel' | 'rerankModel';
|
||||||
|
|
||||||
const selectedModelType = ref<SelectableModelType>('');
|
const selectedModelType = ref<SelectableModelType>('');
|
||||||
@@ -129,6 +134,13 @@ const handleTagClick = (item: ModelAbilityItem) => {
|
|||||||
}
|
}
|
||||||
item.selected = !item.selected;
|
item.selected = !item.selected;
|
||||||
formData[item.field] = item.selected;
|
formData[item.field] = item.selected;
|
||||||
|
if (item.field === 'supportImage' && !item.selected) {
|
||||||
|
formData.supportImageB64Only = false;
|
||||||
|
const base64Ability = modelAbility.value.find(
|
||||||
|
(ability) => ability.field === 'supportImageB64Only',
|
||||||
|
);
|
||||||
|
if (base64Ability) base64Ability.selected = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelTypeChipClick = (
|
const handleModelTypeChipClick = (
|
||||||
@@ -353,7 +365,7 @@ const save = async () => {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
<button
|
<button
|
||||||
v-for="item in modelAbility"
|
v-for="item in visibleModelAbility"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
type="button"
|
type="button"
|
||||||
class="model-modal__ability-chip"
|
class="model-modal__ability-chip"
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChatImageAttachment, ChatImageLoader } from './types';
|
||||||
|
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useEasyFlowModal } from '@easyflow-core/popup-ui';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
compact?: boolean;
|
||||||
|
imageLoader?: ChatImageLoader;
|
||||||
|
items: ChatImageAttachment[];
|
||||||
|
removable?: boolean;
|
||||||
|
retryable?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
compact: false,
|
||||||
|
imageLoader: undefined,
|
||||||
|
removable: false,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
remove: [item: ChatImageAttachment];
|
||||||
|
retry: [item: ChatImageAttachment];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const resolvedUrls = ref<Record<string, string>>({});
|
||||||
|
const failedSources = ref<Set<string>>(new Set());
|
||||||
|
const previewItem = ref<ChatImageAttachment>();
|
||||||
|
const ownedUrls = new Set<string>();
|
||||||
|
let resolutionVersion = 0;
|
||||||
|
|
||||||
|
const [ImagePreviewModal, imagePreviewModalApi] = useEasyFlowModal({
|
||||||
|
onOpenChange(open) {
|
||||||
|
if (!open) previewItem.value = undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function sourceOf(item: ChatImageAttachment) {
|
||||||
|
return item.previewUrl || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedUrl(item: ChatImageAttachment) {
|
||||||
|
return resolvedUrls.value[sourceOf(item)] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewUrl = computed(() =>
|
||||||
|
previewItem.value ? resolvedUrl(previewItem.value) : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
function canPreview(item: ChatImageAttachment) {
|
||||||
|
const source = sourceOf(item);
|
||||||
|
return (
|
||||||
|
Boolean(resolvedUrl(item)) &&
|
||||||
|
item.status !== 'error' &&
|
||||||
|
item.status !== 'uploading' &&
|
||||||
|
!failedSources.value.has(source)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(item: ChatImageAttachment) {
|
||||||
|
if (!canPreview(item)) return;
|
||||||
|
previewItem.value = item;
|
||||||
|
imagePreviewModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreview() {
|
||||||
|
previewItem.value = undefined;
|
||||||
|
imagePreviewModalApi.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseOwnedUrl(url?: string) {
|
||||||
|
if (!url || !ownedUrls.delete(url)) return;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImages() {
|
||||||
|
const version = ++resolutionVersion;
|
||||||
|
const activeSources = new Set(
|
||||||
|
props.items.map((item) => sourceOf(item)).filter(Boolean),
|
||||||
|
);
|
||||||
|
if (previewItem.value && !activeSources.has(sourceOf(previewItem.value))) {
|
||||||
|
closePreview();
|
||||||
|
}
|
||||||
|
const nextUrls = { ...resolvedUrls.value };
|
||||||
|
for (const [source, url] of Object.entries(nextUrls)) {
|
||||||
|
if (!activeSources.has(source)) {
|
||||||
|
releaseOwnedUrl(url);
|
||||||
|
delete nextUrls[source];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolvedUrls.value = nextUrls;
|
||||||
|
failedSources.value = new Set(
|
||||||
|
[...failedSources.value].filter((source) => activeSources.has(source)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const source of activeSources) {
|
||||||
|
if (resolvedUrls.value[source]) continue;
|
||||||
|
if (!props.imageLoader || /^(?:blob:|data:)/i.test(source)) {
|
||||||
|
resolvedUrls.value = { ...resolvedUrls.value, [source]: source };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = await props.imageLoader(source);
|
||||||
|
if (version !== resolutionVersion || !activeSources.has(source)) {
|
||||||
|
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (url.startsWith('blob:')) ownedUrls.add(url);
|
||||||
|
resolvedUrls.value = { ...resolvedUrls.value, [source]: url };
|
||||||
|
} catch {
|
||||||
|
if (version === resolutionVersion) {
|
||||||
|
failedSources.value = new Set([source, ...failedSources.value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
props.items.map((item) => sourceOf(item)).join('|'),
|
||||||
|
props.imageLoader,
|
||||||
|
] as const,
|
||||||
|
() => void resolveImages(),
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resolutionVersion += 1;
|
||||||
|
for (const url of ownedUrls) URL.revokeObjectURL(url);
|
||||||
|
ownedUrls.clear();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="items.length > 0"
|
||||||
|
class="chat-image-attachments"
|
||||||
|
:class="{ 'is-compact': compact }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.uploadId || item.imageRef || item.localId || item.previewUrl"
|
||||||
|
class="chat-image-attachments__item"
|
||||||
|
:class="`is-${item.status || 'ready'}`"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="resolvedUrl(item)"
|
||||||
|
type="button"
|
||||||
|
class="chat-image-attachments__preview-trigger"
|
||||||
|
:disabled="!canPreview(item)"
|
||||||
|
:aria-label="`查看完整图片:${item.name}`"
|
||||||
|
title="查看完整图片"
|
||||||
|
@click="openPreview(item)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="chat-image-attachments__image"
|
||||||
|
:src="resolvedUrl(item)"
|
||||||
|
:alt="item.name"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="item.status === 'uploading'"
|
||||||
|
class="chat-image-attachments__state"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span class="chat-image-attachments__spinner"></span>
|
||||||
|
上传中
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="item.status === 'error'"
|
||||||
|
class="chat-image-attachments__state is-error"
|
||||||
|
:title="item.error || '上传失败'"
|
||||||
|
>
|
||||||
|
上传失败
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="failedSources.has(sourceOf(item))"
|
||||||
|
class="chat-image-attachments__state is-error"
|
||||||
|
>
|
||||||
|
加载失败
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!resolvedUrl(item)"
|
||||||
|
class="chat-image-attachments__state"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span class="chat-image-attachments__spinner"></span>
|
||||||
|
加载中
|
||||||
|
</div>
|
||||||
|
<div class="chat-image-attachments__name" :title="item.name">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
<div class="chat-image-attachments__actions">
|
||||||
|
<button
|
||||||
|
v-if="retryable && item.status === 'error'"
|
||||||
|
type="button"
|
||||||
|
class="chat-image-attachments__action"
|
||||||
|
aria-label="重新上传"
|
||||||
|
title="重新上传"
|
||||||
|
@click.stop="emit('retry', item)"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="removable"
|
||||||
|
type="button"
|
||||||
|
class="chat-image-attachments__action"
|
||||||
|
aria-label="移除图片"
|
||||||
|
title="移除图片"
|
||||||
|
@click.stop="emit('remove', item)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImagePreviewModal
|
||||||
|
:bordered="false"
|
||||||
|
centered
|
||||||
|
class="!max-h-[calc(100vh-24px)] !w-fit max-w-[calc(100vw-24px)]"
|
||||||
|
close-on-click-modal
|
||||||
|
content-class="!p-0 !overflow-hidden"
|
||||||
|
destroy-on-close
|
||||||
|
:footer="false"
|
||||||
|
:fullscreen-button="false"
|
||||||
|
:header="false"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="previewUrl"
|
||||||
|
class="chat-image-preview__image"
|
||||||
|
:src="previewUrl"
|
||||||
|
:alt="previewItem?.name || '聊天图片'"
|
||||||
|
/>
|
||||||
|
</ImagePreviewModal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-image-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
align-items: flex-start;
|
||||||
|
width: min(100%, 640px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__item {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__preview-trigger {
|
||||||
|
display: block;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: zoom-in;
|
||||||
|
background: hsl(var(--surface-subtle));
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__preview-trigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__preview-trigger:not(:disabled):hover {
|
||||||
|
background: hsl(var(--surface-contrast-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__preview-trigger:not(:disabled):focus-visible {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
outline: 2px solid var(--el-color-primary-light-5);
|
||||||
|
outline: 2px solid
|
||||||
|
color-mix(in srgb, var(--el-color-primary) 48%, transparent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__image {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 160px;
|
||||||
|
height: auto;
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__name {
|
||||||
|
max-width: 160px;
|
||||||
|
padding: var(--space-1, 4px) var(--space-2, 8px);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__state {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 25px;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1, 4px);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
background: color-mix(in srgb, var(--el-bg-color) 84%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__state.is-error {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--el-border-color);
|
||||||
|
border-top-color: var(--el-color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: chat-image-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__actions {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--space-1, 4px);
|
||||||
|
right: var(--space-1, 4px);
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
background: color-mix(in srgb, var(--el-bg-color) 88%, transparent);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments__action:hover,
|
||||||
|
.chat-image-attachments__action:focus-visible {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
outline: 2px solid var(--el-color-primary-light-5);
|
||||||
|
outline: 2px solid
|
||||||
|
color-mix(in srgb, var(--el-color-primary) 36%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments.is-compact .chat-image-attachments__image {
|
||||||
|
max-width: 240px;
|
||||||
|
max-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments.is-compact .chat-image-attachments__name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-attachments.is-compact .chat-image-attachments__state {
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-image-preview__image {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: calc(100vw - var(--space-6, 24px));
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - var(--space-6, 24px));
|
||||||
|
object-fit: scale-down;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chat-image-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type {
|
||||||
ChatTimelineItem as ChatTimelineItemType,
|
ChatTimelineItem as ChatTimelineItemType,
|
||||||
|
ChatImageLoader,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineToolApprovalPayload,
|
ChatTimelineToolApprovalPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import {nextTick, onBeforeUnmount, ref, watch} from 'vue';
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
import ChatTimelineItem from './ChatTimelineItem.vue';
|
import ChatTimelineItem from './ChatTimelineItem.vue';
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ const props = defineProps<{
|
|||||||
approvalLoading?: boolean;
|
approvalLoading?: boolean;
|
||||||
copyable?: (item: ChatTimelineMessageItem) => boolean;
|
copyable?: (item: ChatTimelineMessageItem) => boolean;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
|
imageLoader?: ChatImageLoader;
|
||||||
items: ChatTimelineItemType[];
|
items: ChatTimelineItemType[];
|
||||||
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
regenerable?: (item: ChatTimelineMessageItem) => boolean;
|
||||||
regenerateDisabled?: boolean;
|
regenerateDisabled?: boolean;
|
||||||
@@ -153,6 +155,7 @@ watch(
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)"
|
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)"
|
||||||
:item="item"
|
:item="item"
|
||||||
|
:image-loader="imageLoader"
|
||||||
:approval-loading="approvalLoading"
|
:approval-loading="approvalLoading"
|
||||||
:copyable="canCopyMessage(item)"
|
:copyable="canCopyMessage(item)"
|
||||||
:regenerable="canRegenerateMessage(item)"
|
:regenerable="canRegenerateMessage(item)"
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {
|
import type {
|
||||||
ChatTimelineItem,
|
ChatTimelineItem,
|
||||||
|
ChatImageLoader,
|
||||||
ChatTimelineMessageItem,
|
ChatTimelineMessageItem,
|
||||||
ChatTimelineMessagePart,
|
ChatTimelineMessagePart,
|
||||||
ChatTimelineToolApprovalPayload,
|
ChatTimelineToolApprovalPayload,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import {computed} from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
|
import ChatThinkingBlock from '../chat-thinking/ChatThinkingBlock.vue';
|
||||||
import ChatErrorNotice from './ChatErrorNotice.vue';
|
import ChatErrorNotice from './ChatErrorNotice.vue';
|
||||||
import ChatKnowledgeCard from './ChatKnowledgeCard.vue';
|
import ChatKnowledgeCard from './ChatKnowledgeCard.vue';
|
||||||
|
import ChatImageAttachments from './ChatImageAttachments.vue';
|
||||||
import ChatMessageToolbar from './ChatMessageToolbar.vue';
|
import ChatMessageToolbar from './ChatMessageToolbar.vue';
|
||||||
import ChatTextBlock from './ChatTextBlock.vue';
|
import ChatTextBlock from './ChatTextBlock.vue';
|
||||||
import ChatTimelineStatusRow from './ChatTimelineStatusRow.vue';
|
import ChatTimelineStatusRow from './ChatTimelineStatusRow.vue';
|
||||||
@@ -22,6 +24,7 @@ const props = defineProps<{
|
|||||||
regenerable?: boolean;
|
regenerable?: boolean;
|
||||||
regenerateDisabled?: boolean;
|
regenerateDisabled?: boolean;
|
||||||
assistantActionsVisible?: boolean;
|
assistantActionsVisible?: boolean;
|
||||||
|
imageLoader?: ChatImageLoader;
|
||||||
item: ChatTimelineItem;
|
item: ChatTimelineItem;
|
||||||
variantLoading?: boolean;
|
variantLoading?: boolean;
|
||||||
}>();
|
}>();
|
||||||
@@ -127,6 +130,12 @@ function updateThinkingExpanded(partId: string, expanded: boolean) {
|
|||||||
{ 'has-variant-navigator': showVariantNavigator },
|
{ 'has-variant-navigator': showVariantNavigator },
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
<ChatImageAttachments
|
||||||
|
v-if="messageItem.images?.length"
|
||||||
|
:items="messageItem.images"
|
||||||
|
:image-loader="imageLoader"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
|
<template v-for="part in getMessageParts(messageItem)" :key="part.id">
|
||||||
<ChatThinkingBlock
|
<ChatThinkingBlock
|
||||||
v-if="part.type === 'thinking'"
|
v-if="part.type === 'thinking'"
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ChatImageAttachment } from '../types';
|
||||||
|
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import ChatImageAttachments from '../ChatImageAttachments.vue';
|
||||||
|
|
||||||
|
const image: ChatImageAttachment = {
|
||||||
|
name: '界面截图.png',
|
||||||
|
previewUrl: 'data:image/png;base64,aW1hZ2U=',
|
||||||
|
status: 'ready',
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chat image attachments', () => {
|
||||||
|
it('opens the complete image preview from a ready thumbnail', async () => {
|
||||||
|
const wrapper = mount(ChatImageAttachments, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
items: [image],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const trigger = wrapper.get('[aria-label="查看完整图片:界面截图.png"]');
|
||||||
|
expect(trigger.attributes('disabled')).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
wrapper.get('.chat-image-attachments__image').attributes('src'),
|
||||||
|
).toBe(image.previewUrl);
|
||||||
|
|
||||||
|
await trigger.trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const dialog = document.body.querySelector('[role="dialog"]');
|
||||||
|
const preview = document.body.querySelector<HTMLImageElement>(
|
||||||
|
'.chat-image-preview__image',
|
||||||
|
);
|
||||||
|
expect(dialog).not.toBeNull();
|
||||||
|
expect(preview?.src).toBe(image.previewUrl);
|
||||||
|
expect(preview?.alt).toBe(image.name);
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an uploading image unavailable for preview', async () => {
|
||||||
|
const wrapper = mount(ChatImageAttachments, {
|
||||||
|
props: {
|
||||||
|
items: [{ ...image, status: 'uploading' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const trigger = wrapper.get('[aria-label="查看完整图片:界面截图.png"]');
|
||||||
|
expect(trigger.attributes('disabled')).toBeDefined();
|
||||||
|
|
||||||
|
await trigger.trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the preview when its image is removed', async () => {
|
||||||
|
const wrapper = mount(ChatImageAttachments, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
items: [image],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
await wrapper
|
||||||
|
.get('[aria-label="查看完整图片:界面截图.png"]')
|
||||||
|
.trigger('click');
|
||||||
|
await flushPromises();
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
||||||
|
|
||||||
|
await wrapper.setProps({ items: [] });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -319,7 +319,7 @@ export const ChatTimelineBuilder = {
|
|||||||
metadata?: Partial<ChatTimelineMessageItem>,
|
metadata?: Partial<ChatTimelineMessageItem>,
|
||||||
) {
|
) {
|
||||||
const text = normalizeText(content);
|
const text = normalizeText(content);
|
||||||
if (!text) {
|
if (!text && !metadata?.images?.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item: ChatTimelineMessageItem = {
|
const item: ChatTimelineMessageItem = {
|
||||||
@@ -327,13 +327,15 @@ export const ChatTimelineBuilder = {
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
parts: [
|
parts: text
|
||||||
{
|
? [
|
||||||
id: createId('text'),
|
{
|
||||||
content: text,
|
id: createId('text'),
|
||||||
type: 'text',
|
content: text,
|
||||||
},
|
type: 'text' as const,
|
||||||
],
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
type: 'message',
|
type: 'message',
|
||||||
...metadata,
|
...metadata,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { ChatTimelineBuilder } from './builder';
|
export { ChatTimelineBuilder } from './builder';
|
||||||
export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
|
export { default as ChatErrorNotice } from './ChatErrorNotice.vue';
|
||||||
export { default as ChatKnowledgeCard } from './ChatKnowledgeCard.vue';
|
export { default as ChatKnowledgeCard } from './ChatKnowledgeCard.vue';
|
||||||
|
export { default as ChatImageAttachments } from './ChatImageAttachments.vue';
|
||||||
export { default as ChatMessageToolbar } from './ChatMessageToolbar.vue';
|
export { default as ChatMessageToolbar } from './ChatMessageToolbar.vue';
|
||||||
export { default as ChatTextBlock } from './ChatTextBlock.vue';
|
export { default as ChatTextBlock } from './ChatTextBlock.vue';
|
||||||
export { default as ChatTimeline } from './ChatTimeline.vue';
|
export { default as ChatTimeline } from './ChatTimeline.vue';
|
||||||
@@ -11,6 +12,8 @@ export { default as ChatToolCard } from './ChatToolCard.vue';
|
|||||||
export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue';
|
export { default as ChatVariantNavigator } from './ChatVariantNavigator.vue';
|
||||||
export type {
|
export type {
|
||||||
ChatTimelineErrorItem,
|
ChatTimelineErrorItem,
|
||||||
|
ChatImageAttachment,
|
||||||
|
ChatImageLoader,
|
||||||
ChatTimelineItem,
|
ChatTimelineItem,
|
||||||
ChatTimelineItemStatus,
|
ChatTimelineItemStatus,
|
||||||
ChatTimelineKnowledgeHit,
|
ChatTimelineKnowledgeHit,
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ export type ChatTimelineToolStatus =
|
|||||||
export type ChatTimelineStatusStatus = 'done' | 'running';
|
export type ChatTimelineStatusStatus = 'done' | 'running';
|
||||||
export type ChatTimelineStatusTone = 'muted';
|
export type ChatTimelineStatusTone = 'muted';
|
||||||
|
|
||||||
|
export interface ChatImageAttachment {
|
||||||
|
error?: string;
|
||||||
|
height?: number;
|
||||||
|
imageRef?: string;
|
||||||
|
localId?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
name: string;
|
||||||
|
previewUrl: string;
|
||||||
|
size?: number;
|
||||||
|
status?: 'error' | 'ready' | 'uploading';
|
||||||
|
uploadId?: string;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatImageLoader = (previewUrl: string) => Promise<string>;
|
||||||
|
|
||||||
export interface ChatTimelineToolApprovalPayload {
|
export interface ChatTimelineToolApprovalPayload {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
resumeToken: string;
|
resumeToken: string;
|
||||||
@@ -50,6 +66,7 @@ export interface ChatTimelineItemBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
|
export interface ChatTimelineMessageItem extends ChatTimelineItemBase {
|
||||||
|
images?: ChatImageAttachment[];
|
||||||
knowledgeItems?: ChatTimelineKnowledgeHit[];
|
knowledgeItems?: ChatTimelineKnowledgeHit[];
|
||||||
parts: ChatTimelineMessagePart[];
|
parts: ChatTimelineMessagePart[];
|
||||||
regenerable?: boolean;
|
regenerable?: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user