feat: 重构知识库文档导入任务化流程
- 新增上传建单、异步解析、分块处理与异步向量化闭环 - 收口分享页权限、完成态检索过滤与 SSE 局部状态刷新
This commit is contained in:
@@ -146,10 +146,14 @@ public class DocumentCollectionController extends BaseCurdController<DocumentCol
|
|||||||
)
|
)
|
||||||
public Result<List<KnowledgeSearchResultItem>> search(@RequestParam BigInteger knowledgeId,
|
public Result<List<KnowledgeSearchResultItem>> search(@RequestParam BigInteger knowledgeId,
|
||||||
@RequestParam String keyword,
|
@RequestParam String keyword,
|
||||||
@RequestParam(required = false) String retrievalMode) {
|
@RequestParam(required = false) String retrievalMode,
|
||||||
|
@RequestParam(required = false) Integer docRecallMaxNum,
|
||||||
|
@RequestParam(required = false) Double simThreshold) {
|
||||||
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
|
KnowledgeRetrievalRequest request = new KnowledgeRetrievalRequest();
|
||||||
request.setKnowledgeId(knowledgeId);
|
request.setKnowledgeId(knowledgeId);
|
||||||
request.setQuery(keyword);
|
request.setQuery(keyword);
|
||||||
|
request.setLimit(docRecallMaxNum);
|
||||||
|
request.setMinSimilarity(simThreshold);
|
||||||
request.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
|
request.setRetrievalMode(KnowledgeRetrievalModes.parse(retrievalMode));
|
||||||
request.setCallerType("API");
|
request.setCallerType("API");
|
||||||
request.setCallerId(String.valueOf(knowledgeId));
|
request.setCallerId(String.valueOf(knowledgeId));
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import jakarta.servlet.http.HttpServletResponse;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
||||||
|
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
|
||||||
import tech.easyflow.ai.entity.Document;
|
import tech.easyflow.ai.entity.Document;
|
||||||
import tech.easyflow.ai.entity.DocumentCollection;
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
import tech.easyflow.ai.entity.DocumentCollectionSplitParams;
|
import tech.easyflow.ai.entity.DocumentCollectionSplitParams;
|
||||||
@@ -77,6 +80,9 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ResourceAccessService resourceAccessService;
|
private ResourceAccessService resourceAccessService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
|
||||||
|
|
||||||
@Value("${easyflow.storage.local.root:}")
|
@Value("${easyflow.storage.local.root:}")
|
||||||
private String fileUploadPath;
|
private String fileUploadPath;
|
||||||
|
|
||||||
@@ -233,6 +239,79 @@ public class DocumentController extends BaseCurdController<DocumentService, Docu
|
|||||||
return documentService.commitImport(request);
|
return documentService.commitImport(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("import/task/create")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(@JsonBody DocumentImportDtos.TaskCreateRequest request) {
|
||||||
|
if (request.getKnowledgeId() == null) {
|
||||||
|
throw new BusinessException("知识库id不能为空");
|
||||||
|
}
|
||||||
|
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
|
||||||
|
return documentService.createImportTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("import/task/detail")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/query")
|
||||||
|
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(@RequestParam BigInteger taskId) {
|
||||||
|
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
|
||||||
|
if (result.getData() != null && result.getData().getKnowledgeId() != null) {
|
||||||
|
getDocumentCollection(result.getData().getKnowledgeId().toString(), ResourceAction.READ, "无权限访问知识库");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅知识库文档任务状态流。
|
||||||
|
*
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
* @return SSE 推送连接
|
||||||
|
*/
|
||||||
|
@PostMapping(value = "import/task/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/query")
|
||||||
|
public SseEmitter streamImportTask(@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId) {
|
||||||
|
getDocumentCollection(knowledgeId.toString(), ResourceAction.READ, "无权限访问知识库");
|
||||||
|
return documentImportTaskStatusStreamService.subscribe(knowledgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("import/task/preview")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(@JsonBody DocumentImportDtos.PreviewRequest request) {
|
||||||
|
if (request.getKnowledgeId() == null) {
|
||||||
|
throw new BusinessException("知识库id不能为空");
|
||||||
|
}
|
||||||
|
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
|
||||||
|
return documentService.previewImportTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("import/task/startIndex")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(@JsonBody DocumentImportDtos.TaskStartIndexRequest request) {
|
||||||
|
if (request.getKnowledgeId() == null) {
|
||||||
|
throw new BusinessException("知识库id不能为空");
|
||||||
|
}
|
||||||
|
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
|
||||||
|
return documentService.startIndexTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("import/task/retryParse")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(@JsonBody DocumentImportDtos.TaskRetryRequest request) {
|
||||||
|
if (request.getKnowledgeId() == null) {
|
||||||
|
throw new BusinessException("知识库id不能为空");
|
||||||
|
}
|
||||||
|
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
|
||||||
|
return documentService.retryParseTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("import/task/retryIndex")
|
||||||
|
@SaCheckPermission("/api/v1/documentCollection/save")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(@JsonBody DocumentImportDtos.TaskRetryRequest request) {
|
||||||
|
if (request.getKnowledgeId() == null) {
|
||||||
|
throw new BusinessException("知识库id不能为空");
|
||||||
|
}
|
||||||
|
getDocumentCollection(request.getKnowledgeId().toString(), ResourceAction.MANAGE, "无权限管理知识库");
|
||||||
|
return documentService.retryIndexTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新 entity
|
* 更新 entity
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import org.springframework.web.bind.annotation.PostMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
||||||
|
import tech.easyflow.ai.documentimport.task.DocumentImportTaskStatusStreamService;
|
||||||
import tech.easyflow.ai.dto.KnowledgeShareLimitedConfigRequest;
|
import tech.easyflow.ai.dto.KnowledgeShareLimitedConfigRequest;
|
||||||
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
|
import tech.easyflow.ai.dto.KnowledgeSearchResultItem;
|
||||||
import tech.easyflow.ai.entity.Document;
|
import tech.easyflow.ai.entity.Document;
|
||||||
@@ -42,6 +44,7 @@ import tech.easyflow.ai.service.KnowledgeShareService;
|
|||||||
import tech.easyflow.ai.service.ModelService;
|
import tech.easyflow.ai.service.ModelService;
|
||||||
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.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.vo.UploadResVo;
|
import tech.easyflow.common.vo.UploadResVo;
|
||||||
@@ -99,6 +102,8 @@ public class ShareKnowledgeController {
|
|||||||
private KnowledgeEmbeddingService knowledgeEmbeddingService;
|
private KnowledgeEmbeddingService knowledgeEmbeddingService;
|
||||||
@Resource(name = "default")
|
@Resource(name = "default")
|
||||||
private FileStorageService fileStorageService;
|
private FileStorageService fileStorageService;
|
||||||
|
@Resource
|
||||||
|
private DocumentImportTaskStatusStreamService documentImportTaskStatusStreamService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取知识库详情。
|
* 获取知识库详情。
|
||||||
@@ -107,14 +112,17 @@ public class ShareKnowledgeController {
|
|||||||
* @return 知识库详情
|
* @return 知识库详情
|
||||||
*/
|
*/
|
||||||
@GetMapping("/documentCollection/detail")
|
@GetMapping("/documentCollection/detail")
|
||||||
public Result<DocumentCollection> detail(@RequestParam String shareKey) {
|
public Result<KnowledgeShareViewDetail> detail(@RequestParam String shareKey) {
|
||||||
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
shareKey,
|
shareKey,
|
||||||
null,
|
null,
|
||||||
KnowledgeShareActionScope.VIEW.name()
|
KnowledgeShareActionScope.VIEW.name()
|
||||||
);
|
);
|
||||||
audit(context, "访问知识库分享页", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", context.getKnowledge().getId()));
|
audit(context, "访问知识库分享页", "KNOWLEDGE_SHARE_URL_ACCESS", false, auditDetail("knowledgeId", context.getKnowledge().getId()));
|
||||||
return Result.ok(context.getKnowledge());
|
KnowledgeShareViewDetail detail = new KnowledgeShareViewDetail();
|
||||||
|
detail.setKnowledge(context.getKnowledge());
|
||||||
|
detail.setPermissionScopes(new java.util.ArrayList<String>(context.getShare().getPermissionScopeSet()));
|
||||||
|
return Result.ok(detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -234,6 +242,26 @@ public class ShareKnowledgeController {
|
|||||||
return Result.ok(documentService.getDocumentList(context.getKnowledge().getId().toString(), pageSize, pageNumber, title));
|
return Result.ok(documentService.getDocumentList(context.getKnowledge().getId().toString(), pageSize, pageNumber, title));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅分享知识库的文档任务状态流。
|
||||||
|
*
|
||||||
|
* @param shareKey 分享访问密钥
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
* @return SSE 推送连接
|
||||||
|
*/
|
||||||
|
@PostMapping(value = "/document/import/task/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter streamDocumentTask(
|
||||||
|
@RequestParam String shareKey,
|
||||||
|
@JsonBody(value = "knowledgeId", required = true) BigInteger knowledgeId
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
|
shareKey,
|
||||||
|
knowledgeId,
|
||||||
|
KnowledgeShareActionScope.VIEW.name()
|
||||||
|
);
|
||||||
|
return documentImportTaskStatusStreamService.subscribe(context.getKnowledge().getId());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载文档。
|
* 下载文档。
|
||||||
*/
|
*/
|
||||||
@@ -344,6 +372,104 @@ public class ShareKnowledgeController {
|
|||||||
return documentService.commitImport(request);
|
return documentService.commitImport(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/create")
|
||||||
|
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(
|
||||||
|
@RequestParam String shareKey,
|
||||||
|
@JsonBody DocumentImportDtos.TaskCreateRequest request
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
|
shareKey,
|
||||||
|
request == null ? null : request.getKnowledgeId(),
|
||||||
|
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||||
|
);
|
||||||
|
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||||
|
request.setKnowledgeId(knowledgeId);
|
||||||
|
audit(context, "创建分享文档导入任务", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||||
|
return documentService.createImportTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/document/import/task/detail")
|
||||||
|
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(
|
||||||
|
@RequestParam String shareKey,
|
||||||
|
@RequestParam BigInteger taskId
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
|
shareKey,
|
||||||
|
null,
|
||||||
|
KnowledgeShareActionScope.VIEW.name()
|
||||||
|
);
|
||||||
|
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
|
||||||
|
BigInteger knowledgeId = result.getData() == null ? null : result.getData().getKnowledgeId();
|
||||||
|
if (knowledgeId == null || knowledgeId.compareTo(context.getKnowledge().getId()) != 0) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/preview")
|
||||||
|
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(
|
||||||
|
@RequestParam String shareKey,
|
||||||
|
@JsonBody DocumentImportDtos.PreviewRequest request
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
|
shareKey,
|
||||||
|
request == null ? null : request.getKnowledgeId(),
|
||||||
|
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||||
|
);
|
||||||
|
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||||
|
request.setKnowledgeId(knowledgeId);
|
||||||
|
audit(context, "预览分享文档分块", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||||
|
return documentService.previewImportTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/startIndex")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(
|
||||||
|
@RequestParam String shareKey,
|
||||||
|
@JsonBody DocumentImportDtos.TaskStartIndexRequest request
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
|
shareKey,
|
||||||
|
request == null ? null : request.getKnowledgeId(),
|
||||||
|
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||||
|
);
|
||||||
|
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||||
|
request.setKnowledgeId(knowledgeId);
|
||||||
|
audit(context, "启动分享文档向量化", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||||
|
return documentService.startIndexTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/retryParse")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(
|
||||||
|
@RequestParam String shareKey,
|
||||||
|
@JsonBody DocumentImportDtos.TaskRetryRequest request
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
|
shareKey,
|
||||||
|
request == null ? null : request.getKnowledgeId(),
|
||||||
|
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||||
|
);
|
||||||
|
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||||
|
request.setKnowledgeId(knowledgeId);
|
||||||
|
audit(context, "重试分享文档解析", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||||
|
return documentService.retryParseTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/retryIndex")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(
|
||||||
|
@RequestParam String shareKey,
|
||||||
|
@JsonBody DocumentImportDtos.TaskRetryRequest request
|
||||||
|
) {
|
||||||
|
KnowledgeShareAuthContext context = knowledgeShareService.assertUrlShareAccess(
|
||||||
|
shareKey,
|
||||||
|
request == null ? null : request.getKnowledgeId(),
|
||||||
|
KnowledgeShareActionScope.CONTENT_CREATE.name()
|
||||||
|
);
|
||||||
|
BigInteger knowledgeId = resolveKnowledgeId(context, request == null ? null : request.getKnowledgeId());
|
||||||
|
request.setKnowledgeId(knowledgeId);
|
||||||
|
audit(context, "重试分享文档向量化", "KNOWLEDGE_SHARE_URL_WRITE", true, auditDetail("knowledgeId", knowledgeId));
|
||||||
|
return documentService.retryIndexTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chunk 分页。
|
* Chunk 分页。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -224,6 +224,83 @@ public class PublicKnowledgeShareController {
|
|||||||
return documentService.commitImport(request);
|
return documentService.commitImport(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/create")
|
||||||
|
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(
|
||||||
|
@RequestHeader("ApiKey") String apiKey,
|
||||||
|
@JsonBody DocumentImportDtos.TaskCreateRequest request,
|
||||||
|
HttpServletRequest servletRequest
|
||||||
|
) {
|
||||||
|
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||||
|
requireDocumentKnowledge(request.getKnowledgeId());
|
||||||
|
audit(apiKey, "API创建文档导入任务", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||||
|
return documentService.createImportTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/document/import/task/detail")
|
||||||
|
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(
|
||||||
|
@RequestHeader("ApiKey") String apiKey,
|
||||||
|
@RequestParam BigInteger knowledgeId,
|
||||||
|
@RequestParam BigInteger taskId,
|
||||||
|
HttpServletRequest request
|
||||||
|
) {
|
||||||
|
assertApiShare(apiKey, request.getRequestURI(), knowledgeId, KnowledgeShareActionScope.VIEW.name());
|
||||||
|
requireDocumentKnowledge(knowledgeId);
|
||||||
|
Result<DocumentImportDtos.TaskDetailResponse> result = documentService.getImportTaskDetail(taskId);
|
||||||
|
if (result.getData() == null || result.getData().getKnowledgeId() == null
|
||||||
|
|| result.getData().getKnowledgeId().compareTo(knowledgeId) != 0) {
|
||||||
|
throw new BusinessException("任务不存在");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/preview")
|
||||||
|
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(
|
||||||
|
@RequestHeader("ApiKey") String apiKey,
|
||||||
|
@JsonBody DocumentImportDtos.PreviewRequest request,
|
||||||
|
HttpServletRequest servletRequest
|
||||||
|
) {
|
||||||
|
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||||
|
requireDocumentKnowledge(request.getKnowledgeId());
|
||||||
|
audit(apiKey, "API预览文档分块", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||||
|
return documentService.previewImportTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/startIndex")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(
|
||||||
|
@RequestHeader("ApiKey") String apiKey,
|
||||||
|
@JsonBody DocumentImportDtos.TaskStartIndexRequest request,
|
||||||
|
HttpServletRequest servletRequest
|
||||||
|
) {
|
||||||
|
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||||
|
requireDocumentKnowledge(request.getKnowledgeId());
|
||||||
|
audit(apiKey, "API启动文档向量化", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||||
|
return documentService.startIndexTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/retryParse")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(
|
||||||
|
@RequestHeader("ApiKey") String apiKey,
|
||||||
|
@JsonBody DocumentImportDtos.TaskRetryRequest request,
|
||||||
|
HttpServletRequest servletRequest
|
||||||
|
) {
|
||||||
|
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||||
|
requireDocumentKnowledge(request.getKnowledgeId());
|
||||||
|
audit(apiKey, "API重试文档解析", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||||
|
return documentService.retryParseTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/document/import/task/retryIndex")
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(
|
||||||
|
@RequestHeader("ApiKey") String apiKey,
|
||||||
|
@JsonBody DocumentImportDtos.TaskRetryRequest request,
|
||||||
|
HttpServletRequest servletRequest
|
||||||
|
) {
|
||||||
|
assertApiShare(apiKey, servletRequest.getRequestURI(), request.getKnowledgeId(), KnowledgeShareActionScope.CONTENT_CREATE.name());
|
||||||
|
requireDocumentKnowledge(request.getKnowledgeId());
|
||||||
|
audit(apiKey, "API重试文档向量化", "KNOWLEDGE_API_SHARE_WRITE", servletRequest.getRequestURI(), Map.of("knowledgeId", request.getKnowledgeId()));
|
||||||
|
return documentService.retryIndexTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chunk 分页。
|
* Chunk 分页。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package tech.easyflow.common.mq.redis;
|
package tech.easyflow.common.mq.redis;
|
||||||
|
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.context.SmartLifecycle;
|
import org.springframework.context.SmartLifecycle;
|
||||||
import org.springframework.data.domain.Range;
|
import org.springframework.data.domain.Range;
|
||||||
import org.springframework.data.redis.connection.RedisConnection;
|
import org.springframework.data.redis.connection.RedisConnection;
|
||||||
@@ -34,6 +36,8 @@ import java.util.concurrent.TimeUnit;
|
|||||||
|
|
||||||
public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifecycle {
|
public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifecycle {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(RedisMQConsumerContainer.class);
|
||||||
|
|
||||||
private final RedisConnectionFactory redisConnectionFactory;
|
private final RedisConnectionFactory redisConnectionFactory;
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
private final MQProperties properties;
|
private final MQProperties properties;
|
||||||
@@ -71,6 +75,8 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
MQSubscription subscription = handler.subscription();
|
MQSubscription subscription = handler.subscription();
|
||||||
for (int shard = 0; shard < Math.max(subscription.getShardCount(), 1); shard++) {
|
for (int shard = 0; shard < Math.max(subscription.getShardCount(), 1); shard++) {
|
||||||
int currentShard = shard;
|
int currentShard = shard;
|
||||||
|
LOG.info("启动 MQ 消费线程: topic={}, group={}, shard={}, handler={}",
|
||||||
|
subscription.getTopic(), subscription.getConsumerGroup(), currentShard, handler.getClass().getSimpleName());
|
||||||
executorService.submit(() -> consumeLoop(handler, subscription, currentShard));
|
executorService.submit(() -> consumeLoop(handler, subscription, currentShard));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +112,8 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
String streamKey = keySupport.streamKey(subscription.getTopic(), shard);
|
String streamKey = keySupport.streamKey(subscription.getTopic(), shard);
|
||||||
String consumerName = subscription.getConsumerGroup() + "-" + shard;
|
String consumerName = subscription.getConsumerGroup() + "-" + shard;
|
||||||
ensureConsumerGroup(streamKey, subscription.getConsumerGroup());
|
ensureConsumerGroup(streamKey, subscription.getConsumerGroup());
|
||||||
|
LOG.info("MQ 消费循环已启动: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}",
|
||||||
|
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, handler.getClass().getSimpleName());
|
||||||
while (running) {
|
while (running) {
|
||||||
try {
|
try {
|
||||||
reclaimPending(streamKey, subscription.getConsumerGroup(), consumerName);
|
reclaimPending(streamKey, subscription.getConsumerGroup(), consumerName);
|
||||||
@@ -123,8 +131,18 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
if (messages.isEmpty()) {
|
if (messages.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
LOG.info("MQ 收到消息批次: topic={}, group={}, shard={}, consumer={}, streamKey={}, count={}",
|
||||||
|
subscription.getTopic(), subscription.getConsumerGroup(), shard, consumerName, streamKey, messages.size());
|
||||||
handleMessages(handler, streamKey, subscription.getConsumerGroup(), messages);
|
handleMessages(handler, streamKey, subscription.getConsumerGroup(), messages);
|
||||||
} catch (Exception ignored) {
|
} catch (Exception exception) {
|
||||||
|
LOG.error("MQ 消费循环异常: topic={}, group={}, shard={}, consumer={}, streamKey={}, handler={}",
|
||||||
|
subscription.getTopic(),
|
||||||
|
subscription.getConsumerGroup(),
|
||||||
|
shard,
|
||||||
|
consumerName,
|
||||||
|
streamKey,
|
||||||
|
handler.getClass().getSimpleName(),
|
||||||
|
exception);
|
||||||
sleepSilently(1000L);
|
sleepSilently(1000L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,8 +210,12 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
message.setRetryCount(retryCount);
|
message.setRetryCount(retryCount);
|
||||||
message.getHeaders().put("lastError", reason == null ? "" : reason);
|
message.getHeaders().put("lastError", reason == null ? "" : reason);
|
||||||
if (retryCount > properties.getRedis().getMaxRetry()) {
|
if (retryCount > properties.getRedis().getMaxRetry()) {
|
||||||
|
LOG.error("MQ 消息超过最大重试次数,进入死信队列: topic={}, messageId={}, streamKey={}, retryCount={}, reason={}",
|
||||||
|
message.getTopic(), message.getMessageId(), message.getStreamKey(), retryCount, reason);
|
||||||
deadLetterService.deadLetter(message, reason);
|
deadLetterService.deadLetter(message, reason);
|
||||||
} else {
|
} else {
|
||||||
|
LOG.warn("MQ 消息消费失败,准备重试: topic={}, messageId={}, streamKey={}, retryCount={}, reason={}",
|
||||||
|
message.getTopic(), message.getMessageId(), message.getStreamKey(), retryCount, reason);
|
||||||
stringRedisTemplate.opsForStream().add(
|
stringRedisTemplate.opsForStream().add(
|
||||||
org.springframework.data.redis.connection.stream.StreamRecords.string(
|
org.springframework.data.redis.connection.stream.StreamRecords.string(
|
||||||
Map.of("payload", messageConverter.serialize(message))
|
Map.of("payload", messageConverter.serialize(message))
|
||||||
@@ -205,10 +227,16 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
|
|
||||||
private void handleMessages(MQConsumerHandler handler, String streamKey, String group, List<MQMessage> messages) throws Exception {
|
private void handleMessages(MQConsumerHandler handler, String streamKey, String group, List<MQMessage> messages) throws Exception {
|
||||||
try {
|
try {
|
||||||
|
LOG.info("MQ 开始批量处理消息: group={}, streamKey={}, count={}, handler={}",
|
||||||
|
group, streamKey, messages.size(), handler.getClass().getSimpleName());
|
||||||
handler.handle(messages);
|
handler.handle(messages);
|
||||||
acknowledge(streamKey, group, messages);
|
acknowledge(streamKey, group, messages);
|
||||||
|
LOG.info("MQ 批量处理消息完成: group={}, streamKey={}, count={}, handler={}",
|
||||||
|
group, streamKey, messages.size(), handler.getClass().getSimpleName());
|
||||||
return;
|
return;
|
||||||
} catch (Exception batchEx) {
|
} catch (Exception batchEx) {
|
||||||
|
LOG.error("MQ 批量处理消息失败,准备降级单条处理: group={}, streamKey={}, count={}, handler={}",
|
||||||
|
group, streamKey, messages.size(), handler.getClass().getSimpleName(), batchEx);
|
||||||
if (messages.size() == 1) {
|
if (messages.size() == 1) {
|
||||||
retryOrDeadLetter(messages, resolveReason(batchEx));
|
retryOrDeadLetter(messages, resolveReason(batchEx));
|
||||||
acknowledge(streamKey, group, messages);
|
acknowledge(streamKey, group, messages);
|
||||||
@@ -218,7 +246,11 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
|
|
||||||
for (MQMessage message : messages) {
|
for (MQMessage message : messages) {
|
||||||
try {
|
try {
|
||||||
|
LOG.info("MQ 开始单条处理消息: group={}, streamKey={}, messageId={}, handler={}",
|
||||||
|
group, streamKey, message.getMessageId(), handler.getClass().getSimpleName());
|
||||||
handler.handle(List.of(message));
|
handler.handle(List.of(message));
|
||||||
|
LOG.info("MQ 单条处理消息完成: group={}, streamKey={}, messageId={}, handler={}",
|
||||||
|
group, streamKey, message.getMessageId(), handler.getClass().getSimpleName());
|
||||||
} catch (Exception singleEx) {
|
} catch (Exception singleEx) {
|
||||||
retryOrDeadLetter(List.of(message), resolveReason(singleEx));
|
retryOrDeadLetter(List.of(message), resolveReason(singleEx));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -240,6 +272,7 @@ public class RedisMQConsumerContainer implements MQConsumerContainer, SmartLifec
|
|||||||
}
|
}
|
||||||
MQAcknowledger acknowledger = records -> stringRedisTemplate.opsForStream().acknowledge(streamKey, group, ids);
|
MQAcknowledger acknowledger = records -> stringRedisTemplate.opsForStream().acknowledge(streamKey, group, ids);
|
||||||
acknowledger.acknowledge(messages);
|
acknowledger.acknowledge(messages);
|
||||||
|
LOG.info("MQ 消息确认完成: group={}, streamKey={}, count={}", group, streamKey, ids.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveReason(Exception exception) {
|
private String resolveReason(Exception exception) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package tech.easyflow.common.mq.redis;
|
package tech.easyflow.common.mq.redis;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.data.redis.connection.stream.RecordId;
|
import org.springframework.data.redis.connection.stream.RecordId;
|
||||||
import org.springframework.data.redis.connection.stream.StreamRecords;
|
import org.springframework.data.redis.connection.stream.StreamRecords;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
@@ -15,6 +17,8 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public class RedisMQProducer implements MQProducer {
|
public class RedisMQProducer implements MQProducer {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(RedisMQProducer.class);
|
||||||
|
|
||||||
private final StringRedisTemplate stringRedisTemplate;
|
private final StringRedisTemplate stringRedisTemplate;
|
||||||
private final MQProperties properties;
|
private final MQProperties properties;
|
||||||
private final MQMessageConverter messageConverter;
|
private final MQMessageConverter messageConverter;
|
||||||
@@ -47,12 +51,16 @@ public class RedisMQProducer implements MQProducer {
|
|||||||
int shardCount = Math.max(properties.getRedis().getChatPersistShardCount(), 1);
|
int shardCount = Math.max(properties.getRedis().getChatPersistShardCount(), 1);
|
||||||
int shard = keySupport.resolveShard(message.getKey(), shardCount);
|
int shard = keySupport.resolveShard(message.getKey(), shardCount);
|
||||||
String streamKey = keySupport.streamKey(message.getTopic(), shard);
|
String streamKey = keySupport.streamKey(message.getTopic(), shard);
|
||||||
|
LOG.info("MQ 开始投递消息: topic={}, messageId={}, key={}, shard={}, streamKey={}",
|
||||||
|
message.getTopic(), message.getMessageId(), message.getKey(), shard, streamKey);
|
||||||
RecordId recordId = stringRedisTemplate.opsForStream().add(
|
RecordId recordId = stringRedisTemplate.opsForStream().add(
|
||||||
StreamRecords.string(Map.of("payload", messageConverter.serialize(message))).withStreamKey(streamKey)
|
StreamRecords.string(Map.of("payload", messageConverter.serialize(message))).withStreamKey(streamKey)
|
||||||
);
|
);
|
||||||
if (recordId == null) {
|
if (recordId == null) {
|
||||||
throw new MQException("MQ 消息投递失败");
|
throw new MQException("MQ 消息投递失败");
|
||||||
}
|
}
|
||||||
|
LOG.info("MQ 消息投递完成: topic={}, messageId={}, key={}, shard={}, streamKey={}, recordId={}",
|
||||||
|
message.getTopic(), message.getMessageId(), message.getKey(), shard, streamKey, recordId.getValue());
|
||||||
return recordId.getValue();
|
return recordId.getValue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,10 @@
|
|||||||
<groupId>tech.easyflow</groupId>
|
<groupId>tech.easyflow</groupId>
|
||||||
<artifactId>easyflow-common-chat-protocol</artifactId>
|
<artifactId>easyflow-common-chat-protocol</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>tech.easyflow</groupId>
|
||||||
|
<artifactId>easyflow-common-mq</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.easyagents</groupId>
|
<groupId>com.easyagents</groupId>
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ public class ThreadPoolConfig {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(ThreadPoolConfig.class);
|
private static final Logger log = LoggerFactory.getLogger(ThreadPoolConfig.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE消息发送专用线程池
|
* 创建 SSE 消息发送线程池。
|
||||||
* 核心原则:IO密集型任务(网络推送),线程数 = CPU核心数 * 2 + 1
|
*
|
||||||
|
* @return SSE 推送线程池
|
||||||
*/
|
*/
|
||||||
@Bean(name = "sseThreadPool")
|
@Bean(name = "sseThreadPool")
|
||||||
public ThreadPoolTaskExecutor sseThreadPool() {
|
public ThreadPoolTaskExecutor sseThreadPool() {
|
||||||
@@ -37,4 +38,29 @@ public class ThreadPoolConfig {
|
|||||||
executor.initialize();
|
executor.initialize();
|
||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建知识库文档导入任务线程池。
|
||||||
|
*
|
||||||
|
* @return 文档导入任务线程池
|
||||||
|
*/
|
||||||
|
@Bean(name = "documentImportTaskExecutor")
|
||||||
|
public ThreadPoolTaskExecutor documentImportTaskExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
int cpuCoreNum = Runtime.getRuntime().availableProcessors();
|
||||||
|
executor.setCorePoolSize(Math.max(2, cpuCoreNum));
|
||||||
|
executor.setMaxPoolSize(Math.max(4, cpuCoreNum * 2));
|
||||||
|
executor.setQueueCapacity(200);
|
||||||
|
executor.setKeepAliveSeconds(60);
|
||||||
|
executor.setThreadNamePrefix("document-import-");
|
||||||
|
executor.setRejectedExecutionHandler((runnable, executorService) -> {
|
||||||
|
log.error("文档导入线程池过载!核心线程数:{},最大线程数:{},队列任务数:{}",
|
||||||
|
executorService.getCorePoolSize(),
|
||||||
|
executorService.getMaximumPoolSize(),
|
||||||
|
executorService.getQueue().size());
|
||||||
|
throw new BusinessException("文档导入任务繁忙,请稍后重试");
|
||||||
|
});
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.easyagents.document.core.model.ParseResponse;
|
|||||||
import com.easyagents.document.core.model.ParseResult;
|
import com.easyagents.document.core.model.ParseResult;
|
||||||
import com.easyagents.document.core.model.ParseTaskInfo;
|
import com.easyagents.document.core.model.ParseTaskInfo;
|
||||||
import com.easyagents.document.core.model.ParseTaskStatus;
|
import com.easyagents.document.core.model.ParseTaskStatus;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.lang.Nullable;
|
import org.springframework.lang.Nullable;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -30,6 +32,8 @@ import tech.easyflow.ai.utils.DocUtil;
|
|||||||
@Service
|
@Service
|
||||||
public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeService {
|
public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeService {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentParseBridgeServiceImpl.class);
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private final DocumentParseService documentParseService;
|
private final DocumentParseService documentParseService;
|
||||||
private final DocumentSourceLoader documentSourceLoader;
|
private final DocumentSourceLoader documentSourceLoader;
|
||||||
@@ -52,12 +56,21 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
|
|||||||
@Override
|
@Override
|
||||||
public DocumentParsedResult parse(DocumentSourceRef source, DocumentParseScenario scenario) {
|
public DocumentParsedResult parse(DocumentSourceRef source, DocumentParseScenario scenario) {
|
||||||
try {
|
try {
|
||||||
LoadedDocumentSource loadedSource = preparePdfSource(source);
|
LoadedDocumentSource loadedSource = prepareSupportedSource(source);
|
||||||
|
LOG.info("桥接服务开始同步解析文档: fileName={}, contentType={}, scenario={}",
|
||||||
|
loadedSource.getFileName(), loadedSource.getContentType(), scenario);
|
||||||
ParseResponse response = requireService().parse(parseRequestFactory.build(loadedSource, scenario));
|
ParseResponse response = requireService().parse(parseRequestFactory.build(loadedSource, scenario));
|
||||||
return parseResultMapper.map(extractSingleResult(response, false));
|
DocumentParsedResult result = parseResultMapper.map(extractSingleResult(response, false));
|
||||||
|
LOG.info("桥接服务同步解析完成: fileName={}, scenario={}, preferredTextLength={}",
|
||||||
|
loadedSource.getFileName(), scenario, resolveTextLength(result));
|
||||||
|
return result;
|
||||||
} catch (DocumentParseBridgeException e) {
|
} catch (DocumentParseBridgeException e) {
|
||||||
|
LOG.error("桥接服务同步解析失败: fileName={}, scenario={}",
|
||||||
|
source == null ? null : source.getFileName(), scenario, e);
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
LOG.error("桥接服务同步解析异常: fileName={}, scenario={}",
|
||||||
|
source == null ? null : source.getFileName(), scenario, e);
|
||||||
throw DocumentParseBridgeException.parseFailed("同步文档解析失败", e);
|
throw DocumentParseBridgeException.parseFailed("同步文档解析失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,12 +81,21 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
|
|||||||
@Override
|
@Override
|
||||||
public DocumentParseTaskStatus submit(DocumentSourceRef source, DocumentParseScenario scenario) {
|
public DocumentParseTaskStatus submit(DocumentSourceRef source, DocumentParseScenario scenario) {
|
||||||
try {
|
try {
|
||||||
LoadedDocumentSource loadedSource = preparePdfSource(source);
|
LoadedDocumentSource loadedSource = prepareSupportedSource(source);
|
||||||
|
LOG.info("桥接服务开始提交异步解析任务: fileName={}, contentType={}, scenario={}",
|
||||||
|
loadedSource.getFileName(), loadedSource.getContentType(), scenario);
|
||||||
ParseTaskStatus taskStatus = requireService().submit(parseRequestFactory.build(loadedSource, scenario));
|
ParseTaskStatus taskStatus = requireService().submit(parseRequestFactory.build(loadedSource, scenario));
|
||||||
return parseResultMapper.map(taskStatus);
|
DocumentParseTaskStatus mappedStatus = parseResultMapper.map(taskStatus);
|
||||||
|
LOG.info("桥接服务异步解析任务提交完成: fileName={}, scenario={}, providerTaskId={}, status={}",
|
||||||
|
loadedSource.getFileName(), scenario, mappedStatus.getTaskId(), mappedStatus.getStatus());
|
||||||
|
return mappedStatus;
|
||||||
} catch (DocumentParseBridgeException e) {
|
} catch (DocumentParseBridgeException e) {
|
||||||
|
LOG.error("桥接服务提交异步解析任务失败: fileName={}, scenario={}",
|
||||||
|
source == null ? null : source.getFileName(), scenario, e);
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
LOG.error("桥接服务提交异步解析任务异常: fileName={}, scenario={}",
|
||||||
|
source == null ? null : source.getFileName(), scenario, e);
|
||||||
throw DocumentParseBridgeException.taskFailed("提交异步文档解析任务失败", e);
|
throw DocumentParseBridgeException.taskFailed("提交异步文档解析任务失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,11 +126,17 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
|
|||||||
throw DocumentParseBridgeException.resultFetchFailed("taskId 不能为空");
|
throw DocumentParseBridgeException.resultFetchFailed("taskId 不能为空");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
LOG.info("桥接服务开始获取异步解析结果: providerTaskId={}", taskId);
|
||||||
ParseResponse response = requireService().queryResult(taskId);
|
ParseResponse response = requireService().queryResult(taskId);
|
||||||
return parseResultMapper.map(extractSingleResult(response, true));
|
DocumentParsedResult result = parseResultMapper.map(extractSingleResult(response, true));
|
||||||
|
LOG.info("桥接服务获取异步解析结果完成: providerTaskId={}, preferredTextLength={}",
|
||||||
|
taskId, resolveTextLength(result));
|
||||||
|
return result;
|
||||||
} catch (DocumentParseBridgeException e) {
|
} catch (DocumentParseBridgeException e) {
|
||||||
|
LOG.error("桥接服务获取异步解析结果失败: providerTaskId={}", taskId, e);
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
LOG.error("桥接服务获取异步解析结果异常: providerTaskId={}", taskId, e);
|
||||||
throw DocumentParseBridgeException.resultFetchFailed("获取异步文档解析结果失败", e);
|
throw DocumentParseBridgeException.resultFetchFailed("获取异步文档解析结果失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,14 +151,32 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
ParseTaskInfo taskInfo = requireService().queryTaskInfo(taskId);
|
ParseTaskInfo taskInfo = requireService().queryTaskInfo(taskId);
|
||||||
return parseResultMapper.map(taskInfo);
|
DocumentParseTaskInfo mappedTaskInfo = parseResultMapper.map(taskInfo);
|
||||||
|
LOG.info("桥接服务查询异步解析任务状态: providerTaskId={}, status={}, hasResult={}",
|
||||||
|
taskId,
|
||||||
|
mappedTaskInfo == null ? null : mappedTaskInfo.getStatus(),
|
||||||
|
mappedTaskInfo != null && mappedTaskInfo.getResult() != null);
|
||||||
|
return mappedTaskInfo;
|
||||||
} catch (DocumentParseBridgeException e) {
|
} catch (DocumentParseBridgeException e) {
|
||||||
|
LOG.error("桥接服务查询异步解析任务状态失败: providerTaskId={}", taskId, e);
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
LOG.error("桥接服务查询异步解析任务状态异常: providerTaskId={}", taskId, e);
|
||||||
throw DocumentParseBridgeException.taskFailed("聚合查询异步文档解析任务信息失败", e);
|
throw DocumentParseBridgeException.taskFailed("聚合查询异步文档解析任务信息失败", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int resolveTextLength(DocumentParsedResult result) {
|
||||||
|
String text = result == null ? null : result.getPreferredText();
|
||||||
|
if (!StringUtils.hasText(text) && result != null) {
|
||||||
|
text = result.getMarkdown();
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(text) && result != null) {
|
||||||
|
text = result.getPlainText();
|
||||||
|
}
|
||||||
|
return text == null ? 0 : text.length();
|
||||||
|
}
|
||||||
|
|
||||||
private DocumentParseService requireService() {
|
private DocumentParseService requireService() {
|
||||||
if (documentParseService == null) {
|
if (documentParseService == null) {
|
||||||
throw DocumentParseBridgeException.serviceNotEnabled();
|
throw DocumentParseBridgeException.serviceNotEnabled();
|
||||||
@@ -138,24 +184,32 @@ public class DocumentParseBridgeServiceImpl implements DocumentParseBridgeServic
|
|||||||
return documentParseService;
|
return documentParseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoadedDocumentSource preparePdfSource(DocumentSourceRef source) {
|
private LoadedDocumentSource prepareSupportedSource(DocumentSourceRef source) {
|
||||||
LoadedDocumentSource loadedSource = documentSourceLoader.load(source);
|
LoadedDocumentSource loadedSource = documentSourceLoader.load(source);
|
||||||
if (!isPdf(loadedSource)) {
|
if (!isSupportedByBridge(loadedSource)) {
|
||||||
throw DocumentParseBridgeException.unsupportedSource("统一文档解析桥接首版仅支持 PDF 文件");
|
throw DocumentParseBridgeException.unsupportedSource("统一文档解析桥接当前仅支持 PDF、DOCX 文件");
|
||||||
}
|
}
|
||||||
return loadedSource;
|
return loadedSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isPdf(LoadedDocumentSource loadedSource) {
|
private boolean isSupportedByBridge(LoadedDocumentSource loadedSource) {
|
||||||
String contentType = loadedSource.getContentType();
|
String contentType = loadedSource.getContentType();
|
||||||
if (StringUtils.hasText(contentType) && contentType.toLowerCase().contains("pdf")) {
|
if (StringUtils.hasText(contentType)) {
|
||||||
|
String normalizedContentType = contentType.toLowerCase();
|
||||||
|
if (normalizedContentType.contains("pdf")
|
||||||
|
|| normalizedContentType.contains("wordprocessingml.document")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
String fileName = loadedSource.getFileName();
|
String fileName = loadedSource.getFileName();
|
||||||
if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
|
if (!StringUtils.hasText(fileName) || !fileName.contains(".")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return "pdf".equals(DocUtil.normalizeSuffix(DocUtil.getSuffix(fileName)));
|
String suffix = DocUtil.normalizeSuffix(DocUtil.getSuffix(fileName));
|
||||||
|
if ("pdf".equals(suffix) || "docx".equals(suffix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ParseResult extractSingleResult(ParseResponse response, boolean resultFetchPhase) {
|
private ParseResult extractSingleResult(ParseResponse response, boolean resultFetchPhase) {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ public final class DocumentImportDtos {
|
|||||||
|
|
||||||
public static class PreviewRequest implements Serializable {
|
public static class PreviewRequest implements Serializable {
|
||||||
private BigInteger knowledgeId;
|
private BigInteger knowledgeId;
|
||||||
|
private BigInteger documentId;
|
||||||
private List<PreviewFileRequest> files = new ArrayList<PreviewFileRequest>();
|
private List<PreviewFileRequest> files = new ArrayList<PreviewFileRequest>();
|
||||||
|
|
||||||
public BigInteger getKnowledgeId() {
|
public BigInteger getKnowledgeId() {
|
||||||
@@ -103,6 +104,14 @@ public final class DocumentImportDtos {
|
|||||||
this.knowledgeId = knowledgeId;
|
this.knowledgeId = knowledgeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigInteger getDocumentId() {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocumentId(BigInteger documentId) {
|
||||||
|
this.documentId = documentId;
|
||||||
|
}
|
||||||
|
|
||||||
public List<PreviewFileRequest> getFiles() {
|
public List<PreviewFileRequest> getFiles() {
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
@@ -114,6 +123,7 @@ public final class DocumentImportDtos {
|
|||||||
|
|
||||||
public static class CommitRequest implements Serializable {
|
public static class CommitRequest implements Serializable {
|
||||||
private BigInteger knowledgeId;
|
private BigInteger knowledgeId;
|
||||||
|
private BigInteger documentId;
|
||||||
private List<String> previewSessionIds = new ArrayList<String>();
|
private List<String> previewSessionIds = new ArrayList<String>();
|
||||||
|
|
||||||
public BigInteger getKnowledgeId() {
|
public BigInteger getKnowledgeId() {
|
||||||
@@ -124,6 +134,14 @@ public final class DocumentImportDtos {
|
|||||||
this.knowledgeId = knowledgeId;
|
this.knowledgeId = knowledgeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigInteger getDocumentId() {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocumentId(BigInteger documentId) {
|
||||||
|
this.documentId = documentId;
|
||||||
|
}
|
||||||
|
|
||||||
public List<String> getPreviewSessionIds() {
|
public List<String> getPreviewSessionIds() {
|
||||||
return previewSessionIds;
|
return previewSessionIds;
|
||||||
}
|
}
|
||||||
@@ -241,16 +259,158 @@ public final class DocumentImportDtos {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class PreviewSourceRange implements Serializable {
|
||||||
|
private Integer start;
|
||||||
|
private Integer end;
|
||||||
|
|
||||||
|
public Integer getStart() {
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStart(Integer start) {
|
||||||
|
this.start = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getEnd() {
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnd(Integer end) {
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PreviewChunkResult implements Serializable {
|
||||||
|
private String answer;
|
||||||
|
private Integer charCount;
|
||||||
|
private String chunkId;
|
||||||
|
private String chunkType;
|
||||||
|
private String content;
|
||||||
|
private List<String> headingPath = new ArrayList<String>();
|
||||||
|
private Integer partNo;
|
||||||
|
private Integer partTotal;
|
||||||
|
private String question;
|
||||||
|
private String sourceLabel;
|
||||||
|
private Integer tokenEstimate;
|
||||||
|
private List<String> warnings = new ArrayList<String>();
|
||||||
|
private List<PreviewSourceRange> sourceRanges = new ArrayList<PreviewSourceRange>();
|
||||||
|
|
||||||
|
public String getAnswer() {
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAnswer(String answer) {
|
||||||
|
this.answer = answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getCharCount() {
|
||||||
|
return charCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCharCount(Integer charCount) {
|
||||||
|
this.charCount = charCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getChunkId() {
|
||||||
|
return chunkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChunkId(String chunkId) {
|
||||||
|
this.chunkId = chunkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getChunkType() {
|
||||||
|
return chunkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChunkType(String chunkType) {
|
||||||
|
this.chunkType = chunkType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setContent(String content) {
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getHeadingPath() {
|
||||||
|
return headingPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setHeadingPath(List<String> headingPath) {
|
||||||
|
this.headingPath = headingPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPartNo() {
|
||||||
|
return partNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPartNo(Integer partNo) {
|
||||||
|
this.partNo = partNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getPartTotal() {
|
||||||
|
return partTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPartTotal(Integer partTotal) {
|
||||||
|
this.partTotal = partTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getQuestion() {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setQuestion(String question) {
|
||||||
|
this.question = question;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceLabel() {
|
||||||
|
return sourceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLabel(String sourceLabel) {
|
||||||
|
this.sourceLabel = sourceLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTokenEstimate() {
|
||||||
|
return tokenEstimate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTokenEstimate(Integer tokenEstimate) {
|
||||||
|
this.tokenEstimate = tokenEstimate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getWarnings() {
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWarnings(List<String> warnings) {
|
||||||
|
this.warnings = warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PreviewSourceRange> getSourceRanges() {
|
||||||
|
return sourceRanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceRanges(List<PreviewSourceRange> sourceRanges) {
|
||||||
|
this.sourceRanges = sourceRanges;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static class PreviewFileResult implements Serializable {
|
public static class PreviewFileResult implements Serializable {
|
||||||
private String previewSessionId;
|
private String previewSessionId;
|
||||||
private String filePath;
|
private String filePath;
|
||||||
private String fileName;
|
private String fileName;
|
||||||
|
private String normalizedContent;
|
||||||
private String strategyCode;
|
private String strategyCode;
|
||||||
private String strategyLabel;
|
private String strategyLabel;
|
||||||
private AnalysisResult analysis;
|
private AnalysisResult analysis;
|
||||||
private Integer totalChunks;
|
private Integer totalChunks;
|
||||||
private Integer totalWarnings;
|
private Integer totalWarnings;
|
||||||
private List<RagChunk> chunks = new ArrayList<RagChunk>();
|
private List<PreviewChunkResult> chunks = new ArrayList<PreviewChunkResult>();
|
||||||
|
|
||||||
public String getPreviewSessionId() {
|
public String getPreviewSessionId() {
|
||||||
return previewSessionId;
|
return previewSessionId;
|
||||||
@@ -276,6 +436,14 @@ public final class DocumentImportDtos {
|
|||||||
this.fileName = fileName;
|
this.fileName = fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getNormalizedContent() {
|
||||||
|
return normalizedContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNormalizedContent(String normalizedContent) {
|
||||||
|
this.normalizedContent = normalizedContent;
|
||||||
|
}
|
||||||
|
|
||||||
public String getStrategyCode() {
|
public String getStrategyCode() {
|
||||||
return strategyCode;
|
return strategyCode;
|
||||||
}
|
}
|
||||||
@@ -316,11 +484,11 @@ public final class DocumentImportDtos {
|
|||||||
this.totalWarnings = totalWarnings;
|
this.totalWarnings = totalWarnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<RagChunk> getChunks() {
|
public List<PreviewChunkResult> getChunks() {
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setChunks(List<RagChunk> chunks) {
|
public void setChunks(List<PreviewChunkResult> chunks) {
|
||||||
this.chunks = chunks;
|
this.chunks = chunks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,6 +622,7 @@ public final class DocumentImportDtos {
|
|||||||
public static class PreviewSession implements Serializable {
|
public static class PreviewSession implements Serializable {
|
||||||
private String sessionId;
|
private String sessionId;
|
||||||
private BigInteger knowledgeId;
|
private BigInteger knowledgeId;
|
||||||
|
private BigInteger documentId;
|
||||||
private String filePath;
|
private String filePath;
|
||||||
private String fileName;
|
private String fileName;
|
||||||
private String sourceFormat;
|
private String sourceFormat;
|
||||||
@@ -480,6 +649,14 @@ public final class DocumentImportDtos {
|
|||||||
this.knowledgeId = knowledgeId;
|
this.knowledgeId = knowledgeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public BigInteger getDocumentId() {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocumentId(BigInteger documentId) {
|
||||||
|
this.documentId = documentId;
|
||||||
|
}
|
||||||
|
|
||||||
public String getFilePath() {
|
public String getFilePath() {
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
@@ -552,4 +729,265 @@ public final class DocumentImportDtos {
|
|||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class TaskCreateRequest implements Serializable {
|
||||||
|
private BigInteger knowledgeId;
|
||||||
|
private String filePath;
|
||||||
|
private String fileName;
|
||||||
|
|
||||||
|
public BigInteger getKnowledgeId() {
|
||||||
|
return knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKnowledgeId(BigInteger knowledgeId) {
|
||||||
|
this.knowledgeId = knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFilePath() {
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFilePath(String filePath) {
|
||||||
|
this.filePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileName() {
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFileName(String fileName) {
|
||||||
|
this.fileName = fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TaskCreateResponse implements Serializable {
|
||||||
|
private BigInteger documentId;
|
||||||
|
private BigInteger taskId;
|
||||||
|
private String processStatus;
|
||||||
|
|
||||||
|
public BigInteger getDocumentId() {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocumentId(BigInteger documentId) {
|
||||||
|
this.documentId = documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getTaskId() {
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskId(BigInteger taskId) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProcessStatus() {
|
||||||
|
return processStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProcessStatus(String processStatus) {
|
||||||
|
this.processStatus = processStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TaskDetailResponse implements Serializable {
|
||||||
|
private BigInteger taskId;
|
||||||
|
private BigInteger documentId;
|
||||||
|
private BigInteger knowledgeId;
|
||||||
|
private String phase;
|
||||||
|
private String status;
|
||||||
|
private String processStatus;
|
||||||
|
private Integer progressPercent;
|
||||||
|
private Integer totalChunks;
|
||||||
|
private Integer completedChunks;
|
||||||
|
private Integer failedChunks;
|
||||||
|
private String providerTaskId;
|
||||||
|
private String errorSummary;
|
||||||
|
private Date startedAt;
|
||||||
|
private Date finishedAt;
|
||||||
|
|
||||||
|
public BigInteger getTaskId() {
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskId(BigInteger taskId) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getDocumentId() {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocumentId(BigInteger documentId) {
|
||||||
|
this.documentId = documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getKnowledgeId() {
|
||||||
|
return knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKnowledgeId(BigInteger knowledgeId) {
|
||||||
|
this.knowledgeId = knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhase() {
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhase(String phase) {
|
||||||
|
this.phase = phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProcessStatus() {
|
||||||
|
return processStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProcessStatus(String processStatus) {
|
||||||
|
this.processStatus = processStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getProgressPercent() {
|
||||||
|
return progressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProgressPercent(Integer progressPercent) {
|
||||||
|
this.progressPercent = progressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTotalChunks() {
|
||||||
|
return totalChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalChunks(Integer totalChunks) {
|
||||||
|
this.totalChunks = totalChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getCompletedChunks() {
|
||||||
|
return completedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCompletedChunks(Integer completedChunks) {
|
||||||
|
this.completedChunks = completedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFailedChunks() {
|
||||||
|
return failedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailedChunks(Integer failedChunks) {
|
||||||
|
this.failedChunks = failedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProviderTaskId() {
|
||||||
|
return providerTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProviderTaskId(String providerTaskId) {
|
||||||
|
this.providerTaskId = providerTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorSummary() {
|
||||||
|
return errorSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorSummary(String errorSummary) {
|
||||||
|
this.errorSummary = errorSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getStartedAt() {
|
||||||
|
return startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartedAt(Date startedAt) {
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getFinishedAt() {
|
||||||
|
return finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFinishedAt(Date finishedAt) {
|
||||||
|
this.finishedAt = finishedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TaskStartIndexRequest implements Serializable {
|
||||||
|
private BigInteger knowledgeId;
|
||||||
|
private BigInteger documentId;
|
||||||
|
private String previewSessionId;
|
||||||
|
|
||||||
|
public BigInteger getKnowledgeId() {
|
||||||
|
return knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKnowledgeId(BigInteger knowledgeId) {
|
||||||
|
this.knowledgeId = knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getDocumentId() {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocumentId(BigInteger documentId) {
|
||||||
|
this.documentId = documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPreviewSessionId() {
|
||||||
|
return previewSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPreviewSessionId(String previewSessionId) {
|
||||||
|
this.previewSessionId = previewSessionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TaskStartIndexResponse implements Serializable {
|
||||||
|
private BigInteger taskId;
|
||||||
|
private String processStatus;
|
||||||
|
|
||||||
|
public BigInteger getTaskId() {
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskId(BigInteger taskId) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProcessStatus() {
|
||||||
|
return processStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProcessStatus(String processStatus) {
|
||||||
|
this.processStatus = processStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class TaskRetryRequest implements Serializable {
|
||||||
|
private BigInteger knowledgeId;
|
||||||
|
private BigInteger documentId;
|
||||||
|
|
||||||
|
public BigInteger getKnowledgeId() {
|
||||||
|
return knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKnowledgeId(BigInteger knowledgeId) {
|
||||||
|
this.knowledgeId = knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getDocumentId() {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocumentId(BigInteger documentId) {
|
||||||
|
this.documentId = documentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,8 @@ public final class DocumentImportKeys {
|
|||||||
public static final String KEY_DOCUMENT_ANALYSIS_SUMMARY = "splitter.analysisSummary";
|
public static final String KEY_DOCUMENT_ANALYSIS_SUMMARY = "splitter.analysisSummary";
|
||||||
public static final String KEY_DOCUMENT_SOURCE_FILE_EXT = "splitter.sourceFileExt";
|
public static final String KEY_DOCUMENT_SOURCE_FILE_EXT = "splitter.sourceFileExt";
|
||||||
public static final String KEY_DOCUMENT_PREVIEW_VERSION = "splitter.previewVersion";
|
public static final String KEY_DOCUMENT_PREVIEW_VERSION = "splitter.previewVersion";
|
||||||
|
public static final String KEY_DOCUMENT_PARSE_BACKEND = "parse.backend";
|
||||||
|
public static final String KEY_DOCUMENT_PARSE_METADATA = "parse.metadata";
|
||||||
|
public static final String KEY_DOCUMENT_PARSE_WARNINGS = "parse.warnings";
|
||||||
|
public static final String KEY_DOCUMENT_PROVIDER_TASK_ID = "parse.providerTaskId";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.common.mq.config.MQProperties;
|
||||||
|
import tech.easyflow.common.mq.core.MQConsumerHandler;
|
||||||
|
import tech.easyflow.common.mq.core.MQMessage;
|
||||||
|
import tech.easyflow.common.mq.core.MQSubscription;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档向量化任务消费者。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DocumentImportIndexTaskConsumer implements MQConsumerHandler {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportIndexTaskConsumer.class);
|
||||||
|
|
||||||
|
private final KnowledgeDocumentImportTaskAppService appService;
|
||||||
|
private final MQProperties mqProperties;
|
||||||
|
|
||||||
|
public DocumentImportIndexTaskConsumer(KnowledgeDocumentImportTaskAppService appService,
|
||||||
|
MQProperties mqProperties) {
|
||||||
|
this.appService = appService;
|
||||||
|
this.mqProperties = mqProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MQSubscription subscription() {
|
||||||
|
MQSubscription subscription = new MQSubscription();
|
||||||
|
subscription.setTopic(DocumentImportTaskMqConstants.INDEX_TOPIC);
|
||||||
|
subscription.setConsumerGroup(DocumentImportTaskMqConstants.INDEX_GROUP);
|
||||||
|
subscription.setShardCount(resolveShardCount());
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(List<MQMessage> messages) {
|
||||||
|
LOG.info("文档向量化消费者收到消息批次: count={}", messages == null ? 0 : messages.size());
|
||||||
|
for (MQMessage message : messages) {
|
||||||
|
DocumentImportTaskMessage event = JSON.parseObject(message.getBody(), DocumentImportTaskMessage.class);
|
||||||
|
if (event == null || event.getTaskId() == null) {
|
||||||
|
LOG.warn("文档向量化消费者跳过非法消息: streamMessageId={}, messageId={}",
|
||||||
|
message == null ? null : message.getStreamMessageId(),
|
||||||
|
message == null ? null : message.getMessageId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LOG.info("文档向量化消费者开始处理消息: taskId={}, messageId={}, streamMessageId={}",
|
||||||
|
event.getTaskId(), message.getMessageId(), message.getStreamMessageId());
|
||||||
|
try {
|
||||||
|
appService.handleIndexTask(event.getTaskId());
|
||||||
|
LOG.info("文档向量化消费者处理完成: taskId={}, messageId={}, streamMessageId={}",
|
||||||
|
event.getTaskId(), message.getMessageId(), message.getStreamMessageId());
|
||||||
|
} catch (Exception exception) {
|
||||||
|
LOG.error("文档向量化消费者处理失败: taskId={}, messageId={}, streamMessageId={}",
|
||||||
|
event.getTaskId(), message.getMessageId(), message.getStreamMessageId(), exception);
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向量化消费者需覆盖生产端的所有分片,避免消息落入未订阅分片。
|
||||||
|
*
|
||||||
|
* @return 当前 Redis Stream 分片数
|
||||||
|
*/
|
||||||
|
private int resolveShardCount() {
|
||||||
|
return Math.max(mqProperties.getRedis().getChatPersistShardCount(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.common.mq.core.MQMessage;
|
||||||
|
import tech.easyflow.common.mq.core.MQProducer;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档向量化任务消息生产者。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DocumentImportIndexTaskProducer {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportIndexTaskProducer.class);
|
||||||
|
|
||||||
|
private final MQProducer mqProducer;
|
||||||
|
|
||||||
|
public DocumentImportIndexTaskProducer(MQProducer mqProducer) {
|
||||||
|
this.mqProducer = mqProducer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送向量化任务消息。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
*/
|
||||||
|
public void send(BigInteger taskId) {
|
||||||
|
DocumentImportTaskMessage event = new DocumentImportTaskMessage();
|
||||||
|
event.setTaskId(taskId);
|
||||||
|
event.setOccurredAt(new Date());
|
||||||
|
|
||||||
|
MQMessage message = new MQMessage();
|
||||||
|
message.setMessageId("index-" + taskId);
|
||||||
|
message.setTopic(DocumentImportTaskMqConstants.INDEX_TOPIC);
|
||||||
|
message.setKey(String.valueOf(taskId));
|
||||||
|
message.setCreatedAt(event.getOccurredAt());
|
||||||
|
message.setBody(JSON.toJSONString(event));
|
||||||
|
LOG.info("准备投递文档向量化 MQ 消息: topic={}, taskId={}, messageId={}",
|
||||||
|
message.getTopic(), taskId, message.getMessageId());
|
||||||
|
String recordId = mqProducer.send(message);
|
||||||
|
LOG.info("文档向量化 MQ 消息投递完成: topic={}, taskId={}, messageId={}, recordId={}",
|
||||||
|
message.getTopic(), taskId, message.getMessageId(), recordId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库文档解析任务收敛器。
|
||||||
|
*
|
||||||
|
* <p>该调度器只负责轮询运行中的桥接解析任务,不承担提交任务职责。</p>
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-15
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DocumentImportParseMonitor {
|
||||||
|
|
||||||
|
private final KnowledgeDocumentImportTaskAppService appService;
|
||||||
|
|
||||||
|
public DocumentImportParseMonitor(KnowledgeDocumentImportTaskAppService appService) {
|
||||||
|
this.appService = appService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时收敛运行中的桥接解析任务状态。
|
||||||
|
*/
|
||||||
|
@Scheduled(
|
||||||
|
fixedDelayString = "${easyflow.ai.document-import.parse-monitor.fixed-delay:3000}",
|
||||||
|
initialDelayString = "${easyflow.ai.document-import.parse-monitor.initial-delay:5000}"
|
||||||
|
)
|
||||||
|
public void reconcileRunningParseTasks() {
|
||||||
|
appService.monitorRunningParseTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import tech.easyflow.common.mq.config.MQProperties;
|
||||||
|
import tech.easyflow.common.mq.core.MQConsumerHandler;
|
||||||
|
import tech.easyflow.common.mq.core.MQMessage;
|
||||||
|
import tech.easyflow.common.mq.core.MQSubscription;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档解析任务消费者。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DocumentImportParseTaskConsumer implements MQConsumerHandler {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportParseTaskConsumer.class);
|
||||||
|
|
||||||
|
private final KnowledgeDocumentImportTaskAppService appService;
|
||||||
|
private final MQProperties mqProperties;
|
||||||
|
|
||||||
|
public DocumentImportParseTaskConsumer(KnowledgeDocumentImportTaskAppService appService,
|
||||||
|
MQProperties mqProperties) {
|
||||||
|
this.appService = appService;
|
||||||
|
this.mqProperties = mqProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MQSubscription subscription() {
|
||||||
|
MQSubscription subscription = new MQSubscription();
|
||||||
|
subscription.setTopic(DocumentImportTaskMqConstants.PARSE_TOPIC);
|
||||||
|
subscription.setConsumerGroup(DocumentImportTaskMqConstants.PARSE_GROUP);
|
||||||
|
subscription.setShardCount(resolveShardCount());
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(List<MQMessage> messages) {
|
||||||
|
LOG.info("文档解析消费者收到消息批次: count={}", messages == null ? 0 : messages.size());
|
||||||
|
for (MQMessage message : messages) {
|
||||||
|
DocumentImportTaskMessage event = JSON.parseObject(message.getBody(), DocumentImportTaskMessage.class);
|
||||||
|
if (event == null || event.getTaskId() == null) {
|
||||||
|
LOG.warn("文档解析消费者跳过非法消息: streamMessageId={}, messageId={}",
|
||||||
|
message == null ? null : message.getStreamMessageId(),
|
||||||
|
message == null ? null : message.getMessageId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LOG.info("文档解析消费者开始处理消息: taskId={}, messageId={}, streamMessageId={}",
|
||||||
|
event.getTaskId(), message.getMessageId(), message.getStreamMessageId());
|
||||||
|
try {
|
||||||
|
appService.handleParseTask(event.getTaskId());
|
||||||
|
LOG.info("文档解析消费者处理完成: taskId={}, messageId={}, streamMessageId={}",
|
||||||
|
event.getTaskId(), message.getMessageId(), message.getStreamMessageId());
|
||||||
|
} catch (Exception exception) {
|
||||||
|
LOG.error("文档解析消费者处理失败: taskId={}, messageId={}, streamMessageId={}",
|
||||||
|
event.getTaskId(), message.getMessageId(), message.getStreamMessageId(), exception);
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析消费者需覆盖生产端的所有分片,避免消息落入未订阅分片。
|
||||||
|
*
|
||||||
|
* @return 当前 Redis Stream 分片数
|
||||||
|
*/
|
||||||
|
private int resolveShardCount() {
|
||||||
|
return Math.max(mqProperties.getRedis().getChatPersistShardCount(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.common.mq.core.MQMessage;
|
||||||
|
import tech.easyflow.common.mq.core.MQProducer;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档解析任务消息生产者。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DocumentImportParseTaskProducer {
|
||||||
|
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentImportParseTaskProducer.class);
|
||||||
|
|
||||||
|
private final MQProducer mqProducer;
|
||||||
|
|
||||||
|
public DocumentImportParseTaskProducer(MQProducer mqProducer) {
|
||||||
|
this.mqProducer = mqProducer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送解析任务消息。
|
||||||
|
*
|
||||||
|
* @param taskId 任务 ID
|
||||||
|
*/
|
||||||
|
public void send(BigInteger taskId) {
|
||||||
|
DocumentImportTaskMessage event = new DocumentImportTaskMessage();
|
||||||
|
event.setTaskId(taskId);
|
||||||
|
event.setOccurredAt(new Date());
|
||||||
|
|
||||||
|
MQMessage message = new MQMessage();
|
||||||
|
message.setMessageId("parse-" + taskId);
|
||||||
|
message.setTopic(DocumentImportTaskMqConstants.PARSE_TOPIC);
|
||||||
|
message.setKey(String.valueOf(taskId));
|
||||||
|
message.setCreatedAt(event.getOccurredAt());
|
||||||
|
message.setBody(JSON.toJSONString(event));
|
||||||
|
LOG.info("准备投递文档解析 MQ 消息: topic={}, taskId={}, messageId={}",
|
||||||
|
message.getTopic(), taskId, message.getMessageId());
|
||||||
|
String recordId = mqProducer.send(message);
|
||||||
|
LOG.info("文档解析 MQ 消息投递完成: topic={}, taskId={}, messageId={}, recordId={}",
|
||||||
|
message.getTopic(), taskId, message.getMessageId(), recordId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档导入任务消息。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
public class DocumentImportTaskMessage implements Serializable {
|
||||||
|
|
||||||
|
private BigInteger taskId;
|
||||||
|
private Date occurredAt;
|
||||||
|
|
||||||
|
public BigInteger getTaskId() {
|
||||||
|
return taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskId(BigInteger taskId) {
|
||||||
|
this.taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getOccurredAt() {
|
||||||
|
return occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOccurredAt(Date occurredAt) {
|
||||||
|
this.occurredAt = occurredAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档导入任务 MQ 常量。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
public final class DocumentImportTaskMqConstants {
|
||||||
|
|
||||||
|
private DocumentImportTaskMqConstants() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final String PARSE_TOPIC = "knowledge-document-parse";
|
||||||
|
public static final String PARSE_GROUP = "knowledge-document-parse-group";
|
||||||
|
public static final String INDEX_TOPIC = "knowledge-document-index";
|
||||||
|
public static final String INDEX_GROUP = "knowledge-document-index-group";
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
import tech.easyflow.ai.entity.Document;
|
||||||
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库文档任务状态 SSE 推送服务。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-15
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DocumentImportTaskStatusStreamService {
|
||||||
|
|
||||||
|
private static final long SSE_TIMEOUT_MS = Duration.ofMinutes(30).toMillis();
|
||||||
|
|
||||||
|
private final Map<String, Set<SseEmitter>> knowledgeEmitters = new ConcurrentHashMap<String, Set<SseEmitter>>();
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private DocumentMapper documentMapper;
|
||||||
|
|
||||||
|
@Resource(name = "sseThreadPool")
|
||||||
|
private ThreadPoolTaskExecutor sseThreadPool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅知识库文档任务状态流。
|
||||||
|
*
|
||||||
|
* @param knowledgeId 知识库 ID
|
||||||
|
* @return SSE 连接
|
||||||
|
*/
|
||||||
|
public SseEmitter subscribe(BigInteger knowledgeId) {
|
||||||
|
if (knowledgeId == null) {
|
||||||
|
throw new BusinessException("知识库id不能为空");
|
||||||
|
}
|
||||||
|
String topicKey = toTopicKey(knowledgeId);
|
||||||
|
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
|
||||||
|
knowledgeEmitters.computeIfAbsent(topicKey, key -> ConcurrentHashMap.newKeySet()).add(emitter);
|
||||||
|
emitter.onCompletion(() -> removeEmitter(topicKey, emitter));
|
||||||
|
emitter.onTimeout(() -> {
|
||||||
|
removeEmitter(topicKey, emitter);
|
||||||
|
emitter.complete();
|
||||||
|
});
|
||||||
|
emitter.onError(error -> removeEmitter(topicKey, emitter));
|
||||||
|
sendAsync(topicKey, emitter, "connected", buildConnectedPayload(knowledgeId));
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在事务提交后推送文档任务状态变更。
|
||||||
|
*
|
||||||
|
* @param documentId 文档 ID
|
||||||
|
*/
|
||||||
|
public void publishAfterCommit(BigInteger documentId) {
|
||||||
|
if (documentId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Runnable publishAction = () -> publishNow(documentId);
|
||||||
|
if (TransactionSynchronizationManager.isSynchronizationActive()
|
||||||
|
&& TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||||
|
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
publishAction.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publishAction.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void publishNow(BigInteger documentId) {
|
||||||
|
Document document = documentMapper.selectOneById(documentId);
|
||||||
|
if (document == null || document.getCollectionId() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String topicKey = toTopicKey(document.getCollectionId());
|
||||||
|
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
|
||||||
|
if (emitters == null || emitters.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> payload = buildDocumentPayload(document);
|
||||||
|
for (SseEmitter emitter : emitters) {
|
||||||
|
sendAsync(topicKey, emitter, "document-status", payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildConnectedPayload(BigInteger knowledgeId) {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<String, Object>();
|
||||||
|
payload.put("knowledgeId", knowledgeId.toString());
|
||||||
|
payload.put("type", "connected");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> buildDocumentPayload(Document document) {
|
||||||
|
Map<String, Object> payload = new LinkedHashMap<String, Object>();
|
||||||
|
payload.put("type", "document-status");
|
||||||
|
payload.put("knowledgeId", document.getCollectionId() == null ? null : document.getCollectionId().toString());
|
||||||
|
payload.put("documentId", document.getId() == null ? null : document.getId().toString());
|
||||||
|
payload.put("processStatus", document.getProcessStatus());
|
||||||
|
payload.put("progressPercent", document.getProgressPercent());
|
||||||
|
payload.put("totalChunks", document.getTotalChunks());
|
||||||
|
payload.put("completedChunks", document.getCompletedChunks());
|
||||||
|
payload.put("failedChunks", document.getFailedChunks());
|
||||||
|
payload.put("lastTaskError", document.getLastTaskError());
|
||||||
|
payload.put("taskModifiedAt", document.getTaskModifiedAt());
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendAsync(String topicKey, SseEmitter emitter, String eventName, Map<String, Object> payload) {
|
||||||
|
sseThreadPool.execute(() -> {
|
||||||
|
try {
|
||||||
|
emitter.send(
|
||||||
|
SseEmitter.event()
|
||||||
|
.name(eventName)
|
||||||
|
.data(payload, MediaType.APPLICATION_JSON)
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
removeEmitter(topicKey, emitter);
|
||||||
|
try {
|
||||||
|
emitter.completeWithError(e);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeEmitter(String topicKey, SseEmitter emitter) {
|
||||||
|
Set<SseEmitter> emitters = knowledgeEmitters.get(topicKey);
|
||||||
|
if (emitters == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitters.remove(emitter);
|
||||||
|
if (emitters.isEmpty()) {
|
||||||
|
knowledgeEmitters.remove(topicKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toTopicKey(BigInteger knowledgeId) {
|
||||||
|
return String.valueOf(knowledgeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -55,4 +55,16 @@ public class Document extends DocumentBase {
|
|||||||
public void setOverlapSize(int overlapSize) {
|
public void setOverlapSize(int overlapSize) {
|
||||||
this.overlapSize = overlapSize;
|
this.overlapSize = overlapSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取列表展示时的优先分块数。
|
||||||
|
*
|
||||||
|
* @return 分块数
|
||||||
|
*/
|
||||||
|
public long getDisplayChunkCount() {
|
||||||
|
if (getTotalChunks() != null && getTotalChunks() > 0) {
|
||||||
|
return getTotalChunks();
|
||||||
|
}
|
||||||
|
return chunkCount == null ? 0L : chunkCount.longValue();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package tech.easyflow.ai.entity;
|
||||||
|
|
||||||
|
import com.mybatisflex.annotation.Column;
|
||||||
|
import com.mybatisflex.annotation.Id;
|
||||||
|
import com.mybatisflex.annotation.KeyType;
|
||||||
|
import com.mybatisflex.annotation.Table;
|
||||||
|
import com.mybatisflex.core.handler.FastjsonTypeHandler;
|
||||||
|
import tech.easyflow.common.entity.DateEntity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库文档导入任务。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
@Table(value = "tb_document_import_task", comment = "知识库文档导入任务")
|
||||||
|
public class DocumentImportTask extends DateEntity implements Serializable {
|
||||||
|
|
||||||
|
@Id(keyType = KeyType.Generator, value = "snowFlakeId")
|
||||||
|
private BigInteger id;
|
||||||
|
|
||||||
|
@Column(comment = "文档ID")
|
||||||
|
private BigInteger documentId;
|
||||||
|
|
||||||
|
@Column(comment = "知识库ID")
|
||||||
|
private BigInteger knowledgeId;
|
||||||
|
|
||||||
|
@Column(comment = "任务阶段")
|
||||||
|
private String phase;
|
||||||
|
|
||||||
|
@Column(comment = "任务状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(comment = "底层任务ID")
|
||||||
|
private String providerTaskId;
|
||||||
|
|
||||||
|
@Column(typeHandler = FastjsonTypeHandler.class, comment = "任务载荷")
|
||||||
|
private Map<String, Object> payloadJson;
|
||||||
|
|
||||||
|
@Column(comment = "错误摘要")
|
||||||
|
private String errorSummary;
|
||||||
|
|
||||||
|
@Column(comment = "开始时间")
|
||||||
|
private Date startedAt;
|
||||||
|
|
||||||
|
@Column(comment = "结束时间")
|
||||||
|
private Date finishedAt;
|
||||||
|
|
||||||
|
@Column(comment = "创建时间")
|
||||||
|
private Date created;
|
||||||
|
|
||||||
|
@Column(comment = "创建人")
|
||||||
|
private BigInteger createdBy;
|
||||||
|
|
||||||
|
@Column(comment = "修改时间")
|
||||||
|
private Date modified;
|
||||||
|
|
||||||
|
@Column(comment = "修改人")
|
||||||
|
private BigInteger modifiedBy;
|
||||||
|
|
||||||
|
public BigInteger getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(BigInteger id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getDocumentId() {
|
||||||
|
return documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDocumentId(BigInteger documentId) {
|
||||||
|
this.documentId = documentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getKnowledgeId() {
|
||||||
|
return knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKnowledgeId(BigInteger knowledgeId) {
|
||||||
|
this.knowledgeId = knowledgeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPhase() {
|
||||||
|
return phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPhase(String phase) {
|
||||||
|
this.phase = phase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProviderTaskId() {
|
||||||
|
return providerTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProviderTaskId(String providerTaskId) {
|
||||||
|
this.providerTaskId = providerTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getPayloadJson() {
|
||||||
|
return payloadJson == null ? new LinkedHashMap<String, Object>() : payloadJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPayloadJson(Map<String, Object> payloadJson) {
|
||||||
|
this.payloadJson = payloadJson == null ? new LinkedHashMap<String, Object>() : payloadJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getErrorSummary() {
|
||||||
|
return errorSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorSummary(String errorSummary) {
|
||||||
|
this.errorSummary = errorSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getStartedAt() {
|
||||||
|
return startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStartedAt(Date startedAt) {
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getFinishedAt() {
|
||||||
|
return finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFinishedAt(Date finishedAt) {
|
||||||
|
this.finishedAt = finishedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Date getCreated() {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setCreated(Date created) {
|
||||||
|
this.created = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getCreatedBy() {
|
||||||
|
return createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCreatedBy(BigInteger createdBy) {
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Date getModified() {
|
||||||
|
return modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setModified(Date modified) {
|
||||||
|
this.modified = modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigInteger getModifiedBy() {
|
||||||
|
return modifiedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModifiedBy(BigInteger modifiedBy) {
|
||||||
|
this.modifiedBy = modifiedBy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,48 @@ public class DocumentBase extends DateEntity implements Serializable {
|
|||||||
@Column(typeHandler = FastjsonTypeHandler.class, comment = "其他配置项")
|
@Column(typeHandler = FastjsonTypeHandler.class, comment = "其他配置项")
|
||||||
private Map<String, Object> options;
|
private Map<String, Object> options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理状态
|
||||||
|
*/
|
||||||
|
@Column(comment = "处理状态")
|
||||||
|
private String processStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总分块数
|
||||||
|
*/
|
||||||
|
@Column(comment = "总分块数")
|
||||||
|
private Integer totalChunks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已完成分块数
|
||||||
|
*/
|
||||||
|
@Column(comment = "已完成分块数")
|
||||||
|
private Integer completedChunks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败分块数
|
||||||
|
*/
|
||||||
|
@Column(comment = "失败分块数")
|
||||||
|
private Integer failedChunks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理进度百分比
|
||||||
|
*/
|
||||||
|
@Column(comment = "处理进度百分比")
|
||||||
|
private Integer progressPercent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近任务错误摘要
|
||||||
|
*/
|
||||||
|
@Column(comment = "最近任务错误摘要")
|
||||||
|
private String lastTaskError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务状态更新时间
|
||||||
|
*/
|
||||||
|
@Column(comment = "任务状态更新时间")
|
||||||
|
private Date taskModifiedAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建时间
|
* 创建时间
|
||||||
*/
|
*/
|
||||||
@@ -176,6 +218,62 @@ public class DocumentBase extends DateEntity implements Serializable {
|
|||||||
this.options = options;
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getProcessStatus() {
|
||||||
|
return processStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProcessStatus(String processStatus) {
|
||||||
|
this.processStatus = processStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getTotalChunks() {
|
||||||
|
return totalChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalChunks(Integer totalChunks) {
|
||||||
|
this.totalChunks = totalChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getCompletedChunks() {
|
||||||
|
return completedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCompletedChunks(Integer completedChunks) {
|
||||||
|
this.completedChunks = completedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getFailedChunks() {
|
||||||
|
return failedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailedChunks(Integer failedChunks) {
|
||||||
|
this.failedChunks = failedChunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getProgressPercent() {
|
||||||
|
return progressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProgressPercent(Integer progressPercent) {
|
||||||
|
this.progressPercent = progressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLastTaskError() {
|
||||||
|
return lastTaskError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastTaskError(String lastTaskError) {
|
||||||
|
this.lastTaskError = lastTaskError;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Date getTaskModifiedAt() {
|
||||||
|
return taskModifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTaskModifiedAt(Date taskModifiedAt) {
|
||||||
|
this.taskModifiedAt = taskModifiedAt;
|
||||||
|
}
|
||||||
|
|
||||||
public Date getCreated() {
|
public Date getCreated() {
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package tech.easyflow.ai.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档导入任务阶段。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
public enum DocumentImportTaskPhase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档解析阶段。
|
||||||
|
*/
|
||||||
|
PARSE,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向量化阶段。
|
||||||
|
*/
|
||||||
|
INDEX
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package tech.easyflow.ai.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档导入任务状态。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
public enum DocumentImportTaskStatus {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已创建,等待执行。
|
||||||
|
*/
|
||||||
|
PENDING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 正在执行。
|
||||||
|
*/
|
||||||
|
RUNNING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行失败。
|
||||||
|
*/
|
||||||
|
FAILED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行完成。
|
||||||
|
*/
|
||||||
|
COMPLETED
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tech.easyflow.ai.enums;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档处理状态。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
public enum DocumentProcessStatus {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已上传,尚未进入异步处理。
|
||||||
|
*/
|
||||||
|
UPLOADED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析中。
|
||||||
|
*/
|
||||||
|
PARSING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析失败。
|
||||||
|
*/
|
||||||
|
PARSE_FAILED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可继续配置分块。
|
||||||
|
*/
|
||||||
|
READY_FOR_SEGMENT,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已确认分块,可开始向量化。
|
||||||
|
*/
|
||||||
|
READY_FOR_INDEX,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向量化处理中。
|
||||||
|
*/
|
||||||
|
INDEXING,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向量化失败。
|
||||||
|
*/
|
||||||
|
INDEX_FAILED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全流程完成。
|
||||||
|
*/
|
||||||
|
COMPLETED;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前状态是否属于运行中状态。
|
||||||
|
*
|
||||||
|
* @return 是否运行中
|
||||||
|
*/
|
||||||
|
public boolean isProcessing() {
|
||||||
|
return this == PARSING || this == INDEXING;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package tech.easyflow.ai.mapper;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.BaseMapper;
|
||||||
|
import tech.easyflow.ai.entity.DocumentImportTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档导入任务映射层。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
public interface DocumentImportTaskMapper extends BaseMapper<DocumentImportTask> {
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public class KnowledgeRetrievalRequest {
|
|||||||
private BigInteger knowledgeId;
|
private BigInteger knowledgeId;
|
||||||
private String query;
|
private String query;
|
||||||
private Integer limit;
|
private Integer limit;
|
||||||
|
private Double minSimilarity;
|
||||||
private RetrievalMode retrievalMode = RetrievalMode.HYBRID;
|
private RetrievalMode retrievalMode = RetrievalMode.HYBRID;
|
||||||
private String callerType;
|
private String callerType;
|
||||||
private String callerId;
|
private String callerId;
|
||||||
@@ -37,6 +38,24 @@ public class KnowledgeRetrievalRequest {
|
|||||||
this.limit = limit;
|
this.limit = limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回检索时使用的最小相似度阈值。
|
||||||
|
*
|
||||||
|
* @return 最小相似度阈值
|
||||||
|
*/
|
||||||
|
public Double getMinSimilarity() {
|
||||||
|
return minSimilarity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置检索时使用的最小相似度阈值。
|
||||||
|
*
|
||||||
|
* @param minSimilarity 最小相似度阈值
|
||||||
|
*/
|
||||||
|
public void setMinSimilarity(Double minSimilarity) {
|
||||||
|
this.minSimilarity = minSimilarity;
|
||||||
|
}
|
||||||
|
|
||||||
public RetrievalMode getRetrievalMode() {
|
public RetrievalMode getRetrievalMode() {
|
||||||
return retrievalMode;
|
return retrievalMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package tech.easyflow.ai.service;
|
||||||
|
|
||||||
|
import com.mybatisflex.core.service.IService;
|
||||||
|
import tech.easyflow.ai.entity.DocumentImportTask;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档导入任务服务。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
public interface DocumentImportTaskService extends IService<DocumentImportTask> {
|
||||||
|
}
|
||||||
@@ -32,4 +32,16 @@ public interface DocumentService extends IService<Document> {
|
|||||||
Result<DocumentImportDtos.PreviewResponse> previewImport(DocumentImportDtos.PreviewRequest request);
|
Result<DocumentImportDtos.PreviewResponse> previewImport(DocumentImportDtos.PreviewRequest request);
|
||||||
|
|
||||||
Result<DocumentImportDtos.CommitResponse> commitImport(DocumentImportDtos.CommitRequest request);
|
Result<DocumentImportDtos.CommitResponse> commitImport(DocumentImportDtos.CommitRequest request);
|
||||||
|
|
||||||
|
Result<DocumentImportDtos.TaskCreateResponse> createImportTask(DocumentImportDtos.TaskCreateRequest request);
|
||||||
|
|
||||||
|
Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(BigInteger taskId);
|
||||||
|
|
||||||
|
Result<DocumentImportDtos.PreviewResponse> previewImportTask(DocumentImportDtos.PreviewRequest request);
|
||||||
|
|
||||||
|
Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(DocumentImportDtos.TaskStartIndexRequest request);
|
||||||
|
|
||||||
|
Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(DocumentImportDtos.TaskRetryRequest request);
|
||||||
|
|
||||||
|
Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(DocumentImportDtos.TaskRetryRequest request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ import tech.easyflow.ai.entity.DocumentChunk;
|
|||||||
import tech.easyflow.ai.entity.DocumentCollection;
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
import tech.easyflow.ai.entity.FaqItem;
|
import tech.easyflow.ai.entity.FaqItem;
|
||||||
import tech.easyflow.ai.entity.Model;
|
import tech.easyflow.ai.entity.Model;
|
||||||
|
import tech.easyflow.ai.enums.DocumentProcessStatus;
|
||||||
import tech.easyflow.ai.enums.PublishStatus;
|
import tech.easyflow.ai.enums.PublishStatus;
|
||||||
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentCollectionMapper;
|
import tech.easyflow.ai.mapper.DocumentCollectionMapper;
|
||||||
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
import tech.easyflow.ai.mapper.FaqItemMapper;
|
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;
|
||||||
@@ -71,6 +73,8 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(DocumentCollectionServiceImpl.class);
|
private static final Logger LOG = LoggerFactory.getLogger(DocumentCollectionServiceImpl.class);
|
||||||
private static final int MAX_FAQ_IMAGES_IN_PROMPT = 3;
|
private static final int MAX_FAQ_IMAGES_IN_PROMPT = 3;
|
||||||
|
private static final int INTERNAL_RECALL_MULTIPLIER = 5;
|
||||||
|
private static final int MAX_INTERNAL_RECALL_LIMIT = 100;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private ModelService llmService;
|
private ModelService llmService;
|
||||||
@@ -81,6 +85,9 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
@Autowired
|
@Autowired
|
||||||
private DocumentChunkMapper documentChunkMapper;
|
private DocumentChunkMapper documentChunkMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DocumentMapper documentMapper;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private FaqItemMapper faqItemMapper;
|
private FaqItemMapper faqItemMapper;
|
||||||
|
|
||||||
@@ -111,24 +118,27 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
throw new BusinessException("知识库不存在");
|
throw new BusinessException("知识库不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
int docRecallMaxNum = readIntegerOption(documentCollection, KEY_DOC_RECALL_MAX_NUM, 5);
|
int docRecallMaxNum = resolveDocRecallMaxNum(request, documentCollection);
|
||||||
float minSimilarity = readFloatOption(documentCollection, KEY_SIMILARITY_THRESHOLD, 0.6F);
|
int internalRecallLimit = resolveInternalRecallLimit(docRecallMaxNum);
|
||||||
|
float minSimilarity = resolveMinSimilarity(request, documentCollection);
|
||||||
|
|
||||||
RagQuery ragQuery = new RagQuery();
|
RagQuery ragQuery = new RagQuery();
|
||||||
ragQuery.setQuery(keyword);
|
ragQuery.setQuery(keyword);
|
||||||
ragQuery.setRetrievalMode(retrievalMode);
|
ragQuery.setRetrievalMode(retrievalMode);
|
||||||
ragQuery.setTopK(docRecallMaxNum);
|
ragQuery.setTopK(internalRecallLimit);
|
||||||
ragQuery.setMinScore((double) minSimilarity);
|
ragQuery.setMinScore((double) minSimilarity);
|
||||||
|
|
||||||
RagRetrievalExecutor retrievalExecutor = new RagRetrievalExecutor(
|
RagRetrievalExecutor retrievalExecutor = new RagRetrievalExecutor(
|
||||||
buildVectorRetriever(documentCollection, docRecallMaxNum, retrievalMode == RetrievalMode.VECTOR ? minSimilarity : null),
|
buildVectorRetriever(documentCollection, internalRecallLimit, retrievalMode == RetrievalMode.VECTOR ? minSimilarity : null),
|
||||||
buildKeywordRetriever(documentCollection, docRecallMaxNum),
|
buildKeywordRetriever(documentCollection, internalRecallLimit),
|
||||||
new RrfFusionStrategy()
|
new RrfFusionStrategy()
|
||||||
);
|
);
|
||||||
|
|
||||||
RagRetrievalResult retrievalResult = retrievalExecutor.retrieve(ragQuery);
|
RagRetrievalResult retrievalResult = retrievalExecutor.retrieve(ragQuery);
|
||||||
List<Document> searchDocuments = toDocuments(retrievalResult.getHits());
|
List<Document> searchDocuments = prepareSearchDocuments(
|
||||||
fillSearchContent(documentCollection, searchDocuments);
|
documentCollection,
|
||||||
|
toDocuments(retrievalResult.getHits())
|
||||||
|
);
|
||||||
if (searchDocuments.isEmpty()) {
|
if (searchDocuments.isEmpty()) {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
@@ -138,7 +148,10 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
if (rerankModel != null) {
|
if (rerankModel != null) {
|
||||||
try {
|
try {
|
||||||
RagRetrievalResult rerankResult = retrievalExecutor.rerank(keyword, toRagHits(searchDocuments), rerankModel, docRecallMaxNum);
|
RagRetrievalResult rerankResult = retrievalExecutor.rerank(keyword, toRagHits(searchDocuments), rerankModel, docRecallMaxNum);
|
||||||
searchDocuments = toDocuments(rerankResult.getHits());
|
searchDocuments = prepareSearchDocuments(
|
||||||
|
documentCollection,
|
||||||
|
toDocuments(rerankResult.getHits())
|
||||||
|
);
|
||||||
reranked = true;
|
reranked = true;
|
||||||
} catch (RerankException e) {
|
} catch (RerankException e) {
|
||||||
LOG.warn("Rerank failed for collectionId={}, modelId={}, fallback to retrieved results. message={}",
|
LOG.warn("Rerank failed for collectionId={}, modelId={}, fallback to retrieved results. message={}",
|
||||||
@@ -320,6 +333,84 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
return !reranked && retrievalMode == RetrievalMode.VECTOR;
|
return !reranked && retrievalMode == RetrievalMode.VECTOR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析本次查询使用的召回上限,优先采用请求参数,其次回退到知识库默认配置。
|
||||||
|
*
|
||||||
|
* @param request 查询请求
|
||||||
|
* @param documentCollection 知识库实体
|
||||||
|
* @return 规范化后的召回上限
|
||||||
|
*/
|
||||||
|
private int resolveDocRecallMaxNum(KnowledgeRetrievalRequest request, DocumentCollection documentCollection) {
|
||||||
|
Integer requestLimit = request == null ? null : request.getLimit();
|
||||||
|
if (requestLimit != null) {
|
||||||
|
return Math.max(requestLimit, 1);
|
||||||
|
}
|
||||||
|
return readIntegerOption(documentCollection, KEY_DOC_RECALL_MAX_NUM, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析本次查询使用的最小相似度阈值,优先采用请求参数,其次回退到知识库默认配置。
|
||||||
|
*
|
||||||
|
* @param request 查询请求
|
||||||
|
* @param documentCollection 知识库实体
|
||||||
|
* @return 规范化后的最小相似度
|
||||||
|
*/
|
||||||
|
private float resolveMinSimilarity(KnowledgeRetrievalRequest request, DocumentCollection documentCollection) {
|
||||||
|
Double requestMinSimilarity = request == null ? null : request.getMinSimilarity();
|
||||||
|
if (requestMinSimilarity != null) {
|
||||||
|
double normalizedValue = Math.max(0D, Math.min(requestMinSimilarity, 1D));
|
||||||
|
return (float) normalizedValue;
|
||||||
|
}
|
||||||
|
return readFloatOption(documentCollection, KEY_SIMILARITY_THRESHOLD, 0.6F);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算内部候选召回上限。
|
||||||
|
*
|
||||||
|
* @param docRecallMaxNum 业务召回上限
|
||||||
|
* @return 内部候选集上限
|
||||||
|
*/
|
||||||
|
private int resolveInternalRecallLimit(int docRecallMaxNum) {
|
||||||
|
int normalizedLimit = Math.max(docRecallMaxNum, 1);
|
||||||
|
return Math.min(normalizedLimit * INTERNAL_RECALL_MULTIPLIER, MAX_INTERNAL_RECALL_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在重排前过滤掉未完成文档对应的 chunk 命中,避免进行中的文档占用正式召回名额。
|
||||||
|
*
|
||||||
|
* @param documentCollection 知识库
|
||||||
|
* @param searchDocuments 当前召回结果
|
||||||
|
* @return 仅保留完成态文档命中的结果
|
||||||
|
*/
|
||||||
|
private List<Document> prepareSearchDocuments(DocumentCollection documentCollection, List<Document> searchDocuments) {
|
||||||
|
if (searchDocuments == null || searchDocuments.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
if (documentCollection == null) {
|
||||||
|
return searchDocuments;
|
||||||
|
}
|
||||||
|
if (documentCollection.isFaqCollection()) {
|
||||||
|
fillSearchContent(documentCollection, searchDocuments);
|
||||||
|
return searchDocuments;
|
||||||
|
}
|
||||||
|
DocumentHitSnapshot hitSnapshot = loadDocumentHitSnapshot(documentCollection, searchDocuments);
|
||||||
|
if (hitSnapshot.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchDocuments.stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.filter(item -> {
|
||||||
|
String content = hitSnapshot.findChunkContent(item.getId());
|
||||||
|
if (!StringUtil.hasText(content)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
item.setContent(content);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public DocumentCollection getDetail(String idOrAlias) {
|
public DocumentCollection getDetail(String idOrAlias) {
|
||||||
DocumentCollection knowledge = null;
|
DocumentCollection knowledge = null;
|
||||||
@@ -418,18 +509,93 @@ public class DocumentCollectionServiceImpl extends ServiceImpl<DocumentCollectio
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QueryWrapper queryWrapper = QueryWrapper.create();
|
DocumentHitSnapshot hitSnapshot = loadDocumentHitSnapshot(documentCollection, searchDocuments);
|
||||||
queryWrapper.in(DocumentChunk::getId, ids);
|
|
||||||
queryWrapper.eq(DocumentChunk::getDocumentCollectionId, documentCollection.getId());
|
|
||||||
Map<String, DocumentChunk> chunkMap = documentChunkMapper.selectListByQuery(queryWrapper).stream()
|
|
||||||
.collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
|
|
||||||
searchDocuments.removeIf(item -> !chunkMap.containsKey(String.valueOf(item.getId())));
|
|
||||||
searchDocuments.forEach(item -> {
|
searchDocuments.forEach(item -> {
|
||||||
DocumentChunk documentChunk = chunkMap.get(String.valueOf(item.getId()));
|
item.setContent(hitSnapshot.findChunkContent(item.getId()));
|
||||||
if (documentChunk != null && !StringUtil.noText(documentChunk.getContent())) {
|
|
||||||
item.setContent(documentChunk.getContent());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
searchDocuments.removeIf(item -> !StringUtil.hasText(item.getContent()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量加载命中 chunk 及其完成态父文档,供过滤和内容填充复用。
|
||||||
|
*
|
||||||
|
* @param documentCollection 知识库
|
||||||
|
* @param searchDocuments 检索命中
|
||||||
|
* @return 命中快照
|
||||||
|
*/
|
||||||
|
private DocumentHitSnapshot loadDocumentHitSnapshot(DocumentCollection documentCollection, List<Document> searchDocuments) {
|
||||||
|
if (documentCollection == null || searchDocuments == null || searchDocuments.isEmpty()) {
|
||||||
|
return DocumentHitSnapshot.empty();
|
||||||
|
}
|
||||||
|
List<Serializable> chunkIds = searchDocuments.stream()
|
||||||
|
.map(Document::getId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.map(item -> (Serializable) item)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (chunkIds.isEmpty()) {
|
||||||
|
return DocumentHitSnapshot.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryWrapper chunkWrapper = QueryWrapper.create();
|
||||||
|
chunkWrapper.in(DocumentChunk::getId, chunkIds);
|
||||||
|
chunkWrapper.eq(DocumentChunk::getDocumentCollectionId, documentCollection.getId());
|
||||||
|
Map<String, DocumentChunk> chunkMap = documentChunkMapper.selectListByQuery(chunkWrapper).stream()
|
||||||
|
.collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
|
||||||
|
if (chunkMap.isEmpty()) {
|
||||||
|
return DocumentHitSnapshot.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Serializable> documentIds = chunkMap.values().stream()
|
||||||
|
.map(DocumentChunk::getDocumentId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.distinct()
|
||||||
|
.map(item -> (Serializable) item)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
if (documentIds.isEmpty()) {
|
||||||
|
return DocumentHitSnapshot.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
QueryWrapper documentWrapper = QueryWrapper.create();
|
||||||
|
documentWrapper.in(tech.easyflow.ai.entity.Document::getId, documentIds);
|
||||||
|
documentWrapper.eq(tech.easyflow.ai.entity.Document::getCollectionId, documentCollection.getId());
|
||||||
|
documentWrapper.eq(tech.easyflow.ai.entity.Document::getProcessStatus, DocumentProcessStatus.COMPLETED.name());
|
||||||
|
Map<String, tech.easyflow.ai.entity.Document> documentMap = documentMapper.selectListByQuery(documentWrapper).stream()
|
||||||
|
.collect(Collectors.toMap(item -> item.getId().toString(), item -> item, (a, b) -> a));
|
||||||
|
return new DocumentHitSnapshot(chunkMap, documentMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档检索命中的批量快照,避免过滤和填充阶段重复查询。
|
||||||
|
*/
|
||||||
|
private static class DocumentHitSnapshot {
|
||||||
|
|
||||||
|
private final Map<String, DocumentChunk> chunkMap;
|
||||||
|
private final Map<String, tech.easyflow.ai.entity.Document> documentMap;
|
||||||
|
|
||||||
|
private DocumentHitSnapshot(Map<String, DocumentChunk> chunkMap,
|
||||||
|
Map<String, tech.easyflow.ai.entity.Document> documentMap) {
|
||||||
|
this.chunkMap = chunkMap == null ? Collections.emptyMap() : chunkMap;
|
||||||
|
this.documentMap = documentMap == null ? Collections.emptyMap() : documentMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentHitSnapshot empty() {
|
||||||
|
return new DocumentHitSnapshot(Collections.emptyMap(), Collections.emptyMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isEmpty() {
|
||||||
|
return chunkMap.isEmpty() || documentMap.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findChunkContent(Object chunkId) {
|
||||||
|
DocumentChunk documentChunk = chunkMap.get(String.valueOf(chunkId));
|
||||||
|
if (documentChunk == null || documentChunk.getDocumentId() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!documentMap.containsKey(String.valueOf(documentChunk.getDocumentId()))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return StringUtil.noText(documentChunk.getContent()) ? null : documentChunk.getContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildFaqPromptContent(FaqItem faqItem, List<Map<String, String>> images) {
|
private String buildFaqPromptContent(FaqItem faqItem, List<Map<String, String>> images) {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import tech.easyflow.ai.entity.DocumentImportTask;
|
||||||
|
import tech.easyflow.ai.mapper.DocumentImportTaskMapper;
|
||||||
|
import tech.easyflow.ai.service.DocumentImportTaskService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档导入任务服务实现。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-14
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class DocumentImportTaskServiceImpl extends ServiceImpl<DocumentImportTaskMapper, DocumentImportTask>
|
||||||
|
implements DocumentImportTaskService {
|
||||||
|
}
|
||||||
@@ -34,7 +34,9 @@ import tech.easyflow.ai.config.SearcherFactory;
|
|||||||
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
import tech.easyflow.ai.documentimport.DocumentImportDtos;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportKeys;
|
import tech.easyflow.ai.documentimport.DocumentImportKeys;
|
||||||
import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
|
import tech.easyflow.ai.documentimport.DocumentImportPreviewService;
|
||||||
|
import tech.easyflow.ai.documentimport.task.KnowledgeDocumentImportTaskAppService;
|
||||||
import tech.easyflow.ai.entity.*;
|
import tech.easyflow.ai.entity.*;
|
||||||
|
import tech.easyflow.ai.enums.DocumentProcessStatus;
|
||||||
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
||||||
import tech.easyflow.ai.mapper.DocumentMapper;
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
import tech.easyflow.ai.service.DocumentChunkService;
|
import tech.easyflow.ai.service.DocumentChunkService;
|
||||||
@@ -69,6 +71,7 @@ import static tech.easyflow.ai.entity.table.DocumentTableDef.DOCUMENT;
|
|||||||
@Service("AiService")
|
@Service("AiService")
|
||||||
public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> implements DocumentService {
|
public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> implements DocumentService {
|
||||||
protected Logger Log = LoggerFactory.getLogger(DocumentServiceImpl.class);
|
protected Logger Log = LoggerFactory.getLogger(DocumentServiceImpl.class);
|
||||||
|
private static final String SOURCE_RANGES_KEY = "sourceRanges";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private DocumentMapper documentMapper;
|
private DocumentMapper documentMapper;
|
||||||
@@ -97,6 +100,9 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
@Autowired
|
@Autowired
|
||||||
private DocumentImportPreviewService documentImportPreviewService;
|
private DocumentImportPreviewService documentImportPreviewService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private KnowledgeDocumentImportTaskAppService importTaskAppService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Page<Document> getDocumentList(String knowledgeId, int pageSize, int pageNum, String fileName) {
|
public Page<Document> getDocumentList(String knowledgeId, int pageSize, int pageNum, String fileName) {
|
||||||
QueryWrapper queryWrapper=QueryWrapper.create()
|
QueryWrapper queryWrapper=QueryWrapper.create()
|
||||||
@@ -130,6 +136,13 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
// 查询该文档对应哪些分割的字段,先删除
|
// 查询该文档对应哪些分割的字段,先删除
|
||||||
QueryWrapper queryWrapperDocument = QueryWrapper.create().eq(Document::getId, id);
|
QueryWrapper queryWrapperDocument = QueryWrapper.create().eq(Document::getId, id);
|
||||||
Document oneByQuery = documentMapper.selectOneByQuery(queryWrapperDocument);
|
Document oneByQuery = documentMapper.selectOneByQuery(queryWrapperDocument);
|
||||||
|
if (oneByQuery == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (DocumentProcessStatus.PARSING.name().equals(oneByQuery.getProcessStatus())
|
||||||
|
|| DocumentProcessStatus.INDEXING.name().equals(oneByQuery.getProcessStatus())) {
|
||||||
|
throw new BusinessException("文档处理中,暂不允许删除");
|
||||||
|
}
|
||||||
DocumentCollection knowledge = knowledgeService.getById(oneByQuery.getCollectionId());
|
DocumentCollection knowledge = knowledgeService.getById(oneByQuery.getCollectionId());
|
||||||
if (knowledge == null) {
|
if (knowledge == null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -214,6 +227,12 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
aiDocument.setChunkSize(documentCollectionSplitParams.getChunkSize());
|
aiDocument.setChunkSize(documentCollectionSplitParams.getChunkSize());
|
||||||
aiDocument.setOverlapSize(documentCollectionSplitParams.getOverlapSize());
|
aiDocument.setOverlapSize(documentCollectionSplitParams.getOverlapSize());
|
||||||
aiDocument.setTitle(fileOriginName);
|
aiDocument.setTitle(fileOriginName);
|
||||||
|
aiDocument.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
|
||||||
|
aiDocument.setTotalChunks(previewList.size());
|
||||||
|
aiDocument.setCompletedChunks(previewList.size());
|
||||||
|
aiDocument.setFailedChunks(0);
|
||||||
|
aiDocument.setProgressPercent(100);
|
||||||
|
aiDocument.setTaskModifiedAt(new Date());
|
||||||
Map<String, Object> res = new HashMap<>();
|
Map<String, Object> res = new HashMap<>();
|
||||||
|
|
||||||
List<DocumentChunk> documentChunks = null;
|
List<DocumentChunk> documentChunks = null;
|
||||||
@@ -334,10 +353,11 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
item.setPreviewSessionId(sessionId);
|
item.setPreviewSessionId(sessionId);
|
||||||
item.setFilePath(file.getFilePath());
|
item.setFilePath(file.getFilePath());
|
||||||
item.setFileName(file.getFileName());
|
item.setFileName(file.getFileName());
|
||||||
|
item.setNormalizedContent(session.getAnalysis() == null ? null : session.getAnalysis().getNormalizedContent());
|
||||||
item.setStrategyCode(session.getStrategyConfig().getStrategyCode());
|
item.setStrategyCode(session.getStrategyConfig().getStrategyCode());
|
||||||
item.setStrategyLabel(ragIngestionService.toStrategyLabel(session.getStrategyConfig().getStrategyCode()));
|
item.setStrategyLabel(ragIngestionService.toStrategyLabel(session.getStrategyConfig().getStrategyCode()));
|
||||||
item.setAnalysis(session.getAnalysis());
|
item.setAnalysis(session.getAnalysis());
|
||||||
item.setChunks(session.getPreviewChunks());
|
item.setChunks(toPreviewChunkResults(session.getPreviewChunks()));
|
||||||
item.setTotalChunks(session.getPreviewChunks().size());
|
item.setTotalChunks(session.getPreviewChunks().size());
|
||||||
item.setTotalWarnings(countWarnings(session.getPreviewChunks()));
|
item.setTotalWarnings(countWarnings(session.getPreviewChunks()));
|
||||||
items.add(item);
|
items.add(item);
|
||||||
@@ -398,6 +418,12 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
document.setModified(new Date());
|
document.setModified(new Date());
|
||||||
document.setCreatedBy(BigInteger.valueOf(StpUtil.getLoginIdAsLong()));
|
document.setCreatedBy(BigInteger.valueOf(StpUtil.getLoginIdAsLong()));
|
||||||
document.setModifiedBy(BigInteger.valueOf(StpUtil.getLoginIdAsLong()));
|
document.setModifiedBy(BigInteger.valueOf(StpUtil.getLoginIdAsLong()));
|
||||||
|
document.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
|
||||||
|
document.setTotalChunks(session.getDocumentChunks().size());
|
||||||
|
document.setCompletedChunks(session.getDocumentChunks().size());
|
||||||
|
document.setFailedChunks(0);
|
||||||
|
document.setProgressPercent(100);
|
||||||
|
document.setTaskModifiedAt(new Date());
|
||||||
for (DocumentChunk chunk : session.getDocumentChunks()) {
|
for (DocumentChunk chunk : session.getDocumentChunks()) {
|
||||||
chunk.setDocumentId(document.getId());
|
chunk.setDocumentId(document.getId());
|
||||||
chunk.setDocumentCollectionId(document.getCollectionId());
|
chunk.setDocumentCollectionId(document.getCollectionId());
|
||||||
@@ -430,6 +456,7 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
|
|
||||||
DocumentImportDtos.PreviewSession session = new DocumentImportDtos.PreviewSession();
|
DocumentImportDtos.PreviewSession session = new DocumentImportDtos.PreviewSession();
|
||||||
session.setKnowledgeId(knowledge.getId());
|
session.setKnowledgeId(knowledge.getId());
|
||||||
|
session.setDocumentId(document.getId());
|
||||||
session.setFilePath(fileRequest.getFilePath());
|
session.setFilePath(fileRequest.getFilePath());
|
||||||
session.setFileName(fileRequest.getFileName());
|
session.setFileName(fileRequest.getFileName());
|
||||||
session.setSourceFormat(analysis.getSourceFormat());
|
session.setSourceFormat(analysis.getSourceFormat());
|
||||||
@@ -656,6 +683,55 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<DocumentImportDtos.PreviewChunkResult> toPreviewChunkResults(List<RagChunk> chunks) {
|
||||||
|
List<DocumentImportDtos.PreviewChunkResult> result = new ArrayList<>();
|
||||||
|
if (chunks == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (RagChunk chunk : chunks) {
|
||||||
|
DocumentImportDtos.PreviewChunkResult item = new DocumentImportDtos.PreviewChunkResult();
|
||||||
|
item.setAnswer(chunk.getAnswer());
|
||||||
|
item.setCharCount(chunk.getCharCount());
|
||||||
|
item.setChunkId(chunk.getChunkId());
|
||||||
|
item.setChunkType(chunk.getChunkType());
|
||||||
|
item.setContent(chunk.getContent());
|
||||||
|
item.setHeadingPath(chunk.getHeadingPath() == null ? new ArrayList<>() : new ArrayList<>(chunk.getHeadingPath()));
|
||||||
|
item.setPartNo(chunk.getPartNo());
|
||||||
|
item.setPartTotal(chunk.getPartTotal());
|
||||||
|
item.setQuestion(chunk.getQuestion());
|
||||||
|
item.setSourceLabel(chunk.getSourceLabel());
|
||||||
|
item.setTokenEstimate(chunk.getTokenEstimate());
|
||||||
|
item.setWarnings(chunk.getWarnings() == null ? new ArrayList<>() : new ArrayList<>(chunk.getWarnings()));
|
||||||
|
item.setSourceRanges(copySourceRanges(chunk));
|
||||||
|
result.add(item);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private List<DocumentImportDtos.PreviewSourceRange> copySourceRanges(RagChunk chunk) {
|
||||||
|
List<DocumentImportDtos.PreviewSourceRange> result = new ArrayList<>();
|
||||||
|
if (chunk == null || chunk.getOptions() == null) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Object rawRanges = chunk.getOptions().get(SOURCE_RANGES_KEY);
|
||||||
|
if (!(rawRanges instanceof List<?> rangeList)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
for (Object item : rangeList) {
|
||||||
|
if (!(item instanceof Map<?, ?> rangeMap)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
DocumentImportDtos.PreviewSourceRange range = new DocumentImportDtos.PreviewSourceRange();
|
||||||
|
range.setStart(asInteger(rangeMap.get("start"), null));
|
||||||
|
range.setEnd(asInteger(rangeMap.get("end"), null));
|
||||||
|
if (range.getStart() != null && range.getEnd() != null) {
|
||||||
|
result.add(range);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private StoreExecutionContext prepareStoreContext(Document entity) {
|
private StoreExecutionContext prepareStoreContext(Document entity) {
|
||||||
DocumentCollection knowledge = knowledgeService.getById(entity.getCollectionId());
|
DocumentCollection knowledge = knowledgeService.getById(entity.getCollectionId());
|
||||||
if (knowledge == null) {
|
if (knowledge == null) {
|
||||||
@@ -882,4 +958,34 @@ public class DocumentServiceImpl extends ServiceImpl<DocumentMapper, Document> i
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<DocumentImportDtos.TaskCreateResponse> createImportTask(DocumentImportDtos.TaskCreateRequest request) {
|
||||||
|
return importTaskAppService.createImportTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<DocumentImportDtos.TaskDetailResponse> getImportTaskDetail(BigInteger taskId) {
|
||||||
|
return importTaskAppService.getImportTaskDetail(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<DocumentImportDtos.PreviewResponse> previewImportTask(DocumentImportDtos.PreviewRequest request) {
|
||||||
|
return importTaskAppService.previewImportTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> startIndexTask(DocumentImportDtos.TaskStartIndexRequest request) {
|
||||||
|
return importTaskAppService.startIndexTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> retryParseTask(DocumentImportDtos.TaskRetryRequest request) {
|
||||||
|
return importTaskAppService.retryParseTask(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<DocumentImportDtos.TaskStartIndexResponse> retryIndexTask(DocumentImportDtos.TaskRetryRequest request) {
|
||||||
|
return importTaskAppService.retryIndexTask(request);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class KnowledgeSharePermissionServiceImpl implements KnowledgeSharePermis
|
|||||||
"/public-api/knowledge-share/detail",
|
"/public-api/knowledge-share/detail",
|
||||||
"/public-api/knowledge-share/document/page",
|
"/public-api/knowledge-share/document/page",
|
||||||
"/public-api/knowledge-share/document/download",
|
"/public-api/knowledge-share/document/download",
|
||||||
|
"/public-api/knowledge-share/document/import/task/detail",
|
||||||
"/public-api/knowledge-share/documentChunk/page",
|
"/public-api/knowledge-share/documentChunk/page",
|
||||||
"/public-api/knowledge-share/faq/page",
|
"/public-api/knowledge-share/faq/page",
|
||||||
"/public-api/knowledge-share/faq/detail"
|
"/public-api/knowledge-share/faq/detail"
|
||||||
@@ -48,6 +49,11 @@ public class KnowledgeSharePermissionServiceImpl implements KnowledgeSharePermis
|
|||||||
"/public-api/knowledge-share/document/import/analyze",
|
"/public-api/knowledge-share/document/import/analyze",
|
||||||
"/public-api/knowledge-share/document/import/preview",
|
"/public-api/knowledge-share/document/import/preview",
|
||||||
"/public-api/knowledge-share/document/import/commit",
|
"/public-api/knowledge-share/document/import/commit",
|
||||||
|
"/public-api/knowledge-share/document/import/task/create",
|
||||||
|
"/public-api/knowledge-share/document/import/task/preview",
|
||||||
|
"/public-api/knowledge-share/document/import/task/startIndex",
|
||||||
|
"/public-api/knowledge-share/document/import/task/retryParse",
|
||||||
|
"/public-api/knowledge-share/document/import/task/retryIndex",
|
||||||
"/public-api/knowledge-share/faq/save"
|
"/public-api/knowledge-share/faq/save"
|
||||||
));
|
));
|
||||||
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.CONTENT_UPDATE.name(), List.of(
|
URI_SCOPE_MAPPING.put(KnowledgeShareActionScope.CONTENT_UPDATE.name(), List.of(
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package tech.easyflow.ai.vo;
|
||||||
|
|
||||||
|
import tech.easyflow.ai.entity.DocumentCollection;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识库分享页详情视图。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-15
|
||||||
|
*/
|
||||||
|
public class KnowledgeShareViewDetail implements Serializable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前分享对应的知识库。
|
||||||
|
*/
|
||||||
|
private DocumentCollection knowledge;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前分享授权范围。
|
||||||
|
*/
|
||||||
|
private List<String> permissionScopes = new ArrayList<String>();
|
||||||
|
|
||||||
|
public DocumentCollection getKnowledge() {
|
||||||
|
return knowledge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setKnowledge(DocumentCollection knowledge) {
|
||||||
|
this.knowledge = knowledge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getPermissionScopes() {
|
||||||
|
return permissionScopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPermissionScopes(List<String> permissionScopes) {
|
||||||
|
this.permissionScopes = permissionScopes == null
|
||||||
|
? new ArrayList<String>()
|
||||||
|
: new ArrayList<String>(permissionScopes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package tech.easyflow.ai.documentimport.task;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.ai.entity.DocumentImportTask;
|
||||||
|
import tech.easyflow.ai.enums.DocumentImportTaskStatus;
|
||||||
|
import tech.easyflow.ai.enums.DocumentProcessStatus;
|
||||||
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
|
import tech.easyflow.ai.service.DocumentImportTaskService;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link KnowledgeDocumentImportTaskAppService} 回归测试。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-15
|
||||||
|
*/
|
||||||
|
public class KnowledgeDocumentImportTaskAppServiceTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证向量化失败会按整文档失败语义重置进度,并刷新任务错误信息。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void markIndexFailedShouldResetProgressAndPersistLatestError() throws Exception {
|
||||||
|
BigInteger documentId = BigInteger.valueOf(10);
|
||||||
|
BigInteger knowledgeId = BigInteger.valueOf(20);
|
||||||
|
|
||||||
|
tech.easyflow.ai.entity.Document persistedDocument = new tech.easyflow.ai.entity.Document();
|
||||||
|
persistedDocument.setId(documentId);
|
||||||
|
persistedDocument.setCollectionId(knowledgeId);
|
||||||
|
persistedDocument.setProcessStatus(DocumentProcessStatus.INDEXING.name());
|
||||||
|
persistedDocument.setTotalChunks(8);
|
||||||
|
persistedDocument.setCompletedChunks(5);
|
||||||
|
persistedDocument.setFailedChunks(1);
|
||||||
|
persistedDocument.setProgressPercent(63);
|
||||||
|
persistedDocument.setLastTaskError("旧错误");
|
||||||
|
|
||||||
|
AtomicReference<tech.easyflow.ai.entity.Document> updatedDocumentRef = new AtomicReference<tech.easyflow.ai.entity.Document>();
|
||||||
|
AtomicReference<DocumentImportTask> updatedTaskRef = new AtomicReference<DocumentImportTask>();
|
||||||
|
|
||||||
|
KnowledgeDocumentImportTaskAppService service = new KnowledgeDocumentImportTaskAppService();
|
||||||
|
setField(service, "documentMapper", mockDocumentMapper(persistedDocument, updatedDocumentRef));
|
||||||
|
setField(service, "documentImportTaskService", mockDocumentImportTaskService(updatedTaskRef));
|
||||||
|
setField(service, "documentImportTaskStatusStreamService", new NoopTaskStatusStreamService());
|
||||||
|
|
||||||
|
DocumentImportTask task = new DocumentImportTask();
|
||||||
|
task.setId(BigInteger.valueOf(30));
|
||||||
|
task.setDocumentId(documentId);
|
||||||
|
task.setKnowledgeId(knowledgeId);
|
||||||
|
task.setStatus(DocumentImportTaskStatus.RUNNING.name());
|
||||||
|
task.setErrorSummary("旧错误");
|
||||||
|
|
||||||
|
tech.easyflow.ai.entity.Document inputDocument = new tech.easyflow.ai.entity.Document();
|
||||||
|
inputDocument.setId(documentId);
|
||||||
|
inputDocument.setCollectionId(knowledgeId);
|
||||||
|
|
||||||
|
Method method = KnowledgeDocumentImportTaskAppService.class.getDeclaredMethod(
|
||||||
|
"markIndexFailed",
|
||||||
|
DocumentImportTask.class,
|
||||||
|
tech.easyflow.ai.entity.Document.class,
|
||||||
|
String.class
|
||||||
|
);
|
||||||
|
method.setAccessible(true);
|
||||||
|
method.invoke(service, task, inputDocument, "新错误");
|
||||||
|
|
||||||
|
tech.easyflow.ai.entity.Document updatedDocument = updatedDocumentRef.get();
|
||||||
|
Assert.assertNotNull(updatedDocument);
|
||||||
|
Assert.assertEquals(DocumentProcessStatus.INDEX_FAILED.name(), updatedDocument.getProcessStatus());
|
||||||
|
Assert.assertEquals(Integer.valueOf(0), updatedDocument.getCompletedChunks());
|
||||||
|
Assert.assertEquals(Integer.valueOf(8), updatedDocument.getFailedChunks());
|
||||||
|
Assert.assertEquals(Integer.valueOf(0), updatedDocument.getProgressPercent());
|
||||||
|
Assert.assertEquals("新错误", updatedDocument.getLastTaskError());
|
||||||
|
|
||||||
|
DocumentImportTask updatedTask = updatedTaskRef.get();
|
||||||
|
Assert.assertNotNull(updatedTask);
|
||||||
|
Assert.assertEquals(DocumentImportTaskStatus.FAILED.name(), updatedTask.getStatus());
|
||||||
|
Assert.assertEquals("新错误", updatedTask.getErrorSummary());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentMapper mockDocumentMapper(tech.easyflow.ai.entity.Document persistedDocument,
|
||||||
|
AtomicReference<tech.easyflow.ai.entity.Document> updatedDocumentRef) {
|
||||||
|
return (DocumentMapper) Proxy.newProxyInstance(
|
||||||
|
DocumentMapper.class.getClassLoader(),
|
||||||
|
new Class<?>[]{DocumentMapper.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("selectOneById".equals(method.getName())) {
|
||||||
|
return persistedDocument;
|
||||||
|
}
|
||||||
|
if ("update".equals(method.getName())) {
|
||||||
|
updatedDocumentRef.set((tech.easyflow.ai.entity.Document) args[0]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentImportTaskService mockDocumentImportTaskService(AtomicReference<DocumentImportTask> updatedTaskRef) {
|
||||||
|
return (DocumentImportTaskService) Proxy.newProxyInstance(
|
||||||
|
DocumentImportTaskService.class.getClassLoader(),
|
||||||
|
new Class<?>[]{DocumentImportTaskService.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("updateById".equals(method.getName())) {
|
||||||
|
updatedTaskRef.set((DocumentImportTask) args[0]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = KnowledgeDocumentImportTaskAppService.class.getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object defaultValue(Class<?> returnType) {
|
||||||
|
if (returnType == boolean.class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (returnType == int.class) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (returnType == long.class) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试用 SSE 推送桩,避免依赖线程池和真实推送。
|
||||||
|
*/
|
||||||
|
private static class NoopTaskStatusStreamService extends DocumentImportTaskStatusStreamService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publishAfterCommit(BigInteger documentId) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package tech.easyflow.ai.service.impl;
|
||||||
|
|
||||||
|
import com.easyagents.core.document.Document;
|
||||||
|
import com.easyagents.search.engine.service.DocumentSearcher;
|
||||||
|
import com.easyagents.search.engine.service.KeywordSearchRequest;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import tech.easyflow.ai.config.SearcherFactory;
|
||||||
|
import tech.easyflow.ai.enums.DocumentProcessStatus;
|
||||||
|
import tech.easyflow.ai.mapper.DocumentChunkMapper;
|
||||||
|
import tech.easyflow.ai.mapper.DocumentMapper;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static tech.easyflow.ai.entity.DocumentCollection.KEY_DOC_RECALL_MAX_NUM;
|
||||||
|
import static tech.easyflow.ai.entity.DocumentCollection.KEY_SIMILARITY_THRESHOLD;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link DocumentCollectionServiceImpl} 回归测试。
|
||||||
|
*
|
||||||
|
* @author Codex
|
||||||
|
* @since 2026-04-15
|
||||||
|
*/
|
||||||
|
public class DocumentCollectionServiceImplTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证检索结果会在重排前过滤掉未完成文档,避免高分进行中文档挤占最终名额。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射注入异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void searchShouldFilterNonCompletedChunksBeforeFinalTopK() throws Exception {
|
||||||
|
BigInteger knowledgeId = BigInteger.ONE;
|
||||||
|
BigInteger completedDocumentId = BigInteger.valueOf(101);
|
||||||
|
BigInteger indexingDocumentId = BigInteger.valueOf(102);
|
||||||
|
BigInteger completedChunkId = BigInteger.valueOf(1001);
|
||||||
|
BigInteger indexingChunkId = BigInteger.valueOf(1002);
|
||||||
|
|
||||||
|
tech.easyflow.ai.entity.DocumentCollection collection = new tech.easyflow.ai.entity.DocumentCollection();
|
||||||
|
collection.setId(knowledgeId);
|
||||||
|
collection.setCollectionType(tech.easyflow.ai.entity.DocumentCollection.TYPE_DOCUMENT);
|
||||||
|
collection.setOptions(new HashMap<String, Object>() {{
|
||||||
|
put(KEY_DOC_RECALL_MAX_NUM, 1);
|
||||||
|
put(KEY_SIMILARITY_THRESHOLD, BigDecimal.ZERO);
|
||||||
|
}});
|
||||||
|
|
||||||
|
tech.easyflow.ai.entity.DocumentChunk completedChunk = new tech.easyflow.ai.entity.DocumentChunk();
|
||||||
|
completedChunk.setId(completedChunkId);
|
||||||
|
completedChunk.setDocumentId(completedDocumentId);
|
||||||
|
completedChunk.setDocumentCollectionId(knowledgeId);
|
||||||
|
completedChunk.setContent("completed chunk");
|
||||||
|
|
||||||
|
tech.easyflow.ai.entity.DocumentChunk indexingChunk = new tech.easyflow.ai.entity.DocumentChunk();
|
||||||
|
indexingChunk.setId(indexingChunkId);
|
||||||
|
indexingChunk.setDocumentId(indexingDocumentId);
|
||||||
|
indexingChunk.setDocumentCollectionId(knowledgeId);
|
||||||
|
indexingChunk.setContent("indexing chunk");
|
||||||
|
|
||||||
|
tech.easyflow.ai.entity.Document completedDocument = new tech.easyflow.ai.entity.Document();
|
||||||
|
completedDocument.setId(completedDocumentId);
|
||||||
|
completedDocument.setCollectionId(knowledgeId);
|
||||||
|
completedDocument.setProcessStatus(DocumentProcessStatus.COMPLETED.name());
|
||||||
|
completedDocument.setTitle("completed");
|
||||||
|
|
||||||
|
TestKeywordSearcher searcher = new TestKeywordSearcher(List.of(
|
||||||
|
buildHit(indexingChunkId, 0.99D),
|
||||||
|
buildHit(completedChunkId, 0.75D)
|
||||||
|
));
|
||||||
|
|
||||||
|
DocumentCollectionServiceImpl service = new TestDocumentCollectionService(collection);
|
||||||
|
setField(service, "searcherFactory", new SearcherFactory(new StaticObjectProvider<DocumentSearcher>(searcher)));
|
||||||
|
setField(service, "documentChunkMapper", mockDocumentChunkMapper(completedChunk, indexingChunk));
|
||||||
|
setField(service, "documentMapper", mockDocumentMapper(completedDocument));
|
||||||
|
|
||||||
|
tech.easyflow.ai.rag.KnowledgeRetrievalRequest request = new tech.easyflow.ai.rag.KnowledgeRetrievalRequest();
|
||||||
|
request.setKnowledgeId(knowledgeId);
|
||||||
|
request.setQuery("test-query");
|
||||||
|
request.setRetrievalMode(com.easyagents.rag.retrieval.RetrievalMode.KEYWORD);
|
||||||
|
|
||||||
|
List<Document> result = service.search(request);
|
||||||
|
|
||||||
|
Assert.assertEquals("内部关键词召回应扩容到业务 topK 的 5 倍", 5, searcher.lastRequestCount);
|
||||||
|
Assert.assertEquals("知识库过滤后只应保留完成态文档", 1, result.size());
|
||||||
|
Assert.assertEquals(completedChunkId, result.get(0).getId());
|
||||||
|
Assert.assertEquals("completed chunk", result.get(0).getContent());
|
||||||
|
Assert.assertEquals(String.valueOf(knowledgeId), searcher.lastKnowledgeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Document buildHit(BigInteger id, double score) {
|
||||||
|
Document document = new Document();
|
||||||
|
document.setId(id);
|
||||||
|
document.setScore(score);
|
||||||
|
document.setContent("raw-hit-" + id);
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentChunkMapper mockDocumentChunkMapper(tech.easyflow.ai.entity.DocumentChunk... chunks) {
|
||||||
|
Map<String, tech.easyflow.ai.entity.DocumentChunk> chunkMap = new HashMap<String, tech.easyflow.ai.entity.DocumentChunk>();
|
||||||
|
for (tech.easyflow.ai.entity.DocumentChunk chunk : chunks) {
|
||||||
|
chunkMap.put(String.valueOf(chunk.getId()), chunk);
|
||||||
|
}
|
||||||
|
return (DocumentChunkMapper) Proxy.newProxyInstance(
|
||||||
|
DocumentChunkMapper.class.getClassLoader(),
|
||||||
|
new Class<?>[]{DocumentChunkMapper.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("selectListByQuery".equals(method.getName())) {
|
||||||
|
return List.copyOf(chunkMap.values());
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DocumentMapper mockDocumentMapper(tech.easyflow.ai.entity.Document completedDocument) {
|
||||||
|
return (DocumentMapper) Proxy.newProxyInstance(
|
||||||
|
DocumentMapper.class.getClassLoader(),
|
||||||
|
new Class<?>[]{DocumentMapper.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("selectListByQuery".equals(method.getName())) {
|
||||||
|
return List.of(completedDocument);
|
||||||
|
}
|
||||||
|
return defaultValue(method.getReturnType());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||||
|
Field field = DocumentCollectionServiceImpl.class.getDeclaredField(fieldName);
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(target, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object defaultValue(Class<?> returnType) {
|
||||||
|
if (returnType == boolean.class) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (returnType == int.class) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (returnType == long.class) {
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 固定返回测试知识库实体,避免依赖数据库。
|
||||||
|
*/
|
||||||
|
private static class TestDocumentCollectionService extends DocumentCollectionServiceImpl {
|
||||||
|
|
||||||
|
private final tech.easyflow.ai.entity.DocumentCollection collection;
|
||||||
|
|
||||||
|
private TestDocumentCollectionService(tech.easyflow.ai.entity.DocumentCollection collection) {
|
||||||
|
this.collection = collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public tech.easyflow.ai.entity.DocumentCollection getById(Serializable id) {
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录关键词检索请求参数的搜索器桩实现。
|
||||||
|
*/
|
||||||
|
private static class TestKeywordSearcher implements DocumentSearcher {
|
||||||
|
|
||||||
|
private final List<Document> documents;
|
||||||
|
private int lastRequestCount;
|
||||||
|
private String lastKnowledgeId;
|
||||||
|
|
||||||
|
private TestKeywordSearcher(List<Document> documents) {
|
||||||
|
this.documents = documents;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean addDocument(Document document) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean deleteDocument(Object id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean updateDocument(Document document) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Document> searchDocuments(KeywordSearchRequest request) {
|
||||||
|
this.lastRequestCount = request.getCount();
|
||||||
|
this.lastKnowledgeId = request.getKnowledgeId();
|
||||||
|
return documents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最小 ObjectProvider 实现,仅服务搜索器工厂测试注入。
|
||||||
|
*/
|
||||||
|
private static class StaticObjectProvider<T> implements ObjectProvider<T> {
|
||||||
|
|
||||||
|
private final T value;
|
||||||
|
|
||||||
|
private StaticObjectProvider(T value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public T getObject(Object... args) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public T getIfAvailable() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public T getIfUnique() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public T getObject() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
package tech.easyflow.starter;
|
package tech.easyflow.starter;
|
||||||
|
|
||||||
import org.dromara.x.file.storage.spring.EnableFileStorage;
|
import org.dromara.x.file.storage.spring.EnableFileStorage;
|
||||||
import tech.easyflow.common.spring.BaseApp;
|
import org.springframework.boot.actuate.autoconfigure.elasticsearch.ElasticsearchRestHealthContributorAutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import tech.easyflow.common.spring.BaseApp;
|
||||||
|
|
||||||
@SpringBootApplication
|
/**
|
||||||
|
* EasyFlow 启动入口。
|
||||||
|
*/
|
||||||
|
@SpringBootApplication(exclude = ElasticsearchRestHealthContributorAutoConfiguration.class)
|
||||||
@EnableFileStorage
|
@EnableFileStorage
|
||||||
public class MainApplication extends BaseApp {
|
public class MainApplication extends BaseApp {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 EasyFlow 应用。
|
||||||
|
*
|
||||||
|
* @param args 启动参数
|
||||||
|
*/
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
run(MainApplication.class, args);
|
run(MainApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
ALTER TABLE `tb_document`
|
||||||
|
ADD COLUMN `process_status` varchar(32) NULL DEFAULT NULL COMMENT '处理状态' AFTER `options`,
|
||||||
|
ADD COLUMN `total_chunks` int NULL DEFAULT 0 COMMENT '总分块数' AFTER `process_status`,
|
||||||
|
ADD COLUMN `completed_chunks` int NULL DEFAULT 0 COMMENT '已完成分块数' AFTER `total_chunks`,
|
||||||
|
ADD COLUMN `failed_chunks` int NULL DEFAULT 0 COMMENT '失败分块数' AFTER `completed_chunks`,
|
||||||
|
ADD COLUMN `progress_percent` int NULL DEFAULT 0 COMMENT '处理进度百分比' AFTER `failed_chunks`,
|
||||||
|
ADD COLUMN `last_task_error` varchar(1024) NULL DEFAULT NULL COMMENT '最近任务错误摘要' AFTER `progress_percent`,
|
||||||
|
ADD COLUMN `task_modified_at` datetime NULL DEFAULT NULL COMMENT '任务状态更新时间' AFTER `last_task_error`;
|
||||||
|
|
||||||
|
CREATE TABLE `tb_document_import_task`
|
||||||
|
(
|
||||||
|
`id` bigint UNSIGNED NOT NULL COMMENT '主键',
|
||||||
|
`document_id` bigint UNSIGNED NOT NULL COMMENT '文档ID',
|
||||||
|
`knowledge_id` bigint UNSIGNED NOT NULL COMMENT '知识库ID',
|
||||||
|
`phase` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '任务阶段',
|
||||||
|
`status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '任务状态',
|
||||||
|
`provider_task_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '底层任务ID',
|
||||||
|
`payload_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '任务载荷',
|
||||||
|
`error_summary` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '错误摘要',
|
||||||
|
`started_at` datetime NULL DEFAULT NULL COMMENT '开始时间',
|
||||||
|
`finished_at` datetime NULL DEFAULT NULL COMMENT '结束时间',
|
||||||
|
`created` datetime NULL DEFAULT NULL COMMENT '创建时间',
|
||||||
|
`created_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '创建人',
|
||||||
|
`modified` datetime NULL DEFAULT NULL COMMENT '修改时间',
|
||||||
|
`modified_by` bigint UNSIGNED NULL DEFAULT NULL COMMENT '修改人',
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
INDEX `idx_document_import_task_document`(`document_id`) USING BTREE,
|
||||||
|
INDEX `idx_document_import_task_knowledge`(`knowledge_id`) USING BTREE,
|
||||||
|
INDEX `idx_document_import_task_phase_status`(`phase`, `status`) USING BTREE
|
||||||
|
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '知识库文档导入任务' ROW_FORMAT = DYNAMIC;
|
||||||
|
|
||||||
|
UPDATE `tb_document` d
|
||||||
|
SET d.`process_status` = 'COMPLETED',
|
||||||
|
d.`total_chunks` = (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM `tb_document_chunk` c
|
||||||
|
WHERE c.`document_id` = d.`id`
|
||||||
|
),
|
||||||
|
d.`completed_chunks` = (
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM `tb_document_chunk` c
|
||||||
|
WHERE c.`document_id` = d.`id`
|
||||||
|
),
|
||||||
|
d.`failed_chunks` = 0,
|
||||||
|
d.`progress_percent` = 100,
|
||||||
|
d.`last_task_error` = NULL,
|
||||||
|
d.`task_modified_at` = COALESCE(d.`modified`, d.`created`, NOW())
|
||||||
|
WHERE d.`process_status` IS NULL;
|
||||||
@@ -78,6 +78,25 @@ const handleCurrentChange = (newPage: number) => {
|
|||||||
pageInfo.pageNumber = newPage;
|
pageInfo.pageNumber = newPage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const patchRowById = (
|
||||||
|
id: number | string,
|
||||||
|
patch: Record<string, any>,
|
||||||
|
): boolean => {
|
||||||
|
const rowIndex = pageList.value.findIndex(
|
||||||
|
(item: Record<string, any>) => String(item?.id ?? '') === String(id ?? ''),
|
||||||
|
);
|
||||||
|
if (rowIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nextPageList = [...pageList.value];
|
||||||
|
nextPageList[rowIndex] = {
|
||||||
|
...nextPageList[rowIndex],
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
pageList.value = nextPageList;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// 暴露给父组件的方法 (替代 useImperativeHandle)
|
// 暴露给父组件的方法 (替代 useImperativeHandle)
|
||||||
const setQuery = (newQueryParams: Record<string, any>) => {
|
const setQuery = (newQueryParams: Record<string, any>) => {
|
||||||
pageInfo.pageNumber = 1;
|
pageInfo.pageNumber = 1;
|
||||||
@@ -89,6 +108,7 @@ const setQuery = (newQueryParams: Record<string, any>) => {
|
|||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
reload: getPageList,
|
reload: getPageList,
|
||||||
|
patchRowById,
|
||||||
setQuery,
|
setQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,13 @@ const triggerFileSelect = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearFiles = () => {
|
||||||
|
uploadRef.value?.clearFiles?.();
|
||||||
|
};
|
||||||
|
|
||||||
// 对外暴露方法(父组件可通过ref调用)
|
// 对外暴露方法(父组件可通过ref调用)
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
clearFiles,
|
||||||
triggerFileSelect,
|
triggerFileSelect,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -48,5 +48,9 @@
|
|||||||
"more": "Mode",
|
"more": "Mode",
|
||||||
"submitDeleteApproval": "Submit Delete Approval",
|
"submitDeleteApproval": "Submit Delete Approval",
|
||||||
"submitPublishApproval": "Submit Publish Approval",
|
"submitPublishApproval": "Submit Publish Approval",
|
||||||
"viewSegmentation": "ViewSegmentation"
|
"viewSegmentation": "View Segments",
|
||||||
|
"continueProcess": "Continue",
|
||||||
|
"startIndex": "Start Indexing",
|
||||||
|
"retryParse": "Retry Parse",
|
||||||
|
"retryIndex": "Retry Index"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,10 @@
|
|||||||
"rerankLlmId": "RerankLlm",
|
"rerankLlmId": "RerankLlm",
|
||||||
"searchEngineEnable": "SearchEngineEnable",
|
"searchEngineEnable": "SearchEngineEnable",
|
||||||
"englishName": "EnglishName",
|
"englishName": "EnglishName",
|
||||||
"documentType": "DocumentType",
|
|
||||||
"fileName": "fileName",
|
"fileName": "fileName",
|
||||||
"knowledgeCount": "Number of knowledge items",
|
"chunkCount": "Chunks",
|
||||||
|
"processStatus": "Status",
|
||||||
|
"progress": "Progress",
|
||||||
"publishStatusDraft": "Draft",
|
"publishStatusDraft": "Draft",
|
||||||
"publishStatusPublishPending": "Publish Pending",
|
"publishStatusPublishPending": "Publish Pending",
|
||||||
"publishStatusPublished": "Published",
|
"publishStatusPublished": "Published",
|
||||||
@@ -77,17 +78,32 @@
|
|||||||
"fileName": "File Name",
|
"fileName": "File Name",
|
||||||
"progressUpload": "Progress of file upload",
|
"progressUpload": "Progress of file upload",
|
||||||
"fileSize": "File size",
|
"fileSize": "File size",
|
||||||
|
"uploadCreateTip": "After upload, the document appears in the list first and is parsed asynchronously. Continue with chunking after parsing finishes.",
|
||||||
"analysisTip": "The system analyzes multilingual structure first and recommends a splitting strategy. You can still adjust each file manually.",
|
"analysisTip": "The system analyzes multilingual structure first and recommends a splitting strategy. You can still adjust each file manually.",
|
||||||
|
"manualStrategyTip": "The preview refreshes automatically when the chunking strategy changes. Start indexing after it looks right.",
|
||||||
"confidence": "Confidence",
|
"confidence": "Confidence",
|
||||||
"recommendReason": "Reasons",
|
"recommendReason": "Reasons",
|
||||||
"candidateStrategies": "Candidates",
|
"candidateStrategies": "Candidates",
|
||||||
"strategySelection": "Strategy",
|
"strategySelection": "Strategy",
|
||||||
"previewTip": "The preview result is the final import basis. Confirm it before committing.",
|
"previewTip": "The preview result is the final import basis. Confirm it before committing.",
|
||||||
|
"previewPaneTitle": "Chunk Preview",
|
||||||
"previewEmpty": "No preview data",
|
"previewEmpty": "No preview data",
|
||||||
|
"previewReady": "Preview ready",
|
||||||
|
"previewRefreshing": "Refreshing preview",
|
||||||
|
"previewRequestFailed": "Failed to refresh preview. Please try again.",
|
||||||
"warningCount": "Warnings",
|
"warningCount": "Warnings",
|
||||||
"chunkCount": "Chunks",
|
"chunkCount": "Chunks",
|
||||||
|
"lockedState": "Locked",
|
||||||
|
"normalizedDocumentTitle": "Source Text",
|
||||||
|
"normalizedDocumentTip": "Shows the normalized source text so each chunk can be checked in context.",
|
||||||
"resultEmpty": "No import result",
|
"resultEmpty": "No import result",
|
||||||
"importFailed": "Import failed"
|
"importFailed": "Import failed",
|
||||||
|
"createSuccess": "Documents added to the list",
|
||||||
|
"partialCreateSuccess": "Only some documents were added successfully",
|
||||||
|
"indexQueued": "Indexing started",
|
||||||
|
"previewAction": "Generate Preview",
|
||||||
|
"workbenchEyebrow": "Processing Workspace",
|
||||||
|
"workbenchTitle": "Document Processing"
|
||||||
},
|
},
|
||||||
"splitterDoc": {
|
"splitterDoc": {
|
||||||
"fileType": "FileType",
|
"fileType": "FileType",
|
||||||
@@ -113,6 +129,16 @@
|
|||||||
"uploading": "Parsing in progress",
|
"uploading": "Parsing in progress",
|
||||||
"importSuccess": "ImportSuccess"
|
"importSuccess": "ImportSuccess"
|
||||||
},
|
},
|
||||||
|
"taskStatus": {
|
||||||
|
"UPLOADED": "Uploaded",
|
||||||
|
"PARSING": "Parsing",
|
||||||
|
"PARSE_FAILED": "Parse Failed",
|
||||||
|
"READY_FOR_SEGMENT": "Ready for Chunking",
|
||||||
|
"READY_FOR_INDEX": "Ready for Indexing",
|
||||||
|
"INDEXING": "Indexing",
|
||||||
|
"INDEX_FAILED": "Index Failed",
|
||||||
|
"COMPLETED": "Completed"
|
||||||
|
},
|
||||||
"documentManagement": "Document management",
|
"documentManagement": "Document management",
|
||||||
"actions": {
|
"actions": {
|
||||||
"knowledge": "Knowledge",
|
"knowledge": "Knowledge",
|
||||||
@@ -188,5 +214,6 @@
|
|||||||
"vectorEmbedModelTips": "After successful vector data, it is not allowed to modify the vector model",
|
"vectorEmbedModelTips": "After successful vector data, it is not allowed to modify the vector model",
|
||||||
"dimensionOfVectorModelTips": "After successful vector data, it is not allowed to modify the dimensions of the vector model",
|
"dimensionOfVectorModelTips": "After successful vector data, it is not allowed to modify the dimensions of the vector model",
|
||||||
"dimensionOfVectorModel": "Dimension of vector model",
|
"dimensionOfVectorModel": "Dimension of vector model",
|
||||||
"managePermissionHint": "Only the creator or super admin can modify this knowledge base"
|
"managePermissionHint": "Only the creator or super admin can modify this knowledge base",
|
||||||
|
"processingDeleteBlocked": "Documents in progress cannot be deleted"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,5 +48,9 @@
|
|||||||
"more": "更多",
|
"more": "更多",
|
||||||
"submitDeleteApproval": "提交删除审批",
|
"submitDeleteApproval": "提交删除审批",
|
||||||
"submitPublishApproval": "提交发布审批",
|
"submitPublishApproval": "提交发布审批",
|
||||||
"viewSegmentation": "查看分段"
|
"viewSegmentation": "查看分段",
|
||||||
|
"continueProcess": "继续处理",
|
||||||
|
"startIndex": "开始向量化",
|
||||||
|
"retryParse": "重试解析",
|
||||||
|
"retryIndex": "重试向量化"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,9 +32,10 @@
|
|||||||
"rerankLlmId": "重排模型",
|
"rerankLlmId": "重排模型",
|
||||||
"searchEngineEnable": "是否启用搜索引擎",
|
"searchEngineEnable": "是否启用搜索引擎",
|
||||||
"englishName": "英文名称",
|
"englishName": "英文名称",
|
||||||
"documentType": "文件类型",
|
|
||||||
"fileName": "文件名",
|
"fileName": "文件名",
|
||||||
"knowledgeCount": "知识条数",
|
"chunkCount": "分块数",
|
||||||
|
"processStatus": "处理状态",
|
||||||
|
"progress": "处理进度",
|
||||||
"publishStatusDraft": "草稿",
|
"publishStatusDraft": "草稿",
|
||||||
"publishStatusPublishPending": "发布审批中",
|
"publishStatusPublishPending": "发布审批中",
|
||||||
"publishStatusPublished": "已发布",
|
"publishStatusPublished": "已发布",
|
||||||
@@ -77,17 +78,32 @@
|
|||||||
"fileName": "文件名称",
|
"fileName": "文件名称",
|
||||||
"progressUpload": "文件上传进度",
|
"progressUpload": "文件上传进度",
|
||||||
"fileSize": "文件大小",
|
"fileSize": "文件大小",
|
||||||
|
"uploadCreateTip": "上传完成后,文档会先进入列表并异步解析,解析完成后再继续分块和向量化。",
|
||||||
"analysisTip": "系统会先基于文档结构做中英文规则分析,再推荐拆分策略,你也可以逐个文件手动调整。",
|
"analysisTip": "系统会先基于文档结构做中英文规则分析,再推荐拆分策略,你也可以逐个文件手动调整。",
|
||||||
|
"manualStrategyTip": "调整分块策略后会自动刷新预览,确认效果后再启动向量化。",
|
||||||
"confidence": "置信度",
|
"confidence": "置信度",
|
||||||
"recommendReason": "推荐理由",
|
"recommendReason": "推荐理由",
|
||||||
"candidateStrategies": "备选策略",
|
"candidateStrategies": "备选策略",
|
||||||
"strategySelection": "拆分策略",
|
"strategySelection": "拆分策略",
|
||||||
"previewTip": "预览结果就是最终入库依据,确认无误后再执行导入。",
|
"previewTip": "预览结果就是最终入库依据,确认无误后再执行导入。",
|
||||||
|
"previewPaneTitle": "分块预览",
|
||||||
"previewEmpty": "暂无可预览内容",
|
"previewEmpty": "暂无可预览内容",
|
||||||
|
"previewReady": "预览已更新",
|
||||||
|
"previewRefreshing": "正在更新预览",
|
||||||
|
"previewRequestFailed": "预览生成失败,请稍后重试",
|
||||||
"warningCount": "警告数",
|
"warningCount": "警告数",
|
||||||
"chunkCount": "分块数",
|
"chunkCount": "分块数",
|
||||||
|
"lockedState": "已锁定",
|
||||||
|
"normalizedDocumentTitle": "原文",
|
||||||
|
"normalizedDocumentTip": "展示解析后的标准化文本,便于核对每个分块落点。",
|
||||||
"resultEmpty": "暂无导入结果",
|
"resultEmpty": "暂无导入结果",
|
||||||
"importFailed": "导入失败"
|
"importFailed": "导入失败",
|
||||||
|
"createSuccess": "文档已加入列表",
|
||||||
|
"partialCreateSuccess": "已加入部分文档,请检查失败项",
|
||||||
|
"indexQueued": "已开始向量化",
|
||||||
|
"previewAction": "生成预览",
|
||||||
|
"workbenchEyebrow": "处理工作台",
|
||||||
|
"workbenchTitle": "文档处理"
|
||||||
},
|
},
|
||||||
"splitterDoc": {
|
"splitterDoc": {
|
||||||
"fileType": "文件类型",
|
"fileType": "文件类型",
|
||||||
@@ -113,6 +129,16 @@
|
|||||||
"uploading": "解析中",
|
"uploading": "解析中",
|
||||||
"importSuccess": "导入成功"
|
"importSuccess": "导入成功"
|
||||||
},
|
},
|
||||||
|
"taskStatus": {
|
||||||
|
"UPLOADED": "已上传",
|
||||||
|
"PARSING": "解析中",
|
||||||
|
"PARSE_FAILED": "解析失败",
|
||||||
|
"READY_FOR_SEGMENT": "待分块",
|
||||||
|
"READY_FOR_INDEX": "待向量化",
|
||||||
|
"INDEXING": "向量化中",
|
||||||
|
"INDEX_FAILED": "向量化失败",
|
||||||
|
"COMPLETED": "已完成"
|
||||||
|
},
|
||||||
"documentManagement": "文档管理",
|
"documentManagement": "文档管理",
|
||||||
"actions": {
|
"actions": {
|
||||||
"knowledge": "知识",
|
"knowledge": "知识",
|
||||||
@@ -188,5 +214,6 @@
|
|||||||
"vectorEmbedModelTips": "成功向量数据之后不允许修改向量模型",
|
"vectorEmbedModelTips": "成功向量数据之后不允许修改向量模型",
|
||||||
"dimensionOfVectorModelTips": "成功向量数据之后不允许修改向量模型维度",
|
"dimensionOfVectorModelTips": "成功向量数据之后不允许修改向量模型维度",
|
||||||
"dimensionOfVectorModel": "向量模型维度",
|
"dimensionOfVectorModel": "向量模型维度",
|
||||||
"managePermissionHint": "仅创建者或超级管理员可修改当前知识库"
|
"managePermissionHint": "仅创建者或超级管理员可修改当前知识库",
|
||||||
|
"processingDeleteBlocked": "文档处理中,暂不允许删除"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { useAccess } from '@easyflow/access';
|
import { useAccess } from '@easyflow/access';
|
||||||
@@ -7,7 +7,7 @@ import { $t } from '@easyflow/locales';
|
|||||||
import { useUserStore } from '@easyflow/stores';
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
import { ArrowLeft, Plus } from '@element-plus/icons-vue';
|
||||||
import { ElIcon, ElImage } from 'element-plus';
|
import { ElButton, ElIcon, ElImage } from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
import bookIcon from '#/assets/ai/knowledge/book.svg';
|
||||||
@@ -18,8 +18,8 @@ import DocumentTable from '#/views/ai/documentCollection/DocumentTable.vue';
|
|||||||
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
||||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||||
import KnowledgeSearchConfig from '#/views/ai/documentCollection/KnowledgeSearchConfig.vue';
|
|
||||||
import KnowledgeShareManagement from '#/views/ai/documentCollection/KnowledgeShareManagement.vue';
|
import KnowledgeShareManagement from '#/views/ai/documentCollection/KnowledgeShareManagement.vue';
|
||||||
|
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -147,8 +147,10 @@ const headerButtons = [
|
|||||||
data: { action: 'importFile' },
|
data: { action: 'importFile' },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const isImportFileVisible = ref(false);
|
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||||
const documentTableRef = ref();
|
const documentTableRef = ref();
|
||||||
|
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
||||||
|
const documentTitle = ref('');
|
||||||
const handleSearch = (searchParams: string) => {
|
const handleSearch = (searchParams: string) => {
|
||||||
documentTableRef.value.search(searchParams);
|
documentTableRef.value.search(searchParams);
|
||||||
};
|
};
|
||||||
@@ -160,30 +162,39 @@ const handleButtonClick = (event: any) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'importFile': {
|
case 'importFile': {
|
||||||
isImportFileVisible.value = true;
|
importDocModalRef.value?.openDialog?.();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleCategoryClick = (menuKey: string) => {
|
const handleCategoryClick = (menuKey: string) => {
|
||||||
selectedCategory.value = menuKey;
|
selectedCategory.value = menuKey;
|
||||||
viewDocVisible.value = false;
|
panelMode.value = 'list';
|
||||||
};
|
};
|
||||||
const viewDocVisible = ref(false);
|
|
||||||
const documentId = ref('');
|
const documentId = ref('');
|
||||||
// 子组件传递事件,显示查看文档详情
|
// 子组件传递事件,显示查看文档详情
|
||||||
const viewDoc = (docId: string) => {
|
const viewDoc = (docId: string) => {
|
||||||
viewDocVisible.value = true;
|
panelMode.value = 'chunk';
|
||||||
documentId.value = docId;
|
documentId.value = docId;
|
||||||
};
|
};
|
||||||
const backDoc = () => {
|
const continueProcess = (doc: any) => {
|
||||||
isImportFileVisible.value = false;
|
panelMode.value = 'process';
|
||||||
|
documentId.value = String(doc?.id || '');
|
||||||
|
documentTitle.value = doc?.title || '';
|
||||||
|
};
|
||||||
|
const backDoc = async () => {
|
||||||
|
if (panelMode.value !== 'list') {
|
||||||
|
panelMode.value = 'list';
|
||||||
|
}
|
||||||
|
documentTitle.value = '';
|
||||||
|
await nextTick();
|
||||||
|
documentTableRef.value?.reload?.();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="document-container">
|
<div class="document-container">
|
||||||
<div v-if="!isImportFileVisible" class="doc-header-container">
|
<div class="doc-header-container">
|
||||||
<div class="doc-knowledge-container">
|
<div class="doc-knowledge-container">
|
||||||
<div @click="back()" style="cursor: pointer">
|
<div @click="back()" style="cursor: pointer">
|
||||||
<ElIcon><ArrowLeft /></ElIcon>
|
<ElIcon><ArrowLeft /></ElIcon>
|
||||||
@@ -215,9 +226,10 @@ const backDoc = () => {
|
|||||||
<div class="doc-content">
|
<div class="doc-content">
|
||||||
<div
|
<div
|
||||||
class="doc-table-content menu-container border border-[var(--el-border-color)]"
|
class="doc-table-content menu-container border border-[var(--el-border-color)]"
|
||||||
|
:class="{ 'doc-table-content--process': panelMode === 'process' }"
|
||||||
>
|
>
|
||||||
<div v-if="selectedCategory === 'documentList'" class="doc-table">
|
<div v-if="selectedCategory === 'documentList'" class="doc-table">
|
||||||
<div class="doc-header" v-if="!viewDocVisible">
|
<div class="doc-header" v-if="panelMode === 'list'">
|
||||||
<HeaderSearch
|
<HeaderSearch
|
||||||
v-if="!isFaqCollection"
|
v-if="!isFaqCollection"
|
||||||
:buttons="canManageCurrentKnowledge ? headerButtons : []"
|
:buttons="canManageCurrentKnowledge ? headerButtons : []"
|
||||||
@@ -225,20 +237,38 @@ const backDoc = () => {
|
|||||||
@button-click="handleButtonClick"
|
@button-click="handleButtonClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="panelMode === 'chunk'" class="doc-sub-back">
|
||||||
|
<ElButton @click="backDoc">
|
||||||
|
{{ $t('button.back') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
<DocumentTable
|
<DocumentTable
|
||||||
|
v-if="panelMode === 'list'"
|
||||||
ref="documentTableRef"
|
ref="documentTableRef"
|
||||||
:knowledge-id="knowledgeId"
|
:knowledge-id="knowledgeId"
|
||||||
:manageable="canManageCurrentKnowledge"
|
:permissions="{
|
||||||
|
canCreateContent: canManageCurrentKnowledge,
|
||||||
|
canDeleteContent: canManageCurrentKnowledge,
|
||||||
|
canDownloadContent: true,
|
||||||
|
}"
|
||||||
|
@continue-process="continueProcess"
|
||||||
@view-doc="viewDoc"
|
@view-doc="viewDoc"
|
||||||
v-if="!viewDocVisible"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ChunkDocumentTable
|
<ChunkDocumentTable
|
||||||
v-else
|
v-else-if="panelMode === 'chunk'"
|
||||||
:document-id="documentId"
|
:document-id="documentId"
|
||||||
:manageable="canManageCurrentKnowledge"
|
:manageable="canManageCurrentKnowledge"
|
||||||
:default-summary-prompt="knowledgeInfo.summaryPrompt"
|
:default-summary-prompt="knowledgeInfo.summaryPrompt"
|
||||||
/>
|
/>
|
||||||
|
<SegmenterDoc
|
||||||
|
v-else-if="panelMode === 'process'"
|
||||||
|
:knowledge-id="String(knowledgeId)"
|
||||||
|
:document-id="documentId"
|
||||||
|
:document-title="documentTitle"
|
||||||
|
@cancel="backDoc"
|
||||||
|
@started="backDoc"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedCategory === 'faqList'" class="doc-table">
|
<div v-if="selectedCategory === 'faqList'" class="doc-table">
|
||||||
<FaqTable
|
<FaqTable
|
||||||
@@ -251,11 +281,7 @@ const backDoc = () => {
|
|||||||
v-if="selectedCategory === 'knowledgeSearch'"
|
v-if="selectedCategory === 'knowledgeSearch'"
|
||||||
class="doc-search-container"
|
class="doc-search-container"
|
||||||
>
|
>
|
||||||
<KnowledgeSearchConfig
|
<KnowledgeSearch :knowledge-id="knowledgeId" :show-config="true" />
|
||||||
:document-collection-id="knowledgeId"
|
|
||||||
:manageable="canManageCurrentKnowledge"
|
|
||||||
/>
|
|
||||||
<KnowledgeSearch :knowledge-id="knowledgeId" />
|
|
||||||
</div>
|
</div>
|
||||||
<!--配置-->
|
<!--配置-->
|
||||||
<div v-if="selectedCategory === 'config'">
|
<div v-if="selectedCategory === 'config'">
|
||||||
@@ -275,9 +301,11 @@ const backDoc = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="doc-imp-container">
|
<ImportKnowledgeDocFile
|
||||||
<ImportKnowledgeDocFile @import-back="backDoc" />
|
ref="importDocModalRef"
|
||||||
</div>
|
:knowledge-id-prop="String(knowledgeId)"
|
||||||
|
@imported="backDoc"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -306,6 +334,12 @@ const backDoc = () => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doc-table-content--process {
|
||||||
|
padding: 18px 20px 0;
|
||||||
|
border-color: rgb(15 23 42 / 6%);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.doc-header {
|
.doc-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 21px;
|
padding-bottom: 21px;
|
||||||
@@ -378,10 +412,8 @@ const backDoc = () => {
|
|||||||
background-color: var(--el-bg-color);
|
background-color: var(--el-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-imp-container {
|
.doc-sub-back {
|
||||||
box-sizing: border-box;
|
padding: 0 6px 16px;
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.doc-header-container {
|
.doc-header-container {
|
||||||
@@ -428,6 +460,8 @@ const backDoc = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-container {
|
.menu-container {
|
||||||
|
|||||||
@@ -1,36 +1,65 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
import { downloadFileFromBlob } from '@easyflow/utils';
|
import { downloadFileFromBlob } from '@easyflow/utils';
|
||||||
|
|
||||||
import { Delete, Download, MoreFilled } from '@element-plus/icons-vue';
|
import {
|
||||||
|
CloseBold,
|
||||||
|
Delete,
|
||||||
|
Download,
|
||||||
|
Files,
|
||||||
|
Loading,
|
||||||
|
Opportunity,
|
||||||
|
Select,
|
||||||
|
UploadFilled,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
ElDropdown,
|
ElIcon,
|
||||||
ElDropdownItem,
|
|
||||||
ElDropdownMenu,
|
|
||||||
ElImage,
|
ElImage,
|
||||||
ElMessage,
|
ElMessage,
|
||||||
ElMessageBox,
|
ElMessageBox,
|
||||||
|
ElProgress,
|
||||||
ElTable,
|
ElTable,
|
||||||
ElTableColumn,
|
ElTableColumn,
|
||||||
|
ElTooltip,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { buildKnowledgeShareUrl } from '#/api/knowledge-share';
|
||||||
|
import { api, SseClient } from '#/api/request';
|
||||||
import documentIcon from '#/assets/ai/knowledge/document.svg';
|
import documentIcon from '#/assets/ai/knowledge/document.svg';
|
||||||
import PageData from '#/components/page/PageData.vue';
|
import PageData from '#/components/page/PageData.vue';
|
||||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
|
|
||||||
|
interface DocumentStatusPayload {
|
||||||
|
completedChunks?: number;
|
||||||
|
documentId?: number | string;
|
||||||
|
failedChunks?: number;
|
||||||
|
knowledgeId?: number | string;
|
||||||
|
lastTaskError?: string;
|
||||||
|
processStatus?: string;
|
||||||
|
progressPercent?: number;
|
||||||
|
taskModifiedAt?: string;
|
||||||
|
totalChunks?: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentTablePermissions {
|
||||||
|
canCreateContent?: boolean;
|
||||||
|
canDeleteContent?: boolean;
|
||||||
|
canDownloadContent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionKey = keyof DocumentTablePermissions;
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
knowledgeId: {
|
knowledgeId: {
|
||||||
required: true,
|
required: true,
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
manageable: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
requestClient: {
|
requestClient: {
|
||||||
type: Object as any,
|
type: Object as any,
|
||||||
default: () => api,
|
default: () => api,
|
||||||
@@ -39,19 +68,283 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
permissions: {
|
||||||
|
type: Object as () => DocumentTablePermissions,
|
||||||
|
default: () => ({
|
||||||
|
canCreateContent: true,
|
||||||
|
canDeleteContent: true,
|
||||||
|
canDownloadContent: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const emits = defineEmits(['viewDoc']);
|
|
||||||
|
const emits = defineEmits(['viewDoc', 'continueProcess']);
|
||||||
|
|
||||||
|
const STREAM_RECONNECT_DELAY = 1500;
|
||||||
|
const STREAM_RELOAD_DELAY = 250;
|
||||||
|
|
||||||
|
const pageDataRef = ref();
|
||||||
|
const taskStatusStreamClient = new SseClient();
|
||||||
|
let reconnectTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
let reloadTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
reload() {
|
||||||
|
pageDataRef.value?.reload?.();
|
||||||
|
},
|
||||||
search(searchText: string) {
|
search(searchText: string) {
|
||||||
pageDataRef.value.setQuery({
|
pageDataRef.value?.setQuery?.({
|
||||||
title: searchText,
|
title: searchText,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const pageDataRef = ref();
|
|
||||||
|
const processingStatuses = new Set(['INDEXING', 'PARSING']);
|
||||||
|
|
||||||
|
const isProcessingStatus = (status?: string) =>
|
||||||
|
processingStatuses.has(status || '');
|
||||||
|
|
||||||
|
const resolvedPermissions = computed(() => ({
|
||||||
|
canCreateContent: props.permissions?.canCreateContent ?? true,
|
||||||
|
canDeleteContent: props.permissions?.canDeleteContent ?? true,
|
||||||
|
canDownloadContent: props.permissions?.canDownloadContent ?? true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasPermission = (key: PermissionKey) => Boolean(resolvedPermissions.value[key]);
|
||||||
|
|
||||||
|
const statusMetaMap: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
icon: Component;
|
||||||
|
toneClass: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
COMPLETED: {
|
||||||
|
icon: Select,
|
||||||
|
toneClass: 'status-pill--success',
|
||||||
|
},
|
||||||
|
INDEX_FAILED: {
|
||||||
|
icon: CloseBold,
|
||||||
|
toneClass: 'status-pill--danger',
|
||||||
|
},
|
||||||
|
INDEXING: {
|
||||||
|
icon: Loading,
|
||||||
|
toneClass: 'status-pill--warning',
|
||||||
|
},
|
||||||
|
PARSE_FAILED: {
|
||||||
|
icon: CloseBold,
|
||||||
|
toneClass: 'status-pill--danger',
|
||||||
|
},
|
||||||
|
PARSING: {
|
||||||
|
icon: Loading,
|
||||||
|
toneClass: 'status-pill--warning',
|
||||||
|
},
|
||||||
|
READY_FOR_INDEX: {
|
||||||
|
icon: Opportunity,
|
||||||
|
toneClass: 'status-pill--primary',
|
||||||
|
},
|
||||||
|
READY_FOR_SEGMENT: {
|
||||||
|
icon: Files,
|
||||||
|
toneClass: 'status-pill--primary',
|
||||||
|
},
|
||||||
|
UPLOADED: {
|
||||||
|
icon: UploadFilled,
|
||||||
|
toneClass: 'status-pill--info',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status?: string) =>
|
||||||
|
$t(`documentCollection.taskStatus.${status || 'UPLOADED'}`);
|
||||||
|
|
||||||
|
const getStatusMeta = (status?: string) =>
|
||||||
|
statusMetaMap[status || 'UPLOADED'] || statusMetaMap.UPLOADED;
|
||||||
|
|
||||||
|
const getChunkCount = (row: any) => {
|
||||||
|
const totalChunks = Number(row.totalChunks || 0);
|
||||||
|
if (totalChunks > 0) {
|
||||||
|
return totalChunks;
|
||||||
|
}
|
||||||
|
return Number(row.chunkCount || 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressText = (row: any) => {
|
||||||
|
const completed = Number(row.completedChunks || 0);
|
||||||
|
const total = Number(row.totalChunks || 0);
|
||||||
|
if (total <= 0) {
|
||||||
|
return `${Number(row.progressPercent || 0)}%`;
|
||||||
|
}
|
||||||
|
return `${Number(row.progressPercent || 0)}% · ${completed}/${total}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReconnectTimer = () => {
|
||||||
|
if (!reconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReloadTimer = () => {
|
||||||
|
if (!reloadTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(reloadTimer);
|
||||||
|
reloadTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleReload = () => {
|
||||||
|
if (reloadTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reloadTimer = setTimeout(() => {
|
||||||
|
reloadTimer = null;
|
||||||
|
pageDataRef.value?.reload?.();
|
||||||
|
}, STREAM_RELOAD_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchDocumentRow = (payload: DocumentStatusPayload) => {
|
||||||
|
if (!payload.documentId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nextPatch: Record<string, any> = {
|
||||||
|
completedChunks: payload.completedChunks,
|
||||||
|
failedChunks: payload.failedChunks,
|
||||||
|
lastTaskError: payload.lastTaskError,
|
||||||
|
processStatus: payload.processStatus,
|
||||||
|
progressPercent: payload.progressPercent,
|
||||||
|
taskModifiedAt: payload.taskModifiedAt,
|
||||||
|
totalChunks: payload.totalChunks,
|
||||||
|
};
|
||||||
|
if (payload.taskModifiedAt) {
|
||||||
|
nextPatch.modified = payload.taskModifiedAt;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
pageDataRef.value?.patchRowById?.(payload.documentId, nextPatch) ?? false
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildTaskStreamUrl = () => {
|
||||||
|
const path = buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/document/import/task/stream',
|
||||||
|
);
|
||||||
|
return props.endpointPrefix ? buildKnowledgeShareUrl(path) : path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleStreamReconnect = () => {
|
||||||
|
if (disposed || reconnectTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null;
|
||||||
|
openTaskStatusStream();
|
||||||
|
}, STREAM_RECONNECT_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTaskStatusMessage = (message: {
|
||||||
|
data?: string;
|
||||||
|
event?: string;
|
||||||
|
}) => {
|
||||||
|
if (!message?.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.event && message.event !== 'document-status') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(message.data) as DocumentStatusPayload;
|
||||||
|
if (payload?.type !== 'document-status') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (String(payload.knowledgeId || '') !== String(props.knowledgeId || '')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (patchDocumentRow(payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 文档状态流约定始终返回 JSON;异常负载直接忽略,避免误触发列表刷新。
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleReload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTaskStatusStream = async () => {
|
||||||
|
if (!props.knowledgeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
taskStatusStreamClient.abort();
|
||||||
|
clearReconnectTimer();
|
||||||
|
await taskStatusStreamClient.post(
|
||||||
|
buildTaskStreamUrl(),
|
||||||
|
{
|
||||||
|
knowledgeId: props.knowledgeId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onMessage: handleTaskStatusMessage,
|
||||||
|
onError: () => {
|
||||||
|
if (!disposed) {
|
||||||
|
scheduleStreamReconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFinished: () => {
|
||||||
|
if (!disposed) {
|
||||||
|
scheduleStreamReconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePermission = (key: PermissionKey) => {
|
||||||
|
if (hasPermission(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestTaskAction = async (
|
||||||
|
path: string,
|
||||||
|
payload: Record<string, any>,
|
||||||
|
successMessage: string,
|
||||||
|
) => {
|
||||||
|
if (!ensurePermission('canCreateContent')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(props.endpointPrefix, path),
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success(successMessage);
|
||||||
|
pageDataRef.value?.reload?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinue = (row: any) => {
|
||||||
|
if (!ensurePermission('canCreateContent')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emits('continueProcess', row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetryParse = async (row: any) => {
|
||||||
|
await requestTaskAction(
|
||||||
|
'/api/v1/document/import/task/retryParse',
|
||||||
|
{
|
||||||
|
knowledgeId: props.knowledgeId,
|
||||||
|
documentId: row.id,
|
||||||
|
},
|
||||||
|
getStatusLabel('PARSING'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleView = (row: any) => {
|
const handleView = (row: any) => {
|
||||||
emits('viewDoc', row.id);
|
emits('viewDoc', row.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (row: any) => {
|
const handleDownload = async (row: any) => {
|
||||||
const blob = await props.requestClient.download(
|
const blob = await props.requestClient.download(
|
||||||
buildKnowledgePath(
|
buildKnowledgePath(
|
||||||
@@ -64,30 +357,108 @@ const handleDownload = async (row: any) => {
|
|||||||
source: blob,
|
source: blob,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (row: any) => {
|
const handleDelete = (row: any) => {
|
||||||
if (!props.manageable) {
|
if (!ensurePermission('canDeleteContent')) {
|
||||||
ElMessage.warning($t('documentCollection.managePermissionHint'));
|
return;
|
||||||
|
}
|
||||||
|
if (processingStatuses.has(row.processStatus)) {
|
||||||
|
ElMessage.warning($t('documentCollection.processingDeleteBlocked'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||||
confirmButtonText: $t('button.confirm'),
|
confirmButtonText: $t('button.confirm'),
|
||||||
cancelButtonText: $t('button.cancel'),
|
cancelButtonText: $t('button.cancel'),
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
}).then(() => {
|
}).then(async () => {
|
||||||
props.requestClient
|
const res = await props.requestClient.post(
|
||||||
.post(
|
|
||||||
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
buildKnowledgePath(props.endpointPrefix, '/api/v1/document/removeDoc'),
|
||||||
{ id: row.id },
|
{ id: row.id },
|
||||||
)
|
);
|
||||||
.then((res: any) => {
|
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.deleteOkMessage'));
|
ElMessage.success($t('message.deleteOkMessage'));
|
||||||
pageDataRef.value.setQuery({ id: props.knowledgeId });
|
pageDataRef.value?.reload?.();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 删除逻辑
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const primaryActionConfigs: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
handler: (row: any) => void;
|
||||||
|
label: () => string;
|
||||||
|
permission?: PermissionKey;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
COMPLETED: {
|
||||||
|
handler: handleView,
|
||||||
|
label: () => $t('button.viewSegmentation'),
|
||||||
|
},
|
||||||
|
INDEX_FAILED: {
|
||||||
|
handler: handleContinue,
|
||||||
|
label: () => $t('button.continueProcess'),
|
||||||
|
permission: 'canCreateContent',
|
||||||
|
},
|
||||||
|
PARSE_FAILED: {
|
||||||
|
handler: handleRetryParse,
|
||||||
|
label: () => $t('button.retryParse'),
|
||||||
|
permission: 'canCreateContent',
|
||||||
|
},
|
||||||
|
READY_FOR_INDEX: {
|
||||||
|
handler: handleContinue,
|
||||||
|
label: () => $t('button.continueProcess'),
|
||||||
|
permission: 'canCreateContent',
|
||||||
|
},
|
||||||
|
READY_FOR_SEGMENT: {
|
||||||
|
handler: handleContinue,
|
||||||
|
label: () => $t('button.continueProcess'),
|
||||||
|
permission: 'canCreateContent',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPrimaryActionLabel = (row: any) => {
|
||||||
|
const config = primaryActionConfigs[row.processStatus || ''];
|
||||||
|
if (!config) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (config.permission && !hasPermission(config.permission)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return config.label();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrimaryAction = (row: any) => {
|
||||||
|
const config = primaryActionConfigs[row.processStatus || ''];
|
||||||
|
if (!config) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (config.permission && !hasPermission(config.permission)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
config.handler(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
disposed = false;
|
||||||
|
openTaskStatusStream();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true;
|
||||||
|
clearReconnectTimer();
|
||||||
|
clearReloadTimer();
|
||||||
|
taskStatusStreamClient.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => `${props.endpointPrefix}:${props.knowledgeId}`,
|
||||||
|
() => {
|
||||||
|
if (disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openTaskStatusStream();
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -109,6 +480,9 @@ const handleDelete = (row: any) => {
|
|||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
prop="fileName"
|
prop="fileName"
|
||||||
:label="$t('documentCollection.fileName')"
|
:label="$t('documentCollection.fileName')"
|
||||||
|
min-width="220"
|
||||||
|
align="left"
|
||||||
|
header-align="left"
|
||||||
>
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span class="file-name-container">
|
<span class="file-name-container">
|
||||||
@@ -119,19 +493,23 @@ const handleDelete = (row: any) => {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
prop="documentType"
|
:label="$t('documentCollection.chunkCount')"
|
||||||
:label="$t('documentCollection.documentType')"
|
width="96"
|
||||||
width="180"
|
align="left"
|
||||||
/>
|
header-align="left"
|
||||||
<ElTableColumn
|
>
|
||||||
prop="chunkCount"
|
<template #default="{ row }">
|
||||||
:label="$t('documentCollection.knowledgeCount')"
|
{{ getChunkCount(row) }}
|
||||||
width="180"
|
</template>
|
||||||
/>
|
</ElTableColumn>
|
||||||
|
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
:label="$t('documentCollection.createdModifyTime')"
|
:label="$t('documentCollection.createdModifyTime')"
|
||||||
width="200"
|
width="176"
|
||||||
|
align="left"
|
||||||
|
header-align="left"
|
||||||
>
|
>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="time-container">
|
<div class="time-container">
|
||||||
@@ -140,34 +518,101 @@ const handleDelete = (row: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn :label="$t('common.handle')" width="120" align="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<ElButton link type="primary" @click="handleView(row)">
|
|
||||||
{{ $t('button.viewSegmentation') }}
|
|
||||||
</ElButton>
|
|
||||||
|
|
||||||
<ElDropdown>
|
<ElTableColumn
|
||||||
<ElButton link :icon="MoreFilled" />
|
:label="$t('documentCollection.processStatus')"
|
||||||
|
min-width="156"
|
||||||
<template #dropdown>
|
align="left"
|
||||||
<ElDropdownMenu>
|
header-align="left"
|
||||||
<ElDropdownItem @click="handleDownload(row)">
|
|
||||||
<ElButton link :icon="Download">
|
|
||||||
{{ $t('button.download') }}
|
|
||||||
</ElButton>
|
|
||||||
</ElDropdownItem>
|
|
||||||
<ElDropdownItem
|
|
||||||
v-if="props.manageable"
|
|
||||||
@click="handleDelete(row)"
|
|
||||||
>
|
>
|
||||||
<ElButton link :icon="Delete" type="danger">
|
<template #default="{ row }">
|
||||||
{{ $t('button.delete') }}
|
<div class="status-cell">
|
||||||
</ElButton>
|
<div
|
||||||
</ElDropdownItem>
|
class="status-pill"
|
||||||
</ElDropdownMenu>
|
:class="getStatusMeta(row.processStatus).toneClass"
|
||||||
|
>
|
||||||
|
<span class="status-pill__icon-shell">
|
||||||
|
<ElIcon
|
||||||
|
class="status-pill__icon"
|
||||||
|
:class="
|
||||||
|
isProcessingStatus(row.processStatus)
|
||||||
|
? 'status-pill__icon--spinning'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<component :is="getStatusMeta(row.processStatus).icon" />
|
||||||
|
</ElIcon>
|
||||||
|
</span>
|
||||||
|
<span class="status-pill__label">
|
||||||
|
{{ getStatusLabel(row.processStatus) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="row.processStatus === 'INDEXING'"
|
||||||
|
class="status-progress"
|
||||||
|
>
|
||||||
|
<ElProgress
|
||||||
|
:percentage="Number(row.progressPercent || 0)"
|
||||||
|
:stroke-width="8"
|
||||||
|
/>
|
||||||
|
<span class="status-progress__text">
|
||||||
|
{{ getProgressText(row) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="row.lastTaskError"
|
||||||
|
class="status-error"
|
||||||
|
:title="row.lastTaskError"
|
||||||
|
>
|
||||||
|
{{ row.lastTaskError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElDropdown>
|
</ElTableColumn>
|
||||||
|
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('common.handle')"
|
||||||
|
width="148"
|
||||||
|
align="left"
|
||||||
|
header-align="left"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="action-cell">
|
||||||
|
<ElButton
|
||||||
|
v-if="getPrimaryActionLabel(row)"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="handlePrimaryAction(row)"
|
||||||
|
>
|
||||||
|
{{ getPrimaryActionLabel(row) }}
|
||||||
|
</ElButton>
|
||||||
|
|
||||||
|
<ElTooltip
|
||||||
|
v-if="hasPermission('canDownloadContent')"
|
||||||
|
:content="$t('button.download')"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="Download"
|
||||||
|
:aria-label="$t('button.download')"
|
||||||
|
@click="handleDownload(row)"
|
||||||
|
/>
|
||||||
|
</ElTooltip>
|
||||||
|
|
||||||
|
<ElTooltip
|
||||||
|
v-if="hasPermission('canDeleteContent')"
|
||||||
|
:content="$t('button.delete')"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:icon="Delete"
|
||||||
|
:aria-label="$t('button.delete')"
|
||||||
|
@click="handleDelete(row)"
|
||||||
|
/>
|
||||||
|
</ElTooltip>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
@@ -180,21 +625,166 @@ const handleDelete = (row: any) => {
|
|||||||
.time-container {
|
.time-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 4px;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name-container {
|
.file-name-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: #1a1a1a;
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: min(168px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-progress__text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-transform: none;
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
max-width: 176px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px 12px 4px 8px;
|
||||||
|
border: 1px solid var(--status-pill-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--status-pill-bg);
|
||||||
|
color: var(--status-pill-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill__icon-shell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--status-pill-icon-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--status-pill-icon-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill__icon--spinning {
|
||||||
|
animation: status-spin 1.15s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--primary {
|
||||||
|
--status-pill-bg: var(--el-color-primary-light-9);
|
||||||
|
--status-pill-border: var(--el-color-primary-light-7);
|
||||||
|
--status-pill-icon-bg: var(--el-color-primary-light-8);
|
||||||
|
--status-pill-icon-color: var(--el-color-primary);
|
||||||
|
--status-pill-text: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--success {
|
||||||
|
--status-pill-bg: var(--el-color-success-light-9);
|
||||||
|
--status-pill-border: var(--el-color-success-light-7);
|
||||||
|
--status-pill-icon-bg: var(--el-color-success-light-8);
|
||||||
|
--status-pill-icon-color: var(--el-color-success);
|
||||||
|
--status-pill-text: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--warning {
|
||||||
|
--status-pill-bg: var(--el-color-warning-light-9);
|
||||||
|
--status-pill-border: var(--el-color-warning-light-7);
|
||||||
|
--status-pill-icon-bg: var(--el-color-warning-light-8);
|
||||||
|
--status-pill-icon-color: var(--el-color-warning);
|
||||||
|
--status-pill-text: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--danger {
|
||||||
|
--status-pill-bg: var(--el-color-danger-light-9);
|
||||||
|
--status-pill-border: var(--el-color-danger-light-7);
|
||||||
|
--status-pill-icon-bg: var(--el-color-danger-light-8);
|
||||||
|
--status-pill-icon-color: var(--el-color-danger);
|
||||||
|
--status-pill-text: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--info {
|
||||||
|
--status-pill-bg: var(--el-fill-color-light);
|
||||||
|
--status-pill-border: var(--el-border-color-light);
|
||||||
|
--status-pill-icon-bg: var(--el-fill-color);
|
||||||
|
--status-pill-icon-color: var(--el-text-color-secondary);
|
||||||
|
--status-pill-text: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cell {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cell :deep(.el-button) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cell :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes status-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,35 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import { Back } from '@element-plus/icons-vue';
|
import { ElMessage } from 'element-plus';
|
||||||
import { ElButton, ElMessage, ElStep, ElSteps } from 'element-plus';
|
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import ComfirmImportDocument from '#/views/ai/documentCollection/ComfirmImportDocument.vue';
|
|
||||||
import ImportKnowledgeFileContainer from '#/views/ai/documentCollection/ImportKnowledgeFileContainer.vue';
|
import ImportKnowledgeFileContainer from '#/views/ai/documentCollection/ImportKnowledgeFileContainer.vue';
|
||||||
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
|
||||||
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
|
|
||||||
|
|
||||||
interface UploadFileItem {
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AnalyzeItem {
|
|
||||||
fileName: string;
|
|
||||||
filePath: string;
|
|
||||||
strategyConfig: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreviewItem {
|
|
||||||
fileName: string;
|
|
||||||
previewSessionId: string;
|
|
||||||
totalChunks?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
requestClient: {
|
requestClient: {
|
||||||
@@ -46,283 +26,165 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(['importBack']);
|
const emits = defineEmits(['imported']);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const fileUploadRef = ref<InstanceType<typeof ImportKnowledgeFileContainer>>();
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
const knowledgeId = computed(
|
const knowledgeId = computed(
|
||||||
() => props.knowledgeIdProp || (route.query.id as string) || '',
|
() => props.knowledgeIdProp || (route.query.id as string) || '',
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileUploadRef = ref<InstanceType<typeof ImportKnowledgeFileContainer>>();
|
const resetDialogState = () => {
|
||||||
const segmenterDocRef = ref<InstanceType<typeof SegmenterDoc>>();
|
fileUploadRef.value?.reset?.();
|
||||||
|
};
|
||||||
|
|
||||||
const activeStep = ref(0);
|
const closeDialog = () => {
|
||||||
const files = ref<UploadFileItem[]>([]);
|
if (submitting.value) {
|
||||||
const analysisItems = ref<AnalyzeItem[]>([]);
|
return false;
|
||||||
const previewItems = ref<PreviewItem[]>([]);
|
|
||||||
const commitResults = ref<any[]>([]);
|
|
||||||
|
|
||||||
const analyzing = ref(false);
|
|
||||||
const previewing = ref(false);
|
|
||||||
const committing = ref(false);
|
|
||||||
let autoBackTimer: null | ReturnType<typeof setTimeout> = null;
|
|
||||||
|
|
||||||
const canGoPrevious = computed(() => activeStep.value > 0 && !committing.value);
|
|
||||||
|
|
||||||
function back() {
|
|
||||||
emits('importBack');
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleBackAfterSuccess() {
|
|
||||||
if (autoBackTimer) {
|
|
||||||
clearTimeout(autoBackTimer);
|
|
||||||
}
|
}
|
||||||
autoBackTimer = setTimeout(() => {
|
dialogVisible.value = false;
|
||||||
back();
|
resetDialogState();
|
||||||
}, 300);
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
function getUploadedFiles() {
|
const openDialog = () => {
|
||||||
return fileUploadRef.value?.getFilesData?.() || [];
|
resetDialogState();
|
||||||
}
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
async function goToNextStep() {
|
const createTasks = async () => {
|
||||||
if (activeStep.value === 0) {
|
const files = fileUploadRef.value?.getFilesData?.() || [];
|
||||||
const currentFiles = getUploadedFiles();
|
if (files.length === 0) {
|
||||||
if (currentFiles.length === 0) {
|
|
||||||
ElMessage.error($t('message.uploadFileFirst'));
|
ElMessage.error($t('message.uploadFileFirst'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
files.value = currentFiles;
|
|
||||||
const analyzed = await runAnalyze();
|
|
||||||
if (analyzed) {
|
|
||||||
activeStep.value = 1;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeStep.value === 1) {
|
submitting.value = true;
|
||||||
const previewed = await runPreview();
|
let successCount = 0;
|
||||||
if (previewed) {
|
let failedCount = 0;
|
||||||
activeStep.value = 2;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeStep.value === 2) {
|
|
||||||
activeStep.value = 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPreviousStep() {
|
|
||||||
if (!canGoPrevious.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
activeStep.value -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAnalyze() {
|
|
||||||
analyzing.value = true;
|
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, any> = {
|
for (const file of files) {
|
||||||
files: files.value.map((item) => ({
|
try {
|
||||||
fileName: item.fileName,
|
|
||||||
filePath: item.filePath,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
if (knowledgeId.value) {
|
|
||||||
payload.knowledgeId = knowledgeId.value;
|
|
||||||
}
|
|
||||||
const res = await props.requestClient.post(
|
const res = await props.requestClient.post(
|
||||||
buildKnowledgePath(
|
buildKnowledgePath(
|
||||||
props.endpointPrefix,
|
props.endpointPrefix,
|
||||||
'/api/v1/document/import/analyze',
|
'/api/v1/document/import/task/create',
|
||||||
),
|
),
|
||||||
payload,
|
{
|
||||||
|
knowledgeId: knowledgeId.value,
|
||||||
|
fileName: file.fileName,
|
||||||
|
filePath: file.filePath,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
analysisItems.value = res.data?.items || [];
|
if (res.errorCode === 0) {
|
||||||
return true;
|
successCount += 1;
|
||||||
} finally {
|
} else {
|
||||||
analyzing.value = false;
|
failedCount += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failedCount += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function runPreview() {
|
if (successCount > 0) {
|
||||||
const previewRequestItems =
|
ElMessage.success(
|
||||||
segmenterDocRef.value?.getPreviewRequestItems?.() || [];
|
failedCount > 0
|
||||||
if (previewRequestItems.length === 0) {
|
? $t('documentCollection.importDoc.partialCreateSuccess')
|
||||||
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
|
: $t('documentCollection.importDoc.createSuccess'),
|
||||||
return false;
|
|
||||||
}
|
|
||||||
previewing.value = true;
|
|
||||||
try {
|
|
||||||
const payload: Record<string, any> = {
|
|
||||||
files: previewRequestItems,
|
|
||||||
};
|
|
||||||
if (knowledgeId.value) {
|
|
||||||
payload.knowledgeId = knowledgeId.value;
|
|
||||||
}
|
|
||||||
const res = await props.requestClient.post(
|
|
||||||
buildKnowledgePath(
|
|
||||||
props.endpointPrefix,
|
|
||||||
'/api/v1/document/import/preview',
|
|
||||||
),
|
|
||||||
payload,
|
|
||||||
);
|
);
|
||||||
previewItems.value = res.data?.items || [];
|
dialogVisible.value = false;
|
||||||
commitResults.value = [];
|
resetDialogState();
|
||||||
return true;
|
emits('imported');
|
||||||
} finally {
|
|
||||||
previewing.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmImport() {
|
|
||||||
if (previewItems.value.length === 0) {
|
|
||||||
ElMessage.error($t('documentCollection.importDoc.previewEmpty'));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
committing.value = true;
|
|
||||||
try {
|
|
||||||
const payload: Record<string, any> = {
|
|
||||||
previewSessionIds: previewItems.value.map(
|
|
||||||
(item) => item.previewSessionId,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
if (knowledgeId.value) {
|
|
||||||
payload.knowledgeId = knowledgeId.value;
|
|
||||||
}
|
|
||||||
const res = await props.requestClient.post(
|
|
||||||
buildKnowledgePath(
|
|
||||||
props.endpointPrefix,
|
|
||||||
'/api/v1/document/import/commit',
|
|
||||||
),
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
commitResults.value = res.data?.results || [];
|
|
||||||
if ((res.data?.errorCount || 0) === 0) {
|
|
||||||
ElMessage.success($t('documentCollection.splitterDoc.importSuccess'));
|
|
||||||
scheduleBackAfterSuccess();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
committing.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
ElMessage.error($t('documentCollection.importDoc.importFailed'));
|
||||||
if (autoBackTimer) {
|
} finally {
|
||||||
clearTimeout(autoBackTimer);
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
closeDialog,
|
||||||
|
openDialog,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="imp-doc-kno-container">
|
<EasyFlowFormModal
|
||||||
<div class="imp-doc-header">
|
v-model:open="dialogVisible"
|
||||||
<ElButton :icon="Back" @click="back">
|
:title="$t('button.importFile')"
|
||||||
{{ $t('button.back') }}
|
:before-close="closeDialog"
|
||||||
</ElButton>
|
:closable="!submitting"
|
||||||
</div>
|
:centered="true"
|
||||||
|
:confirm-loading="submitting"
|
||||||
<div class="imp-doc-kno-content">
|
:confirm-text="$t('button.importFile')"
|
||||||
<div class="step-card">
|
:submitting="submitting"
|
||||||
<ElSteps :active="activeStep" align-center>
|
width="xl"
|
||||||
<ElStep :title="$t('documentCollection.importDoc.fileUpload')" />
|
@confirm="createTasks"
|
||||||
<ElStep
|
|
||||||
:title="$t('documentCollection.importDoc.strategyAnalysis')"
|
|
||||||
/>
|
|
||||||
<ElStep
|
|
||||||
:title="$t('documentCollection.importDoc.segmentedPreview')"
|
|
||||||
/>
|
|
||||||
<ElStep :title="$t('documentCollection.importDoc.confirmImport')" />
|
|
||||||
</ElSteps>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="step-body">
|
|
||||||
<ImportKnowledgeFileContainer
|
|
||||||
v-if="activeStep === 0"
|
|
||||||
ref="fileUploadRef"
|
|
||||||
/>
|
|
||||||
<SegmenterDoc
|
|
||||||
v-else-if="activeStep === 1"
|
|
||||||
ref="segmenterDocRef"
|
|
||||||
:analysis-items="analysisItems"
|
|
||||||
/>
|
|
||||||
<SplitterDocPreview
|
|
||||||
v-else-if="activeStep === 2"
|
|
||||||
:preview-items="previewItems"
|
|
||||||
/>
|
|
||||||
<ComfirmImportDocument
|
|
||||||
v-else
|
|
||||||
:preview-items="previewItems"
|
|
||||||
:commit-results="commitResults"
|
|
||||||
:loading="committing"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="imp-doc-footer">
|
|
||||||
<ElButton v-if="canGoPrevious" @click="goToPreviousStep">
|
|
||||||
{{ $t('button.previousStep') }}
|
|
||||||
</ElButton>
|
|
||||||
<ElButton
|
|
||||||
v-if="activeStep < 3"
|
|
||||||
type="primary"
|
|
||||||
:loading="analyzing || previewing"
|
|
||||||
@click="goToNextStep"
|
|
||||||
>
|
>
|
||||||
{{ $t('button.nextStep') }}
|
<div class="import-dialog">
|
||||||
</ElButton>
|
<p class="import-dialog__tip">
|
||||||
<ElButton
|
{{ $t('documentCollection.importDoc.uploadCreateTip') }}
|
||||||
v-else
|
</p>
|
||||||
type="primary"
|
|
||||||
:loading="committing"
|
<ImportKnowledgeFileContainer ref="fileUploadRef" />
|
||||||
:disabled="committing"
|
|
||||||
@click="confirmImport"
|
|
||||||
>
|
|
||||||
{{ $t('button.startImport') }}
|
|
||||||
</ElButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</EasyFlowFormModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.imp-doc-kno-container {
|
.import-dialog {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
|
||||||
padding: 24px;
|
|
||||||
background: var(--el-bg-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imp-doc-kno-content {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 20px;
|
|
||||||
padding-top: 16px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-card {
|
|
||||||
padding: 20px 24px;
|
|
||||||
background: var(--el-fill-color-blank);
|
|
||||||
border: 1px solid var(--el-border-color-light);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.step-body {
|
|
||||||
flex: 1;
|
|
||||||
padding-bottom: 72px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.imp-doc-footer {
|
|
||||||
position: absolute;
|
|
||||||
right: 24px;
|
|
||||||
bottom: 24px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
}
|
||||||
|
|
||||||
|
.import-dialog__tip {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.upload-demo) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.upload-demo .el-upload) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.upload-demo .el-upload-dragger) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.upload-demo .el-icon) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table) {
|
||||||
|
--el-table-border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table__inner-wrapper::before) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table th.el-table__cell) {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-table td.el-table__cell) {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -18,10 +18,16 @@ interface FileInfo {
|
|||||||
}
|
}
|
||||||
const fileData = ref<FileInfo[]>([]);
|
const fileData = ref<FileInfo[]>([]);
|
||||||
const filesPath = ref([]);
|
const filesPath = ref([]);
|
||||||
|
const dragUploadRef = ref<InstanceType<typeof DragFileUpload>>();
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getFilesData() {
|
getFilesData() {
|
||||||
return fileData.value.filter((item) => item.filePath);
|
return fileData.value.filter((item) => item.filePath);
|
||||||
},
|
},
|
||||||
|
reset() {
|
||||||
|
fileData.value = [];
|
||||||
|
filesPath.value = [];
|
||||||
|
dragUploadRef.value?.clearFiles?.();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
function handleSuccess(response: any) {
|
function handleSuccess(response: any) {
|
||||||
filesPath.value = response.data;
|
filesPath.value = response.data;
|
||||||
@@ -59,11 +65,13 @@ function handleRemove(row: any) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="import-file-container">
|
||||||
<div>
|
<DragFileUpload
|
||||||
<DragFileUpload @success="handleSuccess" @on-change="handleChange" />
|
ref="dragUploadRef"
|
||||||
</div>
|
@success="handleSuccess"
|
||||||
<div>
|
@on-change="handleChange"
|
||||||
|
/>
|
||||||
|
<div class="import-file-container__table">
|
||||||
<ElTable :data="fileData" style="width: 100%" size="large">
|
<ElTable :data="fileData" style="width: 100%" size="large">
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
prop="fileName"
|
prop="fileName"
|
||||||
@@ -104,4 +112,14 @@ function handleRemove(row: any) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.import-file-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-container__table {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import { ElButton, ElInput, ElMessage } from 'element-plus';
|
import { InfoFilled } from '@element-plus/icons-vue';
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElInput,
|
||||||
|
ElInputNumber,
|
||||||
|
ElMessage,
|
||||||
|
ElTooltip,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
|
import PreviewSearchKnowledge from '#/views/ai/documentCollection/PreviewSearchKnowledge.vue';
|
||||||
@@ -33,12 +40,22 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
showConfig: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchDataList = ref<SearchResultItem[]>([]);
|
const searchDataList = ref<SearchResultItem[]>([]);
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
const retrievalMode = ref<RetrievalMode>('HYBRID');
|
const retrievalMode = ref<RetrievalMode>('HYBRID');
|
||||||
|
const isSearching = ref(false);
|
||||||
|
const hasSearched = ref(false);
|
||||||
const previewSearchKnowledgeRef = ref();
|
const previewSearchKnowledgeRef = ref();
|
||||||
|
const searchConfig = reactive({
|
||||||
|
docRecallMaxNum: 5,
|
||||||
|
simThreshold: 0.6,
|
||||||
|
});
|
||||||
|
|
||||||
const retrievalModeDescriptions = computed(() => [
|
const retrievalModeDescriptions = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -64,14 +81,65 @@ const retrievalModeDescriptions = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSearch = () => {
|
const applySearchConfig = (options?: Record<string, any>) => {
|
||||||
if (!keyword.value) {
|
const rawRecallMax = Number(options?.docRecallMaxNum);
|
||||||
|
const rawSimilarity = Number(options?.simThreshold);
|
||||||
|
searchConfig.docRecallMaxNum =
|
||||||
|
Number.isFinite(rawRecallMax) && rawRecallMax > 0 ? rawRecallMax : 5;
|
||||||
|
searchConfig.simThreshold =
|
||||||
|
Number.isFinite(rawSimilarity) && rawSimilarity >= 0 && rawSimilarity <= 1
|
||||||
|
? rawSimilarity
|
||||||
|
: 0.6;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSearchConfig = async () => {
|
||||||
|
if (!props.showConfig || !props.knowledgeId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await props.requestClient.get(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/documentCollection/detail',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
id: props.knowledgeId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
applySearchConfig(res.data?.options);
|
||||||
|
} catch {
|
||||||
|
applySearchConfig();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSearchConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.knowledgeId,
|
||||||
|
() => {
|
||||||
|
loadSearchConfig();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSearch = async () => {
|
||||||
|
const normalizedKeyword = keyword.value.trim();
|
||||||
|
if (!normalizedKeyword) {
|
||||||
ElMessage.error($t('message.pleaseInputContent'));
|
ElMessage.error($t('message.pleaseInputContent'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
previewSearchKnowledgeRef.value.loadingContent(true);
|
if (isSearching.value) {
|
||||||
props.requestClient
|
return;
|
||||||
.get(
|
}
|
||||||
|
keyword.value = normalizedKeyword;
|
||||||
|
isSearching.value = true;
|
||||||
|
hasSearched.value = true;
|
||||||
|
previewSearchKnowledgeRef.value?.loadingContent(true);
|
||||||
|
try {
|
||||||
|
const res = await props.requestClient.get(
|
||||||
buildKnowledgePath(
|
buildKnowledgePath(
|
||||||
props.endpointPrefix,
|
props.endpointPrefix,
|
||||||
'/api/v1/documentCollection/search',
|
'/api/v1/documentCollection/search',
|
||||||
@@ -79,21 +147,21 @@ const handleSearch = () => {
|
|||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
knowledgeId: props.knowledgeId,
|
knowledgeId: props.knowledgeId,
|
||||||
keyword: keyword.value,
|
keyword: normalizedKeyword,
|
||||||
retrievalMode: retrievalMode.value,
|
retrievalMode: retrievalMode.value,
|
||||||
|
docRecallMaxNum: searchConfig.docRecallMaxNum,
|
||||||
|
simThreshold: searchConfig.simThreshold,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.then((res) => {
|
searchDataList.value = res.data || [];
|
||||||
searchDataList.value = res.data;
|
} catch {
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
ElMessage.error($t('documentCollection.searchFailed'));
|
ElMessage.error($t('documentCollection.searchFailed'));
|
||||||
searchDataList.value = [];
|
searchDataList.value = [];
|
||||||
})
|
} finally {
|
||||||
.finally(() => {
|
isSearching.value = false;
|
||||||
previewSearchKnowledgeRef.value.loadingContent(false);
|
previewSearchKnowledgeRef.value?.loadingContent(false);
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
||||||
@@ -102,22 +170,80 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="search-container">
|
<div class="knowledge-search-shell">
|
||||||
|
<div class="knowledge-search-sidebar">
|
||||||
|
<div class="search-controls">
|
||||||
|
<div class="search-controls__search">
|
||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
|
clearable
|
||||||
:placeholder="$t('common.searchPlaceholder')"
|
:placeholder="$t('common.searchPlaceholder')"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
<ElButton type="primary" @click="handleSearch">
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:loading="isSearching"
|
||||||
|
@click="handleSearch"
|
||||||
|
>
|
||||||
{{ $t('button.query') }}
|
{{ $t('button.query') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-hint">
|
|
||||||
<div class="search-hint__title">
|
|
||||||
{{ $t('documentCollectionSearch.retrievalModeTitle') }}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showConfig" class="search-controls__header">
|
||||||
|
<div class="config-grid">
|
||||||
|
<div class="config-item">
|
||||||
|
<div class="config-item__label">
|
||||||
|
<span>{{
|
||||||
|
$t('documentCollectionSearch.docRecallMaxNum.label')
|
||||||
|
}}</span>
|
||||||
|
<ElTooltip
|
||||||
|
:content="
|
||||||
|
$t('documentCollectionSearch.docRecallMaxNum.tooltip')
|
||||||
|
"
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
<InfoFilled class="info-icon" />
|
||||||
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="searchConfig.docRecallMaxNum"
|
||||||
|
:min="1"
|
||||||
|
:max="50"
|
||||||
|
:step="1"
|
||||||
|
class="config-item__control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-item">
|
||||||
|
<div class="config-item__label">
|
||||||
|
<span>{{
|
||||||
|
$t('documentCollectionSearch.simThreshold.label')
|
||||||
|
}}</span>
|
||||||
|
<ElTooltip
|
||||||
|
:content="$t('documentCollectionSearch.simThreshold.tooltip')"
|
||||||
|
placement="top"
|
||||||
|
effect="dark"
|
||||||
|
>
|
||||||
|
<InfoFilled class="info-icon" />
|
||||||
|
</ElTooltip>
|
||||||
|
</div>
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="searchConfig.simThreshold"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
controls-position="right"
|
||||||
|
class="config-item__control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-controls__mode">
|
||||||
<div class="search-hint__list">
|
<div class="search-hint__list">
|
||||||
<button
|
<button
|
||||||
v-for="item in retrievalModeDescriptions"
|
v-for="item in retrievalModeDescriptions"
|
||||||
@@ -132,11 +258,14 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="search-result">
|
<div class="knowledge-search-preview">
|
||||||
<PreviewSearchKnowledge
|
<PreviewSearchKnowledge
|
||||||
ref="previewSearchKnowledgeRef"
|
ref="previewSearchKnowledgeRef"
|
||||||
:data="searchDataList"
|
:data="searchDataList"
|
||||||
|
:is-searching="hasSearched"
|
||||||
:retrieval-mode="retrievalMode"
|
:retrieval-mode="retrievalMode"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,11 +273,81 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.search-container {
|
.knowledge-search-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 332px) minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.knowledge-search-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-controls {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
border: 1px solid rgb(15 23 42 / 6%);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-controls__header {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-controls__search {
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-controls__mode {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item__label {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item__control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 0 20px;
|
}
|
||||||
|
|
||||||
|
.config-item__control :deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
@@ -161,48 +360,34 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-hint {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 16px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
background: var(--el-fill-color-blank);
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-hint__title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-hint__list {
|
.search-hint__list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
gap: 10px;
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-hint__item {
|
.search-hint__item {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 14px 16px;
|
padding: 12px 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: var(--el-fill-color-light);
|
background: rgb(248 250 252 / 88%);
|
||||||
border: 1px solid transparent;
|
border: 1px solid rgb(15 23 42 / 6%);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s ease,
|
border-color 0.2s ease,
|
||||||
background-color 0.2s ease,
|
background-color 0.2s ease;
|
||||||
box-shadow 0.2s ease;
|
}
|
||||||
|
|
||||||
|
.search-hint__item:hover {
|
||||||
|
border-color: rgb(59 130 246 / 22%);
|
||||||
|
background: rgb(248 250 252 / 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-hint__item.is-active {
|
.search-hint__item.is-active {
|
||||||
background: var(--el-color-primary-light-9);
|
background: rgb(239 246 255 / 92%);
|
||||||
border-color: var(--el-color-primary-light-7);
|
border-color: rgb(96 165 250 / 45%);
|
||||||
box-shadow: 0 6px 18px rgb(64 158 255 / 10%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-hint__item:focus-visible {
|
.search-hint__item:focus-visible {
|
||||||
@@ -222,17 +407,31 @@ const handleRetrievalModeChange = (mode: RetrievalMode) => {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-result {
|
.knowledge-search-preview {
|
||||||
padding-top: 20px;
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 1024px) {
|
||||||
.search-hint__list {
|
.knowledge-search-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.knowledge-search-preview {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-input :deep(.el-button) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
manageable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['reload']);
|
const emit = defineEmits(['reload']);
|
||||||
@@ -131,8 +135,16 @@ onMounted(() => {
|
|||||||
<div class="share-config-card__desc">
|
<div class="share-config-card__desc">
|
||||||
仅开放模型切换、检索参数与向量重建
|
仅开放模型切换、检索参数与向量重建
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="!props.manageable" class="share-config-card__tip">
|
||||||
|
当前分享仅支持查看,不允许修改配置
|
||||||
</div>
|
</div>
|
||||||
<ElButton type="primary" :loading="saving" @click="handleSave">
|
</div>
|
||||||
|
<ElButton
|
||||||
|
v-if="props.manageable"
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
{{ $t('button.save') }}
|
{{ $t('button.save') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,7 +152,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<ElForm label-position="top" class="share-config-form">
|
<ElForm label-position="top" class="share-config-form">
|
||||||
<ElFormItem :label="$t('documentCollection.vectorEmbedLlmId')">
|
<ElFormItem :label="$t('documentCollection.vectorEmbedLlmId')">
|
||||||
<ElSelect v-model="form.vectorEmbedModelId">
|
<ElSelect v-model="form.vectorEmbedModelId" :disabled="!props.manageable">
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="item in embeddingModels"
|
v-for="item in embeddingModels"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -151,7 +163,7 @@ onMounted(() => {
|
|||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem :label="$t('documentCollection.rerankLlmId')">
|
<ElFormItem :label="$t('documentCollection.rerankLlmId')">
|
||||||
<ElSelect v-model="form.rerankModelId" clearable>
|
<ElSelect v-model="form.rerankModelId" clearable :disabled="!props.manageable">
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="item in rerankModels"
|
v-for="item in rerankModels"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -162,11 +174,16 @@ onMounted(() => {
|
|||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem :label="$t('documentCollection.rerankEnable')">
|
<ElFormItem :label="$t('documentCollection.rerankEnable')">
|
||||||
<ElSwitch v-model="form.rerankEnable" />
|
<ElSwitch v-model="form.rerankEnable" :disabled="!props.manageable" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem :label="$t('documentCollectionSearch.docRecallMaxNum.label')">
|
<ElFormItem :label="$t('documentCollectionSearch.docRecallMaxNum.label')">
|
||||||
<ElInputNumber v-model="form.docRecallMaxNum" :min="1" :max="50" />
|
<ElInputNumber
|
||||||
|
v-model="form.docRecallMaxNum"
|
||||||
|
:min="1"
|
||||||
|
:max="50"
|
||||||
|
:disabled="!props.manageable"
|
||||||
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem :label="$t('documentCollectionSearch.simThreshold.label')">
|
<ElFormItem :label="$t('documentCollectionSearch.simThreshold.label')">
|
||||||
@@ -175,11 +192,12 @@ onMounted(() => {
|
|||||||
:min="0"
|
:min="0"
|
||||||
:max="1"
|
:max="1"
|
||||||
:step="0.01"
|
:step="0.01"
|
||||||
|
:disabled="!props.manageable"
|
||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem label="保存后重建向量">
|
<ElFormItem label="保存后重建向量">
|
||||||
<ElSwitch v-model="form.rebuildVectors" />
|
<ElSwitch v-model="form.rebuildVectors" :disabled="!props.manageable" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
@@ -208,6 +226,12 @@ onMounted(() => {
|
|||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-config-card__tip {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.share-config-form {
|
.share-config-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -129,16 +129,37 @@ const endpointDocs = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/document/import/commit',
|
path: '/document/import/task/create',
|
||||||
hint: '导入文档',
|
hint: '创建导入任务',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'knowledgeId', location: 'body', required: true },
|
{ name: 'knowledgeId', location: 'body', required: true },
|
||||||
{
|
{
|
||||||
name: 'previewSessionIds',
|
name: 'fileName',
|
||||||
location: 'body',
|
location: 'body',
|
||||||
required: true,
|
required: true,
|
||||||
note: '预览接口返回的会话 ID 数组',
|
note: '文件名',
|
||||||
},
|
},
|
||||||
|
{ name: 'filePath', location: 'body', required: true, note: '上传后的文件路径' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/document/import/task/preview',
|
||||||
|
hint: '生成分块预览',
|
||||||
|
params: [
|
||||||
|
{ name: 'knowledgeId', location: 'body', required: true },
|
||||||
|
{ name: 'documentId', location: 'body', required: true },
|
||||||
|
{ name: 'files[0].strategyConfig', location: 'body', note: '拆分策略配置' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/document/import/task/startIndex',
|
||||||
|
hint: '启动向量化',
|
||||||
|
params: [
|
||||||
|
{ name: 'knowledgeId', location: 'body', required: true },
|
||||||
|
{ name: 'documentId', location: 'body', required: true },
|
||||||
|
{ name: 'previewSessionId', location: 'body', note: '预览接口返回的会话 ID' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,15 +13,23 @@ import FaqTable from '#/views/ai/documentCollection/FaqTable.vue';
|
|||||||
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
import ImportKnowledgeDocFile from '#/views/ai/documentCollection/ImportKnowledgeDocFile.vue';
|
||||||
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
import KnowledgeSearch from '#/views/ai/documentCollection/KnowledgeSearch.vue';
|
||||||
import KnowledgeShareConfigPanel from '#/views/ai/documentCollection/KnowledgeShareConfigPanel.vue';
|
import KnowledgeShareConfigPanel from '#/views/ai/documentCollection/KnowledgeShareConfigPanel.vue';
|
||||||
|
import SegmenterDoc from '#/views/ai/documentCollection/SegmenterDoc.vue';
|
||||||
|
|
||||||
|
interface KnowledgeShareViewDetail {
|
||||||
|
knowledge?: Record<string, any>;
|
||||||
|
permissionScopes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const endpointPrefix = '/api/v1/share/knowledge';
|
const endpointPrefix = '/api/v1/share/knowledge';
|
||||||
const knowledgeInfo = ref<any>({});
|
const knowledgeInfo = ref<any>({});
|
||||||
|
const permissionScopes = ref<string[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const selectedCategory = ref('');
|
const selectedCategory = ref('');
|
||||||
const viewDocVisible = ref(false);
|
const panelMode = ref<'chunk' | 'list' | 'process'>('list');
|
||||||
const documentId = ref('');
|
const documentId = ref('');
|
||||||
const importVisible = ref(false);
|
const documentTitle = ref('');
|
||||||
const documentTableRef = ref();
|
const documentTableRef = ref();
|
||||||
|
const importDocModalRef = ref<InstanceType<typeof ImportKnowledgeDocFile>>();
|
||||||
|
|
||||||
const isFaqCollection = computed(
|
const isFaqCollection = computed(
|
||||||
() => knowledgeInfo.value.collectionType === 'FAQ',
|
() => knowledgeInfo.value.collectionType === 'FAQ',
|
||||||
@@ -41,6 +49,31 @@ const knowledgeTitle = computed(
|
|||||||
const knowledgeDescription = computed(
|
const knowledgeDescription = computed(
|
||||||
() => knowledgeInfo.value.description || '',
|
() => knowledgeInfo.value.description || '',
|
||||||
);
|
);
|
||||||
|
const permissionScopeSet = computed(
|
||||||
|
() => new Set((permissionScopes.value || []).map((item) => String(item || '').toUpperCase())),
|
||||||
|
);
|
||||||
|
const canCreateContent = computed(() =>
|
||||||
|
permissionScopeSet.value.has('CONTENT_CREATE'),
|
||||||
|
);
|
||||||
|
const canDeleteContent = computed(() =>
|
||||||
|
permissionScopeSet.value.has('CONTENT_DELETE'),
|
||||||
|
);
|
||||||
|
const canUpdateContent = computed(() =>
|
||||||
|
permissionScopeSet.value.has('CONTENT_UPDATE'),
|
||||||
|
);
|
||||||
|
const canUpdateConfig = computed(() =>
|
||||||
|
permissionScopeSet.value.has('CONFIG_UPDATE'),
|
||||||
|
);
|
||||||
|
const canDownloadContent = computed(() =>
|
||||||
|
permissionScopeSet.value.has('VIEW'),
|
||||||
|
);
|
||||||
|
const canManageContent = computed(
|
||||||
|
() =>
|
||||||
|
canCreateContent.value ||
|
||||||
|
canDeleteContent.value ||
|
||||||
|
canUpdateContent.value ||
|
||||||
|
permissionScopeSet.value.has('IMPORT_EXPORT'),
|
||||||
|
);
|
||||||
const categoryData = computed(() => {
|
const categoryData = computed(() => {
|
||||||
if (isFaqCollection.value) {
|
if (isFaqCollection.value) {
|
||||||
return [
|
return [
|
||||||
@@ -68,7 +101,9 @@ const loadKnowledge = async () => {
|
|||||||
const res = await knowledgeShareApi.get(
|
const res = await knowledgeShareApi.get(
|
||||||
`${endpointPrefix}/documentCollection/detail`,
|
`${endpointPrefix}/documentCollection/detail`,
|
||||||
);
|
);
|
||||||
knowledgeInfo.value = res.data || {};
|
const detail = (res.data || {}) as KnowledgeShareViewDetail;
|
||||||
|
knowledgeInfo.value = detail.knowledge || {};
|
||||||
|
permissionScopes.value = detail.permissionScopes || [];
|
||||||
selectedCategory.value =
|
selectedCategory.value =
|
||||||
knowledgeInfo.value.collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
knowledgeInfo.value.collectionType === 'FAQ' ? 'faqList' : 'documentList';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -78,15 +113,28 @@ const loadKnowledge = async () => {
|
|||||||
|
|
||||||
const handleViewDoc = (id: string) => {
|
const handleViewDoc = (id: string) => {
|
||||||
documentId.value = id;
|
documentId.value = id;
|
||||||
viewDocVisible.value = true;
|
panelMode.value = 'chunk';
|
||||||
};
|
};
|
||||||
|
|
||||||
const backToDocumentList = () => {
|
const backToDocumentList = () => {
|
||||||
viewDocVisible.value = false;
|
panelMode.value = 'list';
|
||||||
|
documentTitle.value = '';
|
||||||
|
documentTableRef.value?.reload?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const continueProcess = (doc: any) => {
|
||||||
|
documentId.value = String(doc?.id || '');
|
||||||
|
documentTitle.value = doc?.title || '';
|
||||||
|
panelMode.value = 'process';
|
||||||
};
|
};
|
||||||
|
|
||||||
const openImport = () => {
|
const openImport = () => {
|
||||||
importVisible.value = true;
|
importDocModalRef.value?.openDialog?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryClick = (key: string) => {
|
||||||
|
selectedCategory.value = key;
|
||||||
|
panelMode.value = 'list';
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -96,7 +144,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="share-page" v-loading="loading">
|
<div class="share-page" v-loading="loading">
|
||||||
<div v-if="!importVisible" class="share-shell">
|
<div class="share-shell">
|
||||||
<ElCard shadow="never" class="share-hero">
|
<ElCard shadow="never" class="share-hero">
|
||||||
<div class="share-hero__main">
|
<div class="share-hero__main">
|
||||||
<div class="share-hero__meta">
|
<div class="share-hero__meta">
|
||||||
@@ -115,7 +163,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="share-hero__actions">
|
<div class="share-hero__actions">
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="!isFaqCollection"
|
v-if="!isFaqCollection && canCreateContent"
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="openImport"
|
@click="openImport"
|
||||||
>
|
>
|
||||||
@@ -129,7 +177,7 @@ onMounted(() => {
|
|||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="share-tab"
|
class="share-tab"
|
||||||
:class="{ 'is-active': selectedCategory === item.key }"
|
:class="{ 'is-active': selectedCategory === item.key }"
|
||||||
@click="selectedCategory = item.key"
|
@click="handleCategoryClick(item.key)"
|
||||||
>
|
>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</button>
|
</button>
|
||||||
@@ -139,22 +187,37 @@ onMounted(() => {
|
|||||||
<div class="share-body">
|
<div class="share-body">
|
||||||
<div v-if="selectedCategory === 'documentList'" class="share-panel">
|
<div v-if="selectedCategory === 'documentList'" class="share-panel">
|
||||||
<DocumentTable
|
<DocumentTable
|
||||||
v-if="!viewDocVisible"
|
v-if="panelMode === 'list'"
|
||||||
ref="documentTableRef"
|
ref="documentTableRef"
|
||||||
:knowledge-id="knowledgeInfo.id"
|
:knowledge-id="knowledgeInfo.id"
|
||||||
:manageable="true"
|
:permissions="{
|
||||||
|
canCreateContent: canCreateContent,
|
||||||
|
canDeleteContent: canDeleteContent,
|
||||||
|
canDownloadContent: canDownloadContent,
|
||||||
|
}"
|
||||||
:request-client="knowledgeShareApi"
|
:request-client="knowledgeShareApi"
|
||||||
:endpoint-prefix="endpointPrefix"
|
:endpoint-prefix="endpointPrefix"
|
||||||
|
@continue-process="continueProcess"
|
||||||
@view-doc="handleViewDoc"
|
@view-doc="handleViewDoc"
|
||||||
/>
|
/>
|
||||||
<ChunkDocumentTable
|
<ChunkDocumentTable
|
||||||
v-else
|
v-else-if="panelMode === 'chunk'"
|
||||||
:document-id="documentId"
|
:document-id="documentId"
|
||||||
:manageable="true"
|
:manageable="canManageContent"
|
||||||
:request-client="knowledgeShareApi"
|
:request-client="knowledgeShareApi"
|
||||||
:endpoint-prefix="endpointPrefix"
|
:endpoint-prefix="endpointPrefix"
|
||||||
/>
|
/>
|
||||||
<div v-if="viewDocVisible" class="share-panel__footer">
|
<SegmenterDoc
|
||||||
|
v-else-if="panelMode === 'process'"
|
||||||
|
:knowledge-id="String(knowledgeInfo.id || '')"
|
||||||
|
:document-id="documentId"
|
||||||
|
:document-title="documentTitle"
|
||||||
|
:request-client="knowledgeShareApi"
|
||||||
|
:endpoint-prefix="endpointPrefix"
|
||||||
|
@cancel="backToDocumentList"
|
||||||
|
@started="backToDocumentList"
|
||||||
|
/>
|
||||||
|
<div v-if="panelMode === 'chunk'" class="share-panel__footer">
|
||||||
<ElButton @click="backToDocumentList">
|
<ElButton @click="backToDocumentList">
|
||||||
{{ $t('button.back') }}
|
{{ $t('button.back') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -164,7 +227,7 @@ onMounted(() => {
|
|||||||
<div v-if="selectedCategory === 'faqList'" class="share-panel">
|
<div v-if="selectedCategory === 'faqList'" class="share-panel">
|
||||||
<FaqTable
|
<FaqTable
|
||||||
:knowledge-id="knowledgeInfo.id"
|
:knowledge-id="knowledgeInfo.id"
|
||||||
:manageable="true"
|
:manageable="canManageContent"
|
||||||
:request-client="knowledgeShareApi"
|
:request-client="knowledgeShareApi"
|
||||||
:endpoint-prefix="endpointPrefix"
|
:endpoint-prefix="endpointPrefix"
|
||||||
/>
|
/>
|
||||||
@@ -182,6 +245,7 @@ onMounted(() => {
|
|||||||
<KnowledgeShareConfigPanel
|
<KnowledgeShareConfigPanel
|
||||||
:knowledge-id="String(knowledgeInfo.id || '')"
|
:knowledge-id="String(knowledgeInfo.id || '')"
|
||||||
:detail-data="knowledgeInfo"
|
:detail-data="knowledgeInfo"
|
||||||
|
:manageable="canUpdateConfig"
|
||||||
:request-client="knowledgeShareApi"
|
:request-client="knowledgeShareApi"
|
||||||
:endpoint-prefix="endpointPrefix"
|
:endpoint-prefix="endpointPrefix"
|
||||||
@reload="loadKnowledge"
|
@reload="loadKnowledge"
|
||||||
@@ -191,11 +255,11 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImportKnowledgeDocFile
|
<ImportKnowledgeDocFile
|
||||||
v-else
|
ref="importDocModalRef"
|
||||||
:request-client="knowledgeShareApi"
|
:request-client="knowledgeShareApi"
|
||||||
:endpoint-prefix="endpointPrefix"
|
:endpoint-prefix="endpointPrefix"
|
||||||
:knowledge-id-prop="String(knowledgeInfo.id || '')"
|
:knowledge-id-prop="String(knowledgeInfo.id || '')"
|
||||||
@import-back="importVisible = false"
|
@imported="backToDocumentList"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ref } from 'vue';
|
|||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import { Document } from '@element-plus/icons-vue';
|
import { Document } from '@element-plus/icons-vue';
|
||||||
import { ElButton, ElIcon, ElTag } from 'element-plus';
|
import { ElButton, ElEmpty, ElIcon, ElTag } from 'element-plus';
|
||||||
|
|
||||||
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
type RetrievalMode = 'HYBRID' | 'KEYWORD' | 'VECTOR';
|
||||||
type HitSource = 'BOTH' | 'KEYWORD' | 'VECTOR';
|
type HitSource = 'BOTH' | 'KEYWORD' | 'VECTOR';
|
||||||
@@ -100,6 +100,20 @@ const resolveHitSourceType = (hitSource?: HitSource) => {
|
|||||||
return 'info';
|
return 'info';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizePreviewContent = (content?: string) => {
|
||||||
|
if (!content) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && typeof DOMParser !== 'undefined') {
|
||||||
|
const doc = new DOMParser().parseFromString(content, 'text/html');
|
||||||
|
return (doc.body.textContent || '').replaceAll(/\n\s*\n/g, '\n').trim();
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
.replaceAll(/<[^>]+>/g, ' ')
|
||||||
|
.replaceAll(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
loadingContent: (state: boolean) => {
|
loadingContent: (state: boolean) => {
|
||||||
loadingStatus.value = state;
|
loadingStatus.value = state;
|
||||||
@@ -109,7 +123,6 @@ defineExpose({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="preview-container" v-loading="loadingStatus">
|
<div class="preview-container" v-loading="loadingStatus">
|
||||||
<!-- 头部区域:标题 + 统计信息 -->
|
|
||||||
<div class="preview-header">
|
<div class="preview-header">
|
||||||
<h3>
|
<h3>
|
||||||
<ElIcon class="header-icon" size="20">
|
<ElIcon class="header-icon" size="20">
|
||||||
@@ -128,23 +141,18 @@ defineExpose({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 内容区域:列表预览 -->
|
|
||||||
<div class="preview-content">
|
<div class="preview-content">
|
||||||
<div class="preview-list">
|
<div v-if="data.length > 0" class="preview-list">
|
||||||
<div
|
<div v-for="(item, index) in data" :key="index" class="preview-item">
|
||||||
v-for="(item, index) in data"
|
<div class="preview-item__header">
|
||||||
:key="index"
|
<div class="preview-item__meta">
|
||||||
class="el-list-item-container"
|
|
||||||
>
|
|
||||||
<div class="el-list-item">
|
|
||||||
<div class="segment-badge">
|
<div class="segment-badge">
|
||||||
{{ item.sorting ?? index + 1 }}
|
{{ item.sorting ?? index + 1 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="el-list-item-meta">
|
|
||||||
<div v-if="!hideScore" class="score-text">
|
<div v-if="!hideScore" class="score-text">
|
||||||
{{ $t('documentCollection.similarityScore') }}: {{ item.score }}
|
{{ $t('documentCollection.similarityScore') }}: {{ item.score }}
|
||||||
</div>
|
</div>
|
||||||
<div class="content-desc">{{ item.content }}</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="resolveDisplayHitSource(item.hitSource, retrievalMode)"
|
v-if="resolveDisplayHitSource(item.hitSource, retrievalMode)"
|
||||||
class="hit-source-row"
|
class="hit-source-row"
|
||||||
@@ -166,17 +174,26 @@ defineExpose({
|
|||||||
</ElTag>
|
</ElTag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="content-desc">
|
||||||
|
{{ normalizePreviewContent(item.content) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ElEmpty
|
||||||
|
v-else
|
||||||
|
:description="
|
||||||
|
isSearching
|
||||||
|
? $t('documentCollection.searchResults')
|
||||||
|
: $t('documentCollection.documentPreview')
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮区域(仅导入确认模式显示) -->
|
|
||||||
<div class="preview-actions" v-if="confirmImport">
|
<div class="preview-actions" v-if="confirmImport">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<ElButton
|
<ElButton
|
||||||
:style="{ minWidth: '100px', height: '36px' }"
|
:style="{ minWidth: '100px', height: '36px' }"
|
||||||
click="onCancel"
|
@click="onCancel"
|
||||||
>
|
>
|
||||||
{{ $t('documentCollection.actions.confirmImport') }}
|
{{ $t('documentCollection.actions.confirmImport') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -184,7 +201,7 @@ defineExpose({
|
|||||||
type="primary"
|
type="primary"
|
||||||
:style="{ minWidth: '100px', height: '36px' }"
|
:style="{ minWidth: '100px', height: '36px' }"
|
||||||
:loading="disabledConfirm"
|
:loading="disabledConfirm"
|
||||||
click="onConfirm"
|
@click="onConfirm"
|
||||||
>
|
>
|
||||||
{{ $t('documentCollection.actions.cancelImport') }}
|
{{ $t('documentCollection.actions.cancelImport') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -195,10 +212,14 @@ defineExpose({
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.preview-container {
|
.preview-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
background-color: var(--el-bg-color);
|
background-color: var(--el-bg-color);
|
||||||
border-radius: 8px;
|
border: 1px solid rgb(15 23 42 / 6%);
|
||||||
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 8%);
|
border-radius: 20px;
|
||||||
|
|
||||||
.preview-header {
|
.preview-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -228,9 +249,15 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preview-content {
|
.preview-content {
|
||||||
padding: 20px;
|
flex: 1;
|
||||||
|
padding: 18px 20px;
|
||||||
|
overflow: hidden auto;
|
||||||
|
|
||||||
.preview-list {
|
.preview-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
|
||||||
.segment-badge {
|
.segment-badge {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -244,59 +271,45 @@ defineExpose({
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.similarity-score {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-desc {
|
.content-desc {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 14px 16px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
background-color: var(--el-bg-color);
|
word-break: break-word;
|
||||||
border-left: 3px solid #e2e8f0;
|
background: rgb(248 250 252 / 90%);
|
||||||
border-radius: 6px;
|
border-radius: 14px;
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #4361ee;
|
|
||||||
box-shadow: 0 4px 12px rgb(67 97 238 / 8%);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.score-text {
|
.score-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item__header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-item__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.hit-source-row {
|
.hit-source-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-self: flex-end;
|
flex: none;
|
||||||
justify-content: flex-end;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-list-item {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-list-item-meta {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,14 +331,4 @@ defineExpose({
|
|||||||
.el-list--loading .el-list-loading {
|
.el-list--loading .el-list-loading {
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-list-item {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 12px;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,56 +1,103 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch } from 'vue';
|
import { reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ElAlert,
|
ElButton,
|
||||||
ElCard,
|
|
||||||
ElCol,
|
|
||||||
ElForm,
|
ElForm,
|
||||||
ElFormItem,
|
ElFormItem,
|
||||||
ElInput,
|
ElInput,
|
||||||
|
ElMessage,
|
||||||
ElOption,
|
ElOption,
|
||||||
ElRow,
|
|
||||||
ElSelect,
|
ElSelect,
|
||||||
ElSlider,
|
ElSlider,
|
||||||
ElTag,
|
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
interface StrategyConfig {
|
import { api } from '#/api/request';
|
||||||
chunkSize?: number;
|
import { buildKnowledgePath } from '#/views/ai/documentCollection/share-path';
|
||||||
mdSplitterLevel?: number;
|
import SplitterDocPreview from '#/views/ai/documentCollection/SplitterDocPreview.vue';
|
||||||
overlapSize?: number;
|
|
||||||
regex?: string;
|
interface SourceRange {
|
||||||
rowsPerChunk?: number;
|
end: number;
|
||||||
strategyCode?: string;
|
start: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StrategyCandidate {
|
interface ChunkItem {
|
||||||
score?: number;
|
answer?: string;
|
||||||
strategyCode: string;
|
charCount?: number;
|
||||||
strategyLabel: string;
|
chunkId?: string;
|
||||||
|
chunkType?: string;
|
||||||
|
content?: string;
|
||||||
|
headingPath?: string[];
|
||||||
|
options?: Record<string, any>;
|
||||||
|
partNo?: number;
|
||||||
|
partTotal?: number;
|
||||||
|
question?: string;
|
||||||
|
sourceLabel?: string;
|
||||||
|
sourceRanges?: SourceRange[];
|
||||||
|
tokenEstimate?: number;
|
||||||
|
warnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnalysisResult {
|
interface PreviewItem {
|
||||||
candidateStrategies?: StrategyCandidate[];
|
analysis?: {
|
||||||
confidence?: number;
|
normalizedContent?: string;
|
||||||
reasons?: string[];
|
|
||||||
recommendedStrategyCode?: string;
|
|
||||||
recommendedStrategyLabel?: string;
|
recommendedStrategyLabel?: string;
|
||||||
recommendedStructureType?: string;
|
};
|
||||||
}
|
chunks?: ChunkItem[];
|
||||||
|
|
||||||
interface AnalyzeItem {
|
|
||||||
analysis?: AnalysisResult;
|
|
||||||
fileName: string;
|
fileName: string;
|
||||||
filePath: string;
|
normalizedContent?: string;
|
||||||
strategyConfig?: StrategyConfig;
|
previewSessionId: string;
|
||||||
|
strategyCode?: string;
|
||||||
|
strategyLabel?: string;
|
||||||
|
totalChunks?: number;
|
||||||
|
totalWarnings?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps({
|
||||||
analysisItems?: AnalyzeItem[];
|
documentId: {
|
||||||
}>();
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
documentTitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
endpointPrefix: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
knowledgeId: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
requestClient: {
|
||||||
|
type: Object as any,
|
||||||
|
default: () => api,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(['cancel', 'started']);
|
||||||
|
|
||||||
|
const createDefaultFormState = () => ({
|
||||||
|
chunkSize: 512,
|
||||||
|
mdSplitterLevel: 2,
|
||||||
|
overlapSize: 128,
|
||||||
|
regex: '',
|
||||||
|
rowsPerChunk: 1,
|
||||||
|
strategyCode: 'AUTO',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formState = reactive(createDefaultFormState());
|
||||||
|
const previewItems = ref<PreviewItem[]>([]);
|
||||||
|
const currentPreviewSessionId = ref('');
|
||||||
|
const activeDocumentId = ref(props.documentId || '');
|
||||||
|
const previewError = ref('');
|
||||||
|
const previewLoading = ref(false);
|
||||||
|
const startLoading = ref(false);
|
||||||
|
let previewDebounceTimer: null | ReturnType<typeof setTimeout> = null;
|
||||||
|
let previewSequence = 0;
|
||||||
|
|
||||||
const strategyOptions = [
|
const strategyOptions = [
|
||||||
{
|
{
|
||||||
@@ -81,146 +128,199 @@ const strategyOptions = [
|
|||||||
|
|
||||||
const mdLevels = [1, 2, 3, 4, 5, 6];
|
const mdLevels = [1, 2, 3, 4, 5, 6];
|
||||||
|
|
||||||
const formMap = reactive<Record<string, StrategyConfig>>({});
|
const showLengthSettings = (strategyCode?: string) =>
|
||||||
|
['AUTO', 'MARKDOWN_SECTION', 'OUTLINE_SECTION', 'PARAGRAPH_LENGTH'].includes(
|
||||||
|
strategyCode || '',
|
||||||
|
);
|
||||||
|
|
||||||
function createDefaultStrategyConfig(item?: AnalyzeItem): StrategyConfig {
|
const showOverlapSettings = (strategyCode?: string) =>
|
||||||
return {
|
['AUTO', 'PARAGRAPH_LENGTH'].includes(strategyCode || '');
|
||||||
chunkSize: item?.strategyConfig?.chunkSize ?? 512,
|
|
||||||
mdSplitterLevel: item?.strategyConfig?.mdSplitterLevel ?? 2,
|
|
||||||
overlapSize: item?.strategyConfig?.overlapSize ?? 128,
|
|
||||||
regex: item?.strategyConfig?.regex ?? '',
|
|
||||||
rowsPerChunk: item?.strategyConfig?.rowsPerChunk ?? 1,
|
|
||||||
strategyCode:
|
|
||||||
item?.strategyConfig?.strategyCode ||
|
|
||||||
item?.analysis?.recommendedStrategyCode ||
|
|
||||||
'AUTO',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStrategyForm(filePath: string, item?: AnalyzeItem): StrategyConfig {
|
const clearPreviewTimer = () => {
|
||||||
if (!formMap[filePath]) {
|
if (!previewDebounceTimer) {
|
||||||
formMap[filePath] = createDefaultStrategyConfig(item);
|
return;
|
||||||
}
|
}
|
||||||
return formMap[filePath]!;
|
clearTimeout(previewDebounceTimer);
|
||||||
}
|
previewDebounceTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPreviewState = () => {
|
||||||
|
previewItems.value = [];
|
||||||
|
currentPreviewSessionId.value = '';
|
||||||
|
previewError.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStrategyConfig = () => ({
|
||||||
|
...formState,
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeSourceRanges = (ranges?: SourceRange[]) =>
|
||||||
|
Array.isArray(ranges)
|
||||||
|
? ranges.filter(
|
||||||
|
(item) =>
|
||||||
|
Number.isFinite(item?.start) &&
|
||||||
|
Number.isFinite(item?.end) &&
|
||||||
|
Number(item.end) > Number(item.start),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizePreviewItems = (items: PreviewItem[]) =>
|
||||||
|
(items || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
normalizedContent:
|
||||||
|
item.normalizedContent || item.analysis?.normalizedContent || '',
|
||||||
|
strategyLabel:
|
||||||
|
item.strategyLabel || item.analysis?.recommendedStrategyLabel || '',
|
||||||
|
totalChunks:
|
||||||
|
Number(item.totalChunks || 0) > 0
|
||||||
|
? item.totalChunks
|
||||||
|
: (item.chunks || []).length,
|
||||||
|
chunks: (item.chunks || []).map((chunk) => ({
|
||||||
|
...chunk,
|
||||||
|
sourceRanges: normalizeSourceRanges(
|
||||||
|
chunk.sourceRanges ||
|
||||||
|
(Array.isArray(chunk.options?.sourceRanges)
|
||||||
|
? chunk.options?.sourceRanges
|
||||||
|
: []),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const generatePreview = async () => {
|
||||||
|
if (!activeDocumentId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requestSequence = ++previewSequence;
|
||||||
|
previewLoading.value = true;
|
||||||
|
previewError.value = '';
|
||||||
|
try {
|
||||||
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/document/import/task/preview',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
documentId: activeDocumentId.value,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
strategyConfig: buildStrategyConfig(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
knowledgeId: props.knowledgeId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (requestSequence !== previewSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = normalizePreviewItems(
|
||||||
|
(res.data?.items || []) as PreviewItem[],
|
||||||
|
);
|
||||||
|
previewItems.value = items;
|
||||||
|
currentPreviewSessionId.value = items[0]?.previewSessionId || '';
|
||||||
|
previewError.value = '';
|
||||||
|
} catch (error: any) {
|
||||||
|
if (requestSequence !== previewSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message =
|
||||||
|
error?.message || $t('documentCollection.importDoc.previewRequestFailed');
|
||||||
|
previewItems.value = [];
|
||||||
|
currentPreviewSessionId.value = '';
|
||||||
|
previewError.value = message;
|
||||||
|
ElMessage.error(message);
|
||||||
|
} finally {
|
||||||
|
if (requestSequence === previewSequence) {
|
||||||
|
previewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedulePreviewGeneration = () => {
|
||||||
|
if (!activeDocumentId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearPreviewTimer();
|
||||||
|
resetPreviewState();
|
||||||
|
previewDebounceTimer = setTimeout(() => {
|
||||||
|
previewDebounceTimer = null;
|
||||||
|
void generatePreview();
|
||||||
|
}, 320);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewSessionChange = (previewSessionId: string) => {
|
||||||
|
currentPreviewSessionId.value = previewSessionId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
previewSequence += 1;
|
||||||
|
clearPreviewTimer();
|
||||||
|
previewLoading.value = false;
|
||||||
|
emits('cancel');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startIndex = async () => {
|
||||||
|
if (!currentPreviewSessionId.value) {
|
||||||
|
ElMessage.warning($t('documentCollection.importDoc.previewEmpty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await props.requestClient.post(
|
||||||
|
buildKnowledgePath(
|
||||||
|
props.endpointPrefix,
|
||||||
|
'/api/v1/document/import/task/startIndex',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
documentId: activeDocumentId.value,
|
||||||
|
knowledgeId: props.knowledgeId,
|
||||||
|
previewSessionId: currentPreviewSessionId.value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success($t('documentCollection.importDoc.indexQueued'));
|
||||||
|
emits('started');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
startLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.analysisItems,
|
() => props.documentId,
|
||||||
(items) => {
|
(value) => {
|
||||||
for (const item of items || []) {
|
activeDocumentId.value = value || '';
|
||||||
formMap[item.filePath] = createDefaultStrategyConfig(item);
|
previewSequence += 1;
|
||||||
|
clearPreviewTimer();
|
||||||
|
Object.assign(formState, createDefaultFormState());
|
||||||
|
resetPreviewState();
|
||||||
|
if (activeDocumentId.value) {
|
||||||
|
schedulePreviewGeneration();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const items = computed(() => props.analysisItems ?? []);
|
watch(
|
||||||
|
formState,
|
||||||
defineExpose({
|
() => {
|
||||||
getPreviewRequestItems() {
|
if (!activeDocumentId.value) {
|
||||||
return items.value.map((item) => ({
|
return;
|
||||||
fileName: item.fileName,
|
}
|
||||||
filePath: item.filePath,
|
schedulePreviewGeneration();
|
||||||
strategyConfig: {
|
|
||||||
...getStrategyForm(item.filePath, item),
|
|
||||||
},
|
},
|
||||||
}));
|
{ deep: true },
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
function showLengthSettings(strategyCode?: string) {
|
|
||||||
return [
|
|
||||||
'AUTO',
|
|
||||||
'MARKDOWN_SECTION',
|
|
||||||
'OUTLINE_SECTION',
|
|
||||||
'PARAGRAPH_LENGTH',
|
|
||||||
].includes(strategyCode || '');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="strategy-container">
|
<div class="workbench">
|
||||||
<ElAlert
|
<ElForm :model="formState" label-position="top" class="workbench__form">
|
||||||
:title="$t('documentCollection.importDoc.analysisTip')"
|
<div class="workbench__form-grid">
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
class="strategy-tip"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="strategy-list">
|
|
||||||
<ElCard
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.filePath"
|
|
||||||
class="strategy-card"
|
|
||||||
shadow="never"
|
|
||||||
>
|
|
||||||
<div class="strategy-card__header">
|
|
||||||
<div>
|
|
||||||
<div class="strategy-card__title">{{ item.fileName }}</div>
|
|
||||||
<div class="strategy-card__meta">
|
|
||||||
{{ item.analysis?.recommendedStructureType || '-' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="strategy-card__badges">
|
|
||||||
<ElTag type="success" effect="plain">
|
|
||||||
{{
|
|
||||||
item.analysis?.recommendedStrategyLabel ||
|
|
||||||
$t('documentCollection.splitterDoc.autoStrategy')
|
|
||||||
}}
|
|
||||||
</ElTag>
|
|
||||||
<ElTag effect="plain">
|
|
||||||
{{ $t('documentCollection.importDoc.confidence') }}
|
|
||||||
{{ item.analysis?.confidence ?? 0 }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElRow :gutter="16" class="strategy-card__content">
|
|
||||||
<ElCol :span="12">
|
|
||||||
<div class="strategy-block">
|
|
||||||
<div class="strategy-block__label">
|
|
||||||
{{ $t('documentCollection.importDoc.recommendReason') }}
|
|
||||||
</div>
|
|
||||||
<ul class="strategy-reason-list">
|
|
||||||
<li
|
|
||||||
v-for="reason in item.analysis?.reasons || []"
|
|
||||||
:key="reason"
|
|
||||||
class="strategy-reason-list__item"
|
|
||||||
>
|
|
||||||
{{ reason }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="strategy-block">
|
|
||||||
<div class="strategy-block__label">
|
|
||||||
{{ $t('documentCollection.importDoc.candidateStrategies') }}
|
|
||||||
</div>
|
|
||||||
<div class="strategy-candidate-list">
|
|
||||||
<ElTag
|
|
||||||
v-for="candidate in item.analysis?.candidateStrategies || []"
|
|
||||||
:key="candidate.strategyCode"
|
|
||||||
effect="plain"
|
|
||||||
>
|
|
||||||
{{ candidate.strategyLabel }} / {{ candidate.score }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ElCol>
|
|
||||||
|
|
||||||
<ElCol :span="12">
|
|
||||||
<ElForm
|
|
||||||
:model="getStrategyForm(item.filePath, item)"
|
|
||||||
label-position="top"
|
|
||||||
class="strategy-form"
|
|
||||||
>
|
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
:label="$t('documentCollection.importDoc.strategySelection')"
|
:label="$t('documentCollection.importDoc.strategySelection')"
|
||||||
|
class="workbench__form-full"
|
||||||
>
|
>
|
||||||
<ElSelect
|
<ElSelect v-model="formState.strategyCode" class="w-full">
|
||||||
v-model="getStrategyForm(item.filePath, item).strategyCode"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="option in strategyOptions"
|
v-for="option in strategyOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@@ -231,15 +331,16 @@ function showLengthSettings(strategyCode?: string) {
|
|||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
v-if="
|
v-if="showLengthSettings(formState.strategyCode)"
|
||||||
showLengthSettings(
|
|
||||||
getStrategyForm(item.filePath, item).strategyCode,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
:label="$t('documentCollection.splitterDoc.chunkSize')"
|
:label="$t('documentCollection.splitterDoc.chunkSize')"
|
||||||
|
:class="
|
||||||
|
showOverlapSettings(formState.strategyCode)
|
||||||
|
? ''
|
||||||
|
: 'workbench__form-full'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<ElSlider
|
<ElSlider
|
||||||
v-model="getStrategyForm(item.filePath, item).chunkSize"
|
v-model="formState.chunkSize"
|
||||||
:max="2048"
|
:max="2048"
|
||||||
:min="128"
|
:min="128"
|
||||||
show-input
|
show-input
|
||||||
@@ -247,15 +348,11 @@ function showLengthSettings(strategyCode?: string) {
|
|||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
v-if="
|
v-if="showOverlapSettings(formState.strategyCode)"
|
||||||
getStrategyForm(item.filePath, item).strategyCode ===
|
|
||||||
'PARAGRAPH_LENGTH' ||
|
|
||||||
getStrategyForm(item.filePath, item).strategyCode === 'AUTO'
|
|
||||||
"
|
|
||||||
:label="$t('documentCollection.splitterDoc.overlapSize')"
|
:label="$t('documentCollection.splitterDoc.overlapSize')"
|
||||||
>
|
>
|
||||||
<ElSlider
|
<ElSlider
|
||||||
v-model="getStrategyForm(item.filePath, item).overlapSize"
|
v-model="formState.overlapSize"
|
||||||
:max="512"
|
:max="512"
|
||||||
:min="0"
|
:min="0"
|
||||||
show-input
|
show-input
|
||||||
@@ -263,16 +360,11 @@ function showLengthSettings(strategyCode?: string) {
|
|||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
v-if="
|
v-if="formState.strategyCode === 'MARKDOWN_SECTION'"
|
||||||
getStrategyForm(item.filePath, item).strategyCode ===
|
|
||||||
'MARKDOWN_SECTION'
|
|
||||||
"
|
|
||||||
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
|
:label="$t('documentCollection.splitterDoc.mdSplitterLevel')"
|
||||||
|
class="workbench__form-full"
|
||||||
>
|
>
|
||||||
<ElSelect
|
<ElSelect v-model="formState.mdSplitterLevel" class="w-full">
|
||||||
v-model="getStrategyForm(item.filePath, item).mdSplitterLevel"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="level in mdLevels"
|
v-for="level in mdLevels"
|
||||||
:key="level"
|
:key="level"
|
||||||
@@ -283,111 +375,132 @@ function showLengthSettings(strategyCode?: string) {
|
|||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
v-if="
|
v-if="formState.strategyCode === 'CUSTOM_REGEX'"
|
||||||
getStrategyForm(item.filePath, item).strategyCode ===
|
|
||||||
'CUSTOM_REGEX'
|
|
||||||
"
|
|
||||||
:label="$t('documentCollection.splitterDoc.regex')"
|
:label="$t('documentCollection.splitterDoc.regex')"
|
||||||
|
class="workbench__form-full"
|
||||||
>
|
>
|
||||||
<ElInput v-model="getStrategyForm(item.filePath, item).regex" />
|
<ElInput v-model="formState.regex" />
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
</div>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</ElCol>
|
|
||||||
</ElRow>
|
<section class="workbench__content">
|
||||||
</ElCard>
|
<SplitterDocPreview
|
||||||
|
:loading="previewLoading"
|
||||||
|
:preview-items="previewItems"
|
||||||
|
@preview-session-change="handlePreviewSessionChange"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="previewError" class="workbench__error">
|
||||||
|
{{ previewError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="workbench__footer">
|
||||||
|
<ElButton :disabled="startLoading" @click="handleCancel">
|
||||||
|
{{ $t('button.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:disabled="previewLoading || !currentPreviewSessionId"
|
||||||
|
:loading="startLoading"
|
||||||
|
@click="startIndex"
|
||||||
|
>
|
||||||
|
{{ $t('button.startIndex') }}
|
||||||
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.strategy-container {
|
.workbench {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 20px;
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 4px 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-tip {
|
.workbench__form {
|
||||||
border-radius: 12px;
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-list {
|
.workbench__form-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 6px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-card {
|
.workbench__form-full {
|
||||||
border: 1px solid var(--el-border-color-light);
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench__content {
|
||||||
|
min-height: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench__error {
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--el-color-danger-dark-2);
|
||||||
|
background: color-mix(in srgb, var(--el-color-danger-light-9) 88%, white);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--el-color-danger) 14%, white);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-card__header {
|
.workbench__footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
justify-content: flex-end;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
padding-bottom: 16px;
|
padding: 16px 0 8px;
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
margin-top: auto;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgb(246 248 251 / 0%) 0%,
|
||||||
|
rgb(246 248 251 / 82%) 30%,
|
||||||
|
rgb(246 248 251 / 100%) 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-card__title {
|
:deep(.el-form-item__label) {
|
||||||
font-size: 16px;
|
padding-bottom: 6px;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategy-card__meta {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-card__badges {
|
:deep(.workbench__form .el-input__wrapper),
|
||||||
display: flex;
|
:deep(.workbench__form .el-select__wrapper) {
|
||||||
flex-wrap: wrap;
|
box-shadow: 0 0 0 1px rgb(15 23 42 / 7%) inset;
|
||||||
gap: 8px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-card__content {
|
:deep(.workbench__form .el-slider__runway) {
|
||||||
margin-top: 16px;
|
background: rgb(15 23 42 / 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-block {
|
@media (max-width: 1180px) {
|
||||||
display: flex;
|
.workbench__content {
|
||||||
flex-direction: column;
|
min-height: 520px;
|
||||||
gap: 10px;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.strategy-block + .strategy-block {
|
@media (max-width: 768px) {
|
||||||
margin-top: 16px;
|
.workbench {
|
||||||
}
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.strategy-block__label {
|
.workbench__header,
|
||||||
font-size: 13px;
|
.workbench__form-grid {
|
||||||
font-weight: 600;
|
grid-template-columns: 1fr;
|
||||||
color: var(--el-text-color-primary);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.strategy-reason-list {
|
.workbench__form,
|
||||||
padding-left: 18px;
|
.workbench__content {
|
||||||
margin: 0;
|
min-height: auto;
|
||||||
line-height: 1.7;
|
}
|
||||||
color: var(--el-text-color-regular);
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategy-reason-list__item {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategy-candidate-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategy-form {
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import { computed, ref, watch } from 'vue';
|
|||||||
|
|
||||||
import { $t } from '@easyflow/locales';
|
import { $t } from '@easyflow/locales';
|
||||||
|
|
||||||
import {
|
import { ElEmpty, ElSkeleton, ElTag } from 'element-plus';
|
||||||
ElAlert,
|
|
||||||
ElDescriptions,
|
interface SourceRange {
|
||||||
ElDescriptionsItem,
|
end: number;
|
||||||
ElEmpty,
|
start: number;
|
||||||
ElTabPane,
|
}
|
||||||
ElTabs,
|
|
||||||
ElTag,
|
|
||||||
} from 'element-plus';
|
|
||||||
|
|
||||||
interface ChunkItem {
|
interface ChunkItem {
|
||||||
answer?: string;
|
answer?: string;
|
||||||
@@ -20,30 +17,46 @@ interface ChunkItem {
|
|||||||
chunkType?: string;
|
chunkType?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
headingPath?: string[];
|
headingPath?: string[];
|
||||||
|
options?: Record<string, any>;
|
||||||
partNo?: number;
|
partNo?: number;
|
||||||
partTotal?: number;
|
partTotal?: number;
|
||||||
question?: string;
|
question?: string;
|
||||||
sourceLabel?: string;
|
sourceLabel?: string;
|
||||||
|
sourceRanges?: SourceRange[];
|
||||||
tokenEstimate?: number;
|
tokenEstimate?: number;
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreviewItem {
|
interface PreviewItem {
|
||||||
analysis?: {
|
analysis?: {
|
||||||
confidence?: number;
|
normalizedContent?: string;
|
||||||
recommendedStructureType?: string;
|
recommendedStrategyLabel?: string;
|
||||||
};
|
};
|
||||||
chunks?: ChunkItem[];
|
chunks?: ChunkItem[];
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
normalizedContent?: string;
|
||||||
previewSessionId: string;
|
previewSessionId: string;
|
||||||
strategyLabel?: string;
|
strategyLabel?: string;
|
||||||
totalChunks?: number;
|
totalChunks?: number;
|
||||||
totalWarnings?: number;
|
totalWarnings?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
activeChunkId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
lockedChunkId?: string;
|
||||||
previewItems?: PreviewItem[];
|
previewItems?: PreviewItem[];
|
||||||
}>();
|
}>(),
|
||||||
|
{
|
||||||
|
activeChunkId: '',
|
||||||
|
loading: false,
|
||||||
|
lockedChunkId: '',
|
||||||
|
previewItems: () => [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emits = defineEmits(['previewSessionChange']);
|
||||||
|
|
||||||
const activeFile = ref('');
|
const activeFile = ref('');
|
||||||
|
|
||||||
@@ -60,6 +73,7 @@ watch(
|
|||||||
(items) => {
|
(items) => {
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
activeFile.value = '';
|
activeFile.value = '';
|
||||||
|
emits('previewSessionChange', '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!items.some((item) => item.previewSessionId === activeFile.value)) {
|
if (!items.some((item) => item.previewSessionId === activeFile.value)) {
|
||||||
@@ -68,62 +82,74 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
currentPreview,
|
||||||
|
(preview) => {
|
||||||
|
emits('previewSessionChange', preview?.previewSessionId || '');
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLockedChunk = (chunk: ChunkItem) =>
|
||||||
|
String(chunk.chunkId || '') === String(props.lockedChunkId || '');
|
||||||
|
|
||||||
|
const isActiveChunk = (chunk: ChunkItem) =>
|
||||||
|
String(chunk.chunkId || '') === String(props.activeChunkId || '');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="preview-shell">
|
<div class="preview-shell">
|
||||||
<ElAlert
|
<div class="preview-shell__header">
|
||||||
:title="$t('documentCollection.importDoc.previewTip')"
|
<div>
|
||||||
type="info"
|
<div class="preview-shell__title">
|
||||||
:closable="false"
|
{{ $t('documentCollection.importDoc.previewPaneTitle') }}
|
||||||
class="preview-alert"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentPreview" class="preview-shell__summary">
|
||||||
|
<ElTag effect="plain" round>
|
||||||
|
{{ currentPreview.strategyLabel || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag effect="plain" round>
|
||||||
|
{{ currentPreview.totalChunks || 0 }}
|
||||||
|
{{ $t('documentCollection.importDoc.chunkCount') }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading && previewItems.length === 0"
|
||||||
|
class="preview-shell__loading"
|
||||||
|
>
|
||||||
|
<ElSkeleton animated :rows="7" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<ElEmpty
|
<ElEmpty
|
||||||
v-if="previewItems.length === 0"
|
v-else-if="previewItems.length === 0"
|
||||||
:description="$t('documentCollection.importDoc.previewEmpty')"
|
:description="$t('documentCollection.importDoc.previewEmpty')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="preview-panel">
|
<div v-else class="preview-shell__content">
|
||||||
<ElTabs v-model="activeFile" class="preview-tabs">
|
|
||||||
<ElTabPane
|
|
||||||
v-for="item in previewItems"
|
|
||||||
:key="item.previewSessionId"
|
|
||||||
:label="item.fileName"
|
|
||||||
:name="item.previewSessionId"
|
|
||||||
/>
|
|
||||||
</ElTabs>
|
|
||||||
|
|
||||||
<div v-if="currentPreview" class="preview-detail">
|
|
||||||
<ElDescriptions :column="4" border class="preview-summary">
|
|
||||||
<ElDescriptionsItem :label="$t('documentCollection.fileName')">
|
|
||||||
{{ currentPreview.fileName }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem
|
|
||||||
:label="$t('documentCollection.importDoc.strategySelection')"
|
|
||||||
>
|
|
||||||
{{ currentPreview.strategyLabel || '-' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem :label="$t('documentCollection.total')">
|
|
||||||
{{ currentPreview.totalChunks || 0 }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem
|
|
||||||
:label="$t('documentCollection.importDoc.warningCount')"
|
|
||||||
>
|
|
||||||
{{ currentPreview.totalWarnings || 0 }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
</ElDescriptions>
|
|
||||||
|
|
||||||
<div class="chunk-list">
|
<div class="chunk-list">
|
||||||
<div
|
<div
|
||||||
v-for="chunk in currentPreview.chunks || []"
|
v-for="chunk in currentPreview?.chunks || []"
|
||||||
:key="chunk.chunkId"
|
:key="chunk.chunkId"
|
||||||
class="chunk-card"
|
class="chunk-card"
|
||||||
|
:class="{
|
||||||
|
'chunk-card--active': isActiveChunk(chunk),
|
||||||
|
'chunk-card--locked': isLockedChunk(chunk),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div class="chunk-card__header">
|
<div class="chunk-card__header">
|
||||||
<div>
|
<div class="chunk-card__main">
|
||||||
<div class="chunk-card__title">
|
<div class="chunk-card__title-row">
|
||||||
|
<span class="chunk-card__title">
|
||||||
{{ chunk.sourceLabel || chunk.chunkId }}
|
{{ chunk.sourceLabel || chunk.chunkId }}
|
||||||
|
</span>
|
||||||
|
<span v-if="isLockedChunk(chunk)" class="chunk-card__state">
|
||||||
|
{{ $t('documentCollection.importDoc.lockedState') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="chunk.headingPath && chunk.headingPath.length > 0"
|
v-if="chunk.headingPath && chunk.headingPath.length > 0"
|
||||||
@@ -132,15 +158,19 @@ watch(
|
|||||||
{{ chunk.headingPath.join(' / ') }}
|
{{ chunk.headingPath.join(' / ') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chunk-card__meta">
|
<div class="chunk-card__meta">
|
||||||
<ElTag effect="plain">{{ chunk.chunkType || '-' }}</ElTag>
|
<ElTag effect="plain" round>
|
||||||
<ElTag effect="plain">
|
{{ chunk.chunkType || '-' }}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag effect="plain" round>
|
||||||
{{ chunk.charCount || 0 }} / {{ chunk.tokenEstimate || 0 }}
|
{{ chunk.charCount || 0 }} / {{ chunk.tokenEstimate || 0 }}
|
||||||
</ElTag>
|
</ElTag>
|
||||||
<ElTag
|
<ElTag
|
||||||
v-if="(chunk.partTotal || 1) > 1"
|
v-if="(chunk.partTotal || 1) > 1"
|
||||||
type="warning"
|
|
||||||
effect="plain"
|
effect="plain"
|
||||||
|
round
|
||||||
|
type="warning"
|
||||||
>
|
>
|
||||||
{{ chunk.partNo }}/{{ chunk.partTotal }}
|
{{ chunk.partNo }}/{{ chunk.partTotal }}
|
||||||
</ElTag>
|
</ElTag>
|
||||||
@@ -150,11 +180,11 @@ watch(
|
|||||||
<div v-if="chunk.chunkType === 'qa_pair'" class="qa-block">
|
<div v-if="chunk.chunkType === 'qa_pair'" class="qa-block">
|
||||||
<div class="qa-block__item">
|
<div class="qa-block__item">
|
||||||
<span class="qa-block__label">Q</span>
|
<span class="qa-block__label">Q</span>
|
||||||
<span>{{ chunk.question }}</span>
|
<span class="qa-block__text">{{ chunk.question }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="qa-block__item">
|
<div class="qa-block__item">
|
||||||
<span class="qa-block__label">A</span>
|
<span class="qa-block__label">A</span>
|
||||||
<span>{{ chunk.answer }}</span>
|
<span class="qa-block__text">{{ chunk.answer }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,8 +197,9 @@ watch(
|
|||||||
<ElTag
|
<ElTag
|
||||||
v-for="warning in chunk.warnings"
|
v-for="warning in chunk.warnings"
|
||||||
:key="warning"
|
:key="warning"
|
||||||
type="warning"
|
|
||||||
effect="plain"
|
effect="plain"
|
||||||
|
round
|
||||||
|
type="warning"
|
||||||
>
|
>
|
||||||
{{ warning }}
|
{{ warning }}
|
||||||
</ElTag>
|
</ElTag>
|
||||||
@@ -177,62 +208,128 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.preview-shell {
|
.preview-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-alert {
|
.preview-shell__header {
|
||||||
border-radius: 12px;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-panel {
|
.preview-shell__title {
|
||||||
padding: 20px;
|
font-size: 13px;
|
||||||
background: var(--el-bg-color);
|
font-weight: 500;
|
||||||
border: 1px solid var(--el-border-color-light);
|
color: var(--el-text-color-secondary);
|
||||||
border-radius: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-summary {
|
.preview-shell__summary {
|
||||||
margin-bottom: 20px;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell__loading {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shell__content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-list {
|
.chunk-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
max-height: 560px;
|
min-height: 0;
|
||||||
|
padding-right: 4px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-card {
|
.chunk-card {
|
||||||
padding: 16px;
|
padding: 15px 16px 14px;
|
||||||
background: var(--el-fill-color-blank);
|
cursor: pointer;
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
user-select: none;
|
||||||
|
background: color-mix(in srgb, var(--el-fill-color-blank) 92%, white);
|
||||||
|
border: 1px solid rgb(15 23 42 / 7%);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-card:hover {
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
border-color: rgb(11 111 211 / 22%);
|
||||||
|
box-shadow: 0 10px 20px rgb(15 23 42 / 6%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-card--active {
|
||||||
|
border-color: rgb(22 111 211 / 26%);
|
||||||
|
background: linear-gradient(180deg, rgb(242 248 255 / 96%), #fff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-card--locked {
|
||||||
|
border-color: rgb(11 111 211 / 34%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(11 111 211 / 10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-card__header {
|
.chunk-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chunk-card__main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-card__title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.chunk-card__title {
|
.chunk-card__title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--el-text-color-primary);
|
color: var(--el-text-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chunk-card__state {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0b6fd3;
|
||||||
|
background: rgb(11 111 211 / 8%);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.chunk-card__path {
|
.chunk-card__path {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,15 +337,17 @@ watch(
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-card__content {
|
.chunk-card__content {
|
||||||
margin: 16px 0 0;
|
margin: 14px 0 0;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
line-height: 1.7;
|
font-size: 13px;
|
||||||
|
line-height: 1.75;
|
||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
overflow-wrap: anywhere;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chunk-card__warnings {
|
.chunk-card__warnings {
|
||||||
@@ -259,28 +358,49 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.qa-block {
|
.qa-block {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
gap: 10px;
|
||||||
gap: 12px;
|
padding: 12px 14px;
|
||||||
padding: 12px;
|
margin-top: 14px;
|
||||||
margin-top: 16px;
|
background: rgb(15 23 42 / 3%);
|
||||||
background: var(--el-fill-color-light);
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-block__item {
|
.qa-block__item {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 24px minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
line-height: 1.6;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qa-block__label {
|
.qa-block__label {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--el-color-primary);
|
color: #0b6fd3;
|
||||||
background: var(--el-color-primary-light-9);
|
background: rgb(11 111 211 / 8%);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qa-block__text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.preview-shell__header,
|
||||||
|
.chunk-card__header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-card__meta {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user