feat: 支持聊天多版本答案切换
- 为管理端、公共聊天和用户中心补充回答变体查询与切换能力 - 支持基于指定轮次重新生成并同步前后端多版本状态 - 保留 application.yml 与本地截图文件为未提交状态
This commit is contained in:
@@ -2,17 +2,23 @@ package tech.easyflow.admin.controller.ai;
|
|||||||
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||||
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
|
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
|
||||||
import tech.easyflow.chatlog.service.ChatHistoryManageService;
|
import tech.easyflow.chatlog.service.ChatHistoryManageService;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/chatHistory")
|
@RequestMapping("/api/v1/chatHistory")
|
||||||
@@ -38,4 +44,18 @@ public class ChatHistoryController {
|
|||||||
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
|
public Result<ChatHistoryPage> queryMessages(@PathVariable BigInteger sessionId, ChatPageQuery query) {
|
||||||
return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query));
|
return Result.ok(chatHistoryManageService.queryAdminMessages(sessionId, query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
|
||||||
|
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
|
||||||
|
@PathVariable BigInteger roundId) {
|
||||||
|
return Result.ok(chatHistoryManageService.listAdminRoundVariants(sessionId, roundId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
|
||||||
|
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
|
||||||
|
@PathVariable BigInteger roundId,
|
||||||
|
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
return Result.ok(chatHistoryManageService.selectAdminRoundVariant(sessionId, roundId, variantIndex, account.getId()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
package tech.easyflow.admin.controller.ai;
|
package tech.easyflow.admin.controller.ai;
|
||||||
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
||||||
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
|
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/public-chat")
|
@RequestMapping("/api/v1/public-chat")
|
||||||
@@ -35,4 +40,24 @@ public class PublicChatSessionController {
|
|||||||
);
|
);
|
||||||
return Result.ok(result);
|
return Result.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/session/{sessionId}/rounds/{roundId}/variants")
|
||||||
|
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
|
||||||
|
@PathVariable BigInteger roundId,
|
||||||
|
BigInteger botId) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
BigInteger userId = account == null ? null : account.getId();
|
||||||
|
return Result.ok(publicChatSessionRestoreService.listVariants(userId, botId, sessionId, roundId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/session/{sessionId}/rounds/{roundId}/selectVariant")
|
||||||
|
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
|
||||||
|
@PathVariable BigInteger roundId,
|
||||||
|
BigInteger botId,
|
||||||
|
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
BigInteger userId = account == null ? null : account.getId();
|
||||||
|
BigInteger operatorId = account == null ? BigInteger.ZERO : account.getId();
|
||||||
|
return Result.ok(publicChatSessionRestoreService.selectVariant(userId, botId, sessionId, roundId, variantIndex, operatorId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
|||||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||||
import tech.easyflow.core.runtime.ChatChannel;
|
import tech.easyflow.core.runtime.ChatChannel;
|
||||||
|
import tech.easyflow.core.runtime.ChatRuntimeExtKeys;
|
||||||
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
import tech.easyflow.core.runtime.ChatRuntimeContext;
|
||||||
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
import tech.easyflow.system.entity.vo.RoleCategoryAccessSnapshot;
|
||||||
import tech.easyflow.system.service.CategoryPermissionService;
|
import tech.easyflow.system.service.CategoryPermissionService;
|
||||||
@@ -67,6 +69,8 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
|||||||
private Cache<String, Object> cache;
|
private Cache<String, Object> cache;
|
||||||
@Resource
|
@Resource
|
||||||
private AudioServiceManager audioServiceManager;
|
private AudioServiceManager audioServiceManager;
|
||||||
|
@Resource
|
||||||
|
private ChatRoundOperateService chatRoundOperateService;
|
||||||
|
|
||||||
public UcBotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
|
public UcBotController(BotService service, ModelService modelService, BotWorkflowService botWorkflowService,
|
||||||
BotDocumentCollectionService botDocumentCollectionService) {
|
BotDocumentCollectionService botDocumentCollectionService) {
|
||||||
@@ -153,13 +157,17 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
|||||||
@JsonBody(value = "botId", required = true) BigInteger botId,
|
@JsonBody(value = "botId", required = true) BigInteger botId,
|
||||||
@JsonBody(value = "conversationId", required = true) BigInteger conversationId,
|
@JsonBody(value = "conversationId", required = true) BigInteger conversationId,
|
||||||
@JsonBody(value = "messages") List<Map<String, String>> messages,
|
@JsonBody(value = "messages") List<Map<String, String>> messages,
|
||||||
@JsonBody(value = "attachments") List<String> attachments
|
@JsonBody(value = "attachments") List<String> attachments,
|
||||||
|
@JsonBody(value = "regenerateRoundId") BigInteger regenerateRoundId
|
||||||
|
|
||||||
) {
|
) {
|
||||||
BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult();
|
BotServiceImpl.ChatCheckResult chatCheckResult = new BotServiceImpl.ChatCheckResult();
|
||||||
|
if (regenerateRoundId != null) {
|
||||||
|
chatRoundOperateService.requireRegeneratableRound(conversationId, regenerateRoundId);
|
||||||
|
}
|
||||||
|
|
||||||
// 前置校验:失败则直接返回错误SseEmitter
|
// 前置校验:失败则直接返回错误SseEmitter
|
||||||
SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult);
|
SseEmitter errorEmitter = botService.checkChatBeforeStart(botId, prompt, conversationId.toString(), chatCheckResult, regenerateRoundId);
|
||||||
if (errorEmitter != null) {
|
if (errorEmitter != null) {
|
||||||
return errorEmitter;
|
return errorEmitter;
|
||||||
}
|
}
|
||||||
@@ -171,7 +179,7 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
|||||||
messages,
|
messages,
|
||||||
chatCheckResult,
|
chatCheckResult,
|
||||||
attachments,
|
attachments,
|
||||||
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments)
|
buildRuntimeContext(chatCheckResult.getAiBot(), conversationId, prompt, attachments, regenerateRoundId)
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -287,7 +295,8 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
|||||||
return super.onSaveOrUpdateBefore(entity, isSave);
|
return super.onSaveOrUpdateBefore(entity, isSave);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments) {
|
private ChatRuntimeContext buildRuntimeContext(Bot bot, BigInteger conversationId, String prompt, List<String> attachments,
|
||||||
|
BigInteger regenerateRoundId) {
|
||||||
LoginAccount account = requireCurrentLoginAccount();
|
LoginAccount account = requireCurrentLoginAccount();
|
||||||
ChatRuntimeContext context = new ChatRuntimeContext();
|
ChatRuntimeContext context = new ChatRuntimeContext();
|
||||||
context.setChannel(ChatChannel.USER_CENTER);
|
context.setChannel(ChatChannel.USER_CENTER);
|
||||||
@@ -303,6 +312,9 @@ public class UcBotController extends BaseCurdController<BotService, Bot> {
|
|||||||
context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt);
|
context.setSessionTitle(prompt.length() > 200 ? prompt.substring(0, 200) : prompt);
|
||||||
context.setAnonymous(false);
|
context.setAnonymous(false);
|
||||||
context.setAttachments(attachments);
|
context.setAttachments(attachments);
|
||||||
|
if (regenerateRoundId != null) {
|
||||||
|
context.getExt().put(ChatRuntimeExtKeys.REGENERATE_ROUND_ID, regenerateRoundId);
|
||||||
|
}
|
||||||
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot);
|
ChatTimeToolAvailabilityContext.bindLoggedInSnapshot(context, account, bot);
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||||
@@ -17,6 +18,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
|||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/userCenter/chatHistory")
|
@RequestMapping("/userCenter/chatHistory")
|
||||||
@@ -61,4 +63,19 @@ public class UcChatHistoryController {
|
|||||||
chatHistoryManageService.deleteUserSession(account.getId(), sessionId, account.getId());
|
chatHistoryManageService.deleteUserSession(account.getId(), sessionId, account.getId());
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/sessions/{sessionId}/rounds/{roundId}/variants")
|
||||||
|
public Result<List<ChatMessageRecord>> listRoundVariants(@PathVariable BigInteger sessionId,
|
||||||
|
@PathVariable BigInteger roundId) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
return Result.ok(chatHistoryManageService.listUserRoundVariants(account.getId(), sessionId, roundId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/sessions/{sessionId}/rounds/{roundId}/selectVariant")
|
||||||
|
public Result<ChatMessageRecord> selectRoundVariant(@PathVariable BigInteger sessionId,
|
||||||
|
@PathVariable BigInteger roundId,
|
||||||
|
@JsonBody(value = "variantIndex", required = true) Integer variantIndex) {
|
||||||
|
LoginAccount account = SaTokenUtil.getLoginAccount();
|
||||||
|
return Result.ok(chatHistoryManageService.selectUserRoundVariant(account.getId(), sessionId, roundId, variantIndex, account.getId()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ public class TinyFlowService {
|
|||||||
ChainState chainState = chainStateRepository.load(executeId);
|
ChainState chainState = chainStateRepository.load(executeId);
|
||||||
ChainInfo res = getChainInfo(executeId, chainState);
|
ChainInfo res = getChainInfo(executeId, chainState);
|
||||||
|
|
||||||
|
if (nodes != null) {
|
||||||
for (NodeInfo node : nodes) {
|
for (NodeInfo node : nodes) {
|
||||||
processNodeState(executeId, node, chainStateRepository, nodeStateRepository);
|
processNodeState(executeId, node, chainStateRepository, nodeStateRepository);
|
||||||
res.getNodes().put(node.getNodeId(), node);
|
res.getNodes().put(node.getNodeId(), node);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package tech.easyflow.chatlog.domain.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮次答案版本视图。
|
||||||
|
*/
|
||||||
|
public class ChatRoundVariantView implements Serializable {
|
||||||
|
|
||||||
|
private BigInteger roundId;
|
||||||
|
private Integer roundNo;
|
||||||
|
private Integer variantCount;
|
||||||
|
private Integer selectedVariantIndex;
|
||||||
|
private Boolean switchable;
|
||||||
|
private ChatMessageRecord selectedMessage;
|
||||||
|
|
||||||
|
public BigInteger getRoundId() {
|
||||||
|
return roundId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRoundId(BigInteger roundId) {
|
||||||
|
this.roundId = roundId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getRoundNo() {
|
||||||
|
return roundNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRoundNo(Integer roundNo) {
|
||||||
|
this.roundNo = roundNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getVariantCount() {
|
||||||
|
return variantCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVariantCount(Integer variantCount) {
|
||||||
|
this.variantCount = variantCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getSelectedVariantIndex() {
|
||||||
|
return selectedVariantIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSelectedVariantIndex(Integer selectedVariantIndex) {
|
||||||
|
this.selectedVariantIndex = selectedVariantIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getSwitchable() {
|
||||||
|
return switchable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSwitchable(Boolean switchable) {
|
||||||
|
this.switchable = switchable;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatMessageRecord getSelectedMessage() {
|
||||||
|
return selectedMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSelectedMessage(ChatMessageRecord selectedMessage) {
|
||||||
|
this.selectedMessage = selectedMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
package tech.easyflow.chatlog.service;
|
package tech.easyflow.chatlog.service;
|
||||||
|
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||||
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
|
import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface ChatHistoryManageService {
|
public interface ChatHistoryManageService {
|
||||||
|
|
||||||
@@ -25,4 +27,12 @@ public interface ChatHistoryManageService {
|
|||||||
void renameUserSession(BigInteger userId, BigInteger sessionId, String title, BigInteger operatorId);
|
void renameUserSession(BigInteger userId, BigInteger sessionId, String title, BigInteger operatorId);
|
||||||
|
|
||||||
void deleteUserSession(BigInteger userId, BigInteger sessionId, BigInteger operatorId);
|
void deleteUserSession(BigInteger userId, BigInteger sessionId, BigInteger operatorId);
|
||||||
|
|
||||||
|
List<ChatMessageRecord> listUserRoundVariants(BigInteger userId, BigInteger sessionId, BigInteger roundId);
|
||||||
|
|
||||||
|
ChatMessageRecord selectUserRoundVariant(BigInteger userId, BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId);
|
||||||
|
|
||||||
|
List<ChatMessageRecord> listAdminRoundVariants(BigInteger sessionId, BigInteger roundId);
|
||||||
|
|
||||||
|
ChatMessageRecord selectAdminRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package tech.easyflow.chatlog.service;
|
package tech.easyflow.chatlog.service;
|
||||||
|
|
||||||
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface PublicChatSessionRestoreService {
|
public interface PublicChatSessionRestoreService {
|
||||||
|
|
||||||
PublicChatSessionRestoreResult restoreSession(BigInteger userId, BigInteger assistantId, BigInteger sessionId, Integer limit);
|
PublicChatSessionRestoreResult restoreSession(BigInteger userId, BigInteger assistantId, BigInteger sessionId, Integer limit);
|
||||||
|
|
||||||
|
List<ChatMessageRecord> listVariants(BigInteger userId, BigInteger assistantId, BigInteger sessionId, BigInteger roundId);
|
||||||
|
|
||||||
|
ChatMessageRecord selectVariant(BigInteger userId, BigInteger assistantId, BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import tech.easyflow.chatlog.domain.query.ChatSessionFilterQuery;
|
|||||||
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
|
import tech.easyflow.chatlog.repository.analyticaldb.ChatAnalyticalDBRepository;
|
||||||
import tech.easyflow.chatlog.service.ChatHistoryManageService;
|
import tech.easyflow.chatlog.service.ChatHistoryManageService;
|
||||||
import tech.easyflow.chatlog.service.ChatHistoryQueryService;
|
import tech.easyflow.chatlog.service.ChatHistoryQueryService;
|
||||||
|
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||||
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
import tech.easyflow.chatlog.service.ChatSessionCommandService;
|
||||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
@@ -23,15 +24,18 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
|||||||
private final ChatSessionQueryService chatSessionQueryService;
|
private final ChatSessionQueryService chatSessionQueryService;
|
||||||
private final ChatSessionCommandService chatSessionCommandService;
|
private final ChatSessionCommandService chatSessionCommandService;
|
||||||
private final ChatHistoryQueryService chatHistoryQueryService;
|
private final ChatHistoryQueryService chatHistoryQueryService;
|
||||||
|
private final ChatRoundOperateService chatRoundOperateService;
|
||||||
private final ChatAnalyticalDBRepository chatAnalyticalDBRepository;
|
private final ChatAnalyticalDBRepository chatAnalyticalDBRepository;
|
||||||
|
|
||||||
public ChatHistoryManageServiceImpl(ChatSessionQueryService chatSessionQueryService,
|
public ChatHistoryManageServiceImpl(ChatSessionQueryService chatSessionQueryService,
|
||||||
ChatSessionCommandService chatSessionCommandService,
|
ChatSessionCommandService chatSessionCommandService,
|
||||||
ChatHistoryQueryService chatHistoryQueryService,
|
ChatHistoryQueryService chatHistoryQueryService,
|
||||||
|
ChatRoundOperateService chatRoundOperateService,
|
||||||
ChatAnalyticalDBRepository chatAnalyticalDBRepository) {
|
ChatAnalyticalDBRepository chatAnalyticalDBRepository) {
|
||||||
this.chatSessionQueryService = chatSessionQueryService;
|
this.chatSessionQueryService = chatSessionQueryService;
|
||||||
this.chatSessionCommandService = chatSessionCommandService;
|
this.chatSessionCommandService = chatSessionCommandService;
|
||||||
this.chatHistoryQueryService = chatHistoryQueryService;
|
this.chatHistoryQueryService = chatHistoryQueryService;
|
||||||
|
this.chatRoundOperateService = chatRoundOperateService;
|
||||||
this.chatAnalyticalDBRepository = chatAnalyticalDBRepository;
|
this.chatAnalyticalDBRepository = chatAnalyticalDBRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +72,21 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ChatHistoryPage queryUserMessages(BigInteger userId, BigInteger sessionId, ChatPageQuery query) {
|
public ChatHistoryPage queryUserMessages(BigInteger userId, BigInteger sessionId, ChatPageQuery query) {
|
||||||
getUserSession(userId, sessionId);
|
ChatSessionSummary summary = getUserSession(userId, sessionId);
|
||||||
|
ChatHistoryPage firstPage = restoreRecentMessages(summary, query);
|
||||||
|
if (firstPage != null) {
|
||||||
|
return firstPage;
|
||||||
|
}
|
||||||
return chatHistoryQueryService.queryHistoryMessages(sessionId, query);
|
return chatHistoryQueryService.queryHistoryMessages(sessionId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ChatHistoryPage queryAdminMessages(BigInteger sessionId, ChatPageQuery query) {
|
public ChatHistoryPage queryAdminMessages(BigInteger sessionId, ChatPageQuery query) {
|
||||||
getAdminSession(sessionId);
|
ChatSessionSummary summary = getAdminSession(sessionId);
|
||||||
|
ChatHistoryPage firstPage = restoreRecentMessages(summary, query);
|
||||||
|
if (firstPage != null) {
|
||||||
|
return firstPage;
|
||||||
|
}
|
||||||
return chatHistoryQueryService.queryHistoryMessages(sessionId, query);
|
return chatHistoryQueryService.queryHistoryMessages(sessionId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,4 +104,48 @@ public class ChatHistoryManageServiceImpl implements ChatHistoryManageService {
|
|||||||
getUserSession(userId, sessionId);
|
getUserSession(userId, sessionId);
|
||||||
chatSessionCommandService.deleteSession(sessionId, userId, operatorId);
|
chatSessionCommandService.deleteSession(sessionId, userId, operatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.List<ChatMessageRecord> listUserRoundVariants(BigInteger userId, BigInteger sessionId, BigInteger roundId) {
|
||||||
|
getUserSession(userId, sessionId);
|
||||||
|
return chatRoundOperateService.listVariants(sessionId, roundId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatMessageRecord selectUserRoundVariant(BigInteger userId, BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||||
|
getUserSession(userId, sessionId);
|
||||||
|
return chatRoundOperateService.selectVariant(sessionId, roundId, variantIndex, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.util.List<ChatMessageRecord> listAdminRoundVariants(BigInteger sessionId, BigInteger roundId) {
|
||||||
|
getAdminSession(sessionId);
|
||||||
|
return chatRoundOperateService.listVariants(sessionId, roundId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatMessageRecord selectAdminRoundVariant(BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||||
|
getAdminSession(sessionId);
|
||||||
|
return chatRoundOperateService.selectVariant(sessionId, roundId, variantIndex, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatHistoryPage restoreRecentMessages(ChatSessionSummary summary, ChatPageQuery query) {
|
||||||
|
if (summary == null || query == null || query.getPageNumber() != 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
java.util.List<ChatMessageRecord> records = chatSessionQueryService.getRecentTail(
|
||||||
|
summary.getId(),
|
||||||
|
Math.toIntExact(query.getPageSize())
|
||||||
|
);
|
||||||
|
if (records == null || records.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ChatHistoryPage page = new ChatHistoryPage();
|
||||||
|
page.setPageNumber(query.getPageNumber());
|
||||||
|
page.setPageSize(query.getPageSize());
|
||||||
|
page.setRecords(records);
|
||||||
|
long total = summary.getMessageCount() == null ? 0L : summary.getMessageCount();
|
||||||
|
page.setTotal(Math.max(total, records.size()));
|
||||||
|
return page;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import tech.easyflow.chatlog.config.ChatCacheProperties;
|
|||||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
||||||
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
import tech.easyflow.chatlog.domain.dto.PublicChatSessionRestoreResult;
|
||||||
|
import tech.easyflow.chatlog.service.ChatRoundOperateService;
|
||||||
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
import tech.easyflow.chatlog.service.ChatSessionQueryService;
|
||||||
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
|
import tech.easyflow.chatlog.service.PublicChatSessionRestoreService;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -18,11 +20,14 @@ import java.util.Objects;
|
|||||||
public class PublicChatSessionRestoreServiceImpl implements PublicChatSessionRestoreService {
|
public class PublicChatSessionRestoreServiceImpl implements PublicChatSessionRestoreService {
|
||||||
|
|
||||||
private final ChatSessionQueryService chatSessionQueryService;
|
private final ChatSessionQueryService chatSessionQueryService;
|
||||||
|
private final ChatRoundOperateService chatRoundOperateService;
|
||||||
private final ChatCacheProperties chatCacheProperties;
|
private final ChatCacheProperties chatCacheProperties;
|
||||||
|
|
||||||
public PublicChatSessionRestoreServiceImpl(ChatSessionQueryService chatSessionQueryService,
|
public PublicChatSessionRestoreServiceImpl(ChatSessionQueryService chatSessionQueryService,
|
||||||
|
ChatRoundOperateService chatRoundOperateService,
|
||||||
ChatCacheProperties chatCacheProperties) {
|
ChatCacheProperties chatCacheProperties) {
|
||||||
this.chatSessionQueryService = chatSessionQueryService;
|
this.chatSessionQueryService = chatSessionQueryService;
|
||||||
|
this.chatRoundOperateService = chatRoundOperateService;
|
||||||
this.chatCacheProperties = chatCacheProperties;
|
this.chatCacheProperties = chatCacheProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +59,18 @@ public class PublicChatSessionRestoreServiceImpl implements PublicChatSessionRes
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ChatMessageRecord> listVariants(BigInteger userId, BigInteger assistantId, BigInteger sessionId, BigInteger roundId) {
|
||||||
|
requireOwnedSession(userId, assistantId, sessionId);
|
||||||
|
return chatRoundOperateService.listVariants(sessionId, roundId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatMessageRecord selectVariant(BigInteger userId, BigInteger assistantId, BigInteger sessionId, BigInteger roundId, Integer variantIndex, BigInteger operatorId) {
|
||||||
|
requireOwnedSession(userId, assistantId, sessionId);
|
||||||
|
return chatRoundOperateService.selectVariant(sessionId, roundId, variantIndex, operatorId);
|
||||||
|
}
|
||||||
|
|
||||||
private int resolveLimit(Integer limit) {
|
private int resolveLimit(Integer limit) {
|
||||||
int defaultLimit = Math.max(chatCacheProperties.getTailSize(), 1);
|
int defaultLimit = Math.max(chatCacheProperties.getTailSize(), 1);
|
||||||
if (limit == null || limit <= 0) {
|
if (limit == null || limit <= 0) {
|
||||||
@@ -61,4 +78,15 @@ public class PublicChatSessionRestoreServiceImpl implements PublicChatSessionRes
|
|||||||
}
|
}
|
||||||
return Math.min(limit, defaultLimit);
|
return Math.min(limit, defaultLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ChatSessionSummary requireOwnedSession(BigInteger userId, BigInteger assistantId, BigInteger sessionId) {
|
||||||
|
ChatSessionSummary summary = chatSessionQueryService.getSessionSummary(sessionId);
|
||||||
|
if (summary == null || Integer.valueOf(1).equals(summary.getIsDeleted())) {
|
||||||
|
throw new BusinessException("会话不存在");
|
||||||
|
}
|
||||||
|
if (!Objects.equals(summary.getUserId(), userId) || !Objects.equals(summary.getAssistantId(), assistantId)) {
|
||||||
|
throw new BusinessException("无权访问该会话");
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import { Close } from '@element-plus/icons-vue';
|
import { Close } from '@element-plus/icons-vue';
|
||||||
import { ElButton, ElEmpty, ElIcon, ElScrollbar } from 'element-plus';
|
import { ElButton, ElEmpty, ElIcon, ElMessage, ElScrollbar } from 'element-plus';
|
||||||
|
|
||||||
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
||||||
|
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
|
||||||
|
|
||||||
interface ChatHistoryDetailDrawerProps {
|
interface ChatHistoryDetailDrawerProps {
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
@@ -13,6 +14,7 @@ interface ChatHistoryDetailDrawerProps {
|
|||||||
messages?: ChatTimeTimelineItem[];
|
messages?: ChatTimeTimelineItem[];
|
||||||
hasMore?: boolean;
|
hasMore?: boolean;
|
||||||
onLoadMore?: (() => Promise<void> | void) | undefined;
|
onLoadMore?: (() => Promise<void> | void) | undefined;
|
||||||
|
switchingRoundIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
|
const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
|
||||||
@@ -22,10 +24,17 @@ const props = withDefaults(defineProps<ChatHistoryDetailDrawerProps>(), {
|
|||||||
messages: () => [],
|
messages: () => [],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
onLoadMore: undefined,
|
onLoadMore: undefined,
|
||||||
|
switchingRoundIds: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: [];
|
close: [];
|
||||||
|
selectVariant: [
|
||||||
|
payload: {
|
||||||
|
direction: 'next' | 'previous';
|
||||||
|
item: ChatTimeTimelineItem;
|
||||||
|
},
|
||||||
|
];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function formatTime(value?: number | string) {
|
function formatTime(value?: number | string) {
|
||||||
@@ -57,6 +66,77 @@ function resolveSenderName(item: any) {
|
|||||||
async function handleLoadMore() {
|
async function handleLoadMore() {
|
||||||
await props.onLoadMore?.();
|
await props.onLoadMore?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldShowVariantNavigator(item: ChatTimeTimelineItem) {
|
||||||
|
return (
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
isFinalAssistantInRound(item) &&
|
||||||
|
Number(item.variantCount || 0) > 1 &&
|
||||||
|
Boolean(item.roundId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSwitchVariant(
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
item.role !== 'assistant' ||
|
||||||
|
!isFinalAssistantInRound(item) ||
|
||||||
|
!item.switchable ||
|
||||||
|
isVariantSwitching(item)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const total = Number(item.variantCount || 1);
|
||||||
|
if (direction === 'previous') {
|
||||||
|
return current > 1;
|
||||||
|
}
|
||||||
|
return current < total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariantSwitching(item: ChatTimeTimelineItem) {
|
||||||
|
return Boolean(
|
||||||
|
item.roundId && props.switchingRoundIds.includes(String(item.roundId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinalAssistantInRound(item: ChatTimeTimelineItem) {
|
||||||
|
if (item.role !== 'assistant') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!item.roundId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (let index = props.messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = props.messages[index];
|
||||||
|
if (
|
||||||
|
candidate?.role === 'assistant' &&
|
||||||
|
candidate.roundId === item.roundId &&
|
||||||
|
String(candidate.variantIndex || '') === String(item.variantIndex || '')
|
||||||
|
) {
|
||||||
|
return candidate.id === item.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCopyMessage(item: ChatTimeTimelineItem) {
|
||||||
|
return item.role !== 'tool' && Boolean(String(item.content || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyMessage(item: ChatTimeTimelineItem) {
|
||||||
|
if (!canCopyMessage(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(String(item.content || ''));
|
||||||
|
ElMessage.success('已复制');
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -147,6 +227,26 @@ async function handleLoadMore() {
|
|||||||
>
|
>
|
||||||
<ChatTimeMessageContent :item="item" readonly-thinking />
|
<ChatTimeMessageContent :item="item" readonly-thinking />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChatMessageActionBar
|
||||||
|
:align="item.role === 'user' ? 'end' : 'start'"
|
||||||
|
:allow-copy="canCopyMessage(item)"
|
||||||
|
:show-variant-navigator="shouldShowVariantNavigator(item)"
|
||||||
|
:disabled-variant-next="!canSwitchVariant(item, 'next')"
|
||||||
|
:disabled-variant-previous="!canSwitchVariant(item, 'previous')"
|
||||||
|
:variant-loading="isVariantSwitching(item)"
|
||||||
|
:variant-current="
|
||||||
|
Number(item.variantIndex || item.selectedVariantIndex || 1)
|
||||||
|
"
|
||||||
|
:variant-total="Number(item.variantCount || 1)"
|
||||||
|
@copy="handleCopyMessage(item)"
|
||||||
|
@select-next-variant="
|
||||||
|
emit('selectVariant', { direction: 'next', item })
|
||||||
|
"
|
||||||
|
@select-previous-variant="
|
||||||
|
emit('selectVariant', { direction: 'previous', item })
|
||||||
|
"
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
|
|||||||
import type { BotInfo, ChatTimeTimelineItem } from '@easyflow/types';
|
import type { BotInfo, ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
onMounted,
|
onMounted,
|
||||||
@@ -22,6 +23,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
import { useBotStore } from '@easyflow/stores';
|
import { useBotStore } from '@easyflow/stores';
|
||||||
import {
|
import {
|
||||||
|
createChatVariantSwitchController,
|
||||||
ChatTimeHistoryMapper,
|
ChatTimeHistoryMapper,
|
||||||
ChatTimeTimelineBuilder,
|
ChatTimeTimelineBuilder,
|
||||||
cn,
|
cn,
|
||||||
@@ -30,9 +32,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowDownBold,
|
ArrowDownBold,
|
||||||
CopyDocument,
|
|
||||||
Paperclip,
|
Paperclip,
|
||||||
RefreshRight,
|
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
import { ElButton, ElIcon, ElMessage, ElSpace } from 'element-plus';
|
import { ElButton, ElIcon, ElMessage, ElSpace } from 'element-plus';
|
||||||
import { tryit } from 'radash';
|
import { tryit } from 'radash';
|
||||||
@@ -40,6 +40,7 @@ import { tryit } from 'radash';
|
|||||||
import { getMessageList, getPerQuestions } from '#/api';
|
import { getMessageList, getPerQuestions } from '#/api';
|
||||||
import { api, sseClient } from '#/api/request';
|
import { api, sseClient } from '#/api/request';
|
||||||
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
import ChatTimeMessageContent from '#/components/chat/ChatTimeMessageContent.vue';
|
||||||
|
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
|
||||||
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
|
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
|
||||||
import SendIcon from '#/components/icons/SendIcon.vue';
|
import SendIcon from '#/components/icons/SendIcon.vue';
|
||||||
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
|
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
|
||||||
@@ -62,6 +63,19 @@ interface presetQuestionsType {
|
|||||||
key: string;
|
key: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
interface SendMessageOptions {
|
||||||
|
prompt?: string;
|
||||||
|
regenerateRoundId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SseRoundMeta {
|
||||||
|
roundId?: string;
|
||||||
|
roundNo?: number;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
|
switchable?: boolean;
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
|
}
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const botId = ref<string>((route.params.id as string) || '');
|
const botId = ref<string>((route.params.id as string) || '');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -74,7 +88,20 @@ const showBackToBottomButton = ref(false);
|
|||||||
const senderRef = ref<InstanceType<typeof ElSender>>();
|
const senderRef = ref<InstanceType<typeof ElSender>>();
|
||||||
const senderValue = ref('');
|
const senderValue = ref('');
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
|
const variantSwitchStateVersion = ref(0);
|
||||||
const BACK_TO_BOTTOM_THRESHOLD = 160;
|
const BACK_TO_BOTTOM_THRESHOLD = 160;
|
||||||
|
const variantSwitchController = createChatVariantSwitchController<any, ChatTimeTimelineItem>({
|
||||||
|
mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records),
|
||||||
|
onError: () => ElMessage.error('答案版本切换失败'),
|
||||||
|
onStateChange: () => {
|
||||||
|
variantSwitchStateVersion.value += 1;
|
||||||
|
},
|
||||||
|
replaceRound: (items, roundId, nextItems) =>
|
||||||
|
ChatTimeTimelineBuilder.replaceRoundMessages(items, roundId, nextItems),
|
||||||
|
});
|
||||||
|
const latestAssistantMessage = computed(() => {
|
||||||
|
return [...bubbleItems.value].reverse().find((item) => item.role === 'assistant');
|
||||||
|
});
|
||||||
const getConversationId = async () => {
|
const getConversationId = async () => {
|
||||||
const res = await api.get('/api/v1/bot/generateConversationId');
|
const res = await api.get('/api/v1/bot/generateConversationId');
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -132,6 +159,7 @@ watchEffect(async () => {
|
|||||||
bubbleItems.value = ChatTimeHistoryMapper.fromHistoryRecords(
|
bubbleItems.value = ChatTimeHistoryMapper.fromHistoryRecords(
|
||||||
res.data as any[],
|
res.data as any[],
|
||||||
);
|
);
|
||||||
|
prefetchVisibleVariants();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bubbleItems.value = [];
|
bubbleItems.value = [];
|
||||||
@@ -180,6 +208,7 @@ const bindBubbleListScroll = () => {
|
|||||||
};
|
};
|
||||||
const finalizeTimelineTail = () => {
|
const finalizeTimelineTail = () => {
|
||||||
ChatTimeTimelineBuilder.finalize(bubbleItems.value);
|
ChatTimeTimelineBuilder.finalize(bubbleItems.value);
|
||||||
|
prefetchVisibleVariants();
|
||||||
};
|
};
|
||||||
const stopSse = () => {
|
const stopSse = () => {
|
||||||
sseClient.abort();
|
sseClient.abort();
|
||||||
@@ -190,14 +219,25 @@ const clearSenderFiles = () => {
|
|||||||
files.value = [];
|
files.value = [];
|
||||||
attachmentsRef.value?.clearFiles();
|
attachmentsRef.value?.clearFiles();
|
||||||
};
|
};
|
||||||
const handleSubmit = async (refreshContent: string) => {
|
const handleSubmit = async (refreshContent: string, options: SendMessageOptions = {}) => {
|
||||||
const attachments = attachmentsRef.value?.getFileList();
|
const attachments = attachmentsRef.value?.getFileList();
|
||||||
const currentPrompt = refreshContent || senderValue.value.trim();
|
const currentPrompt = (options.prompt || refreshContent || senderValue.value).trim();
|
||||||
if (!currentPrompt) {
|
if (!currentPrompt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const regenerateRoundId = options.regenerateRoundId
|
||||||
|
? String(options.regenerateRoundId)
|
||||||
|
: '';
|
||||||
|
const isRegenerate = !!regenerateRoundId;
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
lastUserMessage.value = currentPrompt;
|
lastUserMessage.value = currentPrompt;
|
||||||
|
if (!isRegenerate && latestAssistantMessage.value?.roundId) {
|
||||||
|
ChatTimeTimelineBuilder.setRoundSwitchable(
|
||||||
|
bubbleItems.value,
|
||||||
|
latestAssistantMessage.value.roundId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: currentPrompt,
|
content: currentPrompt,
|
||||||
@@ -209,12 +249,16 @@ const handleSubmit = async (refreshContent: string) => {
|
|||||||
conversationId: localeConversationId.value,
|
conversationId: localeConversationId.value,
|
||||||
messages: copyMessages,
|
messages: copyMessages,
|
||||||
attachments,
|
attachments,
|
||||||
|
regenerateRoundId: regenerateRoundId || undefined,
|
||||||
};
|
};
|
||||||
clearSenderFiles();
|
clearSenderFiles();
|
||||||
messages.value.pop();
|
messages.value.pop();
|
||||||
const mockMessages = generateMockMessages(refreshContent);
|
if (!isRegenerate) {
|
||||||
|
const mockMessages = generateMockMessages(refreshContent || currentPrompt);
|
||||||
bubbleItems.value.push(...mockMessages);
|
bubbleItems.value.push(...mockMessages);
|
||||||
senderRef.value?.clear();
|
senderRef.value?.clear();
|
||||||
|
}
|
||||||
|
let receivedAssistantPayload = false;
|
||||||
sseClient.post('/api/v1/bot/chat', data, {
|
sseClient.post('/api/v1/bot/chat', data, {
|
||||||
onMessage(message) {
|
onMessage(message) {
|
||||||
const event = message.event;
|
const event = message.event;
|
||||||
@@ -230,22 +274,33 @@ const handleSubmit = async (refreshContent: string) => {
|
|||||||
}
|
}
|
||||||
// 处理系统错误
|
// 处理系统错误
|
||||||
const sseData = JSON.parse(message.data);
|
const sseData = JSON.parse(message.data);
|
||||||
|
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
|
||||||
|
ChatTimeTimelineBuilder.bindLatestPendingUserMessage(
|
||||||
|
bubbleItems.value,
|
||||||
|
streamMeta,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
sseData?.domain === 'SYSTEM' &&
|
sseData?.domain === 'SYSTEM' &&
|
||||||
sseData.payload?.code === 'SYSTEM_ERROR'
|
sseData.payload?.code === 'SYSTEM_ERROR'
|
||||||
) {
|
) {
|
||||||
|
if (isRegenerate && !receivedAssistantPayload) {
|
||||||
|
ElMessage.error(sseData.payload.message || '重新生成失败');
|
||||||
|
} else {
|
||||||
ChatTimeTimelineBuilder.applySystemError(
|
ChatTimeTimelineBuilder.applySystemError(
|
||||||
bubbleItems.value,
|
bubbleItems.value,
|
||||||
sseData.payload.message,
|
sseData.payload.message,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sseData?.domain === 'TOOL') {
|
if (sseData?.domain === 'TOOL') {
|
||||||
|
receivedAssistantPayload = true;
|
||||||
if (sseData?.type === 'TOOL_CALL') {
|
if (sseData?.type === 'TOOL_CALL') {
|
||||||
ChatTimeTimelineBuilder.upsertToolCall(bubbleItems.value, {
|
ChatTimeTimelineBuilder.upsertToolCall(bubbleItems.value, {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
|
...streamMeta,
|
||||||
name: sseData?.payload?.name,
|
name: sseData?.payload?.name,
|
||||||
toolCallId: sseData?.payload?.tool_call_id,
|
toolCallId: sseData?.payload?.tool_call_id,
|
||||||
value: sseData?.payload?.arguments,
|
value: sseData?.payload?.arguments,
|
||||||
@@ -253,6 +308,7 @@ const handleSubmit = async (refreshContent: string) => {
|
|||||||
} else {
|
} else {
|
||||||
ChatTimeTimelineBuilder.upsertToolResult(bubbleItems.value, {
|
ChatTimeTimelineBuilder.upsertToolResult(bubbleItems.value, {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
|
...streamMeta,
|
||||||
name: sseData?.payload?.name,
|
name: sseData?.payload?.name,
|
||||||
result: sseData?.payload?.result,
|
result: sseData?.payload?.result,
|
||||||
toolCallId: sseData?.payload?.tool_call_id,
|
toolCallId: sseData?.payload?.tool_call_id,
|
||||||
@@ -267,16 +323,20 @@ const handleSubmit = async (refreshContent: string) => {
|
|||||||
|
|
||||||
if (delta) {
|
if (delta) {
|
||||||
if (sseData.type === 'THINKING') {
|
if (sseData.type === 'THINKING') {
|
||||||
|
receivedAssistantPayload = true;
|
||||||
ChatTimeTimelineBuilder.appendThinkingDelta(
|
ChatTimeTimelineBuilder.appendThinkingDelta(
|
||||||
bubbleItems.value,
|
bubbleItems.value,
|
||||||
delta,
|
delta,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
streamMeta,
|
||||||
);
|
);
|
||||||
} else if (sseData.type === 'MESSAGE') {
|
} else if (sseData.type === 'MESSAGE') {
|
||||||
|
receivedAssistantPayload = true;
|
||||||
ChatTimeTimelineBuilder.appendMessageDelta(
|
ChatTimeTimelineBuilder.appendMessageDelta(
|
||||||
bubbleItems.value,
|
bubbleItems.value,
|
||||||
delta,
|
delta,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
streamMeta,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,8 +395,138 @@ const handleCopy = (content: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
handleSubmit(lastUserMessage.value);
|
const roundId = String(latestAssistantMessage.value?.roundId || '');
|
||||||
|
if (!roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSubmit('', {
|
||||||
|
prompt: resolveRoundPrompt(roundId),
|
||||||
|
regenerateRoundId: roundId,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canRegenerateMessage = (item: ChatTimeTimelineItem) => {
|
||||||
|
return (
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
item.id === latestAssistantMessage.value?.id &&
|
||||||
|
!!String(item.roundId || '').trim()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShowVariantNavigator = (item: ChatTimeTimelineItem) => {
|
||||||
|
return (
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
isFinalAssistantInRound(item) &&
|
||||||
|
Number(item.variantCount || 0) > 1 &&
|
||||||
|
!!item.roundId
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSwitchVariant = (
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
item.role !== 'assistant' ||
|
||||||
|
!isFinalAssistantInRound(item) ||
|
||||||
|
!item.switchable ||
|
||||||
|
isVariantSwitching(item)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const total = Number(item.variantCount || 1);
|
||||||
|
if (direction === 'previous') {
|
||||||
|
return current > 1;
|
||||||
|
}
|
||||||
|
return current < total;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectVariant = async (
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) => {
|
||||||
|
if (!item.roundId || !localeConversationId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
|
||||||
|
await variantSwitchController.switchVariant({
|
||||||
|
fetchVariants: () =>
|
||||||
|
fetchRoundVariants(String(localeConversationId.value), String(item.roundId)),
|
||||||
|
items: bubbleItems.value,
|
||||||
|
persistVariant: async () => {
|
||||||
|
const [, res] = await tryit(api.post)(
|
||||||
|
`/api/v1/chatWorkspace/sessions/${localeConversationId.value}/rounds/${item.roundId}/selectVariant`,
|
||||||
|
{
|
||||||
|
variantIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0 || !res?.data) {
|
||||||
|
throw new Error(res?.message || '答案版本切换失败');
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId: localeConversationId.value,
|
||||||
|
targetVariantIndex: variantIndex,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchRoundVariants(sessionId: string, roundId: string) {
|
||||||
|
const [, res] = await tryit(api.get)(
|
||||||
|
`/api/v1/chatWorkspace/sessions/${sessionId}/rounds/${roundId}/variants`,
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0) {
|
||||||
|
throw new Error(res?.message || '答案版本加载失败');
|
||||||
|
}
|
||||||
|
return res.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariantSwitching(item: ChatTimeTimelineItem) {
|
||||||
|
variantSwitchStateVersion.value;
|
||||||
|
return variantSwitchController.isSwitching(localeConversationId.value, item.roundId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinalAssistantInRound(item: ChatTimeTimelineItem) {
|
||||||
|
if (item.role !== 'assistant') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!item.roundId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (let index = bubbleItems.value.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = bubbleItems.value[index];
|
||||||
|
if (
|
||||||
|
candidate?.role === 'assistant' &&
|
||||||
|
candidate.roundId === item.roundId &&
|
||||||
|
String(candidate.variantIndex || '') === String(item.variantIndex || '')
|
||||||
|
) {
|
||||||
|
return candidate.id === item.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchVisibleVariants() {
|
||||||
|
if (!localeConversationId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of bubbleItems.value) {
|
||||||
|
if (
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
item.roundId &&
|
||||||
|
Number(item.variantCount || 0) > 1
|
||||||
|
) {
|
||||||
|
variantSwitchController.prefetchVariants({
|
||||||
|
fetchVariants: () =>
|
||||||
|
fetchRoundVariants(String(localeConversationId.value), String(item.roundId)),
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId: localeConversationId.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
bubbleListRef.value?.scrollToBottom();
|
bubbleListRef.value?.scrollToBottom();
|
||||||
if (!bubbleListRef.value && bubbleListScrollElement.value) {
|
if (!bubbleListRef.value && bubbleListScrollElement.value) {
|
||||||
@@ -356,6 +546,35 @@ function triggerFileSelect() {
|
|||||||
function handleDeleteAllSenderFiles() {
|
function handleDeleteAllSenderFiles() {
|
||||||
files.value = [];
|
files.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRoundPrompt(roundId: string) {
|
||||||
|
const target = [...bubbleItems.value].reverse().find(
|
||||||
|
(item) => item.role === 'user' && item.roundId === roundId,
|
||||||
|
);
|
||||||
|
return String(target?.content || lastUserMessage.value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSseRoundMeta(meta: any): SseRoundMeta {
|
||||||
|
if (!meta || typeof meta !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const variantIndex = meta.variantIndex ? Number(meta.variantIndex) : undefined;
|
||||||
|
return {
|
||||||
|
roundId: meta.roundId ? String(meta.roundId) : undefined,
|
||||||
|
roundNo: meta.roundNo ? Number(meta.roundNo) : undefined,
|
||||||
|
selectedVariantIndex: meta.selectedVariantIndex
|
||||||
|
? Number(meta.selectedVariantIndex)
|
||||||
|
: variantIndex,
|
||||||
|
switchable:
|
||||||
|
typeof meta.switchable === 'boolean'
|
||||||
|
? meta.switchable
|
||||||
|
: variantIndex != null
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
variantCount: meta.variantCount ? Number(meta.variantCount) : variantIndex,
|
||||||
|
variantIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
watch(
|
watch(
|
||||||
() => [localeConversationId.value, bubbleItems.value.length],
|
() => [localeConversationId.value, bubbleItems.value.length],
|
||||||
() => {
|
() => {
|
||||||
@@ -419,22 +638,25 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
<!-- 自定义底部 -->
|
<!-- 自定义底部 -->
|
||||||
<template #footer="{ item }">
|
<template #footer="{ item }">
|
||||||
<ElSpace v-if="item.role !== 'tool'" :size="10">
|
<ChatMessageActionBar
|
||||||
<ElSpace v-if="item.role === 'assistant'">
|
v-if="item.role !== 'tool'"
|
||||||
<span @click="handleRefresh()" style="cursor: pointer">
|
:align="item.role === 'user' ? 'end' : 'start'"
|
||||||
<ElIcon>
|
:allow-copy="!!String(item.content || '').trim()"
|
||||||
<RefreshRight />
|
:allow-regenerate="canRegenerateMessage(item)"
|
||||||
</ElIcon>
|
:disabled-variant-next="!canSwitchVariant(item, 'next')"
|
||||||
</span>
|
:disabled-variant-previous="!canSwitchVariant(item, 'previous')"
|
||||||
</ElSpace>
|
:regenerate-disabled="sending"
|
||||||
<ElSpace>
|
:show-variant-navigator="shouldShowVariantNavigator(item)"
|
||||||
<span @click="handleCopy(item.content)" style="cursor: pointer">
|
:variant-loading="isVariantSwitching(item)"
|
||||||
<ElIcon>
|
:variant-current="
|
||||||
<CopyDocument />
|
Number(item.variantIndex || item.selectedVariantIndex || 1)
|
||||||
</ElIcon>
|
"
|
||||||
</span>
|
:variant-total="Number(item.variantCount || 1)"
|
||||||
</ElSpace>
|
@copy="handleCopy(item.content)"
|
||||||
</ElSpace>
|
@regenerate="handleRefresh"
|
||||||
|
@select-next-variant="handleSelectVariant(item, 'next')"
|
||||||
|
@select-previous-variant="handleSelectVariant(item, 'previous')"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ElBubbleList>
|
</ElBubbleList>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type { ChatTimeTimelineItem } from '@easyflow/types';
|
|||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { useEasyFlowDrawer } from '@easyflow/common-ui';
|
import { useEasyFlowDrawer } from '@easyflow/common-ui';
|
||||||
import { ChatTimeHistoryMapper } from '@easyflow/utils';
|
import {
|
||||||
|
createChatVariantSwitchController,
|
||||||
|
ChatTimeHistoryMapper,
|
||||||
|
ChatTimeTimelineBuilder,
|
||||||
|
} from '@easyflow/utils';
|
||||||
|
|
||||||
import { Search } from '@element-plus/icons-vue';
|
import { Search } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +57,15 @@ const messagePage = ref({
|
|||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
});
|
});
|
||||||
|
const variantSwitchStateVersion = ref(0);
|
||||||
|
const variantSwitchController = createChatVariantSwitchController<any, ChatTimeTimelineItem>({
|
||||||
|
mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records),
|
||||||
|
onStateChange: () => {
|
||||||
|
variantSwitchStateVersion.value += 1;
|
||||||
|
},
|
||||||
|
replaceRound: (items, roundId, nextItems) =>
|
||||||
|
ChatTimeTimelineBuilder.replaceRoundMessages(items, roundId, nextItems),
|
||||||
|
});
|
||||||
|
|
||||||
const [Drawer, drawerApi] = useEasyFlowDrawer({
|
const [Drawer, drawerApi] = useEasyFlowDrawer({
|
||||||
appendToMain: false,
|
appendToMain: false,
|
||||||
@@ -164,12 +177,88 @@ async function loadMessages(reset = false) {
|
|||||||
: loadedMessageRecordCount.value + (res.data?.records || []).length;
|
: loadedMessageRecordCount.value + (res.data?.records || []).length;
|
||||||
messagePage.value.total = res.data?.total || 0;
|
messagePage.value.total = res.data?.total || 0;
|
||||||
messagePage.value.pageNumber = nextPageNumber;
|
messagePage.value.pageNumber = nextPageNumber;
|
||||||
|
prefetchVisibleVariants();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessages(records: any[]) {
|
function normalizeMessages(records: any[]) {
|
||||||
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
|
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSelectVariant(
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) {
|
||||||
|
if (!currentSession.value?.id || !item.roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
|
||||||
|
await variantSwitchController.switchVariant({
|
||||||
|
fetchVariants: () =>
|
||||||
|
fetchRoundVariants(String(currentSession.value.id), String(item.roundId)),
|
||||||
|
items: messageList.value,
|
||||||
|
persistVariant: async () => {
|
||||||
|
const [, res] = await tryit(api.post)(
|
||||||
|
`/api/v1/chatHistory/sessions/${currentSession.value.id}/rounds/${item.roundId}/selectVariant`,
|
||||||
|
{
|
||||||
|
variantIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0 || !res?.data) {
|
||||||
|
throw new Error(res?.message || '答案版本切换失败');
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId: currentSession.value.id,
|
||||||
|
targetVariantIndex: variantIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoundVariants(sessionId: string, roundId: string) {
|
||||||
|
const [, res] = await tryit(api.get)(
|
||||||
|
`/api/v1/chatHistory/sessions/${sessionId}/rounds/${roundId}/variants`,
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0) {
|
||||||
|
throw new Error(res?.message || '答案版本加载失败');
|
||||||
|
}
|
||||||
|
return res.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchVisibleVariants() {
|
||||||
|
const sessionId = String(currentSession.value?.id || '');
|
||||||
|
if (!sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of messageList.value) {
|
||||||
|
if (
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
item.roundId &&
|
||||||
|
Number(item.variantCount || 0) > 1
|
||||||
|
) {
|
||||||
|
variantSwitchController.prefetchVariants({
|
||||||
|
fetchVariants: () => fetchRoundVariants(sessionId, String(item.roundId)),
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSwitchingRoundIds() {
|
||||||
|
variantSwitchStateVersion.value;
|
||||||
|
const sessionId = String(currentSession.value?.id || '');
|
||||||
|
if (!sessionId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return messageList.value
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.roundId && variantSwitchController.isSwitching(sessionId, item.roundId),
|
||||||
|
)
|
||||||
|
.map((item) => String(item.roundId));
|
||||||
|
}
|
||||||
|
|
||||||
function changePage(pageNumber: number) {
|
function changePage(pageNumber: number) {
|
||||||
query.value.pageNumber = pageNumber;
|
query.value.pageNumber = pageNumber;
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
@@ -421,6 +510,8 @@ function closeDetail() {
|
|||||||
:messages="messageList"
|
:messages="messageList"
|
||||||
:has-more="hasMoreMessages"
|
:has-more="hasMoreMessages"
|
||||||
:on-load-more="() => loadMessages(false)"
|
:on-load-more="() => loadMessages(false)"
|
||||||
|
:switching-round-ids="currentSwitchingRoundIds()"
|
||||||
|
@select-variant="handleSelectVariant($event.item, $event.direction)"
|
||||||
@close="closeDetail"
|
@close="closeDetail"
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ import { LOGIN_PATH } from '@easyflow/constants';
|
|||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
import { useAccessStore } from '@easyflow/stores';
|
import { useAccessStore } from '@easyflow/stores';
|
||||||
import { uuid } from '@easyflow/utils';
|
import { createChatVariantSwitchController, uuid } from '@easyflow/utils';
|
||||||
|
|
||||||
import { useTitle } from '@vueuse/core';
|
import { useTitle } from '@vueuse/core';
|
||||||
import { ElAvatar, ElButton, ElInput, ElSkeleton } from 'element-plus';
|
import { ElAvatar, ElButton, ElInput, ElMessage, ElSkeleton } from 'element-plus';
|
||||||
|
|
||||||
import { refreshTokenApi } from '#/api/core';
|
import { refreshTokenApi } from '#/api/core';
|
||||||
import { baseRequestClient, sseClient } from '#/api/request';
|
import { baseRequestClient, sseClient } from '#/api/request';
|
||||||
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
||||||
|
import ChatMessageActionBar from '#/components/chat-workspace/ChatMessageActionBar.vue';
|
||||||
|
|
||||||
type MessageRole = 'assistant' | 'user';
|
type MessageRole = 'assistant' | 'user';
|
||||||
type ToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
|
type ToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
|
||||||
@@ -71,8 +72,26 @@ interface BubbleMessage {
|
|||||||
content: string;
|
content: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
messageKind?: string;
|
||||||
|
roundId?: string;
|
||||||
|
roundNo?: number;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
|
switchable?: boolean;
|
||||||
toolCalls?: ToolTraceItem[];
|
toolCalls?: ToolTraceItem[];
|
||||||
segments?: AssistantSegment[];
|
segments?: AssistantSegment[];
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SseRoundMeta {
|
||||||
|
regenerate?: boolean;
|
||||||
|
regenerateRoundId?: string;
|
||||||
|
roundId?: string;
|
||||||
|
roundNo?: number;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
|
switchable?: boolean;
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicChatApiResponse<T> {
|
interface PublicChatApiResponse<T> {
|
||||||
@@ -100,6 +119,13 @@ interface PublicChatMessageRecord {
|
|||||||
contentText?: string;
|
contentText?: string;
|
||||||
contentPayload?: null | Record<string, any>;
|
contentPayload?: null | Record<string, any>;
|
||||||
created?: number | string;
|
created?: number | string;
|
||||||
|
messageKind?: string;
|
||||||
|
roundId?: number | string;
|
||||||
|
roundNo?: number;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
|
switchable?: boolean;
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PublicChatSessionRestoreResult {
|
interface PublicChatSessionRestoreResult {
|
||||||
@@ -142,6 +168,34 @@ const dockHeight = ref(170);
|
|||||||
const collapsedToolIds = ref<Set<string>>(new Set());
|
const collapsedToolIds = ref<Set<string>>(new Set());
|
||||||
const requestAccessToken = ref('');
|
const requestAccessToken = ref('');
|
||||||
const authenticatedAccess = ref(false);
|
const authenticatedAccess = ref(false);
|
||||||
|
const variantSwitchStateVersion = ref(0);
|
||||||
|
const variantSwitchController = createChatVariantSwitchController<
|
||||||
|
PublicChatMessageRecord,
|
||||||
|
BubbleMessage
|
||||||
|
>({
|
||||||
|
mapRecords: (records) => records.map((record) => buildBubbleMessageFromRecord(record)),
|
||||||
|
onError: () => ElMessage.error('答案版本切换失败'),
|
||||||
|
onStateChange: () => {
|
||||||
|
variantSwitchStateVersion.value += 1;
|
||||||
|
},
|
||||||
|
replaceRound(items, roundId, nextItems) {
|
||||||
|
const assistantMessage = nextItems.find((item) => item.role === 'assistant');
|
||||||
|
if (!assistantMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetIndex = items.findIndex(
|
||||||
|
(item) => item.role === 'assistant' && item.roundId === roundId,
|
||||||
|
);
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
items.splice(targetIndex, 1, assistantMessage);
|
||||||
|
} else {
|
||||||
|
items.push(assistantMessage);
|
||||||
|
}
|
||||||
|
const nextCollapsed = new Set(collapsedToolIds.value);
|
||||||
|
assistantMessage.toolCalls?.forEach((item) => nextCollapsed.add(item.id));
|
||||||
|
collapsedToolIds.value = nextCollapsed;
|
||||||
|
},
|
||||||
|
});
|
||||||
const botId = computed(() => String(route.params.botId || ''));
|
const botId = computed(() => String(route.params.botId || ''));
|
||||||
const isEmbedMode = computed(() => String(route.query.embed || '') === '1');
|
const isEmbedMode = computed(() => String(route.query.embed || '') === '1');
|
||||||
const presetQuestions = computed(() =>
|
const presetQuestions = computed(() =>
|
||||||
@@ -305,6 +359,83 @@ const normalizeTimestamp = (value: any) => {
|
|||||||
return Number.isNaN(parsed) ? Date.now() : parsed;
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeRoundId = (value: any) => {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
return normalized || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePositiveInteger = (value: any) => {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyRoundMeta = (
|
||||||
|
target: Partial<BubbleMessage>,
|
||||||
|
source?: null | Partial<PublicChatMessageRecord> | SseRoundMeta,
|
||||||
|
) => {
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roundId = normalizeRoundId(source.roundId);
|
||||||
|
if (roundId) {
|
||||||
|
target.roundId = roundId;
|
||||||
|
}
|
||||||
|
const roundNo = normalizePositiveInteger(source.roundNo);
|
||||||
|
if (roundNo) {
|
||||||
|
target.roundNo = roundNo;
|
||||||
|
}
|
||||||
|
const variantIndex = normalizePositiveInteger(source.variantIndex);
|
||||||
|
if (variantIndex) {
|
||||||
|
target.variantIndex = variantIndex;
|
||||||
|
}
|
||||||
|
const variantCount = normalizePositiveInteger(source.variantCount);
|
||||||
|
if (variantCount) {
|
||||||
|
target.variantCount = variantCount;
|
||||||
|
}
|
||||||
|
const selectedVariantIndex = normalizePositiveInteger(
|
||||||
|
source.selectedVariantIndex,
|
||||||
|
);
|
||||||
|
if (selectedVariantIndex) {
|
||||||
|
target.selectedVariantIndex = selectedVariantIndex;
|
||||||
|
}
|
||||||
|
if (typeof source.switchable === 'boolean') {
|
||||||
|
target.switchable = source.switchable;
|
||||||
|
}
|
||||||
|
if ('messageKind' in source) {
|
||||||
|
const messageKind = String(source.messageKind ?? '').trim();
|
||||||
|
if (messageKind) {
|
||||||
|
target.messageKind = messageKind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindLatestPendingUserMessage = (meta?: SseRoundMeta) => {
|
||||||
|
const roundId = normalizeRoundId(meta?.roundId);
|
||||||
|
if (!roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = messages.value[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.role !== 'user') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyRoundMeta(item, {
|
||||||
|
roundId,
|
||||||
|
roundNo: meta?.roundNo,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const restoreToolTraceFromChain = (chain: Record<string, any>) => {
|
const restoreToolTraceFromChain = (chain: Record<string, any>) => {
|
||||||
const toolId = String(chain.id || '').trim() || uuid();
|
const toolId = String(chain.id || '').trim() || uuid();
|
||||||
const status =
|
const status =
|
||||||
@@ -394,34 +525,47 @@ const buildAssistantMessageFromRecord = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreMessagesFromRecords = (records: PublicChatMessageRecord[]) => {
|
const buildBubbleMessageFromRecord = (record: PublicChatMessageRecord) => {
|
||||||
const restoredMessages: BubbleMessage[] = [];
|
|
||||||
const nextCollapsedToolIds = new Set<string>();
|
|
||||||
|
|
||||||
for (const record of records) {
|
|
||||||
const role = String(record.senderRole || '')
|
const role = String(record.senderRole || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (role === 'assistant') {
|
if (role === 'assistant') {
|
||||||
const assistantMessage = buildAssistantMessageFromRecord(record);
|
const assistantMessage = buildAssistantMessageFromRecord(record);
|
||||||
|
applyRoundMeta(assistantMessage, record);
|
||||||
|
return assistantMessage;
|
||||||
|
}
|
||||||
|
const userMessage: BubbleMessage = {
|
||||||
|
id: String(record.id || uuid()),
|
||||||
|
role: 'user',
|
||||||
|
content: String(record.contentText || ''),
|
||||||
|
createdAt: normalizeTimestamp(record.created),
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
applyRoundMeta(userMessage, record);
|
||||||
|
return userMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreMessagesFromRecords = (records: PublicChatMessageRecord[]) => {
|
||||||
|
const restoredMessages: BubbleMessage[] = [];
|
||||||
|
const nextCollapsedToolIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const message = buildBubbleMessageFromRecord(record);
|
||||||
|
if (message.role === 'assistant') {
|
||||||
|
const assistantMessage = message;
|
||||||
assistantMessage.toolCalls?.forEach((item) =>
|
assistantMessage.toolCalls?.forEach((item) =>
|
||||||
nextCollapsedToolIds.add(item.id),
|
nextCollapsedToolIds.add(item.id),
|
||||||
);
|
);
|
||||||
restoredMessages.push(assistantMessage);
|
restoredMessages.push(assistantMessage);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
restoredMessages.push({
|
restoredMessages.push(message);
|
||||||
id: String(record.id || uuid()),
|
|
||||||
role: 'user',
|
|
||||||
content: String(record.contentText || ''),
|
|
||||||
createdAt: normalizeTimestamp(record.created),
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.value = restoredMessages;
|
messages.value = restoredMessages;
|
||||||
collapsedToolIds.value = nextCollapsedToolIds;
|
collapsedToolIds.value = nextCollapsedToolIds;
|
||||||
shouldAutoScroll.value = true;
|
shouldAutoScroll.value = true;
|
||||||
|
prefetchVisibleVariants();
|
||||||
};
|
};
|
||||||
|
|
||||||
const readUrlApiKey = () => {
|
const readUrlApiKey = () => {
|
||||||
@@ -659,6 +803,47 @@ const appendMessage = (role: MessageRole, content = '', loading = false) => {
|
|||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findMessageIndexByRoundId = (
|
||||||
|
role: MessageRole,
|
||||||
|
roundId?: string,
|
||||||
|
) => {
|
||||||
|
if (!roundId) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = messages.value[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.role === role && item.roundId === roundId) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetAssistantMessageForRegenerate = (roundId: string) => {
|
||||||
|
const targetIndex = findMessageIndexByRoundId('assistant', roundId);
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
appendMessage('assistant', '', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = messages.value[targetIndex];
|
||||||
|
if (!current) {
|
||||||
|
appendMessage('assistant', '', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messages.value[targetIndex] = {
|
||||||
|
...current,
|
||||||
|
content: '',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
loading: true,
|
||||||
|
segments: [],
|
||||||
|
toolCalls: [],
|
||||||
|
};
|
||||||
|
scrollToBottom(true);
|
||||||
|
};
|
||||||
|
|
||||||
const updateLastAssistantMessage = (patch: Partial<BubbleMessage>) => {
|
const updateLastAssistantMessage = (patch: Partial<BubbleMessage>) => {
|
||||||
const lastIndex = messages.value.length - 1;
|
const lastIndex = messages.value.length - 1;
|
||||||
if (lastIndex < 0) return;
|
if (lastIndex < 0) return;
|
||||||
@@ -707,7 +892,7 @@ const getLastAssistantMessage = () => {
|
|||||||
return last;
|
return last;
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendAssistantTextDelta = (delta: string) => {
|
const appendAssistantTextDelta = (delta: string, meta?: SseRoundMeta) => {
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
const last = getLastAssistantMessage();
|
const last = getLastAssistantMessage();
|
||||||
if (!last) return;
|
if (!last) return;
|
||||||
@@ -729,10 +914,11 @@ const appendAssistantTextDelta = (delta: string) => {
|
|||||||
content: `${last.content}${delta}`,
|
content: `${last.content}${delta}`,
|
||||||
segments,
|
segments,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
...meta,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendAssistantThinkingDelta = (delta: string) => {
|
const appendAssistantThinkingDelta = (delta: string, meta?: SseRoundMeta) => {
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
const last = getLastAssistantMessage();
|
const last = getLastAssistantMessage();
|
||||||
if (!last) return;
|
if (!last) return;
|
||||||
@@ -763,6 +949,7 @@ const appendAssistantThinkingDelta = (delta: string) => {
|
|||||||
updateLastAssistantMessage({
|
updateLastAssistantMessage({
|
||||||
segments,
|
segments,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
...meta,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -868,6 +1055,12 @@ const upsertToolTrace = (patch: Partial<ToolTraceItem> & { id?: string }) => {
|
|||||||
toolCalls: nextToolCalls,
|
toolCalls: nextToolCalls,
|
||||||
segments,
|
segments,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
roundId: last.roundId,
|
||||||
|
roundNo: last.roundNo,
|
||||||
|
selectedVariantIndex: last.selectedVariantIndex,
|
||||||
|
switchable: last.switchable,
|
||||||
|
variantCount: last.variantCount,
|
||||||
|
variantIndex: last.variantIndex,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -910,6 +1103,7 @@ const extractToolMetaFromPayload = (payload: any) => {
|
|||||||
|
|
||||||
const handleToolSseEvent = (sseData: any, normalizedType?: string) => {
|
const handleToolSseEvent = (sseData: any, normalizedType?: string) => {
|
||||||
const payload = sseData?.payload || {};
|
const payload = sseData?.payload || {};
|
||||||
|
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
|
||||||
const inferred = extractToolMetaFromPayload(payload);
|
const inferred = extractToolMetaFromPayload(payload);
|
||||||
const normalized = normalizedType || normalizeEventKey(sseData?.type);
|
const normalized = normalizedType || normalizeEventKey(sseData?.type);
|
||||||
let status = normalized as ToolStatus;
|
let status = normalized as ToolStatus;
|
||||||
@@ -932,6 +1126,9 @@ const handleToolSseEvent = (sseData: any, normalizedType?: string) => {
|
|||||||
arguments: status === 'TOOL_CALL' ? inferred.arguments : undefined,
|
arguments: status === 'TOOL_CALL' ? inferred.arguments : undefined,
|
||||||
result: status === 'TOOL_RESULT' ? inferred.result : undefined,
|
result: status === 'TOOL_RESULT' ? inferred.result : undefined,
|
||||||
});
|
});
|
||||||
|
if (Object.keys(streamMeta).length > 0) {
|
||||||
|
updateLastAssistantMessage(streamMeta);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNeedSaveToolMessage = (role: string, content: string) => {
|
const handleNeedSaveToolMessage = (role: string, content: string) => {
|
||||||
@@ -981,6 +1178,8 @@ const onSseMessage = (event: ServerSentEventMessage) => {
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
|
||||||
|
bindLatestPendingUserMessage(streamMeta);
|
||||||
const eventDomain = normalizeEventKey(sseData?.domain);
|
const eventDomain = normalizeEventKey(sseData?.domain);
|
||||||
const eventType = normalizeEventKey(sseData?.type);
|
const eventType = normalizeEventKey(sseData?.type);
|
||||||
if (eventDomain === 'SYSTEM' && sseData?.payload?.code === 'SYSTEM_ERROR') {
|
if (eventDomain === 'SYSTEM' && sseData?.payload?.code === 'SYSTEM_ERROR') {
|
||||||
@@ -989,6 +1188,7 @@ const onSseMessage = (event: ServerSentEventMessage) => {
|
|||||||
updateLastAssistantMessage({
|
updateLastAssistantMessage({
|
||||||
content: sseData.payload?.message || $t('bot.publicChatInitError'),
|
content: sseData.payload?.message || $t('bot.publicChatInitError'),
|
||||||
loading: false,
|
loading: false,
|
||||||
|
...streamMeta,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1227,7 @@ const onSseMessage = (event: ServerSentEventMessage) => {
|
|||||||
typeof deltaRaw === 'string'
|
typeof deltaRaw === 'string'
|
||||||
? deltaRaw
|
? deltaRaw
|
||||||
: normalizeToolPayloadValue(deltaRaw);
|
: normalizeToolPayloadValue(deltaRaw);
|
||||||
appendAssistantTextDelta(delta);
|
appendAssistantTextDelta(delta, streamMeta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1037,24 +1237,116 @@ const onSseMessage = (event: ServerSentEventMessage) => {
|
|||||||
typeof deltaRaw === 'string'
|
typeof deltaRaw === 'string'
|
||||||
? deltaRaw
|
? deltaRaw
|
||||||
: normalizeToolPayloadValue(deltaRaw);
|
: normalizeToolPayloadValue(deltaRaw);
|
||||||
appendAssistantThinkingDelta(delta);
|
appendAssistantThinkingDelta(delta, streamMeta);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleToolSseEvent(sseData, eventType);
|
handleToolSseEvent(sseData, eventType);
|
||||||
};
|
};
|
||||||
|
|
||||||
const doSendMessage = () => {
|
const normalizeSseRoundMeta = (meta: any): SseRoundMeta => {
|
||||||
const prompt = senderValue.value.trim();
|
if (!meta || typeof meta !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const variantIndex = normalizePositiveInteger(meta.variantIndex);
|
||||||
|
return {
|
||||||
|
regenerate: Boolean(meta.regenerate),
|
||||||
|
regenerateRoundId: meta.regenerateRoundId
|
||||||
|
? String(meta.regenerateRoundId)
|
||||||
|
: undefined,
|
||||||
|
roundId: normalizeRoundId(meta.roundId),
|
||||||
|
roundNo: normalizePositiveInteger(meta.roundNo),
|
||||||
|
selectedVariantIndex:
|
||||||
|
normalizePositiveInteger(meta.selectedVariantIndex) || variantIndex,
|
||||||
|
switchable:
|
||||||
|
typeof meta.switchable === 'boolean'
|
||||||
|
? meta.switchable
|
||||||
|
: variantIndex != null
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
variantCount: normalizePositiveInteger(meta.variantCount) || variantIndex,
|
||||||
|
variantIndex,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLatestAssistantMessage = () => {
|
||||||
|
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = messages.value[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.role === 'assistant') {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveRoundUserPrompt = (roundId?: string) => {
|
||||||
|
if (!roundId) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const matched = messages.value.find(
|
||||||
|
(item) => item.role === 'user' && item.roundId === roundId,
|
||||||
|
);
|
||||||
|
return matched ? String(matched.content || '').trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshConversationMessages = async () => {
|
||||||
|
if (!conversationId.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const restoreResp = await getResponseBody<PublicChatSessionRestoreResult>(
|
||||||
|
baseRequestClient.get('/api/v1/public-chat/session/restore', {
|
||||||
|
params: {
|
||||||
|
botId: botId.value,
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
},
|
||||||
|
headers: buildRequestHeaders(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const restoreData = restoreResp.data;
|
||||||
|
if (
|
||||||
|
restoreResp.errorCode !== 0 ||
|
||||||
|
!restoreData?.sessionExists ||
|
||||||
|
!restoreData.conversationId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
conversationId.value = String(restoreData.conversationId);
|
||||||
|
restoreMessagesFromRecords(restoreData.messages || []);
|
||||||
|
upsertPublicChatContext({
|
||||||
|
accessToken: requestAccessToken.value,
|
||||||
|
authenticatedAccess: authenticatedAccess.value,
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSendMessage = (options?: {
|
||||||
|
prompt?: string;
|
||||||
|
regenerateRoundId?: string;
|
||||||
|
}) => {
|
||||||
|
const prompt = String(options?.prompt ?? senderValue.value).trim();
|
||||||
|
const regenerateRoundId = options?.regenerateRoundId
|
||||||
|
? String(options.regenerateRoundId)
|
||||||
|
: '';
|
||||||
|
const isRegenerate = !!regenerateRoundId;
|
||||||
if (!prompt || sending.value || blocked.value || !conversationId.value) {
|
if (!prompt || sending.value || blocked.value || !conversationId.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isRegenerate) {
|
||||||
appendMessage('user', prompt);
|
appendMessage('user', prompt);
|
||||||
appendMessage('assistant', '', true);
|
appendMessage('assistant', '', true);
|
||||||
|
} else {
|
||||||
|
resetAssistantMessageForRegenerate(regenerateRoundId);
|
||||||
|
}
|
||||||
shouldAutoScroll.value = true;
|
shouldAutoScroll.value = true;
|
||||||
userStoppedStreaming.value = false;
|
userStoppedStreaming.value = false;
|
||||||
|
if (!options?.prompt) {
|
||||||
senderValue.value = '';
|
senderValue.value = '';
|
||||||
|
}
|
||||||
sending.value = true;
|
sending.value = true;
|
||||||
|
|
||||||
sseClient.post(
|
sseClient.post(
|
||||||
@@ -1063,16 +1355,18 @@ const doSendMessage = () => {
|
|||||||
botId: botId.value,
|
botId: botId.value,
|
||||||
prompt,
|
prompt,
|
||||||
conversationId: conversationId.value,
|
conversationId: conversationId.value,
|
||||||
|
regenerateRoundId: regenerateRoundId || undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: buildRequestHeaders(),
|
headers: buildRequestHeaders(),
|
||||||
onMessage: onSseMessage,
|
onMessage: onSseMessage,
|
||||||
onFinished: () => {
|
onFinished: async () => {
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
stopAssistantThinking();
|
stopAssistantThinking();
|
||||||
updateLastAssistantMessage({ loading: false });
|
updateLastAssistantMessage({ loading: false });
|
||||||
|
await refreshConversationMessages();
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: async (error: any) => {
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
stopAssistantThinking();
|
stopAssistantThinking();
|
||||||
const abortLike =
|
const abortLike =
|
||||||
@@ -1089,12 +1383,14 @@ const doSendMessage = () => {
|
|||||||
} else {
|
} else {
|
||||||
updateLastAssistantMessage({ loading: false });
|
updateLastAssistantMessage({ loading: false });
|
||||||
}
|
}
|
||||||
|
await refreshConversationMessages();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateLastAssistantMessage({
|
updateLastAssistantMessage({
|
||||||
loading: false,
|
loading: false,
|
||||||
content: $t('bot.publicChatInitError'),
|
content: $t('bot.publicChatInitError'),
|
||||||
});
|
});
|
||||||
|
await refreshConversationMessages();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1159,10 +1455,10 @@ const createConversationId = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreConversationMessages = async () => {
|
const restoreConversationMessages = async (conversationIdValue?: string) => {
|
||||||
const storedContext = readPublicChatContext(botId.value);
|
const storedContext = readPublicChatContext(botId.value);
|
||||||
const storedConversationId = String(
|
const storedConversationId = String(
|
||||||
storedContext?.conversationId || '',
|
conversationIdValue || storedContext?.conversationId || '',
|
||||||
).trim();
|
).trim();
|
||||||
if (!storedConversationId) {
|
if (!storedConversationId) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1299,6 +1595,164 @@ onBeforeUnmount(() => {
|
|||||||
dockResizeObserver?.disconnect();
|
dockResizeObserver?.disconnect();
|
||||||
dockResizeObserver = null;
|
dockResizeObserver = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canCopyMessage = (item: BubbleMessage) =>
|
||||||
|
Boolean(String(item.content || '').trim());
|
||||||
|
|
||||||
|
const shouldShowVariantNavigator = (item: BubbleMessage) =>
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
isFinalAssistantInRound(item) &&
|
||||||
|
Number(item.variantCount || 0) > 1 &&
|
||||||
|
Boolean(item.roundId);
|
||||||
|
|
||||||
|
const canSwitchVariant = (
|
||||||
|
item: BubbleMessage,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
item.role !== 'assistant' ||
|
||||||
|
!isFinalAssistantInRound(item) ||
|
||||||
|
sending.value ||
|
||||||
|
!item.switchable ||
|
||||||
|
isVariantSwitching(item)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const total = Number(item.variantCount || 1);
|
||||||
|
if (direction === 'previous') {
|
||||||
|
return current > 1;
|
||||||
|
}
|
||||||
|
return current < total;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFinalAssistantInRound = (item: BubbleMessage) => {
|
||||||
|
if (item.role !== 'assistant') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!item.roundId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (let index = messages.value.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = messages.value[index];
|
||||||
|
if (
|
||||||
|
candidate?.role === 'assistant' &&
|
||||||
|
candidate.roundId === item.roundId &&
|
||||||
|
String(candidate.variantIndex || '') === String(item.variantIndex || '')
|
||||||
|
) {
|
||||||
|
return candidate.id === item.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyMessage = async (item: BubbleMessage) => {
|
||||||
|
if (!canCopyMessage(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(String(item.content || ''));
|
||||||
|
ElMessage.success($t('bot.publicChatCopySuccess'));
|
||||||
|
} catch {
|
||||||
|
ElMessage.error($t('bot.publicChatCopyFail'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerateMessage = async (item: BubbleMessage) => {
|
||||||
|
if (sending.value || item.role !== 'assistant') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roundId = String(item.roundId || '');
|
||||||
|
const prompt = resolveRoundUserPrompt(roundId);
|
||||||
|
if (!roundId || !prompt) {
|
||||||
|
ElMessage.warning('当前回答暂不支持重新生成');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doSendMessage({
|
||||||
|
prompt,
|
||||||
|
regenerateRoundId: roundId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectVariant = async (
|
||||||
|
item: BubbleMessage,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) => {
|
||||||
|
if (!conversationId.value || !item.roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
|
||||||
|
await variantSwitchController.switchVariant({
|
||||||
|
fetchVariants: () => fetchRoundVariants(conversationId.value, String(item.roundId)),
|
||||||
|
items: messages.value,
|
||||||
|
persistVariant: async () => {
|
||||||
|
const res = await getResponseBody<PublicChatMessageRecord>(
|
||||||
|
baseRequestClient.post(
|
||||||
|
`/api/v1/public-chat/session/${conversationId.value}/rounds/${item.roundId}/selectVariant`,
|
||||||
|
{
|
||||||
|
variantIndex,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
botId: botId.value,
|
||||||
|
},
|
||||||
|
headers: buildRequestHeaders(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (res.errorCode !== 0 || !res.data) {
|
||||||
|
throw new Error(res.message || '答案版本切换失败');
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId: conversationId.value,
|
||||||
|
targetVariantIndex: variantIndex,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchRoundVariants(sessionId: string, roundId: string) {
|
||||||
|
const res = await getResponseBody<PublicChatMessageRecord[]>(
|
||||||
|
baseRequestClient.get(
|
||||||
|
`/api/v1/public-chat/session/${sessionId}/rounds/${roundId}/variants`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
botId: botId.value,
|
||||||
|
},
|
||||||
|
headers: buildRequestHeaders(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (res.errorCode !== 0) {
|
||||||
|
throw new Error(res.message || '答案版本加载失败');
|
||||||
|
}
|
||||||
|
return res.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariantSwitching(item: BubbleMessage) {
|
||||||
|
variantSwitchStateVersion.value;
|
||||||
|
return variantSwitchController.isSwitching(conversationId.value, item.roundId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchVisibleVariants() {
|
||||||
|
if (!conversationId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of messages.value) {
|
||||||
|
if (
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
item.roundId &&
|
||||||
|
Number(item.variantCount || 0) > 1
|
||||||
|
) {
|
||||||
|
variantSwitchController.prefetchVariants({
|
||||||
|
fetchVariants: () => fetchRoundVariants(conversationId.value, String(item.roundId)),
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId: conversationId.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1497,6 +1951,33 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChatMessageActionBar
|
||||||
|
:align="item.role === 'user' ? 'end' : 'start'"
|
||||||
|
:allow-copy="canCopyMessage(item)"
|
||||||
|
:allow-regenerate="
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
getLatestAssistantMessage()?.id === item.id &&
|
||||||
|
Boolean(item.roundId)
|
||||||
|
"
|
||||||
|
:regenerate-disabled="sending"
|
||||||
|
:show-variant-navigator="shouldShowVariantNavigator(item)"
|
||||||
|
:variant-loading="isVariantSwitching(item)"
|
||||||
|
:disabled-variant-next="!canSwitchVariant(item, 'next')"
|
||||||
|
:disabled-variant-previous="
|
||||||
|
!canSwitchVariant(item, 'previous')
|
||||||
|
"
|
||||||
|
:variant-current="
|
||||||
|
Number(item.variantIndex || item.selectedVariantIndex || 1)
|
||||||
|
"
|
||||||
|
:variant-total="Number(item.variantCount || 1)"
|
||||||
|
@copy="handleCopyMessage(item)"
|
||||||
|
@regenerate="handleRegenerateMessage(item)"
|
||||||
|
@select-next-variant="handleSelectVariant(item, 'next')"
|
||||||
|
@select-previous-variant="
|
||||||
|
handleSelectVariant(item, 'previous')
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1555,7 +2036,7 @@ onBeforeUnmount(() => {
|
|||||||
:disabled="
|
:disabled="
|
||||||
blocked || initializing || !!initError || !senderValue.trim()
|
blocked || initializing || !!initError || !senderValue.trim()
|
||||||
"
|
"
|
||||||
@click="doSendMessage"
|
@click="() => doSendMessage()"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconifyIcon icon="solar:plain-linear" />
|
<IconifyIcon icon="solar:plain-linear" />
|
||||||
|
|||||||
@@ -3,26 +3,53 @@ import type { ChatTimeTimelineItem } from '@easyflow/types';
|
|||||||
|
|
||||||
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
|
import { XMarkdown as ElXMarkdown } from 'vue-element-plus-x';
|
||||||
|
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
import { ChatThinkingBlock } from '@easyflow/common-ui';
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { useUserStore } from '@easyflow/stores';
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import { CircleCheck } from '@element-plus/icons-vue';
|
import {
|
||||||
import { ElAvatar, ElIcon } from 'element-plus';
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
CircleCheck,
|
||||||
|
CopyDocument,
|
||||||
|
Loading,
|
||||||
|
RefreshRight,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import { ElAvatar, ElIcon, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
|
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
|
||||||
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
|
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
|
||||||
import ShowJson from '#/components/json/ShowJson.vue';
|
import ShowJson from '#/components/json/ShowJson.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
allowRegenerate?: boolean;
|
||||||
|
allowVariantSwitch?: boolean;
|
||||||
bot: any;
|
bot: any;
|
||||||
messages: ChatTimeTimelineItem[];
|
messages: ChatTimeTimelineItem[];
|
||||||
|
regenerateDisabled?: boolean;
|
||||||
|
switchingRoundIds?: string[];
|
||||||
|
variantSwitchDisabled?: boolean;
|
||||||
}
|
}
|
||||||
const props = defineProps<Props>();
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
allowRegenerate: false,
|
||||||
|
allowVariantSwitch: false,
|
||||||
|
regenerateDisabled: false,
|
||||||
|
switchingRoundIds: () => [],
|
||||||
|
variantSwitchDisabled: false,
|
||||||
|
});
|
||||||
const store = useUserStore();
|
const store = useUserStore();
|
||||||
const expandedToolState = ref<Record<string, boolean>>({});
|
const expandedToolState = ref<Record<string, boolean>>({});
|
||||||
|
const latestAssistantMessageId = computed(() => {
|
||||||
|
return [...props.messages].reverse().find((item) => item.role === 'assistant')?.id || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
regenerate: [item: ChatTimeTimelineItem];
|
||||||
|
selectNextVariant: [item: ChatTimeTimelineItem];
|
||||||
|
selectPreviousVariant: [item: ChatTimeTimelineItem];
|
||||||
|
}>();
|
||||||
|
|
||||||
function getAssistantAvatar() {
|
function getAssistantAvatar() {
|
||||||
return props.bot.icon || defaultAssistantAvatar;
|
return props.bot.icon || defaultAssistantAvatar;
|
||||||
@@ -59,6 +86,87 @@ function toggleToolExpanded(item: ChatTimeTimelineItem) {
|
|||||||
[item.id]: !expandedToolState.value[item.id],
|
[item.id]: !expandedToolState.value[item.id],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canCopy(item: ChatTimeTimelineItem) {
|
||||||
|
return item.role !== 'tool' && Boolean(String(item.content || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRegenerate(item: ChatTimeTimelineItem) {
|
||||||
|
return (
|
||||||
|
props.allowRegenerate &&
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
item.id === latestAssistantMessageId.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowVariantNavigator(item: ChatTimeTimelineItem) {
|
||||||
|
return (
|
||||||
|
props.allowVariantSwitch &&
|
||||||
|
item.role === 'assistant' &&
|
||||||
|
isFinalAssistantInRound(item) &&
|
||||||
|
Number(item.variantCount || 0) > 1 &&
|
||||||
|
Boolean(item.roundId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSwitchVariant(
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
item.role !== 'assistant' ||
|
||||||
|
!isFinalAssistantInRound(item) ||
|
||||||
|
props.variantSwitchDisabled ||
|
||||||
|
isVariantSwitching(item) ||
|
||||||
|
!item.switchable
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const total = Number(item.variantCount || 1);
|
||||||
|
if (direction === 'previous') {
|
||||||
|
return current > 1;
|
||||||
|
}
|
||||||
|
return current < total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariantSwitching(item: ChatTimeTimelineItem) {
|
||||||
|
return Boolean(
|
||||||
|
item.roundId && props.switchingRoundIds.includes(String(item.roundId)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinalAssistantInRound(item: ChatTimeTimelineItem) {
|
||||||
|
if (item.role !== 'assistant') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!item.roundId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (let index = props.messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = props.messages[index];
|
||||||
|
if (
|
||||||
|
candidate?.role === 'assistant' &&
|
||||||
|
candidate.roundId === item.roundId &&
|
||||||
|
String(candidate.variantIndex || '') === String(item.variantIndex || '')
|
||||||
|
) {
|
||||||
|
return candidate.id === item.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy(item: ChatTimeTimelineItem) {
|
||||||
|
if (!canCopy(item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(String(item.content || ''));
|
||||||
|
ElMessage.success('已复制');
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -151,19 +259,65 @@ function toggleToolExpanded(item: ChatTimeTimelineItem) {
|
|||||||
<ElXMarkdown v-else :markdown="item.content" />
|
<ElXMarkdown v-else :markdown="item.content" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 自定义底部 -->
|
<template #footer="{ item }">
|
||||||
<!--<template #footer="{ item }">
|
<div
|
||||||
<div class="flex items-center">
|
v-if="canCopy(item) || canRegenerate(item) || shouldShowVariantNavigator(item)"
|
||||||
<template v-if="item.role === 'assistant'">
|
class="chat-message-actions"
|
||||||
<ElButton :icon="RefreshRight" link />
|
:class="{ 'is-user': item.role === 'user' }"
|
||||||
<ElButton :icon="CopyDocument" link />
|
>
|
||||||
</template>
|
<div
|
||||||
<template v-else>
|
v-if="shouldShowVariantNavigator(item)"
|
||||||
<ElButton :icon="CopyDocument" link />
|
class="chat-message-actions__variant"
|
||||||
<ElButton :icon="EditPen" link />
|
>
|
||||||
</template>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-message-actions__button"
|
||||||
|
:disabled="!canSwitchVariant(item, 'previous')"
|
||||||
|
aria-label="查看上一版答案"
|
||||||
|
@click="emit('selectPreviousVariant', item)"
|
||||||
|
>
|
||||||
|
<Loading v-if="isVariantSwitching(item)" class="is-loading" />
|
||||||
|
<ArrowLeft v-else />
|
||||||
|
</button>
|
||||||
|
<span class="chat-message-actions__variant-label">
|
||||||
|
{{ Number(item.variantIndex || item.selectedVariantIndex || 1) }}/{{
|
||||||
|
Number(item.variantCount || 1)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-message-actions__button"
|
||||||
|
:disabled="!canSwitchVariant(item, 'next')"
|
||||||
|
aria-label="查看下一版答案"
|
||||||
|
@click="emit('selectNextVariant', item)"
|
||||||
|
>
|
||||||
|
<Loading v-if="isVariantSwitching(item)" class="is-loading" />
|
||||||
|
<ArrowRight v-else />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>-->
|
|
||||||
|
<button
|
||||||
|
v-if="canRegenerate(item)"
|
||||||
|
type="button"
|
||||||
|
class="chat-message-actions__button"
|
||||||
|
:disabled="regenerateDisabled"
|
||||||
|
aria-label="重新生成"
|
||||||
|
@click="emit('regenerate', item)"
|
||||||
|
>
|
||||||
|
<RefreshRight />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="canCopy(item)"
|
||||||
|
type="button"
|
||||||
|
class="chat-message-actions__button"
|
||||||
|
aria-label="复制消息"
|
||||||
|
@click="handleCopy(item)"
|
||||||
|
>
|
||||||
|
<CopyDocument />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</ElBubbleList>
|
</ElBubbleList>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -271,4 +425,76 @@ function toggleToolExpanded(item: ChatTimeTimelineItem) {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 10px 10px;
|
padding: 0 10px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-message-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions.is-user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions__variant {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions__variant-label {
|
||||||
|
min-width: 42px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions__button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
transition:
|
||||||
|
color 0.18s ease,
|
||||||
|
background-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions__button:hover:not(:disabled) {
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--foreground) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions__button:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 0.32);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions__button:disabled {
|
||||||
|
opacity: 0.42;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions__button :deep(svg) {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-actions__button .is-loading {
|
||||||
|
animation: chat-action-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes chat-action-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -21,30 +21,75 @@ const props = defineProps<Props>();
|
|||||||
const senderValue = ref('');
|
const senderValue = ref('');
|
||||||
const btnLoading = ref(false);
|
const btnLoading = ref(false);
|
||||||
const getSessionList = inject<any>('getSessionList');
|
const getSessionList = inject<any>('getSessionList');
|
||||||
|
|
||||||
|
interface SendMessageOptions {
|
||||||
|
prompt?: string;
|
||||||
|
regenerateRoundId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SseRoundMeta {
|
||||||
|
roundId?: string;
|
||||||
|
roundNo?: number;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
|
switchable?: boolean;
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getConversationId: () => props.conversationId,
|
||||||
|
sendMessage,
|
||||||
|
});
|
||||||
|
|
||||||
const clearSenderFiles = () => {
|
const clearSenderFiles = () => {
|
||||||
files.value = [];
|
files.value = [];
|
||||||
attachmentsRef.value?.clearFiles();
|
attachmentsRef.value?.clearFiles();
|
||||||
};
|
};
|
||||||
function sendMessage() {
|
function sendMessage(options: SendMessageOptions = {}) {
|
||||||
if (getDisabled()) {
|
const prompt = String(options.prompt || senderValue.value || '').trim();
|
||||||
|
const regenerateRoundId = options.regenerateRoundId
|
||||||
|
? String(options.regenerateRoundId)
|
||||||
|
: '';
|
||||||
|
const isRegenerate = !!regenerateRoundId;
|
||||||
|
if (!props.conversationId || !prompt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
conversationId: props.conversationId,
|
conversationId: props.conversationId,
|
||||||
prompt: senderValue.value,
|
prompt,
|
||||||
botId: props.bot.id,
|
botId: props.bot.id,
|
||||||
attachments: attachmentsRef.value?.getFileList(),
|
attachments: attachmentsRef.value?.getFileList(),
|
||||||
|
regenerateRoundId: regenerateRoundId || undefined,
|
||||||
};
|
};
|
||||||
clearSenderFiles();
|
clearSenderFiles();
|
||||||
btnLoading.value = true;
|
btnLoading.value = true;
|
||||||
|
let userMessageId = '';
|
||||||
|
if (!isRegenerate) {
|
||||||
|
props.mutateMessages((messages) => {
|
||||||
|
const latestAssistant = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((item) => item.role === 'assistant');
|
||||||
|
if (latestAssistant?.roundId) {
|
||||||
|
ChatTimeTimelineBuilder.setRoundSwitchable(
|
||||||
|
messages,
|
||||||
|
latestAssistant.roundId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isRegenerate) {
|
||||||
|
userMessageId = uuid();
|
||||||
props.mutateMessages((messages) => {
|
props.mutateMessages((messages) => {
|
||||||
ChatTimeTimelineBuilder.appendUserMessage(messages, {
|
ChatTimeTimelineBuilder.appendUserMessage(messages, {
|
||||||
content: senderValue.value,
|
content: prompt,
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
id: uuid(),
|
id: userMessageId,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
senderValue.value = '';
|
senderValue.value = '';
|
||||||
|
}
|
||||||
|
let receivedAssistantPayload = false;
|
||||||
|
|
||||||
sseClient.post('/userCenter/bot/chat', data, {
|
sseClient.post('/userCenter/bot/chat', data, {
|
||||||
onMessage(res) {
|
onMessage(res) {
|
||||||
@@ -52,6 +97,10 @@ function sendMessage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sseData = JSON.parse(res.data);
|
const sseData = JSON.parse(res.data);
|
||||||
|
const streamMeta = normalizeSseRoundMeta(sseData?.meta);
|
||||||
|
props.mutateMessages((messages) => {
|
||||||
|
ChatTimeTimelineBuilder.bindLatestPendingUserMessage(messages, streamMeta);
|
||||||
|
});
|
||||||
const delta = sseData.payload?.delta;
|
const delta = sseData.payload?.delta;
|
||||||
|
|
||||||
if (res.event === 'done') {
|
if (res.event === 'done') {
|
||||||
@@ -64,6 +113,10 @@ function sendMessage() {
|
|||||||
sseData?.domain === 'SYSTEM' &&
|
sseData?.domain === 'SYSTEM' &&
|
||||||
sseData.payload?.code === 'SYSTEM_ERROR'
|
sseData.payload?.code === 'SYSTEM_ERROR'
|
||||||
) {
|
) {
|
||||||
|
if (isRegenerate && !receivedAssistantPayload) {
|
||||||
|
btnLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const errorMessage = sseData.payload.message;
|
const errorMessage = sseData.payload.message;
|
||||||
props.mutateMessages((messages) => {
|
props.mutateMessages((messages) => {
|
||||||
ChatTimeTimelineBuilder.applySystemError(messages, errorMessage);
|
ChatTimeTimelineBuilder.applySystemError(messages, errorMessage);
|
||||||
@@ -72,10 +125,12 @@ function sendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sseData?.domain === 'TOOL') {
|
if (sseData?.domain === 'TOOL') {
|
||||||
|
receivedAssistantPayload = true;
|
||||||
props.mutateMessages((messages) => {
|
props.mutateMessages((messages) => {
|
||||||
if (sseData?.type === 'TOOL_CALL') {
|
if (sseData?.type === 'TOOL_CALL') {
|
||||||
ChatTimeTimelineBuilder.upsertToolCall(messages, {
|
ChatTimeTimelineBuilder.upsertToolCall(messages, {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
|
...streamMeta,
|
||||||
name: sseData?.payload?.name,
|
name: sseData?.payload?.name,
|
||||||
toolCallId: sseData?.payload?.tool_call_id,
|
toolCallId: sseData?.payload?.tool_call_id,
|
||||||
value: sseData?.payload?.arguments,
|
value: sseData?.payload?.arguments,
|
||||||
@@ -84,6 +139,7 @@ function sendMessage() {
|
|||||||
}
|
}
|
||||||
ChatTimeTimelineBuilder.upsertToolResult(messages, {
|
ChatTimeTimelineBuilder.upsertToolResult(messages, {
|
||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
|
...streamMeta,
|
||||||
name: sseData?.payload?.name,
|
name: sseData?.payload?.name,
|
||||||
result: sseData?.payload?.result,
|
result: sseData?.payload?.result,
|
||||||
toolCallId: sseData?.payload?.tool_call_id,
|
toolCallId: sseData?.payload?.tool_call_id,
|
||||||
@@ -93,18 +149,38 @@ function sendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sseData.type === 'THINKING') {
|
if (sseData.type === 'THINKING') {
|
||||||
|
receivedAssistantPayload = true;
|
||||||
props.mutateMessages((messages) => {
|
props.mutateMessages((messages) => {
|
||||||
ChatTimeTimelineBuilder.appendThinkingDelta(messages, delta, Date.now());
|
ChatTimeTimelineBuilder.appendThinkingDelta(
|
||||||
|
messages,
|
||||||
|
delta,
|
||||||
|
Date.now(),
|
||||||
|
streamMeta,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else if (sseData.type === 'MESSAGE') {
|
} else if (sseData.type === 'MESSAGE') {
|
||||||
|
receivedAssistantPayload = true;
|
||||||
props.mutateMessages((messages) => {
|
props.mutateMessages((messages) => {
|
||||||
ChatTimeTimelineBuilder.appendMessageDelta(messages, delta, Date.now());
|
ChatTimeTimelineBuilder.appendMessageDelta(
|
||||||
|
messages,
|
||||||
|
delta,
|
||||||
|
Date.now(),
|
||||||
|
streamMeta,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
btnLoading.value = false;
|
btnLoading.value = false;
|
||||||
|
if (!isRegenerate && !receivedAssistantPayload && userMessageId) {
|
||||||
|
props.mutateMessages((messages) => {
|
||||||
|
const index = messages.findIndex((item) => item.id === userMessageId);
|
||||||
|
if (index >= 0) {
|
||||||
|
messages.splice(index, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
props.mutateMessages((messages) => {
|
props.mutateMessages((messages) => {
|
||||||
ChatTimeTimelineBuilder.finalize(messages);
|
ChatTimeTimelineBuilder.finalize(messages);
|
||||||
});
|
});
|
||||||
@@ -139,6 +215,28 @@ function triggerFileSelect() {
|
|||||||
function handleDeleteAllSenderFiles() {
|
function handleDeleteAllSenderFiles() {
|
||||||
files.value = [];
|
files.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSseRoundMeta(meta: any): SseRoundMeta {
|
||||||
|
if (!meta || typeof meta !== 'object') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const variantIndex = meta.variantIndex ? Number(meta.variantIndex) : undefined;
|
||||||
|
return {
|
||||||
|
roundId: meta.roundId ? String(meta.roundId) : undefined,
|
||||||
|
roundNo: meta.roundNo ? Number(meta.roundNo) : undefined,
|
||||||
|
selectedVariantIndex: meta.selectedVariantIndex
|
||||||
|
? Number(meta.selectedVariantIndex)
|
||||||
|
: variantIndex,
|
||||||
|
switchable:
|
||||||
|
typeof meta.switchable === 'boolean'
|
||||||
|
? meta.switchable
|
||||||
|
: variantIndex != null
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
variantCount: meta.variantCount ? Number(meta.variantCount) : variantIndex,
|
||||||
|
variantIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -155,7 +253,7 @@ function handleDeleteAllSenderFiles() {
|
|||||||
clearable
|
clearable
|
||||||
allow-speech
|
allow-speech
|
||||||
placeholder="发送消息"
|
placeholder="发送消息"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="() => sendMessage()"
|
||||||
@paste-file="handlePasteFile"
|
@paste-file="handlePasteFile"
|
||||||
>
|
>
|
||||||
<template #action-list>
|
<template #action-list>
|
||||||
@@ -172,7 +270,7 @@ function handleDeleteAllSenderFiles() {
|
|||||||
type="primary"
|
type="primary"
|
||||||
:icon="Promotion"
|
:icon="Promotion"
|
||||||
:disabled="getDisabled()"
|
:disabled="getDisabled()"
|
||||||
@click="sendMessage"
|
@click="() => sendMessage()"
|
||||||
round
|
round
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ArrowLeft, Minus, Plus } from '@element-plus/icons-vue';
|
import { ArrowLeft, Minus, Plus } from '@element-plus/icons-vue';
|
||||||
|
import {
|
||||||
|
ChatTimeHistoryMapper,
|
||||||
|
ChatTimeTimelineBuilder,
|
||||||
|
createChatVariantSwitchController,
|
||||||
|
} from '@easyflow/utils';
|
||||||
import {
|
import {
|
||||||
ElAside,
|
ElAside,
|
||||||
ElAvatar,
|
ElAvatar,
|
||||||
@@ -15,6 +20,7 @@ import {
|
|||||||
ElMessage,
|
ElMessage,
|
||||||
ElSpace,
|
ElSpace,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
import { tryit } from 'radash';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import defaultBotAvatar from '#/assets/defaultBotAvatar.png';
|
import defaultBotAvatar from '#/assets/defaultBotAvatar.png';
|
||||||
@@ -26,12 +32,18 @@ onMounted(async () => {
|
|||||||
getUserUsed();
|
getUserUsed();
|
||||||
getBotDetail();
|
getBotDetail();
|
||||||
});
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (prefetchTimer) {
|
||||||
|
clearTimeout(prefetchTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const usedList = ref<any[]>([]);
|
const usedList = ref<any[]>([]);
|
||||||
const botInfo = ref<any>({});
|
const botInfo = ref<any>({});
|
||||||
const btnLoading = ref(false);
|
const btnLoading = ref(false);
|
||||||
const conversationId = ref('');
|
const conversationId = ref('');
|
||||||
|
const senderRef = ref<InstanceType<typeof ChatSender>>();
|
||||||
function getUserUsed() {
|
function getUserUsed() {
|
||||||
api.get('/userCenter/botRecentlyUsed/list').then((res) => {
|
api.get('/userCenter/botRecentlyUsed/list').then((res) => {
|
||||||
usedList.value = res.data.map((item: any) => item.botId);
|
usedList.value = res.data.map((item: any) => item.botId);
|
||||||
@@ -82,10 +94,138 @@ function removeBotFromRecentlyUsed(botId: any) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||||||
|
const variantSwitchStateVersion = ref(0);
|
||||||
|
let prefetchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const variantSwitchController = createChatVariantSwitchController<any, ChatTimeTimelineItem>({
|
||||||
|
mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records),
|
||||||
|
onError: () => ElMessage.error('答案版本切换失败'),
|
||||||
|
onStateChange: () => {
|
||||||
|
variantSwitchStateVersion.value += 1;
|
||||||
|
},
|
||||||
|
replaceRound(items, roundId, nextItems) {
|
||||||
|
ChatTimeTimelineBuilder.replaceRoundMessages(items, roundId, nextItems);
|
||||||
|
},
|
||||||
|
});
|
||||||
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
|
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
|
||||||
const next = [...messageList.value];
|
const next = [...messageList.value];
|
||||||
mutator(next);
|
mutator(next);
|
||||||
messageList.value = next;
|
messageList.value = next;
|
||||||
|
schedulePrefetchVisibleVariants();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegenerate(item: ChatTimeTimelineItem) {
|
||||||
|
if (!item.roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prompt = resolveRoundPrompt(String(item.roundId));
|
||||||
|
if (!prompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
senderRef.value?.sendMessage({
|
||||||
|
prompt,
|
||||||
|
regenerateRoundId: String(item.roundId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectVariant(
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) {
|
||||||
|
if (!item.roundId || !conversationId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
|
||||||
|
const sessionId = conversationId.value;
|
||||||
|
await variantSwitchController.switchVariant({
|
||||||
|
fetchVariants: () => fetchRoundVariants(sessionId, item.roundId!),
|
||||||
|
items: messageList.value,
|
||||||
|
persistVariant: async () => {
|
||||||
|
const [, res] = await tryit(api.post)(
|
||||||
|
`/userCenter/chatHistory/sessions/${sessionId}/rounds/${item.roundId}/selectVariant`,
|
||||||
|
{
|
||||||
|
variantIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0 || !res?.data) {
|
||||||
|
throw new Error(String((res as any)?.message || '答案版本切换失败'));
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId,
|
||||||
|
targetVariantIndex: variantIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoundPrompt(roundId: string) {
|
||||||
|
const target = [...messageList.value].reverse().find(
|
||||||
|
(item) => item.role === 'user' && item.roundId === roundId,
|
||||||
|
);
|
||||||
|
return String(target?.content || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoundVariants(sessionId: string, roundId: string | number) {
|
||||||
|
const [, res] = await tryit(api.get)(
|
||||||
|
`/userCenter/chatHistory/sessions/${sessionId}/rounds/${roundId}/variants`,
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0) {
|
||||||
|
throw new Error(String((res as any)?.message || '答案版本加载失败'));
|
||||||
|
}
|
||||||
|
return res?.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePrefetchVisibleVariants() {
|
||||||
|
if (prefetchTimer) {
|
||||||
|
clearTimeout(prefetchTimer);
|
||||||
|
}
|
||||||
|
prefetchTimer = setTimeout(() => {
|
||||||
|
prefetchVisibleVariants();
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchVisibleVariants() {
|
||||||
|
if (!conversationId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefetchedRoundIds = new Set<string>();
|
||||||
|
for (const item of messageList.value) {
|
||||||
|
if (
|
||||||
|
item.role !== 'assistant' ||
|
||||||
|
!item.roundId ||
|
||||||
|
Number(item.variantCount || 0) <= 1 ||
|
||||||
|
prefetchedRoundIds.has(String(item.roundId))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prefetchedRoundIds.add(String(item.roundId));
|
||||||
|
variantSwitchController.prefetchVariants({
|
||||||
|
fetchVariants: () => fetchRoundVariants(conversationId.value, item.roundId!),
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId: conversationId.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSwitchingRoundIds() {
|
||||||
|
void variantSwitchStateVersion.value;
|
||||||
|
if (!conversationId.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
messageList.value
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.roundId &&
|
||||||
|
variantSwitchController.isSwitching(
|
||||||
|
conversationId.value,
|
||||||
|
item.roundId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((item) => String(item.roundId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -114,8 +254,19 @@ function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
|
|||||||
{{ botInfo.description }}
|
{{ botInfo.description }}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</Card>
|
</Card>
|
||||||
<ChatBubbleList v-else :bot="botInfo" :messages="messageList" />
|
<ChatBubbleList
|
||||||
|
v-else
|
||||||
|
:bot="botInfo"
|
||||||
|
:messages="messageList"
|
||||||
|
allow-regenerate
|
||||||
|
allow-variant-switch
|
||||||
|
:switching-round-ids="currentSwitchingRoundIds()"
|
||||||
|
@regenerate="handleRegenerate"
|
||||||
|
@select-next-variant="handleSelectVariant($event, 'next')"
|
||||||
|
@select-previous-variant="handleSelectVariant($event, 'previous')"
|
||||||
|
/>
|
||||||
<ChatSender
|
<ChatSender
|
||||||
|
ref="senderRef"
|
||||||
class="absolute bottom-5 left-0 w-full"
|
class="absolute bottom-5 left-0 w-full"
|
||||||
:bot="botInfo"
|
:bot="botInfo"
|
||||||
:conversation-id="conversationId"
|
:conversation-id="conversationId"
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
import type { ChatTimeTimelineItem } from '@easyflow/types';
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { IconifyIcon } from '@easyflow/icons';
|
import { IconifyIcon } from '@easyflow/icons';
|
||||||
import { cn } from '@easyflow/utils';
|
import {
|
||||||
|
ChatTimeHistoryMapper,
|
||||||
|
ChatTimeTimelineBuilder,
|
||||||
|
cn,
|
||||||
|
createChatVariantSwitchController,
|
||||||
|
} from '@easyflow/utils';
|
||||||
|
|
||||||
import { ElAside, ElContainer, ElMain } from 'element-plus';
|
import { ElAside, ElContainer, ElMain, ElMessage } from 'element-plus';
|
||||||
|
import { tryit } from 'radash';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
|
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
|
||||||
@@ -22,6 +28,11 @@ import { ChatBubbleList, ChatContainer, ChatSender } from '#/components/chat';
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getAssistantList();
|
getAssistantList();
|
||||||
});
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (prefetchTimer) {
|
||||||
|
clearTimeout(prefetchTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
const recentUsedAssistant = ref<any[]>([]);
|
const recentUsedAssistant = ref<any[]>([]);
|
||||||
const currentBot = ref<any>({});
|
const currentBot = ref<any>({});
|
||||||
const handleSelectAssistant = (bot: any) => {
|
const handleSelectAssistant = (bot: any) => {
|
||||||
@@ -37,18 +48,154 @@ function getAssistantList() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||||||
|
const senderRef = ref<InstanceType<typeof ChatSender>>();
|
||||||
|
const variantSwitchStateVersion = ref(0);
|
||||||
|
let prefetchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const variantSwitchController = createChatVariantSwitchController<any, ChatTimeTimelineItem>({
|
||||||
|
mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records),
|
||||||
|
onError: () => ElMessage.error('答案版本切换失败'),
|
||||||
|
onStateChange: () => {
|
||||||
|
variantSwitchStateVersion.value += 1;
|
||||||
|
},
|
||||||
|
replaceRound(items, roundId, nextItems) {
|
||||||
|
ChatTimeTimelineBuilder.replaceRoundMessages(items, roundId, nextItems);
|
||||||
|
},
|
||||||
|
});
|
||||||
function setMessageList(messages: ChatTimeTimelineItem[]) {
|
function setMessageList(messages: ChatTimeTimelineItem[]) {
|
||||||
messageList.value = messages;
|
messageList.value = messages;
|
||||||
|
schedulePrefetchVisibleVariants();
|
||||||
}
|
}
|
||||||
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
|
function mutateMessages(mutator: (messages: ChatTimeTimelineItem[]) => void) {
|
||||||
const next = [...messageList.value];
|
const next = [...messageList.value];
|
||||||
mutator(next);
|
mutator(next);
|
||||||
messageList.value = next;
|
messageList.value = next;
|
||||||
|
schedulePrefetchVisibleVariants();
|
||||||
}
|
}
|
||||||
const isFold = ref(false);
|
const isFold = ref(false);
|
||||||
const toggleFold = () => {
|
const toggleFold = () => {
|
||||||
isFold.value = !isFold.value;
|
isFold.value = !isFold.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function handleRegenerate(item: ChatTimeTimelineItem) {
|
||||||
|
if (!item.roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prompt = resolveRoundPrompt(String(item.roundId));
|
||||||
|
if (!prompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
senderRef.value?.sendMessage({
|
||||||
|
prompt,
|
||||||
|
regenerateRoundId: String(item.roundId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectVariant(
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) {
|
||||||
|
if (!item.roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
|
||||||
|
const conversationId = resolveConversationId();
|
||||||
|
if (!conversationId || variantIndex <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await variantSwitchController.switchVariant({
|
||||||
|
fetchVariants: () => fetchRoundVariants(conversationId, item.roundId!),
|
||||||
|
items: messageList.value,
|
||||||
|
persistVariant: async () => {
|
||||||
|
const [, res] = await tryit(api.post)(
|
||||||
|
`/userCenter/chatHistory/sessions/${conversationId}/rounds/${item.roundId}/selectVariant`,
|
||||||
|
{
|
||||||
|
variantIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0 || !res?.data) {
|
||||||
|
throw new Error(String((res as any)?.message || '答案版本切换失败'));
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId: conversationId,
|
||||||
|
targetVariantIndex: variantIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConversationId() {
|
||||||
|
return String(senderRef.value?.getConversationId?.() || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoundPrompt(roundId: string) {
|
||||||
|
const target = [...messageList.value].reverse().find(
|
||||||
|
(item) => item.role === 'user' && item.roundId === roundId,
|
||||||
|
);
|
||||||
|
return String(target?.content || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoundVariants(sessionId: string, roundId: string | number) {
|
||||||
|
const [, res] = await tryit(api.get)(
|
||||||
|
`/userCenter/chatHistory/sessions/${sessionId}/rounds/${roundId}/variants`,
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0) {
|
||||||
|
throw new Error(String((res as any)?.message || '答案版本加载失败'));
|
||||||
|
}
|
||||||
|
return res?.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulePrefetchVisibleVariants() {
|
||||||
|
if (prefetchTimer) {
|
||||||
|
clearTimeout(prefetchTimer);
|
||||||
|
}
|
||||||
|
prefetchTimer = setTimeout(() => {
|
||||||
|
prefetchVisibleVariants();
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchVisibleVariants() {
|
||||||
|
const conversationId = resolveConversationId();
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prefetchedRoundIds = new Set<string>();
|
||||||
|
for (const item of messageList.value) {
|
||||||
|
if (
|
||||||
|
item.role !== 'assistant' ||
|
||||||
|
!item.roundId ||
|
||||||
|
Number(item.variantCount || 0) <= 1 ||
|
||||||
|
prefetchedRoundIds.has(String(item.roundId))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prefetchedRoundIds.add(String(item.roundId));
|
||||||
|
variantSwitchController.prefetchVariants({
|
||||||
|
fetchVariants: () => fetchRoundVariants(conversationId, item.roundId!),
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId: conversationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSwitchingRoundIds() {
|
||||||
|
void variantSwitchStateVersion.value;
|
||||||
|
const conversationId = resolveConversationId();
|
||||||
|
if (!conversationId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
messageList.value
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.roundId &&
|
||||||
|
variantSwitchController.isSwitching(conversationId, item.roundId),
|
||||||
|
)
|
||||||
|
.map((item) => String(item.roundId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -66,9 +213,19 @@ const toggleFold = () => {
|
|||||||
>
|
>
|
||||||
<template #default="{ conversationId }">
|
<template #default="{ conversationId }">
|
||||||
<div class="flex h-full flex-col justify-between">
|
<div class="flex h-full flex-col justify-between">
|
||||||
<ChatBubbleList :bot="currentBot" :messages="messageList" />
|
<ChatBubbleList
|
||||||
|
:bot="currentBot"
|
||||||
|
:messages="messageList"
|
||||||
|
allow-regenerate
|
||||||
|
allow-variant-switch
|
||||||
|
:switching-round-ids="currentSwitchingRoundIds()"
|
||||||
|
@regenerate="handleRegenerate"
|
||||||
|
@select-next-variant="handleSelectVariant($event, 'next')"
|
||||||
|
@select-previous-variant="handleSelectVariant($event, 'previous')"
|
||||||
|
/>
|
||||||
<div class="mx-auto w-full max-w-[1000px]">
|
<div class="mx-auto w-full max-w-[1000px]">
|
||||||
<ChatSender
|
<ChatSender
|
||||||
|
ref="senderRef"
|
||||||
:bot="currentBot"
|
:bot="currentBot"
|
||||||
:conversation-id="conversationId"
|
:conversation-id="conversationId"
|
||||||
:mutate-messages="mutateMessages"
|
:mutate-messages="mutateMessages"
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type { ChatTimeTimelineItem } from '@easyflow/types';
|
|||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ChatTimeHistoryMapper } from '@easyflow/utils';
|
import {
|
||||||
|
ChatTimeHistoryMapper,
|
||||||
|
ChatTimeTimelineBuilder,
|
||||||
|
createChatVariantSwitchController,
|
||||||
|
} from '@easyflow/utils';
|
||||||
import { Delete, Edit, Search } from '@element-plus/icons-vue';
|
import { Delete, Edit, Search } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
@@ -47,12 +51,23 @@ const drawerVisible = ref(false);
|
|||||||
const drawerLoading = ref(false);
|
const drawerLoading = ref(false);
|
||||||
const currentSession = ref<any>();
|
const currentSession = ref<any>();
|
||||||
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
const messageList = ref<ChatTimeTimelineItem[]>([]);
|
||||||
|
const variantSwitchStateVersion = ref(0);
|
||||||
const loadedMessageRecordCount = ref(0);
|
const loadedMessageRecordCount = ref(0);
|
||||||
const messagePage = ref({
|
const messagePage = ref({
|
||||||
total: 0,
|
total: 0,
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
});
|
});
|
||||||
|
const variantSwitchController = createChatVariantSwitchController<any, ChatTimeTimelineItem>({
|
||||||
|
mapRecords: (records) => ChatTimeHistoryMapper.fromHistoryRecords(records),
|
||||||
|
onError: () => ElMessage.error('答案版本切换失败'),
|
||||||
|
onStateChange: () => {
|
||||||
|
variantSwitchStateVersion.value += 1;
|
||||||
|
},
|
||||||
|
replaceRound(items, roundId, nextItems) {
|
||||||
|
ChatTimeTimelineBuilder.replaceRoundMessages(items, roundId, nextItems);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const filteredSessions = computed(() => {
|
const filteredSessions = computed(() => {
|
||||||
const keyword = queryParams.value.keyword.trim().toLowerCase();
|
const keyword = queryParams.value.keyword.trim().toLowerCase();
|
||||||
@@ -167,6 +182,7 @@ async function loadMessages(reset = false) {
|
|||||||
} else {
|
} else {
|
||||||
messageList.value = [...normalized, ...messageList.value];
|
messageList.value = [...normalized, ...messageList.value];
|
||||||
}
|
}
|
||||||
|
prefetchVisibleVariants();
|
||||||
loadedMessageRecordCount.value = reset
|
loadedMessageRecordCount.value = reset
|
||||||
? (res.data?.records || []).length
|
? (res.data?.records || []).length
|
||||||
: loadedMessageRecordCount.value + (res.data?.records || []).length;
|
: loadedMessageRecordCount.value + (res.data?.records || []).length;
|
||||||
@@ -178,6 +194,90 @@ function normalizeMessages(records: any[]) {
|
|||||||
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
|
return ChatTimeHistoryMapper.fromHistoryRecords([...records].reverse());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSelectVariant(
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
direction: 'next' | 'previous',
|
||||||
|
) {
|
||||||
|
if (!currentSession.value?.id || !item.roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = Number(item.variantIndex || item.selectedVariantIndex || 1);
|
||||||
|
const variantIndex = direction === 'previous' ? current - 1 : current + 1;
|
||||||
|
const sessionId = String(currentSession.value.id);
|
||||||
|
await variantSwitchController.switchVariant({
|
||||||
|
fetchVariants: () => fetchRoundVariants(sessionId, item.roundId!),
|
||||||
|
items: messageList.value,
|
||||||
|
persistVariant: async () => {
|
||||||
|
const [, res] = await tryit(api.post)(
|
||||||
|
`/userCenter/chatHistory/sessions/${sessionId}/rounds/${item.roundId}/selectVariant`,
|
||||||
|
{
|
||||||
|
variantIndex,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0 || !res?.data) {
|
||||||
|
throw new Error(String((res as any)?.message || '答案版本切换失败'));
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId,
|
||||||
|
targetVariantIndex: variantIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRoundVariants(sessionId: string, roundId: string | number) {
|
||||||
|
const [, res] = await tryit(api.get)(
|
||||||
|
`/userCenter/chatHistory/sessions/${sessionId}/rounds/${roundId}/variants`,
|
||||||
|
);
|
||||||
|
if (res?.errorCode !== 0) {
|
||||||
|
throw new Error(String((res as any)?.message || '答案版本加载失败'));
|
||||||
|
}
|
||||||
|
return res?.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchVisibleVariants() {
|
||||||
|
if (!currentSession.value?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionId = String(currentSession.value.id);
|
||||||
|
const prefetchedRoundIds = new Set<string>();
|
||||||
|
for (const item of messageList.value) {
|
||||||
|
if (
|
||||||
|
item.role !== 'assistant' ||
|
||||||
|
!item.roundId ||
|
||||||
|
Number(item.variantCount || 0) <= 1 ||
|
||||||
|
prefetchedRoundIds.has(String(item.roundId))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prefetchedRoundIds.add(String(item.roundId));
|
||||||
|
variantSwitchController.prefetchVariants({
|
||||||
|
fetchVariants: () => fetchRoundVariants(sessionId, item.roundId!),
|
||||||
|
roundId: item.roundId,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSwitchingRoundIds() {
|
||||||
|
void variantSwitchStateVersion.value;
|
||||||
|
if (!currentSession.value?.id) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const sessionId = String(currentSession.value.id);
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
messageList.value
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.roundId &&
|
||||||
|
variantSwitchController.isSwitching(sessionId, item.roundId),
|
||||||
|
)
|
||||||
|
.map((item) => String(item.roundId)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function closeDrawer() {
|
function closeDrawer() {
|
||||||
drawerVisible.value = false;
|
drawerVisible.value = false;
|
||||||
currentSession.value = undefined;
|
currentSession.value = undefined;
|
||||||
@@ -355,6 +455,10 @@ function formatTime(value?: string) {
|
|||||||
<ChatBubbleList
|
<ChatBubbleList
|
||||||
:bot="{ icon: '', title: currentSession?.assistantName || '' }"
|
:bot="{ icon: '', title: currentSession?.assistantName || '' }"
|
||||||
:messages="messageList"
|
:messages="messageList"
|
||||||
|
allow-variant-switch
|
||||||
|
:switching-round-ids="currentSwitchingRoundIds()"
|
||||||
|
@select-next-variant="handleSelectVariant($event, 'next')"
|
||||||
|
@select-previous-variant="handleSelectVariant($event, 'previous')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,14 +2,31 @@ type ChatTimeTimelineRole = 'assistant' | 'tool' | 'user';
|
|||||||
type ChatTimeToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
|
type ChatTimeToolStatus = 'TOOL_CALL' | 'TOOL_RESULT';
|
||||||
type ChatTimeThinkingStatus = 'end' | 'thinking';
|
type ChatTimeThinkingStatus = 'end' | 'thinking';
|
||||||
|
|
||||||
|
interface ChatTimeRoundMeta {
|
||||||
|
messageKind?: string;
|
||||||
|
roundId?: number | string;
|
||||||
|
roundNo?: number;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
|
switchable?: boolean;
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatTimeTimelineItemBase {
|
interface ChatTimeTimelineItemBase {
|
||||||
created: number | string;
|
created: number | string;
|
||||||
id: string;
|
id: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
messageKind?: string;
|
||||||
placement: 'end' | 'start';
|
placement: 'end' | 'start';
|
||||||
|
roundId?: string;
|
||||||
|
roundNo?: number;
|
||||||
role: ChatTimeTimelineRole;
|
role: ChatTimeTimelineRole;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
|
switchable?: boolean;
|
||||||
typing?: boolean;
|
typing?: boolean;
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatTimeAssistantThinkingSegment {
|
interface ChatTimeAssistantThinkingSegment {
|
||||||
@@ -66,14 +83,22 @@ interface ChatTimeHistoryRecord {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
placement?: 'end' | 'start';
|
placement?: 'end' | 'start';
|
||||||
role?: string;
|
role?: string;
|
||||||
|
roundId?: number | string;
|
||||||
|
roundNo?: number;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
senderRole?: string;
|
senderRole?: string;
|
||||||
|
switchable?: boolean;
|
||||||
typing?: boolean;
|
typing?: boolean;
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
|
messageKind?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatTimeToolMutationPayload {
|
interface ChatTimeToolMutationPayload extends ChatTimeRoundMeta {
|
||||||
created?: number | string;
|
created?: number | string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
regenerate?: boolean;
|
||||||
result?: any;
|
result?: any;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
value?: any;
|
value?: any;
|
||||||
@@ -85,6 +110,7 @@ export type {
|
|||||||
ChatTimeAssistantTextSegment,
|
ChatTimeAssistantTextSegment,
|
||||||
ChatTimeAssistantThinkingSegment,
|
ChatTimeAssistantThinkingSegment,
|
||||||
ChatTimeHistoryRecord,
|
ChatTimeHistoryRecord,
|
||||||
|
ChatTimeRoundMeta,
|
||||||
ChatTimeThinkingStatus,
|
ChatTimeThinkingStatus,
|
||||||
ChatTimeTimelineItem,
|
ChatTimeTimelineItem,
|
||||||
ChatTimeTimelineItemBase,
|
ChatTimeTimelineItemBase,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
ChatTimeAssistantItem,
|
ChatTimeAssistantItem,
|
||||||
ChatTimeHistoryRecord,
|
ChatTimeHistoryRecord,
|
||||||
|
ChatTimeRoundMeta,
|
||||||
ChatTimeThinkingStatus,
|
ChatTimeThinkingStatus,
|
||||||
ChatTimeTimelineItem,
|
ChatTimeTimelineItem,
|
||||||
|
ChatTimeTimelineItemBase,
|
||||||
ChatTimeToolItem,
|
ChatTimeToolItem,
|
||||||
ChatTimeToolMutationPayload,
|
ChatTimeToolMutationPayload,
|
||||||
ChatTimeToolStatus,
|
ChatTimeToolStatus,
|
||||||
@@ -28,17 +30,71 @@ class ChatTimeTimelineBuilder {
|
|||||||
content?: string;
|
content?: string;
|
||||||
created?: number | string;
|
created?: number | string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
messageKind?: string;
|
||||||
|
roundId?: number | string;
|
||||||
|
roundNo?: number;
|
||||||
senderName?: string;
|
senderName?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
items.push({
|
const item: ChatTimeTimelineItem = {
|
||||||
content: normalizePlainText(payload.content),
|
content: normalizePlainText(payload.content),
|
||||||
created: normalizeTimestamp(payload.created),
|
created: normalizeTimestamp(payload.created),
|
||||||
id: payload.id || uuid(),
|
id: payload.id || uuid(),
|
||||||
placement: 'end',
|
placement: 'end',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
senderName: payload.senderName,
|
senderName: payload.senderName,
|
||||||
|
};
|
||||||
|
applyRoundMeta(item, payload);
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将最新一条待绑定的用户消息补齐到当前轮次。
|
||||||
|
*/
|
||||||
|
static bindLatestPendingUserMessage(
|
||||||
|
items: ChatTimeTimelineItem[],
|
||||||
|
meta?: ChatTimeRoundMeta,
|
||||||
|
) {
|
||||||
|
const roundId = normalizeRoundId(meta?.roundId);
|
||||||
|
if (!roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.role !== 'user') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.roundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyRoundMeta(item, {
|
||||||
|
roundId,
|
||||||
|
roundNo: meta?.roundNo,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新指定轮次的可切换状态。
|
||||||
|
*/
|
||||||
|
static setRoundSwitchable(
|
||||||
|
items: ChatTimeTimelineItem[],
|
||||||
|
roundId: number | string | undefined,
|
||||||
|
switchable: boolean,
|
||||||
|
) {
|
||||||
|
const normalizedRoundId = normalizeRoundId(roundId);
|
||||||
|
if (!normalizedRoundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.roundId === normalizedRoundId && item.role !== 'user') {
|
||||||
|
item.switchable = switchable;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,12 +104,14 @@ class ChatTimeTimelineBuilder {
|
|||||||
items: ChatTimeTimelineItem[],
|
items: ChatTimeTimelineItem[],
|
||||||
delta?: string,
|
delta?: string,
|
||||||
created?: number | string,
|
created?: number | string,
|
||||||
|
meta?: ChatTimeRoundMeta,
|
||||||
) {
|
) {
|
||||||
const normalizedDelta = normalizePlainText(delta);
|
const normalizedDelta = normalizePlainText(delta);
|
||||||
if (!normalizedDelta) {
|
if (!normalizedDelta) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assistant = ensureAssistantTail(items, created);
|
prepareRoundVariant(items, meta);
|
||||||
|
const assistant = ensureAssistantTail(items, created, meta);
|
||||||
const tail = assistant.segments[assistant.segments.length - 1];
|
const tail = assistant.segments[assistant.segments.length - 1];
|
||||||
if (tail?.type === 'thinking' && tail.status === 'thinking') {
|
if (tail?.type === 'thinking' && tail.status === 'thinking') {
|
||||||
tail.content += normalizedDelta;
|
tail.content += normalizedDelta;
|
||||||
@@ -77,12 +135,14 @@ class ChatTimeTimelineBuilder {
|
|||||||
items: ChatTimeTimelineItem[],
|
items: ChatTimeTimelineItem[],
|
||||||
delta?: string,
|
delta?: string,
|
||||||
created?: number | string,
|
created?: number | string,
|
||||||
|
meta?: ChatTimeRoundMeta,
|
||||||
) {
|
) {
|
||||||
const normalizedDelta = normalizeAssistantText(delta);
|
const normalizedDelta = normalizeAssistantText(delta);
|
||||||
if (!normalizedDelta) {
|
if (!normalizedDelta) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assistant = ensureAssistantTail(items, created);
|
prepareRoundVariant(items, meta);
|
||||||
|
const assistant = ensureAssistantTail(items, created, meta);
|
||||||
stopThinkingForAssistant(assistant);
|
stopThinkingForAssistant(assistant);
|
||||||
const tail = assistant.segments[assistant.segments.length - 1];
|
const tail = assistant.segments[assistant.segments.length - 1];
|
||||||
if (tail?.type === 'text') {
|
if (tail?.type === 'text') {
|
||||||
@@ -117,12 +177,14 @@ class ChatTimeTimelineBuilder {
|
|||||||
items: ChatTimeTimelineItem[],
|
items: ChatTimeTimelineItem[],
|
||||||
payload: ChatTimeToolMutationPayload,
|
payload: ChatTimeToolMutationPayload,
|
||||||
) {
|
) {
|
||||||
|
prepareRoundVariant(items, payload);
|
||||||
this.stopThinking(items);
|
this.stopThinking(items);
|
||||||
const toolItem = ensureToolItem(
|
const toolItem = ensureToolItem(
|
||||||
items,
|
items,
|
||||||
payload.toolCallId,
|
payload.toolCallId,
|
||||||
payload.created,
|
payload.created,
|
||||||
payload.name,
|
payload.name,
|
||||||
|
payload,
|
||||||
);
|
);
|
||||||
toolItem.arguments = normalizePayloadValue(payload.value);
|
toolItem.arguments = normalizePayloadValue(payload.value);
|
||||||
toolItem.content = '';
|
toolItem.content = '';
|
||||||
@@ -136,11 +198,13 @@ class ChatTimeTimelineBuilder {
|
|||||||
items: ChatTimeTimelineItem[],
|
items: ChatTimeTimelineItem[],
|
||||||
payload: ChatTimeToolMutationPayload,
|
payload: ChatTimeToolMutationPayload,
|
||||||
) {
|
) {
|
||||||
|
prepareRoundVariant(items, payload);
|
||||||
const toolItem = ensureToolItem(
|
const toolItem = ensureToolItem(
|
||||||
items,
|
items,
|
||||||
payload.toolCallId,
|
payload.toolCallId,
|
||||||
payload.created,
|
payload.created,
|
||||||
payload.name,
|
payload.name,
|
||||||
|
payload,
|
||||||
);
|
);
|
||||||
toolItem.result = normalizePayloadValue(payload.result);
|
toolItem.result = normalizePayloadValue(payload.result);
|
||||||
toolItem.content = toolItem.result;
|
toolItem.content = toolItem.result;
|
||||||
@@ -178,7 +242,7 @@ class ChatTimeTimelineBuilder {
|
|||||||
* 结束当前轮的 assistant 状态。
|
* 结束当前轮的 assistant 状态。
|
||||||
*/
|
*/
|
||||||
static finalize(items: ChatTimeTimelineItem[]) {
|
static finalize(items: ChatTimeTimelineItem[]) {
|
||||||
const last = items[items.length - 1];
|
const last = findLastAssistant(items);
|
||||||
if (!isAssistantItem(last)) {
|
if (!isAssistantItem(last)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -186,6 +250,26 @@ class ChatTimeTimelineBuilder {
|
|||||||
last.loading = false;
|
last.loading = false;
|
||||||
last.typing = false;
|
last.typing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按轮次替换当前主线可见的 assistant/tool 片段。
|
||||||
|
*/
|
||||||
|
static replaceRoundMessages(
|
||||||
|
items: ChatTimeTimelineItem[],
|
||||||
|
roundId: number | string | undefined,
|
||||||
|
nextMessages: ChatTimeTimelineItem[],
|
||||||
|
) {
|
||||||
|
const normalizedRoundId = normalizeRoundId(roundId);
|
||||||
|
if (!normalizedRoundId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const range = resolveRoundReplaceRange(items, normalizedRoundId);
|
||||||
|
if (range) {
|
||||||
|
items.splice(range.start, range.deleteCount, ...nextMessages);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
items.splice(resolveRoundInsertIndex(items, normalizedRoundId), 0, ...nextMessages);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,7 +280,9 @@ class ChatTimeHistoryMapper {
|
|||||||
* 从聊天历史记录恢复时间线。
|
* 从聊天历史记录恢复时间线。
|
||||||
*/
|
*/
|
||||||
static fromHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
static fromHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
||||||
return records.flatMap((record) => this.fromHistoryRecord(record));
|
return normalizeVisibleHistoryRecords(records).flatMap((record) =>
|
||||||
|
this.fromHistoryRecord(record),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -249,8 +335,15 @@ class ChatTimeHistoryMapper {
|
|||||||
const assistant = createAssistantItem(record.created, {
|
const assistant = createAssistantItem(record.created, {
|
||||||
id: record.id == null ? undefined : String(record.id),
|
id: record.id == null ? undefined : String(record.id),
|
||||||
loading: record.loading,
|
loading: record.loading,
|
||||||
|
messageKind: record.messageKind,
|
||||||
|
roundId: normalizeRoundId(record.roundId),
|
||||||
|
roundNo: record.roundNo,
|
||||||
|
selectedVariantIndex: record.selectedVariantIndex,
|
||||||
senderName: record.senderName,
|
senderName: record.senderName,
|
||||||
|
switchable: record.switchable,
|
||||||
typing: record.typing,
|
typing: record.typing,
|
||||||
|
variantCount: record.variantCount,
|
||||||
|
variantIndex: record.variantIndex,
|
||||||
});
|
});
|
||||||
const tools: ChatTimeTimelineItem[] = [];
|
const tools: ChatTimeTimelineItem[] = [];
|
||||||
|
|
||||||
@@ -267,7 +360,7 @@ class ChatTimeHistoryMapper {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolItem = createToolItemFromChain(rawChain, record.created);
|
const toolItem = createToolItemFromChain(rawChain, record.created, record);
|
||||||
if (toolItem) {
|
if (toolItem) {
|
||||||
tools.push(toolItem);
|
tools.push(toolItem);
|
||||||
}
|
}
|
||||||
@@ -316,6 +409,7 @@ class ChatTimeHistoryMapper {
|
|||||||
rawMessage,
|
rawMessage,
|
||||||
toolMetaMap,
|
toolMetaMap,
|
||||||
record.created,
|
record.created,
|
||||||
|
record,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -325,11 +419,84 @@ class ChatTimeHistoryMapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVisibleHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
||||||
|
const dedupedRecords = dedupeHistoryRecords(records);
|
||||||
|
const userSelectedVariantByRound = new Map<string, number>();
|
||||||
|
const assistantSelectedVariantByRound = new Map<string, number>();
|
||||||
|
const fallbackVariantByRound = new Map<string, number>();
|
||||||
|
for (const record of dedupedRecords) {
|
||||||
|
const roundId = normalizeRoundId(record.roundId);
|
||||||
|
if (!roundId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const selectedVariantIndex = normalizePositiveInteger(
|
||||||
|
record.selectedVariantIndex,
|
||||||
|
);
|
||||||
|
if (selectedVariantIndex) {
|
||||||
|
if (isUserHistoryRecord(record)) {
|
||||||
|
userSelectedVariantByRound.set(roundId, selectedVariantIndex);
|
||||||
|
} else {
|
||||||
|
assistantSelectedVariantByRound.set(roundId, selectedVariantIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const variantIndex = normalizePositiveInteger(record.variantIndex);
|
||||||
|
if (!isUserHistoryRecord(record) && variantIndex) {
|
||||||
|
fallbackVariantByRound.set(roundId, variantIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dedupedRecords.filter((record) => {
|
||||||
|
const roundId = normalizeRoundId(record.roundId);
|
||||||
|
if (!roundId || isUserHistoryRecord(record)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const variantIndex = normalizePositiveInteger(record.variantIndex);
|
||||||
|
if (!variantIndex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const selectedVariantIndex =
|
||||||
|
userSelectedVariantByRound.get(roundId) ||
|
||||||
|
assistantSelectedVariantByRound.get(roundId) ||
|
||||||
|
fallbackVariantByRound.get(roundId);
|
||||||
|
return !selectedVariantIndex || variantIndex === selectedVariantIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeHistoryRecords(records: ChatTimeHistoryRecord[]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: ChatTimeHistoryRecord[] = [];
|
||||||
|
for (const record of records) {
|
||||||
|
const key = resolveHistoryRecordKey(record);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
result.push(record);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHistoryRecordKey(record: ChatTimeHistoryRecord) {
|
||||||
|
if (record.id != null) {
|
||||||
|
return `id:${String(record.id)}`;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'fallback',
|
||||||
|
normalizeRoundId(record.roundId) || '',
|
||||||
|
normalizeRole(record.senderRole || record.role),
|
||||||
|
normalizePositiveInteger(record.variantIndex) || '',
|
||||||
|
normalizePlainText(record.contentText || record.content),
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUserHistoryRecord(record: ChatTimeHistoryRecord) {
|
||||||
|
return normalizeRole(record.senderRole || record.role) === 'user';
|
||||||
|
}
|
||||||
|
|
||||||
function createAssistantItem(
|
function createAssistantItem(
|
||||||
created?: number | string,
|
created?: number | string,
|
||||||
patch?: Partial<ChatTimeAssistantItem>,
|
patch?: Omit<Partial<ChatTimeAssistantItem>, 'roundId'> & ChatTimeRoundMeta,
|
||||||
): ChatTimeAssistantItem {
|
): ChatTimeAssistantItem {
|
||||||
return {
|
const item: ChatTimeAssistantItem = {
|
||||||
content: patch?.content || '',
|
content: patch?.content || '',
|
||||||
created: normalizeTimestamp(created),
|
created: normalizeTimestamp(created),
|
||||||
id: patch?.id || uuid(),
|
id: patch?.id || uuid(),
|
||||||
@@ -340,6 +507,8 @@ function createAssistantItem(
|
|||||||
senderName: patch?.senderName,
|
senderName: patch?.senderName,
|
||||||
typing: patch?.typing,
|
typing: patch?.typing,
|
||||||
};
|
};
|
||||||
|
applyRoundMeta(item, patch);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAssistantItemFromStructuredMessage(
|
function createAssistantItemFromStructuredMessage(
|
||||||
@@ -360,8 +529,15 @@ function createAssistantItemFromStructuredMessage(
|
|||||||
? undefined
|
? undefined
|
||||||
: `${String(record.id)}-assistant-${assistantIndex}`,
|
: `${String(record.id)}-assistant-${assistantIndex}`,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
messageKind: record.messageKind,
|
||||||
|
roundId: normalizeRoundId(record.roundId),
|
||||||
|
roundNo: record.roundNo,
|
||||||
|
selectedVariantIndex: record.selectedVariantIndex,
|
||||||
senderName: record.senderName,
|
senderName: record.senderName,
|
||||||
|
switchable: record.switchable,
|
||||||
typing: false,
|
typing: false,
|
||||||
|
variantCount: record.variantCount,
|
||||||
|
variantIndex: record.variantIndex,
|
||||||
});
|
});
|
||||||
if (reasoning) {
|
if (reasoning) {
|
||||||
assistant.segments.push({
|
assistant.segments.push({
|
||||||
@@ -381,6 +557,7 @@ function createAssistantItemFromStructuredMessage(
|
|||||||
function createToolItemFromChain(
|
function createToolItemFromChain(
|
||||||
rawChain: Record<string, any>,
|
rawChain: Record<string, any>,
|
||||||
created?: number | string,
|
created?: number | string,
|
||||||
|
record?: ChatTimeHistoryRecord,
|
||||||
) {
|
) {
|
||||||
const toolCallId = normalizePlainText(rawChain.id);
|
const toolCallId = normalizePlainText(rawChain.id);
|
||||||
const name = normalizePlainText(rawChain.name);
|
const name = normalizePlainText(rawChain.name);
|
||||||
@@ -393,10 +570,17 @@ function createToolItemFromChain(
|
|||||||
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
|
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
|
||||||
created,
|
created,
|
||||||
id: toolCallId || uuid(),
|
id: toolCallId || uuid(),
|
||||||
|
messageKind: record?.messageKind,
|
||||||
name,
|
name,
|
||||||
|
roundId: record?.roundId,
|
||||||
|
roundNo: record?.roundNo,
|
||||||
result: status === 'TOOL_RESULT' ? argumentsValue : undefined,
|
result: status === 'TOOL_RESULT' ? argumentsValue : undefined,
|
||||||
|
selectedVariantIndex: record?.selectedVariantIndex,
|
||||||
status,
|
status,
|
||||||
|
switchable: record?.switchable,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
|
variantCount: record?.variantCount,
|
||||||
|
variantIndex: record?.variantIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +588,7 @@ function createToolItemFromStructuredMessage(
|
|||||||
rawMessage: Record<string, any>,
|
rawMessage: Record<string, any>,
|
||||||
toolMetaMap: Map<string, ChatTimeToolMeta>,
|
toolMetaMap: Map<string, ChatTimeToolMeta>,
|
||||||
created?: number | string,
|
created?: number | string,
|
||||||
|
record?: ChatTimeHistoryRecord,
|
||||||
) {
|
) {
|
||||||
const toolCallId = normalizePlainText(
|
const toolCallId = normalizePlainText(
|
||||||
rawMessage.toolCallId ?? rawMessage.tool_call_id,
|
rawMessage.toolCallId ?? rawMessage.tool_call_id,
|
||||||
@@ -414,10 +599,17 @@ function createToolItemFromStructuredMessage(
|
|||||||
arguments: toolMeta?.arguments,
|
arguments: toolMeta?.arguments,
|
||||||
created,
|
created,
|
||||||
id: toolCallId || uuid(),
|
id: toolCallId || uuid(),
|
||||||
|
messageKind: record?.messageKind,
|
||||||
name: toolMeta?.name,
|
name: toolMeta?.name,
|
||||||
|
roundId: record?.roundId,
|
||||||
|
roundNo: record?.roundNo,
|
||||||
result,
|
result,
|
||||||
|
selectedVariantIndex: record?.selectedVariantIndex,
|
||||||
status: 'TOOL_RESULT',
|
status: 'TOOL_RESULT',
|
||||||
|
switchable: record?.switchable,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
|
variantCount: record?.variantCount,
|
||||||
|
variantIndex: record?.variantIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,12 +621,19 @@ function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
|
|||||||
return createToolItem({
|
return createToolItem({
|
||||||
created: record.created,
|
created: record.created,
|
||||||
id: record.id == null ? toolCallId || uuid() : String(record.id),
|
id: record.id == null ? toolCallId || uuid() : String(record.id),
|
||||||
|
messageKind: record.messageKind,
|
||||||
name: normalizePlainText(payload.name),
|
name: normalizePlainText(payload.name),
|
||||||
|
roundId: record.roundId,
|
||||||
|
roundNo: record.roundNo,
|
||||||
result: normalizePayloadValue(
|
result: normalizePayloadValue(
|
||||||
payload.content ?? payload.result ?? record.contentText ?? record.content,
|
payload.content ?? payload.result ?? record.contentText ?? record.content,
|
||||||
),
|
),
|
||||||
|
selectedVariantIndex: record.selectedVariantIndex,
|
||||||
status: 'TOOL_RESULT',
|
status: 'TOOL_RESULT',
|
||||||
|
switchable: record.switchable,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
|
variantCount: record.variantCount,
|
||||||
|
variantIndex: record.variantIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,12 +641,19 @@ function createToolItem(payload: {
|
|||||||
arguments?: string;
|
arguments?: string;
|
||||||
created?: number | string;
|
created?: number | string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
messageKind?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
roundId?: number | string;
|
||||||
|
roundNo?: number;
|
||||||
result?: string;
|
result?: string;
|
||||||
|
selectedVariantIndex?: number;
|
||||||
status: ChatTimeToolStatus;
|
status: ChatTimeToolStatus;
|
||||||
|
switchable?: boolean;
|
||||||
toolCallId?: string;
|
toolCallId?: string;
|
||||||
|
variantCount?: number;
|
||||||
|
variantIndex?: number;
|
||||||
}): ChatTimeToolItem {
|
}): ChatTimeToolItem {
|
||||||
return {
|
const item: ChatTimeToolItem = {
|
||||||
arguments: payload.arguments,
|
arguments: payload.arguments,
|
||||||
content: payload.result || '',
|
content: payload.result || '',
|
||||||
created: normalizeTimestamp(payload.created),
|
created: normalizeTimestamp(payload.created),
|
||||||
@@ -459,10 +665,12 @@ function createToolItem(payload: {
|
|||||||
status: payload.status,
|
status: payload.status,
|
||||||
toolCallId: payload.toolCallId || payload.id || uuid(),
|
toolCallId: payload.toolCallId || payload.id || uuid(),
|
||||||
};
|
};
|
||||||
|
applyRoundMeta(item, payload);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
|
function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
|
||||||
return {
|
const item: ChatTimeTimelineItem = {
|
||||||
content: normalizePlainText(record.contentText || record.content),
|
content: normalizePlainText(record.contentText || record.content),
|
||||||
created: normalizeTimestamp(record.created),
|
created: normalizeTimestamp(record.created),
|
||||||
id: record.id == null ? uuid() : String(record.id),
|
id: record.id == null ? uuid() : String(record.id),
|
||||||
@@ -472,6 +680,8 @@ function createUserItem(record: ChatTimeHistoryRecord): ChatTimeTimelineItem {
|
|||||||
senderName: record.senderName,
|
senderName: record.senderName,
|
||||||
typing: record.typing,
|
typing: record.typing,
|
||||||
};
|
};
|
||||||
|
applyRoundMeta(item, record);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendAssistantText(item: ChatTimeAssistantItem, content: string) {
|
function appendAssistantText(item: ChatTimeAssistantItem, content: string) {
|
||||||
@@ -507,14 +717,17 @@ function collectToolMeta(
|
|||||||
function ensureAssistantTail(
|
function ensureAssistantTail(
|
||||||
items: ChatTimeTimelineItem[],
|
items: ChatTimeTimelineItem[],
|
||||||
created?: number | string,
|
created?: number | string,
|
||||||
|
meta?: ChatTimeRoundMeta,
|
||||||
) {
|
) {
|
||||||
const last = items[items.length - 1];
|
const last = items[items.length - 1];
|
||||||
if (isAssistantItem(last)) {
|
if (isAssistantItem(last) && isSameRoundVariant(last, meta)) {
|
||||||
|
applyRoundMeta(last, meta);
|
||||||
return last;
|
return last;
|
||||||
}
|
}
|
||||||
const assistant = createAssistantItem(created, {
|
const assistant = createAssistantItem(created, {
|
||||||
loading: true,
|
loading: true,
|
||||||
typing: true,
|
typing: true,
|
||||||
|
...normalizeRoundMeta(meta),
|
||||||
});
|
});
|
||||||
items.push(assistant);
|
items.push(assistant);
|
||||||
return assistant;
|
return assistant;
|
||||||
@@ -525,38 +738,67 @@ function ensureToolItem(
|
|||||||
toolCallId?: string,
|
toolCallId?: string,
|
||||||
created?: number | string,
|
created?: number | string,
|
||||||
name?: string,
|
name?: string,
|
||||||
|
meta?: ChatTimeRoundMeta,
|
||||||
) {
|
) {
|
||||||
const normalizedToolCallId = normalizePlainText(toolCallId);
|
const normalizedToolCallId = normalizePlainText(toolCallId);
|
||||||
const found = findToolItem(items, normalizedToolCallId);
|
const found = findToolItem(items, normalizedToolCallId, meta);
|
||||||
if (found) {
|
if (found) {
|
||||||
if (name) {
|
if (name) {
|
||||||
found.name = name;
|
found.name = name;
|
||||||
}
|
}
|
||||||
|
applyRoundMeta(found, meta);
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
const toolItem = createToolItem({
|
const toolItem = createToolItem({
|
||||||
created,
|
created,
|
||||||
id: normalizedToolCallId || uuid(),
|
id: normalizedToolCallId || uuid(),
|
||||||
|
messageKind: meta?.messageKind,
|
||||||
name,
|
name,
|
||||||
|
roundId: meta?.roundId,
|
||||||
|
roundNo: meta?.roundNo,
|
||||||
|
selectedVariantIndex: meta?.selectedVariantIndex,
|
||||||
status: 'TOOL_CALL',
|
status: 'TOOL_CALL',
|
||||||
|
switchable: meta?.switchable,
|
||||||
toolCallId: normalizedToolCallId,
|
toolCallId: normalizedToolCallId,
|
||||||
|
variantCount: meta?.variantCount,
|
||||||
|
variantIndex: meta?.variantIndex,
|
||||||
});
|
});
|
||||||
items.push(toolItem);
|
items.push(toolItem);
|
||||||
return toolItem;
|
return toolItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findToolItem(items: ChatTimeTimelineItem[], toolCallId?: string) {
|
function findToolItem(
|
||||||
|
items: ChatTimeTimelineItem[],
|
||||||
|
toolCallId?: string,
|
||||||
|
meta?: ChatTimeRoundMeta,
|
||||||
|
) {
|
||||||
|
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
||||||
|
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
||||||
if (toolCallId) {
|
if (toolCallId) {
|
||||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (isToolItem(item) && item.toolCallId === toolCallId) {
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isToolItem(item) &&
|
||||||
|
item.toolCallId === toolCallId &&
|
||||||
|
matchesRoundVariant(item, normalizedRoundId, normalizedVariantIndex)
|
||||||
|
) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (isToolItem(item) && item.status === 'TOOL_CALL') {
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isToolItem(item) &&
|
||||||
|
item.status === 'TOOL_CALL' &&
|
||||||
|
matchesRoundVariant(item, normalizedRoundId, normalizedVariantIndex)
|
||||||
|
) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,6 +826,182 @@ function isToolItem(item?: ChatTimeTimelineItem): item is ChatTimeToolItem {
|
|||||||
return item?.role === 'tool';
|
return item?.role === 'tool';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findLastAssistant(items: ChatTimeTimelineItem[]) {
|
||||||
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (isAssistantItem(item)) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareRoundVariant(
|
||||||
|
items: ChatTimeTimelineItem[],
|
||||||
|
meta?: ChatTimeRoundMeta,
|
||||||
|
) {
|
||||||
|
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
||||||
|
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
||||||
|
if (!normalizedRoundId || !normalizedVariantIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const assistant = items.find(
|
||||||
|
(item) => item.role === 'assistant' && item.roundId === normalizedRoundId,
|
||||||
|
);
|
||||||
|
if (assistant?.variantIndex === normalizedVariantIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.roundId === normalizedRoundId && item.role !== 'user') {
|
||||||
|
items.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoundInsertIndex(
|
||||||
|
items: ChatTimeTimelineItem[],
|
||||||
|
roundId: string,
|
||||||
|
) {
|
||||||
|
const firstRoundItemIndex = items.findIndex(
|
||||||
|
(item) => item.roundId === roundId && item.role !== 'user',
|
||||||
|
);
|
||||||
|
if (firstRoundItemIndex >= 0) {
|
||||||
|
return firstRoundItemIndex;
|
||||||
|
}
|
||||||
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.roundId === roundId && item.role === 'user') {
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoundReplaceRange(
|
||||||
|
items: ChatTimeTimelineItem[],
|
||||||
|
roundId: string,
|
||||||
|
) {
|
||||||
|
let start = -1;
|
||||||
|
let end = -1;
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (item?.roundId === roundId && item.role !== 'user') {
|
||||||
|
if (start < 0) {
|
||||||
|
start = index;
|
||||||
|
}
|
||||||
|
end = index;
|
||||||
|
} else if (start >= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (start < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
deleteCount: end - start + 1,
|
||||||
|
start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRoundVariant(
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
roundId?: string,
|
||||||
|
variantIndex?: number,
|
||||||
|
) {
|
||||||
|
if (roundId && item.roundId !== roundId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (variantIndex && item.variantIndex && item.variantIndex !== variantIndex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameRoundVariant(
|
||||||
|
item: ChatTimeTimelineItem,
|
||||||
|
meta?: ChatTimeRoundMeta,
|
||||||
|
) {
|
||||||
|
const normalizedRoundId = normalizeRoundId(meta?.roundId);
|
||||||
|
const normalizedVariantIndex = normalizePositiveInteger(meta?.variantIndex);
|
||||||
|
if (!normalizedRoundId || !normalizedVariantIndex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
item.roundId === normalizedRoundId &&
|
||||||
|
normalizePositiveInteger(item.variantIndex) === normalizedVariantIndex
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoundMeta(
|
||||||
|
target: Partial<ChatTimeTimelineItemBase>,
|
||||||
|
source?: ChatTimeRoundMeta | null,
|
||||||
|
) {
|
||||||
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const roundId = normalizeRoundId(source.roundId);
|
||||||
|
if (roundId) {
|
||||||
|
target.roundId = roundId;
|
||||||
|
}
|
||||||
|
const roundNo = normalizePositiveInteger(source.roundNo);
|
||||||
|
if (roundNo) {
|
||||||
|
target.roundNo = roundNo;
|
||||||
|
}
|
||||||
|
const variantIndex = normalizePositiveInteger(source.variantIndex);
|
||||||
|
if (variantIndex) {
|
||||||
|
target.variantIndex = variantIndex;
|
||||||
|
}
|
||||||
|
const variantCount = normalizePositiveInteger(source.variantCount);
|
||||||
|
if (variantCount) {
|
||||||
|
target.variantCount = variantCount;
|
||||||
|
}
|
||||||
|
const selectedVariantIndex = normalizePositiveInteger(
|
||||||
|
source.selectedVariantIndex,
|
||||||
|
);
|
||||||
|
if (selectedVariantIndex) {
|
||||||
|
target.selectedVariantIndex = selectedVariantIndex;
|
||||||
|
}
|
||||||
|
if (typeof source.switchable === 'boolean') {
|
||||||
|
target.switchable = source.switchable;
|
||||||
|
}
|
||||||
|
const messageKind = normalizePlainText(source.messageKind).trim();
|
||||||
|
if (messageKind) {
|
||||||
|
target.messageKind = messageKind;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoundMeta(meta?: ChatTimeRoundMeta): ChatTimeRoundMeta {
|
||||||
|
return {
|
||||||
|
messageKind: meta?.messageKind,
|
||||||
|
roundId: normalizeRoundId(meta?.roundId),
|
||||||
|
roundNo: meta?.roundNo,
|
||||||
|
selectedVariantIndex: meta?.selectedVariantIndex,
|
||||||
|
switchable: meta?.switchable,
|
||||||
|
variantCount: meta?.variantCount,
|
||||||
|
variantIndex: meta?.variantIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoundId(value: any) {
|
||||||
|
const normalized = normalizePlainText(value).trim();
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveInteger(value: any) {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(String(value), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAssistantText(value: any) {
|
function normalizeAssistantText(value: any) {
|
||||||
return normalizePlainText(value)
|
return normalizePlainText(value)
|
||||||
.replace(/^Final Answer:\s*/i, '')
|
.replace(/^Final Answer:\s*/i, '')
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
type VariantRecord = {
|
||||||
|
selectedVariantIndex?: number | string;
|
||||||
|
variantIndex?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ChatVariantSwitchControllerOptions<TRecord extends VariantRecord, TItem> {
|
||||||
|
mapRecords: (records: TRecord[]) => TItem[];
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
onStateChange?: () => void;
|
||||||
|
replaceRound: (items: TItem[], roundId: string, nextItems: TItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnsureVariantsOptions<TRecord extends VariantRecord> {
|
||||||
|
fetchVariants: () => Promise<TRecord[]>;
|
||||||
|
roundId: number | string;
|
||||||
|
sessionId: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SwitchVariantOptions<TRecord extends VariantRecord, TItem>
|
||||||
|
extends EnsureVariantsOptions<TRecord> {
|
||||||
|
items: TItem[];
|
||||||
|
onLocalSwitch?: (record: TRecord) => void;
|
||||||
|
persistVariant: () => Promise<TRecord | void>;
|
||||||
|
targetVariantIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantCacheKey(sessionId: number | string, roundId: number | string) {
|
||||||
|
return `${String(sessionId)}:${String(roundId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVariantIndex(value: unknown) {
|
||||||
|
const parsed = Number.parseInt(String(value || ''), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markVariantSelected<TRecord extends VariantRecord>(
|
||||||
|
record: TRecord,
|
||||||
|
selectedVariantIndex: number,
|
||||||
|
): TRecord {
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
selectedVariantIndex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCachedSelection<TRecord extends VariantRecord>(
|
||||||
|
records: TRecord[],
|
||||||
|
selectedVariantIndex: number,
|
||||||
|
selectedRecord?: TRecord,
|
||||||
|
) {
|
||||||
|
return records.map((record) => {
|
||||||
|
const isSelected =
|
||||||
|
selectedRecord &&
|
||||||
|
normalizeVariantIndex(record.variantIndex) ===
|
||||||
|
normalizeVariantIndex(selectedRecord.variantIndex);
|
||||||
|
return markVariantSelected(
|
||||||
|
isSelected ? { ...record, ...selectedRecord } : record,
|
||||||
|
selectedVariantIndex,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatVariantSwitchController<
|
||||||
|
TRecord extends VariantRecord,
|
||||||
|
TItem,
|
||||||
|
>(options: ChatVariantSwitchControllerOptions<TRecord, TItem>) {
|
||||||
|
const cache = new Map<string, TRecord[]>();
|
||||||
|
const fetchTasks = new Map<string, Promise<TRecord[]>>();
|
||||||
|
const switchingKeys = new Set<string>();
|
||||||
|
|
||||||
|
function notifyStateChange() {
|
||||||
|
options.onStateChange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVariants(params: EnsureVariantsOptions<TRecord>) {
|
||||||
|
const key = variantCacheKey(params.sessionId, params.roundId);
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const existingTask = fetchTasks.get(key);
|
||||||
|
if (existingTask) {
|
||||||
|
return existingTask;
|
||||||
|
}
|
||||||
|
const task = params
|
||||||
|
.fetchVariants()
|
||||||
|
.then((records) => {
|
||||||
|
cache.set(key, records);
|
||||||
|
return records;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
fetchTasks.delete(key);
|
||||||
|
});
|
||||||
|
fetchTasks.set(key, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefetchVariants(params: EnsureVariantsOptions<TRecord>) {
|
||||||
|
void ensureVariants(params).catch(() => {
|
||||||
|
// 预取失败不打断当前页面,用户点击时仍会再次拉取。
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCachedVariant(
|
||||||
|
sessionId: number | string,
|
||||||
|
roundId: number | string,
|
||||||
|
variantIndex: number,
|
||||||
|
) {
|
||||||
|
const records = cache.get(variantCacheKey(sessionId, roundId));
|
||||||
|
return Boolean(
|
||||||
|
records?.some(
|
||||||
|
(record) => normalizeVariantIndex(record.variantIndex) === variantIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSwitching(sessionId?: number | string, roundId?: number | string) {
|
||||||
|
if (!sessionId || !roundId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return switchingKeys.has(variantCacheKey(sessionId, roundId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchVariant(params: SwitchVariantOptions<TRecord, TItem>) {
|
||||||
|
const key = variantCacheKey(params.sessionId, params.roundId);
|
||||||
|
if (switchingKeys.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
switchingKeys.add(key);
|
||||||
|
notifyStateChange();
|
||||||
|
const snapshot = [...params.items];
|
||||||
|
try {
|
||||||
|
const records = await ensureVariants(params);
|
||||||
|
const target = records.find(
|
||||||
|
(record) =>
|
||||||
|
normalizeVariantIndex(record.variantIndex) === params.targetVariantIndex,
|
||||||
|
);
|
||||||
|
if (!target) {
|
||||||
|
throw new Error('目标答案版本不存在');
|
||||||
|
}
|
||||||
|
const localTarget = markVariantSelected(target, params.targetVariantIndex);
|
||||||
|
const nextItems = options.mapRecords([localTarget]);
|
||||||
|
if (nextItems.length === 0) {
|
||||||
|
throw new Error('目标答案版本渲染失败');
|
||||||
|
}
|
||||||
|
options.replaceRound(
|
||||||
|
params.items,
|
||||||
|
String(params.roundId),
|
||||||
|
nextItems,
|
||||||
|
);
|
||||||
|
params.onLocalSwitch?.(localTarget);
|
||||||
|
const persistedRecord = await params.persistVariant();
|
||||||
|
const selectedRecord = markVariantSelected(
|
||||||
|
persistedRecord || localTarget,
|
||||||
|
params.targetVariantIndex,
|
||||||
|
);
|
||||||
|
cache.set(
|
||||||
|
key,
|
||||||
|
syncCachedSelection(records, params.targetVariantIndex, selectedRecord),
|
||||||
|
);
|
||||||
|
return selectedRecord;
|
||||||
|
} catch (error) {
|
||||||
|
params.items.splice(0, params.items.length, ...snapshot);
|
||||||
|
options.onError?.(error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
switchingKeys.delete(key);
|
||||||
|
notifyStateChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheVariants(
|
||||||
|
sessionId: number | string,
|
||||||
|
roundId: number | string,
|
||||||
|
records: TRecord[],
|
||||||
|
) {
|
||||||
|
cache.set(variantCacheKey(sessionId, roundId), records);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheVariants,
|
||||||
|
hasCachedVariant,
|
||||||
|
isSwitching,
|
||||||
|
prefetchVariants,
|
||||||
|
switchVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './chat-time';
|
export * from './chat-time';
|
||||||
|
export * from './chat-variant-switch';
|
||||||
export * from './clipboard';
|
export * from './clipboard';
|
||||||
export * from './find-menu-by-path';
|
export * from './find-menu-by-path';
|
||||||
export * from './generate-menus';
|
export * from './generate-menus';
|
||||||
|
|||||||
Reference in New Issue
Block a user