diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 5fcbc77..42e4f4b 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -64,6 +64,8 @@ dependencies { implementation("dev.langchain4j:langchain4j-open-ai:1.0.0") implementation("dev.langchain4j:langchain4j-pgvector:1.0.1-beta6") implementation("dev.langchain4j:langchain4j-community-zhipu-ai:1.0.1-beta6") + implementation("dev.langchain4j:langchain4j-document-parser-apache-tika:1.1.0-beta7") + implementation("dev.langchain4j:langchain4j-document-loader-amazon-s3:1.1.0-beta7") implementation("io.projectreactor:reactor-core:3.7.6") testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion") testImplementation("org.testcontainers:postgresql:$testcontainersVersion") @@ -168,14 +170,8 @@ jooq { } forcedTypes { forcedType { - name = "varchar" - includeExpression = ".*" - includeTypes = "JSONB?" - } - forcedType { - name = "varchar" - includeExpression = ".*" - includeTypes = "INET" + isJsonConverter = true + includeTypes = "(?i:JSON|JSONB)" } } } diff --git a/backend/src/main/java/com/zl/mjga/ApplicationService.java b/backend/src/main/java/com/zl/mjga/ApplicationService.java index 4c28dd7..7a97b28 100644 --- a/backend/src/main/java/com/zl/mjga/ApplicationService.java +++ b/backend/src/main/java/com/zl/mjga/ApplicationService.java @@ -2,7 +2,9 @@ package com.zl.mjga; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +@EnableAsync @SpringBootApplication(scanBasePackages = {"com.zl.mjga", "org.jooq.generated"}) public class ApplicationService { diff --git a/backend/src/main/java/com/zl/mjga/config/JacksonConfig.java b/backend/src/main/java/com/zl/mjga/config/JacksonConfig.java new file mode 100644 index 0000000..80f4c7e --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/config/JacksonConfig.java @@ -0,0 +1,35 @@ +package com.zl.mjga.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import java.io.IOException; +import org.jooq.JSON; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> + builder + .serializationInclusion(JsonInclude.Include.USE_DEFAULTS) + .serializers(new JooqJsonSerializer()); + } + + private static class JooqJsonSerializer extends StdSerializer { + public JooqJsonSerializer() { + super(JSON.class); + } + + @Override + public void serialize(JSON value, JsonGenerator gen, SerializerProvider serializers) + throws IOException { + gen.writeRawValue(value.data()); + } + } +} diff --git a/backend/src/main/java/com/zl/mjga/config/ai/ChatModelInitializer.java b/backend/src/main/java/com/zl/mjga/config/ai/ChatModelInitializer.java index eac37d4..0f14e2f 100644 --- a/backend/src/main/java/com/zl/mjga/config/ai/ChatModelInitializer.java +++ b/backend/src/main/java/com/zl/mjga/config/ai/ChatModelInitializer.java @@ -1,11 +1,17 @@ package com.zl.mjga.config.ai; +import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey; + import com.zl.mjga.component.PromptConfiguration; import com.zl.mjga.service.LlmService; import dev.langchain4j.community.model.zhipu.ZhipuAiStreamingChatModel; +import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel; +import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.service.AiServices; +import dev.langchain4j.store.embedding.EmbeddingStore; import lombok.RequiredArgsConstructor; import org.jooq.generated.mjga.enums.LlmCodeEnum; import org.jooq.generated.mjga.tables.pojos.AiLlmConfig; @@ -54,11 +60,26 @@ public class ChatModelInitializer { @Bean @DependsOn("flywayInitializer") - public AiChatAssistant zhiPuChatAssistant(ZhipuAiStreamingChatModel zhipuChatModel) { + public AiChatAssistant zhiPuChatAssistant( + ZhipuAiStreamingChatModel zhipuChatModel, + EmbeddingStore zhiPuLibraryEmbeddingStore, + EmbeddingModel zhipuEmbeddingModel) { return AiServices.builder(AiChatAssistant.class) .streamingChatModel(zhipuChatModel) .systemMessageProvider(chatMemoryId -> promptConfiguration.getSystem()) .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10)) + .contentRetriever( + EmbeddingStoreContentRetriever.builder() + .embeddingStore(zhiPuLibraryEmbeddingStore) + .embeddingModel(zhipuEmbeddingModel) + .minScore(0.75) + .maxResults(5) + .dynamicFilter( + query -> { + String libraryId = (String) query.metadata().chatMemoryId(); + return metadataKey("libraryId").isEqualTo(libraryId); + }) + .build()) .build(); } diff --git a/backend/src/main/java/com/zl/mjga/config/ai/EmbeddingInitializer.java b/backend/src/main/java/com/zl/mjga/config/ai/EmbeddingInitializer.java index bc81f98..79aefd0 100644 --- a/backend/src/main/java/com/zl/mjga/config/ai/EmbeddingInitializer.java +++ b/backend/src/main/java/com/zl/mjga/config/ai/EmbeddingInitializer.java @@ -1,7 +1,10 @@ package com.zl.mjga.config.ai; +import com.zl.mjga.config.minio.MinIoConfig; import com.zl.mjga.service.LlmService; import dev.langchain4j.community.model.zhipu.ZhipuAiEmbeddingModel; +import dev.langchain4j.data.document.loader.amazon.s3.AmazonS3DocumentLoader; +import dev.langchain4j.data.document.loader.amazon.s3.AwsCredentials; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingStore; @@ -42,7 +45,7 @@ public class EmbeddingInitializer { } @Bean - public EmbeddingStore zhiPuEmbeddingStore(EmbeddingModel zhipuEmbeddingModel) { + public EmbeddingStore zhiPuEmbeddingStore() { String hostPort = env.getProperty("DATABASE_HOST_PORT"); String host = hostPort.split(":")[0]; return PgVectorEmbeddingStore.builder() @@ -55,4 +58,28 @@ public class EmbeddingInitializer { .dimension(2048) .build(); } + + @Bean + public EmbeddingStore zhiPuLibraryEmbeddingStore() { + String hostPort = env.getProperty("DATABASE_HOST_PORT"); + String host = hostPort.split(":")[0]; + return PgVectorEmbeddingStore.builder() + .host(host) + .port(env.getProperty("DATABASE_EXPOSE_PORT", Integer.class)) + .database(env.getProperty("DATABASE_DB")) + .user(env.getProperty("DATABASE_USER")) + .password(env.getProperty("DATABASE_PASSWORD")) + .table("mjga.zhipu_library_embedding_store") + .dimension(2048) + .build(); + } + + @Bean + public AmazonS3DocumentLoader amazonS3DocumentLoader(MinIoConfig minIoConfig) { + return AmazonS3DocumentLoader.builder() + .endpointUrl(minIoConfig.getEndpoint()) + .forcePathStyle(true) + .awsCredentials(new AwsCredentials(minIoConfig.getAccessKey(), minIoConfig.getSecretKey())) + .build(); + } } diff --git a/backend/src/main/java/com/zl/mjga/config/security/WebSecurityConfig.java b/backend/src/main/java/com/zl/mjga/config/security/WebSecurityConfig.java index 6592366..d43c3cd 100644 --- a/backend/src/main/java/com/zl/mjga/config/security/WebSecurityConfig.java +++ b/backend/src/main/java/com/zl/mjga/config/security/WebSecurityConfig.java @@ -41,6 +41,7 @@ public class WebSecurityConfig { new AntPathRequestMatcher("/auth/sign-up", HttpMethod.POST.name()), new AntPathRequestMatcher("/v3/api-docs/**", HttpMethod.GET.name()), new AntPathRequestMatcher("/swagger-ui/**", HttpMethod.GET.name()), + new AntPathRequestMatcher("/ai/library/upload", HttpMethod.POST.name()), new AntPathRequestMatcher("/swagger-ui.html", HttpMethod.GET.name()), new AntPathRequestMatcher("/error")); } diff --git a/backend/src/main/java/com/zl/mjga/controller/AiController.java b/backend/src/main/java/com/zl/mjga/controller/AiController.java index 3c912b9..b97dff0 100644 --- a/backend/src/main/java/com/zl/mjga/controller/AiController.java +++ b/backend/src/main/java/com/zl/mjga/controller/AiController.java @@ -2,13 +2,14 @@ package com.zl.mjga.controller; import com.zl.mjga.dto.PageRequestDto; import com.zl.mjga.dto.PageResponseDto; +import com.zl.mjga.dto.ai.ChatDto; import com.zl.mjga.dto.ai.LlmQueryDto; import com.zl.mjga.dto.ai.LlmVm; import com.zl.mjga.exception.BusinessException; import com.zl.mjga.repository.*; import com.zl.mjga.service.AiChatService; -import com.zl.mjga.service.EmbeddingService; import com.zl.mjga.service.LlmService; +import com.zl.mjga.service.RagService; import dev.langchain4j.service.TokenStream; import jakarta.validation.Valid; import java.security.Principal; @@ -35,7 +36,7 @@ public class AiController { private final AiChatService aiChatService; private final LlmService llmService; - private final EmbeddingService embeddingService; + private final RagService ragService; private final UserRepository userRepository; private final DepartmentRepository departmentRepository; private final PositionRepository positionRepository; @@ -72,9 +73,9 @@ public class AiController { } @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public Flux chat(Principal principal, @RequestBody String userMessage) { + public Flux chat(Principal principal, @RequestBody ChatDto chatDto) { Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - TokenStream chat = aiChatService.chatPrecedenceLlmWith(principal.getName(), userMessage); + TokenStream chat = aiChatService.chat(principal.getName(), chatDto); chat.onPartialResponse( text -> sink.tryEmitNext( @@ -109,7 +110,7 @@ public class AiController { if (!aiLlmConfig.getEnable()) { throw new BusinessException("命令模型未启用,请开启后再试。"); } - return embeddingService.searchAction(message); + return ragService.searchAction(message); } @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)") diff --git a/backend/src/main/java/com/zl/mjga/controller/IdentityAccessController.java b/backend/src/main/java/com/zl/mjga/controller/IdentityAccessController.java index bf28040..577f08a 100644 --- a/backend/src/main/java/com/zl/mjga/controller/IdentityAccessController.java +++ b/backend/src/main/java/com/zl/mjga/controller/IdentityAccessController.java @@ -1,6 +1,5 @@ package com.zl.mjga.controller; -import com.zl.mjga.config.minio.MinIoConfig; import com.zl.mjga.dto.PageRequestDto; import com.zl.mjga.dto.PageResponseDto; import com.zl.mjga.dto.department.DepartmentBindDto; @@ -13,17 +12,11 @@ import com.zl.mjga.repository.PermissionRepository; import com.zl.mjga.repository.RoleRepository; import com.zl.mjga.repository.UserRepository; import com.zl.mjga.service.IdentityAccessService; -import io.minio.MinioClient; -import io.minio.PutObjectArgs; +import com.zl.mjga.service.UploadService; import jakarta.validation.Valid; -import java.awt.image.BufferedImage; import java.security.Principal; -import java.time.Instant; import java.util.List; -import javax.imageio.ImageIO; import lombok.RequiredArgsConstructor; -import org.apache.commons.lang3.RandomStringUtils; -import org.apache.commons.lang3.StringUtils; import org.jooq.generated.mjga.tables.pojos.User; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -41,8 +34,7 @@ public class IdentityAccessController { private final UserRepository userRepository; private final RoleRepository roleRepository; private final PermissionRepository permissionRepository; - private final MinioClient minioClient; - private final MinIoConfig minIoConfig; + private final UploadService uploadService; @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)") @PostMapping( @@ -50,40 +42,7 @@ public class IdentityAccessController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.TEXT_PLAIN_VALUE) public String uploadAvatar(@RequestPart("file") MultipartFile multipartFile) throws Exception { - String originalFilename = multipartFile.getOriginalFilename(); - if (StringUtils.isEmpty(originalFilename)) { - throw new BusinessException("文件名不能为空"); - } - String contentType = multipartFile.getContentType(); - String extension = ""; - if ("image/jpeg".equals(contentType)) { - extension = ".jpg"; - } else if ("image/png".equals(contentType)) { - extension = ".png"; - } - String objectName = - String.format( - "/avatar/%d%s%s", - Instant.now().toEpochMilli(), - RandomStringUtils.insecure().nextAlphabetic(6), - extension); - if (multipartFile.isEmpty()) { - throw new BusinessException("上传的文件不能为空"); - } - long size = multipartFile.getSize(); - if (size > 200 * 1024) { - throw new BusinessException("头像文件大小不能超过200KB"); - } - BufferedImage img = ImageIO.read(multipartFile.getInputStream()); - if (img == null) { - throw new BusinessException("非法的上传文件"); - } - minioClient.putObject( - PutObjectArgs.builder().bucket(minIoConfig.getDefaultBucket()).object(objectName).stream( - multipartFile.getInputStream(), size, -1) - .contentType(multipartFile.getContentType()) - .build()); - return objectName; + return uploadService.uploadAvatarFile(multipartFile); } @GetMapping("/me") diff --git a/backend/src/main/java/com/zl/mjga/controller/LibraryController.java b/backend/src/main/java/com/zl/mjga/controller/LibraryController.java new file mode 100644 index 0000000..6ccbc9c --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/controller/LibraryController.java @@ -0,0 +1,90 @@ +package com.zl.mjga.controller; + +import com.zl.mjga.dto.knowledge.DocUpdateDto; +import com.zl.mjga.dto.knowledge.LibraryUpsertDto; +import com.zl.mjga.repository.LibraryDocRepository; +import com.zl.mjga.repository.LibraryDocSegmentRepository; +import com.zl.mjga.repository.LibraryRepository; +import com.zl.mjga.service.RagService; +import com.zl.mjga.service.UploadService; +import jakarta.validation.Valid; +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jooq.generated.mjga.tables.pojos.Library; +import org.jooq.generated.mjga.tables.pojos.LibraryDoc; +import org.jooq.generated.mjga.tables.pojos.LibraryDocSegment; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/knowledge") +@RequiredArgsConstructor +@Slf4j +public class LibraryController { + + private final UploadService uploadService; + private final RagService ragService; + private final LibraryRepository libraryRepository; + private final LibraryDocRepository libraryDocRepository; + private final LibraryDocSegmentRepository libraryDocSegmentRepository; + + @GetMapping("/libraries") + public List queryLibraries() { + return libraryRepository.findAll().stream() + .sorted(Comparator.comparing(Library::getId).reversed()) + .toList(); + } + + @GetMapping("/docs") + public List queryLibraryDocs(@RequestParam Long libraryId) { + return libraryDocRepository.fetchByLibId(libraryId).stream() + .sorted(Comparator.comparing(LibraryDoc::getId).reversed()) + .toList(); + } + + @GetMapping("/segments") + public List queryLibraryDocSegments(@RequestParam Long libraryDocId) { + return libraryDocSegmentRepository.fetchByDocId(libraryDocId); + } + + @PostMapping("/library") + public void upsertLibrary(@RequestBody @Valid LibraryUpsertDto libraryUpsertDto) { + Library library = new Library(); + library.setId(libraryUpsertDto.id()); + library.setName(libraryUpsertDto.name()); + library.setDescription(libraryUpsertDto.description()); + libraryRepository.merge(library); + } + + @DeleteMapping("/library") + public void deleteLibrary(@RequestParam Long libraryId) { + ragService.deleteLibraryBy(libraryId); + } + + @DeleteMapping("/doc") + public void deleteLibraryDoc(@RequestParam Long libraryDocId) { + ragService.deleteDocBy(libraryDocId); + } + + @PutMapping("/doc") + public void updateLibraryDoc(@RequestBody @Valid DocUpdateDto docUpdateDto) { + LibraryDoc exist = libraryDocRepository.fetchOneById(docUpdateDto.id()); + exist.setEnable(docUpdateDto.enable()); + libraryDocRepository.merge(exist); + } + + @PostMapping(value = "/doc/upload", produces = MediaType.TEXT_PLAIN_VALUE) + public String uploadLibraryDoc( + @RequestPart("libraryId") String libraryId, @RequestPart("file") MultipartFile multipartFile) + throws Exception { + String objectName = uploadService.uploadLibraryDoc(multipartFile); + Long libraryDocId = + ragService.createLibraryDocBy( + Long.valueOf(libraryId), objectName, multipartFile.getOriginalFilename()); + ragService.embeddingAndCreateDocSegment(Long.valueOf(libraryId), libraryDocId, objectName); + return objectName; + } +} diff --git a/backend/src/main/java/com/zl/mjga/dto/ai/ChatDto.java b/backend/src/main/java/com/zl/mjga/dto/ai/ChatDto.java new file mode 100644 index 0000000..e2a0e13 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/dto/ai/ChatDto.java @@ -0,0 +1,7 @@ +package com.zl.mjga.dto.ai; + +import com.zl.mjga.model.urp.ChatMode; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public record ChatDto(@NotNull ChatMode mode, Long libraryId, @NotEmpty String message) {} diff --git a/backend/src/main/java/com/zl/mjga/dto/knowledge/DocUpdateDto.java b/backend/src/main/java/com/zl/mjga/dto/knowledge/DocUpdateDto.java new file mode 100644 index 0000000..244d371 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/dto/knowledge/DocUpdateDto.java @@ -0,0 +1,5 @@ +package com.zl.mjga.dto.knowledge; + +import jakarta.validation.constraints.NotNull; + +public record DocUpdateDto(@NotNull Long id, @NotNull Long libId, @NotNull Boolean enable) {} diff --git a/backend/src/main/java/com/zl/mjga/dto/knowledge/LibraryUpsertDto.java b/backend/src/main/java/com/zl/mjga/dto/knowledge/LibraryUpsertDto.java new file mode 100644 index 0000000..f336a03 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/dto/knowledge/LibraryUpsertDto.java @@ -0,0 +1,5 @@ +package com.zl.mjga.dto.knowledge; + +import jakarta.validation.constraints.NotEmpty; + +public record LibraryUpsertDto(Long id, @NotEmpty String name, String description) {} diff --git a/backend/src/main/java/com/zl/mjga/model/urp/ChatMode.java b/backend/src/main/java/com/zl/mjga/model/urp/ChatMode.java new file mode 100644 index 0000000..72184bf --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/model/urp/ChatMode.java @@ -0,0 +1,6 @@ +package com.zl.mjga.model.urp; + +public enum ChatMode { + NORMAL, + WITH_LIBRARY +} diff --git a/backend/src/main/java/com/zl/mjga/repository/LibraryDocRepository.java b/backend/src/main/java/com/zl/mjga/repository/LibraryDocRepository.java new file mode 100644 index 0000000..8e7deb5 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/repository/LibraryDocRepository.java @@ -0,0 +1,14 @@ +package com.zl.mjga.repository; + +import org.jooq.Configuration; +import org.jooq.generated.mjga.tables.daos.LibraryDocDao; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +@Repository +public class LibraryDocRepository extends LibraryDocDao { + @Autowired + public LibraryDocRepository(Configuration configuration) { + super(configuration); + } +} diff --git a/backend/src/main/java/com/zl/mjga/repository/LibraryDocSegmentRepository.java b/backend/src/main/java/com/zl/mjga/repository/LibraryDocSegmentRepository.java new file mode 100644 index 0000000..1afd329 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/repository/LibraryDocSegmentRepository.java @@ -0,0 +1,14 @@ +package com.zl.mjga.repository; + +import org.jooq.Configuration; +import org.jooq.generated.mjga.tables.daos.LibraryDocSegmentDao; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +@Repository +public class LibraryDocSegmentRepository extends LibraryDocSegmentDao { + @Autowired + public LibraryDocSegmentRepository(Configuration configuration) { + super(configuration); + } +} diff --git a/backend/src/main/java/com/zl/mjga/repository/LibraryRepository.java b/backend/src/main/java/com/zl/mjga/repository/LibraryRepository.java new file mode 100644 index 0000000..31e039d --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/repository/LibraryRepository.java @@ -0,0 +1,15 @@ +package com.zl.mjga.repository; + +import org.jooq.Configuration; +import org.jooq.generated.mjga.tables.daos.LibraryDao; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +@Repository +public class LibraryRepository extends LibraryDao { + + @Autowired + public LibraryRepository(Configuration configuration) { + super(configuration); + } +} diff --git a/backend/src/main/java/com/zl/mjga/service/AiChatService.java b/backend/src/main/java/com/zl/mjga/service/AiChatService.java index 9ea1a5c..d7897cd 100644 --- a/backend/src/main/java/com/zl/mjga/service/AiChatService.java +++ b/backend/src/main/java/com/zl/mjga/service/AiChatService.java @@ -2,6 +2,7 @@ package com.zl.mjga.service; import com.zl.mjga.config.ai.AiChatAssistant; import com.zl.mjga.config.ai.SystemToolAssistant; +import com.zl.mjga.dto.ai.ChatDto; import com.zl.mjga.exception.BusinessException; import dev.langchain4j.service.TokenStream; import java.util.Optional; @@ -39,8 +40,20 @@ public class AiChatService { }; } - public TokenStream chatPrecedenceLlmWith(String sessionIdentifier, String userMessage) { + public TokenStream chat(String sessionIdentifier, ChatDto chatDto) { + return switch (chatDto.mode()) { + case NORMAL -> chatWithPrecedenceLlm(sessionIdentifier, chatDto); + case WITH_LIBRARY -> chatWithLibrary(chatDto.libraryId(), chatDto); + }; + } + + public TokenStream chatWithLibrary(Long libraryId, ChatDto chatDto) { + return zhiPuChatAssistant.chat(String.valueOf(libraryId), chatDto.message()); + } + + public TokenStream chatWithPrecedenceLlm(String sessionIdentifier, ChatDto chatDto) { LlmCodeEnum code = getPrecedenceLlmCode(); + String userMessage = chatDto.message(); return switch (code) { case ZHI_PU -> zhiPuChatAssistant.chat(sessionIdentifier, userMessage); case DEEP_SEEK -> deepSeekChatAssistant.chat(sessionIdentifier, userMessage); diff --git a/backend/src/main/java/com/zl/mjga/service/EmbeddingService.java b/backend/src/main/java/com/zl/mjga/service/EmbeddingService.java deleted file mode 100644 index 8967838..0000000 --- a/backend/src/main/java/com/zl/mjga/service/EmbeddingService.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.zl.mjga.service; - -import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey; - -import com.zl.mjga.config.ai.ZhiPuEmbeddingModelConfig; -import com.zl.mjga.model.urp.Actions; -import dev.langchain4j.data.document.Metadata; -import dev.langchain4j.data.embedding.Embedding; -import dev.langchain4j.data.segment.TextSegment; -import dev.langchain4j.model.embedding.EmbeddingModel; -import dev.langchain4j.store.embedding.EmbeddingSearchRequest; -import dev.langchain4j.store.embedding.EmbeddingSearchResult; -import dev.langchain4j.store.embedding.EmbeddingStore; -import dev.langchain4j.store.embedding.filter.Filter; -import jakarta.annotation.PostConstruct; -import java.util.HashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.stereotype.Service; - -@Configuration -@RequiredArgsConstructor -@Service -public class EmbeddingService { - - private final EmbeddingModel zhipuEmbeddingModel; - - private final EmbeddingStore zhiPuEmbeddingStore; - - private final ZhiPuEmbeddingModelConfig zhiPuEmbeddingModelConfig; - - public Map searchAction(String message) { - Map result = new HashMap<>(); - EmbeddingSearchRequest embeddingSearchRequest = - EmbeddingSearchRequest.builder() - .queryEmbedding(zhipuEmbeddingModel.embed(message).content()) - .minScore(0.89) - .build(); - EmbeddingSearchResult embeddingSearchResult = - zhiPuEmbeddingStore.search(embeddingSearchRequest); - if (!embeddingSearchResult.matches().isEmpty()) { - Metadata metadata = embeddingSearchResult.matches().getFirst().embedded().metadata(); - result.put(Actions.INDEX_KEY, metadata.getString(Actions.INDEX_KEY)); - } - return result; - } - - @PostConstruct - public void initActionIndex() { - if (!zhiPuEmbeddingModelConfig.getEnable()) { - return; - } - for (Actions action : Actions.values()) { - Embedding queryEmbedding = zhipuEmbeddingModel.embed(action.getContent()).content(); - Filter createUserFilter = metadataKey(Actions.INDEX_KEY).isEqualTo(action.getCode()); - EmbeddingSearchRequest embeddingSearchRequest = - EmbeddingSearchRequest.builder() - .queryEmbedding(queryEmbedding) - .filter(createUserFilter) - .build(); - EmbeddingSearchResult embeddingSearchResult = - zhiPuEmbeddingStore.search(embeddingSearchRequest); - if (embeddingSearchResult.matches().isEmpty()) { - TextSegment segment = - TextSegment.from( - action.getContent(), Metadata.metadata(Actions.INDEX_KEY, action.getCode())); - Embedding embedding = zhipuEmbeddingModel.embed(segment).content(); - zhiPuEmbeddingStore.add(embedding, segment); - } - } - } -} diff --git a/backend/src/main/java/com/zl/mjga/service/RagService.java b/backend/src/main/java/com/zl/mjga/service/RagService.java new file mode 100644 index 0000000..b2e5b26 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/service/RagService.java @@ -0,0 +1,181 @@ +package com.zl.mjga.service; + +import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zl.mjga.config.ai.ZhiPuEmbeddingModelConfig; +import com.zl.mjga.config.minio.MinIoConfig; +import com.zl.mjga.model.urp.Actions; +import com.zl.mjga.repository.LibraryDocRepository; +import com.zl.mjga.repository.LibraryRepository; +import dev.langchain4j.data.document.Document; +import dev.langchain4j.data.document.Metadata; +import dev.langchain4j.data.document.loader.amazon.s3.AmazonS3DocumentLoader; +import dev.langchain4j.data.document.parser.apache.tika.ApacheTikaDocumentParser; +import dev.langchain4j.data.document.splitter.DocumentByParagraphSplitter; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.output.Response; +import dev.langchain4j.store.embedding.*; +import dev.langchain4j.store.embedding.filter.Filter; +import jakarta.annotation.PostConstruct; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.RandomStringUtils; +import org.jooq.JSON; +import org.jooq.generated.mjga.enums.LibraryDocStatusEnum; +import org.jooq.generated.mjga.tables.daos.LibraryDocSegmentDao; +import org.jooq.generated.mjga.tables.pojos.LibraryDoc; +import org.jooq.generated.mjga.tables.pojos.LibraryDocSegment; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.Async; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Configuration +@RequiredArgsConstructor +@Service +@Slf4j +public class RagService { + + private final EmbeddingModel zhipuEmbeddingModel; + + private final EmbeddingStore zhiPuEmbeddingStore; + + private final EmbeddingStore zhiPuLibraryEmbeddingStore; + + private final ZhiPuEmbeddingModelConfig zhiPuEmbeddingModelConfig; + + private final AmazonS3DocumentLoader amazonS3DocumentLoader; + + private final MinIoConfig minIoConfig; + + private final LibraryRepository libraryRepository; + + private final LibraryDocRepository libraryDocRepository; + + private final LibraryDocSegmentDao libraryDocSegmentDao; + + public void deleteLibraryBy(Long libraryId) { + List libraryDocs = libraryDocRepository.fetchByLibId(libraryId); + List docIds = libraryDocs.stream().map(LibraryDoc::getId).toList(); + for (Long docId : docIds) { + deleteDocBy(docId); + } + libraryRepository.deleteById(libraryId); + } + + public void deleteDocBy(Long docId) { + List libraryDocSegments = libraryDocSegmentDao.fetchByDocId(docId); + List embeddingIdList = + libraryDocSegments.stream().map(LibraryDocSegment::getEmbeddingId).toList(); + if (CollectionUtils.isNotEmpty(embeddingIdList)) { + zhiPuLibraryEmbeddingStore.removeAll(embeddingIdList); + } + libraryDocRepository.deleteById(docId); + } + + public Long createLibraryDocBy(Long libraryId, String objectName, String originalName) + throws JsonProcessingException { + String username = SecurityContextHolder.getContext().getAuthentication().getName(); + String identify = + String.format( + "%d%s_%s", + Instant.now().toEpochMilli(), + RandomStringUtils.insecure().nextAlphabetic(6), + originalName); + Map meta = new HashMap<>(); + meta.put("uploader", username); + LibraryDoc libraryDoc = new LibraryDoc(); + ObjectMapper objectMapper = new ObjectMapper(); + String metaJson = objectMapper.writeValueAsString(meta); + libraryDoc.setMeta(JSON.valueOf(metaJson)); + libraryDoc.setPath(objectName); + libraryDoc.setName(originalName); + libraryDoc.setIdentify(identify); + libraryDoc.setLibId(libraryId); + libraryDoc.setStatus(LibraryDocStatusEnum.INDEXING); + libraryDoc.setEnable(Boolean.TRUE); + libraryDocRepository.insert(libraryDoc); + return libraryDocRepository.fetchOneByIdentify(identify).getId(); + } + + @Async + public void embeddingAndCreateDocSegment(Long libraryId, Long libraryDocId, String objectName) { + Document document = + amazonS3DocumentLoader.loadDocument( + minIoConfig.getDefaultBucket(), objectName, new ApacheTikaDocumentParser()); + List libraryDocSegments = new ArrayList<>(); + DocumentByParagraphSplitter documentByParagraphSplitter = + new DocumentByParagraphSplitter(500, 150); + documentByParagraphSplitter + .split(document) + .forEach( + textSegment -> { + Response embed = zhipuEmbeddingModel.embed(textSegment); + Integer tokenUsage = embed.tokenUsage().totalTokenCount(); + Embedding vector = embed.content(); + textSegment.metadata().put("libraryId", libraryId); + String embeddingId = zhiPuLibraryEmbeddingStore.add(vector, textSegment); + LibraryDocSegment libraryDocSegment = new LibraryDocSegment(); + libraryDocSegment.setEmbeddingId(embeddingId); + libraryDocSegment.setContent(textSegment.text()); + libraryDocSegment.setTokenUsage(tokenUsage); + libraryDocSegment.setDocId(libraryDocId); + libraryDocSegments.add(libraryDocSegment); + }); + libraryDocSegmentDao.insert(libraryDocSegments); + LibraryDoc libraryDoc = libraryDocRepository.fetchOneById(libraryDocId); + libraryDoc.setStatus(LibraryDocStatusEnum.SUCCESS); + libraryDocRepository.update(libraryDoc); + } + + public Map searchAction(String message) { + Map result = new HashMap<>(); + EmbeddingSearchRequest embeddingSearchRequest = + EmbeddingSearchRequest.builder() + .queryEmbedding(zhipuEmbeddingModel.embed(message).content()) + .minScore(0.89) + .build(); + EmbeddingSearchResult embeddingSearchResult = + zhiPuEmbeddingStore.search(embeddingSearchRequest); + if (!embeddingSearchResult.matches().isEmpty()) { + Metadata metadata = embeddingSearchResult.matches().getFirst().embedded().metadata(); + result.put(Actions.INDEX_KEY, metadata.getString(Actions.INDEX_KEY)); + } + return result; + } + + @PostConstruct + public void initActionIndex() { + if (!zhiPuEmbeddingModelConfig.getEnable()) { + return; + } + for (Actions action : Actions.values()) { + Embedding queryEmbedding = zhipuEmbeddingModel.embed(action.getContent()).content(); + Filter createUserFilter = metadataKey(Actions.INDEX_KEY).isEqualTo(action.getCode()); + EmbeddingSearchRequest embeddingSearchRequest = + EmbeddingSearchRequest.builder() + .queryEmbedding(queryEmbedding) + .filter(createUserFilter) + .build(); + EmbeddingSearchResult embeddingSearchResult = + zhiPuEmbeddingStore.search(embeddingSearchRequest); + if (embeddingSearchResult.matches().isEmpty()) { + TextSegment segment = + TextSegment.from( + action.getContent(), Metadata.metadata(Actions.INDEX_KEY, action.getCode())); + Embedding embedding = zhipuEmbeddingModel.embed(segment).content(); + zhiPuEmbeddingStore.add(embedding, segment); + } + } + } +} diff --git a/backend/src/main/java/com/zl/mjga/service/UploadService.java b/backend/src/main/java/com/zl/mjga/service/UploadService.java new file mode 100644 index 0000000..7aa45f5 --- /dev/null +++ b/backend/src/main/java/com/zl/mjga/service/UploadService.java @@ -0,0 +1,81 @@ +package com.zl.mjga.service; + +import com.zl.mjga.config.minio.MinIoConfig; +import com.zl.mjga.exception.BusinessException; +import io.minio.*; +import java.awt.image.BufferedImage; +import java.time.Instant; +import javax.imageio.ImageIO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UploadService { + + private final MinioClient minioClient; + private final MinIoConfig minIoConfig; + + public String uploadAvatarFile(MultipartFile multipartFile) throws Exception { + String originalFilename = multipartFile.getOriginalFilename(); + if (StringUtils.isEmpty(originalFilename)) { + throw new BusinessException("文件名不能为空"); + } + String contentType = multipartFile.getContentType(); + String extension = ""; + if ("image/jpeg".equals(contentType)) { + extension = ".jpg"; + } else if ("image/png".equals(contentType)) { + extension = ".png"; + } + String objectName = + String.format( + "/library/%d%s%s", + Instant.now().toEpochMilli(), + RandomStringUtils.insecure().nextAlphabetic(6), + extension); + if (multipartFile.isEmpty()) { + throw new BusinessException("上传的文件不能为空"); + } + long size = multipartFile.getSize(); + if (size > 200 * 1024) { + throw new BusinessException("头像大小不能超过200KB"); + } + BufferedImage img = ImageIO.read(multipartFile.getInputStream()); + if (img == null) { + throw new BusinessException("非法的上传文件"); + } + minioClient.putObject( + PutObjectArgs.builder().bucket(minIoConfig.getDefaultBucket()).object(objectName).stream( + multipartFile.getInputStream(), size, -1) + .contentType(multipartFile.getContentType()) + .build()); + return objectName; + } + + public String uploadLibraryDoc(MultipartFile multipartFile) throws Exception { + String originalFilename = multipartFile.getOriginalFilename(); + if (StringUtils.isEmpty(originalFilename)) { + throw new BusinessException("文件名不能为空"); + } + String objectName = String.format("/library/%s", originalFilename); + if (multipartFile.isEmpty()) { + throw new BusinessException("上传的文件不能为空"); + } + long size = multipartFile.getSize(); + if (size > 1024 * 1024) { + throw new BusinessException("知识库文档大小不能超过1MB"); + } + minioClient.putObject( + PutObjectArgs.builder().bucket(minIoConfig.getDefaultBucket()).object(objectName).stream( + multipartFile.getInputStream(), size, -1) + .contentType(multipartFile.getContentType()) + .build()); + return objectName; + } +} diff --git a/backend/src/main/resources/db/migration/V1_0_0__init_table.sql b/backend/src/main/resources/db/migration/V1_0_0__init_table.sql index e76556f..2eb34b4 100644 --- a/backend/src/main/resources/db/migration/V1_0_0__init_table.sql +++ b/backend/src/main/resources/db/migration/V1_0_0__init_table.sql @@ -4,7 +4,7 @@ CREATE TABLE mjga.user ( id BIGSERIAL PRIMARY KEY, username VARCHAR NOT NULL UNIQUE, avatar VARCHAR, - create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, password VARCHAR NOT NULL, enable BOOLEAN NOT NULL DEFAULT TRUE ); @@ -39,7 +39,7 @@ CREATE TABLE mjga.user_role_map ( CREATE TABLE mjga.department ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR NOT NULL UNIQUE, parent_id BIGINT, FOREIGN KEY (parent_id) REFERENCES mjga.department(id) @@ -56,7 +56,7 @@ CREATE TABLE mjga.user_department_map ( CREATE TABLE mjga.position ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE + name VARCHAR NOT NULL UNIQUE ); CREATE TABLE mjga.user_position_map ( @@ -80,12 +80,12 @@ CREATE TYPE "llm_type_enum" AS ENUM ( CREATE TABLE mjga.ai_llm_config ( id BIGSERIAL NOT NULL UNIQUE, - name VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR NOT NULL UNIQUE, code mjga.llm_code_enum NOT NULL UNIQUE, - model_name VARCHAR(255) NOT NULL, + model_name VARCHAR NOT NULL, type LLM_TYPE_ENUM NOT NULL, - api_key VARCHAR(255) NOT NULL, - url VARCHAR(255) NOT NULL, + api_key VARCHAR NOT NULL, + url VARCHAR NOT NULL, enable BOOLEAN NOT NULL DEFAULT true, priority SMALLINT NOT NULL DEFAULT 0, PRIMARY KEY(id) diff --git a/backend/src/main/resources/db/migration/V1_0_3__init_library.sql b/backend/src/main/resources/db/migration/V1_0_3__init_library.sql new file mode 100644 index 0000000..166a832 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1_0_3__init_library.sql @@ -0,0 +1,34 @@ +CREATE TABLE mjga.library ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL UNIQUE, + description VARCHAR, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TYPE mjga.library_doc_status_enum AS ENUM ( + 'SUCCESS', + 'INDEXING' +); + +CREATE TABLE mjga.library_doc ( + id BIGSERIAL PRIMARY KEY, + lib_id BIGINT NOT NULL, + name VARCHAR NOT NULL, + identify VARCHAR NOT NULL UNIQUE, + path VARCHAR NOT NULL, + meta JSON NOT NULL, + enable BOOLEAN NOT NULL DEFAULT true, + status mjga.library_doc_status_enum NOT NULL, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMPTZ, + FOREIGN KEY (lib_id) REFERENCES mjga.library (id) ON DELETE CASCADE +); + +CREATE TABLE mjga.library_doc_segment ( + id BIGSERIAL PRIMARY KEY, + doc_id BIGINT NOT NULL, + embedding_id VARCHAR NOT NULL UNIQUE, + content TEXT, + token_usage INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (doc_id) REFERENCES mjga.library_doc (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/backend/src/test/resources/db/migration/test/V1_0_0__init_table.sql b/backend/src/test/resources/db/migration/test/V1_0_0__init_table.sql index a77b7de..daca81a 100644 --- a/backend/src/test/resources/db/migration/test/V1_0_0__init_table.sql +++ b/backend/src/test/resources/db/migration/test/V1_0_0__init_table.sql @@ -4,7 +4,7 @@ CREATE TABLE mjga.user ( id BIGSERIAL PRIMARY KEY, username VARCHAR NOT NULL UNIQUE, avatar VARCHAR, - create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, password VARCHAR NOT NULL, enable BOOLEAN NOT NULL DEFAULT TRUE ); @@ -39,7 +39,7 @@ CREATE TABLE mjga.user_role_map ( CREATE TABLE mjga.department ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR NOT NULL UNIQUE, parent_id BIGINT, FOREIGN KEY (parent_id) REFERENCES mjga.department(id) @@ -56,7 +56,7 @@ CREATE TABLE mjga.user_department_map ( CREATE TABLE mjga.position ( id BIGSERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE + name VARCHAR NOT NULL UNIQUE ); CREATE TABLE mjga.user_position_map ( @@ -80,12 +80,12 @@ CREATE TYPE "llm_type_enum" AS ENUM ( CREATE TABLE mjga.ai_llm_config ( id BIGSERIAL NOT NULL UNIQUE, - name VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR NOT NULL UNIQUE, code mjga.llm_code_enum NOT NULL UNIQUE, - model_name VARCHAR(255) NOT NULL, + model_name VARCHAR NOT NULL, type LLM_TYPE_ENUM NOT NULL, - api_key VARCHAR(255) NOT NULL, - url VARCHAR(255) NOT NULL, + api_key VARCHAR NOT NULL, + url VARCHAR NOT NULL, enable BOOLEAN NOT NULL DEFAULT true, priority SMALLINT NOT NULL DEFAULT 0, PRIMARY KEY(id) diff --git a/backend/src/test/resources/db/migration/test/V1_0_3__init_library.sql b/backend/src/test/resources/db/migration/test/V1_0_3__init_library.sql new file mode 100644 index 0000000..e1cdde8 --- /dev/null +++ b/backend/src/test/resources/db/migration/test/V1_0_3__init_library.sql @@ -0,0 +1,28 @@ +CREATE TABLE mjga.library ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL UNIQUE, + data_count INTEGER NOT NULL DEFAULT 0, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE mjga.library_doc ( + id BIGSERIAL PRIMARY KEY, + lib_id BIGINT NOT NULL, + name VARCHAR NOT NULL, + identify VARCHAR NOT NULL UNIQUE, + path VARCHAR NOT NULL, + meta JSON NOT NULL, + enable BOOLEAN NOT NULL DEFAULT true, + create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMPTZ, + FOREIGN KEY (lib_id) REFERENCES mjga.library (id) ON DELETE CASCADE +); + +CREATE TABLE mjga.library_doc_segment ( + id BIGSERIAL PRIMARY KEY, + doc_id BIGINT NOT NULL, + embedding_id VARCHAR NOT NULL UNIQUE, + content TEXT, + token_usage INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (doc_id) REFERENCES mjga.library_doc (id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index 59847e1..4a3e8e6 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -186,3 +186,5 @@ compose.yaml Dockerfile Caddyfile start.sh + +.cursor diff --git a/frontend/src/api/schema/openapi.json b/frontend/src/api/schema/openapi.json index 0822105..8dbaf0b 100644 --- a/frontend/src/api/schema/openapi.json +++ b/frontend/src/api/schema/openapi.json @@ -44,6 +44,51 @@ } } }, + "/knowledge/doc": { + "put": { + "tags": [ + "library-controller" + ], + "operationId": "updateLibraryDoc", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "tags": [ + "library-controller" + ], + "operationId": "deleteLibraryDoc", + "parameters": [ + { + "name": "libraryDocId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/ai/llm": { "put": { "tags": [ @@ -192,6 +237,93 @@ } } }, + "/knowledge/library": { + "post": { + "tags": [ + "library-controller" + ], + "operationId": "upsertLibrary", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "tags": [ + "library-controller" + ], + "operationId": "deleteLibrary", + "parameters": [ + { + "name": "libraryId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/knowledge/doc/upload": { + "post": { + "tags": [ + "library-controller" + ], + "operationId": "uploadLibraryDoc", + "requestBody": { + "content": { + "application/json": { + "schema": { + "required": [ + "file", + "libraryId" + ], + "type": "object", + "properties": { + "libraryId": { + "type": "string" + }, + "file": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/iam/user": { "get": { "tags": [ @@ -762,7 +894,7 @@ "content": { "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/ChatDto" } } }, @@ -963,6 +1095,97 @@ } } }, + "/knowledge/segments": { + "get": { + "tags": [ + "library-controller" + ], + "operationId": "queryLibraryDocSegments", + "parameters": [ + { + "name": "libraryDocId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryDocSegment" + } + } + } + } + } + } + } + }, + "/knowledge/libraries": { + "get": { + "tags": [ + "library-controller" + ], + "operationId": "queryLibraries", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Library" + } + } + } + } + } + } + } + }, + "/knowledge/docs": { + "get": { + "tags": [ + "library-controller" + ], + "operationId": "queryLibraryDocs", + "parameters": [ + { + "name": "libraryId", + "in": "query", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LibraryDoc" + } + } + } + } + } + } + } + }, "/iam/users": { "get": { "tags": [ @@ -1354,6 +1577,27 @@ } } }, + "DocUpdateDto": { + "required": [ + "enable", + "id", + "libId" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "libId": { + "type": "integer", + "format": "int64" + }, + "enable": { + "type": "boolean" + } + } + }, "LlmVm": { "required": [ "apiKey", @@ -1422,6 +1666,24 @@ } } }, + "LibraryUpsertDto": { + "required": [ + "name" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, "UserUpsertDto": { "required": [ "enable", @@ -1611,6 +1873,29 @@ } } }, + "ChatDto": { + "required": [ + "message", + "mode" + ], + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "NORMAL", + "WITH_LIBRARY" + ] + }, + "libraryId": { + "type": "integer", + "format": "int64" + }, + "message": { + "type": "string" + } + } + }, "PageRequestDto": { "type": "object", "properties": { @@ -1789,6 +2074,94 @@ } } }, + "LibraryDocSegment": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "docId": { + "type": "integer", + "format": "int64" + }, + "embeddingId": { + "type": "string" + }, + "content": { + "type": "string" + }, + "tokenUsage": { + "type": "integer", + "format": "int32" + } + } + }, + "Library": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "createTime": { + "type": "string", + "format": "date-time" + } + } + }, + "JSON": { + "type": "object" + }, + "LibraryDoc": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "libId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "identify": { + "type": "string" + }, + "path": { + "type": "string" + }, + "meta": { + "$ref": "#/components/schemas/JSON" + }, + "enable": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "SUCCESS", + "INDEXING" + ] + }, + "createTime": { + "type": "string", + "format": "date-time" + }, + "updateTime": { + "type": "string", + "format": "date-time" + } + } + }, "UserQueryDto": { "type": "object", "properties": { diff --git a/frontend/src/api/types/schema.d.ts b/frontend/src/api/types/schema.d.ts index 80fea2c..13459af 100644 --- a/frontend/src/api/types/schema.d.ts +++ b/frontend/src/api/types/schema.d.ts @@ -20,6 +20,22 @@ export interface paths { patch?: never; trace?: never; }; + "/knowledge/doc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["updateLibraryDoc"]; + post?: never; + delete: operations["deleteLibraryDoc"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ai/llm": { parameters: { query?: never; @@ -100,6 +116,38 @@ export interface paths { patch?: never; trace?: never; }; + "/knowledge/library": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["upsertLibrary"]; + delete: operations["deleteLibrary"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/knowledge/doc/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["uploadLibraryDoc"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/iam/user": { parameters: { query?: never; @@ -484,6 +532,54 @@ export interface paths { patch?: never; trace?: never; }; + "/knowledge/segments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["queryLibraryDocSegments"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/knowledge/libraries": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["queryLibraries"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/knowledge/docs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["queryLibraryDocs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/iam/users": { parameters: { query?: never; @@ -684,6 +780,13 @@ export interface components { name: string; group: string; }; + DocUpdateDto: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + libId: number; + enable: boolean; + }; LlmVm: { /** Format: int64 */ id: number; @@ -705,6 +808,12 @@ export interface components { id?: number; name?: string; }; + LibraryUpsertDto: { + /** Format: int64 */ + id?: number; + name: string; + description?: string; + }; UserUpsertDto: { /** Format: int64 */ id?: number; @@ -760,6 +869,13 @@ export interface components { username: string; password: string; }; + ChatDto: { + /** @enum {string} */ + mode: "NORMAL" | "WITH_LIBRARY"; + /** Format: int64 */ + libraryId?: number; + message: string; + }; PageRequestDto: { /** Format: int64 */ page?: number; @@ -829,6 +945,42 @@ export interface components { name: string; isBound?: boolean; }; + LibraryDocSegment: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + docId?: number; + embeddingId?: string; + content?: string; + /** Format: int32 */ + tokenUsage?: number; + }; + Library: { + /** Format: int64 */ + id?: number; + name?: string; + description?: string; + /** Format: date-time */ + createTime?: string; + }; + JSON: Record; + LibraryDoc: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + libId?: number; + name?: string; + identify?: string; + path?: string; + meta?: components["schemas"]["JSON"]; + enable?: boolean; + /** @enum {string} */ + status?: "SUCCESS" | "INDEXING"; + /** Format: date-time */ + createTime?: string; + /** Format: date-time */ + updateTime?: string; + }; UserQueryDto: { username?: string; /** Format: date-time */ @@ -973,6 +1125,48 @@ export interface operations { }; }; }; + updateLibraryDoc: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DocUpdateDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteLibraryDoc: { + parameters: { + query: { + libraryDocId: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; updateLlm: { parameters: { query?: never; @@ -1105,6 +1299,76 @@ export interface operations { }; }; }; + upsertLibrary: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LibraryUpsertDto"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteLibrary: { + parameters: { + query: { + libraryId: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + uploadLibraryDoc: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + libraryId: string; + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/plain": string; + }; + }; + }; + }; queryUserWithRolePermission: { parameters: { query: { @@ -1633,7 +1897,7 @@ export interface operations { }; requestBody: { content: { - "application/json": string; + "application/json": components["schemas"]["ChatDto"]; }; }; responses: { @@ -1782,6 +2046,70 @@ export interface operations { }; }; }; + queryLibraryDocSegments: { + parameters: { + query: { + libraryDocId: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["LibraryDocSegment"][]; + }; + }; + }; + }; + queryLibraries: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Library"][]; + }; + }; + }; + }; + queryLibraryDocs: { + parameters: { + query: { + libraryId: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["LibraryDoc"][]; + }; + }; + }; + }; queryUsers: { parameters: { query: { diff --git a/frontend/src/components/common/Assistant.vue b/frontend/src/components/common/Assistant.vue index 387469c..ba0a6b4 100644 --- a/frontend/src/components/common/Assistant.vue +++ b/frontend/src/components/common/Assistant.vue @@ -5,14 +5,18 @@
  • -
    + :class="['flex flex-col leading-1.5 p-4 border-gray-200 max-w-[calc(100%-40px)]', chatElement.isUser ? 'bg-blue-100 rounded-tl-xl rounded-bl-xl rounded-br-xl' : 'bg-gray-100 rounded-e-xl rounded-es-xl']">
    {{ chatElement.username }} + + {{ chatElement.libraryName }} +
    - +
    + + + +
    @@ -51,7 +65,7 @@ " required>
    -
    - + @@ -53,6 +53,7 @@ diff --git a/frontend/src/components/ui/Button.vue b/frontend/src/components/ui/Button.vue new file mode 100644 index 0000000..b21f933 --- /dev/null +++ b/frontend/src/components/ui/Button.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 0000000..e4900c2 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,7 @@ +import Alert from "./Alert.vue"; +import Avatar from "./Avatar.vue"; +import Button from "./Button.vue"; +import InputButton from "./InputButton.vue"; +import SortIcon from "./SortIcon.vue"; + +export { Alert, Avatar, Button, InputButton, SortIcon }; diff --git a/frontend/src/composables/ai/useAiChat.ts b/frontend/src/composables/ai/useAiChat.ts index 0c55e3c..9e326d2 100644 --- a/frontend/src/composables/ai/useAiChat.ts +++ b/frontend/src/composables/ai/useAiChat.ts @@ -11,13 +11,19 @@ export const useAiChat = () => { isUser: boolean; username: string; command?: string; + withLibrary?: boolean; + libraryName?: string; }[] >([]); const isLoading = ref(false); let currentController: AbortController | null = null; - const chat = async (message: string) => { + const chat = async ( + message: string, + libraryId?: number | null, + libraryName?: string, + ) => { isLoading.value = true; const authStore = useAuthStore(); const ctrl = new AbortController(); @@ -27,6 +33,8 @@ export const useAiChat = () => { type: "chat", isUser: false, username: "知路智能体", + withLibrary: libraryId !== undefined, + libraryName: libraryName, }); try { const baseUrl = `${import.meta.env.VITE_BASE_URL}`; @@ -36,7 +44,11 @@ export const useAiChat = () => { Authorization: authStore.get(), "Content-Type": "application/json", }, - body: message, + body: JSON.stringify({ + mode: libraryId !== undefined ? "WITH_LIBRARY" : "NORMAL", + libraryId: libraryId, + message: message, + }), signal: ctrl.signal, onmessage(ev) { messages.value[messages.value.length - 1].content += ev.data; diff --git a/frontend/src/composables/common/index.ts b/frontend/src/composables/common/index.ts new file mode 100644 index 0000000..e70b26e --- /dev/null +++ b/frontend/src/composables/common/index.ts @@ -0,0 +1,6 @@ +import { useErrorHandling } from "./useErrorHandling"; +import { usePagination } from "./usePagination"; +import { useSorting } from "./useSorting"; +import { useStyleSystem } from "./useStyleSystem"; + +export { useErrorHandling, usePagination, useSorting, useStyleSystem }; diff --git a/frontend/src/composables/knowledge/useKnowledgeQuery.ts b/frontend/src/composables/knowledge/useKnowledgeQuery.ts new file mode 100644 index 0000000..cdad5d2 --- /dev/null +++ b/frontend/src/composables/knowledge/useKnowledgeQuery.ts @@ -0,0 +1,65 @@ +import client from "@/api/client"; +import type { + DocQueryParams, + Library, + LibraryDoc, + LibraryDocSegment, + SegmentQueryParams, +} from "@/types/KnowledgeTypes"; +import { ref } from "vue"; + +export const useKnowledgeQuery = () => { + const libraries = ref([]); + const docs = ref([]); + const segments = ref([]); + const doc = ref(null); + + const fetchLibraries = async () => { + const { data } = await client.GET("/knowledge/libraries", {}); + libraries.value = data || []; + }; + + const fetchLibraryDocs = async (params: DocQueryParams) => { + const { data } = await client.GET("/knowledge/docs", { + params: { + query: { + libraryId: params.libraryId, + }, + }, + }); + docs.value = data || []; + }; + + const fetchDocById = async (docId: number) => { + const { data } = await client.GET("/knowledge/docs", { + params: { + query: {}, + }, + }); + if (data && Array.isArray(data)) { + doc.value = data.find((item) => item.id === docId) || null; + } + }; + + const fetchDocSegments = async (params: SegmentQueryParams) => { + const { data } = await client.GET("/knowledge/segments", { + params: { + query: { + libraryDocId: params.libraryDocId || params.docId || 0, + }, + }, + }); + segments.value = data || []; + }; + + return { + libraries, + fetchLibraries, + docs, + fetchLibraryDocs, + doc, + fetchDocById, + segments, + fetchDocSegments, + }; +}; diff --git a/frontend/src/composables/knowledge/useKnowledgeUpsert.ts b/frontend/src/composables/knowledge/useKnowledgeUpsert.ts new file mode 100644 index 0000000..f30c201 --- /dev/null +++ b/frontend/src/composables/knowledge/useKnowledgeUpsert.ts @@ -0,0 +1,72 @@ +import client from "@/api/client"; +import type { + DocUpdateModel, + LibraryUpsertModel, +} from "@/types/KnowledgeTypes"; + +export const useKnowledgeUpsert = () => { + const upsertLibrary = async (library: LibraryUpsertModel) => { + await client.POST("/knowledge/library", { + body: { + id: library.id, + name: library.name, + description: library.description, + }, + }); + }; + + const deleteLibrary = async (libraryId: number) => { + await client.DELETE("/knowledge/library", { + params: { + query: { + libraryId, + }, + }, + }); + }; + + const uploadDoc = async (libraryId: number, file: File) => { + await client.POST("/knowledge/doc/upload", { + body: { + libraryId: libraryId.toString(), + file: file as unknown as string, + }, + bodySerializer: (body) => { + const formData = new FormData(); + for (const [key, value] of Object.entries(body!)) { + formData.set(key, value as unknown as string); + } + return formData; + }, + parseAs: "text", + }); + }; + + const deleteDoc = async (libraryDocId: number) => { + await client.DELETE("/knowledge/doc", { + params: { + query: { + libraryDocId, + }, + }, + }); + }; + + const updateDoc = async (doc: DocUpdateModel) => { + await client.PUT("/knowledge/doc", { + body: { + id: doc.id, + libId: doc.libId, + enable: doc.enable, + }, + }); + }; + + return { + upsertLibrary, + deleteLibrary, + uploadDoc, + deleteDoc, + updateDoc, + }; +}; diff --git a/frontend/src/router/constants.ts b/frontend/src/router/constants.ts index ab23b80..d8ed54f 100644 --- a/frontend/src/router/constants.ts +++ b/frontend/src/router/constants.ts @@ -133,9 +133,9 @@ export const UserRoutes = { export const AiRoutes = { LLMCONFIGVIEW: { path: "llm/config", - name: "llm/config", + name: "llm-config", fullPath: () => `${BaseRoutes.DASHBOARD.path}/llm/config`, - withParams: () => ({ name: "llm/config" }), + withParams: () => ({ name: "llm-config" }), }, SCHEDULERVIEW: { path: "scheduler", @@ -143,6 +143,37 @@ export const AiRoutes = { fullPath: () => `${BaseRoutes.DASHBOARD.path}/scheduler`, withParams: () => ({ name: "scheduler" }), }, + KNOWLEDGEVIEW: { + path: "knowledge", + name: "knowledge", + fullPath: () => `${BaseRoutes.DASHBOARD.path}/knowledge`, + withParams: () => ({ name: "knowledge" }), + }, + KNOWLEDGEDOCVIEW: { + path: "knowledge/:libraryId", + name: "knowledge-docs", + fullPath: () => `${BaseRoutes.DASHBOARD.path}/knowledge/:libraryId`, + withParams: (params: T) => ({ + name: "knowledge-docs", + params: { libraryId: params.libraryId.toString() }, + }), + }, + KNOWLEDGESEGMENTSVIEW: { + path: "knowledge/:libraryId/:docId", + name: "knowledge-segments", + fullPath: () => `${BaseRoutes.DASHBOARD.path}/knowledge/:libraryId/:docId`, + withParams: < + T extends { libraryId: string | number; docId: string | number }, + >( + params: T, + ) => ({ + name: "knowledge-segments", + params: { + libraryId: params.libraryId.toString(), + docId: params.docId.toString(), + }, + }), + }, } as const; export const Routes = { diff --git a/frontend/src/router/guards.ts b/frontend/src/router/guards.ts index df43b55..8b56689 100644 --- a/frontend/src/router/guards.ts +++ b/frontend/src/router/guards.ts @@ -13,7 +13,7 @@ export const authGuard: NavigationGuard = (to) => { }; } if (to.path === Routes.LOGIN.path && userStore.user) { - return { path: `${Routes.DASHBOARD.path}/${Routes.USERVIEW.path}` }; + return { path: Routes.USERVIEW.fullPath() }; } }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index d96e956..c88f252 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -15,7 +15,7 @@ const routes: RouteRecordRaw[] = [ path: Routes.HOME.path, name: Routes.HOME.name, redirect: { - path: `${Routes.DASHBOARD.path}/${Routes.USERVIEW.path}`, + path: Routes.USERVIEW.fullPath(), }, }, ]; @@ -27,7 +27,7 @@ const router = createRouter({ router.onError((err) => { console.error("router err:", err); - router.push(Routes.USERVIEW.name); + router.push(Routes.USERVIEW.fullPath()); return false; }); diff --git a/frontend/src/router/modules/ai.ts b/frontend/src/router/modules/ai.ts index 0bce41d..e48f45b 100644 --- a/frontend/src/router/modules/ai.ts +++ b/frontend/src/router/modules/ai.ts @@ -11,6 +11,30 @@ const aiRoutes: RouteRecordRaw[] = [ hasPermission: EPermission.READ_LLM_CONFIG_PERMISSION, }, }, + { + path: Routes.KNOWLEDGEVIEW.path, + name: Routes.KNOWLEDGEVIEW.name, + component: () => import("@/views/KnowledgeManagementPage.vue"), + meta: { + requiresAuth: true, + }, + }, + { + path: Routes.KNOWLEDGEDOCVIEW.path, + name: Routes.KNOWLEDGEDOCVIEW.name, + component: () => import("@/views/KnowledgeDocManagementPage.vue"), + meta: { + requiresAuth: true, + }, + }, + { + path: Routes.KNOWLEDGESEGMENTSVIEW.path, + name: Routes.KNOWLEDGESEGMENTSVIEW.name, + component: () => import("@/views/KnowledgeSegmentsPage.vue"), + meta: { + requiresAuth: true, + }, + }, ]; export default aiRoutes; diff --git a/frontend/src/router/modules/dashboard.ts b/frontend/src/router/modules/dashboard.ts index ecb391d..6de228e 100644 --- a/frontend/src/router/modules/dashboard.ts +++ b/frontend/src/router/modules/dashboard.ts @@ -12,6 +12,8 @@ const dashboardRoutes: RouteRecordRaw = { requiresAuth: true, }, children: [ + ...userManagementRoutes, + ...aiRoutes, { path: Routes.OVERVIEW.path, name: Routes.OVERVIEW.name, @@ -28,8 +30,6 @@ const dashboardRoutes: RouteRecordRaw = { requiresAuth: true, }, }, - ...userManagementRoutes, - ...aiRoutes, { path: Routes.NOTFOUND.path, name: Routes.NOTFOUND.name, diff --git a/frontend/src/types/KnowledgeTypes.ts b/frontend/src/types/KnowledgeTypes.ts new file mode 100644 index 0000000..76f1957 --- /dev/null +++ b/frontend/src/types/KnowledgeTypes.ts @@ -0,0 +1,36 @@ +import type { components } from "@/api/types/schema"; + +export type Library = components["schemas"]["Library"]; +export type LibraryDoc = components["schemas"]["LibraryDoc"]; +export type LibraryDocSegment = components["schemas"]["LibraryDocSegment"]; + +export interface LibraryUpsertModel { + id?: number; + name: string; + description?: string; +} + +export interface DocUpdateModel { + id: number; + libId: number; + enable: boolean; +} + +export interface LibraryQueryParams { + page: number; + size: number; +} + +export interface DocQueryParams { + libraryId: number; +} + +export interface SegmentQueryParams { + libraryDocId?: number; + docId?: number; +} + +export enum DocStatus { + SUCCESS = "SUCCESS", + INDEXING = "INDEXING", +} diff --git a/frontend/src/utils/avatarUtil.ts b/frontend/src/utils/avatarUtil.ts index 75f3ec0..7aac218 100644 --- a/frontend/src/utils/avatarUtil.ts +++ b/frontend/src/utils/avatarUtil.ts @@ -1,5 +1,16 @@ -export const getUserAvatarUrl = (avatar?: string): string | undefined => { - if (avatar?.startsWith("/")) { +/** + * 获取用户头像URL + * @param avatar 头像路径 + * @returns 完整的头像URL或默认头像 + */ +export const getUserAvatarUrl = (avatar?: string): string => { + if (!avatar) { + return "/trump.jpg"; // 默认头像 + } + + if (avatar.startsWith("/")) { return `${import.meta.env.VITE_STATIC_URL}${avatar}`; } + + return avatar; // 如果已经是完整URL则直接返回 }; diff --git a/frontend/src/utils/dateUtil.ts b/frontend/src/utils/dateUtil.ts index 1fc88cf..ea18120 100644 --- a/frontend/src/utils/dateUtil.ts +++ b/frontend/src/utils/dateUtil.ts @@ -13,4 +13,9 @@ const formatDate = (date?: Date) => { return dayjs(date).format("YYYY-MM-DDTHH:mm:ss.SSSZ"); }; -export { dayjs, formatDate }; +const formatDateString = (dateString?: string, format = "YYYY-MM-DD HH:mm") => { + if (!dateString) return "未知"; + return dayjs(dateString).format(format); +}; + +export { dayjs, formatDate, formatDateString }; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts new file mode 100644 index 0000000..a61aaf8 --- /dev/null +++ b/frontend/src/utils/index.ts @@ -0,0 +1,4 @@ +import { getUserAvatarUrl } from "./avatarUtil"; +import { dayjs, formatDate, formatDateString } from "./dateUtil"; + +export { getUserAvatarUrl, dayjs, formatDate, formatDateString }; diff --git a/frontend/src/views/KnowledgeDocManagementPage.vue b/frontend/src/views/KnowledgeDocManagementPage.vue new file mode 100644 index 0000000..2f68ebb --- /dev/null +++ b/frontend/src/views/KnowledgeDocManagementPage.vue @@ -0,0 +1,203 @@ + + + diff --git a/frontend/src/views/KnowledgeManagementPage.vue b/frontend/src/views/KnowledgeManagementPage.vue new file mode 100644 index 0000000..b87ce8a --- /dev/null +++ b/frontend/src/views/KnowledgeManagementPage.vue @@ -0,0 +1,167 @@ + + + diff --git a/frontend/src/views/KnowledgeSegmentsPage.vue b/frontend/src/views/KnowledgeSegmentsPage.vue new file mode 100644 index 0000000..ee53bcf --- /dev/null +++ b/frontend/src/views/KnowledgeSegmentsPage.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/src/views/RoleManagementPage.vue b/frontend/src/views/RoleManagementPage.vue index 1fa0805..d4ffb6a 100644 --- a/frontend/src/views/RoleManagementPage.vue +++ b/frontend/src/views/RoleManagementPage.vue @@ -1,6 +1,6 @@