Compare commits
8 Commits
ff863e3c27
...
1a6ea64e80
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6ea64e80 | |||
| da58077d59 | |||
| 47c2bad839 | |||
| 2ad8935a61 | |||
| 21b1bc82f6 | |||
| 4a15124183 | |||
| e27834ee0c | |||
| c1590b0d8a |
@@ -13,12 +13,17 @@ import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.admin.controller.ai.support.AiResourceCreatorNameSupport;
|
||||
import tech.easyflow.admin.service.ai.ChatWorkspaceService;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
|
||||
import tech.easyflow.ai.easyagents.listener.PromptChoreChatStreamListener;
|
||||
import tech.easyflow.ai.entity.*;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.publish.BotPublishAppService;
|
||||
import tech.easyflow.approval.entity.vo.ApprovalActionResult;
|
||||
import tech.easyflow.ai.service.*;
|
||||
@@ -30,9 +35,11 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||
import tech.easyflow.core.chat.protocol.sse.ChatSseUtil;
|
||||
import tech.easyflow.core.runtime.ChatChannel;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
@@ -73,9 +80,13 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
@Resource
|
||||
private BotPublishAppService botPublishAppService;
|
||||
@Resource
|
||||
private ChatRoundOperateService chatRoundOperateService;
|
||||
@Resource
|
||||
private AiResourceApprovalStateService aiResourceApprovalStateService;
|
||||
@Resource
|
||||
private AiResourceCreatorNameSupport aiResourceCreatorNameSupport;
|
||||
@Resource
|
||||
private ChatWorkspaceService chatWorkspaceService;
|
||||
|
||||
public BotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
|
||||
BotDocumentCollectionService botDocumentCollectionService, BotMessageService botMessageService) {
|
||||
@@ -161,13 +172,30 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
@JsonBody(value = "botId", required = true) BigInteger botId,
|
||||
@JsonBody(value = "conversationId", required = true) BigInteger conversationId,
|
||||
@JsonBody(value = "messages") List<Map<String, String>> messages,
|
||||
@JsonBody(value = "attachments") List<String> attachments
|
||||
@JsonBody(value = "attachments") List<String> attachments,
|
||||
@JsonBody(value = "publishedOnly") Boolean publishedOnly,
|
||||
@JsonBody(value = "extraKnowledgeIds") List<BigInteger> extraKnowledgeIds,
|
||||
@JsonBody(value = "regenerateRoundId") BigInteger regenerateRoundId
|
||||
|
||||
) {
|
||||
boolean usePublishedOnly = Boolean.TRUE.equals(publishedOnly);
|
||||
BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult();
|
||||
if (usePublishedOnly) {
|
||||
chatWorkspaceService.assertSessionContinuable(requireCurrentLoginAccount(), conversationId, botId);
|
||||
}
|
||||
if (regenerateRoundId != null) {
|
||||
chatRoundOperateService.requireRegeneratableRound(conversationId, regenerateRoundId);
|
||||
}
|
||||
|
||||
// 前置校验:失败则直接返回错误SseEmitter
|
||||
SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult);
|
||||
SseEmitter errorEmitter = botService.checkChatBeforeStart(
|
||||
botId,
|
||||
prompt,
|
||||
conversationId.toString(),
|
||||
chatCheckResult,
|
||||
usePublishedOnly,
|
||||
regenerateRoundId
|
||||
);
|
||||
if (errorEmitter != null) {
|
||||
return errorEmitter;
|
||||
}
|
||||
@@ -178,7 +206,7 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
messages,
|
||||
chatCheckResult,
|
||||
attachments,
|
||||
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments)
|
||||
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments, extraKnowledgeIds, regenerateRoundId)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,16 +221,24 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
@GetMapping("getDetail")
|
||||
@SaIgnore
|
||||
public Result<Bot> getDetail(String id) {
|
||||
Bot bot = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id);
|
||||
if (bot != null && StpUtil.isLogin()) {
|
||||
categoryPermissionService.assertCategoryResourceVisible("BOT", bot.getCreatedBy(), bot.getCategoryId(), "无权限访问聊天助手");
|
||||
boolean publishedOnly = isPublishedOnlyRequest();
|
||||
Bot rawBot = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id);
|
||||
if (rawBot != null && StpUtil.isLogin()) {
|
||||
categoryPermissionService.assertCategoryResourceVisible("BOT", rawBot.getCreatedBy(), rawBot.getCategoryId(), "无权限访问聊天助手");
|
||||
}
|
||||
if (bot == null) {
|
||||
if (rawBot == null) {
|
||||
return Result.ok(null);
|
||||
}
|
||||
if (!StpUtil.isLogin() && !tech.easyflow.ai.enums.PublishStatus.from(bot.getPublishStatus()).isExternallyVisible()) {
|
||||
if (!StpUtil.isLogin() && !PublishStatus.from(rawBot.getPublishStatus()).isExternallyVisible()) {
|
||||
throw new BusinessException("聊天助手尚未发布");
|
||||
}
|
||||
Bot bot = rawBot;
|
||||
if (publishedOnly && StpUtil.isLogin()) {
|
||||
if (PublishStatus.from(rawBot.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
throw new BusinessException("聊天助手尚未发布");
|
||||
}
|
||||
bot = botService.toPublishedView(rawBot);
|
||||
}
|
||||
if (StpUtil.isLogin()) {
|
||||
aiResourceApprovalStateService.fillBotApprovalState(bot);
|
||||
}
|
||||
@@ -212,17 +248,25 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
@Override
|
||||
@SaIgnore
|
||||
public Result<Bot> detail(String id) {
|
||||
Bot data = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id);
|
||||
if (data == null) {
|
||||
return Result.ok(data);
|
||||
boolean publishedOnly = isPublishedOnlyRequest();
|
||||
Bot rawData = StpUtil.isLogin() ? botService.getDetail(id) : botService.getPublishedDetail(id);
|
||||
if (rawData == null) {
|
||||
return Result.ok(rawData);
|
||||
}
|
||||
if (StpUtil.isLogin()) {
|
||||
categoryPermissionService.assertCategoryResourceVisible("BOT", data.getCreatedBy(), data.getCategoryId(), "无权限访问聊天助手");
|
||||
categoryPermissionService.assertCategoryResourceVisible("BOT", rawData.getCreatedBy(), rawData.getCategoryId(), "无权限访问聊天助手");
|
||||
}
|
||||
|
||||
if (!StpUtil.isLogin() && !tech.easyflow.ai.enums.PublishStatus.from(data.getPublishStatus()).isExternallyVisible()) {
|
||||
if (!StpUtil.isLogin() && !PublishStatus.from(rawData.getPublishStatus()).isExternallyVisible()) {
|
||||
throw new BusinessException("聊天助手尚未发布");
|
||||
}
|
||||
Bot data = rawData;
|
||||
if (publishedOnly && StpUtil.isLogin()) {
|
||||
if (PublishStatus.from(rawData.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
throw new BusinessException("聊天助手尚未发布");
|
||||
}
|
||||
data = botService.toPublishedView(rawData);
|
||||
}
|
||||
|
||||
Map<String, Object> llmOptions = data.getModelOptions();
|
||||
if (llmOptions == null) {
|
||||
@@ -297,8 +341,12 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
public Result<List<Bot>> list(Bot entity, Boolean asTree, String sortKey, String sortType) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
|
||||
applyCategoryPermission(queryWrapper);
|
||||
applyPublishedOnlyFilter(queryWrapper);
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
List<Bot> bots = service.list(queryWrapper);
|
||||
if (isPublishedOnlyRequest()) {
|
||||
bots = bots.stream().map(botService::toPublishedView).toList();
|
||||
}
|
||||
aiResourceApprovalStateService.fillBotApprovalState(bots);
|
||||
return Result.ok(bots);
|
||||
}
|
||||
@@ -306,7 +354,11 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
@Override
|
||||
protected Page<Bot> queryPage(Page<Bot> page, QueryWrapper queryWrapper) {
|
||||
applyCategoryPermission(queryWrapper);
|
||||
applyPublishedOnlyFilter(queryWrapper);
|
||||
Page<Bot> result = super.queryPage(page, queryWrapper);
|
||||
if (isPublishedOnlyRequest()) {
|
||||
result.setRecords(result.getRecords().stream().map(botService::toPublishedView).toList());
|
||||
}
|
||||
aiResourceApprovalStateService.fillBotApprovalState(result.getRecords());
|
||||
aiResourceCreatorNameSupport.fillBotCreatorNames(result.getRecords());
|
||||
return result;
|
||||
@@ -406,24 +458,55 @@ public class BotController extends BaseCurdController<BotService, Bot> {
|
||||
return result;
|
||||
}
|
||||
|
||||
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments,
|
||||
List<BigInteger> extraKnowledgeIds,
|
||||
BigInteger regenerateRoundId) {
|
||||
LoginAccount account = requireCurrentLoginAccount();
|
||||
ChatRuntimeContext context = new ChatRuntimeContext();
|
||||
context.setChannel(ChatChannel.ADMIN);
|
||||
context.setSessionId(conversationId);
|
||||
context.setTenantId(account == null ? BigInteger.ZERO : account.getTenantId());
|
||||
context.setDeptId(account == null ? BigInteger.ZERO : account.getDeptId());
|
||||
context.setUserId(account == null ? BigInteger.ZERO : account.getId());
|
||||
context.setUserAccount(account == null ? "admin" : account.getLoginName());
|
||||
context.setUserName(account == null ? "管理员" : (StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName()));
|
||||
context.setTenantId(account.getTenantId());
|
||||
context.setDeptId(account.getDeptId());
|
||||
context.setUserId(account.getId());
|
||||
context.setUserAccount(account.getLoginName());
|
||||
context.setUserName(StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName());
|
||||
context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId());
|
||||
context.setAssistantCode(bot == null ? null : bot.getAlias());
|
||||
context.setAssistantName(bot == null ? null : bot.getTitle());
|
||||
context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt);
|
||||
context.setAttachments(attachments);
|
||||
if (extraKnowledgeIds != null) {
|
||||
context.getExt().put(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS, extraKnowledgeIds);
|
||||
}
|
||||
if (regenerateRoundId != null) {
|
||||
context.getExt().put(ChatRuntimeExtKeys.REGENERATE_ROUND_ID, regenerateRoundId);
|
||||
}
|
||||
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot);
|
||||
return context;
|
||||
}
|
||||
|
||||
private void applyPublishedOnlyFilter(QueryWrapper queryWrapper) {
|
||||
if (isPublishedOnlyRequest()) {
|
||||
queryWrapper.eq("publish_status", PublishStatus.PUBLISHED.getCode());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPublishedOnlyRequest() {
|
||||
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attributes == null) {
|
||||
return false;
|
||||
}
|
||||
return "true".equalsIgnoreCase(attributes.getRequest().getParameter("publishedOnly"));
|
||||
}
|
||||
|
||||
private LoginAccount requireCurrentLoginAccount() {
|
||||
try {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Result<?> onRemoveBefore(Collection<Serializable> ids) {
|
||||
QueryWrapper queryWrapperKnowledge = QueryWrapper.create().in(BotDocumentCollection::getBotId, ids);
|
||||
|
||||
@@ -2,17 +2,23 @@ package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
|
||||
import tech.easyflow.chatlog.service.ChatHistoryManageService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/chatHistory")
|
||||
@@ -38,4 +44,18 @@ public class ChatHistoryController {
|
||||
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
|
||||
return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
|
||||
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
|
||||
@PathVariable BigInteger roundId) {
|
||||
return Result.ok(chatHistoryManageService.listAdminRoundVariants(sessionId, roundId));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
|
||||
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
|
||||
@PathVariable BigInteger roundId,
|
||||
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(chatHistoryManageService.selectAdminRoundVariant(sessionId, roundId, variantIndex, account.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
|
||||
import tech.easyflow.admin.service.ai.ChatWorkspaceService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 管理端聊天工作台控制器。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/chatWorkspace")
|
||||
public class ChatWorkspaceController {
|
||||
|
||||
private final ChatWorkspaceService chatWorkspaceService;
|
||||
|
||||
public ChatWorkspaceController(ChatWorkspaceService chatWorkspaceService) {
|
||||
this.chatWorkspaceService = chatWorkspaceService;
|
||||
}
|
||||
|
||||
@GetMapping("/sessions")
|
||||
public Result<ChatWorkspaceSessionPage> listSessions(BigInteger assistantId, ChatPageQuery query) {
|
||||
return Result.ok(chatWorkspaceService.queryCurrentUserSessions(currentAccount(), assistantId, query));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}")
|
||||
public Result<ChatWorkspaceSessionDetailView> getSession(@PathVariable BigInteger sessionId) {
|
||||
return Result.ok(chatWorkspaceService.getCurrentUserSession(currentAccount(), sessionId));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/messages")
|
||||
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
|
||||
return Result.ok(chatWorkspaceService.queryCurrentUserMessages(currentAccount(), sessionId, query));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/conversation")
|
||||
public Result<ChatWorkspaceConversationView> getConversation(@PathVariable BigInteger sessionId) {
|
||||
return Result.ok(chatWorkspaceService.getCurrentUserConversation(currentAccount(), sessionId));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/rename")
|
||||
public Result<Void> renameSession(@PathVariable BigInteger sessionId,
|
||||
@JsonBody(value = "title", required = true) String title) {
|
||||
chatWorkspaceService.renameCurrentUserSession(currentAccount(), sessionId, title);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/delete")
|
||||
public Result<Void> deleteSession(@PathVariable BigInteger sessionId) {
|
||||
chatWorkspaceService.deleteCurrentUserSession(currentAccount(), sessionId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
|
||||
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
|
||||
@PathVariable BigInteger roundId) {
|
||||
return Result.ok(chatWorkspaceService.listCurrentUserRoundVariants(currentAccount(), sessionId, roundId));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
|
||||
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
|
||||
@PathVariable BigInteger roundId,
|
||||
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
|
||||
return Result.ok(chatWorkspaceService.selectCurrentUserRoundVariant(currentAccount(), sessionId, roundId, variantIndex));
|
||||
}
|
||||
|
||||
private LoginAccount currentAccount() {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
package tech.easyflow.admin.controller.ai;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
||||
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/public-chat")
|
||||
@@ -35,4 +40,24 @@ public class PublicChatSessionController {
|
||||
);
|
||||
return Result.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/session/{sessionId}/rounds/{roundId}/variants")
|
||||
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
|
||||
@PathVariable BigInteger roundId,
|
||||
BigInteger botId) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
BigInteger userId = account == null ? null : account.getId();
|
||||
return Result.ok(publicChatSessionRestoreService.listVariants(userId, botId, sessionId, roundId));
|
||||
}
|
||||
|
||||
@PostMapping("/session/{sessionId}/rounds/{roundId}/selectVariant")
|
||||
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
|
||||
@PathVariable BigInteger roundId,
|
||||
BigInteger botId,
|
||||
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
BigInteger userId = account == null ? null : account.getId();
|
||||
BigInteger operatorId = account == null ? BigInteger.ZERO : account.getId();
|
||||
return Result.ok(publicChatSessionRestoreService.selectVariant(userId, botId, sessionId, roundId, variantIndex, operatorId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||
import tech.easyflow.ai.service.WorkflowApiPermissionService;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
@@ -46,6 +47,8 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
||||
private SysApiKeyResourceMappingService sysApiKeyResourceMappingService;
|
||||
@Resource
|
||||
private KnowledgeSharePermissionService knowledgeSharePermissionService;
|
||||
@Resource
|
||||
private WorkflowApiPermissionService workflowApiPermissionService;
|
||||
/**
|
||||
* 添加(保存)数据
|
||||
*
|
||||
@@ -88,6 +91,9 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
||||
if (entity.getKnowledgeShareEnabled() != null) {
|
||||
knowledgeSharePermissionService.replaceApiShareEnabled(entity.getId(), entity.getKnowledgeShareEnabled());
|
||||
}
|
||||
if (entity.getWorkflowApiEnabled() != null) {
|
||||
workflowApiPermissionService.replaceWorkflowApiEnabled(entity.getId(), entity.getWorkflowApiEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -129,5 +135,11 @@ public class SysApiKeyController extends BaseCurdController<SysApiKeyService, Sy
|
||||
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
|
||||
.eq(SysApiKeyResourceMapping::getResourceType, "KNOWLEDGE");
|
||||
entity.setKnowledgeShareEnabled(sysApiKeyResourceMappingService.count(knowledgeWrapper) > 0);
|
||||
|
||||
QueryWrapper workflowWrapper = QueryWrapper.create()
|
||||
.select(SysApiKeyResourceMapping::getId)
|
||||
.eq(SysApiKeyResourceMapping::getApiKeyId, entity.getId())
|
||||
.eq(SysApiKeyResourceMapping::getResourceType, WorkflowApiPermissionService.RESOURCE_TYPE_WORKFLOW);
|
||||
entity.setWorkflowApiEnabled(sysApiKeyResourceMappingService.count(workflowWrapper) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package tech.easyflow.admin.controller.system;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import tech.easyflow.common.annotation.UsePermission;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.system.entity.SysApiKeyResource;
|
||||
import tech.easyflow.system.service.SysApiKeyResourceService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 请求接口表 控制层。
|
||||
*
|
||||
@@ -20,4 +25,26 @@ public class SysApiKeyResourceController extends BaseCurdController<SysApiKeyRes
|
||||
public SysApiKeyResourceController(SysApiKeyResourceService service) {
|
||||
super(service);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询普通 API Key 接口授权资源。
|
||||
*
|
||||
* <p>工作流 Public API 使用独立的全局授权开关,不进入普通接口授权列表,避免用户误以为勾选
|
||||
* 具体接口资源即可完成工作流调用授权。</p>
|
||||
*
|
||||
* @param entity 查询条件
|
||||
* @param asTree 是否树形返回
|
||||
* @param sortKey 排序字段
|
||||
* @param sortType 排序方向
|
||||
* @return 普通接口授权资源
|
||||
*/
|
||||
@Override
|
||||
@GetMapping("list")
|
||||
public Result<List<SysApiKeyResource>> list(SysApiKeyResource entity, Boolean asTree, String sortKey, String sortType) {
|
||||
QueryWrapper queryWrapper = QueryWrapper.create(entity, buildOperators(entity));
|
||||
queryWrapper.notLike(SysApiKeyResource::getRequestInterface, "/public-api/workflow/");
|
||||
queryWrapper.notLike(SysApiKeyResource::getRequestInterface, "/public-api/knowledge-share/");
|
||||
queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy()));
|
||||
return Result.ok(service.list(queryWrapper));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package tech.easyflow.admin.dto.chatworkspace;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 工作台助手展示快照。
|
||||
*/
|
||||
public class ChatWorkspaceAssistantView implements Serializable {
|
||||
|
||||
private BigInteger id;
|
||||
private String alias;
|
||||
private String title;
|
||||
private String description;
|
||||
private String icon;
|
||||
|
||||
public BigInteger getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(BigInteger id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getAlias() {
|
||||
return alias;
|
||||
}
|
||||
|
||||
public void setAlias(String alias) {
|
||||
this.alias = alias;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getIcon() {
|
||||
return icon;
|
||||
}
|
||||
|
||||
public void setIcon(String icon) {
|
||||
this.icon = icon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package tech.easyflow.admin.dto.chatworkspace;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 管理端聊天工作台完整会话视图。
|
||||
*/
|
||||
public class ChatWorkspaceConversationView implements Serializable {
|
||||
|
||||
private long total;
|
||||
private List<ChatMessageRecord> records = new ArrayList<>();
|
||||
private Map<String, List<ChatMessageRecord>> variantsByRound = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* 获取当前主线可见消息数量。
|
||||
*
|
||||
* @return 主线消息数量
|
||||
*/
|
||||
public long getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前主线可见消息数量。
|
||||
*
|
||||
* @param total 主线消息数量
|
||||
*/
|
||||
public void setTotal(long total) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前主线可见消息。
|
||||
*
|
||||
* @return 当前主线可见消息
|
||||
*/
|
||||
public List<ChatMessageRecord> getRecords() {
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前主线可见消息。
|
||||
*
|
||||
* @param records 当前主线可见消息
|
||||
*/
|
||||
public void setRecords(List<ChatMessageRecord> records) {
|
||||
this.records = records == null ? new ArrayList<>() : records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按轮次分组的全部答案版本。
|
||||
*
|
||||
* @return roundId 到答案版本列表的映射
|
||||
*/
|
||||
public Map<String, List<ChatMessageRecord>> getVariantsByRound() {
|
||||
return variantsByRound;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置按轮次分组的全部答案版本。
|
||||
*
|
||||
* @param variantsByRound roundId 到答案版本列表的映射
|
||||
*/
|
||||
public void setVariantsByRound(Map<String, List<ChatMessageRecord>> variantsByRound) {
|
||||
this.variantsByRound = variantsByRound == null ? new LinkedHashMap<>() : variantsByRound;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package tech.easyflow.admin.dto.chatworkspace;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 工作台知识库展示对象。
|
||||
*/
|
||||
public class ChatWorkspaceKnowledgeView implements Serializable {
|
||||
|
||||
private BigInteger id;
|
||||
private String alias;
|
||||
private String title;
|
||||
private String description;
|
||||
private String icon;
|
||||
|
||||
public BigInteger getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(BigInteger id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getAlias() {
|
||||
return alias;
|
||||
}
|
||||
|
||||
public void setAlias(String alias) {
|
||||
this.alias = alias;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getIcon() {
|
||||
return icon;
|
||||
}
|
||||
|
||||
public void setIcon(String icon) {
|
||||
this.icon = icon;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package tech.easyflow.admin.dto.chatworkspace;
|
||||
|
||||
/**
|
||||
* 管理端聊天工作台只读原因。
|
||||
*/
|
||||
public enum ChatWorkspaceReadOnlyReason {
|
||||
ASSISTANT_OFFLINE,
|
||||
ASSISTANT_DELETED,
|
||||
NO_PERMISSION
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package tech.easyflow.admin.dto.chatworkspace;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作台会话详情。
|
||||
*/
|
||||
public class ChatWorkspaceSessionDetailView extends ChatWorkspaceSessionView implements Serializable {
|
||||
|
||||
private ChatWorkspaceAssistantView assistant;
|
||||
private List<ChatWorkspaceKnowledgeView> boundKnowledges = new ArrayList<>();
|
||||
private List<ChatWorkspaceKnowledgeView> extraKnowledges = new ArrayList<>();
|
||||
private List<String> removedExtraKnowledgeNames = new ArrayList<>();
|
||||
|
||||
public ChatWorkspaceAssistantView getAssistant() {
|
||||
return assistant;
|
||||
}
|
||||
|
||||
public void setAssistant(ChatWorkspaceAssistantView assistant) {
|
||||
this.assistant = assistant;
|
||||
}
|
||||
|
||||
public List<ChatWorkspaceKnowledgeView> getBoundKnowledges() {
|
||||
return boundKnowledges;
|
||||
}
|
||||
|
||||
public void setBoundKnowledges(List<ChatWorkspaceKnowledgeView> boundKnowledges) {
|
||||
this.boundKnowledges = boundKnowledges == null ? new ArrayList<>() : boundKnowledges;
|
||||
}
|
||||
|
||||
public List<ChatWorkspaceKnowledgeView> getExtraKnowledges() {
|
||||
return extraKnowledges;
|
||||
}
|
||||
|
||||
public void setExtraKnowledges(List<ChatWorkspaceKnowledgeView> extraKnowledges) {
|
||||
this.extraKnowledges = extraKnowledges == null ? new ArrayList<>() : extraKnowledges;
|
||||
}
|
||||
|
||||
public List<String> getRemovedExtraKnowledgeNames() {
|
||||
return removedExtraKnowledgeNames;
|
||||
}
|
||||
|
||||
public void setRemovedExtraKnowledgeNames(List<String> removedExtraKnowledgeNames) {
|
||||
this.removedExtraKnowledgeNames = removedExtraKnowledgeNames == null ? new ArrayList<>() : removedExtraKnowledgeNames;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package tech.easyflow.admin.dto.chatworkspace;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作台会话分页结果。
|
||||
*/
|
||||
public class ChatWorkspaceSessionPage implements Serializable {
|
||||
|
||||
private Long total;
|
||||
private Long pageNumber;
|
||||
private Long pageSize;
|
||||
private List<ChatWorkspaceSessionView> records = new ArrayList<>();
|
||||
|
||||
public Long getTotal() {
|
||||
return total;
|
||||
}
|
||||
|
||||
public void setTotal(Long total) {
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
public Long getPageNumber() {
|
||||
return pageNumber;
|
||||
}
|
||||
|
||||
public void setPageNumber(Long pageNumber) {
|
||||
this.pageNumber = pageNumber;
|
||||
}
|
||||
|
||||
public Long getPageSize() {
|
||||
return pageSize;
|
||||
}
|
||||
|
||||
public void setPageSize(Long pageSize) {
|
||||
this.pageSize = pageSize;
|
||||
}
|
||||
|
||||
public List<ChatWorkspaceSessionView> getRecords() {
|
||||
return records;
|
||||
}
|
||||
|
||||
public void setRecords(List<ChatWorkspaceSessionView> records) {
|
||||
this.records = records == null ? new ArrayList<>() : records;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package tech.easyflow.admin.dto.chatworkspace;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 工作台会话摘要。
|
||||
*/
|
||||
public class ChatWorkspaceSessionView implements Serializable {
|
||||
|
||||
private BigInteger sessionId;
|
||||
private BigInteger assistantId;
|
||||
private String assistantCode;
|
||||
private String assistantName;
|
||||
private String title;
|
||||
private String lastMessagePreview;
|
||||
private Integer messageCount;
|
||||
private Date accessAt;
|
||||
private Date lastMessageAt;
|
||||
private Boolean continuable;
|
||||
private ChatWorkspaceReadOnlyReason readOnlyReason;
|
||||
|
||||
public BigInteger getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(BigInteger sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public BigInteger getAssistantId() {
|
||||
return assistantId;
|
||||
}
|
||||
|
||||
public void setAssistantId(BigInteger assistantId) {
|
||||
this.assistantId = assistantId;
|
||||
}
|
||||
|
||||
public String getAssistantCode() {
|
||||
return assistantCode;
|
||||
}
|
||||
|
||||
public void setAssistantCode(String assistantCode) {
|
||||
this.assistantCode = assistantCode;
|
||||
}
|
||||
|
||||
public String getAssistantName() {
|
||||
return assistantName;
|
||||
}
|
||||
|
||||
public void setAssistantName(String assistantName) {
|
||||
this.assistantName = assistantName;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getLastMessagePreview() {
|
||||
return lastMessagePreview;
|
||||
}
|
||||
|
||||
public void setLastMessagePreview(String lastMessagePreview) {
|
||||
this.lastMessagePreview = lastMessagePreview;
|
||||
}
|
||||
|
||||
public Integer getMessageCount() {
|
||||
return messageCount;
|
||||
}
|
||||
|
||||
public void setMessageCount(Integer messageCount) {
|
||||
this.messageCount = messageCount;
|
||||
}
|
||||
|
||||
public Date getAccessAt() {
|
||||
return accessAt;
|
||||
}
|
||||
|
||||
public void setAccessAt(Date accessAt) {
|
||||
this.accessAt = accessAt;
|
||||
}
|
||||
|
||||
public Date getLastMessageAt() {
|
||||
return lastMessageAt;
|
||||
}
|
||||
|
||||
public void setLastMessageAt(Date lastMessageAt) {
|
||||
this.lastMessageAt = lastMessageAt;
|
||||
}
|
||||
|
||||
public Boolean getContinuable() {
|
||||
return continuable;
|
||||
}
|
||||
|
||||
public void setContinuable(Boolean continuable) {
|
||||
this.continuable = continuable;
|
||||
}
|
||||
|
||||
public ChatWorkspaceReadOnlyReason getReadOnlyReason() {
|
||||
return readOnlyReason;
|
||||
}
|
||||
|
||||
public void setReadOnlyReason(ChatWorkspaceReadOnlyReason readOnlyReason) {
|
||||
this.readOnlyReason = readOnlyReason;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package tech.easyflow.admin.service.ai;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceAssistantView;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceConversationView;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceKnowledgeView;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceReadOnlyReason;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionDetailView;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionPage;
|
||||
import tech.easyflow.admin.dto.chatworkspace.ChatWorkspaceSessionView;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.permission.KnowledgeReadAccessSnapshot;
|
||||
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
|
||||
import tech.easyflow.ai.service.BotService;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionExtPayload;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
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.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static tech.easyflow.ai.entity.table.BotTableDef.BOT;
|
||||
|
||||
/**
|
||||
* 管理端聊天工作台服务。
|
||||
*/
|
||||
@Service
|
||||
public class ChatWorkspaceService {
|
||||
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatSessionCommandService chatSessionCommandService;
|
||||
private final ChatRoundOperateService chatRoundOperateService;
|
||||
private final BotService botService;
|
||||
private final DocumentCollectionService documentCollectionService;
|
||||
private final CategoryPermissionService categoryPermissionService;
|
||||
private final KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper;
|
||||
private final ChatJsonSupport chatJsonSupport;
|
||||
|
||||
public ChatWorkspaceService(ChatSessionQueryService chatSessionQueryService,
|
||||
ChatSessionCommandService chatSessionCommandService,
|
||||
ChatRoundOperateService chatRoundOperateService,
|
||||
BotService botService,
|
||||
DocumentCollectionService documentCollectionService,
|
||||
CategoryPermissionService categoryPermissionService,
|
||||
KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper,
|
||||
ChatJsonSupport chatJsonSupport) {
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.chatSessionCommandService = chatSessionCommandService;
|
||||
this.chatRoundOperateService = chatRoundOperateService;
|
||||
this.botService = botService;
|
||||
this.documentCollectionService = documentCollectionService;
|
||||
this.categoryPermissionService = categoryPermissionService;
|
||||
this.knowledgeVisibilityQueryHelper = knowledgeVisibilityQueryHelper;
|
||||
this.chatJsonSupport = chatJsonSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户会话分页。
|
||||
*
|
||||
* @param account 当前登录用户
|
||||
* @param assistantId 助手过滤条件
|
||||
* @param query 分页参数
|
||||
* @return 工作台会话分页
|
||||
*/
|
||||
public ChatWorkspaceSessionPage queryCurrentUserSessions(LoginAccount account, BigInteger assistantId, ChatPageQuery query) {
|
||||
ChatSessionPage page = chatSessionQueryService.pageSessions(account.getId(), assistantId, query);
|
||||
Map<BigInteger, AssistantAvailability> availabilityMap = resolveAssistantAvailability(account, page.getRecords());
|
||||
ChatWorkspaceSessionPage result = new ChatWorkspaceSessionPage();
|
||||
result.setTotal(page.getTotal());
|
||||
result.setPageNumber(page.getPageNumber());
|
||||
result.setPageSize(page.getPageSize());
|
||||
List<ChatWorkspaceSessionView> records = new ArrayList<>();
|
||||
for (ChatSessionSummary summary : page.getRecords()) {
|
||||
records.add(toSessionView(summary, availabilityMap.get(summary.getAssistantId())));
|
||||
}
|
||||
result.setRecords(records);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户会话详情。
|
||||
*
|
||||
* @param account 当前登录用户
|
||||
* @param sessionId 会话 ID
|
||||
* @return 工作台会话详情
|
||||
*/
|
||||
public ChatWorkspaceSessionDetailView getCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = requireUserSession(account, sessionId);
|
||||
AssistantAvailability availability = resolveAssistantAvailability(account, List.of(summary)).get(summary.getAssistantId());
|
||||
ChatWorkspaceSessionDetailView detail = new ChatWorkspaceSessionDetailView();
|
||||
fillSessionView(detail, summary, availability);
|
||||
if (availability != null && availability.displayBot() != null) {
|
||||
detail.setAssistant(toAssistantView(availability.displayBot(), summary));
|
||||
detail.setBoundKnowledges(resolveBoundKnowledges(availability.displayBot()));
|
||||
} else {
|
||||
detail.setAssistant(toAssistantView(null, summary));
|
||||
}
|
||||
ExtraKnowledgeResolution extraKnowledgeResolution = resolveExtraKnowledges(summary);
|
||||
detail.setExtraKnowledges(extraKnowledgeResolution.validKnowledges());
|
||||
detail.setRemovedExtraKnowledgeNames(extraKnowledgeResolution.removedNames());
|
||||
if (extraKnowledgeResolution.shouldSync()) {
|
||||
syncSessionExtraKnowledges(summary, extraKnowledgeResolution.validKnowledgeIds(), account.getId());
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户会话消息。
|
||||
*
|
||||
* @param account 当前登录用户
|
||||
* @param sessionId 会话 ID
|
||||
* @param query 分页参数
|
||||
* @return 历史消息分页
|
||||
*/
|
||||
public ChatHistoryPage queryCurrentUserMessages(LoginAccount account, BigInteger sessionId, ChatPageQuery query) {
|
||||
ChatSessionSummary summary = requireUserSession(account, sessionId);
|
||||
ChatHistoryPage firstPage = restoreRecentMessages(summary, query);
|
||||
if (firstPage != null) {
|
||||
return firstPage;
|
||||
}
|
||||
return chatSessionQueryService.pageMainlineMessages(sessionId, query);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户完整工作台会话。
|
||||
*
|
||||
* @param account 当前登录用户
|
||||
* @param sessionId 会话 ID
|
||||
* @return 完整会话视图
|
||||
*/
|
||||
public ChatWorkspaceConversationView getCurrentUserConversation(LoginAccount account, BigInteger sessionId) {
|
||||
requireUserSession(account, sessionId);
|
||||
List<ChatMessageRecord> records = chatSessionQueryService.listMainlineMessages(sessionId);
|
||||
Map<String, List<ChatMessageRecord>> variantsByRound = new LinkedHashMap<>();
|
||||
Set<BigInteger> roundIds = new LinkedHashSet<>();
|
||||
for (ChatMessageRecord record : records) {
|
||||
if (record == null || record.getRoundId() == null) {
|
||||
continue;
|
||||
}
|
||||
Integer variantCount = record.getVariantCount();
|
||||
if (variantCount != null && variantCount > 1) {
|
||||
roundIds.add(record.getRoundId());
|
||||
}
|
||||
}
|
||||
for (BigInteger roundId : roundIds) {
|
||||
variantsByRound.put(roundId.toString(), chatRoundOperateService.listVariants(sessionId, roundId));
|
||||
}
|
||||
ChatWorkspaceConversationView view = new ChatWorkspaceConversationView();
|
||||
view.setRecords(records);
|
||||
view.setVariantsByRound(variantsByRound);
|
||||
view.setTotal(records.size());
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名当前用户会话。
|
||||
*
|
||||
* @param account 当前登录用户
|
||||
* @param sessionId 会话 ID
|
||||
* @param title 新标题
|
||||
*/
|
||||
public void renameCurrentUserSession(LoginAccount account, BigInteger sessionId, String title) {
|
||||
if (!StringUtils.hasText(title)) {
|
||||
throw new BusinessException("标题不能为空");
|
||||
}
|
||||
requireUserSession(account, sessionId);
|
||||
chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户会话。
|
||||
*
|
||||
* @param account 当前登录用户
|
||||
* @param sessionId 会话 ID
|
||||
*/
|
||||
public void deleteCurrentUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
requireUserSession(account, sessionId);
|
||||
chatSessionCommandService.deleteSession(sessionId, account.getId(), account.getId());
|
||||
}
|
||||
|
||||
public List<ChatMessageRecord> listCurrentUserRoundVariants(LoginAccount account, BigInteger sessionId, BigInteger roundId) {
|
||||
requireUserSession(account, sessionId);
|
||||
return chatRoundOperateService.listVariants(sessionId, roundId);
|
||||
}
|
||||
|
||||
public ChatMessageRecord selectCurrentUserRoundVariant(LoginAccount account, BigInteger sessionId, BigInteger roundId, Integer variantIndex) {
|
||||
requireUserSession(account, sessionId);
|
||||
return chatRoundOperateService.selectVariant(sessionId, roundId, variantIndex, account.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送前校验会话是否仍可继续聊天。
|
||||
*
|
||||
* @param account 当前登录用户
|
||||
* @param sessionId 会话 ID
|
||||
* @param requestBotId 本次请求助手 ID
|
||||
*/
|
||||
public void assertSessionContinuable(LoginAccount account, BigInteger sessionId, BigInteger requestBotId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
||||
throw new BusinessException("无权访问该会话");
|
||||
}
|
||||
if (requestBotId != null && summary.getAssistantId() != null && !Objects.equals(summary.getAssistantId(), requestBotId)) {
|
||||
throw new BusinessException("当前会话与所选聊天助手不匹配");
|
||||
}
|
||||
AssistantAvailability availability = resolveAssistantAvailability(account, List.of(summary)).get(summary.getAssistantId());
|
||||
if (availability == null || !availability.continuable()) {
|
||||
throw new BusinessException(buildReadOnlyMessage(availability == null ? ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED : availability.reason()));
|
||||
}
|
||||
}
|
||||
|
||||
private ChatSessionSummary requireUserSession(LoginAccount account, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||
throw new BusinessException("会话不存在");
|
||||
}
|
||||
if (!Objects.equals(summary.getUserId(), account.getId())) {
|
||||
throw new BusinessException("无权访问该会话");
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首屏优先从热态恢复最近消息,避免分析库延迟导致刚完成的回复不可见。
|
||||
*
|
||||
* @param summary 会话摘要
|
||||
* @param query 分页参数
|
||||
* @return 命中热态时返回恢复结果,否则返回 null 继续走历史库
|
||||
*/
|
||||
private ChatHistoryPage restoreRecentMessages(ChatSessionSummary summary, ChatPageQuery query) {
|
||||
if (summary == null || query == null || query.getPageNumber() != 1) {
|
||||
return null;
|
||||
}
|
||||
List<tech.easyflow.chatlog.domain.dto.ChatMessageRecord> records =
|
||||
chatSessionQueryService.getRecentTail(summary.getId(), Math.toIntExact(query.getPageSize()));
|
||||
if (records == null || records.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (!isRestoredTailReliable(records)) {
|
||||
return null;
|
||||
}
|
||||
ChatHistoryPage page = new ChatHistoryPage();
|
||||
page.setPageNumber(query.getPageNumber());
|
||||
page.setPageSize(query.getPageSize());
|
||||
page.setRecords(records);
|
||||
long total = summary.getMessageCount() == null ? 0L : summary.getMessageCount();
|
||||
page.setTotal(Math.max(total, records.size()));
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Redis tail 是否仍符合当前主线版本语义。
|
||||
*
|
||||
* @param records Redis tail 消息
|
||||
* @return true 表示可直接用于首屏恢复
|
||||
*/
|
||||
private boolean isRestoredTailReliable(List<ChatMessageRecord> records) {
|
||||
Map<BigInteger, Integer> selectedVariantByRound = new LinkedHashMap<>();
|
||||
Map<BigInteger, Set<Integer>> assistantVariantsByRound = new LinkedHashMap<>();
|
||||
for (ChatMessageRecord record : records) {
|
||||
if (record == null || record.getRoundId() == null) {
|
||||
continue;
|
||||
}
|
||||
Integer selectedVariantIndex = record.getSelectedVariantIndex();
|
||||
if (selectedVariantIndex != null && selectedVariantIndex > 0) {
|
||||
Integer previous = selectedVariantByRound.putIfAbsent(record.getRoundId(), selectedVariantIndex);
|
||||
if (previous != null && !Objects.equals(previous, selectedVariantIndex)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ("assistant".equalsIgnoreCase(record.getSenderRole())
|
||||
&& record.getVariantIndex() != null
|
||||
&& record.getVariantIndex() > 0) {
|
||||
assistantVariantsByRound
|
||||
.computeIfAbsent(record.getRoundId(), key -> new LinkedHashSet<>())
|
||||
.add(record.getVariantIndex());
|
||||
}
|
||||
}
|
||||
for (Map.Entry<BigInteger, Integer> entry : selectedVariantByRound.entrySet()) {
|
||||
Set<Integer> visibleVariants = assistantVariantsByRound.get(entry.getKey());
|
||||
if (visibleVariants != null && !visibleVariants.isEmpty() && !visibleVariants.contains(entry.getValue())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private Map<BigInteger, AssistantAvailability> resolveAssistantAvailability(LoginAccount account, List<ChatSessionSummary> sessions) {
|
||||
Map<BigInteger, AssistantAvailability> result = new LinkedHashMap<>();
|
||||
if (sessions == null || sessions.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Set<BigInteger> assistantIds = new LinkedHashSet<>();
|
||||
for (ChatSessionSummary session : sessions) {
|
||||
if (session != null && session.getAssistantId() != null) {
|
||||
assistantIds.add(session.getAssistantId());
|
||||
}
|
||||
}
|
||||
if (assistantIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Bot> bots = botService.list(QueryWrapper.create().where(BOT.ID.in(assistantIds)));
|
||||
Map<BigInteger, Bot> botMap = new LinkedHashMap<>();
|
||||
for (Bot bot : bots) {
|
||||
botMap.put(bot.getId(), bot);
|
||||
}
|
||||
RoleCategoryAccessSnapshot accessSnapshot = categoryPermissionService.getAccess("BOT", account);
|
||||
for (BigInteger assistantId : assistantIds) {
|
||||
Bot currentBot = botMap.get(assistantId);
|
||||
if (currentBot == null) {
|
||||
result.put(assistantId, new AssistantAvailability(false, ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED, null));
|
||||
continue;
|
||||
}
|
||||
if (!accessSnapshot.canAccess(currentBot.getCreatedBy(), currentBot.getCategoryId())) {
|
||||
result.put(assistantId, new AssistantAvailability(false, ChatWorkspaceReadOnlyReason.NO_PERMISSION, null));
|
||||
continue;
|
||||
}
|
||||
Bot displayBot = botService.toPublishedView(currentBot);
|
||||
boolean online = Integer.valueOf(1).equals(currentBot.getStatus())
|
||||
&& PublishStatus.from(currentBot.getPublishStatus()) == PublishStatus.PUBLISHED;
|
||||
result.put(assistantId, new AssistantAvailability(
|
||||
online,
|
||||
online ? null : ChatWorkspaceReadOnlyReason.ASSISTANT_OFFLINE,
|
||||
displayBot
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private ChatWorkspaceSessionView toSessionView(ChatSessionSummary summary, AssistantAvailability availability) {
|
||||
ChatWorkspaceSessionView view = new ChatWorkspaceSessionView();
|
||||
fillSessionView(view, summary, availability);
|
||||
return view;
|
||||
}
|
||||
|
||||
private void fillSessionView(ChatWorkspaceSessionView view, ChatSessionSummary summary, AssistantAvailability availability) {
|
||||
view.setSessionId(summary.getId());
|
||||
view.setAssistantId(summary.getAssistantId());
|
||||
view.setAssistantCode(summary.getAssistantCode());
|
||||
view.setAssistantName(summary.getAssistantName());
|
||||
view.setTitle(summary.getTitle());
|
||||
view.setLastMessagePreview(summary.getLastMessagePreview());
|
||||
view.setMessageCount(summary.getMessageCount());
|
||||
view.setAccessAt(summary.getAccessAt());
|
||||
view.setLastMessageAt(summary.getLastMessageAt());
|
||||
view.setContinuable(availability != null && availability.continuable());
|
||||
view.setReadOnlyReason(availability == null ? ChatWorkspaceReadOnlyReason.ASSISTANT_DELETED : availability.reason());
|
||||
}
|
||||
|
||||
private ChatWorkspaceAssistantView toAssistantView(Bot bot, ChatSessionSummary summary) {
|
||||
ChatWorkspaceAssistantView view = new ChatWorkspaceAssistantView();
|
||||
if (bot != null) {
|
||||
view.setId(bot.getId());
|
||||
view.setAlias(bot.getAlias());
|
||||
view.setTitle(bot.getTitle());
|
||||
view.setDescription(bot.getDescription());
|
||||
view.setIcon(bot.getIcon());
|
||||
return view;
|
||||
}
|
||||
view.setId(summary == null ? null : summary.getAssistantId());
|
||||
view.setAlias(summary == null ? null : summary.getAssistantCode());
|
||||
view.setTitle(summary == null ? null : summary.getAssistantName());
|
||||
return view;
|
||||
}
|
||||
|
||||
private List<ChatWorkspaceKnowledgeView> resolveBoundKnowledges(Bot displayBot) {
|
||||
if (displayBot == null || displayBot.getPublishedSnapshotJson() == null) {
|
||||
return List.of();
|
||||
}
|
||||
Object rawBindings = displayBot.getPublishedSnapshotJson().get("knowledgeBindings");
|
||||
if (!(rawBindings instanceof List<?> bindings) || bindings.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<BigInteger> knowledgeIds = new ArrayList<>();
|
||||
for (Object binding : bindings) {
|
||||
if (!(binding instanceof Map<?, ?> bindingMap) || bindingMap.get("knowledgeId") == null) {
|
||||
continue;
|
||||
}
|
||||
knowledgeIds.add(new BigInteger(String.valueOf(bindingMap.get("knowledgeId"))));
|
||||
}
|
||||
return resolveVisibleKnowledgeViews(knowledgeIds).validKnowledges();
|
||||
}
|
||||
|
||||
private ExtraKnowledgeResolution resolveExtraKnowledges(ChatSessionSummary summary) {
|
||||
ChatSessionExtPayload payload = chatJsonSupport.fromJson(summary.getExtJson(), ChatSessionExtPayload.class);
|
||||
List<BigInteger> extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds();
|
||||
return resolveVisibleKnowledgeViews(extraKnowledgeIds);
|
||||
}
|
||||
|
||||
private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List<BigInteger> knowledgeIds) {
|
||||
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
|
||||
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
|
||||
}
|
||||
List<BigInteger> normalizedIds = new ArrayList<>();
|
||||
for (BigInteger knowledgeId : knowledgeIds) {
|
||||
if (knowledgeId != null && !normalizedIds.contains(knowledgeId)) {
|
||||
normalizedIds.add(knowledgeId);
|
||||
}
|
||||
}
|
||||
if (normalizedIds.isEmpty()) {
|
||||
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
|
||||
}
|
||||
List<DocumentCollection> collections = documentCollectionService.listByIds(normalizedIds);
|
||||
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
|
||||
for (DocumentCollection collection : collections) {
|
||||
collectionMap.put(collection.getId(), collection);
|
||||
}
|
||||
KnowledgeReadAccessSnapshot accessSnapshot = knowledgeVisibilityQueryHelper.getCurrentReadSnapshot();
|
||||
List<ChatWorkspaceKnowledgeView> validKnowledges = new ArrayList<>();
|
||||
List<BigInteger> validKnowledgeIds = new ArrayList<>();
|
||||
List<String> removedNames = new ArrayList<>();
|
||||
boolean changed = false;
|
||||
for (BigInteger knowledgeId : normalizedIds) {
|
||||
DocumentCollection current = collectionMap.get(knowledgeId);
|
||||
if (current == null) {
|
||||
removedNames.add("知识库#" + knowledgeId);
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (PublishStatus.from(current.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
removedNames.add(current.getTitle());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if (!knowledgeVisibilityQueryHelper.canRead(current, accessSnapshot)) {
|
||||
removedNames.add(current.getTitle());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
validKnowledges.add(toKnowledgeView(documentCollectionService.toPublishedView(current)));
|
||||
validKnowledgeIds.add(current.getId());
|
||||
}
|
||||
if (!Objects.equals(normalizedIds, validKnowledgeIds)) {
|
||||
changed = true;
|
||||
}
|
||||
return new ExtraKnowledgeResolution(validKnowledges, validKnowledgeIds, removedNames, changed);
|
||||
}
|
||||
|
||||
private ChatWorkspaceKnowledgeView toKnowledgeView(DocumentCollection collection) {
|
||||
ChatWorkspaceKnowledgeView view = new ChatWorkspaceKnowledgeView();
|
||||
view.setId(collection.getId());
|
||||
view.setAlias(collection.getAlias());
|
||||
view.setTitle(collection.getTitle());
|
||||
view.setDescription(collection.getDescription());
|
||||
view.setIcon(collection.getIcon());
|
||||
return view;
|
||||
}
|
||||
|
||||
private void syncSessionExtraKnowledges(ChatSessionSummary summary, List<BigInteger> validKnowledgeIds, BigInteger operatorId) {
|
||||
ChatSessionExtPayload payload = new ChatSessionExtPayload();
|
||||
payload.setExtraKnowledgeIds(validKnowledgeIds);
|
||||
ChatSessionUpsertCommand command = new ChatSessionUpsertCommand();
|
||||
command.setSessionId(summary.getId());
|
||||
command.setTenantId(summary.getTenantId());
|
||||
command.setDeptId(summary.getDeptId());
|
||||
command.setUserId(summary.getUserId());
|
||||
command.setUserAccount(summary.getUserAccount());
|
||||
command.setAssistantId(summary.getAssistantId());
|
||||
command.setAssistantCode(summary.getAssistantCode());
|
||||
command.setAssistantName(summary.getAssistantName());
|
||||
command.setTitle(summary.getTitle());
|
||||
command.setExtJson(chatJsonSupport.toJson(payload));
|
||||
command.setOperatorId(operatorId);
|
||||
command.setOperateAt(new Date());
|
||||
chatSessionCommandService.createOrTouchSession(command);
|
||||
}
|
||||
|
||||
private String buildReadOnlyMessage(ChatWorkspaceReadOnlyReason reason) {
|
||||
if (reason == ChatWorkspaceReadOnlyReason.NO_PERMISSION) {
|
||||
return "当前会话对应的聊天助手已无权限访问,仅支持查看历史记录";
|
||||
}
|
||||
if (reason == ChatWorkspaceReadOnlyReason.ASSISTANT_OFFLINE) {
|
||||
return "当前会话对应的聊天助手已下架,无法继续聊天";
|
||||
}
|
||||
return "当前会话对应的聊天助手已删除,无法继续聊天";
|
||||
}
|
||||
|
||||
private record AssistantAvailability(boolean continuable,
|
||||
ChatWorkspaceReadOnlyReason reason,
|
||||
Bot displayBot) {
|
||||
}
|
||||
|
||||
private record ExtraKnowledgeResolution(List<ChatWorkspaceKnowledgeView> validKnowledges,
|
||||
List<BigInteger> validKnowledgeIds,
|
||||
List<String> removedNames,
|
||||
boolean shouldSync) {
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,31 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import tech.easyflow.approval.annotation.RequirePublishedAccess;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.ChainInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.NodeInfo;
|
||||
import tech.easyflow.ai.easyagentsflow.entity.WorkflowCheckStage;
|
||||
import tech.easyflow.ai.easyagentsflow.service.TinyFlowService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowCheckService;
|
||||
import tech.easyflow.ai.easyagentsflow.service.WorkflowRunningParameterResolver;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
import tech.easyflow.ai.entity.WorkflowExecResult;
|
||||
import tech.easyflow.ai.enums.PublishStatus;
|
||||
import tech.easyflow.ai.service.WorkflowExecResultService;
|
||||
import tech.easyflow.ai.service.WorkflowService;
|
||||
import tech.easyflow.ai.service.WorkflowApiPermissionService;
|
||||
import tech.easyflow.ai.utils.WorkFlowUtil;
|
||||
import tech.easyflow.common.constant.Constants;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.HashMap;
|
||||
@@ -43,6 +52,10 @@ public class PublicWorkflowController {
|
||||
private WorkflowCheckService workflowCheckService;
|
||||
@Resource
|
||||
private WorkflowRunningParameterResolver workflowRunningParameterResolver;
|
||||
@Resource
|
||||
private WorkflowApiPermissionService workflowApiPermissionService;
|
||||
@Resource
|
||||
private WorkflowExecResultService workflowExecResultService;
|
||||
|
||||
/**
|
||||
* 通过id或别名获取工作流详情
|
||||
@@ -54,8 +67,11 @@ public class PublicWorkflowController {
|
||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#key", denyMessage = "工作流尚未发布")
|
||||
public Result<Workflow> getByIdOrAlias(
|
||||
@RequestParam
|
||||
@NotBlank(message = "key不能为空") String key) {
|
||||
@NotBlank(message = "key不能为空") String key,
|
||||
HttpServletRequest request) {
|
||||
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
Workflow workflow = workflowService.getPublishedDetail(key);
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
return Result.ok(workflow);
|
||||
}
|
||||
|
||||
@@ -88,19 +104,20 @@ public class PublicWorkflowController {
|
||||
* 运行工作流 - v2
|
||||
*/
|
||||
@PostMapping("/runAsync")
|
||||
@SaCheckPermission("/api/v1/workflow/save")
|
||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
|
||||
public Result<String> runAsync(@JsonBody(value = "id", required = true) BigInteger id,
|
||||
@JsonBody("variables") Map<String, Object> variables) {
|
||||
@JsonBody("variables") Map<String, Object> variables,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
if (variables == null) {
|
||||
variables = new HashMap<>();
|
||||
}
|
||||
Workflow workflow = workflowService.getPublishedById(id);
|
||||
if (workflow == null) {
|
||||
throw new RuntimeException("工作流不存在");
|
||||
}
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
variables = workflowRunningParameterResolver.normalizeRuntimeVariables(workflow.getContent(), variables);
|
||||
variables.put(Constants.LOGIN_USER_KEY, buildApiKeyLoginAccount(apiKey));
|
||||
variables.put(WorkFlowUtil.CREATED_KEY_MEMORY_KEY, WorkFlowUtil.API_KEY);
|
||||
String executeId = chainExecutor.executeAsync(PublishedWorkflowDefinitionIds.published(id.toString()), variables);
|
||||
return Result.ok(executeId);
|
||||
}
|
||||
@@ -110,7 +127,10 @@ public class PublicWorkflowController {
|
||||
*/
|
||||
@PostMapping("/getChainStatus")
|
||||
public Result<ChainInfo> getChainStatus(@JsonBody(value = "executeId") String executeId,
|
||||
@JsonBody("nodes") List<NodeInfo> nodes) {
|
||||
@JsonBody("nodes") List<NodeInfo> nodes,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||
ChainInfo res = tinyFlowService.getChainStatus(executeId, nodes);
|
||||
return Result.ok(res);
|
||||
}
|
||||
@@ -119,22 +139,23 @@ public class PublicWorkflowController {
|
||||
* 恢复工作流运行 - v2
|
||||
*/
|
||||
@PostMapping("/resume")
|
||||
@SaCheckPermission("/api/v1/workflow/save")
|
||||
public Result<Void> resume(@JsonBody(value = "executeId", required = true) String executeId,
|
||||
@JsonBody("confirmParams") Map<String, Object> confirmParams) {
|
||||
@JsonBody("confirmParams") Map<String, Object> confirmParams,
|
||||
HttpServletRequest request) {
|
||||
SysApiKey apiKey = workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
WorkflowExecResult execResult = assertApiKeyExecutionOwnership(apiKey, executeId);
|
||||
assertWorkflowExecutionResumable(execResult);
|
||||
chainExecutor.resumeAsync(executeId, confirmParams);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@GetMapping("getRunningParameters")
|
||||
@SaCheckPermission("/api/v1/workflow/query")
|
||||
@RequirePublishedAccess(resourceType = "WORKFLOW", idExpr = "#id", denyMessage = "工作流尚未发布")
|
||||
public Result<?> getRunningParameters(@RequestParam BigInteger id) {
|
||||
public Result<?> getRunningParameters(@RequestParam BigInteger id, HttpServletRequest request) {
|
||||
workflowApiPermissionService.assertWorkflowApi(request.getHeader("ApiKey"), request.getRequestURI());
|
||||
Workflow workflow = workflowService.getPublishedById(id);
|
||||
|
||||
if (workflow == null) {
|
||||
return Result.fail(1, "can not find the workflow by id: " + id);
|
||||
}
|
||||
assertStrictPublishedWorkflow(workflow);
|
||||
workflowCheckService.checkOrThrow(workflow.getContent(), WorkflowCheckStage.PRE_EXECUTE, workflow.getId());
|
||||
Map<String, Object> res = workflowRunningParameterResolver.buildRunningParametersView(workflow);
|
||||
if (res == null) {
|
||||
@@ -142,4 +163,72 @@ public class PublicWorkflowController {
|
||||
}
|
||||
return Result.ok(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 API Key 调用方的运行身份。
|
||||
*
|
||||
* @param apiKey 访问令牌
|
||||
* @return 工作流运行身份
|
||||
*/
|
||||
private LoginAccount buildApiKeyLoginAccount(SysApiKey apiKey) {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(apiKey.getId());
|
||||
account.setDeptId(apiKey.getDeptId() == null ? BigInteger.ZERO : apiKey.getDeptId());
|
||||
account.setTenantId(apiKey.getTenantId() == null ? BigInteger.ZERO : apiKey.getTenantId());
|
||||
account.setLoginName("apikey:" + apiKey.getId());
|
||||
account.setNickname("API 调用方");
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验工作流 Public API 只能访问严格已发布且存在发布快照的工作流。
|
||||
*
|
||||
* @param workflow 工作流发布视图
|
||||
*/
|
||||
private void assertStrictPublishedWorkflow(Workflow workflow) {
|
||||
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
|
||||
throw new BusinessException("工作流尚未发布");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Public Workflow API 后续操作只能作用于当前 API Key 发起的执行实例。
|
||||
*
|
||||
* @param apiKey 当前 API Key
|
||||
* @param executeId 执行 ID
|
||||
* @return 已通过归属校验的执行记录
|
||||
*/
|
||||
private WorkflowExecResult assertApiKeyExecutionOwnership(SysApiKey apiKey, String executeId) {
|
||||
if (executeId == null || executeId.isBlank()) {
|
||||
throw new BusinessException("执行ID不能为空");
|
||||
}
|
||||
WorkflowExecResult execResult = workflowExecResultService.getByExecKey(executeId);
|
||||
if (execResult == null) {
|
||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||
}
|
||||
if (!WorkFlowUtil.API_KEY.equals(execResult.getCreatedKey())
|
||||
|| apiKey == null
|
||||
|| apiKey.getId() == null
|
||||
|| !String.valueOf(apiKey.getId()).equals(execResult.getCreatedBy())) {
|
||||
throw new BusinessException("无权限访问当前工作流执行记录");
|
||||
}
|
||||
return execResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前执行实例是否仍允许恢复。
|
||||
*
|
||||
* @param execResult 执行记录
|
||||
*/
|
||||
private void assertWorkflowExecutionResumable(WorkflowExecResult execResult) {
|
||||
if (execResult == null || execResult.getWorkflowId() == null) {
|
||||
throw new BusinessException("工作流执行记录不存在,请稍后重试");
|
||||
}
|
||||
Workflow workflow = workflowService.getById(execResult.getWorkflowId());
|
||||
if (workflow == null || !PublishStatus.PUBLISHED.getCode().equals(workflow.getPublishStatus())
|
||||
|| workflow.getPublishedSnapshotJson() == null || workflow.getPublishedSnapshotJson().isEmpty()) {
|
||||
throw new BusinessException("工作流已下线或不可恢复执行");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
|
||||
import tech.easyflow.ai.entity.*;
|
||||
import tech.easyflow.ai.service.*;
|
||||
import tech.easyflow.ai.service.impl.BotServiceImpl;
|
||||
@@ -28,7 +29,9 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
import tech.easyflow.core.runtime.ChatChannel;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
@@ -66,6 +69,8 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
||||
private Cache<String, Object> cache;
|
||||
@Resource
|
||||
private AudioServiceManager audioServiceManager;
|
||||
@Resource
|
||||
private ChatRoundOperateService chatRoundOperateService;
|
||||
|
||||
public UcBotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
|
||||
BotDocumentCollectionService botDocumentCollectionService) {
|
||||
@@ -152,13 +157,17 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
||||
@JsonBody(value = "botId", required = true) BigInteger botId,
|
||||
@JsonBody(value = "conversationId", required = true) BigInteger conversationId,
|
||||
@JsonBody(value = "messages") List<Map<String, String>> messages,
|
||||
@JsonBody(value = "attachments") List<String> attachments
|
||||
@JsonBody(value = "attachments") List<String> attachments,
|
||||
@JsonBody(value = "regenerateRoundId") BigInteger regenerateRoundId
|
||||
|
||||
) {
|
||||
BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult();
|
||||
if (regenerateRoundId != null) {
|
||||
chatRoundOperateService.requireRegeneratableRound(conversationId, regenerateRoundId);
|
||||
}
|
||||
|
||||
// 前置校验:失败则直接返回错误SseEmitter
|
||||
SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult);
|
||||
SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult, regenerateRoundId);
|
||||
if (errorEmitter != null) {
|
||||
return errorEmitter;
|
||||
}
|
||||
@@ -170,7 +179,7 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
||||
messages,
|
||||
chatCheckResult,
|
||||
attachments,
|
||||
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments)
|
||||
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments, regenerateRoundId)
|
||||
);
|
||||
|
||||
}
|
||||
@@ -286,25 +295,38 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||
}
|
||||
|
||||
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments,
|
||||
BigInteger regenerateRoundId) {
|
||||
LoginAccount account = requireCurrentLoginAccount();
|
||||
ChatRuntimeContext context = new ChatRuntimeContext();
|
||||
context.setChannel(ChatChannel.USER_CENTER);
|
||||
context.setSessionId(conversationId);
|
||||
context.setTenantId(account == null ? BigInteger.ZERO : account.getTenantId());
|
||||
context.setDeptId(account == null ? BigInteger.ZERO : account.getDeptId());
|
||||
context.setUserId(account == null ? BigInteger.ZERO : account.getId());
|
||||
context.setUserAccount(account == null ? "anonymous" : account.getLoginName());
|
||||
context.setUserName(account == null ? "匿名用户" : (StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName()));
|
||||
context.setTenantId(account.getTenantId());
|
||||
context.setDeptId(account.getDeptId());
|
||||
context.setUserId(account.getId());
|
||||
context.setUserAccount(account.getLoginName());
|
||||
context.setUserName(StringUtils.hasText(account.getNickname()) ? account.getNickname() : account.getLoginName());
|
||||
context.setAssistantId(bot == null ? BigInteger.ZERO : bot.getId());
|
||||
context.setAssistantCode(bot == null ? null : bot.getAlias());
|
||||
context.setAssistantName(bot == null ? null : bot.getTitle());
|
||||
context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt);
|
||||
context.setAnonymous(account == null || BigInteger.ZERO.equals(account.getId()));
|
||||
context.setAnonymous(false);
|
||||
context.setAttachments(attachments);
|
||||
if (regenerateRoundId != null) {
|
||||
context.getExt().put(ChatRuntimeExtKeys.REGENERATE_ROUND_ID, regenerateRoundId);
|
||||
}
|
||||
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot);
|
||||
return context;
|
||||
}
|
||||
|
||||
private LoginAccount requireCurrentLoginAccount() {
|
||||
try {
|
||||
return SaTokenUtil.getLoginAccount();
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("当前登录状态失效,请重新登录后再试");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> getDefaultLlmOptions() {
|
||||
Map<String, Object> defaultLlmOptions = new HashMap<>();
|
||||
defaultLlmOptions.put("temperature", 0.7);
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
@@ -17,6 +18,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/userCenter/chatHistory")
|
||||
@@ -61,4 +63,19 @@ public class UcChatHistoryController {
|
||||
chatHistoryManageService.deleteUserSession(account.getId(), sessionId, account.getId());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
|
||||
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
|
||||
@PathVariable BigInteger roundId) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(chatHistoryManageService.listUserRoundVariants(account.getId(), sessionId, roundId));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
|
||||
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
|
||||
@PathVariable BigInteger roundId,
|
||||
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
|
||||
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||
return Result.ok(chatHistoryManageService.selectUserRoundVariant(account.getId(), sessionId, roundId, variantIndex, account.getId()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
|
||||
|
||||
# EasyFlow Chat Protocol Specification v1.1
|
||||
|
||||
* **Protocol Name:** `easyflow-chat`
|
||||
* **Version:** `1.1`
|
||||
* **Status:** Draft / Recommended
|
||||
* **Transport:** Server-Sent Events (SSE)
|
||||
* **Encoding:** UTF-8
|
||||
|
||||
|
||||
|
||||
## 1. 设计背景与目标
|
||||
|
||||
本协议用于描述 **EasyFlow 对话系统中的服务端事件流通信规范**,支持:
|
||||
|
||||
* AI 对话的 **流式输出**
|
||||
* 模型 **思考过程(Thinking)**
|
||||
* **工具调用(Tool Calling)**
|
||||
* **系统 / 业务错误**
|
||||
* **工作流 / Agent 状态**
|
||||
* **对话中的用户交互(表单、确认等)**
|
||||
* **中断与恢复(Suspend / Resume)**
|
||||
|
||||
设计目标:
|
||||
|
||||
* 前后端解耦
|
||||
* 协议长期可扩展
|
||||
* 不绑定具体模型厂商
|
||||
* 易于与 Workflow / Agent / Chain 架构集成
|
||||
|
||||
|
||||
|
||||
## 2. 传输层规范(Transport)
|
||||
|
||||
* 使用 HTTP + SSE(支持未来扩展为其他协议,比如 WebSocket 等)
|
||||
* Response Header:
|
||||
|
||||
```http
|
||||
Content-Type: text/event-stream
|
||||
Cache-Control: no-cache
|
||||
Connection: keep-alive
|
||||
```
|
||||
* 通信方向:**Server → Client**
|
||||
* 所有业务数据通过 `data` 字段传输,格式为 **JSON 字符串**
|
||||
|
||||
|
||||
|
||||
## 3. SSE Event 级别规范
|
||||
|
||||
### 3.1 Event Name(固定)
|
||||
|
||||
| event | 含义 |
|
||||
| - |-------|
|
||||
| message | 正常业务事件 |
|
||||
| error | 错误事件 |
|
||||
| done | 流结束事件 |
|
||||
|
||||
> ⚠️ **禁止在 event name 中承载业务语义**
|
||||
|
||||
|
||||
|
||||
## 4. 统一 Envelope 结构(核心)
|
||||
|
||||
### 4.1 基本结构
|
||||
|
||||
```json
|
||||
{
|
||||
"protocol": "easyflow-chat",
|
||||
"version": "1.1",
|
||||
"domain": "llm | tool | system | business | workflow | interaction | debug",
|
||||
"type": "string",
|
||||
"conversation_id": "string",
|
||||
"message_id": "string",
|
||||
"index": 0,
|
||||
"payload": {},
|
||||
"meta": {}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 4.2 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| -- |---------| -- |------------------------|
|
||||
| protocol | string | ✔ | 固定值 `easyflow-chat` |
|
||||
| version | string | ✔ | 协议版本 |
|
||||
| domain | string | ✔ | 事件所属领域 |
|
||||
| type | string | ✔ | 领域内事件类型 |
|
||||
| conversation_id | string | ✔ | 会话唯一标识 |
|
||||
| message_id | string | ✖ | assistant 消息 ID |
|
||||
| index | number | ✖ | 流式输出序号 |
|
||||
| payload | object | ✔ | 事件数据 |
|
||||
| meta | object | ✖ | 元信息(token、耗时等) |
|
||||
|
||||
|
||||
|
||||
## 5. Domain 定义
|
||||
|
||||
| Domain | 说明 |
|
||||
| -- | -- |
|
||||
| llm | 模型语义输出 |
|
||||
| tool | 工具调用与结果 |
|
||||
| system | 系统级事件 |
|
||||
| business | 业务规则 |
|
||||
| workflow | 工作流 / Agent 状态 |
|
||||
| interaction | 用户交互(表单等) |
|
||||
| debug | 调试信息 |
|
||||
|
||||
|
||||
|
||||
## 6. llm Domain
|
||||
|
||||
### 6.1 thinking
|
||||
|
||||
表示模型的思考过程。
|
||||
|
||||
#### 流式输出(delta)
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "llm",
|
||||
"type": "thinking",
|
||||
"payload": {
|
||||
"delta": "分析用户需求"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 完整输出(可选)
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "llm",
|
||||
"type": "message",
|
||||
"payload": {
|
||||
"content": "这是一个完整的回答"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 6.2 message
|
||||
|
||||
#### 流式输出(delta)
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "llm",
|
||||
"type": "message",
|
||||
"index": 12,
|
||||
"payload": {
|
||||
"delta": "这是一个"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 完整输出(可选)
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "llm",
|
||||
"type": "message",
|
||||
"payload": {
|
||||
"content": "这是一个完整的回答"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 7. tool Domain
|
||||
|
||||
### 7.1 tool_call
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "tool",
|
||||
"type": "tool_call",
|
||||
"payload": {
|
||||
"tool_call_id": "call_1",
|
||||
"name": "search",
|
||||
"arguments": {
|
||||
"query": "SSE 协议设计"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 7.2 tool_result
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "tool",
|
||||
"type": "tool_result",
|
||||
"payload": {
|
||||
"tool_call_id": "call_1",
|
||||
"status": "success | error",
|
||||
"result": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 8. system Domain
|
||||
|
||||
### 8.1 error
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "system",
|
||||
"type": "error",
|
||||
"payload": {
|
||||
"code": "MODEL_CONFIG_INVALID",
|
||||
"message": "模型配置错误",
|
||||
"retryable": false,
|
||||
"detail": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 8.2 status
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "system",
|
||||
"type": "status",
|
||||
"payload": {
|
||||
"state": "initializing | running | suspended | resumed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 9. business Domain
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "business",
|
||||
"type": "error",
|
||||
"payload": {
|
||||
"code": "QUOTA_EXCEEDED",
|
||||
"message": "配额不足"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 10. workflow Domain
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "workflow",
|
||||
"type": "status",
|
||||
"payload": {
|
||||
"node_id": "node_1",
|
||||
"state": "start | suspend | resume | end",
|
||||
"reason": "interaction"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 11. interaction Domain(对话内交互)
|
||||
|
||||
### 11.1 form_request
|
||||
|
||||
表示请求用户填写表单,对话进入挂起状态。
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "interaction",
|
||||
"type": "form_request",
|
||||
"payload": {
|
||||
"form_id": "user_info_form",
|
||||
"title": "补充信息",
|
||||
"description": "请填写以下信息以继续",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["age", "email"],
|
||||
"properties": {
|
||||
"age": {
|
||||
"type": "number",
|
||||
"title": "年龄"
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"title": "邮箱",
|
||||
"format": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ui": {
|
||||
"submit_text": "继续",
|
||||
"cancel_text": "取消"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 表单 schema **符合 JSON Schema 标准**
|
||||
|
||||
|
||||
|
||||
### 11.2 form_cancel
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "interaction",
|
||||
"type": "form_cancel",
|
||||
"payload": {
|
||||
"form_id": "user_info_form"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 12. 表单提交与恢复(非 SSE)
|
||||
|
||||
表单提交通过 **普通 HTTP / WebSocket 请求**:
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id": "conv_1",
|
||||
"form_id": "user_info_form",
|
||||
"values": {
|
||||
"age": 30,
|
||||
"email": "a@b.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
成功后服务端恢复 SSE 流。
|
||||
|
||||
|
||||
|
||||
## 13. done 事件(流结束)
|
||||
|
||||
```json
|
||||
{
|
||||
"domain": "system",
|
||||
"type": "done",
|
||||
"meta": {
|
||||
"prompt_tokens": 1234,
|
||||
"completion_tokens": 456,
|
||||
"latency_ms": 2300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 14. 错误处理规则
|
||||
|
||||
* 收到 `event: error` 后客户端应终止流
|
||||
* 错误语义由:
|
||||
|
||||
```
|
||||
domain + type + payload.code
|
||||
```
|
||||
|
||||
共同决定
|
||||
|
||||
|
||||
|
||||
## 15. 状态机视角(推荐)
|
||||
|
||||
```text
|
||||
RUNNING
|
||||
↓
|
||||
LLM_OUTPUT
|
||||
↓
|
||||
INTERACTION_REQUESTED
|
||||
↓
|
||||
SUSPENDED
|
||||
↓
|
||||
FORM_SUBMITTED
|
||||
↓
|
||||
RESUMED
|
||||
↓
|
||||
RUNNING
|
||||
↓
|
||||
DONE
|
||||
```
|
||||
|
||||
|
||||
|
||||
## 16. 扩展与兼容规则
|
||||
|
||||
1. 可新增 domain
|
||||
2. 可新增 type
|
||||
3. 不允许删除已有字段
|
||||
4. payload 可自由扩展
|
||||
5. 1.x 版本保持向后兼容
|
||||
|
||||
|
||||
|
||||
## 17. 设计原则
|
||||
|
||||
> * SSE 只负责事件流
|
||||
> * domain 定义责任边界
|
||||
> * type 定义语义动作
|
||||
> * payload 定义数据结构
|
||||
> * 前端不依赖 event name 判断业务,不依赖协议本身,支持其他协议的扩展
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -5,55 +5,136 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 负责聚合单轮聊天中的 assistant thinking、tool 调用和最终回答,
|
||||
* 并产出可用于历史回放的结构化 payload。
|
||||
*/
|
||||
public class ChatAssistantAccumulator {
|
||||
|
||||
private final StringBuilder content = new StringBuilder();
|
||||
private final StringBuilder reasoning = new StringBuilder();
|
||||
private final StringBuilder displayReasoning = new StringBuilder();
|
||||
private final List<Map<String, Object>> chains = new ArrayList<>();
|
||||
private final List<Map<String, Object>> messageChain = new ArrayList<>();
|
||||
private final List<Map<String, Object>> toolMessages = new ArrayList<>();
|
||||
private Map<String, Object> latestToolCallAssistant;
|
||||
private boolean toolCallBatchOpen;
|
||||
|
||||
/**
|
||||
* 追加当前 assistant 片段的文本内容。
|
||||
*
|
||||
* @param delta 内容增量
|
||||
*/
|
||||
public void appendContent(String delta) {
|
||||
if (delta != null && !delta.isEmpty()) {
|
||||
content.append(delta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加当前 assistant 片段的 reasoning 内容。
|
||||
*
|
||||
* @param delta reasoning 增量
|
||||
*/
|
||||
public void appendReasoning(String delta) {
|
||||
if (delta != null && !delta.isEmpty()) {
|
||||
reasoning.append(delta);
|
||||
displayReasoning.append(delta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 tool call,同时把当前 assistant 片段固化为一条结构化 assistant 消息。
|
||||
*
|
||||
* @param id tool call id
|
||||
* @param name tool 名称
|
||||
* @param arguments tool 参数
|
||||
*/
|
||||
public void appendToolCall(String id, String name, Object arguments) {
|
||||
Map<String, Object> chain = findToolChain(id, name);
|
||||
chain.put("status", "TOOL_CALL");
|
||||
chain.put("result", arguments);
|
||||
chain.put("arguments", arguments);
|
||||
|
||||
Map<String, Object> assistantMessage = ensureToolCallAssistantMessage();
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> toolCalls = (List<Map<String, Object>>) assistantMessage.computeIfAbsent("toolCalls",
|
||||
key -> new ArrayList<Map<String, Object>>());
|
||||
Map<String, Object> toolCall = new LinkedHashMap<>();
|
||||
toolCall.put("id", id);
|
||||
toolCall.put("name", name);
|
||||
toolCall.put("arguments", arguments == null ? null : String.valueOf(arguments));
|
||||
toolCalls.add(toolCall);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 tool result,并附加到结构化消息链中。
|
||||
*
|
||||
* @param id tool call id
|
||||
* @param name tool 名称
|
||||
* @param result tool 结果
|
||||
*/
|
||||
public void appendToolResult(String id, String name, Object result) {
|
||||
Map<String, Object> chain = findToolChain(id, name);
|
||||
chain.put("status", "TOOL_RESULT");
|
||||
chain.put("result", result);
|
||||
Map<String, Object> toolMessage = ChatRuntimeHistoryPayloadHelper.toolMessage(
|
||||
id,
|
||||
result == null ? null : String.valueOf(result)
|
||||
);
|
||||
toolMessages.add(toolMessage);
|
||||
messageChain.add(ChatRuntimeHistoryPayloadHelper.deepCopyMap(toolMessage));
|
||||
toolCallBatchOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 assistant 片段的文本内容。
|
||||
*
|
||||
* @return 文本内容
|
||||
*/
|
||||
public String getContent() {
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
public Map<String, Object> buildPayload() {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
/**
|
||||
* 获取最近一次 tool-call assistant 的 reasoning 内容,供实时内存消息回写复用。
|
||||
*
|
||||
* @return reasoning 内容
|
||||
*/
|
||||
public String getLatestToolCallReasoning() {
|
||||
return latestToolCallAssistant == null ? null : stringValue(latestToolCallAssistant.get("reasoningContent"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近一次 tool-call assistant 的内容,供实时内存消息回写复用。
|
||||
*
|
||||
* @return 内容
|
||||
*/
|
||||
public String getLatestToolCallContent() {
|
||||
return latestToolCallAssistant == null ? null : stringValue(latestToolCallAssistant.get("content"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 产出结构化 payload。
|
||||
*
|
||||
* @param finalContent 最终 assistant 文本
|
||||
* @return payload
|
||||
*/
|
||||
public Map<String, Object> buildPayload(String finalContent) {
|
||||
List<Map<String, Object>> payloadChains = new ArrayList<>();
|
||||
if (reasoning.length() > 0) {
|
||||
if (displayReasoning.length() > 0) {
|
||||
Map<String, Object> think = new LinkedHashMap<>();
|
||||
think.put("reasoning_content", reasoning.toString());
|
||||
think.put("reasoning_content", displayReasoning.toString());
|
||||
think.put("thinkingStatus", "end");
|
||||
think.put("thinlCollapse", Boolean.TRUE);
|
||||
payloadChains.add(think);
|
||||
}
|
||||
payloadChains.addAll(chains);
|
||||
if (!payloadChains.isEmpty()) {
|
||||
payload.put("chains", payloadChains);
|
||||
List<Map<String, Object>> payloadMessageChain = ChatRuntimeHistoryPayloadHelper.deepCopyList(messageChain);
|
||||
Map<String, Object> finalAssistantMessage = buildFinalAssistantMessage(finalContent);
|
||||
if (!finalAssistantMessage.isEmpty()) {
|
||||
payloadMessageChain.add(finalAssistantMessage);
|
||||
}
|
||||
return payload;
|
||||
return ChatRuntimeHistoryPayloadHelper.buildPayload(payloadMessageChain, toolMessages, payloadChains);
|
||||
}
|
||||
|
||||
private Map<String, Object> findToolChain(String id, String name) {
|
||||
@@ -71,4 +152,43 @@ public class ChatAssistantAccumulator {
|
||||
chains.add(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
private Map<String, Object> ensureToolCallAssistantMessage() {
|
||||
if (toolCallBatchOpen && latestToolCallAssistant != null && !hasPendingAssistantContent()) {
|
||||
return latestToolCallAssistant;
|
||||
}
|
||||
latestToolCallAssistant = ChatRuntimeHistoryPayloadHelper.assistantMessage(
|
||||
content.length() == 0 ? null : content.toString(),
|
||||
reasoning.length() == 0 ? null : reasoning.toString(),
|
||||
null
|
||||
);
|
||||
messageChain.add(latestToolCallAssistant);
|
||||
content.setLength(0);
|
||||
reasoning.setLength(0);
|
||||
toolCallBatchOpen = true;
|
||||
return latestToolCallAssistant;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildFinalAssistantMessage(String finalContent) {
|
||||
String assistantContent = finalContent;
|
||||
if ((assistantContent == null || assistantContent.isEmpty()) && content.length() > 0) {
|
||||
assistantContent = content.toString();
|
||||
}
|
||||
if ((assistantContent == null || assistantContent.isEmpty()) && reasoning.length() == 0) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
return ChatRuntimeHistoryPayloadHelper.assistantMessage(
|
||||
assistantContent,
|
||||
reasoning.length() == 0 ? null : reasoning.toString(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private boolean hasPendingAssistantContent() {
|
||||
return content.length() > 0 || reasoning.length() > 0;
|
||||
}
|
||||
|
||||
private String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package tech.easyflow.core.runtime;
|
||||
|
||||
/**
|
||||
* 聊天运行时扩展字段键。
|
||||
*/
|
||||
public final class ChatRuntimeExtKeys {
|
||||
|
||||
/**
|
||||
* 会话级额外知识库 ID 列表。
|
||||
*/
|
||||
public static final String EXTRA_KNOWLEDGE_IDS = "extraKnowledgeIds";
|
||||
|
||||
/**
|
||||
* 当前请求要重答的轮次 ID。
|
||||
*/
|
||||
public static final String REGENERATE_ROUND_ID = "regenerateRoundId";
|
||||
|
||||
/**
|
||||
* 当前请求归属的轮次 ID。
|
||||
*/
|
||||
public static final String CURRENT_ROUND_ID = "currentRoundId";
|
||||
|
||||
/**
|
||||
* 当前请求归属的轮次序号。
|
||||
*/
|
||||
public static final String CURRENT_ROUND_NO = "currentRoundNo";
|
||||
|
||||
/**
|
||||
* 当前请求生成的答案版本序号。
|
||||
*/
|
||||
public static final String CURRENT_VARIANT_INDEX = "currentVariantIndex";
|
||||
|
||||
private ChatRuntimeExtKeys() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package tech.easyflow.core.runtime;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 聊天运行时历史 payload 的统一读写工具。
|
||||
* <p>
|
||||
* 该工具只处理 {@link Map} / {@link List} 结构,避免把 easy-agents 类型泄漏到通用协议模块。
|
||||
*/
|
||||
public final class ChatRuntimeHistoryPayloadHelper {
|
||||
|
||||
public static final String KEY_MESSAGE_CHAIN = "messageChain";
|
||||
public static final String KEY_ASSISTANT_MESSAGE = "assistantMessage";
|
||||
public static final String KEY_FINAL_ASSISTANT_MESSAGE = "finalAssistantMessage";
|
||||
public static final String KEY_TOOL_MESSAGES = "toolMessages";
|
||||
public static final String KEY_DISPLAY_CHAINS = "displayChains";
|
||||
public static final String KEY_CHAINS = "chains";
|
||||
|
||||
private ChatRuntimeHistoryPayloadHelper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 assistant 历史消息结构。
|
||||
*
|
||||
* @param content assistant 内容
|
||||
* @param reasoningContent assistant reasoning 内容
|
||||
* @param toolCalls assistant tool calls
|
||||
* @return assistant 历史消息结构
|
||||
*/
|
||||
public static Map<String, Object> assistantMessage(String content,
|
||||
String reasoningContent,
|
||||
List<Map<String, Object>> toolCalls) {
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("role", "assistant");
|
||||
if (content != null) {
|
||||
message.put("content", content);
|
||||
}
|
||||
if (reasoningContent != null && !reasoningContent.isEmpty()) {
|
||||
message.put("reasoningContent", reasoningContent);
|
||||
}
|
||||
if (toolCalls != null && !toolCalls.isEmpty()) {
|
||||
message.put("toolCalls", deepCopyList(toolCalls));
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 tool 历史消息结构。
|
||||
*
|
||||
* @param toolCallId tool call id
|
||||
* @param content tool 结果内容
|
||||
* @return tool 历史消息结构
|
||||
*/
|
||||
public static Map<String, Object> toolMessage(String toolCallId, String content) {
|
||||
Map<String, Object> message = new LinkedHashMap<>();
|
||||
message.put("role", "tool");
|
||||
message.put("toolCallId", toolCallId);
|
||||
if (content != null) {
|
||||
message.put("content", content);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造统一的历史 payload。
|
||||
*
|
||||
* @param messageChain 完整 assistant/tool 历史链
|
||||
* @param toolMessages tool 历史列表
|
||||
* @param displayChains 前端展示链
|
||||
* @return payload
|
||||
*/
|
||||
public static Map<String, Object> buildPayload(List<Map<String, Object>> messageChain,
|
||||
List<Map<String, Object>> toolMessages,
|
||||
List<Map<String, Object>> displayChains) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
List<Map<String, Object>> safeMessageChain = deepCopyList(messageChain);
|
||||
List<Map<String, Object>> safeToolMessages = deepCopyList(toolMessages);
|
||||
List<Map<String, Object>> safeDisplayChains = deepCopyList(displayChains);
|
||||
if (!safeMessageChain.isEmpty()) {
|
||||
payload.put(KEY_MESSAGE_CHAIN, safeMessageChain);
|
||||
Map<String, Object> firstAssistant = findAssistant(safeMessageChain, false);
|
||||
if (!firstAssistant.isEmpty()) {
|
||||
payload.put(KEY_ASSISTANT_MESSAGE, firstAssistant);
|
||||
}
|
||||
Map<String, Object> lastAssistant = findAssistant(safeMessageChain, true);
|
||||
if (!lastAssistant.isEmpty() && !lastAssistant.equals(firstAssistant)) {
|
||||
payload.put(KEY_FINAL_ASSISTANT_MESSAGE, lastAssistant);
|
||||
}
|
||||
}
|
||||
if (!safeToolMessages.isEmpty()) {
|
||||
payload.put(KEY_TOOL_MESSAGES, safeToolMessages);
|
||||
}
|
||||
if (!safeDisplayChains.isEmpty()) {
|
||||
payload.put(KEY_DISPLAY_CHAINS, safeDisplayChains);
|
||||
payload.put(KEY_CHAINS, deepCopyList(safeDisplayChains));
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取结构化消息链。
|
||||
*
|
||||
* @param payload contentPayload
|
||||
* @return 结构化消息链
|
||||
*/
|
||||
public static List<Map<String, Object>> getMessageChain(Map<String, Object> payload) {
|
||||
return getMapList(payload == null ? null : payload.get(KEY_MESSAGE_CHAIN));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 assistant 历史消息。
|
||||
*
|
||||
* @param payload contentPayload
|
||||
* @return assistant 历史消息
|
||||
*/
|
||||
public static Map<String, Object> getAssistantMessage(Map<String, Object> payload) {
|
||||
return getMap(payload == null ? null : payload.get(KEY_ASSISTANT_MESSAGE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取最终 assistant 历史消息。
|
||||
*
|
||||
* @param payload contentPayload
|
||||
* @return 最终 assistant 历史消息
|
||||
*/
|
||||
public static Map<String, Object> getFinalAssistantMessage(Map<String, Object> payload) {
|
||||
return getMap(payload == null ? null : payload.get(KEY_FINAL_ASSISTANT_MESSAGE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 tool 历史消息列表。
|
||||
*
|
||||
* @param payload contentPayload
|
||||
* @return tool 历史消息列表
|
||||
*/
|
||||
public static List<Map<String, Object>> getToolMessages(Map<String, Object> payload) {
|
||||
return getMapList(payload == null ? null : payload.get(KEY_TOOL_MESSAGES));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 payload 是否已经包含新结构化历史。
|
||||
*
|
||||
* @param payload contentPayload
|
||||
* @return true 表示包含新结构
|
||||
*/
|
||||
public static boolean hasStructuredHistory(Map<String, Object> payload) {
|
||||
return !getMessageChain(payload).isEmpty()
|
||||
|| !getAssistantMessage(payload).isEmpty()
|
||||
|| !getToolMessages(payload).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取对象为 Map。
|
||||
*
|
||||
* @param value 值
|
||||
* @return Map 视图
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Map<String, Object> getMap(Object value) {
|
||||
if (!(value instanceof Map<?, ?> source)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : source.entrySet()) {
|
||||
if (entry.getKey() != null) {
|
||||
result.put(String.valueOf(entry.getKey()), entry.getValue());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取对象为 Map 列表。
|
||||
*
|
||||
* @param value 值
|
||||
* @return Map 列表
|
||||
*/
|
||||
public static List<Map<String, Object>> getMapList(Object value) {
|
||||
if (!(value instanceof List<?> source)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Map<String, Object>> result = new ArrayList<>(source.size());
|
||||
for (Object item : source) {
|
||||
Map<String, Object> map = getMap(item);
|
||||
if (!map.isEmpty()) {
|
||||
result.add(map);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝 Map 列表。
|
||||
*
|
||||
* @param source 原始列表
|
||||
* @return 深拷贝后的列表
|
||||
*/
|
||||
public static List<Map<String, Object>> deepCopyList(List<Map<String, Object>> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<Map<String, Object>> copy = new ArrayList<>(source.size());
|
||||
for (Map<String, Object> item : source) {
|
||||
copy.add(deepCopyMap(item));
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝 Map。
|
||||
*
|
||||
* @param source 原始 Map
|
||||
* @return 深拷贝后的 Map
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static Map<String, Object> deepCopyMap(Map<String, Object> source) {
|
||||
if (source == null || source.isEmpty()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> copy = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> entry : source.entrySet()) {
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof Map<?, ?> mapValue) {
|
||||
copy.put(entry.getKey(), deepCopyMap((Map<String, Object>) mapValue));
|
||||
} else if (value instanceof List<?> listValue) {
|
||||
copy.put(entry.getKey(), deepCopyValueList((List<Object>) listValue));
|
||||
} else {
|
||||
copy.put(entry.getKey(), value);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static List<Object> deepCopyValueList(List<Object> source) {
|
||||
List<Object> copy = new ArrayList<>(source.size());
|
||||
for (Object item : source) {
|
||||
if (item instanceof Map<?, ?> mapItem) {
|
||||
copy.add(deepCopyMap((Map<String, Object>) mapItem));
|
||||
} else if (item instanceof List<?> listItem) {
|
||||
copy.add(deepCopyValueList((List<Object>) listItem));
|
||||
} else {
|
||||
copy.add(item);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private static Map<String, Object> findAssistant(List<Map<String, Object>> messageChain, boolean reverse) {
|
||||
if (messageChain == null || messageChain.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
if (reverse) {
|
||||
for (int i = messageChain.size() - 1; i >= 0; i--) {
|
||||
Map<String, Object> item = messageChain.get(i);
|
||||
if ("assistant".equalsIgnoreCase(String.valueOf(item.get("role")))) {
|
||||
return deepCopyMap(item);
|
||||
}
|
||||
}
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
for (Map<String, Object> item : messageChain) {
|
||||
if ("assistant".equalsIgnoreCase(String.valueOf(item.get("role")))) {
|
||||
return deepCopyMap(item);
|
||||
}
|
||||
}
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ public class ChatRuntimeMessage implements Serializable {
|
||||
private Date createdAt = new Date();
|
||||
private BigInteger senderId;
|
||||
private String senderName;
|
||||
private BigInteger roundId;
|
||||
private Integer roundNo;
|
||||
private String messageKind;
|
||||
private Integer variantIndex;
|
||||
|
||||
public BigInteger getMessageId() {
|
||||
return messageId;
|
||||
@@ -80,4 +84,36 @@ public class ChatRuntimeMessage implements Serializable {
|
||||
public void setSenderName(String senderName) {
|
||||
this.senderName = senderName;
|
||||
}
|
||||
|
||||
public BigInteger getRoundId() {
|
||||
return roundId;
|
||||
}
|
||||
|
||||
public void setRoundId(BigInteger roundId) {
|
||||
this.roundId = roundId;
|
||||
}
|
||||
|
||||
public Integer getRoundNo() {
|
||||
return roundNo;
|
||||
}
|
||||
|
||||
public void setRoundNo(Integer roundNo) {
|
||||
this.roundNo = roundNo;
|
||||
}
|
||||
|
||||
public String getMessageKind() {
|
||||
return messageKind;
|
||||
}
|
||||
|
||||
public void setMessageKind(String messageKind) {
|
||||
this.messageKind = messageKind;
|
||||
}
|
||||
|
||||
public Integer getVariantIndex() {
|
||||
return variantIndex;
|
||||
}
|
||||
|
||||
public void setVariantIndex(Integer variantIndex) {
|
||||
this.variantIndex = variantIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package tech.easyflow.ai.chattime.availability;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.ai.entity.BotDocumentCollection;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.permission.KnowledgeReadAccessSnapshot;
|
||||
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.enums.CategoryResourceType;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysDeptService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 知识库聊天时可用性判定器。
|
||||
*/
|
||||
@Component
|
||||
public class ChatTimeKnowledgeAvailabilityResolver implements ChatTimeToolAvailabilityResolver {
|
||||
|
||||
private final KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper;
|
||||
private final CategoryPermissionService categoryPermissionService;
|
||||
private final SysDeptService sysDeptService;
|
||||
|
||||
public ChatTimeKnowledgeAvailabilityResolver(KnowledgeVisibilityQueryHelper knowledgeVisibilityQueryHelper,
|
||||
CategoryPermissionService categoryPermissionService,
|
||||
SysDeptService sysDeptService) {
|
||||
this.knowledgeVisibilityQueryHelper = knowledgeVisibilityQueryHelper;
|
||||
this.categoryPermissionService = categoryPermissionService;
|
||||
this.sysDeptService = sysDeptService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean supports(Object candidate) {
|
||||
return candidate instanceof DocumentCollection || candidate instanceof BotDocumentCollection;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public ChatTimeToolAvailabilityDecision resolve(ChatTimeToolAvailabilityContext context, Object candidate) {
|
||||
LoginAccount loginAccount = context == null ? null : context.getLoginAccount();
|
||||
if (loginAccount == null || loginAccount.getId() == null) {
|
||||
return ChatTimeToolAvailabilityDecision.unavailable("CHAT_TIME_LOGIN_ACCOUNT_MISSING", "聊天上下文缺少当前用户身份");
|
||||
}
|
||||
DocumentCollection knowledge = extractKnowledge(candidate);
|
||||
if (knowledge == null) {
|
||||
return ChatTimeToolAvailabilityDecision.unavailable("CHAT_TIME_KNOWLEDGE_MISSING", "聊天绑定的知识库不存在");
|
||||
}
|
||||
KnowledgeReadAccessSnapshot readSnapshot = buildReadSnapshot(loginAccount);
|
||||
if (knowledgeVisibilityQueryHelper.canRead(knowledge, readSnapshot)) {
|
||||
return ChatTimeToolAvailabilityDecision.available();
|
||||
}
|
||||
return ChatTimeToolAvailabilityDecision.unavailable("CHAT_TIME_KNOWLEDGE_FORBIDDEN", "当前用户无权在聊天中访问该知识库");
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于显式登录快照构造知识库读权限快照,避免依赖线程登录态。
|
||||
*
|
||||
* @param loginAccount 当前聊天用户
|
||||
* @return 读权限快照
|
||||
*/
|
||||
protected KnowledgeReadAccessSnapshot buildReadSnapshot(LoginAccount loginAccount) {
|
||||
RoleCategoryAccessSnapshot categoryAccess = categoryPermissionService.getAccess(
|
||||
CategoryResourceType.KNOWLEDGE.getCode(),
|
||||
loginAccount
|
||||
);
|
||||
if (categoryAccess.isSuperAdmin()) {
|
||||
return new KnowledgeReadAccessSnapshot(categoryAccess, Collections.emptySet());
|
||||
}
|
||||
BigInteger deptId = loginAccount.getDeptId();
|
||||
Set<BigInteger> readableDeptIds = deptId == null
|
||||
? Collections.emptySet()
|
||||
: sysDeptService.getSelfAndAncestorDeptIds(deptId);
|
||||
return new KnowledgeReadAccessSnapshot(categoryAccess, readableDeptIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从聊天工具候选项中提取知识库实体。
|
||||
*
|
||||
* @param candidate 候选项
|
||||
* @return 知识库实体
|
||||
*/
|
||||
protected DocumentCollection extractKnowledge(Object candidate) {
|
||||
if (candidate instanceof DocumentCollection documentCollection) {
|
||||
return documentCollection;
|
||||
}
|
||||
if (candidate instanceof BotDocumentCollection botDocumentCollection) {
|
||||
return botDocumentCollection.getKnowledge();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package tech.easyflow.ai.chattime.availability;
|
||||
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.core.runtime.ChatChannel;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 聊天时工具可用性判定上下文。
|
||||
*/
|
||||
public class ChatTimeToolAvailabilityContext implements Serializable {
|
||||
|
||||
/**
|
||||
* 运行时上下文中保存聊天时权限快照的扩展字段 key。
|
||||
*/
|
||||
public static final String RUNTIME_EXT_KEY = "chatTimeToolAvailabilityContext";
|
||||
|
||||
private LoginAccount loginAccount;
|
||||
|
||||
private Bot bot;
|
||||
|
||||
private ChatChannel chatChannel;
|
||||
|
||||
private BigInteger sessionId;
|
||||
|
||||
/**
|
||||
* 将当前登录用户快照绑定到运行时上下文。
|
||||
*
|
||||
* @param runtimeContext 聊天运行时上下文
|
||||
* @param loginAccount 登录用户快照
|
||||
* @param bot 当前聊天助手
|
||||
*/
|
||||
public static void bindLoggedInSnapshot(ChatRuntimeContext runtimeContext, LoginAccount loginAccount, Bot bot) {
|
||||
if (!hasLoggedInAccount(loginAccount)) {
|
||||
return;
|
||||
}
|
||||
ChatTimeToolAvailabilityContext chatTimeContext = new ChatTimeToolAvailabilityContext();
|
||||
chatTimeContext.setLoginAccount(loginAccount);
|
||||
chatTimeContext.setBot(bot);
|
||||
chatTimeContext.setChatChannel(runtimeContext == null ? null : runtimeContext.getChannel());
|
||||
chatTimeContext.setSessionId(runtimeContext == null ? null : runtimeContext.getSessionId());
|
||||
chatTimeContext.bindToRuntimeContext(runtimeContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为可用于聊天态权限判定的登录用户。
|
||||
*
|
||||
* @param loginAccount 登录用户快照
|
||||
* @return 是否为有效登录用户
|
||||
*/
|
||||
public static boolean hasLoggedInAccount(LoginAccount loginAccount) {
|
||||
return loginAccount != null
|
||||
&& loginAccount.getId() != null
|
||||
&& !BigInteger.ZERO.equals(loginAccount.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定到聊天运行时上下文,供异步链路显式透传。
|
||||
*
|
||||
* @param runtimeContext 聊天运行时上下文
|
||||
*/
|
||||
public void bindToRuntimeContext(ChatRuntimeContext runtimeContext) {
|
||||
if (runtimeContext == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> ext = runtimeContext.getExt();
|
||||
ext.put(RUNTIME_EXT_KEY, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从聊天运行时上下文中读取聊天时权限上下文。
|
||||
*
|
||||
* @param runtimeContext 聊天运行时上下文
|
||||
* @return 聊天时权限上下文,不存在时返回 null
|
||||
*/
|
||||
public static ChatTimeToolAvailabilityContext fromRuntimeContext(ChatRuntimeContext runtimeContext) {
|
||||
if (runtimeContext == null || runtimeContext.getExt() == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = runtimeContext.getExt().get(RUNTIME_EXT_KEY);
|
||||
if (value instanceof ChatTimeToolAvailabilityContext context) {
|
||||
return context;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public LoginAccount getLoginAccount() {
|
||||
return loginAccount;
|
||||
}
|
||||
|
||||
public void setLoginAccount(LoginAccount loginAccount) {
|
||||
this.loginAccount = loginAccount;
|
||||
}
|
||||
|
||||
public Bot getBot() {
|
||||
return bot;
|
||||
}
|
||||
|
||||
public void setBot(Bot bot) {
|
||||
this.bot = bot;
|
||||
}
|
||||
|
||||
public ChatChannel getChatChannel() {
|
||||
return chatChannel;
|
||||
}
|
||||
|
||||
public void setChatChannel(ChatChannel chatChannel) {
|
||||
this.chatChannel = chatChannel;
|
||||
}
|
||||
|
||||
public BigInteger getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(BigInteger sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package tech.easyflow.ai.chattime.availability;
|
||||
|
||||
/**
|
||||
* 聊天时工具可用性判定结果。
|
||||
*/
|
||||
public class ChatTimeToolAvailabilityDecision {
|
||||
|
||||
private boolean available;
|
||||
|
||||
private String reasonCode;
|
||||
|
||||
private String reasonMessage;
|
||||
|
||||
/**
|
||||
* 创建可用判定。
|
||||
*
|
||||
* @return 可用判定
|
||||
*/
|
||||
public static ChatTimeToolAvailabilityDecision available() {
|
||||
ChatTimeToolAvailabilityDecision decision = new ChatTimeToolAvailabilityDecision();
|
||||
decision.setAvailable(true);
|
||||
return decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建不可用判定。
|
||||
*
|
||||
* @param reasonCode 原因编码
|
||||
* @param reasonMessage 原因说明
|
||||
* @return 不可用判定
|
||||
*/
|
||||
public static ChatTimeToolAvailabilityDecision unavailable(String reasonCode, String reasonMessage) {
|
||||
ChatTimeToolAvailabilityDecision decision = new ChatTimeToolAvailabilityDecision();
|
||||
decision.setAvailable(false);
|
||||
decision.setReasonCode(reasonCode);
|
||||
decision.setReasonMessage(reasonMessage);
|
||||
return decision;
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
return available;
|
||||
}
|
||||
|
||||
public void setAvailable(boolean available) {
|
||||
this.available = available;
|
||||
}
|
||||
|
||||
public String getReasonCode() {
|
||||
return reasonCode;
|
||||
}
|
||||
|
||||
public void setReasonCode(String reasonCode) {
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
|
||||
public String getReasonMessage() {
|
||||
return reasonMessage;
|
||||
}
|
||||
|
||||
public void setReasonMessage(String reasonMessage) {
|
||||
this.reasonMessage = reasonMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package tech.easyflow.ai.chattime.availability;
|
||||
|
||||
/**
|
||||
* 聊天时工具可用性判定器。
|
||||
*/
|
||||
public interface ChatTimeToolAvailabilityResolver {
|
||||
|
||||
/**
|
||||
* 当前判定器是否支持指定候选项。
|
||||
*
|
||||
* @param candidate 聊天工具候选项
|
||||
* @return 是否支持
|
||||
*/
|
||||
boolean supports(Object candidate);
|
||||
|
||||
/**
|
||||
* 计算候选项在当前聊天上下文中的可用性。
|
||||
*
|
||||
* @param context 聊天时上下文
|
||||
* @param candidate 聊天工具候选项
|
||||
* @return 判定结果
|
||||
*/
|
||||
ChatTimeToolAvailabilityDecision resolve(ChatTimeToolAvailabilityContext context, Object candidate);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package tech.easyflow.ai.chattime.availability;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 聊天时工具可用性编排服务。
|
||||
*/
|
||||
public interface ChatTimeToolAvailabilityService {
|
||||
|
||||
/**
|
||||
* 评估单个候选项的聊天时可用性。
|
||||
*
|
||||
* @param context 聊天时上下文
|
||||
* @param candidate 聊天工具候选项
|
||||
* @return 判定结果
|
||||
*/
|
||||
ChatTimeToolAvailabilityDecision evaluate(ChatTimeToolAvailabilityContext context, Object candidate);
|
||||
|
||||
/**
|
||||
* 过滤当前聊天上下文中可用的候选项。
|
||||
*
|
||||
* @param context 聊天时上下文
|
||||
* @param candidates 候选项列表
|
||||
* @param <T> 候选项类型
|
||||
* @return 过滤后的候选项
|
||||
*/
|
||||
<T> List<T> filterAvailable(ChatTimeToolAvailabilityContext context, List<T> candidates);
|
||||
|
||||
/**
|
||||
* 断言候选项在当前聊天上下文中可用。
|
||||
*
|
||||
* @param context 聊天时上下文
|
||||
* @param candidate 聊天工具候选项
|
||||
* @param fallbackMessage 默认兜底文案
|
||||
*/
|
||||
void assertAvailable(ChatTimeToolAvailabilityContext context, Object candidate, String fallbackMessage);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package tech.easyflow.ai.chattime.availability;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 聊天时工具可用性编排服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class ChatTimeToolAvailabilityServiceImpl implements ChatTimeToolAvailabilityService {
|
||||
|
||||
private final List<ChatTimeToolAvailabilityResolver> resolvers;
|
||||
|
||||
public ChatTimeToolAvailabilityServiceImpl(List<ChatTimeToolAvailabilityResolver> resolvers) {
|
||||
this.resolvers = resolvers == null ? Collections.emptyList() : resolvers;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public ChatTimeToolAvailabilityDecision evaluate(ChatTimeToolAvailabilityContext context, Object candidate) {
|
||||
if (candidate == null) {
|
||||
return ChatTimeToolAvailabilityDecision.unavailable("TOOL_CANDIDATE_MISSING", "聊天工具候选项不存在");
|
||||
}
|
||||
for (ChatTimeToolAvailabilityResolver resolver : resolvers) {
|
||||
if (resolver.supports(candidate)) {
|
||||
return resolver.resolve(context, candidate);
|
||||
}
|
||||
}
|
||||
return ChatTimeToolAvailabilityDecision.unavailable("TOOL_RESOLVER_MISSING", "当前聊天工具缺少可用性判定器");
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public <T> List<T> filterAvailable(ChatTimeToolAvailabilityContext context, List<T> candidates) {
|
||||
if (candidates == null || candidates.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return candidates.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(candidate -> evaluate(context, candidate).isAvailable())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void assertAvailable(ChatTimeToolAvailabilityContext context, Object candidate, String fallbackMessage) {
|
||||
ChatTimeToolAvailabilityDecision decision = evaluate(context, candidate);
|
||||
if (decision.isAvailable()) {
|
||||
return;
|
||||
}
|
||||
String message = decision.getReasonMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
message = fallbackMessage;
|
||||
}
|
||||
throw new BusinessException(message == null || message.isBlank() ? "当前聊天工具不可用" : message);
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,11 @@ import tech.easyflow.core.chat.protocol.payload.ErrorPayload;
|
||||
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeManager;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeMessage;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -87,6 +89,7 @@ public class ChatStreamListener implements StreamResponseListener {
|
||||
sendToolCallEnvelope(toolCall);
|
||||
}
|
||||
}
|
||||
applyToolCallHistorySnapshot(aiMessage);
|
||||
aiMessage.setContent(null);
|
||||
memoryPrompt.addMessage(aiMessage);
|
||||
List<ToolMessage> toolMessages = aiMessageResponse.executeToolCallsAndGetToolMessages();
|
||||
@@ -175,6 +178,7 @@ public class ChatStreamListener implements StreamResponseListener {
|
||||
deltaMap.put("role", MessageRole.ASSISTANT.getValue());
|
||||
deltaMap.put("delta", deltaContent);
|
||||
chatEnvelope.setPayload(deltaMap);
|
||||
chatEnvelope.setMeta(buildStreamMeta());
|
||||
|
||||
boolean sent = sseEmitter.send(chatEnvelope);
|
||||
if (!sent) {
|
||||
@@ -195,6 +199,7 @@ public class ChatStreamListener implements StreamResponseListener {
|
||||
payload.put("name", toolCall.getName());
|
||||
payload.put("arguments", toolCall.getArguments());
|
||||
chatEnvelope.setPayload(payload);
|
||||
chatEnvelope.setMeta(buildStreamMeta());
|
||||
boolean sent = sseEmitter.send(chatEnvelope);
|
||||
if (!sent) {
|
||||
throw new IllegalStateException("SSE emitter has already completed while sending tool call envelope");
|
||||
@@ -213,6 +218,7 @@ public class ChatStreamListener implements StreamResponseListener {
|
||||
payload.put("tool_call_id", toolMessage.getToolCallId());
|
||||
payload.put("result", toolMessage.getContent());
|
||||
chatEnvelope.setPayload(payload);
|
||||
chatEnvelope.setMeta(buildStreamMeta());
|
||||
boolean sent = sseEmitter.send(chatEnvelope);
|
||||
if (!sent) {
|
||||
throw new IllegalStateException("SSE emitter has already completed while sending tool result envelope");
|
||||
@@ -235,6 +241,7 @@ public class ChatStreamListener implements StreamResponseListener {
|
||||
envelope.setPayload(payload);
|
||||
envelope.setDomain(ChatDomain.SYSTEM);
|
||||
envelope.setType(ChatType.ERROR);
|
||||
envelope.setMeta(buildStreamMeta());
|
||||
boolean sent = sseEmitter.sendError(envelope);
|
||||
if (!sent) {
|
||||
LOG.warn("sendSystemError skipped because emitter is closed, conversationId={}", conversationId);
|
||||
@@ -242,6 +249,54 @@ public class ChatStreamListener implements StreamResponseListener {
|
||||
sseEmitter.complete();
|
||||
}
|
||||
|
||||
private Map<String, Object> buildStreamMeta() {
|
||||
Map<String, Object> meta = new LinkedHashMap<>();
|
||||
BigInteger roundId = getBigIntegerExt(ChatRuntimeExtKeys.CURRENT_ROUND_ID);
|
||||
Integer roundNo = getIntegerExt(ChatRuntimeExtKeys.CURRENT_ROUND_NO);
|
||||
Integer variantIndex = getIntegerExt(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX);
|
||||
BigInteger regenerateRoundId = getBigIntegerExt(ChatRuntimeExtKeys.REGENERATE_ROUND_ID);
|
||||
if (roundId != null) {
|
||||
meta.put("roundId", roundId.toString());
|
||||
}
|
||||
if (roundNo != null) {
|
||||
meta.put("roundNo", roundNo);
|
||||
}
|
||||
if (variantIndex != null) {
|
||||
meta.put("variantIndex", variantIndex);
|
||||
}
|
||||
meta.put("regenerate", regenerateRoundId != null);
|
||||
if (regenerateRoundId != null) {
|
||||
meta.put("regenerateRoundId", regenerateRoundId.toString());
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
private BigInteger getBigIntegerExt(String key) {
|
||||
Object value = runtimeContext == null || runtimeContext.getExt() == null
|
||||
? null
|
||||
: runtimeContext.getExt().get(key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof BigInteger number) {
|
||||
return number;
|
||||
}
|
||||
return new BigInteger(String.valueOf(value));
|
||||
}
|
||||
|
||||
private Integer getIntegerExt(String key) {
|
||||
Object value = runtimeContext == null || runtimeContext.getExt() == null
|
||||
? null
|
||||
: runtimeContext.getExt().get(key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Integer number) {
|
||||
return number;
|
||||
}
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
}
|
||||
|
||||
private void stopStreamClient(StreamContext context, String reason, Throwable source) {
|
||||
try {
|
||||
if (context != null && context.getClient() != null) {
|
||||
@@ -266,6 +321,7 @@ public class ChatStreamListener implements StreamResponseListener {
|
||||
message.setCreatedAt(new Date());
|
||||
message.setSenderId(runtimeContext.getAssistantId());
|
||||
message.setSenderName(runtimeContext.getAssistantName());
|
||||
applyRoundMetadata(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -275,11 +331,44 @@ public class ChatStreamListener implements StreamResponseListener {
|
||||
message.setContentType("TEXT");
|
||||
String fullContent = context != null && context.getFullMessage() != null ? context.getFullMessage().getContent() : null;
|
||||
message.setContentText(StringUtil.hasText(fullContent) ? fullContent : assistantAccumulator.getContent());
|
||||
message.setContentPayload(assistantAccumulator.buildPayload());
|
||||
message.setContentPayload(assistantAccumulator.buildPayload(message.getContentText()));
|
||||
message.setCreatedAt(new Date());
|
||||
message.setSenderId(runtimeContext.getAssistantId());
|
||||
message.setSenderName(runtimeContext.getAssistantName());
|
||||
applyRoundMetadata(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把当前轮次元数据写回 assistant 消息,确保 SSE 与后续持久化链路都能识别轮次和版本。
|
||||
*
|
||||
* @param message assistant 运行时消息
|
||||
*/
|
||||
private void applyRoundMetadata(ChatRuntimeMessage message) {
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
message.setRoundId(getBigIntegerExt(ChatRuntimeExtKeys.CURRENT_ROUND_ID));
|
||||
message.setRoundNo(getIntegerExt(ChatRuntimeExtKeys.CURRENT_ROUND_NO));
|
||||
message.setVariantIndex(getIntegerExt(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 tool call assistant 写入临时 memory 前,把 reasoning/content 快照回填到消息对象中,
|
||||
* 以便前端 history 透传和 DeepSeek 下一轮请求都能拿到完整链路。
|
||||
*
|
||||
* @param aiMessage tool call assistant 消息
|
||||
*/
|
||||
private void applyToolCallHistorySnapshot(AiMessage aiMessage) {
|
||||
if (aiMessage == null) {
|
||||
return;
|
||||
}
|
||||
if (!StringUtil.hasText(aiMessage.getReasoningContent())) {
|
||||
aiMessage.setReasoningContent(assistantAccumulator.getLatestToolCallReasoning());
|
||||
}
|
||||
if (!StringUtil.hasText(aiMessage.getTextContent())) {
|
||||
aiMessage.setFullContent(assistantAccumulator.getLatestToolCallContent());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package tech.easyflow.ai.easyagents.memory;
|
||||
|
||||
import com.easyagents.core.message.AiMessage;
|
||||
import com.easyagents.core.message.Message;
|
||||
import com.easyagents.core.message.ToolCall;
|
||||
import com.easyagents.core.message.ToolMessage;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeHistoryPayloadHelper;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeMessage;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 负责把聊天运行时持久化 payload 恢复为 easy-agents 消息链。
|
||||
*/
|
||||
public final class ChatRuntimeHistoryMessageMapper {
|
||||
|
||||
private ChatRuntimeHistoryMessageMapper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 从运行时消息中恢复 assistant / tool 历史消息链。
|
||||
*
|
||||
* @param runtimeMessage 运行时消息
|
||||
* @return 恢复后的消息列表
|
||||
*/
|
||||
public static List<Message> toStructuredMessages(ChatRuntimeMessage runtimeMessage) {
|
||||
if (runtimeMessage == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Map<String, Object> payload = runtimeMessage.getContentPayload();
|
||||
List<Map<String, Object>> messageChain = ChatRuntimeHistoryPayloadHelper.getMessageChain(payload);
|
||||
if (!messageChain.isEmpty()) {
|
||||
return toMessages(messageChain);
|
||||
}
|
||||
if (!ChatRuntimeHistoryPayloadHelper.hasStructuredHistory(payload)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<Message> messages = new ArrayList<>();
|
||||
Map<String, Object> assistantMessage = ChatRuntimeHistoryPayloadHelper.getAssistantMessage(payload);
|
||||
if (!assistantMessage.isEmpty()) {
|
||||
AiMessage aiMessage = toAiMessage(assistantMessage);
|
||||
if (aiMessage != null) {
|
||||
messages.add(aiMessage);
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> toolMessages = ChatRuntimeHistoryPayloadHelper.getToolMessages(payload);
|
||||
messages.addAll(toMessages(toolMessages));
|
||||
Map<String, Object> finalAssistantMessage = ChatRuntimeHistoryPayloadHelper.getFinalAssistantMessage(payload);
|
||||
if (!finalAssistantMessage.isEmpty()) {
|
||||
AiMessage finalAiMessage = toAiMessage(finalAssistantMessage);
|
||||
if (finalAiMessage != null) {
|
||||
messages.add(finalAiMessage);
|
||||
}
|
||||
} else if (!messages.isEmpty()
|
||||
&& (runtimeMessage.getContentText() != null && !runtimeMessage.getContentText().isBlank())
|
||||
&& assistantMessage.containsKey("toolCalls")) {
|
||||
messages.add(new AiMessage(runtimeMessage.getContentText()));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static List<Message> toMessages(List<Map<String, Object>> messageChain) {
|
||||
List<Message> messages = new ArrayList<>();
|
||||
for (Map<String, Object> item : messageChain) {
|
||||
String role = String.valueOf(item.get("role"));
|
||||
if ("assistant".equalsIgnoreCase(role)) {
|
||||
AiMessage aiMessage = toAiMessage(item);
|
||||
if (aiMessage != null) {
|
||||
messages.add(aiMessage);
|
||||
}
|
||||
} else if ("tool".equalsIgnoreCase(role)) {
|
||||
ToolMessage toolMessage = toToolMessage(item);
|
||||
if (toolMessage != null) {
|
||||
messages.add(toolMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static AiMessage toAiMessage(Map<String, Object> item) {
|
||||
if (item == null || item.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String content = stringValue(item.get("content"));
|
||||
String reasoningContent = stringValue(firstNonNull(item.get("reasoningContent"), item.get("reasoning_content")));
|
||||
List<ToolCall> toolCalls = toToolCalls(ChatRuntimeHistoryPayloadHelper.getMapList(firstNonNull(item.get("toolCalls"), item.get("tool_calls"))));
|
||||
boolean hasContent = content != null && !content.isBlank();
|
||||
boolean hasReasoning = reasoningContent != null && !reasoningContent.isBlank();
|
||||
boolean hasToolCalls = toolCalls != null && !toolCalls.isEmpty();
|
||||
if (!hasContent && !hasReasoning && !hasToolCalls) {
|
||||
return null;
|
||||
}
|
||||
AiMessage aiMessage = new AiMessage(hasContent ? content : null);
|
||||
if (hasReasoning) {
|
||||
aiMessage.setReasoningContent(reasoningContent);
|
||||
aiMessage.setFullReasoningContent(reasoningContent);
|
||||
}
|
||||
if (hasToolCalls) {
|
||||
aiMessage.setToolCalls(toolCalls);
|
||||
}
|
||||
return aiMessage;
|
||||
}
|
||||
|
||||
private static ToolMessage toToolMessage(Map<String, Object> item) {
|
||||
if (item == null || item.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String content = stringValue(item.get("content"));
|
||||
String toolCallId = stringValue(firstNonNull(item.get("toolCallId"), item.get("tool_call_id")));
|
||||
if ((content == null || content.isBlank()) && (toolCallId == null || toolCallId.isBlank())) {
|
||||
return null;
|
||||
}
|
||||
ToolMessage toolMessage = new ToolMessage();
|
||||
toolMessage.setContent(content);
|
||||
toolMessage.setToolCallId(toolCallId);
|
||||
return toolMessage;
|
||||
}
|
||||
|
||||
private static List<ToolCall> toToolCalls(List<Map<String, Object>> rawToolCalls) {
|
||||
if (rawToolCalls == null || rawToolCalls.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<ToolCall> toolCalls = new ArrayList<>(rawToolCalls.size());
|
||||
for (Map<String, Object> rawToolCall : rawToolCalls) {
|
||||
ToolCall toolCall = new ToolCall();
|
||||
toolCall.setId(stringValue(rawToolCall.get("id")));
|
||||
toolCall.setName(stringValue(firstNonNull(rawToolCall.get("name"), rawToolCall.get("toolName"))));
|
||||
toolCall.setArguments(stringValue(rawToolCall.get("arguments")));
|
||||
toolCalls.add(toolCall);
|
||||
}
|
||||
return toolCalls;
|
||||
}
|
||||
|
||||
private static Object firstNonNull(Object first, Object second) {
|
||||
return first != null ? first : second;
|
||||
}
|
||||
|
||||
private static String stringValue(Object value) {
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import tech.easyflow.core.runtime.ChatRuntimeMessage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 从聊天运行时消息恢复 easy-agents 历史消息。
|
||||
*/
|
||||
public class RuntimeChatMemory implements ChatMemory {
|
||||
|
||||
private final Object id;
|
||||
@@ -19,10 +22,7 @@ public class RuntimeChatMemory implements ChatMemory {
|
||||
this.id = id;
|
||||
if (runtimeMessages != null) {
|
||||
for (ChatRuntimeMessage runtimeMessage : runtimeMessages) {
|
||||
Message message = toMessage(runtimeMessage);
|
||||
if (message != null) {
|
||||
this.messages.add(message);
|
||||
}
|
||||
this.messages.addAll(toMessages(runtimeMessage));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,20 +55,40 @@ public class RuntimeChatMemory implements ChatMemory {
|
||||
return id;
|
||||
}
|
||||
|
||||
private Message toMessage(ChatRuntimeMessage runtimeMessage) {
|
||||
if (runtimeMessage == null || runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
|
||||
return null;
|
||||
private List<Message> toMessages(ChatRuntimeMessage runtimeMessage) {
|
||||
if (runtimeMessage == null) {
|
||||
return List.of();
|
||||
}
|
||||
String role = runtimeMessage.getRole();
|
||||
if ("assistant".equalsIgnoreCase(role)) {
|
||||
return new AiMessage(runtimeMessage.getContentText());
|
||||
List<Message> structuredMessages = ChatRuntimeHistoryMessageMapper.toStructuredMessages(runtimeMessage);
|
||||
if (!structuredMessages.isEmpty()) {
|
||||
return structuredMessages;
|
||||
}
|
||||
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(new AiMessage(runtimeMessage.getContentText()));
|
||||
}
|
||||
if ("system".equalsIgnoreCase(role)) {
|
||||
return new SystemMessage(runtimeMessage.getContentText());
|
||||
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(new SystemMessage(runtimeMessage.getContentText()));
|
||||
}
|
||||
if ("tool".equalsIgnoreCase(role)) {
|
||||
return new SystemMessage(runtimeMessage.getContentText());
|
||||
List<Message> structuredMessages = ChatRuntimeHistoryMessageMapper.toStructuredMessages(runtimeMessage);
|
||||
if (!structuredMessages.isEmpty()) {
|
||||
return structuredMessages;
|
||||
}
|
||||
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(new SystemMessage(runtimeMessage.getContentText()));
|
||||
}
|
||||
return new UserMessage(runtimeMessage.getContentText());
|
||||
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(new UserMessage(runtimeMessage.getContentText()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package tech.easyflow.ai.easyagents.tool;
|
||||
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 聊天工具名称辅助类。
|
||||
*
|
||||
* <p>聊天工具名称最终会作为 OpenAI-compatible 协议里的 function.name 发给上游模型,
|
||||
* 因此在启用英文名称时需要确保名称稳定且满足 ASCII 约束。</p>
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-05-11
|
||||
*/
|
||||
public final class ChatToolNameHelper {
|
||||
|
||||
private static final Pattern SAFE_TOOL_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$");
|
||||
|
||||
private ChatToolNameHelper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析聊天工具名称。
|
||||
*
|
||||
* @param needEnglishName 是否优先使用英文名称
|
||||
* @param englishName 英文名称
|
||||
* @param displayName 展示名称
|
||||
* @param fallbackPrefix 安全兜底名前缀
|
||||
* @param resourceId 资源 ID
|
||||
* @return 最终工具名称
|
||||
*/
|
||||
public static String resolveToolName(boolean needEnglishName,
|
||||
String englishName,
|
||||
String displayName,
|
||||
String fallbackPrefix,
|
||||
BigInteger resourceId) {
|
||||
if (!needEnglishName) {
|
||||
return StringUtils.hasText(displayName) ? displayName : buildFallbackName(fallbackPrefix, resourceId);
|
||||
}
|
||||
if (StringUtils.hasText(englishName) && SAFE_TOOL_NAME_PATTERN.matcher(englishName).matches()) {
|
||||
return englishName;
|
||||
}
|
||||
return buildFallbackName(fallbackPrefix, resourceId);
|
||||
}
|
||||
|
||||
private static String buildFallbackName(String fallbackPrefix, BigInteger resourceId) {
|
||||
String prefix = StringUtils.hasText(fallbackPrefix) ? fallbackPrefix : "tool";
|
||||
String suffix = resourceId == null ? "unknown" : resourceId.toString();
|
||||
return prefix + "_" + suffix;
|
||||
}
|
||||
}
|
||||
@@ -4,35 +4,76 @@ import com.easyagents.core.document.Document;
|
||||
import com.easyagents.core.model.chat.tool.BaseTool;
|
||||
import com.easyagents.core.model.chat.tool.Parameter;
|
||||
import com.easyagents.rag.retrieval.RetrievalMode;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityService;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.common.util.SpringContextUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 知识库聊天工具。
|
||||
*/
|
||||
public class DocumentCollectionTool extends BaseTool {
|
||||
|
||||
private BigInteger knowledgeId;
|
||||
private RetrievalMode retrievalMode = RetrievalMode.HYBRID;
|
||||
private ChatTimeToolAvailabilityContext chatTimeContext;
|
||||
|
||||
/**
|
||||
* 默认构造器。
|
||||
*/
|
||||
public DocumentCollectionTool() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于知识库实体构造聊天工具。
|
||||
*
|
||||
* @param documentCollection 知识库
|
||||
* @param needEnglishName 是否使用英文名
|
||||
*/
|
||||
public DocumentCollectionTool(DocumentCollection documentCollection, boolean needEnglishName) {
|
||||
this(documentCollection, needEnglishName, RetrievalMode.HYBRID);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于知识库实体构造聊天工具。
|
||||
*
|
||||
* @param documentCollection 知识库
|
||||
* @param needEnglishName 是否使用英文名
|
||||
* @param retrievalMode 检索模式
|
||||
*/
|
||||
public DocumentCollectionTool(DocumentCollection documentCollection, boolean needEnglishName, RetrievalMode retrievalMode) {
|
||||
this(documentCollection, needEnglishName, retrievalMode, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于知识库实体和聊天时权限上下文构造聊天工具。
|
||||
*
|
||||
* @param documentCollection 知识库
|
||||
* @param needEnglishName 是否使用英文名
|
||||
* @param retrievalMode 检索模式
|
||||
* @param chatTimeContext 聊天时权限上下文
|
||||
*/
|
||||
public DocumentCollectionTool(DocumentCollection documentCollection,
|
||||
boolean needEnglishName,
|
||||
RetrievalMode retrievalMode,
|
||||
ChatTimeToolAvailabilityContext chatTimeContext) {
|
||||
this.knowledgeId = documentCollection.getId();
|
||||
this.retrievalMode = retrievalMode == null ? RetrievalMode.HYBRID : retrievalMode;
|
||||
if (needEnglishName) {
|
||||
this.name = documentCollection.getEnglishName();
|
||||
} else {
|
||||
this.name = documentCollection.getTitle();
|
||||
}
|
||||
this.chatTimeContext = chatTimeContext;
|
||||
this.name = ChatToolNameHelper.resolveToolName(
|
||||
needEnglishName,
|
||||
documentCollection.getEnglishName(),
|
||||
documentCollection.getTitle(),
|
||||
"knowledge",
|
||||
documentCollection.getId()
|
||||
);
|
||||
this.description = documentCollection.getDescription();
|
||||
this.parameters = getDefaultParameters();
|
||||
}
|
||||
@@ -63,10 +104,42 @@ public class DocumentCollectionTool extends BaseTool {
|
||||
this.retrievalMode = retrievalMode == null ? RetrievalMode.HYBRID : retrievalMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天时权限上下文。
|
||||
*
|
||||
* @return 聊天时权限上下文
|
||||
*/
|
||||
public ChatTimeToolAvailabilityContext getChatTimeContext() {
|
||||
return chatTimeContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置聊天时权限上下文。
|
||||
*
|
||||
* @param chatTimeContext 聊天时权限上下文
|
||||
*/
|
||||
public void setChatTimeContext(ChatTimeToolAvailabilityContext chatTimeContext) {
|
||||
this.chatTimeContext = chatTimeContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行知识库检索。
|
||||
*
|
||||
* @param argsMap 工具入参
|
||||
* @return 检索结果拼接文本
|
||||
*/
|
||||
@Override
|
||||
public Object invoke(Map<String, Object> argsMap) {
|
||||
|
||||
DocumentCollectionService knowledgeService = SpringContextUtil.getBean(DocumentCollectionService.class);
|
||||
DocumentCollection knowledge = null;
|
||||
if (this.knowledgeId != null) {
|
||||
knowledge = knowledgeService.getById(this.knowledgeId);
|
||||
}
|
||||
if (knowledge == null) {
|
||||
throw new BusinessException("知识库不存在");
|
||||
}
|
||||
assertChatTimeAvailability(knowledge);
|
||||
|
||||
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
|
||||
request.setKnowledgeId(this.knowledgeId);
|
||||
request.setQuery((String) argsMap.get("input"));
|
||||
@@ -91,5 +164,17 @@ public class DocumentCollectionTool extends BaseTool {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 当工具由聊天运行时装配时,执行聊天态权限兜底。
|
||||
*
|
||||
* @param knowledge 当前知识库实体
|
||||
*/
|
||||
protected void assertChatTimeAvailability(DocumentCollection knowledge) {
|
||||
if (chatTimeContext == null) {
|
||||
return;
|
||||
}
|
||||
ChatTimeToolAvailabilityService availabilityService = SpringContextUtil.getBean(ChatTimeToolAvailabilityService.class);
|
||||
availabilityService.assertAvailable(chatTimeContext, knowledge, "当前用户无权在聊天中访问该知识库");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -26,11 +26,13 @@ public class WorkflowTool extends BaseTool {
|
||||
public WorkflowTool(Workflow workflow, boolean needEnglishName, String definitionId) {
|
||||
this.workflowId = workflow.getId();
|
||||
this.definitionId = definitionId;
|
||||
if (needEnglishName) {
|
||||
this.name = workflow.getEnglishName();
|
||||
} else {
|
||||
this.name = workflow.getTitle();
|
||||
}
|
||||
this.name = ChatToolNameHelper.resolveToolName(
|
||||
needEnglishName,
|
||||
workflow.getEnglishName(),
|
||||
workflow.getTitle(),
|
||||
"workflow",
|
||||
workflow.getId()
|
||||
);
|
||||
this.description = workflow.getDescription();
|
||||
this.parameters = toParameters(workflow, definitionId);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ public class ChainEventListenerForSave implements ChainEventListener {
|
||||
record.setWorkflowJson(workflow.getContent());
|
||||
record.setStartTime(new Date());
|
||||
record.setStatus(state.getStatus().getValue());
|
||||
record.setCreatedKey(WorkFlowUtil.USER_KEY);
|
||||
record.setCreatedKey(WorkFlowUtil.getCreatedKey(chain));
|
||||
record.setCreatedBy(WorkFlowUtil.getOperator(chain).getId().toString());
|
||||
try {
|
||||
workflowExecResultService.save(record);
|
||||
|
||||
@@ -31,9 +31,11 @@ public class TinyFlowService {
|
||||
ChainState chainState = chainStateRepository.load(executeId);
|
||||
ChainInfo res = getChainInfo(executeId, chainState);
|
||||
|
||||
for (NodeInfo node : nodes) {
|
||||
processNodeState(executeId, node, chainStateRepository, nodeStateRepository);
|
||||
res.getNodes().put(node.getNodeId(), node);
|
||||
if (nodes != null) {
|
||||
for (NodeInfo node : nodes) {
|
||||
processNodeState(executeId, node, chainStateRepository, nodeStateRepository);
|
||||
res.getNodes().put(node.getNodeId(), node);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package tech.easyflow.ai.entity;
|
||||
|
||||
import com.easyagents.core.message.*;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.easyagents.core.message.AiMessage;
|
||||
import com.easyagents.core.message.Message;
|
||||
import com.easyagents.core.message.SystemMessage;
|
||||
import com.easyagents.core.message.ToolCall;
|
||||
import com.easyagents.core.message.ToolMessage;
|
||||
import com.easyagents.core.message.UserMessage;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@@ -11,6 +14,9 @@ import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Public API 聊天请求参数。
|
||||
*/
|
||||
public class ChatRequestParams implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@@ -24,7 +30,7 @@ public class ChatRequestParams implements Serializable {
|
||||
@JsonProperty("messages")
|
||||
public void setMessagesFromJson(List<Object> rawMessages) {
|
||||
if (rawMessages == null) {
|
||||
this.messages = null;
|
||||
this.messages = new ArrayList<>();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -57,8 +63,8 @@ public class ChatRequestParams implements Serializable {
|
||||
return switch (role) {
|
||||
case "user" -> jsonObj.toJavaObject(UserMessage.class);
|
||||
case "system" -> jsonObj.toJavaObject(SystemMessage.class);
|
||||
case "assistant" -> jsonObj.toJavaObject(AiMessage.class);
|
||||
case "tool" -> jsonObj.toJavaObject(ToolMessage.class);
|
||||
case "assistant" -> toAiMessage(jsonObj);
|
||||
case "tool" -> toToolMessage(jsonObj);
|
||||
default -> {
|
||||
UserMessage defaultMsg = new UserMessage();
|
||||
defaultMsg.setContent(content);
|
||||
@@ -67,6 +73,85 @@ public class ChatRequestParams implements Serializable {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 JSON 结构恢复为 assistant 消息,兼容 `reasoningContent` / `reasoning_content`
|
||||
* 以及 `toolCalls` / `tool_calls` 两种写法。
|
||||
*
|
||||
* @param jsonObj 原始 JSON
|
||||
* @return assistant 消息
|
||||
*/
|
||||
private AiMessage toAiMessage(JSONObject jsonObj) {
|
||||
AiMessage aiMessage = jsonObj.toJavaObject(AiMessage.class);
|
||||
String reasoningContent = jsonObj.getString("reasoningContent");
|
||||
if (reasoningContent == null || reasoningContent.isBlank()) {
|
||||
reasoningContent = jsonObj.getString("reasoning_content");
|
||||
}
|
||||
if (reasoningContent != null && !reasoningContent.isBlank()) {
|
||||
aiMessage.setReasoningContent(reasoningContent);
|
||||
aiMessage.setFullReasoningContent(reasoningContent);
|
||||
}
|
||||
List<ToolCall> toolCalls = parseToolCalls(jsonObj);
|
||||
if (!toolCalls.isEmpty()) {
|
||||
aiMessage.setToolCalls(toolCalls);
|
||||
}
|
||||
return aiMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 JSON 结构恢复为 tool 消息,兼容 `toolCallId` / `tool_call_id` 两种写法。
|
||||
*
|
||||
* @param jsonObj 原始 JSON
|
||||
* @return tool 消息
|
||||
*/
|
||||
private ToolMessage toToolMessage(JSONObject jsonObj) {
|
||||
ToolMessage toolMessage = jsonObj.toJavaObject(ToolMessage.class);
|
||||
if (toolMessage.getToolCallId() == null || toolMessage.getToolCallId().isBlank()) {
|
||||
toolMessage.setToolCallId(jsonObj.getString("tool_call_id"));
|
||||
}
|
||||
return toolMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 assistant 上的 tool calls。
|
||||
*
|
||||
* @param jsonObj 原始 JSON
|
||||
* @return tool call 列表
|
||||
*/
|
||||
private List<ToolCall> parseToolCalls(JSONObject jsonObj) {
|
||||
Object rawToolCalls = jsonObj.get("toolCalls");
|
||||
if (rawToolCalls == null) {
|
||||
rawToolCalls = jsonObj.get("tool_calls");
|
||||
}
|
||||
List<ToolCall> toolCalls = new ArrayList<>();
|
||||
if (!(rawToolCalls instanceof List<?> rawList)) {
|
||||
return toolCalls;
|
||||
}
|
||||
for (Object rawToolCall : rawList) {
|
||||
JSONObject toolCallJson = JSONObject.from(rawToolCall);
|
||||
if (toolCallJson == null) {
|
||||
continue;
|
||||
}
|
||||
ToolCall toolCall = new ToolCall();
|
||||
toolCall.setId(toolCallJson.getString("id"));
|
||||
String toolName = toolCallJson.getString("name");
|
||||
if (toolName == null || toolName.isBlank()) {
|
||||
JSONObject functionJson = toolCallJson.getJSONObject("function");
|
||||
if (functionJson != null) {
|
||||
toolName = functionJson.getString("name");
|
||||
if (toolCallJson.getString("arguments") == null || toolCallJson.getString("arguments").isBlank()) {
|
||||
toolCall.setArguments(functionJson.getString("arguments"));
|
||||
}
|
||||
}
|
||||
}
|
||||
toolCall.setName(toolName);
|
||||
if (toolCall.getArguments() == null || toolCall.getArguments().isBlank()) {
|
||||
toolCall.setArguments(toolCallJson.getString("arguments"));
|
||||
}
|
||||
toolCalls.add(toolCall);
|
||||
}
|
||||
return toolCalls;
|
||||
}
|
||||
|
||||
public List<Message> getMessages() {
|
||||
return messages;
|
||||
}
|
||||
@@ -83,7 +168,11 @@ public class ChatRequestParams implements Serializable {
|
||||
this.botId = botId;
|
||||
}
|
||||
|
||||
public String getConversationId() {return conversationId;}
|
||||
public String getConversationId() {
|
||||
return conversationId;
|
||||
}
|
||||
|
||||
public void setConversationId(String conversationId) {this.conversationId = conversationId;}
|
||||
}
|
||||
public void setConversationId(String conversationId) {
|
||||
this.conversationId = conversationId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.easyagents.store.milvus.MilvusVectorStore;
|
||||
import com.easyagents.store.milvus.MilvusVectorStoreConfig;
|
||||
import com.mybatisflex.annotation.Table;
|
||||
import tech.easyflow.ai.config.AiMilvusConfig;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
|
||||
import tech.easyflow.ai.easyagents.tool.DocumentCollectionTool;
|
||||
import tech.easyflow.ai.entity.base.DocumentCollectionBase;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalModes;
|
||||
@@ -111,7 +112,19 @@ public class DocumentCollection extends DocumentCollectionBase implements Visibi
|
||||
}
|
||||
|
||||
public Tool toFunction(boolean needEnglishName, String retrievalMode) {
|
||||
return new DocumentCollectionTool(this, needEnglishName, KnowledgeRetrievalModes.parse(retrievalMode));
|
||||
return toFunction(needEnglishName, retrievalMode, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造知识库聊天工具。
|
||||
*
|
||||
* @param needEnglishName 是否使用英文名称
|
||||
* @param retrievalMode 检索模式
|
||||
* @param chatTimeContext 聊天时权限上下文
|
||||
* @return 聊天工具
|
||||
*/
|
||||
public Tool toFunction(boolean needEnglishName, String retrievalMode, ChatTimeToolAvailabilityContext chatTimeContext) {
|
||||
return new DocumentCollectionTool(this, needEnglishName, KnowledgeRetrievalModes.parse(retrievalMode), chatTimeContext);
|
||||
}
|
||||
|
||||
public Object getOptionsByKey(String key) {
|
||||
|
||||
@@ -75,6 +75,12 @@ public class Model extends ModelBase {
|
||||
deepseekConfig.setApiKey(checkAndGetApiKey());
|
||||
deepseekConfig.setModel(checkAndGetModelName());
|
||||
deepseekConfig.setRequestPath(checkAndGetRequestPath());
|
||||
deepseekConfig.setSupportThinking(Boolean.TRUE);
|
||||
deepseekConfig.setThinkingProtocol("deepseek");
|
||||
deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE);
|
||||
if (getSupportToolMessage() != null) {
|
||||
deepseekConfig.setSupportToolMessage(getSupportToolMessage());
|
||||
}
|
||||
return new DeepseekChatModel(deepseekConfig);
|
||||
default:
|
||||
OpenAIChatConfig openAIChatConfig = new OpenAIChatConfig();
|
||||
|
||||
@@ -56,6 +56,25 @@ public interface BotService extends IService<Bot> {
|
||||
|
||||
SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId, BotServiceImpl.ChatCheckResult chatCheckResult);
|
||||
|
||||
SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId,
|
||||
BotServiceImpl.ChatCheckResult chatCheckResult, BigInteger regenerateRoundId);
|
||||
|
||||
/**
|
||||
* 聊天前置校验,并根据调用方要求决定是否强制走发布态。
|
||||
*
|
||||
* @param botId 聊天助手 ID
|
||||
* @param prompt 用户问题
|
||||
* @param conversationId 会话 ID
|
||||
* @param chatCheckResult 校验结果承载对象
|
||||
* @param publishedOnly 是否强制走发布态
|
||||
* @return 校验失败时返回错误 SSE;成功时返回 {@code null}
|
||||
*/
|
||||
SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId,
|
||||
BotServiceImpl.ChatCheckResult chatCheckResult, boolean publishedOnly);
|
||||
|
||||
SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId,
|
||||
BotServiceImpl.ChatCheckResult chatCheckResult, boolean publishedOnly, BigInteger regenerateRoundId);
|
||||
|
||||
SseEmitter startChat(BigInteger botId, String prompt, BigInteger conversationId, List<Map<String, String>> messages,
|
||||
BotServiceImpl.ChatCheckResult chatCheckResult, List<String> attachments, ChatRuntimeContext runtimeContext);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package tech.easyflow.ai.service;
|
||||
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 工作流 Public API 访问令牌授权服务。
|
||||
*/
|
||||
public interface WorkflowApiPermissionService {
|
||||
|
||||
/**
|
||||
* 工作流资源类型。
|
||||
*/
|
||||
String RESOURCE_TYPE_WORKFLOW = "WORKFLOW";
|
||||
|
||||
/**
|
||||
* 工作流 Public API 调用动作范围。
|
||||
*/
|
||||
String ACTION_SCOPE_INVOKE = "INVOKE";
|
||||
|
||||
/**
|
||||
* 按访问令牌维度开启或关闭工作流 Public API 调用授权。
|
||||
*
|
||||
* @param apiKeyId 系统访问令牌 ID
|
||||
* @param enabled 是否启用工作流 API 调用授权
|
||||
*/
|
||||
void replaceWorkflowApiEnabled(BigInteger apiKeyId, boolean enabled);
|
||||
|
||||
/**
|
||||
* 断言当前令牌具备工作流 Public API 调用权限。
|
||||
*
|
||||
* @param apiKey 原始访问令牌
|
||||
* @param requestUri 请求地址
|
||||
* @return 已校验通过的访问令牌实体
|
||||
*/
|
||||
SysApiKey assertWorkflowApi(String apiKey, String requestUri);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import com.easyagents.core.model.chat.ChatOptions;
|
||||
import com.easyagents.core.model.chat.StreamResponseListener;
|
||||
import com.easyagents.core.model.chat.tool.Tool;
|
||||
import com.easyagents.core.prompt.MemoryPrompt;
|
||||
import com.easyagents.rag.retrieval.RetrievalMode;
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||
import org.slf4j.Logger;
|
||||
@@ -24,10 +25,13 @@ import org.springframework.web.context.request.RequestAttributes;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityService;
|
||||
import tech.easyflow.ai.easyagents.listener.ChatStreamListener;
|
||||
import tech.easyflow.ai.easyagents.memory.DefaultBotMessageMemory;
|
||||
import tech.easyflow.ai.easyagents.memory.PublicBotMessageMemory;
|
||||
import tech.easyflow.ai.easyagents.memory.RuntimeChatMemory;
|
||||
import tech.easyflow.ai.easyagents.tool.DocumentCollectionTool;
|
||||
import tech.easyflow.ai.easyagents.tool.WorkflowTool;
|
||||
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
|
||||
import tech.easyflow.ai.entity.*;
|
||||
@@ -39,6 +43,7 @@ import tech.easyflow.ai.utils.CustomBeanUtils;
|
||||
import tech.easyflow.ai.utils.RegexUtils;
|
||||
import tech.easyflow.common.filestorage.FileStorageService;
|
||||
import tech.easyflow.common.filestorage.utils.PathGeneratorUtil;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||
import tech.easyflow.common.util.MapUtil;
|
||||
import tech.easyflow.common.util.Maps;
|
||||
@@ -47,6 +52,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||
import tech.easyflow.core.chat.protocol.sse.ChatSseUtil;
|
||||
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeManager;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeMessage;
|
||||
@@ -56,9 +62,11 @@ import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static tech.easyflow.ai.entity.table.BotPluginTableDef.BOT_PLUGIN;
|
||||
import static tech.easyflow.ai.entity.table.PluginItemTableDef.PLUGIN_ITEM;
|
||||
@@ -74,6 +82,8 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BotServiceImpl.class);
|
||||
private static final String FAQ_IMAGE_SYSTEM_RULE = "当知识工具返回 Markdown 图片(格式:)时,你必须在最终回答中保留并输出对应的图片 Markdown,禁止改写、替换或省略图片 URL。";
|
||||
private static final String EXTRA_KNOWLEDGE_PRIORITY_RULE = "若当前会话显式选择了额外知识库,请优先参考这些额外知识库;仅在额外知识库无法回答时,再回退到助手默认绑定知识库。";
|
||||
private static final int MAX_EXTRA_KNOWLEDGE_COUNT = 3;
|
||||
|
||||
public static class ChatCheckResult {
|
||||
private Bot aiBot;
|
||||
@@ -131,6 +141,8 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
private CategoryPermissionService categoryPermissionService;
|
||||
@Resource
|
||||
private ChatRuntimeManager chatRuntimeManager;
|
||||
@Resource
|
||||
private ChatTimeToolAvailabilityService chatTimeToolAvailabilityService;
|
||||
|
||||
@Override
|
||||
public Bot getDetail(String id) {
|
||||
@@ -209,6 +221,24 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
}
|
||||
|
||||
public SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId, ChatCheckResult chatCheckResult) {
|
||||
return checkChatBeforeStart(botId, prompt, conversationId, chatCheckResult, false, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId,
|
||||
ChatCheckResult chatCheckResult, BigInteger regenerateRoundId) {
|
||||
return checkChatBeforeStart(botId, prompt, conversationId, chatCheckResult, false, regenerateRoundId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId,
|
||||
ChatCheckResult chatCheckResult, boolean publishedOnly) {
|
||||
return checkChatBeforeStart(botId, prompt, conversationId, chatCheckResult, publishedOnly, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId,
|
||||
ChatCheckResult chatCheckResult, boolean publishedOnly, BigInteger regenerateRoundId) {
|
||||
if (!StringUtils.hasLength(prompt)) {
|
||||
return ChatSseUtil.sendSystemError(conversationId, "提示词不能为空");
|
||||
}
|
||||
@@ -242,7 +272,13 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
if ((!login || anonymousAccount) && !aiBot.isAnonymousEnabled()) {
|
||||
return ChatSseUtil.sendSystemError(conversationId, "此聊天助手不支持匿名访问");
|
||||
}
|
||||
if (!login || anonymousAccount) {
|
||||
if (publishedOnly && login && !anonymousAccount) {
|
||||
if (PublishStatus.from(aiBot.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
return ChatSseUtil.sendSystemError(conversationId, "聊天助手尚未发布");
|
||||
}
|
||||
aiBot = toPublishedView(aiBot);
|
||||
chatCheckResult.setPublishedAccess(true);
|
||||
} else if (!login || anonymousAccount) {
|
||||
Bot publishedBot = toPublishedView(aiBot);
|
||||
if (!PublishStatus.from(aiBot.getPublishStatus()).isExternallyVisible()) {
|
||||
return ChatSseUtil.sendSystemError(conversationId, "聊天助手尚未发布");
|
||||
@@ -261,7 +297,6 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
if (chatModel == null) {
|
||||
return ChatSseUtil.sendSystemError(conversationId, "对话模型获取失败,请检查配置");
|
||||
}
|
||||
|
||||
chatCheckResult.setAiBot(aiBot);
|
||||
chatCheckResult.setModelOptions(modelOptions);
|
||||
chatCheckResult.setChatModel(chatModel);
|
||||
@@ -274,9 +309,11 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
BotServiceImpl.ChatCheckResult chatCheckResult, List<String> attachments, ChatRuntimeContext runtimeContext) {
|
||||
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
|
||||
ChatModel chatModel = chatCheckResult.getChatModel();
|
||||
ChatTimeToolAvailabilityContext chatTimeContext = buildChatTimeToolAvailabilityContext(runtimeContext, chatCheckResult.getAiBot());
|
||||
final MemoryPrompt memoryPrompt = new MemoryPrompt();
|
||||
String systemPrompt = buildSystemPromptWithFaqImageRule(
|
||||
MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT)
|
||||
String systemPrompt = buildSystemPrompt(
|
||||
MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT),
|
||||
runtimeContext
|
||||
);
|
||||
Integer maxMessageCount = MapUtil.getInteger(modelOptions, Bot.KEY_MAX_MESSAGE_COUNT);
|
||||
if (maxMessageCount != null) {
|
||||
@@ -291,9 +328,11 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
}
|
||||
UserMessage userMessage = new UserMessage(prompt);
|
||||
userMessage.addTools(buildFunctionList(Maps.of("botId", botId)
|
||||
.set("needEnglishName", false)
|
||||
.set("needEnglishName", true)
|
||||
.set("bot", chatCheckResult.getAiBot())
|
||||
.set("publishedOnly", chatCheckResult.isPublishedAccess())));
|
||||
.set("chatTimeContext", chatTimeContext)
|
||||
.set("publishedOnly", chatCheckResult.isPublishedAccess())
|
||||
.set("extraKnowledgeIds", resolveExtraKnowledgeIds(runtimeContext))));
|
||||
ChatOptions chatOptions = getChatOptions(modelOptions);
|
||||
Boolean enableDeepThinking = MapUtil.getBoolean(modelOptions, Bot.KEY_ENABLE_DEEP_THINKING, false);
|
||||
chatOptions.setThinkingEnabled(enableDeepThinking);
|
||||
@@ -342,16 +381,20 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext) {
|
||||
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
|
||||
ChatOptions chatOptions = getChatOptions(modelOptions);
|
||||
Boolean enableDeepThinking = MapUtil.getBoolean(modelOptions, Bot.KEY_ENABLE_DEEP_THINKING, false);
|
||||
chatOptions.setThinkingEnabled(enableDeepThinking);
|
||||
ChatModel chatModel = chatCheckResult.getChatModel();
|
||||
String systemPrompt = buildSystemPromptWithFaqImageRule(
|
||||
MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT)
|
||||
String systemPrompt = buildSystemPrompt(
|
||||
MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT),
|
||||
runtimeContext
|
||||
);
|
||||
UserMessage userMessage = new UserMessage(prompt);
|
||||
userMessage.addTools(buildFunctionList(Maps.of("botId", botId)
|
||||
.set("needEnglishName", false)
|
||||
.set("needEnglishName", true)
|
||||
.set("needAccountId", false)
|
||||
.set("bot", chatCheckResult.getAiBot())
|
||||
.set("publishedOnly", chatCheckResult.isPublishedAccess())
|
||||
.set("extraKnowledgeIds", resolveExtraKnowledgeIds(runtimeContext))
|
||||
));
|
||||
ChatSseEmitter chatSseEmitter = new ChatSseEmitter();
|
||||
SseEmitter emitter = chatSseEmitter.getEmitter();
|
||||
@@ -463,15 +506,19 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
needEnglishName = false;
|
||||
}
|
||||
Bot runtimeBot = (Bot) buildParams.get("bot");
|
||||
ChatTimeToolAvailabilityContext chatTimeContext = (ChatTimeToolAvailabilityContext) buildParams.get("chatTimeContext");
|
||||
List<BigInteger> extraKnowledgeIds = sanitizeExtraKnowledgeIds((List<BigInteger>) buildParams.get("extraKnowledgeIds"));
|
||||
boolean usePublishedSnapshot = Boolean.TRUE.equals(buildParams.get("publishedOnly"))
|
||||
&& runtimeBot != null
|
||||
&& runtimeBot.getPublishedSnapshotJson() != null
|
||||
&& PublishStatus.from(runtimeBot.getPublishStatus()).isExternallyVisible();
|
||||
|
||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
||||
Set<BigInteger> existingKnowledgeIds = new LinkedHashSet<>();
|
||||
appendExtraKnowledgeTools(functionList, extraKnowledgeIds, needEnglishName, chatTimeContext, existingKnowledgeIds);
|
||||
if (usePublishedSnapshot) {
|
||||
appendPublishedKnowledgeTools(functionList, runtimeBot, needEnglishName, chatTimeContext, existingKnowledgeIds);
|
||||
appendPublishedWorkflowTools(functionList, runtimeBot, needEnglishName);
|
||||
appendPublishedKnowledgeTools(functionList, runtimeBot, needEnglishName);
|
||||
} else {
|
||||
// 工作流 function 集合
|
||||
queryWrapper.eq(BotWorkflow::getBotId, botId);
|
||||
@@ -489,13 +536,7 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
queryWrapper.eq(BotDocumentCollection::getBotId, botId);
|
||||
List<BotDocumentCollection> botDocumentCollections = botDocumentCollectionService.getMapper()
|
||||
.selectListWithRelationsByQuery(queryWrapper);
|
||||
if (botDocumentCollections != null && !botDocumentCollections.isEmpty()) {
|
||||
for (BotDocumentCollection botDocumentCollection : botDocumentCollections) {
|
||||
Tool function = botDocumentCollection.getKnowledge()
|
||||
.toFunction(needEnglishName, botDocumentCollection.getRetrievalMode().name());
|
||||
functionList.add(function);
|
||||
}
|
||||
}
|
||||
functionList.addAll(buildKnowledgeTools(botDocumentCollections, needEnglishName, chatTimeContext, existingKnowledgeIds));
|
||||
}
|
||||
|
||||
// 插件 function 集合
|
||||
@@ -534,6 +575,55 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
return functionList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Bot 绑定的知识库候选项收敛为当前聊天可用的工具列表。
|
||||
*
|
||||
* @param botDocumentCollections Bot 知识库绑定项
|
||||
* @param needEnglishName 是否使用英文名称
|
||||
* @param chatTimeContext 聊天时权限上下文
|
||||
* @return 知识库工具列表
|
||||
*/
|
||||
List<Tool> buildKnowledgeTools(List<BotDocumentCollection> botDocumentCollections,
|
||||
boolean needEnglishName,
|
||||
ChatTimeToolAvailabilityContext chatTimeContext) {
|
||||
return buildKnowledgeTools(botDocumentCollections, needEnglishName, chatTimeContext, new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Bot 绑定的知识库候选项收敛为当前聊天可用的工具列表,并与已选临时知识库去重。
|
||||
*
|
||||
* @param botDocumentCollections Bot 知识库绑定项
|
||||
* @param needEnglishName 是否使用英文名称
|
||||
* @param chatTimeContext 聊天时权限上下文
|
||||
* @param existingKnowledgeIds 已装配知识库 ID 集
|
||||
* @return 知识库工具列表
|
||||
*/
|
||||
List<Tool> buildKnowledgeTools(List<BotDocumentCollection> botDocumentCollections,
|
||||
boolean needEnglishName,
|
||||
ChatTimeToolAvailabilityContext chatTimeContext,
|
||||
Set<BigInteger> existingKnowledgeIds) {
|
||||
List<Tool> functionList = new ArrayList<>();
|
||||
if (botDocumentCollections == null || botDocumentCollections.isEmpty()) {
|
||||
return functionList;
|
||||
}
|
||||
List<BotDocumentCollection> availableBindings = chatTimeContext == null
|
||||
? botDocumentCollections
|
||||
: chatTimeToolAvailabilityService.filterAvailable(chatTimeContext, botDocumentCollections);
|
||||
for (BotDocumentCollection botDocumentCollection : availableBindings) {
|
||||
DocumentCollection knowledge = botDocumentCollection.getKnowledge();
|
||||
if (knowledge == null || isDuplicateKnowledge(existingKnowledgeIds, knowledge.getId())) {
|
||||
continue;
|
||||
}
|
||||
DocumentCollectionTool function = (DocumentCollectionTool) knowledge.toFunction(
|
||||
needEnglishName,
|
||||
botDocumentCollection.getRetrievalMode().name(),
|
||||
chatTimeContext
|
||||
);
|
||||
functionList.add(function);
|
||||
}
|
||||
return functionList;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendPublishedWorkflowTools(List<Tool> functionList, Bot runtimeBot, boolean needEnglishName) {
|
||||
Object workflows = runtimeBot.getPublishedSnapshotJson().get("workflowBindings");
|
||||
@@ -562,7 +652,11 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void appendPublishedKnowledgeTools(List<Tool> functionList, Bot runtimeBot, boolean needEnglishName) {
|
||||
private void appendPublishedKnowledgeTools(List<Tool> functionList,
|
||||
Bot runtimeBot,
|
||||
boolean needEnglishName,
|
||||
ChatTimeToolAvailabilityContext chatTimeContext,
|
||||
Set<BigInteger> existingKnowledgeIds) {
|
||||
Object knowledges = runtimeBot.getPublishedSnapshotJson().get("knowledgeBindings");
|
||||
if (!(knowledges instanceof List<?> knowledgeBindings)) {
|
||||
return;
|
||||
@@ -576,17 +670,82 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
continue;
|
||||
}
|
||||
DocumentCollection knowledge = documentCollectionService.getPublishedById(new BigInteger(String.valueOf(knowledgeId)));
|
||||
if (knowledge == null) {
|
||||
if (knowledge == null || PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
continue;
|
||||
}
|
||||
if (chatTimeContext != null && !chatTimeToolAvailabilityService.evaluate(chatTimeContext, knowledge).isAvailable()) {
|
||||
continue;
|
||||
}
|
||||
if (isDuplicateKnowledge(existingKnowledgeIds, knowledge.getId())) {
|
||||
continue;
|
||||
}
|
||||
Object retrievalMode = bindingMap.get("retrievalMode");
|
||||
functionList.add(knowledge.toFunction(
|
||||
needEnglishName,
|
||||
retrievalMode == null ? null : String.valueOf(retrievalMode)
|
||||
retrievalMode == null ? null : String.valueOf(retrievalMode),
|
||||
chatTimeContext
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装会话级临时知识库工具,并按用户选择顺序优先插入。
|
||||
*
|
||||
* @param functionList 工具集合
|
||||
* @param extraKnowledgeIds 额外知识库 ID
|
||||
* @param needEnglishName 是否使用英文名称
|
||||
* @param chatTimeContext 聊天时权限上下文
|
||||
* @param existingKnowledgeIds 已装配知识库 ID 集
|
||||
*/
|
||||
protected void appendExtraKnowledgeTools(List<Tool> functionList,
|
||||
List<BigInteger> extraKnowledgeIds,
|
||||
boolean needEnglishName,
|
||||
ChatTimeToolAvailabilityContext chatTimeContext,
|
||||
Set<BigInteger> existingKnowledgeIds) {
|
||||
if (extraKnowledgeIds == null || extraKnowledgeIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (BigInteger knowledgeId : extraKnowledgeIds) {
|
||||
if (knowledgeId == null || isDuplicateKnowledge(existingKnowledgeIds, knowledgeId)) {
|
||||
continue;
|
||||
}
|
||||
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||
if (knowledge == null) {
|
||||
throw new BusinessException("额外知识库不存在");
|
||||
}
|
||||
if (PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||
throw new BusinessException("额外知识库未发布,无法用于聊天");
|
||||
}
|
||||
if (chatTimeContext != null && !chatTimeToolAvailabilityService.evaluate(chatTimeContext, knowledge).isAvailable()) {
|
||||
throw new BusinessException("当前用户无权使用所选知识库");
|
||||
}
|
||||
functionList.add(documentCollectionService.toPublishedView(knowledge)
|
||||
.toFunction(needEnglishName, RetrievalMode.HYBRID.name(), chatTimeContext));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造聊天时工具可用性上下文,并显式回填到运行时上下文中供后续异步工具调用复用。
|
||||
*
|
||||
* @param runtimeContext 聊天运行时上下文
|
||||
* @param bot 当前聊天助手
|
||||
* @return 聊天时工具可用性上下文
|
||||
*/
|
||||
private ChatTimeToolAvailabilityContext buildChatTimeToolAvailabilityContext(ChatRuntimeContext runtimeContext, Bot bot) {
|
||||
ChatTimeToolAvailabilityContext existing = ChatTimeToolAvailabilityContext.fromRuntimeContext(runtimeContext);
|
||||
LoginAccount loginAccount = existing == null ? null : existing.getLoginAccount();
|
||||
if (!ChatTimeToolAvailabilityContext.hasLoggedInAccount(loginAccount)) {
|
||||
return null;
|
||||
}
|
||||
ChatTimeToolAvailabilityContext context = existing == null ? new ChatTimeToolAvailabilityContext() : existing;
|
||||
context.setLoginAccount(loginAccount);
|
||||
context.setBot(bot);
|
||||
context.setChatChannel(runtimeContext == null ? null : runtimeContext.getChannel());
|
||||
context.setSessionId(runtimeContext == null ? null : runtimeContext.getSessionId());
|
||||
context.bindToRuntimeContext(runtimeContext);
|
||||
return context;
|
||||
}
|
||||
|
||||
public String attachmentsToString(List<String> fileList) {
|
||||
StringBuilder messageBuilder = new StringBuilder();
|
||||
if (fileList != null && !fileList.isEmpty()) {
|
||||
@@ -637,14 +796,64 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
|
||||
return context.getUserAccount();
|
||||
}
|
||||
|
||||
private String buildSystemPromptWithFaqImageRule(String systemPrompt) {
|
||||
if (!StringUtils.hasLength(systemPrompt)) {
|
||||
return FAQ_IMAGE_SYSTEM_RULE;
|
||||
private String buildSystemPrompt(String systemPrompt, ChatRuntimeContext runtimeContext) {
|
||||
String mergedPrompt = appendPromptRule(systemPrompt, FAQ_IMAGE_SYSTEM_RULE);
|
||||
if (hasExtraKnowledgeSelection(runtimeContext)) {
|
||||
mergedPrompt = appendPromptRule(mergedPrompt, EXTRA_KNOWLEDGE_PRIORITY_RULE);
|
||||
}
|
||||
if (systemPrompt.contains(FAQ_IMAGE_SYSTEM_RULE)) {
|
||||
return mergedPrompt;
|
||||
}
|
||||
|
||||
private String appendPromptRule(String systemPrompt, String rule) {
|
||||
if (!StringUtils.hasLength(systemPrompt)) {
|
||||
return rule;
|
||||
}
|
||||
if (systemPrompt.contains(rule)) {
|
||||
return systemPrompt;
|
||||
}
|
||||
return systemPrompt + "\n\n" + FAQ_IMAGE_SYSTEM_RULE;
|
||||
return systemPrompt + "\n\n" + rule;
|
||||
}
|
||||
|
||||
private List<BigInteger> resolveExtraKnowledgeIds(ChatRuntimeContext runtimeContext) {
|
||||
if (runtimeContext == null || runtimeContext.getExt() == null) {
|
||||
return List.of();
|
||||
}
|
||||
Object rawValue = runtimeContext.getExt().get(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS);
|
||||
if (!(rawValue instanceof List<?> rawList) || rawList.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<BigInteger> values = new ArrayList<>(rawList.size());
|
||||
for (Object item : rawList) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
values.add(new BigInteger(String.valueOf(item)));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private List<BigInteger> sanitizeExtraKnowledgeIds(List<BigInteger> extraKnowledgeIds) {
|
||||
if (extraKnowledgeIds == null || extraKnowledgeIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
LinkedHashSet<BigInteger> dedup = new LinkedHashSet<>();
|
||||
for (BigInteger knowledgeId : extraKnowledgeIds) {
|
||||
if (knowledgeId != null) {
|
||||
dedup.add(knowledgeId);
|
||||
}
|
||||
}
|
||||
if (dedup.size() > MAX_EXTRA_KNOWLEDGE_COUNT) {
|
||||
throw new BusinessException("额外知识库最多选择 3 个");
|
||||
}
|
||||
return new ArrayList<>(dedup);
|
||||
}
|
||||
|
||||
private boolean hasExtraKnowledgeSelection(ChatRuntimeContext runtimeContext) {
|
||||
return !resolveExtraKnowledgeIds(runtimeContext).isEmpty();
|
||||
}
|
||||
|
||||
private boolean isDuplicateKnowledge(Set<BigInteger> existingKnowledgeIds, BigInteger knowledgeId) {
|
||||
return knowledgeId != null && existingKnowledgeIds != null && !existingKnowledgeIds.add(knowledgeId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package tech.easyflow.ai.service.impl;
|
||||
|
||||
import com.mybatisflex.core.query.QueryWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.ai.service.WorkflowApiPermissionService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.SysApiKey;
|
||||
import tech.easyflow.system.entity.SysApiKeyResource;
|
||||
import tech.easyflow.system.entity.SysApiKeyResourceMapping;
|
||||
import tech.easyflow.system.service.SysApiKeyResourceMappingService;
|
||||
import tech.easyflow.system.service.SysApiKeyResourceService;
|
||||
import tech.easyflow.system.service.SysApiKeyService;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 工作流 Public API 访问令牌授权服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class WorkflowApiPermissionServiceImpl implements WorkflowApiPermissionService {
|
||||
|
||||
private static final String RESOURCE_TITLE = "工作流 API 调用";
|
||||
|
||||
private static final List<String> WORKFLOW_API_URIS = List.of(
|
||||
"/public-api/workflow/getByIdOrAlias",
|
||||
"/public-api/workflow/getRunningParameters",
|
||||
"/public-api/workflow/runAsync",
|
||||
"/public-api/workflow/getChainStatus",
|
||||
"/public-api/workflow/resume"
|
||||
);
|
||||
|
||||
@Resource
|
||||
private SysApiKeyService sysApiKeyService;
|
||||
@Resource
|
||||
private SysApiKeyResourceService resourceService;
|
||||
@Resource
|
||||
private SysApiKeyResourceMappingService mappingService;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void replaceWorkflowApiEnabled(BigInteger apiKeyId, boolean enabled) {
|
||||
if (apiKeyId == null) {
|
||||
throw new BusinessException("系统访问令牌不能为空");
|
||||
}
|
||||
SysApiKey apiKey = sysApiKeyService.getById(apiKeyId);
|
||||
if (apiKey == null) {
|
||||
throw new BusinessException("系统访问令牌不存在");
|
||||
}
|
||||
mappingService.removeScopedMappings(apiKeyId, RESOURCE_TYPE_WORKFLOW);
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<SysApiKeyResourceMapping> rows = new ArrayList<>(WORKFLOW_API_URIS.size());
|
||||
for (String uri : WORKFLOW_API_URIS) {
|
||||
SysApiKeyResource resource = ensureResource(uri);
|
||||
SysApiKeyResourceMapping row = new SysApiKeyResourceMapping();
|
||||
row.setApiKeyId(apiKeyId);
|
||||
row.setApiKeyResourceId(resource.getId());
|
||||
row.setResourceType(RESOURCE_TYPE_WORKFLOW);
|
||||
row.setActionScope(ACTION_SCOPE_INVOKE);
|
||||
rows.add(row);
|
||||
}
|
||||
if (!rows.isEmpty()) {
|
||||
mappingService.saveBatch(rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public SysApiKey assertWorkflowApi(String apiKey, String requestUri) {
|
||||
SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey);
|
||||
SysApiKeyResource resource = getResource(requestUri);
|
||||
QueryWrapper wrapper = QueryWrapper.create()
|
||||
.eq(SysApiKeyResourceMapping::getApiKeyId, sysApiKey.getId())
|
||||
.eq(SysApiKeyResourceMapping::getApiKeyResourceId, resource.getId())
|
||||
.eq(SysApiKeyResourceMapping::getResourceType, RESOURCE_TYPE_WORKFLOW)
|
||||
.isNull(SysApiKeyResourceMapping::getResourceTargetId)
|
||||
.eq(SysApiKeyResourceMapping::getActionScope, ACTION_SCOPE_INVOKE);
|
||||
if (mappingService.count(wrapper) == 0) {
|
||||
throw new BusinessException("该apiKey无权限调用工作流 API");
|
||||
}
|
||||
return sysApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作流 Public API 资源。
|
||||
*
|
||||
* @param requestInterface 请求地址
|
||||
* @return API 资源
|
||||
*/
|
||||
private SysApiKeyResource getResource(String requestInterface) {
|
||||
QueryWrapper wrapper = QueryWrapper.create()
|
||||
.eq(SysApiKeyResource::getRequestInterface, requestInterface);
|
||||
SysApiKeyResource resource = resourceService.getOne(wrapper);
|
||||
if (resource == null) {
|
||||
throw new BusinessException("该接口不存在");
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保工作流 Public API 资源已存在。
|
||||
*
|
||||
* @param requestInterface 请求地址
|
||||
* @return API 资源
|
||||
*/
|
||||
private SysApiKeyResource ensureResource(String requestInterface) {
|
||||
QueryWrapper wrapper = QueryWrapper.create()
|
||||
.eq(SysApiKeyResource::getRequestInterface, requestInterface);
|
||||
SysApiKeyResource resource = resourceService.getOne(wrapper);
|
||||
if (resource != null) {
|
||||
return resource;
|
||||
}
|
||||
resource = new SysApiKeyResource();
|
||||
resource.setRequestInterface(requestInterface);
|
||||
resource.setTitle(RESOURCE_TITLE);
|
||||
resourceService.save(resource);
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ import java.math.BigInteger;
|
||||
public class WorkFlowUtil {
|
||||
|
||||
public final static String USER_KEY = "user";
|
||||
public final static String API_KEY = "API_KEY";
|
||||
public final static String WORKFLOW_KEY = "workflow";
|
||||
public final static String CREATED_KEY_MEMORY_KEY = "workflowCreatedKey";
|
||||
|
||||
public static String removeSensitiveInfo(String originJson) {
|
||||
JSONObject workflowInfo = JSON.parseObject(originJson);
|
||||
@@ -35,6 +37,17 @@ public class WorkFlowUtil {
|
||||
return cache == null ? defaultAccount() : (LoginAccount) cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作流执行记录的执行人标识。
|
||||
*
|
||||
* @param chain 当前工作流执行链
|
||||
* @return 执行人标识
|
||||
*/
|
||||
public static String getCreatedKey(Chain chain) {
|
||||
Object value = chain.getState().getMemory().get(CREATED_KEY_MEMORY_KEY);
|
||||
return value == null ? USER_KEY : String.valueOf(value);
|
||||
}
|
||||
|
||||
public static LoginAccount defaultAccount() {
|
||||
LoginAccount account = new LoginAccount();
|
||||
account.setId(new BigInteger("0"));
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package tech.easyflow.ai.chattime.availability;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysDeptService;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* {@link ChatTimeKnowledgeAvailabilityResolver} 单元测试。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-05-10
|
||||
*/
|
||||
public class ChatTimeKnowledgeAvailabilityResolverTest {
|
||||
|
||||
/**
|
||||
* 创建者应始终可访问自己的私有知识库。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldAllowCreatorForPrivateKnowledge() {
|
||||
LoginAccount loginAccount = buildLoginAccount(11, 3);
|
||||
ChatTimeKnowledgeAvailabilityResolver resolver = buildResolver(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(11), false, false, Collections.emptySet()),
|
||||
setOf(BigInteger.valueOf(3))
|
||||
);
|
||||
|
||||
ChatTimeToolAvailabilityDecision decision = resolver.resolve(
|
||||
buildContext(loginAccount),
|
||||
buildKnowledge(11, 21, null, "PRIVATE")
|
||||
);
|
||||
|
||||
Assert.assertTrue(decision.isAvailable());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类权限不通过时,即使知识库是公开范围也不可访问。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldRejectWhenCategoryPermissionFails() {
|
||||
LoginAccount loginAccount = buildLoginAccount(12, 3);
|
||||
ChatTimeKnowledgeAvailabilityResolver resolver = buildResolver(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(99))),
|
||||
setOf(BigInteger.valueOf(3))
|
||||
);
|
||||
|
||||
ChatTimeToolAvailabilityDecision decision = resolver.resolve(
|
||||
buildContext(loginAccount),
|
||||
buildKnowledge(11, 21, null, "PUBLIC")
|
||||
);
|
||||
|
||||
Assert.assertFalse(decision.isAvailable());
|
||||
Assert.assertEquals("CHAT_TIME_KNOWLEDGE_FORBIDDEN", decision.getReasonCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 同部门或祖先部门命中时,应允许访问部门可见知识库。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldAllowDeptScopedKnowledgeForReadableDept() {
|
||||
LoginAccount loginAccount = buildLoginAccount(12, 9);
|
||||
ChatTimeKnowledgeAvailabilityResolver resolver = buildResolver(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))),
|
||||
setOf(BigInteger.valueOf(1), BigInteger.valueOf(3), BigInteger.valueOf(9))
|
||||
);
|
||||
|
||||
ChatTimeToolAvailabilityDecision decision = resolver.resolve(
|
||||
buildContext(loginAccount),
|
||||
buildKnowledge(11, 21, BigInteger.valueOf(3), "DEPT")
|
||||
);
|
||||
|
||||
Assert.assertTrue(decision.isAvailable());
|
||||
}
|
||||
|
||||
/**
|
||||
* 超级管理员始终可访问。
|
||||
*/
|
||||
@Test
|
||||
public void resolveShouldAllowSuperAdmin() {
|
||||
LoginAccount loginAccount = buildLoginAccount(99, 1);
|
||||
ChatTimeKnowledgeAvailabilityResolver resolver = buildResolver(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(99), true, true, Collections.emptySet()),
|
||||
Collections.emptySet()
|
||||
);
|
||||
|
||||
ChatTimeToolAvailabilityDecision decision = resolver.resolve(
|
||||
buildContext(loginAccount),
|
||||
buildKnowledge(11, 21, null, "PRIVATE")
|
||||
);
|
||||
|
||||
Assert.assertTrue(decision.isAvailable());
|
||||
}
|
||||
|
||||
private ChatTimeKnowledgeAvailabilityResolver buildResolver(RoleCategoryAccessSnapshot accessSnapshot,
|
||||
Set<BigInteger> deptIds) {
|
||||
return new ChatTimeKnowledgeAvailabilityResolver(
|
||||
new KnowledgeVisibilityQueryHelper(),
|
||||
mockCategoryPermissionService(accessSnapshot),
|
||||
mockSysDeptService(deptIds)
|
||||
);
|
||||
}
|
||||
|
||||
private ChatTimeToolAvailabilityContext buildContext(LoginAccount loginAccount) {
|
||||
ChatTimeToolAvailabilityContext context = new ChatTimeToolAvailabilityContext();
|
||||
context.setLoginAccount(loginAccount);
|
||||
return context;
|
||||
}
|
||||
|
||||
private LoginAccount buildLoginAccount(long accountId, long deptId) {
|
||||
LoginAccount loginAccount = new LoginAccount();
|
||||
loginAccount.setId(BigInteger.valueOf(accountId));
|
||||
loginAccount.setDeptId(BigInteger.valueOf(deptId));
|
||||
return loginAccount;
|
||||
}
|
||||
|
||||
private DocumentCollection buildKnowledge(long createdBy, long categoryId, BigInteger deptId, String visibilityScope) {
|
||||
DocumentCollection knowledge = new DocumentCollection();
|
||||
knowledge.setId(BigInteger.valueOf(101));
|
||||
knowledge.setCreatedBy(BigInteger.valueOf(createdBy));
|
||||
knowledge.setCategoryId(BigInteger.valueOf(categoryId));
|
||||
knowledge.setDeptId(deptId);
|
||||
knowledge.setVisibilityScope(visibilityScope);
|
||||
return knowledge;
|
||||
}
|
||||
|
||||
private CategoryPermissionService mockCategoryPermissionService(RoleCategoryAccessSnapshot accessSnapshot) {
|
||||
return (CategoryPermissionService) Proxy.newProxyInstance(
|
||||
CategoryPermissionService.class.getClassLoader(),
|
||||
new Class<?>[]{CategoryPermissionService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getAccess".equals(method.getName())) {
|
||||
return accessSnapshot;
|
||||
}
|
||||
if ("isSuperAdmin".equals(method.getName())) {
|
||||
return accessSnapshot.isSuperAdmin();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private SysDeptService mockSysDeptService(Set<BigInteger> deptIds) {
|
||||
Set<BigInteger> readableDeptIds = deptIds == null ? Collections.emptySet() : deptIds;
|
||||
return (SysDeptService) Proxy.newProxyInstance(
|
||||
SysDeptService.class.getClassLoader(),
|
||||
new Class<?>[]{SysDeptService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getSelfAndAncestorDeptIds".equals(method.getName())) {
|
||||
return readableDeptIds;
|
||||
}
|
||||
if ("canUserAccessDeptScopedResource".equals(method.getName())) {
|
||||
BigInteger resourceDeptId = (BigInteger) args[1];
|
||||
return readableDeptIds.contains(resourceDeptId);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private Set<BigInteger> setOf(BigInteger... values) {
|
||||
Set<BigInteger> result = new LinkedHashSet<>();
|
||||
Collections.addAll(result, values);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Object defaultValue(Class<?> returnType) {
|
||||
if (returnType == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (returnType == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (returnType == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package tech.easyflow.ai.chattime.availability;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.core.runtime.ChatChannel;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* {@link ChatTimeToolAvailabilityContext} 单元测试。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-05-10
|
||||
*/
|
||||
public class ChatTimeToolAvailabilityContextTest {
|
||||
|
||||
/**
|
||||
* 登录用户快照应被显式绑定到聊天运行时上下文。
|
||||
*/
|
||||
@Test
|
||||
public void bindLoggedInSnapshotShouldAttachContextToRuntimeExt() {
|
||||
ChatRuntimeContext runtimeContext = new ChatRuntimeContext();
|
||||
runtimeContext.setChannel(ChatChannel.ADMIN);
|
||||
runtimeContext.setSessionId(BigInteger.valueOf(2001));
|
||||
LoginAccount loginAccount = new LoginAccount();
|
||||
loginAccount.setId(BigInteger.valueOf(12));
|
||||
Bot bot = new Bot();
|
||||
bot.setId(BigInteger.valueOf(99));
|
||||
|
||||
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(runtimeContext, loginAccount, bot);
|
||||
|
||||
ChatTimeToolAvailabilityContext chatTimeContext = ChatTimeToolAvailabilityContext.fromRuntimeContext(runtimeContext);
|
||||
Assert.assertNotNull(chatTimeContext);
|
||||
Assert.assertEquals(BigInteger.valueOf(12), chatTimeContext.getLoginAccount().getId());
|
||||
Assert.assertEquals(BigInteger.valueOf(99), chatTimeContext.getBot().getId());
|
||||
Assert.assertEquals(ChatChannel.ADMIN, chatTimeContext.getChatChannel());
|
||||
Assert.assertEquals(BigInteger.valueOf(2001), chatTimeContext.getSessionId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 匿名或缺失账号快照时不应绑定聊天态权限上下文。
|
||||
*/
|
||||
@Test
|
||||
public void bindLoggedInSnapshotShouldIgnoreAnonymousAccount() {
|
||||
ChatRuntimeContext runtimeContext = new ChatRuntimeContext();
|
||||
runtimeContext.setChannel(ChatChannel.USER_CENTER);
|
||||
runtimeContext.setSessionId(BigInteger.valueOf(3001));
|
||||
LoginAccount loginAccount = new LoginAccount();
|
||||
loginAccount.setId(BigInteger.ZERO);
|
||||
|
||||
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(runtimeContext, loginAccount, new Bot());
|
||||
|
||||
Assert.assertNull(ChatTimeToolAvailabilityContext.fromRuntimeContext(runtimeContext));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package tech.easyflow.ai.easyagents.memory;
|
||||
|
||||
import com.easyagents.core.message.AiMessage;
|
||||
import com.easyagents.core.message.Message;
|
||||
import com.easyagents.core.message.ToolCall;
|
||||
import com.easyagents.core.message.ToolMessage;
|
||||
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
|
||||
import com.easyagents.llm.deepseek.DeepseekConfig;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeMessage;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link RuntimeChatMemory} 测试。
|
||||
*/
|
||||
public class RuntimeChatMemoryTest {
|
||||
|
||||
/**
|
||||
* 应当从结构化 payload 中恢复完整的 assistant/tool 消息链。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRestoreStructuredAssistantAndToolHistory() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendReasoning("思考-1");
|
||||
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
|
||||
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
|
||||
accumulator.appendReasoning("思考-2");
|
||||
accumulator.appendContent("最终回答");
|
||||
|
||||
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
|
||||
runtimeMessage.setRole("assistant");
|
||||
runtimeMessage.setContentText("最终回答");
|
||||
runtimeMessage.setContentPayload(accumulator.buildPayload("最终回答"));
|
||||
|
||||
RuntimeChatMemory memory = new RuntimeChatMemory("c1", List.of(runtimeMessage));
|
||||
List<Message> messages = memory.getMessages(10);
|
||||
|
||||
Assert.assertEquals(3, messages.size());
|
||||
Assert.assertTrue(messages.get(0) instanceof AiMessage);
|
||||
Assert.assertTrue(messages.get(1) instanceof ToolMessage);
|
||||
Assert.assertTrue(messages.get(2) instanceof AiMessage);
|
||||
|
||||
AiMessage toolAssistant = (AiMessage) messages.get(0);
|
||||
Assert.assertEquals("思考-1", toolAssistant.getReasoningContent());
|
||||
Assert.assertEquals(1, toolAssistant.getToolCalls().size());
|
||||
Assert.assertEquals("call-1", toolAssistant.getToolCalls().get(0).getId());
|
||||
Assert.assertEquals("{\"query\":\"java\"}", toolAssistant.getToolCalls().get(0).getArguments());
|
||||
|
||||
ToolMessage toolMessage = (ToolMessage) messages.get(1);
|
||||
Assert.assertEquals("call-1", toolMessage.getToolCallId());
|
||||
Assert.assertEquals("{\"hits\":1}", toolMessage.getContent());
|
||||
|
||||
AiMessage finalAssistant = (AiMessage) messages.get(2);
|
||||
Assert.assertEquals("最终回答", finalAssistant.getTextContent());
|
||||
Assert.assertEquals("思考-2", finalAssistant.getReasoningContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧结构历史应保持纯文本兼容。
|
||||
*/
|
||||
@Test
|
||||
public void shouldFallbackToPlainAssistantMessageForLegacyPayload() {
|
||||
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
|
||||
runtimeMessage.setRole("assistant");
|
||||
runtimeMessage.setContentText("旧版回答");
|
||||
|
||||
RuntimeChatMemory memory = new RuntimeChatMemory("c2", List.of(runtimeMessage));
|
||||
List<Message> messages = memory.getMessages(10);
|
||||
|
||||
Assert.assertEquals(1, messages.size());
|
||||
Assert.assertTrue(messages.get(0) instanceof AiMessage);
|
||||
Assert.assertEquals("旧版回答", ((AiMessage) messages.get(0)).getTextContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部聊天结构化历史回放后,DeepSeek 序列化应继续带上 reasoning_content 与 tool 链。
|
||||
*/
|
||||
@Test
|
||||
public void shouldSerializeRestoredRuntimeHistoryForDeepseekFollowUp() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendReasoning("先思考");
|
||||
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
|
||||
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
|
||||
|
||||
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
|
||||
runtimeMessage.setRole("assistant");
|
||||
runtimeMessage.setContentPayload(accumulator.buildPayload(null));
|
||||
|
||||
RuntimeChatMemory memory = new RuntimeChatMemory("c3", List.of(runtimeMessage));
|
||||
List<Message> restoredMessages = memory.getMessages(10);
|
||||
|
||||
DeepseekConfig config = new DeepseekConfig();
|
||||
config.setSupportThinking(Boolean.TRUE);
|
||||
config.setThinkingProtocol("deepseek");
|
||||
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
|
||||
|
||||
List<java.util.Map<String, Object>> serialized = new OpenAIChatMessageSerializer()
|
||||
.serializeMessages(restoredMessages, config);
|
||||
|
||||
Assert.assertEquals(2, serialized.size());
|
||||
Assert.assertEquals("assistant", serialized.get(0).get("role"));
|
||||
Assert.assertEquals("先思考", serialized.get(0).get("reasoning_content"));
|
||||
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
|
||||
Assert.assertEquals("tool", serialized.get(1).get("role"));
|
||||
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续多轮 tool-only assistant 回放后仍应保持独立顺序。
|
||||
*/
|
||||
@Test
|
||||
public void shouldRestoreSeparatedToolOnlyAssistantSegments() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendToolCall("call-1", "tool_one", "{\"step\":1}");
|
||||
accumulator.appendToolResult("call-1", "tool_one", "{\"ok\":1}");
|
||||
accumulator.appendToolCall("call-2", "tool_two", "{\"step\":2}");
|
||||
accumulator.appendToolResult("call-2", "tool_two", "{\"ok\":2}");
|
||||
|
||||
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
|
||||
runtimeMessage.setRole("assistant");
|
||||
runtimeMessage.setContentPayload(accumulator.buildPayload(null));
|
||||
|
||||
RuntimeChatMemory memory = new RuntimeChatMemory("c4", List.of(runtimeMessage));
|
||||
List<Message> messages = memory.getMessages(10);
|
||||
|
||||
Assert.assertEquals(4, messages.size());
|
||||
Assert.assertTrue(messages.get(0) instanceof AiMessage);
|
||||
Assert.assertTrue(messages.get(1) instanceof ToolMessage);
|
||||
Assert.assertTrue(messages.get(2) instanceof AiMessage);
|
||||
Assert.assertTrue(messages.get(3) instanceof ToolMessage);
|
||||
|
||||
AiMessage firstAssistant = (AiMessage) messages.get(0);
|
||||
AiMessage secondAssistant = (AiMessage) messages.get(2);
|
||||
Assert.assertEquals(1, firstAssistant.getToolCalls().size());
|
||||
Assert.assertEquals("call-1", firstAssistant.getToolCalls().get(0).getId());
|
||||
Assert.assertEquals(1, secondAssistant.getToolCalls().size());
|
||||
Assert.assertEquals("call-2", secondAssistant.getToolCalls().get(0).getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package tech.easyflow.ai.easyagents.tool;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* {@link ChatToolNameHelper} 单元测试。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-05-11
|
||||
*/
|
||||
public class ChatToolNameHelperTest {
|
||||
|
||||
/**
|
||||
* 启用英文名时应优先返回合法英文名称。
|
||||
*/
|
||||
@Test
|
||||
public void resolveToolNameShouldPreferValidEnglishName() {
|
||||
String name = ChatToolNameHelper.resolveToolName(
|
||||
true,
|
||||
"knowledge_search",
|
||||
"知识库检索",
|
||||
"knowledge",
|
||||
BigInteger.valueOf(101)
|
||||
);
|
||||
|
||||
Assert.assertEquals("knowledge_search", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 英文名缺失时应回退为稳定安全名称。
|
||||
*/
|
||||
@Test
|
||||
public void resolveToolNameShouldFallbackWhenEnglishNameMissing() {
|
||||
String name = ChatToolNameHelper.resolveToolName(
|
||||
true,
|
||||
null,
|
||||
"知识库检索",
|
||||
"knowledge",
|
||||
BigInteger.valueOf(101)
|
||||
);
|
||||
|
||||
Assert.assertEquals("knowledge_101", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 英文名不满足协议约束时应回退为稳定安全名称。
|
||||
*/
|
||||
@Test
|
||||
public void resolveToolNameShouldFallbackWhenEnglishNameInvalid() {
|
||||
String name = ChatToolNameHelper.resolveToolName(
|
||||
true,
|
||||
"工作流-A",
|
||||
"工作流检索",
|
||||
"workflow",
|
||||
BigInteger.valueOf(202)
|
||||
);
|
||||
|
||||
Assert.assertEquals("workflow_202", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未启用英文名时应保留展示名称。
|
||||
*/
|
||||
@Test
|
||||
public void resolveToolNameShouldKeepDisplayNameWhenEnglishNameDisabled() {
|
||||
String name = ChatToolNameHelper.resolveToolName(
|
||||
false,
|
||||
"workflow_search",
|
||||
"工作流检索",
|
||||
"workflow",
|
||||
BigInteger.valueOf(202)
|
||||
);
|
||||
|
||||
Assert.assertEquals("工作流检索", name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package tech.easyflow.ai.easyagents.tool;
|
||||
|
||||
import com.easyagents.core.document.Document;
|
||||
import com.easyagents.rag.retrieval.RetrievalMode;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeKnowledgeAvailabilityResolver;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityService;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityServiceImpl;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
|
||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.common.util.SpringContextUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysDeptService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.FutureTask;
|
||||
|
||||
/**
|
||||
* {@link DocumentCollectionTool} 单元测试。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-05-10
|
||||
*/
|
||||
public class DocumentCollectionToolTest {
|
||||
|
||||
/**
|
||||
* 直接构造一个本不应暴露的知识库 Tool 调用时,必须抛出无权限异常。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void invokeShouldThrowWhenChatTimeKnowledgeUnavailable() throws Exception {
|
||||
TestDocumentCollectionService documentCollectionService = new TestDocumentCollectionService(
|
||||
buildKnowledge(101, 11, 21, null, "PUBLIC"),
|
||||
List.of(buildSearchDocument("should-not-reach"))
|
||||
);
|
||||
ChatTimeToolAvailabilityService availabilityService = buildAvailabilityService(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(99))),
|
||||
Collections.emptySet()
|
||||
);
|
||||
ApplicationContext previousContext = getStaticField("applicationContext");
|
||||
Object previousBeanFactory = getStaticField("beanFactory");
|
||||
try {
|
||||
setStaticField("beanFactory", null);
|
||||
setStaticField("applicationContext", mockApplicationContext(documentCollectionService.toProxy(), availabilityService));
|
||||
DocumentCollectionTool tool = new DocumentCollectionTool(
|
||||
buildKnowledge(101, 11, 21, null, "PUBLIC"),
|
||||
false,
|
||||
RetrievalMode.HYBRID,
|
||||
buildContext(12, 3)
|
||||
);
|
||||
|
||||
BusinessException exception = Assert.assertThrows(
|
||||
BusinessException.class,
|
||||
() -> tool.invoke(Map.of("input", "test"))
|
||||
);
|
||||
|
||||
Assert.assertEquals("当前用户无权在聊天中访问该知识库", exception.getMessage());
|
||||
Assert.assertEquals(0, documentCollectionService.searchCount);
|
||||
} finally {
|
||||
setStaticField("applicationContext", previousContext);
|
||||
setStaticField("beanFactory", previousBeanFactory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步线程中即使没有当前线程登录态,也应能基于显式快照完成判权并执行检索。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void invokeShouldUseExplicitLoginSnapshotWithoutThreadState() throws Exception {
|
||||
TestDocumentCollectionService documentCollectionService = new TestDocumentCollectionService(
|
||||
buildKnowledge(101, 11, 21, null, "PUBLIC"),
|
||||
List.of(buildSearchDocument("知识片段A"), buildSearchDocument("知识片段B"))
|
||||
);
|
||||
ChatTimeToolAvailabilityService availabilityService = buildAvailabilityService(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))),
|
||||
Collections.emptySet()
|
||||
);
|
||||
ApplicationContext previousContext = getStaticField("applicationContext");
|
||||
Object previousBeanFactory = getStaticField("beanFactory");
|
||||
try {
|
||||
setStaticField("beanFactory", null);
|
||||
setStaticField("applicationContext", mockApplicationContext(documentCollectionService.toProxy(), availabilityService));
|
||||
DocumentCollectionTool tool = new DocumentCollectionTool(
|
||||
buildKnowledge(101, 11, 21, null, "PUBLIC"),
|
||||
false,
|
||||
RetrievalMode.KEYWORD,
|
||||
buildContext(12, 3)
|
||||
);
|
||||
FutureTask<Object> task = new FutureTask<>(() -> tool.invoke(Map.of("input", "异步查询")));
|
||||
Thread thread = new Thread(task, "document-collection-tool-test");
|
||||
thread.start();
|
||||
|
||||
Object result = task.get();
|
||||
|
||||
Assert.assertEquals("知识片段A\n\n---\n\n知识片段B", result);
|
||||
Assert.assertEquals(1, documentCollectionService.searchCount);
|
||||
Assert.assertNotNull(documentCollectionService.lastRequest);
|
||||
Assert.assertEquals("异步查询", documentCollectionService.lastRequest.getQuery());
|
||||
Assert.assertEquals(RetrievalMode.KEYWORD, documentCollectionService.lastRequest.getRetrievalMode());
|
||||
Assert.assertEquals("BOT_TOOL", documentCollectionService.lastRequest.getCallerType());
|
||||
Assert.assertEquals("101", documentCollectionService.lastRequest.getCallerId());
|
||||
} finally {
|
||||
setStaticField("applicationContext", previousContext);
|
||||
setStaticField("beanFactory", previousBeanFactory);
|
||||
}
|
||||
}
|
||||
|
||||
private ChatTimeToolAvailabilityService buildAvailabilityService(RoleCategoryAccessSnapshot accessSnapshot,
|
||||
Set<BigInteger> deptIds) {
|
||||
return new ChatTimeToolAvailabilityServiceImpl(List.of(
|
||||
new ChatTimeKnowledgeAvailabilityResolver(
|
||||
new KnowledgeVisibilityQueryHelper(),
|
||||
mockCategoryPermissionService(accessSnapshot),
|
||||
mockSysDeptService(deptIds)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
private ChatTimeToolAvailabilityContext buildContext(long accountId, long deptId) {
|
||||
LoginAccount loginAccount = new LoginAccount();
|
||||
loginAccount.setId(BigInteger.valueOf(accountId));
|
||||
loginAccount.setDeptId(BigInteger.valueOf(deptId));
|
||||
ChatTimeToolAvailabilityContext context = new ChatTimeToolAvailabilityContext();
|
||||
context.setLoginAccount(loginAccount);
|
||||
return context;
|
||||
}
|
||||
|
||||
private DocumentCollection buildKnowledge(long knowledgeId,
|
||||
long createdBy,
|
||||
long categoryId,
|
||||
BigInteger deptId,
|
||||
String visibilityScope) {
|
||||
DocumentCollection knowledge = new DocumentCollection();
|
||||
knowledge.setId(BigInteger.valueOf(knowledgeId));
|
||||
knowledge.setTitle("knowledge-" + knowledgeId);
|
||||
knowledge.setDescription("desc-" + knowledgeId);
|
||||
knowledge.setCreatedBy(BigInteger.valueOf(createdBy));
|
||||
knowledge.setCategoryId(BigInteger.valueOf(categoryId));
|
||||
knowledge.setDeptId(deptId);
|
||||
knowledge.setVisibilityScope(visibilityScope);
|
||||
return knowledge;
|
||||
}
|
||||
|
||||
private Document buildSearchDocument(String content) {
|
||||
Document document = new Document();
|
||||
document.setContent(content);
|
||||
return document;
|
||||
}
|
||||
|
||||
private ApplicationContext mockApplicationContext(DocumentCollectionService documentCollectionService,
|
||||
ChatTimeToolAvailabilityService availabilityService) {
|
||||
return (ApplicationContext) Proxy.newProxyInstance(
|
||||
ApplicationContext.class.getClassLoader(),
|
||||
new Class[]{ApplicationContext.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof Class<?> clazz) {
|
||||
if (clazz == DocumentCollectionService.class) {
|
||||
return documentCollectionService;
|
||||
}
|
||||
if (clazz == ChatTimeToolAvailabilityService.class) {
|
||||
return availabilityService;
|
||||
}
|
||||
}
|
||||
if ("equals".equals(method.getName())) {
|
||||
return proxy == args[0];
|
||||
}
|
||||
if ("hashCode".equals(method.getName())) {
|
||||
return System.identityHashCode(proxy);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private CategoryPermissionService mockCategoryPermissionService(RoleCategoryAccessSnapshot accessSnapshot) {
|
||||
return (CategoryPermissionService) Proxy.newProxyInstance(
|
||||
CategoryPermissionService.class.getClassLoader(),
|
||||
new Class<?>[]{CategoryPermissionService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getAccess".equals(method.getName())) {
|
||||
return accessSnapshot;
|
||||
}
|
||||
if ("isSuperAdmin".equals(method.getName())) {
|
||||
return accessSnapshot.isSuperAdmin();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private SysDeptService mockSysDeptService(Set<BigInteger> deptIds) {
|
||||
Set<BigInteger> readableDeptIds = deptIds == null ? Collections.emptySet() : deptIds;
|
||||
return (SysDeptService) Proxy.newProxyInstance(
|
||||
SysDeptService.class.getClassLoader(),
|
||||
new Class<?>[]{SysDeptService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getSelfAndAncestorDeptIds".equals(method.getName())) {
|
||||
return readableDeptIds;
|
||||
}
|
||||
if ("canUserAccessDeptScopedResource".equals(method.getName())) {
|
||||
BigInteger resourceDeptId = (BigInteger) args[1];
|
||||
return readableDeptIds.contains(resourceDeptId);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T getStaticField(String fieldName) throws Exception {
|
||||
Field field = SpringContextUtil.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
return (T) field.get(null);
|
||||
}
|
||||
|
||||
private void setStaticField(String fieldName, Object value) throws Exception {
|
||||
Field field = SpringContextUtil.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(null, value);
|
||||
}
|
||||
|
||||
private Set<BigInteger> setOf(BigInteger... values) {
|
||||
Set<BigInteger> result = new LinkedHashSet<>();
|
||||
Collections.addAll(result, values);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Object defaultValue(Class<?> returnType) {
|
||||
if (returnType == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (returnType == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (returnType == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录检索调用的最小知识库服务桩。
|
||||
*/
|
||||
private static class TestDocumentCollectionService {
|
||||
|
||||
private final DocumentCollection knowledge;
|
||||
private final List<Document> searchResult;
|
||||
private int searchCount;
|
||||
private KnowledgeRetrievalRequest lastRequest;
|
||||
|
||||
private TestDocumentCollectionService(DocumentCollection knowledge, List<Document> searchResult) {
|
||||
this.knowledge = knowledge;
|
||||
this.searchResult = searchResult;
|
||||
}
|
||||
|
||||
private DocumentCollectionService toProxy() {
|
||||
return (DocumentCollectionService) Proxy.newProxyInstance(
|
||||
DocumentCollectionService.class.getClassLoader(),
|
||||
new Class<?>[]{DocumentCollectionService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getById".equals(method.getName())) {
|
||||
return knowledge;
|
||||
}
|
||||
if ("search".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof KnowledgeRetrievalRequest request) {
|
||||
this.searchCount++;
|
||||
this.lastRequest = request;
|
||||
return searchResult;
|
||||
}
|
||||
return defaultStaticValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static Object defaultStaticValue(Class<?> returnType) {
|
||||
if (returnType == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (returnType == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (returnType == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package tech.easyflow.ai.easyagents.tool;
|
||||
|
||||
import com.easyagents.flow.core.chain.ChainDefinition;
|
||||
import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository;
|
||||
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
|
||||
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
|
||||
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import tech.easyflow.ai.entity.Workflow;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* {@link WorkflowTool} 单元测试。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-05-11
|
||||
*/
|
||||
public class WorkflowToolTest {
|
||||
|
||||
/**
|
||||
* 启用英文名且字段合法时,应直接使用英文名。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void shouldUseValidEnglishNameWhenEnabled() throws Exception {
|
||||
ApplicationContext previousContext = getStaticField("applicationContext");
|
||||
Object previousBeanFactory = getStaticField("beanFactory");
|
||||
try {
|
||||
setStaticField("beanFactory", null);
|
||||
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-1")));
|
||||
|
||||
WorkflowTool tool = new WorkflowTool(buildWorkflow(101, "workflow_search", "工作流检索"), true, "workflow-1");
|
||||
|
||||
Assert.assertEquals("workflow_search", tool.getName());
|
||||
} finally {
|
||||
setStaticField("applicationContext", previousContext);
|
||||
setStaticField("beanFactory", previousBeanFactory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用英文名但字段缺失时,应回退为稳定安全名称。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void shouldFallbackToSafeNameWhenEnglishNameMissing() throws Exception {
|
||||
ApplicationContext previousContext = getStaticField("applicationContext");
|
||||
Object previousBeanFactory = getStaticField("beanFactory");
|
||||
try {
|
||||
setStaticField("beanFactory", null);
|
||||
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-2")));
|
||||
|
||||
WorkflowTool tool = new WorkflowTool(buildWorkflow(202, null, "工作流检索"), true, "workflow-2");
|
||||
|
||||
Assert.assertEquals("workflow_202", tool.getName());
|
||||
} finally {
|
||||
setStaticField("applicationContext", previousContext);
|
||||
setStaticField("beanFactory", previousBeanFactory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用英文名但字段非法时,应回退为稳定安全名称。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void shouldFallbackToSafeNameWhenEnglishNameInvalid() throws Exception {
|
||||
ApplicationContext previousContext = getStaticField("applicationContext");
|
||||
Object previousBeanFactory = getStaticField("beanFactory");
|
||||
try {
|
||||
setStaticField("beanFactory", null);
|
||||
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-3")));
|
||||
|
||||
WorkflowTool tool = new WorkflowTool(buildWorkflow(303, "工作流-A", "工作流检索"), true, "workflow-3");
|
||||
|
||||
Assert.assertEquals("workflow_303", tool.getName());
|
||||
} finally {
|
||||
setStaticField("applicationContext", previousContext);
|
||||
setStaticField("beanFactory", previousBeanFactory);
|
||||
}
|
||||
}
|
||||
|
||||
private Workflow buildWorkflow(long id, String englishName, String title) {
|
||||
Workflow workflow = new Workflow();
|
||||
workflow.setId(BigInteger.valueOf(id));
|
||||
workflow.setEnglishName(englishName);
|
||||
workflow.setTitle(title);
|
||||
workflow.setDescription("desc-" + id);
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private ChainExecutor buildChainExecutor(String definitionId) {
|
||||
ChainDefinitionRepository definitionRepository = id -> {
|
||||
ChainDefinition definition = new ChainDefinition();
|
||||
definition.setId(definitionId);
|
||||
return definition;
|
||||
};
|
||||
return new ChainExecutor(
|
||||
definitionRepository,
|
||||
new InMemoryChainStateRepository(),
|
||||
new InMemoryNodeStateRepository()
|
||||
);
|
||||
}
|
||||
|
||||
private ApplicationContext mockApplicationContext(ChainExecutor chainExecutor) {
|
||||
return (ApplicationContext) Proxy.newProxyInstance(
|
||||
ApplicationContext.class.getClassLoader(),
|
||||
new Class[]{ApplicationContext.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] == ChainExecutor.class) {
|
||||
return chainExecutor;
|
||||
}
|
||||
if ("equals".equals(method.getName())) {
|
||||
return proxy == args[0];
|
||||
}
|
||||
if ("hashCode".equals(method.getName())) {
|
||||
return System.identityHashCode(proxy);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static <T> T getStaticField(String name) throws Exception {
|
||||
Field field = Class.forName("tech.easyflow.common.util.SpringContextUtil").getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
T value = (T) field.get(null);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void setStaticField(String name, Object value) throws Exception {
|
||||
Field field = Class.forName("tech.easyflow.common.util.SpringContextUtil").getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(null, value);
|
||||
}
|
||||
|
||||
private Object defaultValue(Class<?> returnType) {
|
||||
if (returnType == null || returnType == Void.TYPE) {
|
||||
return null;
|
||||
}
|
||||
if (!returnType.isPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
if (returnType == Boolean.TYPE) {
|
||||
return false;
|
||||
}
|
||||
if (returnType == Character.TYPE) {
|
||||
return '\0';
|
||||
}
|
||||
if (returnType == Byte.TYPE) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (returnType == Short.TYPE) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (returnType == Integer.TYPE) {
|
||||
return 0;
|
||||
}
|
||||
if (returnType == Long.TYPE) {
|
||||
return 0L;
|
||||
}
|
||||
if (returnType == Float.TYPE) {
|
||||
return 0F;
|
||||
}
|
||||
if (returnType == Double.TYPE) {
|
||||
return 0D;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package tech.easyflow.ai.entity;
|
||||
|
||||
import com.easyagents.core.message.AiMessage;
|
||||
import com.easyagents.core.message.Message;
|
||||
import com.easyagents.core.message.ToolMessage;
|
||||
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
|
||||
import com.easyagents.llm.deepseek.DeepseekConfig;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.easyagents.memory.PublicBotMessageMemory;
|
||||
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link ChatRequestParams} 测试。
|
||||
*/
|
||||
public class ChatRequestParamsTest {
|
||||
|
||||
/**
|
||||
* 应兼容解析 assistant 的 reasoning 与 OpenAI 风格 tool_calls。
|
||||
*/
|
||||
@Test
|
||||
public void shouldParseAssistantReasoningAndToolCalls() {
|
||||
ChatRequestParams params = new ChatRequestParams();
|
||||
params.setMessagesFromJson(List.of(
|
||||
Map.of(
|
||||
"role", "assistant",
|
||||
"content", "",
|
||||
"reasoning_content", "先思考",
|
||||
"tool_calls", List.of(
|
||||
Map.of(
|
||||
"id", "call-1",
|
||||
"type", "function",
|
||||
"function", Map.of(
|
||||
"name", "kb_search",
|
||||
"arguments", "{\"query\":\"test\"}"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
));
|
||||
|
||||
Message message = params.getMessages().get(0);
|
||||
Assert.assertTrue(message instanceof AiMessage);
|
||||
AiMessage aiMessage = (AiMessage) message;
|
||||
Assert.assertEquals("先思考", aiMessage.getReasoningContent());
|
||||
Assert.assertEquals(1, aiMessage.getToolCalls().size());
|
||||
Assert.assertEquals("kb_search", aiMessage.getToolCalls().get(0).getName());
|
||||
Assert.assertEquals("{\"query\":\"test\"}", aiMessage.getToolCalls().get(0).getArguments());
|
||||
}
|
||||
|
||||
/**
|
||||
* 应兼容解析 tool 消息的下划线字段。
|
||||
*/
|
||||
@Test
|
||||
public void shouldParseToolMessageWithSnakeCaseToolCallId() {
|
||||
ChatRequestParams params = new ChatRequestParams();
|
||||
params.setMessagesFromJson(List.of(
|
||||
Map.of(
|
||||
"role", "tool",
|
||||
"content", "{\"hits\":1}",
|
||||
"tool_call_id", "call-2"
|
||||
)
|
||||
));
|
||||
|
||||
Message message = params.getMessages().get(0);
|
||||
Assert.assertTrue(message instanceof ToolMessage);
|
||||
ToolMessage toolMessage = (ToolMessage) message;
|
||||
Assert.assertEquals("call-2", toolMessage.getToolCallId());
|
||||
Assert.assertEquals("{\"hits\":1}", toolMessage.getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* public-api 多轮 assistant/tool 历史透传后,DeepSeek 后续轮次序列化不应丢字段。
|
||||
*/
|
||||
@Test
|
||||
public void shouldKeepPublicApiAssistantAndToolHistoryForDeepseekFollowUp() {
|
||||
ChatRequestParams params = new ChatRequestParams();
|
||||
params.setMessagesFromJson(List.of(
|
||||
Map.of(
|
||||
"role", "assistant",
|
||||
"content", "",
|
||||
"reasoning_content", "先思考",
|
||||
"tool_calls", List.of(
|
||||
Map.of(
|
||||
"id", "call-1",
|
||||
"type", "function",
|
||||
"function", Map.of(
|
||||
"name", "kb_search",
|
||||
"arguments", "{\"query\":\"test\"}"
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
Map.of(
|
||||
"role", "tool",
|
||||
"content", "{\"hits\":1}",
|
||||
"tool_call_id", "call-1"
|
||||
)
|
||||
));
|
||||
|
||||
PublicBotMessageMemory memory = new PublicBotMessageMemory(new ChatSseEmitter(), params.getMessages());
|
||||
DeepseekConfig config = new DeepseekConfig();
|
||||
config.setSupportThinking(Boolean.TRUE);
|
||||
config.setThinkingProtocol("deepseek");
|
||||
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
|
||||
|
||||
List<Map<String, Object>> serialized = new OpenAIChatMessageSerializer()
|
||||
.serializeMessages(memory.getMessages(10), config);
|
||||
|
||||
Assert.assertEquals(2, serialized.size());
|
||||
Assert.assertEquals("assistant", serialized.get(0).get("role"));
|
||||
Assert.assertEquals("先思考", serialized.get(0).get("reasoning_content"));
|
||||
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
|
||||
Assert.assertEquals("tool", serialized.get(1).get("role"));
|
||||
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package tech.easyflow.ai.entity;
|
||||
|
||||
import com.easyagents.core.message.AiMessage;
|
||||
import com.easyagents.core.message.ToolCall;
|
||||
import com.easyagents.core.message.ToolMessage;
|
||||
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
|
||||
import com.easyagents.llm.deepseek.DeepseekChatModel;
|
||||
import com.easyagents.llm.deepseek.DeepseekConfig;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* DeepSeek thinking/tool 配置测试。
|
||||
*/
|
||||
public class ModelDeepseekConfigTest {
|
||||
|
||||
/**
|
||||
* DeepSeek 聊天模型应显式开启 thinking/tool 相关协议开关。
|
||||
*/
|
||||
@Test
|
||||
public void shouldEnableDeepseekThinkingProtocolFlags() {
|
||||
ModelProvider provider = new ModelProvider();
|
||||
provider.setProviderType("deepseek");
|
||||
provider.setProviderName("deepseek");
|
||||
|
||||
Model model = new Model();
|
||||
model.setModelProvider(provider);
|
||||
model.setEndpoint("https://api.deepseek.com");
|
||||
model.setApiKey("sk-test");
|
||||
model.setModelName("deepseek-chat");
|
||||
model.setRequestPath("/chat/completions");
|
||||
|
||||
DeepseekChatModel chatModel = (DeepseekChatModel) model.toChatModel();
|
||||
DeepseekConfig config = chatModel.getConfig();
|
||||
|
||||
Assert.assertTrue(config.isSupportThinking());
|
||||
Assert.assertEquals("deepseek", config.getThinkingProtocol());
|
||||
Assert.assertTrue(config.isNeedReasoningContentForToolMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek 在 tool call assistant 历史上应序列化 reasoning_content。
|
||||
*/
|
||||
@Test
|
||||
public void shouldSerializeReasoningContentForToolAssistantMessage() {
|
||||
DeepseekConfig config = new DeepseekConfig();
|
||||
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
|
||||
config.setThinkingProtocol("deepseek");
|
||||
config.setSupportThinking(Boolean.TRUE);
|
||||
|
||||
AiMessage assistant = new AiMessage(null);
|
||||
assistant.setReasoningContent("先推理");
|
||||
assistant.setToolCalls(List.of(new ToolCall("call-1", "kb_search", "{\"query\":\"java\"}")));
|
||||
|
||||
ToolMessage toolMessage = new ToolMessage();
|
||||
toolMessage.setToolCallId("call-1");
|
||||
toolMessage.setContent("{\"hits\":1}");
|
||||
|
||||
List<Map<String, Object>> serialized = new OpenAIChatMessageSerializer().serializeMessages(
|
||||
List.of(assistant, toolMessage),
|
||||
config
|
||||
);
|
||||
|
||||
Assert.assertEquals(2, serialized.size());
|
||||
Assert.assertEquals("assistant", serialized.get(0).get("role"));
|
||||
Assert.assertEquals("先推理", serialized.get(0).get("reasoning_content"));
|
||||
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
|
||||
Assert.assertEquals("tool", serialized.get(1).get("role"));
|
||||
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package tech.easyflow.ai.service.impl;
|
||||
|
||||
import com.easyagents.core.model.chat.tool.Tool;
|
||||
import com.easyagents.rag.retrieval.RetrievalMode;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeKnowledgeAvailabilityResolver;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityContext;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityService;
|
||||
import tech.easyflow.ai.chattime.availability.ChatTimeToolAvailabilityServiceImpl;
|
||||
import tech.easyflow.ai.easyagents.tool.DocumentCollectionTool;
|
||||
import tech.easyflow.ai.entity.Bot;
|
||||
import tech.easyflow.ai.entity.BotDocumentCollection;
|
||||
import tech.easyflow.ai.entity.DocumentCollection;
|
||||
import tech.easyflow.ai.permission.KnowledgeVisibilityQueryHelper;
|
||||
import tech.easyflow.common.entity.LoginAccount;
|
||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||
import tech.easyflow.system.service.CategoryPermissionService;
|
||||
import tech.easyflow.system.service.SysDeptService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* {@link BotServiceImpl} 单元测试。
|
||||
*
|
||||
* @author Codex
|
||||
* @since 2026-05-10
|
||||
*/
|
||||
public class BotServiceImplTest {
|
||||
|
||||
/**
|
||||
* 仅应为当前用户可访问的知识库生成聊天工具,并保留绑定检索模式。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void buildKnowledgeToolsShouldOnlyCreateAvailableTools() throws Exception {
|
||||
BotServiceImpl service = new BotServiceImpl();
|
||||
injectAvailabilityService(service, buildAvailabilityService(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))),
|
||||
Collections.emptySet()
|
||||
));
|
||||
|
||||
List<Tool> tools = service.buildKnowledgeTools(
|
||||
List.of(
|
||||
buildBinding(101, 11, 21, "PUBLIC", RetrievalMode.KEYWORD),
|
||||
buildBinding(102, 11, 99, "PUBLIC", RetrievalMode.HYBRID)
|
||||
),
|
||||
false,
|
||||
buildContext(12, 3)
|
||||
);
|
||||
|
||||
Assert.assertEquals(1, tools.size());
|
||||
Assert.assertTrue(tools.get(0) instanceof DocumentCollectionTool);
|
||||
DocumentCollectionTool tool = (DocumentCollectionTool) tools.get(0);
|
||||
Assert.assertEquals(BigInteger.valueOf(101), tool.getKnowledgeId());
|
||||
Assert.assertEquals(RetrievalMode.KEYWORD, tool.getRetrievalMode());
|
||||
Assert.assertNotNull(tool.getChatTimeContext());
|
||||
}
|
||||
|
||||
/**
|
||||
* 当全部绑定知识库都不可用时,聊天工具列表应为空。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void buildKnowledgeToolsShouldReturnEmptyWhenAllBindingsUnavailable() throws Exception {
|
||||
BotServiceImpl service = new BotServiceImpl();
|
||||
injectAvailabilityService(service, buildAvailabilityService(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(99))),
|
||||
Collections.emptySet()
|
||||
));
|
||||
|
||||
List<Tool> tools = service.buildKnowledgeTools(
|
||||
List.of(buildBinding(101, 11, 21, "PUBLIC", RetrievalMode.HYBRID)),
|
||||
false,
|
||||
buildContext(12, 3)
|
||||
);
|
||||
|
||||
Assert.assertTrue(tools.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* 已发布快照分支也应按聊天态权限过滤知识库,并保留检索模式。
|
||||
*
|
||||
* @throws Exception 反射调用异常
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void appendPublishedKnowledgeToolsShouldFilterUnavailableBindings() throws Exception {
|
||||
BotServiceImpl service = new BotServiceImpl();
|
||||
injectAvailabilityService(service, buildAvailabilityService(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))),
|
||||
Collections.emptySet()
|
||||
));
|
||||
injectDocumentCollectionService(service, mockDocumentCollectionService(
|
||||
buildKnowledge(101, 11, 21, "PUBLIC"),
|
||||
buildKnowledge(102, 11, 99, "PUBLIC")
|
||||
));
|
||||
|
||||
Bot runtimeBot = new Bot();
|
||||
Map<String, Object> snapshot = new HashMap<>();
|
||||
snapshot.put("knowledgeBindings", List.of(
|
||||
buildPublishedBinding(101, RetrievalMode.KEYWORD),
|
||||
buildPublishedBinding(102, RetrievalMode.HYBRID)
|
||||
));
|
||||
runtimeBot.setPublishedSnapshotJson(snapshot);
|
||||
List<Tool> functionList = new ArrayList<>();
|
||||
Method method = BotServiceImpl.class.getDeclaredMethod(
|
||||
"appendPublishedKnowledgeTools",
|
||||
List.class,
|
||||
Bot.class,
|
||||
boolean.class,
|
||||
ChatTimeToolAvailabilityContext.class,
|
||||
Set.class
|
||||
);
|
||||
method.setAccessible(true);
|
||||
|
||||
method.invoke(service, functionList, runtimeBot, false, buildContext(12, 3), new LinkedHashSet<>());
|
||||
|
||||
Assert.assertEquals(1, functionList.size());
|
||||
DocumentCollectionTool tool = (DocumentCollectionTool) functionList.get(0);
|
||||
Assert.assertEquals(BigInteger.valueOf(101), tool.getKnowledgeId());
|
||||
Assert.assertEquals(RetrievalMode.KEYWORD, tool.getRetrievalMode());
|
||||
}
|
||||
|
||||
/**
|
||||
* 额外知识库应按用户选择顺序去重,并限制最多 3 个。
|
||||
*
|
||||
* @throws Exception 反射调用异常
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void sanitizeExtraKnowledgeIdsShouldDeduplicateAndKeepOrder() throws Exception {
|
||||
BotServiceImpl service = new BotServiceImpl();
|
||||
Method method = BotServiceImpl.class.getDeclaredMethod("sanitizeExtraKnowledgeIds", List.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
List<BigInteger> result = (List<BigInteger>) method.invoke(
|
||||
service,
|
||||
List.of(
|
||||
BigInteger.valueOf(3),
|
||||
BigInteger.valueOf(1),
|
||||
BigInteger.valueOf(3),
|
||||
BigInteger.valueOf(2)
|
||||
)
|
||||
);
|
||||
|
||||
Assert.assertEquals(
|
||||
List.of(BigInteger.valueOf(3), BigInteger.ONE, BigInteger.valueOf(2)),
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 额外知识库超过 3 个时应直接拒绝请求。
|
||||
*
|
||||
* @throws Exception 反射调用异常
|
||||
*/
|
||||
@Test
|
||||
public void sanitizeExtraKnowledgeIdsShouldRejectWhenMoreThanThree() throws Exception {
|
||||
BotServiceImpl service = new BotServiceImpl();
|
||||
Method method = BotServiceImpl.class.getDeclaredMethod("sanitizeExtraKnowledgeIds", List.class);
|
||||
method.setAccessible(true);
|
||||
|
||||
try {
|
||||
method.invoke(
|
||||
service,
|
||||
List.of(
|
||||
BigInteger.ONE,
|
||||
BigInteger.valueOf(2),
|
||||
BigInteger.valueOf(3),
|
||||
BigInteger.valueOf(4)
|
||||
)
|
||||
);
|
||||
Assert.fail("expected BusinessException");
|
||||
} catch (java.lang.reflect.InvocationTargetException exception) {
|
||||
Assert.assertTrue(exception.getTargetException() instanceof tech.easyflow.common.web.exceptions.BusinessException);
|
||||
Assert.assertEquals("额外知识库最多选择 3 个", exception.getTargetException().getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 额外知识库工具应强制使用发布态视图,并跳过重复选择。
|
||||
*
|
||||
* @throws Exception 反射注入异常
|
||||
*/
|
||||
@Test
|
||||
public void appendExtraKnowledgeToolsShouldUsePublishedKnowledgeAndDeduplicate() throws Exception {
|
||||
BotServiceImpl service = new BotServiceImpl();
|
||||
injectAvailabilityService(service, buildAvailabilityService(
|
||||
new RoleCategoryAccessSnapshot("KNOWLEDGE", BigInteger.valueOf(12), false, false, setOf(BigInteger.valueOf(21))),
|
||||
Collections.emptySet()
|
||||
));
|
||||
|
||||
DocumentCollection draftKnowledge = buildKnowledge(101, 11, 21, "PUBLIC");
|
||||
draftKnowledge.setPublishStatus("PUBLISHED");
|
||||
draftKnowledge.setTitle("draft-title");
|
||||
draftKnowledge.setDescription("draft-desc");
|
||||
|
||||
DocumentCollection publishedKnowledge = buildKnowledge(101, 11, 21, "PUBLIC");
|
||||
publishedKnowledge.setPublishStatus("PUBLISHED");
|
||||
publishedKnowledge.setTitle("published-title");
|
||||
publishedKnowledge.setDescription("published-desc");
|
||||
|
||||
injectDocumentCollectionService(service, mockDocumentCollectionServiceForExtra(draftKnowledge, publishedKnowledge));
|
||||
|
||||
List<Tool> tools = new ArrayList<>();
|
||||
service.appendExtraKnowledgeTools(
|
||||
tools,
|
||||
List.of(BigInteger.valueOf(101), BigInteger.valueOf(101)),
|
||||
false,
|
||||
buildContext(12, 3),
|
||||
new LinkedHashSet<>()
|
||||
);
|
||||
|
||||
Assert.assertEquals(1, tools.size());
|
||||
DocumentCollectionTool tool = (DocumentCollectionTool) tools.get(0);
|
||||
Assert.assertEquals(BigInteger.valueOf(101), tool.getKnowledgeId());
|
||||
Assert.assertEquals("published-desc", tool.getDescription());
|
||||
}
|
||||
|
||||
private void injectAvailabilityService(BotServiceImpl service, ChatTimeToolAvailabilityService availabilityService) throws Exception {
|
||||
Field field = BotServiceImpl.class.getDeclaredField("chatTimeToolAvailabilityService");
|
||||
field.setAccessible(true);
|
||||
field.set(service, availabilityService);
|
||||
}
|
||||
|
||||
private void injectDocumentCollectionService(BotServiceImpl service, tech.easyflow.ai.service.DocumentCollectionService documentCollectionService) throws Exception {
|
||||
Field field = BotServiceImpl.class.getDeclaredField("documentCollectionService");
|
||||
field.setAccessible(true);
|
||||
field.set(service, documentCollectionService);
|
||||
}
|
||||
|
||||
private ChatTimeToolAvailabilityService buildAvailabilityService(RoleCategoryAccessSnapshot accessSnapshot,
|
||||
Set<BigInteger> deptIds) {
|
||||
return new ChatTimeToolAvailabilityServiceImpl(List.of(
|
||||
new ChatTimeKnowledgeAvailabilityResolver(
|
||||
new KnowledgeVisibilityQueryHelper(),
|
||||
mockCategoryPermissionService(accessSnapshot),
|
||||
mockSysDeptService(deptIds)
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
private ChatTimeToolAvailabilityContext buildContext(long accountId, long deptId) {
|
||||
LoginAccount loginAccount = new LoginAccount();
|
||||
loginAccount.setId(BigInteger.valueOf(accountId));
|
||||
loginAccount.setDeptId(BigInteger.valueOf(deptId));
|
||||
ChatTimeToolAvailabilityContext context = new ChatTimeToolAvailabilityContext();
|
||||
context.setLoginAccount(loginAccount);
|
||||
return context;
|
||||
}
|
||||
|
||||
private BotDocumentCollection buildBinding(long knowledgeId,
|
||||
long createdBy,
|
||||
long categoryId,
|
||||
String visibilityScope,
|
||||
RetrievalMode retrievalMode) {
|
||||
DocumentCollection knowledge = buildKnowledge(knowledgeId, createdBy, categoryId, visibilityScope);
|
||||
BotDocumentCollection binding = new BotDocumentCollection();
|
||||
binding.setDocumentCollectionId(BigInteger.valueOf(knowledgeId));
|
||||
binding.setKnowledge(knowledge);
|
||||
binding.setRetrievalMode(retrievalMode);
|
||||
return binding;
|
||||
}
|
||||
|
||||
private DocumentCollection buildKnowledge(long knowledgeId,
|
||||
long createdBy,
|
||||
long categoryId,
|
||||
String visibilityScope) {
|
||||
DocumentCollection knowledge = new DocumentCollection();
|
||||
knowledge.setId(BigInteger.valueOf(knowledgeId));
|
||||
knowledge.setTitle("knowledge-" + knowledgeId);
|
||||
knowledge.setDescription("desc-" + knowledgeId);
|
||||
knowledge.setCreatedBy(BigInteger.valueOf(createdBy));
|
||||
knowledge.setCategoryId(BigInteger.valueOf(categoryId));
|
||||
knowledge.setVisibilityScope(visibilityScope);
|
||||
knowledge.setPublishStatus("PUBLISHED");
|
||||
return knowledge;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPublishedBinding(long knowledgeId, RetrievalMode retrievalMode) {
|
||||
Map<String, Object> binding = new HashMap<>();
|
||||
binding.put("knowledgeId", String.valueOf(knowledgeId));
|
||||
binding.put("retrievalMode", retrievalMode.name());
|
||||
return binding;
|
||||
}
|
||||
|
||||
private tech.easyflow.ai.service.DocumentCollectionService mockDocumentCollectionService(DocumentCollection... collections) {
|
||||
Map<BigInteger, DocumentCollection> knowledgeMap = new HashMap<>();
|
||||
for (DocumentCollection collection : collections) {
|
||||
knowledgeMap.put(collection.getId(), collection);
|
||||
}
|
||||
return (tech.easyflow.ai.service.DocumentCollectionService) Proxy.newProxyInstance(
|
||||
tech.easyflow.ai.service.DocumentCollectionService.class.getClassLoader(),
|
||||
new Class<?>[]{tech.easyflow.ai.service.DocumentCollectionService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getPublishedById".equals(method.getName()) && args != null && args.length == 1 && args[0] instanceof BigInteger knowledgeId) {
|
||||
return knowledgeMap.get(knowledgeId);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private tech.easyflow.ai.service.DocumentCollectionService mockDocumentCollectionServiceForExtra(DocumentCollection draftCollection,
|
||||
DocumentCollection publishedCollection) {
|
||||
return (tech.easyflow.ai.service.DocumentCollectionService) Proxy.newProxyInstance(
|
||||
tech.easyflow.ai.service.DocumentCollectionService.class.getClassLoader(),
|
||||
new Class<?>[]{tech.easyflow.ai.service.DocumentCollectionService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getById".equals(method.getName())) {
|
||||
return draftCollection;
|
||||
}
|
||||
if ("toPublishedView".equals(method.getName())) {
|
||||
return publishedCollection;
|
||||
}
|
||||
if ("getPublishedById".equals(method.getName())) {
|
||||
return publishedCollection;
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private CategoryPermissionService mockCategoryPermissionService(RoleCategoryAccessSnapshot accessSnapshot) {
|
||||
return (CategoryPermissionService) Proxy.newProxyInstance(
|
||||
CategoryPermissionService.class.getClassLoader(),
|
||||
new Class<?>[]{CategoryPermissionService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getAccess".equals(method.getName())) {
|
||||
return accessSnapshot;
|
||||
}
|
||||
if ("isSuperAdmin".equals(method.getName())) {
|
||||
return accessSnapshot.isSuperAdmin();
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private SysDeptService mockSysDeptService(Set<BigInteger> deptIds) {
|
||||
Set<BigInteger> readableDeptIds = deptIds == null ? Collections.emptySet() : deptIds;
|
||||
return (SysDeptService) Proxy.newProxyInstance(
|
||||
SysDeptService.class.getClassLoader(),
|
||||
new Class<?>[]{SysDeptService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getSelfAndAncestorDeptIds".equals(method.getName())) {
|
||||
return readableDeptIds;
|
||||
}
|
||||
if ("canUserAccessDeptScopedResource".equals(method.getName())) {
|
||||
BigInteger resourceDeptId = (BigInteger) args[1];
|
||||
return readableDeptIds.contains(resourceDeptId);
|
||||
}
|
||||
return defaultValue(method.getReturnType());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private Set<BigInteger> setOf(BigInteger... values) {
|
||||
Set<BigInteger> result = new LinkedHashSet<>();
|
||||
Collections.addAll(result, values);
|
||||
return result;
|
||||
}
|
||||
|
||||
private Object defaultValue(Class<?> returnType) {
|
||||
if (returnType == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (returnType == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (returnType == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package tech.easyflow.core.runtime;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* {@link ChatAssistantAccumulator} 测试。
|
||||
*/
|
||||
public class ChatAssistantAccumulatorTest {
|
||||
|
||||
/**
|
||||
* 应同时保留展示链和可回放的结构化消息链。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldBuildStructuredPayloadWithDisplayChains() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendReasoning("思考-1");
|
||||
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
|
||||
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
|
||||
accumulator.appendReasoning("思考-2");
|
||||
accumulator.appendContent("最终回答");
|
||||
|
||||
Map<String, Object> payload = accumulator.buildPayload("最终回答");
|
||||
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
|
||||
List<Map<String, Object>> chains = (List<Map<String, Object>>) payload.get("chains");
|
||||
|
||||
Assert.assertEquals(3, messageChain.size());
|
||||
Assert.assertEquals("assistant", messageChain.get(0).get("role"));
|
||||
Assert.assertEquals("思考-1", messageChain.get(0).get("reasoningContent"));
|
||||
Assert.assertEquals("tool", messageChain.get(1).get("role"));
|
||||
Assert.assertEquals("assistant", messageChain.get(2).get("role"));
|
||||
Assert.assertEquals("最终回答", messageChain.get(2).get("content"));
|
||||
Assert.assertEquals("思考-2", messageChain.get(2).get("reasoningContent"));
|
||||
|
||||
Assert.assertFalse(chains.isEmpty());
|
||||
Assert.assertEquals("思考-1思考-2", chains.get(0).get("reasoning_content"));
|
||||
Assert.assertEquals("{\"query\":\"java\"}", chains.get(1).get("arguments"));
|
||||
Assert.assertEquals("{\"hits\":1}", chains.get(1).get("result"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续多轮 tool-only assistant 不应被错误合并到同一条 assistant 历史。
|
||||
*/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void shouldKeepToolOnlyAssistantSegmentsSeparatedAcrossRecursiveRounds() {
|
||||
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
|
||||
accumulator.appendToolCall("call-1", "tool_one", "{\"step\":1}");
|
||||
accumulator.appendToolResult("call-1", "tool_one", "{\"ok\":1}");
|
||||
accumulator.appendToolCall("call-2", "tool_two", "{\"step\":2}");
|
||||
accumulator.appendToolResult("call-2", "tool_two", "{\"ok\":2}");
|
||||
|
||||
Map<String, Object> payload = accumulator.buildPayload(null);
|
||||
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
|
||||
|
||||
Assert.assertEquals(4, messageChain.size());
|
||||
Assert.assertEquals("assistant", messageChain.get(0).get("role"));
|
||||
Assert.assertEquals("tool", messageChain.get(1).get("role"));
|
||||
Assert.assertEquals("assistant", messageChain.get(2).get("role"));
|
||||
Assert.assertEquals("tool", messageChain.get(3).get("role"));
|
||||
|
||||
List<Map<String, Object>> firstToolCalls = (List<Map<String, Object>>) messageChain.get(0).get("toolCalls");
|
||||
List<Map<String, Object>> secondToolCalls = (List<Map<String, Object>>) messageChain.get(2).get("toolCalls");
|
||||
Assert.assertEquals(1, firstToolCalls.size());
|
||||
Assert.assertEquals("call-1", firstToolCalls.get(0).get("id"));
|
||||
Assert.assertEquals(1, secondToolCalls.size());
|
||||
Assert.assertEquals("call-2", secondToolCalls.get(0).get("id"));
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,13 @@ import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.chatlog.config.ChatCacheProperties;
|
||||
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
@@ -61,6 +65,9 @@ public class ChatHotStateService {
|
||||
if (command.getTitle() != null && !command.getTitle().isBlank()) {
|
||||
summary.setTitle(command.getTitle());
|
||||
}
|
||||
if (command.getExtJson() != null && !command.getExtJson().isBlank()) {
|
||||
summary.setExtJson(command.getExtJson());
|
||||
}
|
||||
summary.setAccessAt(defaultDate(command.getOperateAt()));
|
||||
summary.setModified(defaultDate(command.getOperateAt()));
|
||||
summary.setModifiedBy(command.getOperatorId());
|
||||
@@ -119,9 +126,78 @@ public class ChatHotStateService {
|
||||
summary.setModified(defaultDate(command.getCreated()));
|
||||
summary.setModifiedBy(command.getCreatedBy());
|
||||
summary.setIsDeleted(0);
|
||||
summary.setMessageCount((summary.getMessageCount() == null ? 0 : summary.getMessageCount()) + 1);
|
||||
summary.setMessageCount((summary.getMessageCount() == null ? 0 : summary.getMessageCount())
|
||||
+ resolveVisibleMessageIncrement(command));
|
||||
cacheSessionSummaryStrict(summary);
|
||||
appendTail(toMessageRecord(command));
|
||||
appendVisibleTail(toMessageRecord(command));
|
||||
}
|
||||
|
||||
public ChatRoundRecord createOrTouchRound(ChatRoundUpsertCommand command) {
|
||||
if (command == null || command.getSessionId() == null || command.getRoundId() == null) {
|
||||
return null;
|
||||
}
|
||||
ChatRoundRecord record = getRound(command.getSessionId(), command.getRoundId());
|
||||
if (record == null) {
|
||||
record = new ChatRoundRecord();
|
||||
record.setId(command.getRoundId());
|
||||
record.setSessionId(command.getSessionId());
|
||||
record.setCreated(defaultDate(command.getOperateAt()));
|
||||
}
|
||||
if (command.getRoundNo() != null) {
|
||||
record.setRoundNo(command.getRoundNo());
|
||||
}
|
||||
if (command.getUserMessageId() != null) {
|
||||
record.setUserMessageId(command.getUserMessageId());
|
||||
}
|
||||
if (command.getSelectedAssistantMessageId() != null) {
|
||||
record.setSelectedAssistantMessageId(command.getSelectedAssistantMessageId());
|
||||
}
|
||||
if (command.getSelectedVariantIndex() != null) {
|
||||
record.setSelectedVariantIndex(command.getSelectedVariantIndex());
|
||||
}
|
||||
if (command.getVariantCount() != null) {
|
||||
record.setVariantCount(command.getVariantCount());
|
||||
}
|
||||
if (command.getStatus() != null && !command.getStatus().isBlank()) {
|
||||
record.setStatus(command.getStatus());
|
||||
}
|
||||
record.setModified(defaultDate(command.getOperateAt()));
|
||||
cacheRoundStrict(record);
|
||||
syncTailRoundMeta(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
public void selectVariant(ChatRoundSelectCommand command) {
|
||||
if (command == null || command.getSessionId() == null || command.getRoundId() == null) {
|
||||
return;
|
||||
}
|
||||
ChatRoundRecord record = getRound(command.getSessionId(), command.getRoundId());
|
||||
if (record == null) {
|
||||
return;
|
||||
}
|
||||
record.setSelectedAssistantMessageId(command.getSelectedAssistantMessageId());
|
||||
record.setSelectedVariantIndex(command.getSelectedVariantIndex());
|
||||
record.setModified(defaultDate(command.getOperateAt()));
|
||||
cacheRoundStrict(record);
|
||||
if (command.getSelectedAssistantMessage() != null) {
|
||||
applyRoundMeta(command.getSelectedAssistantMessage(), record);
|
||||
replaceSelectedAssistant(record, command.getSelectedAssistantMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public ChatRoundRecord getLatestRound(BigInteger sessionId) {
|
||||
return readValue(keyLatestRound(sessionId), ChatRoundRecord.class);
|
||||
}
|
||||
|
||||
public ChatRoundRecord getRound(BigInteger sessionId, BigInteger roundId) {
|
||||
return readValue(keyRound(sessionId, roundId), ChatRoundRecord.class);
|
||||
}
|
||||
|
||||
public void cacheRound(ChatRoundRecord record) {
|
||||
try {
|
||||
cacheRoundStrict(record);
|
||||
} catch (IllegalStateException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public List<BigInteger> listSessionIds(BigInteger userId, long offset, long limit) {
|
||||
@@ -263,18 +339,168 @@ public class ChatHotStateService {
|
||||
}
|
||||
|
||||
public void appendTail(ChatMessageRecord record) {
|
||||
appendVisibleTail(record);
|
||||
}
|
||||
|
||||
public void appendVisibleTail(ChatMessageRecord record) {
|
||||
if (record == null || record.getSessionId() == null) {
|
||||
return;
|
||||
}
|
||||
ChatRoundRecord round = record.getRoundId() == null ? null : getRound(record.getSessionId(), record.getRoundId());
|
||||
applyRoundMeta(record, round);
|
||||
List<ChatMessageRecord> current = getSessionTail(record.getSessionId());
|
||||
List<ChatMessageRecord> updated = new ArrayList<>();
|
||||
updated.add(record);
|
||||
if (current != null) {
|
||||
updated.addAll(current);
|
||||
List<ChatMessageRecord> updated = current == null ? new ArrayList<>() : new ArrayList<>(current);
|
||||
removeExistingVisibleRecord(updated, record);
|
||||
insertSorted(updated, record);
|
||||
if (ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT.equals(record.getMessageKind())
|
||||
&& record.getVariantIndex() != null
|
||||
&& record.getVariantIndex() > 1) {
|
||||
removeOlderSelectedAssistant(updated, record);
|
||||
}
|
||||
writeValueStrict(keySessionTail(record.getSessionId()), trimTail(updated), properties.getSessionTailTtl());
|
||||
}
|
||||
|
||||
private void replaceSelectedAssistant(ChatRoundRecord round, ChatMessageRecord record) {
|
||||
List<ChatMessageRecord> current = getSessionTail(record.getSessionId());
|
||||
List<ChatMessageRecord> updated = current == null ? new ArrayList<>() : new ArrayList<>(current);
|
||||
syncRoundMeta(updated, round);
|
||||
removeExistingVisibleRecord(updated, record);
|
||||
removeOlderSelectedAssistant(updated, record);
|
||||
insertSorted(updated, record);
|
||||
writeValueStrict(keySessionTail(record.getSessionId()), trimTail(updated), properties.getSessionTailTtl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 将轮次选中态同步到 Redis tail,避免前端主线过滤读到过期版本号。
|
||||
*
|
||||
* @param round 轮次记录
|
||||
*/
|
||||
private void syncTailRoundMeta(ChatRoundRecord round) {
|
||||
if (round == null || round.getSessionId() == null || round.getId() == null) {
|
||||
return;
|
||||
}
|
||||
List<ChatMessageRecord> current = getSessionTail(round.getSessionId());
|
||||
if (current == null || current.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<ChatMessageRecord> updated = new ArrayList<>(current);
|
||||
syncRoundMeta(updated, round);
|
||||
if (round.getSelectedAssistantMessageId() != null) {
|
||||
updated.removeIf(item -> item != null
|
||||
&& item.getRoundId() != null
|
||||
&& item.getRoundId().equals(round.getId())
|
||||
&& "assistant".equalsIgnoreCase(item.getSenderRole())
|
||||
&& item.getId() != null
|
||||
&& !item.getId().equals(round.getSelectedAssistantMessageId()));
|
||||
}
|
||||
writeValueStrict(keySessionTail(round.getSessionId()), trimTail(updated), properties.getSessionTailTtl());
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量同步同一轮次的版本元信息。
|
||||
*
|
||||
* @param records tail 消息列表
|
||||
* @param round 轮次记录
|
||||
*/
|
||||
private void syncRoundMeta(List<ChatMessageRecord> records, ChatRoundRecord round) {
|
||||
if (records == null || records.isEmpty() || round == null || round.getId() == null) {
|
||||
return;
|
||||
}
|
||||
for (ChatMessageRecord item : records) {
|
||||
if (item != null && round.getId().equals(item.getRoundId())) {
|
||||
applyRoundMeta(item, round);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将轮次元信息写入单条消息。
|
||||
*
|
||||
* @param record 消息记录
|
||||
* @param round 轮次记录
|
||||
*/
|
||||
private void applyRoundMeta(ChatMessageRecord record, ChatRoundRecord round) {
|
||||
if (record == null || round == null) {
|
||||
return;
|
||||
}
|
||||
if (round.getRoundNo() != null) {
|
||||
record.setRoundNo(round.getRoundNo());
|
||||
}
|
||||
if (round.getVariantCount() != null) {
|
||||
record.setVariantCount(round.getVariantCount());
|
||||
}
|
||||
if (round.getSelectedVariantIndex() != null) {
|
||||
record.setSelectedVariantIndex(round.getSelectedVariantIndex());
|
||||
}
|
||||
if (round.getStatus() != null) {
|
||||
record.setSwitchable(!ChatConstants.ROUND_STATUS_LOCKED.equalsIgnoreCase(round.getStatus()));
|
||||
}
|
||||
}
|
||||
|
||||
private void removeOlderSelectedAssistant(List<ChatMessageRecord> records, ChatMessageRecord record) {
|
||||
if (record.getRoundId() == null) {
|
||||
return;
|
||||
}
|
||||
records.removeIf(item -> item != null
|
||||
&& item.getId() != null
|
||||
&& !item.getId().equals(record.getId())
|
||||
&& item.getRoundId() != null
|
||||
&& item.getRoundId().equals(record.getRoundId())
|
||||
&& "assistant".equalsIgnoreCase(item.getSenderRole()));
|
||||
}
|
||||
|
||||
private void removeExistingVisibleRecord(List<ChatMessageRecord> records, ChatMessageRecord record) {
|
||||
if (records == null || record == null || record.getId() == null) {
|
||||
return;
|
||||
}
|
||||
records.removeIf(item -> item != null && record.getId().equals(item.getId()));
|
||||
}
|
||||
|
||||
private void insertSorted(List<ChatMessageRecord> records, ChatMessageRecord record) {
|
||||
int insertIndex = 0;
|
||||
while (insertIndex < records.size()) {
|
||||
ChatMessageRecord current = records.get(insertIndex);
|
||||
if (shouldComeAfter(record, current)) {
|
||||
insertIndex++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
records.add(insertIndex, record);
|
||||
}
|
||||
|
||||
private boolean shouldComeAfter(ChatMessageRecord candidate, ChatMessageRecord current) {
|
||||
if (candidate == null) {
|
||||
return true;
|
||||
}
|
||||
if (current == null) {
|
||||
return false;
|
||||
}
|
||||
Date candidateCreated = defaultDate(candidate.getCreated());
|
||||
Date currentCreated = defaultDate(current.getCreated());
|
||||
if (candidateCreated.before(currentCreated)) {
|
||||
return true;
|
||||
}
|
||||
if (candidateCreated.after(currentCreated)) {
|
||||
return false;
|
||||
}
|
||||
BigInteger candidateId = candidate.getId() == null ? BigInteger.ZERO : candidate.getId();
|
||||
BigInteger currentId = current.getId() == null ? BigInteger.ZERO : current.getId();
|
||||
return candidateId.compareTo(currentId) < 0;
|
||||
}
|
||||
|
||||
private void cacheRoundStrict(ChatRoundRecord record) {
|
||||
if (record == null || record.getSessionId() == null || record.getId() == null) {
|
||||
return;
|
||||
}
|
||||
writeValueStrict(keyRound(record.getSessionId(), record.getId()), record, properties.getSessionTailTtl());
|
||||
ChatRoundRecord latest = getLatestRound(record.getSessionId());
|
||||
if (latest == null || latest.getRoundNo() == null
|
||||
|| (record.getRoundNo() != null && record.getRoundNo() >= latest.getRoundNo())) {
|
||||
writeValueStrict(keyLatestRound(record.getSessionId()), record, properties.getSessionTailTtl());
|
||||
}
|
||||
}
|
||||
|
||||
public void evictSessionTail(BigInteger sessionId) {
|
||||
delete(keySessionTail(sessionId));
|
||||
}
|
||||
@@ -292,6 +518,10 @@ public class ChatHotStateService {
|
||||
record.setSenderId(command.getSenderId());
|
||||
record.setSenderName(command.getSenderName());
|
||||
record.setSenderRole(command.getSenderRole());
|
||||
record.setRoundId(command.getRoundId());
|
||||
record.setRoundNo(command.getRoundNo());
|
||||
record.setMessageKind(command.getMessageKind());
|
||||
record.setVariantIndex(command.getVariantIndex());
|
||||
record.setContentType(command.getContentType());
|
||||
record.setContentText(command.getContentText());
|
||||
record.setContentPayload(command.getContentPayload());
|
||||
@@ -301,6 +531,17 @@ public class ChatHotStateService {
|
||||
return record;
|
||||
}
|
||||
|
||||
private int resolveVisibleMessageIncrement(ChatAppendMessageCommand command) {
|
||||
if (command == null) {
|
||||
return 0;
|
||||
}
|
||||
if (ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT.equals(command.getMessageKind())) {
|
||||
Integer variantIndex = command.getVariantIndex();
|
||||
return variantIndex != null && variantIndex > 1 ? 0 : 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private List<ChatMessageRecord> trimTail(List<ChatMessageRecord> records) {
|
||||
if (records == null || records.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
@@ -326,6 +567,14 @@ public class ChatHotStateService {
|
||||
return "chat:session:tail:" + sessionId;
|
||||
}
|
||||
|
||||
private String keyLatestRound(BigInteger sessionId) {
|
||||
return "chat:session:round:latest:" + sessionId;
|
||||
}
|
||||
|
||||
private String keyRound(BigInteger sessionId, BigInteger roundId) {
|
||||
return "chat:session:round:" + sessionId + ":" + roundId;
|
||||
}
|
||||
|
||||
private void removeFromSessionIndex(BigInteger userId, BigInteger sessionId) {
|
||||
if (userId == null || sessionId == null) {
|
||||
return;
|
||||
|
||||
@@ -23,6 +23,10 @@ public class ChatAppendMessageCommand implements Serializable {
|
||||
private String contentType;
|
||||
private String contentText;
|
||||
private Map<String, Object> contentPayload;
|
||||
private BigInteger roundId;
|
||||
private Integer roundNo;
|
||||
private String messageKind;
|
||||
private Integer variantIndex;
|
||||
private BigInteger createdBy;
|
||||
private Date created = new Date();
|
||||
|
||||
@@ -154,6 +158,38 @@ public class ChatAppendMessageCommand implements Serializable {
|
||||
this.contentPayload = contentPayload;
|
||||
}
|
||||
|
||||
public BigInteger getRoundId() {
|
||||
return roundId;
|
||||
}
|
||||
|
||||
public void setRoundId(BigInteger roundId) {
|
||||
this.roundId = roundId;
|
||||
}
|
||||
|
||||
public Integer getRoundNo() {
|
||||
return roundNo;
|
||||
}
|
||||
|
||||
public void setRoundNo(Integer roundNo) {
|
||||
this.roundNo = roundNo;
|
||||
}
|
||||
|
||||
public String getMessageKind() {
|
||||
return messageKind;
|
||||
}
|
||||
|
||||
public void setMessageKind(String messageKind) {
|
||||
this.messageKind = messageKind;
|
||||
}
|
||||
|
||||
public Integer getVariantIndex() {
|
||||
return variantIndex;
|
||||
}
|
||||
|
||||
public void setVariantIndex(Integer variantIndex) {
|
||||
this.variantIndex = variantIndex;
|
||||
}
|
||||
|
||||
public BigInteger getCreatedBy() {
|
||||
return createdBy;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package tech.easyflow.chatlog.domain.command;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 轮次答案版本切换命令。
|
||||
*/
|
||||
public class ChatRoundSelectCommand implements Serializable {
|
||||
|
||||
private BigInteger sessionId;
|
||||
private BigInteger roundId;
|
||||
private Integer selectedVariantIndex;
|
||||
private BigInteger selectedAssistantMessageId;
|
||||
private ChatMessageRecord selectedAssistantMessage;
|
||||
private BigInteger operatorId;
|
||||
private Date operateAt = new Date();
|
||||
|
||||
public BigInteger getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(BigInteger sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public BigInteger getRoundId() {
|
||||
return roundId;
|
||||
}
|
||||
|
||||
public void setRoundId(BigInteger roundId) {
|
||||
this.roundId = roundId;
|
||||
}
|
||||
|
||||
public Integer getSelectedVariantIndex() {
|
||||
return selectedVariantIndex;
|
||||
}
|
||||
|
||||
public void setSelectedVariantIndex(Integer selectedVariantIndex) {
|
||||
this.selectedVariantIndex = selectedVariantIndex;
|
||||
}
|
||||
|
||||
public BigInteger getSelectedAssistantMessageId() {
|
||||
return selectedAssistantMessageId;
|
||||
}
|
||||
|
||||
public void setSelectedAssistantMessageId(BigInteger selectedAssistantMessageId) {
|
||||
this.selectedAssistantMessageId = selectedAssistantMessageId;
|
||||
}
|
||||
|
||||
public ChatMessageRecord getSelectedAssistantMessage() {
|
||||
return selectedAssistantMessage;
|
||||
}
|
||||
|
||||
public void setSelectedAssistantMessage(ChatMessageRecord selectedAssistantMessage) {
|
||||
this.selectedAssistantMessage = selectedAssistantMessage;
|
||||
}
|
||||
|
||||
public BigInteger getOperatorId() {
|
||||
return operatorId;
|
||||
}
|
||||
|
||||
public void setOperatorId(BigInteger operatorId) {
|
||||
this.operatorId = operatorId;
|
||||
}
|
||||
|
||||
public Date getOperateAt() {
|
||||
return operateAt;
|
||||
}
|
||||
|
||||
public void setOperateAt(Date operateAt) {
|
||||
this.operateAt = operateAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package tech.easyflow.chatlog.domain.command;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 轮次聚合写入命令。
|
||||
*/
|
||||
public class ChatRoundUpsertCommand implements Serializable {
|
||||
|
||||
private BigInteger roundId;
|
||||
private BigInteger sessionId;
|
||||
private Integer roundNo;
|
||||
private BigInteger userMessageId;
|
||||
private BigInteger selectedAssistantMessageId;
|
||||
private Integer selectedVariantIndex;
|
||||
private Integer variantCount;
|
||||
private String status;
|
||||
private BigInteger operatorId;
|
||||
private Date operateAt = new Date();
|
||||
|
||||
public BigInteger getRoundId() {
|
||||
return roundId;
|
||||
}
|
||||
|
||||
public void setRoundId(BigInteger roundId) {
|
||||
this.roundId = roundId;
|
||||
}
|
||||
|
||||
public BigInteger getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(BigInteger sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public Integer getRoundNo() {
|
||||
return roundNo;
|
||||
}
|
||||
|
||||
public void setRoundNo(Integer roundNo) {
|
||||
this.roundNo = roundNo;
|
||||
}
|
||||
|
||||
public BigInteger getUserMessageId() {
|
||||
return userMessageId;
|
||||
}
|
||||
|
||||
public void setUserMessageId(BigInteger userMessageId) {
|
||||
this.userMessageId = userMessageId;
|
||||
}
|
||||
|
||||
public BigInteger getSelectedAssistantMessageId() {
|
||||
return selectedAssistantMessageId;
|
||||
}
|
||||
|
||||
public void setSelectedAssistantMessageId(BigInteger selectedAssistantMessageId) {
|
||||
this.selectedAssistantMessageId = selectedAssistantMessageId;
|
||||
}
|
||||
|
||||
public Integer getSelectedVariantIndex() {
|
||||
return selectedVariantIndex;
|
||||
}
|
||||
|
||||
public void setSelectedVariantIndex(Integer selectedVariantIndex) {
|
||||
this.selectedVariantIndex = selectedVariantIndex;
|
||||
}
|
||||
|
||||
public Integer getVariantCount() {
|
||||
return variantCount;
|
||||
}
|
||||
|
||||
public void setVariantCount(Integer variantCount) {
|
||||
this.variantCount = variantCount;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public BigInteger getOperatorId() {
|
||||
return operatorId;
|
||||
}
|
||||
|
||||
public void setOperatorId(BigInteger operatorId) {
|
||||
this.operatorId = operatorId;
|
||||
}
|
||||
|
||||
public Date getOperateAt() {
|
||||
return operateAt;
|
||||
}
|
||||
|
||||
public void setOperateAt(Date operateAt) {
|
||||
this.operateAt = operateAt;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,11 @@ public class ChatSessionSummaryCommand implements Serializable {
|
||||
private String lastSenderName;
|
||||
private String lastMessagePreview;
|
||||
private Date lastMessageAt = new Date();
|
||||
private Date accessAt = new Date();
|
||||
private Date modifiedAt = new Date();
|
||||
private BigInteger operatorId;
|
||||
private int messageIncrement = 1;
|
||||
private boolean forceOverwrite;
|
||||
|
||||
public BigInteger getSessionId() {
|
||||
return sessionId;
|
||||
@@ -63,6 +66,22 @@ public class ChatSessionSummaryCommand implements Serializable {
|
||||
this.lastMessageAt = lastMessageAt;
|
||||
}
|
||||
|
||||
public Date getAccessAt() {
|
||||
return accessAt;
|
||||
}
|
||||
|
||||
public void setAccessAt(Date accessAt) {
|
||||
this.accessAt = accessAt;
|
||||
}
|
||||
|
||||
public Date getModifiedAt() {
|
||||
return modifiedAt;
|
||||
}
|
||||
|
||||
public void setModifiedAt(Date modifiedAt) {
|
||||
this.modifiedAt = modifiedAt;
|
||||
}
|
||||
|
||||
public BigInteger getOperatorId() {
|
||||
return operatorId;
|
||||
}
|
||||
@@ -78,4 +97,12 @@ public class ChatSessionSummaryCommand implements Serializable {
|
||||
public void setMessageIncrement(int messageIncrement) {
|
||||
this.messageIncrement = Math.max(messageIncrement, 0);
|
||||
}
|
||||
|
||||
public boolean isForceOverwrite() {
|
||||
return forceOverwrite;
|
||||
}
|
||||
|
||||
public void setForceOverwrite(boolean forceOverwrite) {
|
||||
this.forceOverwrite = forceOverwrite;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public class ChatSessionUpsertCommand implements Serializable {
|
||||
private String assistantCode;
|
||||
private String assistantName;
|
||||
private String title;
|
||||
private String extJson;
|
||||
private BigInteger operatorId;
|
||||
private Date operateAt = new Date();
|
||||
|
||||
@@ -90,6 +91,14 @@ public class ChatSessionUpsertCommand implements Serializable {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getExtJson() {
|
||||
return extJson;
|
||||
}
|
||||
|
||||
public void setExtJson(String extJson) {
|
||||
this.extJson = extJson;
|
||||
}
|
||||
|
||||
public BigInteger getOperatorId() {
|
||||
return operatorId;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ public class ChatMessageRecord implements Serializable {
|
||||
private String contentType;
|
||||
private String contentText;
|
||||
private Map<String, Object> contentPayload;
|
||||
private BigInteger roundId;
|
||||
private Integer roundNo;
|
||||
private String messageKind;
|
||||
private Integer variantIndex;
|
||||
private Integer variantCount;
|
||||
private Integer selectedVariantIndex;
|
||||
private Boolean switchable;
|
||||
private Date created;
|
||||
private BigInteger createdBy;
|
||||
private Long syncVersion;
|
||||
@@ -101,6 +108,62 @@ public class ChatMessageRecord implements Serializable {
|
||||
this.contentPayload = contentPayload;
|
||||
}
|
||||
|
||||
public BigInteger getRoundId() {
|
||||
return roundId;
|
||||
}
|
||||
|
||||
public void setRoundId(BigInteger roundId) {
|
||||
this.roundId = roundId;
|
||||
}
|
||||
|
||||
public Integer getRoundNo() {
|
||||
return roundNo;
|
||||
}
|
||||
|
||||
public void setRoundNo(Integer roundNo) {
|
||||
this.roundNo = roundNo;
|
||||
}
|
||||
|
||||
public String getMessageKind() {
|
||||
return messageKind;
|
||||
}
|
||||
|
||||
public void setMessageKind(String messageKind) {
|
||||
this.messageKind = messageKind;
|
||||
}
|
||||
|
||||
public Integer getVariantIndex() {
|
||||
return variantIndex;
|
||||
}
|
||||
|
||||
public void setVariantIndex(Integer variantIndex) {
|
||||
this.variantIndex = variantIndex;
|
||||
}
|
||||
|
||||
public Integer getVariantCount() {
|
||||
return variantCount;
|
||||
}
|
||||
|
||||
public void setVariantCount(Integer variantCount) {
|
||||
this.variantCount = variantCount;
|
||||
}
|
||||
|
||||
public Integer getSelectedVariantIndex() {
|
||||
return selectedVariantIndex;
|
||||
}
|
||||
|
||||
public void setSelectedVariantIndex(Integer selectedVariantIndex) {
|
||||
this.selectedVariantIndex = selectedVariantIndex;
|
||||
}
|
||||
|
||||
public Boolean getSwitchable() {
|
||||
return switchable;
|
||||
}
|
||||
|
||||
public void setSwitchable(Boolean switchable) {
|
||||
this.switchable = switchable;
|
||||
}
|
||||
|
||||
public Date getCreated() {
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package tech.easyflow.chatlog.domain.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 聊天轮次记录。
|
||||
*/
|
||||
public class ChatRoundRecord implements Serializable {
|
||||
|
||||
private BigInteger id;
|
||||
private BigInteger sessionId;
|
||||
private Integer roundNo;
|
||||
private BigInteger userMessageId;
|
||||
private BigInteger selectedAssistantMessageId;
|
||||
private Integer selectedVariantIndex;
|
||||
private Integer variantCount;
|
||||
private String status;
|
||||
private Date created;
|
||||
private Date modified;
|
||||
|
||||
public BigInteger getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(BigInteger id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public BigInteger getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(BigInteger sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public Integer getRoundNo() {
|
||||
return roundNo;
|
||||
}
|
||||
|
||||
public void setRoundNo(Integer roundNo) {
|
||||
this.roundNo = roundNo;
|
||||
}
|
||||
|
||||
public BigInteger getUserMessageId() {
|
||||
return userMessageId;
|
||||
}
|
||||
|
||||
public void setUserMessageId(BigInteger userMessageId) {
|
||||
this.userMessageId = userMessageId;
|
||||
}
|
||||
|
||||
public BigInteger getSelectedAssistantMessageId() {
|
||||
return selectedAssistantMessageId;
|
||||
}
|
||||
|
||||
public void setSelectedAssistantMessageId(BigInteger selectedAssistantMessageId) {
|
||||
this.selectedAssistantMessageId = selectedAssistantMessageId;
|
||||
}
|
||||
|
||||
public Integer getSelectedVariantIndex() {
|
||||
return selectedVariantIndex;
|
||||
}
|
||||
|
||||
public void setSelectedVariantIndex(Integer selectedVariantIndex) {
|
||||
this.selectedVariantIndex = selectedVariantIndex;
|
||||
}
|
||||
|
||||
public Integer getVariantCount() {
|
||||
return variantCount;
|
||||
}
|
||||
|
||||
public void setVariantCount(Integer variantCount) {
|
||||
this.variantCount = variantCount;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Date getCreated() {
|
||||
return created;
|
||||
}
|
||||
|
||||
public void setCreated(Date created) {
|
||||
this.created = created;
|
||||
}
|
||||
|
||||
public Date getModified() {
|
||||
return modified;
|
||||
}
|
||||
|
||||
public void setModified(Date modified) {
|
||||
this.modified = modified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package tech.easyflow.chatlog.domain.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
|
||||
/**
|
||||
* 轮次答案版本视图。
|
||||
*/
|
||||
public class ChatRoundVariantView implements Serializable {
|
||||
|
||||
private BigInteger roundId;
|
||||
private Integer roundNo;
|
||||
private Integer variantCount;
|
||||
private Integer selectedVariantIndex;
|
||||
private Boolean switchable;
|
||||
private ChatMessageRecord selectedMessage;
|
||||
|
||||
public BigInteger getRoundId() {
|
||||
return roundId;
|
||||
}
|
||||
|
||||
public void setRoundId(BigInteger roundId) {
|
||||
this.roundId = roundId;
|
||||
}
|
||||
|
||||
public Integer getRoundNo() {
|
||||
return roundNo;
|
||||
}
|
||||
|
||||
public void setRoundNo(Integer roundNo) {
|
||||
this.roundNo = roundNo;
|
||||
}
|
||||
|
||||
public Integer getVariantCount() {
|
||||
return variantCount;
|
||||
}
|
||||
|
||||
public void setVariantCount(Integer variantCount) {
|
||||
this.variantCount = variantCount;
|
||||
}
|
||||
|
||||
public Integer getSelectedVariantIndex() {
|
||||
return selectedVariantIndex;
|
||||
}
|
||||
|
||||
public void setSelectedVariantIndex(Integer selectedVariantIndex) {
|
||||
this.selectedVariantIndex = selectedVariantIndex;
|
||||
}
|
||||
|
||||
public Boolean getSwitchable() {
|
||||
return switchable;
|
||||
}
|
||||
|
||||
public void setSwitchable(Boolean switchable) {
|
||||
this.switchable = switchable;
|
||||
}
|
||||
|
||||
public ChatMessageRecord getSelectedMessage() {
|
||||
return selectedMessage;
|
||||
}
|
||||
|
||||
public void setSelectedMessage(ChatMessageRecord selectedMessage) {
|
||||
this.selectedMessage = selectedMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package tech.easyflow.chatlog.domain.dto;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 会话扩展载荷。
|
||||
*/
|
||||
public class ChatSessionExtPayload implements Serializable {
|
||||
|
||||
private List<BigInteger> extraKnowledgeIds = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 获取会话级额外知识库 ID 列表。
|
||||
*
|
||||
* @return 额外知识库 ID 列表
|
||||
*/
|
||||
public List<BigInteger> getExtraKnowledgeIds() {
|
||||
return extraKnowledgeIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置会话级额外知识库 ID 列表。
|
||||
*
|
||||
* @param extraKnowledgeIds 额外知识库 ID 列表
|
||||
*/
|
||||
public void setExtraKnowledgeIds(List<BigInteger> extraKnowledgeIds) {
|
||||
this.extraKnowledgeIds = extraKnowledgeIds == null ? new ArrayList<>() : new ArrayList<>(extraKnowledgeIds);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ public class ChatSessionSummary implements Serializable {
|
||||
private String assistantCode;
|
||||
private String assistantName;
|
||||
private String title;
|
||||
private String extJson;
|
||||
private String lastMessagePreview;
|
||||
private BigInteger lastSenderId;
|
||||
private String lastSenderName;
|
||||
@@ -99,6 +100,14 @@ public class ChatSessionSummary implements Serializable {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getExtJson() {
|
||||
return extJson;
|
||||
}
|
||||
|
||||
public void setExtJson(String extJson) {
|
||||
this.extJson = extJson;
|
||||
}
|
||||
|
||||
public String getLastMessagePreview() {
|
||||
return lastMessagePreview;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package tech.easyflow.chatlog.domain.event;
|
||||
public enum ChatPersistEventType {
|
||||
|
||||
SESSION_PREPARED,
|
||||
ROUND_UPSERTED,
|
||||
ROUND_VARIANT_SELECTED,
|
||||
USER_MESSAGE_APPENDED,
|
||||
ASSISTANT_MESSAGE_APPENDED,
|
||||
SESSION_RENAMED,
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.springframework.stereotype.Repository;
|
||||
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
import tech.easyflow.chatlog.support.ChatTableRouter;
|
||||
|
||||
import java.math.BigInteger;
|
||||
@@ -49,8 +50,8 @@ public class MySqlChatLogRepository {
|
||||
for (Map.Entry<YearMonth, List<ChatAppendMessageCommand>> entry : grouped.entrySet()) {
|
||||
String table = tableRouter.resolveLogTable(entry.getKey());
|
||||
String sql = "INSERT IGNORE INTO `" + table + "` " +
|
||||
"(id, tenant_id, dept_id, session_id, user_id, assistant_id, sender_id, sender_name, sender_role, content_type, content_text, content_payload, created, created_by, sync_version) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
"(id, tenant_id, dept_id, session_id, user_id, assistant_id, round_id, round_no, sender_id, sender_name, sender_role, message_kind, variant_index, content_type, content_text, content_payload, created, created_by, sync_version) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
int[] results = jdbcTemplate.batchUpdate(sql, new org.springframework.jdbc.core.BatchPreparedStatementSetter() {
|
||||
@Override
|
||||
public void setValues(java.sql.PreparedStatement ps, int i) throws java.sql.SQLException {
|
||||
@@ -62,15 +63,19 @@ public class MySqlChatLogRepository {
|
||||
ps.setObject(4, command.getSessionId());
|
||||
ps.setObject(5, command.getUserId());
|
||||
ps.setObject(6, command.getAssistantId());
|
||||
ps.setObject(7, command.getSenderId());
|
||||
ps.setString(8, command.getSenderName());
|
||||
ps.setString(9, command.getSenderRole());
|
||||
ps.setString(10, command.getContentType());
|
||||
ps.setString(11, command.getContentText());
|
||||
ps.setString(12, jsonSupport.toJson(command.getContentPayload()));
|
||||
ps.setTimestamp(13, created);
|
||||
ps.setObject(14, command.getCreatedBy());
|
||||
ps.setLong(15, command.getCreated().getTime());
|
||||
ps.setObject(7, command.getRoundId());
|
||||
ps.setObject(8, command.getRoundNo());
|
||||
ps.setObject(9, command.getSenderId());
|
||||
ps.setString(10, command.getSenderName());
|
||||
ps.setString(11, command.getSenderRole());
|
||||
ps.setString(12, command.getMessageKind());
|
||||
ps.setObject(13, command.getVariantIndex());
|
||||
ps.setString(14, command.getContentType());
|
||||
ps.setString(15, command.getContentText());
|
||||
ps.setString(16, jsonSupport.toJson(command.getContentPayload()));
|
||||
ps.setTimestamp(17, created);
|
||||
ps.setObject(18, command.getCreatedBy());
|
||||
ps.setLong(19, command.getCreated().getTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,7 +97,14 @@ public class MySqlChatLogRepository {
|
||||
for (YearMonth month : months) {
|
||||
String table = tableRouter.resolveLogTable(month);
|
||||
List<ChatMessageRecord> current = jdbcTemplate.query(
|
||||
"SELECT * FROM `" + table + "` WHERE session_id=? ORDER BY created DESC, id DESC LIMIT ?",
|
||||
"SELECT l.*, r.round_no AS joined_round_no, r.variant_count, r.selected_variant_index, " +
|
||||
"CASE WHEN r.status IS NOT NULL AND r.status <> 'LOCKED' " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM `" + ChatConstants.ROUND_TABLE + "` newer WHERE newer.session_id = r.session_id AND newer.round_no > r.round_no) " +
|
||||
"THEN 1 ELSE 0 END AS switchable " +
|
||||
"FROM `" + table + "` l " +
|
||||
"LEFT JOIN `" + ChatConstants.ROUND_TABLE + "` r ON l.round_id = r.id " +
|
||||
"WHERE l.session_id=? AND (l.round_id IS NULL OR r.id IS NULL OR l.id = r.user_message_id OR l.id = r.selected_assistant_message_id) " +
|
||||
"ORDER BY l.created DESC, l.id DESC LIMIT ?",
|
||||
(rs, rowNum) -> mapRow(rs),
|
||||
sessionId,
|
||||
limit
|
||||
@@ -114,6 +126,159 @@ public class MySqlChatLogRepository {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MySQL 热表分页查询主线可见消息。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param months 查询月份
|
||||
* @param offset 分页偏移
|
||||
* @param limit 分页条数
|
||||
* @return 主线消息列表,按 created desc、id desc 排序
|
||||
*/
|
||||
public List<ChatMessageRecord> listMainlineMessages(BigInteger sessionId, List<YearMonth> months, long offset, int limit) {
|
||||
if (sessionId == null || months == null || months.isEmpty() || limit <= 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
int candidateLimit = resolveCandidateLimit(offset, limit);
|
||||
Map<BigInteger, ChatMessageRecord> recordMap = new LinkedHashMap<>();
|
||||
for (YearMonth month : months) {
|
||||
String table = tableRouter.resolveLogTable(month);
|
||||
List<ChatMessageRecord> current = jdbcTemplate.query(
|
||||
"SELECT l.*, r.round_no AS joined_round_no, r.variant_count, r.selected_variant_index, " +
|
||||
"CASE WHEN r.status IS NOT NULL AND r.status <> 'LOCKED' " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM `" + ChatConstants.ROUND_TABLE + "` newer WHERE newer.session_id = r.session_id AND newer.round_no > r.round_no) " +
|
||||
"THEN 1 ELSE 0 END AS switchable " +
|
||||
"FROM `" + table + "` l " +
|
||||
"LEFT JOIN `" + ChatConstants.ROUND_TABLE + "` r ON l.round_id = r.id " +
|
||||
"WHERE l.session_id=? AND (l.round_id IS NULL OR r.id IS NULL OR l.id = r.user_message_id OR l.id = r.selected_assistant_message_id) " +
|
||||
"ORDER BY l.created DESC, l.id DESC LIMIT ?",
|
||||
(rs, rowNum) -> mapRow(rs),
|
||||
sessionId,
|
||||
candidateLimit
|
||||
);
|
||||
for (ChatMessageRecord record : current) {
|
||||
if (record != null && record.getId() != null) {
|
||||
recordMap.putIfAbsent(record.getId(), record);
|
||||
}
|
||||
}
|
||||
}
|
||||
return recordMap.values().stream()
|
||||
.sorted((a, b) -> {
|
||||
int compare = b.getCreated().compareTo(a.getCreated());
|
||||
if (compare != 0) {
|
||||
return compare;
|
||||
}
|
||||
return b.getId().compareTo(a.getId());
|
||||
})
|
||||
.skip(Math.max(offset, 0))
|
||||
.limit(limit)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 MySQL 热表查询全部主线可见消息。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param months 查询月份
|
||||
* @return 主线消息列表,按 created asc、id asc 排序
|
||||
*/
|
||||
public List<ChatMessageRecord> listMainlineMessages(BigInteger sessionId, List<YearMonth> months) {
|
||||
if (sessionId == null || months == null || months.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Map<BigInteger, ChatMessageRecord> recordMap = new LinkedHashMap<>();
|
||||
for (YearMonth month : months) {
|
||||
String table = tableRouter.resolveLogTable(month);
|
||||
List<ChatMessageRecord> current = jdbcTemplate.query(
|
||||
"SELECT l.*, r.round_no AS joined_round_no, r.variant_count, r.selected_variant_index, " +
|
||||
"CASE WHEN r.status IS NOT NULL AND r.status <> 'LOCKED' " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM `" + ChatConstants.ROUND_TABLE + "` newer WHERE newer.session_id = r.session_id AND newer.round_no > r.round_no) " +
|
||||
"THEN 1 ELSE 0 END AS switchable " +
|
||||
"FROM `" + table + "` l " +
|
||||
"LEFT JOIN `" + ChatConstants.ROUND_TABLE + "` r ON l.round_id = r.id " +
|
||||
"WHERE l.session_id=? AND (l.round_id IS NULL OR r.id IS NULL OR l.id = r.user_message_id OR l.id = r.selected_assistant_message_id) " +
|
||||
"ORDER BY l.created ASC, l.id ASC",
|
||||
(rs, rowNum) -> mapRow(rs),
|
||||
sessionId
|
||||
);
|
||||
for (ChatMessageRecord record : current) {
|
||||
if (record != null && record.getId() != null) {
|
||||
recordMap.putIfAbsent(record.getId(), record);
|
||||
}
|
||||
}
|
||||
}
|
||||
return recordMap.values().stream()
|
||||
.sorted((a, b) -> {
|
||||
int compare = a.getCreated().compareTo(b.getCreated());
|
||||
if (compare != 0) {
|
||||
return compare;
|
||||
}
|
||||
return a.getId().compareTo(b.getId());
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<ChatMessageRecord> listRoundVariants(BigInteger sessionId, BigInteger roundId, List<YearMonth> months) {
|
||||
List<ChatMessageRecord> records = new ArrayList<>();
|
||||
for (YearMonth month : months) {
|
||||
String table = tableRouter.resolveLogTable(month);
|
||||
records.addAll(jdbcTemplate.query(
|
||||
"SELECT l.*, r.round_no AS joined_round_no, r.variant_count, r.selected_variant_index, " +
|
||||
"0 AS switchable " +
|
||||
"FROM `" + table + "` l " +
|
||||
"INNER JOIN `" + ChatConstants.ROUND_TABLE + "` r ON l.round_id = r.id " +
|
||||
"WHERE l.session_id=? AND l.round_id=? AND l.message_kind=? " +
|
||||
"ORDER BY l.variant_index ASC, l.created ASC, l.id ASC",
|
||||
(rs, rowNum) -> mapRow(rs),
|
||||
sessionId,
|
||||
roundId,
|
||||
ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT
|
||||
));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 精准查询轮次下指定答案版本。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @param variantIndex 答案版本序号
|
||||
* @param months 查询月份
|
||||
* @return 目标答案版本
|
||||
*/
|
||||
public ChatMessageRecord findRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, List<YearMonth> months) {
|
||||
if (sessionId == null || roundId == null || variantIndex == null || variantIndex <= 0 || months == null || months.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (YearMonth month : months) {
|
||||
String table = tableRouter.resolveLogTable(month);
|
||||
List<ChatMessageRecord> records = jdbcTemplate.query(
|
||||
"SELECT l.*, r.round_no AS joined_round_no, r.variant_count, r.selected_variant_index, 0 AS switchable " +
|
||||
"FROM `" + table + "` l " +
|
||||
"INNER JOIN `" + ChatConstants.ROUND_TABLE + "` r ON l.round_id = r.id " +
|
||||
"WHERE l.session_id=? AND l.round_id=? AND l.message_kind=? AND l.variant_index=? " +
|
||||
"ORDER BY l.created DESC, l.id DESC LIMIT 1",
|
||||
(rs, rowNum) -> mapRow(rs),
|
||||
sessionId,
|
||||
roundId,
|
||||
ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT,
|
||||
variantIndex
|
||||
);
|
||||
if (!records.isEmpty()) {
|
||||
return records.get(0);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private int resolveCandidateLimit(long offset, int limit) {
|
||||
long normalizedOffset = Math.max(offset, 0);
|
||||
long normalizedLimit = Math.max(limit, 1);
|
||||
long candidateLimit = normalizedOffset + normalizedLimit;
|
||||
return (int) Math.min(candidateLimit, Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
public List<ChatMessageRecord> loadIncremental(String table, Date cursorTime, BigInteger cursorId, int limit) {
|
||||
Timestamp timestamp = cursorTime == null ? new Timestamp(0L) : new Timestamp(cursorTime.getTime());
|
||||
return jdbcTemplate.query(
|
||||
@@ -149,9 +314,20 @@ public class MySqlChatLogRepository {
|
||||
record.setSessionId(bigInteger(rs, "session_id"));
|
||||
record.setUserId(bigInteger(rs, "user_id"));
|
||||
record.setAssistantId(bigInteger(rs, "assistant_id"));
|
||||
record.setRoundId(bigInteger(rs, "round_id"));
|
||||
record.setRoundNo(optionalInteger(rs, "round_no"));
|
||||
Integer joinedRoundNo = optionalInteger(rs, "joined_round_no");
|
||||
if (joinedRoundNo != null) {
|
||||
record.setRoundNo(joinedRoundNo);
|
||||
}
|
||||
record.setSenderId(bigInteger(rs, "sender_id"));
|
||||
record.setSenderName(rs.getString("sender_name"));
|
||||
record.setSenderRole(rs.getString("sender_role"));
|
||||
record.setMessageKind(optionalString(rs, "message_kind"));
|
||||
record.setVariantIndex(optionalInteger(rs, "variant_index"));
|
||||
record.setVariantCount(optionalInteger(rs, "variant_count"));
|
||||
record.setSelectedVariantIndex(optionalInteger(rs, "selected_variant_index"));
|
||||
record.setSwitchable(optionalBoolean(rs, "switchable"));
|
||||
record.setContentType(rs.getString("content_type"));
|
||||
record.setContentText(rs.getString("content_text"));
|
||||
record.setContentPayload(jsonSupport.toMap(rs.getString("content_payload")));
|
||||
@@ -168,4 +344,39 @@ public class MySqlChatLogRepository {
|
||||
}
|
||||
return new BigInteger(String.valueOf(value));
|
||||
}
|
||||
|
||||
private Integer optionalInteger(ResultSet rs, String column) {
|
||||
try {
|
||||
Object value = rs.getObject(column);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
} catch (SQLException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Boolean optionalBoolean(ResultSet rs, String column) {
|
||||
try {
|
||||
Object value = rs.getObject(column);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Boolean booleanValue) {
|
||||
return booleanValue;
|
||||
}
|
||||
return Integer.parseInt(String.valueOf(value)) != 0;
|
||||
} catch (SQLException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String optionalString(ResultSet rs, String column) {
|
||||
try {
|
||||
return rs.getString(column);
|
||||
} catch (SQLException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package tech.easyflow.chatlog.repository.mysql;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MySQL 轮次仓储。
|
||||
*/
|
||||
@Repository
|
||||
public class MySqlChatRoundRepository {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public MySqlChatRoundRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量写入或更新轮次聚合。
|
||||
*
|
||||
* @param commands 轮次命令
|
||||
*/
|
||||
public void createOrTouchBatch(List<ChatRoundUpsertCommand> commands) {
|
||||
if (commands == null || commands.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String sql = "INSERT INTO `" + ChatConstants.ROUND_TABLE + "` " +
|
||||
"(id, session_id, round_no, user_message_id, selected_assistant_message_id, selected_variant_index, variant_count, status, created, modified) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE user_message_id=COALESCE(VALUES(user_message_id), user_message_id), " +
|
||||
"selected_assistant_message_id=CASE " +
|
||||
"WHEN VALUES(selected_assistant_message_id) IS NOT NULL AND (selected_assistant_message_id IS NULL OR VALUES(modified) > modified) " +
|
||||
"THEN VALUES(selected_assistant_message_id) ELSE selected_assistant_message_id END, " +
|
||||
"selected_variant_index=CASE " +
|
||||
"WHEN VALUES(selected_variant_index) IS NOT NULL AND (selected_variant_index IS NULL OR selected_variant_index = 0 OR VALUES(modified) > modified) " +
|
||||
"THEN VALUES(selected_variant_index) ELSE selected_variant_index END, " +
|
||||
"variant_count=GREATEST(COALESCE(VALUES(variant_count), 0), COALESCE(variant_count, 0)), " +
|
||||
"status=CASE " +
|
||||
"WHEN VALUES(status) IS NULL OR VALUES(status) = '' THEN status " +
|
||||
"WHEN VALUES(modified) > modified THEN VALUES(status) " +
|
||||
"WHEN VALUES(modified) = modified AND status = '" + ChatConstants.ROUND_STATUS_ANSWERING + "' " +
|
||||
"AND VALUES(status) <> '" + ChatConstants.ROUND_STATUS_ANSWERING + "' THEN VALUES(status) " +
|
||||
"WHEN VALUES(modified) = modified AND status <> '" + ChatConstants.ROUND_STATUS_LOCKED + "' " +
|
||||
"AND VALUES(status) = '" + ChatConstants.ROUND_STATUS_LOCKED + "' THEN VALUES(status) " +
|
||||
"ELSE status END, " +
|
||||
"modified=GREATEST(VALUES(modified), modified)";
|
||||
jdbcTemplate.batchUpdate(sql, commands, commands.size(), (ps, command) -> {
|
||||
Timestamp operateAt = timestamp(command.getOperateAt());
|
||||
ps.setObject(1, command.getRoundId());
|
||||
ps.setObject(2, command.getSessionId());
|
||||
ps.setObject(3, command.getRoundNo());
|
||||
ps.setObject(4, command.getUserMessageId());
|
||||
ps.setObject(5, command.getSelectedAssistantMessageId());
|
||||
ps.setObject(6, command.getSelectedVariantIndex());
|
||||
ps.setObject(7, command.getVariantCount());
|
||||
ps.setString(8, command.getStatus());
|
||||
ps.setTimestamp(9, operateAt);
|
||||
ps.setTimestamp(10, operateAt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用答案版本切换。
|
||||
*
|
||||
* @param commands 切换命令
|
||||
*/
|
||||
public void selectVariants(List<ChatRoundSelectCommand> commands) {
|
||||
if (commands == null || commands.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String sql = "UPDATE `" + ChatConstants.ROUND_TABLE + "` SET selected_assistant_message_id=?, selected_variant_index=?, modified=? " +
|
||||
"WHERE id=? AND session_id=? AND modified <= ?";
|
||||
jdbcTemplate.batchUpdate(sql, commands, commands.size(), (ps, command) -> {
|
||||
Timestamp operateAt = timestamp(command.getOperateAt());
|
||||
ps.setObject(1, command.getSelectedAssistantMessageId());
|
||||
ps.setObject(2, command.getSelectedVariantIndex());
|
||||
ps.setTimestamp(3, operateAt);
|
||||
ps.setObject(4, command.getRoundId());
|
||||
ps.setObject(5, command.getSessionId());
|
||||
ps.setTimestamp(6, operateAt);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定轮次。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @return 轮次记录
|
||||
*/
|
||||
public ChatRoundRecord findRound(BigInteger sessionId, BigInteger roundId) {
|
||||
List<ChatRoundRecord> records = jdbcTemplate.query(
|
||||
"SELECT * FROM `" + ChatConstants.ROUND_TABLE + "` WHERE session_id=? AND id=? LIMIT 1",
|
||||
rowMapper(),
|
||||
sessionId,
|
||||
roundId
|
||||
);
|
||||
return records.isEmpty() ? null : records.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会话最新轮次。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 最新轮次
|
||||
*/
|
||||
public ChatRoundRecord findLatestRound(BigInteger sessionId) {
|
||||
List<ChatRoundRecord> records = jdbcTemplate.query(
|
||||
"SELECT * FROM `" + ChatConstants.ROUND_TABLE + "` WHERE session_id=? ORDER BY round_no DESC, id DESC LIMIT 1",
|
||||
rowMapper(),
|
||||
sessionId
|
||||
);
|
||||
return records.isEmpty() ? null : records.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断会话是否已使用轮次模型。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 是否存在轮次
|
||||
*/
|
||||
public boolean existsRounds(BigInteger sessionId) {
|
||||
Long count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(1) FROM `" + ChatConstants.ROUND_TABLE + "` WHERE session_id=?",
|
||||
Long.class,
|
||||
sessionId
|
||||
);
|
||||
return count != null && count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取指定时间之后变更的轮次。
|
||||
*
|
||||
* @param cursorTime 游标时间
|
||||
* @param cursorId 游标 ID
|
||||
* @param limit 批大小
|
||||
* @return 轮次记录列表
|
||||
*/
|
||||
public List<ChatRoundRecord> loadModifiedAfter(Date cursorTime, BigInteger cursorId, int limit) {
|
||||
Timestamp timestamp = cursorTime == null ? new Timestamp(0L) : new Timestamp(cursorTime.getTime());
|
||||
return jdbcTemplate.query(
|
||||
"SELECT * FROM `" + ChatConstants.ROUND_TABLE + "` WHERE (modified > ?) OR (modified = ? AND id > ?) " +
|
||||
"ORDER BY modified ASC, id ASC LIMIT ?",
|
||||
rowMapper(),
|
||||
timestamp,
|
||||
timestamp,
|
||||
cursorId == null ? BigInteger.ZERO : cursorId,
|
||||
limit
|
||||
);
|
||||
}
|
||||
|
||||
private RowMapper<ChatRoundRecord> rowMapper() {
|
||||
return new RowMapper<>() {
|
||||
@Override
|
||||
public ChatRoundRecord mapRow(ResultSet rs, int rowNum) throws SQLException {
|
||||
ChatRoundRecord record = new ChatRoundRecord();
|
||||
record.setId(bigInteger(rs, "id"));
|
||||
record.setSessionId(bigInteger(rs, "session_id"));
|
||||
record.setRoundNo(rs.getInt("round_no"));
|
||||
record.setUserMessageId(bigInteger(rs, "user_message_id"));
|
||||
record.setSelectedAssistantMessageId(bigInteger(rs, "selected_assistant_message_id"));
|
||||
record.setSelectedVariantIndex(rs.getInt("selected_variant_index"));
|
||||
record.setVariantCount(rs.getInt("variant_count"));
|
||||
record.setStatus(rs.getString("status"));
|
||||
record.setCreated(rs.getTimestamp("created"));
|
||||
record.setModified(rs.getTimestamp("modified"));
|
||||
return record;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private BigInteger bigInteger(ResultSet rs, String column) throws SQLException {
|
||||
Object value = rs.getObject(column);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return new BigInteger(String.valueOf(value));
|
||||
}
|
||||
|
||||
private Timestamp timestamp(Date value) {
|
||||
return new Timestamp((value == null ? new Date() : value).getTime());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import java.math.BigInteger;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.sql.Types;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -39,11 +40,12 @@ public class MySqlChatSessionRepository {
|
||||
}
|
||||
String table = tableRouter.resolveSessionTable();
|
||||
String sql = "INSERT INTO `" + table + "` " +
|
||||
"(id, tenant_id, dept_id, user_id, user_account, assistant_id, assistant_code, assistant_name, title, last_message_preview, message_count, access_at, created, created_by, modified, modified_by, is_deleted) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '', 0, ?, ?, ?, ?, ?, 0) " +
|
||||
"(id, tenant_id, dept_id, user_id, user_account, assistant_id, assistant_code, assistant_name, title, ext_json, last_message_preview, message_count, access_at, created, created_by, modified, modified_by, is_deleted) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', 0, ?, ?, ?, ?, ?, 0) " +
|
||||
"ON DUPLICATE KEY UPDATE user_account=COALESCE(NULLIF(VALUES(user_account), ''), user_account), " +
|
||||
"assistant_id=VALUES(assistant_id), assistant_code=COALESCE(NULLIF(VALUES(assistant_code), ''), assistant_code), " +
|
||||
"assistant_name=COALESCE(NULLIF(VALUES(assistant_name), ''), assistant_name), " +
|
||||
"ext_json=COALESCE(VALUES(ext_json), ext_json), " +
|
||||
"title=COALESCE(NULLIF(VALUES(title), ''), title), " +
|
||||
"access_at=VALUES(access_at), modified=VALUES(modified), modified_by=VALUES(modified_by), is_deleted=0";
|
||||
jdbcTemplate.batchUpdate(sql, commands, commands.size(), (ps, command) -> {
|
||||
@@ -57,11 +59,12 @@ public class MySqlChatSessionRepository {
|
||||
ps.setString(7, safeString(command.getAssistantCode()));
|
||||
ps.setString(8, safeString(command.getAssistantName()));
|
||||
ps.setString(9, safeString(command.getTitle()));
|
||||
ps.setTimestamp(10, operateAt);
|
||||
setNullableJson(ps, 10, command.getExtJson());
|
||||
ps.setTimestamp(11, operateAt);
|
||||
ps.setObject(12, command.getOperatorId());
|
||||
ps.setTimestamp(13, operateAt);
|
||||
ps.setObject(14, command.getOperatorId());
|
||||
ps.setTimestamp(12, operateAt);
|
||||
ps.setObject(13, command.getOperatorId());
|
||||
ps.setTimestamp(14, operateAt);
|
||||
ps.setObject(15, command.getOperatorId());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,33 +74,38 @@ public class MySqlChatSessionRepository {
|
||||
}
|
||||
String table = tableRouter.resolveSessionTable();
|
||||
String sql = "UPDATE `" + table + "` SET " +
|
||||
"last_sender_id=CASE WHEN last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE last_sender_id END, " +
|
||||
"last_sender_name=CASE WHEN last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE last_sender_name END, " +
|
||||
"last_message_preview=CASE WHEN last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE last_message_preview END, " +
|
||||
"last_message_at=CASE WHEN last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE last_message_at END, " +
|
||||
"access_at=CASE WHEN last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE access_at END, " +
|
||||
"last_sender_id=CASE WHEN ?=1 OR last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE last_sender_id END, " +
|
||||
"last_sender_name=CASE WHEN ?=1 OR last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE last_sender_name END, " +
|
||||
"last_message_preview=CASE WHEN ?=1 OR last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE last_message_preview END, " +
|
||||
"last_message_at=CASE WHEN ?=1 OR last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE last_message_at END, " +
|
||||
"access_at=CASE WHEN ?=1 OR last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE access_at END, " +
|
||||
"message_count=COALESCE(message_count, 0) + ?, " +
|
||||
"modified=CASE WHEN last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE modified END, " +
|
||||
"modified_by=CASE WHEN last_message_at IS NULL OR last_message_at <= ? THEN ? ELSE modified_by END " +
|
||||
"modified=?, modified_by=? " +
|
||||
"WHERE id=?";
|
||||
jdbcTemplate.batchUpdate(sql, commands, commands.size(), (ps, command) -> {
|
||||
Timestamp lastMessageAt = timestamp(command.getLastMessageAt());
|
||||
ps.setTimestamp(1, lastMessageAt);
|
||||
ps.setObject(2, command.getLastSenderId());
|
||||
ps.setTimestamp(3, lastMessageAt);
|
||||
ps.setString(4, command.getLastSenderName());
|
||||
Timestamp accessAt = timestamp(command.getAccessAt());
|
||||
Timestamp modifiedAt = timestamp(command.getModifiedAt());
|
||||
int forceOverwrite = command.isForceOverwrite() ? 1 : 0;
|
||||
ps.setInt(1, forceOverwrite);
|
||||
ps.setTimestamp(2, lastMessageAt);
|
||||
ps.setObject(3, command.getLastSenderId());
|
||||
ps.setInt(4, forceOverwrite);
|
||||
ps.setTimestamp(5, lastMessageAt);
|
||||
ps.setString(6, command.getLastMessagePreview());
|
||||
ps.setTimestamp(7, lastMessageAt);
|
||||
ps.setString(6, command.getLastSenderName());
|
||||
ps.setInt(7, forceOverwrite);
|
||||
ps.setTimestamp(8, lastMessageAt);
|
||||
ps.setTimestamp(9, lastMessageAt);
|
||||
ps.setTimestamp(10, lastMessageAt);
|
||||
ps.setInt(11, Math.max(command.getMessageIncrement(), 1));
|
||||
ps.setString(9, safeString(command.getLastMessagePreview()));
|
||||
ps.setInt(10, forceOverwrite);
|
||||
ps.setTimestamp(11, lastMessageAt);
|
||||
ps.setTimestamp(12, lastMessageAt);
|
||||
ps.setTimestamp(13, lastMessageAt);
|
||||
ps.setInt(13, forceOverwrite);
|
||||
ps.setTimestamp(14, lastMessageAt);
|
||||
ps.setObject(15, command.getOperatorId());
|
||||
ps.setObject(16, command.getSessionId());
|
||||
ps.setTimestamp(15, accessAt);
|
||||
ps.setInt(16, Math.max(command.getMessageIncrement(), 0));
|
||||
ps.setTimestamp(17, modifiedAt);
|
||||
ps.setObject(18, command.getOperatorId());
|
||||
ps.setObject(19, command.getSessionId());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,13 +113,13 @@ public class MySqlChatSessionRepository {
|
||||
String table = tableRouter.resolveSessionTable();
|
||||
List<Object> params = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder("SELECT * FROM `").append(table)
|
||||
.append("` WHERE user_id=? AND is_deleted=0");
|
||||
.append("` WHERE user_id=? AND is_deleted=0 AND last_message_at IS NOT NULL");
|
||||
params.add(userId);
|
||||
if (assistantId != null) {
|
||||
sql.append(" AND assistant_id=?");
|
||||
params.add(assistantId);
|
||||
}
|
||||
sql.append(" ORDER BY access_at DESC, id DESC LIMIT ? OFFSET ?");
|
||||
sql.append(" ORDER BY last_message_at DESC, id DESC LIMIT ? OFFSET ?");
|
||||
params.add(query.getPageSize());
|
||||
params.add(query.getOffset());
|
||||
return jdbcTemplate.query(sql.toString(), sessionRowMapper(), params.toArray());
|
||||
@@ -121,7 +129,7 @@ public class MySqlChatSessionRepository {
|
||||
String table = tableRouter.resolveSessionTable();
|
||||
List<Object> params = new ArrayList<>();
|
||||
StringBuilder sql = new StringBuilder("SELECT COUNT(1) FROM `").append(table)
|
||||
.append("` WHERE user_id=? AND is_deleted=0");
|
||||
.append("` WHERE user_id=? AND is_deleted=0 AND last_message_at IS NOT NULL");
|
||||
params.add(userId);
|
||||
if (assistantId != null) {
|
||||
sql.append(" AND assistant_id=?");
|
||||
@@ -248,6 +256,7 @@ public class MySqlChatSessionRepository {
|
||||
summary.setAssistantCode(rs.getString("assistant_code"));
|
||||
summary.setAssistantName(rs.getString("assistant_name"));
|
||||
summary.setTitle(rs.getString("title"));
|
||||
summary.setExtJson(rs.getString("ext_json"));
|
||||
summary.setLastMessagePreview(rs.getString("last_message_preview"));
|
||||
summary.setLastSenderId(bigInteger(rs, "last_sender_id"));
|
||||
summary.setLastSenderName(rs.getString("last_sender_name"));
|
||||
@@ -279,4 +288,12 @@ public class MySqlChatSessionRepository {
|
||||
private String safeString(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private void setNullableJson(java.sql.PreparedStatement ps, int parameterIndex, String value) throws SQLException {
|
||||
if (value == null || value.isBlank()) {
|
||||
ps.setNull(parameterIndex, Types.VARCHAR);
|
||||
return;
|
||||
}
|
||||
ps.setString(parameterIndex, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
public interface ChatHistoryManageService {
|
||||
|
||||
@@ -25,4 +27,12 @@ public interface ChatHistoryManageService {
|
||||
void renameUserSession(BigInteger userId, BigInteger sessionId, String title, BigInteger operatorId);
|
||||
|
||||
void deleteUserSession(BigInteger userId, BigInteger sessionId, BigInteger operatorId);
|
||||
|
||||
List<ChatMessageRecord> listUserRoundVariants(BigInteger userId, BigInteger sessionId, BigInteger roundId);
|
||||
|
||||
ChatMessageRecord selectUserRoundVariant(BigInteger userId, BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId);
|
||||
|
||||
List<ChatMessageRecord> listAdminRoundVariants(BigInteger sessionId, BigInteger roundId);
|
||||
|
||||
ChatMessageRecord selectAdminRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.chatlog.cache.ChatHotStateService;
|
||||
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
|
||||
import tech.easyflow.chatlog.domain.event.ChatPersistEventType;
|
||||
import tech.easyflow.chatlog.domain.event.payload.ChatSessionDeletePayload;
|
||||
import tech.easyflow.chatlog.domain.event.payload.ChatSessionRenamePayload;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
@@ -19,21 +25,26 @@ import java.util.UUID;
|
||||
@Service
|
||||
public class ChatPersistDispatcher {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChatPersistDispatcher.class);
|
||||
|
||||
private final ChatHotStateService chatHotStateService;
|
||||
private final ChatPersistEventProducer eventProducer;
|
||||
private final ChatPersistMySqlApplyService mySqlApplyService;
|
||||
private final ChatJsonSupport chatJsonSupport;
|
||||
|
||||
public ChatPersistDispatcher(ChatHotStateService chatHotStateService,
|
||||
ChatPersistEventProducer eventProducer,
|
||||
ChatPersistMySqlApplyService mySqlApplyService,
|
||||
ChatJsonSupport chatJsonSupport) {
|
||||
this.chatHotStateService = chatHotStateService;
|
||||
this.eventProducer = eventProducer;
|
||||
this.mySqlApplyService = mySqlApplyService;
|
||||
this.chatJsonSupport = chatJsonSupport;
|
||||
}
|
||||
|
||||
public ChatSessionSummary createOrTouchSession(ChatSessionUpsertCommand command) {
|
||||
ChatSessionSummary summary = chatHotStateService.touchSession(command);
|
||||
eventProducer.send(buildEvent(
|
||||
ChatPersistEvent event = buildEvent(
|
||||
UUID.randomUUID().toString(),
|
||||
ChatPersistEventType.SESSION_PREPARED,
|
||||
command.getSessionId(),
|
||||
@@ -41,10 +52,43 @@ public class ChatPersistDispatcher {
|
||||
command.getAssistantId(),
|
||||
command.getOperateAt(),
|
||||
chatJsonSupport.toJson(command)
|
||||
));
|
||||
);
|
||||
persistImmediately(event);
|
||||
eventProducer.send(event);
|
||||
return summary;
|
||||
}
|
||||
|
||||
public ChatRoundRecord createOrTouchRound(ChatRoundUpsertCommand command) {
|
||||
ChatRoundRecord record = chatHotStateService.createOrTouchRound(command);
|
||||
ChatPersistEvent event = buildEvent(
|
||||
UUID.randomUUID().toString(),
|
||||
ChatPersistEventType.ROUND_UPSERTED,
|
||||
command.getSessionId(),
|
||||
BigInteger.ZERO,
|
||||
BigInteger.ZERO,
|
||||
command.getOperateAt(),
|
||||
chatJsonSupport.toJson(command)
|
||||
);
|
||||
persistImmediately(event);
|
||||
eventProducer.send(event);
|
||||
return record;
|
||||
}
|
||||
|
||||
public void selectRoundVariant(ChatRoundSelectCommand command) {
|
||||
chatHotStateService.selectVariant(command);
|
||||
ChatPersistEvent event = buildEvent(
|
||||
UUID.randomUUID().toString(),
|
||||
ChatPersistEventType.ROUND_VARIANT_SELECTED,
|
||||
command.getSessionId(),
|
||||
BigInteger.ZERO,
|
||||
BigInteger.ZERO,
|
||||
command.getOperateAt(),
|
||||
chatJsonSupport.toJson(command)
|
||||
);
|
||||
persistImmediately(event);
|
||||
eventProducer.send(event);
|
||||
}
|
||||
|
||||
public void appendUserMessage(ChatAppendMessageCommand command) {
|
||||
appendMessage(command, ChatPersistEventType.USER_MESSAGE_APPENDED);
|
||||
}
|
||||
@@ -96,7 +140,7 @@ public class ChatPersistDispatcher {
|
||||
|
||||
private void appendMessage(ChatAppendMessageCommand command, ChatPersistEventType eventType) {
|
||||
chatHotStateService.appendMessage(command);
|
||||
eventProducer.send(buildEvent(
|
||||
ChatPersistEvent event = buildEvent(
|
||||
eventId("message", command.getMessageId()),
|
||||
eventType,
|
||||
command.getSessionId(),
|
||||
@@ -104,7 +148,27 @@ public class ChatPersistDispatcher {
|
||||
command.getAssistantId(),
|
||||
command.getCreated(),
|
||||
chatJsonSupport.toJson(command)
|
||||
));
|
||||
);
|
||||
persistImmediately(event);
|
||||
eventProducer.send(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* 先同步写入 MySQL,再发送异步事件,保证会话列表和版本切换读取有确定来源。
|
||||
*
|
||||
* @param event 持久化事件
|
||||
*/
|
||||
private void persistImmediately(ChatPersistEvent event) {
|
||||
try {
|
||||
mySqlApplyService.apply(java.util.List.of(event));
|
||||
} catch (RuntimeException ex) {
|
||||
log.error("聊天记录同步写入 MySQL 失败,eventId={}, eventType={}, sessionId={}",
|
||||
event == null ? null : event.getEventId(),
|
||||
event == null ? null : event.getEventType(),
|
||||
event == null ? null : event.getSessionId(),
|
||||
ex);
|
||||
throw new BusinessException("聊天记录持久化失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private ChatPersistEvent buildEvent(String eventId,
|
||||
|
||||
@@ -3,6 +3,8 @@ package tech.easyflow.chatlog.service;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionSummaryCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
|
||||
@@ -11,7 +13,9 @@ import tech.easyflow.chatlog.domain.event.payload.ChatSessionDeletePayload;
|
||||
import tech.easyflow.chatlog.domain.event.payload.ChatSessionRenamePayload;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatRoundRepository;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
|
||||
import java.math.BigInteger;
|
||||
@@ -30,15 +34,18 @@ public class ChatPersistMySqlApplyService {
|
||||
|
||||
private final MySqlChatSessionRepository sessionRepository;
|
||||
private final MySqlChatLogRepository logRepository;
|
||||
private final MySqlChatRoundRepository roundRepository;
|
||||
private final MySqlChatLogTableManager tableManager;
|
||||
private final ChatJsonSupport chatJsonSupport;
|
||||
|
||||
public ChatPersistMySqlApplyService(MySqlChatSessionRepository sessionRepository,
|
||||
MySqlChatLogRepository logRepository,
|
||||
MySqlChatRoundRepository roundRepository,
|
||||
MySqlChatLogTableManager tableManager,
|
||||
ChatJsonSupport chatJsonSupport) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.logRepository = logRepository;
|
||||
this.roundRepository = roundRepository;
|
||||
this.tableManager = tableManager;
|
||||
this.chatJsonSupport = chatJsonSupport;
|
||||
}
|
||||
@@ -50,6 +57,8 @@ public class ChatPersistMySqlApplyService {
|
||||
}
|
||||
|
||||
Map<BigInteger, ChatSessionUpsertCommand> sessionUpserts = new LinkedHashMap<>();
|
||||
Map<BigInteger, ChatRoundUpsertCommand> roundUpserts = new LinkedHashMap<>();
|
||||
List<ChatRoundSelectCommand> roundSelections = new ArrayList<>();
|
||||
List<ChatAppendMessageCommand> appendCommands = new ArrayList<>();
|
||||
Map<BigInteger, ChatSessionSummaryCommand> summaryCommands = new LinkedHashMap<>();
|
||||
List<ChatSessionRenamePayload> renamePayloads = new ArrayList<>();
|
||||
@@ -67,6 +76,18 @@ public class ChatPersistMySqlApplyService {
|
||||
sessionUpserts.put(command.getSessionId(), command);
|
||||
}
|
||||
}
|
||||
case ROUND_UPSERTED -> {
|
||||
ChatRoundUpsertCommand command = chatJsonSupport.fromJson(event.getPayload(), ChatRoundUpsertCommand.class);
|
||||
if (command != null && command.getRoundId() != null) {
|
||||
roundUpserts.put(command.getRoundId(), command);
|
||||
}
|
||||
}
|
||||
case ROUND_VARIANT_SELECTED -> {
|
||||
ChatRoundSelectCommand command = chatJsonSupport.fromJson(event.getPayload(), ChatRoundSelectCommand.class);
|
||||
if (command != null && command.getRoundId() != null) {
|
||||
roundSelections.add(command);
|
||||
}
|
||||
}
|
||||
case USER_MESSAGE_APPENDED, ASSISTANT_MESSAGE_APPENDED -> {
|
||||
ChatAppendMessageCommand command = chatJsonSupport.fromJson(event.getPayload(), ChatAppendMessageCommand.class);
|
||||
if (command == null || command.getSessionId() == null || command.getMessageId() == null) {
|
||||
@@ -96,6 +117,9 @@ public class ChatPersistMySqlApplyService {
|
||||
if (!sessionUpserts.isEmpty()) {
|
||||
sessionRepository.createOrTouchBatch(new ArrayList<>(sessionUpserts.values()));
|
||||
}
|
||||
if (!roundUpserts.isEmpty()) {
|
||||
roundRepository.createOrTouchBatch(new ArrayList<>(roundUpserts.values()));
|
||||
}
|
||||
if (!months.isEmpty()) {
|
||||
for (YearMonth month : months) {
|
||||
tableManager.ensureMonthTable(month);
|
||||
@@ -113,6 +137,9 @@ public class ChatPersistMySqlApplyService {
|
||||
}
|
||||
sessionRepository.updateSummaries(new ArrayList<>(summaryCommands.values()));
|
||||
}
|
||||
if (!roundSelections.isEmpty()) {
|
||||
roundRepository.selectVariants(roundSelections);
|
||||
}
|
||||
if (!renamePayloads.isEmpty()) {
|
||||
sessionRepository.renameSessions(renamePayloads);
|
||||
}
|
||||
@@ -127,15 +154,26 @@ public class ChatPersistMySqlApplyService {
|
||||
ChatSessionSummaryCommand created = new ChatSessionSummaryCommand();
|
||||
created.setSessionId(command.getSessionId());
|
||||
created.setUserId(command.getUserId());
|
||||
created.setLastMessageAt(null);
|
||||
created.setAccessAt(null);
|
||||
created.setModifiedAt(null);
|
||||
created.setMessageIncrement(0);
|
||||
return created;
|
||||
});
|
||||
summary.setMessageIncrement(summary.getMessageIncrement() + 1);
|
||||
if (summary.getLastMessageAt() == null || !command.getCreated().before(summary.getLastMessageAt())) {
|
||||
if (ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT.equals(command.getMessageKind())
|
||||
&& command.getVariantIndex() != null
|
||||
&& command.getVariantIndex() > 1) {
|
||||
summary.setMessageIncrement(Math.max(summary.getMessageIncrement() - 1, 0));
|
||||
}
|
||||
Date commandCreated = defaultDate(command.getCreated());
|
||||
if (summary.getLastMessageAt() == null || !commandCreated.before(summary.getLastMessageAt())) {
|
||||
summary.setLastSenderId(command.getSenderId());
|
||||
summary.setLastSenderName(command.getSenderName());
|
||||
summary.setLastMessagePreview(trimPreview(command.getContentText()));
|
||||
summary.setLastMessageAt(command.getCreated());
|
||||
summary.setLastMessageAt(commandCreated);
|
||||
summary.setAccessAt(commandCreated);
|
||||
summary.setModifiedAt(commandCreated);
|
||||
summary.setOperatorId(command.getCreatedBy());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
|
||||
/**
|
||||
* 聊天轮次写服务。
|
||||
*/
|
||||
public interface ChatRoundCommandService {
|
||||
|
||||
/**
|
||||
* 创建或更新轮次聚合。
|
||||
*
|
||||
* @param command 轮次命令
|
||||
* @return 最新轮次记录
|
||||
*/
|
||||
ChatRoundRecord createOrTouchRound(ChatRoundUpsertCommand command);
|
||||
|
||||
/**
|
||||
* 切换轮次当前选中的答案版本。
|
||||
*
|
||||
* @param command 切换命令
|
||||
*/
|
||||
void selectVariant(ChatRoundSelectCommand command);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 聊天轮次业务操作服务。
|
||||
*/
|
||||
public interface ChatRoundOperateService {
|
||||
|
||||
/**
|
||||
* 校验并返回允许重答的轮次。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @return 轮次记录
|
||||
*/
|
||||
ChatRoundRecord requireRegeneratableRound(BigInteger sessionId, BigInteger roundId);
|
||||
|
||||
/**
|
||||
* 查询轮次下所有答案版本。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @return 答案版本列表
|
||||
*/
|
||||
List<ChatMessageRecord> listVariants(BigInteger sessionId, BigInteger roundId);
|
||||
|
||||
/**
|
||||
* 切换指定轮次当前选中的答案版本。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @param variantIndex 目标版本序号
|
||||
* @param operatorId 操作人
|
||||
* @return 选中的答案消息
|
||||
*/
|
||||
ChatMessageRecord selectVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 聊天轮次读服务。
|
||||
*/
|
||||
public interface ChatRoundQueryService {
|
||||
|
||||
/**
|
||||
* 查询会话最新轮次。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 最新轮次
|
||||
*/
|
||||
ChatRoundRecord getLatestRound(BigInteger sessionId);
|
||||
|
||||
/**
|
||||
* 查询指定轮次。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @return 轮次记录
|
||||
*/
|
||||
ChatRoundRecord getRound(BigInteger sessionId, BigInteger roundId);
|
||||
|
||||
/**
|
||||
* 查询轮次下所有助手答案版本。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @return 答案版本列表
|
||||
*/
|
||||
List<ChatMessageRecord> listRoundVariants(BigInteger sessionId, BigInteger roundId);
|
||||
|
||||
/**
|
||||
* 查询轮次下指定答案版本。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param roundId 轮次 ID
|
||||
* @param variantIndex 答案版本序号
|
||||
* @return 答案版本记录
|
||||
*/
|
||||
ChatMessageRecord getRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex);
|
||||
|
||||
/**
|
||||
* 判断会话是否已经启用轮次模型。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 是否存在轮次
|
||||
*/
|
||||
boolean hasRounds(BigInteger sessionId);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
@@ -18,5 +19,22 @@ public interface ChatSessionQueryService {
|
||||
|
||||
ChatSessionSummary getSessionSummary(BigInteger sessionId);
|
||||
|
||||
/**
|
||||
* 分页查询当前会话的主线可见消息。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @param query 分页参数
|
||||
* @return 主线消息分页
|
||||
*/
|
||||
ChatHistoryPage pageMainlineMessages(BigInteger sessionId, ChatPageQuery query);
|
||||
|
||||
/**
|
||||
* 查询当前会话的全部主线可见消息。
|
||||
*
|
||||
* @param sessionId 会话 ID
|
||||
* @return 主线消息列表
|
||||
*/
|
||||
List<ChatMessageRecord> listMainlineMessages(BigInteger sessionId);
|
||||
|
||||
List<ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
public interface PublicChatSessionRestoreService {
|
||||
|
||||
PublicChatSessionRestoreResult restoreSession(BigInteger userId, BigInteger assistantId, BigInteger sessionId, Integer limit);
|
||||
|
||||
List<ChatMessageRecord> listVariants(BigInteger userId, BigInteger assistantId, BigInteger sessionId, BigInteger roundId);
|
||||
|
||||
ChatMessageRecord selectVariant(BigInteger userId, BigInteger assistantId, BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
|
||||
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
|
||||
import tech.easyflow.chatlog.service.ChatHistoryManageService;
|
||||
import tech.easyflow.chatlog.service.ChatHistoryQueryService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
@@ -23,15 +24,18 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatSessionCommandService chatSessionCommandService;
|
||||
private final ChatHistoryQueryService chatHistoryQueryService;
|
||||
private final ChatRoundOperateService chatRoundOperateService;
|
||||
private final ChatAnalyticalDBRepository chatAnalyticalDBRepository;
|
||||
|
||||
public ChatHistoryManageServiceImpl(ChatSessionQueryService chatSessionQueryService,
|
||||
ChatSessionCommandService chatSessionCommandService,
|
||||
ChatHistoryQueryService chatHistoryQueryService,
|
||||
ChatRoundOperateService chatRoundOperateService,
|
||||
ChatAnalyticalDBRepository chatAnalyticalDBRepository) {
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.chatSessionCommandService = chatSessionCommandService;
|
||||
this.chatHistoryQueryService = chatHistoryQueryService;
|
||||
this.chatRoundOperateService = chatRoundOperateService;
|
||||
this.chatAnalyticalDBRepository = chatAnalyticalDBRepository;
|
||||
}
|
||||
|
||||
@@ -68,13 +72,21 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
||||
|
||||
@Override
|
||||
public ChatHistoryPage queryUserMessages(BigInteger userId, BigInteger sessionId, ChatPageQuery query) {
|
||||
getUserSession(userId, sessionId);
|
||||
ChatSessionSummary summary = getUserSession(userId, sessionId);
|
||||
ChatHistoryPage firstPage = restoreRecentMessages(summary, query);
|
||||
if (firstPage != null) {
|
||||
return firstPage;
|
||||
}
|
||||
return chatHistoryQueryService.queryHistoryMessages(sessionId, query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatHistoryPage queryAdminMessages(BigInteger sessionId, ChatPageQuery query) {
|
||||
getAdminSession(sessionId);
|
||||
ChatSessionSummary summary = getAdminSession(sessionId);
|
||||
ChatHistoryPage firstPage = restoreRecentMessages(summary, query);
|
||||
if (firstPage != null) {
|
||||
return firstPage;
|
||||
}
|
||||
return chatHistoryQueryService.queryHistoryMessages(sessionId, query);
|
||||
}
|
||||
|
||||
@@ -92,4 +104,48 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
||||
getUserSession(userId, sessionId);
|
||||
chatSessionCommandService.deleteSession(sessionId, userId, operatorId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<ChatMessageRecord> listUserRoundVariants(BigInteger userId, BigInteger sessionId, BigInteger roundId) {
|
||||
getUserSession(userId, sessionId);
|
||||
return chatRoundOperateService.listVariants(sessionId, roundId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMessageRecord selectUserRoundVariant(BigInteger userId, BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||
getUserSession(userId, sessionId);
|
||||
return chatRoundOperateService.selectVariant(sessionId, roundId, variantIndex, operatorId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<ChatMessageRecord> listAdminRoundVariants(BigInteger sessionId, BigInteger roundId) {
|
||||
getAdminSession(sessionId);
|
||||
return chatRoundOperateService.listVariants(sessionId, roundId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMessageRecord selectAdminRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||
getAdminSession(sessionId);
|
||||
return chatRoundOperateService.selectVariant(sessionId, roundId, variantIndex, operatorId);
|
||||
}
|
||||
|
||||
private ChatHistoryPage restoreRecentMessages(ChatSessionSummary summary, ChatPageQuery query) {
|
||||
if (summary == null || query == null || query.getPageNumber() != 1) {
|
||||
return null;
|
||||
}
|
||||
java.util.List<ChatMessageRecord> records = chatSessionQueryService.getRecentTail(
|
||||
summary.getId(),
|
||||
Math.toIntExact(query.getPageSize())
|
||||
);
|
||||
if (records == null || records.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
ChatHistoryPage page = new ChatHistoryPage();
|
||||
page.setPageNumber(query.getPageNumber());
|
||||
page.setPageSize(query.getPageSize());
|
||||
page.setRecords(records);
|
||||
long total = summary.getMessageCount() == null ? 0L : summary.getMessageCount();
|
||||
page.setTotal(Math.max(total, records.size()));
|
||||
return page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatRoundCommandService;
|
||||
|
||||
/**
|
||||
* 聊天轮次写服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class ChatRoundCommandServiceImpl implements ChatRoundCommandService {
|
||||
|
||||
private final ChatPersistDispatcher chatPersistDispatcher;
|
||||
|
||||
public ChatRoundCommandServiceImpl(ChatPersistDispatcher chatPersistDispatcher) {
|
||||
this.chatPersistDispatcher = chatPersistDispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord createOrTouchRound(ChatRoundUpsertCommand command) {
|
||||
return chatPersistDispatcher.createOrTouchRound(command);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void selectVariant(ChatRoundSelectCommand command) {
|
||||
chatPersistDispatcher.selectRoundVariant(command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.service.ChatRoundCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 聊天轮次业务操作服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class ChatRoundOperateServiceImpl implements ChatRoundOperateService {
|
||||
|
||||
private final ChatRoundQueryService chatRoundQueryService;
|
||||
private final ChatRoundCommandService chatRoundCommandService;
|
||||
|
||||
public ChatRoundOperateServiceImpl(ChatRoundQueryService chatRoundQueryService,
|
||||
ChatRoundCommandService chatRoundCommandService) {
|
||||
this.chatRoundQueryService = chatRoundQueryService;
|
||||
this.chatRoundCommandService = chatRoundCommandService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord requireRegeneratableRound(BigInteger sessionId, BigInteger roundId) {
|
||||
ChatRoundRecord round = requireLatestRound(sessionId, roundId);
|
||||
if (round.getSelectedAssistantMessageId() == null || round.getSelectedVariantIndex() == null
|
||||
|| round.getSelectedVariantIndex() <= 0) {
|
||||
throw new BusinessException("当前轮次暂无可重答的回答");
|
||||
}
|
||||
return round;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listVariants(BigInteger sessionId, BigInteger roundId) {
|
||||
ChatRoundRecord round = chatRoundQueryService.getRound(sessionId, roundId);
|
||||
if (round == null) {
|
||||
throw new BusinessException("轮次不存在");
|
||||
}
|
||||
ChatRoundRecord latestRound = chatRoundQueryService.getLatestRound(sessionId);
|
||||
boolean switchable = latestRound != null
|
||||
&& Objects.equals(latestRound.getId(), round.getId())
|
||||
&& !ChatConstants.ROUND_STATUS_LOCKED.equalsIgnoreCase(round.getStatus());
|
||||
List<ChatMessageRecord> variants = new ArrayList<>(chatRoundQueryService.listRoundVariants(sessionId, roundId));
|
||||
for (ChatMessageRecord variant : variants) {
|
||||
variant.setVariantCount(round.getVariantCount());
|
||||
variant.setSelectedVariantIndex(round.getSelectedVariantIndex());
|
||||
variant.setSwitchable(switchable);
|
||||
}
|
||||
return variants;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMessageRecord selectVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||
ChatRoundRecord round = requireLatestRound(sessionId, roundId);
|
||||
if (variantIndex == null || variantIndex <= 0) {
|
||||
throw new BusinessException("目标答案版本无效");
|
||||
}
|
||||
ChatMessageRecord selected = chatRoundQueryService.getRoundVariant(sessionId, roundId, variantIndex);
|
||||
if (selected == null) {
|
||||
throw new BusinessException("目标答案版本不存在");
|
||||
}
|
||||
|
||||
ChatRoundSelectCommand command = new ChatRoundSelectCommand();
|
||||
command.setSessionId(sessionId);
|
||||
command.setRoundId(roundId);
|
||||
command.setSelectedVariantIndex(variantIndex);
|
||||
command.setSelectedAssistantMessageId(selected.getId());
|
||||
command.setSelectedAssistantMessage(selected);
|
||||
command.setOperatorId(operatorId);
|
||||
chatRoundCommandService.selectVariant(command);
|
||||
selected.setSelectedVariantIndex(variantIndex);
|
||||
selected.setVariantCount(round.getVariantCount());
|
||||
selected.setSwitchable(true);
|
||||
return selected;
|
||||
}
|
||||
|
||||
private ChatRoundRecord requireLatestRound(BigInteger sessionId, BigInteger roundId) {
|
||||
ChatRoundRecord round = chatRoundQueryService.getRound(sessionId, roundId);
|
||||
if (round == null) {
|
||||
throw new BusinessException("轮次不存在");
|
||||
}
|
||||
ChatRoundRecord latestRound = chatRoundQueryService.getLatestRound(sessionId);
|
||||
if (latestRound == null || !Objects.equals(latestRound.getId(), round.getId())) {
|
||||
throw new BusinessException("当前轮次已有后续对话,不支持切换答案版本");
|
||||
}
|
||||
if (ChatConstants.ROUND_STATUS_LOCKED.equalsIgnoreCase(round.getStatus())) {
|
||||
throw new BusinessException("当前轮次已有后续对话,不支持切换答案版本");
|
||||
}
|
||||
return round;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.chatlog.cache.ChatHotStateService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatRoundRepository;
|
||||
import tech.easyflow.chatlog.service.ChatRoundQueryService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 聊天轮次读服务实现。
|
||||
*/
|
||||
@Service
|
||||
public class ChatRoundQueryServiceImpl implements ChatRoundQueryService {
|
||||
|
||||
private final MySqlChatRoundRepository roundRepository;
|
||||
private final MySqlChatLogRepository logRepository;
|
||||
private final MySqlChatLogTableManager tableManager;
|
||||
private final ChatHotStateService chatHotStateService;
|
||||
|
||||
public ChatRoundQueryServiceImpl(MySqlChatRoundRepository roundRepository,
|
||||
MySqlChatLogRepository logRepository,
|
||||
MySqlChatLogTableManager tableManager,
|
||||
ChatHotStateService chatHotStateService) {
|
||||
this.roundRepository = roundRepository;
|
||||
this.logRepository = logRepository;
|
||||
this.tableManager = tableManager;
|
||||
this.chatHotStateService = chatHotStateService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord getLatestRound(BigInteger sessionId) {
|
||||
ChatRoundRecord cached = chatHotStateService.getLatestRound(sessionId);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
ChatRoundRecord record = roundRepository.findLatestRound(sessionId);
|
||||
if (record != null) {
|
||||
chatHotStateService.cacheRound(record);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord getRound(BigInteger sessionId, BigInteger roundId) {
|
||||
ChatRoundRecord cached = chatHotStateService.getRound(sessionId, roundId);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
ChatRoundRecord record = roundRepository.findRound(sessionId, roundId);
|
||||
if (record != null) {
|
||||
chatHotStateService.cacheRound(record);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listRoundVariants(BigInteger sessionId, BigInteger roundId) {
|
||||
return logRepository.listRoundVariants(sessionId, roundId, tableManager.listRecentExistingMonths(3));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMessageRecord getRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex) {
|
||||
return logRepository.findRoundVariant(sessionId, roundId, variantIndex, tableManager.listRecentExistingMonths(3));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasRounds(BigInteger sessionId) {
|
||||
return roundRepository.existsRounds(sessionId);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import tech.easyflow.chatlog.cache.ChatHotStateService;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
@@ -12,7 +13,12 @@ import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
|
||||
@@ -34,21 +40,7 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
|
||||
|
||||
@Override
|
||||
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
|
||||
if (assistantId == null) {
|
||||
List<BigInteger> sessionIds = chatHotStateService.listSessionIds(userId, query.getOffset(), query.getPageSize());
|
||||
if (!sessionIds.isEmpty()) {
|
||||
List<ChatSessionSummary> cached = chatHotStateService.getSessionSummaries(sessionIds);
|
||||
if (cached.size() == sessionIds.size()) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
List<ChatSessionSummary> sessions = sessionRepository.listSessions(userId, null, query);
|
||||
chatHotStateService.cacheSessionSummaries(sessions);
|
||||
return sessions;
|
||||
}
|
||||
List<ChatSessionSummary> sessions = sessionRepository.listSessions(userId, assistantId, query);
|
||||
chatHotStateService.cacheSessionSummaries(sessions);
|
||||
return sessions;
|
||||
return sessionRepository.listSessions(userId, assistantId, query);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -62,21 +54,6 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
|
||||
page.setPageNumber(query.getPageNumber());
|
||||
page.setPageSize(query.getPageSize());
|
||||
|
||||
if (assistantId == null && chatHotStateService.hasSessionIndex(userId)) {
|
||||
List<BigInteger> sessionIds = chatHotStateService.listSessionIds(userId, query.getOffset(), query.getPageSize());
|
||||
if (sessionIds.isEmpty()) {
|
||||
page.setTotal(chatHotStateService.countSessions(userId));
|
||||
page.setRecords(List.of());
|
||||
return page;
|
||||
}
|
||||
List<ChatSessionSummary> cached = chatHotStateService.getSessionSummaries(sessionIds);
|
||||
if (cached.size() == sessionIds.size()) {
|
||||
page.setTotal(chatHotStateService.countSessions(userId));
|
||||
page.setRecords(cached);
|
||||
return page;
|
||||
}
|
||||
}
|
||||
|
||||
page.setTotal(sessionRepository.countSessions(userId, assistantId));
|
||||
page.setRecords(listSessions(userId, assistantId, query));
|
||||
return page;
|
||||
@@ -95,14 +72,74 @@ public class ChatSessionQueryServiceImpl implements ChatSessionQueryService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatHistoryPage pageMainlineMessages(BigInteger sessionId, ChatPageQuery query) {
|
||||
ChatHistoryPage page = new ChatHistoryPage();
|
||||
page.setPageNumber(query.getPageNumber());
|
||||
page.setPageSize(query.getPageSize());
|
||||
ChatSessionSummary summary = getSessionSummary(sessionId);
|
||||
long total = summary == null || summary.getMessageCount() == null ? 0L : summary.getMessageCount();
|
||||
List<ChatMessageRecord> records = logRepository.listMainlineMessages(
|
||||
sessionId,
|
||||
tableManager.listRecentExistingMonths(3),
|
||||
query.getOffset(),
|
||||
Math.toIntExact(query.getPageSize())
|
||||
);
|
||||
page.setRecords(records);
|
||||
page.setTotal(Math.max(total, query.getOffset() + records.size()));
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listMainlineMessages(BigInteger sessionId) {
|
||||
return logRepository.listMainlineMessages(sessionId, tableManager.listRecentExistingMonths(3));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit) {
|
||||
List<ChatMessageRecord> cached = chatHotStateService.getSessionTail(sessionId);
|
||||
if (cached != null) {
|
||||
if (cached != null && isTailReliable(cached)) {
|
||||
return cached.subList(0, Math.min(limit, cached.size()));
|
||||
}
|
||||
List<ChatMessageRecord> records = logRepository.listRecentTail(sessionId, tableManager.listRecentExistingMonths(3), limit);
|
||||
chatHotStateService.setSessionTail(sessionId, records);
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Redis tail 是否符合当前主线版本语义,防止过期选中版本把可见回答过滤掉。
|
||||
*
|
||||
* @param records Redis tail 消息
|
||||
* @return true 表示可直接使用缓存
|
||||
*/
|
||||
private boolean isTailReliable(List<ChatMessageRecord> records) {
|
||||
Map<BigInteger, Integer> selectedVariantByRound = new LinkedHashMap<>();
|
||||
Map<BigInteger, Set<Integer>> assistantVariantsByRound = new LinkedHashMap<>();
|
||||
for (ChatMessageRecord record : records) {
|
||||
if (record == null || record.getRoundId() == null) {
|
||||
continue;
|
||||
}
|
||||
Integer selectedVariantIndex = record.getSelectedVariantIndex();
|
||||
if (selectedVariantIndex != null && selectedVariantIndex > 0) {
|
||||
Integer previous = selectedVariantByRound.putIfAbsent(record.getRoundId(), selectedVariantIndex);
|
||||
if (previous != null && !Objects.equals(previous, selectedVariantIndex)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ("assistant".equalsIgnoreCase(record.getSenderRole())
|
||||
&& record.getVariantIndex() != null
|
||||
&& record.getVariantIndex() > 0) {
|
||||
assistantVariantsByRound
|
||||
.computeIfAbsent(record.getRoundId(), key -> new LinkedHashSet<>())
|
||||
.add(record.getVariantIndex());
|
||||
}
|
||||
}
|
||||
for (Map.Entry<BigInteger, Integer> entry : selectedVariantByRound.entrySet()) {
|
||||
Set<Integer> visibleVariants = assistantVariantsByRound.get(entry.getKey());
|
||||
if (visibleVariants != null && !visibleVariants.isEmpty() && !visibleVariants.contains(entry.getValue())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,20 @@ import com.mybatisflex.core.keygen.impl.SnowFlakeIDKeyGenerator;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionExtPayload;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundQueryService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeHistoryPayloadHelper;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeListener;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeMessage;
|
||||
@@ -26,12 +35,21 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
|
||||
private final SnowFlakeIDKeyGenerator idGenerator = new SnowFlakeIDKeyGenerator();
|
||||
|
||||
private final ChatPersistDispatcher chatPersistDispatcher;
|
||||
private final ChatRoundOperateService chatRoundOperateService;
|
||||
private final ChatRoundQueryService chatRoundQueryService;
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatJsonSupport chatJsonSupport;
|
||||
|
||||
public ChatlogRuntimeListener(ChatPersistDispatcher chatPersistDispatcher,
|
||||
ChatSessionQueryService chatSessionQueryService) {
|
||||
ChatRoundOperateService chatRoundOperateService,
|
||||
ChatRoundQueryService chatRoundQueryService,
|
||||
ChatSessionQueryService chatSessionQueryService,
|
||||
ChatJsonSupport chatJsonSupport) {
|
||||
this.chatPersistDispatcher = chatPersistDispatcher;
|
||||
this.chatRoundOperateService = chatRoundOperateService;
|
||||
this.chatRoundQueryService = chatRoundQueryService;
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.chatJsonSupport = chatJsonSupport;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -47,6 +65,7 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
|
||||
command.setAssistantCode(context.getAssistantCode());
|
||||
command.setAssistantName(context.getAssistantName());
|
||||
command.setTitle(context.getSessionTitle());
|
||||
command.setExtJson(resolveExtJson(context));
|
||||
command.setOperatorId(defaultNumber(context.getUserId()));
|
||||
chatPersistDispatcher.createOrTouchSession(command);
|
||||
} catch (RuntimeException ex) {
|
||||
@@ -57,6 +76,9 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
|
||||
@Override
|
||||
public void onUserMessage(ChatRuntimeContext context, ChatRuntimeMessage message) {
|
||||
try {
|
||||
if (prepareRoundContext(context, message)) {
|
||||
return;
|
||||
}
|
||||
chatPersistDispatcher.appendUserMessage(toAppendCommand(context, message));
|
||||
} catch (RuntimeException ex) {
|
||||
throw persistFailed(ex);
|
||||
@@ -66,7 +88,36 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
|
||||
@Override
|
||||
public void onAssistantCompleted(ChatRuntimeContext context, ChatRuntimeMessage message) {
|
||||
try {
|
||||
applyAssistantRoundMetadata(context, message);
|
||||
chatPersistDispatcher.appendAssistantMessage(toAppendCommand(context, message));
|
||||
chatPersistDispatcher.createOrTouchRound(buildAssistantCompletedRoundCommand(context, message));
|
||||
} catch (RuntimeException ex) {
|
||||
throw persistFailed(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatFailed(ChatRuntimeContext context, Throwable throwable) {
|
||||
try {
|
||||
BigInteger roundId = resolveNumber(context, ChatRuntimeExtKeys.CURRENT_ROUND_ID);
|
||||
if (context == null || context.getSessionId() == null || roundId == null) {
|
||||
return;
|
||||
}
|
||||
ChatRoundRecord currentRound = chatRoundQueryService.getRound(context.getSessionId(), roundId);
|
||||
if (currentRound == null) {
|
||||
return;
|
||||
}
|
||||
ChatRoundUpsertCommand command = new ChatRoundUpsertCommand();
|
||||
command.setRoundId(currentRound.getId());
|
||||
command.setSessionId(currentRound.getSessionId());
|
||||
command.setRoundNo(currentRound.getRoundNo());
|
||||
command.setUserMessageId(currentRound.getUserMessageId());
|
||||
command.setSelectedAssistantMessageId(currentRound.getSelectedAssistantMessageId());
|
||||
command.setSelectedVariantIndex(currentRound.getSelectedVariantIndex());
|
||||
command.setVariantCount(currentRound.getVariantCount());
|
||||
command.setStatus(ChatConstants.ROUND_STATUS_READY);
|
||||
command.setOperatorId(defaultNumber(context.getUserId()));
|
||||
chatPersistDispatcher.createOrTouchRound(command);
|
||||
} catch (RuntimeException ex) {
|
||||
throw persistFailed(ex);
|
||||
}
|
||||
@@ -77,11 +128,20 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
|
||||
if (context == null || context.getSessionId() == null || limit <= 0) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<ChatMessageRecord> records = new ArrayList<>(chatSessionQueryService.getRecentTail(context.getSessionId(), limit));
|
||||
BigInteger regenerateRoundId = resolveNumber(context, ChatRuntimeExtKeys.REGENERATE_ROUND_ID);
|
||||
int queryLimit = regenerateRoundId == null ? limit : limit + 4;
|
||||
List<ChatMessageRecord> records = new ArrayList<>(chatSessionQueryService.getRecentTail(context.getSessionId(), queryLimit));
|
||||
if (regenerateRoundId != null) {
|
||||
records.removeIf(record -> regenerateRoundId.equals(record.getRoundId()));
|
||||
if (records.size() > limit) {
|
||||
records = new ArrayList<>(records.subList(0, limit));
|
||||
}
|
||||
}
|
||||
Collections.reverse(records);
|
||||
List<ChatRuntimeMessage> messages = new ArrayList<>(records.size());
|
||||
for (ChatMessageRecord record : records) {
|
||||
if (record.getContentText() == null || record.getContentText().isBlank()) {
|
||||
if ((record.getContentText() == null || record.getContentText().isBlank())
|
||||
&& !ChatRuntimeHistoryPayloadHelper.hasStructuredHistory(record.getContentPayload())) {
|
||||
continue;
|
||||
}
|
||||
ChatRuntimeMessage message = new ChatRuntimeMessage();
|
||||
@@ -116,11 +176,127 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
|
||||
command.setContentType(message.getContentType());
|
||||
command.setContentText(message.getContentText());
|
||||
command.setContentPayload(message.getContentPayload());
|
||||
command.setRoundId(message.getRoundId());
|
||||
command.setRoundNo(message.getRoundNo());
|
||||
command.setMessageKind(message.getMessageKind());
|
||||
command.setVariantIndex(message.getVariantIndex());
|
||||
command.setCreatedBy(defaultNumber(context.getUserId()));
|
||||
command.setCreated(message.getCreatedAt());
|
||||
return command;
|
||||
}
|
||||
|
||||
private boolean prepareRoundContext(ChatRuntimeContext context, ChatRuntimeMessage message) {
|
||||
if (context == null || message == null || context.getSessionId() == null) {
|
||||
return false;
|
||||
}
|
||||
BigInteger regenerateRoundId = resolveNumber(context, ChatRuntimeExtKeys.REGENERATE_ROUND_ID);
|
||||
if (regenerateRoundId != null) {
|
||||
ChatRoundRecord round = chatRoundOperateService.requireRegeneratableRound(context.getSessionId(), regenerateRoundId);
|
||||
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, round.getId());
|
||||
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_NO, round.getRoundNo());
|
||||
context.getExt().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, Math.max(round.getVariantCount() + 1, 1));
|
||||
ChatRoundUpsertCommand command = new ChatRoundUpsertCommand();
|
||||
command.setRoundId(round.getId());
|
||||
command.setSessionId(round.getSessionId());
|
||||
command.setRoundNo(round.getRoundNo());
|
||||
command.setUserMessageId(round.getUserMessageId());
|
||||
command.setSelectedAssistantMessageId(round.getSelectedAssistantMessageId());
|
||||
command.setSelectedVariantIndex(round.getSelectedVariantIndex());
|
||||
command.setVariantCount(round.getVariantCount());
|
||||
command.setStatus(ChatConstants.ROUND_STATUS_ANSWERING);
|
||||
command.setOperatorId(defaultNumber(context.getUserId()));
|
||||
chatPersistDispatcher.createOrTouchRound(command);
|
||||
return true;
|
||||
}
|
||||
ChatRoundRecord latestRound = chatRoundQueryService.getLatestRound(context.getSessionId());
|
||||
if (latestRound != null && latestRound.getId() != null
|
||||
&& !ChatConstants.ROUND_STATUS_LOCKED.equalsIgnoreCase(latestRound.getStatus())) {
|
||||
ChatRoundUpsertCommand lockCommand = new ChatRoundUpsertCommand();
|
||||
lockCommand.setRoundId(latestRound.getId());
|
||||
lockCommand.setSessionId(latestRound.getSessionId());
|
||||
lockCommand.setRoundNo(latestRound.getRoundNo());
|
||||
lockCommand.setUserMessageId(latestRound.getUserMessageId());
|
||||
lockCommand.setSelectedAssistantMessageId(latestRound.getSelectedAssistantMessageId());
|
||||
lockCommand.setSelectedVariantIndex(latestRound.getSelectedVariantIndex());
|
||||
lockCommand.setVariantCount(latestRound.getVariantCount());
|
||||
lockCommand.setStatus(ChatConstants.ROUND_STATUS_LOCKED);
|
||||
lockCommand.setOperatorId(defaultNumber(context.getUserId()));
|
||||
chatPersistDispatcher.createOrTouchRound(lockCommand);
|
||||
}
|
||||
BigInteger roundId = BigInteger.valueOf(idGenerator.nextId());
|
||||
int roundNo = latestRound == null || latestRound.getRoundNo() == null ? 1 : latestRound.getRoundNo() + 1;
|
||||
if (message.getMessageId() == null) {
|
||||
message.setMessageId(BigInteger.valueOf(idGenerator.nextId()));
|
||||
}
|
||||
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_ID, roundId);
|
||||
context.getExt().put(ChatRuntimeExtKeys.CURRENT_ROUND_NO, roundNo);
|
||||
context.getExt().put(ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX, 1);
|
||||
message.setRoundId(roundId);
|
||||
message.setRoundNo(roundNo);
|
||||
message.setMessageKind(ChatConstants.MESSAGE_KIND_USER_PROMPT);
|
||||
message.setVariantIndex(null);
|
||||
ChatRoundUpsertCommand command = new ChatRoundUpsertCommand();
|
||||
command.setRoundId(roundId);
|
||||
command.setSessionId(context.getSessionId());
|
||||
command.setRoundNo(roundNo);
|
||||
command.setUserMessageId(message.getMessageId());
|
||||
command.setSelectedVariantIndex(0);
|
||||
command.setVariantCount(0);
|
||||
command.setStatus(ChatConstants.ROUND_STATUS_ANSWERING);
|
||||
command.setOperatorId(defaultNumber(context.getUserId()));
|
||||
chatPersistDispatcher.createOrTouchRound(command);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void applyAssistantRoundMetadata(ChatRuntimeContext context, ChatRuntimeMessage message) {
|
||||
if (message.getMessageId() == null) {
|
||||
message.setMessageId(BigInteger.valueOf(idGenerator.nextId()));
|
||||
}
|
||||
message.setRoundId(resolveNumber(context, ChatRuntimeExtKeys.CURRENT_ROUND_ID));
|
||||
message.setRoundNo(resolveInteger(context, ChatRuntimeExtKeys.CURRENT_ROUND_NO));
|
||||
message.setVariantIndex(resolveInteger(context, ChatRuntimeExtKeys.CURRENT_VARIANT_INDEX));
|
||||
message.setMessageKind(ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT);
|
||||
}
|
||||
|
||||
private ChatRoundUpsertCommand buildAssistantCompletedRoundCommand(ChatRuntimeContext context, ChatRuntimeMessage message) {
|
||||
ChatRoundUpsertCommand command = new ChatRoundUpsertCommand();
|
||||
command.setRoundId(message.getRoundId());
|
||||
command.setSessionId(context.getSessionId());
|
||||
command.setRoundNo(message.getRoundNo());
|
||||
ChatRoundRecord existing = chatRoundQueryService.getRound(context.getSessionId(), message.getRoundId());
|
||||
if (existing != null) {
|
||||
command.setUserMessageId(existing.getUserMessageId());
|
||||
}
|
||||
command.setSelectedAssistantMessageId(message.getMessageId());
|
||||
command.setSelectedVariantIndex(message.getVariantIndex());
|
||||
command.setVariantCount(message.getVariantIndex());
|
||||
command.setStatus(ChatConstants.ROUND_STATUS_READY);
|
||||
command.setOperatorId(defaultNumber(context.getUserId()));
|
||||
return command;
|
||||
}
|
||||
|
||||
private BigInteger resolveNumber(ChatRuntimeContext context, String key) {
|
||||
if (context == null || context.getExt() == null || key == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = context.getExt().get(key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return new BigInteger(String.valueOf(value));
|
||||
}
|
||||
|
||||
private Integer resolveInteger(ChatRuntimeContext context, String key) {
|
||||
if (context == null || context.getExt() == null || key == null) {
|
||||
return null;
|
||||
}
|
||||
Object value = context.getExt().get(key);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
}
|
||||
|
||||
private BigInteger defaultNumber(BigInteger value) {
|
||||
return value == null ? BigInteger.ZERO : value;
|
||||
}
|
||||
@@ -131,4 +307,27 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
|
||||
}
|
||||
return new BusinessException("聊天记录持久化失败,请稍后重试");
|
||||
}
|
||||
|
||||
private String resolveExtJson(ChatRuntimeContext context) {
|
||||
if (context == null || context.getExt() == null || context.getExt().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (!context.getExt().containsKey(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS)) {
|
||||
return null;
|
||||
}
|
||||
Object rawExtraKnowledgeIds = context.getExt().get(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS);
|
||||
if (!(rawExtraKnowledgeIds instanceof List<?> rawList)) {
|
||||
return null;
|
||||
}
|
||||
List<BigInteger> extraKnowledgeIds = new ArrayList<>();
|
||||
for (Object item : rawList) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
extraKnowledgeIds.add(new BigInteger(String.valueOf(item)));
|
||||
}
|
||||
ChatSessionExtPayload payload = new ChatSessionExtPayload();
|
||||
payload.setExtraKnowledgeIds(extraKnowledgeIds);
|
||||
return chatJsonSupport.toJson(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import tech.easyflow.chatlog.config.ChatCacheProperties;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.ArrayList;
|
||||
@@ -18,11 +20,14 @@ import java.util.Objects;
|
||||
public class PublicChatSessionRestoreServiceImpl implements PublicChatSessionRestoreService {
|
||||
|
||||
private final ChatSessionQueryService chatSessionQueryService;
|
||||
private final ChatRoundOperateService chatRoundOperateService;
|
||||
private final ChatCacheProperties chatCacheProperties;
|
||||
|
||||
public PublicChatSessionRestoreServiceImpl(ChatSessionQueryService chatSessionQueryService,
|
||||
ChatRoundOperateService chatRoundOperateService,
|
||||
ChatCacheProperties chatCacheProperties) {
|
||||
this.chatSessionQueryService = chatSessionQueryService;
|
||||
this.chatRoundOperateService = chatRoundOperateService;
|
||||
this.chatCacheProperties = chatCacheProperties;
|
||||
}
|
||||
|
||||
@@ -54,6 +59,18 @@ public class PublicChatSessionRestoreServiceImpl implements PublicChatSessionRes
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listVariants(BigInteger userId, BigInteger assistantId, BigInteger sessionId, BigInteger roundId) {
|
||||
requireOwnedSession(userId, assistantId, sessionId);
|
||||
return chatRoundOperateService.listVariants(sessionId, roundId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMessageRecord selectVariant(BigInteger userId, BigInteger assistantId, BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||
requireOwnedSession(userId, assistantId, sessionId);
|
||||
return chatRoundOperateService.selectVariant(sessionId, roundId, variantIndex, operatorId);
|
||||
}
|
||||
|
||||
private int resolveLimit(Integer limit) {
|
||||
int defaultLimit = Math.max(chatCacheProperties.getTailSize(), 1);
|
||||
if (limit == null || limit <= 0) {
|
||||
@@ -61,4 +78,15 @@ public class PublicChatSessionRestoreServiceImpl implements PublicChatSessionRes
|
||||
}
|
||||
return Math.min(limit, defaultLimit);
|
||||
}
|
||||
|
||||
private ChatSessionSummary requireOwnedSession(BigInteger userId, BigInteger assistantId, BigInteger sessionId) {
|
||||
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||
throw new BusinessException("会话不存在");
|
||||
}
|
||||
if (!Objects.equals(summary.getUserId(), userId) || !Objects.equals(summary.getAssistantId(), assistantId)) {
|
||||
throw new BusinessException("无权访问该会话");
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@ package tech.easyflow.chatlog.support;
|
||||
public final class ChatConstants {
|
||||
|
||||
public static final String SESSION_TABLE = "chat_session";
|
||||
public static final String ROUND_TABLE = "chat_round";
|
||||
public static final String CHAT_LOG_TEMPLATE = "chat_log_template";
|
||||
public static final String CHAT_LOG_PREFIX = "chat_log_";
|
||||
public static final String CHAT_PERSIST_TOPIC = "chat-persist";
|
||||
public static final String CHAT_PERSIST_GROUP = "chat-persist-group";
|
||||
public static final String CHECKPOINT_SYNC_CODE_SESSION = "chat_session_sync";
|
||||
public static final String CHECKPOINT_SYNC_CODE_LOG = "chat_log_sync";
|
||||
public static final String ROUND_STATUS_ANSWERING = "ANSWERING";
|
||||
public static final String ROUND_STATUS_READY = "READY";
|
||||
public static final String ROUND_STATUS_LOCKED = "LOCKED";
|
||||
public static final String MESSAGE_KIND_USER_PROMPT = "USER_PROMPT";
|
||||
public static final String MESSAGE_KIND_ASSISTANT_VARIANT = "ASSISTANT_VARIANT";
|
||||
|
||||
private ChatConstants() {
|
||||
}
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
package tech.easyflow.chatlog.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.chatlog.domain.command.ChatAppendMessageCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionSummaryCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.event.ChatPersistEvent;
|
||||
import tech.easyflow.chatlog.domain.event.ChatPersistEventType;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatRoundRepository;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
public class ChatPersistMySqlApplyServiceTest {
|
||||
|
||||
private final ChatPersistMySqlApplyService service =
|
||||
new ChatPersistMySqlApplyService(null, null, null, null);
|
||||
new ChatPersistMySqlApplyService(null, null, null, null, new ChatJsonSupport(new ObjectMapper()));
|
||||
|
||||
@Test
|
||||
public void shouldBuildMissingSessionUpsertFromMessageMetadata() {
|
||||
@@ -69,4 +80,101 @@ public class ChatPersistMySqlApplyServiceTest {
|
||||
Assert.assertEquals("会话-202", upsert.getTitle());
|
||||
Assert.assertEquals(BigInteger.valueOf(7), upsert.getOperatorId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotDoubleCountSummaryWhenMessageEventReplayed() {
|
||||
ChatJsonSupport jsonSupport = new ChatJsonSupport(new ObjectMapper());
|
||||
FakeSessionRepository sessionRepository = new FakeSessionRepository();
|
||||
FakeLogRepository logRepository = new FakeLogRepository(jsonSupport);
|
||||
ChatPersistMySqlApplyService applyService = new ChatPersistMySqlApplyService(
|
||||
sessionRepository,
|
||||
logRepository,
|
||||
new FakeRoundRepository(),
|
||||
new FakeTableManager(),
|
||||
jsonSupport
|
||||
);
|
||||
ChatAppendMessageCommand command = new ChatAppendMessageCommand();
|
||||
command.setMessageId(BigInteger.valueOf(301));
|
||||
command.setSessionId(BigInteger.valueOf(401));
|
||||
command.setTenantId(BigInteger.ONE);
|
||||
command.setDeptId(BigInteger.ONE);
|
||||
command.setUserId(BigInteger.valueOf(7));
|
||||
command.setAssistantId(BigInteger.valueOf(8));
|
||||
command.setSenderId(BigInteger.valueOf(7));
|
||||
command.setSenderName("admin");
|
||||
command.setSenderRole("user");
|
||||
command.setContentText("第一条消息");
|
||||
command.setCreatedBy(BigInteger.valueOf(7));
|
||||
command.setCreated(new Date(4_000L));
|
||||
|
||||
ChatPersistEvent event = new ChatPersistEvent();
|
||||
event.setEventId("message-301");
|
||||
event.setEventType(ChatPersistEventType.USER_MESSAGE_APPENDED);
|
||||
event.setSessionId(command.getSessionId());
|
||||
event.setPayload(jsonSupport.toJson(command));
|
||||
|
||||
applyService.apply(List.of(event));
|
||||
applyService.apply(List.of(event));
|
||||
|
||||
Assert.assertEquals(1, sessionRepository.summaryCommands.size());
|
||||
ChatSessionSummaryCommand summaryCommand = sessionRepository.summaryCommands.get(0);
|
||||
Assert.assertEquals(1, summaryCommand.getMessageIncrement());
|
||||
Assert.assertEquals("第一条消息", summaryCommand.getLastMessagePreview());
|
||||
Assert.assertEquals(new Date(4_000L), summaryCommand.getLastMessageAt());
|
||||
Assert.assertEquals(new Date(4_000L), summaryCommand.getAccessAt());
|
||||
}
|
||||
|
||||
private static final class FakeSessionRepository extends MySqlChatSessionRepository {
|
||||
|
||||
private final List<ChatSessionSummaryCommand> summaryCommands = new ArrayList<>();
|
||||
|
||||
private FakeSessionRepository() {
|
||||
super(null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void createOrTouchBatch(List<ChatSessionUpsertCommand> commands) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateSummaries(List<ChatSessionSummaryCommand> commands) {
|
||||
summaryCommands.addAll(commands);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeLogRepository extends MySqlChatLogRepository {
|
||||
|
||||
private boolean inserted;
|
||||
|
||||
private FakeLogRepository(ChatJsonSupport jsonSupport) {
|
||||
super(null, null, jsonSupport);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatAppendMessageCommand> appendMessages(List<ChatAppendMessageCommand> commands) {
|
||||
if (inserted) {
|
||||
return List.of();
|
||||
}
|
||||
inserted = true;
|
||||
return commands;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeRoundRepository extends MySqlChatRoundRepository {
|
||||
|
||||
private FakeRoundRepository() {
|
||||
super(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FakeTableManager extends MySqlChatLogTableManager {
|
||||
|
||||
private FakeTableManager() {
|
||||
super(null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ensureMonthTable(YearMonth month) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundSelectCommand;
|
||||
import tech.easyflow.chatlog.domain.command.ChatRoundUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.service.ChatRoundCommandService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatConstants;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link ChatRoundOperateServiceImpl} 单元测试。
|
||||
*/
|
||||
public class ChatRoundOperateServiceImplTest {
|
||||
|
||||
/**
|
||||
* 切换答案版本时应精准查询目标版本,避免先加载全部版本再过滤。
|
||||
*/
|
||||
@Test
|
||||
public void selectVariantShouldReadTargetVariantDirectly() {
|
||||
FakeRoundQueryService queryService = new FakeRoundQueryService();
|
||||
queryService.round = round(BigInteger.valueOf(1001), BigInteger.valueOf(2001), 2, ChatConstants.ROUND_STATUS_READY);
|
||||
queryService.latestRound = queryService.round;
|
||||
queryService.targetVariant = message(BigInteger.valueOf(3002), 2);
|
||||
FakeRoundCommandService commandService = new FakeRoundCommandService();
|
||||
ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, commandService);
|
||||
|
||||
ChatMessageRecord selected = service.selectVariant(
|
||||
BigInteger.valueOf(1001),
|
||||
BigInteger.valueOf(2001),
|
||||
2,
|
||||
BigInteger.valueOf(7)
|
||||
);
|
||||
|
||||
Assert.assertEquals(BigInteger.valueOf(3002), selected.getId());
|
||||
Assert.assertEquals(Integer.valueOf(2), selected.getSelectedVariantIndex());
|
||||
Assert.assertEquals(Integer.valueOf(2), selected.getVariantCount());
|
||||
Assert.assertEquals(Boolean.TRUE, selected.getSwitchable());
|
||||
Assert.assertEquals(1, queryService.getRoundVariantCalls);
|
||||
Assert.assertEquals(0, queryService.listRoundVariantsCalls);
|
||||
Assert.assertNotNull(commandService.selectedCommand);
|
||||
Assert.assertEquals(BigInteger.valueOf(3002), commandService.selectedCommand.getSelectedAssistantMessageId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出答案版本时应由业务层统一补齐当前选中态和可切换状态。
|
||||
*/
|
||||
@Test
|
||||
public void listVariantsShouldFillVariantMetadata() {
|
||||
FakeRoundQueryService queryService = new FakeRoundQueryService();
|
||||
queryService.round = round(BigInteger.valueOf(1001), BigInteger.valueOf(2001), 2, ChatConstants.ROUND_STATUS_READY);
|
||||
queryService.latestRound = queryService.round;
|
||||
queryService.variants = List.of(message(BigInteger.valueOf(3001), 1), message(BigInteger.valueOf(3002), 2));
|
||||
ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, new FakeRoundCommandService());
|
||||
|
||||
List<ChatMessageRecord> variants = service.listVariants(BigInteger.valueOf(1001), BigInteger.valueOf(2001));
|
||||
|
||||
Assert.assertEquals(2, variants.size());
|
||||
for (ChatMessageRecord variant : variants) {
|
||||
Assert.assertEquals(Integer.valueOf(2), variant.getVariantCount());
|
||||
Assert.assertEquals(Integer.valueOf(2), variant.getSelectedVariantIndex());
|
||||
Assert.assertEquals(Boolean.TRUE, variant.getSwitchable());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 已锁定轮次禁止切换,避免改变已有后续上下文。
|
||||
*/
|
||||
@Test(expected = BusinessException.class)
|
||||
public void selectVariantShouldRejectLockedRound() {
|
||||
FakeRoundQueryService queryService = new FakeRoundQueryService();
|
||||
queryService.round = round(BigInteger.valueOf(1001), BigInteger.valueOf(2001), 2, ChatConstants.ROUND_STATUS_LOCKED);
|
||||
queryService.latestRound = queryService.round;
|
||||
ChatRoundOperateServiceImpl service = new ChatRoundOperateServiceImpl(queryService, new FakeRoundCommandService());
|
||||
|
||||
service.selectVariant(BigInteger.valueOf(1001), BigInteger.valueOf(2001), 1, BigInteger.valueOf(7));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增迁移必须为热表版本切换查询补齐索引。
|
||||
*
|
||||
* @throws Exception 读取迁移文件失败时抛出
|
||||
*/
|
||||
@Test
|
||||
public void migrationShouldCreateRoundVariantIndex() throws Exception {
|
||||
String sql = Files.readString(
|
||||
resolveMigrationPath("V18__mysql_chat_round_variant_index.sql"),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
Assert.assertTrue(sql.contains("idx_chat_log_round_variant"));
|
||||
Assert.assertTrue(sql.contains("`session_id`, `round_id`, `message_kind`, `variant_index`, `created`, `id`"));
|
||||
Assert.assertFalse(sql.contains("V16__mysql_chat_round_variant"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前测试工作目录向上查找迁移文件,兼容根工程与模块工程两种运行方式。
|
||||
*
|
||||
* @param fileName 迁移文件名
|
||||
* @return 迁移文件路径
|
||||
* @throws Exception 未找到迁移文件时抛出
|
||||
*/
|
||||
private static Path resolveMigrationPath(String fileName) throws Exception {
|
||||
Path current = Path.of("").toAbsolutePath();
|
||||
while (current != null) {
|
||||
Path candidate = current.resolve(
|
||||
"easyflow-starter/easyflow-starter-all/src/main/resources/db/migration/mysql/" + fileName
|
||||
);
|
||||
if (Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
throw new java.nio.file.NoSuchFileException(fileName);
|
||||
}
|
||||
|
||||
private static ChatRoundRecord round(BigInteger sessionId, BigInteger roundId, int selectedVariantIndex, String status) {
|
||||
ChatRoundRecord round = new ChatRoundRecord();
|
||||
round.setId(roundId);
|
||||
round.setSessionId(sessionId);
|
||||
round.setRoundNo(1);
|
||||
round.setSelectedVariantIndex(selectedVariantIndex);
|
||||
round.setVariantCount(2);
|
||||
round.setStatus(status);
|
||||
return round;
|
||||
}
|
||||
|
||||
private static ChatMessageRecord message(BigInteger id, int variantIndex) {
|
||||
ChatMessageRecord record = new ChatMessageRecord();
|
||||
record.setId(id);
|
||||
record.setSessionId(BigInteger.valueOf(1001));
|
||||
record.setRoundId(BigInteger.valueOf(2001));
|
||||
record.setVariantIndex(variantIndex);
|
||||
record.setSenderRole("assistant");
|
||||
record.setMessageKind(ChatConstants.MESSAGE_KIND_ASSISTANT_VARIANT);
|
||||
record.setContentText("答案 " + variantIndex);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮次读服务测试替身。
|
||||
*/
|
||||
private static final class FakeRoundQueryService implements ChatRoundQueryService {
|
||||
|
||||
private ChatRoundRecord round;
|
||||
private ChatRoundRecord latestRound;
|
||||
private ChatMessageRecord targetVariant;
|
||||
private List<ChatMessageRecord> variants = List.of();
|
||||
private int getRoundVariantCalls;
|
||||
private int listRoundVariantsCalls;
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord getLatestRound(BigInteger sessionId) {
|
||||
return latestRound;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord getRound(BigInteger sessionId, BigInteger roundId) {
|
||||
return round;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listRoundVariants(BigInteger sessionId, BigInteger roundId) {
|
||||
listRoundVariantsCalls += 1;
|
||||
return variants;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatMessageRecord getRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex) {
|
||||
getRoundVariantCalls += 1;
|
||||
return targetVariant;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasRounds(BigInteger sessionId) {
|
||||
return round != null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮次写服务测试替身。
|
||||
*/
|
||||
private static final class FakeRoundCommandService implements ChatRoundCommandService {
|
||||
|
||||
private ChatRoundSelectCommand selectedCommand;
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord createOrTouchRound(ChatRoundUpsertCommand command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void selectVariant(ChatRoundSelectCommand command) {
|
||||
selectedCommand = command;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.chatlog.cache.ChatHotStateService;
|
||||
import tech.easyflow.chatlog.config.ChatCacheProperties;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogRepository;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatLogTableManager;
|
||||
import tech.easyflow.chatlog.repository.mysql.MySqlChatSessionRepository;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.time.YearMonth;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link ChatSessionQueryServiceImpl} 单元测试。
|
||||
*/
|
||||
public class ChatSessionQueryServiceImplTest {
|
||||
|
||||
/**
|
||||
* 会话列表必须以 MySQL 会话表为唯一权威来源,不再使用 Redis 列表索引。
|
||||
*/
|
||||
@Test
|
||||
public void pageSessionsShouldUseMysqlRepositoryAsAuthority() {
|
||||
FakeSessionRepository sessionRepository = new FakeSessionRepository();
|
||||
sessionRepository.sessions = List.of(session(BigInteger.valueOf(1001), 4));
|
||||
sessionRepository.count = 1;
|
||||
ChatSessionQueryServiceImpl service = new ChatSessionQueryServiceImpl(
|
||||
sessionRepository,
|
||||
new FakeLogRepository(),
|
||||
new FakeTableManager(List.of()),
|
||||
new FakeHotStateService()
|
||||
);
|
||||
|
||||
ChatSessionPage page = service.pageSessions(BigInteger.valueOf(7), null, new ChatPageQuery());
|
||||
|
||||
Assert.assertEquals(1, page.getTotal());
|
||||
Assert.assertEquals(1, page.getRecords().size());
|
||||
Assert.assertEquals(1, sessionRepository.countSessionsCalls);
|
||||
Assert.assertEquals(1, sessionRepository.listSessionsCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作台消息分页必须走 MySQL 热表主线查询,并保持分页参数语义。
|
||||
*/
|
||||
@Test
|
||||
public void pageMainlineMessagesShouldReadMysqlHotTables() {
|
||||
FakeSessionRepository sessionRepository = new FakeSessionRepository();
|
||||
sessionRepository.summary = session(BigInteger.valueOf(2001), 6);
|
||||
FakeLogRepository logRepository = new FakeLogRepository();
|
||||
logRepository.records = List.of(message(3001), message(3002));
|
||||
List<YearMonth> months = List.of(YearMonth.of(2026, 5));
|
||||
ChatSessionQueryServiceImpl service = new ChatSessionQueryServiceImpl(
|
||||
sessionRepository,
|
||||
logRepository,
|
||||
new FakeTableManager(months),
|
||||
new FakeHotStateService()
|
||||
);
|
||||
ChatPageQuery query = new ChatPageQuery();
|
||||
query.setPageNumber(2);
|
||||
query.setPageSize(2);
|
||||
|
||||
ChatHistoryPage page = service.pageMainlineMessages(BigInteger.valueOf(2001), query);
|
||||
|
||||
Assert.assertEquals(6, page.getTotal());
|
||||
Assert.assertEquals(2, page.getRecords().size());
|
||||
Assert.assertEquals(BigInteger.valueOf(2001), logRepository.capturedSessionId);
|
||||
Assert.assertEquals(months, logRepository.capturedMonths);
|
||||
Assert.assertEquals(2, logRepository.capturedOffset);
|
||||
Assert.assertEquals(2, logRepository.capturedLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 当 MySQL 摘要计数滞后时,分页 total 至少覆盖当前已返回的数据范围。
|
||||
*/
|
||||
@Test
|
||||
public void pageMainlineMessagesShouldNotReturnTotalSmallerThanCurrentPage() {
|
||||
FakeSessionRepository sessionRepository = new FakeSessionRepository();
|
||||
sessionRepository.summary = session(BigInteger.valueOf(2002), 1);
|
||||
FakeLogRepository logRepository = new FakeLogRepository();
|
||||
logRepository.records = List.of(message(4001), message(4002));
|
||||
ChatSessionQueryServiceImpl service = new ChatSessionQueryServiceImpl(
|
||||
sessionRepository,
|
||||
logRepository,
|
||||
new FakeTableManager(List.of(YearMonth.of(2026, 5))),
|
||||
new FakeHotStateService()
|
||||
);
|
||||
ChatPageQuery query = new ChatPageQuery();
|
||||
query.setPageNumber(2);
|
||||
query.setPageSize(2);
|
||||
|
||||
ChatHistoryPage page = service.pageMainlineMessages(BigInteger.valueOf(2002), query);
|
||||
|
||||
Assert.assertEquals(4, page.getTotal());
|
||||
}
|
||||
|
||||
private static ChatSessionSummary session(BigInteger id, int messageCount) {
|
||||
ChatSessionSummary summary = new ChatSessionSummary();
|
||||
summary.setId(id);
|
||||
summary.setUserId(BigInteger.valueOf(7));
|
||||
summary.setMessageCount(messageCount);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static ChatMessageRecord message(long id) {
|
||||
ChatMessageRecord record = new ChatMessageRecord();
|
||||
record.setId(BigInteger.valueOf(id));
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 会话仓储测试替身。
|
||||
*/
|
||||
private static final class FakeSessionRepository extends MySqlChatSessionRepository {
|
||||
|
||||
private long count;
|
||||
private int countSessionsCalls;
|
||||
private int listSessionsCalls;
|
||||
private ChatSessionSummary summary;
|
||||
private List<ChatSessionSummary> sessions = new ArrayList<>();
|
||||
|
||||
private FakeSessionRepository() {
|
||||
super(null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, ChatPageQuery query) {
|
||||
listSessionsCalls += 1;
|
||||
return sessions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countSessions(BigInteger userId, BigInteger assistantId) {
|
||||
countSessionsCalls += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatSessionSummary findBySessionId(BigInteger sessionId) {
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 消息仓储测试替身。
|
||||
*/
|
||||
private static final class FakeLogRepository extends MySqlChatLogRepository {
|
||||
|
||||
private BigInteger capturedSessionId;
|
||||
private List<YearMonth> capturedMonths;
|
||||
private long capturedOffset;
|
||||
private int capturedLimit;
|
||||
private List<ChatMessageRecord> records = new ArrayList<>();
|
||||
|
||||
private FakeLogRepository() {
|
||||
super(null, null, new ChatJsonSupport(new ObjectMapper()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> listMainlineMessages(BigInteger sessionId, List<YearMonth> months, long offset, int limit) {
|
||||
capturedSessionId = sessionId;
|
||||
capturedMonths = months;
|
||||
capturedOffset = offset;
|
||||
capturedLimit = limit;
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL 热表管理器测试替身。
|
||||
*/
|
||||
private static final class FakeTableManager extends MySqlChatLogTableManager {
|
||||
|
||||
private final List<YearMonth> months;
|
||||
|
||||
private FakeTableManager(List<YearMonth> months) {
|
||||
super(null, null);
|
||||
this.months = months;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<YearMonth> listRecentExistingMonths(int retentionMonths) {
|
||||
return months;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 热态测试替身,避免单测依赖真实 Redis。
|
||||
*/
|
||||
private static final class FakeHotStateService extends ChatHotStateService {
|
||||
|
||||
private FakeHotStateService() {
|
||||
super(null, new ObjectMapper(), new ChatCacheProperties());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatSessionSummary getSessionSummary(BigInteger sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cacheSessionSummary(ChatSessionSummary summary) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> getSessionTail(BigInteger sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSessionTail(BigInteger sessionId, List<ChatMessageRecord> records) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package tech.easyflow.chatlog.service.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatRoundRecord;
|
||||
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionExtPayload;
|
||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||
import tech.easyflow.chatlog.service.ChatPersistDispatcher;
|
||||
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||
import tech.easyflow.chatlog.service.ChatRoundQueryService;
|
||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||
import tech.easyflow.chatlog.support.ChatJsonSupport;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||
|
||||
import java.math.BigInteger;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* {@link ChatlogRuntimeListener} 单元测试。
|
||||
*/
|
||||
public class ChatlogRuntimeListenerTest {
|
||||
|
||||
/**
|
||||
* 会话准备阶段应把额外知识库选择写入 ext_json。
|
||||
*/
|
||||
@Test
|
||||
public void onSessionPreparedShouldWriteExtraKnowledgeIdsToExtJson() {
|
||||
CapturingChatPersistDispatcher dispatcher = new CapturingChatPersistDispatcher();
|
||||
ChatlogRuntimeListener listener = new ChatlogRuntimeListener(
|
||||
dispatcher,
|
||||
new NoopChatRoundOperateService(),
|
||||
new NoopChatRoundQueryService(),
|
||||
new NoopChatSessionQueryService(),
|
||||
new ChatJsonSupport(new ObjectMapper())
|
||||
);
|
||||
ChatRuntimeContext context = new ChatRuntimeContext();
|
||||
context.setSessionId(BigInteger.valueOf(1001));
|
||||
context.setTenantId(BigInteger.ONE);
|
||||
context.setDeptId(BigInteger.TEN);
|
||||
context.setUserId(BigInteger.valueOf(7));
|
||||
context.setUserAccount("admin");
|
||||
context.setAssistantId(BigInteger.valueOf(88));
|
||||
context.setAssistantCode("bot-88");
|
||||
context.setAssistantName("测试助手");
|
||||
context.setSessionTitle("你好");
|
||||
context.getExt().put(
|
||||
ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS,
|
||||
List.of(BigInteger.valueOf(11), BigInteger.valueOf(22))
|
||||
);
|
||||
|
||||
listener.onSessionPrepared(context);
|
||||
|
||||
Assert.assertNotNull(dispatcher.captured);
|
||||
ChatSessionExtPayload payload = new ChatJsonSupport(new ObjectMapper())
|
||||
.fromJson(dispatcher.captured.getExtJson(), ChatSessionExtPayload.class);
|
||||
Assert.assertEquals(
|
||||
List.of(BigInteger.valueOf(11), BigInteger.valueOf(22)),
|
||||
payload.getExtraKnowledgeIds()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成时历史上下文应排除当前轮旧问题和旧答案。
|
||||
*/
|
||||
@Test
|
||||
public void loadMessagesShouldExcludeRegenerateRoundHistory() {
|
||||
ChatlogRuntimeListener listener = new ChatlogRuntimeListener(
|
||||
null,
|
||||
new NoopChatRoundOperateService(),
|
||||
new NoopChatRoundQueryService(),
|
||||
new TailChatSessionQueryService(List.of(
|
||||
record(4, 2, "assistant", "旧答案"),
|
||||
record(3, 2, "user", "当前问题"),
|
||||
record(2, 1, "assistant", "上一轮答案"),
|
||||
record(1, 1, "user", "上一轮问题")
|
||||
)),
|
||||
new ChatJsonSupport(new ObjectMapper())
|
||||
);
|
||||
ChatRuntimeContext context = new ChatRuntimeContext();
|
||||
context.setSessionId(BigInteger.valueOf(1001));
|
||||
context.getExt().put(ChatRuntimeExtKeys.REGENERATE_ROUND_ID, BigInteger.valueOf(2));
|
||||
|
||||
List<tech.easyflow.core.runtime.ChatRuntimeMessage> messages = listener.loadMessages(context, 10);
|
||||
|
||||
Assert.assertEquals(2, messages.size());
|
||||
Assert.assertEquals("上一轮问题", messages.get(0).getContentText());
|
||||
Assert.assertEquals("上一轮答案", messages.get(1).getContentText());
|
||||
}
|
||||
|
||||
private static ChatMessageRecord record(long id, int roundId, String role, String text) {
|
||||
ChatMessageRecord record = new ChatMessageRecord();
|
||||
record.setId(BigInteger.valueOf(id));
|
||||
record.setSessionId(BigInteger.valueOf(1001));
|
||||
record.setRoundId(BigInteger.valueOf(roundId));
|
||||
record.setSenderRole(role);
|
||||
record.setContentType("TEXT");
|
||||
record.setContentText(text);
|
||||
record.setCreated(new Date(id));
|
||||
return record;
|
||||
}
|
||||
|
||||
private static class CapturingChatPersistDispatcher extends ChatPersistDispatcher {
|
||||
|
||||
private ChatSessionUpsertCommand captured;
|
||||
|
||||
private CapturingChatPersistDispatcher() {
|
||||
super(null, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatSessionSummary createOrTouchSession(ChatSessionUpsertCommand command) {
|
||||
this.captured = command;
|
||||
return new ChatSessionSummary();
|
||||
}
|
||||
}
|
||||
|
||||
private static class NoopChatRoundOperateService implements ChatRoundOperateService {
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord requireRegeneratableRound(BigInteger sessionId, BigInteger roundId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<tech.easyflow.chatlog.domain.dto.ChatMessageRecord> listVariants(BigInteger sessionId, BigInteger roundId) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public tech.easyflow.chatlog.domain.dto.ChatMessageRecord selectVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NoopChatRoundQueryService implements ChatRoundQueryService {
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord getLatestRound(BigInteger sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatRoundRecord getRound(BigInteger sessionId, BigInteger roundId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<tech.easyflow.chatlog.domain.dto.ChatMessageRecord> listRoundVariants(BigInteger sessionId, BigInteger roundId) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public tech.easyflow.chatlog.domain.dto.ChatMessageRecord getRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasRounds(BigInteger sessionId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static class NoopChatSessionQueryService implements ChatSessionQueryService {
|
||||
|
||||
@Override
|
||||
public List<ChatSessionSummary> listSessions(BigInteger userId, BigInteger assistantId, tech.easyflow.chatlog.domain.query.ChatPageQuery query) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countSessions(BigInteger userId, BigInteger assistantId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public tech.easyflow.chatlog.domain.dto.ChatSessionPage pageSessions(BigInteger userId, BigInteger assistantId, tech.easyflow.chatlog.domain.query.ChatPageQuery query) {
|
||||
return new tech.easyflow.chatlog.domain.dto.ChatSessionPage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatSessionSummary getSessionSummary(BigInteger sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public tech.easyflow.chatlog.domain.dto.ChatHistoryPage pageMainlineMessages(BigInteger sessionId, tech.easyflow.chatlog.domain.query.ChatPageQuery query) {
|
||||
return new tech.easyflow.chatlog.domain.dto.ChatHistoryPage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<tech.easyflow.chatlog.domain.dto.ChatMessageRecord> listMainlineMessages(BigInteger sessionId) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<tech.easyflow.chatlog.domain.dto.ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static class TailChatSessionQueryService extends NoopChatSessionQueryService {
|
||||
|
||||
private final List<ChatMessageRecord> records;
|
||||
|
||||
private TailChatSessionQueryService(List<ChatMessageRecord> records) {
|
||||
this.records = records;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ChatMessageRecord> getRecentTail(BigInteger sessionId, int limit) {
|
||||
return records.subList(0, Math.min(records.size(), limit));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ public class SysApiKey extends SysApiKeyBase {
|
||||
@Column(ignore = true)
|
||||
private Boolean knowledgeShareEnabled;
|
||||
|
||||
@Column(ignore = true)
|
||||
private Boolean workflowApiEnabled;
|
||||
|
||||
@RelationOneToMany(selfField = "id", targetField = "apiKeyId", targetTable = "tb_sys_api_key_resource_mapping")
|
||||
private List<SysApiKeyResourceMapping> resourcePermissions;
|
||||
|
||||
@@ -52,4 +55,12 @@ public class SysApiKey extends SysApiKeyBase {
|
||||
public void setKnowledgeShareEnabled(Boolean knowledgeShareEnabled) {
|
||||
this.knowledgeShareEnabled = knowledgeShareEnabled;
|
||||
}
|
||||
|
||||
public Boolean getWorkflowApiEnabled() {
|
||||
return workflowApiEnabled;
|
||||
}
|
||||
|
||||
public void setWorkflowApiEnabled(Boolean workflowApiEnabled) {
|
||||
this.workflowApiEnabled = workflowApiEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `chat_session`
|
||||
ADD COLUMN `ext_json` json NULL COMMENT '会话扩展信息' AFTER `title`;
|
||||
@@ -0,0 +1,20 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO `tb_sys_menu` (
|
||||
`id`, `parent_id`, `menu_type`, `menu_title`, `menu_url`, `component`, `menu_icon`,
|
||||
`is_show`, `permission_tag`, `sort_no`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`
|
||||
)
|
||||
SELECT
|
||||
367200000000000001, 0, 0, 'menus.ai.chat', '/ai/chat', '/ai/chat/index', 'svg:talk',
|
||||
1, '', 12, 0, '2026-05-12 10:00:00', 1, '2026-05-12 10:00:00', 1, '管理端聊天工作台菜单'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `tb_sys_menu` WHERE `id` = 367200000000000001
|
||||
);
|
||||
|
||||
INSERT INTO `tb_sys_role_menu` (`id`, `role_id`, `menu_id`)
|
||||
SELECT 367200000000000101, 1, 367200000000000001
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `tb_sys_role_menu` WHERE `id` = 367200000000000101
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
CREATE TABLE IF NOT EXISTS `chat_round`
|
||||
(
|
||||
`id` bigint UNSIGNED NOT NULL COMMENT '轮次ID',
|
||||
`session_id` bigint UNSIGNED NOT NULL COMMENT '会话ID',
|
||||
`round_no` int NOT NULL COMMENT '轮次序号',
|
||||
`user_message_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT '用户消息ID',
|
||||
`selected_assistant_message_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT '当前选中的助手答案消息ID',
|
||||
`selected_variant_index` int NOT NULL DEFAULT 0 COMMENT '当前选中的答案版本序号',
|
||||
`variant_count` int NOT NULL DEFAULT 0 COMMENT '答案版本总数',
|
||||
`status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'READY' COMMENT '轮次状态',
|
||||
`created` datetime NOT NULL COMMENT '创建时间',
|
||||
`modified` datetime NOT NULL COMMENT '修改时间',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_chat_round_session_round_no` (`session_id`, `round_no`) USING BTREE,
|
||||
KEY `idx_chat_round_session_modified` (`session_id`, `modified`, `id`) USING BTREE
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '聊天轮次热数据表';
|
||||
|
||||
SET @chat_log_template_alter = (
|
||||
SELECT CASE
|
||||
WHEN COUNT(1) = 0 THEN 'SELECT 1'
|
||||
ELSE CONCAT(
|
||||
'ALTER TABLE `chat_log_template` ',
|
||||
GROUP_CONCAT(stmt ORDER BY ord SEPARATOR ', ')
|
||||
)
|
||||
END
|
||||
FROM (
|
||||
SELECT 1 AS ord,
|
||||
'ADD COLUMN `round_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT ''轮次ID'' AFTER `assistant_id`' AS stmt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'chat_log_template'
|
||||
AND column_name = 'round_id'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 2 AS ord,
|
||||
'ADD COLUMN `round_no` int NULL DEFAULT NULL COMMENT ''轮次序号'' AFTER `round_id`' AS stmt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'chat_log_template'
|
||||
AND column_name = 'round_no'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 3 AS ord,
|
||||
'ADD COLUMN `message_kind` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT ''消息类型'' AFTER `sender_role`' AS stmt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'chat_log_template'
|
||||
AND column_name = 'message_kind'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 4 AS ord,
|
||||
'ADD COLUMN `variant_index` int NULL DEFAULT NULL COMMENT ''答案版本序号'' AFTER `message_kind`' AS stmt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'chat_log_template'
|
||||
AND column_name = 'variant_index'
|
||||
)
|
||||
) changes
|
||||
);
|
||||
|
||||
PREPARE stmt_chat_log_template_alter FROM @chat_log_template_alter;
|
||||
EXECUTE stmt_chat_log_template_alter;
|
||||
DEALLOCATE PREPARE stmt_chat_log_template_alter;
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_chat_round_log_columns;
|
||||
|
||||
DELIMITER $$
|
||||
|
||||
CREATE PROCEDURE migrate_chat_round_log_columns()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE v_table_name varchar(128);
|
||||
DECLARE v_sql LONGTEXT;
|
||||
DECLARE table_cursor CURSOR FOR
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'chat_log\\_%'
|
||||
AND table_name <> 'chat_log_template';
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN table_cursor;
|
||||
|
||||
table_loop:
|
||||
LOOP
|
||||
FETCH table_cursor INTO v_table_name;
|
||||
IF done = 1 THEN
|
||||
LEAVE table_loop;
|
||||
END IF;
|
||||
|
||||
SET v_sql = (
|
||||
SELECT CASE
|
||||
WHEN COUNT(1) = 0 THEN 'SELECT 1'
|
||||
ELSE CONCAT(
|
||||
'ALTER TABLE `', v_table_name, '` ',
|
||||
GROUP_CONCAT(stmt ORDER BY ord SEPARATOR ', ')
|
||||
)
|
||||
END
|
||||
FROM (
|
||||
SELECT 1 AS ord,
|
||||
'ADD COLUMN `round_id` bigint UNSIGNED NULL DEFAULT NULL COMMENT ''轮次ID'' AFTER `assistant_id`' AS stmt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = v_table_name
|
||||
AND column_name = 'round_id'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 2 AS ord,
|
||||
'ADD COLUMN `round_no` int NULL DEFAULT NULL COMMENT ''轮次序号'' AFTER `round_id`' AS stmt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = v_table_name
|
||||
AND column_name = 'round_no'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 3 AS ord,
|
||||
'ADD COLUMN `message_kind` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT ''消息类型'' AFTER `sender_role`' AS stmt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = v_table_name
|
||||
AND column_name = 'message_kind'
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 4 AS ord,
|
||||
'ADD COLUMN `variant_index` int NULL DEFAULT NULL COMMENT ''答案版本序号'' AFTER `message_kind`' AS stmt
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = v_table_name
|
||||
AND column_name = 'variant_index'
|
||||
)
|
||||
) changes
|
||||
);
|
||||
|
||||
SET @chat_round_log_table_alter = v_sql;
|
||||
PREPARE stmt_chat_round_log_alter FROM @chat_round_log_table_alter;
|
||||
EXECUTE stmt_chat_round_log_alter;
|
||||
DEALLOCATE PREPARE stmt_chat_round_log_alter;
|
||||
END LOOP;
|
||||
|
||||
CLOSE table_cursor;
|
||||
END $$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_chat_round_log_columns();
|
||||
DROP PROCEDURE IF EXISTS migrate_chat_round_log_columns;
|
||||
@@ -0,0 +1,8 @@
|
||||
INSERT INTO `tb_sys_api_key_resource` (`id`, `request_interface`, `title`)
|
||||
VALUES
|
||||
(366700000000000003, '/public-api/workflow/getByIdOrAlias', '工作流 API 调用'),
|
||||
(366700000000000004, '/public-api/workflow/getRunningParameters', '工作流 API 调用'),
|
||||
(366700000000000005, '/public-api/workflow/runAsync', '工作流 API 调用'),
|
||||
(366700000000000006, '/public-api/workflow/getChainStatus', '工作流 API 调用'),
|
||||
(366700000000000007, '/public-api/workflow/resume', '工作流 API 调用')
|
||||
ON DUPLICATE KEY UPDATE `title` = VALUES(`title`);
|
||||
@@ -0,0 +1,58 @@
|
||||
SET @chat_log_template_round_variant_index = (
|
||||
SELECT CASE
|
||||
WHEN COUNT(1) > 0 THEN 'SELECT 1'
|
||||
ELSE 'ALTER TABLE `chat_log_template` ADD INDEX `idx_chat_log_round_variant` (`session_id`, `round_id`, `message_kind`, `variant_index`, `created`, `id`)'
|
||||
END
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'chat_log_template'
|
||||
AND index_name = 'idx_chat_log_round_variant'
|
||||
);
|
||||
|
||||
PREPARE stmt_chat_log_template_round_variant_index FROM @chat_log_template_round_variant_index;
|
||||
EXECUTE stmt_chat_log_template_round_variant_index;
|
||||
DEALLOCATE PREPARE stmt_chat_log_template_round_variant_index;
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_chat_log_round_variant_index;
|
||||
|
||||
CREATE PROCEDURE migrate_chat_log_round_variant_index()
|
||||
BEGIN
|
||||
DECLARE done INT DEFAULT 0;
|
||||
DECLARE v_table_name VARCHAR(128);
|
||||
DECLARE table_cursor CURSOR FOR
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'chat_log\_%'
|
||||
AND table_name <> 'chat_log_template';
|
||||
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
|
||||
|
||||
OPEN table_cursor;
|
||||
read_loop: LOOP
|
||||
FETCH table_cursor INTO v_table_name;
|
||||
IF done = 1 THEN
|
||||
LEAVE read_loop;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = v_table_name
|
||||
AND index_name = 'idx_chat_log_round_variant'
|
||||
) THEN
|
||||
SET @chat_log_round_variant_index = CONCAT(
|
||||
'ALTER TABLE `',
|
||||
v_table_name,
|
||||
'` ADD INDEX `idx_chat_log_round_variant` (`session_id`, `round_id`, `message_kind`, `variant_index`, `created`, `id`)'
|
||||
);
|
||||
PREPARE stmt_chat_log_round_variant_index FROM @chat_log_round_variant_index;
|
||||
EXECUTE stmt_chat_log_round_variant_index;
|
||||
DEALLOCATE PREPARE stmt_chat_log_round_variant_index;
|
||||
END IF;
|
||||
END LOOP;
|
||||
CLOSE table_cursor;
|
||||
END;
|
||||
|
||||
CALL migrate_chat_log_round_variant_index();
|
||||
DROP PROCEDURE IF EXISTS migrate_chat_log_round_variant_index;
|
||||
@@ -197,6 +197,28 @@ export class SseClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
if (!contentType.includes('text/event-stream')) {
|
||||
let errorMessage = '请求失败,请稍后再试';
|
||||
try {
|
||||
const body = await res.json();
|
||||
errorMessage =
|
||||
body?.error ?? body?.message ?? body?.data?.message ?? errorMessage;
|
||||
} catch {
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (text.trim()) {
|
||||
errorMessage = text.trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore body parse failures and keep fallback message
|
||||
}
|
||||
}
|
||||
showErrorOnce(errorMessage);
|
||||
options?.onError?.(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
// 在开始事件流之前检查是否还是同一个请求
|
||||
if (this.currentRequestId !== currentRequestId) {
|
||||
return;
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
|
||||
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||
|
||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
import { Close } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElEmpty, ElIcon, ElMessage, ElScrollbar } from 'element-plus';
|
||||
|
||||
import { CircleCheck, Close } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElScrollbar,
|
||||
} from 'element-plus';
|
||||
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
||||
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
|
||||
|
||||
interface ChatHistoryDetailDrawerProps {
|
||||
visible?: boolean;
|
||||
loading?: boolean;
|
||||
session?: any;
|
||||
messages?: any[];
|
||||
messages?: ChatTimeTimelineItem[];
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: (() => Promise<void> | void) | undefined;
|
||||
switchingRoundIds?: string[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
|
||||
@@ -32,13 +24,20 @@ const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
|
||||
messages: () => [],
|
||||
hasMore: false,
|
||||
onLoadMore: undefined,
|
||||
switchingRoundIds: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
selectVariant: [
|
||||
payload: {
|
||||
direction: 'next' | 'previous';
|
||||
item: ChatTimeTimelineItem;
|
||||
},
|
||||
];
|
||||
}>();
|
||||
|
||||
function formatTime(value?: string) {
|
||||
function formatTime(value?: number | string) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
@@ -58,20 +57,86 @@ function resolveSenderName(item: any) {
|
||||
if (item?.senderName) {
|
||||
return item.senderName;
|
||||
}
|
||||
if (item?.role === 'tool') {
|
||||
return '工具调用';
|
||||
}
|
||||
return item?.role === 'assistant' ? '聊天助手' : '聊天用户';
|
||||
}
|
||||
|
||||
function isThinkingChain(chain: any) {
|
||||
return !chain?.id;
|
||||
}
|
||||
|
||||
function toolStatusText(status?: string) {
|
||||
return status === 'TOOL_RESULT' ? '调用成功' : '工具调用中';
|
||||
}
|
||||
|
||||
async function handleLoadMore() {
|
||||
await props.onLoadMore?.();
|
||||
}
|
||||
|
||||
function shouldShowVariantNavigator(item: ChatTimeTimelineItem) {
|
||||
return (
|
||||
item.role === 'assistant' &&
|
||||
isFinalAssistantInRound(item) &&
|
||||
Number(item.variantCount || 0) > 1 &&
|
||||
Boolean(item.roundId)
|
||||
);
|
||||
}
|
||||
|
||||
function canSwitchVariant(
|
||||
item: ChatTimeTimelineItem,
|
||||
direction: 'next' | 'previous',
|
||||
) {
|
||||
if (
|
||||
item.role !== 'assistant' ||
|
||||
!isFinalAssistantInRound(item) ||
|
||||
!item.switchable ||
|
||||
isVariantSwitching(item)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||
const total = Number(item.variantCount || 1);
|
||||
if (direction === 'previous') {
|
||||
return current > 1;
|
||||
}
|
||||
return current < total;
|
||||
}
|
||||
|
||||
function isVariantSwitching(item: ChatTimeTimelineItem) {
|
||||
return Boolean(
|
||||
item.roundId && props.switchingRoundIds.includes(String(item.roundId)),
|
||||
);
|
||||
}
|
||||
|
||||
function isFinalAssistantInRound(item: ChatTimeTimelineItem) {
|
||||
if (item.role !== 'assistant') {
|
||||
return false;
|
||||
}
|
||||
if (!item.roundId) {
|
||||
return true;
|
||||
}
|
||||
for (let index = props.messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = props.messages[index];
|
||||
if (
|
||||
candidate?.role === 'assistant' &&
|
||||
candidate.roundId === item.roundId &&
|
||||
String(candidate.variantIndex || '') === String(item.variantIndex || '')
|
||||
) {
|
||||
return candidate.id === item.id;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function canCopyMessage(item: ChatTimeTimelineItem) {
|
||||
return item.role !== 'tool' && Boolean(String(item.content || '').trim());
|
||||
}
|
||||
|
||||
async function handleCopyMessage(item: ChatTimeTimelineItem) {
|
||||
if (!canCopyMessage(item)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(item.content || ''));
|
||||
ElMessage.success('已复制');
|
||||
} catch {
|
||||
ElMessage.error('复制失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -140,7 +205,7 @@ async function handleLoadMore() {
|
||||
>
|
||||
<article
|
||||
v-for="item in messages"
|
||||
:key="item.key"
|
||||
:key="item.id"
|
||||
class="chat-history-detail__message"
|
||||
:class="`is-${item.role}`"
|
||||
>
|
||||
@@ -160,62 +225,28 @@ async function handleLoadMore() {
|
||||
class="chat-history-detail__message-bubble"
|
||||
:class="`is-${item.role}`"
|
||||
>
|
||||
<div
|
||||
v-if="item.chains?.length"
|
||||
class="chat-history-detail__message-chains"
|
||||
>
|
||||
<template
|
||||
v-for="(chain, index) in item.chains"
|
||||
:key="chain.id || index"
|
||||
>
|
||||
<ChatThinkingBlock
|
||||
v-if="isThinkingChain(chain)"
|
||||
v-model:expanded="chain.thinkingExpanded"
|
||||
:content="chain.reasoning_content"
|
||||
readonly
|
||||
:status="chain.thinkingStatus"
|
||||
class="chat-history-detail__thinking"
|
||||
/>
|
||||
|
||||
<ElCollapse v-else class="chat-history-detail__tool-panel">
|
||||
<ElCollapseItem :title="chain.name" :name="chain.id">
|
||||
<template #title>
|
||||
<div class="chat-history-detail__tool-title">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:wrench" />
|
||||
</ElIcon>
|
||||
<span class="chat-history-detail__tool-name">
|
||||
{{ chain.name }}
|
||||
</span>
|
||||
<div class="chat-history-detail__tool-status">
|
||||
<ElIcon
|
||||
v-if="chain.status === 'TOOL_RESULT'"
|
||||
size="14"
|
||||
color="var(--el-color-success)"
|
||||
>
|
||||
<CircleCheck />
|
||||
</ElIcon>
|
||||
<IconifyIcon
|
||||
v-else
|
||||
icon="mdi:clock-time-five-outline"
|
||||
/>
|
||||
<span>{{ toolStatusText(chain.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<ShowJson :value="chain.result" />
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.content && String(item.content).trim()"
|
||||
class="chat-history-detail__markdown"
|
||||
>
|
||||
<ElXMarkdown :markdown="item.content" />
|
||||
</div>
|
||||
<ChatTimeMessageContent :item="item" readonly-thinking />
|
||||
</div>
|
||||
|
||||
<ChatMessageActionBar
|
||||
:align="item.role === 'user' ? 'end' : 'start'"
|
||||
:allow-copy="canCopyMessage(item)"
|
||||
:show-variant-navigator="shouldShowVariantNavigator(item)"
|
||||
:disabled-variant-next="!canSwitchVariant(item, 'next')"
|
||||
:disabled-variant-previous="!canSwitchVariant(item, 'previous')"
|
||||
:variant-loading="isVariantSwitching(item)"
|
||||
:variant-current="
|
||||
Number(item.variantIndex || item.selectedVariantIndex || 1)
|
||||
"
|
||||
:variant-total="Number(item.variantCount || 1)"
|
||||
@copy="handleCopyMessage(item)"
|
||||
@select-next-variant="
|
||||
emit('selectVariant', { direction: 'next', item })
|
||||
"
|
||||
@select-previous-variant="
|
||||
emit('selectVariant', { direction: 'previous', item })
|
||||
"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Loading,
|
||||
} from '@element-plus/icons-vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
current?: number;
|
||||
disabled?: boolean;
|
||||
disabledNext?: boolean;
|
||||
disabledPrev?: boolean;
|
||||
loading?: boolean;
|
||||
total?: number;
|
||||
}>(),
|
||||
{
|
||||
current: 1,
|
||||
disabled: false,
|
||||
disabledNext: false,
|
||||
disabledPrev: false,
|
||||
loading: false,
|
||||
total: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
next: [];
|
||||
previous: [];
|
||||
}>();
|
||||
|
||||
function handlePrevious() {
|
||||
if (props.disabled || props.disabledPrev || props.loading) {
|
||||
return;
|
||||
}
|
||||
emit('previous');
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (props.disabled || props.disabledNext || props.loading) {
|
||||
return;
|
||||
}
|
||||
emit('next');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="total > 1"
|
||||
class="variant-nav"
|
||||
:class="{ 'is-loading': loading }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="variant-nav__button"
|
||||
:disabled="disabled || disabledPrev || loading"
|
||||
aria-label="查看上一版答案"
|
||||
title="上一版"
|
||||
@click="handlePrevious"
|
||||
>
|
||||
<Loading v-if="loading" />
|
||||
<ArrowLeft v-else />
|
||||
</button>
|
||||
|
||||
<span class="variant-nav__label">{{ current }}/{{ total }}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="variant-nav__button"
|
||||
:disabled="disabled || disabledNext || loading"
|
||||
aria-label="查看下一版答案"
|
||||
title="下一版"
|
||||
@click="handleNext"
|
||||
>
|
||||
<Loading v-if="loading" />
|
||||
<ArrowRight v-else />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.variant-nav {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.variant-nav__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.variant-nav__button:hover:not(:disabled) {
|
||||
color: hsl(var(--text-strong));
|
||||
background: hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
.variant-nav__button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 0.32);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.variant-nav__button:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.variant-nav__button :deep(svg) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.variant-nav.is-loading .variant-nav__button :deep(svg) {
|
||||
animation: variant-nav-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.variant-nav__label {
|
||||
min-width: 44px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
@keyframes variant-nav-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,429 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ArrowDown,
|
||||
Check,
|
||||
Close,
|
||||
Plus,
|
||||
} from '@element-plus/icons-vue';
|
||||
import { ElPopover } from 'element-plus';
|
||||
|
||||
interface KnowledgeOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface KnowledgeView {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const MAX_EXTRA_KNOWLEDGE_COUNT = 3;
|
||||
const knowledgePanelOpen = ref(false);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
boundKnowledges?: KnowledgeView[];
|
||||
selectedExtraKnowledges?: KnowledgeView[];
|
||||
extraKnowledgeIds?: string[];
|
||||
extraKnowledgeOptions?: KnowledgeOption[];
|
||||
disabled?: boolean;
|
||||
knowledgeDisabled?: boolean;
|
||||
loading?: boolean;
|
||||
mode?: 'editable' | 'readonly';
|
||||
}>(),
|
||||
{
|
||||
boundKnowledges: () => [],
|
||||
selectedExtraKnowledges: () => [],
|
||||
extraKnowledgeIds: () => [],
|
||||
extraKnowledgeOptions: () => [],
|
||||
disabled: false,
|
||||
knowledgeDisabled: false,
|
||||
loading: false,
|
||||
mode: 'editable',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:extraKnowledgeIds': [value: string[]];
|
||||
}>();
|
||||
|
||||
const selectedExtraIdSet = computed(() => new Set(props.extraKnowledgeIds));
|
||||
const hasAnyKnowledge = computed(
|
||||
() =>
|
||||
props.boundKnowledges.length > 0 || props.selectedExtraKnowledges.length > 0,
|
||||
);
|
||||
|
||||
function updateExtraKnowledges(value: string[]) {
|
||||
emit('update:extraKnowledgeIds', value || []);
|
||||
}
|
||||
|
||||
function toggleKnowledge(id: string) {
|
||||
const normalizedId = String(id);
|
||||
const nextIds = [...props.extraKnowledgeIds];
|
||||
const currentIndex = nextIds.findIndex((item) => String(item) === normalizedId);
|
||||
if (currentIndex >= 0) {
|
||||
nextIds.splice(currentIndex, 1);
|
||||
updateExtraKnowledges(nextIds);
|
||||
return;
|
||||
}
|
||||
if (nextIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT) {
|
||||
return;
|
||||
}
|
||||
nextIds.push(normalizedId);
|
||||
updateExtraKnowledges(nextIds);
|
||||
}
|
||||
|
||||
function removeExtraKnowledge(id: string) {
|
||||
updateExtraKnowledges(
|
||||
props.extraKnowledgeIds.filter((item) => String(item) !== String(id)),
|
||||
);
|
||||
}
|
||||
|
||||
function isKnowledgeSelected(id: string) {
|
||||
return selectedExtraIdSet.value.has(String(id));
|
||||
}
|
||||
|
||||
function isKnowledgeDisabled(id: string) {
|
||||
if (props.disabled || props.knowledgeDisabled || props.loading) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
props.extraKnowledgeIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT &&
|
||||
!isKnowledgeSelected(id)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="mode === 'editable' || hasAnyKnowledge"
|
||||
class="context-rail"
|
||||
:class="{ 'is-readonly': mode === 'readonly' }"
|
||||
>
|
||||
<div class="context-rail__row">
|
||||
<ElPopover
|
||||
v-if="mode === 'editable'"
|
||||
v-model:visible="knowledgePanelOpen"
|
||||
placement="top-start"
|
||||
:teleported="false"
|
||||
popper-class="chat-knowledge-popper"
|
||||
:show-arrow="false"
|
||||
:width="356"
|
||||
:offset="10"
|
||||
trigger="click"
|
||||
>
|
||||
<template #reference>
|
||||
<button
|
||||
type="button"
|
||||
class="context-rail__trigger"
|
||||
:disabled="disabled || knowledgeDisabled || loading"
|
||||
:class="{ 'is-open': knowledgePanelOpen }"
|
||||
>
|
||||
<Plus class="context-rail__trigger-icon" />
|
||||
<span>{{ loading ? '加载知识库中' : '知识库' }}</span>
|
||||
<ArrowDown class="context-rail__trigger-caret" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="context-rail__panel">
|
||||
<div class="context-rail__panel-head">
|
||||
<span class="context-rail__panel-title">选择知识库</span>
|
||||
<span class="context-rail__panel-caption">最多 3 个</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="extraKnowledgeOptions.length > 0"
|
||||
class="context-rail__option-list"
|
||||
>
|
||||
<button
|
||||
v-for="item in extraKnowledgeOptions"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="context-rail__option"
|
||||
:class="{
|
||||
'is-active': isKnowledgeSelected(item.value),
|
||||
'is-disabled': isKnowledgeDisabled(item.value),
|
||||
}"
|
||||
:disabled="isKnowledgeDisabled(item.value)"
|
||||
@click="toggleKnowledge(item.value)"
|
||||
>
|
||||
<span class="context-rail__option-label">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<Check
|
||||
v-if="isKnowledgeSelected(item.value)"
|
||||
class="context-rail__option-check"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="context-rail__empty">
|
||||
暂无可选知识库
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
|
||||
<div
|
||||
v-if="hasAnyKnowledge"
|
||||
class="context-rail__chips"
|
||||
>
|
||||
<span
|
||||
v-for="knowledge in boundKnowledges"
|
||||
:key="knowledge.id"
|
||||
class="context-rail__chip is-bound"
|
||||
>
|
||||
{{ knowledge.title }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-for="knowledge in selectedExtraKnowledges"
|
||||
:key="knowledge.id"
|
||||
class="context-rail__chip is-extra"
|
||||
>
|
||||
<span class="context-rail__chip-label">{{ knowledge.title }}</span>
|
||||
<button
|
||||
v-if="mode === 'editable'"
|
||||
type="button"
|
||||
class="context-rail__chip-remove"
|
||||
aria-label="移除知识库"
|
||||
@click="removeExtraKnowledge(knowledge.id)"
|
||||
>
|
||||
<Close />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.context-rail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-rail__row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.context-rail__trigger {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--text-strong));
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.86);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--surface-panel));
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.context-rail__trigger:hover:not(:disabled) {
|
||||
border-color: hsl(var(--primary) / 0.22);
|
||||
background: hsl(var(--surface-subtle));
|
||||
}
|
||||
|
||||
.context-rail__trigger.is-open {
|
||||
border-color: hsl(var(--primary) / 0.24);
|
||||
background: hsl(var(--surface-subtle));
|
||||
}
|
||||
|
||||
.context-rail__trigger:focus-visible,
|
||||
.context-rail__option:focus-visible,
|
||||
.context-rail__chip-remove:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 0.28);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.context-rail__trigger:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.context-rail__trigger-icon,
|
||||
.context-rail__trigger-caret {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.context-rail__chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.context-rail__chip {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: min(100%, 220px);
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--text-muted));
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.84);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--surface-panel));
|
||||
}
|
||||
|
||||
.context-rail__chip.is-extra {
|
||||
color: hsl(var(--text-strong));
|
||||
border-color: hsl(var(--primary) / 0.18);
|
||||
background: hsl(var(--primary) / 0.06);
|
||||
}
|
||||
|
||||
.context-rail__chip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-rail__chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
color: hsl(var(--text-muted));
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.context-rail__chip-remove:hover {
|
||||
color: hsl(var(--text-strong));
|
||||
background: hsl(var(--surface-panel) / 0.7);
|
||||
}
|
||||
|
||||
.context-rail__chip-remove :deep(svg) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.context-rail__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.context-rail__panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.context-rail__panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--text-strong));
|
||||
}
|
||||
|
||||
.context-rail__panel-caption {
|
||||
font-size: 11px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.context-rail__option-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.context-rail__option {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
text-align: left;
|
||||
color: hsl(var(--text-strong));
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--surface-subtle) / 0.82);
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.context-rail__option:hover:not(:disabled) {
|
||||
border-color: hsl(var(--primary) / 0.16);
|
||||
background: hsl(var(--surface-panel));
|
||||
}
|
||||
|
||||
.context-rail__option.is-active {
|
||||
border-color: hsl(var(--primary) / 0.18);
|
||||
background: hsl(var(--primary) / 0.07);
|
||||
}
|
||||
|
||||
.context-rail__option.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.context-rail__option-label {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-rail__option-check {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.context-rail__empty {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
:global(.chat-knowledge-popper.el-popper) {
|
||||
padding: 12px;
|
||||
border: 1px solid hsl(var(--divider-faint) / 0.88);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--surface-panel) / 0.98) 0%, hsl(var(--surface-subtle) / 0.96) 100%);
|
||||
box-shadow: 0 18px 40px -32px hsl(var(--foreground) / 0.22);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
:global(.chat-knowledge-popper.el-popper .el-popper__arrow) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.context-rail__row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.context-rail__trigger {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CopyDocument,
|
||||
RefreshRight,
|
||||
} from '@element-plus/icons-vue';
|
||||
|
||||
import ChatAnswerVariantNavigator from './ChatAnswerVariantNavigator.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
align?: 'end' | 'start';
|
||||
allowCopy?: boolean;
|
||||
allowRegenerate?: boolean;
|
||||
disabledVariantNext?: boolean;
|
||||
disabledVariantPrevious?: boolean;
|
||||
regenerateDisabled?: boolean;
|
||||
showVariantNavigator?: boolean;
|
||||
variantLoading?: boolean;
|
||||
variantCurrent?: number;
|
||||
variantTotal?: number;
|
||||
}>(),
|
||||
{
|
||||
align: 'start',
|
||||
allowCopy: false,
|
||||
allowRegenerate: false,
|
||||
disabledVariantNext: false,
|
||||
disabledVariantPrevious: false,
|
||||
regenerateDisabled: false,
|
||||
showVariantNavigator: false,
|
||||
variantLoading: false,
|
||||
variantCurrent: 1,
|
||||
variantTotal: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
copy: [];
|
||||
regenerate: [];
|
||||
selectNextVariant: [];
|
||||
selectPreviousVariant: [];
|
||||
}>();
|
||||
|
||||
function handleCopy() {
|
||||
emit('copy');
|
||||
}
|
||||
|
||||
function handleRegenerate() {
|
||||
if (props.regenerateDisabled) {
|
||||
return;
|
||||
}
|
||||
emit('regenerate');
|
||||
}
|
||||
|
||||
function handleSelectPreviousVariant() {
|
||||
emit('selectPreviousVariant');
|
||||
}
|
||||
|
||||
function handleSelectNextVariant() {
|
||||
emit('selectNextVariant');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="allowCopy || allowRegenerate || showVariantNavigator"
|
||||
class="message-actions"
|
||||
:class="`is-${align}`"
|
||||
>
|
||||
<ChatAnswerVariantNavigator
|
||||
v-if="showVariantNavigator"
|
||||
:current="variantCurrent"
|
||||
:total="variantTotal"
|
||||
:disabled-prev="disabledVariantPrevious"
|
||||
:disabled-next="disabledVariantNext"
|
||||
:loading="variantLoading"
|
||||
@previous="handleSelectPreviousVariant"
|
||||
@next="handleSelectNextVariant"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-if="allowCopy"
|
||||
type="button"
|
||||
class="message-actions__button"
|
||||
aria-label="复制消息"
|
||||
title="复制"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<CopyDocument />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="allowRegenerate"
|
||||
type="button"
|
||||
class="message-actions__button"
|
||||
:disabled="regenerateDisabled"
|
||||
aria-label="重新生成"
|
||||
title="重新生成"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<RefreshRight />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-actions {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.message-actions.is-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-actions__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
transition:
|
||||
color 0.18s ease,
|
||||
background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.message-actions__button:hover:not(:disabled) {
|
||||
color: hsl(var(--text-strong));
|
||||
background: hsl(var(--foreground) / 0.05);
|
||||
}
|
||||
|
||||
.message-actions__button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 0.32);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.message-actions__button:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message-actions__button :deep(svg) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user