mirror of
https://github.com/ccmjga/zhilu-admin
synced 2026-04-09 15:38:05 +00: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-open-ai:1.0.0")
|
||||||
implementation("dev.langchain4j:langchain4j-pgvector:1.0.1-beta6")
|
implementation("dev.langchain4j:langchain4j-pgvector:1.0.1-beta6")
|
||||||
implementation("dev.langchain4j:langchain4j-community-zhipu-ai:1.0.1-beta6")
|
implementation("dev.langchain4j:langchain4j-community-zhipu-ai:1.0.1-beta6")
|
||||||
|
implementation("dev.langchain4j:langchain4j-document-parser-apache-tika:1.1.0-beta7")
|
||||||
|
implementation("dev.langchain4j:langchain4j-document-loader-amazon-s3:1.1.0-beta7")
|
||||||
implementation("io.projectreactor:reactor-core:3.7.6")
|
implementation("io.projectreactor:reactor-core:3.7.6")
|
||||||
testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion")
|
testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion")
|
||||||
testImplementation("org.testcontainers:postgresql:$testcontainersVersion")
|
testImplementation("org.testcontainers:postgresql:$testcontainersVersion")
|
||||||
@@ -168,14 +170,8 @@ jooq {
|
|||||||
}
|
}
|
||||||
forcedTypes {
|
forcedTypes {
|
||||||
forcedType {
|
forcedType {
|
||||||
name = "varchar"
|
isJsonConverter = true
|
||||||
includeExpression = ".*"
|
includeTypes = "(?i:JSON|JSONB)"
|
||||||
includeTypes = "JSONB?"
|
|
||||||
}
|
|
||||||
forcedType {
|
|
||||||
name = "varchar"
|
|
||||||
includeExpression = ".*"
|
|
||||||
includeTypes = "INET"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.zl.mjga;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
||||||
|
@EnableAsync
|
||||||
@SpringBootApplication(scanBasePackages = {"com.zl.mjga", "org.jooq.generated"})
|
@SpringBootApplication(scanBasePackages = {"com.zl.mjga", "org.jooq.generated"})
|
||||||
public class ApplicationService {
|
public class ApplicationService {
|
||||||
|
|
||||||
|
|||||||
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;
|
package com.zl.mjga.config.ai;
|
||||||
|
|
||||||
|
import static dev.langchain4j.store.embedding.filter.MetadataFilterBuilder.metadataKey;
|
||||||
|
|
||||||
import com.zl.mjga.component.PromptConfiguration;
|
import com.zl.mjga.component.PromptConfiguration;
|
||||||
import com.zl.mjga.service.LlmService;
|
import com.zl.mjga.service.LlmService;
|
||||||
import dev.langchain4j.community.model.zhipu.ZhipuAiStreamingChatModel;
|
import dev.langchain4j.community.model.zhipu.ZhipuAiStreamingChatModel;
|
||||||
|
import dev.langchain4j.data.segment.TextSegment;
|
||||||
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
|
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
|
||||||
|
import dev.langchain4j.model.embedding.EmbeddingModel;
|
||||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||||
|
import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
|
||||||
import dev.langchain4j.service.AiServices;
|
import dev.langchain4j.service.AiServices;
|
||||||
|
import dev.langchain4j.store.embedding.EmbeddingStore;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.jooq.generated.mjga.enums.LlmCodeEnum;
|
import org.jooq.generated.mjga.enums.LlmCodeEnum;
|
||||||
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
|
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
|
||||||
@@ -54,11 +60,26 @@ public class ChatModelInitializer {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@DependsOn("flywayInitializer")
|
@DependsOn("flywayInitializer")
|
||||||
public AiChatAssistant zhiPuChatAssistant(ZhipuAiStreamingChatModel zhipuChatModel) {
|
public AiChatAssistant zhiPuChatAssistant(
|
||||||
|
ZhipuAiStreamingChatModel zhipuChatModel,
|
||||||
|
EmbeddingStore<TextSegment> zhiPuLibraryEmbeddingStore,
|
||||||
|
EmbeddingModel zhipuEmbeddingModel) {
|
||||||
return AiServices.builder(AiChatAssistant.class)
|
return AiServices.builder(AiChatAssistant.class)
|
||||||
.streamingChatModel(zhipuChatModel)
|
.streamingChatModel(zhipuChatModel)
|
||||||
.systemMessageProvider(chatMemoryId -> promptConfiguration.getSystem())
|
.systemMessageProvider(chatMemoryId -> promptConfiguration.getSystem())
|
||||||
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
|
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
|
||||||
|
.contentRetriever(
|
||||||
|
EmbeddingStoreContentRetriever.builder()
|
||||||
|
.embeddingStore(zhiPuLibraryEmbeddingStore)
|
||||||
|
.embeddingModel(zhipuEmbeddingModel)
|
||||||
|
.minScore(0.75)
|
||||||
|
.maxResults(5)
|
||||||
|
.dynamicFilter(
|
||||||
|
query -> {
|
||||||
|
String libraryId = (String) query.metadata().chatMemoryId();
|
||||||
|
return metadataKey("libraryId").isEqualTo(libraryId);
|
||||||
|
})
|
||||||
|
.build())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.zl.mjga.config.ai;
|
package com.zl.mjga.config.ai;
|
||||||
|
|
||||||
|
import com.zl.mjga.config.minio.MinIoConfig;
|
||||||
import com.zl.mjga.service.LlmService;
|
import com.zl.mjga.service.LlmService;
|
||||||
import dev.langchain4j.community.model.zhipu.ZhipuAiEmbeddingModel;
|
import dev.langchain4j.community.model.zhipu.ZhipuAiEmbeddingModel;
|
||||||
|
import dev.langchain4j.data.document.loader.amazon.s3.AmazonS3DocumentLoader;
|
||||||
|
import dev.langchain4j.data.document.loader.amazon.s3.AwsCredentials;
|
||||||
import dev.langchain4j.data.segment.TextSegment;
|
import dev.langchain4j.data.segment.TextSegment;
|
||||||
import dev.langchain4j.model.embedding.EmbeddingModel;
|
import dev.langchain4j.model.embedding.EmbeddingModel;
|
||||||
import dev.langchain4j.store.embedding.EmbeddingStore;
|
import dev.langchain4j.store.embedding.EmbeddingStore;
|
||||||
@@ -42,7 +45,7 @@ public class EmbeddingInitializer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public EmbeddingStore<TextSegment> zhiPuEmbeddingStore(EmbeddingModel zhipuEmbeddingModel) {
|
public EmbeddingStore<TextSegment> zhiPuEmbeddingStore() {
|
||||||
String hostPort = env.getProperty("DATABASE_HOST_PORT");
|
String hostPort = env.getProperty("DATABASE_HOST_PORT");
|
||||||
String host = hostPort.split(":")[0];
|
String host = hostPort.split(":")[0];
|
||||||
return PgVectorEmbeddingStore.builder()
|
return PgVectorEmbeddingStore.builder()
|
||||||
@@ -55,4 +58,28 @@ public class EmbeddingInitializer {
|
|||||||
.dimension(2048)
|
.dimension(2048)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public EmbeddingStore<TextSegment> zhiPuLibraryEmbeddingStore() {
|
||||||
|
String hostPort = env.getProperty("DATABASE_HOST_PORT");
|
||||||
|
String host = hostPort.split(":")[0];
|
||||||
|
return PgVectorEmbeddingStore.builder()
|
||||||
|
.host(host)
|
||||||
|
.port(env.getProperty("DATABASE_EXPOSE_PORT", Integer.class))
|
||||||
|
.database(env.getProperty("DATABASE_DB"))
|
||||||
|
.user(env.getProperty("DATABASE_USER"))
|
||||||
|
.password(env.getProperty("DATABASE_PASSWORD"))
|
||||||
|
.table("mjga.zhipu_library_embedding_store")
|
||||||
|
.dimension(2048)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AmazonS3DocumentLoader amazonS3DocumentLoader(MinIoConfig minIoConfig) {
|
||||||
|
return AmazonS3DocumentLoader.builder()
|
||||||
|
.endpointUrl(minIoConfig.getEndpoint())
|
||||||
|
.forcePathStyle(true)
|
||||||
|
.awsCredentials(new AwsCredentials(minIoConfig.getAccessKey(), minIoConfig.getSecretKey()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class WebSecurityConfig {
|
|||||||
new AntPathRequestMatcher("/auth/sign-up", HttpMethod.POST.name()),
|
new AntPathRequestMatcher("/auth/sign-up", HttpMethod.POST.name()),
|
||||||
new AntPathRequestMatcher("/v3/api-docs/**", HttpMethod.GET.name()),
|
new AntPathRequestMatcher("/v3/api-docs/**", HttpMethod.GET.name()),
|
||||||
new AntPathRequestMatcher("/swagger-ui/**", HttpMethod.GET.name()),
|
new AntPathRequestMatcher("/swagger-ui/**", HttpMethod.GET.name()),
|
||||||
|
new AntPathRequestMatcher("/ai/library/upload", HttpMethod.POST.name()),
|
||||||
new AntPathRequestMatcher("/swagger-ui.html", HttpMethod.GET.name()),
|
new AntPathRequestMatcher("/swagger-ui.html", HttpMethod.GET.name()),
|
||||||
new AntPathRequestMatcher("/error"));
|
new AntPathRequestMatcher("/error"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package com.zl.mjga.controller;
|
|||||||
|
|
||||||
import com.zl.mjga.dto.PageRequestDto;
|
import com.zl.mjga.dto.PageRequestDto;
|
||||||
import com.zl.mjga.dto.PageResponseDto;
|
import com.zl.mjga.dto.PageResponseDto;
|
||||||
|
import com.zl.mjga.dto.ai.ChatDto;
|
||||||
import com.zl.mjga.dto.ai.LlmQueryDto;
|
import com.zl.mjga.dto.ai.LlmQueryDto;
|
||||||
import com.zl.mjga.dto.ai.LlmVm;
|
import com.zl.mjga.dto.ai.LlmVm;
|
||||||
import com.zl.mjga.exception.BusinessException;
|
import com.zl.mjga.exception.BusinessException;
|
||||||
import com.zl.mjga.repository.*;
|
import com.zl.mjga.repository.*;
|
||||||
import com.zl.mjga.service.AiChatService;
|
import com.zl.mjga.service.AiChatService;
|
||||||
import com.zl.mjga.service.EmbeddingService;
|
|
||||||
import com.zl.mjga.service.LlmService;
|
import com.zl.mjga.service.LlmService;
|
||||||
|
import com.zl.mjga.service.RagService;
|
||||||
import dev.langchain4j.service.TokenStream;
|
import dev.langchain4j.service.TokenStream;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
@@ -35,7 +36,7 @@ public class AiController {
|
|||||||
|
|
||||||
private final AiChatService aiChatService;
|
private final AiChatService aiChatService;
|
||||||
private final LlmService llmService;
|
private final LlmService llmService;
|
||||||
private final EmbeddingService embeddingService;
|
private final RagService ragService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final DepartmentRepository departmentRepository;
|
private final DepartmentRepository departmentRepository;
|
||||||
private final PositionRepository positionRepository;
|
private final PositionRepository positionRepository;
|
||||||
@@ -72,9 +73,9 @@ public class AiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
public Flux<String> chat(Principal principal, @RequestBody String userMessage) {
|
public Flux<String> chat(Principal principal, @RequestBody ChatDto chatDto) {
|
||||||
Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
|
Sinks.Many<String> sink = Sinks.many().unicast().onBackpressureBuffer();
|
||||||
TokenStream chat = aiChatService.chatPrecedenceLlmWith(principal.getName(), userMessage);
|
TokenStream chat = aiChatService.chat(principal.getName(), chatDto);
|
||||||
chat.onPartialResponse(
|
chat.onPartialResponse(
|
||||||
text ->
|
text ->
|
||||||
sink.tryEmitNext(
|
sink.tryEmitNext(
|
||||||
@@ -109,7 +110,7 @@ public class AiController {
|
|||||||
if (!aiLlmConfig.getEnable()) {
|
if (!aiLlmConfig.getEnable()) {
|
||||||
throw new BusinessException("命令模型未启用,请开启后再试。");
|
throw new BusinessException("命令模型未启用,请开启后再试。");
|
||||||
}
|
}
|
||||||
return embeddingService.searchAction(message);
|
return ragService.searchAction(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")
|
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.zl.mjga.controller;
|
package com.zl.mjga.controller;
|
||||||
|
|
||||||
import com.zl.mjga.config.minio.MinIoConfig;
|
|
||||||
import com.zl.mjga.dto.PageRequestDto;
|
import com.zl.mjga.dto.PageRequestDto;
|
||||||
import com.zl.mjga.dto.PageResponseDto;
|
import com.zl.mjga.dto.PageResponseDto;
|
||||||
import com.zl.mjga.dto.department.DepartmentBindDto;
|
import com.zl.mjga.dto.department.DepartmentBindDto;
|
||||||
@@ -13,17 +12,11 @@ import com.zl.mjga.repository.PermissionRepository;
|
|||||||
import com.zl.mjga.repository.RoleRepository;
|
import com.zl.mjga.repository.RoleRepository;
|
||||||
import com.zl.mjga.repository.UserRepository;
|
import com.zl.mjga.repository.UserRepository;
|
||||||
import com.zl.mjga.service.IdentityAccessService;
|
import com.zl.mjga.service.IdentityAccessService;
|
||||||
import io.minio.MinioClient;
|
import com.zl.mjga.service.UploadService;
|
||||||
import io.minio.PutObjectArgs;
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.commons.lang3.RandomStringUtils;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
|
||||||
import org.jooq.generated.mjga.tables.pojos.User;
|
import org.jooq.generated.mjga.tables.pojos.User;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -41,8 +34,7 @@ public class IdentityAccessController {
|
|||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final RoleRepository roleRepository;
|
private final RoleRepository roleRepository;
|
||||||
private final PermissionRepository permissionRepository;
|
private final PermissionRepository permissionRepository;
|
||||||
private final MinioClient minioClient;
|
private final UploadService uploadService;
|
||||||
private final MinIoConfig minIoConfig;
|
|
||||||
|
|
||||||
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")
|
@PreAuthorize("hasAuthority(T(com.zl.mjga.model.urp.EPermission).WRITE_USER_ROLE_PERMISSION)")
|
||||||
@PostMapping(
|
@PostMapping(
|
||||||
@@ -50,40 +42,7 @@ public class IdentityAccessController {
|
|||||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||||
produces = MediaType.TEXT_PLAIN_VALUE)
|
produces = MediaType.TEXT_PLAIN_VALUE)
|
||||||
public String uploadAvatar(@RequestPart("file") MultipartFile multipartFile) throws Exception {
|
public String uploadAvatar(@RequestPart("file") MultipartFile multipartFile) throws Exception {
|
||||||
String originalFilename = multipartFile.getOriginalFilename();
|
return uploadService.uploadAvatarFile(multipartFile);
|
||||||
if (StringUtils.isEmpty(originalFilename)) {
|
|
||||||
throw new BusinessException("文件名不能为空");
|
|
||||||
}
|
|
||||||
String contentType = multipartFile.getContentType();
|
|
||||||
String extension = "";
|
|
||||||
if ("image/jpeg".equals(contentType)) {
|
|
||||||
extension = ".jpg";
|
|
||||||
} else if ("image/png".equals(contentType)) {
|
|
||||||
extension = ".png";
|
|
||||||
}
|
|
||||||
String objectName =
|
|
||||||
String.format(
|
|
||||||
"/avatar/%d%s%s",
|
|
||||||
Instant.now().toEpochMilli(),
|
|
||||||
RandomStringUtils.insecure().nextAlphabetic(6),
|
|
||||||
extension);
|
|
||||||
if (multipartFile.isEmpty()) {
|
|
||||||
throw new BusinessException("上传的文件不能为空");
|
|
||||||
}
|
|
||||||
long size = multipartFile.getSize();
|
|
||||||
if (size > 200 * 1024) {
|
|
||||||
throw new BusinessException("头像文件大小不能超过200KB");
|
|
||||||
}
|
|
||||||
BufferedImage img = ImageIO.read(multipartFile.getInputStream());
|
|
||||||
if (img == null) {
|
|
||||||
throw new BusinessException("非法的上传文件");
|
|
||||||
}
|
|
||||||
minioClient.putObject(
|
|
||||||
PutObjectArgs.builder().bucket(minIoConfig.getDefaultBucket()).object(objectName).stream(
|
|
||||||
multipartFile.getInputStream(), size, -1)
|
|
||||||
.contentType(multipartFile.getContentType())
|
|
||||||
.build());
|
|
||||||
return objectName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
|
|||||||
@@ -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.AiChatAssistant;
|
||||||
import com.zl.mjga.config.ai.SystemToolAssistant;
|
import com.zl.mjga.config.ai.SystemToolAssistant;
|
||||||
|
import com.zl.mjga.dto.ai.ChatDto;
|
||||||
import com.zl.mjga.exception.BusinessException;
|
import com.zl.mjga.exception.BusinessException;
|
||||||
import dev.langchain4j.service.TokenStream;
|
import dev.langchain4j.service.TokenStream;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -39,8 +40,20 @@ public class AiChatService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public TokenStream chatPrecedenceLlmWith(String sessionIdentifier, String userMessage) {
|
public TokenStream chat(String sessionIdentifier, ChatDto chatDto) {
|
||||||
|
return switch (chatDto.mode()) {
|
||||||
|
case NORMAL -> chatWithPrecedenceLlm(sessionIdentifier, chatDto);
|
||||||
|
case WITH_LIBRARY -> chatWithLibrary(chatDto.libraryId(), chatDto);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public TokenStream chatWithLibrary(Long libraryId, ChatDto chatDto) {
|
||||||
|
return zhiPuChatAssistant.chat(String.valueOf(libraryId), chatDto.message());
|
||||||
|
}
|
||||||
|
|
||||||
|
public TokenStream chatWithPrecedenceLlm(String sessionIdentifier, ChatDto chatDto) {
|
||||||
LlmCodeEnum code = getPrecedenceLlmCode();
|
LlmCodeEnum code = getPrecedenceLlmCode();
|
||||||
|
String userMessage = chatDto.message();
|
||||||
return switch (code) {
|
return switch (code) {
|
||||||
case ZHI_PU -> zhiPuChatAssistant.chat(sessionIdentifier, userMessage);
|
case ZHI_PU -> zhiPuChatAssistant.chat(sessionIdentifier, userMessage);
|
||||||
case DEEP_SEEK -> deepSeekChatAssistant.chat(sessionIdentifier, userMessage);
|
case DEEP_SEEK -> deepSeekChatAssistant.chat(sessionIdentifier, userMessage);
|
||||||
|
|||||||
@@ -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,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
username VARCHAR NOT NULL UNIQUE,
|
username VARCHAR NOT NULL UNIQUE,
|
||||||
avatar VARCHAR,
|
avatar VARCHAR,
|
||||||
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
password VARCHAR NOT NULL,
|
password VARCHAR NOT NULL,
|
||||||
enable BOOLEAN NOT NULL DEFAULT TRUE
|
enable BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
);
|
);
|
||||||
@@ -39,7 +39,7 @@ CREATE TABLE mjga.user_role_map (
|
|||||||
|
|
||||||
CREATE TABLE mjga.department (
|
CREATE TABLE mjga.department (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
name VARCHAR NOT NULL UNIQUE,
|
||||||
parent_id BIGINT,
|
parent_id BIGINT,
|
||||||
FOREIGN KEY (parent_id)
|
FOREIGN KEY (parent_id)
|
||||||
REFERENCES mjga.department(id)
|
REFERENCES mjga.department(id)
|
||||||
@@ -56,7 +56,7 @@ CREATE TABLE mjga.user_department_map (
|
|||||||
|
|
||||||
CREATE TABLE mjga.position (
|
CREATE TABLE mjga.position (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL UNIQUE
|
name VARCHAR NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE mjga.user_position_map (
|
CREATE TABLE mjga.user_position_map (
|
||||||
@@ -80,12 +80,12 @@ CREATE TYPE "llm_type_enum" AS ENUM (
|
|||||||
|
|
||||||
CREATE TABLE mjga.ai_llm_config (
|
CREATE TABLE mjga.ai_llm_config (
|
||||||
id BIGSERIAL NOT NULL UNIQUE,
|
id BIGSERIAL NOT NULL UNIQUE,
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
name VARCHAR NOT NULL UNIQUE,
|
||||||
code mjga.llm_code_enum NOT NULL UNIQUE,
|
code mjga.llm_code_enum NOT NULL UNIQUE,
|
||||||
model_name VARCHAR(255) NOT NULL,
|
model_name VARCHAR NOT NULL,
|
||||||
type LLM_TYPE_ENUM NOT NULL,
|
type LLM_TYPE_ENUM NOT NULL,
|
||||||
api_key VARCHAR(255) NOT NULL,
|
api_key VARCHAR NOT NULL,
|
||||||
url VARCHAR(255) NOT NULL,
|
url VARCHAR NOT NULL,
|
||||||
enable BOOLEAN NOT NULL DEFAULT true,
|
enable BOOLEAN NOT NULL DEFAULT true,
|
||||||
priority SMALLINT NOT NULL DEFAULT 0,
|
priority SMALLINT NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY(id)
|
PRIMARY KEY(id)
|
||||||
|
|||||||
@@ -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,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
username VARCHAR NOT NULL UNIQUE,
|
username VARCHAR NOT NULL UNIQUE,
|
||||||
avatar VARCHAR,
|
avatar VARCHAR,
|
||||||
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
create_time TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
password VARCHAR NOT NULL,
|
password VARCHAR NOT NULL,
|
||||||
enable BOOLEAN NOT NULL DEFAULT TRUE
|
enable BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
);
|
);
|
||||||
@@ -39,7 +39,7 @@ CREATE TABLE mjga.user_role_map (
|
|||||||
|
|
||||||
CREATE TABLE mjga.department (
|
CREATE TABLE mjga.department (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
name VARCHAR NOT NULL UNIQUE,
|
||||||
parent_id BIGINT,
|
parent_id BIGINT,
|
||||||
FOREIGN KEY (parent_id)
|
FOREIGN KEY (parent_id)
|
||||||
REFERENCES mjga.department(id)
|
REFERENCES mjga.department(id)
|
||||||
@@ -56,7 +56,7 @@ CREATE TABLE mjga.user_department_map (
|
|||||||
|
|
||||||
CREATE TABLE mjga.position (
|
CREATE TABLE mjga.position (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL UNIQUE
|
name VARCHAR NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE mjga.user_position_map (
|
CREATE TABLE mjga.user_position_map (
|
||||||
@@ -80,12 +80,12 @@ CREATE TYPE "llm_type_enum" AS ENUM (
|
|||||||
|
|
||||||
CREATE TABLE mjga.ai_llm_config (
|
CREATE TABLE mjga.ai_llm_config (
|
||||||
id BIGSERIAL NOT NULL UNIQUE,
|
id BIGSERIAL NOT NULL UNIQUE,
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
name VARCHAR NOT NULL UNIQUE,
|
||||||
code mjga.llm_code_enum NOT NULL UNIQUE,
|
code mjga.llm_code_enum NOT NULL UNIQUE,
|
||||||
model_name VARCHAR(255) NOT NULL,
|
model_name VARCHAR NOT NULL,
|
||||||
type LLM_TYPE_ENUM NOT NULL,
|
type LLM_TYPE_ENUM NOT NULL,
|
||||||
api_key VARCHAR(255) NOT NULL,
|
api_key VARCHAR NOT NULL,
|
||||||
url VARCHAR(255) NOT NULL,
|
url VARCHAR NOT NULL,
|
||||||
enable BOOLEAN NOT NULL DEFAULT true,
|
enable BOOLEAN NOT NULL DEFAULT true,
|
||||||
priority SMALLINT NOT NULL DEFAULT 0,
|
priority SMALLINT NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY(id)
|
PRIMARY KEY(id)
|
||||||
|
|||||||
@@ -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
|
Dockerfile
|
||||||
Caddyfile
|
Caddyfile
|
||||||
start.sh
|
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": {
|
"/ai/llm": {
|
||||||
"put": {
|
"put": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -192,6 +237,93 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/knowledge/library": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"library-controller"
|
||||||
|
],
|
||||||
|
"operationId": "upsertLibrary",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LibraryUpsertDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"tags": [
|
||||||
|
"library-controller"
|
||||||
|
],
|
||||||
|
"operationId": "deleteLibrary",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "libraryId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/knowledge/doc/upload": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"library-controller"
|
||||||
|
],
|
||||||
|
"operationId": "uploadLibraryDoc",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"required": [
|
||||||
|
"file",
|
||||||
|
"libraryId"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"libraryId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"text/plain": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/iam/user": {
|
"/iam/user": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -762,7 +894,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"$ref": "#/components/schemas/ChatDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -963,6 +1095,97 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/knowledge/segments": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"library-controller"
|
||||||
|
],
|
||||||
|
"operationId": "queryLibraryDocSegments",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "libraryDocId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"*/*": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LibraryDocSegment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/knowledge/libraries": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"library-controller"
|
||||||
|
],
|
||||||
|
"operationId": "queryLibraries",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"*/*": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Library"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/knowledge/docs": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"library-controller"
|
||||||
|
],
|
||||||
|
"operationId": "queryLibraryDocs",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "libraryId",
|
||||||
|
"in": "query",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"*/*": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/LibraryDoc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/iam/users": {
|
"/iam/users": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -1354,6 +1577,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"DocUpdateDto": {
|
||||||
|
"required": [
|
||||||
|
"enable",
|
||||||
|
"id",
|
||||||
|
"libId"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"libId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"enable": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"LlmVm": {
|
"LlmVm": {
|
||||||
"required": [
|
"required": [
|
||||||
"apiKey",
|
"apiKey",
|
||||||
@@ -1422,6 +1666,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LibraryUpsertDto": {
|
||||||
|
"required": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UserUpsertDto": {
|
"UserUpsertDto": {
|
||||||
"required": [
|
"required": [
|
||||||
"enable",
|
"enable",
|
||||||
@@ -1611,6 +1873,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ChatDto": {
|
||||||
|
"required": [
|
||||||
|
"message",
|
||||||
|
"mode"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"NORMAL",
|
||||||
|
"WITH_LIBRARY"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"libraryId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"PageRequestDto": {
|
"PageRequestDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1789,6 +2074,94 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"LibraryDocSegment": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"docId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"embeddingId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tokenUsage": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Library": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"createTime": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"JSON": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"LibraryDoc": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"libId": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"identify": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/components/schemas/JSON"
|
||||||
|
},
|
||||||
|
"enable": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"SUCCESS",
|
||||||
|
"INDEXING"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"createTime": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"updateTime": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UserQueryDto": {
|
"UserQueryDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
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;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/knowledge/doc": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put: operations["updateLibraryDoc"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteLibraryDoc"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/ai/llm": {
|
"/ai/llm": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -100,6 +116,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/knowledge/library": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["upsertLibrary"];
|
||||||
|
delete: operations["deleteLibrary"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/knowledge/doc/upload": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["uploadLibraryDoc"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/iam/user": {
|
"/iam/user": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -484,6 +532,54 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/knowledge/segments": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["queryLibraryDocSegments"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/knowledge/libraries": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["queryLibraries"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/knowledge/docs": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["queryLibraryDocs"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/iam/users": {
|
"/iam/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -684,6 +780,13 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
group: string;
|
group: string;
|
||||||
};
|
};
|
||||||
|
DocUpdateDto: {
|
||||||
|
/** Format: int64 */
|
||||||
|
id: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
libId: number;
|
||||||
|
enable: boolean;
|
||||||
|
};
|
||||||
LlmVm: {
|
LlmVm: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
id: number;
|
id: number;
|
||||||
@@ -705,6 +808,12 @@ export interface components {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
};
|
};
|
||||||
|
LibraryUpsertDto: {
|
||||||
|
/** Format: int64 */
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
UserUpsertDto: {
|
UserUpsertDto: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -760,6 +869,13 @@ export interface components {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
ChatDto: {
|
||||||
|
/** @enum {string} */
|
||||||
|
mode: "NORMAL" | "WITH_LIBRARY";
|
||||||
|
/** Format: int64 */
|
||||||
|
libraryId?: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
PageRequestDto: {
|
PageRequestDto: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -829,6 +945,42 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
isBound?: boolean;
|
isBound?: boolean;
|
||||||
};
|
};
|
||||||
|
LibraryDocSegment: {
|
||||||
|
/** Format: int64 */
|
||||||
|
id?: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
docId?: number;
|
||||||
|
embeddingId?: string;
|
||||||
|
content?: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
tokenUsage?: number;
|
||||||
|
};
|
||||||
|
Library: {
|
||||||
|
/** Format: int64 */
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createTime?: string;
|
||||||
|
};
|
||||||
|
JSON: Record<string, never>;
|
||||||
|
LibraryDoc: {
|
||||||
|
/** Format: int64 */
|
||||||
|
id?: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
libId?: number;
|
||||||
|
name?: string;
|
||||||
|
identify?: string;
|
||||||
|
path?: string;
|
||||||
|
meta?: components["schemas"]["JSON"];
|
||||||
|
enable?: boolean;
|
||||||
|
/** @enum {string} */
|
||||||
|
status?: "SUCCESS" | "INDEXING";
|
||||||
|
/** Format: date-time */
|
||||||
|
createTime?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updateTime?: string;
|
||||||
|
};
|
||||||
UserQueryDto: {
|
UserQueryDto: {
|
||||||
username?: string;
|
username?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -973,6 +1125,48 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
updateLibraryDoc: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DocUpdateDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteLibraryDoc: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
libraryDocId: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
updateLlm: {
|
updateLlm: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1105,6 +1299,76 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
upsertLibrary: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LibraryUpsertDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deleteLibrary: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
libraryId: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
uploadLibraryDoc: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
libraryId: string;
|
||||||
|
/** Format: binary */
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"text/plain": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
queryUserWithRolePermission: {
|
queryUserWithRolePermission: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
@@ -1633,7 +1897,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
requestBody: {
|
requestBody: {
|
||||||
content: {
|
content: {
|
||||||
"application/json": string;
|
"application/json": components["schemas"]["ChatDto"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: {
|
responses: {
|
||||||
@@ -1782,6 +2046,70 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
queryLibraryDocSegments: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
libraryDocId: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["LibraryDocSegment"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
queryLibraries: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["Library"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
queryLibraryDocs: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
libraryId: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["LibraryDoc"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
queryUsers: {
|
queryUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -5,14 +5,18 @@
|
|||||||
<div class="flex flex-col gap-y-5 flex-1 pb-2">
|
<div class="flex flex-col gap-y-5 flex-1 pb-2">
|
||||||
<li v-for="chatElement in messages" :key="chatElement.content"
|
<li v-for="chatElement in messages" :key="chatElement.content"
|
||||||
:class="['flex items-start gap-2.5', chatElement.isUser ? 'flex-row-reverse' : 'flex-row']">
|
:class="['flex items-start gap-2.5', chatElement.isUser ? 'flex-row-reverse' : 'flex-row']">
|
||||||
<Avatar :src="chatElement.isUser ? user.avatar : '/trump.jpg'" size="sm"
|
<Avatar :src="chatElement.isUser ? user.avatar : undefined" size="sm"
|
||||||
:alt="chatElement.isUser ? '用户头像' : 'AI头像'" />
|
:alt="chatElement.isUser ? '用户头像' : 'AI头像'" />
|
||||||
<div
|
<div
|
||||||
:class="['flex flex-col leading-1.5 p-4 border-gray-200 rounded-e-xl rounded-es-xl max-w-[calc(100%-40px)]', chatElement.isUser ? 'bg-blue-100' : 'bg-gray-100']">
|
:class="['flex flex-col leading-1.5 p-4 border-gray-200 max-w-[calc(100%-40px)]', chatElement.isUser ? 'bg-blue-100 rounded-tl-xl rounded-bl-xl rounded-br-xl' : 'bg-gray-100 rounded-e-xl rounded-es-xl']">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span class="text-sm font-semibold text-gray-900 ">{{ chatElement.username }}</span>
|
<span class="text-sm font-semibold text-gray-900 ">{{ chatElement.username }}</span>
|
||||||
<LoadingIcon :textColor="'text-gray-900'"
|
<LoadingIcon :textColor="'text-gray-900'"
|
||||||
v-if="isLoading && !chatElement.isUser && chatElement.content === ''" />
|
v-if="isLoading && !chatElement.isUser && chatElement.content === ''" />
|
||||||
|
<span v-if="!chatElement.isUser && chatElement.withLibrary"
|
||||||
|
class="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">
|
||||||
|
{{ chatElement.libraryName }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="markdown-content markdown-body text-base font-normal py-2.5 text-gray-900 break-words"
|
<div class="markdown-content markdown-body text-base font-normal py-2.5 text-gray-900 break-words"
|
||||||
@@ -34,14 +38,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="sticky">
|
<form class="sticky">
|
||||||
<button @click.prevent="clearConversation"
|
<div class="flex items-center justify-between gap-2 mb-2">
|
||||||
class="relative inline-flex items-center justify-center p-0.5 mb-2 me-2
|
<button @click.prevent="clearConversation"
|
||||||
overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-600 to-blue-500 group-hover:from-purple-600 group-hover:to-blue-500 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 ">
|
class="relative inline-flex items-center justify-center p-0.5
|
||||||
<span
|
overflow-hidden text-sm font-medium text-gray-900 rounded-lg group bg-gradient-to-br from-purple-600 to-blue-500 group-hover:from-purple-600 group-hover:to-blue-500 hover:text-white focus:ring-4 focus:outline-none focus:ring-blue-300 ">
|
||||||
class="relative px-3 py-2 text-xs font-medium transition-all ease-in duration-75 bg-white rounded-md group-hover:bg-transparent">
|
<span
|
||||||
开启新对话
|
class="relative px-3 py-2 text-xs font-medium transition-all ease-in duration-75 bg-white rounded-md group-hover:bg-transparent">
|
||||||
</span>
|
开启新对话
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select v-if="commandMode === 'chat'" v-model="selectedLibraryId"
|
||||||
|
class="bg-white border border-gray-300 text-gray-900 text-xs rounded-lg py-2 px-2 flex-1 max-w-48">
|
||||||
|
<option :value="undefined">不使用知识库</option>
|
||||||
|
<option v-for="library in libraries" :key="library.id" :value="library.id">
|
||||||
|
{{ library.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="w-full border border-gray-200 rounded-lg bg-gray-50">
|
<div class="w-full border border-gray-200 rounded-lg bg-gray-50">
|
||||||
<div class="px-4 py-2 bg-white rounded-t-lg">
|
<div class="px-4 py-2 bg-white rounded-t-lg">
|
||||||
<label for="comment" class="sr-only"></label>
|
<label for="comment" class="sr-only"></label>
|
||||||
@@ -51,7 +65,7 @@
|
|||||||
" required></textarea>
|
" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between px-2 py-2 border-t border-gray-200">
|
<div class="flex justify-between px-2 py-2 border-t border-gray-200">
|
||||||
<select id="countries" v-model="commandMode"
|
<select id="commandMode" v-model="commandMode"
|
||||||
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg block">
|
class="bg-white border border-gray-300 text-gray-900 text-sm rounded-lg block">
|
||||||
<option selected :value="'execute'">指令模式</option>
|
<option selected :value="'execute'">指令模式</option>
|
||||||
<option :value="'search'">搜索模式</option>
|
<option :value="'search'">搜索模式</option>
|
||||||
@@ -62,7 +76,6 @@
|
|||||||
{{ isLoading ? '中止' : '发送' }}
|
{{ isLoading ? '中止' : '发送' }}
|
||||||
</TableButton>
|
</TableButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,6 +157,9 @@ import PositionDeleteModal from "@/components/modals/ConfirmationDialog.vue";
|
|||||||
import PositionFormDialog from "@/components/modals/PositionFormDialog.vue";
|
import PositionFormDialog from "@/components/modals/PositionFormDialog.vue";
|
||||||
import RoleDeleteModal from "@/components/modals/ConfirmationDialog.vue";
|
import RoleDeleteModal from "@/components/modals/ConfirmationDialog.vue";
|
||||||
import RoleFormDialog from "@/components/modals/RoleFormDialog.vue";
|
import RoleFormDialog from "@/components/modals/RoleFormDialog.vue";
|
||||||
|
import { useKnowledgeQuery } from "@/composables/knowledge/useKnowledgeQuery";
|
||||||
|
import { UserFormDialog } from "../modals";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -190,6 +206,15 @@ const actionExcStore = useActionExcStore();
|
|||||||
const { availableDepartments, fetchAvailableDepartments } =
|
const { availableDepartments, fetchAvailableDepartments } =
|
||||||
useDepartmentQuery();
|
useDepartmentQuery();
|
||||||
|
|
||||||
|
// 知识库相关
|
||||||
|
const { libraries, fetchLibraries } = useKnowledgeQuery();
|
||||||
|
const selectedLibraryId = ref<number | null | undefined>(undefined);
|
||||||
|
const selectedLibraryName = computed(() => {
|
||||||
|
return libraries.value.find(
|
||||||
|
(library) => library.id === selectedLibraryId.value,
|
||||||
|
)?.name;
|
||||||
|
});
|
||||||
|
|
||||||
const commandPlaceholderMap: Record<string, string> = {
|
const commandPlaceholderMap: Record<string, string> = {
|
||||||
chat: "随便聊聊",
|
chat: "随便聊聊",
|
||||||
search: "输入「创建用户、删除部门、创建岗位、创建角色、创建权限」试试看",
|
search: "输入「创建用户、删除部门、创建岗位、创建角色、创建权限」试试看",
|
||||||
@@ -235,31 +260,6 @@ const renderMarkdown = (content: string | undefined) => {
|
|||||||
// console.log('处理后HTML:', renderMarkdown(newVal[newVal.length - 1]));
|
// console.log('处理后HTML:', renderMarkdown(newVal[newVal.length - 1]));
|
||||||
// }, { deep: true });
|
// }, { deep: true });
|
||||||
|
|
||||||
const handleDeleteUserClick = (input: string) => {
|
|
||||||
currentDeleteUsername.value = input;
|
|
||||||
userDeleteModal.value?.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteDepartmentClick = (input: string) => {
|
|
||||||
currentDeleteDepartmentName.value = input;
|
|
||||||
departmentDeleteModal.value?.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePositionClick = (input: string) => {
|
|
||||||
currentDeletePositionName.value = input;
|
|
||||||
positionDeleteModal.value?.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteRoleClick = (input: string) => {
|
|
||||||
currentDeleteRoleName.value = input;
|
|
||||||
roleDeleteModal.value?.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePermissionClick = (input: string) => {
|
|
||||||
currentDeletePermissionName.value = input;
|
|
||||||
permissionDeleteModal.value?.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpsertUserSubmit = async (data: UserUpsertSubmitModel) => {
|
const handleUpsertUserSubmit = async (data: UserUpsertSubmitModel) => {
|
||||||
await userUpsert.upsertUser(data);
|
await userUpsert.upsertUser(data);
|
||||||
userUpsertModal.value?.hide();
|
userUpsertModal.value?.hide();
|
||||||
@@ -402,7 +402,12 @@ const chatByMode = async (
|
|||||||
await executeAction(message);
|
await executeAction(message);
|
||||||
actionExcStore.notify(true);
|
actionExcStore.notify(true);
|
||||||
} else {
|
} else {
|
||||||
await chat(message);
|
// 聊天模式,判断是否使用知识库
|
||||||
|
if (selectedLibraryId.value !== undefined) {
|
||||||
|
await chat(message, selectedLibraryId.value);
|
||||||
|
} else {
|
||||||
|
await chat(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,6 +432,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
initFlowbite();
|
initFlowbite();
|
||||||
|
// 加载知识库列表
|
||||||
|
await fetchLibraries();
|
||||||
|
|
||||||
const $upsertModalElement: HTMLElement | null =
|
const $upsertModalElement: HTMLElement | null =
|
||||||
document.querySelector("#user-upsert-modal");
|
document.querySelector("#user-upsert-modal");
|
||||||
if ($upsertModalElement) {
|
if ($upsertModalElement) {
|
||||||
|
|||||||
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">
|
<script setup lang="ts">
|
||||||
defineProps({
|
defineProps({
|
||||||
href: {
|
href: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
imageSrc: {
|
imageSrc: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true,
|
||||||
},
|
},
|
||||||
imageAlt: {
|
imageAlt: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'promotion'
|
default: "promotion",
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '官方教程'
|
default: "官方教程",
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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 SettingsIcon } from "./SettingsIcon.vue";
|
||||||
export { default as UsersIcon } from "./UsersIcon.vue";
|
export { default as UsersIcon } from "./UsersIcon.vue";
|
||||||
export { default as PermissionIcon } from "./PermissionIcon.vue";
|
export { default as PermissionIcon } from "./PermissionIcon.vue";
|
||||||
|
export { default as KnowledgeIcon } from "./KnowledgeIcon.vue";
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { RouterLink, useRoute } from "vue-router";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DepartmentIcon,
|
DepartmentIcon,
|
||||||
|
KnowledgeIcon,
|
||||||
LlmConfigIcon,
|
LlmConfigIcon,
|
||||||
PermissionIcon,
|
PermissionIcon,
|
||||||
PositionIcon,
|
PositionIcon,
|
||||||
@@ -113,6 +114,11 @@ const menuItems = [
|
|||||||
path: Routes.LLMCONFIGVIEW.fullPath(),
|
path: Routes.LLMCONFIGVIEW.fullPath(),
|
||||||
icon: LlmConfigIcon,
|
icon: LlmConfigIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "知识库管理",
|
||||||
|
path: Routes.KNOWLEDGEVIEW.fullPath(),
|
||||||
|
icon: KnowledgeIcon,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|||||||
@@ -29,7 +29,18 @@
|
|||||||
import { Modal, initFlowbite } from "flowbite";
|
import { Modal, initFlowbite } from "flowbite";
|
||||||
import { computed, onMounted } from "vue";
|
import { computed, onMounted } from "vue";
|
||||||
|
|
||||||
export type ModalSize = "xs" | "sm" | "md" | "lg" | "xl";
|
export type ModalSize =
|
||||||
|
| "xs"
|
||||||
|
| "sm"
|
||||||
|
| "md"
|
||||||
|
| "lg"
|
||||||
|
| "xl"
|
||||||
|
| "2xl"
|
||||||
|
| "3xl"
|
||||||
|
| "4xl"
|
||||||
|
| "5xl"
|
||||||
|
| "6xl"
|
||||||
|
| "7xl";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 对话框标题 */
|
/** 对话框标题 */
|
||||||
@@ -50,6 +61,12 @@ const maxWidthClass = computed(() => {
|
|||||||
md: "max-w-md",
|
md: "max-w-md",
|
||||||
lg: "max-w-lg",
|
lg: "max-w-lg",
|
||||||
xl: "max-w-xl",
|
xl: "max-w-xl",
|
||||||
|
"2xl": "max-w-2xl",
|
||||||
|
"3xl": "max-w-3xl",
|
||||||
|
"4xl": "max-w-4xl",
|
||||||
|
"5xl": "max-w-5xl",
|
||||||
|
"6xl": "max-w-6xl",
|
||||||
|
"7xl": "max-w-7xl",
|
||||||
};
|
};
|
||||||
|
|
||||||
return sizes[props.size || "md"];
|
return sizes[props.size || "md"];
|
||||||
|
|||||||
@@ -9,25 +9,33 @@
|
|||||||
<h3 class="mb-4 text-base sm:text-lg font-medium text-gray-800">
|
<h3 class="mb-4 text-base sm:text-lg font-medium text-gray-800">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
<p v-if="content" class="mb-4 text-sm text-gray-500">{{ content }}</p>
|
||||||
<div class="flex justify-center items-center space-x-3 sm:space-x-4">
|
<div class="flex justify-center items-center space-x-3 sm:space-x-4">
|
||||||
<button type="button" @click="onSubmit"
|
<Button variant="danger" @click="onSubmit">
|
||||||
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center min-w-[80px]">
|
|
||||||
是
|
是
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" @click="closeModal"
|
<Button variant="secondary" @click="closeModal">
|
||||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 min-w-[80px]">否</button>
|
否
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
import BaseDialog from "./BaseDialog.vue";
|
import BaseDialog from "./BaseDialog.vue";
|
||||||
|
|
||||||
const { title, id, closeModal, onSubmit } = defineProps<{
|
const props = defineProps<{
|
||||||
|
/** 对话框标题 */
|
||||||
title: string;
|
title: string;
|
||||||
|
/** 对话框内容 */
|
||||||
|
content?: string;
|
||||||
|
/** 对话框ID */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** 关闭对话框的回调函数 */
|
||||||
closeModal: () => void;
|
closeModal: () => void;
|
||||||
|
/** 确认操作的回调函数 */
|
||||||
onSubmit: () => Promise<void>;
|
onSubmit: () => Promise<void>;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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;
|
throw err;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} finally {
|
||||||
(event.target as HTMLInputElement).value = "";
|
(event.target as HTMLInputElement).value = "";
|
||||||
uploadLoading.value = false;
|
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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<button type="submit"
|
<Button variant="primary" size="sm" @click.prevent="handleSearch">
|
||||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-3 py-2 min-w-[70px] flex items-center justify-center"
|
<template #icon>
|
||||||
@click.prevent="handleSearch">
|
<svg class="w-4 h-4" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||||
<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
||||||
d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
|
</svg>
|
||||||
</svg>
|
</template>
|
||||||
搜索
|
搜索
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- 额外操作按钮插槽 -->
|
<!-- 额外操作按钮插槽 -->
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, watch } from "vue";
|
import { onMounted, reactive, watch } from "vue";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
export interface FilterOption {
|
export interface FilterOption {
|
||||||
value: string | number | boolean;
|
value: string | number | boolean;
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div class="rounded-full border border-gray-200 flex items-center justify-center overflow-hidden flex-shrink-0"
|
<div class="rounded-full border border-gray-200 flex items-center justify-center overflow-hidden flex-shrink-0"
|
||||||
:class="sizeClass">
|
:class="sizeClass">
|
||||||
<img v-if="processedSrc" :src="processedSrc" class="w-full h-full object-cover" :alt="alt">
|
<img :src="processedSrc" class="w-full h-full object-cover" :alt="alt">
|
||||||
<div v-else class="w-full h-full bg-gray-100"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -10,34 +9,29 @@
|
|||||||
import { getUserAvatarUrl } from "@/utils/avatarUtil";
|
import { getUserAvatarUrl } from "@/utils/avatarUtil";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
const {
|
const props = defineProps<{
|
||||||
src = "",
|
/** 头像图片源 */
|
||||||
alt = "用户头像",
|
|
||||||
size = "md",
|
|
||||||
} = defineProps<{
|
|
||||||
src?: string;
|
src?: string;
|
||||||
|
/** 头像替代文本 */
|
||||||
alt?: string;
|
alt?: string;
|
||||||
size?: "sm" | "md" | "lg";
|
/** 头像尺寸 */
|
||||||
|
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
/** 尺寸样式映射 */
|
||||||
const sizeClass = computed(() => {
|
const sizeClass = computed(() => {
|
||||||
switch (size) {
|
const sizes = {
|
||||||
case "sm":
|
xs: "w-6 h-6",
|
||||||
return "w-8 h-8";
|
sm: "w-8 h-8",
|
||||||
case "lg":
|
md: "w-10 h-10",
|
||||||
return "w-12 h-12";
|
lg: "w-12 h-12",
|
||||||
default:
|
xl: "w-16 h-16",
|
||||||
return "w-10 h-10";
|
};
|
||||||
}
|
return sizes[props.size || "md"];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 处理后的图片源 */
|
||||||
const processedSrc = computed(() => {
|
const processedSrc = computed(() => {
|
||||||
if (!src) {
|
return getUserAvatarUrl(props.src);
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (src === "/trump.jpg") {
|
|
||||||
return src;
|
|
||||||
}
|
|
||||||
return getUserAvatarUrl(src);
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
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 };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user