Browse Source

完成了模型配置管理页面(5家AI提供商集成+运行时动态切换)

master
wanghanlin 2 days ago
parent
commit
f79350c363
  1. 27
      CLAUDE.md
  2. 68
      pom.xml
  3. 11
      src/main/java/com/wok/supportbot/SupportBotApplication.java
  4. 87
      src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java
  5. 66
      src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java
  6. 194
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  7. 40
      src/main/java/com/wok/supportbot/app/ProductInfoApp.java
  8. 19
      src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java
  9. 7
      src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java
  10. 136
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  11. 107
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  12. 78
      src/main/java/com/wok/supportbot/config/ModelConfigLoader.java
  13. 278
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  14. 12
      src/main/java/com/wok/supportbot/dao/AiModelConfigMapper.java
  15. 15
      src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java
  16. 128
      src/main/java/com/wok/supportbot/entity/AiModelConfig.java
  17. 14
      src/main/java/com/wok/supportbot/rag/config/QueryExpanderConfig.java
  18. 24
      src/main/java/com/wok/supportbot/rag/config/QueryTransformerConfig.java
  19. 21
      src/main/java/com/wok/supportbot/rag/preretrieval/CompressionQueryRewriter.java
  20. 23
      src/main/java/com/wok/supportbot/rag/preretrieval/MultiQueryExpanderRewriter.java
  21. 22
      src/main/java/com/wok/supportbot/rag/preretrieval/RewriteQueryRewriter.java
  22. 21
      src/main/java/com/wok/supportbot/rag/preretrieval/TranslationQueryRewriter.java
  23. 268
      src/main/java/com/wok/supportbot/service/AiModelConfigService.java
  24. 496
      src/main/resources/static/components/ModelConfigManager.js
  25. 53
      src/main/resources/static/js/api.js
  26. 10
      src/main/resources/static/js/app.js

27
CLAUDE.md

@ -25,7 +25,7 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
./mvnw test -Dtest=SupportBotApplicationTests#testRag
```
**前提条件**: PostgreSQL 12+ 需运行且安装 PGVector 扩展,数据库 `support_bot` 需存在。`knowledge_category``knowledge_document` 表由 `DatabaseInitConfig` 自动创建,无需手动建表。
**前提条件**: PostgreSQL 12+ 需运行且安装 PGVector 扩展,数据库 `support_bot` 需存在。`knowledge_category`、`knowledge_document`、`ai_model_config` 等表由 `DatabaseInitConfig` 自动创建,无需手动建表。
**测试说明**: 所有测试均为集成测试(`@SpringBootTest`),需要运行中的 PostgreSQL 和有效的 DashScope API Key。测试类:`SupportBotApplicationTests`(对话/RAG)、`PgVectorVectorStoreConfigTest`(向量存储)、`QueryTransformerTests`(查询重写策略)。无单元测试。
@ -66,10 +66,23 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true
- **上传校验**: `ALLOWED_EXTENSIONS` 白名单 + 50MB 大小限制(`spring.servlet.multipart` 配置),前后端双重校验
- **文档去重**: `KnowledgeDocument.contentHash` 字段(SHA-256),上传时自动计算并查重
- **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document` 表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准
### 依赖版本注意
Spring AI 相关依赖版本混合:`spring-ai-alibaba-starter` 1.0.0-M6.1、`spring-ai-pgvector-store` 1.0.0-M6、`spring-ai-tika-document-reader` 1.0.0(正式版),可能存在 API 不兼容风险。pom.xml 中注释掉了 `spring-ai-starter-vector-store-pgvector` 1.0.0-M7(自动整合版本,未启用)。
- **数据库自动初始化**: `DatabaseInitConfig` 在启动时检查并创建 `knowledge_category`/`knowledge_document`/`ai_model_config` 等表,对已存在的 `knowledge_document` 表会自动补加 `content_hash` 列。注意 `knowledge-base.sql` 脚本为早期版本,缺少此列,实际以 `DatabaseInitConfig` 为准
### 模型配置管理
- **ai_model_config 表**: 存储大模型配置,支持多套配置按 App 类型(CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE)独立管理
- **激活互斥**: 同一 App 类型只能有一个 `is_active=true` 的配置,激活操作由 Service 层 `@Transactional` 保证
- **启动 seed**: 首次启动时自动从 `application.yml` 读取当前配置写入 DB 作为默认数据
- **启动校验**: `ModelConfigLoader` 在应用就绪后比较 DB 活跃配置与 yml 配置,不一致时打印 WARNING 日志
- **API Key 脱敏**: 前端展示时只显示前 4 位 + `****` + 后 4 位
- **运行时切换**: 通过 `ChatModelFactory` 按 DB 活跃配置动态创建/缓存 ChatModel,配置变更时立即生效(无需重启)
- **多提供商支持**: DashScope(通义千问)+ OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI),通过 `spring-ai-openai` + 自定义 `baseUrl` 接入
- **缓存刷新**: 配置增删改激活时 Controller 自动调用 `ChatModelFactory.clearCache()` + `AssistantApp.clearCache()`
### 依赖版本
- Spring AI BOM: `1.0.1`,统一管理所有 `org.springframework.ai` 依赖版本
- `spring-ai-alibaba-starter-dashscope`: `1.0.0.4`(新版 starter,替代老版 `spring-ai-alibaba-starter` M6.1)
- `spring-ai-openai`: BOM 管理(OpenAI 兼容提供商支持)
- `spring-ai-alibaba-starter` (M6.1) 已移除,不再使用
## 前端架构
@ -84,6 +97,7 @@ Spring AI 相关依赖版本混合:`spring-ai-alibaba-starter` 1.0.0-M6.1、`s
## API 路由约定
- AI 对话: `/ai/*`(`AiController`)
- 模型配置: `/model-config/*`(`AiModelConfigController`)
- 文档上传: `/upload/*`(`DocumentController`)
- 文档管理: `/document/*`(`DocumentController`)
- 批量操作: `/document/batch/*`(`DocumentController`,用 POST 避免 DELETE+RequestBody 路径冲突)
@ -93,6 +107,7 @@ Spring AI 相关依赖版本混合:`spring-ai-alibaba-starter` 1.0.0-M6.1、`s
- `AssistantApp.doChatWithRagEnhance()`: `queryTransformers` 未生效
- `DocumentService.updateDocumentMetadata()`: Spring AI 无直接更新 vector_store metadata 的 API,向量元数据同步留后续
- `DocumentService.searchDocuments()`: Spring AI 1.0.0-M6 的 filter 支持有限,分类过滤暂未实现
- `DocumentService.searchDocuments()`: Spring AI 1.0.1 的 filter 支持有限,分类过滤暂未实现
- `CompressionQueryRewriter`: 当前传入空历史列表
- MyBatis Plus 3.5.12 的 `mybatis-plus-spring-boot3-starter` 不含 `PaginationInnerInterceptor`,分页通过 SQL `LIMIT/OFFSET` 手动实现
- `ChatModelFactory` 中 DashScope 配置始终返回同一个自动配置 Bean,不支持按 DB 中的不同模型名/温度创建独立实例

68
pom.xml

@ -6,29 +6,29 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
<relativePath/> <!-- lookup parent from repository -->
<relativePath/>
</parent>
<groupId>com.yupi</groupId>
<artifactId>yu-ai-agent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>yu-ai-agent</name>
<description>yu-ai-agent</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
<spring-ai-alibaba.version>1.0.0.4</spring-ai-alibaba.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring AI BOM:统一管理所有 Spring AI 依赖版本 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
@ -41,12 +41,19 @@
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<!-- Spring AI Alibaba -->
<!-- Spring AI Alibaba DashScope(通义千问)- 新版 starter,替代老版 spring-ai-alibaba-starter -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter</artifactId>
<version>1.0.0-M6.1</version>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
<version>${spring-ai-alibaba.version}</version>
</dependency>
<!-- Spring AI OpenAI(用于接入 OpenAI 兼容提供商:DeepSeek / Kimi / 豆包 / 智谱) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
<!--Hutool 工具库-->
<dependency>
<groupId>cn.hutool</groupId>
@ -74,7 +81,6 @@
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-markdown-document-reader</artifactId>
<version>1.0.0-M6</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
@ -100,20 +106,18 @@
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store</artifactId>
<version>1.0.0-M6</version>
</dependency>
<!-- 自动整合 PGVector 向量存储 -->
<!--<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
<version>1.0.0-M7</version>
</dependency>-->
<!-- spring-ai-tika-document-reader -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Spring AI ToolCallingAutoConfiguration 依赖 spring-security-oauth2-client,补充该依赖避免 ClassNotFoundException -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
@ -150,16 +154,4 @@
</plugin>
</plugins>
</build>
<!-- 需要引入仓库配置,才能下载到最新的 Spring AI 相关的依赖 -->
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
</project>

11
src/main/java/com/wok/supportbot/SupportBotApplication.java

@ -1,12 +1,15 @@
package com.wok.supportbot;
import org.springframework.ai.autoconfigure.vectorstore.pgvector.PgVectorStoreAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(exclude = PgVectorStoreAutoConfiguration.class)
public class
SupportBotApplication {
/**
* 主启动类
* spring-security-oauth2-client 依赖已通过 pom.xml 补充
* 确保 ToolCallingAutoConfiguration 不会抛出 ClassNotFoundException
*/
@SpringBootApplication
public class SupportBotApplication {
public static void main(String[] args) {
SpringApplication.run(SupportBotApplication.class, args);

87
src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java

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

66
src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java

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

194
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -1,8 +1,8 @@
package com.wok.supportbot.app;
import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.advisor.ReReadingAdvisor;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter;
import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
@ -11,16 +11,12 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.chat.client.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.rag.preretrieval.query.expansion.MultiQueryExpander;
import org.springframework.ai.rag.preretrieval.query.transformation.QueryTransformer;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
@ -29,65 +25,68 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;
import java.util.concurrent.ConcurrentHashMap;
/**
* @Classname AssistantApp
* @Description
* @Version 1.0.0
* @Date 2025/06/27 14:11
* @Author lyx
* 智能客服应用 - 支持多提供商动态切换
* 通过 ChatModelFactory DB 活跃配置获取 ChatModel
*/
@Component
@Slf4j
public class AssistantApp {
/** ChatMemory context 参数 key(与 BaseChatMemoryAdvisor.getConversationId 内部一致) */
private static final String CHAT_MEMORY_CONVERSATION_ID_KEY = "chat_memory_conversation_id";
private static final String CHAT_MEMORY_RETRIEVE_SIZE_KEY = "chat_memory_retrieve_size";
@Resource
private VectorStore pgVectorVectorStore;
private final ChatClient chatClient;
private final ChatModelFactory chatModelFactory;
private final DatabaseChatMemory chatMemory;
/** ChatClient 缓存:key = appType,避免每次调用重复构建 */
private final ConcurrentHashMap<String, ChatClient> chatClientCache = new ConcurrentHashMap<>();
private static final String SYSTEM_PROMPT = "你是一名智能客服助手,负责解答用户问题。" +
"请主动引导用户提供关键信息,并尽量在不转人工的情况下解决问题。保持专业、耐心、礼貌。";
public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) {
this.chatModelFactory = chatModelFactory;
this.chatMemory = chatMemory;
}
/**
* 初始化 ChatClient
*
* @param dashscopeChatModel
* 获取指定应用类型的 ChatClient带缓存
*/
public AssistantApp(ChatModel dashscopeChatModel, DatabaseChatMemory chatMemory) {
// 初始化基于文件的对话记忆
//String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
//ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
// 初始化基于内存的对话记忆
// ChatMemory chatMemory = new InMemoryChatMemory();
chatClient = ChatClient.builder(dashscopeChatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory),
// 自定义日志 Advisor可按需开启
new MyLoggerAdvisor()
// 自定义推理增强 Advisor可按需开启
//,new ReReadingAdvisor()
)
.build();
private ChatClient getChatClient(String appType) {
return chatClientCache.computeIfAbsent(appType, type -> {
ChatModel chatModel = chatModelFactory.getChatModel(type);
return ChatClient.builder(chatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new MyLoggerAdvisor()
)
.build();
});
}
/**
* 清除 ChatClient 缓存配置变更时调用
*/
public void clearCache() {
chatClientCache.clear();
log.info("AssistantApp ChatClient 缓存已清除");
}
/**
* AI 基础对话支持多轮对话记忆
*
* @param message
* @param chatId
* @return
*/
public String doChat(String message, String chatId) {
ChatResponse chatResponse = chatClient
ChatResponse chatResponse = getChatClient("CHAT")
.prompt()
.user(message)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
@ -99,13 +98,9 @@ public class AssistantApp {
/**
* AI 基础对话支持多轮对话记忆SSE 流式传输
*
* @param message
* @param chatId
* @return
*/
public Flux<String> doChatByStream(String message, String chatId) {
return chatClient
return getChatClient("CHAT")
.prompt()
.user(message)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
@ -114,7 +109,6 @@ public class AssistantApp {
.content();
}
// AI 恋爱知识库问答功能
@Resource
RewriteQueryRewriter rewriteQueryRewriter;
@Resource
@ -124,29 +118,20 @@ public class AssistantApp {
@Resource
TranslationQueryRewriter translationQueryRewriter;
/**
* RAG 知识库进行对话
*
* @param message
* @param chatId
* @return
*/
public String doChatWithRag(String message, String chatId) {
// 在预检索阶段系统接收用户的原始查询通过查询转换和查询扩展等方法对其进行优化输出增强的用户查询
// String rewrittenMessage = translationQueryRewriter.doQueryRewrite(message);
String rewrittenMessage = rewriteQueryRewriter.doQueryRewrite(message);
ChatResponse chatResponse = chatClient
var ragAdvisor = buildRagAdvisor(4, Collections.emptyList());
ChatResponse chatResponse = getChatClient("CHAT")
.prompt()
.user(rewrittenMessage)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
// 应用 RAG 知识库问答
.advisors(QuestionAnswerAdvisor.builder(pgVectorVectorStore)
// 相似度阈值为 0.0并返回最相关的前 4 个结果
.searchRequest(buildRagSearchRequest(4, Collections.emptyList()))
.build())
.advisors(ragAdvisor)
.call()
.chatResponse();
return chatResponse.getResult().getOutput().getText();
@ -154,11 +139,6 @@ public class AssistantApp {
/**
* RAG 知识库进行对话支持动态选择查询重写策略
*
* @param message 用户消息
* @param chatId 会话ID
* @param strategy 查询重写策略NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY
* @return AI 回答
*/
public String doChatWithRagStrategy(String message, String chatId, String strategy) {
return doChatWithRagStrategy(message, chatId, strategy, Collections.emptyList());
@ -170,15 +150,11 @@ public class AssistantApp {
}
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds) {
// 对于 MULTI_QUERY 策略需要使用特殊的处理方式
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRag(message, chatId, categoryIds);
}
// 其他策略单查询处理
String rewrittenMessage = message;
// 根据策略选择对应的 Query Rewriter
if (strategy != null && !strategy.isEmpty()) {
switch (strategy.toUpperCase()) {
case "REWRITE":
@ -188,8 +164,6 @@ public class AssistantApp {
rewrittenMessage = translationQueryRewriter.doQueryRewrite(message);
break;
case "COMPRESSION":
// 查询压缩需要对话历史这里传入空列表简化处理
// 如果需要利用多轮对话上下文应该从 chatMemory 中获取历史消息
rewrittenMessage = compressionQueryRewriter.doQueryRewrite(message, java.util.Collections.emptyList());
break;
case "NONE":
@ -199,16 +173,14 @@ public class AssistantApp {
}
}
ChatResponse chatResponse = chatClient
var ragAdvisor = buildRagAdvisor(4, categoryIds);
ChatResponse chatResponse = getChatClient("CHAT")
.prompt()
.user(rewrittenMessage)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
// 应用 RAG 知识库问答
.advisors(QuestionAnswerAdvisor.builder(pgVectorVectorStore)
// 相似度阈值为 0.0并返回最相关的前 4 个结果
.searchRequest(buildRagSearchRequest(4, categoryIds))
.build())
.advisors(ragAdvisor)
.call()
.chatResponse();
return chatResponse.getResult().getOutput().getText();
@ -216,38 +188,50 @@ public class AssistantApp {
/**
* 使用多路查询扩展的 RAG 对话
* 将原始查询扩展为多个语义不同的查询分别检索后合并结果
*
* @param message 用户消息
* @param chatId 会话ID
* @return AI 回答
*/
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds) {
// 执行多路查询扩展得到多个查询文本
List<String> expandedQueries = multiQueryExpanderRewriter.doQueryRewrite(message);
log.info("多路查询扩展结果: {}", expandedQueries);
// 对每个扩展后的查询执行向量检索收集所有文档
// 这里我们使用第一个查询作为主查询进行 RAG 对话
// 更复杂的实现可以合并多个查询的检索结果
String primaryQuery = expandedQueries.isEmpty() ? message : expandedQueries.get(0);
ChatResponse chatResponse = chatClient
var ragAdvisor = buildRagAdvisor(8, categoryIds);
ChatResponse chatResponse = getChatClient("CHAT")
.prompt()
.user(primaryQuery)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
// 应用 RAG 知识库问答
.advisors(QuestionAnswerAdvisor.builder(pgVectorVectorStore)
// 多路查询时增加 topK 以获取更多相关文档
.searchRequest(buildRagSearchRequest(8, categoryIds))
.build())
.advisors(ragAdvisor)
.call()
.chatResponse();
return chatResponse.getResult().getOutput().getText();
}
/**
* 构建 RAG 检索增强 Advisor
*/
private RetrievalAugmentationAdvisor buildRagAdvisor(int topK, List<Long> categoryIds) {
var retrieverBuilder = VectorStoreDocumentRetriever.builder()
.vectorStore(pgVectorVectorStore)
.similarityThreshold(0.0)
.topK(topK);
List<Object> values = normalizeCategoryIds(categoryIds).stream()
.map(value -> (Object) value)
.toList();
if (!values.isEmpty()) {
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
retrieverBuilder.filterExpression(filterBuilder.in("categoryId", values).build());
}
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(retrieverBuilder.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.build())
.build();
}
private SearchRequest buildRagSearchRequest(int topK, Long categoryId) {
List<Long> categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList();
return buildRagSearchRequest(topK, categoryIds);
@ -286,33 +270,25 @@ public class AssistantApp {
/**
* RAG 知识库进行对话(另外一种使用方式)
*
* @param message
* @param chatId
* @return
*/
public String doChatWithRagEnhance(String message, String chatId) {
Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
// todo 不生效
//.queryTransformers(queryTransformers)
//.queryExpander(multiQueryExpander)
var ragAdvisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(pgVectorVectorStore)
.similarityThreshold(0.5)
.topK(4)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false) // 不允许模型在没有找到相关文档的情况下也生成回答
.allowEmptyContext(false)
.build())
.build();
ChatResponse chatResponse = chatClient
ChatResponse chatResponse = getChatClient("CHAT")
.prompt()
.user(message)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
// 应用 RAG 知识库问答
.advisors(retrievalAugmentationAdvisor)
.advisors(ragAdvisor)
.call()
.chatResponse();
return chatResponse.getResult().getOutput().getText();

40
src/main/java/com/wok/supportbot/app/ProductInfoApp.java

@ -1,45 +1,31 @@
package com.wok.supportbot.app;
import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.entity.ProductInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemory;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.stereotype.Component;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;
import static org.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_RETRIEVE_SIZE_KEY;
/**
* @Classname ProductInfoApp
* @Description 电商商品信息抽取助手App
* @Version 1.0.0
* @Date 2025/06/27
* @Author lyx
* 电商商品信息抽取助手App - 支持多提供商动态切换
*/
@Component
@Slf4j
public class ProductInfoApp {
private final ChatClient chatClient;
private final ChatModelFactory chatModelFactory;
private static final String SYSTEM_PROMPT = "你是一名电商商品信息抽取助手," +
"请从用户提供的商品网页内容中提取标题(title)、描述(description)、价格(price)、评分(rating)、评论数(reviewCount)、品牌(brand)、分类(category)等字段。" +
"请严格按照JSON格式返回,不要带任何解释和多余内容。";
public ProductInfoApp(ChatModel dashscopeChatModel) {
ChatMemory chatMemory = new InMemoryChatMemory();
chatClient = ChatClient.builder(dashscopeChatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
new MessageChatMemoryAdvisor(chatMemory),
// 可以按需添加日志或其它advisor
new MyLoggerAdvisor()
)
.build();
public ProductInfoApp(ChatModelFactory chatModelFactory) {
this.chatModelFactory = chatModelFactory;
}
/**
@ -48,6 +34,17 @@ public class ProductInfoApp {
* @return 结构化的商品信息对象
*/
public ProductInfo extractProductInfo(String rawContent) {
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.chatMemoryRepository(new InMemoryChatMemoryRepository())
.build();
ChatClient chatClient = ChatClient.builder(chatModelFactory.getChatModel("PRODUCT_EXTRACT"))
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new MyLoggerAdvisor()
)
.build();
ProductInfo productInfo = chatClient
.prompt()
.system(SYSTEM_PROMPT)
@ -55,7 +52,6 @@ public class ProductInfoApp {
.call()
.entity(ProductInfo.class);
log.info("Extracted product info: {}", productInfo);
// todo 保存到数据库
return productInfo;
}
}

19
src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java

@ -14,6 +14,10 @@ import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 基于 PostgreSQL 的对话记忆持久化
* 适配 Spring AI 1.0.1 ChatMemory 接口
*/
@Component
@RequiredArgsConstructor
public class DatabaseChatMemory implements ChatMemory {
@ -26,25 +30,22 @@ public class DatabaseChatMemory implements ChatMemory {
List<ChatMessage> chatMessages = messages.stream()
.map(message -> MessageConverter.toChatMessage(message, conversationId))
.collect(Collectors.toList());
chatMessageRepository.saveBatch(chatMessages, chatMessages.size());
}
@Override
public List<Message> get(String conversationId, int lastN) {
public List<Message> get(String conversationId) {
LambdaQueryWrapper<ChatMessage> queryWrapper = new LambdaQueryWrapper<>();
// 查询最近的 lastN 条消息
queryWrapper.eq(ChatMessage::getConversationId, conversationId)
.orderByDesc(ChatMessage::getCreateTime)
.last(lastN > 0, "LIMIT " + lastN);
.orderByDesc(ChatMessage::getCreateTime);
List<ChatMessage> chatMessages = chatMessageRepository.list(queryWrapper);
// 按照时间顺序返回
// 按照时间顺序返回查询是 DESC需要反转为 ASC
if (!chatMessages.isEmpty()) {
Collections.reverse(chatMessages);
}
return chatMessages
.stream()
.map(MessageConverter::toMessage)

7
src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java

@ -45,11 +45,8 @@ public class FileBasedChatMemory implements ChatMemory {
}
@Override
public List<Message> get(String conversationId, int lastN) {
List<Message> allMessages = getOrCreateConversation(conversationId);
return allMessages.stream()
.skip(Math.max(0, allMessages.size() - lastN))
.toList();
public List<Message> get(String conversationId) {
return getOrCreateConversation(conversationId);
}
@Override

136
src/main/java/com/wok/supportbot/config/ChatModelFactory.java

@ -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 缓存已清除");
}
}

107
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -3,6 +3,7 @@ package com.wok.supportbot.config;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@ -17,6 +18,19 @@ public class DatabaseInitConfig {
@Autowired
private JdbcTemplate jdbcTemplate;
/** 从 application.yml 注入当前大模型配置,用于 seed 默认数据 */
@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.chat.options.temperature:0.7}")
private Double chatTemperature;
@Value("${spring.ai.dashscope.embedding.options.model:text-embedding-v2}")
private String embeddingModelName;
@PostConstruct
public void init() {
try {
@ -59,6 +73,15 @@ public class DatabaseInitConfig {
}
seedDefaultCustomerServiceRoles();
// 检查 ai_model_config 表是否存在
boolean aiModelConfigTableExists = checkTableExists("ai_model_config");
if (!aiModelConfigTableExists) {
log.info("创建 AI 模型配置表 ai_model_config");
createAiModelConfigTable();
// seed 默认配置 application.yml 读取
seedDefaultAiModelConfigs();
}
log.info("数据库初始化完成");
} catch (Exception e) {
log.error("数据库初始化失败", e);
@ -236,4 +259,88 @@ public class DatabaseInitConfig {
log.warn("添加 content_hash 列时出错", e);
}
}
/**
* 创建 AI 模型配置表
*/
private void createAiModelConfigTable() {
String sql = """
CREATE TABLE IF NOT EXISTS ai_model_config (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
app_type VARCHAR(50) NOT NULL,
provider VARCHAR(50) NOT NULL DEFAULT 'dashscope',
api_key VARCHAR(512) NOT NULL,
model_name VARCHAR(100) NOT NULL,
temperature DOUBLE PRECISION DEFAULT 0.7,
max_tokens INTEGER DEFAULT 2000,
base_url VARCHAR(512),
extra_config JSONB DEFAULT '{}' NOT NULL,
is_active BOOLEAN DEFAULT FALSE NOT NULL,
priority INTEGER DEFAULT 0 NOT NULL,
description TEXT,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
)
""";
jdbcTemplate.execute(sql);
// 创建索引
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_ai_model_config_app_type ON ai_model_config (app_type)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_ai_model_config_is_active ON ai_model_config (is_active)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_ai_model_config_provider ON ai_model_config (provider)");
}
/**
* 种子数据 application.yml 读取当前大模型配置写入 ai_model_config
* CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE 各创建一条默认配置
*/
private void seedDefaultAiModelConfigs() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM ai_model_config WHERE is_delete = false",
Integer.class);
if (count != null && count > 0) {
return;
}
String maskedKey = dashscopeApiKey.length() > 8
? dashscopeApiKey.substring(0, 4) + "****" + dashscopeApiKey.substring(dashscopeApiKey.length() - 4)
: "****";
// CHAT - 智能客服对话默认激活
insertDefaultModelConfig("智能客服对话-默认", "CHAT", "dashscope",
dashscopeApiKey, chatModelName, chatTemperature, 2000,
true, 100, "默认对话模型配置(来自 application.yml)");
// PRODUCT_EXTRACT - 商品信息抽取
insertDefaultModelConfig("商品信息抽取-默认", "PRODUCT_EXTRACT", "dashscope",
dashscopeApiKey, chatModelName, 0.3, 2000,
true, 90, "商品信息结构化抽取模型配置");
// EMBEDDING - 向量化
insertDefaultModelConfig("文本向量化-默认", "EMBEDDING", "dashscope",
dashscopeApiKey, embeddingModelName, null, null,
true, 80, "文本向量化 Embedding 模型配置");
// RAG_REWRITE - RAG 查询重写
insertDefaultModelConfig("RAG查询重写-默认", "RAG_REWRITE", "dashscope",
dashscopeApiKey, chatModelName, 0.5, 1000,
true, 70, "RAG 预检索查询重写模型配置");
log.info("已种子化默认 AI 模型配置(API Key: {})", maskedKey);
}
/**
* 插入一条默认模型配置
*/
private void insertDefaultModelConfig(String name, String appType, String provider,
String apiKey, String modelName, Double temperature,
Integer maxTokens, boolean isActive, int priority,
String description) {
jdbcTemplate.update("""
INSERT INTO ai_model_config (name, app_type, provider, api_key, model_name, temperature, max_tokens, is_active, priority, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", name, appType, provider, apiKey, modelName, temperature, maxTokens, isActive, priority, description);
}
}

78
src/main/java/com/wok/supportbot/config/ModelConfigLoader.java

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

278
src/main/java/com/wok/supportbot/controller/AiModelConfigController.java

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

12
src/main/java/com/wok/supportbot/dao/AiModelConfigMapper.java

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

15
src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java

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

128
src/main/java/com/wok/supportbot/entity/AiModelConfig.java

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

14
src/main/java/com/wok/supportbot/rag/config/QueryExpanderConfig.java

@ -1,18 +1,26 @@
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.expansion.MultiQueryExpander;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 查询扩展器 Bean 配置
* 通过 ChatModelFactory 获取 ChatModel支持多提供商动态切换
*/
@Configuration
public class QueryExpanderConfig {
@Autowired
private ChatModelFactory chatModelFactory;
@Bean
public MultiQueryExpander multiQueryExpander(ChatModel dashscopeChatModel) {
public MultiQueryExpander multiQueryExpander() {
return MultiQueryExpander.builder()
.chatClientBuilder(ChatClient.builder(dashscopeChatModel))
.chatClientBuilder(ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE")))
.numberOfQueries(3)
.includeOriginal(true)
.build();

24
src/main/java/com/wok/supportbot/rag/config/QueryTransformerConfig.java

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

21
src/main/java/com/wok/supportbot/rag/preretrieval/CompressionQueryRewriter.java

@ -1,9 +1,8 @@
package com.wok.supportbot.rag.preretrieval;
import com.wok.supportbot.config.ChatModelFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.preretrieval.query.transformation.CompressionQueryTransformer;
import org.springframework.ai.rag.preretrieval.query.transformation.QueryTransformer;
@ -13,31 +12,29 @@ import java.util.List;
/**
* 查询压缩器 - CompressionQueryTransformer
* 通过 ChatModelFactory 获取 ChatModel支持多提供商动态切换
*/
@Component
public class CompressionQueryRewriter {
private final QueryTransformer queryTransformer;
private final ChatModelFactory chatModelFactory;
public CompressionQueryRewriter(ChatModel dashscopeChatModel) {
ChatClient.Builder builder = ChatClient.builder(dashscopeChatModel);
queryTransformer = CompressionQueryTransformer.builder()
.chatClientBuilder(builder)
.build();
public CompressionQueryRewriter(ChatModelFactory chatModelFactory) {
this.chatModelFactory = chatModelFactory;
}
/**
* 执行查询压缩带对话历史
*
* @param prompt 当前查询文本
* @return 压缩后的查询文本
*/
public String doQueryRewrite(String prompt, List<Message> history) {
ChatClient.Builder builder = ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE"));
QueryTransformer queryTransformer = CompressionQueryTransformer.builder()
.chatClientBuilder(builder)
.build();
Query query = Query.builder()
.text(prompt)
.history(history)
.build();
Query transformed = queryTransformer.transform(query);
return transformed.text();
}

23
src/main/java/com/wok/supportbot/rag/preretrieval/MultiQueryExpanderRewriter.java

@ -1,7 +1,7 @@
package com.wok.supportbot.rag.preretrieval;
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.Query;
import org.springframework.ai.rag.preretrieval.query.expansion.MultiQueryExpander;
import org.springframework.stereotype.Component;
@ -11,28 +11,27 @@ import java.util.stream.Collectors;
/**
* 多查询扩展器 - MultiQueryExpander
* 通过 ChatModelFactory 获取 ChatModel支持多提供商动态切换
*/
@Component
public class MultiQueryExpanderRewriter {
private final MultiQueryExpander queryExpander;
private final ChatModelFactory chatModelFactory;
public MultiQueryExpanderRewriter(ChatModel dashscopeChatModel) {
ChatClient.Builder builder = ChatClient.builder(dashscopeChatModel);
queryExpander = MultiQueryExpander.builder()
.chatClientBuilder(builder)
.numberOfQueries(3)
.includeOriginal(true) //在扩展查询列表中包含原始查询
.build();
public MultiQueryExpanderRewriter(ChatModelFactory chatModelFactory) {
this.chatModelFactory = chatModelFactory;
}
/**
* 执行查询扩展返回多个查询文本
*
* @param prompt 原始查询
* @return 多个语义不同的查询文本列表
*/
public List<String> doQueryRewrite(String prompt) {
ChatClient.Builder builder = ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE"));
MultiQueryExpander queryExpander = MultiQueryExpander.builder()
.chatClientBuilder(builder)
.numberOfQueries(3)
.includeOriginal(true)
.build();
List<Query> queries = queryExpander.expand(new Query(prompt));
return queries.stream()
.map(Query::text)

22
src/main/java/com/wok/supportbot/rag/preretrieval/RewriteQueryRewriter.java

@ -1,7 +1,7 @@
package com.wok.supportbot.rag.preretrieval;
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.Query;
import org.springframework.ai.rag.preretrieval.query.transformation.QueryTransformer;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
@ -9,31 +9,27 @@ import org.springframework.stereotype.Component;
/**
* 查询重写器 - RewriteQueryTransformer
* 通过 ChatModelFactory 获取 ChatModel支持多提供商动态切换
*/
@Component
public class RewriteQueryRewriter {
private final QueryTransformer queryTransformer;
private final ChatModelFactory chatModelFactory;
public RewriteQueryRewriter(ChatModel dashscopeChatModel) {
ChatClient.Builder builder = ChatClient.builder(dashscopeChatModel);
// 创建查询重写转换器
queryTransformer = RewriteQueryTransformer.builder()
.chatClientBuilder(builder)
.build();
public RewriteQueryRewriter(ChatModelFactory chatModelFactory) {
this.chatModelFactory = chatModelFactory;
}
/**
* 执行查询重写
*
* @param prompt
* @return
*/
public String doQueryRewrite(String prompt) {
ChatClient.Builder builder = ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE"));
QueryTransformer queryTransformer = RewriteQueryTransformer.builder()
.chatClientBuilder(builder)
.build();
Query query = new Query(prompt);
// 执行查询重写
Query transformedQuery = queryTransformer.transform(query);
// 输出重写后的查询
return transformedQuery.text();
}
}

21
src/main/java/com/wok/supportbot/rag/preretrieval/TranslationQueryRewriter.java

@ -1,7 +1,7 @@
package com.wok.supportbot.rag.preretrieval;
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.Query;
import org.springframework.ai.rag.preretrieval.query.transformation.QueryTransformer;
import org.springframework.ai.rag.preretrieval.query.transformation.TranslationQueryTransformer;
@ -9,27 +9,26 @@ import org.springframework.stereotype.Component;
/**
* 查询翻译器 - TranslationQueryTransformer
* 通过 ChatModelFactory 获取 ChatModel支持多提供商动态切换
*/
@Component
public class TranslationQueryRewriter {
private final QueryTransformer queryTransformer;
private final ChatModelFactory chatModelFactory;
public TranslationQueryRewriter(ChatModel dashscopeChatModel) {
ChatClient.Builder builder = ChatClient.builder(dashscopeChatModel);
queryTransformer = TranslationQueryTransformer.builder()
.chatClientBuilder(builder)
.targetLanguage("chinese")
.build();
public TranslationQueryRewriter(ChatModelFactory chatModelFactory) {
this.chatModelFactory = chatModelFactory;
}
/**
* 执行查询翻译
*
* @param prompt 原始查询文本
* @return 翻译后的查询文本
*/
public String doQueryRewrite(String prompt) {
ChatClient.Builder builder = ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE"));
QueryTransformer queryTransformer = TranslationQueryTransformer.builder()
.chatClientBuilder(builder)
.targetLanguage("chinese")
.build();
Query query = new Query(prompt);
Query transformedQuery = queryTransformer.transform(query);
return transformedQuery.text();

268
src/main/java/com/wok/supportbot/service/AiModelConfigService.java

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

496
src/main/resources/static/components/ModelConfigManager.js

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

53
src/main/resources/static/js/api.js

@ -395,3 +395,56 @@ export function exportConversation(conversationId) {
export function getConversationStats() {
return getJSON('/conversation/stats')
}
// ==================== 模型配置管理 ====================
/**
* 模型配置列表分页
*/
export function listModelConfigs(page = 1, size = 10, appType) {
let path = `/model-config/list?page=${page}&size=${size}`
if (appType) path += `&appType=${encodeURIComponent(appType)}`
return getJSON(path)
}
/**
* 模型配置详情
*/
export function getModelConfigDetail(id) {
return getJSON(`/model-config/${id}`)
}
/**
* 获取指定类型的活跃模型配置
*/
export function getActiveModelConfig(appType) {
return getJSON(`/model-config/active/${encodeURIComponent(appType)}`)
}
/**
* 新建模型配置
*/
export function createModelConfig(data) {
return postJSON('/model-config', data)
}
/**
* 更新模型配置
*/
export function updateModelConfig(id, data) {
return putJSONWithBody(`/model-config/${id}`, data)
}
/**
* 激活模型配置
*/
export function activateModelConfig(id) {
return putJSON(`/model-config/${id}/activate`)
}
/**
* 删除模型配置
*/
export function deleteModelConfig(id) {
return deleteJSON(`/model-config/${id}`)
}

10
src/main/resources/static/js/app.js

@ -15,6 +15,7 @@ import DocList from '../components/DocList.js'
import DocUpload from '../components/DocUpload.js'
import DocDetail from '../components/DocDetail.js'
import ConversationManager from '../components/ConversationManager.js'
import ModelConfigManager from '../components/ModelConfigManager.js'
const app = createApp({
setup() {
@ -54,6 +55,9 @@ const app = createApp({
<button :class="['tab-btn', activeTab === 'conversation' ? 'active' : '']" @click="switchTab('conversation')">
<span class="tab-icon">💬</span>
</button>
<button :class="['tab-btn', activeTab === 'modelConfig' ? 'active' : '']" @click="switchTab('modelConfig')">
<span class="tab-icon"></span>
</button>
</div>
<!-- 内容区 -->
@ -82,6 +86,11 @@ const app = createApp({
<conversation-manager></conversation-manager>
</div>
<!-- Tab 5: 模型配置管理 -->
<div v-if="activeTab === 'modelConfig'" style="animation: fadeIn .3s ease;">
<model-config-manager></model-config-manager>
</div>
<!-- 文档详情弹窗 -->
<doc-detail></doc-detail>
</div>
@ -101,5 +110,6 @@ app.component('doc-list', DocList)
app.component('doc-upload', DocUpload)
app.component('doc-detail', DocDetail)
app.component('conversation-manager', ConversationManager)
app.component('model-config-manager', ModelConfigManager)
app.mount('#app')
Loading…
Cancel
Save