Browse Source

fix: restore model config flow and make RAG optional

master
pyx 2 days ago
parent
commit
d3ed72f62b
  1. 59
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  2. 77
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  3. 5
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  4. 39
      src/main/resources/static/components/ChatPanel.js
  5. 5
      src/main/resources/static/css/main.css
  6. 34
      src/main/resources/static/js/api.js
  7. 3
      src/main/resources/static/js/app.js

59
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<String, ChatClient> 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<String> 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<Long> 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<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> 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))

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

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

39
src/main/resources/static/components/ChatPanel.js

@ -73,15 +73,28 @@ export default {
<h2>{{ currentRole.name }}</h2>
<div class="chat-subline">
<span :class="['rag-dot', isRagMode ? 'on' : '']"></span>{{ ragStatusText }}
<span class="dot-sep">·</span>{{ selectedCategoryNames.length ? selectedCategoryNames.join('') : '' }}
<span class="dot-sep">&middot;</span>{{ selectedCategoryNames.length ? selectedCategoryNames.join(String.fromCharCode(12289)) : '\u5168\u90e8\u77e5\u8bc6\u5e93' }}
</div>
</div>
<select class="select chat-mode" v-model="mode">
<option value="sync">同步调用</option>
<option value="sse">SSE 流式</option>
<option value="sse2">ServerSentEvent</option>
<option value="sse3">SseEmitter</option>
</select>
<div class="chat-actions">
<label class="rag-toggle">
<input type="checkbox" v-model="isRagMode">
<span>RAG &#x68C0;&#x7D22;</span>
</label>
<select v-if="isRagMode" class="select rag-strategy" v-model="ragStrategy">
<option value="NONE">&#x4E0D;&#x91CD;&#x5199;</option>
<option value="REWRITE">&#x67E5;&#x8BE2;&#x91CD;&#x5199;</option>
<option value="TRANSLATION">&#x7FFB;&#x8BD1;&#x6269;&#x5C55;</option>
<option value="COMPRESSION">&#x67E5;&#x8BE2;&#x538B;&#x7F29;</option>
<option value="MULTI_QUERY">&#x591A;&#x8DEF;&#x6269;&#x5C55;</option>
</select>
<select class="select chat-mode" v-model="mode">
<option value="sync">&#x540C;&#x6B65;&#x8C03;&#x7528;</option>
<option value="sse">SSE &#x6D41;&#x5F0F;</option>
<option value="sse2">ServerSentEvent</option>
<option value="sse3">SseEmitter</option>
</select>
</div>
</header>
<div class="quick-row" v-if="messages.length <= 1">
@ -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')

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

34
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 })

3
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({
<div v-if="activeTab === 'settings'" style="animation: fadeIn .3s ease;">
<account-manager></account-manager>
<role-manager></role-manager>
<model-config-manager></model-config-manager>
</div>
<!-- 文档详情弹窗 -->
@ -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')
Loading…
Cancel
Save