From f79350c36322bd93b28f31f04d03bdbfeb54fa2b Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Thu, 25 Jun 2026 14:10:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=EF=BC=88?= =?UTF-8?q?5=E5=AE=B6AI=E6=8F=90=E4=BE=9B=E5=95=86=E9=9B=86=E6=88=90+?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=8A=A8=E6=80=81=E5=88=87=E6=8D=A2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 27 +- pom.xml | 68 ++- .../wok/supportbot/SupportBotApplication.java | 11 +- .../supportbot/advisor/MyLoggerAdvisor.java | 87 ++- .../supportbot/advisor/ReReadingAdvisor.java | 66 +-- .../com/wok/supportbot/app/AssistantApp.java | 194 +++---- .../wok/supportbot/app/ProductInfoApp.java | 40 +- .../chatmemory/DatabaseChatMemory.java | 19 +- .../chatmemory/FileBasedChatMemory.java | 7 +- .../supportbot/config/ChatModelFactory.java | 136 +++++ .../supportbot/config/DatabaseInitConfig.java | 107 ++++ .../supportbot/config/ModelConfigLoader.java | 78 +++ .../controller/AiModelConfigController.java | 278 ++++++++++ .../supportbot/dao/AiModelConfigMapper.java | 12 + .../document/transform/MyKeywordEnricher.java | 15 +- .../wok/supportbot/entity/AiModelConfig.java | 128 +++++ .../rag/config/QueryExpanderConfig.java | 14 +- .../rag/config/QueryTransformerConfig.java | 24 +- .../CompressionQueryRewriter.java | 21 +- .../MultiQueryExpanderRewriter.java | 23 +- .../preretrieval/RewriteQueryRewriter.java | 22 +- .../TranslationQueryRewriter.java | 21 +- .../service/AiModelConfigService.java | 268 ++++++++++ .../static/components/ModelConfigManager.js | 496 ++++++++++++++++++ src/main/resources/static/js/api.js | 53 ++ src/main/resources/static/js/app.js | 10 + 26 files changed, 1876 insertions(+), 349 deletions(-) create mode 100644 src/main/java/com/wok/supportbot/config/ChatModelFactory.java create mode 100644 src/main/java/com/wok/supportbot/config/ModelConfigLoader.java create mode 100644 src/main/java/com/wok/supportbot/controller/AiModelConfigController.java create mode 100644 src/main/java/com/wok/supportbot/dao/AiModelConfigMapper.java create mode 100644 src/main/java/com/wok/supportbot/entity/AiModelConfig.java create mode 100644 src/main/java/com/wok/supportbot/service/AiModelConfigService.java create mode 100644 src/main/resources/static/components/ModelConfigManager.js diff --git a/CLAUDE.md b/CLAUDE.md index 333ad2b..aba1ab1 100644 --- a/CLAUDE.md +++ b/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 中的不同模型名/温度创建独立实例 diff --git a/pom.xml b/pom.xml index 55c6202..c7ec8f8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,29 +6,29 @@ org.springframework.boot spring-boot-starter-parent 3.4.4 - + com.yupi yu-ai-agent 0.0.1-SNAPSHOT yu-ai-agent yu-ai-agent - - - - - - - - - - - - - 17 + 1.0.0.4 + + + + + org.springframework.ai + spring-ai-bom + 1.0.1 + pom + import + + + org.springframework.boot @@ -41,12 +41,19 @@ spring-boot-starter-logging - + com.alibaba.cloud.ai - spring-ai-alibaba-starter - 1.0.0-M6.1 + spring-ai-alibaba-starter-dashscope + ${spring-ai-alibaba.version} + + + + org.springframework.ai + spring-ai-openai + + cn.hutool @@ -74,7 +81,6 @@ org.springframework.ai spring-ai-markdown-document-reader - 1.0.0-M6 org.projectlombok @@ -100,20 +106,18 @@ org.springframework.ai spring-ai-pgvector-store - 1.0.0-M6 - - org.springframework.ai spring-ai-tika-document-reader - 1.0.0 + + + org.springframework.security + spring-security-oauth2-client + + com.baomidou mybatis-plus-spring-boot3-starter @@ -150,16 +154,4 @@ - - - - - spring-milestones - Spring Milestones - https://repo.spring.io/milestone - - false - - - diff --git a/src/main/java/com/wok/supportbot/SupportBotApplication.java b/src/main/java/com/wok/supportbot/SupportBotApplication.java index 3757811..87771ef 100644 --- a/src/main/java/com/wok/supportbot/SupportBotApplication.java +++ b/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); diff --git a/src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java b/src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java index ccb7105..d3167ed 100644 --- a/src/main/java/com/wok/supportbot/advisor/MyLoggerAdvisor.java +++ b/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 aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) { - - advisedRequest = before(advisedRequest); - - Flux 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; + } } diff --git a/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java b/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java index 2c3e8ed..3bff56a 100644 --- a/src/main/java/com/wok/supportbot/advisor/ReReadingAdvisor.java +++ b/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 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 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 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; + } } diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java index 6469368..b32431c 100644 --- a/src/main/java/com/wok/supportbot/app/AssistantApp.java +++ b/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 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 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 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 categoryIds) { - // 执行多路查询扩展,得到多个查询文本 List 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 categoryIds) { + var retrieverBuilder = VectorStoreDocumentRetriever.builder() + .vectorStore(pgVectorVectorStore) + .similarityThreshold(0.0) + .topK(topK); + + List 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 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(); diff --git a/src/main/java/com/wok/supportbot/app/ProductInfoApp.java b/src/main/java/com/wok/supportbot/app/ProductInfoApp.java index c04526f..4289a95 100644 --- a/src/main/java/com/wok/supportbot/app/ProductInfoApp.java +++ b/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; } } diff --git a/src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java b/src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java index 145c334..c1047de 100644 --- a/src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java +++ b/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 chatMessages = messages.stream() .map(message -> MessageConverter.toChatMessage(message, conversationId)) .collect(Collectors.toList()); - chatMessageRepository.saveBatch(chatMessages, chatMessages.size()); } @Override - public List get(String conversationId, int lastN) { + public List get(String conversationId) { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); - // 查询最近的 lastN 条消息 queryWrapper.eq(ChatMessage::getConversationId, conversationId) - .orderByDesc(ChatMessage::getCreateTime) - .last(lastN > 0, "LIMIT " + lastN); - + .orderByDesc(ChatMessage::getCreateTime); + List chatMessages = chatMessageRepository.list(queryWrapper); - - // 按照时间顺序返回 + + // 按照时间顺序返回(查询是 DESC,需要反转为 ASC) if (!chatMessages.isEmpty()) { Collections.reverse(chatMessages); } - + return chatMessages .stream() .map(MessageConverter::toMessage) diff --git a/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java b/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java index 1276726..8fefb82 100644 --- a/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java +++ b/src/main/java/com/wok/supportbot/chatmemory/FileBasedChatMemory.java @@ -45,11 +45,8 @@ public class FileBasedChatMemory implements ChatMemory { } @Override - public List get(String conversationId, int lastN) { - List allMessages = getOrCreateConversation(conversationId); - return allMessages.stream() - .skip(Math.max(0, allMessages.size() - lastN)) - .toList(); + public List get(String conversationId) { + return getOrCreateConversation(conversationId); } @Override diff --git a/src/main/java/com/wok/supportbot/config/ChatModelFactory.java b/src/main/java/com/wok/supportbot/config/ChatModelFactory.java new file mode 100644 index 0000000..1598e64 --- /dev/null +++ b/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 chatModelCache = new ConcurrentHashMap<>(); + + /** + * 各提供商默认的 API 基础地址 + */ + private static final Map 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 缓存已清除"); + } +} diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 8be91bd..0fa075b 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/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); + } } diff --git a/src/main/java/com/wok/supportbot/config/ModelConfigLoader.java b/src/main/java/com/wok/supportbot/config/ModelConfigLoader.java new file mode 100644 index 0000000..9e0ab6b --- /dev/null +++ b/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 { + + @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()); + } + } +} diff --git a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java new file mode 100644 index 0000000..4037c04 --- /dev/null +++ b/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> listConfigs( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String appType) { + try { + Map result = aiModelConfigService.listConfigs(appType, page, size); + Map 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> 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> 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> 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> 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> 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> 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(); + } +} diff --git a/src/main/java/com/wok/supportbot/dao/AiModelConfigMapper.java b/src/main/java/com/wok/supportbot/dao/AiModelConfigMapper.java new file mode 100644 index 0000000..bbf7fa0 --- /dev/null +++ b/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 { +} diff --git a/src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java b/src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java index 0c14c13..fcf6aa5 100644 --- a/src/main/java/com/wok/supportbot/document/transform/MyKeywordEnricher.java +++ b/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 enrichDocuments(List 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); } } diff --git a/src/main/java/com/wok/supportbot/entity/AiModelConfig.java b/src/main/java/com/wok/supportbot/entity/AiModelConfig.java new file mode 100644 index 0000000..2364b14 --- /dev/null +++ b/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 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; +} diff --git a/src/main/java/com/wok/supportbot/rag/config/QueryExpanderConfig.java b/src/main/java/com/wok/supportbot/rag/config/QueryExpanderConfig.java index d78ef2b..df0a2a6 100644 --- a/src/main/java/com/wok/supportbot/rag/config/QueryExpanderConfig.java +++ b/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(); diff --git a/src/main/java/com/wok/supportbot/rag/config/QueryTransformerConfig.java b/src/main/java/com/wok/supportbot/rag/config/QueryTransformerConfig.java index 79645b2..ea42fb3 100644 --- a/src/main/java/com/wok/supportbot/rag/config/QueryTransformerConfig.java +++ b/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(); } } diff --git a/src/main/java/com/wok/supportbot/rag/preretrieval/CompressionQueryRewriter.java b/src/main/java/com/wok/supportbot/rag/preretrieval/CompressionQueryRewriter.java index 74b3dae..60fbd08 100644 --- a/src/main/java/com/wok/supportbot/rag/preretrieval/CompressionQueryRewriter.java +++ b/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 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(); } diff --git a/src/main/java/com/wok/supportbot/rag/preretrieval/MultiQueryExpanderRewriter.java b/src/main/java/com/wok/supportbot/rag/preretrieval/MultiQueryExpanderRewriter.java index 57036d9..4d2836d 100644 --- a/src/main/java/com/wok/supportbot/rag/preretrieval/MultiQueryExpanderRewriter.java +++ b/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 doQueryRewrite(String prompt) { + ChatClient.Builder builder = ChatClient.builder(chatModelFactory.getChatModel("RAG_REWRITE")); + MultiQueryExpander queryExpander = MultiQueryExpander.builder() + .chatClientBuilder(builder) + .numberOfQueries(3) + .includeOriginal(true) + .build(); List queries = queryExpander.expand(new Query(prompt)); return queries.stream() .map(Query::text) diff --git a/src/main/java/com/wok/supportbot/rag/preretrieval/RewriteQueryRewriter.java b/src/main/java/com/wok/supportbot/rag/preretrieval/RewriteQueryRewriter.java index 28fcd8e..3570bfe 100644 --- a/src/main/java/com/wok/supportbot/rag/preretrieval/RewriteQueryRewriter.java +++ b/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(); } } diff --git a/src/main/java/com/wok/supportbot/rag/preretrieval/TranslationQueryRewriter.java b/src/main/java/com/wok/supportbot/rag/preretrieval/TranslationQueryRewriter.java index 548386d..50b2edb 100644 --- a/src/main/java/com/wok/supportbot/rag/preretrieval/TranslationQueryRewriter.java +++ b/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(); diff --git a/src/main/java/com/wok/supportbot/service/AiModelConfigService.java b/src/main/java/com/wok/supportbot/service/AiModelConfigService.java new file mode 100644 index 0000000..55a99fa --- /dev/null +++ b/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 listConfigs(String appType, int page, int size) { + // 构建查询条件 + StringBuilder whereClause = new StringBuilder("WHERE is_delete = false "); + List 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 queryParams = new ArrayList<>(params); + queryParams.add(size); + queryParams.add((page - 1) * size); + + List> records = jdbcTemplate.queryForList(listSql, queryParams.toArray()); + + // 脱敏 API Key 并格式化结果 + List> formattedRecords = new ArrayList<>(); + for (Map record : records) { + Map 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 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 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 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 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 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); + } +} diff --git a/src/main/resources/static/components/ModelConfigManager.js b/src/main/resources/static/components/ModelConfigManager.js new file mode 100644 index 0000000..f42290f --- /dev/null +++ b/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: ` + + ⚙️ AI 大模型配置管理 + + + + + {{ opt.label }} + + ➕ 新建配置 + 🔄 刷新 + + + + + + + + 配置名称 + 应用类型 + 模型名称 + 提供商 + 温度 + API Key + 状态 + 操作 + + + + + 暂无配置记录 + + + + {{ c.name || '-' }} + {{ c.description }} + + + {{ getAppTypeLabel(c.app_type) }} + + {{ c.model_name }} + {{ getProviderLabel(c.provider) }} + {{ c.temperature != null ? c.temperature : '-' }} + {{ c.api_key || '-' }} + + + 🟢 活跃 + + + ⚫ 未激活 + + + + 编辑 + 激活 + 删除 + + + + + + + + + 上一页 + + {{ i }} + ... + + 下一页 + + + + + + + × + {{ editModal.mode === 'add' ? '➕ 新建模型配置' : '✏️ 编辑模型配置' }} + + + + + 配置名称 * + + + + + + + 应用类型 * + + {{ opt.label }} + + + + 提供商 * + + {{ opt.label }} + + + + + + + 💡 {{ providerTip }} + + + + + API Key * + + + + {{ showApiKey ? '🙈 隐藏' : '👁️ 显示' }} + + + + + + + 模型名称 * + + + {{ m }} + 自定义输入... + + + + + + + + 温度 (Temperature) + + + + 最大 Token 数 + + + + + + + API 基础地址 + + + + + + 优先级 + + + + + + + 设为活跃配置(同类型只能有一个活跃) + + + + + 描述说明 + + + + + + + 取消 + 💾 保存 + + + + `, + 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 + } + } +} diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 056bc03..29685a2 100644 --- a/src/main/resources/static/js/api.js +++ b/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}`) +} diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 314a147..1b9515d 100644 --- a/src/main/resources/static/js/app.js +++ b/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({ 💬会话管理 + + ⚙️模型配置管理 + @@ -82,6 +86,11 @@ const app = createApp({ + + + + + @@ -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')
{{ c.model_name }}
{{ c.api_key || '-' }}