mirror of
https://github.com/ccmjga/zhilu-admin
synced 2026-03-13 21:27:19 +08:00
Merge branch 'dev'
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
35
backend/src/main/java/com/zl/mjga/config/JacksonConfig.java
Normal file
35
backend/src/main/java/com/zl/mjga/config/JacksonConfig.java
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
7
backend/src/main/java/com/zl/mjga/dto/ai/ChatDto.java
Normal file
7
backend/src/main/java/com/zl/mjga/dto/ai/ChatDto.java
Normal 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) {}
|
||||
@@ -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) {}
|
||||
@@ -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) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.zl.mjga.model.urp;
|
||||
|
||||
public enum ChatMode {
|
||||
NORMAL,
|
||||
WITH_LIBRARY
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
181
backend/src/main/java/com/zl/mjga/service/RagService.java
Normal file
181
backend/src/main/java/com/zl/mjga/service/RagService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
81
backend/src/main/java/com/zl/mjga/service/UploadService.java
Normal file
81
backend/src/main/java/com/zl/mjga/service/UploadService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
@@ -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
2
frontend/.gitignore
vendored
@@ -186,3 +186,5 @@ compose.yaml
|
||||
Dockerfile
|
||||
Caddyfile
|
||||
start.sh
|
||||
|
||||
.cursor
|
||||
|
||||
@@ -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": {
|
||||
|
||||
330
frontend/src/api/types/schema.d.ts
vendored
330
frontend/src/api/types/schema.d.ts
vendored
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
44
frontend/src/components/common/CardBase.vue
Normal file
44
frontend/src/components/common/CardBase.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
4
frontend/src/components/common/index.ts
Normal file
4
frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import CardBase from "./CardBase.vue";
|
||||
import PromotionBanner from "./PromotionBanner.vue";
|
||||
|
||||
export { CardBase, PromotionBanner };
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
37
frontend/src/components/common/knowledge/SegmentCard.vue
Normal file
37
frontend/src/components/common/knowledge/SegmentCard.vue
Normal 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>
|
||||
11
frontend/src/components/common/knowledge/index.ts
Normal file
11
frontend/src/components/common/knowledge/index.ts
Normal 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,
|
||||
};
|
||||
4
frontend/src/components/form/index.ts
Normal file
4
frontend/src/components/form/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import FormInput from "./FormInput.vue";
|
||||
import FormSelect from "./FormSelect.vue";
|
||||
|
||||
export { FormInput, FormSelect };
|
||||
7
frontend/src/components/icons/KnowledgeIcon.vue
Normal file
7
frontend/src/components/icons/KnowledgeIcon.vue
Normal 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>
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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>
|
||||
|
||||
93
frontend/src/components/modals/LibraryFormDialog.vue
Normal file
93
frontend/src/components/modals/LibraryFormDialog.vue
Normal 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>
|
||||
@@ -116,10 +116,9 @@ const handleFileChange = (event: Event) => {
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
} finally {
|
||||
(event.target as HTMLInputElement).value = "";
|
||||
uploadLoading.value = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
23
frontend/src/components/modals/index.ts
Normal file
23
frontend/src/components/modals/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
15
frontend/src/components/tables/index.ts
Normal file
15
frontend/src/components/tables/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
122
frontend/src/components/ui/Button.vue
Normal file
122
frontend/src/components/ui/Button.vue
Normal 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>
|
||||
7
frontend/src/components/ui/index.ts
Normal file
7
frontend/src/components/ui/index.ts
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
6
frontend/src/composables/common/index.ts
Normal file
6
frontend/src/composables/common/index.ts
Normal 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 };
|
||||
65
frontend/src/composables/knowledge/useKnowledgeQuery.ts
Normal file
65
frontend/src/composables/knowledge/useKnowledgeQuery.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
72
frontend/src/composables/knowledge/useKnowledgeUpsert.ts
Normal file
72
frontend/src/composables/knowledge/useKnowledgeUpsert.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
36
frontend/src/types/KnowledgeTypes.ts
Normal file
36
frontend/src/types/KnowledgeTypes.ts
Normal 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",
|
||||
}
|
||||
@@ -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则直接返回
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
4
frontend/src/utils/index.ts
Normal file
4
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { getUserAvatarUrl } from "./avatarUtil";
|
||||
import { dayjs, formatDate, formatDateString } from "./dateUtil";
|
||||
|
||||
export { getUserAvatarUrl, dayjs, formatDate, formatDateString };
|
||||
203
frontend/src/views/KnowledgeDocManagementPage.vue
Normal file
203
frontend/src/views/KnowledgeDocManagementPage.vue
Normal 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>
|
||||
167
frontend/src/views/KnowledgeManagementPage.vue
Normal file
167
frontend/src/views/KnowledgeManagementPage.vue
Normal 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>
|
||||
76
frontend/src/views/KnowledgeSegmentsPage.vue
Normal file
76
frontend/src/views/KnowledgeSegmentsPage.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user