Merge branch 'dev'

This commit is contained in:
ccmjga
2025-06-28 22:31:49 +08:00
67 changed files with 2637 additions and 260 deletions

View File

@@ -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)"
}
}
}

View File

@@ -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 {

View File

@@ -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<JSON> {
public JooqJsonSerializer() {
super(JSON.class);
}
@Override
public void serialize(JSON value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeRawValue(value.data());
}
}
}

View File

@@ -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<TextSegment> 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();
}

View File

@@ -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<TextSegment> zhiPuEmbeddingStore(EmbeddingModel zhipuEmbeddingModel) {
public EmbeddingStore<TextSegment> 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<TextSegment> 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();
}
}

View File

@@ -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"));
}

View File

@@ -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<String> chat(Principal principal, @RequestBody String userMessage) {
public Flux<String> chat(Principal principal, @RequestBody ChatDto chatDto) {
Sinks.Many<String> 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)")

View File

@@ -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")

View File

@@ -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<Library> queryLibraries() {
return libraryRepository.findAll().stream()
.sorted(Comparator.comparing(Library::getId).reversed())
.toList();
}
@GetMapping("/docs")
public List<LibraryDoc> queryLibraryDocs(@RequestParam Long libraryId) {
return libraryDocRepository.fetchByLibId(libraryId).stream()
.sorted(Comparator.comparing(LibraryDoc::getId).reversed())
.toList();
}
@GetMapping("/segments")
public List<LibraryDocSegment> 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;
}
}

View File

@@ -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) {}

View File

@@ -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) {}

View File

@@ -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) {}

View File

@@ -0,0 +1,6 @@
package com.zl.mjga.model.urp;
public enum ChatMode {
NORMAL,
WITH_LIBRARY
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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<TextSegment> zhiPuEmbeddingStore;
private final ZhiPuEmbeddingModelConfig zhiPuEmbeddingModelConfig;
public Map<String, String> searchAction(String message) {
Map<String, String> result = new HashMap<>();
EmbeddingSearchRequest embeddingSearchRequest =
EmbeddingSearchRequest.builder()
.queryEmbedding(zhipuEmbeddingModel.embed(message).content())
.minScore(0.89)
.build();
EmbeddingSearchResult<TextSegment> 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<TextSegment> 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);
}
}
}
}

View File

@@ -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<TextSegment> zhiPuEmbeddingStore;
private final EmbeddingStore<TextSegment> 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<LibraryDoc> libraryDocs = libraryDocRepository.fetchByLibId(libraryId);
List<Long> docIds = libraryDocs.stream().map(LibraryDoc::getId).toList();
for (Long docId : docIds) {
deleteDocBy(docId);
}
libraryRepository.deleteById(libraryId);
}
public void deleteDocBy(Long docId) {
List<LibraryDocSegment> libraryDocSegments = libraryDocSegmentDao.fetchByDocId(docId);
List<String> 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<String, String> 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<LibraryDocSegment> libraryDocSegments = new ArrayList<>();
DocumentByParagraphSplitter documentByParagraphSplitter =
new DocumentByParagraphSplitter(500, 150);
documentByParagraphSplitter
.split(document)
.forEach(
textSegment -> {
Response<Embedding> 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<String, String> searchAction(String message) {
Map<String, String> result = new HashMap<>();
EmbeddingSearchRequest embeddingSearchRequest =
EmbeddingSearchRequest.builder()
.queryEmbedding(zhipuEmbeddingModel.embed(message).content())
.minScore(0.89)
.build();
EmbeddingSearchResult<TextSegment> 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<TextSegment> 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);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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
);

View File

@@ -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)

View File

@@ -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
);

2
frontend/.gitignore vendored
View File

@@ -186,3 +186,5 @@ compose.yaml
Dockerfile
Caddyfile
start.sh
.cursor

View File

@@ -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": {

View File

@@ -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<string, never>;
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: {

View File

@@ -5,14 +5,18 @@
<div class="flex flex-col gap-y-5 flex-1 pb-2">
<li v-for="chatElement in messages" :key="chatElement.content"
:class="['flex items-start gap-2.5', chatElement.isUser ? 'flex-row-reverse' : 'flex-row']">
<Avatar :src="chatElement.isUser ? user.avatar : '/trump.jpg'" size="sm"
<Avatar :src="chatElement.isUser ? user.avatar : undefined" size="sm"
:alt="chatElement.isUser ? '用户头像' : 'AI头像'" />
<div
:class="['flex flex-col leading-1.5 p-4 border-gray-200 rounded-e-xl rounded-es-xl max-w-[calc(100%-40px)]', chatElement.isUser ? 'bg-blue-100' : 'bg-gray-100']">
: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']">
<div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-gray-900 ">{{ chatElement.username }}</span>
<LoadingIcon :textColor="'text-gray-900'"
v-if="isLoading && !chatElement.isUser && chatElement.content === ''" />
<span v-if="!chatElement.isUser && chatElement.withLibrary"
class="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">
{{ chatElement.libraryName }}
</span>
</div>
<div>
<div class="markdown-content markdown-body text-base font-normal py-2.5 text-gray-900 break-words"
@@ -34,14 +38,24 @@
</div>
<form class="sticky">
<button @click.prevent="clearConversation"
class="relative inline-flex items-center justify-center p-0.5 mb-2 me-2
overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-600 to-blue-500 group-hover:from-purple-600 group-hover:to-blue-500 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 ">
<span
class="relative px-3 py-2 text-xs font-medium transition-all ease-in duration-75 bg-white rounded-md group-hover:bg-transparent">
开启新对话
</span>
</button>
<div class="flex items-center justify-between gap-2 mb-2">
<button @click.prevent="clearConversation"
class="relative inline-flex items-center justify-center p-0.5
overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-600 to-blue-500 group-hover:from-purple-600 group-hover:to-blue-500 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 ">
<span
class="relative px-3 py-2 text-xs font-medium transition-all ease-in duration-75 bg-white rounded-md group-hover:bg-transparent">
开启新对话
</span>
</button>
<select v-if="commandMode === 'chat'" v-model="selectedLibraryId"
class="bg-white border border-gray-300 text-gray-900 text-xs rounded-lg py-2 px-2 flex-1 max-w-48">
<option :value="undefined">不使用知识库</option>
<option v-for="library in libraries" :key="library.id" :value="library.id">
{{ library.name }}
</option>
</select>
</div>
<div class="w-full border border-gray-200 rounded-lg bg-gray-50">
<div class="px-4 py-2 bg-white rounded-t-lg">
<label for="comment" class="sr-only"></label>
@@ -51,7 +65,7 @@
" required></textarea>
</div>
<div class="flex justify-between px-2 py-2 border-t border-gray-200">
<select id="countries" v-model="commandMode"
<select id="commandMode" v-model="commandMode"
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg block">
<option selected :value="'execute'">指令模式</option>
<option :value="'search'">搜索模式</option>
@@ -62,7 +76,6 @@
{{ isLoading ? '中止' : '发送' }}
</TableButton>
</div>
</div>
</form>
</div>
@@ -144,6 +157,9 @@ import PositionDeleteModal from "@/components/modals/ConfirmationDialog.vue";
import PositionFormDialog from "@/components/modals/PositionFormDialog.vue";
import RoleDeleteModal from "@/components/modals/ConfirmationDialog.vue";
import RoleFormDialog from "@/components/modals/RoleFormDialog.vue";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { UserFormDialog } from "../modals";
import { computed } from "vue";
const {
messages,
@@ -190,6 +206,15 @@ const actionExcStore = useActionExcStore();
const { availableDepartments, fetchAvailableDepartments } =
useDepartmentQuery();
// 知识库相关
const { libraries, fetchLibraries } = useKnowledgeQuery();
const selectedLibraryId = ref<number | null | undefined>(undefined);
const selectedLibraryName = computed(() => {
return libraries.value.find(
(library) => library.id === selectedLibraryId.value,
)?.name;
});
const commandPlaceholderMap: Record<string, string> = {
chat: "随便聊聊",
search: "输入「创建用户、删除部门、创建岗位、创建角色、创建权限」试试看",
@@ -235,31 +260,6 @@ const renderMarkdown = (content: string | undefined) => {
// console.log('处理后HTML:', renderMarkdown(newVal[newVal.length - 1]));
// }, { deep: true });
const handleDeleteUserClick = (input: string) => {
currentDeleteUsername.value = input;
userDeleteModal.value?.show();
};
const handleDeleteDepartmentClick = (input: string) => {
currentDeleteDepartmentName.value = input;
departmentDeleteModal.value?.show();
};
const handleDeletePositionClick = (input: string) => {
currentDeletePositionName.value = input;
positionDeleteModal.value?.show();
};
const handleDeleteRoleClick = (input: string) => {
currentDeleteRoleName.value = input;
roleDeleteModal.value?.show();
};
const handleDeletePermissionClick = (input: string) => {
currentDeletePermissionName.value = input;
permissionDeleteModal.value?.show();
};
const handleUpsertUserSubmit = async (data: UserUpsertSubmitModel) => {
await userUpsert.upsertUser(data);
userUpsertModal.value?.hide();
@@ -402,7 +402,12 @@ const chatByMode = async (
await executeAction(message);
actionExcStore.notify(true);
} else {
await chat(message);
// 聊天模式,判断是否使用知识库
if (selectedLibraryId.value !== undefined) {
await chat(message, selectedLibraryId.value);
} else {
await chat(message);
}
}
};
@@ -427,6 +432,9 @@ onUnmounted(() => {
onMounted(async () => {
initFlowbite();
// 加载知识库列表
await fetchLibraries();
const $upsertModalElement: HTMLElement | null =
document.querySelector("#user-upsert-modal");
if ($upsertModalElement) {

View File

@@ -0,0 +1,44 @@
<template>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-shadow">
<div class="p-4">
<div class="flex justify-between items-start mb-3">
<div class="flex-1">
<h5 :class="[titleClass || 'text-xl font-semibold tracking-tight text-gray-900 mb-1 truncate']">
<slot name="title"></slot>
</h5>
<div v-if="$slots.subtitle" class="flex items-center mb-2">
<slot name="subtitle"></slot>
</div>
</div>
<div v-if="$slots['header-actions']" class="flex space-x-2">
<slot name="header-actions"></slot>
</div>
</div>
<div v-if="$slots.content" class="text-sm text-gray-600 mb-3 space-y-2">
<slot name="content"></slot>
</div>
<div class="flex justify-between items-center">
<slot name="footer-left">
<span v-if="$slots.timestamp" class="text-xs text-gray-500">
<slot name="timestamp"></slot>
</span>
</slot>
<div v-if="$slots['footer-actions']" class="flex space-x-2">
<slot name="footer-actions"></slot>
</div>
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
titleClass: {
type: String,
default: "",
},
});
</script>

View File

@@ -21,25 +21,25 @@
<script setup lang="ts">
defineProps({
href: {
type: String,
required: true
},
imageSrc: {
type: String,
required: true
},
imageAlt: {
type: String,
default: 'promotion'
},
label: {
type: String,
default: '官方教程'
},
text: {
type: String,
required: true
}
href: {
type: String,
required: true,
},
imageSrc: {
type: String,
required: true,
},
imageAlt: {
type: String,
default: "promotion",
},
label: {
type: String,
default: "官方教程",
},
text: {
type: String,
required: true,
},
});
</script>

View File

@@ -0,0 +1,4 @@
import CardBase from "./CardBase.vue";
import PromotionBanner from "./PromotionBanner.vue";
export { CardBase, PromotionBanner };

View File

@@ -0,0 +1,31 @@
<template>
<CardBase>
<template #title>{{ doc.name }}</template>
<template #subtitle>
<KnowledgeStatusBadge :status="doc.status" type="status" class="mr-2" />
<KnowledgeStatusBadge :enabled="doc.enable" type="enabled" />
</template>
<template #header-actions>
<slot name="toggle-switch"></slot>
</template>
<template #footer-left>
<span class="text-xs text-gray-500">
{{ formatDateString(doc.createTime) }}
</span>
</template>
<template #footer-actions>
<slot name="actions"></slot>
</template>
</CardBase>
</template>
<script setup lang="ts">
import CardBase from "@/components/common/CardBase.vue";
import { KnowledgeStatusBadge } from "@/components/common/knowledge";
import type { LibraryDoc } from "@/types/KnowledgeTypes";
import { formatDateString } from "@/utils/dateUtil";
const props = defineProps<{
doc: LibraryDoc;
}>();
</script>

View File

@@ -0,0 +1,31 @@
<template>
<CardBase>
<template #title>{{ library.name }}</template>
<template #header-actions>
<slot name="actions-top"></slot>
</template>
<template #content>
<p class="text-sm text-gray-600 line-clamp-2">
{{ library.description || '暂无描述' }}
</p>
</template>
<template #footer-left>
<span class="text-xs text-gray-500">
创建时间: {{ formatDateString(library.createTime) }}
</span>
</template>
<template #footer-actions>
<slot name="actions-bottom"></slot>
</template>
</CardBase>
</template>
<script setup lang="ts">
import CardBase from "@/components/common/CardBase.vue";
import type { Library } from "@/types/KnowledgeTypes";
import { formatDateString } from "@/utils/dateUtil";
const props = defineProps<{
library: Library;
}>();
</script>

View File

@@ -0,0 +1,37 @@
<template>
<span :class="`inline-flex items-center px-2 py-1 text-xs font-medium rounded-full ${
getStatusClass()
}`">
{{ getStatusText() }}
</span>
</template>
<script setup lang="ts">
import { DocStatus } from "@/types/KnowledgeTypes";
const props = defineProps<{
status?: string;
enabled?: boolean;
type: "status" | "enabled";
}>();
const getStatusClass = () => {
if (props.type === "status") {
return props.status === DocStatus.SUCCESS
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800";
}
return props.enabled
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-800";
};
const getStatusText = () => {
if (props.type === "status") {
return props.status === DocStatus.SUCCESS ? "解析完成" : "解析中";
}
return props.enabled ? "已启用" : "已禁用";
};
</script>

View File

@@ -0,0 +1,37 @@
<template>
<CardBase>
<template #title>分段 #{{ index + 1 }}</template>
<template #header-actions>
<div class="text-xs text-gray-500">
ID: {{ segment.id }}
</div>
</template>
<template #subtitle>
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
Embedding ID: {{ segment.embeddingId || '无' }}
</span>
<span class="inline-flex items-center px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
Token 使用量: {{ segment.tokenUsage || 0 }}
</span>
</div>
</template>
<template #content>
<div class="border-t border-gray-200 pt-3">
<h6 class="text-sm font-medium text-gray-900 mb-2">内容:</h6>
<pre
class="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-3 rounded-lg max-h-60 overflow-y-auto">{{ segment.content }}</pre>
</div>
</template>
</CardBase>
</template>
<script setup lang="ts">
import CardBase from "@/components/common/CardBase.vue";
import type { LibraryDocSegment } from "@/types/KnowledgeTypes";
const props = defineProps<{
segment: LibraryDocSegment;
index: number;
}>();
</script>

View File

@@ -0,0 +1,11 @@
import KnowledgeDocCard from "./KnowledgeDocCard.vue";
import KnowledgeLibraryCard from "./KnowledgeLibraryCard.vue";
import KnowledgeStatusBadge from "./KnowledgeStatusBadge.vue";
import SegmentCard from "./SegmentCard.vue";
export {
KnowledgeStatusBadge,
KnowledgeDocCard,
KnowledgeLibraryCard,
SegmentCard,
};

View File

@@ -0,0 +1,4 @@
import FormInput from "./FormInput.vue";
import FormSelect from "./FormSelect.vue";
export { FormInput, FormSelect };

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-icon">
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20" />
<path d="m9 10 2 2 4-4" />
</svg>
</template>

View File

@@ -11,3 +11,4 @@ export { default as RoleIcon } from "./RoleIcon.vue";
export { default as SettingsIcon } from "./SettingsIcon.vue";
export { default as UsersIcon } from "./UsersIcon.vue";
export { default as PermissionIcon } from "./PermissionIcon.vue";
export { default as KnowledgeIcon } from "./KnowledgeIcon.vue";

View File

@@ -35,6 +35,7 @@ import { RouterLink, useRoute } from "vue-router";
import {
DepartmentIcon,
KnowledgeIcon,
LlmConfigIcon,
PermissionIcon,
PositionIcon,
@@ -113,6 +114,11 @@ const menuItems = [
path: Routes.LLMCONFIGVIEW.fullPath(),
icon: LlmConfigIcon,
},
{
title: "知识库管理",
path: Routes.KNOWLEDGEVIEW.fullPath(),
icon: KnowledgeIcon,
},
];
const route = useRoute();

View File

@@ -29,7 +29,18 @@
import { Modal, initFlowbite } from "flowbite";
import { computed, onMounted } from "vue";
export type ModalSize = "xs" | "sm" | "md" | "lg" | "xl";
export type ModalSize =
| "xs"
| "sm"
| "md"
| "lg"
| "xl"
| "2xl"
| "3xl"
| "4xl"
| "5xl"
| "6xl"
| "7xl";
const props = defineProps<{
/** 对话框标题 */
@@ -50,6 +61,12 @@ const maxWidthClass = computed(() => {
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
"3xl": "max-w-3xl",
"4xl": "max-w-4xl",
"5xl": "max-w-5xl",
"6xl": "max-w-6xl",
"7xl": "max-w-7xl",
};
return sizes[props.size || "md"];

View File

@@ -9,25 +9,33 @@
<h3 class="mb-4 text-base sm:text-lg font-medium text-gray-800">
{{ title }}
</h3>
<p v-if="content" class="mb-4 text-sm text-gray-500">{{ content }}</p>
<div class="flex justify-center items-center space-x-3 sm:space-x-4">
<button type="button" @click="onSubmit"
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center min-w-[80px]">
<Button variant="danger" @click="onSubmit">
</button>
<button type="button" @click="closeModal"
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 min-w-[80px]"></button>
</Button>
<Button variant="secondary" @click="closeModal">
</Button>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { Button } from "@/components/ui";
import BaseDialog from "./BaseDialog.vue";
const { title, id, closeModal, onSubmit } = defineProps<{
const props = defineProps<{
/** 对话框标题 */
title: string;
/** 对话框内容 */
content?: string;
/** 对话框ID */
id: string;
/** 关闭对话框的回调函数 */
closeModal: () => void;
/** 确认操作的回调函数 */
onSubmit: () => Promise<void>;
}>();
</script>

View File

@@ -0,0 +1,93 @@
<template>
<BaseDialog :id="id" title="知识库管理" size="md" :closeModal="closeModal">
<!-- Modal body -->
<div class="p-4 md:p-5">
<div class="grid gap-4 mb-4 grid-cols-1">
<div class="col-span-full">
<label for="name" class="block mb-2 text-sm font-medium text-gray-900">知识库名称</label>
<input type="text" id="name" v-model="formData.name"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required />
</div>
<div class="col-span-full">
<label for="description" class="block mb-2 text-sm font-medium text-gray-900">知识库描述</label>
<textarea id="description" v-model="formData.description" rows="3"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"></textarea>
</div>
</div>
<button type="submit" @click="handleSubmit"
class="w-auto text-sm px-4 py-2 text-white flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-center self-start mt-5">
保存
</button>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import useAlertStore from "@/composables/store/useAlertStore";
import type { Library } from "@/types/KnowledgeTypes";
import type { LibraryUpsertModel } from "@/types/KnowledgeTypes";
import { ref, watch } from "vue";
import { z } from "zod";
import BaseDialog from "./BaseDialog.vue";
const alertStore = useAlertStore();
const { library, closeModal, onSubmit, id } = defineProps<{
library?: Library;
closeModal: () => void;
onSubmit: (data: LibraryUpsertModel) => Promise<void>;
id: string;
}>();
const formData = ref<LibraryUpsertModel>({
name: "",
description: "",
});
const updateFormData = (newLibrary: typeof library) => {
if (!newLibrary) {
formData.value = {
name: "",
description: "",
};
return;
}
formData.value = {
id: newLibrary.id,
name: newLibrary.name ?? "",
description: newLibrary.description ?? "",
};
};
watch(() => library, updateFormData, { immediate: true });
const handleSubmit = async () => {
const schema = z.object({
name: z.string().min(1, "知识库名称不能为空"),
description: z.string().optional(),
});
try {
const validatedData = schema.parse(formData.value);
await onSubmit({
...formData.value,
name: validatedData.name,
description: validatedData.description,
});
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
type: "error",
message: error.errors[0].message,
});
} else {
console.error("表单提交错误:", error);
alertStore.showAlert({
type: "error",
message: "表单提交失败,请重试",
});
}
}
};
</script>

View File

@@ -116,10 +116,9 @@ const handleFileChange = (event: Event) => {
throw err;
},
});
} catch (error) {
} finally {
(event.target as HTMLInputElement).value = "";
uploadLoading.value = false;
throw error;
}
};

View File

@@ -0,0 +1,23 @@
import BaseDialog from "./BaseDialog.vue";
import ConfirmationDialog from "./ConfirmationDialog.vue";
import DepartmentFormDialog from "./DepartmentFormDialog.vue";
import LibraryFormDialog from "./LibraryFormDialog.vue";
import LlmFormDialog from "./LlmFormDialog.vue";
import PermissionFormDialog from "./PermissionFormDialog.vue";
import PositionFormDialog from "./PositionFormDialog.vue";
import RoleFormDialog from "./RoleFormDialog.vue";
import SchedulerFormDialog from "./SchedulerFormDialog.vue";
import UserFormDialog from "./UserFormDialog.vue";
export {
BaseDialog,
ConfirmationDialog,
DepartmentFormDialog,
LibraryFormDialog,
LlmFormDialog,
PermissionFormDialog,
PositionFormDialog,
RoleFormDialog,
SchedulerFormDialog,
UserFormDialog,
};

View File

@@ -33,15 +33,15 @@
</select>
</div>
</template>
<button type="submit"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-2 min-w-[70px] flex items-center justify-center"
@click.prevent="handleSearch">
<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
</svg>
<Button variant="primary" size="sm" @click.prevent="handleSearch">
<template #icon>
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
</svg>
</template>
搜索
</button>
</Button>
</form>
<!-- 额外操作按钮插槽 -->
@@ -53,6 +53,7 @@
<script setup lang="ts">
import { onMounted, reactive, watch } from "vue";
import { Button } from "@/components/ui";
export interface FilterOption {
value: string | number | boolean;

View File

@@ -0,0 +1,15 @@
import MobileCardList from "./MobileCardList.vue";
import MobileCardListWithCheckbox from "./MobileCardListWithCheckbox.vue";
import TableButton from "./TableButton.vue";
import TableFilterForm from "./TableFilterForm.vue";
import TableFormLayout from "./TableFormLayout.vue";
import TablePagination from "./TablePagination.vue";
export {
MobileCardList,
MobileCardListWithCheckbox,
TableButton,
TableFilterForm,
TableFormLayout,
TablePagination,
};

View File

@@ -1,8 +1,7 @@
<template>
<div class="rounded-full border border-gray-200 flex items-center justify-center overflow-hidden flex-shrink-0"
:class="sizeClass">
<img v-if="processedSrc" :src="processedSrc" class="w-full h-full object-cover" :alt="alt">
<div v-else class="w-full h-full bg-gray-100"></div>
<img :src="processedSrc" class="w-full h-full object-cover" :alt="alt">
</div>
</template>
@@ -10,34 +9,29 @@
import { getUserAvatarUrl } from "@/utils/avatarUtil";
import { computed } from "vue";
const {
src = "",
alt = "用户头像",
size = "md",
} = defineProps<{
const props = defineProps<{
/** 头像图片源 */
src?: string;
/** 头像替代文本 */
alt?: string;
size?: "sm" | "md" | "lg";
/** 头像尺寸 */
size?: "xs" | "sm" | "md" | "lg" | "xl";
}>();
/** 尺寸样式映射 */
const sizeClass = computed(() => {
switch (size) {
case "sm":
return "w-8 h-8";
case "lg":
return "w-12 h-12";
default:
return "w-10 h-10";
}
const sizes = {
xs: "w-6 h-6",
sm: "w-8 h-8",
md: "w-10 h-10",
lg: "w-12 h-12",
xl: "w-16 h-16",
};
return sizes[props.size || "md"];
});
/** 处理后的图片源 */
const processedSrc = computed(() => {
if (!src) {
return "";
}
if (src === "/trump.jpg") {
return src;
}
return getUserAvatarUrl(src);
return getUserAvatarUrl(props.src);
});
</script>

View File

@@ -0,0 +1,122 @@
<template>
<button :class="[
'flex items-center justify-center gap-x-1 whitespace-nowrap font-medium rounded-lg focus:ring-4 focus:outline-none',
sizeClasses,
colorClasses,
(disabled || (isLoading && !abortable)) ? 'opacity-50 cursor-not-allowed' : '',
fullWidth ? 'w-full' : '',
className
]" :disabled="disabled || (isLoading && !abortable)" @click="handleClick" :type="type">
<div v-if="isLoading && !abortable" class="animate-spin mr-1" :class="iconSizeClasses">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
</div>
<StopIcon v-else-if="isLoading && abortable" :class="iconSizeClasses" />
<slot v-else name="icon"></slot>
<span v-if="$slots.default">
<slot></slot>
</span>
</button>
</template>
<script setup lang="ts">
import { StopIcon } from "@/components/icons";
import { computed } from "vue";
export type ButtonVariant =
| "primary"
| "secondary"
| "success"
| "danger"
| "warning"
| "info";
export type ButtonSize = "xs" | "sm" | "md" | "lg";
export type ButtonType = "button" | "submit" | "reset";
const props = defineProps<{
/** 按钮变体类型 */
variant?: ButtonVariant;
/** 按钮尺寸 */
size?: ButtonSize;
/** 是否禁用 */
disabled?: boolean;
/** 自定义CSS类名 */
className?: string;
/** 是否为移动端尺寸 */
isMobile?: boolean;
/** 是否处于加载状态 */
isLoading?: boolean;
/** 是否可中止 */
abortable?: boolean;
/** 按钮类型 */
type?: ButtonType;
/** 是否占满宽度 */
fullWidth?: boolean;
}>();
const emit = defineEmits<{
click: [event: MouseEvent];
}>();
/** 按钮颜色样式映射 */
const colorClasses = computed(() => {
const variants: Record<ButtonVariant, string> = {
primary: "text-white bg-blue-700 hover:bg-blue-800 focus:ring-blue-300",
secondary:
"text-gray-900 bg-white border border-gray-300 hover:bg-gray-100 focus:ring-gray-100",
success: "text-white bg-green-700 hover:bg-green-800 focus:ring-green-300",
danger: "text-white bg-red-700 hover:bg-red-800 focus:ring-red-300",
warning:
"text-gray-900 bg-yellow-400 hover:bg-yellow-500 focus:ring-yellow-300",
info: "text-white bg-cyan-700 hover:bg-cyan-800 focus:ring-cyan-300",
};
return variants[props.variant || "primary"];
});
/** 按钮尺寸样式映射 */
const sizeClasses = computed(() => {
// 移动端尺寸
if (props.isMobile) {
const sizes: Record<ButtonSize, string> = {
xs: "text-xs px-2 py-1",
sm: "text-xs px-3 py-1.5",
md: "text-sm px-3 py-2",
lg: "text-sm px-4 py-2.5",
};
return sizes[props.size || "sm"];
}
// PC端尺寸
const sizes: Record<ButtonSize, string> = {
xs: "text-xs px-3 py-1.5",
sm: "text-sm px-3 py-2",
md: "text-sm px-4 py-2.5",
lg: "text-base px-5 py-3",
};
return sizes[props.size || "md"];
});
/** 图标尺寸样式映射 */
const iconSizeClasses = computed(() => {
const sizes: Record<ButtonSize, string> = {
xs: "w-3.5 h-3.5",
sm: "w-4 h-4",
md: "w-4.5 h-4.5",
lg: "w-5 h-5",
};
return sizes[props.size || "md"];
});
/** 处理点击事件 */
const handleClick = (event: MouseEvent) => {
if (!props.disabled && !(props.isLoading && !props.abortable)) {
emit("click", event);
}
};
</script>

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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<Library[]>([]);
const docs = ref<LibraryDoc[]>([]);
const segments = ref<LibraryDocSegment[]>([]);
const doc = ref<LibraryDoc | null>(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,
};
};

View File

@@ -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,
};
};

View File

@@ -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: <T extends { libraryId: string | number }>(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 = {

View File

@@ -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() };
}
};

View File

@@ -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;
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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",
}

View File

@@ -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则直接返回
};

View File

@@ -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 };

View File

@@ -0,0 +1,4 @@
import { getUserAvatarUrl } from "./avatarUtil";
import { dayjs, formatDate, formatDateString } from "./dateUtil";
export { getUserAvatarUrl, dayjs, formatDate, formatDateString };

View File

@@ -0,0 +1,203 @@
<template>
<div class="px-2 sm:px-4 pt-6 sm:rounded-lg">
<Breadcrumbs :names="['知识库管理', '文档管理']" :routes="[Routes.KNOWLEDGEVIEW.fullPath()]" />
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl">{{ currentLibrary?.name || '知识库' }} - 文档管理</h1>
</div>
<!-- 文档列表 -->
<div v-if="docs.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<KnowledgeDocCard v-for="doc in docs" :key="doc.id" :doc="doc">
<template #toggle-switch>
<label class="inline-flex items-center mb-5"
:class="!doc.enable ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'">
<input type="checkbox" class="sr-only peer" :checked="doc.enable" @change="handleToggleDocStatus(doc)">
<div
class="relative w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:w-5 after:h-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600">
</div>
</label>
</template>
<template #actions>
<Button variant="primary" size="xs" @click="navigateToDocSegments(doc)"
:disabled="doc.status !== DocStatus.SUCCESS">
查看内容
</Button>
<Button variant="danger" size="xs" @click="handleDeleteDoc(doc)">
删除
</Button>
</template>
</KnowledgeDocCard>
</div>
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-10">
<div class="text-gray-500 text-lg mb-4">暂无文档</div>
<div>
<input ref="fileInputRef" class="hidden" id="doc_file_input" type="file" @change="handleFileChange">
<Button variant="primary" @click="triggerFileInput">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
上传文档
</Button>
</div>
</div>
</div>
<!-- 删除确认对话框 -->
<ConfirmationDialog :id="'doc-delete-modal'" :title="`确定删除文档 '${selectedDoc?.name || ''}' 吗?`"
content="删除后将无法恢复,且其中的所有分段内容也将被删除。" :closeModal="() => {
docDeleteModal?.hide();
}" :onSubmit="handleDocDeleteSubmit" />
</template>
<script setup lang="ts">
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
import { onMounted, ref, watchEffect } from "vue";
import { useRoute, useRouter } from "vue-router";
import { KnowledgeDocCard } from "@/components/common/knowledge";
import { PlusIcon } from "@/components/icons";
import Breadcrumbs from "@/components/layout/Breadcrumbs.vue";
import ConfirmationDialog from "@/components/modals/ConfirmationDialog.vue";
import { Button } from "@/components/ui";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { useKnowledgeUpsert } from "@/composables/knowledge/useKnowledgeUpsert";
import useAlertStore from "@/composables/store/useAlertStore";
import { Routes } from "@/router/constants";
import { formatDateString } from "@/utils/dateUtil";
import type { Library, LibraryDoc } from "@/types/KnowledgeTypes";
import { DocStatus } from "@/types/KnowledgeTypes";
// 路由参数
const route = useRoute();
const router = useRouter();
const libraryId = ref<number>(
Number.parseInt(route.params.libraryId as string, 10),
);
// 文件输入引用
const fileInputRef = ref<HTMLInputElement | null>(null);
// 获取知识库信息
const { libraries, fetchLibraries } = useKnowledgeQuery();
const currentLibrary = ref<Library | undefined>();
// 获取文档列表
const { docs, fetchLibraryDocs } = useKnowledgeQuery();
const { uploadDoc, deleteDoc, updateDoc } = useKnowledgeUpsert();
// 模态框引用
const docDeleteModal = ref<ModalInterface>();
// 选中的文档
const selectedDoc = ref<LibraryDoc | undefined>();
// 提示store
const alertStore = useAlertStore();
// 触发文件选择
const triggerFileInput = () => {
fileInputRef.value?.click();
};
// 处理文件选择
const handleFileChange = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
await uploadDoc(libraryId.value, file);
alertStore.showAlert({
level: "success",
content: "文档上传成功",
});
// 清空文件选择框
(event.target as HTMLInputElement).value = "";
// 刷新文档列表
await fetchLibraryDocs({ libraryId: libraryId.value });
} finally {
// 清空文件选择框
(event.target as HTMLInputElement).value = "";
}
};
// 处理删除文档
const handleDeleteDoc = (doc: LibraryDoc) => {
selectedDoc.value = doc;
docDeleteModal.value?.show();
};
// 处理文档删除确认
const handleDocDeleteSubmit = async () => {
if (!selectedDoc.value?.id) return;
await deleteDoc(selectedDoc.value.id);
alertStore.showAlert({
level: "success",
content: "文档删除成功",
});
docDeleteModal.value?.hide();
await fetchLibraryDocs({ libraryId: libraryId.value });
};
// 处理切换文档状态
const handleToggleDocStatus = async (doc: LibraryDoc) => {
try {
doc.enable = !doc.enable;
await updateDoc({
id: doc.id!,
libId: doc.libId!,
enable: doc.enable,
});
alertStore.showAlert({
level: "success",
content: "操作成功",
});
} finally {
await fetchLibraryDocs({ libraryId: libraryId.value });
}
};
// 导航到文档分段页面
const navigateToDocSegments = (doc: LibraryDoc) => {
router.push(
Routes.KNOWLEDGESEGMENTSVIEW.withParams({
libraryId: libraryId.value,
docId: doc.id!,
}),
);
};
// 初始化
onMounted(async () => {
initFlowbite();
// 初始化模态框
const docDeleteElement = document.getElementById("doc-delete-modal");
if (docDeleteElement) {
docDeleteModal.value = new Modal(docDeleteElement);
}
// 获取知识库列表和文档列表
await fetchLibraries();
await fetchLibraryDocs({ libraryId: libraryId.value });
});
// 监听知识库列表变化,找到当前知识库
watchEffect(() => {
if (libraries.value && libraries.value.length > 0) {
currentLibrary.value = libraries.value.find(
(lib) => lib.id === libraryId.value,
);
}
});
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div class="px-2 sm:px-4 pt-6 sm:rounded-lg">
<Breadcrumbs :names="['知识库管理']" />
<div class="mb-4">
<h1 class="text-2xl font-semibold text-gray-900">知识库管理</h1>
</div>
<!-- 知识库列表 -->
<div v-if="libraries.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<KnowledgeLibraryCard v-for="library in libraries" :key="library.id" :library="library">
<template #actions-top>
<button @click="handleEditLibrary(library)" class="text-gray-500 hover:text-blue-700 focus:outline-none">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z">
</path>
</svg>
</button>
<button @click="handleDeleteLibrary(library)" class="text-gray-500 hover:text-red-700 focus:outline-none">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
</path>
</svg>
</button>
</template>
<template #actions-bottom>
<Button variant="primary" size="xs" @click="navigateToLibraryDocs(library)">
查看知识库
</Button>
</template>
</KnowledgeLibraryCard>
</div>
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-10">
<div class="text-gray-500 text-lg mb-4">暂无知识库</div>
<div>
<Button variant="primary" @click="handleCreateLibraryClick">
创建知识库
</Button>
</div>
</div>
</div>
<!-- 知识库表单对话框 -->
<LibraryFormDialog :id="'library-form-modal'" :library="selectedLibrary" :closeModal="() => {
libraryFormModal?.hide();
}" :onSubmit="handleLibraryFormSubmit" />
<!-- 删除确认对话框 -->
<ConfirmationDialog :id="'library-delete-modal'" :title="`确定删除知识库 '${selectedLibrary?.name || ''}' 吗?`"
content="删除后将无法恢复,且其中的所有文档也将被删除。" :closeModal="() => {
libraryDeleteModal?.hide();
}" :onSubmit="handleLibraryDeleteSubmit" />
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { KnowledgeLibraryCard } from "@/components/common/knowledge";
import Breadcrumbs from "@/components/layout/Breadcrumbs.vue";
import ConfirmationDialog from "@/components/modals/ConfirmationDialog.vue";
import LibraryFormDialog from "@/components/modals/LibraryFormDialog.vue";
import { Button } from "@/components/ui";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { useKnowledgeUpsert } from "@/composables/knowledge/useKnowledgeUpsert";
import useAlertStore from "@/composables/store/useAlertStore";
import { Routes } from "@/router/constants";
import type { Library, LibraryUpsertModel } from "@/types/KnowledgeTypes";
// 获取知识库列表
const { libraries, fetchLibraries } = useKnowledgeQuery();
// 知识库操作
const { upsertLibrary, deleteLibrary } = useKnowledgeUpsert();
// 模态框引用
const libraryFormModal = ref<ModalInterface>();
const libraryDeleteModal = ref<ModalInterface>();
// 选中的知识库
const selectedLibrary = ref<Library | undefined>();
// 路由
const router = useRouter();
const alertStore = useAlertStore();
// 格式化日期
const formatDate = (dateString?: string) => {
if (!dateString) return "未知";
return dayjs(dateString).format("YYYY-MM-DD HH:mm");
};
// 处理创建知识库点击
const handleCreateLibraryClick = () => {
selectedLibrary.value = undefined;
libraryFormModal.value?.show();
};
// 处理编辑知识库
const handleEditLibrary = (library: Library) => {
selectedLibrary.value = library;
libraryFormModal.value?.show();
};
// 处理删除知识库
const handleDeleteLibrary = (library: Library) => {
selectedLibrary.value = library;
libraryDeleteModal.value?.show();
};
// 处理知识库表单提交
const handleLibraryFormSubmit = async (data: LibraryUpsertModel) => {
await upsertLibrary(data);
alertStore.showAlert({
level: "success",
content: data.id ? "知识库更新成功" : "知识库创建成功",
});
libraryFormModal.value?.hide();
await fetchLibraries();
};
// 处理知识库删除确认
const handleLibraryDeleteSubmit = async () => {
if (!selectedLibrary.value?.id) return;
await deleteLibrary(selectedLibrary.value.id);
alertStore.showAlert({
level: "success",
content: "知识库删除成功",
});
libraryDeleteModal.value?.hide();
await fetchLibraries();
};
// 导航到知识库文档页面
const navigateToLibraryDocs = (library: Library) => {
if (!library.id) return;
router.push(Routes.KNOWLEDGEDOCVIEW.withParams({ libraryId: library.id }));
};
// 初始化
onMounted(async () => {
initFlowbite();
// 初始化模态框
const libraryFormElement = document.getElementById("library-form-modal");
if (libraryFormElement) {
libraryFormModal.value = new Modal(libraryFormElement);
}
const libraryDeleteElement = document.getElementById("library-delete-modal");
if (libraryDeleteElement) {
libraryDeleteModal.value = new Modal(libraryDeleteElement);
}
// 获取知识库列表
await fetchLibraries();
});
</script>

View File

@@ -0,0 +1,76 @@
<template>
<div class="px-2 sm:px-4 pt-6 sm:rounded-lg">
<Breadcrumbs :names="['知识库管理', '文档管理', '文档分段']" :routes="[
Routes.KNOWLEDGEVIEW.fullPath(),
Routes.KNOWLEDGEDOCVIEW.withParams({ libraryId })
]" />
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl">{{ currentDoc?.name || '文档' }} - 分段内容</h1>
</div>
<div class="mb-4">
<Button variant="secondary" size="sm" @click="navigateBack">
<template #icon>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18">
</path>
</svg>
</template>
返回文档列表
</Button>
</div>
<!-- 分段列表 -->
<div v-if="segments.length > 0" class="space-y-4">
<SegmentCard v-for="(segment, index) in segments" :key="segment.id" :segment="segment" :index="index" />
</div>
<!-- 空状态 -->
<div v-else class="flex flex-col items-center justify-center py-10">
<div class="text-gray-500 text-lg mb-4">暂无分段内容</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watchEffect } from "vue";
import { useRoute, useRouter } from "vue-router";
import { SegmentCard } from "@/components/common/knowledge";
import Breadcrumbs from "@/components/layout/Breadcrumbs.vue";
import { Button } from "@/components/ui";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { Routes } from "@/router/constants";
import type { LibraryDoc } from "@/types/KnowledgeTypes";
// 路由参数
const route = useRoute();
const router = useRouter();
const libraryId = Number.parseInt(route.params.libraryId as string, 10);
const docId = Number.parseInt(route.params.docId as string, 10);
// 获取文档信息和分段列表
const { docs, segments, fetchLibraryDocs, fetchDocSegments } =
useKnowledgeQuery();
const currentDoc = ref<LibraryDoc | undefined>();
// 导航回文档列表
const navigateBack = () => {
router.push(Routes.KNOWLEDGEDOCVIEW.withParams({ libraryId }));
};
// 初始化
onMounted(async () => {
await fetchLibraryDocs({ libraryId });
await fetchDocSegments({ libraryDocId: docId });
});
// 监听文档列表变化,找到当前文档
watchEffect(() => {
if (docs.value && docs.value.length > 0) {
currentDoc.value = docs.value.find((doc) => doc.id === docId);
}
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="px-2 sm:px-4 pt-6 sm:rounded-lg">
<div class="mb-4 col-span-full">
<div class="mb-4">
<Breadcrumbs :names="['角色管理']" />
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl">角色管理</h1>
</div>