Compare commits
2 Commits
72df00f25b
...
1c205c3720
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c205c3720 | |||
| 11e595b088 |
@@ -78,7 +78,7 @@ public class AgentCategoryController extends BaseCurdController<AgentCategorySer
|
|||||||
for (Serializable id : ids) {
|
for (Serializable id : ids) {
|
||||||
QueryWrapper queryWrapper = QueryWrapper.create().eq(Agent::getCategoryId, id);
|
QueryWrapper queryWrapper = QueryWrapper.create().eq(Agent::getCategoryId, id);
|
||||||
List<Agent> agents = agentMapper.selectListByQuery(queryWrapper);
|
List<Agent> agents = agentMapper.selectListByQuery(queryWrapper);
|
||||||
if (!agents.isEmpty()) {
|
if (agents != null && !agents.isEmpty()) {
|
||||||
throw new BusinessException("请先删除该分类下的所有 Agent");
|
throw new BusinessException("请先删除该分类下的所有 Agent");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 管理端会话控制器。
|
* Agent 管理端会话控制器。
|
||||||
@@ -104,6 +105,19 @@ public class AgentSessionController {
|
|||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存 Agent 会话临时知识库。
|
||||||
|
*
|
||||||
|
* @param sessionId 会话 ID
|
||||||
|
* @param knowledgeIds 临时知识库 ID
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/{sessionId}/extraKnowledges")
|
||||||
|
public Result<ChatWorkspaceSessionDetailView> saveExtraKnowledges(@PathVariable BigInteger sessionId,
|
||||||
|
@JsonBody(value = "knowledgeIds") List<BigInteger> knowledgeIds) {
|
||||||
|
return Result.ok(agentSessionService.saveCurrentUserExtraKnowledges(currentAccount(), sessionId, knowledgeIds));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除 Agent 会话。
|
* 删除 Agent 会话。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import tech.easyflow.ai.entity.Model;
|
|||||||
import tech.easyflow.ai.service.DocumentChunkService;
|
import tech.easyflow.ai.service.DocumentChunkService;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.common.annotation.UsePermission;
|
import tech.easyflow.common.annotation.UsePermission;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
@@ -93,6 +94,7 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
return Result.fail(2, "知识库没有配置向量库");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
// 设置向量模型
|
// 设置向量模型
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
@@ -109,6 +111,9 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
|||||||
document.setMetadataMap(metadata);
|
document.setMetadataMap(metadata);
|
||||||
StoreResult result = documentStore.update(document, options); // 更新已有记录
|
StoreResult result = documentStore.update(document, options); // 更新已有记录
|
||||||
return Result.ok(result);
|
return Result.ok(result);
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
@@ -135,6 +140,7 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
return Result.fail(3, "知识库没有配置向量库");
|
return Result.fail(3, "知识库没有配置向量库");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
// 设置向量模型
|
// 设置向量模型
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
@@ -149,5 +155,8 @@ public class DocumentChunkController extends BaseCurdController<DocumentChunkSer
|
|||||||
documentChunkService.removeChunk(knowledge, chunkId);
|
documentChunkService.removeChunk(knowledge, chunkId);
|
||||||
|
|
||||||
return super.remove(chunkId);
|
return super.remove(chunkId);
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import tech.easyflow.ai.service.KnowledgeEmbeddingService;
|
|||||||
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
||||||
import tech.easyflow.ai.service.KnowledgeShareService;
|
import tech.easyflow.ai.service.KnowledgeShareService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.ai.vo.FaqImportResultVo;
|
import tech.easyflow.ai.vo.FaqImportResultVo;
|
||||||
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
|
import tech.easyflow.ai.vo.KnowledgeShareAuthContext;
|
||||||
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
|
import tech.easyflow.ai.vo.KnowledgeShareViewDetail;
|
||||||
@@ -520,6 +521,7 @@ public class ShareKnowledgeController {
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
return Result.fail(2, "知识库没有配置向量库");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
return Result.fail(3, "知识库没有配置向量模型");
|
||||||
@@ -533,6 +535,9 @@ public class ShareKnowledgeController {
|
|||||||
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
audit(context, "更新分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
|
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", documentChunk.getId()));
|
||||||
return Result.ok(result);
|
return Result.ok(result);
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
@@ -559,6 +564,7 @@ public class ShareKnowledgeController {
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
return Result.fail(2, "知识库没有配置向量库");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(context.getKnowledge().getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
return Result.fail(3, "知识库没有配置向量模型");
|
||||||
@@ -570,6 +576,9 @@ public class ShareKnowledgeController {
|
|||||||
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
audit(context, "删除分享文档 Chunk", "KNOWLEDGE_SHARE_URL_WRITE", true,
|
||||||
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
|
auditDetail("knowledgeId", context.getKnowledge().getId(), "chunkId", chunkId));
|
||||||
return Result.ok(true);
|
return Result.ok(true);
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ import tech.easyflow.agent.service.AgentService;
|
|||||||
import tech.easyflow.ai.entity.DocumentCollection;
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatHistoryPage;
|
import tech.easyflow.chatlog.domain.command.ChatSessionUpsertCommand;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
|
import tech.easyflow.chatlog.domain.dto.*;
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionPage;
|
|
||||||
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
|
|
||||||
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
import tech.easyflow.chatlog.domain.query.ChatPageQuery;
|
||||||
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.chatlog.support.ChatJsonSupport;
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.system.enums.CategoryResourceType;
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
@@ -40,6 +39,7 @@ public class AgentSessionService {
|
|||||||
private final DocumentCollectionService documentCollectionService;
|
private final DocumentCollectionService documentCollectionService;
|
||||||
private final ResourceAccessService resourceAccessService;
|
private final ResourceAccessService resourceAccessService;
|
||||||
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
|
private final AgentRuntimeStateCleanupService agentRuntimeStateCleanupService;
|
||||||
|
private final ChatJsonSupport chatJsonSupport;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 Agent 管理端会话服务。
|
* 创建 Agent 管理端会话服务。
|
||||||
@@ -50,19 +50,22 @@ public class AgentSessionService {
|
|||||||
* @param documentCollectionService 知识库服务
|
* @param documentCollectionService 知识库服务
|
||||||
* @param resourceAccessService 资源访问服务
|
* @param resourceAccessService 资源访问服务
|
||||||
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
|
* @param agentRuntimeStateCleanupService Agent 运行态清理服务
|
||||||
|
* @param chatJsonSupport 聊天 JSON 工具
|
||||||
*/
|
*/
|
||||||
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
|
public AgentSessionService(ChatSessionQueryService chatSessionQueryService,
|
||||||
ChatSessionCommandService chatSessionCommandService,
|
ChatSessionCommandService chatSessionCommandService,
|
||||||
AgentService agentService,
|
AgentService agentService,
|
||||||
DocumentCollectionService documentCollectionService,
|
DocumentCollectionService documentCollectionService,
|
||||||
ResourceAccessService resourceAccessService,
|
ResourceAccessService resourceAccessService,
|
||||||
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService) {
|
AgentRuntimeStateCleanupService agentRuntimeStateCleanupService,
|
||||||
|
ChatJsonSupport chatJsonSupport) {
|
||||||
this.chatSessionQueryService = chatSessionQueryService;
|
this.chatSessionQueryService = chatSessionQueryService;
|
||||||
this.chatSessionCommandService = chatSessionCommandService;
|
this.chatSessionCommandService = chatSessionCommandService;
|
||||||
this.agentService = agentService;
|
this.agentService = agentService;
|
||||||
this.documentCollectionService = documentCollectionService;
|
this.documentCollectionService = documentCollectionService;
|
||||||
this.resourceAccessService = resourceAccessService;
|
this.resourceAccessService = resourceAccessService;
|
||||||
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
|
this.agentRuntimeStateCleanupService = agentRuntimeStateCleanupService;
|
||||||
|
this.chatJsonSupport = chatJsonSupport;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +106,12 @@ public class AgentSessionService {
|
|||||||
Agent displayAgent = availability == null ? null : availability.displayAgent();
|
Agent displayAgent = availability == null ? null : availability.displayAgent();
|
||||||
detail.setAssistant(toAssistantView(displayAgent, summary));
|
detail.setAssistant(toAssistantView(displayAgent, summary));
|
||||||
detail.setBoundKnowledges(resolveBoundKnowledges(displayAgent));
|
detail.setBoundKnowledges(resolveBoundKnowledges(displayAgent));
|
||||||
|
ExtraKnowledgeResolution extraKnowledgeResolution = resolveExtraKnowledges(summary);
|
||||||
|
detail.setExtraKnowledges(extraKnowledgeResolution.validKnowledges());
|
||||||
|
detail.setRemovedExtraKnowledgeNames(extraKnowledgeResolution.removedNames());
|
||||||
|
if (extraKnowledgeResolution.shouldSync()) {
|
||||||
|
syncSessionExtraKnowledges(summary, extraKnowledgeResolution.validKnowledgeIds(), account.getId());
|
||||||
|
}
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +159,26 @@ public class AgentSessionService {
|
|||||||
chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId());
|
chatSessionCommandService.renameSession(sessionId, account.getId(), title.trim(), account.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存当前用户 Agent 会话的临时知识库。
|
||||||
|
*
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @param sessionId 会话 ID
|
||||||
|
* @param knowledgeIds 临时知识库 ID
|
||||||
|
* @return 更新后的会话详情
|
||||||
|
*/
|
||||||
|
public ChatWorkspaceSessionDetailView saveCurrentUserExtraKnowledges(LoginAccount account,
|
||||||
|
BigInteger sessionId,
|
||||||
|
List<BigInteger> knowledgeIds) {
|
||||||
|
ChatSessionSummary summary = requireUserAgentSession(account, sessionId);
|
||||||
|
ExtraKnowledgeResolution resolution = resolveVisibleKnowledgeViews(normalizeExtraKnowledgeIds(knowledgeIds));
|
||||||
|
if (!resolution.removedNames().isEmpty()) {
|
||||||
|
throw new BusinessException("所选知识库已失效或无权限使用");
|
||||||
|
}
|
||||||
|
syncSessionExtraKnowledges(summary, resolution.validKnowledgeIds(), account.getId());
|
||||||
|
return getCurrentUserSession(account, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除当前用户的 Agent 会话。
|
* 删除当前用户的 Agent 会话。
|
||||||
*
|
*
|
||||||
@@ -295,8 +324,97 @@ public class AgentSessionService {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ExtraKnowledgeResolution resolveExtraKnowledges(ChatSessionSummary summary) {
|
||||||
|
ChatSessionExtPayload payload = chatJsonSupport.fromJson(summary.getExtJson(), ChatSessionExtPayload.class);
|
||||||
|
List<BigInteger> extraKnowledgeIds = payload == null ? List.of() : payload.getExtraKnowledgeIds();
|
||||||
|
return resolveVisibleKnowledgeViews(extraKnowledgeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExtraKnowledgeResolution resolveVisibleKnowledgeViews(List<BigInteger> knowledgeIds) {
|
||||||
|
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
|
||||||
|
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
|
||||||
|
}
|
||||||
|
List<BigInteger> normalizedIds = normalizeExtraKnowledgeIds(knowledgeIds);
|
||||||
|
if (normalizedIds.isEmpty()) {
|
||||||
|
return new ExtraKnowledgeResolution(List.of(), List.of(), List.of(), false);
|
||||||
|
}
|
||||||
|
List<DocumentCollection> collections = documentCollectionService.listByIds(normalizedIds);
|
||||||
|
Map<BigInteger, DocumentCollection> collectionMap = new LinkedHashMap<>();
|
||||||
|
for (DocumentCollection collection : collections) {
|
||||||
|
collectionMap.put(collection.getId(), collection);
|
||||||
|
}
|
||||||
|
List<ChatWorkspaceKnowledgeView> validKnowledges = new ArrayList<>();
|
||||||
|
List<BigInteger> validKnowledgeIds = new ArrayList<>();
|
||||||
|
List<String> removedNames = new ArrayList<>();
|
||||||
|
boolean changed = false;
|
||||||
|
for (BigInteger knowledgeId : normalizedIds) {
|
||||||
|
DocumentCollection current = collectionMap.get(knowledgeId);
|
||||||
|
if (current == null) {
|
||||||
|
removedNames.add("知识库#" + knowledgeId);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (PublishStatus.from(current.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||||
|
removedNames.add(current.getTitle());
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!resourceAccessService.canAccess(CategoryResourceType.KNOWLEDGE, current, ResourceAction.USE)) {
|
||||||
|
removedNames.add(current.getTitle());
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
validKnowledges.add(toKnowledgeView(documentCollectionService.toPublishedView(current)));
|
||||||
|
validKnowledgeIds.add(current.getId());
|
||||||
|
}
|
||||||
|
if (!Objects.equals(normalizedIds, validKnowledgeIds)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return new ExtraKnowledgeResolution(validKnowledges, validKnowledgeIds, removedNames, changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<BigInteger> normalizeExtraKnowledgeIds(List<BigInteger> knowledgeIds) {
|
||||||
|
if (knowledgeIds == null || knowledgeIds.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<BigInteger> normalizedIds = new ArrayList<>();
|
||||||
|
for (BigInteger knowledgeId : knowledgeIds) {
|
||||||
|
if (knowledgeId != null && !normalizedIds.contains(knowledgeId)) {
|
||||||
|
normalizedIds.add(knowledgeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normalizedIds.size() > 3) {
|
||||||
|
throw new BusinessException("临时知识库最多选择 3 个");
|
||||||
|
}
|
||||||
|
return normalizedIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncSessionExtraKnowledges(ChatSessionSummary summary, List<BigInteger> validKnowledgeIds, BigInteger operatorId) {
|
||||||
|
ChatSessionExtPayload payload = new ChatSessionExtPayload();
|
||||||
|
payload.setExtraKnowledgeIds(validKnowledgeIds);
|
||||||
|
ChatSessionUpsertCommand command = new ChatSessionUpsertCommand();
|
||||||
|
command.setSessionId(summary.getId());
|
||||||
|
command.setTenantId(summary.getTenantId());
|
||||||
|
command.setDeptId(summary.getDeptId());
|
||||||
|
command.setUserId(summary.getUserId());
|
||||||
|
command.setUserAccount(summary.getUserAccount());
|
||||||
|
command.setAssistantId(summary.getAssistantId());
|
||||||
|
command.setAssistantCode(summary.getAssistantCode());
|
||||||
|
command.setAssistantName(summary.getAssistantName());
|
||||||
|
command.setTitle(summary.getTitle());
|
||||||
|
command.setExtJson(chatJsonSupport.toJson(payload));
|
||||||
|
command.setOperatorId(operatorId);
|
||||||
|
chatSessionCommandService.createOrTouchSession(command);
|
||||||
|
}
|
||||||
|
|
||||||
private record AgentAvailability(boolean continuable,
|
private record AgentAvailability(boolean continuable,
|
||||||
ChatWorkspaceReadOnlyReason reason,
|
ChatWorkspaceReadOnlyReason reason,
|
||||||
Agent displayAgent) {
|
Agent displayAgent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record ExtraKnowledgeResolution(List<ChatWorkspaceKnowledgeView> validKnowledges,
|
||||||
|
List<BigInteger> validKnowledgeIds,
|
||||||
|
List<String> removedNames,
|
||||||
|
boolean shouldSync) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import tech.easyflow.ai.service.KnowledgeShareAuditService;
|
|||||||
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
import tech.easyflow.ai.service.KnowledgeSharePermissionService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl;
|
import tech.easyflow.ai.service.impl.KnowledgeSharePermissionServiceImpl;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.ai.vo.FaqImportResultVo;
|
import tech.easyflow.ai.vo.FaqImportResultVo;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
@@ -342,6 +343,7 @@ public class PublicKnowledgeShareController {
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
return Result.fail(2, "知识库没有配置向量库");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
return Result.fail(3, "知识库没有配置向量模型");
|
||||||
@@ -354,6 +356,9 @@ public class PublicKnowledgeShareController {
|
|||||||
StoreResult result = documentStore.update(doc, options);
|
StoreResult result = documentStore.update(doc, options);
|
||||||
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
|
audit(apiKey, "API更新文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", documentChunk.getId()));
|
||||||
return Result.ok(result);
|
return Result.ok(result);
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Result.ok(false);
|
return Result.ok(false);
|
||||||
}
|
}
|
||||||
@@ -376,6 +381,7 @@ public class PublicKnowledgeShareController {
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
return Result.fail(2, "知识库没有配置向量库");
|
return Result.fail(2, "知识库没有配置向量库");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
return Result.fail(3, "知识库没有配置向量模型");
|
return Result.fail(3, "知识库没有配置向量模型");
|
||||||
@@ -386,6 +392,9 @@ public class PublicKnowledgeShareController {
|
|||||||
documentChunkService.removeById(chunkId);
|
documentChunkService.removeById(chunkId);
|
||||||
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
|
audit(apiKey, "API删除文档 Chunk", "KNOWLEDGE_API_SHARE_WRITE", request.getRequestURI(), Map.of("knowledgeId", knowledgeId, "chunkId", chunkId));
|
||||||
return Result.ok(true);
|
return Result.ok(true);
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-actuator</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.clickhouse</groupId>
|
<groupId>com.clickhouse</groupId>
|
||||||
<artifactId>clickhouse-jdbc</artifactId>
|
<artifactId>clickhouse-jdbc</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package tech.easyflow.common.analyticaldb.support;
|
||||||
|
|
||||||
|
import org.springframework.boot.actuate.health.Health;
|
||||||
|
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分析数据库健康检查。
|
||||||
|
*/
|
||||||
|
@Component("analyticalDbHealthIndicator")
|
||||||
|
public class AnalyticalDBHealthIndicator implements HealthIndicator {
|
||||||
|
|
||||||
|
private final AnalyticalDBHealthSupport healthSupport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建分析数据库健康检查器。
|
||||||
|
*
|
||||||
|
* @param healthSupport 分析数据库健康检查支持
|
||||||
|
*/
|
||||||
|
public AnalyticalDBHealthIndicator(AnalyticalDBHealthSupport healthSupport) {
|
||||||
|
this.healthSupport = healthSupport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查分析数据库是否可用。
|
||||||
|
*
|
||||||
|
* @return 健康状态
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Health health() {
|
||||||
|
if (!healthSupport.enabled()) {
|
||||||
|
return Health.up().withDetail("enabled", false).build();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
healthSupport.selfCheck();
|
||||||
|
return Health.up().withDetail("enabled", true).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Health.down(e).withDetail("enabled", true).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package tech.easyflow.common.audio.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频模块线程池配置。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.thread-pool.scheduler")
|
||||||
|
public class AudioThreadPoolProperties {
|
||||||
|
|
||||||
|
private int poolSize = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取调度线程池大小。
|
||||||
|
*
|
||||||
|
* @return 调度线程池大小
|
||||||
|
*/
|
||||||
|
public int getPoolSize() {
|
||||||
|
return poolSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置调度线程池大小。
|
||||||
|
*
|
||||||
|
* @param poolSize 调度线程池大小
|
||||||
|
*/
|
||||||
|
public void setPoolSize(int poolSize) {
|
||||||
|
this.poolSize = poolSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,38 @@
|
|||||||
package tech.easyflow.common.audio.socket;
|
package tech.easyflow.common.audio.socket;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.scheduling.TaskScheduler;
|
import org.springframework.scheduling.TaskScheduler;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||||
|
import tech.easyflow.common.audio.config.AudioThreadPoolProperties;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
@EnableConfigurationProperties(AudioThreadPoolProperties.class)
|
||||||
public class SchedulingConfig {
|
public class SchedulingConfig {
|
||||||
|
|
||||||
|
private final AudioThreadPoolProperties properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建音频调度配置。
|
||||||
|
*
|
||||||
|
* @param properties 音频调度线程池配置
|
||||||
|
*/
|
||||||
|
public SchedulingConfig(AudioThreadPoolProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建调度线程池。
|
||||||
|
*
|
||||||
|
* @return 调度线程池
|
||||||
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public TaskScheduler taskScheduler() {
|
public TaskScheduler taskScheduler() {
|
||||||
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
|
||||||
scheduler.setPoolSize(10);
|
scheduler.setPoolSize(properties.getPoolSize());
|
||||||
scheduler.setThreadNamePrefix("scheduled-task-");
|
scheduler.setThreadNamePrefix("scheduled-task-");
|
||||||
scheduler.setDaemon(true);
|
scheduler.setDaemon(true);
|
||||||
scheduler.initialize();
|
scheduler.initialize();
|
||||||
|
|||||||
@@ -22,5 +22,10 @@
|
|||||||
<artifactId>jackson-databind</artifactId>
|
<artifactId>jackson-databind</artifactId>
|
||||||
<version>${jackson.version}</version>
|
<version>${jackson.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-pool2</artifactId>
|
||||||
|
<version>2.11.1</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import org.springframework.context.annotation.Bean;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.redis.connection.RedisPassword;
|
import org.springframework.data.redis.connection.RedisPassword;
|
||||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||||
|
import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import tech.easyflow.common.mq.core.MQConsumerContainer;
|
import tech.easyflow.common.mq.core.MQConsumerContainer;
|
||||||
import tech.easyflow.common.mq.core.MQConsumerHandler;
|
import tech.easyflow.common.mq.core.MQConsumerHandler;
|
||||||
@@ -24,6 +26,10 @@ import tech.easyflow.common.mq.redis.RedisMQProducer;
|
|||||||
import tech.easyflow.common.mq.redis.RedisStreamKeySupport;
|
import tech.easyflow.common.mq.redis.RedisStreamKeySupport;
|
||||||
import tech.easyflow.common.mq.support.MQHealthSupport;
|
import tech.easyflow.common.mq.support.MQHealthSupport;
|
||||||
|
|
||||||
|
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
|
||||||
|
|
||||||
|
import io.lettuce.core.api.StatefulConnection;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@@ -43,11 +49,27 @@ public class MQConfiguration {
|
|||||||
if (redisProperties.getPassword() != null) {
|
if (redisProperties.getPassword() != null) {
|
||||||
configuration.setPassword(RedisPassword.of(redisProperties.getPassword()));
|
configuration.setPassword(RedisPassword.of(redisProperties.getPassword()));
|
||||||
}
|
}
|
||||||
LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(configuration);
|
LettuceClientConfiguration clientConfiguration = createClientConfiguration(redisProperties, mqProperties);
|
||||||
|
LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(configuration, clientConfiguration);
|
||||||
connectionFactory.afterPropertiesSet();
|
connectionFactory.afterPropertiesSet();
|
||||||
return new MQRedisResources(connectionFactory, new StringRedisTemplate(connectionFactory));
|
return new MQRedisResources(connectionFactory, new StringRedisTemplate(connectionFactory));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private LettuceClientConfiguration createClientConfiguration(RedisProperties redisProperties,
|
||||||
|
MQProperties mqProperties) {
|
||||||
|
MQProperties.Redis.Pool pool = mqProperties.getRedis().getPool();
|
||||||
|
GenericObjectPoolConfig<StatefulConnection<?, ?>> poolConfig = new GenericObjectPoolConfig<>();
|
||||||
|
poolConfig.setMaxTotal(pool.getMaxActive());
|
||||||
|
poolConfig.setMaxIdle(pool.getMaxIdle());
|
||||||
|
poolConfig.setMinIdle(pool.getMinIdle());
|
||||||
|
LettucePoolingClientConfiguration.LettucePoolingClientConfigurationBuilder builder =
|
||||||
|
LettucePoolingClientConfiguration.builder().poolConfig(poolConfig);
|
||||||
|
if (redisProperties.getTimeout() != null) {
|
||||||
|
builder.commandTimeout(redisProperties.getTimeout());
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean(name = "mqRedisConnectionFactory", autowireCandidate = false, defaultCandidate = false)
|
@Bean(name = "mqRedisConnectionFactory", autowireCandidate = false, defaultCandidate = false)
|
||||||
@ConditionalOnProperty(prefix = "easyflow.mq", name = "enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(prefix = "easyflow.mq", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
public LettuceConnectionFactory mqRedisConnectionFactory(MQRedisResources mqRedisResources) {
|
public LettuceConnectionFactory mqRedisConnectionFactory(MQRedisResources mqRedisResources) {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public class MQProperties {
|
|||||||
private Duration consumerBlockTimeout = Duration.ofMillis(2000);
|
private Duration consumerBlockTimeout = Duration.ofMillis(2000);
|
||||||
private Duration pendingClaimIdle = Duration.ofMillis(60000);
|
private Duration pendingClaimIdle = Duration.ofMillis(60000);
|
||||||
private int maxRetry = 16;
|
private int maxRetry = 16;
|
||||||
|
private ConsumerExecutor consumerExecutor = new ConsumerExecutor();
|
||||||
|
private Pool pool = new Pool();
|
||||||
|
|
||||||
public int getDatabase() {
|
public int getDatabase() {
|
||||||
return database;
|
return database;
|
||||||
@@ -96,5 +98,98 @@ public class MQProperties {
|
|||||||
public void setMaxRetry(int maxRetry) {
|
public void setMaxRetry(int maxRetry) {
|
||||||
this.maxRetry = maxRetry;
|
this.maxRetry = maxRetry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ConsumerExecutor getConsumerExecutor() {
|
||||||
|
return consumerExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConsumerExecutor(ConsumerExecutor consumerExecutor) {
|
||||||
|
this.consumerExecutor = consumerExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Pool getPool() {
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPool(Pool pool) {
|
||||||
|
this.pool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis MQ 消费线程池配置。
|
||||||
|
*/
|
||||||
|
public static class ConsumerExecutor {
|
||||||
|
|
||||||
|
private int coreSize = 4;
|
||||||
|
private int maxSize = 12;
|
||||||
|
private int queueCapacity = 64;
|
||||||
|
private int keepAliveSeconds = 60;
|
||||||
|
|
||||||
|
public int getCoreSize() {
|
||||||
|
return coreSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoreSize(int coreSize) {
|
||||||
|
this.coreSize = coreSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMaxSize() {
|
||||||
|
return maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxSize(int maxSize) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getQueueCapacity() {
|
||||||
|
return queueCapacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setQueueCapacity(int queueCapacity) {
|
||||||
|
this.queueCapacity = queueCapacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getKeepAliveSeconds() {
|
||||||
|
return keepAliveSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKeepAliveSeconds(int keepAliveSeconds) {
|
||||||
|
this.keepAliveSeconds = keepAliveSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis MQ 连接池配置。
|
||||||
|
*/
|
||||||
|
public static class Pool {
|
||||||
|
|
||||||
|
private int maxActive = 12;
|
||||||
|
private int maxIdle = 8;
|
||||||
|
private int minIdle = 1;
|
||||||
|
|
||||||
|
public int getMaxActive() {
|
||||||
|
return maxActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxActive(int maxActive) {
|
||||||
|
this.maxActive = maxActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMaxIdle() {
|
||||||
|
return maxIdle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxIdle(int maxIdle) {
|
||||||
|
this.maxIdle = maxIdle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMinIdle() {
|
||||||
|
return minIdle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMinIdle(int minIdle) {
|
||||||
|
this.minIdle = minIdle;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifecycle {
|
public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifecycle {
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
private final MQDeadLetterService deadLetterService;
|
private final MQDeadLetterService deadLetterService;
|
||||||
private final RedisStreamKeySupport keySupport;
|
private final RedisStreamKeySupport keySupport;
|
||||||
private final List<MQConsumerHandler> handlers;
|
private final List<MQConsumerHandler> handlers;
|
||||||
private final ExecutorService executorService = Executors.newCachedThreadPool();
|
private final ExecutorService executorService;
|
||||||
|
|
||||||
private volatile boolean running;
|
private volatile boolean running;
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
this.deadLetterService = deadLetterService;
|
this.deadLetterService = deadLetterService;
|
||||||
this.keySupport = keySupport;
|
this.keySupport = keySupport;
|
||||||
this.handlers = handlers;
|
this.handlers = handlers;
|
||||||
|
this.executorService = createExecutor(properties, handlers);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -77,7 +80,12 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
int currentShard = shard;
|
int currentShard = shard;
|
||||||
LOG.info("启动 MQ 消费线程: topic={}, group={}, shard={}, handler={}",
|
LOG.info("启动 MQ 消费线程: topic={}, group={}, shard={}, handler={}",
|
||||||
subscription.getTopic(), subscription.getConsumerGroup(), currentShard, handler.getClass().getSimpleName());
|
subscription.getTopic(), subscription.getConsumerGroup(), currentShard, handler.getClass().getSimpleName());
|
||||||
|
try {
|
||||||
executorService.submit(() -> consumeLoop(handler, subscription, currentShard));
|
executorService.submit(() -> consumeLoop(handler, subscription, currentShard));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
running = false;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +116,42 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
stop();
|
stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ExecutorService createExecutor(MQProperties properties, List<MQConsumerHandler> handlers) {
|
||||||
|
MQProperties.Redis.ConsumerExecutor config = properties.getRedis().getConsumerExecutor();
|
||||||
|
int consumerTaskCount = handlers.stream()
|
||||||
|
.map(MQConsumerHandler::subscription)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.mapToInt(subscription -> Math.max(subscription.getShardCount(), 1))
|
||||||
|
.sum();
|
||||||
|
if (config.getCoreSize() > config.getMaxSize()) {
|
||||||
|
throw new IllegalStateException("Redis MQ 消费线程池配置错误:core-size 不能大于 max-size");
|
||||||
|
}
|
||||||
|
if (consumerTaskCount > config.getMaxSize()) {
|
||||||
|
throw new IllegalStateException("Redis MQ 消费线程池配置错误:max-size="
|
||||||
|
+ config.getMaxSize() + " 小于消费循环数 " + consumerTaskCount
|
||||||
|
+ ",请调大 easyflow.mq.redis.consumer-executor.max-size");
|
||||||
|
}
|
||||||
|
int coreSize = Math.max(config.getCoreSize(), consumerTaskCount);
|
||||||
|
int maxSize = config.getMaxSize();
|
||||||
|
AtomicInteger threadIndex = new AtomicInteger(1);
|
||||||
|
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||||
|
coreSize,
|
||||||
|
maxSize,
|
||||||
|
config.getKeepAliveSeconds(),
|
||||||
|
TimeUnit.SECONDS,
|
||||||
|
new ArrayBlockingQueue<>(config.getQueueCapacity()),
|
||||||
|
task -> {
|
||||||
|
Thread thread = new Thread(task);
|
||||||
|
thread.setName("redis-mq-consumer-" + threadIndex.getAndIncrement());
|
||||||
|
thread.setDaemon(false);
|
||||||
|
return thread;
|
||||||
|
},
|
||||||
|
new ThreadPoolExecutor.AbortPolicy()
|
||||||
|
);
|
||||||
|
executor.allowCoreThreadTimeOut(true);
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
|
||||||
private void consumeLoop(MQConsumerHandler handler, MQSubscription subscription, int shard) {
|
private void consumeLoop(MQConsumerHandler handler, MQSubscription subscription, int shard) {
|
||||||
String streamKey = keySupport.streamKey(subscription.getTopic(), shard);
|
String streamKey = keySupport.streamKey(subscription.getTopic(), shard);
|
||||||
String consumerName = subscription.getConsumerGroup() + "-" + shard;
|
String consumerName = subscription.getConsumerGroup() + "-" + shard;
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package tech.easyflow.agent.config;
|
|||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 模块自动配置。
|
* Agent 模块自动配置。
|
||||||
*/
|
*/
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
@MapperScan("tech.easyflow.agent.mapper")
|
@MapperScan("tech.easyflow.agent.mapper")
|
||||||
|
@ComponentScan("tech.easyflow.agent")
|
||||||
@EnableConfigurationProperties(AgentRuntimeProperties.class)
|
@EnableConfigurationProperties(AgentRuntimeProperties.class)
|
||||||
public class AgentModuleConfig {
|
public class AgentModuleConfig {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 聊天临时能力请求项。
|
||||||
|
*/
|
||||||
|
public class AgentChatCapability implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String type;
|
||||||
|
private List<BigInteger> resourceIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取能力类型。
|
||||||
|
*
|
||||||
|
* @return 能力类型
|
||||||
|
*/
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置能力类型。
|
||||||
|
*
|
||||||
|
* @param type 能力类型
|
||||||
|
*/
|
||||||
|
public void setType(String type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资源 ID 列表。
|
||||||
|
*
|
||||||
|
* @return 资源 ID 列表
|
||||||
|
*/
|
||||||
|
public List<BigInteger> getResourceIds() {
|
||||||
|
return resourceIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置资源 ID 列表。
|
||||||
|
*
|
||||||
|
* @param resourceIds 资源 ID 列表
|
||||||
|
*/
|
||||||
|
public void setResourceIds(List<BigInteger> resourceIds) {
|
||||||
|
this.resourceIds = resourceIds == null ? new ArrayList<>() : new ArrayList<>(resourceIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 聊天临时能力编排服务。
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AgentChatCapabilityService {
|
||||||
|
|
||||||
|
private static final String KNOWLEDGE_CAPABILITY_TYPE = "KNOWLEDGE";
|
||||||
|
private static final String DEFAULT_RETRIEVAL_MODE = "HYBRID";
|
||||||
|
private static final int MAX_EXTRA_KNOWLEDGE_COUNT = 3;
|
||||||
|
|
||||||
|
private final DocumentCollectionService documentCollectionService;
|
||||||
|
private final ResourceAccessService resourceAccessService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Agent 聊天临时能力编排服务。
|
||||||
|
*
|
||||||
|
* @param documentCollectionService 知识库服务
|
||||||
|
* @param resourceAccessService 资源访问服务
|
||||||
|
* @param objectMapper 对象复制工具
|
||||||
|
*/
|
||||||
|
public AgentChatCapabilityService(DocumentCollectionService documentCollectionService,
|
||||||
|
ResourceAccessService resourceAccessService,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.documentCollectionService = documentCollectionService;
|
||||||
|
this.resourceAccessService = resourceAccessService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将临时聊天能力合并到运行时 Agent 定义中。
|
||||||
|
*
|
||||||
|
* @param agent 已发布 Agent 运行视图
|
||||||
|
* @param capabilities 临时能力请求
|
||||||
|
* @param account 当前登录账号
|
||||||
|
* @return 能力解析结果
|
||||||
|
*/
|
||||||
|
public AgentChatCapabilityResolution apply(Agent agent,
|
||||||
|
List<AgentChatCapability> capabilities,
|
||||||
|
LoginAccount account) {
|
||||||
|
List<BigInteger> extraKnowledgeIds = resolveKnowledgeIds(capabilities);
|
||||||
|
boolean knowledgeCapabilityProvided = hasKnowledgeCapability(capabilities);
|
||||||
|
if (agent == null || extraKnowledgeIds.isEmpty()) {
|
||||||
|
return new AgentChatCapabilityResolution(agent, extraKnowledgeIds, knowledgeCapabilityProvided);
|
||||||
|
}
|
||||||
|
Agent runtimeAgent = objectMapper.convertValue(agent, Agent.class);
|
||||||
|
List<AgentKnowledgeBinding> mergedBindings = new ArrayList<>();
|
||||||
|
Set<BigInteger> existingKnowledgeIds = new LinkedHashSet<>();
|
||||||
|
if (runtimeAgent.getKnowledgeBindings() != null) {
|
||||||
|
for (AgentKnowledgeBinding binding : runtimeAgent.getKnowledgeBindings()) {
|
||||||
|
if (binding == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mergedBindings.add(binding);
|
||||||
|
if (Boolean.TRUE.equals(binding.getEnabled()) && binding.getKnowledgeId() != null) {
|
||||||
|
existingKnowledgeIds.add(binding.getKnowledgeId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int sortNo = mergedBindings.size();
|
||||||
|
for (BigInteger knowledgeId : extraKnowledgeIds) {
|
||||||
|
if (existingKnowledgeIds.contains(knowledgeId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DocumentCollection knowledge = documentCollectionService.getById(knowledgeId);
|
||||||
|
validateKnowledge(knowledge);
|
||||||
|
resourceAccessService.assertAccess(
|
||||||
|
CategoryResourceType.KNOWLEDGE,
|
||||||
|
knowledge,
|
||||||
|
ResourceAction.USE,
|
||||||
|
"无权限使用所选知识库"
|
||||||
|
);
|
||||||
|
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
|
||||||
|
binding.setTenantId(account == null ? runtimeAgent.getTenantId() : account.getTenantId());
|
||||||
|
binding.setAgentId(runtimeAgent.getId());
|
||||||
|
binding.setKnowledgeId(knowledgeId);
|
||||||
|
binding.setRetrievalMode(DEFAULT_RETRIEVAL_MODE);
|
||||||
|
binding.setEnabled(true);
|
||||||
|
binding.setSortNo(sortNo++);
|
||||||
|
mergedBindings.add(binding);
|
||||||
|
existingKnowledgeIds.add(knowledgeId);
|
||||||
|
}
|
||||||
|
runtimeAgent.setKnowledgeBindings(mergedBindings);
|
||||||
|
return new AgentChatCapabilityResolution(runtimeAgent, extraKnowledgeIds, knowledgeCapabilityProvided);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从能力列表提取知识库 ID。
|
||||||
|
*
|
||||||
|
* @param capabilities 临时能力列表
|
||||||
|
* @return 已去重知识库 ID
|
||||||
|
*/
|
||||||
|
public List<BigInteger> resolveKnowledgeIds(List<AgentChatCapability> capabilities) {
|
||||||
|
if (capabilities == null || capabilities.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
LinkedHashSet<BigInteger> ids = new LinkedHashSet<>();
|
||||||
|
for (AgentChatCapability capability : capabilities) {
|
||||||
|
if (capability == null || !isKnowledgeCapability(capability.getType())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (capability.getResourceIds() == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (BigInteger resourceId : capability.getResourceIds()) {
|
||||||
|
if (resourceId != null) {
|
||||||
|
ids.add(resourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.size() > MAX_EXTRA_KNOWLEDGE_COUNT) {
|
||||||
|
throw new BusinessException("临时知识库最多选择 3 个");
|
||||||
|
}
|
||||||
|
return new ArrayList<>(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isKnowledgeCapability(String type) {
|
||||||
|
return Objects.equals(KNOWLEDGE_CAPABILITY_TYPE, type == null ? null : type.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasKnowledgeCapability(List<AgentChatCapability> capabilities) {
|
||||||
|
if (capabilities == null || capabilities.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (AgentChatCapability capability : capabilities) {
|
||||||
|
if (capability != null && isKnowledgeCapability(capability.getType())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateKnowledge(DocumentCollection knowledge) {
|
||||||
|
if (knowledge == null) {
|
||||||
|
throw new BusinessException("所选知识库不存在");
|
||||||
|
}
|
||||||
|
if (PublishStatus.from(knowledge.getPublishStatus()) != PublishStatus.PUBLISHED) {
|
||||||
|
throw new BusinessException("所选知识库未发布,无法用于聊天");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 聊天临时能力解析结果。
|
||||||
|
*
|
||||||
|
* @param agent 合并临时能力后的运行时 Agent
|
||||||
|
* @param extraKnowledgeIds 本次选择的临时知识库 ID
|
||||||
|
* @param knowledgeCapabilityProvided 请求是否显式传入知识库能力
|
||||||
|
*/
|
||||||
|
public record AgentChatCapabilityResolution(Agent agent,
|
||||||
|
List<BigInteger> extraKnowledgeIds,
|
||||||
|
boolean knowledgeCapabilityProvided) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package tech.easyflow.agent.runtime;
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent 管理端运行请求。
|
* Agent 管理端运行请求。
|
||||||
@@ -10,6 +12,7 @@ public class AgentChatRequest {
|
|||||||
private BigInteger agentId;
|
private BigInteger agentId;
|
||||||
private BigInteger sessionId;
|
private BigInteger sessionId;
|
||||||
private String prompt;
|
private String prompt;
|
||||||
|
private List<AgentChatCapability> capabilities = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 Agent ID。
|
* 获取 Agent ID。
|
||||||
@@ -52,4 +55,22 @@ public class AgentChatRequest {
|
|||||||
* @param prompt 用户输入
|
* @param prompt 用户输入
|
||||||
*/
|
*/
|
||||||
public void setPrompt(String prompt) { this.prompt = prompt; }
|
public void setPrompt(String prompt) { this.prompt = prompt; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本次聊天启用的临时能力。
|
||||||
|
*
|
||||||
|
* @return 临时能力列表
|
||||||
|
*/
|
||||||
|
public List<AgentChatCapability> getCapabilities() {
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置本次聊天启用的临时能力。
|
||||||
|
*
|
||||||
|
* @param capabilities 临时能力列表
|
||||||
|
*/
|
||||||
|
public void setCapabilities(List<AgentChatCapability> capabilities) {
|
||||||
|
this.capabilities = capabilities == null ? new ArrayList<>() : new ArrayList<>(capabilities);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ public class AgentRunService {
|
|||||||
@Resource
|
@Resource
|
||||||
private AgentRuntimeFactory agentRuntimeFactory;
|
private AgentRuntimeFactory agentRuntimeFactory;
|
||||||
@Resource
|
@Resource
|
||||||
|
private AgentChatCapabilityService agentChatCapabilityService;
|
||||||
|
@Resource
|
||||||
private AgentSessionStore agentSessionStore;
|
private AgentSessionStore agentSessionStore;
|
||||||
@Resource
|
@Resource
|
||||||
private EasyFlowAgentSessionStore easyFlowAgentSessionStore;
|
private EasyFlowAgentSessionStore easyFlowAgentSessionStore;
|
||||||
@@ -121,10 +123,16 @@ public class AgentRunService {
|
|||||||
ChatSessionSummary existingSession = resolveExistingSession(account, sessionId, chatRequest.getAgentId());
|
ChatSessionSummary existingSession = resolveExistingSession(account, sessionId, chatRequest.getAgentId());
|
||||||
// 获取 Agent 发布快照
|
// 获取 Agent 发布快照
|
||||||
Agent agent = agentService.getPublishedView(chatRequest.getAgentId());
|
Agent agent = agentService.getPublishedView(chatRequest.getAgentId());
|
||||||
|
AgentChatCapabilityService.AgentChatCapabilityResolution capabilityResolution =
|
||||||
|
agentChatCapabilityService.apply(agent, chatRequest.getCapabilities(), account);
|
||||||
|
agent = capabilityResolution.agent();
|
||||||
String requestId = UUID.randomUUID().toString();
|
String requestId = UUID.randomUUID().toString();
|
||||||
String traceId = UUID.randomUUID().toString();
|
String traceId = UUID.randomUUID().toString();
|
||||||
// 组建会话上下文必要信息
|
// 组建会话上下文必要信息
|
||||||
ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, sessionId, chatRequest.getPrompt(), account);
|
ChatRuntimeContext chatContext = buildChatRuntimeContext(agent, sessionId, chatRequest.getPrompt(), account);
|
||||||
|
if (capabilityResolution.knowledgeCapabilityProvided()) {
|
||||||
|
chatContext.getExt().put(ChatRuntimeExtKeys.EXTRA_KNOWLEDGE_IDS, capabilityResolution.extraKnowledgeIds());
|
||||||
|
}
|
||||||
applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession);
|
applyFormalSessionTitle(chatContext, chatRequest.getPrompt(), existingSession);
|
||||||
// 执行对话
|
// 执行对话
|
||||||
return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(),
|
return run(agent, chatRequest.getPrompt(), requestId, traceId, sessionId.toString(),
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package tech.easyflow.agent.runtime;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.agent.entity.Agent;
|
||||||
|
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
|
||||||
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
import tech.easyflow.system.enums.CategoryResourceType;
|
||||||
|
import tech.easyflow.system.enums.ResourceAction;
|
||||||
|
import tech.easyflow.system.permission.resource.VisibilityResource;
|
||||||
|
import tech.easyflow.system.service.ResourceAccessService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 聊天临时能力编排服务测试。
|
||||||
|
*/
|
||||||
|
public class AgentChatCapabilityServiceTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void applyShouldAppendPublishedKnowledgeAndSkipBoundDuplicate() {
|
||||||
|
DocumentCollectionService documentService = documentService(
|
||||||
|
knowledge(1, PublishStatus.PUBLISHED),
|
||||||
|
knowledge(2, PublishStatus.PUBLISHED)
|
||||||
|
);
|
||||||
|
AgentChatCapabilityService service = new AgentChatCapabilityService(
|
||||||
|
documentService,
|
||||||
|
new AllowResourceAccessService(),
|
||||||
|
new ObjectMapper()
|
||||||
|
);
|
||||||
|
Agent agent = agentWithBoundKnowledge(1);
|
||||||
|
|
||||||
|
AgentChatCapabilityService.AgentChatCapabilityResolution resolution = service.apply(
|
||||||
|
agent,
|
||||||
|
List.of(capability(1, 2, 2)),
|
||||||
|
account()
|
||||||
|
);
|
||||||
|
|
||||||
|
List<AgentKnowledgeBinding> bindings = resolution.agent().getKnowledgeBindings();
|
||||||
|
Assert.assertEquals(List.of(BigInteger.ONE, BigInteger.valueOf(2)), resolution.extraKnowledgeIds());
|
||||||
|
Assert.assertEquals(2, bindings.size());
|
||||||
|
Assert.assertEquals(BigInteger.ONE, bindings.get(0).getKnowledgeId());
|
||||||
|
Assert.assertEquals(BigInteger.valueOf(2), bindings.get(1).getKnowledgeId());
|
||||||
|
Assert.assertEquals("HYBRID", bindings.get(1).getRetrievalMode());
|
||||||
|
Assert.assertTrue(bindings.get(1).getEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void applyShouldAppendWhenBoundKnowledgeIsDisabled() {
|
||||||
|
DocumentCollectionService documentService = documentService(knowledge(2, PublishStatus.PUBLISHED));
|
||||||
|
AgentChatCapabilityService service = new AgentChatCapabilityService(
|
||||||
|
documentService,
|
||||||
|
new AllowResourceAccessService(),
|
||||||
|
new ObjectMapper()
|
||||||
|
);
|
||||||
|
Agent agent = agentWithBoundKnowledge(2);
|
||||||
|
agent.getKnowledgeBindings().get(0).setEnabled(false);
|
||||||
|
|
||||||
|
AgentChatCapabilityService.AgentChatCapabilityResolution resolution = service.apply(
|
||||||
|
agent,
|
||||||
|
List.of(capability(2)),
|
||||||
|
account()
|
||||||
|
);
|
||||||
|
|
||||||
|
List<AgentKnowledgeBinding> bindings = resolution.agent().getKnowledgeBindings();
|
||||||
|
Assert.assertEquals(2, bindings.size());
|
||||||
|
Assert.assertFalse(bindings.get(0).getEnabled());
|
||||||
|
Assert.assertTrue(bindings.get(1).getEnabled());
|
||||||
|
Assert.assertEquals(BigInteger.valueOf(2), bindings.get(1).getKnowledgeId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void resolveKnowledgeIdsShouldRejectTooManyItems() {
|
||||||
|
AgentChatCapabilityService service = new AgentChatCapabilityService(
|
||||||
|
documentService(),
|
||||||
|
new AllowResourceAccessService(),
|
||||||
|
new ObjectMapper()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> service.resolveKnowledgeIds(List.of(capability(1, 2, 3, 4))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void applyShouldRejectUnpublishedKnowledge() {
|
||||||
|
DocumentCollectionService documentService = documentService(knowledge(2, PublishStatus.OFFLINE));
|
||||||
|
AgentChatCapabilityService service = new AgentChatCapabilityService(
|
||||||
|
documentService,
|
||||||
|
new AllowResourceAccessService(),
|
||||||
|
new ObjectMapper()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> service.apply(agentWithBoundKnowledge(1), List.of(capability(2)), account()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void applyShouldRejectUnauthorizedKnowledge() {
|
||||||
|
DocumentCollectionService documentService = documentService(knowledge(2, PublishStatus.PUBLISHED));
|
||||||
|
AgentChatCapabilityService service = new AgentChatCapabilityService(
|
||||||
|
documentService,
|
||||||
|
new DenyResourceAccessService(),
|
||||||
|
new ObjectMapper()
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.assertThrows(BusinessException.class,
|
||||||
|
() -> service.apply(agentWithBoundKnowledge(1), List.of(capability(2)), account()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Agent agentWithBoundKnowledge(int knowledgeId) {
|
||||||
|
Agent agent = new Agent();
|
||||||
|
agent.setId(BigInteger.TEN);
|
||||||
|
agent.setTenantId(BigInteger.ONE);
|
||||||
|
AgentKnowledgeBinding binding = new AgentKnowledgeBinding();
|
||||||
|
binding.setAgentId(agent.getId());
|
||||||
|
binding.setKnowledgeId(BigInteger.valueOf(knowledgeId));
|
||||||
|
binding.setRetrievalMode("HYBRID");
|
||||||
|
binding.setEnabled(true);
|
||||||
|
agent.setKnowledgeBindings(List.of(binding));
|
||||||
|
return agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AgentChatCapability capability(int... ids) {
|
||||||
|
AgentChatCapability capability = new AgentChatCapability();
|
||||||
|
capability.setType("KNOWLEDGE");
|
||||||
|
capability.setResourceIds(java.util.Arrays.stream(ids)
|
||||||
|
.mapToObj(BigInteger::valueOf)
|
||||||
|
.toList());
|
||||||
|
return capability;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentCollection knowledge(int id, PublishStatus status) {
|
||||||
|
DocumentCollection collection = new DocumentCollection();
|
||||||
|
collection.setId(BigInteger.valueOf(id));
|
||||||
|
collection.setTitle("知识库" + id);
|
||||||
|
collection.setPublishStatus(status.name());
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LoginAccount account() {
|
||||||
|
LoginAccount account = new LoginAccount();
|
||||||
|
account.setId(BigInteger.valueOf(100));
|
||||||
|
account.setTenantId(BigInteger.ONE);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentCollectionService documentService(DocumentCollection... collections) {
|
||||||
|
Map<BigInteger, DocumentCollection> collectionMap = new HashMap<>();
|
||||||
|
for (DocumentCollection collection : collections) {
|
||||||
|
collectionMap.put(collection.getId(), collection);
|
||||||
|
}
|
||||||
|
return (DocumentCollectionService) Proxy.newProxyInstance(
|
||||||
|
AgentChatCapabilityServiceTest.class.getClassLoader(),
|
||||||
|
new Class<?>[]{DocumentCollectionService.class},
|
||||||
|
(proxy, method, args) -> switch (method.getName()) {
|
||||||
|
case "getById" -> collectionMap.get(new BigInteger(String.valueOf(args[0])));
|
||||||
|
case "toPublishedView" -> args[0];
|
||||||
|
case "listByIds" -> ((java.util.Collection<?>) args[0]).stream()
|
||||||
|
.map(id -> collectionMap.get(new BigInteger(String.valueOf(id))))
|
||||||
|
.filter(java.util.Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
default -> throw new UnsupportedOperationException(method.getName());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class AllowResourceAccessService implements ResourceAccessService {
|
||||||
|
@Override public boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) { return true; }
|
||||||
|
@Override public boolean canAccess(LoginAccount loginAccount, CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) { return true; }
|
||||||
|
@Override public void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class DenyResourceAccessService extends AllowResourceAccessService {
|
||||||
|
@Override public void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message) {
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,11 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-mq</artifactId>
|
<artifactId>easyflow-common-mq</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-actuator</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.easyagents</groupId>
|
<groupId>com.easyagents</groupId>
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ package tech.easyflow.ai.config;
|
|||||||
|
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
import tech.easyflow.ai.documentimport.task.DocumentImportParseMonitorProperties;
|
||||||
|
|
||||||
@MapperScan("tech.easyflow.ai.mapper")
|
@MapperScan("tech.easyflow.ai.mapper")
|
||||||
|
@ComponentScan("tech.easyflow.ai")
|
||||||
|
@EnableConfigurationProperties({
|
||||||
|
DocumentImportParseMonitorProperties.class,
|
||||||
|
RagHealthProperties.class
|
||||||
|
})
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
public class AiModuleConfig {
|
public class AiModuleConfig {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package tech.easyflow.ai.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.actuate.health.Health;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康检查短缓存支持。
|
||||||
|
*/
|
||||||
|
public abstract class CachedHealthIndicatorSupport {
|
||||||
|
|
||||||
|
private final RagHealthProperties properties;
|
||||||
|
private final Clock clock;
|
||||||
|
private volatile CacheEntry cacheEntry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建健康检查缓存支持。
|
||||||
|
*
|
||||||
|
* @param properties RAG 健康检查配置
|
||||||
|
*/
|
||||||
|
protected CachedHealthIndicatorSupport(RagHealthProperties properties) {
|
||||||
|
this(properties, Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建健康检查缓存支持。
|
||||||
|
*
|
||||||
|
* @param properties RAG 健康检查配置
|
||||||
|
* @param clock 时钟
|
||||||
|
*/
|
||||||
|
protected CachedHealthIndicatorSupport(RagHealthProperties properties, Clock clock) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行带短缓存的健康检查。
|
||||||
|
*
|
||||||
|
* @return 健康状态
|
||||||
|
*/
|
||||||
|
protected Health cachedHealth() {
|
||||||
|
long now = clock.millis();
|
||||||
|
CacheEntry current = cacheEntry;
|
||||||
|
if (current != null && current.expireAtMillis > now) {
|
||||||
|
return current.health;
|
||||||
|
}
|
||||||
|
synchronized (this) {
|
||||||
|
current = cacheEntry;
|
||||||
|
if (current != null && current.expireAtMillis > now) {
|
||||||
|
return current.health;
|
||||||
|
}
|
||||||
|
Health health = doHealthCheck();
|
||||||
|
cacheEntry = new CacheEntry(health, now + cacheTtlMillis());
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行实际健康检查。
|
||||||
|
*
|
||||||
|
* @return 健康状态
|
||||||
|
*/
|
||||||
|
protected abstract Health doHealthCheck();
|
||||||
|
|
||||||
|
private long cacheTtlMillis() {
|
||||||
|
Duration cacheTtl = properties.getCacheTtl();
|
||||||
|
if (cacheTtl == null || cacheTtl.isZero() || cacheTtl.isNegative()) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return cacheTtl.toMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class CacheEntry {
|
||||||
|
|
||||||
|
private final Health health;
|
||||||
|
private final long expireAtMillis;
|
||||||
|
|
||||||
|
private CacheEntry(Health health, long expireAtMillis) {
|
||||||
|
this.health = health;
|
||||||
|
this.expireAtMillis = expireAtMillis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package tech.easyflow.ai.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EasyFlow 业务线程池配置。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.thread-pool")
|
||||||
|
public class EasyFlowThreadPoolProperties {
|
||||||
|
|
||||||
|
private Pool sse = new Pool(4, 16, 2000, 30, true);
|
||||||
|
private Pool documentImport = new Pool(2, 4, 200, 60, true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 SSE 线程池配置。
|
||||||
|
*
|
||||||
|
* @return SSE 线程池配置
|
||||||
|
*/
|
||||||
|
public Pool getSse() {
|
||||||
|
return sse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 SSE 线程池配置。
|
||||||
|
*
|
||||||
|
* @param sse SSE 线程池配置
|
||||||
|
*/
|
||||||
|
public void setSse(Pool sse) {
|
||||||
|
this.sse = sse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档导入线程池配置。
|
||||||
|
*
|
||||||
|
* @return 文档导入线程池配置
|
||||||
|
*/
|
||||||
|
public Pool getDocumentImport() {
|
||||||
|
return documentImport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置文档导入线程池配置。
|
||||||
|
*
|
||||||
|
* @param documentImport 文档导入线程池配置
|
||||||
|
*/
|
||||||
|
public void setDocumentImport(Pool documentImport) {
|
||||||
|
this.documentImport = documentImport;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 线程池配置项。
|
||||||
|
*/
|
||||||
|
public static class Pool {
|
||||||
|
|
||||||
|
private int coreSize;
|
||||||
|
private int maxSize;
|
||||||
|
private int queueCapacity;
|
||||||
|
private int keepAliveSeconds;
|
||||||
|
private boolean allowCoreThreadTimeout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建默认线程池配置。
|
||||||
|
*/
|
||||||
|
public Pool() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建线程池配置。
|
||||||
|
*
|
||||||
|
* @param coreSize 核心线程数
|
||||||
|
* @param maxSize 最大线程数
|
||||||
|
* @param queueCapacity 队列容量
|
||||||
|
* @param keepAliveSeconds 空闲线程存活时间
|
||||||
|
* @param allowCoreThreadTimeout 是否允许核心线程超时
|
||||||
|
*/
|
||||||
|
public Pool(int coreSize, int maxSize, int queueCapacity, int keepAliveSeconds, boolean allowCoreThreadTimeout) {
|
||||||
|
this.coreSize = coreSize;
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
this.queueCapacity = queueCapacity;
|
||||||
|
this.keepAliveSeconds = keepAliveSeconds;
|
||||||
|
this.allowCoreThreadTimeout = allowCoreThreadTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getCoreSize() {
|
||||||
|
return coreSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCoreSize(int coreSize) {
|
||||||
|
this.coreSize = coreSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMaxSize() {
|
||||||
|
return maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaxSize(int maxSize) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getQueueCapacity() {
|
||||||
|
return queueCapacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setQueueCapacity(int queueCapacity) {
|
||||||
|
this.queueCapacity = queueCapacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getKeepAliveSeconds() {
|
||||||
|
return keepAliveSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKeepAliveSeconds(int keepAliveSeconds) {
|
||||||
|
this.keepAliveSeconds = keepAliveSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllowCoreThreadTimeout() {
|
||||||
|
return allowCoreThreadTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAllowCoreThreadTimeout(boolean allowCoreThreadTimeout) {
|
||||||
|
this.allowCoreThreadTimeout = allowCoreThreadTimeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package tech.easyflow.ai.config;
|
||||||
|
|
||||||
|
import com.easyagents.engine.es.ElasticSearcher;
|
||||||
|
import com.easyagents.search.engine.service.DocumentSearcher;
|
||||||
|
import com.easyagents.store.milvus.MilvusVectorStore;
|
||||||
|
import org.springframework.boot.actuate.health.Health;
|
||||||
|
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.ai.rag.KeywordEngineType;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
|
import tech.easyflow.common.util.SpringContextUtil;
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG 依赖中间件健康检查。
|
||||||
|
*/
|
||||||
|
public class RagHealthIndicator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Milvus 健康检查。
|
||||||
|
*/
|
||||||
|
@Component("ragMilvusHealthIndicator")
|
||||||
|
public static class RagMilvusHealthIndicator extends CachedHealthIndicatorSupport implements HealthIndicator {
|
||||||
|
|
||||||
|
private final AiMilvusConfig aiMilvusConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Milvus 健康检查器。
|
||||||
|
*
|
||||||
|
* @param aiMilvusConfig Milvus 配置
|
||||||
|
* @param healthProperties RAG 健康检查配置
|
||||||
|
*/
|
||||||
|
public RagMilvusHealthIndicator(AiMilvusConfig aiMilvusConfig, RagHealthProperties healthProperties) {
|
||||||
|
super(healthProperties);
|
||||||
|
this.aiMilvusConfig = aiMilvusConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 Milvus 是否可连接。
|
||||||
|
*
|
||||||
|
* @return 健康状态
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Health health() {
|
||||||
|
return cachedHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Health doHealthCheck() {
|
||||||
|
MilvusVectorStore vectorStore = null;
|
||||||
|
try {
|
||||||
|
vectorStore = new MilvusVectorStore(
|
||||||
|
aiMilvusConfig.copyForCollection("__rag_health_probe__")
|
||||||
|
);
|
||||||
|
if (vectorStore.checkAvailable()) {
|
||||||
|
return Health.up().withDetail("uri", aiMilvusConfig.getUri()).build();
|
||||||
|
}
|
||||||
|
return Health.down().withDetail("uri", aiMilvusConfig.getUri()).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return Health.down(e).withDetail("uri", aiMilvusConfig.getUri()).build();
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(vectorStore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关键词检索健康检查。
|
||||||
|
*/
|
||||||
|
@Component("ragKeywordSearchHealthIndicator")
|
||||||
|
public static class RagKeywordSearchHealthIndicator extends CachedHealthIndicatorSupport implements HealthIndicator {
|
||||||
|
|
||||||
|
private final SearcherFactory searcherFactory;
|
||||||
|
private final AiLuceneConfig aiLuceneConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建关键词检索健康检查器。
|
||||||
|
*
|
||||||
|
* @param searcherFactory 检索器工厂
|
||||||
|
* @param aiLuceneConfig Lucene 配置
|
||||||
|
* @param healthProperties RAG 健康检查配置
|
||||||
|
*/
|
||||||
|
public RagKeywordSearchHealthIndicator(SearcherFactory searcherFactory,
|
||||||
|
AiLuceneConfig aiLuceneConfig,
|
||||||
|
RagHealthProperties healthProperties) {
|
||||||
|
super(healthProperties);
|
||||||
|
this.searcherFactory = searcherFactory;
|
||||||
|
this.aiLuceneConfig = aiLuceneConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查当前关键词检索引擎是否可用。
|
||||||
|
*
|
||||||
|
* @return 健康状态
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Health health() {
|
||||||
|
return cachedHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Health doHealthCheck() {
|
||||||
|
KeywordEngineType engineType = KeywordEngineType.from(SpringContextUtil.getProperty("rag.engine", "ES"));
|
||||||
|
if (engineType == KeywordEngineType.LUCENE) {
|
||||||
|
return checkLuceneDirectory(engineType);
|
||||||
|
}
|
||||||
|
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||||
|
if (searcher instanceof ElasticSearcher elasticSearcher && elasticSearcher.checkAvailable()) {
|
||||||
|
return Health.up().withDetail("engine", engineType.name()).build();
|
||||||
|
}
|
||||||
|
return Health.down().withDetail("engine", engineType.name()).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Health checkLuceneDirectory(KeywordEngineType engineType) {
|
||||||
|
String indexDirPath = aiLuceneConfig.getIndexDirPath();
|
||||||
|
if (StringUtil.noText(indexDirPath)) {
|
||||||
|
return Health.down()
|
||||||
|
.withDetail("engine", engineType.name())
|
||||||
|
.withDetail("reason", "Lucene 索引目录未配置")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
File indexDir = new File(indexDirPath);
|
||||||
|
if (indexDir.exists() && indexDir.isDirectory() && indexDir.canRead() && indexDir.canWrite()) {
|
||||||
|
return Health.up()
|
||||||
|
.withDetail("engine", engineType.name())
|
||||||
|
.withDetail("indexDir", indexDirPath)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
return Health.down()
|
||||||
|
.withDetail("engine", engineType.name())
|
||||||
|
.withDetail("indexDir", indexDirPath)
|
||||||
|
.withDetail("reason", "Lucene 索引目录不可读写")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package tech.easyflow.ai.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAG 健康检查配置。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.ai.rag.health")
|
||||||
|
public class RagHealthProperties {
|
||||||
|
|
||||||
|
private Duration cacheTtl = Duration.ofSeconds(5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取健康检查结果缓存时间。
|
||||||
|
*
|
||||||
|
* @return 缓存时间
|
||||||
|
*/
|
||||||
|
public Duration getCacheTtl() {
|
||||||
|
return cacheTtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置健康检查结果缓存时间。
|
||||||
|
*
|
||||||
|
* @param cacheTtl 缓存时间
|
||||||
|
*/
|
||||||
|
public void setCacheTtl(Duration cacheTtl) {
|
||||||
|
this.cacheTtl = cacheTtl == null || cacheTtl.isNegative() ? Duration.ofSeconds(5) : cacheTtl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
package tech.easyflow.ai.config;
|
package tech.easyflow.ai.config;
|
||||||
|
|
||||||
import com.easyagents.engine.es.ElasticSearcher;
|
|
||||||
import com.easyagents.search.engine.service.DocumentSearcher;
|
|
||||||
import com.easyagents.store.milvus.MilvusVectorStore;
|
|
||||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import tech.easyflow.ai.rag.KeywordEngineType;
|
import tech.easyflow.ai.rag.KeywordEngineType;
|
||||||
@@ -16,9 +13,6 @@ import java.io.File;
|
|||||||
@Component
|
@Component
|
||||||
public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
||||||
|
|
||||||
private static final int STARTUP_CHECK_RETRY_TIMES = 10;
|
|
||||||
private static final long STARTUP_CHECK_RETRY_INTERVAL_MS = 1000L;
|
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private AiMilvusConfig aiMilvusConfig;
|
private AiMilvusConfig aiMilvusConfig;
|
||||||
|
|
||||||
@@ -26,31 +20,21 @@ public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
|||||||
private AiLuceneConfig aiLuceneConfig;
|
private AiLuceneConfig aiLuceneConfig;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SearcherFactory searcherFactory;
|
private AiEsConfig aiEsConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 RAG 基础配置。
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void afterSingletonsInstantiated() {
|
public void afterSingletonsInstantiated() {
|
||||||
validateMilvus();
|
validateMilvusConfig();
|
||||||
validateKeywordSearcher();
|
validateKeywordSearcher();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateMilvus() {
|
private void validateMilvusConfig() {
|
||||||
Exception lastException = null;
|
if (StringUtil.noText(aiMilvusConfig.getUri())) {
|
||||||
for (int i = 0; i < STARTUP_CHECK_RETRY_TIMES; i++) {
|
throw new BusinessException("Milvus uri 未配置,请检查 rag.milvus.uri");
|
||||||
try {
|
|
||||||
MilvusVectorStore vectorStore = new MilvusVectorStore(aiMilvusConfig.copyForCollection("__rag_boot_probe__"));
|
|
||||||
if (vectorStore.checkAvailable()) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
|
||||||
lastException = e;
|
|
||||||
}
|
|
||||||
sleepBeforeRetry();
|
|
||||||
}
|
|
||||||
if (lastException != null) {
|
|
||||||
throw new BusinessException("Milvus 服务不可用,项目启动失败,请检查 rag.milvus 配置与服务状态: " + lastException.getMessage());
|
|
||||||
}
|
|
||||||
throw new BusinessException("Milvus 服务不可用,项目启动失败,请检查 rag.milvus 配置与服务状态");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateKeywordSearcher() {
|
private void validateKeywordSearcher() {
|
||||||
@@ -61,21 +45,12 @@ public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
|||||||
validateLuceneDirectory();
|
validateLuceneDirectory();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (StringUtil.noText(aiEsConfig.getHost())) {
|
||||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
throw new BusinessException("ES 地址未配置,请检查 rag.searcher.elastic.host");
|
||||||
if (!(searcher instanceof ElasticSearcher) || !checkElasticAvailable((ElasticSearcher) searcher)) {
|
|
||||||
throw new BusinessException("ES 服务不可用,项目启动失败,请检查 rag.engine 与 rag.searcher.elastic 配置");
|
|
||||||
}
|
}
|
||||||
|
if (StringUtil.noText(aiEsConfig.getIndexName())) {
|
||||||
|
throw new BusinessException("ES 索引未配置,请检查 rag.searcher.elastic.indexName");
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean checkElasticAvailable(ElasticSearcher elasticSearcher) {
|
|
||||||
for (int i = 0; i < STARTUP_CHECK_RETRY_TIMES; i++) {
|
|
||||||
if (elasticSearcher.checkAvailable()) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
sleepBeforeRetry();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateLuceneDirectory() {
|
private void validateLuceneDirectory() {
|
||||||
@@ -92,12 +67,4 @@ public class RagInfrastructureValidator implements SmartInitializingSingleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sleepBeforeRetry() {
|
|
||||||
try {
|
|
||||||
Thread.sleep(STARTUP_CHECK_RETRY_INTERVAL_MS);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new BusinessException("中间件启动校验被中断");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,28 @@ package tech.easyflow.ai.config;
|
|||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(EasyFlowThreadPoolProperties.class)
|
||||||
public class ThreadPoolConfig {
|
public class ThreadPoolConfig {
|
||||||
private static final Logger log = LoggerFactory.getLogger(ThreadPoolConfig.class);
|
private static final Logger log = LoggerFactory.getLogger(ThreadPoolConfig.class);
|
||||||
|
|
||||||
|
private final EasyFlowThreadPoolProperties properties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建线程池配置。
|
||||||
|
*
|
||||||
|
* @param properties 线程池配置属性
|
||||||
|
*/
|
||||||
|
public ThreadPoolConfig(EasyFlowThreadPoolProperties properties) {
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 SSE 消息发送线程池。
|
* 创建 SSE 消息发送线程池。
|
||||||
*
|
*
|
||||||
@@ -19,11 +32,12 @@ public class ThreadPoolConfig {
|
|||||||
@Bean(name = "sseThreadPool")
|
@Bean(name = "sseThreadPool")
|
||||||
public ThreadPoolTaskExecutor sseThreadPool() {
|
public ThreadPoolTaskExecutor sseThreadPool() {
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
int cpuCoreNum = Runtime.getRuntime().availableProcessors(); // 获取CPU核心数(4核返回4)
|
EasyFlowThreadPoolProperties.Pool pool = properties.getSse();
|
||||||
executor.setCorePoolSize(cpuCoreNum * 2); // 核心线程数
|
executor.setCorePoolSize(pool.getCoreSize());
|
||||||
executor.setMaxPoolSize(cpuCoreNum * 10); // 最大线程数(峰值时扩容,避免线程过多导致上下文切换)
|
executor.setMaxPoolSize(pool.getMaxSize());
|
||||||
executor.setQueueCapacity(8000); // 任务队列容量
|
executor.setQueueCapacity(pool.getQueueCapacity());
|
||||||
executor.setKeepAliveSeconds(30); // 空闲线程存活时间:30秒(非核心线程空闲后销毁,节省资源)
|
executor.setKeepAliveSeconds(pool.getKeepAliveSeconds());
|
||||||
|
executor.setAllowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
|
||||||
executor.setThreadNamePrefix("sse-sender-");
|
executor.setThreadNamePrefix("sse-sender-");
|
||||||
|
|
||||||
// 拒绝策略
|
// 拒绝策略
|
||||||
@@ -47,11 +61,12 @@ public class ThreadPoolConfig {
|
|||||||
@Bean(name = "documentImportTaskExecutor")
|
@Bean(name = "documentImportTaskExecutor")
|
||||||
public ThreadPoolTaskExecutor documentImportTaskExecutor() {
|
public ThreadPoolTaskExecutor documentImportTaskExecutor() {
|
||||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
int cpuCoreNum = Runtime.getRuntime().availableProcessors();
|
EasyFlowThreadPoolProperties.Pool pool = properties.getDocumentImport();
|
||||||
executor.setCorePoolSize(Math.max(2, cpuCoreNum));
|
executor.setCorePoolSize(pool.getCoreSize());
|
||||||
executor.setMaxPoolSize(Math.max(4, cpuCoreNum * 2));
|
executor.setMaxPoolSize(pool.getMaxSize());
|
||||||
executor.setQueueCapacity(200);
|
executor.setQueueCapacity(pool.getQueueCapacity());
|
||||||
executor.setKeepAliveSeconds(60);
|
executor.setKeepAliveSeconds(pool.getKeepAliveSeconds());
|
||||||
|
executor.setAllowCoreThreadTimeOut(pool.isAllowCoreThreadTimeout());
|
||||||
executor.setThreadNamePrefix("document-import-");
|
executor.setThreadNamePrefix("document-import-");
|
||||||
executor.setRejectedExecutionHandler((runnable, executorService) -> {
|
executor.setRejectedExecutionHandler((runnable, executorService) -> {
|
||||||
log.error("文档导入线程池过载!核心线程数:{},最大线程数:{},队列任务数:{}",
|
log.error("文档导入线程池过载!核心线程数:{},最大线程数:{},队列任务数:{}",
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ public class DocumentImportParseMonitor {
|
|||||||
* 定时收敛运行中的桥接解析任务状态。
|
* 定时收敛运行中的桥接解析任务状态。
|
||||||
*/
|
*/
|
||||||
@Scheduled(
|
@Scheduled(
|
||||||
fixedDelayString = "${easyflow.ai.document-import.parse-monitor.fixed-delay:3000}",
|
fixedDelayString = "${easyflow.ai.document-import.parse-monitor.fixed-delay:10000}",
|
||||||
initialDelayString = "${easyflow.ai.document-import.parse-monitor.initial-delay:5000}"
|
initialDelayString = "${easyflow.ai.document-import.parse-monitor.initial-delay:10000}"
|
||||||
)
|
)
|
||||||
public void reconcileRunningParseTasks() {
|
public void reconcileRunningParseTasks() {
|
||||||
appService.monitorRunningParseTasks();
|
appService.monitorRunningParseTasks();
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档解析任务监控配置。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "easyflow.ai.document-import.parse-monitor")
|
||||||
|
public class DocumentImportParseMonitorProperties {
|
||||||
|
|
||||||
|
private int batchSize = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单次监控批量。
|
||||||
|
*
|
||||||
|
* @return 单次监控批量
|
||||||
|
*/
|
||||||
|
public int getBatchSize() {
|
||||||
|
return batchSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置单次监控批量。
|
||||||
|
*
|
||||||
|
* @param batchSize 单次监控批量
|
||||||
|
*/
|
||||||
|
public void setBatchSize(int batchSize) {
|
||||||
|
this.batchSize = batchSize <= 0 ? 10 : batchSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,17 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportKeys;
|
import tech.easyflow.ai.documentimport.DocumentImportKeys;
|
||||||
import tech.easyflow.ai.entity.Document;
|
import tech.easyflow.ai.entity.Document;
|
||||||
import tech.easyflow.ai.mapper.DocumentMapper;
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
|
import java.io.IOException;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -28,6 +32,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
@Service
|
@Service
|
||||||
public class DocumentImportTaskStatusStreamService {
|
public class DocumentImportTaskStatusStreamService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportTaskStatusStreamService.class);
|
||||||
private static final long SSE_TIMEOUT_MS = Duration.ofMinutes(30).toMillis();
|
private static final long SSE_TIMEOUT_MS = Duration.ofMinutes(30).toMillis();
|
||||||
|
|
||||||
private final Map<String, Set<SseEmitter>> knowledgeEmitters = new ConcurrentHashMap<String, Set<SseEmitter>>();
|
private final Map<String, Set<SseEmitter>> knowledgeEmitters = new ConcurrentHashMap<String, Set<SseEmitter>>();
|
||||||
@@ -134,6 +139,9 @@ public class DocumentImportTaskStatusStreamService {
|
|||||||
|
|
||||||
private void sendAsync(String topicKey, SseEmitter emitter, String eventName, Map<String, Object> payload) {
|
private void sendAsync(String topicKey, SseEmitter emitter, String eventName, Map<String, Object> payload) {
|
||||||
sseThreadPool.execute(() -> {
|
sseThreadPool.execute(() -> {
|
||||||
|
if (!isEmitterRegistered(topicKey, emitter)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
emitter.send(
|
emitter.send(
|
||||||
SseEmitter.event()
|
SseEmitter.event()
|
||||||
@@ -142,14 +150,29 @@ public class DocumentImportTaskStatusStreamService {
|
|||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
removeEmitter(topicKey, emitter);
|
removeEmitter(topicKey, emitter);
|
||||||
try {
|
if (isClientDisconnected(e)) {
|
||||||
emitter.completeWithError(e);
|
LOG.debug("文档导入状态流客户端已断开: topicKey={}, eventName={}, message={}",
|
||||||
} catch (Exception ignored) {
|
topicKey, eventName, e.getMessage());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
LOG.warn("文档导入状态流推送失败: topicKey={}, eventName={}", topicKey, eventName, e);
|
||||||
|
completeQuietly(emitter);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断指定 SSE 连接是否仍注册在主题下,避免已清理连接继续被异步任务写入。
|
||||||
|
*
|
||||||
|
* @param topicKey 主题键
|
||||||
|
* @param emitter SSE 连接
|
||||||
|
* @return 是否仍处于注册状态
|
||||||
|
*/
|
||||||
|
private boolean isEmitterRegistered(String topicKey, SseEmitter emitter) {
|
||||||
|
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
|
||||||
|
return emitters != null && emitters.contains(emitter);
|
||||||
|
}
|
||||||
|
|
||||||
private void removeEmitter(String topicKey, SseEmitter emitter) {
|
private void removeEmitter(String topicKey, SseEmitter emitter) {
|
||||||
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
|
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
|
||||||
if (emitters == null) {
|
if (emitters == null) {
|
||||||
@@ -161,6 +184,46 @@ public class DocumentImportTaskStatusStreamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断异常是否由客户端断开 SSE 连接导致。
|
||||||
|
*
|
||||||
|
* @param throwable 异常
|
||||||
|
* @return 是否为客户端断连
|
||||||
|
*/
|
||||||
|
private boolean isClientDisconnected(Throwable throwable) {
|
||||||
|
Throwable current = throwable;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof AsyncRequestNotUsableException || current instanceof IOException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String message = current.getMessage();
|
||||||
|
if (message != null) {
|
||||||
|
String lowerMessage = message.toLowerCase();
|
||||||
|
if (lowerMessage.contains("broken pipe")
|
||||||
|
|| lowerMessage.contains("connection reset")
|
||||||
|
|| lowerMessage.contains("response not usable")
|
||||||
|
|| lowerMessage.contains("client abort")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.getCause();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安静关闭 SSE 连接。
|
||||||
|
*
|
||||||
|
* @param emitter SSE 连接
|
||||||
|
*/
|
||||||
|
private void completeQuietly(SseEmitter emitter) {
|
||||||
|
try {
|
||||||
|
emitter.complete();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.debug("关闭文档导入状态流失败: message={}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private String toTopicKey(BigInteger knowledgeId) {
|
private String toTopicKey(BigInteger knowledgeId) {
|
||||||
return String.valueOf(knowledgeId);
|
return String.valueOf(knowledgeId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import tech.easyflow.ai.service.DocumentChunkService;
|
|||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
import tech.easyflow.ai.service.DocumentImportTaskService;
|
import tech.easyflow.ai.service.DocumentImportTaskService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
import tech.easyflow.common.util.FileUtil;
|
import tech.easyflow.common.util.FileUtil;
|
||||||
@@ -92,7 +93,6 @@ import java.util.regex.Pattern;
|
|||||||
public class KnowledgeDocumentImportTaskAppService {
|
public class KnowledgeDocumentImportTaskAppService {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(KnowledgeDocumentImportTaskAppService.class);
|
private static final Logger LOG = LoggerFactory.getLogger(KnowledgeDocumentImportTaskAppService.class);
|
||||||
private static final int PARSE_MONITOR_BATCH_SIZE = 20;
|
|
||||||
private static final int INDEX_BATCH_SIZE = 20;
|
private static final int INDEX_BATCH_SIZE = 20;
|
||||||
private static final String SOURCE_RANGES_KEY = "sourceRanges";
|
private static final String SOURCE_RANGES_KEY = "sourceRanges";
|
||||||
private static final String KNOWLEDGE_PARSE_IMAGE_CATEGORY = "knowledge-parse";
|
private static final String KNOWLEDGE_PARSE_IMAGE_CATEGORY = "knowledge-parse";
|
||||||
@@ -122,6 +122,9 @@ public class KnowledgeDocumentImportTaskAppService {
|
|||||||
@Resource
|
@Resource
|
||||||
private DocumentImportTaskService documentImportTaskService;
|
private DocumentImportTaskService documentImportTaskService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DocumentImportParseMonitorProperties parseMonitorProperties;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private DocumentImportPreviewService documentImportPreviewService;
|
private DocumentImportPreviewService documentImportPreviewService;
|
||||||
|
|
||||||
@@ -403,7 +406,7 @@ public class KnowledgeDocumentImportTaskAppService {
|
|||||||
.eq(DocumentImportTask::getPhase, DocumentImportTaskPhase.PARSE.name())
|
.eq(DocumentImportTask::getPhase, DocumentImportTaskPhase.PARSE.name())
|
||||||
.eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
|
.eq(DocumentImportTask::getStatus, DocumentImportTaskStatus.RUNNING.name())
|
||||||
.orderBy(DocumentImportTask::getModified, true)
|
.orderBy(DocumentImportTask::getModified, true)
|
||||||
.limit(PARSE_MONITOR_BATCH_SIZE);
|
.limit(parseMonitorProperties.getBatchSize());
|
||||||
List<DocumentImportTask> runningTasks = documentImportTaskService.list(queryWrapper);
|
List<DocumentImportTask> runningTasks = documentImportTaskService.list(queryWrapper);
|
||||||
if (runningTasks == null || runningTasks.isEmpty()) {
|
if (runningTasks == null || runningTasks.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@@ -516,6 +519,8 @@ public class KnowledgeDocumentImportTaskAppService {
|
|||||||
rollbackStoredChunks(taskId, document.getId(), storeContext, storedChunks);
|
rollbackStoredChunks(taskId, document.getId(), storeContext, storedChunks);
|
||||||
}
|
}
|
||||||
markIndexFailed(task, document, truncateError(e.getMessage()));
|
markIndexFailed(task, document, truncateError(e.getMessage()));
|
||||||
|
} finally {
|
||||||
|
closeStoreContext(storeContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2123,6 +2128,7 @@ public class KnowledgeDocumentImportTaskAppService {
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
throw new BusinessException("向量数据库配置错误");
|
throw new BusinessException("向量数据库配置错误");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw new BusinessException("该知识库未配置向量模型");
|
throw new BusinessException("该知识库未配置向量模型");
|
||||||
@@ -2143,6 +2149,10 @@ public class KnowledgeDocumentImportTaskAppService {
|
|||||||
options,
|
options,
|
||||||
searcherFactory.getSearcher()
|
searcherFactory.getSearcher()
|
||||||
);
|
);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void storeDocumentChunks(StoreExecutionContext storeContext, List<DocumentChunk> documentChunks) {
|
private void storeDocumentChunks(StoreExecutionContext storeContext, List<DocumentChunk> documentChunks) {
|
||||||
@@ -2221,6 +2231,13 @@ public class KnowledgeDocumentImportTaskAppService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void closeStoreContext(StoreExecutionContext storeContext) {
|
||||||
|
if (storeContext == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(storeContext.documentStore);
|
||||||
|
}
|
||||||
|
|
||||||
private void clearPersistedChunks(BigInteger documentId) {
|
private void clearPersistedChunks(BigInteger documentId) {
|
||||||
if (documentId == null) {
|
if (documentId == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import tech.easyflow.ai.mapper.FaqItemMapper;
|
|||||||
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
import tech.easyflow.ai.rag.KnowledgeRetrievalRequest;
|
||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.ai.utils.CustomBeanUtils;
|
import tech.easyflow.ai.utils.CustomBeanUtils;
|
||||||
import tech.easyflow.ai.utils.RegexUtils;
|
import tech.easyflow.ai.utils.RegexUtils;
|
||||||
import tech.easyflow.common.util.StringUtil;
|
import tech.easyflow.common.util.StringUtil;
|
||||||
@@ -283,6 +284,7 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
throw new BusinessException("知识库没有配置向量库");
|
throw new BusinessException("知识库没有配置向量库");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
Model model = llmService.getModelInstance(documentCollection.getVectorEmbedModelId());
|
Model model = llmService.getModelInstance(documentCollection.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw new BusinessException("知识库没有配置向量模型");
|
throw new BusinessException("知识库没有配置向量模型");
|
||||||
@@ -311,6 +313,9 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
summarizeDocuments(result)
|
summarizeDocuments(result)
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Document> searchKeywordDocuments(DocumentCollection documentCollection, String keyword, int docRecallMaxNum) {
|
private List<Document> searchKeywordDocuments(DocumentCollection documentCollection, String keyword, int docRecallMaxNum) {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import tech.easyflow.ai.service.DocumentChunkService;
|
|||||||
import tech.easyflow.ai.service.DocumentCollectionService;
|
import tech.easyflow.ai.service.DocumentCollectionService;
|
||||||
import tech.easyflow.ai.service.DocumentService;
|
import tech.easyflow.ai.service.DocumentService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.common.ai.rag.ExcelDocumentSplitter;
|
import tech.easyflow.common.ai.rag.ExcelDocumentSplitter;
|
||||||
import tech.easyflow.common.domain.Result;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.filestorage.FileStorageService;
|
import tech.easyflow.common.filestorage.FileStorageService;
|
||||||
@@ -154,6 +155,7 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
Model model = modelService.getById(knowledge.getVectorEmbedModelId());
|
Model model = modelService.getById(knowledge.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -182,6 +184,9 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
Document document = documentMapper.selectOneByQuery(queryWrapperDocument);
|
Document document = documentMapper.selectOneByQuery(queryWrapperDocument);
|
||||||
storageService.delete(document.getDocumentPath());
|
storageService.delete(document.getDocumentPath());
|
||||||
return true;
|
return true;
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -286,8 +291,8 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
}
|
}
|
||||||
|
|
||||||
StoreExecutionContext storeContext = prepareStoreContext(document);
|
StoreExecutionContext storeContext = prepareStoreContext(document);
|
||||||
storeDocumentChunks(storeContext, validChunks);
|
|
||||||
try {
|
try {
|
||||||
|
storeDocumentChunks(storeContext, validChunks);
|
||||||
persistDocumentWithChunks(document, validChunks);
|
persistDocumentWithChunks(document, validChunks);
|
||||||
updateKnowledgeAfterStore(storeContext);
|
updateKnowledgeAfterStore(storeContext);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
@@ -296,14 +301,20 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
rollbackStoredChunks(storeContext, validChunks);
|
rollbackStoredChunks(storeContext, validChunks);
|
||||||
Log.error("保存文档失败: documentId={}, title={}", document.getId(), document.getTitle(), e);
|
Log.error("保存文档失败: documentId={}, title={}", document.getId(), document.getTitle(), e);
|
||||||
throw new BusinessException("保存失败:" + e.getMessage());
|
throw new BusinessException("保存失败:" + e.getMessage());
|
||||||
|
} finally {
|
||||||
|
closeStoreContext(storeContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected Boolean storeDocument(Document entity, List<DocumentChunk> documentChunks) {
|
protected Boolean storeDocument(Document entity, List<DocumentChunk> documentChunks) {
|
||||||
StoreExecutionContext storeContext = prepareStoreContext(entity);
|
StoreExecutionContext storeContext = prepareStoreContext(entity);
|
||||||
|
try {
|
||||||
storeDocumentChunks(storeContext, documentChunks);
|
storeDocumentChunks(storeContext, documentChunks);
|
||||||
updateKnowledgeAfterStore(storeContext);
|
updateKnowledgeAfterStore(storeContext);
|
||||||
return true;
|
return true;
|
||||||
|
} finally {
|
||||||
|
closeStoreContext(storeContext);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -430,14 +441,16 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
}
|
}
|
||||||
|
|
||||||
StoreExecutionContext storeContext = prepareStoreContext(document);
|
StoreExecutionContext storeContext = prepareStoreContext(document);
|
||||||
storeDocumentChunks(storeContext, session.getDocumentChunks());
|
|
||||||
try {
|
try {
|
||||||
|
storeDocumentChunks(storeContext, session.getDocumentChunks());
|
||||||
persistDocumentWithChunks(document, session.getDocumentChunks());
|
persistDocumentWithChunks(document, session.getDocumentChunks());
|
||||||
updateKnowledgeAfterStore(storeContext);
|
updateKnowledgeAfterStore(storeContext);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
cleanupPersistedDocument(document);
|
cleanupPersistedDocument(document);
|
||||||
rollbackStoredChunks(storeContext, session.getDocumentChunks());
|
rollbackStoredChunks(storeContext, session.getDocumentChunks());
|
||||||
throw new BusinessException("提交导入失败:" + e.getMessage());
|
throw new BusinessException("提交导入失败:" + e.getMessage());
|
||||||
|
} finally {
|
||||||
|
closeStoreContext(storeContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,7 +764,7 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
throw new BusinessException("向量数据库配置错误");
|
throw new BusinessException("向量数据库配置错误");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw new BusinessException("该知识库未配置大模型");
|
throw new BusinessException("该知识库未配置大模型");
|
||||||
@@ -769,6 +782,10 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
DocumentSearcher searcher = null;
|
DocumentSearcher searcher = null;
|
||||||
searcher = searcherFactory.getSearcher();
|
searcher = searcherFactory.getSearcher();
|
||||||
return new StoreExecutionContext(knowledge, model, embeddingModel, documentStore, options, searcher);
|
return new StoreExecutionContext(knowledge, model, embeddingModel, documentStore, options, searcher);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void storeDocumentChunks(StoreExecutionContext storeContext, List<DocumentChunk> documentChunks) {
|
private void storeDocumentChunks(StoreExecutionContext storeContext, List<DocumentChunk> documentChunks) {
|
||||||
@@ -841,6 +858,13 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void closeStoreContext(StoreExecutionContext storeContext) {
|
||||||
|
if (storeContext == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(storeContext.documentStore);
|
||||||
|
}
|
||||||
|
|
||||||
private void persistDocumentWithChunks(Document document, List<DocumentChunk> chunks) {
|
private void persistDocumentWithChunks(Document document, List<DocumentChunk> chunks) {
|
||||||
this.getMapper().insert(document);
|
this.getMapper().insert(document);
|
||||||
AtomicInteger sort = new AtomicInteger(1);
|
AtomicInteger sort = new AtomicInteger(1);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import tech.easyflow.ai.service.DocumentCollectionService;
|
|||||||
import tech.easyflow.ai.service.FaqCategoryService;
|
import tech.easyflow.ai.service.FaqCategoryService;
|
||||||
import tech.easyflow.ai.service.FaqItemService;
|
import tech.easyflow.ai.service.FaqItemService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.ai.vo.FaqImportErrorRowVo;
|
import tech.easyflow.ai.vo.FaqImportErrorRowVo;
|
||||||
import tech.easyflow.ai.vo.FaqImportResultVo;
|
import tech.easyflow.ai.vo.FaqImportResultVo;
|
||||||
import tech.easyflow.common.util.StringUtil;
|
import tech.easyflow.common.util.StringUtil;
|
||||||
@@ -348,6 +349,7 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
|
|
||||||
private void storeToVector(DocumentCollection collection, FaqItem entity, boolean isUpdate) {
|
private void storeToVector(DocumentCollection collection, FaqItem entity, boolean isUpdate) {
|
||||||
PreparedStore preparedStore = prepareStore(collection);
|
PreparedStore preparedStore = prepareStore(collection);
|
||||||
|
try {
|
||||||
com.easyagents.core.document.Document doc = toSearchDocument(entity);
|
com.easyagents.core.document.Document doc = toSearchDocument(entity);
|
||||||
StoreResult result = isUpdate
|
StoreResult result = isUpdate
|
||||||
? preparedStore.documentStore.update(doc, preparedStore.storeOptions)
|
? preparedStore.documentStore.update(doc, preparedStore.storeOptions)
|
||||||
@@ -364,14 +366,21 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
searcher.addDocument(doc);
|
searcher.addDocument(doc);
|
||||||
}
|
}
|
||||||
markCollectionEmbedded(collection, preparedStore.embeddingModel);
|
markCollectionEmbedded(collection, preparedStore.embeddingModel);
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(preparedStore.documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void removeFromVector(DocumentCollection collection, FaqItem entity) {
|
private void removeFromVector(DocumentCollection collection, FaqItem entity) {
|
||||||
PreparedStore preparedStore = prepareStore(collection);
|
PreparedStore preparedStore = prepareStore(collection);
|
||||||
|
try {
|
||||||
boolean deleteSuccess = deleteFromVectorStore(preparedStore.documentStore, preparedStore.storeOptions, entity.getId());
|
boolean deleteSuccess = deleteFromVectorStore(preparedStore.documentStore, preparedStore.storeOptions, entity.getId());
|
||||||
if (!deleteSuccess) {
|
if (!deleteSuccess) {
|
||||||
throw new BusinessException("FAQ向量删除失败");
|
throw new BusinessException("FAQ向量删除失败");
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(preparedStore.documentStore);
|
||||||
|
}
|
||||||
|
|
||||||
DocumentSearcher searcher = searcherFactory.getSearcher();
|
DocumentSearcher searcher = searcherFactory.getSearcher();
|
||||||
if (searcher != null) {
|
if (searcher != null) {
|
||||||
@@ -413,6 +422,7 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
throw new BusinessException("向量数据库配置错误");
|
throw new BusinessException("向量数据库配置错误");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
Model model = modelService.getModelInstance(collection.getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(collection.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw new BusinessException("该知识库未配置向量模型");
|
throw new BusinessException("该知识库未配置向量模型");
|
||||||
@@ -427,6 +437,10 @@ public class FaqItemServiceImpl extends ServiceImpl<FaqItemMapper, FaqItem> impl
|
|||||||
options.setEmbeddingOptions(embeddingOptions);
|
options.setEmbeddingOptions(embeddingOptions);
|
||||||
options.setIndexName(options.getCollectionName());
|
options.setIndexName(options.getCollectionName());
|
||||||
return new PreparedStore(documentStore, options, embeddingModel);
|
return new PreparedStore(documentStore, options, embeddingModel);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private com.easyagents.core.document.Document toSearchDocument(FaqItem entity) {
|
private com.easyagents.core.document.Document toSearchDocument(FaqItem entity) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import tech.easyflow.ai.service.DocumentCollectionService;
|
|||||||
import tech.easyflow.ai.service.FaqItemService;
|
import tech.easyflow.ai.service.FaqItemService;
|
||||||
import tech.easyflow.ai.service.KnowledgeEmbeddingService;
|
import tech.easyflow.ai.service.KnowledgeEmbeddingService;
|
||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
|
import tech.easyflow.ai.support.DocumentStoreLifecycleSupport;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
@@ -50,6 +51,7 @@ public class KnowledgeEmbeddingServiceImpl implements KnowledgeEmbeddingService
|
|||||||
if (documentStore == null) {
|
if (documentStore == null) {
|
||||||
throw new BusinessException("知识库没有配置向量库");
|
throw new BusinessException("知识库没有配置向量库");
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
Model model = modelService.getModelInstance(knowledge.getVectorEmbedModelId());
|
||||||
if (model == null) {
|
if (model == null) {
|
||||||
throw new BusinessException("知识库没有配置向量模型");
|
throw new BusinessException("知识库没有配置向量模型");
|
||||||
@@ -64,6 +66,9 @@ public class KnowledgeEmbeddingServiceImpl implements KnowledgeEmbeddingService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rebuildDocumentVectors(knowledge, documentStore, storeOptions, embeddingModel);
|
rebuildDocumentVectors(knowledge, documentStore, storeOptions, embeddingModel);
|
||||||
|
} finally {
|
||||||
|
DocumentStoreLifecycleSupport.closeQuietly(documentStore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void rebuildDocumentVectors(
|
private void rebuildDocumentVectors(
|
||||||
@@ -153,4 +158,3 @@ public class KnowledgeEmbeddingServiceImpl implements KnowledgeEmbeddingService
|
|||||||
documentCollectionService.updateById(update);
|
documentCollectionService.updateById(update);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package tech.easyflow.ai.support;
|
||||||
|
|
||||||
|
import com.easyagents.core.store.DocumentStore;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档向量库生命周期辅助工具。
|
||||||
|
*/
|
||||||
|
public final class DocumentStoreLifecycleSupport {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentStoreLifecycleSupport.class);
|
||||||
|
|
||||||
|
private DocumentStoreLifecycleSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭支持关闭语义的文档向量库。
|
||||||
|
*
|
||||||
|
* @param documentStore 文档向量库实例
|
||||||
|
*/
|
||||||
|
public static void closeQuietly(DocumentStore documentStore) {
|
||||||
|
if (!(documentStore instanceof AutoCloseable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
((AutoCloseable) documentStore).close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOG.warn("关闭文档向量库连接失败: store={}", documentStore.getClass().getSimpleName(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package tech.easyflow.ai.config;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.springframework.boot.actuate.health.Health;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康检查短缓存测试。
|
||||||
|
*/
|
||||||
|
public class CachedHealthIndicatorSupportTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 TTL 内重复健康检查复用缓存。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldReuseHealthWithinCacheTtl() {
|
||||||
|
RagHealthProperties properties = new RagHealthProperties();
|
||||||
|
properties.setCacheTtl(Duration.ofSeconds(5));
|
||||||
|
MutableClock clock = new MutableClock();
|
||||||
|
CountingHealthIndicator indicator = new CountingHealthIndicator(properties, clock);
|
||||||
|
|
||||||
|
indicator.cachedHealth();
|
||||||
|
indicator.cachedHealth();
|
||||||
|
|
||||||
|
Assert.assertEquals(1, indicator.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 TTL 过期后重新执行健康检查。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRefreshHealthAfterCacheExpired() {
|
||||||
|
RagHealthProperties properties = new RagHealthProperties();
|
||||||
|
properties.setCacheTtl(Duration.ofSeconds(5));
|
||||||
|
MutableClock clock = new MutableClock();
|
||||||
|
CountingHealthIndicator indicator = new CountingHealthIndicator(properties, clock);
|
||||||
|
|
||||||
|
indicator.cachedHealth();
|
||||||
|
clock.plus(Duration.ofSeconds(6));
|
||||||
|
indicator.cachedHealth();
|
||||||
|
|
||||||
|
Assert.assertEquals(2, indicator.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class CountingHealthIndicator extends CachedHealthIndicatorSupport {
|
||||||
|
|
||||||
|
private final AtomicInteger counter = new AtomicInteger();
|
||||||
|
|
||||||
|
private CountingHealthIndicator(RagHealthProperties properties, Clock clock) {
|
||||||
|
super(properties, clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Health doHealthCheck() {
|
||||||
|
counter.incrementAndGet();
|
||||||
|
return Health.up().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int count() {
|
||||||
|
return counter.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class MutableClock extends Clock {
|
||||||
|
|
||||||
|
private Instant instant = Instant.parse("2026-05-25T00:00:00Z");
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ZoneId getZone() {
|
||||||
|
return ZoneId.of("UTC");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Clock withZone(ZoneId zone) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant instant() {
|
||||||
|
return instant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void plus(Duration duration) {
|
||||||
|
instant = instant.plus(duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ package tech.easyflow.approval.config;
|
|||||||
|
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审批模块配置。
|
* 审批模块配置。
|
||||||
*/
|
*/
|
||||||
@MapperScan("tech.easyflow.approval.mapper")
|
@MapperScan("tech.easyflow.approval.mapper")
|
||||||
|
@ComponentScan("tech.easyflow.approval")
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
public class ApprovalModuleConfig {
|
public class ApprovalModuleConfig {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package tech.easyflow.auth.config;
|
package tech.easyflow.auth.config;
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
|
@ComponentScan("tech.easyflow.auth")
|
||||||
public class AuthModuleConfig {
|
public class AuthModuleConfig {
|
||||||
|
|
||||||
public AuthModuleConfig() {
|
public AuthModuleConfig() {
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import org.springframework.context.annotation.ComponentScan;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
@ComponentScan({"tech.easyflow"})
|
@ComponentScan({
|
||||||
|
"tech.easyflow.admin",
|
||||||
|
"tech.easyflow.usercenter",
|
||||||
|
"tech.easyflow.publicapi",
|
||||||
|
"tech.easyflow.common",
|
||||||
|
"tech.easyflow.core",
|
||||||
|
"tech.easyflow.autoconfig"
|
||||||
|
})
|
||||||
@org.springframework.boot.autoconfigure.AutoConfiguration
|
@org.springframework.boot.autoconfigure.AutoConfiguration
|
||||||
public class AutoConfig {
|
public class AutoConfig {
|
||||||
public AutoConfig() {
|
public AutoConfig() {
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
tech.easyflow.autoconfig.config.AutoConfig
|
tech.easyflow.autoconfig.config.AutoConfig
|
||||||
|
tech.easyflow.ai.config.AiModuleConfig
|
||||||
|
tech.easyflow.agent.config.AgentModuleConfig
|
||||||
|
tech.easyflow.approval.config.ApprovalModuleConfig
|
||||||
|
tech.easyflow.auth.config.AuthModuleConfig
|
||||||
|
tech.easyflow.chatlog.config.ChatlogModuleConfig
|
||||||
|
tech.easyflow.datacenter.config.DatacenterModuleConfig
|
||||||
|
tech.easyflow.job.config.JobModuleConfig
|
||||||
|
tech.easyflow.log.config.LogModuleConfig
|
||||||
|
tech.easyflow.system.config.SysModuleConfig
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package tech.easyflow.chatlog.config;
|
|||||||
|
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
@MapperScan("tech.easyflow.chatlog.mapper")
|
@MapperScan("tech.easyflow.chatlog.mapper")
|
||||||
|
@ComponentScan("tech.easyflow.chatlog")
|
||||||
public class ChatlogModuleConfig {
|
public class ChatlogModuleConfig {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ public interface ChatSyncService {
|
|||||||
|
|
||||||
void maintainMysqlTables();
|
void maintainMysqlTables();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行启动期必要的 MySQL 表准备。
|
||||||
|
*/
|
||||||
void startupCheck();
|
void startupCheck();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,9 +173,6 @@ public class ChatSyncServiceImpl implements ChatSyncService {
|
|||||||
@Override
|
@Override
|
||||||
public void startupCheck() {
|
public void startupCheck() {
|
||||||
tableManager.ensureCurrentAndNextMonth();
|
tableManager.ensureCurrentAndNextMonth();
|
||||||
if (analyticalDBRepository.enabled()) {
|
|
||||||
analyticalDBRepository.selfCheck();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearExpiredSessions() {
|
private void clearExpiredSessions() {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package tech.easyflow.datacenter.config;
|
package tech.easyflow.datacenter.config;
|
||||||
|
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
@Configuration
|
@AutoConfiguration
|
||||||
@MapperScan("tech.easyflow.datacenter.mapper")
|
@MapperScan("tech.easyflow.datacenter.mapper")
|
||||||
|
@ComponentScan("tech.easyflow.datacenter")
|
||||||
public class DatacenterModuleConfig {
|
public class DatacenterModuleConfig {
|
||||||
|
|
||||||
public DatacenterModuleConfig() {
|
public DatacenterModuleConfig() {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package tech.easyflow.job.config;
|
package tech.easyflow.job.config;
|
||||||
|
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
@Configuration
|
@AutoConfiguration
|
||||||
@MapperScan("tech.easyflow.job.mapper")
|
@MapperScan("tech.easyflow.job.mapper")
|
||||||
|
@ComponentScan("tech.easyflow.job")
|
||||||
public class JobModuleConfig {
|
public class JobModuleConfig {
|
||||||
|
|
||||||
public JobModuleConfig() {
|
public JobModuleConfig() {
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
package tech.easyflow.log.config;
|
package tech.easyflow.log.config;
|
||||||
|
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
import tech.easyflow.log.reporter.ActionLogReporterProperties;
|
import tech.easyflow.log.reporter.ActionLogReporterProperties;
|
||||||
import tech.easyflow.log.reporter.ActionReportInterceptor;
|
import tech.easyflow.log.reporter.ActionReportInterceptor;
|
||||||
|
|
||||||
@MapperScan("tech.easyflow.log.mapper")
|
@MapperScan("tech.easyflow.log.mapper")
|
||||||
@Configuration
|
@AutoConfiguration
|
||||||
|
@ComponentScan("tech.easyflow.log")
|
||||||
public class LogModuleConfig implements WebMvcConfigurer {
|
public class LogModuleConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
private final ActionLogReporterProperties logProperties;
|
private final ActionLogReporterProperties logProperties;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package tech.easyflow.system.config;
|
|||||||
|
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
@MapperScan("tech.easyflow.system.mapper")
|
@MapperScan("tech.easyflow.system.mapper")
|
||||||
|
@ComponentScan("tech.easyflow.system")
|
||||||
@AutoConfiguration
|
@AutoConfiguration
|
||||||
public class SysModuleConfig {
|
public class SysModuleConfig {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ spring:
|
|||||||
url: jdbc:mysql://127.0.0.1:23306/easyflow?useInformationSchema=true&characterEncoding=utf-8
|
url: jdbc:mysql://127.0.0.1:23306/easyflow?useInformationSchema=true&characterEncoding=utf-8
|
||||||
username: easyflow
|
username: easyflow
|
||||||
password: root
|
password: root
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 20
|
||||||
|
minimum-idle: 4
|
||||||
|
connection-timeout: 5000
|
||||||
|
validation-timeout: 3000
|
||||||
|
idle-timeout: 600000
|
||||||
|
max-lifetime: 1800000
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
host: 127.0.0.1
|
host: 127.0.0.1
|
||||||
@@ -37,6 +44,15 @@ easyflow:
|
|||||||
consumer-block-timeout: 2000ms
|
consumer-block-timeout: 2000ms
|
||||||
pending-claim-idle: 60000ms
|
pending-claim-idle: 60000ms
|
||||||
max-retry: 16
|
max-retry: 16
|
||||||
|
consumer-executor:
|
||||||
|
core-size: 4
|
||||||
|
max-size: 12
|
||||||
|
queue-capacity: 64
|
||||||
|
keep-alive-seconds: 60
|
||||||
|
pool:
|
||||||
|
max-active: 12
|
||||||
|
max-idle: 8
|
||||||
|
min-idle: 1
|
||||||
analytical-db:
|
analytical-db:
|
||||||
enabled: true
|
enabled: true
|
||||||
url: ${EASYFLOW_ANALYTICAL_DB_URL:jdbc:clickhouse://127.0.0.1:8123/easyflow?jdbc_ignore_unsupported_values=true&socket_timeout=30000&compress=false&ssl=false}
|
url: ${EASYFLOW_ANALYTICAL_DB_URL:jdbc:clickhouse://127.0.0.1:8123/easyflow?jdbc_ignore_unsupported_values=true&socket_timeout=30000&compress=false&ssl=false}
|
||||||
@@ -58,3 +74,27 @@ easyflow:
|
|||||||
validate-on-migrate: true
|
validate-on-migrate: true
|
||||||
storage:
|
storage:
|
||||||
type: xFileStorage
|
type: xFileStorage
|
||||||
|
ai:
|
||||||
|
rag:
|
||||||
|
health:
|
||||||
|
cache-ttl: 5s
|
||||||
|
document-import:
|
||||||
|
parse-monitor:
|
||||||
|
fixed-delay: 10000
|
||||||
|
initial-delay: 10000
|
||||||
|
batch-size: 10
|
||||||
|
thread-pool:
|
||||||
|
sse:
|
||||||
|
core-size: 4
|
||||||
|
max-size: 16
|
||||||
|
queue-capacity: 2000
|
||||||
|
keep-alive-seconds: 30
|
||||||
|
allow-core-thread-timeout: true
|
||||||
|
document-import:
|
||||||
|
core-size: 2
|
||||||
|
max-size: 4
|
||||||
|
queue-capacity: 200
|
||||||
|
keep-alive-seconds: 60
|
||||||
|
allow-core-thread-timeout: true
|
||||||
|
scheduler:
|
||||||
|
pool-size: 4
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ spring:
|
|||||||
url: jdbc:mysql://127.0.0.1:33306/easyflow?useInformationSchema=true&characterEncoding=utf-8
|
url: jdbc:mysql://127.0.0.1:33306/easyflow?useInformationSchema=true&characterEncoding=utf-8
|
||||||
username: root
|
username: root
|
||||||
password: root
|
password: root
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: 12
|
||||||
|
minimum-idle: 2
|
||||||
|
connection-timeout: 5000
|
||||||
|
validation-timeout: 3000
|
||||||
|
idle-timeout: 600000
|
||||||
|
max-lifetime: 1800000
|
||||||
flyway:
|
flyway:
|
||||||
enabled: true
|
enabled: true
|
||||||
locations: classpath:db/migration/mysql
|
locations: classpath:db/migration/mysql
|
||||||
@@ -69,7 +76,7 @@ spring:
|
|||||||
tablePrefix: TB_QRTZ_
|
tablePrefix: TB_QRTZ_
|
||||||
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
|
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
|
||||||
threadPool:
|
threadPool:
|
||||||
threadCount: 20
|
threadCount: 8
|
||||||
threadPriority: 5
|
threadPriority: 5
|
||||||
threads:
|
threads:
|
||||||
virtual:
|
virtual:
|
||||||
@@ -104,6 +111,15 @@ easyflow:
|
|||||||
consumer-block-timeout: 2000ms
|
consumer-block-timeout: 2000ms
|
||||||
pending-claim-idle: 60000ms
|
pending-claim-idle: 60000ms
|
||||||
max-retry: 16
|
max-retry: 16
|
||||||
|
consumer-executor:
|
||||||
|
core-size: 4
|
||||||
|
max-size: 12
|
||||||
|
queue-capacity: 64
|
||||||
|
keep-alive-seconds: 60
|
||||||
|
pool:
|
||||||
|
max-active: 12
|
||||||
|
max-idle: 8
|
||||||
|
min-idle: 1
|
||||||
analytical-db:
|
analytical-db:
|
||||||
# 是否启用分析数据库
|
# 是否启用分析数据库
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -148,6 +164,30 @@ easyflow:
|
|||||||
root: /Users/slience/postgraduate/easyflow/attachment
|
root: /Users/slience/postgraduate/easyflow/attachment
|
||||||
# 后端接口地址,用于拼接完整 url
|
# 后端接口地址,用于拼接完整 url
|
||||||
prefix: http://localhost:8111/attachment
|
prefix: http://localhost:8111/attachment
|
||||||
|
ai:
|
||||||
|
rag:
|
||||||
|
health:
|
||||||
|
cache-ttl: 5s
|
||||||
|
document-import:
|
||||||
|
parse-monitor:
|
||||||
|
fixed-delay: 10000
|
||||||
|
initial-delay: 10000
|
||||||
|
batch-size: 10
|
||||||
|
thread-pool:
|
||||||
|
sse:
|
||||||
|
core-size: 4
|
||||||
|
max-size: 16
|
||||||
|
queue-capacity: 2000
|
||||||
|
keep-alive-seconds: 30
|
||||||
|
allow-core-thread-timeout: true
|
||||||
|
document-import:
|
||||||
|
core-size: 2
|
||||||
|
max-size: 4
|
||||||
|
queue-capacity: 200
|
||||||
|
keep-alive-seconds: 60
|
||||||
|
allow-core-thread-timeout: true
|
||||||
|
scheduler:
|
||||||
|
pool-size: 4
|
||||||
|
|
||||||
# xFileStorage存储文件配置
|
# xFileStorage存储文件配置
|
||||||
# 文档:https://x-file-storage.xuyanwu.cn/
|
# 文档:https://x-file-storage.xuyanwu.cn/
|
||||||
@@ -211,9 +251,9 @@ jetcache:
|
|||||||
valueEncoder: java
|
valueEncoder: java
|
||||||
valueDecoder: java
|
valueDecoder: java
|
||||||
poolConfig:
|
poolConfig:
|
||||||
minIdle: 5
|
minIdle: 1
|
||||||
maxIdle: 20
|
maxIdle: 12
|
||||||
maxTotal: 50
|
maxTotal: 32
|
||||||
host: ${spring.data.redis.host}
|
host: ${spring.data.redis.host}
|
||||||
port: ${spring.data.redis.port}
|
port: ${spring.data.redis.port}
|
||||||
password: ${spring.data.redis.password}
|
password: ${spring.data.redis.password}
|
||||||
|
|||||||
@@ -0,0 +1,459 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed, ref, watch} from 'vue';
|
||||||
|
|
||||||
|
import {ArrowRight, Check, Close, Collection, Plus,} from '@element-plus/icons-vue';
|
||||||
|
import {ElPopover} from 'element-plus';
|
||||||
|
|
||||||
|
interface KnowledgeOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnowledgeView {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
disabled?: boolean;
|
||||||
|
extraKnowledgeIds?: string[];
|
||||||
|
knowledgeOptions?: KnowledgeOption[];
|
||||||
|
loading?: boolean;
|
||||||
|
selectedKnowledges?: KnowledgeView[];
|
||||||
|
showTrigger?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
extraKnowledgeIds: () => [],
|
||||||
|
knowledgeOptions: () => [],
|
||||||
|
loading: false,
|
||||||
|
selectedKnowledges: () => [],
|
||||||
|
showTrigger: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:extraKnowledgeIds': [value: string[]];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const MAX_EXTRA_KNOWLEDGE_COUNT = 3;
|
||||||
|
const rootOpen = ref(false);
|
||||||
|
const knowledgeOpen = ref(false);
|
||||||
|
const selectedIdSet = computed(
|
||||||
|
() => new Set(props.extraKnowledgeIds.map(String)),
|
||||||
|
);
|
||||||
|
|
||||||
|
function setKnowledgeOpen(open: boolean) {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
knowledgeOpen.value = open;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(rootOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
knowledgeOpen.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateKnowledgeIds(value: string[]) {
|
||||||
|
emit('update:extraKnowledgeIds', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(id: string) {
|
||||||
|
return selectedIdSet.value.has(String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnowledgeDisabled(id: string) {
|
||||||
|
return (
|
||||||
|
props.disabled ||
|
||||||
|
props.loading ||
|
||||||
|
(props.extraKnowledgeIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT &&
|
||||||
|
!isSelected(id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleKnowledge(id: string) {
|
||||||
|
const normalizedId = String(id);
|
||||||
|
const nextIds = props.extraKnowledgeIds.map(String);
|
||||||
|
const currentIndex = nextIds.indexOf(normalizedId);
|
||||||
|
if (currentIndex !== -1) {
|
||||||
|
nextIds.splice(currentIndex, 1);
|
||||||
|
updateKnowledgeIds(nextIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextIds.length >= MAX_EXTRA_KNOWLEDGE_COUNT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nextIds.push(normalizedId);
|
||||||
|
updateKnowledgeIds(nextIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKnowledge(id: string) {
|
||||||
|
updateKnowledgeIds(
|
||||||
|
props.extraKnowledgeIds.filter((item) => String(item) !== String(id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="showTrigger || selectedKnowledges.length > 0"
|
||||||
|
class="chat-capability"
|
||||||
|
>
|
||||||
|
<div v-if="selectedKnowledges.length > 0" class="chat-capability__chips">
|
||||||
|
<span
|
||||||
|
v-for="knowledge in selectedKnowledges"
|
||||||
|
:key="knowledge.id"
|
||||||
|
class="chat-capability__chip"
|
||||||
|
>
|
||||||
|
<Collection class="chat-capability__chip-icon" />
|
||||||
|
<span class="chat-capability__chip-label">{{ knowledge.title }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-capability__chip-remove"
|
||||||
|
aria-label="移除知识库"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="removeKnowledge(knowledge.id)"
|
||||||
|
>
|
||||||
|
<Close />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElPopover
|
||||||
|
v-if="showTrigger"
|
||||||
|
v-model:visible="rootOpen"
|
||||||
|
placement="top-start"
|
||||||
|
:show-arrow="false"
|
||||||
|
:width="120"
|
||||||
|
:offset="8"
|
||||||
|
popper-class="chat-capability-popper"
|
||||||
|
trigger="click"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-capability__trigger"
|
||||||
|
:class="{ 'is-open': rootOpen }"
|
||||||
|
:disabled="disabled"
|
||||||
|
aria-label="添加能力"
|
||||||
|
title="添加能力"
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="chat-capability__menu" @mouseleave="setKnowledgeOpen(false)">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chat-capability__menu-item"
|
||||||
|
:class="{ 'is-open': knowledgeOpen }"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="setKnowledgeOpen(true)"
|
||||||
|
@focus="setKnowledgeOpen(true)"
|
||||||
|
@mouseenter="setKnowledgeOpen(true)"
|
||||||
|
>
|
||||||
|
<Collection class="chat-capability__menu-icon" />
|
||||||
|
<span>知识库</span>
|
||||||
|
<ArrowRight class="chat-capability__menu-arrow" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="knowledgeOpen"
|
||||||
|
class="chat-capability__knowledge-panel"
|
||||||
|
@mouseenter="setKnowledgeOpen(true)"
|
||||||
|
@mouseleave="setKnowledgeOpen(false)"
|
||||||
|
>
|
||||||
|
<div class="chat-capability__knowledge-head">
|
||||||
|
<span>选择知识库</span>
|
||||||
|
<span>最多 3 个</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="knowledgeOptions.length > 0"
|
||||||
|
class="chat-capability__knowledge-list"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="item in knowledgeOptions"
|
||||||
|
:key="item.value"
|
||||||
|
type="button"
|
||||||
|
class="chat-capability__knowledge-item"
|
||||||
|
:class="{
|
||||||
|
'is-active': isSelected(item.value),
|
||||||
|
'is-disabled': isKnowledgeDisabled(item.value),
|
||||||
|
}"
|
||||||
|
:disabled="isKnowledgeDisabled(item.value)"
|
||||||
|
@click.stop="toggleKnowledge(item.value)"
|
||||||
|
>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<Check
|
||||||
|
v-if="isSelected(item.value)"
|
||||||
|
class="chat-capability__knowledge-check"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="chat-capability__empty">
|
||||||
|
{{ loading ? '加载中' : '暂无可选知识库' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElPopover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-capability {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 220px;
|
||||||
|
min-height: 28px;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
border: 1px solid var(--el-color-primary-light-7);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__chip-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__chip-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__chip-remove,
|
||||||
|
.chat-capability__trigger,
|
||||||
|
.chat-capability__menu-item,
|
||||||
|
.chat-capability__knowledge-item {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__chip-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__chip-remove:hover:not(:disabled),
|
||||||
|
.chat-capability__chip-remove:focus-visible {
|
||||||
|
background: var(--el-color-primary-light-8);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__chip-remove :deep(.el-icon),
|
||||||
|
.chat-capability__chip-remove svg {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition:
|
||||||
|
background-color 0.16s ease,
|
||||||
|
color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__trigger:hover:not(:disabled),
|
||||||
|
.chat-capability__trigger:focus-visible,
|
||||||
|
.chat-capability__trigger.is-open {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__trigger:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__trigger svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__menu,
|
||||||
|
.chat-capability__knowledge-panel {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__menu-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__menu-item:hover:not(:disabled),
|
||||||
|
.chat-capability__menu-item:focus-visible,
|
||||||
|
.chat-capability__menu-item.is-open {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__menu-item:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__menu-icon,
|
||||||
|
.chat-capability__menu-arrow {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: calc(100% + 4px);
|
||||||
|
width: 296px;
|
||||||
|
background: var(--el-bg-color-overlay);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-item span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-item:hover:not(:disabled),
|
||||||
|
.chat-capability__knowledge-item:focus-visible,
|
||||||
|
.chat-capability__knowledge-item.is-active {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-item.is-active {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-item:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__knowledge-check {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-capability__empty {
|
||||||
|
padding: 16px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.chat-capability-popper) {
|
||||||
|
width: 120px !important;
|
||||||
|
min-width: 120px !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
padding: 4px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.chat-capability-popper .chat-capability__menu) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.chat-capability-popper.el-popper) {
|
||||||
|
transition:
|
||||||
|
opacity 0.08s ease,
|
||||||
|
transform 0.08s ease !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {ChatInputTriggerItem} from './input-triggers/types';
|
||||||
|
|
||||||
|
import {Collection} from '@element-plus/icons-vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
activeIndex?: number;
|
||||||
|
groupLabel?: string;
|
||||||
|
items?: ChatInputTriggerItem[];
|
||||||
|
keyword?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [item: ChatInputTriggerItem, index: number];
|
||||||
|
setActive: [index: number];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chat-input-trigger-panel">
|
||||||
|
<div v-if="groupLabel" class="chat-input-trigger-panel__head">
|
||||||
|
{{ groupLabel }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="items && items.length > 0"
|
||||||
|
class="chat-input-trigger-panel__list"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="chat-input-trigger-panel__item"
|
||||||
|
:class="{ 'is-active': index === activeIndex }"
|
||||||
|
:disabled="item.disabled"
|
||||||
|
@mouseenter="emit('setActive', index)"
|
||||||
|
@mousedown.prevent="emit('select', item, index)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon || Collection"
|
||||||
|
class="chat-input-trigger-panel__icon"
|
||||||
|
/>
|
||||||
|
<span class="chat-input-trigger-panel__content">
|
||||||
|
<span class="chat-input-trigger-panel__label">{{ item.label }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="chat-input-trigger-panel__empty">
|
||||||
|
{{ keyword ? '没有匹配项' : '暂无可选项' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-input-trigger-panel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--el-fill-color-extra-light);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__head {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 32px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__item:hover:not(:disabled),
|
||||||
|
.chat-input-trigger-panel__item.is-active {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__item:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary-light-5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__item:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: none;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__content {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-trigger-panel__empty {
|
||||||
|
padding: 18px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type {Component} from 'vue';
|
||||||
|
|
||||||
|
export type ChatInputTriggerSymbol = '#' | '$' | '/' | '@';
|
||||||
|
|
||||||
|
export interface ChatInputTriggerItem {
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: Component;
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatInputTriggerGroup {
|
||||||
|
items: ChatInputTriggerItem[];
|
||||||
|
label: string;
|
||||||
|
symbol: ChatInputTriggerSymbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatInputTriggerMatch {
|
||||||
|
keyword: string;
|
||||||
|
start: number;
|
||||||
|
symbol: ChatInputTriggerSymbol;
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import type {Ref} from 'vue';
|
||||||
|
import {computed, nextTick, ref} from 'vue';
|
||||||
|
|
||||||
|
import type {ChatInputTriggerGroup, ChatInputTriggerMatch, ChatInputTriggerSymbol,} from './types';
|
||||||
|
|
||||||
|
const TRIGGER_SYMBOLS = new Set<ChatInputTriggerSymbol>(['#', '$', '/', '@']);
|
||||||
|
const TRIGGER_BOUNDARY = /[\s,。!?;:,.!?;:()[\]{}]/;
|
||||||
|
|
||||||
|
export interface ChatInputTriggerOptions {
|
||||||
|
disabled: Ref<boolean>;
|
||||||
|
groups: Ref<ChatInputTriggerGroup[]>;
|
||||||
|
inputRef: Ref<undefined | unknown>;
|
||||||
|
text: Ref<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理聊天输入框快捷触发状态。
|
||||||
|
*
|
||||||
|
* @param options 触发器依赖
|
||||||
|
* @return 输入触发状态与操作方法
|
||||||
|
*/
|
||||||
|
export function useChatInputTrigger(options: ChatInputTriggerOptions) {
|
||||||
|
const activeMatch = ref<ChatInputTriggerMatch>();
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
|
||||||
|
const activeGroup = computed(() => {
|
||||||
|
if (!activeMatch.value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return options.groups.value.find(
|
||||||
|
(group) => group.symbol === activeMatch.value?.symbol,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleItems = computed(() => {
|
||||||
|
const group = activeGroup.value;
|
||||||
|
if (!group) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const keyword = activeMatch.value?.keyword.trim().toLowerCase() || '';
|
||||||
|
if (!keyword) {
|
||||||
|
return group.items;
|
||||||
|
}
|
||||||
|
return group.items.filter((item) => {
|
||||||
|
const label = item.label.toLowerCase();
|
||||||
|
const description = item.description?.toLowerCase() || '';
|
||||||
|
return label.includes(keyword) || description.includes(keyword);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const activePanel = computed(() => {
|
||||||
|
const group = activeGroup.value;
|
||||||
|
if (!activeMatch.value || !group) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
groupLabel: group.label,
|
||||||
|
keyword: activeMatch.value.keyword,
|
||||||
|
symbol: activeMatch.value.symbol,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function getTextareaElement() {
|
||||||
|
const rawRef = options.inputRef.value as
|
||||||
|
| undefined
|
||||||
|
| { textarea?: HTMLTextAreaElement };
|
||||||
|
return rawRef?.textarea;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
activeMatch.value = undefined;
|
||||||
|
activeIndex.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
if (options.disabled.value) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const textarea = getTextareaElement();
|
||||||
|
const cursor = textarea?.selectionStart ?? options.text.value.length;
|
||||||
|
const match = findTriggerMatch(options.text.value, cursor);
|
||||||
|
if (!match) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeMatch.value = match;
|
||||||
|
activeIndex.value = Math.min(
|
||||||
|
activeIndex.value,
|
||||||
|
Math.max(visibleItems.value.length - 1, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceTriggerText(replacement = '') {
|
||||||
|
const match = activeMatch.value;
|
||||||
|
if (!match) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const textarea = getTextareaElement();
|
||||||
|
const cursor = textarea?.selectionStart ?? options.text.value.length;
|
||||||
|
const before = options.text.value.slice(0, match.start);
|
||||||
|
const after = options.text.value.slice(cursor);
|
||||||
|
const nextText = `${before}${replacement}${after}`;
|
||||||
|
const nextCursor = before.length + replacement.length;
|
||||||
|
options.text.value = nextText;
|
||||||
|
close();
|
||||||
|
await nextTick();
|
||||||
|
textarea?.focus();
|
||||||
|
textarea?.setSelectionRange(nextCursor, nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(delta: number) {
|
||||||
|
const total = visibleItems.value.length;
|
||||||
|
if (!activeMatch.value || total === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeIndex.value = (activeIndex.value + delta + total) % total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveIndex(index: number) {
|
||||||
|
if (index < 0 || index >= visibleItems.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeIndex.value = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTriggerMatch(
|
||||||
|
text: string,
|
||||||
|
cursor: number,
|
||||||
|
): ChatInputTriggerMatch | undefined {
|
||||||
|
const beforeCursor = text.slice(0, cursor);
|
||||||
|
for (let index = beforeCursor.length - 1; index >= 0; index--) {
|
||||||
|
const char = beforeCursor[index];
|
||||||
|
if (!char) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (TRIGGER_BOUNDARY.test(char)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!TRIGGER_SYMBOLS.has(char as ChatInputTriggerSymbol)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const previous = index === 0 ? '' : beforeCursor[index - 1];
|
||||||
|
if (previous && !TRIGGER_BOUNDARY.test(previous)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
keyword: beforeCursor.slice(index + 1),
|
||||||
|
start: index,
|
||||||
|
symbol: char as ChatInputTriggerSymbol,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeIndex,
|
||||||
|
activePanel,
|
||||||
|
close,
|
||||||
|
move,
|
||||||
|
replaceTriggerText,
|
||||||
|
setActiveIndex,
|
||||||
|
sync,
|
||||||
|
visibleItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: '智能体聊天',
|
title: '智能体聊天',
|
||||||
fullPathKey: false,
|
fullPathKey: false,
|
||||||
hideInMenu: true,
|
hideInMenu: true,
|
||||||
activePath: '/ai/agents',
|
activePath: '/ai/agent-chat',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -111,6 +111,135 @@ describe('agentTimelineAdapter', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides knowledge retrieval cards when restoring agent chat history', () => {
|
||||||
|
const items = recordsToTimelineItems([
|
||||||
|
{
|
||||||
|
id: '417197643647811584',
|
||||||
|
senderRole: 'assistant',
|
||||||
|
contentText:
|
||||||
|
'根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。',
|
||||||
|
roundId: '417197622424633344',
|
||||||
|
contentPayload: {
|
||||||
|
chains: [
|
||||||
|
{
|
||||||
|
id: 'call_0fc660e9d203416983ccca7e',
|
||||||
|
name: 'retrieve_knowledge',
|
||||||
|
result: 'Retrieved 2 relevant document(s)',
|
||||||
|
status: 'TOOL_RESULT',
|
||||||
|
arguments: {
|
||||||
|
query: '暑假时间',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
agentResult: {
|
||||||
|
text: '根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。',
|
||||||
|
knowledgeReferences: [
|
||||||
|
{
|
||||||
|
chunkContent:
|
||||||
|
'问题:2026 年暑假安排\n答案:2026 年7 月 1 日到 8 月 15 日',
|
||||||
|
documentId: '411358369563336704',
|
||||||
|
knowledgeName: 'faq',
|
||||||
|
knowledgeType: 'FAQ',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
messageChain: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: 'call_0fc660e9d203416983ccca7e',
|
||||||
|
name: 'retrieve_knowledge',
|
||||||
|
arguments: '{query=暑假时间}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
content: 'Retrieved 2 relevant document(s)',
|
||||||
|
toolCallId: 'call_0fc660e9d203416983ccca7e',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'根据知识库中的信息,2026年暑假的时间安排是:7月1日到8月15日。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.some((item) => item.type === 'tool')).toBe(false);
|
||||||
|
expect(
|
||||||
|
items.some(
|
||||||
|
(item) => item.type === 'status' && item.label === '已检索知识库',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
items.some((item) => item.type === 'message' && item.role === 'assistant'),
|
||||||
|
).toBe(true);
|
||||||
|
const assistant = items.find(
|
||||||
|
(item): item is ChatTimelineMessageItem =>
|
||||||
|
item.type === 'message' && item.role === 'assistant',
|
||||||
|
);
|
||||||
|
expect(assistant?.knowledgeItems?.[0]?.knowledgeName).toBe('faq');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides internal fragment and context reload tools from history', () => {
|
||||||
|
const items = recordsToTimelineItems([
|
||||||
|
{
|
||||||
|
id: 'internal-tools',
|
||||||
|
senderRole: 'assistant',
|
||||||
|
contentText: '已处理',
|
||||||
|
roundId: 'round-internal',
|
||||||
|
contentPayload: {
|
||||||
|
chains: [
|
||||||
|
{
|
||||||
|
id: 'fragment-1',
|
||||||
|
name: '__fragment__',
|
||||||
|
status: 'TOOL_RESULT',
|
||||||
|
result: 'fragment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'context-1',
|
||||||
|
name: 'context_reload',
|
||||||
|
status: 'TOOL_RESULT',
|
||||||
|
result: 'reload',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
agentResult: {
|
||||||
|
text: '已处理',
|
||||||
|
},
|
||||||
|
messageChain: [
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
toolCalls: [
|
||||||
|
{ id: 'fragment-1', name: '__fragment__' },
|
||||||
|
{ id: 'context-1', name: 'context_reload' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: 'fragment-1',
|
||||||
|
content: 'fragment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: 'context-1',
|
||||||
|
content: 'reload',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items.some((item) => item.type === 'tool')).toBe(false);
|
||||||
|
expect(items.some((item) => item.type === 'status')).toBe(false);
|
||||||
|
expect(
|
||||||
|
items.some((item) => item.type === 'message' && item.role === 'assistant'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('parses raw SSE text as message delta', () => {
|
it('parses raw SSE text as message delta', () => {
|
||||||
const envelope = parseAgentSseMessage({
|
const envelope = parseAgentSseMessage({
|
||||||
data: 'hello',
|
data: 'hello',
|
||||||
@@ -227,4 +356,123 @@ describe('agentTimelineAdapter', () => {
|
|||||||
expect(assistant?.roundId).toBe('runtime-round-1');
|
expect(assistant?.roundId).toBe('runtime-round-1');
|
||||||
expect(assistant?.parts[0]?.content).toBe('准备调用工具');
|
expect(assistant?.parts[0]?.content).toBe('准备调用工具');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates memory compression status within the current round', () => {
|
||||||
|
const items: any[] = [];
|
||||||
|
|
||||||
|
applyAgentSseEnvelope(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
domain: 'BUSINESS',
|
||||||
|
type: 'STATUS',
|
||||||
|
payload: {
|
||||||
|
label: '正在整理上下文',
|
||||||
|
phase: 'started',
|
||||||
|
status: 'running',
|
||||||
|
statusKey: 'memory-compression',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ roundId: 'round-a' },
|
||||||
|
);
|
||||||
|
applyAgentSseEnvelope(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
domain: 'BUSINESS',
|
||||||
|
type: 'STATUS',
|
||||||
|
payload: {
|
||||||
|
compressed: true,
|
||||||
|
label: '已整理上下文',
|
||||||
|
phase: 'completed',
|
||||||
|
status: 'done',
|
||||||
|
statusKey: 'memory-compression',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ roundId: 'round-a' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const statuses = items.filter((item) => item.type === 'status');
|
||||||
|
expect(statuses).toHaveLength(1);
|
||||||
|
expect(statuses[0]?.label).toBe('已整理上下文');
|
||||||
|
expect(statuses[0]?.status).toBe('done');
|
||||||
|
expect(statuses[0]?.statusKey).toBe('memory-compression:round-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps memory compression statuses isolated by round', () => {
|
||||||
|
const items: any[] = [];
|
||||||
|
|
||||||
|
applyAgentSseEnvelope(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
domain: 'BUSINESS',
|
||||||
|
type: 'STATUS',
|
||||||
|
payload: {
|
||||||
|
compressed: true,
|
||||||
|
label: '已整理上下文',
|
||||||
|
phase: 'completed',
|
||||||
|
status: 'done',
|
||||||
|
statusKey: 'memory-compression',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ roundId: 'round-a' },
|
||||||
|
);
|
||||||
|
applyAgentSseEnvelope(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
domain: 'BUSINESS',
|
||||||
|
type: 'STATUS',
|
||||||
|
payload: {
|
||||||
|
compressed: false,
|
||||||
|
label: '无需压缩上下文',
|
||||||
|
phase: 'completed',
|
||||||
|
status: 'done',
|
||||||
|
statusKey: 'memory-compression',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ roundId: 'round-b' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const statuses = items.filter((item) => item.type === 'status');
|
||||||
|
expect(statuses).toHaveLength(1);
|
||||||
|
expect(statuses[0]?.statusKey).toBe('memory-compression:round-a');
|
||||||
|
expect(statuses[0]?.label).toBe('已整理上下文');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show no-compression status before a later compression run', () => {
|
||||||
|
const items: any[] = [];
|
||||||
|
|
||||||
|
applyAgentSseEnvelope(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
domain: 'BUSINESS',
|
||||||
|
type: 'STATUS',
|
||||||
|
payload: {
|
||||||
|
compressed: false,
|
||||||
|
label: '无需压缩上下文',
|
||||||
|
phase: 'completed',
|
||||||
|
status: 'done',
|
||||||
|
statusKey: 'memory-compression',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ roundId: 'round-a' },
|
||||||
|
);
|
||||||
|
applyAgentSseEnvelope(
|
||||||
|
items,
|
||||||
|
{
|
||||||
|
domain: 'BUSINESS',
|
||||||
|
type: 'STATUS',
|
||||||
|
payload: {
|
||||||
|
label: '正在整理上下文',
|
||||||
|
phase: 'started',
|
||||||
|
status: 'running',
|
||||||
|
statusKey: 'memory-compression',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ roundId: 'round-a' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const statuses = items.filter((item) => item.type === 'status');
|
||||||
|
expect(statuses).toHaveLength(1);
|
||||||
|
expect(statuses[0]?.label).toBe('正在整理上下文');
|
||||||
|
expect(statuses[0]?.status).toBe('running');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,38 @@ function normalizeToolCallId(payload: Record<string, any>) {
|
|||||||
return asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id);
|
return asText(payload.toolCallId ?? payload.tool_call_id ?? payload.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBlankToolName(value: unknown) {
|
||||||
|
return !normalizeToolName(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipToolProjection(value: unknown) {
|
||||||
|
const normalizedName = normalizeToolName(value).toLowerCase();
|
||||||
|
return (
|
||||||
|
normalizedName === 'context_reload' ||
|
||||||
|
normalizedName === '__fragment__'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCallName(payload: Record<string, any>) {
|
||||||
|
const fn = asRecord(payload.function);
|
||||||
|
return normalizeToolName(payload.name ?? payload.toolName ?? fn.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCallInput(payload: Record<string, any>) {
|
||||||
|
const fn = asRecord(payload.function);
|
||||||
|
return payload.arguments ?? payload.input ?? fn.arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusKeyForProjection(
|
||||||
|
payload: Record<string, any>,
|
||||||
|
metadata?: Partial<ChatTimelineMessageItem>,
|
||||||
|
fallback = 'status',
|
||||||
|
) {
|
||||||
|
const statusKey = asText(payload.statusKey) || fallback;
|
||||||
|
const roundId = asText(metadata?.roundId);
|
||||||
|
return roundId ? `${statusKey}:${roundId}` : statusKey;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMetadata(record: AgentChatMessageRecord) {
|
function normalizeMetadata(record: AgentChatMessageRecord) {
|
||||||
return {
|
return {
|
||||||
createdAt: asTimestamp(record.created),
|
createdAt: asTimestamp(record.created),
|
||||||
@@ -139,14 +171,10 @@ function appendAssistantText(
|
|||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ChatTimelineBuilder.appendMessageDelta(
|
ChatTimelineBuilder.appendMessageDelta(items, text, {
|
||||||
items,
|
|
||||||
text,
|
|
||||||
{
|
|
||||||
...assistantMetadata(record, suffix),
|
...assistantMetadata(record, suffix),
|
||||||
...metadata,
|
...metadata,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendAssistantThinking(
|
function appendAssistantThinking(
|
||||||
@@ -160,14 +188,10 @@ function appendAssistantThinking(
|
|||||||
if (!text) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ChatTimelineBuilder.appendThinkingDelta(
|
ChatTimelineBuilder.appendThinkingDelta(items, text, {
|
||||||
items,
|
|
||||||
text,
|
|
||||||
{
|
|
||||||
...assistantMetadata(record, suffix),
|
...assistantMetadata(record, suffix),
|
||||||
...metadata,
|
...metadata,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectHistoryChain(
|
function projectHistoryChain(
|
||||||
@@ -177,6 +201,7 @@ function projectHistoryChain(
|
|||||||
const payload = asRecord(record.contentPayload);
|
const payload = asRecord(record.contentPayload);
|
||||||
let hasAssistantText = false;
|
let hasAssistantText = false;
|
||||||
let hasAssistantThinking = false;
|
let hasAssistantThinking = false;
|
||||||
|
const toolNameByCallId = new Map<string, string>();
|
||||||
const displayChains = asArray(payload.displayChains ?? payload.chains);
|
const displayChains = asArray(payload.displayChains ?? payload.chains);
|
||||||
for (const chain of displayChains) {
|
for (const chain of displayChains) {
|
||||||
const item = asRecord(chain);
|
const item = asRecord(chain);
|
||||||
@@ -187,12 +212,21 @@ function projectHistoryChain(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const toolName = normalizeToolName(item.name ?? item.toolName);
|
const toolName = normalizeToolName(item.name ?? item.toolName);
|
||||||
if (toolName) {
|
const toolCallId = normalizeToolCallId(item);
|
||||||
|
if (toolCallId && toolName) {
|
||||||
|
toolNameByCallId.set(toolCallId, toolName);
|
||||||
|
}
|
||||||
|
if (toolName && !shouldSkipToolProjection(toolName)) {
|
||||||
ChatTimelineBuilder.upsertToolCall(items, {
|
ChatTimelineBuilder.upsertToolCall(items, {
|
||||||
input: item.arguments ?? item.input,
|
input: item.arguments ?? item.input,
|
||||||
output: item.result ?? item.output,
|
output: item.result ?? item.output,
|
||||||
status: asText(item.status) === 'TOOL_RESULT' ? 'success' : 'running',
|
status: asText(item.status) === 'TOOL_RESULT' ? 'success' : 'running',
|
||||||
toolCallId: asText(item.id ?? item.toolCallId),
|
statusKey: statusKeyForProjection(
|
||||||
|
item,
|
||||||
|
normalizeMetadata(record),
|
||||||
|
'knowledge-retrieval',
|
||||||
|
),
|
||||||
|
toolCallId,
|
||||||
toolName,
|
toolName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -213,21 +247,45 @@ function projectHistoryChain(
|
|||||||
}
|
}
|
||||||
for (const toolCall of asArray(item.toolCalls)) {
|
for (const toolCall of asArray(item.toolCalls)) {
|
||||||
const tool = asRecord(toolCall);
|
const tool = asRecord(toolCall);
|
||||||
|
const toolCallId = normalizeToolCallId(tool);
|
||||||
|
const toolName = normalizeToolCallName(tool);
|
||||||
|
if (toolCallId && toolName) {
|
||||||
|
toolNameByCallId.set(toolCallId, toolName);
|
||||||
|
}
|
||||||
|
if (isBlankToolName(toolName) || shouldSkipToolProjection(toolName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ChatTimelineBuilder.upsertToolCall(items, {
|
ChatTimelineBuilder.upsertToolCall(items, {
|
||||||
input: tool.arguments ?? tool.input,
|
input: normalizeToolCallInput(tool),
|
||||||
status: 'running',
|
status: 'running',
|
||||||
toolCallId: asText(tool.id ?? tool.toolCallId),
|
statusKey: statusKeyForProjection(
|
||||||
toolName: normalizeToolName(tool.name ?? tool.toolName),
|
tool,
|
||||||
|
normalizeMetadata(record),
|
||||||
|
'knowledge-retrieval',
|
||||||
|
),
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (role === 'tool') {
|
if (role === 'tool') {
|
||||||
|
const toolCallId = normalizeToolCallId(item);
|
||||||
|
const toolName =
|
||||||
|
normalizeToolCallName(item) || toolNameByCallId.get(toolCallId) || '';
|
||||||
|
if (isBlankToolName(toolName) || shouldSkipToolProjection(toolName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ChatTimelineBuilder.upsertToolCall(items, {
|
ChatTimelineBuilder.upsertToolCall(items, {
|
||||||
output: item.content ?? item.result,
|
output: item.content ?? item.result,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
toolCallId: asText(item.toolCallId ?? item.id),
|
statusKey: statusKeyForProjection(
|
||||||
toolName: normalizeToolName(item.name ?? item.toolName) || '工具调用',
|
item,
|
||||||
|
normalizeMetadata(record),
|
||||||
|
'knowledge-retrieval',
|
||||||
|
),
|
||||||
|
toolCallId,
|
||||||
|
toolName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,7 +427,11 @@ export function applyAgentSseEnvelope(
|
|||||||
input: payload.input ?? payload.toolInput,
|
input: payload.input ?? payload.toolInput,
|
||||||
output: payload.output ?? payload.result ?? payload.text,
|
output: payload.output ?? payload.result ?? payload.text,
|
||||||
status: type === 'TOOL_RESULT' ? 'success' : 'running',
|
status: type === 'TOOL_RESULT' ? 'success' : 'running',
|
||||||
statusKey: asText(payload.statusKey) || undefined,
|
statusKey: statusKeyForProjection(
|
||||||
|
payload,
|
||||||
|
metadata,
|
||||||
|
'knowledge-retrieval',
|
||||||
|
),
|
||||||
toolCallId: normalizeToolCallId(payload),
|
toolCallId: normalizeToolCallId(payload),
|
||||||
toolName: normalizeToolName(
|
toolName: normalizeToolName(
|
||||||
payload.toolDisplayName ?? payload.toolName ?? payload.name,
|
payload.toolDisplayName ?? payload.toolName ?? payload.name,
|
||||||
@@ -394,7 +456,11 @@ export function applyAgentSseEnvelope(
|
|||||||
label: asText(payload.label),
|
label: asText(payload.label),
|
||||||
phase: asText(payload.phase),
|
phase: asText(payload.phase),
|
||||||
status: asText(payload.status),
|
status: asText(payload.status),
|
||||||
statusKey: asText(payload.statusKey),
|
statusKey: statusKeyForProjection(
|
||||||
|
payload,
|
||||||
|
metadata,
|
||||||
|
'memory-compression',
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -402,7 +468,7 @@ export function applyAgentSseEnvelope(
|
|||||||
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
|
ChatTimelineBuilder.upsertKnowledgeRetrievalStatus(
|
||||||
items,
|
items,
|
||||||
asText(payload.status) === 'running' ? 'running' : 'done',
|
asText(payload.status) === 'running' ? 'running' : 'done',
|
||||||
asText(payload.statusKey),
|
statusKeyForProjection(payload, metadata, 'knowledge-retrieval'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type {ChatTimelineItem} from '@easyflow/common-ui';
|
import type {ChatTimelineItem} from '@easyflow/common-ui';
|
||||||
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
import {ChatTimelineBuilder} from '@easyflow/common-ui';
|
||||||
|
|
||||||
|
import type {AgentChatCapabilityPayload} from './api';
|
||||||
import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api';
|
import {generateAgentSessionId, sendAgentChat, stopAgentChatStream,} from './api';
|
||||||
|
|
||||||
import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter';
|
import {applyAgentSseEnvelope, parseAgentSseMessage,} from './adapters/agentTimelineAdapter';
|
||||||
|
|
||||||
interface RuntimeSessionState {
|
interface RuntimeSessionState {
|
||||||
@@ -34,6 +36,7 @@ interface StartOptions {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
baseItems?: ChatTimelineItem[];
|
baseItems?: ChatTimelineItem[];
|
||||||
|
capabilities?: AgentChatCapabilityPayload[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
@@ -47,7 +50,8 @@ const listeners = new Set<() => void>();
|
|||||||
let latestSessionId = '';
|
let latestSessionId = '';
|
||||||
|
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
return JSON.parse(JSON.stringify(value)) as T;
|
const serialized = JSON.stringify(value);
|
||||||
|
return JSON.parse(serialized) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRoundId() {
|
function createRoundId() {
|
||||||
@@ -225,6 +229,7 @@ export const agentChatRuntimeManager = {
|
|||||||
void sendAgentChat(
|
void sendAgentChat(
|
||||||
{
|
{
|
||||||
agentId: options.agentId,
|
agentId: options.agentId,
|
||||||
|
capabilities: options.capabilities,
|
||||||
prompt: options.prompt,
|
prompt: options.prompt,
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ export interface AgentChatSessionView {
|
|||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentChatKnowledgeView {
|
||||||
|
alias?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
id?: number | string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentChatSessionDetailView extends AgentChatSessionView {
|
||||||
|
boundKnowledges?: AgentChatKnowledgeView[];
|
||||||
|
extraKnowledges?: AgentChatKnowledgeView[];
|
||||||
|
removedExtraKnowledgeNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentChatSessionPage {
|
export interface AgentChatSessionPage {
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -58,6 +72,11 @@ export interface AgentChatConversationView {
|
|||||||
variantsByRound?: Record<string, AgentChatMessageRecord[]>;
|
variantsByRound?: Record<string, AgentChatMessageRecord[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentChatCapabilityPayload {
|
||||||
|
resourceIds: Array<number | string>;
|
||||||
|
type: 'KNOWLEDGE';
|
||||||
|
}
|
||||||
|
|
||||||
export function getPublishedAgents() {
|
export function getPublishedAgents() {
|
||||||
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/list', {
|
return api.get<RequestResult<AgentInfo[]>>('/api/v1/agent/list', {
|
||||||
params: { publishedOnly: true },
|
params: { publishedOnly: true },
|
||||||
@@ -69,11 +88,20 @@ export function generateAgentSessionId() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAgentSession(sessionId: number | string) {
|
export function getAgentSession(sessionId: number | string) {
|
||||||
return api.get<RequestResult<AgentChatSessionView>>(
|
return api.get<RequestResult<AgentChatSessionDetailView>>(
|
||||||
`/api/v1/agent/session/${sessionId}`,
|
`/api/v1/agent/session/${sessionId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPublishedKnowledges() {
|
||||||
|
return api.get<RequestResult<AgentChatKnowledgeView[]>>(
|
||||||
|
'/api/v1/documentCollection/list',
|
||||||
|
{
|
||||||
|
params: { publishedOnly: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAgentSessions(params?: {
|
export function getAgentSessions(params?: {
|
||||||
agentId?: number | string;
|
agentId?: number | string;
|
||||||
pageNumber?: number;
|
pageNumber?: number;
|
||||||
@@ -103,6 +131,18 @@ export function renameAgentSession(sessionId: number | string, title: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveAgentSessionExtraKnowledges(
|
||||||
|
sessionId: number | string,
|
||||||
|
knowledgeIds: Array<number | string>,
|
||||||
|
) {
|
||||||
|
return api.post<RequestResult<AgentChatSessionDetailView>>(
|
||||||
|
`/api/v1/agent/session/${sessionId}/extraKnowledges`,
|
||||||
|
{
|
||||||
|
knowledgeIds,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteAgentSession(sessionId: number | string) {
|
export function deleteAgentSession(sessionId: number | string) {
|
||||||
return api.post<RequestResult>(`/api/v1/agent/session/${sessionId}/delete`);
|
return api.post<RequestResult>(`/api/v1/agent/session/${sessionId}/delete`);
|
||||||
}
|
}
|
||||||
@@ -129,6 +169,7 @@ export function rejectAgentRun(
|
|||||||
export function sendAgentChat(
|
export function sendAgentChat(
|
||||||
data: {
|
data: {
|
||||||
agentId: number | string;
|
agentId: number | string;
|
||||||
|
capabilities?: AgentChatCapabilityPayload[];
|
||||||
prompt: string;
|
prompt: string;
|
||||||
sessionId?: number | string;
|
sessionId?: number | string;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="agent-chat-welcome-state" aria-live="polite">
|
||||||
|
<h2 class="agent-chat-welcome-state__title">{{ title }}</h2>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agent-chat-welcome-state {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat-welcome-state__title {
|
||||||
|
max-width: min(720px, 100%);
|
||||||
|
margin: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: clamp(24px, 2.6vw, 36px);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.16;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,14 +15,21 @@ import {
|
|||||||
getAgentSession,
|
getAgentSession,
|
||||||
getAgentSessions,
|
getAgentSessions,
|
||||||
getPublishedAgents,
|
getPublishedAgents,
|
||||||
|
getPublishedKnowledges,
|
||||||
rejectAgentRun,
|
rejectAgentRun,
|
||||||
renameAgentSession,
|
renameAgentSession,
|
||||||
|
saveAgentSessionExtraKnowledges,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ChatInputTriggerGroup,
|
||||||
|
ChatInputTriggerItem,
|
||||||
|
} from '#/components/chat-workspace/input-triggers/types';
|
||||||
|
|
||||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||||
import {useRoute, useRouter} from 'vue-router';
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
import {Delete, EditPen, MoreFilled, Plus, Promotion,} from '@element-plus/icons-vue';
|
|
||||||
|
|
||||||
|
import {Delete, EditPen, MoreFilled, Plus, Promotion,} from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
ElDropdown,
|
ElDropdown,
|
||||||
@@ -35,25 +42,49 @@ import {
|
|||||||
ElOption,
|
ElOption,
|
||||||
ElSelect,
|
ElSelect,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import ChatCapabilityMenu from '#/components/chat-workspace/ChatCapabilityMenu.vue';
|
||||||
|
import ChatInputTriggerPanel from '#/components/chat-workspace/ChatInputTriggerPanel.vue';
|
||||||
|
import {useChatInputTrigger} from '#/components/chat-workspace/input-triggers/useChatInputTrigger';
|
||||||
|
|
||||||
import {recordsToTimelineItems} from './adapters/agentTimelineAdapter';
|
import {recordsToTimelineItems} from './adapters/agentTimelineAdapter';
|
||||||
import {agentChatRuntimeManager} from './agentChatRuntimeManager';
|
import {agentChatRuntimeManager} from './agentChatRuntimeManager';
|
||||||
|
import AgentChatWelcomeState from './components/AgentChatWelcomeState.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const WELCOME_TITLES = [
|
||||||
|
'我们应该做些什么',
|
||||||
|
'让协作发生',
|
||||||
|
'今天想推进什么',
|
||||||
|
'把想法变成行动',
|
||||||
|
'让智能体开始工作',
|
||||||
|
'从一个问题开始',
|
||||||
|
'一起把事情理清楚',
|
||||||
|
'把下一步交给协作',
|
||||||
|
];
|
||||||
|
|
||||||
const agents = ref<AgentInfo[]>([]);
|
const agents = ref<AgentInfo[]>([]);
|
||||||
const sessions = ref<AgentChatSessionView[]>([]);
|
const sessions = ref<AgentChatSessionView[]>([]);
|
||||||
const timelineItems = ref<ChatTimelineItem[]>([]);
|
const timelineItems = ref<ChatTimelineItem[]>([]);
|
||||||
const selectedAgentId = ref('');
|
const selectedAgentId = ref('');
|
||||||
const currentSessionId = ref('');
|
const currentSessionId = ref('');
|
||||||
const promptText = ref('');
|
const promptText = ref('');
|
||||||
|
const promptInputRef = ref();
|
||||||
const loadingAgents = ref(false);
|
const loadingAgents = ref(false);
|
||||||
const loadingSessions = ref(false);
|
const loadingSessions = ref(false);
|
||||||
const loadingConversation = ref(false);
|
const loadingConversation = ref(false);
|
||||||
|
const loadingKnowledges = ref(false);
|
||||||
|
const savingExtraKnowledges = ref(false);
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
const runtimeRunning = ref(false);
|
const runtimeRunning = ref(false);
|
||||||
const approvalLoadingKey = ref('');
|
const approvalLoadingKey = ref('');
|
||||||
|
const knowledgeOptions = ref<{ label: string; value: string }[]>([]);
|
||||||
|
const knowledgeMap = ref(new Map<string, { id: string; title: string }>());
|
||||||
|
const extraKnowledgeIds = ref<string[]>([]);
|
||||||
const runtimeSendingState = new Map<string, boolean>();
|
const runtimeSendingState = new Map<string, boolean>();
|
||||||
|
const MAX_EXTRA_KNOWLEDGE_COUNT = 3;
|
||||||
let runtimeUnsubscribe: (() => void) | undefined;
|
let runtimeUnsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
const selectedAgent = computed(() =>
|
const selectedAgent = computed(() =>
|
||||||
@@ -75,13 +106,66 @@ const canSend = computed(
|
|||||||
const composerPlaceholder = computed(() =>
|
const composerPlaceholder = computed(() =>
|
||||||
selectedAgent.value ? '输入消息' : '请选择智能体',
|
selectedAgent.value ? '输入消息' : '请选择智能体',
|
||||||
);
|
);
|
||||||
const agentSelectWidth = computed(() => {
|
const selectedExtraKnowledges = computed(() => {
|
||||||
const name = selectedAgent.value?.name || '选择智能体';
|
const knowledges: { id: string; title: string }[] = [];
|
||||||
const textWidth = Array.from(name).reduce(
|
for (const id of extraKnowledgeIds.value) {
|
||||||
(total, char) => total + (/[\u4E00-\u9FFF]/.test(char) ? 14 : 8),
|
const knowledge = knowledgeMap.value.get(String(id));
|
||||||
|
if (knowledge) {
|
||||||
|
knowledges.push(knowledge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return knowledges;
|
||||||
|
});
|
||||||
|
const capabilityDisabled = computed(
|
||||||
|
() =>
|
||||||
|
sending.value ||
|
||||||
|
runtimeRunning.value ||
|
||||||
|
savingExtraKnowledges.value ||
|
||||||
|
!selectedAgentId.value,
|
||||||
|
);
|
||||||
|
const isWelcomeState = computed(
|
||||||
|
() =>
|
||||||
|
!loadingConversation.value &&
|
||||||
|
!currentSessionId.value &&
|
||||||
|
timelineItems.value.length === 0,
|
||||||
|
);
|
||||||
|
const welcomeTitle = computed(() => {
|
||||||
|
const agentKey = selectedAgentId.value || selectedAgent.value?.name || '';
|
||||||
|
const index = [...agentKey].reduce(
|
||||||
|
(total, char) => total + char.charCodeAt(0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return `${Math.min(Math.max(textWidth + 36, 92), 240)}px`;
|
return WELCOME_TITLES[index % WELCOME_TITLES.length] || '我们应该做些什么';
|
||||||
|
});
|
||||||
|
const agentSelectWidth = computed(() => {
|
||||||
|
const name = selectedAgent.value?.name || '选择智能体';
|
||||||
|
const textWidth = [...name].reduce(
|
||||||
|
(total, char) => total + (/[\u4E00-\u9FFF]/.test(char) ? 15 : 8),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return `${Math.min(Math.max(textWidth + 36, 116), 320)}px`;
|
||||||
|
});
|
||||||
|
const triggerGroups = computed<ChatInputTriggerGroup[]>(() => [
|
||||||
|
{
|
||||||
|
items: knowledgeOptions.value.map((item) => {
|
||||||
|
const selected = extraKnowledgeIds.value.includes(String(item.value));
|
||||||
|
return {
|
||||||
|
disabled:
|
||||||
|
!selected &&
|
||||||
|
extraKnowledgeIds.value.length >= MAX_EXTRA_KNOWLEDGE_COUNT,
|
||||||
|
id: item.value,
|
||||||
|
label: item.label,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
label: '知识库',
|
||||||
|
symbol: '@',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const chatInputTrigger = useChatInputTrigger({
|
||||||
|
disabled: capabilityDisabled,
|
||||||
|
groups: triggerGroups,
|
||||||
|
inputRef: promptInputRef,
|
||||||
|
text: promptText,
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(value?: string) {
|
function formatDate(value?: string) {
|
||||||
@@ -171,8 +255,42 @@ async function loadSessions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadKnowledges() {
|
||||||
|
loadingKnowledges.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getPublishedKnowledges();
|
||||||
|
if (res.errorCode !== 0) {
|
||||||
|
throw new Error(res.message || '知识库加载失败');
|
||||||
|
}
|
||||||
|
const records = Array.isArray(res.data) ? res.data : [];
|
||||||
|
knowledgeOptions.value = records
|
||||||
|
.filter((item) => item?.id)
|
||||||
|
.map((item) => ({
|
||||||
|
label: item.title || item.alias || String(item.id),
|
||||||
|
value: String(item.id),
|
||||||
|
}));
|
||||||
|
knowledgeMap.value = new Map(
|
||||||
|
records
|
||||||
|
.filter((item) => item?.id)
|
||||||
|
.map((item) => [
|
||||||
|
String(item.id),
|
||||||
|
{
|
||||||
|
id: String(item.id),
|
||||||
|
title: item.title || item.alias || String(item.id),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '知识库加载失败');
|
||||||
|
} finally {
|
||||||
|
loadingKnowledges.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSessionSortTime(session: AgentChatSessionView) {
|
function resolveSessionSortTime(session: AgentChatSessionView) {
|
||||||
const time = new Date(session.lastMessageAt || session.accessAt || '').getTime();
|
const time = new Date(
|
||||||
|
session.lastMessageAt || session.accessAt || '',
|
||||||
|
).getTime();
|
||||||
return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
|
return Number.isFinite(time) ? time : Number.NEGATIVE_INFINITY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,14 +313,14 @@ function upsertSessionRecord(session: AgentChatSessionView) {
|
|||||||
const currentIndex = next.findIndex(
|
const currentIndex = next.findIndex(
|
||||||
(item) => String(item.sessionId) === sessionId,
|
(item) => String(item.sessionId) === sessionId,
|
||||||
);
|
);
|
||||||
if (currentIndex >= 0) {
|
if (currentIndex === -1) {
|
||||||
next.splice(currentIndex, 1, {
|
next.push({
|
||||||
...next[currentIndex],
|
|
||||||
...session,
|
...session,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
next.push({
|
next.splice(currentIndex, 1, {
|
||||||
|
...next[currentIndex],
|
||||||
...session,
|
...session,
|
||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
@@ -318,7 +436,8 @@ async function loadConversation(sessionId: string) {
|
|||||||
try {
|
try {
|
||||||
const detailRes = await getAgentSession(sessionId);
|
const detailRes = await getAgentSession(sessionId);
|
||||||
const res = await getAgentConversation(sessionId);
|
const res = await getAgentConversation(sessionId);
|
||||||
const latestRuntimeSnapshot = agentChatRuntimeManager.getSnapshot(sessionId);
|
const latestRuntimeSnapshot =
|
||||||
|
agentChatRuntimeManager.getSnapshot(sessionId);
|
||||||
if (latestRuntimeSnapshot?.sending) {
|
if (latestRuntimeSnapshot?.sending) {
|
||||||
syncRuntimeSnapshot(sessionId);
|
syncRuntimeSnapshot(sessionId);
|
||||||
await syncSessionRoute(sessionId);
|
await syncSessionRoute(sessionId);
|
||||||
@@ -335,6 +454,24 @@ async function loadConversation(sessionId: string) {
|
|||||||
if (session?.assistantId) {
|
if (session?.assistantId) {
|
||||||
selectedAgentId.value = String(session.assistantId);
|
selectedAgentId.value = String(session.assistantId);
|
||||||
}
|
}
|
||||||
|
if (detailRes.errorCode === 0 && detailRes.data) {
|
||||||
|
extraKnowledgeIds.value = (detailRes.data.extraKnowledges || [])
|
||||||
|
.map((item) => String(item.id || ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const item of detailRes.data.extraKnowledges || []) {
|
||||||
|
if (!item.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
knowledgeMap.value.set(String(item.id), {
|
||||||
|
id: String(item.id),
|
||||||
|
title: item.title || item.alias || String(item.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((detailRes.data.removedExtraKnowledgeNames || []).length > 0) {
|
||||||
|
const removedNames = detailRes.data.removedExtraKnowledgeNames || [];
|
||||||
|
ElMessage.warning(`以下知识库已失效并移除:${removedNames.join('、')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
currentSessionId.value = sessionId;
|
currentSessionId.value = sessionId;
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
await syncSessionRoute(sessionId);
|
await syncSessionRoute(sessionId);
|
||||||
@@ -349,6 +486,7 @@ async function createNewSession() {
|
|||||||
currentSessionId.value = '';
|
currentSessionId.value = '';
|
||||||
timelineItems.value = [];
|
timelineItems.value = [];
|
||||||
promptText.value = '';
|
promptText.value = '';
|
||||||
|
extraKnowledgeIds.value = [];
|
||||||
sending.value = false;
|
sending.value = false;
|
||||||
await syncSessionRoute();
|
await syncSessionRoute();
|
||||||
}
|
}
|
||||||
@@ -362,20 +500,77 @@ async function bindCreatedSession(sessionId: string, prompt: string) {
|
|||||||
(session) => String(session.sessionId) === sessionId,
|
(session) => String(session.sessionId) === sessionId,
|
||||||
);
|
);
|
||||||
const nextSession = buildOptimisticSession(sessionId, prompt);
|
const nextSession = buildOptimisticSession(sessionId, prompt);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex === -1) {
|
||||||
upsertSessionRecord(nextSession);
|
|
||||||
} else {
|
|
||||||
sessions.value = [nextSession, ...sessions.value];
|
sessions.value = [nextSession, ...sessions.value];
|
||||||
|
} else {
|
||||||
|
upsertSessionRecord(nextSession);
|
||||||
}
|
}
|
||||||
await syncSessionRoute(sessionId);
|
await syncSessionRoute(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAgentChange() {
|
function handleAgentChange() {
|
||||||
|
extraKnowledgeIds.value = [];
|
||||||
if (timelineItems.value.length > 0 || currentSessionId.value) {
|
if (timelineItems.value.length > 0 || currentSessionId.value) {
|
||||||
void createNewSession();
|
void createNewSession();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleExtraKnowledgeIdsChange(value: string[]) {
|
||||||
|
const previousIds = [...extraKnowledgeIds.value];
|
||||||
|
const nextIds = value.map(String);
|
||||||
|
extraKnowledgeIds.value = nextIds;
|
||||||
|
if (!currentSessionId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingExtraKnowledges.value = true;
|
||||||
|
try {
|
||||||
|
const res = await saveAgentSessionExtraKnowledges(
|
||||||
|
currentSessionId.value,
|
||||||
|
nextIds,
|
||||||
|
);
|
||||||
|
if (res.errorCode !== 0 || !res.data) {
|
||||||
|
throw new Error(res.message || '知识库保存失败');
|
||||||
|
}
|
||||||
|
extraKnowledgeIds.value = (res.data.extraKnowledges || [])
|
||||||
|
.map((item) => String(item.id || ''))
|
||||||
|
.filter(Boolean);
|
||||||
|
upsertSessionRecord({
|
||||||
|
...res.data,
|
||||||
|
sessionId: currentSessionId.value,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
extraKnowledgeIds.value = previousIds;
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '知识库保存失败');
|
||||||
|
} finally {
|
||||||
|
savingExtraKnowledges.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTriggerSelect(item: ChatInputTriggerItem) {
|
||||||
|
if (item.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chatInputTrigger.activePanel.value?.symbol !== '@') {
|
||||||
|
await chatInputTrigger.replaceTriggerText('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIds = extraKnowledgeIds.value.map(String);
|
||||||
|
if (!nextIds.includes(String(item.id))) {
|
||||||
|
nextIds.push(String(item.id));
|
||||||
|
await handleExtraKnowledgeIdsChange(nextIds);
|
||||||
|
}
|
||||||
|
await chatInputTrigger.replaceTriggerText('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCapabilities() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
resourceIds: [...extraKnowledgeIds.value],
|
||||||
|
type: 'KNOWLEDGE' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const content = promptText.value.trim();
|
const content = promptText.value.trim();
|
||||||
if (!content || !selectedAgentId.value || sending.value) {
|
if (!content || !selectedAgentId.value || sending.value) {
|
||||||
@@ -392,6 +587,7 @@ async function handleSend() {
|
|||||||
agentId: selectedAgentId.value,
|
agentId: selectedAgentId.value,
|
||||||
agentName: selectedAgent.value?.name,
|
agentName: selectedAgent.value?.name,
|
||||||
baseItems: timelineItems.value,
|
baseItems: timelineItems.value,
|
||||||
|
capabilities: buildCapabilities(),
|
||||||
prompt: content,
|
prompt: content,
|
||||||
sessionId: currentSessionId.value,
|
sessionId: currentSessionId.value,
|
||||||
});
|
});
|
||||||
@@ -406,6 +602,55 @@ async function handleSend() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePromptInput() {
|
||||||
|
chatInputTrigger.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePromptKeyup() {
|
||||||
|
chatInputTrigger.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePromptClick() {
|
||||||
|
chatInputTrigger.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePromptKeydown(event: Event | KeyboardEvent) {
|
||||||
|
if (!(event instanceof KeyboardEvent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (chatInputTrigger.activePanel.value) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
chatInputTrigger.move(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
chatInputTrigger.move(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
chatInputTrigger.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
const item =
|
||||||
|
chatInputTrigger.visibleItems.value[chatInputTrigger.activeIndex.value];
|
||||||
|
if (item) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleTriggerSelect(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
void handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleStop() {
|
function handleStop() {
|
||||||
if (!canStopRuntime.value) {
|
if (!canStopRuntime.value) {
|
||||||
return;
|
return;
|
||||||
@@ -539,7 +784,7 @@ async function handleReject(payload: ChatTimelineToolApprovalPayload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await Promise.all([loadAgents(), loadSessions()]);
|
await Promise.all([loadAgents(), loadSessions(), loadKnowledges()]);
|
||||||
const routeSessionId = String(route.query.sessionId || '');
|
const routeSessionId = String(route.query.sessionId || '');
|
||||||
if (routeSessionId) {
|
if (routeSessionId) {
|
||||||
await loadConversation(routeSessionId);
|
await loadConversation(routeSessionId);
|
||||||
@@ -645,10 +890,17 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="agent-chat__timeline-wrap">
|
<div
|
||||||
|
class="agent-chat__timeline-wrap"
|
||||||
|
:class="{ 'is-welcome': isWelcomeState }"
|
||||||
|
>
|
||||||
<div v-if="loadingConversation" class="agent-chat__state is-center">
|
<div v-if="loadingConversation" class="agent-chat__state is-center">
|
||||||
加载中
|
加载中
|
||||||
</div>
|
</div>
|
||||||
|
<AgentChatWelcomeState
|
||||||
|
v-else-if="isWelcomeState"
|
||||||
|
:title="welcomeTitle"
|
||||||
|
/>
|
||||||
<ChatTimeline
|
<ChatTimeline
|
||||||
v-else
|
v-else
|
||||||
:items="timelineItems"
|
:items="timelineItems"
|
||||||
@@ -663,8 +915,31 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="agent-chat__composer">
|
<div
|
||||||
|
class="agent-chat__composer"
|
||||||
|
:class="{ 'is-welcome': isWelcomeState }"
|
||||||
|
>
|
||||||
|
<ChatCapabilityMenu
|
||||||
|
:disabled="capabilityDisabled"
|
||||||
|
:extra-knowledge-ids="extraKnowledgeIds"
|
||||||
|
:knowledge-options="knowledgeOptions"
|
||||||
|
:loading="loadingKnowledges"
|
||||||
|
:selected-knowledges="selectedExtraKnowledges"
|
||||||
|
:show-trigger="false"
|
||||||
|
@update:extra-knowledge-ids="handleExtraKnowledgeIdsChange"
|
||||||
|
/>
|
||||||
|
<ChatInputTriggerPanel
|
||||||
|
v-if="chatInputTrigger.activePanel.value"
|
||||||
|
class="agent-chat__trigger-panel"
|
||||||
|
:active-index="chatInputTrigger.activeIndex.value"
|
||||||
|
:group-label="chatInputTrigger.activePanel.value.groupLabel"
|
||||||
|
:items="chatInputTrigger.visibleItems.value"
|
||||||
|
:keyword="chatInputTrigger.activePanel.value.keyword"
|
||||||
|
@select="handleTriggerSelect"
|
||||||
|
@set-active="chatInputTrigger.setActiveIndex"
|
||||||
|
/>
|
||||||
<ElInput
|
<ElInput
|
||||||
|
ref="promptInputRef"
|
||||||
v-model="promptText"
|
v-model="promptText"
|
||||||
class="agent-chat__composer-input"
|
class="agent-chat__composer-input"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -672,9 +947,22 @@ onBeforeUnmount(() => {
|
|||||||
resize="none"
|
resize="none"
|
||||||
:placeholder="composerPlaceholder"
|
:placeholder="composerPlaceholder"
|
||||||
:disabled="sending || runtimeRunning || !selectedAgentId"
|
:disabled="sending || runtimeRunning || !selectedAgentId"
|
||||||
@keydown.enter.exact.prevent="handleSend"
|
@click="handlePromptClick"
|
||||||
|
@input="handlePromptInput"
|
||||||
|
@keydown="handlePromptKeydown"
|
||||||
|
@keyup="handlePromptKeyup"
|
||||||
/>
|
/>
|
||||||
<div class="agent-chat__composer-footer">
|
<div class="agent-chat__composer-footer">
|
||||||
|
<div class="agent-chat__composer-tools">
|
||||||
|
<ChatCapabilityMenu
|
||||||
|
class="agent-chat__capability-entry"
|
||||||
|
:disabled="capabilityDisabled"
|
||||||
|
:extra-knowledge-ids="extraKnowledgeIds"
|
||||||
|
:knowledge-options="knowledgeOptions"
|
||||||
|
:loading="loadingKnowledges"
|
||||||
|
:selected-knowledges="[]"
|
||||||
|
@update:extra-knowledge-ids="handleExtraKnowledgeIdsChange"
|
||||||
|
/>
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="selectedAgentId"
|
v-model="selectedAgentId"
|
||||||
:loading="loadingAgents"
|
:loading="loadingAgents"
|
||||||
@@ -690,6 +978,7 @@ onBeforeUnmount(() => {
|
|||||||
:value="String(agent.id)"
|
:value="String(agent.id)"
|
||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
|
</div>
|
||||||
<div class="agent-chat__composer-actions">
|
<div class="agent-chat__composer-actions">
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="canStopRuntime"
|
v-if="canStopRuntime"
|
||||||
@@ -700,7 +989,7 @@ onBeforeUnmount(() => {
|
|||||||
class="agent-chat__send-button is-stop"
|
class="agent-chat__send-button is-stop"
|
||||||
@click="handleStop"
|
@click="handleStop"
|
||||||
>
|
>
|
||||||
<span class="agent-chat__stop-glyph" />
|
<span class="agent-chat__stop-glyph"></span>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton
|
<ElButton
|
||||||
v-else
|
v-else
|
||||||
@@ -863,6 +1152,11 @@ onBeforeUnmount(() => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__timeline-wrap.is-welcome {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 min(8vw, 96px) 252px;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-chat__timeline-wrap :deep(.chat-timeline) {
|
.agent-chat__timeline-wrap :deep(.chat-timeline) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -886,6 +1180,20 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow: var(--el-box-shadow-light);
|
box-shadow: var(--el-box-shadow-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__composer.is-welcome {
|
||||||
|
top: calc(50% + 40px);
|
||||||
|
bottom: auto;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__trigger-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 10px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 5;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-chat__composer-input :deep(.el-textarea__inner) {
|
.agent-chat__composer-input :deep(.el-textarea__inner) {
|
||||||
min-height: 48px !important;
|
min-height: 48px !important;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -902,13 +1210,25 @@ onBeforeUnmount(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__composer-tools {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: calc(100% - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-chat__capability-entry {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-chat__agent-select {
|
.agent-chat__agent-select {
|
||||||
max-width: min(240px, 58%);
|
max-width: min(320px, calc(100vw - 240px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__agent-select :deep(.el-select__wrapper) {
|
.agent-chat__agent-select :deep(.el-select__wrapper) {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
padding: 0;
|
padding: 0 4px 0 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -921,7 +1241,7 @@ onBeforeUnmount(() => {
|
|||||||
.agent-chat__agent-select :deep(.el-select__placeholder),
|
.agent-chat__agent-select :deep(.el-select__placeholder),
|
||||||
.agent-chat__agent-select :deep(.el-select__selected-item) {
|
.agent-chat__agent-select :deep(.el-select__selected-item) {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 184px;
|
max-width: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -932,6 +1252,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.agent-chat__agent-select :deep(.el-select__caret) {
|
.agent-chat__agent-select :deep(.el-select__caret) {
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__composer-actions {
|
.agent-chat__composer-actions {
|
||||||
@@ -1002,6 +1323,10 @@ onBeforeUnmount(() => {
|
|||||||
padding-bottom: 184px;
|
padding-bottom: 184px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__timeline-wrap.is-welcome {
|
||||||
|
padding: 0 16px 244px;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-chat__timeline-wrap :deep(.chat-timeline) {
|
.agent-chat__timeline-wrap :deep(.chat-timeline) {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
@@ -1012,12 +1337,18 @@ onBeforeUnmount(() => {
|
|||||||
left: 16px;
|
left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-chat__composer.is-welcome {
|
||||||
|
top: calc(50% + 52px);
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-chat__composer-footer {
|
.agent-chat__composer-footer {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__agent-select {
|
.agent-chat__agent-select {
|
||||||
width: min(220px, calc(100% - 58px));
|
width: min(280px, calc(100% - 58px));
|
||||||
|
max-width: calc(100vw - 128px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-chat__composer-actions {
|
.agent-chat__composer-actions {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {useAgentDesignerState} from './composables/useAgentDesignerState';
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const AGENT_TAB_PAGE_KEY = '/ai/agents';
|
||||||
|
const DEFAULT_AGENT_TITLE = '未命名智能体';
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
addKnowledgeNode,
|
addKnowledgeNode,
|
||||||
@@ -52,6 +54,7 @@ const {
|
|||||||
|
|
||||||
const pageLoading = ref(false);
|
const pageLoading = ref(false);
|
||||||
const saveLoading = ref(false);
|
const saveLoading = ref(false);
|
||||||
|
const offlineLoading = ref(false);
|
||||||
const publishLoading = ref(false);
|
const publishLoading = ref(false);
|
||||||
const issues = ref<AgentValidationIssue[]>([]);
|
const issues = ref<AgentValidationIssue[]>([]);
|
||||||
const categories = ref<AgentOption[]>([]);
|
const categories = ref<AgentOption[]>([]);
|
||||||
@@ -62,14 +65,6 @@ const pluginTools = ref<AgentOption[]>([]);
|
|||||||
|
|
||||||
const isNew = computed(() => String(route.params.id || '') === 'new');
|
const isNew = computed(() => String(route.params.id || '') === 'new');
|
||||||
const publishText = computed(() => {
|
const publishText = computed(() => {
|
||||||
if (
|
|
||||||
canAiResourceOffline(
|
|
||||||
state.agent.displayPublishStatus,
|
|
||||||
state.agent.publishStatus,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return '下线';
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
canAiResourceRepublish(
|
canAiResourceRepublish(
|
||||||
state.agent.displayPublishStatus,
|
state.agent.displayPublishStatus,
|
||||||
@@ -81,6 +76,13 @@ const publishText = computed(() => {
|
|||||||
return '发布';
|
return '发布';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const offlineVisible = computed(() =>
|
||||||
|
canAiResourceOffline(
|
||||||
|
state.agent.displayPublishStatus,
|
||||||
|
state.agent.publishStatus,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const publishDisabled = computed(() => {
|
const publishDisabled = computed(() => {
|
||||||
if (!state.agent.id) return true;
|
if (!state.agent.id) return true;
|
||||||
if (
|
if (
|
||||||
@@ -99,14 +101,15 @@ const publishDisabled = computed(() => {
|
|||||||
canAiResourceRepublish(
|
canAiResourceRepublish(
|
||||||
state.agent.displayPublishStatus,
|
state.agent.displayPublishStatus,
|
||||||
state.agent.publishStatus,
|
state.agent.publishStatus,
|
||||||
) ||
|
|
||||||
canAiResourceOffline(
|
|
||||||
state.agent.displayPublishStatus,
|
|
||||||
state.agent.publishStatus,
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const offlineDisabled = computed(() => {
|
||||||
|
if (!state.agent.id) return true;
|
||||||
|
return !offlineVisible.value;
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
pageLoading.value = true;
|
pageLoading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -119,14 +122,55 @@ onMounted(async () => {
|
|||||||
async function loadAgent() {
|
async function loadAgent() {
|
||||||
if (isNew.value) {
|
if (isNew.value) {
|
||||||
reset();
|
reset();
|
||||||
|
syncNavTitle(DEFAULT_AGENT_TITLE, { force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [, res] = await tryit(getAgentDetail)(String(route.params.id));
|
const [, res] = await tryit(getAgentDetail)(String(route.params.id));
|
||||||
if (res?.errorCode === 0) {
|
if (res?.errorCode === 0) {
|
||||||
reset(res.data);
|
reset(res.data);
|
||||||
|
syncNavTitle(resolveAgentTitle(res.data), { force: !hasNavTitle() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasNavTitle() {
|
||||||
|
const navTitle = Array.isArray(route.query.navTitle)
|
||||||
|
? route.query.navTitle[0]
|
||||||
|
: route.query.navTitle;
|
||||||
|
return typeof navTitle === 'string' && navTitle.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentTitle(agent = state.agent) {
|
||||||
|
return String(agent.name || '').trim() || DEFAULT_AGENT_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncNavTitle(title: string, options: { force?: boolean } = {}) {
|
||||||
|
const normalizedTitle = String(title || '').trim() || DEFAULT_AGENT_TITLE;
|
||||||
|
const query = route.query as Record<string, any>;
|
||||||
|
const currentNavTitle = Array.isArray(query.navTitle)
|
||||||
|
? query.navTitle[0]
|
||||||
|
: query.navTitle;
|
||||||
|
const currentPageKey = Array.isArray(query.pageKey)
|
||||||
|
? query.pageKey[0]
|
||||||
|
: query.pageKey;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!options.force &&
|
||||||
|
currentNavTitle === normalizedTitle &&
|
||||||
|
currentPageKey === AGENT_TAB_PAGE_KEY
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace({
|
||||||
|
path: route.path,
|
||||||
|
query: {
|
||||||
|
...query,
|
||||||
|
pageKey: AGENT_TAB_PAGE_KEY,
|
||||||
|
navTitle: normalizedTitle,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOptions() {
|
async function loadOptions() {
|
||||||
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes] =
|
const [categoryRes, modelRes, knowledgeRes, workflowRes, pluginRes] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -245,8 +289,18 @@ async function handleSave(showMessage = true) {
|
|||||||
id,
|
id,
|
||||||
};
|
};
|
||||||
state.dirty = false;
|
state.dirty = false;
|
||||||
|
const title = resolveAgentTitle();
|
||||||
if (isNew.value) {
|
if (isNew.value) {
|
||||||
await router.replace(`/ai/agents/designer/${id}`);
|
await router.replace({
|
||||||
|
path: `/ai/agents/designer/${id}`,
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
pageKey: AGENT_TAB_PAGE_KEY,
|
||||||
|
navTitle: title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
syncNavTitle(title, { force: true });
|
||||||
}
|
}
|
||||||
if (showMessage) {
|
if (showMessage) {
|
||||||
ElMessage.success('已保存');
|
ElMessage.success('已保存');
|
||||||
@@ -262,29 +316,19 @@ async function handlePublish() {
|
|||||||
const saved = await handleSave(false);
|
const saved = await handleSave(false);
|
||||||
if (!saved) return;
|
if (!saved) return;
|
||||||
|
|
||||||
const offline = canAiResourceOffline(
|
|
||||||
state.agent.displayPublishStatus,
|
|
||||||
state.agent.publishStatus,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm('确认提交发布审批?', '提示', {
|
||||||
offline ? '确认提交下线审批?' : '确认提交发布审批?',
|
|
||||||
'提示',
|
|
||||||
{
|
|
||||||
confirmButtonText: '确认',
|
confirmButtonText: '确认',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: offline ? 'warning' : 'info',
|
type: 'info',
|
||||||
},
|
});
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
publishLoading.value = true;
|
publishLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = offline
|
const res = await submitAgentPublishApproval(String(state.agent.id));
|
||||||
? await submitAgentOfflineApproval(String(state.agent.id))
|
|
||||||
: await submitAgentPublishApproval(String(state.agent.id));
|
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(res.message || '已提交');
|
ElMessage.success(res.message || '已提交');
|
||||||
await loadAgent();
|
await loadAgent();
|
||||||
@@ -294,6 +338,33 @@ async function handlePublish() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleOffline() {
|
||||||
|
if (!state.agent.id) return;
|
||||||
|
const saved = await handleSave(false);
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认提交下线审批?', '提示', {
|
||||||
|
confirmButtonText: '确认',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
offlineLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await submitAgentOfflineApproval(String(state.agent.id));
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success(res.message || '已提交');
|
||||||
|
await loadAgent();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
offlineLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleTryout() {
|
function handleTryout() {
|
||||||
if (!runValidation()) return;
|
if (!runValidation()) return;
|
||||||
openTryout();
|
openTryout();
|
||||||
@@ -330,8 +401,12 @@ function handleCloseTryout() {
|
|||||||
:publish-loading="publishLoading"
|
:publish-loading="publishLoading"
|
||||||
:publish-disabled="publishDisabled"
|
:publish-disabled="publishDisabled"
|
||||||
:publish-text="publishText"
|
:publish-text="publishText"
|
||||||
|
:offline-disabled="offlineDisabled"
|
||||||
|
:offline-loading="offlineLoading"
|
||||||
|
:offline-visible="offlineVisible"
|
||||||
@add="handleAdd"
|
@add="handleAdd"
|
||||||
@save="handleSave()"
|
@save="handleSave()"
|
||||||
|
@offline="handleOffline"
|
||||||
@publish="handlePublish"
|
@publish="handlePublish"
|
||||||
@tryout="handleTryout"
|
@tryout="handleTryout"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
const sideList = ref<any[]>([]);
|
const sideList = ref<any[]>([]);
|
||||||
|
const AGENT_TAB_PAGE_KEY = '/ai/agents';
|
||||||
|
const DEFAULT_AGENT_TITLE = '未命名智能体';
|
||||||
|
|
||||||
const headerButtons = [
|
const headerButtons = [
|
||||||
{
|
{
|
||||||
@@ -53,7 +55,13 @@ const primaryAction: CardPrimaryAction = {
|
|||||||
text: '编排',
|
text: '编排',
|
||||||
permission: '/api/v1/agent/update',
|
permission: '/api/v1/agent/update',
|
||||||
onClick(row: AgentInfo) {
|
onClick(row: AgentInfo) {
|
||||||
router.push(`/ai/agents/designer/${row.id}`);
|
router.push({
|
||||||
|
path: `/ai/agents/designer/${row.id}`,
|
||||||
|
query: {
|
||||||
|
pageKey: AGENT_TAB_PAGE_KEY,
|
||||||
|
navTitle: resolveNavTitle(row),
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,10 +114,20 @@ function handleSearch(keyword: string) {
|
|||||||
|
|
||||||
function handleButtonClick(payload: any) {
|
function handleButtonClick(payload: any) {
|
||||||
if (payload?.key === 'create' || payload?.data?.action === 'create') {
|
if (payload?.key === 'create' || payload?.data?.action === 'create') {
|
||||||
router.push('/ai/agents/designer/new');
|
router.push({
|
||||||
|
path: '/ai/agents/designer/new',
|
||||||
|
query: {
|
||||||
|
pageKey: AGENT_TAB_PAGE_KEY,
|
||||||
|
navTitle: DEFAULT_AGENT_TITLE,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveNavTitle(row: AgentInfo) {
|
||||||
|
return String(row.name || '').trim() || DEFAULT_AGENT_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
function changeCategory(category: any) {
|
function changeCategory(category: any) {
|
||||||
pageDataRef.value?.setQuery({ categoryId: category.id });
|
pageDataRef.value?.setQuery({ categoryId: category.id });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type {AgentCapabilityKind} from '../types';
|
import type {AgentCapabilityKind} from '../types';
|
||||||
|
|
||||||
import {onBeforeUnmount, onMounted, ref} from 'vue';
|
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||||
|
|
||||||
import {
|
import {Connection, Files, Loading, Plus, Share, VideoPlay,} from '@element-plus/icons-vue';
|
||||||
Connection,
|
|
||||||
Files,
|
|
||||||
Loading,
|
|
||||||
Plus,
|
|
||||||
Promotion,
|
|
||||||
Share,
|
|
||||||
VideoPlay,
|
|
||||||
} from '@element-plus/icons-vue';
|
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
|
offlineDisabled?: boolean;
|
||||||
|
offlineLoading?: boolean;
|
||||||
|
offlineVisible?: boolean;
|
||||||
publishDisabled?: boolean;
|
publishDisabled?: boolean;
|
||||||
publishLoading?: boolean;
|
publishLoading?: boolean;
|
||||||
publishText: string;
|
publishText: string;
|
||||||
@@ -21,8 +16,11 @@ defineProps<{
|
|||||||
tryoutDisabled?: boolean;
|
tryoutDisabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const isRepublish = computed(() => props.publishText === '重新发布');
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
add: [kind: AgentCapabilityKind];
|
add: [kind: AgentCapabilityKind];
|
||||||
|
offline: [];
|
||||||
publish: [];
|
publish: [];
|
||||||
save: [];
|
save: [];
|
||||||
tryout: [];
|
tryout: [];
|
||||||
@@ -120,13 +118,23 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
<div class="agent-command-bar__divider"></div>
|
<div class="agent-command-bar__divider"></div>
|
||||||
<button
|
<button
|
||||||
|
v-if="offlineVisible"
|
||||||
class="agent-command-bar__button agent-command-bar__button--ghost"
|
class="agent-command-bar__button agent-command-bar__button--ghost"
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="offlineDisabled || offlineLoading || publishLoading"
|
||||||
|
@click="emit('offline')"
|
||||||
|
>
|
||||||
|
<Loading v-if="offlineLoading" class="is-loading" />
|
||||||
|
<span>下线</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="agent-command-bar__button agent-command-bar__button--ghost"
|
||||||
|
:class="{ 'agent-command-bar__button--republish': isRepublish }"
|
||||||
|
type="button"
|
||||||
:disabled="publishDisabled || publishLoading"
|
:disabled="publishDisabled || publishLoading"
|
||||||
@click="emit('publish')"
|
@click="emit('publish')"
|
||||||
>
|
>
|
||||||
<Loading v-if="publishLoading" class="is-loading" />
|
<Loading v-if="publishLoading" class="is-loading" />
|
||||||
<Promotion v-else />
|
|
||||||
<span>{{ publishText }}</span>
|
<span>{{ publishText }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,11 +288,21 @@ onBeforeUnmount(() => {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-command-bar__button--republish {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
background: var(--el-color-warning-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
.agent-command-bar__button:hover:not(:disabled) {
|
.agent-command-bar__button:hover:not(:disabled) {
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
background: var(--el-fill-color-light);
|
background: var(--el-fill-color-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-command-bar__button--republish:hover:not(:disabled) {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
background: var(--el-color-warning-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
.agent-command-bar__button--primary:hover:not(:disabled) {
|
.agent-command-bar__button--primary:hover:not(:disabled) {
|
||||||
color: var(--el-color-primary);
|
color: var(--el-color-primary);
|
||||||
background: var(--el-color-primary-light-8);
|
background: var(--el-color-primary-light-8);
|
||||||
|
|||||||
@@ -98,6 +98,37 @@ describe('useAgentTryoutRawRounds', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('AgentScope context_reload 工具事件不进入页面时间线', () => {
|
||||||
|
const store = useAgentTryoutRawRounds({
|
||||||
|
mode: 'draft',
|
||||||
|
sessionId: 'session-raw-context-reload',
|
||||||
|
});
|
||||||
|
const roundId = store.createRound('展开第一层');
|
||||||
|
|
||||||
|
store.recordEvent(roundId, {
|
||||||
|
domain: 'TOOL',
|
||||||
|
payload: {
|
||||||
|
input: { working_context_offload_uuid: 'context-id' },
|
||||||
|
toolCallId: 'context-reload-1',
|
||||||
|
toolName: 'context_reload',
|
||||||
|
},
|
||||||
|
type: 'TOOL_CALL',
|
||||||
|
});
|
||||||
|
store.recordEvent(roundId, {
|
||||||
|
domain: 'TOOL',
|
||||||
|
payload: {
|
||||||
|
output: 'context',
|
||||||
|
toolCallId: 'context-reload-1',
|
||||||
|
toolName: 'context_reload',
|
||||||
|
},
|
||||||
|
type: 'TOOL_RESULT',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.buildTimelineItems().map((item) => item.type)).toEqual([
|
||||||
|
'message',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('刷新后能从 raw rounds 恢复 timeline', () => {
|
it('刷新后能从 raw rounds 恢复 timeline', () => {
|
||||||
const first = useAgentTryoutRawRounds({
|
const first = useAgentTryoutRawRounds({
|
||||||
mode: 'draft',
|
mode: 'draft',
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ function normalizeToolName(value: unknown) {
|
|||||||
|
|
||||||
function isHiddenToolName(value: unknown) {
|
function isHiddenToolName(value: unknown) {
|
||||||
const normalizedName = normalizeToolName(value);
|
const normalizedName = normalizeToolName(value);
|
||||||
return normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__';
|
return (
|
||||||
|
normalizedName === 'retrieve_knowledge' ||
|
||||||
|
normalizedName === 'context_reload' ||
|
||||||
|
normalizedName === '__fragment__'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clone<T>(value: T): T {
|
function clone<T>(value: T): T {
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import {computed, onMounted, onUnmounted, ref} from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import {useRoute} from 'vue-router';
|
||||||
|
|
||||||
import { usePreferences } from '@easyflow/preferences';
|
import {usePreferences} from '@easyflow/preferences';
|
||||||
import { getOptions, sortNodes } from '@easyflow/utils';
|
import {getOptions, sortNodes} from '@easyflow/utils';
|
||||||
import { Tinyflow } from '@tinyflow-ai/vue';
|
import {Tinyflow} from '@tinyflow-ai/vue';
|
||||||
|
|
||||||
import {
|
import {ArrowLeft, CircleCheck, Close, Promotion,} from '@element-plus/icons-vue';
|
||||||
ArrowLeft,
|
import {ElButton, ElDrawer, ElMessage, ElMessageBox, ElSkeleton,} from 'element-plus';
|
||||||
CircleCheck,
|
|
||||||
Close,
|
|
||||||
Promotion,
|
|
||||||
} from '@element-plus/icons-vue';
|
|
||||||
import {
|
|
||||||
ElButton,
|
|
||||||
ElDrawer,
|
|
||||||
ElMessage,
|
|
||||||
ElMessageBox,
|
|
||||||
ElSkeleton,
|
|
||||||
} from 'element-plus';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import {api} from '#/api/request';
|
||||||
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
import CommonSelectDataModal from '#/components/commonSelectModal/CommonSelectDataModal.vue';
|
||||||
import { $t } from '#/locales';
|
import {$t} from '#/locales';
|
||||||
import { router } from '#/router';
|
import {router} from '#/router';
|
||||||
import { getIconByValue } from '#/views/ai/model/modelUtils/defaultIcon';
|
import {getIconByValue} from '#/views/ai/model/modelUtils/defaultIcon';
|
||||||
import {
|
import {
|
||||||
canAiResourceRepublish,
|
canAiResourceRepublish,
|
||||||
isAiResourceApprovalPending,
|
isAiResourceApprovalPending,
|
||||||
@@ -35,7 +24,7 @@ import SingleRun from '#/views/ai/workflow/components/SingleRun.vue';
|
|||||||
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
import WorkflowForm from '#/views/ai/workflow/components/WorkflowForm.vue';
|
||||||
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
|
import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
|
||||||
|
|
||||||
import { getCustomNode } from './customNode/index';
|
import {getCustomNode} from './customNode/index';
|
||||||
import nodeNames from './customNode/nodeNames';
|
import nodeNames from './customNode/nodeNames';
|
||||||
import {
|
import {
|
||||||
createInitialWorkflowData,
|
createInitialWorkflowData,
|
||||||
@@ -368,15 +357,17 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
|||||||
saveLoading.value = false;
|
saveLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function getWorkflowInfo(workflowId: any) {
|
async function getWorkflowInfo(workflowId: any, syncFlowData: boolean = true) {
|
||||||
return api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
return api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
||||||
workflowInfo.value = res.data;
|
workflowInfo.value = res.data;
|
||||||
|
if (syncFlowData) {
|
||||||
const parsedContent = workflowInfo.value.content
|
const parsedContent = workflowInfo.value.content
|
||||||
? JSON.parse(workflowInfo.value.content)
|
? JSON.parse(workflowInfo.value.content)
|
||||||
: {};
|
: {};
|
||||||
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
||||||
? createInitialWorkflowData()
|
? createInitialWorkflowData()
|
||||||
: normalizeWorkflowStartNodes(parsedContent);
|
: normalizeWorkflowStartNodes(parsedContent);
|
||||||
|
}
|
||||||
syncNavTitle(workflowInfo.value?.title || '');
|
syncNavTitle(workflowInfo.value?.title || '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -561,7 +552,7 @@ async function handlePublishAction() {
|
|||||||
});
|
});
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success(res.message || $t('message.saveOkMessage'));
|
ElMessage.success(res.message || $t('message.saveOkMessage'));
|
||||||
await getWorkflowInfo(workflowId.value);
|
await getWorkflowInfo(workflowId.value, false);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
publishLoading.value = false;
|
publishLoading.value = false;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export {
|
|||||||
Copy,
|
Copy,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
|
EllipsisVertical,
|
||||||
Expand,
|
Expand,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type {
|
import type {DropdownMenuProps, EasyFlowDropdownMenuItem as IDropdownMenuItem,} from './interface';
|
||||||
DropdownMenuProps,
|
|
||||||
EasyFlowDropdownMenuItem as IDropdownMenuItem,
|
import {computed, ref} from 'vue';
|
||||||
} from './interface';
|
|
||||||
|
import {Search} from '@easyflow-core/icons';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -11,12 +12,29 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
|
Input,
|
||||||
} from '../../ui';
|
} from '../../ui';
|
||||||
|
|
||||||
interface Props extends DropdownMenuProps {}
|
interface Props extends DropdownMenuProps {}
|
||||||
|
|
||||||
defineOptions({ name: 'DropdownMenu' });
|
defineOptions({ name: 'DropdownMenu' });
|
||||||
const props = withDefaults(defineProps<Props>(), {});
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
align: 'start',
|
||||||
|
searchEmptyText: '无匹配标签',
|
||||||
|
searchPlaceholder: '搜索标签',
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
const filteredMenus = computed(() => {
|
||||||
|
const keyword = searchKeyword.value.trim().toLocaleLowerCase();
|
||||||
|
if (!props.searchable || !keyword) {
|
||||||
|
return props.menus;
|
||||||
|
}
|
||||||
|
return props.menus.filter((menu) =>
|
||||||
|
menu.label.toLocaleLowerCase().includes(keyword),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
function handleItemClick(menu: IDropdownMenuItem) {
|
function handleItemClick(menu: IDropdownMenuItem) {
|
||||||
if (menu.disabled) {
|
if (menu.disabled) {
|
||||||
@@ -27,22 +45,50 @@ function handleItemClick(menu: IDropdownMenuItem) {
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger class="flex h-full items-center gap-1">
|
<DropdownMenuTrigger
|
||||||
|
:aria-label="triggerLabel"
|
||||||
|
class="flex h-full items-center gap-1"
|
||||||
|
>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start">
|
<DropdownMenuContent :align="align" :class="contentClass">
|
||||||
|
<div v-if="searchable" class="sticky top-0 z-10 bg-popover p-1">
|
||||||
|
<div class="relative">
|
||||||
|
<Search
|
||||||
|
class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-[hsl(var(--text-muted))]"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
:placeholder="searchPlaceholder"
|
||||||
|
class="h-8 rounded-md border-[hsl(var(--line-subtle))] bg-[hsl(var(--surface-subtle))] py-1 pl-8 pr-2 text-xs"
|
||||||
|
@keydown.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<template v-for="menu in menus" :key="menu.value">
|
<template v-for="menu in filteredMenus" :key="menu.value">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
:disabled="menu.disabled"
|
:disabled="menu.disabled"
|
||||||
|
:class="{
|
||||||
|
'bg-[hsl(var(--nav-item-active))] text-[hsl(var(--nav-item-active-foreground))] font-medium':
|
||||||
|
menu.active,
|
||||||
|
}"
|
||||||
class="data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground text-foreground/80 mb-1 cursor-pointer"
|
class="data-[state=checked]:bg-accent data-[state=checked]:text-accent-foreground text-foreground/80 mb-1 cursor-pointer"
|
||||||
@click="handleItemClick(menu)"
|
@click="handleItemClick(menu)"
|
||||||
>
|
>
|
||||||
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
|
<component :is="menu.icon" v-if="menu.icon" class="mr-2 size-4" />
|
||||||
|
<span class="min-w-0 flex-1 truncate">
|
||||||
{{ menu.label }}
|
{{ menu.label }}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator v-if="menu.separator" class="bg-border" />
|
<DropdownMenuSeparator v-if="menu.separator" class="bg-border" />
|
||||||
</template>
|
</template>
|
||||||
|
<div
|
||||||
|
v-if="filteredMenus.length === 0"
|
||||||
|
class="px-3 py-5 text-center text-xs text-[hsl(var(--text-muted))]"
|
||||||
|
>
|
||||||
|
{{ searchEmptyText }}
|
||||||
|
</div>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { Component } from 'vue';
|
import type {Component} from 'vue';
|
||||||
|
|
||||||
interface EasyFlowDropdownMenuItem {
|
interface EasyFlowDropdownMenuItem {
|
||||||
|
/**
|
||||||
|
* @zh_CN 是否为当前选中项
|
||||||
|
*/
|
||||||
|
active?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* @zh_CN 点击事件处理
|
* @zh_CN 点击事件处理
|
||||||
@@ -26,7 +30,31 @@ interface EasyFlowDropdownMenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DropdownMenuProps {
|
interface DropdownMenuProps {
|
||||||
|
/**
|
||||||
|
* @zh_CN 菜单对齐方向
|
||||||
|
*/
|
||||||
|
align?: 'center' | 'end' | 'start';
|
||||||
|
/**
|
||||||
|
* @zh_CN 菜单浮层样式
|
||||||
|
*/
|
||||||
|
contentClass?: any;
|
||||||
menus: EasyFlowDropdownMenuItem[];
|
menus: EasyFlowDropdownMenuItem[];
|
||||||
|
/**
|
||||||
|
* @zh_CN 搜索无结果文案
|
||||||
|
*/
|
||||||
|
searchEmptyText?: string;
|
||||||
|
/**
|
||||||
|
* @zh_CN 搜索占位文案
|
||||||
|
*/
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
/**
|
||||||
|
* @zh_CN 是否显示菜单搜索框
|
||||||
|
*/
|
||||||
|
searchable?: boolean;
|
||||||
|
/**
|
||||||
|
* @zh_CN 触发按钮的无障碍标签
|
||||||
|
*/
|
||||||
|
triggerLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { DropdownMenuProps, EasyFlowDropdownMenuItem };
|
export type { DropdownMenuProps, EasyFlowDropdownMenuItem };
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { DropdownMenuProps } from '@easyflow-core/shadcn-ui';
|
import type {DropdownMenuProps} from '@easyflow-core/shadcn-ui';
|
||||||
|
import {EasyFlowDropdownMenu} from '@easyflow-core/shadcn-ui';
|
||||||
|
|
||||||
import { ChevronDown } from '@easyflow-core/icons';
|
import {EllipsisVertical} from '@easyflow-core/icons';
|
||||||
import { EasyFlowDropdownMenu } from '@easyflow-core/shadcn-ui';
|
|
||||||
|
|
||||||
defineProps<DropdownMenuProps>();
|
defineProps<DropdownMenuProps>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<EasyFlowDropdownMenu :menus="menus" :modal="false">
|
<EasyFlowDropdownMenu
|
||||||
<div
|
:menus="menus"
|
||||||
class="flex-center hover:text-foreground mr-1 h-8 w-8 cursor-pointer rounded-2xl border border-transparent bg-[hsl(var(--glass-tint))/0.52] text-[hsl(var(--nav-item-muted-foreground))] shadow-[0_10px_24px_-24px_hsl(var(--foreground)/0.3)] backdrop-blur-xl transition-[background-color,color,transform] hover:-translate-y-0.5 hover:bg-[hsl(var(--surface-contrast-soft))/0.92]"
|
align="end"
|
||||||
|
content-class="max-h-[min(70vh,420px)] min-w-56 overflow-y-auto"
|
||||||
|
:modal="false"
|
||||||
|
search-empty-text="无匹配标签"
|
||||||
|
search-placeholder="搜索标签"
|
||||||
|
searchable
|
||||||
|
trigger-label="查看标签页"
|
||||||
>
|
>
|
||||||
<ChevronDown class="size-4" />
|
<div
|
||||||
|
class="flex-center hover:text-foreground mr-1 h-8 w-8 cursor-pointer rounded-2xl border border-transparent bg-[hsl(var(--glass-tint))/0.52] text-[hsl(var(--nav-item-muted-foreground))] shadow-[0_10px_24px_-24px_hsl(var(--foreground)/0.3)] backdrop-blur-xl transition-[background-color,color,transform] hover:-translate-y-0.5 hover:bg-[hsl(var(--surface-contrast-soft))/0.92] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
|
||||||
|
>
|
||||||
|
<EllipsisVertical class="size-4" />
|
||||||
</div>
|
</div>
|
||||||
</EasyFlowDropdownMenu>
|
</EasyFlowDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@vueuse/core": "catalog:",
|
"@vueuse/core": "catalog:",
|
||||||
"@vueuse/integrations": "catalog:",
|
"@vueuse/integrations": "catalog:",
|
||||||
"json-bigint": "catalog:",
|
"json-bigint": "catalog:",
|
||||||
|
"mermaid": "^11.15.0",
|
||||||
"qrcode": "catalog:",
|
"qrcode": "catalog:",
|
||||||
"tippy.js": "catalog:",
|
"tippy.js": "catalog:",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ const incremarkOptions = {
|
|||||||
htmlTree: false,
|
htmlTree: false,
|
||||||
math: true,
|
math: true,
|
||||||
};
|
};
|
||||||
|
const codeBlockConfigs = {
|
||||||
|
mermaid: {
|
||||||
|
takeOver: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
const previousContent = ref('');
|
const previousContent = ref('');
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -33,7 +38,9 @@ watch(
|
|||||||
if (import.meta.env.DEV && streaming) {
|
if (import.meta.env.DEV && streaming) {
|
||||||
const startsWithPrevious = content.startsWith(previous);
|
const startsWithPrevious = content.startsWith(previous);
|
||||||
console.debug('[ChatTimeMarkdown] streaming update', {
|
console.debug('[ChatTimeMarkdown] streaming update', {
|
||||||
deltaLength: startsWithPrevious ? content.length - previous.length : null,
|
deltaLength: startsWithPrevious
|
||||||
|
? content.length - previous.length
|
||||||
|
: null,
|
||||||
length: content.length,
|
length: content.length,
|
||||||
previousLength: previous.length,
|
previousLength: previous.length,
|
||||||
preview: content.slice(-160).replaceAll('\n', '\\n'),
|
preview: content.slice(-160).replaceAll('\n', '\\n'),
|
||||||
@@ -50,6 +57,7 @@ watch(
|
|||||||
<div class="chat-time-markdown">
|
<div class="chat-time-markdown">
|
||||||
<ThemeProvider :theme="isDark ? 'dark' : 'default'">
|
<ThemeProvider :theme="isDark ? 'dark' : 'default'">
|
||||||
<IncremarkContent
|
<IncremarkContent
|
||||||
|
:code-block-configs="codeBlockConfigs"
|
||||||
:content="markdownContent"
|
:content="markdownContent"
|
||||||
:incremark-options="incremarkOptions"
|
:incremark-options="incremarkOptions"
|
||||||
:is-finished="isFinished"
|
:is-finished="isFinished"
|
||||||
@@ -290,6 +298,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-time-markdown :deep(.incremark-code) {
|
.chat-time-markdown :deep(.incremark-code) {
|
||||||
|
position: relative;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -300,10 +309,52 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-time-markdown :deep(.incremark-code .code-header) {
|
.chat-time-markdown :deep(.incremark-code .code-header) {
|
||||||
padding: 8px 12px;
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-code .code-header .language) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-code .code-btn) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
color: hsl(var(--text-muted));
|
color: hsl(var(--text-muted));
|
||||||
background: hsl(var(--surface-subtle) / 0.72);
|
background: hsl(var(--surface-subtle) / 0.68);
|
||||||
border-bottom: 1px solid hsl(var(--divider-faint) / 0.72);
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.64;
|
||||||
|
transition:
|
||||||
|
background-color 0.16s ease,
|
||||||
|
color 0.16s ease,
|
||||||
|
opacity 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-code .code-btn:hover:not(:disabled)) {
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.82);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-code .code-btn svg),
|
||||||
|
.chat-time-markdown :deep(.incremark-mermaid .code-btn svg) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-code .code-btn:focus-visible) {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 0.42);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-time-markdown :deep(.incremark-code .code-content) {
|
.chat-time-markdown :deep(.incremark-code .code-content) {
|
||||||
@@ -317,6 +368,10 @@ watch(
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-code .code-content pre) {
|
||||||
|
padding-right: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-time-markdown :deep(.shiki),
|
.chat-time-markdown :deep(.shiki),
|
||||||
.chat-time-markdown :deep(.shiki code) {
|
.chat-time-markdown :deep(.shiki code) {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
@@ -342,13 +397,121 @@ watch(
|
|||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-time-markdown :deep(.mermaid),
|
.chat-time-markdown :deep(svg.mermaid),
|
||||||
.chat-time-markdown :deep([class*='mermaid']) {
|
.chat-time-markdown
|
||||||
|
:deep(
|
||||||
|
.mermaid:not(
|
||||||
|
.incremark-mermaid,
|
||||||
|
.mermaid-header,
|
||||||
|
.mermaid-actions,
|
||||||
|
.mermaid-content,
|
||||||
|
.mermaid-loading,
|
||||||
|
.mermaid-source-code,
|
||||||
|
.mermaid-svg
|
||||||
|
)
|
||||||
|
) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-mermaid) {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--surface-subtle));
|
||||||
|
border: 1px solid hsl(var(--divider-faint) / 0.82);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-header) {
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-header .language) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-actions) {
|
||||||
|
gap: 2px;
|
||||||
|
padding: 1px;
|
||||||
|
background: hsl(var(--surface-subtle) / 0.68);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
opacity: 0.68;
|
||||||
|
transition:
|
||||||
|
background-color 0.16s ease,
|
||||||
|
opacity 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-actions:hover) {
|
||||||
|
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.82);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-mermaid .code-btn) {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
border-radius: 4px;
|
||||||
|
transition:
|
||||||
|
background-color 0.16s ease,
|
||||||
|
color 0.16s ease,
|
||||||
|
opacity 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-mermaid .code-btn:hover:not(:disabled)) {
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--surface-hover, var(--surface-subtle)) / 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.incremark-mermaid .code-btn:focus-visible) {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 0.42);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-content) {
|
||||||
|
min-height: 96px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-loading) {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-source-code) {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 52px 0 0;
|
||||||
|
overflow: auto;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-svg) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-time-markdown :deep(.mermaid-svg svg) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-time-markdown :deep(svg) {
|
.chat-time-markdown :deep(svg) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {mount} from '@vue/test-utils';
|
||||||
|
import {nextTick} from 'vue';
|
||||||
|
|
||||||
|
import {describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
|
import ChatTimeMarkdown from '../ChatTimeMarkdown.vue';
|
||||||
|
|
||||||
|
vi.mock('@easyflow-core/preferences', () => ({
|
||||||
|
usePreferences: () => ({
|
||||||
|
isDark: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ChatTimeMarkdown', () => {
|
||||||
|
it('renders mermaid code fences with the shared mermaid block', async () => {
|
||||||
|
const wrapper = mount(ChatTimeMarkdown, {
|
||||||
|
props: {
|
||||||
|
content: [
|
||||||
|
'```mermaid',
|
||||||
|
'flowchart TD',
|
||||||
|
'A[开始] --> B[结束]',
|
||||||
|
'```',
|
||||||
|
].join('\n'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('.incremark-mermaid').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.mermaid-header').text().toLowerCase()).toContain(
|
||||||
|
'mermaid',
|
||||||
|
);
|
||||||
|
expect(wrapper.find('.mermaid-source-code').text()).toContain(
|
||||||
|
'flowchart TD',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -156,25 +156,24 @@ describe('chat timeline builder', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows no compression needed when memory compression produced no compressed event', () => {
|
it('removes memory compression status when compression produced no compressed event', () => {
|
||||||
const items: ChatTimelineItem[] = [];
|
const items: ChatTimelineItem[] = [];
|
||||||
|
|
||||||
|
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
|
||||||
|
label: '正在整理上下文',
|
||||||
|
phase: 'started',
|
||||||
|
status: 'running',
|
||||||
|
statusKey: 'memory-compression',
|
||||||
|
});
|
||||||
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
|
ChatTimelineBuilder.upsertMemoryCompressionStatus(items, {
|
||||||
compressed: false,
|
compressed: false,
|
||||||
label: '已整理上下文',
|
label: '无需压缩上下文',
|
||||||
phase: 'completed',
|
phase: 'completed',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
statusKey: 'memory-compression',
|
statusKey: 'memory-compression',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(items).toHaveLength(1);
|
expect(items).toHaveLength(0);
|
||||||
expect(items[0]?.type).toBe('status');
|
|
||||||
if (items[0]?.type === 'status') {
|
|
||||||
expect(items[0].label).toBe('无需压缩上下文');
|
|
||||||
expect(items[0].presentation).toBe('separator');
|
|
||||||
expect(items[0].status).toBe('done');
|
|
||||||
expect(items[0].statusKey).toBe('memory-compression');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ends current thinking before showing knowledge retrieval status', () => {
|
it('ends current thinking before showing knowledge retrieval status', () => {
|
||||||
@@ -227,6 +226,24 @@ describe('chat timeline builder', () => {
|
|||||||
expect(items).toHaveLength(0);
|
expect(items).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps AgentScope context reload hidden without showing a tool card', () => {
|
||||||
|
const items: ChatTimelineItem[] = [];
|
||||||
|
|
||||||
|
ChatTimelineBuilder.upsertToolCall(items, {
|
||||||
|
toolCallId: 'call-context-reload',
|
||||||
|
toolName: 'context_reload',
|
||||||
|
input: { working_context_offload_uuid: 'context-id' },
|
||||||
|
});
|
||||||
|
ChatTimelineBuilder.upsertToolCall(items, {
|
||||||
|
toolCallId: 'call-context-reload',
|
||||||
|
toolName: 'context_reload',
|
||||||
|
output: { result: 'ok' },
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores anonymous tool call events instead of rendering a fallback card', () => {
|
it('ignores anonymous tool call events instead of rendering a fallback card', () => {
|
||||||
const items: ChatTimelineItem[] = [];
|
const items: ChatTimelineItem[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ function normalizeToolName(value?: string) {
|
|||||||
function isHiddenToolName(toolName?: string) {
|
function isHiddenToolName(toolName?: string) {
|
||||||
const normalizedName = normalizeToolName(toolName);
|
const normalizedName = normalizeToolName(toolName);
|
||||||
return (
|
return (
|
||||||
normalizedName === 'retrieve_knowledge' || normalizedName === '__fragment__'
|
normalizedName === 'retrieve_knowledge' ||
|
||||||
|
normalizedName === 'context_reload' ||
|
||||||
|
normalizedName === '__fragment__'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +180,15 @@ function findStatusItem(items: ChatTimelineItem[], statusKey: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeStatusItem(items: ChatTimelineItem[], statusKey: string) {
|
||||||
|
const index = items.findIndex(
|
||||||
|
(item) => item.type === 'status' && item.statusKey === statusKey,
|
||||||
|
);
|
||||||
|
if (index >= 0) {
|
||||||
|
items.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function doneStatusLabel(item: ChatTimelineStatusItem) {
|
function doneStatusLabel(item: ChatTimelineStatusItem) {
|
||||||
if (item.statusKey === 'knowledge-retrieval') {
|
if (item.statusKey === 'knowledge-retrieval') {
|
||||||
return '已检索知识库';
|
return '已检索知识库';
|
||||||
@@ -453,17 +464,20 @@ export const ChatTimelineBuilder = {
|
|||||||
payload?.status === 'done' || payload?.phase === 'completed'
|
payload?.status === 'done' || payload?.phase === 'completed'
|
||||||
? 'done'
|
? 'done'
|
||||||
: 'running';
|
: 'running';
|
||||||
|
const statusKey = payload?.statusKey || 'memory-compression';
|
||||||
finishAssistantMessage(items, false);
|
finishAssistantMessage(items, false);
|
||||||
|
if (status === 'done' && payload?.compressed === false) {
|
||||||
|
removeStatusItem(items, statusKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const label =
|
const label =
|
||||||
status === 'running'
|
status === 'running'
|
||||||
? payload?.label || '正在整理上下文'
|
? payload?.label || '正在整理上下文'
|
||||||
: payload?.compressed === false
|
|
||||||
? '无需压缩上下文'
|
|
||||||
: payload?.label || '已整理上下文';
|
: payload?.label || '已整理上下文';
|
||||||
upsertStatus(items, {
|
upsertStatus(items, {
|
||||||
label,
|
label,
|
||||||
status,
|
status,
|
||||||
statusKey: payload?.statusKey || 'memory-compression',
|
statusKey,
|
||||||
presentation: 'separator',
|
presentation: 'separator',
|
||||||
tone: 'muted',
|
tone: 'muted',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import {computed} from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import {useRoute} from 'vue-router';
|
||||||
|
|
||||||
import { useContentMaximize, useTabs } from '@easyflow/hooks';
|
import {useContentMaximize, useTabs} from '@easyflow/hooks';
|
||||||
import { preferences } from '@easyflow/preferences';
|
import {preferences} from '@easyflow/preferences';
|
||||||
import { useTabbarStore } from '@easyflow/stores';
|
import {useTabbarStore} from '@easyflow/stores';
|
||||||
|
|
||||||
import { TabsToolMore, TabsToolScreen, TabsView } from '@easyflow-core/tabs-ui';
|
import {TabsToolMore, TabsToolScreen, TabsView} from '@easyflow-core/tabs-ui';
|
||||||
|
|
||||||
import { useTabbar } from './use-tabbar';
|
import {useTabbar} from './use-tabbar';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'LayoutTabbar',
|
name: 'LayoutTabbar',
|
||||||
@@ -30,13 +30,17 @@ const {
|
|||||||
} = useTabbar();
|
} = useTabbar();
|
||||||
|
|
||||||
const menus = computed(() => {
|
const menus = computed(() => {
|
||||||
const tab = tabbarStore.getTabByKey(currentActive.value);
|
return (currentTabs.value || []).map((tab) => {
|
||||||
const menus = createContextMenus(tab);
|
const key = tab.key as string;
|
||||||
return menus.map((item) => {
|
const title =
|
||||||
|
(tab.meta?.newTabTitle || tab.meta?.title || tab.name || tab.path) as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
return {
|
return {
|
||||||
...item,
|
active: key === currentActive.value,
|
||||||
label: item.text,
|
handler: () => handleClick(key),
|
||||||
value: item.key,
|
label: title || key,
|
||||||
|
value: key,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type { RouteLocationNormalizedGeneric } from 'vue-router';
|
import type {TabDefinition} from '@easyflow/types';
|
||||||
|
|
||||||
import type { TabDefinition } from '@easyflow/types';
|
import type {IContextMenuItem} from '@easyflow-core/tabs-ui';
|
||||||
|
|
||||||
import type { IContextMenuItem } from '@easyflow-core/tabs-ui';
|
import {computed, ref, watch} from 'vue';
|
||||||
|
import {useRoute, useRouter} from 'vue-router';
|
||||||
|
|
||||||
import { computed, ref, watch } from 'vue';
|
import {useContentMaximize, useTabs} from '@easyflow/hooks';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
import { useContentMaximize, useTabs } from '@easyflow/hooks';
|
|
||||||
import {
|
import {
|
||||||
ArrowLeftToLine,
|
ArrowLeftToLine,
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
@@ -21,9 +19,9 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
X,
|
X,
|
||||||
} from '@easyflow/icons';
|
} from '@easyflow/icons';
|
||||||
import { $t, useI18n } from '@easyflow/locales';
|
import {$t, useI18n} from '@easyflow/locales';
|
||||||
import { getTabKey, useAccessStore, useTabbarStore } from '@easyflow/stores';
|
import {getTabKey, useAccessStore, useTabbarStore} from '@easyflow/stores';
|
||||||
import { filterTree } from '@easyflow/utils';
|
import {filterTree} from '@easyflow/utils';
|
||||||
|
|
||||||
export function useTabbar() {
|
export function useTabbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -52,7 +50,7 @@ export function useTabbar() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { locale } = useI18n();
|
const { locale } = useI18n();
|
||||||
const currentTabs = ref<RouteLocationNormalizedGeneric[]>();
|
const currentTabs = ref<TabDefinition[]>();
|
||||||
watch(
|
watch(
|
||||||
[
|
[
|
||||||
() => tabbarStore.getTabs,
|
() => tabbarStore.getTabs,
|
||||||
@@ -99,7 +97,7 @@ export function useTabbar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapperTabLocale(tab: RouteLocationNormalizedGeneric) {
|
function wrapperTabLocale(tab: TabDefinition) {
|
||||||
const navTitle = tab?.meta?.navTitle as string | undefined;
|
const navTitle = tab?.meta?.navTitle as string | undefined;
|
||||||
return {
|
return {
|
||||||
...tab,
|
...tab,
|
||||||
|
|||||||
@@ -120,6 +120,25 @@ describe('chat-time timeline builder', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not render built-in context reload tools as normal tool cards', () => {
|
||||||
|
const items: any[] = [];
|
||||||
|
|
||||||
|
ChatTimeTimelineBuilder.appendMessageDelta(items, '第一段回答', 1);
|
||||||
|
ChatTimeTimelineBuilder.upsertToolCall(items, {
|
||||||
|
name: 'context_reload',
|
||||||
|
toolCallId: 'context-reload-1',
|
||||||
|
value: '{"working_context_offload_uuid":"context-id"}',
|
||||||
|
});
|
||||||
|
ChatTimeTimelineBuilder.upsertToolResult(items, {
|
||||||
|
name: 'context_reload',
|
||||||
|
result: '{"messages":[]}',
|
||||||
|
toolCallId: 'context-reload-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]).toMatchObject({ content: '第一段回答', role: 'assistant' });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not render anonymous internal tool calls as normal tool cards', () => {
|
it('does not render anonymous internal tool calls as normal tool cards', () => {
|
||||||
const items: any[] = [];
|
const items: any[] = [];
|
||||||
|
|
||||||
@@ -254,6 +273,114 @@ describe('chat-time history mapper', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips internal tools when restoring OpenAI-style structured history', () => {
|
||||||
|
const items = ChatTimeHistoryMapper.fromHistoryRecords([
|
||||||
|
{
|
||||||
|
contentPayload: {
|
||||||
|
messageChain: [
|
||||||
|
{
|
||||||
|
content: '先回答一点',
|
||||||
|
role: 'assistant',
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
function: {
|
||||||
|
arguments: '{"query":"暑假安排"}',
|
||||||
|
name: 'retrieve_knowledge',
|
||||||
|
},
|
||||||
|
id: 'knowledge-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
function: {
|
||||||
|
arguments: '{"working_context_offload_uuid":"context-id"}',
|
||||||
|
name: 'context_reload',
|
||||||
|
},
|
||||||
|
id: 'context-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
function: {
|
||||||
|
arguments: '{"text":"partial"}',
|
||||||
|
name: '__fragment__',
|
||||||
|
},
|
||||||
|
id: 'fragment-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
function: {
|
||||||
|
arguments: '{"query":"java"}',
|
||||||
|
name: 'search_docs',
|
||||||
|
},
|
||||||
|
id: 'tool-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '{"hits":1}',
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: 'knowledge-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '{"messages":[]}',
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: 'context-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '{"ok":true}',
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: 'fragment-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '{"hits":2}',
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: 'tool-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
created: 100,
|
||||||
|
id: 'assistant-record',
|
||||||
|
senderRole: 'assistant',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0]).toMatchObject({
|
||||||
|
content: '先回答一点',
|
||||||
|
role: 'assistant',
|
||||||
|
});
|
||||||
|
expect(items[1]).toMatchObject({
|
||||||
|
arguments: '{"query":"java"}',
|
||||||
|
name: 'search_docs',
|
||||||
|
result: '{"hits":2}',
|
||||||
|
role: 'tool',
|
||||||
|
toolCallId: 'tool-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips nameless tool result records when restoring history', () => {
|
||||||
|
const items = ChatTimeHistoryMapper.fromHistoryRecords([
|
||||||
|
{
|
||||||
|
content: '{"hits":1}',
|
||||||
|
contentPayload: {
|
||||||
|
result: '{"hits":1}',
|
||||||
|
toolCallId: 'knowledge-1',
|
||||||
|
},
|
||||||
|
created: 100,
|
||||||
|
id: 'tool-record',
|
||||||
|
senderRole: 'tool',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: '最终回答',
|
||||||
|
created: 101,
|
||||||
|
id: 'assistant-record',
|
||||||
|
senderRole: 'assistant',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]).toMatchObject({
|
||||||
|
content: '最终回答',
|
||||||
|
role: 'assistant',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to legacy chains when messageChain is unavailable', () => {
|
it('falls back to legacy chains when messageChain is unavailable', () => {
|
||||||
const items = ChatTimeHistoryMapper.fromLegacyMessages([
|
const items = ChatTimeHistoryMapper.fromLegacyMessages([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,13 +19,31 @@ type ChatTimeToolMeta = {
|
|||||||
|
|
||||||
function isHiddenToolName(value?: string) {
|
function isHiddenToolName(value?: string) {
|
||||||
const normalized = normalizePlainText(value).trim().toLowerCase();
|
const normalized = normalizePlainText(value).trim().toLowerCase();
|
||||||
return normalized === 'retrieve_knowledge' || normalized === '__fragment__';
|
return (
|
||||||
|
normalized === 'retrieve_knowledge' ||
|
||||||
|
normalized === 'context_reload' ||
|
||||||
|
normalized === '__fragment__'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBlankToolName(value?: string) {
|
function isBlankToolName(value?: string) {
|
||||||
return !normalizePlainText(value).trim();
|
return !normalizePlainText(value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeToolCallId(value: Record<string, any>) {
|
||||||
|
return normalizePlainText(value.id ?? value.toolCallId ?? value.tool_call_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCallName(value: Record<string, any>) {
|
||||||
|
const fn = toObjectRecord(value.function);
|
||||||
|
return normalizePlainText(value.name ?? value.toolName ?? fn.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolCallArguments(value: Record<string, any>) {
|
||||||
|
const fn = toObjectRecord(value.function);
|
||||||
|
return normalizePayloadValue(value.arguments ?? fn.arguments);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 聊天时间线实时构建器。
|
* 聊天时间线实时构建器。
|
||||||
*/
|
*/
|
||||||
@@ -623,6 +641,9 @@ function createToolItemFromChain(
|
|||||||
if (!toolCallId && !name && !argumentsValue) {
|
if (!toolCallId && !name && !argumentsValue) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isBlankToolName(name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return createToolItem({
|
return createToolItem({
|
||||||
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
|
arguments: status === 'TOOL_CALL' ? argumentsValue : undefined,
|
||||||
created,
|
created,
|
||||||
@@ -655,6 +676,9 @@ function createToolItemFromStructuredMessage(
|
|||||||
if (isHiddenToolName(toolMeta?.name || toolName)) {
|
if (isHiddenToolName(toolMeta?.name || toolName)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isBlankToolName(toolMeta?.name || toolName)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const result = normalizePayloadValue(rawMessage.content);
|
const result = normalizePayloadValue(rawMessage.content);
|
||||||
return createToolItem({
|
return createToolItem({
|
||||||
arguments: toolMeta?.arguments,
|
arguments: toolMeta?.arguments,
|
||||||
@@ -680,6 +704,9 @@ function createToolItemFromTopLevelRecord(record: ChatTimeHistoryRecord) {
|
|||||||
if (isHiddenToolName(name)) {
|
if (isHiddenToolName(name)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isBlankToolName(name)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const toolCallId = normalizePlainText(
|
const toolCallId = normalizePlainText(
|
||||||
payload.toolCallId ?? payload.tool_call_id ?? record.id,
|
payload.toolCallId ?? payload.tool_call_id ?? record.id,
|
||||||
);
|
);
|
||||||
@@ -768,13 +795,13 @@ function collectToolMeta(
|
|||||||
) {
|
) {
|
||||||
const toolCalls = toObjectArray(rawMessage.toolCalls ?? rawMessage.tool_calls);
|
const toolCalls = toObjectArray(rawMessage.toolCalls ?? rawMessage.tool_calls);
|
||||||
for (const toolCall of toolCalls) {
|
for (const toolCall of toolCalls) {
|
||||||
const toolCallId = normalizePlainText(toolCall.id);
|
const toolCallId = normalizeToolCallId(toolCall);
|
||||||
if (!toolCallId) {
|
if (!toolCallId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
toolMetaMap.set(toolCallId, {
|
toolMetaMap.set(toolCallId, {
|
||||||
arguments: normalizePayloadValue(toolCall.arguments),
|
arguments: normalizeToolCallArguments(toolCall),
|
||||||
name: normalizePlainText(toolCall.name ?? toolCall.toolName),
|
name: normalizeToolCallName(toolCall),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
645
easyflow-ui-admin/pnpm-lock.yaml
generated
645
easyflow-ui-admin/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user