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

@@ -16,6 +16,10 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-auth</artifactId>

View File

@@ -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<BotService, Bot> {
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<BotService, Bot> {
return result;
}
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> 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<Serializable> ids) {
QueryWrapper queryWrapperKnowledge = QueryWrapper.create().in(BotDocumentCollection::getBotId, ids);

View File

@@ -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<ChatSessionPage> listSessions(ChatSessionFilterQuery query) {
return Result.ok(chatHistoryManageService.queryAdminSessions(query));
}
@GetMapping("/sessions/{sessionId}")
public Result<ChatSessionSummary> getSession(@PathVariable BigInteger sessionId) {
return Result.ok(chatHistoryManageService.getAdminSession(sessionId));
}
@GetMapping("/sessions/{sessionId}/messages")
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query));
}
}

View File

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

View File

@@ -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<LoginVO> loginByApiKey(@JsonBody(value = "apiKey", required = true) String apiKey) {
return Result.ok(authService.loginByApiKey(apiKey));
}
@PostMapping("logout")
public Result<Void> logout() {
StpUtil.logout();

View File

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

View File

@@ -20,6 +20,10 @@
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-ai</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-module-chatlog</artifactId>
</dependency>
<dependency>
<groupId>tech.easyflow</groupId>
<artifactId>easyflow-common-captcha</artifactId>

View File

@@ -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<BotService, Bot> {
@Resource
private BotPluginService botPluginService;
@Resource
private BotConversationService conversationMessageService;
@Resource
private CategoryPermissionService categoryPermissionService;
@GetMapping("/generateConversationId")
@@ -161,27 +162,16 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
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<BotService, Bot> {
return super.onSaveOrUpdateBefore(entity, isSave);
}
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> 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<String, Object> getDefaultLlmOptions() {
Map<String, Object> defaultLlmOptions = new HashMap<>();
defaultLlmOptions.put("temperature", 0.7);

View File

@@ -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<BotConversationService, BotConversation> {
@Resource
private BotConversationService conversationMessageService;
public UcBotConversationController(BotConversationService service) {
super(service);
}
/**
* 删除指定会话
*/
@GetMapping("/deleteConversation")
public Result<Void> deleteConversation(String botId, String conversationId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
conversationMessageService.deleteConversation(botId, conversationId, account.getId());
return Result.ok();
}
/**
* 更新会话标题
*/
@GetMapping("/updateConversation")
public Result<Void> 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>> 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<BotConversation>> 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<BotConversation> botConversationPage = service.getMapper().paginateWithRelations(pageNumber, pageSize, queryWrapper);
return Result.ok(botConversationPage);
}
/**
* 根据表主键查询数据详情。
*
* @param id 主键值
* @return 内容详情
*/
@GetMapping("detail")
@SaIgnore
public Result<BotConversation> 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));
}
}

View File

@@ -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<BotMessageService, BotMessage> {
private final BotMessageService botMessageService;
public UcBotMessageController(BotMessageService service, BotMessageService botMessageService) {
super(service);
this.botMessageService = botMessageService;
}
@GetMapping("/getMessages")
@SaIgnore
public Result<List<ChatMessageVO>> getMessages(BigInteger botId, BigInteger conversationId) {
List<ChatMessageVO> res = new ArrayList<>();
QueryWrapper w = QueryWrapper.create();
w.eq(BotMessage::getBotId, botId);
w.eq(BotMessage::getConversationId, conversationId);
List<BotMessage> 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);
}
}

View File

@@ -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<ChatSessionPage> listSessions(BigInteger assistantId, ChatPageQuery query) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.queryUserSessions(account.getId(), assistantId, query));
}
@GetMapping("/sessions/{sessionId}")
public Result<ChatSessionSummary> getSession(@PathVariable BigInteger sessionId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
return Result.ok(chatHistoryManageService.getUserSession(account.getId(), sessionId));
}
@GetMapping("/sessions/{sessionId}/messages")
public Result<ChatHistoryPage> 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<Void> 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<Void> deleteSession(@PathVariable BigInteger sessionId) {
LoginAccount account = SaTokenUtil.getLoginAccount();
chatHistoryManageService.deleteUserSession(account.getId(), sessionId, account.getId());
return Result.ok();
}
}

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

View File

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

View File

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

View File

@@ -0,0 +1,557 @@
<script setup lang="ts">
import { ChatThinkingBlock } from '@easyflow/common-ui';
import ElXMarkdown from 'vue-element-plus-x/es/XMarkdown/index.js';
import { IconifyIcon } from '@easyflow/icons';
import { CircleCheck, Close } from '@element-plus/icons-vue';
import {
ElButton,
ElCollapse,
ElCollapseItem,
ElEmpty,
ElIcon,
ElScrollbar,
} from 'element-plus';
import ShowJson from '#/components/json/ShowJson.vue';
interface ChatHistoryDetailDrawerProps {
visible?: boolean;
loading?: boolean;
session?: any;
messages?: any[];
hasMore?: boolean;
onLoadMore?: (() => void | Promise<void>) | undefined;
}
const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
visible: false,
loading: false,
session: undefined,
messages: () => [],
hasMore: false,
onLoadMore: undefined,
});
const emit = defineEmits<{
close: [];
}>();
function formatTime(value?: string) {
if (!value) {
return '-';
}
const time = new Date(value);
if (Number.isNaN(time.getTime())) {
return value;
}
const year = time.getFullYear();
const month = String(time.getMonth() + 1).padStart(2, '0');
const day = String(time.getDate()).padStart(2, '0');
const hour = String(time.getHours()).padStart(2, '0');
const minute = String(time.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
function resolveSenderName(item: any) {
if (item?.senderName) {
return item.senderName;
}
return item?.role === 'assistant' ? '聊天助手' : '聊天用户';
}
function isThinkingChain(chain: any) {
return !chain?.id;
}
function toolStatusText(status?: string) {
return status === 'TOOL_RESULT' ? '调用成功' : '工具调用中';
}
async function handleLoadMore() {
await props.onLoadMore?.();
}
</script>
<template>
<div
v-loading="loading"
class="chat-history-detail"
:class="{ 'is-visible': visible }"
>
<div class="chat-history-detail__summary">
<div class="chat-history-detail__summary-main">
<div class="chat-history-detail__title-row">
<h2 class="chat-history-detail__title">
{{ session?.title || '未命名会话' }}
</h2>
<span class="chat-history-detail__assistant-tag">
{{ session?.assistantName || '聊天助手' }}
</span>
</div>
<div class="chat-history-detail__meta-row">
<span class="chat-history-detail__meta-inline">
<span class="chat-history-detail__meta-label">聊天用户</span>
<span class="chat-history-detail__meta-value">
{{ session?.userAccount || '-' }}
</span>
</span>
<span class="chat-history-detail__meta-divider"></span>
<span class="chat-history-detail__meta-inline">
<span class="chat-history-detail__meta-label">最近活跃</span>
<span class="chat-history-detail__meta-value">
{{ formatTime(session?.lastMessageAt || session?.accessAt) }}
</span>
</span>
<span class="chat-history-detail__meta-divider"></span>
<span class="chat-history-detail__meta-inline">
<span class="chat-history-detail__meta-label">消息数</span>
<span class="chat-history-detail__meta-value">
{{ session?.messageCount ?? messages.length ?? 0 }}
</span>
</span>
</div>
</div>
<button
type="button"
class="chat-history-detail__close"
aria-label="关闭聊天详情"
@click="emit('close')"
>
<ElIcon size="18">
<Close />
</ElIcon>
</button>
</div>
<div class="chat-history-detail__stream">
<div class="chat-history-detail__stream-toolbar">
<ElButton
v-if="hasMore"
text
type="primary"
@click="handleLoadMore"
>
加载更早消息
</ElButton>
</div>
<ElScrollbar class="chat-history-detail__scrollbar">
<div
v-if="messages.length > 0"
class="chat-history-detail__message-list"
>
<article
v-for="item in messages"
:key="item.key"
class="chat-history-detail__message"
:class="`is-${item.role}`"
>
<div
class="chat-history-detail__message-meta"
:class="`is-${item.role}`"
>
<span class="chat-history-detail__message-author">
{{ resolveSenderName(item) }}
</span>
<span class="chat-history-detail__message-time">
{{ formatTime(item.created) }}
</span>
</div>
<div
class="chat-history-detail__message-bubble"
:class="`is-${item.role}`"
>
<div
v-if="item.chains?.length"
class="chat-history-detail__message-chains"
>
<template
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ChatThinkingBlock
v-if="isThinkingChain(chain)"
v-model:expanded="chain.thinkingExpanded"
:content="chain.reasoning_content"
readonly
:status="chain.thinkingStatus"
class="chat-history-detail__thinking"
/>
<ElCollapse v-else class="chat-history-detail__tool-panel">
<ElCollapseItem :title="chain.name" :name="chain.id">
<template #title>
<div class="chat-history-detail__tool-title">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span class="chat-history-detail__tool-name">
{{ chain.name }}
</span>
<div class="chat-history-detail__tool-status">
<ElIcon
v-if="chain.status === 'TOOL_RESULT'"
size="14"
color="var(--el-color-success)"
>
<CircleCheck />
</ElIcon>
<IconifyIcon
v-else
icon="mdi:clock-time-five-outline"
/>
<span>{{ toolStatusText(chain.status) }}</span>
</div>
</div>
</template>
<ShowJson :value="chain.result" />
</ElCollapseItem>
</ElCollapse>
</template>
</div>
<div
v-if="item.content && String(item.content).trim()"
class="chat-history-detail__markdown"
>
<ElXMarkdown :markdown="item.content" />
</div>
</div>
</article>
</div>
<ElEmpty v-else description="暂无聊天消息" />
</ElScrollbar>
</div>
</div>
</template>
<style scoped>
.chat-history-detail {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
background:
radial-gradient(
circle at top right,
hsl(var(--nav-ambient) / 0.1),
transparent 26%
),
linear-gradient(
180deg,
hsl(var(--glass-border) / 0.18) 0%,
hsl(var(--glass-tint) / 0.34) 10%,
hsl(var(--surface-panel) / 0.88) 28%,
hsl(var(--surface-panel) / 0.96) 100%
);
}
.chat-history-detail__summary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 12px;
border-bottom: 1px solid hsl(var(--divider-faint) / 0.16);
}
.chat-history-detail__summary-main {
min-width: 0;
flex: 1;
}
.chat-history-detail__title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.chat-history-detail__title {
margin: 0;
min-width: 0;
font-size: 18px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.chat-history-detail__assistant-tag {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 10px;
border: 1px solid hsl(var(--glass-border) / 0.38);
border-radius: 999px;
background: hsl(var(--glass-tint) / 0.54);
font-size: 12px;
font-weight: 600;
color: hsl(var(--nav-item-active-foreground));
}
.chat-history-detail__meta-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.chat-history-detail__meta-label {
font-size: 11px;
color: hsl(var(--text-muted));
}
.chat-history-detail__meta-value {
min-width: 0;
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-strong));
word-break: break-all;
}
.chat-history-detail__meta-inline {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.chat-history-detail__meta-divider {
display: inline-flex;
height: 12px;
width: 1px;
background: hsl(var(--divider-faint) / 0.42);
}
.chat-history-detail__close {
display: inline-flex;
height: 36px;
width: 36px;
align-items: center;
justify-content: center;
border: 1px solid hsl(var(--divider-faint) / 0.24);
border-radius: 999px;
background: hsl(var(--glass-tint) / 0.46);
color: hsl(var(--text-muted));
transition:
transform 0.18s ease,
background-color 0.18s ease,
color 0.18s ease;
}
.chat-history-detail__close:hover {
color: hsl(var(--text-strong));
background: hsl(var(--surface-contrast-soft) / 0.92);
transform: translateY(-1px);
}
.chat-history-detail__stream {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
padding: 14px 24px 20px;
}
.chat-history-detail__stream-toolbar {
display: flex;
justify-content: center;
padding-bottom: 12px;
}
.chat-history-detail__scrollbar {
min-height: 0;
flex: 1;
}
.chat-history-detail__message-list {
display: flex;
flex-direction: column;
gap: 18px;
}
.chat-history-detail__message {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-history-detail__message.is-user {
align-items: flex-end;
}
.chat-history-detail__message.is-assistant {
align-items: flex-start;
}
.chat-history-detail__message-meta {
display: inline-flex;
max-width: 88%;
align-items: center;
gap: 10px;
font-size: 11px;
color: hsl(var(--text-muted));
}
.chat-history-detail__message-meta.is-user {
justify-content: flex-end;
}
.chat-history-detail__message-author {
font-weight: 600;
color: hsl(var(--text-strong));
}
.chat-history-detail__message-bubble {
width: min(88%, 760px);
border: 1px solid hsl(var(--divider-faint) / 0.34);
border-radius: 22px;
padding: 14px 16px;
font-size: 14px;
line-height: 1.72;
backdrop-filter: blur(10px);
}
.chat-history-detail__message-bubble.is-assistant {
background: hsl(var(--glass-tint) / 0.88);
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.48),
0 12px 26px -24px hsl(var(--foreground) / 0.18);
}
.chat-history-detail__message-bubble.is-user {
background: linear-gradient(
180deg,
hsl(var(--primary) / 0.14) 0%,
hsl(var(--surface-panel) / 0.96) 100%
);
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.42),
0 16px 30px -26px hsl(var(--primary) / 0.24);
}
.chat-history-detail__message-chains {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.chat-history-detail__thinking {
width: 100%;
}
.chat-history-detail__tool-panel {
overflow: hidden;
border: 1px solid hsl(var(--divider-faint) / 0.24);
border-radius: 14px;
background: hsl(var(--surface-panel) / 0.68);
box-shadow: inset 0 1px 0 hsl(var(--glass-border) / 0.18);
}
.chat-history-detail__tool-title {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding-left: 6px;
}
.chat-history-detail__tool-name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-history-detail__tool-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
background: hsl(var(--surface-contrast-soft) / 0.92);
font-size: 12px;
color: hsl(var(--text-muted));
}
.chat-history-detail__markdown {
min-width: 0;
font-size: 14px;
line-height: 1.72;
}
.chat-history-detail__markdown :deep(.markdown-body) {
background: transparent;
font-size: inherit;
line-height: inherit;
}
.chat-history-detail__markdown :deep(.markdown-body > :first-child) {
margin-top: 0;
}
.chat-history-detail__markdown :deep(.markdown-body > :last-child) {
margin-bottom: 0;
}
.chat-history-detail__markdown :deep(.markdown-body p) {
font-size: inherit;
line-height: inherit;
}
.chat-history-detail__markdown :deep(pre) {
max-width: 100%;
overflow: auto;
margin-top: 0;
}
.chat-history-detail__tool-panel :deep(.el-collapse-item__wrap) {
background: transparent;
}
.chat-history-detail__tool-panel :deep(.el-collapse-item__header) {
min-height: 44px;
padding-right: 14px;
background: transparent;
border-bottom-color: hsl(var(--divider-faint) / 0.16);
}
.chat-history-detail__message-bubble :deep(.el-collapse-item__content) {
padding-bottom: 0;
}
@media (max-width: 900px) {
.chat-history-detail__summary {
padding: 18px 18px 12px;
}
.chat-history-detail__stream {
padding: 12px 18px 18px;
}
.chat-history-detail__meta-row {
gap: 6px;
}
.chat-history-detail__meta-divider {
display: none;
}
.chat-history-detail__meta-inline {
width: 100%;
}
.chat-history-detail__message-bubble,
.chat-history-detail__message-meta {
max-width: 100%;
width: 100%;
}
}
</style>

View File

@@ -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"
>
<ElThinking
<ChatThinkingBlock
v-if="isThink(chain)"
v-model="chain.thinlCollapse"
v-model:expanded="chain.thinkingExpanded"
:content="chain.reasoning_content"
:status="chain.thinkingStatus"
class="chat-thinking-block-item"
/>
<ElCollapse v-else class="mb-2">
<ElCollapse v-else class="chat-tool-panel">
<ElCollapseItem :title="chain.name" :name="chain.id">
<template #title>
<div class="flex items-center gap-2 pl-5">
@@ -569,42 +572,6 @@ onBeforeUnmount(() => {
</template>
</template>
<!-- <ElThinking
v-if="item.reasoning_content"
v-model="item.thinlCollapse"
:content="item.reasoning_content"
:status="item.thinkingStatus"
class="mb-3"
/> -->
<!-- <ElCollapse v-if="item.tools" class="mb-2">
<ElCollapseItem
class="mb-2"
v-for="tool in item.tools"
:key="tool.id"
:title="tool.name"
:name="tool.id"
>
<template #title>
<div class="flex items-center gap-2 pl-5">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span>{{ tool.name }}</span>
<template v-if="tool.status === 'TOOL_CALL'">
<ElIcon size="16">
<IconifyIcon icon="svg:spinner" />
</ElIcon>
</template>
<template v-else>
<ElIcon size="16" color="var(--el-color-success)">
<CircleCheck />
</ElIcon>
</template>
</div>
</template>
<ShowJson :value="tool.result" />
</ElCollapseItem>
</ElCollapse> -->
</div>
</template>
<!-- 自定义头像 -->
@@ -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;
}
</style>

View File

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

View File

@@ -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": "工具已返回",

View File

@@ -1,14 +1,21 @@
<script setup lang="ts">
import type {AiLlm, BotInfo} from '@easyflow/types';
import type { AiLlm, BotInfo } from '@easyflow/types';
import {computed, onMounted, ref, watch} from 'vue';
import {useRoute} from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import {$t} from '@easyflow/locales';
import {useBotStore} from '@easyflow/stores';
import { $t } from '@easyflow/locales';
import { useBotStore } from '@easyflow/stores';
import {CopyDocument, Delete, InfoFilled, Link, Plus, Setting} from '@element-plus/icons-vue';
import {useDebounceFn} from '@vueuse/core';
import {
CopyDocument,
Delete,
InfoFilled,
Link,
Plus,
Setting,
} from '@element-plus/icons-vue';
import { useDebounceFn } from '@vueuse/core';
import {
ElAlert,
ElButton,
@@ -19,6 +26,7 @@ import {
ElInput,
ElInputNumber,
ElMessage,
ElOption,
ElRow,
ElSelect,
ElSkeleton,
@@ -26,7 +34,7 @@ import {
ElSwitch,
ElTooltip,
} from 'element-plus';
import {tryit} from 'radash';
import { tryit } from 'radash';
import {
getPerQuestions,
@@ -35,7 +43,7 @@ import {
updateLlmId,
updateLlmOptions,
} from '#/api';
import {api} from '#/api/request';
import { api } from '#/api/request';
import ProblemPresupposition from '#/components/chat/ProblemPresupposition.vue';
import PublishWxOfficalAccount from '#/components/chat/PublishWxOfficalAccount.vue';
import CollapseViewItem from '#/components/collapseViewItem/CollapseViewItem.vue';
@@ -47,6 +55,14 @@ interface SelectedMcpTool {
name: string;
description: string;
}
interface ApiKeyOption {
id: string;
apiKey: string;
expiredAt?: string;
label: string;
}
const props = defineProps<{
bot?: BotInfo;
hasSavePermission?: boolean;
@@ -68,6 +84,8 @@ const dialogueSettings = ref({
enableDeepThinking: false,
anonymousEnabled: false,
});
const selectedPublishApiKey = ref('');
const publishApiKeyOptions = ref<ApiKeyOption[]>([]);
const publishBaseUrl = ref('');
const routerHistoryMode = import.meta.env.VITE_ROUTER_HISTORY;
const normalizePublishBaseUrl = (value: string) => {
@@ -102,18 +120,29 @@ const publicChatPath = computed(() =>
? `/#/embed/chat/${botId.value}`
: `/embed/chat/${botId.value}`,
);
const publicChatUrl = computed(() => {
const buildPublicChatUrl = (embed = false) => {
if (!hasPublishBaseUrl.value) {
return '';
}
const base = normalizePublishBaseUrl(publishBaseUrl.value);
return `${base}${publicChatPath.value}`;
const query = new URLSearchParams();
if (selectedPublishApiKey.value) {
query.set('token', selectedPublishApiKey.value);
}
if (embed) {
query.set('embed', '1');
}
const queryString = query.toString();
if (!queryString) {
return `${base}${publicChatPath.value}`;
}
return `${base}${publicChatPath.value}?${queryString}`;
};
const publicChatUrl = computed(() => {
return buildPublicChatUrl(false);
});
const publicChatEmbedUrl = computed(() => {
if (!publicChatUrl.value) {
return '';
}
return `${publicChatUrl.value}?embed=1`;
return buildPublicChatUrl(true);
});
const iframeCode = computed(() => {
if (!publicChatEmbedUrl.value) {
@@ -233,7 +262,9 @@ const updatingBotIcon = ref(false);
const updatingBasicInfo = ref(false);
const syncingBasicInfoForm = ref(false);
const getPublishBaseUrl = async () => {
const [, res] = await tryit(api.get)('/api/v1/sysOption/list?keys=chat_publish_base_url');
const [, res] = await tryit(api.get)(
'/api/v1/sysOption/list?keys=chat_publish_base_url',
);
if (res?.errorCode === 0) {
publishBaseUrl.value = (res.data?.chat_publish_base_url || '').trim();
}
@@ -267,6 +298,84 @@ const getBotDetail = async () => {
}
});
};
const formatApiKeyOptionLabel = (apiKey: string, expiredAt?: string) => {
const normalized = String(apiKey || '').trim();
if (!normalized) {
return '';
}
const prefix = normalized.slice(0, 8);
const suffix = normalized.slice(-6);
const baseLabel =
normalized.length > 18 ? `${prefix}...${suffix}` : normalized;
return expiredAt ? `${baseLabel} · ${expiredAt}` : baseLabel;
};
const getPublishApiKeyOptions = async () => {
const [resourceErr, resourceRes] = await tryit(api.get)(
'/api/v1/sysApiKeyResourcePermission/list',
);
if (
resourceErr ||
resourceRes?.errorCode !== 0 ||
!Array.isArray(resourceRes?.data)
) {
publishApiKeyOptions.value = [];
return;
}
const publicChatResource = resourceRes.data.find(
(item: any) => item?.requestInterface === '/public-api/bot/chat',
);
if (!publicChatResource?.id) {
publishApiKeyOptions.value = [];
return;
}
const [apiKeyErr, apiKeyRes] = await tryit(api.get)(
'/api/v1/sysApiKey/page',
{
params: {
pageNumber: 1,
pageSize: 200,
sortKey: 'created',
sortType: 'desc',
status: 1,
},
},
);
if (apiKeyErr || apiKeyRes?.errorCode !== 0) {
publishApiKeyOptions.value = [];
return;
}
const records = apiKeyRes?.data?.records || [];
const now = Date.now();
publishApiKeyOptions.value = records
.filter((item: any) => {
if (item?.status !== 1) {
return false;
}
if (item?.expiredAt && new Date(item.expiredAt).getTime() <= now) {
return false;
}
const permissionIds = Array.isArray(item?.permissionIds)
? item.permissionIds.map(String)
: [];
return permissionIds.includes(String(publicChatResource.id));
})
.map((item: any) => ({
id: String(item.id || ''),
apiKey: String(item.apiKey || ''),
expiredAt: item.expiredAt,
label: formatApiKeyOptionLabel(item.apiKey, item.expiredAt),
}));
if (
selectedPublishApiKey.value &&
!publishApiKeyOptions.value.some(
(item) => item.apiKey === selectedPublishApiKey.value,
)
) {
selectedPublishApiKey.value = '';
}
};
const getLlmListData = async () => {
const url = `/api/v1/model/list?modelType=chatModel&added=true`;
api.get(url, {}).then((res) => {
@@ -277,6 +386,7 @@ const getLlmListData = async () => {
};
onMounted(async () => {
getPublishBaseUrl();
getPublishApiKeyOptions();
getAiBotPluginToolList();
getAiBotKnowledgeList();
getAiBotWorkflowList();
@@ -285,9 +395,7 @@ onMounted(async () => {
getLlmListData();
});
const handleAnonymousAccessChange = (
value: boolean | number | string,
) => {
const handleAnonymousAccessChange = (value: boolean | number | string) => {
handleDialogOptionsStrChange('anonymousEnabled', value);
};
@@ -681,14 +789,19 @@ const handleBasicInfoChange = async (
key: 'alias' | 'categoryId' | 'title',
value: any,
) => {
if (!botInfo.value || !props.hasSavePermission || syncingBasicInfoForm.value) {
if (
!botInfo.value ||
!props.hasSavePermission ||
syncingBasicInfoForm.value
) {
return;
}
if (updatingBasicInfo.value) {
return;
}
const normalizedValue = key === 'categoryId' ? value : String(value || '').trim();
const normalizedValue =
key === 'categoryId' ? value : String(value || '').trim();
if ((key === 'title' || key === 'alias') && !normalizedValue) {
ElMessage.warning($t('message.required'));
basicInfoForm.value[key] = botInfo.value[key] as string;
@@ -729,7 +842,7 @@ const handleBasicInfoChange = async (
<div
:class="[
'bot-avatar-upload-wrap',
(!hasSavePermission || updatingBotIcon) ? 'is-disabled' : '',
!hasSavePermission || updatingBotIcon ? 'is-disabled' : '',
]"
>
<UploadAvatar
@@ -742,7 +855,9 @@ const handleBasicInfoChange = async (
</div>
<div class="bot-basic-form-panel">
<div class="bot-basic-form-item">
<span class="bot-basic-form-label">{{ $t('aiWorkflow.title') }}</span>
<span class="bot-basic-form-label">{{
$t('aiWorkflow.title')
}}</span>
<ElInput
v-model="basicInfoForm.title"
:disabled="!hasSavePermission || updatingBasicInfo"
@@ -758,12 +873,16 @@ const handleBasicInfoChange = async (
/>
</div>
<div class="bot-basic-form-item">
<span class="bot-basic-form-label">{{ $t('aiWorkflow.categoryId') }}</span>
<span class="bot-basic-form-label">{{
$t('aiWorkflow.categoryId')
}}</span>
<DictSelect
v-model="basicInfoForm.categoryId"
dict-code="aiBotCategory"
:disabled="!hasSavePermission || updatingBasicInfo"
@change="(value: any) => handleBasicInfoChange('categoryId', value)"
@change="
(value: any) => handleBasicInfoChange('categoryId', value)
"
/>
</div>
</div>
@@ -1165,6 +1284,25 @@ const handleBasicInfoChange = async (
:closable="false"
/>
</div>
<div class="publish-external-item">
<label class="publish-external-label">
{{ $t('bot.chatAccessToken') }}
</label>
<ElSelect
v-model="selectedPublishApiKey"
clearable
filterable
class="w-full"
:placeholder="$t('bot.chatAccessTokenPlaceholder')"
>
<ElOption
v-for="item in publishApiKeyOptions"
:key="item.id"
:label="item.label"
:value="item.apiKey"
/>
</ElSelect>
</div>
<div class="publish-external-item">
<label class="publish-external-label">
{{ $t('bot.chatExternalLink') }}
@@ -1182,11 +1320,7 @@ const handleBasicInfoChange = async (
</ElIcon>
{{ $t('bot.copyLink') }}
</ElButton>
<ElButton
size="small"
type="primary"
@click="openPublicPage"
>
<ElButton size="small" type="primary" @click="openPublicPage">
<ElIcon class="mr-1">
<Link />
</ElIcon>
@@ -1197,15 +1331,16 @@ const handleBasicInfoChange = async (
<div class="publish-external-item">
<label class="publish-external-label flex items-center gap-1">
<span>{{ $t('bot.iframeEmbedCode') }}</span>
<ElTooltip
effect="dark"
placement="top"
>
<ElTooltip effect="dark" placement="top">
<template #content>
<div>{{ $t('bot.embedUsageTip1') }}</div>
<div class="mt-1">{{ $t('bot.embedUsageTip2') }}</div>
</template>
<ElIcon class="text-gray-400 hover:text-gray-600 cursor-pointer transition-colors"><InfoFilled /></ElIcon>
<ElIcon
class="cursor-pointer text-gray-400 transition-colors hover:text-gray-600"
>
<InfoFilled />
</ElIcon>
</ElTooltip>
</label>
<div class="publish-external-code-preview">
@@ -1447,6 +1582,12 @@ const handleBasicInfoChange = async (
color: var(--el-text-color-regular);
}
.publish-external-hint {
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
.publish-external-actions {
display: flex;
justify-content: flex-end;
@@ -1490,7 +1631,8 @@ const handleBasicInfoChange = async (
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 12px;
line-height: 1.6;

View File

@@ -0,0 +1,646 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useEasyFlowDrawer } from '@easyflow/common-ui';
import { Search } from '@element-plus/icons-vue';
import {
ElButton,
ElDatePicker,
ElEmpty,
ElInput,
ElPagination,
ElSelect,
ElTable,
ElTableColumn,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import ChatHistoryDetailDrawer from '#/components/chat-history/ChatHistoryDetailDrawer.vue';
import ListPageShell from '#/components/page/ListPageShell.vue';
const assistantList = ref<any[]>([]);
const sessions = ref<any[]>([]);
const loading = ref(false);
const query = ref({
assistantId: undefined as number | undefined,
userAccount: '',
timeRange: [] as string[],
pageNumber: 1,
pageSize: 20,
});
const quickRange = ref<'' | 'last7' | 'last30' | 'today'>('');
const pageState = ref({
total: 0,
});
const pageSizeOptions = [20, 50, 100];
const quickRangeOptions = [
{ label: '今天', value: 'today' as const },
{ label: '最近 7 天', value: 'last7' as const },
{ label: '最近 30 天', value: 'last30' as const },
];
const drawerLoading = ref(false);
const currentSession = ref<any>();
const messageList = ref<any[]>([]);
const messagePage = ref({
total: 0,
pageNumber: 1,
pageSize: 20,
});
const [Drawer, drawerApi] = useEasyFlowDrawer({
appendToMain: false,
class:
'!w-[820px] max-w-[calc(100vw-24px)] !border-0 !bg-[hsl(var(--glass-tint))/0.28] shadow-[0_24px_48px_-36px_hsl(var(--foreground)/0.12)] supports-[backdrop-filter]:!bg-[hsl(var(--glass-tint))/0.2]',
closable: false,
contentClass: 'p-0',
footer: false,
header: false,
modal: false,
placement: 'right',
});
const hasMoreMessages = computed(
() => messageList.value.length < messagePage.value.total,
);
const selectedSessionId = computed(() =>
String(currentSession.value?.id || ''),
);
onMounted(async () => {
await Promise.all([fetchAssistants(), fetchSessions()]);
});
async function fetchAssistants() {
const [, res] = await tryit(api.get)('/api/v1/bot/list', {
params: { status: 1 },
});
if (res?.errorCode === 0) {
assistantList.value = (res.data || []).map((item: any) => ({
label: item.title,
value: item.id,
}));
}
}
async function fetchSessions() {
loading.value = true;
const [, res] = await tryit(api.get)('/api/v1/chatHistory/sessions', {
params: {
assistantId: query.value.assistantId,
userAccount: query.value.userAccount || undefined,
startTime: query.value.timeRange?.[0],
endTime: query.value.timeRange?.[1],
pageNumber: query.value.pageNumber,
pageSize: query.value.pageSize,
},
});
loading.value = false;
if (res?.errorCode === 0) {
sessions.value = res.data?.records || [];
pageState.value.total = res.data?.total || 0;
}
}
async function openSession(sessionId: number | string) {
drawerLoading.value = true;
currentSession.value =
sessions.value.find((item: any) => String(item.id) === String(sessionId)) ||
undefined;
messageList.value = [];
messagePage.value = {
total: 0,
pageNumber: 1,
pageSize: 20,
};
drawerApi.open();
const [, summaryRes] = await tryit(api.get)(
`/api/v1/chatHistory/sessions/${sessionId}`,
);
if (summaryRes?.errorCode !== 0) {
drawerLoading.value = false;
drawerApi.close();
return;
}
currentSession.value = summaryRes.data;
await loadMessages(true);
drawerLoading.value = false;
}
async function loadMessages(reset = false) {
if (!currentSession.value?.id) {
return;
}
const nextPageNumber = reset ? 1 : messagePage.value.pageNumber + 1;
const [, res] = await tryit(api.get)(
`/api/v1/chatHistory/sessions/${currentSession.value.id}/messages`,
{
params: {
pageNumber: nextPageNumber,
pageSize: messagePage.value.pageSize,
},
},
);
if (res?.errorCode !== 0) {
return;
}
const normalized = normalizeMessages(res.data?.records || []);
messageList.value = reset
? normalized
: [...normalized, ...messageList.value];
messagePage.value.total = res.data?.total || 0;
messagePage.value.pageNumber = nextPageNumber;
}
function normalizeMessages(records: any[]) {
return [...records].reverse().map((item: any) => ({
key: String(item.id),
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
content:
item.senderRole === 'assistant'
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
: item.contentText,
created: item.created,
senderName: item.senderName,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains.map((chain: any) =>
chain?.id
? chain
: {
...chain,
thinkingExpanded: false,
},
)
: [],
}));
}
function changePage(pageNumber: number) {
query.value.pageNumber = pageNumber;
fetchSessions();
}
function changePageSize(pageSize: number) {
query.value.pageNumber = 1;
query.value.pageSize = pageSize;
fetchSessions();
}
function formatTime(value?: string) {
if (!value) {
return '-';
}
const time = new Date(value);
if (Number.isNaN(time.getTime())) {
return value;
}
const year = time.getFullYear();
const month = String(time.getMonth() + 1).padStart(2, '0');
const day = String(time.getDate()).padStart(2, '0');
const hour = String(time.getHours()).padStart(2, '0');
const minute = String(time.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}`;
}
function handleSearch() {
query.value.pageNumber = 1;
fetchSessions();
}
function applyQuickRange(range: 'last7' | 'last30' | 'today') {
quickRange.value = range;
query.value.timeRange = resolveQuickRange(range);
handleSearch();
}
function handleTimeRangeChange(value?: string[]) {
quickRange.value = '';
query.value.timeRange = normalizeTimeRange(value);
handleSearch();
}
function resolveQuickRange(range: 'last7' | 'last30' | 'today') {
const now = new Date();
const end = endOfDay(now);
let start = startOfDay(now);
if (range === 'last7') {
start = startOfDay(addDays(now, -6));
} else if (range === 'last30') {
start = startOfDay(addDays(now, -29));
}
return [formatDateTime(start), formatDateTime(end)];
}
function normalizeTimeRange(value?: string[]) {
if (!value?.length) {
return [];
}
const [startValue, endValue] = value;
if (!startValue || !endValue) {
return [];
}
return [
formatDateTime(startOfDay(new Date(startValue))),
formatDateTime(endOfDay(new Date(endValue))),
];
}
function startOfDay(date: Date) {
const value = new Date(date);
value.setHours(0, 0, 0, 0);
return value;
}
function endOfDay(date: Date) {
const value = new Date(date);
value.setHours(23, 59, 59, 0);
return value;
}
function addDays(date: Date, days: number) {
const value = new Date(date);
value.setDate(value.getDate() + days);
return value;
}
function formatDateTime(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, '0');
const day = String(value.getDate()).padStart(2, '0');
const hour = String(value.getHours()).padStart(2, '0');
const minute = String(value.getMinutes()).padStart(2, '0');
const second = String(value.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
function closeDetail() {
drawerApi.close();
}
</script>
<template>
<div class="chat-history-page flex h-full flex-col gap-6 p-6">
<ListPageShell class="flex-1" :content-padding="20">
<template #filters>
<div class="chat-history-page__filters">
<ElSelect
v-model="query.assistantId"
clearable
placeholder="筛选聊天助手"
:options="assistantList"
class="chat-history-page__filter-control is-select"
@change="handleSearch"
/>
<ElInput
v-model="query.userAccount"
class="chat-history-page__filter-control is-input"
placeholder="搜索聊天用户"
:prefix-icon="Search"
@keyup.enter="handleSearch"
/>
<div class="chat-history-page__quick-ranges">
<ElButton
v-for="item in quickRangeOptions"
:key="item.value"
:type="quickRange === item.value ? 'primary' : 'default'"
class="chat-history-page__quick-range-button"
@click="applyQuickRange(item.value)"
>
{{ item.label }}
</ElButton>
</div>
<ElDatePicker
v-model="query.timeRange"
class="chat-history-page__filter-control is-range"
type="daterange"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
@change="handleTimeRangeChange"
/>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
</div>
</template>
<div v-loading="loading" class="chat-history-page__content">
<div v-if="sessions.length > 0" class="chat-history-page__table-shell">
<ElTable
:data="sessions"
:current-row-key="selectedSessionId || undefined"
row-key="id"
class="chat-history-page__table"
height="100%"
highlight-current-row
empty-text=""
@row-click="(row) => openSession(row.id)"
>
<ElTableColumn label="会话信息" min-width="280">
<template #default="{ row }">
<div class="chat-history-page__session-cell">
<div class="chat-history-page__session-title">
{{ row.title || '未命名会话' }}
</div>
<div
v-if="row.lastMessagePreview"
class="chat-history-page__session-preview"
>
{{ row.lastMessagePreview }}
</div>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="聊天助手" min-width="160">
<template #default="{ row }">
<span class="chat-history-page__assistant-chip">
{{ row.assistantName || '聊天助手' }}
</span>
</template>
</ElTableColumn>
<ElTableColumn prop="userAccount" label="聊天用户" min-width="160">
<template #default="{ row }">
<span class="chat-history-page__user-cell">
{{ row.userAccount || '-' }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="消息数" width="110" align="center">
<template #default="{ row }">
<span class="chat-history-page__count-pill">
{{ row.messageCount || 0 }}
</span>
</template>
</ElTableColumn>
<ElTableColumn label="最近活跃" width="180">
<template #default="{ row }">
<span class="chat-history-page__time-cell">
{{ formatTime(row.lastMessageAt || row.accessAt) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn width="108" align="right">
<template #default="{ row }">
<ElButton
link
type="primary"
class="chat-history-page__detail-action"
@click.stop="openSession(row.id)"
>
查看详情
</ElButton>
</template>
</ElTableColumn>
</ElTable>
</div>
<div v-else class="chat-history-page__empty">
<ElEmpty description="暂无聊天历史" />
</div>
<div v-if="pageState.total > 0" class="chat-history-page__pagination">
<ElPagination
background
layout="total, sizes, prev, pager, next, jumper"
:current-page="query.pageNumber"
:page-size="query.pageSize"
:page-sizes="pageSizeOptions"
:total="pageState.total"
@current-change="changePage"
@size-change="changePageSize"
/>
</div>
</div>
</ListPageShell>
<Drawer>
<ChatHistoryDetailDrawer
:visible="true"
:loading="drawerLoading"
:session="currentSession"
:messages="messageList"
:has-more="hasMoreMessages"
:on-load-more="() => loadMessages(false)"
@close="closeDetail"
/>
</Drawer>
</div>
</template>
<style scoped>
.chat-history-page__filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.chat-history-page__filter-control {
min-width: 0;
}
.chat-history-page__filter-control.is-select,
.chat-history-page__filter-control.is-input {
width: 220px;
}
.chat-history-page__filter-control.is-range {
width: 360px;
}
.chat-history-page__quick-ranges {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.chat-history-page__quick-range-button {
min-width: 88px;
}
.chat-history-page__content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.chat-history-page__table-shell {
min-height: 0;
flex: 1;
overflow: hidden;
border: 1px solid hsl(var(--glass-border) / 0.42);
border-radius: 24px;
background: linear-gradient(
180deg,
hsl(var(--glass-border) / 0.28) 0%,
hsl(var(--glass-tint) / 0.4) 14%,
hsl(var(--surface-panel) / 0.94) 100%
);
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.54),
0 24px 42px -36px hsl(var(--foreground) / 0.16);
}
.chat-history-page__table {
height: 100%;
background: transparent;
}
.chat-history-page__session-cell {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
padding: 2px 0;
}
.chat-history-page__session-title {
overflow: hidden;
font-size: 14px;
font-weight: 600;
color: hsl(var(--text-strong));
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-history-page__session-preview {
display: -webkit-box;
overflow: hidden;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--text-muted));
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.chat-history-page__assistant-chip {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 28px;
padding: 0 12px;
border: 1px solid hsl(var(--glass-border) / 0.48);
border-radius: 999px;
background: hsl(var(--glass-tint) / 0.76);
font-size: 12px;
font-weight: 600;
color: hsl(var(--nav-item-active-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-history-page__user-cell,
.chat-history-page__time-cell {
font-size: 13px;
color: hsl(var(--text-secondary));
}
.chat-history-page__count-pill {
display: inline-flex;
min-width: 42px;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: 999px;
background: hsl(var(--surface-contrast-soft) / 0.88);
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-strong));
}
.chat-history-page__detail-action {
padding-right: 2px;
}
.chat-history-page__empty {
display: flex;
min-height: 360px;
flex: 1;
align-items: center;
justify-content: center;
}
.chat-history-page__pagination {
display: flex;
justify-content: flex-end;
padding-top: 20px;
}
.chat-history-page__content :deep(.el-table),
.chat-history-page__content :deep(.el-table__inner-wrapper),
.chat-history-page__content :deep(.el-table__body-wrapper),
.chat-history-page__content :deep(.el-scrollbar),
.chat-history-page__content :deep(.el-scrollbar__wrap),
.chat-history-page__content :deep(.el-scrollbar__view) {
height: 100%;
}
.chat-history-page__content :deep(.el-table::before) {
display: none;
}
.chat-history-page__content :deep(.el-table tr) {
cursor: pointer;
}
.chat-history-page__content :deep(.el-table th.el-table__cell) {
height: 48px;
background: hsl(var(--surface-contrast-soft) / 0.54);
border-bottom-color: hsl(var(--divider-faint) / 0.28);
font-size: 12px;
font-weight: 600;
color: hsl(var(--text-muted));
}
.chat-history-page__content :deep(.el-table td.el-table__cell) {
padding: 14px 0;
background: transparent;
border-bottom-color: hsl(var(--divider-faint) / 0.22);
}
.chat-history-page__content
:deep(.el-table__body tr:hover > td.el-table__cell) {
background: hsl(var(--primary) / 0.04);
}
.chat-history-page__content
:deep(.el-table__body tr.current-row > td.el-table__cell) {
background: hsl(var(--primary) / 0.08);
}
@media (max-width: 1024px) {
.chat-history-page__filter-control.is-range {
width: min(100%, 360px);
}
}
@media (max-width: 768px) {
.chat-history-page {
padding: 16px;
}
.chat-history-page__filters {
gap: 10px;
}
.chat-history-page__filter-control.is-select,
.chat-history-page__filter-control.is-input,
.chat-history-page__filter-control.is-range {
width: 100%;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ChatThinkingBlockProps } from './types';
defineOptions({
name: 'ChatThinkingBlock',
});
const props = withDefaults(defineProps<ChatThinkingBlockProps>(), {
content: '',
disabled: false,
emptyBehavior: 'hide',
expanded: false,
label: '',
readonly: false,
status: 'end',
summary: '',
});
const emit = defineEmits<{
'update:expanded': [boolean];
}>();
const normalizedContent = computed(() =>
String(props.content || '')
.replace(/\r\n/g, '\n')
.replace(/^\s*\n+/, '')
.trimEnd(),
);
const shouldRender = computed(
() =>
normalizedContent.value.length > 0 || props.emptyBehavior === 'placeholder',
);
const expandedModel = computed({
get: () => props.expanded,
set: (value: boolean) => emit('update:expanded', value),
});
const computedLabel = computed(() => {
if (props.label) {
return props.label;
}
if (props.status === 'thinking') {
return '思考中';
}
if (props.status === 'error') {
return '思考异常';
}
return '已思考';
});
const computedSummary = computed(() => {
if (props.summary) {
return props.summary;
}
const source = normalizedContent.value
.split('\n')
.map((line) => line.trim())
.find(Boolean);
if (!source) {
return props.emptyBehavior === 'placeholder' ? '暂无思考内容' : '';
}
return source.length > 56 ? `${source.slice(0, 56)}...` : source;
});
const canToggle = computed(
() => !props.disabled && normalizedContent.value.length > 0,
);
function toggleExpanded() {
if (!canToggle.value) {
return;
}
expandedModel.value = !expandedModel.value;
}
</script>
<template>
<div
v-if="shouldRender"
class="chat-thinking-block"
:class="[
`is-${status}`,
{
'is-disabled': disabled,
'is-expanded': expandedModel,
'is-readonly': readonly,
},
]"
>
<button
type="button"
class="chat-thinking-block__trigger"
:disabled="!canToggle"
@click="toggleExpanded"
>
<span class="chat-thinking-block__leading">
<span class="chat-thinking-block__indicator" aria-hidden="true"></span>
<span class="chat-thinking-block__label">{{ computedLabel }}</span>
</span>
<span
v-if="!expandedModel && computedSummary"
class="chat-thinking-block__summary"
>
{{ computedSummary }}
</span>
<span
class="chat-thinking-block__chevron"
:class="{ 'is-open': expandedModel }"
aria-hidden="true"
></span>
</button>
<transition name="chat-thinking-block__body-transition">
<div
v-if="expandedModel && normalizedContent"
class="chat-thinking-block__body"
>
<div class="chat-thinking-block__content">
{{ normalizedContent }}
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.chat-thinking-block {
border: 1px solid hsl(var(--divider-faint) / 0.18);
border-radius: 16px;
background:
linear-gradient(
180deg,
hsl(var(--glass-tint) / 0.48) 0%,
hsl(var(--surface-panel) / 0.74) 100%
);
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.24),
0 10px 24px -24px hsl(var(--foreground) / 0.18);
backdrop-filter: blur(12px);
}
.chat-thinking-block__trigger {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 12px;
color: inherit;
text-align: left;
background: transparent;
border: 0;
border-radius: inherit;
transition: background-color 0.18s ease;
}
.chat-thinking-block__trigger:not(:disabled) {
cursor: pointer;
}
.chat-thinking-block__trigger:not(:disabled):hover {
background: hsl(var(--surface-contrast-soft) / 0.34);
}
.chat-thinking-block__trigger:disabled {
cursor: default;
}
.chat-thinking-block__leading {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.chat-thinking-block__indicator {
position: relative;
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 999px;
background: hsl(var(--text-muted) / 0.74);
}
.chat-thinking-block.is-thinking .chat-thinking-block__indicator {
background: hsl(var(--primary) / 0.82);
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
animation: chat-thinking-pulse 1.8s ease-in-out infinite;
}
.chat-thinking-block.is-error .chat-thinking-block__indicator {
background: hsl(var(--destructive) / 0.86);
box-shadow: 0 0 0 4px hsl(var(--destructive) / 0.1);
}
.chat-thinking-block__label {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.chat-thinking-block__summary {
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 1.3;
color: hsl(var(--text-muted));
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-thinking-block__chevron {
width: 9px;
height: 9px;
border-right: 1.5px solid hsl(var(--text-muted));
border-bottom: 1.5px solid hsl(var(--text-muted));
transform: rotate(45deg) translateY(-1px);
transition: transform 0.18s ease;
}
.chat-thinking-block__chevron.is-open {
transform: rotate(225deg) translateY(-1px);
}
.chat-thinking-block__body {
padding: 0 12px 12px;
}
.chat-thinking-block__content {
margin: 0;
padding: 10px 12px;
border-radius: 12px;
background: hsl(var(--surface-panel) / 0.72);
font-size: 12px;
line-height: 1.68;
color: hsl(var(--text-secondary));
white-space: pre-wrap;
word-break: break-word;
}
.chat-thinking-block.is-disabled {
opacity: 0.82;
}
.chat-thinking-block__body-transition-enter-active,
.chat-thinking-block__body-transition-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.chat-thinking-block__body-transition-enter-from,
.chat-thinking-block__body-transition-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@keyframes chat-thinking-pulse {
0%,
100% {
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
opacity: 0.92;
}
50% {
box-shadow: 0 0 0 7px hsl(var(--primary) / 0.04);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as ChatThinkingBlock } from './ChatThinkingBlock.vue';
export type {
ChatThinkingBlockProps,
ChatThinkingBlockStatus,
} from './types';

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<g fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3.5h10v6.1a1.4 1.4 0 0 1-1.4 1.4H8.1l-2.2 2V11H4.4A1.4 1.4 0 0 1 3 9.6V3.5Z"/>
<path d="M5.4 6h3.1M5.4 8.1h1.9"/>
<circle cx="10.4" cy="7.4" r="1.8"/>
<path d="M10.4 6.6v.95l.72.48"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 394 B

View File

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

View File

@@ -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 {
* <Icon icon="svg:avatar"></Icon>
*/
async function loadSvgIcons() {
addIcon('svg:chat-history', {
...parseSvg(
typeof chatHistorySvg === 'object'
? chatHistorySvg.default
: chatHistorySvg,
),
});
const svgEagers = import.meta.glob('./icons/**', {
eager: true,
query: '?raw',

View File

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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ChatThinkingBlock } from '@easyflow/common-ui';
import { IconifyIcon } from '@easyflow/icons';
import { useUserStore } from '@easyflow/stores';
@@ -48,13 +49,14 @@ function getUserAvatar() {
v-for="(chain, index) in item.chains"
:key="chain.id || index"
>
<ElThinking
<ChatThinkingBlock
v-if="!('id' in chain)"
v-model="chain.thinlCollapse"
v-model:expanded="chain.thinkingExpanded"
:content="chain.reasoning_content"
:status="chain.thinkingStatus"
class="chat-thinking-block-item"
/>
<ElCollapse v-else class="mb-2">
<ElCollapse v-else class="chat-tool-panel">
<ElCollapseItem :title="chain.name" :name="chain.id">
<template #title>
<div class="flex items-center gap-2 pl-5">
@@ -90,41 +92,6 @@ function getUserAvatar() {
</template>
</template>
<!-- <ElThinking
v-if="item.reasoning_content"
v-model="item.thinlCollapse"
:content="item.reasoning_content"
:status="item.thinkingStatus"
/> -->
<!-- <ElCollapse v-if="item.tools" class="mb-2">
<ElCollapseItem
class="mb-2"
v-for="tool in item.tools"
:key="tool.id"
:title="tool.name"
:name="tool.id"
>
<template #title>
<div class="flex items-center gap-2 pl-5">
<ElIcon size="16">
<IconifyIcon icon="svg:wrench" />
</ElIcon>
<span>{{ tool.name }}</span>
<template v-if="tool.status === 'TOOL_CALL'">
<ElIcon size="16">
<IconifyIcon icon="svg:spinner" />
</ElIcon>
</template>
<template v-else>
<ElIcon size="16" color="var(--el-color-success)">
<CircleCheck />
</ElIcon>
</template>
</div>
</template>
<ShowJson :value="tool.result" />
</ElCollapseItem>
</ElCollapse> -->
</div>
</template>
@@ -162,23 +129,30 @@ function getUserAvatar() {
--bubble-content-max-width: 100%;
}
:deep(.el-thinking) {
margin: 0;
}
:deep(.el-thinking .content-wrapper) {
--el-thinking-content-wrapper-width: 100%;
.chat-thinking-block-item {
margin-bottom: 8px;
}
:deep(.el-collapse) {
:deep(.chat-tool-panel.el-collapse) {
overflow: hidden;
border: 1px solid var(--el-collapse-border-color);
border-radius: 8px;
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(.el-collapse-item__content) {
: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;
}
</style>

View File

@@ -49,14 +49,16 @@ defineExpose({
function getSessionList(resetSession = false) {
api
.get('/userCenter/botConversation/list', {
.get('/userCenter/chatHistory/sessions', {
params: {
botId: props.bot.id,
assistantId: props.bot.id,
pageNumber: 1,
pageSize: 100,
},
})
.then((res) => {
if (res.errorCode === 0) {
sessionList.value = res.data;
sessionList.value = res.data.records || [];
if (resetSession) {
currentSession.value = {};
}
@@ -92,15 +94,27 @@ function clickSession(session: any) {
}
function getMessageList() {
api
.get('/userCenter/botMessage/getMessages', {
params: {
botId: props.bot.id,
conversationId: currentSession.value.id,
},
.get(`/userCenter/chatHistory/sessions/${currentSession.value.id}/messages`, {
params: { pageNumber: 1, pageSize: 100 },
})
.then((res) => {
if (res.errorCode === 0) {
props.onMessageList?.(res.data);
const records = Array.isArray(res.data?.records) ? [...res.data.records] : [];
props.onMessageList?.(
records.reverse().map((item: any) => ({
key: String(item.id),
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
content:
item.senderRole === 'assistant'
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
: item.contentText,
placement: item.senderRole === 'assistant' ? 'start' : 'end',
created: item.created,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains
: undefined,
})),
);
}
});
}
@@ -125,12 +139,8 @@ const updateLoading = ref(false);
function updateTitle() {
updateLoading.value = true;
api
.get('/userCenter/botConversation/updateConversation', {
params: {
botId: props.bot.id,
conversationId: currentSession.value.id,
.post(`/userCenter/chatHistory/sessions/${currentSession.value.id}/rename`, {
title: currentSession.value.title,
},
})
.then((res) => {
updateLoading.value = false;
@@ -150,12 +160,7 @@ function remove(row: any) {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.get('/userCenter/botConversation/deleteConversation', {
params: {
botId: props.bot.id,
conversationId: row.id,
},
})
.post(`/userCenter/chatHistory/sessions/${row.id}/delete`)
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
@@ -175,6 +180,11 @@ function remove(row: any) {
},
}).catch(() => {});
}
function openRenameDialog(row: any) {
currentSession.value = { ...row };
dialogVisible.value = true;
}
</script>
<template>
@@ -230,7 +240,7 @@ function remove(row: any) {
)
"
>
{{ formatCreatedTime(conversation.created) }}
{{ formatCreatedTime(conversation.lastMessageAt || conversation.accessAt || conversation.created) }}
</span>
<ElDropdown
:class="
@@ -249,7 +259,7 @@ function remove(row: any) {
@mouseenter="handleMouseEvent(conversation.id)"
@mouseleave="handleMouseEvent()"
>
<ElDropdownItem @click="dialogVisible = true">
<ElDropdownItem @click="openRenameDialog(conversation)">
<ElButton link :icon="Edit">编辑</ElButton>
</ElDropdownItem>
<ElDropdownItem>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ChatThinkingBlockStatus } from '@easyflow/common-ui';
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
import { inject, ref } from 'vue';
@@ -16,8 +16,8 @@ import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
type Think = {
reasoning_content?: string;
thinkingStatus?: ThinkingStatus;
thinlCollapse?: boolean;
thinkingExpanded?: boolean;
thinkingStatus?: ChatThinkingBlockStatus;
};
type Tool = {
@@ -152,7 +152,7 @@ function sendMessage() {
if (index === -1) {
chains.push({
thinkingStatus: 'thinking',
thinlCollapse: true,
thinkingExpanded: false,
reasoning_content: delta,
});
} else {

View File

@@ -92,20 +92,6 @@ const coreRoutes: RouteRecordRaw[] = [
},
],
},
{
name: 'ChatHistoryShare',
path: '/share/:id',
component: () => import('#/views/chatHistory/share/index.vue'),
meta: {
title: '分享',
noBasicLayout: true,
hideInMenu: true,
hideInTab: true,
hideInBreadcrumb: true,
ignoreAccess: true,
loaded: true,
},
},
];
export { coreRoutes, fallbackNotFoundRoute };

View File

@@ -28,15 +28,20 @@ const routes: RouteRecordRaw[] = [
meta: {
icon: 'svg:chat-history',
order: 80,
title: '聊天记录',
title: '聊天历史',
},
},
{
name: 'ChatHistoryDetails',
path: '/chatHistory/:id',
component: () => import('#/views/chatHistory/details/index.vue'),
redirect: (to) => ({
path: '/chatHistory',
query: {
sessionId: String(to.params.id || ''),
},
}),
meta: {
title: '聊天记录',
title: '聊天历史',
hideInMenu: true,
hideInTab: true,
hideInBreadcrumb: true,

View File

@@ -1,168 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { IconifyIcon } from '@easyflow/icons';
import { copyToClipboard } from '@easyflow/utils';
import { ArrowLeft, Delete, MoreFilled } from '@element-plus/icons-vue';
import {
ElButton,
ElContainer,
ElDropdown,
ElDropdownItem,
ElHeader,
ElMain,
ElMessage,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import { ChatBubbleList } from '#/components/chat';
import { router } from '#/router';
const route = useRoute();
const ids = reactive({
botId: '',
conversationId: '',
});
const conversationInfo = ref<any>();
const messageList = ref<any[]>([]);
const loading = ref(true);
onMounted(() => {
if (route.params.id) {
ids.conversationId = route.params.id as string;
getConversationDetails();
}
});
function getConversationDetails() {
api
.get('/userCenter/botConversation/detail', {
params: {
id: ids.conversationId,
},
})
.then((res) => {
if (res.errorCode === 0) {
conversationInfo.value = res.data;
ids.botId = res.data.botId;
getMessageList();
}
});
}
function getMessageList() {
api
.get('/userCenter/botMessage/getMessages', {
params: ids,
})
.then((res) => {
if (res.errorCode === 0) {
messageList.value = res.data;
loading.value = false;
}
});
}
async function handleShare() {
const shareLink = import.meta.env.DEV
? `${location.origin}/share/${ids.conversationId}`
: `${location.origin}/#/share/${ids.conversationId}`;
const { success, error } = await copyToClipboard(shareLink);
if (success) {
ElMessage.success('分享链接复制成功!');
} else {
ElMessage.error(error);
}
}
async function handleDelete() {
const [, res] = await tryit(api.post)('/userCenter/botConversation/remove', {
id: ids.conversationId,
});
if (res && res.errorCode === 0) {
ElMessage.success('删除成功');
router.back();
}
}
</script>
<template>
<ElContainer class="bg-background h-full">
<ElHeader height="100px" class="border-border border-b !pr-10">
<div class="flex h-full w-full items-center justify-between">
<!-- Left -->
<div class="flex items-center gap-3">
<ElButton
link
style="font-size: 20px"
:icon="ArrowLeft"
@click="router.back()"
/>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-lg font-medium">{{
conversationInfo?.title
}}</span>
<div
v-if="conversationInfo?.bot.title"
class="text-foreground/70 rounded bg-[var(--el-fill-color-light)] p-1 text-xs"
>
{{ conversationInfo.bot.title }}
</div>
</div>
<span class="text-foreground/50 text-sm">{{
conversationInfo?.created
}}</span>
</div>
</div>
<!-- Right -->
<div class="flex items-center gap-5">
<ElButton link style="font-size: 20px" @click="handleShare">
<template #icon>
<IconifyIcon icon="svg:share" />
</template>
</ElButton>
<ElDropdown>
<ElButton link style="font-size: 20px" :icon="MoreFilled" />
<template #dropdown>
<ElDropdownItem
style="color: var(--el-color-danger)"
:icon="Delete"
@click="handleDelete"
>
删除
</ElDropdownItem>
</template>
</ElDropdown>
</div>
</div>
</ElHeader>
<ElMain class="relative" v-loading="loading">
<div
class="absolute bottom-5 left-1/2 top-5 w-full max-w-[1000px] -translate-x-1/2"
>
<ChatBubbleList
:bot="conversationInfo?.bot"
:messages="messageList"
:editable="false"
:open-editor="() => {}"
/>
</div>
</ElMain>
</ElContainer>
</template>
<style lang="css" scoped>
:deep(.el-bubble-list) {
max-height: 100%;
}
:deep(.el-bubble-content-wrapper .el-bubble-content) {
--bubble-content-max-width: calc(100% - 52px);
}
</style>

View File

@@ -1,177 +1,367 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Delete, MoreFilled, Search } from '@element-plus/icons-vue';
import { Delete, Edit, Search } from '@element-plus/icons-vue';
import {
ElButton,
ElContainer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElDrawer,
ElEmpty,
ElHeader,
ElInput,
ElMain,
ElMessage,
ElMessageBox,
ElPagination,
ElSelect,
ElSpace,
ElText,
ElTag,
} from 'element-plus';
import { tryit } from 'radash';
import { api } from '#/api/request';
import PageData from '#/components/page/PageData.vue';
const listTitles = ['聊天助理名称', '话题', '创建时间', '操作'];
import { ChatBubbleList } from '#/components/chat';
const route = useRoute();
const router = useRouter();
const assistantList = ref<any[]>([]);
const queryParams = ref<any>({});
const pageRef = ref();
onMounted(() => {
getAssistantList();
const assistantList = ref<any[]>([]);
const sessions = ref<any[]>([]);
const loading = ref(false);
const queryParams = ref({
assistantId: undefined as number | undefined,
keyword: '',
pageNumber: 1,
pageSize: 20,
});
async function getAssistantList() {
api
.get('/userCenter/bot/list', {
params: { ...queryParams.value, status: 1 },
})
.then((res) => {
if (res.errorCode === 0) {
assistantList.value = res.data.map((item: any) => ({
label: item.title,
value: item.id,
}));
}
});
}
function search() {
pageRef.value.setQuery({ ...queryParams.value, status: 1 });
}
function toDetail(record: any) {
router.push({ path: `/chatHistory/${record.id}` });
}
async function handleDelete(id: string) {
const [, res] = await tryit(api.post)('/userCenter/botConversation/remove', {
id,
});
const pageState = ref({
total: 0,
});
if (res && res.errorCode === 0) {
search();
ElMessage.success('删除成功');
const drawerVisible = ref(false);
const drawerLoading = ref(false);
const currentSession = ref<any>();
const messageList = ref<any[]>([]);
const messagePage = ref({
total: 0,
pageNumber: 1,
pageSize: 20,
});
const filteredSessions = computed(() => {
const keyword = queryParams.value.keyword.trim().toLowerCase();
if (!keyword) {
return sessions.value;
}
return sessions.value.filter((item) => {
const title = String(item.title || '').toLowerCase();
const preview = String(item.lastMessagePreview || '').toLowerCase();
const assistantName = String(item.assistantName || '').toLowerCase();
return title.includes(keyword) || preview.includes(keyword) || assistantName.includes(keyword);
});
});
onMounted(async () => {
await Promise.all([fetchAssistants(), fetchSessions()]);
const sessionId = route.query.sessionId ? String(route.query.sessionId) : '';
if (sessionId) {
await openSession(sessionId);
}
});
watch(
() => route.query.sessionId,
async (sessionId) => {
if (!sessionId) {
drawerVisible.value = false;
currentSession.value = undefined;
messageList.value = [];
return;
}
if (!currentSession.value || String(currentSession.value.id) !== String(sessionId)) {
await openSession(String(sessionId));
}
},
);
async function fetchAssistants() {
const [, res] = await tryit(api.get)('/userCenter/bot/list', {
params: { status: 1 },
});
if (res?.errorCode === 0) {
assistantList.value = (res.data || []).map((item: any) => ({
label: item.title,
value: item.id,
}));
}
}
async function fetchSessions() {
loading.value = true;
const [, res] = await tryit(api.get)('/userCenter/chatHistory/sessions', {
params: {
assistantId: queryParams.value.assistantId,
pageNumber: queryParams.value.pageNumber,
pageSize: queryParams.value.pageSize,
},
});
loading.value = false;
if (res?.errorCode === 0) {
sessions.value = res.data?.records || [];
pageState.value.total = res.data?.total || 0;
}
}
async function openSession(sessionId: string | number) {
drawerLoading.value = true;
const [, summaryRes] = await tryit(api.get)(`/userCenter/chatHistory/sessions/${sessionId}`);
if (summaryRes?.errorCode !== 0) {
drawerLoading.value = false;
return;
}
currentSession.value = summaryRes.data;
messageList.value = [];
messagePage.value = {
total: 0,
pageNumber: 1,
pageSize: 20,
};
drawerVisible.value = true;
await loadMessages(true);
drawerLoading.value = false;
if (String(route.query.sessionId || '') !== String(sessionId)) {
router.replace({
path: '/chatHistory',
query: { ...route.query, sessionId: String(sessionId) },
});
}
}
async function loadMessages(reset = false) {
if (!currentSession.value?.id) {
return;
}
const nextPageNumber = reset ? 1 : messagePage.value.pageNumber + 1;
const [, res] = await tryit(api.get)(
`/userCenter/chatHistory/sessions/${currentSession.value.id}/messages`,
{
params: {
pageNumber: nextPageNumber,
pageSize: messagePage.value.pageSize,
},
},
);
if (res?.errorCode !== 0) {
return;
}
const normalized = normalizeMessages(res.data?.records || []);
if (reset) {
messageList.value = normalized;
} else {
messageList.value = [...normalized, ...messageList.value];
}
messagePage.value.total = res.data?.total || 0;
messagePage.value.pageNumber = nextPageNumber;
}
function normalizeMessages(records: any[]) {
return [...records]
.reverse()
.map((item: any) => ({
key: String(item.id),
role: item.senderRole === 'assistant' ? 'assistant' : 'user',
content:
item.senderRole === 'assistant'
? String(item.contentText || '').replace(/^Final Answer:\s*/i, '')
: item.contentText,
placement: item.senderRole === 'assistant' ? 'start' : 'end',
created: item.created,
chains: Array.isArray(item.contentPayload?.chains)
? item.contentPayload.chains
: undefined,
}));
}
function closeDrawer() {
drawerVisible.value = false;
currentSession.value = undefined;
messageList.value = [];
router.replace({ path: '/chatHistory', query: {} });
}
function changePage(pageNumber: number) {
queryParams.value.pageNumber = pageNumber;
fetchSessions();
}
async function renameSession(session: any) {
const [, promptRes] = await tryit(ElMessageBox.prompt)(
'请输入新的会话名称',
'重命名会话',
{
inputValue: session.title || '',
inputPlaceholder: '请输入会话名称',
confirmButtonText: '确认',
cancelButtonText: '取消',
},
);
const value = promptRes?.value?.trim();
if (!value) {
return;
}
const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/rename`, {
title: value,
});
if (res?.errorCode === 0) {
ElMessage.success('重命名成功');
if (currentSession.value?.id === session.id) {
currentSession.value.title = value;
}
await fetchSessions();
}
}
function deleteSession(session: any) {
ElMessageBox.confirm('删除后将不再出现在聊天历史中,是否继续?', '删除会话', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
}).then(async () => {
const [, res] = await tryit(api.post)(`/userCenter/chatHistory/sessions/${session.id}/delete`, {});
if (res?.errorCode === 0) {
ElMessage.success('删除成功');
if (currentSession.value?.id === session.id) {
closeDrawer();
}
await fetchSessions();
}
}).catch(() => {});
}
function formatTime(value?: string) {
if (!value) {
return '';
}
const time = new Date(value);
if (Number.isNaN(time.getTime())) {
return value;
}
return `${time.getMonth() + 1}-${time.getDate()} ${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
}
</script>
<template>
<ElContainer class="bg-background-deep h-full">
<ElHeader class="!h-auto !p-8 !pb-0">
<ElSpace direction="vertical" :size="24" alignment="flex-start">
<h1 class="text-2xl font-medium">聊天记录</h1>
<div class="flex items-center gap-5">
<div class="flex items-center gap-4">
<span class="text-nowrap text-sm">聊天助理</span>
<ElSelect
clearable
v-model="queryParams.botId"
:options="assistantList"
placeholder="请选择聊天助理"
@change="search"
/>
</div>
<ElHeader class="!h-auto !px-8 !pb-0 !pt-8">
<ElSpace direction="vertical" :size="20" alignment="flex-start" class="w-full">
<div>
<h1 class="text-2xl font-medium">聊天历史</h1>
<p class="text-foreground/60 mt-2 text-sm">
查看最近会话并在右侧抽屉中回溯完整聊天内容
</p>
</div>
<div class="flex w-full flex-wrap items-center gap-4">
<ElSelect
v-model="queryParams.assistantId"
clearable
placeholder="筛选聊天助理"
:options="assistantList"
class="!w-[220px]"
@change="fetchSessions"
/>
<ElInput
placeholder="搜索关键词"
v-model="queryParams.title"
@keyup.enter="search"
@change="search"
v-model="queryParams.keyword"
class="max-w-[320px]"
placeholder="搜索标题或最近消息"
:prefix-icon="Search"
/>
</div>
</ElSpace>
</ElHeader>
<ElMain class="!px-8">
<ElContainer class="bg-background rounded-lg p-5">
<ElHeader
class="dark:bg-accent grid grid-cols-[repeat(3,minmax(0,1fr))_120px] place-items-center rounded-lg bg-[#f7f9fd] !p-0"
height="54px"
>
<span
class="text-accent-foreground text-sm"
v-for="title in listTitles"
:key="title"
<ElMain class="!px-8 !pb-8">
<div class="bg-background border-border min-h-full rounded-2xl border p-5">
<div v-if="filteredSessions.length > 0" class="flex flex-col gap-3">
<button
v-for="item in filteredSessions"
:key="item.id"
class="border-border hover:border-primary/30 hover:bg-accent/40 flex w-full items-start justify-between gap-4 rounded-2xl border px-5 py-4 text-left transition-colors"
@click="openSession(item.id)"
>
{{ title }}
</span>
</ElHeader>
<ElMain class="!p-0">
<div class="flex flex-col items-center gap-5">
<div class="w-full">
<PageData
page-url="/userCenter/botConversation/pageList"
ref="pageRef"
>
<template #default="{ pageList }">
<div
class="text-foreground/90 grid h-[60px] grid-cols-[repeat(3,minmax(0,1fr))_120px] place-items-center text-sm hover:bg-[var(--el-fill-color-light)]"
v-for="record in pageList"
:key="record.id"
>
<ElText truncated>{{ record.bot.title }}</ElText>
<ElText line-clamp="2">{{ record.title }}</ElText>
<span>{{ record.created }}</span>
<div class="flex items-center gap-3">
<ElButton
class="[--el-font-weight-primary:400]"
link
type="primary"
@click="toDetail(record)"
>
查看详情
</ElButton>
<ElDropdown>
<ElButton :icon="MoreFilled" link />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem>
<ElButton
link
type="danger"
:icon="Delete"
@click="handleDelete(record.id)"
>
删除
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</template>
</PageData>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3">
<span class="truncate text-base font-medium">{{ item.title || '未命名会话' }}</span>
<ElTag size="small" effect="plain">{{ item.assistantName || '聊天助理' }}</ElTag>
</div>
<div class="text-foreground/65 mt-2 line-clamp-2 text-sm">
{{ item.lastMessagePreview || '暂无消息内容' }}
</div>
<div class="text-foreground/50 mt-3 flex flex-wrap items-center gap-4 text-xs">
<span>最近发送人{{ item.lastSenderName || '未知' }}</span>
<span>消息数{{ item.messageCount || 0 }}</span>
<span>活跃时间{{ formatTime(item.lastMessageAt || item.accessAt) }}</span>
</div>
</div>
</div>
</ElMain>
</ElContainer>
<div class="flex items-center gap-2">
<ElButton link :icon="Edit" @click.stop="renameSession(item)">重命名</ElButton>
<ElButton link type="danger" :icon="Delete" @click.stop="deleteSession(item)">删除</ElButton>
</div>
</button>
</div>
<ElEmpty v-else description="暂无聊天历史" />
<div class="mt-6 flex justify-end" v-if="pageState.total > queryParams.pageSize">
<ElPagination
background
layout="prev, pager, next"
:current-page="queryParams.pageNumber"
:page-size="queryParams.pageSize"
:total="pageState.total"
@current-change="changePage"
/>
</div>
</div>
</ElMain>
</ElContainer>
<ElDrawer
v-model="drawerVisible"
:title="currentSession?.title || '聊天详情'"
size="760px"
destroy-on-close
@close="closeDrawer"
>
<div v-loading="drawerLoading" class="flex h-full flex-col">
<div class="border-border mb-4 flex items-center justify-between rounded-2xl border px-4 py-3">
<div class="min-w-0">
<div class="truncate text-base font-medium">{{ currentSession?.title || '聊天详情' }}</div>
<div class="text-foreground/55 mt-1 text-sm">
{{ currentSession?.assistantName || '聊天助理' }}
</div>
</div>
<div class="text-foreground/50 text-xs">
{{ formatTime(currentSession?.lastMessageAt || currentSession?.accessAt) }}
</div>
</div>
<div class="flex-1 overflow-hidden">
<div class="mb-4 flex justify-center">
<ElButton
v-if="messageList.length < messagePage.total"
text
type="primary"
@click="loadMessages(false)"
>
加载更早消息
</ElButton>
</div>
<ChatBubbleList
:bot="{ icon: '', title: currentSession?.assistantName || '' }"
:messages="messageList"
/>
</div>
</div>
</ElDrawer>
</template>
<style lang="css" scoped>
.el-select {
--el-select-width: 165px;
}
.el-select.bot-select {
--el-select-width: 343px;
}
.el-select :deep(.el-select__wrapper) {
--el-border-radius-base: 8px;
}
</style>

View File

@@ -1,127 +0,0 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ElContainer, ElHeader, ElMain } from 'element-plus';
import { api } from '#/api/request';
import { ChatBubbleList } from '#/components/chat';
const route = useRoute();
const ids = reactive({
botId: '',
conversationId: '',
});
const conversationInfo = ref<any>();
const messageList = ref<any[]>([]);
const loading = ref(true);
const logoUrl = `${import.meta.env.BASE_URL || '/'}logo.svg`;
onMounted(() => {
if (route.params.id) {
ids.conversationId = route.params.id as string;
getConversationDetails();
}
});
function getConversationDetails() {
api
.get('/userCenter/botConversation/detail', {
params: {
id: ids.conversationId,
},
})
.then((res) => {
if (res.errorCode === 0) {
conversationInfo.value = res.data;
ids.botId = res.data.botId;
getMessageList();
}
});
}
function getMessageList() {
api
.get('/userCenter/botMessage/getMessages', {
params: ids,
})
.then((res) => {
if (res.errorCode === 0) {
messageList.value = res.data;
loading.value = false;
}
});
}
</script>
<template>
<div class="h-full w-full px-12 py-8 max-sm:p-3">
<ElContainer class="bg-background h-full">
<ElHeader
height="80px"
class="rounded-xl bg-[#F8F8F9] !pr-9 max-sm:!h-16 max-sm:!pr-3"
>
<div class="flex h-full w-full items-center justify-between">
<!-- Left -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-lg font-medium max-sm:text-base">{{
conversationInfo?.title
}}</span>
<div
v-if="conversationInfo?.bot.title"
class="text-foreground/70 rounded bg-[#ECECEE] p-1 text-xs"
>
{{ conversationInfo.bot.title }}
</div>
</div>
<span class="text-foreground/50 text-sm max-sm:text-xs">{{
conversationInfo?.created
}}</span>
</div>
<!-- Right -->
<img :src="logoUrl" class="w-40 max-sm:w-28" />
</div>
</ElHeader>
<ElMain class="relative max-sm:mt-2 max-sm:!p-0" v-loading="loading">
<div
class="absolute bottom-5 left-1/2 top-5 w-full max-w-[1000px] -translate-x-1/2 max-sm:bottom-0 max-sm:top-0"
>
<ChatBubbleList
class="relative mx-auto h-full max-w-[1000px]"
:bot="conversationInfo?.bot"
:messages="messageList"
:editable="false"
:open-editor="() => {}"
/>
</div>
</ElMain>
</ElContainer>
</div>
</template>
<style lang="css" scoped>
:deep(.el-bubble-list) {
max-height: 100%;
}
:deep(.el-bubble-content-wrapper .el-bubble-content) {
--bubble-content-max-width: calc(100% - 52px);
}
@media not all and (min-width: 640px) {
:deep(.el-bubble) {
gap: 8px;
}
:deep(.el-avatar) {
width: 30px;
height: 30px;
}
:deep(.el-bubble-content-wrapper .el-bubble-content) {
--bubble-content-max-width: calc(100% - 38px);
}
}
</style>

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { ChatThinkingBlockProps } from './types';
defineOptions({
name: 'ChatThinkingBlock',
});
const props = withDefaults(defineProps<ChatThinkingBlockProps>(), {
content: '',
disabled: false,
emptyBehavior: 'hide',
expanded: false,
label: '',
readonly: false,
status: 'end',
summary: '',
});
const emit = defineEmits<{
'update:expanded': [boolean];
}>();
const normalizedContent = computed(() =>
String(props.content || '')
.replace(/\r\n/g, '\n')
.replace(/^\s*\n+/, '')
.trimEnd(),
);
const shouldRender = computed(
() =>
normalizedContent.value.length > 0 || props.emptyBehavior === 'placeholder',
);
const expandedModel = computed({
get: () => props.expanded,
set: (value: boolean) => emit('update:expanded', value),
});
const computedLabel = computed(() => {
if (props.label) {
return props.label;
}
if (props.status === 'thinking') {
return '思考中';
}
if (props.status === 'error') {
return '思考异常';
}
return '已思考';
});
const computedSummary = computed(() => {
if (props.summary) {
return props.summary;
}
const source = normalizedContent.value
.split('\n')
.map((line) => line.trim())
.find(Boolean);
if (!source) {
return props.emptyBehavior === 'placeholder' ? '暂无思考内容' : '';
}
return source.length > 56 ? `${source.slice(0, 56)}...` : source;
});
const canToggle = computed(
() => !props.disabled && normalizedContent.value.length > 0,
);
function toggleExpanded() {
if (!canToggle.value) {
return;
}
expandedModel.value = !expandedModel.value;
}
</script>
<template>
<div
v-if="shouldRender"
class="chat-thinking-block"
:class="[
`is-${status}`,
{
'is-disabled': disabled,
'is-expanded': expandedModel,
'is-readonly': readonly,
},
]"
>
<button
type="button"
class="chat-thinking-block__trigger"
:disabled="!canToggle"
@click="toggleExpanded"
>
<span class="chat-thinking-block__leading">
<span class="chat-thinking-block__indicator" aria-hidden="true"></span>
<span class="chat-thinking-block__label">{{ computedLabel }}</span>
</span>
<span
v-if="!expandedModel && computedSummary"
class="chat-thinking-block__summary"
>
{{ computedSummary }}
</span>
<span
class="chat-thinking-block__chevron"
:class="{ 'is-open': expandedModel }"
aria-hidden="true"
></span>
</button>
<transition name="chat-thinking-block__body-transition">
<div
v-if="expandedModel && normalizedContent"
class="chat-thinking-block__body"
>
<div class="chat-thinking-block__content">
{{ normalizedContent }}
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.chat-thinking-block {
border: 1px solid hsl(var(--divider-faint) / 0.18);
border-radius: 16px;
background:
linear-gradient(
180deg,
hsl(var(--glass-tint) / 0.48) 0%,
hsl(var(--surface-panel) / 0.74) 100%
);
box-shadow:
inset 0 1px 0 hsl(var(--glass-border) / 0.24),
0 10px 24px -24px hsl(var(--foreground) / 0.18);
backdrop-filter: blur(12px);
}
.chat-thinking-block__trigger {
display: grid;
width: 100%;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 12px;
color: inherit;
text-align: left;
background: transparent;
border: 0;
border-radius: inherit;
transition: background-color 0.18s ease;
}
.chat-thinking-block__trigger:not(:disabled) {
cursor: pointer;
}
.chat-thinking-block__trigger:not(:disabled):hover {
background: hsl(var(--surface-contrast-soft) / 0.34);
}
.chat-thinking-block__trigger:disabled {
cursor: default;
}
.chat-thinking-block__leading {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
}
.chat-thinking-block__indicator {
position: relative;
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 999px;
background: hsl(var(--text-muted) / 0.74);
}
.chat-thinking-block.is-thinking .chat-thinking-block__indicator {
background: hsl(var(--primary) / 0.82);
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
animation: chat-thinking-pulse 1.8s ease-in-out infinite;
}
.chat-thinking-block.is-error .chat-thinking-block__indicator {
background: hsl(var(--destructive) / 0.86);
box-shadow: 0 0 0 4px hsl(var(--destructive) / 0.1);
}
.chat-thinking-block__label {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.chat-thinking-block__summary {
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 1.3;
color: hsl(var(--text-muted));
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-thinking-block__chevron {
width: 9px;
height: 9px;
border-right: 1.5px solid hsl(var(--text-muted));
border-bottom: 1.5px solid hsl(var(--text-muted));
transform: rotate(45deg) translateY(-1px);
transition: transform 0.18s ease;
}
.chat-thinking-block__chevron.is-open {
transform: rotate(225deg) translateY(-1px);
}
.chat-thinking-block__body {
padding: 0 12px 12px;
}
.chat-thinking-block__content {
margin: 0;
padding: 10px 12px;
border-radius: 12px;
background: hsl(var(--surface-panel) / 0.72);
font-size: 12px;
line-height: 1.68;
color: hsl(var(--text-secondary));
white-space: pre-wrap;
word-break: break-word;
}
.chat-thinking-block.is-disabled {
opacity: 0.82;
}
.chat-thinking-block__body-transition-enter-active,
.chat-thinking-block__body-transition-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.chat-thinking-block__body-transition-enter-from,
.chat-thinking-block__body-transition-leave-to {
opacity: 0;
transform: translateY(-4px);
}
@keyframes chat-thinking-pulse {
0%,
100% {
box-shadow: 0 0 0 4px hsl(var(--primary) / 0.12);
opacity: 0.92;
}
50% {
box-shadow: 0 0 0 7px hsl(var(--primary) / 0.04);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,5 @@
export { default as ChatThinkingBlock } from './ChatThinkingBlock.vue';
export type {
ChatThinkingBlockProps,
ChatThinkingBlockStatus,
} from './types';

View File

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

View File

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

View File

@@ -1,17 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="18px" height="16px" viewBox="0 0 18 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>聊天助理备份 7</title>
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="聊天记录" transform="translate(-29, -463)">
<g id="编组-12备份-2" transform="translate(0, 3)">
<g id="编组-32备份-2" transform="translate(12, 444)">
<g id="聊天助理备份-7" transform="translate(16, 14)">
<rect id="矩形" x="0" y="0" width="20" height="20"></rect>
<path d="M15,2.8 C15.8836556,2.8 16.6836556,3.1581722 17.2627417,3.7372583 C17.8418278,4.3163444 18.2,5.1163444 18.2,6 L18.2,17.2 L5,17.2 C4.1163444,17.2 3.3163444,16.8418278 2.7372583,16.2627417 C2.1581722,15.6836556 1.8,14.8836556 1.8,14 L1.8,6 C1.8,5.1163444 2.1581722,4.3163444 2.7372583,3.7372583 C3.3163444,3.1581722 4.1163444,2.8 5,2.8 Z" id="形状结合" stroke="currentColor" stroke-width="1.6"></path>
<polyline id="路径-2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" points="8.8275 6.93789684 8.8275 10.9003453 12.5 10.9003453"></polyline>
</g>
</g>
</g>
</g>
</g>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.25"
>
<path d="M3 3.4h7.9A1.9 1.9 0 0 1 12.8 5.3v3.5a1.9 1.9 0 0 1-1.9 1.9H7.2l-2.4 2V10.7H4.9A1.9 1.9 0 0 1 3 8.8V3.4Z"/>
<path d="M5.3 5.9h2.2"/>
<circle cx="9.6" cy="7.2" r="1.95"/>
<path d="M9.6 6.2v1.2l.85.58"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -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');
@@ -27,6 +28,7 @@ export {
SvgBellIcon,
SvgCakeIcon,
SvgCardIcon,
SvgChatHistoryIcon,
SvgDingDingIcon,
SvgDownloadIcon,
SvgGithubIcon,