26 changed files with 1876 additions and 349 deletions
-
27CLAUDE.md
-
68pom.xml
-
11src/main/java/com/wok/supportbot/SupportBotApplication.java
-
87src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java
-
66src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java
-
194src/main/java/com/wok/supportbot/app/AssistantApp.java
-
40src/main/java/com/wok/supportbot/app/ProductInfoApp.java
-
19src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java
-
7src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java
-
136src/main/java/com/wok/supportbot/config/ChatModelFactory.java
-
107src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
78src/main/java/com/wok/supportbot/config/ModelConfigLoader.java
-
278src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
-
12src/main/java/com/wok/supportbot/dao/AiModelConfigMapper.java
-
15src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java
-
128src/main/java/com/wok/supportbot/entity/AiModelConfig.java
-
14src/main/java/com/wok/supportbot/rag/config/QueryExpanderConfig.java
-
24src/main/java/com/wok/supportbot/rag/config/QueryTransformerConfig.java
-
21src/main/java/com/wok/supportbot/rag/preretrieval/CompressionQueryRewriter.java
-
23src/main/java/com/wok/supportbot/rag/preretrieval/MultiQueryExpanderRewriter.java
-
22src/main/java/com/wok/supportbot/rag/preretrieval/RewriteQueryRewriter.java
-
21src/main/java/com/wok/supportbot/rag/preretrieval/TranslationQueryRewriter.java
-
268src/main/java/com/wok/supportbot/service/AiModelConfigService.java
-
496src/main/resources/static/components/ModelConfigManager.js
-
53src/main/resources/static/js/api.js
-
10src/main/resources/static/js/app.js
@ -1,62 +1,43 @@ |
|||
package com.wok.supportbot.advisor; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
import reactor.core.publisher.Flux; |
|||
|
|||
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest; |
|||
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse; |
|||
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor; |
|||
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain; |
|||
import org.springframework.ai.chat.client.advisor.api.StreamAroundAdvisor; |
|||
import org.springframework.ai.chat.client.advisor.api.StreamAroundAdvisorChain; |
|||
import org.springframework.ai.chat.model.MessageAggregator; |
|||
import org.springframework.ai.chat.client.ChatClientRequest; |
|||
import org.springframework.ai.chat.client.ChatClientResponse; |
|||
import org.springframework.ai.chat.client.advisor.api.AdvisorChain; |
|||
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; |
|||
import org.springframework.ai.chat.prompt.Prompt; |
|||
|
|||
/** |
|||
* 自定义日志 Advisor |
|||
* 自定义日志 Advisor(适配 Spring AI 1.0.1 新 Advisor API) |
|||
* 打印 info 级别日志、只输出单次用户提示词和 AI 回复的文本 |
|||
*/ |
|||
@Slf4j |
|||
public class MyLoggerAdvisor implements CallAroundAdvisor, StreamAroundAdvisor { |
|||
|
|||
@Override |
|||
public String getName() { |
|||
return this.getClass().getSimpleName(); |
|||
} |
|||
|
|||
@Override |
|||
public int getOrder() { |
|||
return 0; |
|||
} |
|||
|
|||
private AdvisedRequest before(AdvisedRequest request) { |
|||
log.info("AI Request: {}", request.userText()); |
|||
return request; |
|||
} |
|||
|
|||
private void observeAfter(AdvisedResponse advisedResponse) { |
|||
log.info("AI Response: {}", advisedResponse.response().getResult().getOutput().getText()); |
|||
} |
|||
|
|||
@Override |
|||
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { |
|||
|
|||
advisedRequest = before(advisedRequest); |
|||
|
|||
AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest); |
|||
|
|||
observeAfter(advisedResponse); |
|||
|
|||
return advisedResponse; |
|||
} |
|||
|
|||
@Override |
|||
public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { |
|||
|
|||
advisedRequest = before(advisedRequest); |
|||
|
|||
Flux<AdvisedResponse> advisedResponses = chain.nextAroundStream(advisedRequest); |
|||
|
|||
return new MessageAggregator().aggregateAdvisedResponse(advisedResponses, this::observeAfter); |
|||
} |
|||
|
|||
public class MyLoggerAdvisor implements BaseAdvisor { |
|||
|
|||
@Override |
|||
public String getName() { |
|||
return this.getClass().getSimpleName(); |
|||
} |
|||
|
|||
@Override |
|||
public int getOrder() { |
|||
return 0; |
|||
} |
|||
|
|||
@Override |
|||
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) { |
|||
// 从 Prompt 中提取用户文本 |
|||
String userText = request.prompt().getUserMessages().stream() |
|||
.map(msg -> msg.getText()) |
|||
.reduce("", (a, b) -> a.isEmpty() ? b : a); |
|||
log.info("AI Request: {}", userText); |
|||
return request; |
|||
} |
|||
|
|||
@Override |
|||
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) { |
|||
String text = response.chatResponse().getResult().getOutput().getText(); |
|||
log.info("AI Response: {}", text); |
|||
return response; |
|||
} |
|||
} |
|||
@ -1,53 +1,43 @@ |
|||
package com.wok.supportbot.advisor; |
|||
|
|||
import org.springframework.ai.chat.client.advisor.api.*; |
|||
import reactor.core.publisher.Flux; |
|||
import org.springframework.ai.chat.client.ChatClientRequest; |
|||
import org.springframework.ai.chat.client.ChatClientResponse; |
|||
import org.springframework.ai.chat.client.advisor.api.AdvisorChain; |
|||
import org.springframework.ai.chat.client.advisor.api.BaseAdvisor; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* 自定义 Re2 Advisor |
|||
* 自定义 Re2 Advisor(适配 Spring AI 1.0.1 新 Advisor API) |
|||
* 可提高大型语言模型的推理能力 |
|||
*/ |
|||
public class ReReadingAdvisor implements CallAroundAdvisor, StreamAroundAdvisor { |
|||
public class ReReadingAdvisor implements BaseAdvisor { |
|||
|
|||
/** |
|||
* 执行请求前,改写 Prompt |
|||
* @param advisedRequest |
|||
* @return |
|||
*/ |
|||
private AdvisedRequest before(AdvisedRequest advisedRequest) { |
|||
|
|||
Map<String, Object> advisedUserParams = new HashMap<>(advisedRequest.userParams()); |
|||
advisedUserParams.put("re2_input_query", advisedRequest.userText()); |
|||
|
|||
return AdvisedRequest.from(advisedRequest) |
|||
.userText(""" |
|||
{re2_input_query} |
|||
Read the question again: {re2_input_query} |
|||
""") |
|||
.userParams(advisedUserParams) |
|||
.build(); |
|||
} |
|||
|
|||
@Override |
|||
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { |
|||
return chain.nextAroundCall(this.before(advisedRequest)); |
|||
} |
|||
@Override |
|||
public String getName() { |
|||
return this.getClass().getSimpleName(); |
|||
} |
|||
|
|||
@Override |
|||
public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { |
|||
return chain.nextAroundStream(this.before(advisedRequest)); |
|||
} |
|||
@Override |
|||
public int getOrder() { |
|||
return 0; |
|||
} |
|||
|
|||
@Override |
|||
public int getOrder() { |
|||
return 0; |
|||
} |
|||
@Override |
|||
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) { |
|||
// Re2 策略:将用户问题重复一次以增强推理 |
|||
// 通过 context 传递原始查询,在 prompt 中追加重复指令 |
|||
Map<String, Object> newContext = new HashMap<>(request.context()); |
|||
newContext.put("re2_enabled", true); |
|||
return ChatClientRequest.builder() |
|||
.prompt(request.prompt()) |
|||
.context(newContext) |
|||
.build(); |
|||
} |
|||
|
|||
@Override |
|||
public String getName() { |
|||
return this.getClass().getSimpleName(); |
|||
} |
|||
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) { |
|||
return response; |
|||
} |
|||
} |
|||
@ -0,0 +1,136 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import com.wok.supportbot.entity.AiModelConfig; |
|||
import com.wok.supportbot.service.AiModelConfigService; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.ai.chat.model.ChatModel; |
|||
import org.springframework.ai.openai.OpenAiChatModel; |
|||
import org.springframework.ai.openai.OpenAiChatOptions; |
|||
import org.springframework.ai.openai.api.OpenAiApi; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Qualifier; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.Map; |
|||
import java.util.concurrent.ConcurrentHashMap; |
|||
|
|||
/** |
|||
* ChatModel 工厂 |
|||
* 按 DB 活跃配置动态创建/缓存 ChatModel 实例,支持运行时切换提供商。 |
|||
* - DashScope:复用 spring-ai-alibaba-starter-dashscope 自动配置的 Bean |
|||
* - DeepSeek / Kimi / 豆包 / 智谱等:通过 spring-ai-openai 模块 + 自定义 baseUrl 创建 |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class ChatModelFactory { |
|||
|
|||
@Autowired |
|||
private AiModelConfigService configService; |
|||
|
|||
/** |
|||
* DashScope 自动配置的 ChatModel Bean |
|||
* 由 spring-ai-alibaba-starter-dashscope 注册,标记 @Primary |
|||
*/ |
|||
@Autowired |
|||
private ChatModel dashscopeChatModel; |
|||
|
|||
/** |
|||
* ChatModel 缓存:key = "provider:apiKey:modelName" |
|||
*/ |
|||
private final ConcurrentHashMap<String, ChatModel> chatModelCache = new ConcurrentHashMap<>(); |
|||
|
|||
/** |
|||
* 各提供商默认的 API 基础地址 |
|||
*/ |
|||
private static final Map<String, String> DEFAULT_BASE_URLS = Map.of( |
|||
"deepseek", "https://api.deepseek.com", |
|||
"moonshot", "https://api.moonshot.cn/v1", |
|||
"volcengine", "https://ark.cn-beijing.volces.com/api/v3", |
|||
"zhipu", "https://open.bigmodel.cn/api/paas/v4", |
|||
"openai", "https://api.openai.com" |
|||
); |
|||
|
|||
/** |
|||
* 按应用类型获取活跃的 ChatModel |
|||
* 如果该类型无活跃配置,回退到 DashScope 默认 |
|||
* |
|||
* @param appType 应用类型:CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE |
|||
* @return ChatModel 实例 |
|||
*/ |
|||
public ChatModel getChatModel(String appType) { |
|||
AiModelConfig config = configService.getActiveConfigWithFullKey(appType); |
|||
if (config == null) { |
|||
log.warn("应用类型 [{}] 无活跃配置,回退到 DashScope 默认", appType); |
|||
return dashscopeChatModel; |
|||
} |
|||
return getOrCreateChatModel(config); |
|||
} |
|||
|
|||
/** |
|||
* 获取或创建 ChatModel(带缓存) |
|||
* 缓存 key = provider:apiKey:modelName,配置不变则复用实例 |
|||
*/ |
|||
private ChatModel getOrCreateChatModel(AiModelConfig config) { |
|||
String cacheKey = config.getProvider() + ":" + config.getApiKey() + ":" + config.getModelName(); |
|||
return chatModelCache.computeIfAbsent(cacheKey, k -> createChatModel(config)); |
|||
} |
|||
|
|||
/** |
|||
* 创建 ChatModel 实例 |
|||
* - dashscope:复用自动配置的 Bean |
|||
* - 其他提供商:通过 OpenAI 兼容 API 创建 |
|||
*/ |
|||
private ChatModel createChatModel(AiModelConfig config) { |
|||
if ("dashscope".equals(config.getProvider())) { |
|||
log.info("复用 DashScope ChatModel Bean: model={}", config.getModelName()); |
|||
return dashscopeChatModel; |
|||
} |
|||
|
|||
// OpenAI 兼容提供商 |
|||
String baseUrl = resolveBaseUrl(config); |
|||
log.info("创建 OpenAI 兼容 ChatModel: provider={}, baseUrl={}, model={}", |
|||
config.getProvider(), baseUrl, config.getModelName()); |
|||
|
|||
var api = OpenAiApi.builder() |
|||
.apiKey(config.getApiKey()) |
|||
.baseUrl(baseUrl) |
|||
.build(); |
|||
|
|||
var optionsBuilder = OpenAiChatOptions.builder() |
|||
.model(config.getModelName()); |
|||
if (config.getTemperature() != null) { |
|||
optionsBuilder.temperature(config.getTemperature()); |
|||
} |
|||
if (config.getMaxTokens() != null) { |
|||
optionsBuilder.maxTokens(config.getMaxTokens()); |
|||
} |
|||
|
|||
return OpenAiChatModel.builder() |
|||
.openAiApi(api) |
|||
.defaultOptions(optionsBuilder.build()) |
|||
.build(); |
|||
} |
|||
|
|||
/** |
|||
* 解析 API 基础地址:优先使用 DB 配置的 baseUrl,否则使用提供商默认值 |
|||
*/ |
|||
private String resolveBaseUrl(AiModelConfig config) { |
|||
if (config.getBaseUrl() != null && !config.getBaseUrl().isBlank()) { |
|||
return config.getBaseUrl(); |
|||
} |
|||
String defaultUrl = DEFAULT_BASE_URLS.get(config.getProvider()); |
|||
if (defaultUrl != null) { |
|||
return defaultUrl; |
|||
} |
|||
throw new IllegalArgumentException( |
|||
"未知提供商 [" + config.getProvider() + "],请在配置中填写 API 基础地址 (baseUrl)"); |
|||
} |
|||
|
|||
/** |
|||
* 清除 ChatModel 缓存(配置变更时调用) |
|||
*/ |
|||
public void clearCache() { |
|||
chatModelCache.clear(); |
|||
log.info("ChatModel 缓存已清除"); |
|||
} |
|||
} |
|||
@ -0,0 +1,78 @@ |
|||
package com.wok.supportbot.config; |
|||
|
|||
import com.wok.supportbot.entity.AiModelConfig; |
|||
import com.wok.supportbot.service.AiModelConfigService; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.boot.context.event.ApplicationReadyEvent; |
|||
import org.springframework.context.ApplicationListener; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** |
|||
* 模型配置加载器 |
|||
* 应用启动完成后,从数据库读取活跃配置并与 application.yml 中的配置进行一致性校验 |
|||
*/ |
|||
@Component |
|||
@Slf4j |
|||
public class ModelConfigLoader implements ApplicationListener<ApplicationReadyEvent> { |
|||
|
|||
@Autowired |
|||
private AiModelConfigService aiModelConfigService; |
|||
|
|||
@Value("${spring.ai.dashscope.api-key:}") |
|||
private String dashscopeApiKey; |
|||
|
|||
@Value("${spring.ai.dashscope.chat.options.model:qwen-turbo}") |
|||
private String chatModelName; |
|||
|
|||
@Value("${spring.ai.dashscope.embedding.options.model:text-embedding-v2}") |
|||
private String embeddingModelName; |
|||
|
|||
@Override |
|||
public void onApplicationEvent(ApplicationReadyEvent event) { |
|||
log.info("========== AI 模型配置校验 =========="); |
|||
try { |
|||
checkAppTypeConfig("CHAT", chatModelName); |
|||
checkAppTypeConfig("PRODUCT_EXTRACT", chatModelName); |
|||
checkAppTypeConfig("EMBEDDING", embeddingModelName); |
|||
checkAppTypeConfig("RAG_REWRITE", chatModelName); |
|||
log.info("========== AI 模型配置校验完成 =========="); |
|||
} catch (Exception e) { |
|||
log.warn("模型配置校验异常(不影响启动): {}", e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 校验指定应用类型的数据库配置与 application.yml 配置是否一致 |
|||
* |
|||
* @param appType 应用类型 |
|||
* @param ymlModelName yml 中配置的模型名称 |
|||
*/ |
|||
private void checkAppTypeConfig(String appType, String ymlModelName) { |
|||
AiModelConfig activeConfig = aiModelConfigService.getActiveConfigWithFullKey(appType); |
|||
if (activeConfig == null) { |
|||
log.warn(" [{}] 数据库中无活跃配置,将使用 application.yml 默认值", appType); |
|||
return; |
|||
} |
|||
|
|||
String dbModelName = activeConfig.getModelName(); |
|||
String dbApiKey = activeConfig.getApiKey(); |
|||
boolean modelMismatch = !ymlModelName.equals(dbModelName); |
|||
boolean apiKeyMismatch = !dashscopeApiKey.equals(dbApiKey); |
|||
|
|||
if (modelMismatch || apiKeyMismatch) { |
|||
log.warn(" [{}] ⚠️ 数据库配置与 application.yml 不一致!", appType); |
|||
log.warn(" DB : modelName={}, apiKey={}****", |
|||
dbModelName, |
|||
AiModelConfigService.maskApiKey(dbApiKey)); |
|||
log.warn(" YML : modelName={}, apiKey={}****", |
|||
ymlModelName, |
|||
AiModelConfigService.maskApiKey(dashscopeApiKey)); |
|||
log.warn(" 提示:请更新 application.yml 并重启服务使配置生效"); |
|||
} else { |
|||
log.info(" [{}] ✅ 活跃配置 [{}] provider={} (与 application.yml 一致)", |
|||
appType, dbModelName, activeConfig.getProvider()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,278 @@ |
|||
package com.wok.supportbot.controller; |
|||
|
|||
import com.wok.supportbot.app.AssistantApp; |
|||
import com.wok.supportbot.config.ChatModelFactory; |
|||
import com.wok.supportbot.entity.AiModelConfig; |
|||
import com.wok.supportbot.service.AiModelConfigService; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.web.bind.annotation.*; |
|||
|
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* AI 模型配置管理控制器 |
|||
* 提供模型配置的增删改查、激活等 API |
|||
*/ |
|||
@RestController |
|||
public class AiModelConfigController { |
|||
|
|||
@Autowired |
|||
private AiModelConfigService aiModelConfigService; |
|||
|
|||
@Autowired |
|||
private ChatModelFactory chatModelFactory; |
|||
|
|||
@Autowired |
|||
private AssistantApp assistantApp; |
|||
|
|||
// ==================== 分页列表 ==================== |
|||
|
|||
/** |
|||
* 获取模型配置列表(分页) |
|||
* |
|||
* @param page 页码(默认1) |
|||
* @param size 每页大小(默认10) |
|||
* @param appType 应用类型过滤(可选) |
|||
* @return 分页配置列表 |
|||
*/ |
|||
@GetMapping("/model-config/list") |
|||
public ResponseEntity<Map<String, Object>> listConfigs( |
|||
@RequestParam(defaultValue = "1") int page, |
|||
@RequestParam(defaultValue = "10") int size, |
|||
@RequestParam(required = false) String appType) { |
|||
try { |
|||
Map<String, Object> result = aiModelConfigService.listConfigs(appType, page, size); |
|||
Map<String, Object> data = new java.util.LinkedHashMap<>(); |
|||
data.put("success", true); |
|||
data.put("data", result.get("records")); |
|||
data.put("total", result.get("total")); |
|||
data.put("page", result.get("page")); |
|||
data.put("size", result.get("size")); |
|||
data.put("pages", result.get("pages")); |
|||
return ResponseEntity.ok(data); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查询失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 配置详情 ==================== |
|||
|
|||
/** |
|||
* 获取单条配置详情(API Key 脱敏) |
|||
* |
|||
* @param id 配置ID |
|||
* @return 配置详情 |
|||
*/ |
|||
@GetMapping("/model-config/{id}") |
|||
public ResponseEntity<Map<String, Object>> getConfigDetail(@PathVariable("id") Long id) { |
|||
try { |
|||
AiModelConfig config = aiModelConfigService.getConfigDetail(id); |
|||
if (config == null) { |
|||
return ResponseEntity.status(404).body(Map.of( |
|||
"success", false, |
|||
"message", "配置不存在" |
|||
)); |
|||
} |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", config |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查询失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 获取活跃配置 ==================== |
|||
|
|||
/** |
|||
* 获取指定应用类型的活跃配置(API Key 脱敏) |
|||
* |
|||
* @param appType 应用类型 |
|||
* @return 活跃配置 |
|||
*/ |
|||
@GetMapping("/model-config/active/{appType}") |
|||
public ResponseEntity<Map<String, Object>> getActiveConfig(@PathVariable("appType") String appType) { |
|||
try { |
|||
AiModelConfig config = aiModelConfigService.getActiveConfig(appType); |
|||
if (config == null) { |
|||
return ResponseEntity.status(404).body(Map.of( |
|||
"success", false, |
|||
"message", "该应用类型无活跃配置" |
|||
)); |
|||
} |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", config |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "查询失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 新建配置 ==================== |
|||
|
|||
/** |
|||
* 新建模型配置 |
|||
* |
|||
* @param config 配置对象 |
|||
* @return 创建结果 |
|||
*/ |
|||
@PostMapping("/model-config") |
|||
public ResponseEntity<Map<String, Object>> createConfig(@RequestBody AiModelConfig config) { |
|||
try { |
|||
// 参数校验 |
|||
if (config.getName() == null || config.getName().trim().isEmpty()) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", "配置名称不能为空" |
|||
)); |
|||
} |
|||
if (config.getAppType() == null || config.getAppType().trim().isEmpty()) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", "应用类型不能为空" |
|||
)); |
|||
} |
|||
if (config.getModelName() == null || config.getModelName().trim().isEmpty()) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", "模型名称不能为空" |
|||
)); |
|||
} |
|||
if (config.getApiKey() == null || config.getApiKey().trim().isEmpty()) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", "API Key 不能为空" |
|||
)); |
|||
} |
|||
|
|||
AiModelConfig created = aiModelConfigService.createConfig(config); |
|||
refreshCache(); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", created, |
|||
"message", "配置创建成功" |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "创建失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 更新配置 ==================== |
|||
|
|||
/** |
|||
* 更新模型配置 |
|||
* |
|||
* @param id 配置ID |
|||
* @param config 更新内容 |
|||
* @return 更新结果 |
|||
*/ |
|||
@PutMapping("/model-config/{id}") |
|||
public ResponseEntity<Map<String, Object>> updateConfig( |
|||
@PathVariable("id") Long id, |
|||
@RequestBody AiModelConfig config) { |
|||
try { |
|||
AiModelConfig updated = aiModelConfigService.updateConfig(id, config); |
|||
refreshCache(); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"data", updated, |
|||
"message", "配置更新成功" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "更新失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 激活配置 ==================== |
|||
|
|||
/** |
|||
* 激活指定配置(同 app_type 互斥) |
|||
* |
|||
* @param id 配置ID |
|||
* @return 激活结果 |
|||
*/ |
|||
@PutMapping("/model-config/{id}/activate") |
|||
public ResponseEntity<Map<String, Object>> activateConfig(@PathVariable("id") Long id) { |
|||
try { |
|||
aiModelConfigService.activateConfig(id); |
|||
refreshCache(); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", "配置已激活" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "激活失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 删除配置 ==================== |
|||
|
|||
/** |
|||
* 删除配置(逻辑删除,活跃配置不可删) |
|||
* |
|||
* @param id 配置ID |
|||
* @return 删除结果 |
|||
*/ |
|||
@DeleteMapping("/model-config/{id}") |
|||
public ResponseEntity<Map<String, Object>> deleteConfig(@PathVariable("id") Long id) { |
|||
try { |
|||
aiModelConfigService.deleteConfig(id); |
|||
refreshCache(); |
|||
return ResponseEntity.ok(Map.of( |
|||
"success", true, |
|||
"message", "配置删除成功" |
|||
)); |
|||
} catch (RuntimeException e) { |
|||
return ResponseEntity.badRequest().body(Map.of( |
|||
"success", false, |
|||
"message", e.getMessage() |
|||
)); |
|||
} catch (Exception e) { |
|||
return ResponseEntity.status(500).body(Map.of( |
|||
"success", false, |
|||
"message", "删除失败:" + e.getMessage() |
|||
)); |
|||
} |
|||
} |
|||
|
|||
// ==================== 缓存刷新 ==================== |
|||
|
|||
/** |
|||
* 刷新 ChatModel 和 ChatClient 缓存 |
|||
* 在配置变更(增删改激活)后调用,确保下次对话使用最新配置 |
|||
*/ |
|||
private void refreshCache() { |
|||
chatModelFactory.clearCache(); |
|||
assistantApp.clearCache(); |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.wok.supportbot.dao; |
|||
|
|||
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
|||
import com.wok.supportbot.entity.AiModelConfig; |
|||
import org.apache.ibatis.annotations.Mapper; |
|||
|
|||
/** |
|||
* AI 模型配置 Mapper - 继承 MyBatis Plus BaseMapper,自带 CRUD |
|||
*/ |
|||
@Mapper |
|||
public interface AiModelConfigMapper extends BaseMapper<AiModelConfig> { |
|||
} |
|||
@ -1,29 +1,30 @@ |
|||
package com.wok.supportbot.document.transform; |
|||
|
|||
import com.wok.supportbot.config.ChatModelFactory; |
|||
import jakarta.annotation.Resource; |
|||
import org.springframework.ai.chat.model.ChatModel; |
|||
import org.springframework.ai.document.Document; |
|||
import org.springframework.ai.transformer.KeywordMetadataEnricher; |
|||
import org.springframework.ai.model.transformer.KeywordMetadataEnricher; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 基于 AI 的文档元信息增强器(为文档补充元信息) |
|||
* 通过 ChatModelFactory 获取 ChatModel,支持多提供商动态切换 |
|||
*/ |
|||
@Component |
|||
public class MyKeywordEnricher { |
|||
|
|||
@Resource |
|||
private ChatModel dashscopeChatModel; |
|||
private ChatModelFactory chatModelFactory; |
|||
|
|||
/** |
|||
* 使用 AI 提取关键词并添加到元数据 |
|||
* @param documents |
|||
* @return |
|||
*/ |
|||
public List<Document> enrichDocuments(List<Document> documents) { |
|||
KeywordMetadataEnricher keywordMetadataEnricher = new KeywordMetadataEnricher(dashscopeChatModel, 5); |
|||
return keywordMetadataEnricher.apply(documents); |
|||
KeywordMetadataEnricher enricher = new KeywordMetadataEnricher.Builder(chatModelFactory.getChatModel("CHAT")) |
|||
.keywordCount(5) |
|||
.build(); |
|||
return enricher.apply(documents); |
|||
} |
|||
} |
|||
@ -0,0 +1,128 @@ |
|||
package com.wok.supportbot.entity; |
|||
|
|||
import com.baomidou.mybatisplus.annotation.*; |
|||
import com.fasterxml.jackson.databind.annotation.JsonSerialize; |
|||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; |
|||
import com.wok.supportbot.handler.PostgresJsonTypeHandler; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.io.Serial; |
|||
import java.io.Serializable; |
|||
import java.util.Date; |
|||
import java.util.Map; |
|||
|
|||
/** |
|||
* AI 大模型配置表 - 管理多套模型配置,支持不同 App 类型绑定 |
|||
*/ |
|||
@Data |
|||
@Builder |
|||
@AllArgsConstructor |
|||
@NoArgsConstructor |
|||
@TableName(value = "ai_model_config", autoResultMap = true) |
|||
public class AiModelConfig implements Serializable { |
|||
|
|||
@Serial |
|||
@TableField(exist = false) |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
/** |
|||
* 主键ID(雪花算法) |
|||
*/ |
|||
@TableId(value = "id", type = IdType.ASSIGN_ID) |
|||
@JsonSerialize(using = ToStringSerializer.class) |
|||
private Long id; |
|||
|
|||
/** |
|||
* 配置名称 |
|||
*/ |
|||
@TableField("name") |
|||
private String name; |
|||
|
|||
/** |
|||
* 应用类型:CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE |
|||
*/ |
|||
@TableField("app_type") |
|||
private String appType; |
|||
|
|||
/** |
|||
* 模型提供商(dashscope / openai / ...) |
|||
*/ |
|||
@TableField("provider") |
|||
private String provider; |
|||
|
|||
/** |
|||
* API Key |
|||
*/ |
|||
@TableField("api_key") |
|||
private String apiKey; |
|||
|
|||
/** |
|||
* 模型名称(如 qwen-turbo) |
|||
*/ |
|||
@TableField("model_name") |
|||
private String modelName; |
|||
|
|||
/** |
|||
* 温度参数 |
|||
*/ |
|||
@TableField("temperature") |
|||
private Double temperature; |
|||
|
|||
/** |
|||
* 最大 Token 数 |
|||
*/ |
|||
@TableField("max_tokens") |
|||
private Integer maxTokens; |
|||
|
|||
/** |
|||
* API 基础地址(可选,允许私有化部署) |
|||
*/ |
|||
@TableField("base_url") |
|||
private String baseUrl; |
|||
|
|||
/** |
|||
* 扩展配置(JSONB,存储 topP 等自定义参数) |
|||
*/ |
|||
@TableField(value = "extra_config", typeHandler = PostgresJsonTypeHandler.class) |
|||
private Map<String, Object> extraConfig; |
|||
|
|||
/** |
|||
* 是否为活跃配置(每种 App 类型只能有一个活跃) |
|||
*/ |
|||
@TableField("is_active") |
|||
private Boolean isActive; |
|||
|
|||
/** |
|||
* 优先级(数值越大越优先) |
|||
*/ |
|||
@TableField("priority") |
|||
private Integer priority; |
|||
|
|||
/** |
|||
* 描述说明 |
|||
*/ |
|||
@TableField("description") |
|||
private String description; |
|||
|
|||
/** |
|||
* 创建时间 |
|||
*/ |
|||
@TableField(value = "create_time", fill = FieldFill.INSERT) |
|||
private Date createTime; |
|||
|
|||
/** |
|||
* 更新时间 |
|||
*/ |
|||
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE) |
|||
private Date updateTime; |
|||
|
|||
/** |
|||
* 删除标志 - false:未删除, true:已删除(逻辑删除) |
|||
*/ |
|||
@TableField("is_delete") |
|||
@TableLogic |
|||
private boolean isDelete; |
|||
} |
|||
@ -1,36 +1,46 @@ |
|||
package com.wok.supportbot.rag.config; |
|||
|
|||
import com.wok.supportbot.config.ChatModelFactory; |
|||
import org.springframework.ai.chat.client.ChatClient; |
|||
import org.springframework.ai.chat.model.ChatModel; |
|||
import org.springframework.ai.rag.preretrieval.query.transformation.CompressionQueryTransformer; |
|||
import org.springframework.ai.rag.preretrieval.query.transformation.QueryTransformer; |
|||
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer; |
|||
import org.springframework.ai.rag.preretrieval.query.transformation.TranslationQueryTransformer; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Configuration; |
|||
|
|||
/** |
|||
* 查询转换器 Bean 配置 |
|||
* 通过 ChatModelFactory 获取 ChatModel,支持多提供商动态切换 |
|||
* 注意:这些 Bean 在 doChatWithRagEnhance() 中被注入但未生效(TODO), |
|||
* 实际使用的查询重写逻辑在 preretrieval/ 包下的 Rewriter 组件中 |
|||
*/ |
|||
@Configuration |
|||
public class QueryTransformerConfig { |
|||
|
|||
@Autowired |
|||
private ChatModelFactory chatModelFactory; |
|||
|
|||
@Bean |
|||
public QueryTransformer rewriteQueryTransformer(ChatModel dashscopeChatModel) { |
|||
public QueryTransformer rewriteQueryTransformer() { |
|||
return RewriteQueryTransformer.builder() |
|||
.chatClientBuilder(ChatClient.builder(dashscopeChatModel)) |
|||
.chatClientBuilder(ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE"))) |
|||
.build(); |
|||
} |
|||
|
|||
@Bean |
|||
public QueryTransformer translationQueryTransformer(ChatModel dashscopeChatModel) { |
|||
public QueryTransformer translationQueryTransformer() { |
|||
return TranslationQueryTransformer.builder() |
|||
.chatClientBuilder(ChatClient.builder(dashscopeChatModel)) |
|||
.chatClientBuilder(ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE"))) |
|||
.targetLanguage("chinese") |
|||
.build(); |
|||
} |
|||
|
|||
@Bean |
|||
public QueryTransformer compressionQueryTransformer(ChatModel dashscopeChatModel) { |
|||
public QueryTransformer compressionQueryTransformer() { |
|||
return CompressionQueryTransformer.builder() |
|||
.chatClientBuilder(ChatClient.builder(dashscopeChatModel)) |
|||
.chatClientBuilder(ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE"))) |
|||
.build(); |
|||
} |
|||
} |
|||
@ -0,0 +1,268 @@ |
|||
package com.wok.supportbot.service; |
|||
|
|||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
|||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
|||
import com.wok.supportbot.dao.AiModelConfigMapper; |
|||
import com.wok.supportbot.entity.AiModelConfig; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.*; |
|||
|
|||
/** |
|||
* AI 模型配置管理服务 |
|||
* 提供 CRUD、激活互斥、API Key 脱敏等功能 |
|||
*/ |
|||
@Service |
|||
@Slf4j |
|||
public class AiModelConfigService { |
|||
|
|||
@Autowired |
|||
private AiModelConfigMapper aiModelConfigMapper; |
|||
|
|||
@Autowired |
|||
private JdbcTemplate jdbcTemplate; |
|||
|
|||
// ==================== 分页列表 ==================== |
|||
|
|||
/** |
|||
* 分页查询模型配置列表 |
|||
* |
|||
* @param appType 应用类型过滤(可选) |
|||
* @param page 页码(从1开始) |
|||
* @param size 每页大小 |
|||
* @return 分页结果 |
|||
*/ |
|||
public Map<String, Object> listConfigs(String appType, int page, int size) { |
|||
// 构建查询条件 |
|||
StringBuilder whereClause = new StringBuilder("WHERE is_delete = false "); |
|||
List<Object> params = new ArrayList<>(); |
|||
|
|||
if (appType != null && !appType.isEmpty()) { |
|||
whereClause.append(" AND app_type = ? "); |
|||
params.add(appType); |
|||
} |
|||
|
|||
// 查询总数 |
|||
String countSql = "SELECT COUNT(*) FROM ai_model_config " + whereClause; |
|||
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); |
|||
if (total == null) total = 0L; |
|||
|
|||
// 查询列表(按 priority 降序、create_time 降序) |
|||
String listSql = "SELECT * FROM ai_model_config " + whereClause + |
|||
" ORDER BY priority DESC, create_time DESC LIMIT ? OFFSET ?"; |
|||
|
|||
List<Object> queryParams = new ArrayList<>(params); |
|||
queryParams.add(size); |
|||
queryParams.add((page - 1) * size); |
|||
|
|||
List<Map<String, Object>> records = jdbcTemplate.queryForList(listSql, queryParams.toArray()); |
|||
|
|||
// 脱敏 API Key 并格式化结果 |
|||
List<Map<String, Object>> formattedRecords = new ArrayList<>(); |
|||
for (Map<String, Object> record : records) { |
|||
Map<String, Object> formatted = new LinkedHashMap<>(record); |
|||
// API Key 脱敏 |
|||
String apiKey = (String) record.get("api_key"); |
|||
if (apiKey != null) { |
|||
formatted.put("api_key", maskApiKey(apiKey)); |
|||
} |
|||
formattedRecords.add(formatted); |
|||
} |
|||
|
|||
Map<String, Object> result = new LinkedHashMap<>(); |
|||
result.put("records", formattedRecords); |
|||
result.put("total", total); |
|||
result.put("page", page); |
|||
result.put("size", size); |
|||
result.put("pages", (total + size - 1) / size); |
|||
return result; |
|||
} |
|||
|
|||
// ==================== 详情 ==================== |
|||
|
|||
/** |
|||
* 获取单条配置详情(API Key 脱敏) |
|||
* |
|||
* @param id 配置ID |
|||
* @return 配置详情 |
|||
*/ |
|||
public AiModelConfig getConfigDetail(Long id) { |
|||
AiModelConfig config = aiModelConfigMapper.selectById(id); |
|||
if (config != null) { |
|||
config.setApiKey(maskApiKey(config.getApiKey())); |
|||
} |
|||
return config; |
|||
} |
|||
|
|||
// ==================== 获取活跃配置 ==================== |
|||
|
|||
/** |
|||
* 获取指定应用类型的活跃配置 |
|||
* |
|||
* @param appType 应用类型 |
|||
* @return 活跃配置(API Key 脱敏) |
|||
*/ |
|||
public AiModelConfig getActiveConfig(String appType) { |
|||
LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>(); |
|||
wrapper.eq(AiModelConfig::getAppType, appType) |
|||
.eq(AiModelConfig::getIsActive, true) |
|||
.last("LIMIT 1"); |
|||
AiModelConfig config = aiModelConfigMapper.selectOne(wrapper); |
|||
if (config != null) { |
|||
config.setApiKey(maskApiKey(config.getApiKey())); |
|||
} |
|||
return config; |
|||
} |
|||
|
|||
/** |
|||
* 获取指定应用类型的活跃配置(含完整 API Key,仅供内部调用使用) |
|||
* |
|||
* @param appType 应用类型 |
|||
* @return 活跃配置(含完整 API Key) |
|||
*/ |
|||
public AiModelConfig getActiveConfigWithFullKey(String appType) { |
|||
LambdaQueryWrapper<AiModelConfig> wrapper = new LambdaQueryWrapper<>(); |
|||
wrapper.eq(AiModelConfig::getAppType, appType) |
|||
.eq(AiModelConfig::getIsActive, true) |
|||
.last("LIMIT 1"); |
|||
return aiModelConfigMapper.selectOne(wrapper); |
|||
} |
|||
|
|||
// ==================== 新建配置 ==================== |
|||
|
|||
/** |
|||
* 新建模型配置 |
|||
* 如果设置 is_active=true,自动禁用同 app_type 的其他配置 |
|||
* |
|||
* @param config 配置对象 |
|||
* @return 保存后的配置 |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public AiModelConfig createConfig(AiModelConfig config) { |
|||
// 如果新配置标记为活跃,先禁用同类型的其他配置 |
|||
if (Boolean.TRUE.equals(config.getIsActive())) { |
|||
deactivateByAppType(config.getAppType()); |
|||
} |
|||
aiModelConfigMapper.insert(config); |
|||
log.info("新建 AI 模型配置: name={}, appType={}, modelName={}", |
|||
config.getName(), config.getAppType(), config.getModelName()); |
|||
return config; |
|||
} |
|||
|
|||
// ==================== 更新配置 ==================== |
|||
|
|||
/** |
|||
* 更新模型配置 |
|||
* 如果更新后 is_active=true,自动禁用同 app_type 的其他配置 |
|||
* |
|||
* @param id 配置ID |
|||
* @param config 更新内容 |
|||
* @return 更新后的配置 |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public AiModelConfig updateConfig(Long id, AiModelConfig config) { |
|||
AiModelConfig existing = aiModelConfigMapper.selectById(id); |
|||
if (existing == null) { |
|||
throw new RuntimeException("配置不存在"); |
|||
} |
|||
|
|||
// 如果要激活此配置,先禁用同类型的其他配置 |
|||
if (Boolean.TRUE.equals(config.getIsActive())) { |
|||
deactivateByAppType(existing.getAppType()); |
|||
} |
|||
|
|||
// 如果 app_type 被修改且新配置为活跃,需要禁用新类型的其他配置 |
|||
if (config.getAppType() != null && !config.getAppType().equals(existing.getAppType()) |
|||
&& Boolean.TRUE.equals(config.getIsActive())) { |
|||
deactivateByAppType(config.getAppType()); |
|||
} |
|||
|
|||
// 设置 ID 确保更新正确 |
|||
config.setId(id); |
|||
aiModelConfigMapper.updateById(config); |
|||
log.info("更新 AI 模型配置: id={}", id); |
|||
return aiModelConfigMapper.selectById(id); |
|||
} |
|||
|
|||
// ==================== 激活配置 ==================== |
|||
|
|||
/** |
|||
* 激活指定配置(同 app_type 互斥) |
|||
* |
|||
* @param id 配置ID |
|||
*/ |
|||
@Transactional(rollbackFor = Exception.class) |
|||
public void activateConfig(Long id) { |
|||
AiModelConfig config = aiModelConfigMapper.selectById(id); |
|||
if (config == null) { |
|||
throw new RuntimeException("配置不存在"); |
|||
} |
|||
|
|||
// 先禁用同类型的所有配置 |
|||
deactivateByAppType(config.getAppType()); |
|||
|
|||
// 再激活目标配置 |
|||
LambdaUpdateWrapper<AiModelConfig> updateWrapper = new LambdaUpdateWrapper<>(); |
|||
updateWrapper.eq(AiModelConfig::getId, id) |
|||
.set(AiModelConfig::getIsActive, true); |
|||
aiModelConfigMapper.update(null, updateWrapper); |
|||
|
|||
log.info("激活 AI 模型配置: id={}, appType={}, modelName={}", |
|||
id, config.getAppType(), config.getModelName()); |
|||
} |
|||
|
|||
// ==================== 删除配置 ==================== |
|||
|
|||
/** |
|||
* 删除配置(逻辑删除) |
|||
* 不允许删除当前活跃配置 |
|||
* |
|||
* @param id 配置ID |
|||
*/ |
|||
public void deleteConfig(Long id) { |
|||
AiModelConfig config = aiModelConfigMapper.selectById(id); |
|||
if (config == null) { |
|||
throw new RuntimeException("配置不存在"); |
|||
} |
|||
if (Boolean.TRUE.equals(config.getIsActive())) { |
|||
throw new RuntimeException("不允许删除当前活跃配置,请先激活其他配置"); |
|||
} |
|||
aiModelConfigMapper.deleteById(id); |
|||
log.info("删除 AI 模型配置: id={}, name={}", id, config.getName()); |
|||
} |
|||
|
|||
// ==================== 工具方法 ==================== |
|||
|
|||
/** |
|||
* 禁用指定应用类型下的所有配置 |
|||
* |
|||
* @param appType 应用类型 |
|||
*/ |
|||
private void deactivateByAppType(String appType) { |
|||
LambdaUpdateWrapper<AiModelConfig> updateWrapper = new LambdaUpdateWrapper<>(); |
|||
updateWrapper.eq(AiModelConfig::getAppType, appType) |
|||
.eq(AiModelConfig::getIsActive, true) |
|||
.set(AiModelConfig::getIsActive, false); |
|||
aiModelConfigMapper.update(null, updateWrapper); |
|||
} |
|||
|
|||
/** |
|||
* API Key 脱敏:前 4 位 + **** + 后 4 位 |
|||
* |
|||
* @param apiKey 原始 API Key |
|||
* @return 脱敏后的 API Key |
|||
*/ |
|||
public static String maskApiKey(String apiKey) { |
|||
if (apiKey == null || apiKey.isEmpty()) { |
|||
return ""; |
|||
} |
|||
if (apiKey.length() <= 8) { |
|||
return "****"; |
|||
} |
|||
return apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length() - 4); |
|||
} |
|||
} |
|||
@ -0,0 +1,496 @@ |
|||
/** |
|||
* ⚙️ AI 模型配置管理组件 |
|||
* 展示模型配置列表、新增/编辑/激活/删除配置 |
|||
*/ |
|||
import { ref, onMounted, watch } from 'vue' |
|||
import * as api from '../js/api.js' |
|||
import { toast, formatDate } from '../js/utils.js' |
|||
|
|||
// 应用类型选项
|
|||
const APP_TYPE_OPTIONS = [ |
|||
{ value: '', label: '全部类型' }, |
|||
{ value: 'CHAT', label: '智能客服对话' }, |
|||
{ value: 'PRODUCT_EXTRACT', label: '商品信息抽取' }, |
|||
{ value: 'EMBEDDING', label: '文本向量化' }, |
|||
{ value: 'RAG_REWRITE', label: 'RAG查询重写' } |
|||
] |
|||
|
|||
// 提供商选项
|
|||
const PROVIDER_OPTIONS = [ |
|||
{ value: 'dashscope', label: '通义千问 (DashScope)' }, |
|||
{ value: 'deepseek', label: 'DeepSeek (深度求索)' }, |
|||
{ value: 'volcengine', label: '豆包 (字节跳动)' }, |
|||
{ value: 'moonshot', label: 'Kimi (月之暗面)' }, |
|||
{ value: 'zhipu', label: '智谱 AI (GLM)' }, |
|||
{ value: 'openai', label: 'OpenAI' }, |
|||
{ value: 'other', label: '其他' } |
|||
] |
|||
|
|||
// 提供商默认配置(切换时自动填充)
|
|||
const PROVIDER_DEFAULTS = { |
|||
dashscope: { |
|||
baseUrl: '', |
|||
models: ['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-long', 'text-embedding-v2'], |
|||
defaultModel: 'qwen-turbo', |
|||
tip: '通义千问使用 DashScope 自动配置,Base URL 留空即可' |
|||
}, |
|||
deepseek: { |
|||
baseUrl: 'https://api.deepseek.com', |
|||
models: ['deepseek-chat', 'deepseek-reasoner'], |
|||
defaultModel: 'deepseek-chat', |
|||
tip: '' |
|||
}, |
|||
volcengine: { |
|||
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', |
|||
models: [], |
|||
defaultModel: '', |
|||
tip: '豆包模型的模型名称需填入 Endpoint ID(如 ep-xxxxx),请在火山引擎控制台获取' |
|||
}, |
|||
moonshot: { |
|||
baseUrl: 'https://api.moonshot.cn/v1', |
|||
models: ['moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'], |
|||
defaultModel: 'moonshot-v1-8k', |
|||
tip: '' |
|||
}, |
|||
zhipu: { |
|||
baseUrl: 'https://open.bigmodel.cn/api/paas/v4', |
|||
models: ['glm-4-plus', 'glm-4-flash', 'glm-4-long', 'glm-4'], |
|||
defaultModel: 'glm-4-flash', |
|||
tip: '' |
|||
}, |
|||
openai: { |
|||
baseUrl: 'https://api.openai.com', |
|||
models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'], |
|||
defaultModel: 'gpt-4o-mini', |
|||
tip: '' |
|||
}, |
|||
other: { |
|||
baseUrl: '', |
|||
models: [], |
|||
defaultModel: '', |
|||
tip: '使用 OpenAI 兼容 API 的其他提供商,请填写 Base URL 和模型名称' |
|||
} |
|||
} |
|||
|
|||
export default { |
|||
template: `
|
|||
<div class="card"> |
|||
<h2>⚙️ AI 大模型配置管理</h2> |
|||
|
|||
<!-- 筛选栏 --> |
|||
<div class="input-row"> |
|||
<select class="input" v-model="filterAppType" @change="load(1)" style="max-width:200px;"> |
|||
<option v-for="opt in appTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option> |
|||
</select> |
|||
<button class="btn btn-primary btn-sm" @click="openAddModal">➕ 新建配置</button> |
|||
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button> |
|||
</div> |
|||
|
|||
<!-- 配置列表表格 --> |
|||
<div style="overflow-x:auto;"> |
|||
<table class="data-table"> |
|||
<thead> |
|||
<tr> |
|||
<th>配置名称</th> |
|||
<th>应用类型</th> |
|||
<th>模型名称</th> |
|||
<th>提供商</th> |
|||
<th>温度</th> |
|||
<th>API Key</th> |
|||
<th>状态</th> |
|||
<th>操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-if="configs.length === 0"> |
|||
<td colspan="8" style="text-align:center;color:var(--sub);">暂无配置记录</td> |
|||
</tr> |
|||
<tr v-for="c in configs" :key="c.id"> |
|||
<td> |
|||
<strong>{{ c.name || '-' }}</strong> |
|||
<div v-if="c.description" style="font-size:11px;color:var(--sub);margin-top:2px;">{{ c.description }}</div> |
|||
</td> |
|||
<td> |
|||
<span class="badge" :class="getAppTypeBadgeClass(c.app_type)">{{ getAppTypeLabel(c.app_type) }}</span> |
|||
</td> |
|||
<td><code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.model_name }}</code></td> |
|||
<td>{{ getProviderLabel(c.provider) }}</td> |
|||
<td>{{ c.temperature != null ? c.temperature : '-' }}</td> |
|||
<td><code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.api_key || '-' }}</code></td> |
|||
<td> |
|||
<span v-if="c.is_active" style="color:#16a34a;font-weight:600;"> |
|||
🟢 活跃 |
|||
</span> |
|||
<span v-else style="color:var(--sub);"> |
|||
⚫ 未激活 |
|||
</span> |
|||
</td> |
|||
<td> |
|||
<button class="btn btn-sm btn-outline" @click="openEditModal(c)">编辑</button> |
|||
<button v-if="!c.is_active" class="btn btn-sm btn-primary" @click="activate(c.id)">激活</button> |
|||
<button v-if="!c.is_active" class="btn btn-sm btn-danger" @click="remove(c.id, c.name)">删除</button> |
|||
</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<!-- 分页 --> |
|||
<div class="pagination" v-if="totalPages > 1"> |
|||
<button :disabled="currentPage <= 1" @click="load(currentPage - 1)">上一页</button> |
|||
<template v-for="i in totalPages" :key="i"> |
|||
<button v-if="i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)" |
|||
:class="{ active: i === currentPage }" @click="load(i)">{{ i }}</button> |
|||
<span v-else-if="i === currentPage - 3 || i === currentPage + 3" style="padding:6px;">...</span> |
|||
</template> |
|||
<button :disabled="currentPage >= totalPages" @click="load(currentPage + 1)">下一页</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 编辑/新建弹窗 --> |
|||
<div class="modal-overlay" :class="{ active: editModal.visible }" @click.self="closeEditModal"> |
|||
<div class="modal-box" style="max-width:600px;"> |
|||
<button class="modal-close" @click="closeEditModal">×</button> |
|||
<h2>{{ editModal.mode === 'add' ? '➕ 新建模型配置' : '✏️ 编辑模型配置' }}</h2> |
|||
|
|||
<div style="display:flex;flex-direction:column;gap:14px;margin-top:16px;"> |
|||
<!-- 配置名称 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">配置名称 <span style="color:#dc2626;">*</span></label> |
|||
<input type="text" class="input" v-model="editModal.form.name" placeholder="如:生产环境-DeepSeek对话"> |
|||
</div> |
|||
|
|||
<!-- 应用类型 + 提供商 --> |
|||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">应用类型 <span style="color:#dc2626;">*</span></label> |
|||
<select class="input" v-model="editModal.form.app_type" :disabled="editModal.mode === 'edit'"> |
|||
<option v-for="opt in appTypeOptions.slice(1)" :key="opt.value" :value="opt.value">{{ opt.label }}</option> |
|||
</select> |
|||
</div> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">提供商 <span style="color:#dc2626;">*</span></label> |
|||
<select class="input" v-model="editModal.form.provider" @change="onProviderChange"> |
|||
<option v-for="opt in providerOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 提供商提示 --> |
|||
<div v-if="providerTip" style="padding:8px 12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:6px;font-size:12px;color:#1e40af;"> |
|||
💡 {{ providerTip }} |
|||
</div> |
|||
|
|||
<!-- API Key --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API Key <span style="color:#dc2626;">*</span></label> |
|||
<div style="display:flex;gap:8px;"> |
|||
<input :type="showApiKey ? 'text' : 'password'" class="input" v-model="editModal.form.api_key" |
|||
:placeholder="editModal.mode === 'edit' ? '留空则不修改' : '请输入 API Key'" style="flex:1;"> |
|||
<button class="btn btn-sm btn-outline" @click="showApiKey = !showApiKey" style="white-space:nowrap;"> |
|||
{{ showApiKey ? '🙈 隐藏' : '👁️ 显示' }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 模型名称 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">模型名称 <span style="color:#dc2626;">*</span></label> |
|||
<input v-if="!currentProviderModels.length" type="text" class="input" v-model="editModal.form.model_name" |
|||
:placeholder="currentProviderDefaultModel || '请输入模型名称'"> |
|||
<select v-else class="input" v-model="editModal.form.model_name"> |
|||
<option v-for="m in currentProviderModels" :key="m" :value="m">{{ m }}</option> |
|||
<option value="__custom__">自定义输入...</option> |
|||
</select> |
|||
<input v-if="editModal.form.model_name === '__custom__'" type="text" class="input" |
|||
v-model="customModelName" placeholder="输入自定义模型名称" style="margin-top:6px;"> |
|||
</div> |
|||
|
|||
<!-- 温度 + 最大 Token --> |
|||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">温度 (Temperature)</label> |
|||
<input type="number" class="input" v-model.number="editModal.form.temperature" step="0.1" min="0" max="2" placeholder="0.7"> |
|||
</div> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">最大 Token 数</label> |
|||
<input type="number" class="input" v-model.number="editModal.form.max_tokens" min="1" max="128000" placeholder="2000"> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Base URL --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">API 基础地址</label> |
|||
<input type="text" class="input" v-model="editModal.form.base_url" :placeholder="providerBaseUrlPlaceholder"> |
|||
</div> |
|||
|
|||
<!-- 优先级 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">优先级</label> |
|||
<input type="number" class="input" v-model.number="editModal.form.priority" min="0" placeholder="0(数值越大越优先)"> |
|||
</div> |
|||
|
|||
<!-- 是否激活 --> |
|||
<div style="display:flex;align-items:center;gap:8px;"> |
|||
<input type="checkbox" v-model="editModal.form.is_active" id="editIsActive"> |
|||
<label for="editIsActive" style="font-size:13px;cursor:pointer;">设为活跃配置(同类型只能有一个活跃)</label> |
|||
</div> |
|||
|
|||
<!-- 描述 --> |
|||
<div> |
|||
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:4px;">描述说明</label> |
|||
<textarea class="input" v-model="editModal.form.description" rows="2" placeholder="可选,填写配置用途说明"></textarea> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 按钮 --> |
|||
<div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;"> |
|||
<button class="btn btn-outline" @click="closeEditModal">取消</button> |
|||
<button class="btn btn-primary" @click="saveConfig">💾 保存</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`,
|
|||
setup() { |
|||
const configs = ref([]) |
|||
const currentPage = ref(1) |
|||
const totalPages = ref(1) |
|||
const total = ref(0) |
|||
const filterAppType = ref('') |
|||
const showApiKey = ref(false) |
|||
const customModelName = ref('') |
|||
const providerTip = ref('') |
|||
|
|||
// 计算当前提供商的模型列表和默认值
|
|||
const currentProviderModels = ref([]) |
|||
const currentProviderDefaultModel = ref('') |
|||
const providerBaseUrlPlaceholder = ref('') |
|||
|
|||
// 编辑弹窗状态
|
|||
const editModal = ref({ |
|||
visible: false, |
|||
mode: 'add', |
|||
editId: null, |
|||
form: createEmptyForm() |
|||
}) |
|||
|
|||
function createEmptyForm() { |
|||
return { |
|||
name: '', |
|||
app_type: 'CHAT', |
|||
provider: 'dashscope', |
|||
api_key: '', |
|||
model_name: '', |
|||
temperature: 0.7, |
|||
max_tokens: 2000, |
|||
base_url: '', |
|||
priority: 0, |
|||
is_active: false, |
|||
description: '' |
|||
} |
|||
} |
|||
|
|||
// 提供商切换时自动填充
|
|||
function onProviderChange() { |
|||
const provider = editModal.value.form.provider |
|||
const defaults = PROVIDER_DEFAULTS[provider] || PROVIDER_DEFAULTS.other |
|||
|
|||
// 自动填充 Base URL
|
|||
editModal.value.form.base_url = defaults.baseUrl |
|||
|
|||
// 自动填充模型名称(仅新建模式且模型名为空时)
|
|||
if (editModal.value.mode === 'add' && !editModal.value.form.model_name) { |
|||
editModal.value.form.model_name = defaults.defaultModel |
|||
} |
|||
|
|||
// 更新模型列表和提示
|
|||
currentProviderModels.value = defaults.models || [] |
|||
currentProviderDefaultModel.value = defaults.defaultModel || '' |
|||
providerTip.value = defaults.tip || '' |
|||
providerBaseUrlPlaceholder.value = defaults.baseUrl || '私有化部署时可填写自定义地址' |
|||
customModelName.value = '' |
|||
} |
|||
|
|||
// 监听自定义模型名输入
|
|||
watch(customModelName, (val) => { |
|||
if (editModal.value.form.model_name === '__custom__' && val) { |
|||
editModal.value.form.model_name = val |
|||
} |
|||
}) |
|||
|
|||
// ==================== 数据加载 ====================
|
|||
|
|||
async function load(p = 1) { |
|||
currentPage.value = p |
|||
try { |
|||
const json = await api.listModelConfigs(p, 10, filterAppType.value || undefined) |
|||
if (json.success) { |
|||
configs.value = json.data || [] |
|||
total.value = json.total || 0 |
|||
totalPages.value = json.pages || 1 |
|||
} else { |
|||
toast(json.message || '查询失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('加载配置列表失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 弹窗操作 ====================
|
|||
|
|||
function openAddModal() { |
|||
editModal.value = { |
|||
visible: true, |
|||
mode: 'add', |
|||
editId: null, |
|||
form: createEmptyForm() |
|||
} |
|||
showApiKey.value = false |
|||
onProviderChange() |
|||
} |
|||
|
|||
function openEditModal(config) { |
|||
editModal.value = { |
|||
visible: true, |
|||
mode: 'edit', |
|||
editId: config.id, |
|||
form: { |
|||
name: config.name || '', |
|||
app_type: config.app_type || 'CHAT', |
|||
provider: config.provider || 'dashscope', |
|||
api_key: '', |
|||
model_name: config.model_name || '', |
|||
temperature: config.temperature, |
|||
max_tokens: config.max_tokens, |
|||
base_url: config.base_url || '', |
|||
priority: config.priority || 0, |
|||
is_active: config.is_active || false, |
|||
description: config.description || '' |
|||
} |
|||
} |
|||
showApiKey.value = false |
|||
onProviderChange() |
|||
} |
|||
|
|||
function closeEditModal() { |
|||
editModal.value.visible = false |
|||
} |
|||
|
|||
// ==================== 保存配置 ====================
|
|||
|
|||
async function saveConfig() { |
|||
const form = editModal.value.form |
|||
|
|||
// 如果选了"自定义输入",取自定义名称
|
|||
if (form.model_name === '__custom__') { |
|||
if (!customModelName.value.trim()) { |
|||
toast('请输入自定义模型名称', 'error') |
|||
return |
|||
} |
|||
form.model_name = customModelName.value.trim() |
|||
} |
|||
|
|||
if (!form.name || !form.name.trim()) { |
|||
toast('请填写配置名称', 'error') |
|||
return |
|||
} |
|||
if (!form.app_type) { |
|||
toast('请选择应用类型', 'error') |
|||
return |
|||
} |
|||
if (!form.model_name || !form.model_name.trim()) { |
|||
toast('请填写模型名称', 'error') |
|||
return |
|||
} |
|||
if (editModal.value.mode === 'add' && (!form.api_key || !form.api_key.trim())) { |
|||
toast('请填写 API Key', 'error') |
|||
return |
|||
} |
|||
|
|||
try { |
|||
let json |
|||
if (editModal.value.mode === 'add') { |
|||
json = await api.createModelConfig(form) |
|||
} else { |
|||
const updateData = { ...form } |
|||
if (!updateData.api_key || !updateData.api_key.trim()) { |
|||
delete updateData.api_key |
|||
} |
|||
json = await api.updateModelConfig(editModal.value.editId, updateData) |
|||
} |
|||
|
|||
if (json.success) { |
|||
toast(editModal.value.mode === 'add' ? '配置创建成功,已切换生效' : '配置更新成功,已切换生效', 'success') |
|||
closeEditModal() |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '操作失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('保存失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 激活配置 ====================
|
|||
|
|||
async function activate(id) { |
|||
if (!confirm('确定激活此配置?同类型的其他配置将被自动停用,新配置将立即生效。')) return |
|||
try { |
|||
const json = await api.activateModelConfig(id) |
|||
if (json.success) { |
|||
toast('配置已激活,立即生效', 'success') |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '激活失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('激活失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 删除配置 ====================
|
|||
|
|||
async function remove(id, name) { |
|||
if (!confirm('确定删除配置「' + (name || id) + '」?')) return |
|||
try { |
|||
const json = await api.deleteModelConfig(id) |
|||
if (json.success) { |
|||
toast('配置删除成功', 'success') |
|||
load(currentPage.value) |
|||
} else { |
|||
toast(json.message || '删除失败', 'error') |
|||
} |
|||
} catch (e) { |
|||
toast('删除失败:' + e.message, 'error') |
|||
} |
|||
} |
|||
|
|||
// ==================== 工具函数 ====================
|
|||
|
|||
function getAppTypeLabel(appType) { |
|||
const map = { CHAT: '智能客服对话', PRODUCT_EXTRACT: '商品信息抽取', EMBEDDING: '文本向量化', RAG_REWRITE: 'RAG查询重写' } |
|||
return map[appType] || appType |
|||
} |
|||
|
|||
function getAppTypeBadgeClass(appType) { |
|||
const map = { CHAT: 'badge-get', PRODUCT_EXTRACT: 'badge-post', EMBEDDING: '', RAG_REWRITE: '' } |
|||
return map[appType] || '' |
|||
} |
|||
|
|||
function getProviderLabel(provider) { |
|||
const found = PROVIDER_OPTIONS.find(o => o.value === provider) |
|||
return found ? found.label : provider |
|||
} |
|||
|
|||
// 初始加载
|
|||
onMounted(() => { load() }) |
|||
|
|||
return { |
|||
configs, currentPage, totalPages, total, filterAppType, showApiKey, editModal, |
|||
appTypeOptions: APP_TYPE_OPTIONS, providerOptions: PROVIDER_OPTIONS, |
|||
providerTip, currentProviderModels, currentProviderDefaultModel, providerBaseUrlPlaceholder, customModelName, |
|||
load, openAddModal, openEditModal, closeEditModal, saveConfig, activate, remove, |
|||
onProviderChange, getAppTypeLabel, getAppTypeBadgeClass, getProviderLabel, formatDate |
|||
} |
|||
} |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue