diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java index 4bdf89c..76031c3 100644 --- a/src/main/java/com/wok/supportbot/app/AssistantApp.java +++ b/src/main/java/com/wok/supportbot/app/AssistantApp.java @@ -3,6 +3,7 @@ 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; @@ -36,6 +37,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID; @@ -54,10 +56,12 @@ public class AssistantApp { @Resource private VectorStore pgVectorVectorStore; - private final ChatClient chatClient; + private final ChatModelFactory chatModelFactory; private final DatabaseChatMemory chatMemory; + private final ConcurrentHashMap chatClientCache = new ConcurrentHashMap<>(); + private static final String SYSTEM_PROMPT = "你是一名智能客服助手,负责解答用户问题。" + "请主动引导用户提供关键信息,并尽量在不转人工的情况下解决问题。保持专业、耐心、礼貌。"; @@ -66,24 +70,27 @@ public class AssistantApp { * * @param dashscopeChatModel */ - public AssistantApp(ChatModel dashscopeChatModel, DatabaseChatMemory chatMemory) { - // 初始化基于文件的对话记忆 - //String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory"; - //ChatMemory chatMemory = new FileBasedChatMemory(fileDir); - // 初始化基于内存的对话记忆 - // ChatMemory chatMemory = new InMemoryChatMemory(); - + public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) { + this.chatModelFactory = chatModelFactory; this.chatMemory = chatMemory; - chatClient = ChatClient.builder(dashscopeChatModel) - .defaultSystem(SYSTEM_PROMPT) - .defaultAdvisors( - MessageChatMemoryAdvisor.builder(chatMemory).build(), - // 自定义日志 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(); + }); + } + + public void clearCache() { + chatClientCache.clear(); + log.info("AssistantApp ChatClient cache cleared"); } /** @@ -106,7 +113,7 @@ public class AssistantApp { * @return AI 回答 */ public String doChat(String message, String chatId, String systemPrompt) { - ChatClient.ChatClientRequestSpec spec = chatClient + ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT") .prompt() .user(message) .advisors(s -> s.param(CONVERSATION_ID, chatId)); @@ -148,7 +155,7 @@ public class AssistantApp { * @return 流式回答 */ public Flux doChatByStream(String message, String chatId, String systemPrompt) { - ChatClient.ChatClientRequestSpec spec = chatClient + ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT") .prompt() .user(message) .advisors(s -> s.param(CONVERSATION_ID, chatId)); @@ -181,7 +188,7 @@ public class AssistantApp { // String rewrittenMessage = translationQueryRewriter.doQueryRewrite(message); String rewrittenMessage = rewriteQueryRewriter.doQueryRewrite(message); - ChatResponse chatResponse = chatClient + ChatResponse chatResponse = getChatClient("CHAT") .prompt() .user(rewrittenMessage) .advisors(spec -> spec.param(CONVERSATION_ID, chatId)) @@ -222,7 +229,7 @@ public class AssistantApp { // 其他策略:单查询处理 String rewrittenMessage = rewriteQuery(message, chatId, strategy); - ChatClient.ChatClientRequestSpec spec = chatClient + ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT") .prompt() .user(rewrittenMessage) .advisors(s -> s.param(CONVERSATION_ID, chatId)) @@ -277,7 +284,7 @@ public class AssistantApp { String rewrittenMessage = rewriteQuery(message, chatId, strategy); - ChatClient.ChatClientRequestSpec spec = chatClient + ChatClient.ChatClientRequestSpec spec = getChatClient("CHAT") .prompt() .user(rewrittenMessage) .advisors(s -> s.param(CONVERSATION_ID, chatId)) @@ -301,7 +308,7 @@ public class AssistantApp { */ private String doChatWithMultiQueryRag(String message, String chatId, List categoryIds, String systemPrompt) { String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt); - ChatResponse chatResponse = chatClient + ChatResponse chatResponse = getChatClient("CHAT") .prompt() .system(ragSystem) .user(message) @@ -313,7 +320,7 @@ public class AssistantApp { private Flux doChatWithMultiQueryRagByStream(String message, String chatId, List categoryIds, String systemPrompt) { String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt); - return chatClient + return getChatClient("CHAT") .prompt() .system(ragSystem) .user(message) @@ -498,7 +505,7 @@ public class AssistantApp { .build()) .build(); - ChatResponse chatResponse = chatClient + ChatResponse chatResponse = getChatClient("CHAT") .prompt() .user(message) .advisors(spec -> spec.param(CONVERSATION_ID, chatId)) diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 214dabd..72c749e 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,18 @@ public class DatabaseInitConfig { @Autowired private JdbcTemplate jdbcTemplate; + @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 { @@ -75,6 +88,9 @@ public class DatabaseInitConfig { syncDefaultCustomerServiceRoles(); syncDefaultCustomerAccounts(); + createAiModelConfigTable(); + seedDefaultAiModelConfigs(); + log.info("数据库初始化完成"); } catch (Exception e) { log.error("数据库初始化失败", e); @@ -347,4 +363,65 @@ public class DatabaseInitConfig { log.warn("添加 content_hash 列时出错", e); } } + + + 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)"); + } + + 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; + } + + insertDefaultModelConfig("Chat Default", "CHAT", "dashscope", + dashscopeApiKey, chatModelName, chatTemperature, 2000, + true, 100, "Default chat model config from application.yml"); + insertDefaultModelConfig("Product Extract Default", "PRODUCT_EXTRACT", "dashscope", + dashscopeApiKey, chatModelName, 0.3, 2000, + true, 90, "Default product extraction model config"); + insertDefaultModelConfig("Embedding Default", "EMBEDDING", "dashscope", + dashscopeApiKey, embeddingModelName, null, null, + true, 80, "Default embedding model config"); + insertDefaultModelConfig("RAG Rewrite Default", "RAG_REWRITE", "dashscope", + dashscopeApiKey, chatModelName, 0.5, 1000, + true, 70, "Default RAG rewrite model config"); + } + + 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/controller/AiModelConfigController.java b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java index 7b5b457..d8519de 100644 --- a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java +++ b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java @@ -1,5 +1,6 @@ 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; @@ -22,6 +23,9 @@ public class AiModelConfigController { @Autowired private ChatModelFactory chatModelFactory; + @Autowired + private AssistantApp assistantApp; + // ==================== 分页列表 ==================== /** @@ -269,5 +273,6 @@ public class AiModelConfigController { */ private void refreshCache() { chatModelFactory.clearCache(); + assistantApp.clearCache(); } } diff --git a/src/main/resources/static/components/ChatPanel.js b/src/main/resources/static/components/ChatPanel.js index f17a934..98739f2 100644 --- a/src/main/resources/static/components/ChatPanel.js +++ b/src/main/resources/static/components/ChatPanel.js @@ -73,15 +73,28 @@ export default {

{{ currentRole.name }}

{{ ragStatusText }} - ·{{ selectedCategoryNames.length ? selectedCategoryNames.join('、') : '全部知识库' }} + ·{{ selectedCategoryNames.length ? selectedCategoryNames.join(String.fromCharCode(12289)) : '\u5168\u90e8\u77e5\u8bc6\u5e93' }}
- +
+ + + +
@@ -142,14 +155,14 @@ export default { const selectedRole = ref('general') const roles = ref([FALLBACK_ROLE]) const isRagMode = ref(false) - const ragStrategy = ref('REWRITE') + const ragStrategy = ref('MULTI_QUERY') const userInput = ref('') const lastUserInput = ref('') const isSending = ref(false) const messages = ref([ { role: 'assistant', - content: '您好,我是智能客服助手。可以咨询客服、财务、行政相关问题;如果需要基于知识库回答,请先在右侧开启 RAG 检索。', + content: '\u60a8\u597d\uff0c\u6211\u662f\u667a\u80fd\u5ba2\u670d\u52a9\u624b\u3002\u53ef\u4ee5\u54a8\u8be2\u5ba2\u670d\u3001\u8d22\u52a1\u3001\u884c\u653f\u76f8\u5173\u95ee\u9898\uff1b\u9700\u8981\u57fa\u4e8e\u77e5\u8bc6\u5e93\u56de\u7b54\u65f6\uff0c\u53ef\u4ee5\u5728\u4e0a\u65b9\u5f00\u542f RAG \u68c0\u7d22\u3002', streaming: false, time: formatTime() } @@ -192,11 +205,6 @@ export default { chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) } - // RAG 默认策略:角色有授权知识库分类时默认开启检索,客服无需手动判断(仍可手动覆盖) - function syncRagDefault() { - isRagMode.value = selectedCategoryIds.value.length > 0 - } - function selectRole(roleKey) { const role = roles.value.find(item => item.key === roleKey) // 双向绑定:选助手时自动同步到绑定该角色的账号;没有对应账号则进入"无账号"模式 @@ -205,7 +213,6 @@ export default { : null selectedAccount.value = boundAccount ? String(boundAccount.id) : '' selectedRole.value = roleKey - syncRagDefault() newChatId() clearMessages(roleKey) } @@ -216,7 +223,6 @@ export default { const role = roles.value.find(item => String(item.id) === String(account.role_id)) if (role) selectedRole.value = role.key } - syncRagDefault() newChatId() clearMessages() } @@ -252,7 +258,6 @@ export default { if (!roles.value.some(role => role.key === selectedRole.value)) { selectedRole.value = roles.value[0]?.key || 'general' } - syncRagDefault() } } catch (e) { toast('加载客服角色失败:' + e.message, 'error') diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index f833d8d..8e66e24 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -178,6 +178,10 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei" .chat-subline .dot-sep { color:var(--border-strong); } .rag-dot { width:7px; height:7px; border-radius:999px; background:var(--muted); display:inline-block; } .rag-dot.on { background:var(--success); } +.chat-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; } +.rag-toggle { height:40px; padding:0 12px; border:1px solid var(--border); border-radius:8px; display:flex; align-items:center; gap:8px; font-size:13px; font-weight:600; background:#fff; cursor:pointer; user-select:none; } +.rag-toggle input { width:16px; height:16px; accent-color:var(--primary); } +.rag-strategy { min-width:112px; font-size:13px; } .chat-mode { min-width:130px; font-size:13px; } .quick-row { padding:18px 24px 0; display:flex; gap:8px; flex-wrap:wrap; } .quick-row button { border:none; background:#f7f7f8; border-radius:999px; padding:7px 14px; font-size:13px; color:var(--sub); cursor:pointer; transition:all .15s; } @@ -213,6 +217,7 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei" } @media(max-width:640px) { .chat-header { flex-direction:column; align-items:flex-start; } + .chat-actions { width:100%; justify-content:flex-start; } .chat-msg-area .msg, .chat-msg-area .msg.user { max-width:100%; } .quick-row { padding:14px 16px 0; } .chat-msg-area { padding:16px; } diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 6db63e4..bb74ab9 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -469,8 +469,40 @@ export function getConversationStats() { return getJSON('/conversation/stats') } +// ==================== Model config management ==================== + +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}`) +} + /** - * 截断会话:删除第 userTurn 条用户消息及其之后的全部消息(编辑重发用) + * Truncate a conversation from the given user turn onward. */ export function truncateConversation(conversationId, userTurn) { return postJSON(`/conversation/${conversationId}/truncate`, { userTurn }) diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js index 1a6ad1e..937fdec 100644 --- a/src/main/resources/static/js/app.js +++ b/src/main/resources/static/js/app.js @@ -16,6 +16,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() { @@ -84,6 +85,7 @@ const app = createApp({
+
@@ -106,5 +108,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')