feat: 新增多大模型管理功能,重构生成服务

1.  新增大模型配置模块,支持百炼、豆包、腾讯混元三种提供商
2.  重构推荐服务和意图服务,改为调用生成服务的Embedding和生成接口
3.  新增大模型管理前端页面和路由
4.  新增数据库迁移脚本和模型相关的Mapper、Service、Controller
5.  替换旧的硬编码LLM配置为动态可配置模式
This commit is contained in:
jiao 2026-06-04 14:10:09 +08:00
parent 60b6370cd1
commit b3e2afa9fe
46 changed files with 1459 additions and 68 deletions

View File

@ -1,11 +1,13 @@
package com.artedu.generation; package com.artedu.generation;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
/** /**
* LLM生成服务启动类 * LLM生成服务启动类
*/ */
@MapperScan("com.artedu.generation.mapper")
@SpringBootApplication(scanBasePackages = {"com.artedu.generation", "com.artedu.common"}) @SpringBootApplication(scanBasePackages = {"com.artedu.generation", "com.artedu.common"})
public class GenerationServiceApplication { public class GenerationServiceApplication {

View File

@ -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<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model.getModelCode());
Map<String, Object> 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<String, Object> parameters = new HashMap<>();
parameters.put("result_format", "text");
if (maxTokens != null) {
parameters.put("max_tokens", maxTokens);
}
requestBody.put("parameters", parameters);
try {
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> response = restTemplate.postForObject(url, entity, Map.class);
if (response == null) {
log.error("百炼API返回空响应");
return null;
}
Map<String, Object> output = (Map<String, Object>) response.get("output");
if (output == null) {
log.error("百炼API返回错误: {}", response.get("message"));
return null;
}
String text = (String) output.get("text");
log.debug("百炼生成结果, tokens={}",
((Map<String, Object>) 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<Double> 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<String, Object> requestBody = new HashMap<>();
requestBody.put("model", embeddingModel);
requestBody.put("input", text);
try {
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> response = restTemplate.postForObject(url, entity, Map.class);
if (response == null) {
log.error("百炼Embedding API返回空响应");
return List.of();
}
List<Map<String, Object>> data = (List<Map<String, Object>>) response.get("data");
if (data == null || data.isEmpty()) {
log.error("百炼Embedding API返回错误: {}", response.get("error"));
return List.of();
}
List<Double> embedding = (List<Double>) 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<String, Object> config = JsonUtils.fromJsonMap(configJson);
return (String) config.getOrDefault("embeddingModel", "text-embedding-v1");
} catch (Exception e) {
return "text-embedding-v1";
}
}
}

View File

@ -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<Map<String, String>> 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<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model.getModelCode());
requestBody.put("messages", messages);
if (maxTokens != null) {
requestBody.put("max_tokens", maxTokens);
}
try {
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> response = restTemplate.postForObject(url, entity, Map.class);
if (response == null) {
log.error("豆包API返回空响应");
return null;
}
List<Map<String, Object>> choices = (List<Map<String, Object>>) response.get("choices");
if (choices == null || choices.isEmpty()) {
log.error("豆包API返回错误: {}", response.get("error"));
return null;
}
Map<String, Object> message = (Map<String, Object>) 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<Double> embed(String text) {
log.warn("豆包客户端暂不支持embedding,返回空向量");
return List.of();
}
}

View File

@ -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<Map<String, String>> 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<String, Object> requestBody = new HashMap<>();
requestBody.put("Model", model.getModelCode());
requestBody.put("Messages", messages);
if (maxTokens != null) {
requestBody.put("MaxTokens", maxTokens);
}
try {
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);
Map<String, Object> response = restTemplate.postForObject(url, entity, Map.class);
if (response == null) {
log.error("混元API返回空响应");
return null;
}
Map<String, Object> choices = (Map<String, Object>) response.get("Choices");
if (choices == null) {
log.error("混元API返回错误: {}", response.get("Error"));
return null;
}
List<Map<String, Object>> messageList = (List<Map<String, Object>>) 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<Double> embed(String text) {
log.warn("混元客户端暂不支持embedding,返回空向量");
return List.of();
}
}

View File

@ -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<Double> embed(String text);
}

View File

@ -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<Long, LLMClient> 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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1,6 +1,8 @@
package com.artedu.generation.controller; package com.artedu.generation.controller;
import com.artedu.common.result.Result; 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.PromptEngine;
import com.artedu.generation.service.QianwenClient; import com.artedu.generation.service.QianwenClient;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -10,6 +12,7 @@ import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
@Slf4j @Slf4j
@ -24,6 +27,9 @@ public class GenerationController {
@Autowired @Autowired
private PromptEngine promptEngine; private PromptEngine promptEngine;
@Autowired
private LlmModelService llmModelService;
@PostMapping("/utterance") @PostMapping("/utterance")
public Result<Map<String, Object>> generateUtterance( public Result<Map<String, Object>> generateUtterance(
@NotBlank(message = "message不能为空") @RequestParam("message") String message, @NotBlank(message = "message不能为空") @RequestParam("message") String message,
@ -54,4 +60,45 @@ public class GenerationController {
return Result.success(result); return Result.success(result);
} }
/**
* 通用文本生成接口(供其他服务调用)
*/
@PostMapping("/generate")
public Result<Map<String, Object>> generate(
@RequestBody @Validated GenerateRequest request) {
String generated = qianwenClient.generate(request.getSystem(), request.getPrompt(), request.getMaxTokens());
Map<String, Object> 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<Map<String, Object>> embed(
@RequestBody @Validated EmbedRequest request) {
LLMClient client = llmModelService.getDefaultClient();
if (client == null) {
return Result.fail("没有可用的默认模型");
}
List<Double> embedding = client.embed(request.getText());
if (embedding == null || embedding.isEmpty()) {
return Result.fail("Embedding 生成失败,当前模型可能不支持 embedding");
}
Map<String, Object> result = new HashMap<>();
result.put("embedding", embedding);
result.put("dimensions", embedding.size());
result.put("model", client.getModel().getModelName());
return Result.success(result);
}
} }

View File

@ -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<LlmModel> getDefault() {
return Result.success(llmModelService.getDefaultModel());
}
/**
* 查询所有启用的模型(下拉选择用)
*/
@GetMapping("/active")
public Result<List<LlmModel>> listActive() {
return Result.success(llmModelService.listActive());
}
/**
* 分页查询模型列表
*/
@GetMapping
public Result<PageResult<LlmModel>> list(
@RequestParam(defaultValue = "1") @Min(1) int page,
@RequestParam(defaultValue = "20") @Min(1) int size) {
Page<LlmModel> 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<LlmModel> getById(@PathVariable Long id) {
return Result.success(llmModelService.getById(id));
}
/**
* 新增模型
*/
@PostMapping
public Result<String> add(@RequestBody @Validated LlmModel model) {
llmModelService.add(model);
return Result.success("添加成功");
}
/**
* 更新模型
*/
@PutMapping("/{id}")
public Result<String> update(@PathVariable Long id, @RequestBody @Validated LlmModel model) {
model.setId(id);
llmModelService.update(model);
return Result.success("更新成功");
}
/**
* 删除模型
*/
@DeleteMapping("/{id}")
public Result<String> delete(@PathVariable Long id) {
llmModelService.delete(id);
return Result.success("删除成功");
}
/**
* 设置默认模型
*/
@PutMapping("/{id}/default")
public Result<String> setDefault(@PathVariable Long id) {
llmModelService.setDefault(id);
return Result.success("已设置为默认模型");
}
/**
* 切换启用状态
*/
@PutMapping("/{id}/toggle")
public Result<String> toggleActive(@PathVariable Long id) {
llmModelService.toggleActive(id);
return Result.success("状态切换成功");
}
/**
* 测试模型连接
*/
@PostMapping("/{id}/test")
public Result<Map<String, Object>> 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<String, Object> 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());
}
}
}

View File

@ -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;
}

View File

@ -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<LlmModel> {
/**
* 获取默认启用的模型
*/
@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();
}

View File

@ -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<LlmModel> 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<LlmModel> list(int page, int size) {
Page<LlmModel> pageParam = new Page<>(page, size);
LambdaQueryWrapper<LlmModel> wrapper = new LambdaQueryWrapper<>();
wrapper.orderByDesc(LlmModel::getCreatedAt);
return llmModelMapper.selectPage(pageParam, wrapper);
}
/**
* 查询所有启用的模型
*/
public List<LlmModel> listActive() {
LambdaQueryWrapper<LlmModel> 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);
}
}

View File

@ -1,8 +1,10 @@
package com.artedu.generation.service; package com.artedu.generation.service;
import com.artedu.common.util.JsonUtils; import com.artedu.common.util.JsonUtils;
import com.artedu.generation.client.LLMClient;
import com.artedu.generation.entity.LlmModel;
import lombok.extern.slf4j.Slf4j; 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.http.*;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@ -14,21 +16,79 @@ import java.util.Map;
@Service @Service
public class QianwenClient { public class QianwenClient {
@Value("${llm.qianwen.api-key:}") @Autowired
private String apiKey; private LlmModelService llmModelService;
@Value("${llm.qianwen.model:qwen-turbo}")
private String model;
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"; private static final String API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation";
/**
* 文本生成(使用配置的默认模型)
*/
public String generate(String prompt, Integer maxTokens) { public String generate(String prompt, Integer maxTokens) {
long startTime = System.currentTimeMillis(); 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 { try {
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model); requestBody.put("model", "qwen-turbo");
Map<String, Object> input = new HashMap<>(); Map<String, Object> input = new HashMap<>();
input.put("prompt", prompt); input.put("prompt", prompt);
@ -43,7 +103,7 @@ public class QianwenClient {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey); headers.set("Authorization", "Bearer sk-39ceb8d746014b349109e76f893acb18");
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers); HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.postForEntity(API_URL, request, String.class); ResponseEntity<String> response = restTemplate.postForEntity(API_URL, request, String.class);
@ -51,7 +111,7 @@ public class QianwenClient {
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody()); Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
String text = extractText(result); String text = extractText(result);
log.info("LLM生成完成, elapsed={}ms, tokens={}", log.info("LLM生成完成(默认回退), elapsed={}ms, tokens={}",
System.currentTimeMillis() - startTime, System.currentTimeMillis() - startTime,
extractTokens(result)); extractTokens(result));
return text; return text;

View File

@ -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;

View File

@ -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;

View File

@ -1,4 +1,15 @@
com\artedu\generation\controller\GenerationController.class
com\artedu\generation\GenerationServiceApplication.class
com\artedu\generation\service\QianwenClient.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\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

View File

@ -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\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\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\PromptEngine.java
D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\QianwenClient.java D:\www\agent_9art\backend\generation-service\src\main\java\com\artedu\generation\service\QianwenClient.java

View File

@ -15,14 +15,10 @@ import java.util.*;
@Service @Service
public class LLMIntentClient { public class LLMIntentClient {
@Value("${llm.qianwen.api-key:}") @Value("${generation.service.url:http://generation-service:8086}")
private String apiKey; private String generationServiceUrl;
@Value("${llm.qianwen.model:qwen-turbo}")
private String model;
private final RestTemplate restTemplate = new RestTemplate(); 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<IntentCategory> categories) { public IntentResult recognize(String message, String contextSummary, List<IntentCategory> categories) {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
@ -31,30 +27,27 @@ public class LLMIntentClient {
String prompt = buildPrompt(message, contextSummary, categories); String prompt = buildPrompt(message, contextSummary, categories);
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", model); requestBody.put("system", "你是一个专业的教育培训机构客服意图识别助手。请严格按要求的JSON格式输出结果。");
requestBody.put("prompt", prompt);
Map<String, Object> input = new HashMap<>(); requestBody.put("maxTokens", 500);
input.put("prompt", prompt);
requestBody.put("input", input);
Map<String, Object> parameters = new HashMap<>();
parameters.put("result_format", "text");
requestBody.put("parameters", parameters);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers); HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.postForEntity(API_URL, request, String.class); ResponseEntity<String> response = restTemplate.postForEntity(
generationServiceUrl + "/api/v1/generation/generate", request, String.class);
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody()); Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
String text = extractText(result); if (result != null && result.get("data") != null) {
return parseIntentResult(text, categories); Map<String, Object> data = (Map<String, Object>) 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(); return createDefaultResult();
} catch (Exception e) { } catch (Exception e) {
@ -65,7 +58,7 @@ public class LLMIntentClient {
private String buildPrompt(String message, String contextSummary, List<IntentCategory> categories) { private String buildPrompt(String message, String contextSummary, List<IntentCategory> categories) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.append("你是一个专业的教育培训机构客服意图识别助手。请根据学员的咨询消息,判断其意图。\n\n"); sb.append("请根据学员的咨询消息,判断其意图。\n\n");
sb.append("可选意图列表:\n"); sb.append("可选意图列表:\n");
for (IntentCategory cat : categories) { for (IntentCategory cat : categories) {
sb.append("- ").append(cat.getCode()).append(": ").append(cat.getName()) sb.append("- ").append(cat.getCode()).append(": ").append(cat.getName())
@ -77,15 +70,6 @@ public class LLMIntentClient {
return sb.toString(); return sb.toString();
} }
private String extractText(Map<String, Object> result) {
if (result == null) return "";
Map<String, Object> output = (Map<String, Object>) result.get("output");
if (output != null) {
return (String) output.get("text");
}
return "";
}
private IntentResult parseIntentResult(String text, List<IntentCategory> categories) { private IntentResult parseIntentResult(String text, List<IntentCategory> categories) {
IntentResult result = new IntentResult(); IntentResult result = new IntentResult();
try { try {

View File

@ -13,14 +13,10 @@ import java.util.*;
@Service @Service
public class EmbeddingClient { public class EmbeddingClient {
@Value("${llm.qianwen.api-key:}") @Value("${generation.service.url:http://generation-service:8086}")
private String apiKey; private String generationServiceUrl;
@Value("${llm.qianwen.embedding-model:text-embedding-v1}")
private String embeddingModel;
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
private static final String EMBEDDING_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings";
/** /**
* 获取文本的 Embedding 向量 * 获取文本的 Embedding 向量
@ -31,19 +27,22 @@ public class EmbeddingClient {
} }
try { try {
Map<String, Object> requestBody = new HashMap<>(); Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", embeddingModel); requestBody.put("text", text);
requestBody.put("input", text);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + apiKey);
HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers); HttpEntity<Map<String, Object>> request = new HttpEntity<>(requestBody, headers);
ResponseEntity<String> response = restTemplate.postForEntity(EMBEDDING_URL, request, String.class); ResponseEntity<String> response = restTemplate.postForEntity(
generationServiceUrl + "/api/v1/generation/embed", request, String.class);
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody()); Map<String, Object> result = JsonUtils.fromJsonMap(response.getBody());
return extractEmbedding(result); if (result != null && result.get("data") != null) {
Map<String, Object> data = (Map<String, Object>) result.get("data");
List<Double> embedding = (List<Double>) data.get("embedding");
return embedding != null ? embedding : List.of();
}
} }
log.error("Embedding API 调用失败: status={}, body={}", response.getStatusCode(), response.getBody()); log.error("Embedding API 调用失败: status={}, body={}", response.getStatusCode(), response.getBody());
return List.of(); return List.of();
@ -66,19 +65,4 @@ public class EmbeddingClient {
} }
return result; return result;
} }
@SuppressWarnings("unchecked")
private List<Double> extractEmbedding(Map<String, Object> result) {
if (result == null) return List.of();
try {
List<Map<String, Object>> data = (List<Map<String, Object>>) result.get("data");
if (data != null && !data.isEmpty()) {
List<Double> embedding = (List<Double>) data.get(0).get("embedding");
return embedding != null ? embedding : List.of();
}
} catch (Exception e) {
log.error("解析 Embedding 失败", e);
}
return List.of();
}
} }

View File

@ -12,6 +12,7 @@ import CustomerSessionList from './pages/CustomerSessionList'
import SensitiveWordList from './pages/SensitiveWordList' import SensitiveWordList from './pages/SensitiveWordList'
import RiskEventList from './pages/RiskEventList' import RiskEventList from './pages/RiskEventList'
import MessageSearch from './pages/MessageSearch' import MessageSearch from './pages/MessageSearch'
import LlmModelList from './pages/LlmModelList'
function App() { function App() {
return ( return (
@ -31,6 +32,7 @@ function App() {
<Route path="risk/sensitive-words" element={<SensitiveWordList />} /> <Route path="risk/sensitive-words" element={<SensitiveWordList />} />
<Route path="risk/events" element={<RiskEventList />} /> <Route path="risk/events" element={<RiskEventList />} />
<Route path="risk/message-search" element={<MessageSearch />} /> <Route path="risk/message-search" element={<MessageSearch />} />
<Route path="llm-models" element={<LlmModelList />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { Layout as AntLayout, Menu, Button, message, Badge } from 'antd' 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' import { connectRiskEventStream, disconnectRiskEventStream, requestNotificationPermission, showDesktopNotification } from '../utils/notification'
const { Header, Sider, Content } = AntLayout const { Header, Sider, Content } = AntLayout
@ -76,6 +76,7 @@ export default function Layout() {
], ],
}, },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员账号' }, { key: '/admins', icon: <TeamOutlined />, label: '管理员账号' },
{ key: '/llm-models', icon: <RobotOutlined />, label: '大模型管理' },
] ]
const handleLogout = () => { const handleLogout = () => {

View File

@ -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<string, { label: string; color: string }> = {
bailian: { label: '百炼(阿里)', color: 'orange' },
doubao: { label: '豆包(字节)', color: 'blue' },
hunyuan: { label: '混元(腾讯)', color: 'green' },
}
export default function LlmModelList() {
const [models, setModels] = useState<LlmModel[]>([])
const [loading, setLoading] = useState(false)
const [modalVisible, setModalVisible] = useState(false)
const [editingId, setEditingId] = useState<number | null>(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<string, string> = {
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: (
<div>
<p><strong>提供商:</strong>{providerMap[record.provider]?.label || record.provider}</p>
<p><strong>模型:</strong>{data.modelName}</p>
<p><strong>响应时间:</strong>{data.elapsedMs}ms</p>
<p><strong>模型回复:</strong>{data.response}</p>
</div>
),
})
} 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) => (
<Tag color={providerMap[p]?.color || 'default'}>
{providerMap[p]?.label || p}
</Tag>
),
},
{
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 ? <Tag color="gold" icon={<CheckCircleOutlined />}>默认</Tag> : '-',
},
{
title: '状态',
dataIndex: 'isActive',
width: 100,
render: (v: number, record: LlmModel) => (
<Switch
checked={v === 1}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={() => handleToggleActive(record.id)}
/>
),
},
{
title: '操作',
width: 220,
render: (_: any, record: LlmModel) => (
<Space size="small">
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
编辑
</Button>
<Button
size="small"
icon={<ApiOutlined />}
onClick={() => handleTest(record)}
>
测试
</Button>
{record.isDefault !== 1 && (
<Button
size="small"
type="primary"
ghost
icon={<ThunderboltOutlined />}
onClick={() => handleSetDefault(record.id)}
>
设为默认
</Button>
)}
<Popconfirm
title="确定删除此模型?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger icon={<DeleteOutlined />}>
删除
</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div>
<h2>大模型管理</h2>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<span style={{ color: '#666' }}>
管理系统使用的大语言模型,可切换百炼、豆包、混元等不同提供商。
</span>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
添加模型
</Button>
</div>
<Table
columns={columns}
dataSource={models}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10 }}
size="small"
/>
</Card>
<Modal
title={editingId ? '编辑模型' : '添加模型'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={() => form.submit()}
width={600}
destroyOnClose
>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
>
<Form.Item
name="provider"
label="提供商"
rules={[{ required: true, message: '请选择提供商' }]}
>
<Select placeholder="选择提供商" onChange={handleProviderChange}>
<Select.Option value="bailian">百炼(阿里)</Select.Option>
<Select.Option value="doubao">豆包(字节)</Select.Option>
<Select.Option value="hunyuan">混元(腾讯)</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="modelName"
label="模型显示名称"
rules={[{ required: true, message: '请输入模型名称' }]}
>
<Input placeholder="如:通义千问-Turbo" />
</Form.Item>
<Form.Item
name="modelCode"
label="模型代码"
rules={[{ required: true, message: '请输入模型代码' }]}
>
<Input placeholder="如:qwen-turbo" />
</Form.Item>
<Form.Item
name="apiKey"
label="API密钥"
rules={[{ required: true, message: '请输入API密钥' }]}
>
<Input.Password placeholder="sk-..." />
</Form.Item>
<Form.Item
name="apiUrl"
label="API地址"
rules={[{ required: true, message: '请输入API地址' }]}
>
<Input placeholder="https://..." />
</Form.Item>
<Form.Item
name="configJson"
label="额外配置(JSON)"
>
<Input.TextArea
rows={3}
placeholder='{"maxTokens": 1500, "temperature": 0.7}'
/>
</Form.Item>
<Form.Item label="启用状态" name="isActive" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" defaultChecked />
</Form.Item>
<Form.Item label="设为默认" name="isDefault" valuePropName="checked">
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
</Form>
</Modal>
</div>
)
}