feat: 完成管理端聊天工作台收口

- 新增管理端聊天工作台与会话级额外知识库持久化

- 补齐发布态聊天、历史会话只读判断与答案版本切换

- 新增 chat_round 热数据与主线消息读取支撑
This commit is contained in:
2026-05-14 20:22:46 +08:00
parent 2ad8935a61
commit 47c2bad839
63 changed files with 8609 additions and 136 deletions

View File

@@ -13,13 +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.*;
@@ -31,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;
@@ -74,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) {
@@ -162,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;
}
@@ -179,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)
);
}
@@ -194,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);
}
@@ -213,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) {
@@ -298,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);
}
@@ -307,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;
@@ -407,7 +458,9 @@ public class BotController extends BaseCurdController<BotService, Bot> {
return result;
}
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments) {
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);
@@ -422,10 +475,30 @@ public class BotController extends BaseCurdController<BotService, Bot> {
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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
package tech.easyflow.admin.dto.chatworkspace;
/**
* 管理端聊天工作台只读原因。
*/
public enum ChatWorkspaceReadOnlyReason {
ASSISTANT_OFFLINE,
ASSISTANT_DELETED,
NO_PERMISSION
}

View File

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

View File

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

View File

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

View File

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