feat: 接入聊天历史界面与外链会话恢复
- 新增管理端与用户端聊天历史接口和页面 - 外链聊天支持访问令牌登录、身份保活与当前会话恢复 - 聊天执行链路切到统一 runtime 与 chatlog 查询接口
This commit is contained in:
@@ -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>
|
||||
@@ -29,4 +33,4 @@
|
||||
<artifactId>easyflow-common-captcha</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@
|
||||
<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>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "工具已返回",
|
||||
|
||||
@@ -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;
|
||||
|
||||
646
easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue
Normal file
646
easyflow-ui-admin/app/src/views/ai/chatHistory/index.vue
Normal 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
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ChatThinkingBlock } from './ChatThinkingBlock.vue';
|
||||
export type {
|
||||
ChatThinkingBlockProps,
|
||||
ChatThinkingBlockStatus,
|
||||
} from './types';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ChatThinkingBlock } from './ChatThinkingBlock.vue';
|
||||
export type {
|
||||
ChatThinkingBlockProps,
|
||||
ChatThinkingBlockStatus,
|
||||
} from './types';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user