Merge branch 'dev'

This commit is contained in:
Chuck1sn
2025-05-26 12:02:33 +08:00
34 changed files with 594 additions and 264 deletions

View File

@@ -61,6 +61,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-quartz")
implementation("dev.langchain4j:langchain4j: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-community-zhipu-ai:1.0.1-beta6")
implementation("io.projectreactor:reactor-core:3.7.6")
testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion")

View File

@@ -14,17 +14,17 @@ import org.springframework.context.annotation.DependsOn;
@Configuration
@RequiredArgsConstructor
public class ChatModelConfig {
public class ChatModelInitializer {
private final LlmService llmService;
private final PromptConfiguration promptConfiguration;
@Bean
@DependsOn("flywayInitializer")
public ZhipuAiStreamingChatModel zhipuChatModel(ZhiPuConfiguration zhiPuConfiguration) {
public ZhipuAiStreamingChatModel zhipuChatModel(ZhiPuChatModelConfig zhiPuChatModelConfig) {
return ZhipuAiStreamingChatModel.builder()
.model(zhiPuConfiguration.getModelName())
.apiKey(zhiPuConfiguration.getApiKey())
.model(zhiPuChatModelConfig.getModelName())
.apiKey(zhiPuChatModelConfig.getApiKey())
.logRequests(true)
.logResponses(true)
.build();
@@ -32,11 +32,12 @@ public class ChatModelConfig {
@Bean
@DependsOn("flywayInitializer")
public OpenAiStreamingChatModel deepSeekChatModel(DeepSeekConfiguration deepSeekConfiguration) {
public OpenAiStreamingChatModel deepSeekChatModel(
DeepSeekChatModelConfig deepSeekChatModelConfig) {
return OpenAiStreamingChatModel.builder()
.baseUrl(deepSeekConfiguration.getBaseUrl())
.apiKey(deepSeekConfiguration.getApiKey())
.modelName(deepSeekConfiguration.getModelName())
.baseUrl(deepSeekChatModelConfig.getBaseUrl())
.apiKey(deepSeekChatModelConfig.getApiKey())
.modelName(deepSeekChatModelConfig.getModelName())
.build();
}
@@ -62,19 +63,19 @@ public class ChatModelConfig {
@Bean
@DependsOn("flywayInitializer")
public DeepSeekConfiguration deepSeekConfiguration() {
DeepSeekConfiguration deepSeekConfiguration = new DeepSeekConfiguration();
public DeepSeekChatModelConfig deepSeekConfiguration() {
DeepSeekChatModelConfig deepSeekChatModelConfig = new DeepSeekChatModelConfig();
AiLlmConfig deepSeek = llmService.loadConfig(LlmCodeEnum.DEEP_SEEK);
deepSeekConfiguration.init(deepSeek);
return deepSeekConfiguration;
deepSeekChatModelConfig.init(deepSeek);
return deepSeekChatModelConfig;
}
@Bean
@DependsOn("flywayInitializer")
public ZhiPuConfiguration zhiPuConfiguration() {
ZhiPuConfiguration zhiPuConfiguration = new ZhiPuConfiguration();
public ZhiPuChatModelConfig zhiPuConfiguration() {
ZhiPuChatModelConfig zhiPuChatModelConfig = new ZhiPuChatModelConfig();
AiLlmConfig aiLlmConfig = llmService.loadConfig(LlmCodeEnum.ZHI_PU);
zhiPuConfiguration.init(aiLlmConfig);
return zhiPuConfiguration;
zhiPuChatModelConfig.init(aiLlmConfig);
return zhiPuChatModelConfig;
}
}

View File

@@ -2,11 +2,9 @@ package com.zl.mjga.config.ai;
import lombok.Data;
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
import org.springframework.stereotype.Component;
@Data
@Component
public class ZhiPuConfiguration {
public class DeepSeekChatModelConfig {
private String baseUrl;
private String apiKey;

View File

@@ -0,0 +1,58 @@
package com.zl.mjga.config.ai;
import com.zl.mjga.service.LlmService;
import dev.langchain4j.community.model.zhipu.ZhipuAiEmbeddingModel;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.pgvector.PgVectorEmbeddingStore;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import org.jooq.generated.mjga.enums.LlmCodeEnum;
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.core.env.Environment;
@Configuration
@RequiredArgsConstructor
public class EmbeddingInitializer {
@Resource private Environment env;
private final LlmService llmService;
@Bean
@DependsOn("flywayInitializer")
public ZhiPuEmbeddingModelConfig zhiPuEmbeddingModelConfig() {
ZhiPuEmbeddingModelConfig zhiPuEmbeddingModelConfig = new ZhiPuEmbeddingModelConfig();
AiLlmConfig aiLlmConfig = llmService.loadConfig(LlmCodeEnum.ZHI_PU_EMBEDDING);
zhiPuEmbeddingModelConfig.init(aiLlmConfig);
return zhiPuEmbeddingModelConfig;
}
@Bean
@DependsOn("flywayInitializer")
public EmbeddingModel zhipuEmbeddingModel(ZhiPuEmbeddingModelConfig zhiPuEmbeddingModelConfig) {
return ZhipuAiEmbeddingModel.builder()
.apiKey(zhiPuEmbeddingModelConfig.getApiKey())
.model(zhiPuEmbeddingModelConfig.getModelName())
.dimensions(2048)
.build();
}
@Bean
public EmbeddingStore<TextSegment> zhiPuEmbeddingStore(EmbeddingModel zhipuEmbeddingModel) {
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_embedding_store")
.dimension(2048)
.build();
}
}

View File

@@ -4,7 +4,7 @@ import lombok.Data;
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
@Data
public class DeepSeekConfiguration {
public class ZhiPuChatModelConfig {
private String baseUrl;
private String apiKey;

View File

@@ -0,0 +1,20 @@
package com.zl.mjga.config.ai;
import lombok.Data;
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
@Data
public class ZhiPuEmbeddingModelConfig {
private String baseUrl;
private String apiKey;
private String modelName;
private Boolean enable;
public void init(AiLlmConfig config) {
this.baseUrl = config.getUrl();
this.apiKey = config.getApiKey();
this.modelName = config.getModelName();
this.enable = config.getEnable();
}
}

View File

@@ -4,15 +4,20 @@ import com.zl.mjga.dto.PageRequestDto;
import com.zl.mjga.dto.PageResponseDto;
import com.zl.mjga.dto.ai.LlmQueryDto;
import com.zl.mjga.dto.ai.LlmVm;
import com.zl.mjga.exception.BusinessException;
import com.zl.mjga.service.AiChatService;
import com.zl.mjga.service.EmbeddingService;
import com.zl.mjga.service.LlmService;
import dev.langchain4j.service.TokenStream;
import jakarta.validation.Valid;
import java.security.Principal;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jooq.generated.mjga.enums.LlmCodeEnum;
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -28,6 +33,7 @@ public class AiController {
private final AiChatService aiChatService;
private final LlmService llmService;
private final EmbeddingService embeddingService;
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(Principal principal, @RequestBody String userMessage) {
@@ -57,4 +63,13 @@ public class AiController {
@ModelAttribute PageRequestDto pageRequestDto, @ModelAttribute LlmQueryDto llmQueryDto) {
return llmService.pageQueryLlm(pageRequestDto, llmQueryDto);
}
@PostMapping("/action/chat")
public Map<String, String> actionChat(@RequestBody String message) {
AiLlmConfig aiLlmConfig = llmService.loadConfig(LlmCodeEnum.ZHI_PU);
if (!aiLlmConfig.getEnable()) {
throw new BusinessException("命令模型未启用,请开启后再试。");
}
return embeddingService.searchAction(message);
}
}

View File

@@ -1,3 +1,3 @@
package com.zl.mjga.dto.ai;
public record LlmQueryDto(String name) {}
public record LlmQueryDto(String name, String type) {}

View File

@@ -12,6 +12,8 @@ public class LlmVm {
@NotEmpty(message = "模型名称不能为空") private String modelName;
@NotEmpty(message = "模型类型不能为空") private String type;
@NotEmpty(message = "apikey 不能为空") private String apiKey;
@NotEmpty(message = "url 不能为空") private String url;

View File

@@ -0,0 +1,14 @@
package com.zl.mjga.model.urp;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public enum Actions {
CREATE_USER("CREATE_USER", "创建新用户"),
DELETE_USER("DELETE_USER", "删除用户");
public static final String INDEX_KEY = "action";
private final String code;
private final String content;
}

View File

@@ -9,6 +9,7 @@ import org.apache.commons.lang3.StringUtils;
import org.jooq.Configuration;
import org.jooq.Record;
import org.jooq.Result;
import org.jooq.generated.default_schema.enums.LlmTypeEnum;
import org.jooq.generated.mjga.tables.daos.AiLlmConfigDao;
import org.jooq.impl.DSL;
import org.springframework.beans.factory.annotation.Autowired;
@@ -25,12 +26,17 @@ public class LlmRepository extends AiLlmConfigDao {
public Result<Record> pageFetchBy(PageRequestDto pageRequestDto, LlmQueryDto llmQueryDto) {
return ctx()
.select(
AI_LLM_CONFIG.asterisk(), DSL.count().over().as("total_llm").convertFrom(Long::valueOf))
AI_LLM_CONFIG.asterisk(),
DSL.count().over().as("total_llm").convertFrom(Long::valueOf))
.from(AI_LLM_CONFIG)
.where(
StringUtils.isNotEmpty(llmQueryDto.name())
? AI_LLM_CONFIG.NAME.eq(llmQueryDto.name())
: noCondition())
.and(
StringUtils.isNotEmpty(llmQueryDto.type())
? AI_LLM_CONFIG.TYPE.eq(LlmTypeEnum.lookupLiteral(llmQueryDto.type()))
: noCondition())
.orderBy(pageRequestDto.getSortFields())
.limit(pageRequestDto.getSize())
.offset(pageRequestDto.getOffset())

View File

@@ -28,12 +28,13 @@ public class AiChatService {
}
public TokenStream chatPrecedenceLlmWith(String sessionIdentifier, String userMessage) {
Optional<AiLlmConfig> precedenceLlmBy = llmService.getPrecedenceLlmBy(true);
Optional<AiLlmConfig> precedenceLlmBy = llmService.getPrecedenceChatLlmBy(true);
AiLlmConfig aiLlmConfig = precedenceLlmBy.orElseThrow(() -> new BusinessException("没有开启的大模型"));
LlmCodeEnum code = aiLlmConfig.getCode();
return switch (code) {
case ZHI_PU -> zhiPuChatAssistant.chat(sessionIdentifier, userMessage);
case DEEP_SEEK -> deepSeekChatAssistant.chat(sessionIdentifier, userMessage);
default -> throw new BusinessException(String.format("无效的模型代码 %s", code));
};
}
}

View File

@@ -0,0 +1,72 @@
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())
.build();
EmbeddingSearchResult<TextSegment> embeddingSearchResult =
zhiPuEmbeddingStore.search(embeddingSearchRequest);
if (!embeddingSearchResult.matches().isEmpty()) {
Metadata metadata = embeddingSearchResult.matches().getFirst().embedded().metadata();
result.put(Actions.INDEX_KEY, metadata.getString(Actions.INDEX_KEY));
}
return result;
}
@PostConstruct
public void initActionIndex() {
if (!zhiPuEmbeddingModelConfig.getEnable()) {
return;
}
for (Actions action : Actions.values()) {
Embedding queryEmbedding = zhipuEmbeddingModel.embed(action.getContent()).content();
Filter createUserFilter = metadataKey(Actions.INDEX_KEY).isEqualTo(action.getCode());
EmbeddingSearchRequest embeddingSearchRequest =
EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.filter(createUserFilter)
.build();
EmbeddingSearchResult<TextSegment> embeddingSearchResult =
zhiPuEmbeddingStore.search(embeddingSearchRequest);
if (embeddingSearchResult.matches().isEmpty()) {
TextSegment segment =
TextSegment.from(
action.getContent(), Metadata.metadata(Actions.INDEX_KEY, action.getCode()));
Embedding embedding = zhipuEmbeddingModel.embed(segment).content();
zhiPuEmbeddingStore.add(embedding, segment);
}
}
}
}

View File

@@ -12,11 +12,14 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jooq.Record;
import org.jooq.Result;
import org.jooq.generated.default_schema.enums.LlmTypeEnum;
import org.jooq.generated.mjga.enums.LlmCodeEnum;
import org.jooq.generated.mjga.tables.pojos.AiLlmConfig;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import static org.jooq.generated.mjga.Tables.AI_LLM_CONFIG;
@Service
@RequiredArgsConstructor
@Slf4j
@@ -28,9 +31,11 @@ public class LlmService {
return llmRepository.fetchOneByCode(llmCodeEnum);
}
public Optional<AiLlmConfig> getPrecedenceLlmBy(Boolean enable) {
public Optional<AiLlmConfig> getPrecedenceChatLlmBy(Boolean enable) {
List<AiLlmConfig> aiLlmConfigs = llmRepository.fetchByEnable(enable);
return aiLlmConfigs.stream().max((o1, o2) -> o2.getPriority().compareTo(o1.getPriority()));
return aiLlmConfigs.stream()
.filter(aiLlmConfig -> LlmTypeEnum.CHAT.equals(aiLlmConfig.getType()))
.max((o1, o2) -> o2.getPriority().compareTo(o1.getPriority()));
}
public PageResponseDto<List<LlmVm>> pageQueryLlm(
@@ -39,7 +44,11 @@ public class LlmService {
if (records.isEmpty()) {
return PageResponseDto.empty();
}
List<LlmVm> llmVms = records.into(LlmVm.class);
List<LlmVm> llmVms = records.map((record) -> {
LlmVm into = record.into(LlmVm.class);
into.setType(record.get(AI_LLM_CONFIG.TYPE).getLiteral());
return into;
});
Long totalLlm = records.get(0).getValue("total_llm", Long.class);
return new PageResponseDto<>(totalLlm, llmVms);
}

View File

@@ -68,15 +68,21 @@ CREATE TABLE mjga.user_position_map (
CREATE TYPE mjga.llm_code_enum AS ENUM (
'DEEP_SEEK',
'ZHI_PU'
'ZHI_PU',
'ZHI_PU_EMBEDDING'
);
CREATE TYPE "llm_type_enum" AS ENUM (
'CHAT',
'EMBEDDING'
);
CREATE TABLE mjga.ai_llm_config (
id BIGSERIAL NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE,
code mjga.llm_code_enum NOT NULL UNIQUE,
model_name VARCHAR(255) NOT NULL,
type LLM_TYPE_ENUM NOT NULL,
api_key VARCHAR(255) NOT NULL,
url VARCHAR(255) NOT NULL,
enable BOOLEAN NOT NULL DEFAULT true,

View File

@@ -33,7 +33,8 @@ VALUES (1, 1),
(1, 9),
(1, 10);
INSERT INTO mjga.ai_llm_config (name,code,model_name, api_key, url, enable, priority)
INSERT INTO mjga.ai_llm_config (name,code,model_name, type, api_key, url, enable, priority)
VALUES
('DeepSeek','DEEP_SEEK','deepseek-chat','your_api_key', 'https://api.deepseek.com', false, 0),
('智谱清言','ZHI_PU','glm-4-flash', 'your_api_key', 'https://open.bigmodel.cn/', false, 1);
('DeepSeek','DEEP_SEEK','deepseek-chat','CHAT','your_api_key', 'https://api.deepseek.com', false, 0),
('智谱清言','ZHI_PU','glm-4-flash','CHAT', 'your_api_key', 'https://open.bigmodel.cn/', false, 1),
('智谱清言向量','ZHI_PU_EMBEDDING','Embeddings-3','EMBEDDING', 'your_api_key', 'https://open.bigmodel.cn/', false, 0);

View File

@@ -66,9 +66,23 @@ CREATE TABLE mjga.user_position_map (
FOREIGN KEY (position_id) REFERENCES mjga.position(id) ON UPDATE NO ACTION ON DELETE RESTRICT
);
CREATE TYPE mjga.llm_code_enum AS ENUM (
'DEEP_SEEK',
'ZHI_PU',
'ZHI_PU_EMBEDDING'
);
CREATE TYPE "llm_type_enum" AS ENUM (
'CHAT',
'EMBEDDING'
);
CREATE TABLE mjga.ai_llm_config (
id BIGSERIAL NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL UNIQUE,
code mjga.llm_code_enum NOT NULL UNIQUE,
model_name VARCHAR(255) NOT NULL,
type LLM_TYPE_ENUM NOT NULL,
api_key VARCHAR(255) NOT NULL,
url VARCHAR(255) NOT NULL,
enable BOOLEAN NOT NULL DEFAULT true,

View File

@@ -747,6 +747,39 @@
}
}
},
"/ai/action/chat": {
"post": {
"tags": [
"ai-controller"
],
"operationId": "actionChat",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
}
}
},
"/scheduler/page-query": {
"get": {
"tags": [
@@ -1096,6 +1129,7 @@
"modelName",
"name",
"priority",
"type",
"url"
],
"type": "object",
@@ -1110,6 +1144,9 @@
"modelName": {
"type": "string"
},
"type": {
"type": "string"
},
"apiKey": {
"type": "string"
},
@@ -1788,6 +1825,9 @@
"properties": {
"name": {
"type": "string"
},
"type": {
"type": "string"
}
}
},

View File

@@ -372,6 +372,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/ai/action/chat": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["actionChat"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/scheduler/page-query": {
parameters: {
query?: never;
@@ -529,6 +545,7 @@ export interface components {
id: number;
name: string;
modelName: string;
type: string;
apiKey: string;
url: string;
enable: boolean;
@@ -759,6 +776,7 @@ export interface components {
};
LlmQueryDto: {
name?: string;
type?: string;
};
PageResponseDtoListLlmVm: {
/** Format: int64 */
@@ -1446,6 +1464,32 @@ export interface operations {
};
};
};
actionChat: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": string;
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": {
[key: string]: string;
};
};
};
};
};
pageQuery: {
parameters: {
query: {

View File

@@ -90,19 +90,9 @@ const handleSubmit = async () => {
.max(15, "部门名称最多15个字符"),
});
try {
const validatedData = schema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
}
throw error;
}
const validatedData = schema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
};
onMounted(() => {

View File

@@ -34,6 +34,14 @@
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-2 sm:col-span-1">
<label for="type" class="block mb-2 text-sm font-medium text-gray-900 ">类型</label>
<select id="type" v-model="formData.type"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5">
<option :value="'CHAT'">聊天</option>
<option :value="'EMBEDDING'">嵌入</option>
</select>
</div>
<div class="col-span-2">
<label for="apiKey" class="block mb-2 text-sm font-medium autocomplete text-gray-900 ">apiKey</label>
<input type="text" id="apiKey" autocomplete="new-password" v-model="formData.apiKey"
@@ -98,42 +106,35 @@ watch(() => llm, updateFormData, {
});
const handleSubmit = async () => {
try {
const llmSchema = z.object({
id: z.number({
message: "id不能为空",
}),
name: z.string({
message: "名称不能为空",
}),
modelName: z.string({
message: "模型名称不能为空",
}),
apiKey: z.string({
message: "apiKey不能为空",
}),
url: z.string({
message: "url不能为空",
}),
enable: z.boolean({
message: "状态不能为空",
}),
priority: z.number({
message: "优先级必须为数字",
}),
});
const validatedData = llmSchema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
}
throw error;
}
const llmSchema = z.object({
id: z.number({
message: "id不能为空",
}),
name: z.string({
message: "名称不能为空",
}),
modelName: z.string({
message: "模型名称不能为空",
}),
apiKey: z.string({
message: "apiKey不能为空",
}),
url: z.string({
message: "url不能为空",
}),
enable: z.boolean({
message: "状态不能为空",
}),
priority: z.number({
message: "优先级必须为数字",
}),
type: z.string({
message: "类型不能为空",
}),
});
const validatedData = llmSchema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
};
onMounted(() => {

View File

@@ -42,7 +42,6 @@
</template>
<script setup lang="ts">
import useAlertStore from "@/composables/store/useAlertStore";
import type { PermissionUpsertModel } from "@/types/permission";
import { ref, watch } from "vue";
import { z } from "zod";
@@ -55,8 +54,6 @@ const { permission, onSubmit, closeModal } = defineProps<{
onSubmit: (data: PermissionUpsertModel) => Promise<void>;
}>();
const alertStore = useAlertStore();
const formData = ref();
const updateFormData = (newPermission: typeof permission) => {
@@ -86,18 +83,8 @@ const handleSubmit = async () => {
.max(15, "权限代码最多15个字符"),
});
try {
const validatedData = permissionSchema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
}
throw error;
}
const validatedData = permissionSchema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
};
</script>

View File

@@ -12,12 +12,12 @@
<span class="sr-only">Close modal</span>
</button>
<div class="p-4 md:p-5 text-center flex flex-col items-center gap-y-3">
<svg class="mx-auto mb-4 text-gray-400 w-12 h-12 " aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
<svg class="w-16 h-16 mx-auto text-red-600 mt-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<h3 class="mb-5 text-lg font-normal text-gray-500 ">
<h3 class="mb-3 text-lg font-normal text-gray-500 ">
{{ title }}
</h3>
<span>

View File

@@ -78,19 +78,9 @@ const handleSubmit = async () => {
.max(15, "岗位名称最多15个字符"),
});
try {
const validatedData = schema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
}
throw error;
}
const validatedData = schema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
};
onMounted(() => {

View File

@@ -86,19 +86,9 @@ const handleSubmit = async () => {
.max(15, "角色代码最多15个字符"),
});
try {
const validatedData = roleSchema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
}
throw error;
}
const validatedData = roleSchema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
};
onMounted(() => {

View File

@@ -69,17 +69,7 @@ const handleSubmit = async () => {
.min(5, "表达式的长度非法"),
});
try {
const validatedData = jobSchema.parse(formData.value);
await onSubmit(validatedData.cronExpression);
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
}
throw error;
}
const validatedData = jobSchema.parse(formData.value);
await onSubmit(validatedData.cronExpression);
};
</script>

View File

@@ -61,15 +61,12 @@
</template>
<script setup lang="ts">
import useAlertStore from "@/composables/store/useAlertStore";
import type { UserUpsertSubmitModel } from "@/types/user";
import { initFlowbite } from "flowbite";
import { onMounted, ref, watch } from "vue";
import { z } from "zod";
import type { components } from "../api/types/schema";
const alertStore = useAlertStore();
const { user, onSubmit } = defineProps<{
user?: components["schemas"]["UserRolePermissionDto"];
closeModal: () => void;
@@ -130,19 +127,9 @@ const handleSubmit = async () => {
},
);
try {
const validatedData = userSchema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
}
throw error;
}
const validatedData = userSchema.parse(formData.value);
await onSubmit(validatedData);
updateFormData(undefined);
};
onMounted(() => {

View File

@@ -1,23 +1,34 @@
import { fetchEventSource } from "@microsoft/fetch-event-source";
import { ref } from "vue";
import client from "../../api/client";
import useAuthStore from "../store/useAuthStore";
import useAlertStore from "../store/useAlertStore";
const authStore = useAuthStore();
export const useAiChat = () => {
const messages = ref<string[]>([]);
const messages = ref<
{
content: string;
type: "chat" | "action";
isUser: boolean;
username: string;
command?: string;
}[]
>([]);
const isLoading = ref(false);
let currentController: AbortController | null = null;
const chat = async (message: string) => {
isLoading.value = true;
messages.value.push(message);
messages.value.push("");
const ctrl = new AbortController();
currentController = ctrl;
messages.value.push({
content: "",
type: "chat",
isUser: false,
username: "知路智能体",
});
try {
const baseUrl = `${import.meta.env.VITE_BASE_URL}`;
await fetchEventSource(`${baseUrl}/ai/chat`, {
@@ -29,7 +40,7 @@ export const useAiChat = () => {
body: message,
signal: ctrl.signal,
onmessage(ev) {
messages.value[messages.value.length - 1] += ev.data;
messages.value[messages.value.length - 1].content += ev.data;
},
onclose() {
console.log("onclose");
@@ -38,6 +49,29 @@ export const useAiChat = () => {
throw err;
},
});
} catch (error) {
messages.value.pop();
} finally {
isLoading.value = false;
}
};
const actionChat = async (message: string) => {
isLoading.value = true;
try {
const { data } = await client.POST("/ai/action/chat", {
body: message,
});
messages.value.push({
content: data?.action
? "接收到指令,请您执行。"
: "未找到有效指令,请重新输入。",
type: "action",
isUser: false,
username: "知路智能体",
command: data?.action,
});
return data;
} finally {
isLoading.value = false;
}
@@ -50,5 +84,5 @@ export const useAiChat = () => {
}
};
return { messages, chat, isLoading, cancel };
return { messages, chat, isLoading, cancel, actionChat };
};

View File

@@ -7,6 +7,7 @@ import {
RequestError,
UnAuthError,
} from "../types/error";
import { z } from "zod";
const makeErrorHandler =
(
@@ -21,7 +22,7 @@ const makeErrorHandler =
}) => void,
) =>
(err: unknown, instance: ComponentPublicInstance | null, info: string) => {
console.error(err);
console.error(err);
if (err instanceof UnAuthError) {
signOut();
router.push(RoutePath.LOGIN);
@@ -44,6 +45,16 @@ const makeErrorHandler =
level: "error",
content: err.detail ?? err.message,
});
} else if (err instanceof z.ZodError) {
showAlert({
level: "error",
content: err.errors[0].message,
});
} else {
showAlert({
level: "error",
content: "发生异常,请稍候再试",
});
}
};

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col px-96 box-border pt-14 min-h-screen max-h-screen overflow-auto" ref="chatContainer">
<div class="flex flex-col gap-y-5 flex-1 pt-14">
<li v-for="chatElement in chatElements" :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']">
<img class="w-8 h-8 rounded-full" src="/trump.jpg" alt="Jese image">
<div
@@ -11,16 +11,39 @@
<LoadingIcon :textColor="'text-gray-900'"
v-if="isLoading && !chatElement.isUser && chatElement.content === ''" />
</div>
<div class="markdown-content markdown-body text-base font-normal py-2.5 text-gray-900 "
v-html="renderMarkdown(chatElement.content)">
<div>
<div class="markdown-content markdown-body text-base font-normal py-2.5 text-gray-900 "
v-html="renderMarkdown(chatElement.content)">
</div>
<button v-if="chatElement.type === 'action' && chatElement.command" type="button"
@click="commandActionMap[chatElement.command!]"
class="px-3 py-2 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
{{
commandContentMap[chatElement.command!]
}}</button>
</div>
</div>
</li>
</div>
<form class="sticky bottom-4 mt-14">
<div class="w-full border border-gray-200 rounded-lg bg-gray-50 ">
<div class="px-4 py-2 bg-white rounded-t-lg ">
<button @click.prevent="toggleMode"
class="relative inline-flex items-center justify-center p-0.5 mb-2 me-2 overflow-hidden text-sm font-medium text-gray-900 rounded-lg group focus:ring-4 focus:outline-none focus:ring-lime-200"
:class="[
isCommandMode
? 'bg-gradient-to-br from-teal-300 to-lime-300 '
: 'bg-gradient-to-br from-gray-300 to-gray-300 group-hover:from-teal-300 group-hover:to-lime-300'
]">
<span class="relative px-3 py-2 transition-all ease-in duration-75 rounded-md" :class="[
isCommandMode
? 'bg-transparent'
: 'bg-white group-hover:bg-transparent'
]">
命令模式
</span>
</button>
<div class="w-full border border-gray-200 rounded-lg bg-gray-50">
<div class="px-4 py-2 bg-white rounded-t-lg">
<label for="comment" class="sr-only"></label>
<textarea id="comment" rows="3" v-model="inputMessage"
class="w-full px-0 text-gray-900 bg-white border-0 focus:ring-0 " placeholder="发送消息" required></textarea>
@@ -52,31 +75,56 @@
</div>
</form>
</div>
<UserUpsertModal :id="'user-upsert-modal'" :onSubmit="handleUpsertUserSubmit" :closeModal="() => {
userUpsertModal!.hide();
}">
</UserUpsertModal>
</template>
<script setup lang="ts">
import LoadingIcon from "@/components/icons/LoadingIcon.vue";
import useAlertStore from "@/composables/store/useAlertStore";
import DOMPurify from "dompurify";
import { Modal, type ModalInterface, initFlowbite } from "flowbite";
import { marked } from "marked";
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { z } from "zod";
import Button from "../components/Button.vue";
import UserUpsertModal from "../components/UserUpsertModal.vue";
import { useAiChat } from "../composables/ai/useAiChat";
import useUserStore from "../composables/store/useUserStore";
import { useUserUpsert } from "../composables/user/useUserUpsert";
import type { UserUpsertSubmitModel } from "../types/user";
const { messages, chat, isLoading, cancel } = useAiChat();
const { messages, chat, isLoading, cancel, actionChat } = useAiChat();
const { user } = useUserStore();
const userUpsertModal = ref<ModalInterface>();
const inputMessage = ref("");
const chatContainer = ref<HTMLElement | null>(null);
const alertStore = useAlertStore();
const isCommandMode = ref(false);
const userUpsert = useUserUpsert();
const commandActionMap: Record<string, () => void> = {
CREATE_USER: () => {
userUpsertModal.value?.show();
},
};
const commandContentMap: Record<string, string> = {
CREATE_USER: "创建新用户",
};
const toggleMode = () => {
isCommandMode.value = !isCommandMode.value;
};
marked.setOptions({
gfm: true,
breaks: true,
});
const renderMarkdown = (content: string) => {
const renderMarkdown = (content: string | undefined) => {
if (!content) return "";
const restoredContent = content
@@ -91,23 +139,23 @@ const renderMarkdown = (content: string) => {
const rawHtml = marked(processedContent);
return DOMPurify.sanitize(rawHtml as string);
};
const chatElements = computed(() => {
return messages.value.map((message, index) => {
return {
content: message,
username: index % 2 === 0 ? user.username : "知路智能体",
isUser: index % 2 === 0,
};
});
});
// watch(messages, (newVal) => {
// console.log('原始消息:', newVal[newVal.length - 1]);
// console.log('处理后HTML:', renderMarkdown(newVal[newVal.length - 1]));
// }, { deep: true });
const handleUpsertUserSubmit = async (data: UserUpsertSubmitModel) => {
await userUpsert.upsertUser(data);
userUpsertModal.value?.hide();
alertStore.showAlert({
content: "操作成功",
level: "success",
});
};
watch(
chatElements,
messages,
async () => {
await nextTick();
scrollToBottom();
@@ -125,38 +173,50 @@ const abortChat = () => {
cancel();
};
const sendMessage = async () => {
try {
const validInputMessage = z
.string({ message: "消息不能为空" })
.min(1, "消息不能为空")
.parse(inputMessage.value);
scrollToBottom();
inputMessage.value = "";
await chat(validInputMessage);
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
const chatByMode = async (message: string) => {
inputMessage.value = "";
messages.value.push({
content: message,
type: "chat",
isUser: true,
username: user.username!,
});
if (isCommandMode.value) {
await actionChat(message);
} else {
if (isLoading.value) {
abortChat();
} else {
throw error;
await chat(message);
}
}
};
const handleSendClick = async () => {
if (isLoading.value) {
abortChat();
} else {
sendMessage();
}
scrollToBottom();
const validInputMessage = z
.string({ message: "消息不能为空" })
.min(1, "消息不能为空")
.parse(inputMessage.value);
await chatByMode(validInputMessage);
};
onUnmounted(() => {
cancel();
});
onMounted(async () => {
initFlowbite();
const $upsertModalElement: HTMLElement | null =
document.querySelector("#user-upsert-modal");
userUpsertModal.value = new Modal(
$upsertModalElement,
{},
{
id: "user-upsert-modal",
},
);
});
</script>
<style lang="css">

View File

@@ -40,6 +40,7 @@
</th>
<th scope="col" class="px-6 py-3">名称</th>
<th scope="col" class="px-6 py-3">模型名称</th>
<th scope="col" class="px-6 py-3">类型</th>
<th scope="col" class="px-6 py-3">apiKey</th>
<th scope="col" class="px-6 py-3">url</th>
<th scope="col" class="px-6 py-3">状态</th>
@@ -62,6 +63,9 @@
<td class="px-6 py-4 max-w-sm overflow-hidden text-ellipsis">{{
`${llm.modelName}` }}
</td>
<td class="px-6 py-4 max-w-sm overflow-hidden text-ellipsis">{{
llm.type === 'CHAT' ? '聊天' : '嵌入' }}
</td>
<td class="px-6 py-4 max-w-sm overflow-hidden text-ellipsis">{{
llm.apiKey }}
</td>

View File

@@ -45,26 +45,19 @@ const handleLogin = async () => {
password: z.string().min(1, "密码至少1个字符"),
});
try {
const validatedData = userSchema.parse({
username: username.value,
password: password.value,
});
await userAuth.signIn(validatedData.username, validatedData.password);
alertStore.showAlert({
level: "success",
content: "登录成功",
});
const redirectPath =
(route.query.redirect as string) ||
`${RoutePath.DASHBOARD}/${RoutePath.USERVIEW}`;
router.push(redirectPath);
} catch (e) {
alertStore.showAlert({
level: "error",
content: e instanceof z.ZodError ? e.errors[0].message : "账号或密码错误",
});
}
const validatedData = userSchema.parse({
username: username.value,
password: password.value,
});
await userAuth.signIn(validatedData.username, validatedData.password);
alertStore.showAlert({
level: "success",
content: "登录成功",
});
const redirectPath =
(route.query.redirect as string) ||
`${RoutePath.DASHBOARD}/${RoutePath.USERVIEW}`;
router.push(redirectPath);
};
onMounted(() => {

View File

@@ -78,47 +78,38 @@ onMounted(() => {
const handleUpdateClick = async () => {
let validatedData = undefined;
try {
validatedData = z
.object({
username: z
.string({
message: "用户名不能为空",
})
.min(4, "用户名长度不能小于4个字符"),
password: z
.string()
.min(5, "密码长度不能小于5个字符")
.optional()
.nullable(),
confirmPassword: z.string().optional().nullable(),
enable: z.boolean({
message: "状态不能为空",
}),
})
.refine(
(data) => {
if (data.password) {
return data.password === data.confirmPassword;
}
return true;
},
{ message: "密码输入不一致。" },
)
.parse(userForm.value);
await upsertCurrentUser(validatedData);
alertStore.showAlert({
content: "操作成功",
level: "success",
});
} catch (error) {
if (error instanceof z.ZodError) {
alertStore.showAlert({
level: "error",
content: error.errors[0].message,
});
}
throw error;
}
validatedData = z
.object({
username: z
.string({
message: "用户名不能为空",
})
.min(4, "用户名长度不能小于4个字符"),
password: z
.string()
.min(5, "密码长度不能小于5个字符")
.optional()
.nullable(),
confirmPassword: z.string().optional().nullable(),
enable: z.boolean({
message: "状态不能为空",
}),
})
.refine(
(data) => {
if (data.password) {
return data.password === data.confirmPassword;
}
return true;
},
{ message: "密码输入不一致。" },
)
.parse(userForm.value);
await upsertCurrentUser(validatedData);
alertStore.showAlert({
content: "操作成功",
level: "success",
});
};
</script>

View File

@@ -169,7 +169,7 @@ const router = useRouter();
const { total, users, fetchUsersWith } = useUserQuery();
const { deleteUser } = useUserDelete();
const userUpsert = useUserUpsert();
const { sortFields, sortBy, handleSort, getSortField } = useSort();
const { sortBy, handleSort, getSortField } = useSort();
const alertStore = useAlertStore();
onMounted(async () => {