发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
62 changed files with 5333 additions and 189 deletions
Showing only changes of commit 1e6158be77 - Show all commits

View File

@@ -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:

View File

@@ -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 草稿试运行会话。
* *

View File

@@ -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) {

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.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 {
} }

View File

@@ -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);
}
/** /**
* 获取本次聊天启用的临时能力。 * 获取本次聊天启用的临时能力。
* *

View File

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

View File

@@ -8,6 +8,8 @@ import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference; import com.easyagents.agent.runtime.message.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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -249,6 +249,8 @@ public class AgentServiceImpl extends ServiceImpl<AgentMapper, Agent> implements
summary.put("title", model.getTitle()); summary.put("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;
} }

View File

@@ -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());

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

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

View File

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

View File

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

View 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);
}

View File

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

View File

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

View File

@@ -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']);
});
});

View File

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

View File

@@ -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",

View File

@@ -64,9 +64,9 @@
"supportTool": "工具", "supportTool": "工具",
"supportAudio": "音频", "supportAudio": "音频",
"supportVideo": "视频", "supportVideo": "视频",
"supportImage": "图片", "supportImage": "多模态",
"supportFree": "免费", "supportFree": "免费",
"supportImageB64Only": "仅支持Base64图片", "supportImageB64Only": "仅接受 Base64 图片",
"supportToolMessage": "支持Tool消息" "supportToolMessage": "支持Tool消息"
}, },
"requestPath": "请求路径", "requestPath": "请求路径",

View File

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

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

View File

@@ -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'

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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('服务暂时不可用');
});
});

View File

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

View File

@@ -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')"

View File

@@ -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"

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -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)"

View File

@@ -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'"

View File

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

View File

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

View File

@@ -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,

View File

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