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-open-ai:1.0.0")
implementation("dev.langchain4j:langchain4j-pgvector:1.0.1-beta6") implementation("dev.langchain4j:langchain4j-pgvector:1.0.1-beta6")
implementation("dev.langchain4j:langchain4j-community-zhipu-ai: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") implementation("io.projectreactor:reactor-core:3.7.6")
testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion") testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion")
testImplementation("org.testcontainers:postgresql:$testcontainersVersion") testImplementation("org.testcontainers:postgresql:$testcontainersVersion")
@@ -168,14 +170,8 @@ jooq {
} }
forcedTypes { forcedTypes {
forcedType { forcedType {
name = "varchar" isJsonConverter = true
includeExpression = ".*" includeTypes = "(?i:JSON|JSONB)"
includeTypes = "JSONB?"
}
forcedType {
name = "varchar"
includeExpression = ".*"
includeTypes = "INET"
} }
} }
} }

View File

@@ -2,7 +2,9 @@ package com.zl.mjga;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@EnableAsync
@SpringBootApplication(scanBasePackages = {"com.zl.mjga", "org.jooq.generated"}) @SpringBootApplication(scanBasePackages = {"com.zl.mjga", "org.jooq.generated"})
public class ApplicationService { 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; 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.component.PromptConfiguration;
import com.zl.mjga.service.LlmService; import com.zl.mjga.service.LlmService;
import dev.langchain4j.community.model.zhipu.ZhipuAiStreamingChatModel; import dev.langchain4j.community.model.zhipu.ZhipuAiStreamingChatModel;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.openai.OpenAiStreamingChatModel; import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.service.AiServices; import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.embedding.EmbeddingStore;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.jooq.generated.mjga.enums.LlmCodeEnum; import org.jooq.generated.mjga.enums.LlmCodeEnum;
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig; import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
@@ -54,11 +60,26 @@ public class ChatModelInitializer {
@Bean @Bean
@DependsOn("flywayInitializer") @DependsOn("flywayInitializer")
public AiChatAssistant zhiPuChatAssistant(ZhipuAiStreamingChatModel zhipuChatModel) { public AiChatAssistant zhiPuChatAssistant(
ZhipuAiStreamingChatModel zhipuChatModel,
EmbeddingStore<TextSegment> zhiPuLibraryEmbeddingStore,
EmbeddingModel zhipuEmbeddingModel) {
return AiServices.builder(AiChatAssistant.class) return AiServices.builder(AiChatAssistant.class)
.streamingChatModel(zhipuChatModel) .streamingChatModel(zhipuChatModel)
.systemMessageProvider(chatMemoryId -> promptConfiguration.getSystem()) .systemMessageProvider(chatMemoryId -> promptConfiguration.getSystem())
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10)) .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(); .build();
} }

View File

@@ -1,7 +1,10 @@
package com.zl.mjga.config.ai; package com.zl.mjga.config.ai;
import com.zl.mjga.config.minio.MinIoConfig;
import com.zl.mjga.service.LlmService; import com.zl.mjga.service.LlmService;
import dev.langchain4j.community.model.zhipu.ZhipuAiEmbeddingModel; 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.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.EmbeddingStore;
@@ -42,7 +45,7 @@ public class EmbeddingInitializer {
} }
@Bean @Bean
public EmbeddingStore<TextSegment> zhiPuEmbeddingStore(EmbeddingModel zhipuEmbeddingModel) { public EmbeddingStore<TextSegment> zhiPuEmbeddingStore() {
String hostPort = env.getProperty("DATABASE_HOST_PORT"); String hostPort = env.getProperty("DATABASE_HOST_PORT");
String host = hostPort.split(":")[0]; String host = hostPort.split(":")[0];
return PgVectorEmbeddingStore.builder() return PgVectorEmbeddingStore.builder()
@@ -55,4 +58,28 @@ public class EmbeddingInitializer {
.dimension(2048) .dimension(2048)
.build(); .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("/auth/sign-up", HttpMethod.POST.name()),
new AntPathRequestMatcher("/v3/api-docs/**", HttpMethod.GET.name()), new AntPathRequestMatcher("/v3/api-docs/**", HttpMethod.GET.name()),
new AntPathRequestMatcher("/swagger-ui/**", 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("/swagger-ui.html", HttpMethod.GET.name()),
new AntPathRequestMatcher("/error")); 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.PageRequestDto;
import com.zl.mjga.dto.PageResponseDto; 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.LlmQueryDto;
import com.zl.mjga.dto.ai.LlmVm; import com.zl.mjga.dto.ai.LlmVm;
import com.zl.mjga.exception.BusinessException; import com.zl.mjga.exception.BusinessException;
import com.zl.mjga.repository.*; import com.zl.mjga.repository.*;
import com.zl.mjga.service.AiChatService; import com.zl.mjga.service.AiChatService;
import com.zl.mjga.service.EmbeddingService;
import com.zl.mjga.service.LlmService; import com.zl.mjga.service.LlmService;
import com.zl.mjga.service.RagService;
import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.TokenStream;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.security.Principal; import java.security.Principal;
@@ -35,7 +36,7 @@ public class AiController {
private final AiChatService aiChatService; private final AiChatService aiChatService;
private final LlmService llmService; private final LlmService llmService;
private final EmbeddingService embeddingService; private final RagService ragService;
private final UserRepository userRepository; private final UserRepository userRepository;
private final DepartmentRepository departmentRepository; private final DepartmentRepository departmentRepository;
private final PositionRepository positionRepository; private final PositionRepository positionRepository;
@@ -72,9 +73,9 @@ public class AiController {
} }
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) @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(); Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
TokenStream chat = aiChatService.chatPrecedenceLlmWith(principal.getName(), userMessage); TokenStream chat = aiChatService.chat(principal.getName(), chatDto);
chat.onPartialResponse( chat.onPartialResponse(
text -> text ->
sink.tryEmitNext( sink.tryEmitNext(
@@ -109,7 +110,7 @@ public class AiController {
if (!aiLlmConfig.getEnable()) { if (!aiLlmConfig.getEnable()) {
throw new BusinessException("命令模型未启用,请开启后再试。"); throw new BusinessException("命令模型未启用,请开启后再试。");
} }
return embeddingService.searchAction(message); return ragService.searchAction(message);
} }
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)") @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")

View File

@@ -1,6 +1,5 @@
package com.zl.mjga.controller; package com.zl.mjga.controller;
import com.zl.mjga.config.minio.MinIoConfig;
import com.zl.mjga.dto.PageRequestDto; import com.zl.mjga.dto.PageRequestDto;
import com.zl.mjga.dto.PageResponseDto; import com.zl.mjga.dto.PageResponseDto;
import com.zl.mjga.dto.department.DepartmentBindDto; 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.RoleRepository;
import com.zl.mjga.repository.UserRepository; import com.zl.mjga.repository.UserRepository;
import com.zl.mjga.service.IdentityAccessService; import com.zl.mjga.service.IdentityAccessService;
import io.minio.MinioClient; import com.zl.mjga.service.UploadService;
import io.minio.PutObjectArgs;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.awt.image.BufferedImage;
import java.security.Principal; import java.security.Principal;
import java.time.Instant;
import java.util.List; import java.util.List;
import javax.imageio.ImageIO;
import lombok.RequiredArgsConstructor; 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.jooq.generated.mjga.tables.pojos.User;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@@ -41,8 +34,7 @@ public class IdentityAccessController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final RoleRepository roleRepository; private final RoleRepository roleRepository;
private final PermissionRepository permissionRepository; private final PermissionRepository permissionRepository;
private final MinioClient minioClient; private final UploadService uploadService;
private final MinIoConfig minIoConfig;
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)") @PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")
@PostMapping( @PostMapping(
@@ -50,40 +42,7 @@ public class IdentityAccessController {
consumes = MediaType.MULTIPART_FORM_DATA_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE) produces = MediaType.TEXT_PLAIN_VALUE)
public String uploadAvatar(@RequestPart("file") MultipartFile multipartFile) throws Exception { public String uploadAvatar(@RequestPart("file") MultipartFile multipartFile) throws Exception {
String originalFilename = multipartFile.getOriginalFilename(); return uploadService.uploadAvatarFile(multipartFile);
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;
} }
@GetMapping("/me") @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.AiChatAssistant;
import com.zl.mjga.config.ai.SystemToolAssistant; import com.zl.mjga.config.ai.SystemToolAssistant;
import com.zl.mjga.dto.ai.ChatDto;
import com.zl.mjga.exception.BusinessException; import com.zl.mjga.exception.BusinessException;
import dev.langchain4j.service.TokenStream; import dev.langchain4j.service.TokenStream;
import java.util.Optional; 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(); LlmCodeEnum code = getPrecedenceLlmCode();
String userMessage = chatDto.message();
return switch (code) { return switch (code) {
case ZHI_PU -> zhiPuChatAssistant.chat(sessionIdentifier, userMessage); case ZHI_PU -> zhiPuChatAssistant.chat(sessionIdentifier, userMessage);
case DEEP_SEEK -> deepSeekChatAssistant.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, id BIGSERIAL PRIMARY KEY,
username VARCHAR NOT NULL UNIQUE, username VARCHAR NOT NULL UNIQUE,
avatar VARCHAR, 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, password VARCHAR NOT NULL,
enable BOOLEAN NOT NULL DEFAULT TRUE enable BOOLEAN NOT NULL DEFAULT TRUE
); );
@@ -39,7 +39,7 @@ CREATE TABLE mjga.user_role_map (
CREATE TABLE mjga.department ( CREATE TABLE mjga.department (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE, name VARCHAR NOT NULL UNIQUE,
parent_id BIGINT, parent_id BIGINT,
FOREIGN KEY (parent_id) FOREIGN KEY (parent_id)
REFERENCES mjga.department(id) REFERENCES mjga.department(id)
@@ -56,7 +56,7 @@ CREATE TABLE mjga.user_department_map (
CREATE TABLE mjga.position ( CREATE TABLE mjga.position (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE name VARCHAR NOT NULL UNIQUE
); );
CREATE TABLE mjga.user_position_map ( CREATE TABLE mjga.user_position_map (
@@ -80,12 +80,12 @@ CREATE TYPE "llm_type_enum" AS ENUM (
CREATE TABLE mjga.ai_llm_config ( CREATE TABLE mjga.ai_llm_config (
id BIGSERIAL NOT NULL UNIQUE, id BIGSERIAL NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE, name VARCHAR NOT NULL UNIQUE,
code mjga.llm_code_enum 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, type LLM_TYPE_ENUM NOT NULL,
api_key VARCHAR(255) NOT NULL, api_key VARCHAR NOT NULL,
url VARCHAR(255) NOT NULL, url VARCHAR NOT NULL,
enable BOOLEAN NOT NULL DEFAULT true, enable BOOLEAN NOT NULL DEFAULT true,
priority SMALLINT NOT NULL DEFAULT 0, priority SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY(id) 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, id BIGSERIAL PRIMARY KEY,
username VARCHAR NOT NULL UNIQUE, username VARCHAR NOT NULL UNIQUE,
avatar VARCHAR, 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, password VARCHAR NOT NULL,
enable BOOLEAN NOT NULL DEFAULT TRUE enable BOOLEAN NOT NULL DEFAULT TRUE
); );
@@ -39,7 +39,7 @@ CREATE TABLE mjga.user_role_map (
CREATE TABLE mjga.department ( CREATE TABLE mjga.department (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE, name VARCHAR NOT NULL UNIQUE,
parent_id BIGINT, parent_id BIGINT,
FOREIGN KEY (parent_id) FOREIGN KEY (parent_id)
REFERENCES mjga.department(id) REFERENCES mjga.department(id)
@@ -56,7 +56,7 @@ CREATE TABLE mjga.user_department_map (
CREATE TABLE mjga.position ( CREATE TABLE mjga.position (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE name VARCHAR NOT NULL UNIQUE
); );
CREATE TABLE mjga.user_position_map ( CREATE TABLE mjga.user_position_map (
@@ -80,12 +80,12 @@ CREATE TYPE "llm_type_enum" AS ENUM (
CREATE TABLE mjga.ai_llm_config ( CREATE TABLE mjga.ai_llm_config (
id BIGSERIAL NOT NULL UNIQUE, id BIGSERIAL NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE, name VARCHAR NOT NULL UNIQUE,
code mjga.llm_code_enum 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, type LLM_TYPE_ENUM NOT NULL,
api_key VARCHAR(255) NOT NULL, api_key VARCHAR NOT NULL,
url VARCHAR(255) NOT NULL, url VARCHAR NOT NULL,
enable BOOLEAN NOT NULL DEFAULT true, enable BOOLEAN NOT NULL DEFAULT true,
priority SMALLINT NOT NULL DEFAULT 0, priority SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY(id) 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 Dockerfile
Caddyfile Caddyfile
start.sh 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": { "/ai/llm": {
"put": { "put": {
"tags": [ "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": { "/iam/user": {
"get": { "get": {
"tags": [ "tags": [
@@ -762,7 +894,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "/iam/users": {
"get": { "get": {
"tags": [ "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": { "LlmVm": {
"required": [ "required": [
"apiKey", "apiKey",
@@ -1422,6 +1666,24 @@
} }
} }
}, },
"LibraryUpsertDto": {
"required": [
"name"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"description": {
"type": "string"
}
}
},
"UserUpsertDto": { "UserUpsertDto": {
"required": [ "required": [
"enable", "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": { "PageRequestDto": {
"type": "object", "type": "object",
"properties": { "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": { "UserQueryDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -20,6 +20,22 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/ai/llm": {
parameters: { parameters: {
query?: never; query?: never;
@@ -100,6 +116,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/iam/user": {
parameters: { parameters: {
query?: never; query?: never;
@@ -484,6 +532,54 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/iam/users": {
parameters: { parameters: {
query?: never; query?: never;
@@ -684,6 +780,13 @@ export interface components {
name: string; name: string;
group: string; group: string;
}; };
DocUpdateDto: {
/** Format: int64 */
id: number;
/** Format: int64 */
libId: number;
enable: boolean;
};
LlmVm: { LlmVm: {
/** Format: int64 */ /** Format: int64 */
id: number; id: number;
@@ -705,6 +808,12 @@ export interface components {
id?: number; id?: number;
name?: string; name?: string;
}; };
LibraryUpsertDto: {
/** Format: int64 */
id?: number;
name: string;
description?: string;
};
UserUpsertDto: { UserUpsertDto: {
/** Format: int64 */ /** Format: int64 */
id?: number; id?: number;
@@ -760,6 +869,13 @@ export interface components {
username: string; username: string;
password: string; password: string;
}; };
ChatDto: {
/** @enum {string} */
mode: "NORMAL" | "WITH_LIBRARY";
/** Format: int64 */
libraryId?: number;
message: string;
};
PageRequestDto: { PageRequestDto: {
/** Format: int64 */ /** Format: int64 */
page?: number; page?: number;
@@ -829,6 +945,42 @@ export interface components {
name: string; name: string;
isBound?: boolean; 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: { UserQueryDto: {
username?: string; username?: string;
/** Format: date-time */ /** 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: { updateLlm: {
parameters: { parameters: {
query?: never; 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: { queryUserWithRolePermission: {
parameters: { parameters: {
query: { query: {
@@ -1633,7 +1897,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": string; "application/json": components["schemas"]["ChatDto"];
}; };
}; };
responses: { 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: { queryUsers: {
parameters: { parameters: {
query: { query: {

View File

@@ -5,14 +5,18 @@
<div class="flex flex-col gap-y-5 flex-1 pb-2"> <div class="flex flex-col gap-y-5 flex-1 pb-2">
<li v-for="chatElement in messages" :key="chatElement.content" <li v-for="chatElement in messages" :key="chatElement.content"
:class="['flex items-start gap-2.5', chatElement.isUser ? 'flex-row-reverse' : 'flex-row']"> :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头像'" /> :alt="chatElement.isUser ? '用户头像' : 'AI头像'" />
<div <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"> <div class="flex items-center space-x-2">
<span class="text-sm font-semibold text-gray-900 ">{{ chatElement.username }}</span> <span class="text-sm font-semibold text-gray-900 ">{{ chatElement.username }}</span>
<LoadingIcon :textColor="'text-gray-900'" <LoadingIcon :textColor="'text-gray-900'"
v-if="isLoading && !chatElement.isUser && chatElement.content === ''" /> 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> <div>
<div class="markdown-content markdown-body text-base font-normal py-2.5 text-gray-900 break-words" <div class="markdown-content markdown-body text-base font-normal py-2.5 text-gray-900 break-words"
@@ -34,14 +38,24 @@
</div> </div>
<form class="sticky"> <form class="sticky">
<button @click.prevent="clearConversation" <div class="flex items-center justify-between gap-2 mb-2">
class="relative inline-flex items-center justify-center p-0.5 mb-2 me-2 <button @click.prevent="clearConversation"
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 "> class="relative inline-flex items-center justify-center p-0.5
<span 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 ">
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
开启新对话 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> </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="w-full border border-gray-200 rounded-lg bg-gray-50">
<div class="px-4 py-2 bg-white rounded-t-lg"> <div class="px-4 py-2 bg-white rounded-t-lg">
<label for="comment" class="sr-only"></label> <label for="comment" class="sr-only"></label>
@@ -51,7 +65,7 @@
" required></textarea> " required></textarea>
</div> </div>
<div class="flex justify-between px-2 py-2 border-t border-gray-200"> <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"> class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg block">
<option selected :value="'execute'">指令模式</option> <option selected :value="'execute'">指令模式</option>
<option :value="'search'">搜索模式</option> <option :value="'search'">搜索模式</option>
@@ -62,7 +76,6 @@
{{ isLoading ? '中止' : '发送' }} {{ isLoading ? '中止' : '发送' }}
</TableButton> </TableButton>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
@@ -144,6 +157,9 @@ import PositionDeleteModal from "@/components/modals/ConfirmationDialog.vue";
import PositionFormDialog from "@/components/modals/PositionFormDialog.vue"; import PositionFormDialog from "@/components/modals/PositionFormDialog.vue";
import RoleDeleteModal from "@/components/modals/ConfirmationDialog.vue"; import RoleDeleteModal from "@/components/modals/ConfirmationDialog.vue";
import RoleFormDialog from "@/components/modals/RoleFormDialog.vue"; import RoleFormDialog from "@/components/modals/RoleFormDialog.vue";
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
import { UserFormDialog } from "../modals";
import { computed } from "vue";
const { const {
messages, messages,
@@ -190,6 +206,15 @@ const actionExcStore = useActionExcStore();
const { availableDepartments, fetchAvailableDepartments } = const { availableDepartments, fetchAvailableDepartments } =
useDepartmentQuery(); 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> = { const commandPlaceholderMap: Record<string, string> = {
chat: "随便聊聊", chat: "随便聊聊",
search: "输入「创建用户、删除部门、创建岗位、创建角色、创建权限」试试看", search: "输入「创建用户、删除部门、创建岗位、创建角色、创建权限」试试看",
@@ -235,31 +260,6 @@ const renderMarkdown = (content: string | undefined) => {
// console.log('处理后HTML:', renderMarkdown(newVal[newVal.length - 1])); // console.log('处理后HTML:', renderMarkdown(newVal[newVal.length - 1]));
// }, { deep: true }); // }, { 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) => { const handleUpsertUserSubmit = async (data: UserUpsertSubmitModel) => {
await userUpsert.upsertUser(data); await userUpsert.upsertUser(data);
userUpsertModal.value?.hide(); userUpsertModal.value?.hide();
@@ -402,7 +402,12 @@ const chatByMode = async (
await executeAction(message); await executeAction(message);
actionExcStore.notify(true); actionExcStore.notify(true);
} else { } else {
await chat(message); // 聊天模式,判断是否使用知识库
if (selectedLibraryId.value !== undefined) {
await chat(message, selectedLibraryId.value);
} else {
await chat(message);
}
} }
}; };
@@ -427,6 +432,9 @@ onUnmounted(() => {
onMounted(async () => { onMounted(async () => {
initFlowbite(); initFlowbite();
// 加载知识库列表
await fetchLibraries();
const $upsertModalElement: HTMLElement | null = const $upsertModalElement: HTMLElement | null =
document.querySelector("#user-upsert-modal"); document.querySelector("#user-upsert-modal");
if ($upsertModalElement) { 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"> <script setup lang="ts">
defineProps({ defineProps({
href: { href: {
type: String, type: String,
required: true required: true,
}, },
imageSrc: { imageSrc: {
type: String, type: String,
required: true required: true,
}, },
imageAlt: { imageAlt: {
type: String, type: String,
default: 'promotion' default: "promotion",
}, },
label: { label: {
type: String, type: String,
default: '官方教程' default: "官方教程",
}, },
text: { text: {
type: String, type: String,
required: true required: true,
} },
}); });
</script> </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 SettingsIcon } from "./SettingsIcon.vue";
export { default as UsersIcon } from "./UsersIcon.vue"; export { default as UsersIcon } from "./UsersIcon.vue";
export { default as PermissionIcon } from "./PermissionIcon.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 { import {
DepartmentIcon, DepartmentIcon,
KnowledgeIcon,
LlmConfigIcon, LlmConfigIcon,
PermissionIcon, PermissionIcon,
PositionIcon, PositionIcon,
@@ -113,6 +114,11 @@ const menuItems = [
path: Routes.LLMCONFIGVIEW.fullPath(), path: Routes.LLMCONFIGVIEW.fullPath(),
icon: LlmConfigIcon, icon: LlmConfigIcon,
}, },
{
title: "知识库管理",
path: Routes.KNOWLEDGEVIEW.fullPath(),
icon: KnowledgeIcon,
},
]; ];
const route = useRoute(); const route = useRoute();

View File

@@ -29,7 +29,18 @@
import { Modal, initFlowbite } from "flowbite"; import { Modal, initFlowbite } from "flowbite";
import { computed, onMounted } from "vue"; 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<{ const props = defineProps<{
/** 对话框标题 */ /** 对话框标题 */
@@ -50,6 +61,12 @@ const maxWidthClass = computed(() => {
md: "max-w-md", md: "max-w-md",
lg: "max-w-lg", lg: "max-w-lg",
xl: "max-w-xl", 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"]; 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"> <h3 class="mb-4 text-base sm:text-lg font-medium text-gray-800">
{{ title }} {{ title }}
</h3> </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"> <div class="flex justify-center items-center space-x-3 sm:space-x-4">
<button type="button" @click="onSubmit" <Button variant="danger" @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> </Button>
<button type="button" @click="closeModal" <Button variant="secondary" @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>
</div> </div>
</div> </div>
</BaseDialog> </BaseDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Button } from "@/components/ui";
import BaseDialog from "./BaseDialog.vue"; import BaseDialog from "./BaseDialog.vue";
const { title, id, closeModal, onSubmit } = defineProps<{ const props = defineProps<{
/** 对话框标题 */
title: string; title: string;
/** 对话框内容 */
content?: string;
/** 对话框ID */
id: string; id: string;
/** 关闭对话框的回调函数 */
closeModal: () => void; closeModal: () => void;
/** 确认操作的回调函数 */
onSubmit: () => Promise<void>; onSubmit: () => Promise<void>;
}>(); }>();
</script> </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; throw err;
}, },
}); });
} catch (error) { } finally {
(event.target as HTMLInputElement).value = ""; (event.target as HTMLInputElement).value = "";
uploadLoading.value = false; 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> </select>
</div> </div>
</template> </template>
<button type="submit" <Button variant="primary" size="sm" @click.prevent="handleSearch">
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" <template #icon>
@click.prevent="handleSearch"> <svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<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"
<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" />
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" /> </svg>
</svg> </template>
搜索 搜索
</button> </Button>
</form> </form>
<!-- 额外操作按钮插槽 --> <!-- 额外操作按钮插槽 -->
@@ -53,6 +53,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, watch } from "vue"; import { onMounted, reactive, watch } from "vue";
import { Button } from "@/components/ui";
export interface FilterOption { export interface FilterOption {
value: string | number | boolean; 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> <template>
<div class="rounded-full border border-gray-200 flex items-center justify-center overflow-hidden flex-shrink-0" <div class="rounded-full border border-gray-200 flex items-center justify-center overflow-hidden flex-shrink-0"
:class="sizeClass"> :class="sizeClass">
<img v-if="processedSrc" :src="processedSrc" class="w-full h-full object-cover" :alt="alt"> <img :src="processedSrc" class="w-full h-full object-cover" :alt="alt">
<div v-else class="w-full h-full bg-gray-100"></div>
</div> </div>
</template> </template>
@@ -10,34 +9,29 @@
import { getUserAvatarUrl } from "@/utils/avatarUtil"; import { getUserAvatarUrl } from "@/utils/avatarUtil";
import { computed } from "vue"; import { computed } from "vue";
const { const props = defineProps<{
src = "", /** 头像图片源 */
alt = "用户头像",
size = "md",
} = defineProps<{
src?: string; src?: string;
/** 头像替代文本 */
alt?: string; alt?: string;
size?: "sm" | "md" | "lg"; /** 头像尺寸 */
size?: "xs" | "sm" | "md" | "lg" | "xl";
}>(); }>();
/** 尺寸样式映射 */
const sizeClass = computed(() => { const sizeClass = computed(() => {
switch (size) { const sizes = {
case "sm": xs: "w-6 h-6",
return "w-8 h-8"; sm: "w-8 h-8",
case "lg": md: "w-10 h-10",
return "w-12 h-12"; lg: "w-12 h-12",
default: xl: "w-16 h-16",
return "w-10 h-10"; };
} return sizes[props.size || "md"];
}); });
/** 处理后的图片源 */
const processedSrc = computed(() => { const processedSrc = computed(() => {
if (!src) { return getUserAvatarUrl(props.src);
return "";
}
if (src === "/trump.jpg") {
return src;
}
return getUserAvatarUrl(src);
}); });
</script> </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 };

Some files were not shown because too many files have changed in this diff Show More