diff --git a/backend/common/target/common-1.0.0-SNAPSHOT.jar b/backend/common/target/common-1.0.0-SNAPSHOT.jar index d73a25f..0b666b5 100644 Binary files a/backend/common/target/common-1.0.0-SNAPSHOT.jar and b/backend/common/target/common-1.0.0-SNAPSHOT.jar differ diff --git a/backend/generation-service/src/main/java/com/artedu/generation/GenerationServiceApplication.java b/backend/generation-service/src/main/java/com/artedu/generation/GenerationServiceApplication.java index 0d205e4..ce5a619 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/GenerationServiceApplication.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/GenerationServiceApplication.java @@ -1,11 +1,13 @@ package com.artedu.generation; +import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; /** * LLM生成服务启动类 */ +@MapperScan("com.artedu.generation.mapper") @SpringBootApplication(scanBasePackages = {"com.artedu.generation", "com.artedu.common"}) public class GenerationServiceApplication { diff --git a/backend/generation-service/src/main/java/com/artedu/generation/client/BailianClient.java b/backend/generation-service/src/main/java/com/artedu/generation/client/BailianClient.java new file mode 100644 index 0000000..dc512b5 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/client/BailianClient.java @@ -0,0 +1,157 @@ +package com.artedu.generation.client; + +import com.artedu.common.util.JsonUtils; +import com.artedu.generation.entity.LlmModel; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 百炼(阿里Dashscope)客户端 + */ +@Slf4j +public class BailianClient implements LLMClient { + + private LlmModel model; + private final RestTemplate restTemplate = new RestTemplate(); + + public BailianClient(LlmModel model) { + this.model = model; + } + + @Override + public String generate(String prompt, Integer maxTokens) { + return generate(null, prompt, maxTokens); + } + + @Override + public String generate(String system, String prompt, Integer maxTokens) { + if (model == null || model.getApiKey() == null || model.getApiKey().isEmpty()) { + log.error("百炼模型配置不完整"); + return null; + } + + String url = model.getApiUrl(); + if (url == null || url.isEmpty()) { + url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + model.getApiKey()); + + Map requestBody = new HashMap<>(); + requestBody.put("model", model.getModelCode()); + + Map input = new HashMap<>(); + if (system != null && !system.isEmpty()) { + input.put("messages", new Object[]{ + Map.of("role", "system", "content", system), + Map.of("role", "user", "content", prompt) + }); + } else { + input.put("prompt", prompt); + } + requestBody.put("input", input); + + Map parameters = new HashMap<>(); + parameters.put("result_format", "text"); + if (maxTokens != null) { + parameters.put("max_tokens", maxTokens); + } + requestBody.put("parameters", parameters); + + try { + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + Map response = restTemplate.postForObject(url, entity, Map.class); + + if (response == null) { + log.error("百炼API返回空响应"); + return null; + } + + Map output = (Map) response.get("output"); + if (output == null) { + log.error("百炼API返回错误: {}", response.get("message")); + return null; + } + + String text = (String) output.get("text"); + log.debug("百炼生成结果, tokens={}", + ((Map) response.get("usage")).get("total_tokens")); + return text; + } catch (Exception e) { + log.error("百炼API调用失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public LlmModel getModel() { + return model; + } + + @Override + public void setModel(LlmModel model) { + this.model = model; + } + + @Override + public List embed(String text) { + if (model == null || model.getApiKey() == null || model.getApiKey().isEmpty()) { + log.error("百炼模型配置不完整,无法获取embedding"); + return List.of(); + } + + String url = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"; + String embeddingModel = model.getConfigJson() != null && model.getConfigJson().contains("embeddingModel") + ? extractEmbeddingModel(model.getConfigJson()) + : "text-embedding-v1"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + model.getApiKey()); + + Map requestBody = new HashMap<>(); + requestBody.put("model", embeddingModel); + requestBody.put("input", text); + + try { + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + Map response = restTemplate.postForObject(url, entity, Map.class); + + if (response == null) { + log.error("百炼Embedding API返回空响应"); + return List.of(); + } + + List> data = (List>) response.get("data"); + if (data == null || data.isEmpty()) { + log.error("百炼Embedding API返回错误: {}", response.get("error")); + return List.of(); + } + + List embedding = (List) data.get(0).get("embedding"); + log.debug("百炼Embedding成功, dimensions={}", embedding != null ? embedding.size() : 0); + return embedding != null ? embedding : List.of(); + } catch (Exception e) { + log.error("百炼Embedding API调用失败: {}", e.getMessage(), e); + return List.of(); + } + } + + private String extractEmbeddingModel(String configJson) { + try { + Map config = JsonUtils.fromJsonMap(configJson); + return (String) config.getOrDefault("embeddingModel", "text-embedding-v1"); + } catch (Exception e) { + return "text-embedding-v1"; + } + } +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/client/DoubaoClient.java b/backend/generation-service/src/main/java/com/artedu/generation/client/DoubaoClient.java new file mode 100644 index 0000000..219e863 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/client/DoubaoClient.java @@ -0,0 +1,103 @@ +package com.artedu.generation.client; + +import com.artedu.common.util.JsonUtils; +import com.artedu.generation.entity.LlmModel; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 豆包(Volcengine Ark)客户端 + */ +@Slf4j +public class DoubaoClient implements LLMClient { + + private LlmModel model; + private final RestTemplate restTemplate = new RestTemplate(); + + public DoubaoClient(LlmModel model) { + this.model = model; + } + + @Override + public String generate(String prompt, Integer maxTokens) { + return generate(null, prompt, maxTokens); + } + + @Override + public String generate(String system, String prompt, Integer maxTokens) { + if (model == null || model.getApiKey() == null || model.getApiKey().isEmpty()) { + log.error("豆包模型配置不完整"); + return null; + } + + String url = model.getApiUrl(); + if (url == null || url.isEmpty()) { + url = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"; + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + model.getApiKey()); + + List> messages = new ArrayList<>(); + if (system != null && !system.isEmpty()) { + messages.add(Map.of("role", "system", "content", system)); + } + messages.add(Map.of("role", "user", "content", prompt)); + + Map requestBody = new HashMap<>(); + requestBody.put("model", model.getModelCode()); + requestBody.put("messages", messages); + if (maxTokens != null) { + requestBody.put("max_tokens", maxTokens); + } + + try { + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + Map response = restTemplate.postForObject(url, entity, Map.class); + + if (response == null) { + log.error("豆包API返回空响应"); + return null; + } + + List> choices = (List>) response.get("choices"); + if (choices == null || choices.isEmpty()) { + log.error("豆包API返回错误: {}", response.get("error")); + return null; + } + + Map message = (Map) choices.get(0).get("message"); + String text = (String) message.get("content"); + log.debug("豆包生成成功"); + return text; + } catch (Exception e) { + log.error("豆包API调用失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public LlmModel getModel() { + return model; + } + + @Override + public void setModel(LlmModel model) { + this.model = model; + } + + @Override + public List embed(String text) { + log.warn("豆包客户端暂不支持embedding,返回空向量"); + return List.of(); + } +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/client/HunyuanClient.java b/backend/generation-service/src/main/java/com/artedu/generation/client/HunyuanClient.java new file mode 100644 index 0000000..6d692ab --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/client/HunyuanClient.java @@ -0,0 +1,106 @@ +package com.artedu.generation.client; + +import com.artedu.generation.entity.LlmModel; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 腾讯混元客户端 + */ +@Slf4j +public class HunyuanClient implements LLMClient { + + private LlmModel model; + private final RestTemplate restTemplate = new RestTemplate(); + + public HunyuanClient(LlmModel model) { + this.model = model; + } + + @Override + public String generate(String prompt, Integer maxTokens) { + return generate(null, prompt, maxTokens); + } + + @Override + public String generate(String system, String prompt, Integer maxTokens) { + if (model == null || model.getApiKey() == null || model.getApiKey().isEmpty()) { + log.error("混元模型配置不完整"); + return null; + } + + String url = model.getApiUrl(); + if (url == null || url.isEmpty()) { + url = "https://hunyuan.tencentcloudapi.com/"; + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + model.getApiKey()); + + List> messages = new ArrayList<>(); + if (system != null && !system.isEmpty()) { + messages.add(Map.of("Role", "system", "Content", system)); + } + messages.add(Map.of("Role", "user", "Content", prompt)); + + Map requestBody = new HashMap<>(); + requestBody.put("Model", model.getModelCode()); + requestBody.put("Messages", messages); + if (maxTokens != null) { + requestBody.put("MaxTokens", maxTokens); + } + + try { + HttpEntity> entity = new HttpEntity<>(requestBody, headers); + Map response = restTemplate.postForObject(url, entity, Map.class); + + if (response == null) { + log.error("混元API返回空响应"); + return null; + } + + Map choices = (Map) response.get("Choices"); + if (choices == null) { + log.error("混元API返回错误: {}", response.get("Error")); + return null; + } + + List> messageList = (List>) choices.get("Message"); + if (messageList == null || messageList.isEmpty()) { + return null; + } + + String text = (String) messageList.get(0).get("Content"); + log.debug("混元生成成功"); + return text; + } catch (Exception e) { + log.error("混元API调用失败: {}", e.getMessage(), e); + return null; + } + } + + @Override + public LlmModel getModel() { + return model; + } + + @Override + public void setModel(LlmModel model) { + this.model = model; + } + + @Override + public List embed(String text) { + log.warn("混元客户端暂不支持embedding,返回空向量"); + return List.of(); + } +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/client/LLMClient.java b/backend/generation-service/src/main/java/com/artedu/generation/client/LLMClient.java new file mode 100644 index 0000000..38d825f --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/client/LLMClient.java @@ -0,0 +1,46 @@ +package com.artedu.generation.client; + +import com.artedu.generation.entity.LlmModel; + +/** + * 大模型客户端接口 + */ +public interface LLMClient { + + /** + * 文本生成 + * + * @param prompt 提示词 + * @param maxTokens 最大token数 + * @return 生成的文本 + */ + String generate(String prompt, Integer maxTokens); + + /** + * 文本生成(支持system消息) + * + * @param system 系统提示词 + * @param prompt 用户提示词 + * @param maxTokens 最大token数 + * @return 生成的文本 + */ + String generate(String system, String prompt, Integer maxTokens); + + /** + * 获取当前使用的模型配置 + */ + LlmModel getModel(); + + /** + * 更新模型配置 + */ + void setModel(LlmModel model); + + /** + * 获取文本 Embedding 向量 + * + * @param text 输入文本 + * @return 向量列表,如果该模型不支持 embedding 则返回空列表 + */ + java.util.List embed(String text); +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/client/LLMClientFactory.java b/backend/generation-service/src/main/java/com/artedu/generation/client/LLMClientFactory.java new file mode 100644 index 0000000..9ca8363 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/client/LLMClientFactory.java @@ -0,0 +1,72 @@ +package com.artedu.generation.client; + +import com.artedu.generation.entity.LlmModel; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * 大模型客户端工厂 + */ +@Slf4j +public class LLMClientFactory { + + private static final ConcurrentHashMap CLIENT_CACHE = new ConcurrentHashMap<>(); + + /** + * 根据模型配置创建客户端 + */ + public static LLMClient createClient(LlmModel model) { + if (model == null) { + return null; + } + + // 检查缓存 + LLMClient cached = CLIENT_CACHE.get(model.getId()); + if (cached != null && cached.getModel().getUpdatedAt().equals(model.getUpdatedAt())) { + return cached; + } + + LLMClient client; + String provider = model.getProvider(); + if (provider == null) { + provider = "bailian"; + } + + switch (provider.toLowerCase()) { + case "doubao": + case "volcengine": + client = new DoubaoClient(model); + break; + case "hunyuan": + case "tencent": + client = new HunyuanClient(model); + break; + case "bailian": + case "dashscope": + case "aliyun": + default: + client = new BailianClient(model); + break; + } + + CLIENT_CACHE.put(model.getId(), client); + log.info("创建LLM客户端: provider={}, model={}", provider, model.getModelName()); + return client; + } + + /** + * 清除缓存 + */ + public static void clearCache() { + CLIENT_CACHE.clear(); + log.info("LLM客户端缓存已清除"); + } + + /** + * 清除指定模型的缓存 + */ + public static void clearCache(Long modelId) { + CLIENT_CACHE.remove(modelId); + } +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/controller/EmbedRequest.java b/backend/generation-service/src/main/java/com/artedu/generation/controller/EmbedRequest.java new file mode 100644 index 0000000..c0ebd6e --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/controller/EmbedRequest.java @@ -0,0 +1,11 @@ +package com.artedu.generation.controller; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +public class EmbedRequest { + @NotBlank(message = "text不能为空") + private String text; +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerateRequest.java b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerateRequest.java new file mode 100644 index 0000000..13addea --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerateRequest.java @@ -0,0 +1,18 @@ +package com.artedu.generation.controller; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +@Data +public class GenerateRequest { + /** 系统提示词(可选) */ + private String system; + + /** 用户提示词(必填) */ + @NotBlank(message = "prompt不能为空") + private String prompt; + + /** 最大token数(可选,默认500) */ + private Integer maxTokens = 500; +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java index 7b2e366..90bb25a 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/controller/GenerationController.java @@ -1,6 +1,8 @@ package com.artedu.generation.controller; import com.artedu.common.result.Result; +import com.artedu.generation.client.LLMClient; +import com.artedu.generation.service.LlmModelService; import com.artedu.generation.service.PromptEngine; import com.artedu.generation.service.QianwenClient; import lombok.extern.slf4j.Slf4j; @@ -10,6 +12,7 @@ import org.springframework.web.bind.annotation.*; import javax.validation.constraints.NotBlank; import java.util.HashMap; +import java.util.List; import java.util.Map; @Slf4j @@ -24,6 +27,9 @@ public class GenerationController { @Autowired private PromptEngine promptEngine; + @Autowired + private LlmModelService llmModelService; + @PostMapping("/utterance") public Result> generateUtterance( @NotBlank(message = "message不能为空") @RequestParam("message") String message, @@ -54,4 +60,45 @@ public class GenerationController { return Result.success(result); } + + /** + * 通用文本生成接口(供其他服务调用) + */ + @PostMapping("/generate") + public Result> generate( + @RequestBody @Validated GenerateRequest request) { + + String generated = qianwenClient.generate(request.getSystem(), request.getPrompt(), request.getMaxTokens()); + + Map result = new HashMap<>(); + result.put("content", generated); + result.put("model", qianwenClient.getCurrentModel() != null ? qianwenClient.getCurrentModel().getModelName() : "default"); + + return Result.success(result); + } + + /** + * 通用 Embedding 接口(供其他服务调用) + */ + @PostMapping("/embed") + public Result> embed( + @RequestBody @Validated EmbedRequest request) { + + LLMClient client = llmModelService.getDefaultClient(); + if (client == null) { + return Result.fail("没有可用的默认模型"); + } + + List embedding = client.embed(request.getText()); + if (embedding == null || embedding.isEmpty()) { + return Result.fail("Embedding 生成失败,当前模型可能不支持 embedding"); + } + + Map result = new HashMap<>(); + result.put("embedding", embedding); + result.put("dimensions", embedding.size()); + result.put("model", client.getModel().getModelName()); + + return Result.success(result); + } } diff --git a/backend/generation-service/src/main/java/com/artedu/generation/controller/LlmModelController.java b/backend/generation-service/src/main/java/com/artedu/generation/controller/LlmModelController.java new file mode 100644 index 0000000..9aa1ca5 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/controller/LlmModelController.java @@ -0,0 +1,154 @@ +package com.artedu.generation.controller; + +import com.artedu.common.result.PageResult; +import com.artedu.common.result.Result; +import com.artedu.generation.client.LLMClient; +import com.artedu.generation.client.LLMClientFactory; +import com.artedu.generation.entity.LlmModel; +import com.artedu.generation.service.LlmModelService; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 大模型配置管理接口 + * 仅管理员可操作 + */ +@Slf4j +@Validated +@RestController +@RequestMapping("/api/v1/generation/llm-models") +public class LlmModelController { + + @Autowired + private LlmModelService llmModelService; + + /** + * 获取默认模型 + */ + @GetMapping("/default") + public Result getDefault() { + return Result.success(llmModelService.getDefaultModel()); + } + + /** + * 查询所有启用的模型(下拉选择用) + */ + @GetMapping("/active") + public Result> listActive() { + return Result.success(llmModelService.listActive()); + } + + /** + * 分页查询模型列表 + */ + @GetMapping + public Result> list( + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "20") @Min(1) int size) { + Page result = llmModelService.list(page, size); + return Result.success(PageResult.of( + (int) result.getCurrent(), + (int) result.getSize(), + result.getTotal(), + result.getRecords() + )); + } + + /** + * 根据ID查询 + */ + @GetMapping("/{id}") + public Result getById(@PathVariable Long id) { + return Result.success(llmModelService.getById(id)); + } + + /** + * 新增模型 + */ + @PostMapping + public Result add(@RequestBody @Validated LlmModel model) { + llmModelService.add(model); + return Result.success("添加成功"); + } + + /** + * 更新模型 + */ + @PutMapping("/{id}") + public Result update(@PathVariable Long id, @RequestBody @Validated LlmModel model) { + model.setId(id); + llmModelService.update(model); + return Result.success("更新成功"); + } + + /** + * 删除模型 + */ + @DeleteMapping("/{id}") + public Result delete(@PathVariable Long id) { + llmModelService.delete(id); + return Result.success("删除成功"); + } + + /** + * 设置默认模型 + */ + @PutMapping("/{id}/default") + public Result setDefault(@PathVariable Long id) { + llmModelService.setDefault(id); + return Result.success("已设置为默认模型"); + } + + /** + * 切换启用状态 + */ + @PutMapping("/{id}/toggle") + public Result toggleActive(@PathVariable Long id) { + llmModelService.toggleActive(id); + return Result.success("状态切换成功"); + } + + /** + * 测试模型连接 + */ + @PostMapping("/{id}/test") + public Result> testConnection(@PathVariable Long id) { + LlmModel model = llmModelService.getById(id); + if (model == null) { + return Result.fail("模型不存在"); + } + + LLMClient client = LLMClientFactory.createClient(model); + if (client == null) { + return Result.fail("无法创建客户端,请检查提供商配置"); + } + + long startTime = System.currentTimeMillis(); + try { + String result = client.generate("请回复一个字:OK", 10); + if (result == null || result.isEmpty()) { + return Result.fail("模型连接失败:返回内容为空"); + } + + Map data = new HashMap<>(); + data.put("provider", model.getProvider()); + data.put("modelName", model.getModelName()); + data.put("elapsedMs", System.currentTimeMillis() - startTime); + data.put("response", result.trim()); + + return Result.success(data); + } catch (Exception e) { + log.error("模型连接测试失败: id={}, provider={}", id, model.getProvider(), e); + return Result.fail("模型连接失败:" + e.getMessage()); + } + } +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/entity/LlmModel.java b/backend/generation-service/src/main/java/com/artedu/generation/entity/LlmModel.java new file mode 100644 index 0000000..bc6b81a --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/entity/LlmModel.java @@ -0,0 +1,47 @@ +package com.artedu.generation.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 大模型配置实体 + */ +@Data +@TableName("llm_models") +public class LlmModel { + + @TableId(type = IdType.AUTO) + private Long id; + + /** 提供商: bailian(百炼), doubao(豆包), hunyuan(混元) */ + private String provider; + + /** 模型显示名称 */ + private String modelName; + + /** 模型代码(调用API时使用) */ + private String modelCode; + + /** API密钥 */ + private String apiKey; + + /** API基础地址 */ + private String apiUrl; + + /** 是否启用: 1启用 0禁用 */ + private Integer isActive; + + /** 是否为默认模型: 1是 0否 */ + private Integer isDefault; + + /** 额外配置JSON */ + private String configJson; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/mapper/LlmModelMapper.java b/backend/generation-service/src/main/java/com/artedu/generation/mapper/LlmModelMapper.java new file mode 100644 index 0000000..7c60831 --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/mapper/LlmModelMapper.java @@ -0,0 +1,26 @@ +package com.artedu.generation.mapper; + +import com.artedu.generation.entity.LlmModel; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +/** + * 大模型配置Mapper + */ +@Mapper +public interface LlmModelMapper extends BaseMapper { + + /** + * 获取默认启用的模型 + */ + @Select("SELECT * FROM llm_models WHERE is_active = 1 AND is_default = 1 LIMIT 1") + LlmModel selectDefault(); + + /** + * 取消所有默认模型 + */ + @Update("UPDATE llm_models SET is_default = 0 WHERE is_default = 1") + int clearDefault(); +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/service/LlmModelService.java b/backend/generation-service/src/main/java/com/artedu/generation/service/LlmModelService.java new file mode 100644 index 0000000..49c9c4a --- /dev/null +++ b/backend/generation-service/src/main/java/com/artedu/generation/service/LlmModelService.java @@ -0,0 +1,147 @@ +package com.artedu.generation.service; + +import com.artedu.generation.client.LLMClient; +import com.artedu.generation.client.LLMClientFactory; +import com.artedu.generation.entity.LlmModel; +import com.artedu.generation.mapper.LlmModelMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 大模型配置服务 + */ +@Slf4j +@Service +public class LlmModelService { + + @Autowired + private LlmModelMapper llmModelMapper; + + /** + * 获取默认模型 + */ + public LlmModel getDefaultModel() { + LlmModel model = llmModelMapper.selectDefault(); + if (model == null) { + // 如果没有默认模型,返回第一个启用的模型 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LlmModel::getIsActive, 1).orderByAsc(LlmModel::getId).last("LIMIT 1"); + model = llmModelMapper.selectOne(wrapper); + } + return model; + } + + /** + * 根据ID获取模型 + */ + public LlmModel getById(Long id) { + return llmModelMapper.selectById(id); + } + + /** + * 获取默认模型的客户端 + */ + public LLMClient getDefaultClient() { + LlmModel model = getDefaultModel(); + if (model == null) { + log.error("没有可用的默认大模型配置"); + return null; + } + return LLMClientFactory.createClient(model); + } + + /** + * 根据模型ID获取客户端 + */ + public LLMClient getClientById(Long modelId) { + LlmModel model = llmModelMapper.selectById(modelId); + if (model == null || model.getIsActive() != 1) { + log.error("模型不存在或未启用: id={}", modelId); + return null; + } + return LLMClientFactory.createClient(model); + } + + /** + * 分页查询模型列表 + */ + public Page list(int page, int size) { + Page pageParam = new Page<>(page, size); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.orderByDesc(LlmModel::getCreatedAt); + return llmModelMapper.selectPage(pageParam, wrapper); + } + + /** + * 查询所有启用的模型 + */ + public List listActive() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LlmModel::getIsActive, 1).orderByDesc(LlmModel::getIsDefault); + return llmModelMapper.selectList(wrapper); + } + + /** + * 新增模型 + */ + @Transactional + public void add(LlmModel model) { + if (model.getIsDefault() != null && model.getIsDefault() == 1) { + llmModelMapper.clearDefault(); + } + llmModelMapper.insert(model); + LLMClientFactory.clearCache(); + } + + /** + * 更新模型 + */ + @Transactional + public void update(LlmModel model) { + if (model.getIsDefault() != null && model.getIsDefault() == 1) { + llmModelMapper.clearDefault(); + } + llmModelMapper.updateById(model); + LLMClientFactory.clearCache(model.getId()); + } + + /** + * 删除模型 + */ + @Transactional + public void delete(Long id) { + llmModelMapper.deleteById(id); + LLMClientFactory.clearCache(id); + } + + /** + * 设置默认模型 + */ + @Transactional + public void setDefault(Long id) { + llmModelMapper.clearDefault(); + LlmModel model = new LlmModel(); + model.setId(id); + model.setIsDefault(1); + llmModelMapper.updateById(model); + LLMClientFactory.clearCache(); + } + + /** + * 切换模型启用状态 + */ + @Transactional + public void toggleActive(Long id) { + LlmModel model = llmModelMapper.selectById(id); + if (model == null) return; + model.setIsActive(model.getIsActive() == 1 ? 0 : 1); + llmModelMapper.updateById(model); + LLMClientFactory.clearCache(id); + } +} diff --git a/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java b/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java index f273dbb..4e440b6 100644 --- a/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java +++ b/backend/generation-service/src/main/java/com/artedu/generation/service/QianwenClient.java @@ -1,8 +1,10 @@ package com.artedu.generation.service; import com.artedu.common.util.JsonUtils; +import com.artedu.generation.client.LLMClient; +import com.artedu.generation.entity.LlmModel; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @@ -14,21 +16,79 @@ import java.util.Map; @Service public class QianwenClient { - @Value("${llm.qianwen.api-key:}") - private String apiKey; - - @Value("${llm.qianwen.model:qwen-turbo}") - private String model; + @Autowired + private LlmModelService llmModelService; private final RestTemplate restTemplate = new RestTemplate(); private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; + /** + * 文本生成(使用配置的默认模型) + */ public String generate(String prompt, Integer maxTokens) { long startTime = System.currentTimeMillis(); + // 优先使用数据库配置的模型 + LLMClient client = llmModelService.getDefaultClient(); + if (client != null) { + String result = client.generate(prompt, maxTokens); + if (result != null) { + log.info("LLM生成完成(多模型), provider={}, model={}, elapsed={}ms", + client.getModel().getProvider(), + client.getModel().getModelName(), + System.currentTimeMillis() - startTime); + return result; + } + log.warn("多模型客户端生成失败,回退到默认百炼配置"); + } + + // 回退到默认百炼配置(兼容旧逻辑) + return generateWithDefault(prompt, maxTokens); + } + + /** + * 使用默认模型生成(支持system消息) + */ + public String generate(String system, String prompt, Integer maxTokens) { + LLMClient client = llmModelService.getDefaultClient(); + if (client != null) { + String result = client.generate(system, prompt, maxTokens); + if (result != null) { + return result; + } + } + // fallback:忽略system,使用旧方法 + return generate(prompt, maxTokens); + } + + /** + * 使用指定模型生成 + */ + public String generateWithModel(Long modelId, String prompt, Integer maxTokens) { + LLMClient client = llmModelService.getClientById(modelId); + if (client == null) { + log.error("指定模型不存在或未启用: id={}", modelId); + return generate(prompt, maxTokens); + } + return client.generate(prompt, maxTokens); + } + + /** + * 获取当前默认模型信息 + */ + public LlmModel getCurrentModel() { + return llmModelService.getDefaultModel(); + } + + /** + * 兼容旧的百炼直接调用(作为fallback) + */ + private String generateWithDefault(String prompt, Integer maxTokens) { + long startTime = System.currentTimeMillis(); + try { Map requestBody = new HashMap<>(); - requestBody.put("model", model); + requestBody.put("model", "qwen-turbo"); Map input = new HashMap<>(); input.put("prompt", prompt); @@ -43,7 +103,7 @@ public class QianwenClient { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + apiKey); + headers.set("Authorization", "Bearer sk-39ceb8d746014b349109e76f893acb18"); HttpEntity> request = new HttpEntity<>(requestBody, headers); ResponseEntity response = restTemplate.postForEntity(API_URL, request, String.class); @@ -51,7 +111,7 @@ public class QianwenClient { if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { Map result = JsonUtils.fromJsonMap(response.getBody()); String text = extractText(result); - log.info("LLM生成完成, elapsed={}ms, tokens={}", + log.info("LLM生成完成(默认回退), elapsed={}ms, tokens={}", System.currentTimeMillis() - startTime, extractTokens(result)); return text; diff --git a/backend/generation-service/src/main/resources/db/migration/V20240603__add_llm_models.sql b/backend/generation-service/src/main/resources/db/migration/V20240603__add_llm_models.sql new file mode 100644 index 0000000..50e577f --- /dev/null +++ b/backend/generation-service/src/main/resources/db/migration/V20240603__add_llm_models.sql @@ -0,0 +1,24 @@ +-- 大模型配置表 +CREATE TABLE IF NOT EXISTS llm_models ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + provider VARCHAR(32) NOT NULL COMMENT '提供商: bailian(百炼), doubao(豆包), hunyuan(混元)', + model_name VARCHAR(64) NOT NULL COMMENT '模型显示名称', + model_code VARCHAR(64) NOT NULL COMMENT '模型代码(调用API时使用)', + api_key VARCHAR(255) COMMENT 'API密钥', + api_url VARCHAR(255) COMMENT 'API基础地址', + is_active TINYINT DEFAULT 1 COMMENT '是否启用: 1启用 0禁用', + is_default TINYINT DEFAULT 0 COMMENT '是否为默认模型: 1是 0否', + config_json TEXT COMMENT '额外配置JSON', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_provider_model (provider, model_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大模型配置表'; + +-- 插入默认模型(百炼) +INSERT INTO llm_models (provider, model_name, model_code, api_key, api_url, is_active, is_default, config_json) VALUES +('bailian', '通义千问-Turbo', 'qwen-turbo', 'sk-39ceb8d746014b349109e76f893acb18', 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation', 1, 1, '{"maxTokens": 1500, "temperature": 0.7}'), +('bailian', '通义千问-Plus', 'qwen-plus', 'sk-39ceb8d746014b349109e76f893acb18', 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation', 1, 0, '{"maxTokens": 2000, "temperature": 0.7}'), +('doubao', '豆包-Pro', 'doubao-pro-32k', '', 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', 1, 0, '{"maxTokens": 1500, "temperature": 0.7}'), +('hunyuan', '混元-Standard', 'hunyuan-standard', '', 'https://hunyuan.tencentcloudapi.com/', 0, 0, '{"maxTokens": 1500, "temperature": 0.7}') +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; diff --git a/backend/generation-service/target/classes/com/artedu/generation/GenerationServiceApplication.class b/backend/generation-service/target/classes/com/artedu/generation/GenerationServiceApplication.class index 9c91624..c97fc17 100644 Binary files a/backend/generation-service/target/classes/com/artedu/generation/GenerationServiceApplication.class and b/backend/generation-service/target/classes/com/artedu/generation/GenerationServiceApplication.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/client/BailianClient.class b/backend/generation-service/target/classes/com/artedu/generation/client/BailianClient.class new file mode 100644 index 0000000..ea4b949 Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/client/BailianClient.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/client/DoubaoClient.class b/backend/generation-service/target/classes/com/artedu/generation/client/DoubaoClient.class new file mode 100644 index 0000000..088f252 Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/client/DoubaoClient.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/client/HunyuanClient.class b/backend/generation-service/target/classes/com/artedu/generation/client/HunyuanClient.class new file mode 100644 index 0000000..0fc8ee8 Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/client/HunyuanClient.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/client/LLMClient.class b/backend/generation-service/target/classes/com/artedu/generation/client/LLMClient.class new file mode 100644 index 0000000..b724616 Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/client/LLMClient.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/client/LLMClientFactory.class b/backend/generation-service/target/classes/com/artedu/generation/client/LLMClientFactory.class new file mode 100644 index 0000000..6976415 Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/client/LLMClientFactory.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/controller/EmbedRequest.class b/backend/generation-service/target/classes/com/artedu/generation/controller/EmbedRequest.class new file mode 100644 index 0000000..8dd733c Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/controller/EmbedRequest.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/controller/GenerateRequest.class b/backend/generation-service/target/classes/com/artedu/generation/controller/GenerateRequest.class new file mode 100644 index 0000000..8c294f8 Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/controller/GenerateRequest.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/controller/GenerationController.class b/backend/generation-service/target/classes/com/artedu/generation/controller/GenerationController.class index bf00ef4..ab0f73b 100644 Binary files a/backend/generation-service/target/classes/com/artedu/generation/controller/GenerationController.class and b/backend/generation-service/target/classes/com/artedu/generation/controller/GenerationController.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/controller/LlmModelController.class b/backend/generation-service/target/classes/com/artedu/generation/controller/LlmModelController.class new file mode 100644 index 0000000..ce732ea Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/controller/LlmModelController.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/entity/LlmModel.class b/backend/generation-service/target/classes/com/artedu/generation/entity/LlmModel.class new file mode 100644 index 0000000..01056bf Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/entity/LlmModel.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/mapper/LlmModelMapper.class b/backend/generation-service/target/classes/com/artedu/generation/mapper/LlmModelMapper.class new file mode 100644 index 0000000..a0e9f9b Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/mapper/LlmModelMapper.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/service/LlmModelService.class b/backend/generation-service/target/classes/com/artedu/generation/service/LlmModelService.class new file mode 100644 index 0000000..d49d8fe Binary files /dev/null and b/backend/generation-service/target/classes/com/artedu/generation/service/LlmModelService.class differ diff --git a/backend/generation-service/target/classes/com/artedu/generation/service/QianwenClient.class b/backend/generation-service/target/classes/com/artedu/generation/service/QianwenClient.class index 3a9b383..84fa7e0 100644 Binary files a/backend/generation-service/target/classes/com/artedu/generation/service/QianwenClient.class and b/backend/generation-service/target/classes/com/artedu/generation/service/QianwenClient.class differ diff --git a/backend/generation-service/target/classes/db/migration/V20240603__add_llm_models.sql b/backend/generation-service/target/classes/db/migration/V20240603__add_llm_models.sql new file mode 100644 index 0000000..50e577f --- /dev/null +++ b/backend/generation-service/target/classes/db/migration/V20240603__add_llm_models.sql @@ -0,0 +1,24 @@ +-- 大模型配置表 +CREATE TABLE IF NOT EXISTS llm_models ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + provider VARCHAR(32) NOT NULL COMMENT '提供商: bailian(百炼), doubao(豆包), hunyuan(混元)', + model_name VARCHAR(64) NOT NULL COMMENT '模型显示名称', + model_code VARCHAR(64) NOT NULL COMMENT '模型代码(调用API时使用)', + api_key VARCHAR(255) COMMENT 'API密钥', + api_url VARCHAR(255) COMMENT 'API基础地址', + is_active TINYINT DEFAULT 1 COMMENT '是否启用: 1启用 0禁用', + is_default TINYINT DEFAULT 0 COMMENT '是否为默认模型: 1是 0否', + config_json TEXT COMMENT '额外配置JSON', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_provider_model (provider, model_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='大模型配置表'; + +-- 插入默认模型(百炼) +INSERT INTO llm_models (provider, model_name, model_code, api_key, api_url, is_active, is_default, config_json) VALUES +('bailian', '通义千问-Turbo', 'qwen-turbo', 'sk-39ceb8d746014b349109e76f893acb18', 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation', 1, 1, '{"maxTokens": 1500, "temperature": 0.7}'), +('bailian', '通义千问-Plus', 'qwen-plus', 'sk-39ceb8d746014b349109e76f893acb18', 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation', 1, 0, '{"maxTokens": 2000, "temperature": 0.7}'), +('doubao', '豆包-Pro', 'doubao-pro-32k', '', 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', 1, 0, '{"maxTokens": 1500, "temperature": 0.7}'), +('hunyuan', '混元-Standard', 'hunyuan-standard', '', 'https://hunyuan.tencentcloudapi.com/', 0, 0, '{"maxTokens": 1500, "temperature": 0.7}') +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; diff --git a/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar b/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar index 5cad77f..979ed91 100644 Binary files a/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar and b/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar.original b/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar.original index d74f5c4..679ec4a 100644 Binary files a/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar.original and b/backend/generation-service/target/generation-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/generation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/backend/generation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst index 060f2ff..28fd63f 100644 --- a/backend/generation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst +++ b/backend/generation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -1,4 +1,15 @@ -com\artedu\generation\controller\GenerationController.class -com\artedu\generation\GenerationServiceApplication.class com\artedu\generation\service\QianwenClient.class +com\artedu\generation\controller\GenerateRequest.class +com\artedu\generation\service\LlmModelService.class +com\artedu\generation\client\DoubaoClient.class +com\artedu\generation\client\LLMClientFactory.class +com\artedu\generation\controller\LlmModelController.class +com\artedu\generation\mapper\LlmModelMapper.class +com\artedu\generation\entity\LlmModel.class +com\artedu\generation\controller\GenerationController.class com\artedu\generation\service\PromptEngine.class +com\artedu\generation\client\BailianClient.class +com\artedu\generation\client\LLMClient.class +com\artedu\generation\client\HunyuanClient.class +com\artedu\generation\GenerationServiceApplication.class +com\artedu\generation\controller\EmbedRequest.class diff --git a/backend/generation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/backend/generation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst index 99c0982..96bad82 100644 --- a/backend/generation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst +++ b/backend/generation-service/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -1,4 +1,15 @@ +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\client\BailianClient.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\client\DoubaoClient.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\client\HunyuanClient.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\client\LLMClient.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\client\LLMClientFactory.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\controller\EmbedRequest.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\controller\GenerateRequest.java D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\controller\GenerationController.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\controller\LlmModelController.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\entity\LlmModel.java D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\GenerationServiceApplication.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\mapper\LlmModelMapper.java +D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\LlmModelService.java D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\PromptEngine.java D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\QianwenClient.java diff --git a/backend/intent-service/src/main/java/com/artedu/intent/service/LLMIntentClient.java b/backend/intent-service/src/main/java/com/artedu/intent/service/LLMIntentClient.java index 5941e0a..a33b8ec 100644 --- a/backend/intent-service/src/main/java/com/artedu/intent/service/LLMIntentClient.java +++ b/backend/intent-service/src/main/java/com/artedu/intent/service/LLMIntentClient.java @@ -15,14 +15,10 @@ import java.util.*; @Service public class LLMIntentClient { - @Value("${llm.qianwen.api-key:}") - private String apiKey; - - @Value("${llm.qianwen.model:qwen-turbo}") - private String model; + @Value("${generation.service.url:http://generation-service:8086}") + private String generationServiceUrl; private final RestTemplate restTemplate = new RestTemplate(); - private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; public IntentResult recognize(String message, String contextSummary, List categories) { long startTime = System.currentTimeMillis(); @@ -31,30 +27,27 @@ public class LLMIntentClient { String prompt = buildPrompt(message, contextSummary, categories); Map requestBody = new HashMap<>(); - requestBody.put("model", model); - - Map input = new HashMap<>(); - input.put("prompt", prompt); - requestBody.put("input", input); - - Map parameters = new HashMap<>(); - parameters.put("result_format", "text"); - requestBody.put("parameters", parameters); + requestBody.put("system", "你是一个专业的教育培训机构客服意图识别助手。请严格按要求的JSON格式输出结果。"); + requestBody.put("prompt", prompt); + requestBody.put("maxTokens", 500); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + apiKey); HttpEntity> request = new HttpEntity<>(requestBody, headers); - ResponseEntity response = restTemplate.postForEntity(API_URL, request, String.class); + ResponseEntity response = restTemplate.postForEntity( + generationServiceUrl + "/api/v1/generation/generate", request, String.class); if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { Map result = JsonUtils.fromJsonMap(response.getBody()); - String text = extractText(result); - return parseIntentResult(text, categories); + if (result != null && result.get("data") != null) { + Map data = (Map) result.get("data"); + String text = (String) data.get("content"); + return parseIntentResult(text, categories); + } } - log.error("LLM意图识别失败: status={}", response.getStatusCode()); + log.error("LLM意图识别失败: status={}, body={}", response.getStatusCode(), response.getBody()); return createDefaultResult(); } catch (Exception e) { @@ -65,7 +58,7 @@ public class LLMIntentClient { private String buildPrompt(String message, String contextSummary, List categories) { StringBuilder sb = new StringBuilder(); - sb.append("你是一个专业的教育培训机构客服意图识别助手。请根据学员的咨询消息,判断其意图。\n\n"); + sb.append("请根据学员的咨询消息,判断其意图。\n\n"); sb.append("可选意图列表:\n"); for (IntentCategory cat : categories) { sb.append("- ").append(cat.getCode()).append(": ").append(cat.getName()) @@ -77,15 +70,6 @@ public class LLMIntentClient { return sb.toString(); } - private String extractText(Map result) { - if (result == null) return ""; - Map output = (Map) result.get("output"); - if (output != null) { - return (String) output.get("text"); - } - return ""; - } - private IntentResult parseIntentResult(String text, List categories) { IntentResult result = new IntentResult(); try { diff --git a/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class b/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class index 7c2bf97..0690521 100644 Binary files a/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class and b/backend/intent-service/target/classes/com/artedu/intent/service/LLMIntentClient.class differ diff --git a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar index 8763b24..d71dce4 100644 Binary files a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar and b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original index 0ee3ba3..7a98378 100644 Binary files a/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original and b/backend/intent-service/target/intent-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/backend/recommendation-service/src/main/java/com/artedu/recommend/service/EmbeddingClient.java b/backend/recommendation-service/src/main/java/com/artedu/recommend/service/EmbeddingClient.java index e1a73fd..52f21ab 100644 --- a/backend/recommendation-service/src/main/java/com/artedu/recommend/service/EmbeddingClient.java +++ b/backend/recommendation-service/src/main/java/com/artedu/recommend/service/EmbeddingClient.java @@ -13,14 +13,10 @@ import java.util.*; @Service public class EmbeddingClient { - @Value("${llm.qianwen.api-key:}") - private String apiKey; - - @Value("${llm.qianwen.embedding-model:text-embedding-v1}") - private String embeddingModel; + @Value("${generation.service.url:http://generation-service:8086}") + private String generationServiceUrl; private final RestTemplate restTemplate = new RestTemplate(); - private static final String EMBEDDING_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"; /** * 获取文本的 Embedding 向量 @@ -31,19 +27,22 @@ public class EmbeddingClient { } try { Map requestBody = new HashMap<>(); - requestBody.put("model", embeddingModel); - requestBody.put("input", text); + requestBody.put("text", text); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + apiKey); HttpEntity> request = new HttpEntity<>(requestBody, headers); - ResponseEntity response = restTemplate.postForEntity(EMBEDDING_URL, request, String.class); + ResponseEntity response = restTemplate.postForEntity( + generationServiceUrl + "/api/v1/generation/embed", request, String.class); if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { Map result = JsonUtils.fromJsonMap(response.getBody()); - return extractEmbedding(result); + if (result != null && result.get("data") != null) { + Map data = (Map) result.get("data"); + List embedding = (List) data.get("embedding"); + return embedding != null ? embedding : List.of(); + } } log.error("Embedding API 调用失败: status={}, body={}", response.getStatusCode(), response.getBody()); return List.of(); @@ -66,19 +65,4 @@ public class EmbeddingClient { } return result; } - - @SuppressWarnings("unchecked") - private List extractEmbedding(Map result) { - if (result == null) return List.of(); - try { - List> data = (List>) result.get("data"); - if (data != null && !data.isEmpty()) { - List embedding = (List) data.get(0).get("embedding"); - return embedding != null ? embedding : List.of(); - } - } catch (Exception e) { - log.error("解析 Embedding 失败", e); - } - return List.of(); - } } diff --git a/backend/recommendation-service/target/classes/com/artedu/recommend/service/EmbeddingClient.class b/backend/recommendation-service/target/classes/com/artedu/recommend/service/EmbeddingClient.class index db50bc6..d4490f5 100644 Binary files a/backend/recommendation-service/target/classes/com/artedu/recommend/service/EmbeddingClient.class and b/backend/recommendation-service/target/classes/com/artedu/recommend/service/EmbeddingClient.class differ diff --git a/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar b/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar index 43e7085..0a00d78 100644 Binary files a/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar and b/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar differ diff --git a/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar.original b/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar.original index 8a1a154..6525e88 100644 Binary files a/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar.original and b/backend/recommendation-service/target/recommendation-service-1.0.0-SNAPSHOT.jar.original differ diff --git a/frontend/admin/src/App.tsx b/frontend/admin/src/App.tsx index a0a5243..b101eca 100644 --- a/frontend/admin/src/App.tsx +++ b/frontend/admin/src/App.tsx @@ -12,6 +12,7 @@ import CustomerSessionList from './pages/CustomerSessionList' import SensitiveWordList from './pages/SensitiveWordList' import RiskEventList from './pages/RiskEventList' import MessageSearch from './pages/MessageSearch' +import LlmModelList from './pages/LlmModelList' function App() { return ( @@ -31,6 +32,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/admin/src/components/Layout.tsx b/frontend/admin/src/components/Layout.tsx index d6d066b..89058bb 100644 --- a/frontend/admin/src/components/Layout.tsx +++ b/frontend/admin/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { Layout as AntLayout, Menu, Button, message, Badge } from 'antd' -import { DashboardOutlined, CommentOutlined, BookOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined } from '@ant-design/icons' +import { DashboardOutlined, CommentOutlined, BookOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, RobotOutlined } from '@ant-design/icons' import { connectRiskEventStream, disconnectRiskEventStream, requestNotificationPermission, showDesktopNotification } from '../utils/notification' const { Header, Sider, Content } = AntLayout @@ -76,6 +76,7 @@ export default function Layout() { ], }, { key: '/admins', icon: , label: '管理员账号' }, + { key: '/llm-models', icon: , label: '大模型管理' }, ] const handleLogout = () => { diff --git a/frontend/admin/src/pages/LlmModelList.tsx b/frontend/admin/src/pages/LlmModelList.tsx new file mode 100644 index 0000000..c895fee --- /dev/null +++ b/frontend/admin/src/pages/LlmModelList.tsx @@ -0,0 +1,354 @@ +import { useState, useEffect } from 'react' +import { + Table, Card, Button, Modal, Form, Input, Switch, Tag, message, Popconfirm, + Space, Select +} from 'antd' +import { + PlusOutlined, EditOutlined, DeleteOutlined, CheckCircleOutlined, + ThunderboltOutlined, ApiOutlined +} from '@ant-design/icons' +import request from '../utils/request' + +interface LlmModel { + id: number + provider: string + modelName: string + modelCode: string + apiKey: string + apiUrl: string + isActive: number + isDefault: number + configJson: string + createdAt: string +} + +const providerMap: Record = { + bailian: { label: '百炼(阿里)', color: 'orange' }, + doubao: { label: '豆包(字节)', color: 'blue' }, + hunyuan: { label: '混元(腾讯)', color: 'green' }, +} + +export default function LlmModelList() { + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(false) + const [modalVisible, setModalVisible] = useState(false) + const [editingId, setEditingId] = useState(null) + const [form] = Form.useForm() + + const loadModels = async () => { + setLoading(true) + try { + const res: any = await request.get('/v1/generation/llm-models') + setModels(res.data.list || []) + } catch (e) { + message.error('加载模型列表失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadModels() + }, []) + + const defaultApiUrls: Record = { + bailian: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation', + doubao: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', + hunyuan: 'https://hunyuan.tencentcloudapi.com/', + } + + const handleProviderChange = (provider: string) => { + const currentUrl = form.getFieldValue('apiUrl') + // 如果当前URL为空或是默认URL之一,则自动切换 + const isDefaultUrl = Object.values(defaultApiUrls).includes(currentUrl) + if (!currentUrl || isDefaultUrl) { + form.setFieldsValue({ apiUrl: defaultApiUrls[provider] || '' }) + } + } + + const handleAdd = () => { + setEditingId(null) + form.resetFields() + form.setFieldsValue({ + provider: 'bailian', + isActive: 1, + isDefault: 0, + apiUrl: defaultApiUrls['bailian'], + configJson: '{"maxTokens": 1500, "temperature": 0.7}', + }) + setModalVisible(true) + } + + const handleEdit = (record: LlmModel) => { + setEditingId(record.id) + form.setFieldsValue({ + ...record, + isActive: record.isActive === 1, + isDefault: record.isDefault === 1, + }) + setModalVisible(true) + } + + const handleSave = async (values: any) => { + try { + const payload = { + ...values, + isActive: values.isActive ? 1 : 0, + isDefault: values.isDefault ? 1 : 0, + } + + if (editingId) { + await request.put(`/v1/generation/llm-models/${editingId}`, payload) + message.success('更新成功') + } else { + await request.post('/v1/generation/llm-models', payload) + message.success('添加成功') + } + setModalVisible(false) + loadModels() + } catch (e: any) { + message.error(e.response?.data?.message || '保存失败') + } + } + + const handleDelete = async (id: number) => { + try { + await request.delete(`/v1/generation/llm-models/${id}`) + message.success('删除成功') + loadModels() + } catch (e: any) { + message.error(e.response?.data?.message || '删除失败') + } + } + + const handleSetDefault = async (id: number) => { + try { + await request.put(`/v1/generation/llm-models/${id}/default`) + message.success('已设置为默认模型') + loadModels() + } catch (e: any) { + message.error(e.response?.data?.message || '设置失败') + } + } + + const handleToggleActive = async (id: number) => { + try { + await request.put(`/v1/generation/llm-models/${id}/toggle`) + message.success('状态切换成功') + loadModels() + } catch (e: any) { + message.error(e.response?.data?.message || '切换失败') + } + } + + const handleTest = async (record: LlmModel) => { + try { + const res: any = await request.post(`/v1/generation/llm-models/${record.id}/test`) + const data = res.data + Modal.success({ + title: '连接测试成功', + content: ( +
+

提供商:{providerMap[record.provider]?.label || record.provider}

+

模型:{data.modelName}

+

响应时间:{data.elapsedMs}ms

+

模型回复:{data.response}

+
+ ), + }) + } catch (e: any) { + message.error(e.response?.data?.message || '连接测试失败') + } + } + + const columns = [ + { + title: 'ID', + dataIndex: 'id', + width: 60, + }, + { + title: '提供商', + dataIndex: 'provider', + width: 120, + render: (p: string) => ( + + {providerMap[p]?.label || p} + + ), + }, + { + title: '模型名称', + dataIndex: 'modelName', + width: 150, + }, + { + title: '模型代码', + dataIndex: 'modelCode', + width: 180, + }, + { + title: 'API地址', + dataIndex: 'apiUrl', + ellipsis: true, + }, + { + title: '默认', + dataIndex: 'isDefault', + width: 80, + render: (v: number) => + v === 1 ? }>默认 : '-', + }, + { + title: '状态', + dataIndex: 'isActive', + width: 100, + render: (v: number, record: LlmModel) => ( + handleToggleActive(record.id)} + /> + ), + }, + { + title: '操作', + width: 220, + render: (_: any, record: LlmModel) => ( + + + + {record.isDefault !== 1 && ( + + )} + handleDelete(record.id)} + > + + + + ), + }, + ] + + return ( +
+

大模型管理

+ +
+ + 管理系统使用的大语言模型,可切换百炼、豆包、混元等不同提供商。 + + +
+ + + + setModalVisible(false)} + onOk={() => form.submit()} + width={600} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ) +}