feat: 接入聊天历史界面与外链会话恢复

- 新增管理端与用户端聊天历史接口和页面

- 外链聊天支持访问令牌登录、身份保活与当前会话恢复

- 聊天执行链路切到统一 runtime 与 chatlog 查询接口
This commit is contained in:
2026-04-05 11:37:25 +08:00
parent 25e80433a5
commit a4f75a5e4c
48 changed files with 3724 additions and 972 deletions

View File

@@ -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<ToolCall> 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<ToolMessage> 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<Map<String, String>> 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;
}
}

View File

@@ -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<Message> messages = new ArrayList<>();
public RuntimeChatMemory(Object id, List<ChatRuntimeMessage> 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<Message> 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());
}
}

View File

@@ -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<Bot> {
SseEmitter checkChatBeforeStart(BigInteger botId, String prompt, String conversationId, BotServiceImpl.ChatCheckResult chatCheckResult);
SseEmitter startChat(BigInteger botId, String prompt, BigInteger conversationId, List<Map<String, String>> messages, BotServiceImpl.ChatCheckResult chatCheckResult, List<String> attachments);
SseEmitter startChat(BigInteger botId, String prompt, BigInteger conversationId, List<Map<String, String>> messages,
BotServiceImpl.ChatCheckResult chatCheckResult, List<String> attachments, ChatRuntimeContext runtimeContext);
SseEmitter startPublicChat(BigInteger botId, String prompt, List<Message> messages, BotServiceImpl.ChatCheckResult chatCheckResult);
SseEmitter startPublicChat(BigInteger botId, String prompt, List<Message> messages,
BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext);
}

View File

@@ -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<BotMapper, Bot> 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<BotMapper, Bot> 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<BotMapper, Bot> implements BotSe
@Override
public SseEmitter startChat(BigInteger botId, String prompt, BigInteger conversationId, List<Map<String, String>> messages,
BotServiceImpl.ChatCheckResult chatCheckResult, List<String> attachments) {
BotServiceImpl.ChatCheckResult chatCheckResult, List<String> attachments, ChatRuntimeContext runtimeContext) {
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
ChatModel chatModel = chatCheckResult.getChatModel();
final MemoryPrompt memoryPrompt = new MemoryPrompt();
@@ -214,19 +219,33 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> 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<BotMapper, Bot> implements BotSe
* @return
*/
@Override
public SseEmitter startPublicChat(BigInteger botId, String prompt, List<Message> messages, BotServiceImpl.ChatCheckResult chatCheckResult) {
public SseEmitter startPublicChat(BigInteger botId, String prompt, List<Message> messages,
BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext) {
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
ChatOptions chatOptions = getChatOptions(modelOptions);
ChatModel chatModel = chatCheckResult.getChatModel();
@@ -260,11 +280,22 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> 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<BotMapper, Bot> 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<String> 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<String, Object> 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;

View File

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

View File

@@ -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<String> getPermissionList(Object loginId, String loginType) {
List<SysMenu> 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);
}