diff --git a/easyflow-api/easyflow-api-admin/pom.xml b/easyflow-api/easyflow-api-admin/pom.xml index c4d8911..0d648ab 100644 --- a/easyflow-api/easyflow-api-admin/pom.xml +++ b/easyflow-api/easyflow-api-admin/pom.xml @@ -16,6 +16,10 @@ tech.easyflow easyflow-module-ai + + tech.easyflow + easyflow-module-chatlog + tech.easyflow easyflow-module-auth @@ -29,4 +33,4 @@ easyflow-common-captcha - \ No newline at end of file + diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java index 27b8259..0241a9e 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/BotController.java @@ -22,11 +22,15 @@ import tech.easyflow.ai.service.*; import tech.easyflow.ai.service.impl.BotServiceImpl; import tech.easyflow.common.audio.core.AudioServiceManager; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; +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.core.chat.protocol.sse.ChatSseEmitter; import tech.easyflow.core.chat.protocol.sse.ChatSseUtil; +import tech.easyflow.core.runtime.ChatChannel; +import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.service.CategoryPermissionService; @@ -158,7 +162,15 @@ public class BotController extends BaseCurdController { if (errorEmitter != null) { return errorEmitter; } - return botService.startChat(botId, prompt, conversationId, messages, chatCheckResult, attachments); + return botService.startChat( + botId, + prompt, + conversationId, + messages, + chatCheckResult, + attachments, + buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments) + ); } @PostMapping("updateLlmId") @@ -319,6 +331,24 @@ public class BotController extends BaseCurdController { return result; } + private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List attachments) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + 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.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); + return context; + } + @Override protected Result onRemoveBefore(Collection ids) { QueryWrapper queryWrapperKnowledge = QueryWrapper.create().in(BotDocumentCollection::getBotId, ids); diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ChatHistoryController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ChatHistoryController.java new file mode 100644 index 0000000..6aa6477 --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/ChatHistoryController.java @@ -0,0 +1,41 @@ +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +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; +import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery; +import tech.easyflow.chatlog.service.ChatHistoryManageService; +import tech.easyflow.common.domain.Result; + +import java.math.BigInteger; + +@RestController +@RequestMapping("/api/v1/chatHistory") +public class ChatHistoryController { + + private final ChatHistoryManageService chatHistoryManageService; + + public ChatHistoryController(ChatHistoryManageService chatHistoryManageService) { + this.chatHistoryManageService = chatHistoryManageService; + } + + @GetMapping("/sessions") + public Result listSessions(ChatSessionFilterQuery query) { + return Result.ok(chatHistoryManageService.queryAdminSessions(query)); + } + + @GetMapping("/sessions/{sessionId}") + public Result getSession(@PathVariable BigInteger sessionId) { + return Result.ok(chatHistoryManageService.getAdminSession(sessionId)); + } + + @GetMapping("/sessions/{sessionId}/messages") + public Result queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) { + return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query)); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PublicChatSessionController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PublicChatSessionController.java new file mode 100644 index 0000000..adaffba --- /dev/null +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/ai/PublicChatSessionController.java @@ -0,0 +1,38 @@ +package tech.easyflow.admin.controller.ai; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +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 java.math.BigInteger; + +@RestController +@RequestMapping("/api/v1/public-chat") +public class PublicChatSessionController { + + private final PublicChatSessionRestoreService publicChatSessionRestoreService; + + public PublicChatSessionController(PublicChatSessionRestoreService publicChatSessionRestoreService) { + this.publicChatSessionRestoreService = publicChatSessionRestoreService; + } + + @GetMapping("/session/restore") + public Result restoreSession(BigInteger botId, + BigInteger conversationId, + Integer limit) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + BigInteger userId = account == null ? null : account.getId(); + PublicChatSessionRestoreResult result = publicChatSessionRestoreService.restoreSession( + userId, + botId, + conversationId, + limit + ); + return Result.ok(result); + } +} diff --git a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/auth/AuthController.java b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/auth/AuthController.java index 0bca5e4..3a83984 100644 --- a/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/auth/AuthController.java +++ b/easyflow-api/easyflow-api-admin/src/main/java/tech/easyflow/admin/controller/auth/AuthController.java @@ -1,15 +1,16 @@ package tech.easyflow.admin.controller.auth; +import cn.dev33.satoken.annotation.SaIgnore; +import cn.dev33.satoken.stp.StpUtil; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; import tech.easyflow.auth.entity.LoginDTO; import tech.easyflow.auth.entity.LoginVO; import tech.easyflow.auth.service.AuthService; import tech.easyflow.common.domain.Result; import tech.easyflow.common.web.jsonbody.JsonBody; -import cn.dev33.satoken.stp.StpUtil; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; import java.util.List; @@ -27,6 +28,12 @@ public class AuthController { return Result.ok(res); } + @PostMapping("loginByApiKey") + @SaIgnore + public Result loginByApiKey(@JsonBody(value = "apiKey", required = true) String apiKey) { + return Result.ok(authService.loginByApiKey(apiKey)); + } + @PostMapping("logout") public Result logout() { StpUtil.logout(); diff --git a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicBotController.java b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicBotController.java index cfc82cb..27fd01b 100644 --- a/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicBotController.java +++ b/easyflow-api/easyflow-api-public/src/main/java/tech/easyflow/publicapi/controller/PublicBotController.java @@ -13,10 +13,13 @@ import tech.easyflow.ai.service.BotService; import tech.easyflow.ai.service.impl.BotServiceImpl; import tech.easyflow.common.domain.Result; import tech.easyflow.core.chat.protocol.sse.ChatSseUtil; +import tech.easyflow.core.runtime.ChatChannel; +import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.system.entity.SysApiKey; import tech.easyflow.system.service.SysApiKeyService; import javax.annotation.Resource; +import java.math.BigInteger; /** * bot 接口 @@ -51,6 +54,7 @@ public class PublicBotController { return ChatSseUtil.sendSystemError(null, "Apikey不能为空!"); } sysApiKeyService.checkApikeyPermission(apikey, requestURI); + SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apikey); BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult(); int size = chatRequestParams.getMessages().size(); String prompt = null; @@ -62,7 +66,30 @@ public class PublicBotController { if (errorEmitter != null) { return errorEmitter; } - return botService.startPublicChat(chatRequestParams.getBotId(), prompt, chatRequestParams.getMessages(), chatCheckResult); + return botService.startPublicChat( + chatRequestParams.getBotId(), + prompt, + chatRequestParams.getMessages(), + chatCheckResult, + buildRuntimeContext(chatCheckResult.getAiBot(), chatRequestParams.getConversationId(), prompt, sysApiKey) + ); + } + + private ChatRuntimeContext buildRuntimeContext(Bot bot, String conversationId, String prompt, SysApiKey sysApiKey) { + ChatRuntimeContext context = new ChatRuntimeContext(); + context.setChannel(ChatChannel.PUBLIC_API); + context.setSessionId(new BigInteger(conversationId)); + context.setTenantId(BigInteger.ZERO); + context.setDeptId(BigInteger.ZERO); + context.setUserId(BigInteger.ZERO); + context.setUserAccount("apikey:" + sysApiKey.getId()); + context.setUserName("API 调用方"); + 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 != null && prompt.length() > 200 ? prompt.substring(0, 200) : prompt); + context.setAnonymous(true); + return context; } diff --git a/easyflow-api/easyflow-api-usercenter/pom.xml b/easyflow-api/easyflow-api-usercenter/pom.xml index 34eb1c9..81ca7ef 100644 --- a/easyflow-api/easyflow-api-usercenter/pom.xml +++ b/easyflow-api/easyflow-api-usercenter/pom.xml @@ -20,10 +20,14 @@ tech.easyflow easyflow-module-ai + + tech.easyflow + easyflow-module-chatlog + tech.easyflow easyflow-common-captcha - \ No newline at end of file + diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotController.java index e362fa0..fbefe6e 100644 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotController.java +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotController.java @@ -23,10 +23,13 @@ import tech.easyflow.ai.service.impl.BotServiceImpl; import tech.easyflow.common.annotation.UsePermission; import tech.easyflow.common.audio.core.AudioServiceManager; import tech.easyflow.common.domain.Result; +import tech.easyflow.common.entity.LoginAccount; 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.core.runtime.ChatChannel; +import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot; import tech.easyflow.system.service.CategoryPermissionService; @@ -75,8 +78,6 @@ public class UcBotController extends BaseCurdController { @Resource private BotPluginService botPluginService; @Resource - private BotConversationService conversationMessageService; - @Resource private CategoryPermissionService categoryPermissionService; @GetMapping("/generateConversationId") @@ -161,27 +162,16 @@ public class UcBotController extends BaseCurdController { if (errorEmitter != null) { return errorEmitter; } - BotConversation conversation = conversationMessageService.getById(conversationId); - if (conversation == null) { - conversation = new BotConversation(); - conversation.setId(conversationId); - if (prompt.length() > 200) { - conversation.setTitle(prompt.substring(0, 200)); - } else { - conversation.setTitle(prompt); - } - conversation.setBotId(botId); - conversation.setAccountId(SaTokenUtil.getLoginAccount().getId()); - commonFiled(conversation, SaTokenUtil.getLoginAccount().getId(), SaTokenUtil.getLoginAccount().getTenantId(), SaTokenUtil.getLoginAccount().getDeptId()); - try { - conversationMessageService.save(conversation); - } catch (DuplicateKeyException e) { - // 并发重试场景下允许重复创建请求,唯一主键冲突按已创建处理。 - log.debug("conversation already exists, conversationId={}", conversationId, e); - } - } - return botService.startChat(botId, prompt, conversationId, messages, chatCheckResult, attachments); + return botService.startChat( + botId, + prompt, + conversationId, + messages, + chatCheckResult, + attachments, + buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments) + ); } @@ -296,6 +286,25 @@ public class UcBotController extends BaseCurdController { return super.onSaveOrUpdateBefore(entity, isSave); } + private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List attachments) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + 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.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.setAttachments(attachments); + return context; + } + private Map getDefaultLlmOptions() { Map defaultLlmOptions = new HashMap<>(); defaultLlmOptions.put("temperature", 0.7); diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotConversationController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotConversationController.java deleted file mode 100644 index 7e7e7fe..0000000 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotConversationController.java +++ /dev/null @@ -1,108 +0,0 @@ -package tech.easyflow.usercenter.controller.ai; - -import cn.dev33.satoken.annotation.SaIgnore; -import com.mybatisflex.core.paginate.Page; -import com.mybatisflex.core.query.QueryWrapper; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import tech.easyflow.ai.entity.BotConversation; -import tech.easyflow.ai.service.BotConversationService; -import tech.easyflow.common.domain.Result; -import tech.easyflow.common.entity.LoginAccount; -import tech.easyflow.common.satoken.util.SaTokenUtil; -import tech.easyflow.common.web.controller.BaseCurdController; -import tech.easyflow.common.web.exceptions.BusinessException; - -import javax.annotation.Resource; -import java.util.Date; -import java.util.List; - -@RestController -@RequestMapping("/userCenter/botConversation") -@SaIgnore -public class UcBotConversationController extends BaseCurdController { - - @Resource - private BotConversationService conversationMessageService; - - public UcBotConversationController(BotConversationService service) { - super(service); - } - - /** - * 删除指定会话 - */ - @GetMapping("/deleteConversation") - public Result deleteConversation(String botId, String conversationId) { - LoginAccount account = SaTokenUtil.getLoginAccount(); - conversationMessageService.deleteConversation(botId, conversationId, account.getId()); - return Result.ok(); - } - - /** - * 更新会话标题 - */ - @GetMapping("/updateConversation") - public Result updateConversation(String botId, String conversationId, String title) { - LoginAccount account = SaTokenUtil.getLoginAccount(); - conversationMessageService.updateConversation(botId, conversationId, title, account.getId()); - return Result.ok(); - } - - @Override - public Result> list(BotConversation entity, Boolean asTree, String sortKey, String sortType) { - entity.setAccountId(SaTokenUtil.getLoginAccount().getId()); - sortKey = "created"; - sortType = "desc"; - return super.list(entity, asTree, sortKey, sortType); - } - - @Override - protected Result onSaveOrUpdateBefore(BotConversation entity, boolean isSave) { - entity.setAccountId(SaTokenUtil.getLoginAccount().getId()); - entity.setCreated(new Date()); - return super.onSaveOrUpdateBefore(entity, isSave); - } - - /** - * 分页查询会话列表 - * - * @param request 查询数据 - * @param sortKey 排序字段 - * @param sortType 排序方式 asc | desc - * @param pageNumber 当前页码 - * @param pageSize 每页的数据量 - * @return - */ - @GetMapping("pageList") - public Result> page(HttpServletRequest request, String sortKey, String sortType, Long pageNumber, Long pageSize) { - if (pageNumber == null || pageNumber < 1) { - pageNumber = 1L; - } - if (pageSize == null || pageSize < 1) { - pageSize = 10L; - } - - QueryWrapper queryWrapper = buildQueryWrapper(request); - queryWrapper.orderBy(buildOrderBy(sortKey, sortType, getDefaultOrderBy())); - Page botConversationPage = service.getMapper().paginateWithRelations(pageNumber, pageSize, queryWrapper); - return Result.ok(botConversationPage); - } - - /** - * 根据表主键查询数据详情。 - * - * @param id 主键值 - * @return 内容详情 - */ - @GetMapping("detail") - @SaIgnore - public Result detail(String id) { - if (tech.easyflow.common.util.StringUtil.noText(id)) { - throw new BusinessException("id must not be null"); - } - return Result.ok(service.getMapper().selectOneWithRelationsById(id)); - } -} diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotMessageController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotMessageController.java deleted file mode 100644 index 1f7afcb..0000000 --- a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcBotMessageController.java +++ /dev/null @@ -1,60 +0,0 @@ -package tech.easyflow.usercenter.controller.ai; - -import cn.dev33.satoken.annotation.SaIgnore; -import cn.hutool.core.collection.CollectionUtil; -import com.alibaba.fastjson.JSON; -import com.mybatisflex.core.query.QueryWrapper; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import tech.easyflow.ai.entity.BotMessage; -import tech.easyflow.ai.service.BotMessageService; -import tech.easyflow.ai.vo.ChatMessageVO; -import tech.easyflow.common.annotation.UsePermission; -import tech.easyflow.common.domain.Result; -import tech.easyflow.common.satoken.util.SaTokenUtil; -import tech.easyflow.common.web.controller.BaseCurdController; - -import java.math.BigInteger; -import java.util.ArrayList; -import java.util.List; - -/** - * Bot 消息记录表 控制层。 - * - * @author michael - * @since 2024-11-04 - */ -@RestController -@RequestMapping("/userCenter/botMessage") -@UsePermission(moduleName = "/api/v1/bot") -public class UcBotMessageController extends BaseCurdController { - private final BotMessageService botMessageService; - - public UcBotMessageController(BotMessageService service, BotMessageService botMessageService) { - super(service); - this.botMessageService = botMessageService; - } - - @GetMapping("/getMessages") - @SaIgnore - public Result> getMessages(BigInteger botId, BigInteger conversationId) { - List res = new ArrayList<>(); - QueryWrapper w = QueryWrapper.create(); - w.eq(BotMessage::getBotId, botId); - w.eq(BotMessage::getConversationId, conversationId); - List list = botMessageService.list(w); - if (CollectionUtil.isNotEmpty(list)) { - for (BotMessage message : list) { - ChatMessageVO vo = new ChatMessageVO(); - vo.setKey(message.getId().toString()); - vo.setRole(message.getRole()); - vo.setContent(JSON.parseObject(message.getContent()).getString("textContent")); - vo.setPlacement("user".equals(message.getRole()) ? "end" : "start"); - vo.setCreated(message.getCreated()); - res.add(vo); - } - } - return Result.ok(res); - } -} diff --git a/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcChatHistoryController.java b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcChatHistoryController.java new file mode 100644 index 0000000..6ebd230 --- /dev/null +++ b/easyflow-api/easyflow-api-usercenter/src/main/java/tech/easyflow/usercenter/controller/ai/UcChatHistoryController.java @@ -0,0 +1,64 @@ +package tech.easyflow.usercenter.controller.ai; + +import cn.dev33.satoken.annotation.SaIgnore; +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.ChatSessionPage; +import tech.easyflow.chatlog.domain.dto.ChatSessionSummary; +import tech.easyflow.chatlog.domain.query.ChatPageQuery; +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; + +@RestController +@RequestMapping("/userCenter/chatHistory") +@SaIgnore +public class UcChatHistoryController { + + private final ChatHistoryManageService chatHistoryManageService; + + public UcChatHistoryController(ChatHistoryManageService chatHistoryManageService) { + this.chatHistoryManageService = chatHistoryManageService; + } + + @GetMapping("/sessions") + public Result listSessions(BigInteger assistantId, ChatPageQuery query) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + return Result.ok(chatHistoryManageService.queryUserSessions(account.getId(), assistantId, query)); + } + + @GetMapping("/sessions/{sessionId}") + public Result getSession(@PathVariable BigInteger sessionId) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + return Result.ok(chatHistoryManageService.getUserSession(account.getId(), sessionId)); + } + + @GetMapping("/sessions/{sessionId}/messages") + public Result queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + return Result.ok(chatHistoryManageService.queryUserMessages(account.getId(), sessionId, query)); + } + + @PostMapping("/sessions/{sessionId}/rename") + public Result renameSession(@PathVariable BigInteger sessionId, + @JsonBody(value = "title", required = true) String title) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + chatHistoryManageService.renameUserSession(account.getId(), sessionId, title, account.getId()); + return Result.ok(); + } + + @PostMapping("/sessions/{sessionId}/delete") + public Result deleteSession(@PathVariable BigInteger sessionId) { + LoginAccount account = SaTokenUtil.getLoginAccount(); + chatHistoryManageService.deleteUserSession(account.getId(), sessionId, account.getId()); + return Result.ok(); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/listener/ChatStreamListener.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/listener/ChatStreamListener.java index f419b8b..1765eee 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/listener/ChatStreamListener.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/listener/ChatStreamListener.java @@ -18,7 +18,12 @@ import tech.easyflow.core.chat.protocol.ChatType; import tech.easyflow.core.chat.protocol.MessageRole; 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.ChatRuntimeManager; +import tech.easyflow.core.runtime.ChatRuntimeMessage; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -33,6 +38,9 @@ public class ChatStreamListener implements StreamResponseListener { private final MemoryPrompt memoryPrompt; private final ChatSseEmitter sseEmitter; private final ChatOptions chatOptions; + private final ChatRuntimeManager chatRuntimeManager; + private final ChatRuntimeContext runtimeContext; + private final ChatAssistantAccumulator assistantAccumulator; // 核心标记:是否允许执行onStop业务逻辑(仅最后一次无后续工具调用时为true) private boolean canStop = true; // 辅助标记:是否进入过工具调用(避免重复递归判断) @@ -40,12 +48,17 @@ public class ChatStreamListener implements StreamResponseListener { // 流式响应只能结束一次,避免重复发送导致 IllegalStateException private final AtomicBoolean completed = new AtomicBoolean(false); - public ChatStreamListener(String conversationId, ChatModel chatModel, MemoryPrompt memoryPrompt, ChatSseEmitter sseEmitter, ChatOptions chatOptions) { + public ChatStreamListener(String conversationId, ChatModel chatModel, MemoryPrompt memoryPrompt, ChatSseEmitter sseEmitter, + ChatOptions chatOptions, ChatRuntimeManager chatRuntimeManager, + ChatRuntimeContext runtimeContext, ChatAssistantAccumulator assistantAccumulator) { this.conversationId = conversationId; this.chatModel = chatModel; this.memoryPrompt = memoryPrompt; this.sseEmitter = sseEmitter; this.chatOptions = chatOptions; + this.chatRuntimeManager = chatRuntimeManager; + this.runtimeContext = runtimeContext; + this.assistantAccumulator = assistantAccumulator; } @Override @@ -70,6 +83,7 @@ public class ChatStreamListener implements StreamResponseListener { List toolCalls = aiMessage.getToolCalls(); if (toolCalls != null) { for (ToolCall toolCall : toolCalls) { + assistantAccumulator.appendToolCall(toolCall.getId(), toolCall.getName(), toolCall.getArguments()); sendToolCallEnvelope(toolCall); } } @@ -78,6 +92,7 @@ public class ChatStreamListener implements StreamResponseListener { List toolMessages = aiMessageResponse.executeToolCallsAndGetToolMessages(); for (ToolMessage toolMessage : toolMessages) { memoryPrompt.addMessage(toolMessage); + assistantAccumulator.appendToolResult(toolMessage.getToolCallId(), null, toolMessage.getContent()); sendToolResultEnvelope(toolMessage); } chatModel.chatStream(memoryPrompt, this, chatOptions); @@ -87,10 +102,14 @@ public class ChatStreamListener implements StreamResponseListener { } String reasoningContent = aiMessage.getReasoningContent(); if (reasoningContent != null && !reasoningContent.isEmpty()) { + assistantAccumulator.appendReasoning(reasoningContent); + chatRuntimeManager.recordAssistantDelta(runtimeContext, buildAssistantDeltaMessage(reasoningContent, ChatType.THINKING)); sendChatEnvelope(sseEmitter, reasoningContent, ChatType.THINKING); } else { String delta = aiMessage.getContent(); if (delta != null && !delta.isEmpty()) { + assistantAccumulator.appendContent(delta); + chatRuntimeManager.recordAssistantDelta(runtimeContext, buildAssistantDeltaMessage(delta, ChatType.MESSAGE)); sendChatEnvelope(sseEmitter, delta, ChatType.MESSAGE); } } @@ -111,10 +130,13 @@ public class ChatStreamListener implements StreamResponseListener { // 仅当canStop为true(最后一次无后续工具调用的响应)时,执行业务逻辑 if (this.canStop && completed.compareAndSet(false, true)) { if (context.getThrowable() != null) { + chatRuntimeManager.recordFailure(runtimeContext, context.getThrowable()); sendSystemError(sseEmitter, context.getThrowable().getMessage(), context.getThrowable()); return; } memoryPrompt.addMessage(context.getFullMessage()); + chatRuntimeManager.recordAssistantCompleted(runtimeContext, buildAssistantCompletedMessage(context)); + chatRuntimeManager.recordCompleted(runtimeContext); ChatEnvelope> chatEnvelope = new ChatEnvelope<>(); chatEnvelope.setDomain(ChatDomain.SYSTEM); boolean doneSent = sseEmitter.sendDone(chatEnvelope); @@ -133,6 +155,7 @@ public class ChatStreamListener implements StreamResponseListener { conversationId, throwable.getMessage(), throwable.toString(), throwable); } if (throwable != null && completed.compareAndSet(false, true)) { + chatRuntimeManager.recordFailure(runtimeContext, throwable); sendSystemError(sseEmitter, throwable.getMessage(), throwable); } stopStreamClient(context, "on_failure", throwable); @@ -235,4 +258,28 @@ public class ChatStreamListener implements StreamResponseListener { } } + private ChatRuntimeMessage buildAssistantDeltaMessage(String delta, ChatType chatType) { + ChatRuntimeMessage message = new ChatRuntimeMessage(); + message.setRole("assistant"); + message.setContentType(chatType == ChatType.THINKING ? "THINKING" : "TEXT"); + message.setContentText(delta); + message.setCreatedAt(new Date()); + message.setSenderId(runtimeContext.getAssistantId()); + message.setSenderName(runtimeContext.getAssistantName()); + return message; + } + + private ChatRuntimeMessage buildAssistantCompletedMessage(StreamContext context) { + ChatRuntimeMessage message = new ChatRuntimeMessage(); + message.setRole("assistant"); + 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.setCreatedAt(new Date()); + message.setSenderId(runtimeContext.getAssistantId()); + message.setSenderName(runtimeContext.getAssistantName()); + return message; + } + } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemory.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemory.java new file mode 100644 index 0000000..e7db63c --- /dev/null +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/easyagents/memory/RuntimeChatMemory.java @@ -0,0 +1,74 @@ +package tech.easyflow.ai.easyagents.memory; + +import com.easyagents.core.memory.ChatMemory; +import com.easyagents.core.message.AiMessage; +import com.easyagents.core.message.Message; +import com.easyagents.core.message.SystemMessage; +import com.easyagents.core.message.UserMessage; +import tech.easyflow.core.runtime.ChatRuntimeMessage; + +import java.util.ArrayList; +import java.util.List; + +public class RuntimeChatMemory implements ChatMemory { + + private final Object id; + private final List messages = new ArrayList<>(); + + public RuntimeChatMemory(Object id, List runtimeMessages) { + this.id = id; + if (runtimeMessages != null) { + for (ChatRuntimeMessage runtimeMessage : runtimeMessages) { + Message message = toMessage(runtimeMessage); + if (message != null) { + this.messages.add(message); + } + } + } + } + + @Override + public List getMessages(int count) { + if (messages.isEmpty()) { + return null; + } + if (count <= 0 || messages.size() <= count) { + return new ArrayList<>(messages); + } + return new ArrayList<>(messages.subList(Math.max(messages.size() - count, 0), messages.size())); + } + + @Override + public void addMessage(Message message) { + if (message != null) { + messages.add(message); + } + } + + @Override + public void clear() { + messages.clear(); + } + + @Override + public Object id() { + return id; + } + + private Message toMessage(ChatRuntimeMessage runtimeMessage) { + if (runtimeMessage == null || runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) { + return null; + } + String role = runtimeMessage.getRole(); + if ("assistant".equalsIgnoreCase(role)) { + return new AiMessage(runtimeMessage.getContentText()); + } + if ("system".equalsIgnoreCase(role)) { + return new SystemMessage(runtimeMessage.getContentText()); + } + if ("tool".equalsIgnoreCase(role)) { + return new SystemMessage(runtimeMessage.getContentText()); + } + return new UserMessage(runtimeMessage.getContentText()); + } +} diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotService.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotService.java index a25dae2..058b660 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotService.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/BotService.java @@ -10,6 +10,7 @@ import tech.easyflow.ai.entity.Bot; import com.mybatisflex.core.service.IService; import tech.easyflow.ai.service.impl.BotServiceImpl; import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter; +import tech.easyflow.core.runtime.ChatRuntimeContext; import java.math.BigInteger; import java.util.List; @@ -31,8 +32,10 @@ public interface BotService extends IService { SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId, BotServiceImpl.ChatCheckResult chatCheckResult); - SseEmitter startChat(BigInteger botId, String prompt, BigInteger conversationId, List> messages, BotServiceImpl.ChatCheckResult chatCheckResult, List attachments); + SseEmitter startChat(BigInteger botId, String prompt, BigInteger conversationId, List> messages, + BotServiceImpl.ChatCheckResult chatCheckResult, List attachments, ChatRuntimeContext runtimeContext); - SseEmitter startPublicChat(BigInteger botId, String prompt, List messages, BotServiceImpl.ChatCheckResult chatCheckResult); + SseEmitter startPublicChat(BigInteger botId, String prompt, List messages, + BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext); } diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java index 18c319a..9b9efaf 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/service/impl/BotServiceImpl.java @@ -24,9 +24,9 @@ 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.easyagents.listener.ChatStreamListener; -import tech.easyflow.ai.easyagents.memory.BotMessageMemory; 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.entity.*; import tech.easyflow.ai.mapper.BotMapper; import tech.easyflow.ai.service.*; @@ -41,11 +41,17 @@ import tech.easyflow.common.util.UrlEncoderUtil; 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.ChatRuntimeContext; +import tech.easyflow.core.runtime.ChatRuntimeManager; +import tech.easyflow.core.runtime.ChatRuntimeMessage; import tech.easyflow.system.service.CategoryPermissionService; import javax.annotation.Resource; import java.math.BigInteger; import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -87,9 +93,6 @@ public class BotServiceImpl extends ServiceImpl implements BotSe public void setConversationIdStr(String conversationIdStr) {this.conversationIdStr = conversationIdStr;} } - @Resource - private BotMessageService botMessageService; - @Resource(name = "sseThreadPool") private ThreadPoolTaskExecutor threadPoolTaskExecutor; @Resource @@ -110,6 +113,8 @@ public class BotServiceImpl extends ServiceImpl implements BotSe FileStorageService storageService; @Resource private CategoryPermissionService categoryPermissionService; + @Resource + private ChatRuntimeManager chatRuntimeManager; @Override public Bot getDetail(String id) { @@ -189,7 +194,7 @@ public class BotServiceImpl extends ServiceImpl implements BotSe @Override public SseEmitter startChat(BigInteger botId, String prompt, BigInteger conversationId, List> messages, - BotServiceImpl.ChatCheckResult chatCheckResult, List attachments) { + BotServiceImpl.ChatCheckResult chatCheckResult, List attachments, ChatRuntimeContext runtimeContext) { Map modelOptions = chatCheckResult.getModelOptions(); ChatModel chatModel = chatCheckResult.getChatModel(); final MemoryPrompt memoryPrompt = new MemoryPrompt(); @@ -214,19 +219,33 @@ public class BotServiceImpl extends ServiceImpl implements BotSe chatOptions.setThinkingEnabled(enableDeepThinking); ChatSseEmitter chatSseEmitter = new ChatSseEmitter(); SseEmitter emitter = chatSseEmitter.getEmitter(); + int historyLimit = resolveHistoryLimit(maxMessageCount); if (messages != null && !messages.isEmpty()) { ChatMemory defaultChatMemory = new DefaultBotMessageMemory(conversationId, chatSseEmitter, messages); memoryPrompt.setMemory(defaultChatMemory); } else { - BotMessageMemory memory = new BotMessageMemory(botId, SaTokenUtil.getLoginAccount().getId(), conversationId, botMessageService); - memoryPrompt.setMemory(memory); + memoryPrompt.setMemory(new RuntimeChatMemory( + conversationId, + chatRuntimeManager.loadMessages(runtimeContext, historyLimit) + )); } + chatRuntimeManager.prepareSession(runtimeContext); + chatRuntimeManager.recordUserMessage(runtimeContext, buildUserRuntimeMessage(runtimeContext, prompt, attachments)); memoryPrompt.addMessage(userMessage); RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); threadPoolTaskExecutor.execute(() -> { ServletRequestAttributes sra = (ServletRequestAttributes) requestAttributes; RequestContextHolder.setRequestAttributes(sra, true); - StreamResponseListener streamResponseListener = new ChatStreamListener(conversationId.toString(), chatModel, memoryPrompt, chatSseEmitter, chatOptions); + StreamResponseListener streamResponseListener = new ChatStreamListener( + conversationId.toString(), + chatModel, + memoryPrompt, + chatSseEmitter, + chatOptions, + chatRuntimeManager, + runtimeContext, + new ChatAssistantAccumulator() + ); chatModel.chatStream(memoryPrompt, streamResponseListener, chatOptions); }); @@ -239,7 +258,8 @@ public class BotServiceImpl extends ServiceImpl implements BotSe * @return */ @Override - public SseEmitter startPublicChat(BigInteger botId, String prompt, List messages, BotServiceImpl.ChatCheckResult chatCheckResult) { + public SseEmitter startPublicChat(BigInteger botId, String prompt, List messages, + BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext) { Map modelOptions = chatCheckResult.getModelOptions(); ChatOptions chatOptions = getChatOptions(modelOptions); ChatModel chatModel = chatCheckResult.getChatModel(); @@ -260,11 +280,22 @@ public class BotServiceImpl extends ServiceImpl implements BotSe memoryPrompt.setSystemMessage(SystemMessage.of(systemPrompt)); } memoryPrompt.addMessage(userMessage); + chatRuntimeManager.prepareSession(runtimeContext); + chatRuntimeManager.recordUserMessage(runtimeContext, buildUserRuntimeMessage(runtimeContext, prompt, null)); RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes sra = (ServletRequestAttributes) requestAttributes; threadPoolTaskExecutor.execute(() -> { RequestContextHolder.setRequestAttributes(sra, true); - StreamResponseListener streamResponseListener = new ChatStreamListener(chatCheckResult.getConversationIdStr(), chatModel, memoryPrompt, chatSseEmitter, chatOptions); + StreamResponseListener streamResponseListener = new ChatStreamListener( + chatCheckResult.getConversationIdStr(), + chatModel, + memoryPrompt, + chatSseEmitter, + chatOptions, + chatRuntimeManager, + runtimeContext, + new ChatAssistantAccumulator() + ); chatModel.chatStream(memoryPrompt, streamResponseListener, chatOptions); }); @@ -425,6 +456,36 @@ public class BotServiceImpl extends ServiceImpl implements BotSe return messageBuilder.toString(); } + private int resolveHistoryLimit(Integer maxMessageCount) { + if (maxMessageCount == null || maxMessageCount <= 0) { + return 20; + } + return Math.min(maxMessageCount, 200); + } + + private ChatRuntimeMessage buildUserRuntimeMessage(ChatRuntimeContext context, String prompt, List attachments) { + ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage(); + runtimeMessage.setRole("user"); + runtimeMessage.setContentType("TEXT"); + runtimeMessage.setContentText(prompt); + runtimeMessage.setCreatedAt(new Date()); + runtimeMessage.setSenderId(context.getUserId()); + runtimeMessage.setSenderName(resolveUserDisplayName(context)); + if (attachments != null && !attachments.isEmpty()) { + Map payload = new LinkedHashMap<>(); + payload.put("attachments", attachments); + runtimeMessage.setContentPayload(payload); + } + return runtimeMessage; + } + + private String resolveUserDisplayName(ChatRuntimeContext context) { + if (context.getUserName() != null && !context.getUserName().isBlank()) { + return context.getUserName(); + } + return context.getUserAccount(); + } + private String buildSystemPromptWithFaqImageRule(String systemPrompt) { if (!StringUtils.hasLength(systemPrompt)) { return FAQ_IMAGE_SYSTEM_RULE; diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthService.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthService.java index 2734b0e..63eb9b9 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthService.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/AuthService.java @@ -3,6 +3,8 @@ package tech.easyflow.auth.service; import tech.easyflow.auth.entity.LoginDTO; import tech.easyflow.auth.entity.LoginVO; +import java.math.BigInteger; + public interface AuthService { /** * 登录 @@ -13,4 +15,14 @@ public interface AuthService { * 开发模式免登录 */ LoginVO devLogin(String account); + + /** + * 通过访问令牌登录 + */ + LoginVO loginByApiKey(String apiKey); + + /** + * 通过账号ID登录 + */ + LoginVO loginByAccountId(BigInteger accountId, Long timeoutSeconds); } diff --git a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java index 9361944..0910e86 100644 --- a/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java +++ b/easyflow-modules/easyflow-module-auth/src/main/java/tech/easyflow/auth/service/impl/AuthServiceImpl.java @@ -1,5 +1,12 @@ package tech.easyflow.auth.service.impl; +import cn.dev33.satoken.stp.SaLoginModel; +import cn.dev33.satoken.stp.StpInterface; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.bean.BeanUtil; +import com.mybatisflex.core.query.QueryWrapper; +import com.mybatisflex.core.tenant.TenantManager; +import org.springframework.stereotype.Service; import tech.easyflow.auth.entity.LoginDTO; import tech.easyflow.auth.entity.LoginVO; import tech.easyflow.auth.service.AuthService; @@ -7,22 +14,19 @@ import tech.easyflow.common.constant.Constants; import tech.easyflow.common.constant.enums.EnumDataStatus; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.exceptions.BusinessException; +import tech.easyflow.system.entity.SysApiKey; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysMenu; import tech.easyflow.system.entity.SysRole; +import tech.easyflow.system.service.SysApiKeyService; import tech.easyflow.system.service.SysAccountService; import tech.easyflow.system.service.SysMenuService; import tech.easyflow.system.service.SysRoleService; -import cn.dev33.satoken.stp.StpInterface; -import cn.dev33.satoken.stp.StpUtil; -import cn.hutool.core.bean.BeanUtil; import cn.hutool.crypto.digest.BCrypt; -import com.mybatisflex.core.query.QueryWrapper; -import com.mybatisflex.core.tenant.TenantManager; -import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.math.BigInteger; +import java.util.Date; import java.util.List; import java.util.stream.Collectors; @@ -35,6 +39,8 @@ public class AuthServiceImpl implements AuthService, StpInterface { private SysRoleService sysRoleService; @Resource private SysMenuService sysMenuService; + @Resource + private SysApiKeyService sysApiKeyService; @Override public LoginVO login(LoginDTO loginDTO) { @@ -63,6 +69,29 @@ public class AuthServiceImpl implements AuthService, StpInterface { } } + @Override + public LoginVO loginByApiKey(String apiKey) { + try { + TenantManager.ignoreTenantCondition(); + sysApiKeyService.checkApikeyPermission(apiKey, "/public-api/bot/chat"); + SysApiKey sysApiKey = sysApiKeyService.getSysApiKey(apiKey); + BigInteger accountId = sysApiKey.getCreatedBy(); + if (accountId == null) { + throw new BusinessException("访问令牌未绑定创建用户"); + } + Long timeoutSeconds = resolveApiKeyLoginTimeoutSeconds(sysApiKey); + return loginByAccountId(accountId, timeoutSeconds); + } finally { + TenantManager.restoreTenantCondition(); + } + } + + @Override + public LoginVO loginByAccountId(BigInteger accountId, Long timeoutSeconds) { + SysAccount record = getAvailableAccount(accountId, "账号不存在或不可用"); + return createLoginVO(record, timeoutSeconds); + } + @Override public List getPermissionList(Object loginId, String loginType) { List menus = sysMenuService.getMenusByAccountId(new SysMenu(), BigInteger.valueOf(Long.parseLong(loginId.toString()))); @@ -79,7 +108,17 @@ public class AuthServiceImpl implements AuthService, StpInterface { } private LoginVO createLoginVO(SysAccount record) { - StpUtil.login(record.getId()); + return createLoginVO(record, null); + } + + private LoginVO createLoginVO(SysAccount record, Long timeoutSeconds) { + if (timeoutSeconds != null) { + SaLoginModel loginModel = new SaLoginModel(); + loginModel.setTimeout(timeoutSeconds); + StpUtil.login(record.getId(), loginModel); + } else { + StpUtil.login(record.getId()); + } LoginAccount loginAccount = new LoginAccount(); BeanUtil.copyProperties(record, loginAccount); StpUtil.getSession().set(Constants.LOGIN_USER_KEY, loginAccount); @@ -92,10 +131,32 @@ public class AuthServiceImpl implements AuthService, StpInterface { return res; } + private Long resolveApiKeyLoginTimeoutSeconds(SysApiKey sysApiKey) { + Date expiredAt = sysApiKey.getExpiredAt(); + if (expiredAt == null) { + return null; + } + long remainingMs = expiredAt.getTime() - System.currentTimeMillis(); + if (remainingMs <= 0) { + throw new BusinessException("apiKey 已过期"); + } + long timeoutSeconds = (remainingMs + 999) / 1000; + return Math.max(timeoutSeconds, 1L); + } + private SysAccount getAvailableAccount(String account, String accountNotFoundMessage) { QueryWrapper w = QueryWrapper.create(); w.eq(SysAccount::getLoginName, account); SysAccount record = sysAccountService.getOne(w); + return validateAvailableAccount(record, accountNotFoundMessage); + } + + private SysAccount getAvailableAccount(BigInteger accountId, String accountNotFoundMessage) { + SysAccount record = sysAccountService.getById(accountId); + return validateAvailableAccount(record, accountNotFoundMessage); + } + + private SysAccount validateAvailableAccount(SysAccount record, String accountNotFoundMessage) { if (record == null) { throw new BusinessException(accountNotFoundMessage); } diff --git a/easyflow-ui-admin/app/src/api/request.ts b/easyflow-ui-admin/app/src/api/request.ts index 9f0f56e..2f75251 100644 --- a/easyflow-ui-admin/app/src/api/request.ts +++ b/easyflow-ui-admin/app/src/api/request.ts @@ -148,6 +148,7 @@ export const api = createRequestClient(apiURL, { export const baseRequestClient = new RequestClient({ baseURL: apiURL }); export interface SseOptions { + headers?: HeadersInit; onMessage?: (message: ServerSentEventMessage) => void; onError?: (err: any) => void; onFinished?: () => void; @@ -186,7 +187,7 @@ export class SseClient { const res = await fetch(apiURL + url, { method: 'POST', signal, // 使用局部变量 signal - headers: this.getHeaders(), + headers: this.getHeaders(options?.headers), body: JSON.stringify(data), }); @@ -233,13 +234,20 @@ export class SseClient { } } - private getHeaders() { + private getHeaders(extraHeaders?: HeadersInit) { const accessStore = useAccessStore(); - return { + const headers: Record = { Accept: 'text/event-stream', 'Content-Type': 'application/json', 'easyflow-token': accessStore.accessToken || '', }; + if (!extraHeaders) { + return headers; + } + new Headers(extraHeaders).forEach((value, key) => { + headers[key] = value; + }); + return headers; } } diff --git a/easyflow-ui-admin/app/src/bootstrap.ts b/easyflow-ui-admin/app/src/bootstrap.ts index b1dac52..1b166a6 100644 --- a/easyflow-ui-admin/app/src/bootstrap.ts +++ b/easyflow-ui-admin/app/src/bootstrap.ts @@ -5,6 +5,7 @@ import { registerLoadingDirective, setDefaultModalProps, } from '@easyflow/common-ui'; +import '@easyflow/icons'; import { preferences } from '@easyflow/preferences'; import { initStores } from '@easyflow/stores'; import '@easyflow/styles'; diff --git a/easyflow-ui-admin/app/src/components/chat-history/ChatHistoryDetailDrawer.vue b/easyflow-ui-admin/app/src/components/chat-history/ChatHistoryDetailDrawer.vue new file mode 100644 index 0000000..30186f5 --- /dev/null +++ b/easyflow-ui-admin/app/src/components/chat-history/ChatHistoryDetailDrawer.vue @@ -0,0 +1,557 @@ + + + + + diff --git a/easyflow-ui-admin/app/src/components/chat/chat.vue b/easyflow-ui-admin/app/src/components/chat/chat.vue index ab9c51a..9715550 100644 --- a/easyflow-ui-admin/app/src/components/chat/chat.vue +++ b/easyflow-ui-admin/app/src/components/chat/chat.vue @@ -3,16 +3,18 @@ import type { BubbleListInstance, BubbleListProps, } from 'vue-element-plus-x/types/BubbleList'; -import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking'; import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter'; +import { + ChatThinkingBlock, + type ChatThinkingBlockStatus, +} from '@easyflow/common-ui'; import type { BotInfo, ChatMessage } from '@easyflow/types'; import { nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import ElBubbleList from 'vue-element-plus-x/es/BubbleList/index.js'; import ElSender from 'vue-element-plus-x/es/Sender/index.js'; -import ElThinking from 'vue-element-plus-x/es/Thinking/index.js'; import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js'; import { IconifyIcon } from '@easyflow/icons'; @@ -49,8 +51,8 @@ import SendingIcon from '../icons/SendingIcon.vue'; type Think = { reasoning_content?: string; - thinkingStatus?: ThinkingStatus; - thinlCollapse?: boolean; + thinkingStatus?: ChatThinkingBlockStatus; + thinkingExpanded?: boolean; }; type Tool = { @@ -329,7 +331,7 @@ const handleSubmit = async (refreshContent: string) => { if (index === -1) { chains.push({ thinkingStatus: 'thinking', - thinlCollapse: true, + thinkingExpanded: false, reasoning_content: delta, }); } else { @@ -525,13 +527,14 @@ onBeforeUnmount(() => { v-for="(chain, index) in item.chains" :key="chain.id || index" > - - + - - @@ -799,22 +766,38 @@ onBeforeUnmount(() => { width: fit-content; } -:deep(.el-thinking) { - margin: 0; -} - -:deep(.el-thinking .content-wrapper) { - --el-thinking-content-wrapper-width: var(--bubble-content-max-width); - +.chat-thinking-block-item { margin-bottom: 8px; } -:deep(.el-collapse-item) { - overflow: hidden; - border-radius: 8px; +.chat-tool-panel { + margin-bottom: 8px; } -:deep(.el-collapse-item__content) { +:deep(.chat-tool-panel.el-collapse) { + border: 1px solid hsl(var(--divider-faint) / 0.26); + border-radius: 14px; + background: hsl(var(--surface-panel) / 0.7); + box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 0.2); +} + +:deep(.chat-tool-panel .el-collapse-item) { + overflow: hidden; + border-radius: 14px; +} + +:deep(.chat-tool-panel .el-collapse-item__wrap) { + background: transparent; +} + +:deep(.chat-tool-panel .el-collapse-item__header) { + min-height: 44px; + padding-right: 14px; + background: transparent; + border-bottom-color: hsl(var(--divider-faint) / 0.16); +} + +:deep(.chat-tool-panel .el-collapse-item__content) { padding-bottom: 0; } diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/bot.json b/easyflow-ui-admin/app/src/locales/langs/en-US/bot.json index a41ceb5..ef438b2 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/bot.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/bot.json @@ -17,6 +17,9 @@ "notConfigured": "NotConfigured", "chatPublishBaseUrlMissing": "Publish base URL is not configured. Please set it in system settings first.", "chatExternalLink": "Chat External Link", + "chatAccessToken": "Access Token", + "chatAccessTokenPlaceholder": "Optional. Selected links will include the token", + "chatAccessTokenHint": "Only enabled access tokens with public-api chat permission are listed. After selection, copied links and iframe code will automatically include the token.", "iframeEmbedCode": "Iframe Embed Code", "copyLink": "Copy Link", "copyIframeCode": "Copy Code", @@ -36,6 +39,7 @@ "publicChatLoading": "Initializing chat environment...", "publicChatThinking": "Thinking...", "publicChatInitError": "Initialization failed, please try again later", + "publicChatTokenInvalid": "The access token is invalid or expired", "publicChatAssistantReply": "Assistant Reply", "publicChatToolCalling": "Calling tool", "publicChatToolDone": "Tool completed", diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/bot.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/bot.json index 7c050a8..89d4180 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/bot.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/bot.json @@ -17,6 +17,9 @@ "notConfigured": "未配置", "chatPublishBaseUrlMissing": "未配置发布域名,请先到系统设置中配置", "chatExternalLink": "聊天外链", + "chatAccessToken": "访问令牌", + "chatAccessTokenPlaceholder": "可选,选择后外链将携带访问令牌", + "chatAccessTokenHint": "仅展示已启用且具备 public-api 聊天权限的访问令牌。选中后,复制链接和 iframe 代码会自动附带该令牌。", "iframeEmbedCode": "iframe 嵌入代码", "copyLink": "复制链接", "copyIframeCode": "复制代码", @@ -36,6 +39,7 @@ "publicChatLoading": "正在初始化聊天环境...", "publicChatThinking": "思考中...", "publicChatInitError": "初始化失败,请稍后重试", + "publicChatTokenInvalid": "访问令牌无效或已过期", "publicChatAssistantReply": "助手回复", "publicChatToolCalling": "工具调用中", "publicChatToolDone": "工具已返回", diff --git a/easyflow-ui-admin/app/src/views/ai/bots/pages/setting/config.vue b/easyflow-ui-admin/app/src/views/ai/bots/pages/setting/config.vue index 394c6d6..88068a2 100644 --- a/easyflow-ui-admin/app/src/views/ai/bots/pages/setting/config.vue +++ b/easyflow-ui-admin/app/src/views/ai/bots/pages/setting/config.vue @@ -1,14 +1,21 @@ + + + + diff --git a/easyflow-ui-admin/app/src/views/publicChat/index.vue b/easyflow-ui-admin/app/src/views/publicChat/index.vue index e0b44e0..20fe65b 100644 --- a/easyflow-ui-admin/app/src/views/publicChat/index.vue +++ b/easyflow-ui-admin/app/src/views/publicChat/index.vue @@ -1,20 +1,29 @@ + + + + diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/index.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/index.ts new file mode 100644 index 0000000..4ad49ff --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/index.ts @@ -0,0 +1,5 @@ +export { default as ChatThinkingBlock } from './ChatThinkingBlock.vue'; +export type { + ChatThinkingBlockProps, + ChatThinkingBlockStatus, +} from './types'; diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/types.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/types.ts new file mode 100644 index 0000000..5573c09 --- /dev/null +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/chat-thinking/types.ts @@ -0,0 +1,12 @@ +export type ChatThinkingBlockStatus = 'end' | 'error' | 'thinking'; + +export interface ChatThinkingBlockProps { + content?: string; + disabled?: boolean; + emptyBehavior?: 'hide' | 'placeholder'; + expanded?: boolean; + label?: string; + readonly?: boolean; + status?: ChatThinkingBlockStatus; + summary?: string; +} diff --git a/easyflow-ui-admin/packages/effects/common-ui/src/components/index.ts b/easyflow-ui-admin/packages/effects/common-ui/src/components/index.ts index 385558b..a650c5f 100644 --- a/easyflow-ui-admin/packages/effects/common-ui/src/components/index.ts +++ b/easyflow-ui-admin/packages/effects/common-ui/src/components/index.ts @@ -1,5 +1,6 @@ export * from './api-component'; export * from './captcha'; +export * from './chat-thinking'; export * from './col-page'; export * from './count-to'; export * from './ellipsis-text'; diff --git a/easyflow-ui-admin/packages/icons/src/svg/icons/chat-history.svg b/easyflow-ui-admin/packages/icons/src/svg/icons/chat-history.svg new file mode 100644 index 0000000..e6bddbc --- /dev/null +++ b/easyflow-ui-admin/packages/icons/src/svg/icons/chat-history.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/easyflow-ui-admin/packages/icons/src/svg/index.ts b/easyflow-ui-admin/packages/icons/src/svg/index.ts index dc3704b..fbd3086 100644 --- a/easyflow-ui-admin/packages/icons/src/svg/index.ts +++ b/easyflow-ui-admin/packages/icons/src/svg/index.ts @@ -10,6 +10,7 @@ const SvgDownloadIcon = createIconifyIcon('svg:download'); const SvgCardIcon = createIconifyIcon('svg:card'); const SvgBellIcon = createIconifyIcon('svg:bell'); const SvgCakeIcon = createIconifyIcon('svg:cake'); +const SvgChatHistoryIcon = createIconifyIcon('svg:chat-history'); const SvgAntdvLogoIcon = createIconifyIcon('svg:antdv-logo'); const SvgGithubIcon = createIconifyIcon('svg:github'); const SvgGoogleIcon = createIconifyIcon('svg:google'); @@ -44,6 +45,7 @@ export { SvgBellIcon, SvgCakeIcon, SvgCardIcon, + SvgChatHistoryIcon, SvgDataCenterIcon, SvgDepartmentIcon, SvgDingDingIcon, diff --git a/easyflow-ui-admin/packages/icons/src/svg/load.ts b/easyflow-ui-admin/packages/icons/src/svg/load.ts index 88c9c32..be4355f 100644 --- a/easyflow-ui-admin/packages/icons/src/svg/load.ts +++ b/easyflow-ui-admin/packages/icons/src/svg/load.ts @@ -1,6 +1,7 @@ import type { IconifyIconStructure } from '@easyflow-core/icons'; import { addIcon } from '@easyflow-core/icons'; +import chatHistorySvg from './icons/chat-history.svg?raw'; let loaded = false; if (!loaded) { @@ -39,6 +40,14 @@ function parseSvg(svgData: string): IconifyIconStructure { * */ async function loadSvgIcons() { + addIcon('svg:chat-history', { + ...parseSvg( + typeof chatHistorySvg === 'object' + ? chatHistorySvg.default + : chatHistorySvg, + ), + }); + const svgEagers = import.meta.glob('./icons/**', { eager: true, query: '?raw', diff --git a/easyflow-ui-usercenter/app/src/bootstrap.ts b/easyflow-ui-usercenter/app/src/bootstrap.ts index 3bffb99..0419ae2 100644 --- a/easyflow-ui-usercenter/app/src/bootstrap.ts +++ b/easyflow-ui-usercenter/app/src/bootstrap.ts @@ -1,5 +1,5 @@ import { createApp, watchEffect } from 'vue'; -import { BubbleList, Sender, Thinking, XMarkdown } from 'vue-element-plus-x'; +import { BubbleList, Sender, XMarkdown } from 'vue-element-plus-x'; import { registerAccessDirective } from '@easyflow/access'; import { registerLoadingDirective } from '@easyflow/common-ui'; @@ -41,7 +41,6 @@ async function bootstrap(namespace: string) { app.component('ElBubbleList', BubbleList); app.component('ElSender', Sender); app.component('ElXMarkdown', XMarkdown); - app.component('ElThinking', Thinking); // 注册EasyFlow提供的v-loading和v-spinning指令 registerLoadingDirective(app, { diff --git a/easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue b/easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue index 9216b10..f163143 100644 --- a/easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue +++ b/easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue @@ -1,4 +1,5 @@