From dd103639f8b75f3602ea5376f83a236dbe11e095 Mon Sep 17 00:00:00 2001 From: pyx Date: Wed, 24 Jun 2026 11:39:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AC=A1=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/wok/supportbot/app/AssistantApp.java | 52 ++- .../supportbot/config/DatabaseInitConfig.java | 75 ++++ .../supportbot/controller/AiController.java | 24 +- .../CustomerServiceRoleController.java | 53 +++ .../controller/DocumentController.java | 33 +- .../service/CustomerServiceRoleService.java | 80 ++++ .../supportbot/service/DocumentService.java | 51 ++- .../resources/static/components/ChatPanel.js | 382 ++++++++++++++---- src/main/resources/static/css/main.css | 63 +++ src/main/resources/static/js/api.js | 17 +- 10 files changed, 735 insertions(+), 95 deletions(-) create mode 100644 src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java create mode 100644 src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java index 3ee6ce1..6469368 100644 --- a/src/main/java/com/wok/supportbot/app/AssistantApp.java +++ b/src/main/java/com/wok/supportbot/app/AssistantApp.java @@ -24,11 +24,13 @@ import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQuery import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; 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; @@ -143,7 +145,7 @@ public class AssistantApp { // 应用 RAG 知识库问答 .advisors(QuestionAnswerAdvisor.builder(pgVectorVectorStore) // 相似度阈值为 0.0,并返回最相关的前 4 个结果 - .searchRequest(SearchRequest.builder().similarityThreshold(0.0).topK(4).build()) + .searchRequest(buildRagSearchRequest(4, Collections.emptyList())) .build()) .call() .chatResponse(); @@ -159,9 +161,18 @@ public class AssistantApp { * @return AI 回答 */ public String doChatWithRagStrategy(String message, String chatId, String strategy) { + return doChatWithRagStrategy(message, chatId, strategy, Collections.emptyList()); + } + + public String doChatWithRagStrategy(String message, String chatId, String strategy, Long categoryId) { + List categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList(); + return doChatWithRagStrategy(message, chatId, strategy, categoryIds); + } + + public String doChatWithRagStrategy(String message, String chatId, String strategy, List categoryIds) { // 对于 MULTI_QUERY 策略,需要使用特殊的处理方式 if ("MULTI_QUERY".equalsIgnoreCase(strategy)) { - return doChatWithMultiQueryRag(message, chatId); + return doChatWithMultiQueryRag(message, chatId, categoryIds); } // 其他策略:单查询处理 @@ -196,7 +207,7 @@ public class AssistantApp { // 应用 RAG 知识库问答 .advisors(QuestionAnswerAdvisor.builder(pgVectorVectorStore) // 相似度阈值为 0.0,并返回最相关的前 4 个结果 - .searchRequest(SearchRequest.builder().similarityThreshold(0.0).topK(4).build()) + .searchRequest(buildRagSearchRequest(4, categoryIds)) .build()) .call() .chatResponse(); @@ -211,7 +222,7 @@ public class AssistantApp { * @param chatId 会话ID * @return AI 回答 */ - private String doChatWithMultiQueryRag(String message, String chatId) { + private String doChatWithMultiQueryRag(String message, String chatId, List categoryIds) { // 执行多路查询扩展,得到多个查询文本 List expandedQueries = multiQueryExpanderRewriter.doQueryRewrite(message); @@ -230,13 +241,44 @@ public class AssistantApp { // 应用 RAG 知识库问答 .advisors(QuestionAnswerAdvisor.builder(pgVectorVectorStore) // 多路查询时增加 topK 以获取更多相关文档 - .searchRequest(SearchRequest.builder().similarityThreshold(0.0).topK(8).build()) + .searchRequest(buildRagSearchRequest(8, categoryIds)) .build()) .call() .chatResponse(); return chatResponse.getResult().getOutput().getText(); } + private SearchRequest buildRagSearchRequest(int topK, Long categoryId) { + List categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList(); + return buildRagSearchRequest(topK, categoryIds); + } + + private SearchRequest buildRagSearchRequest(int topK, List categoryIds) { + SearchRequest.Builder builder = SearchRequest.builder() + .similarityThreshold(0.0) + .topK(topK); + List values = normalizeCategoryIds(categoryIds).stream() + .map(value -> (Object) value) + .toList(); + if (!values.isEmpty()) { + FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder(); + builder.filterExpression(filterBuilder.in("categoryId", values).build()); + } + return builder.build(); + } + + private List normalizeCategoryIds(List categoryIds) { + if (categoryIds == null || categoryIds.isEmpty()) { + return Collections.emptyList(); + } + return categoryIds.stream() + .filter(java.util.Objects::nonNull) + .filter(id -> id > 0) + .map(String::valueOf) + .distinct() + .toList(); + } + @Autowired private List queryTransformers; @Autowired diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index ebc5102..8be91bd 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java @@ -46,6 +46,19 @@ public class DatabaseInitConfig { addContentHashColumn(); } + boolean roleTableExists = checkTableExists("customer_service_role"); + if (!roleTableExists) { + log.info("创建客服角色表 customer_service_role"); + createCustomerServiceRoleTable(); + } + + boolean roleCategoryTableExists = checkTableExists("customer_service_role_category"); + if (!roleCategoryTableExists) { + log.info("创建客服角色知识库关联表 customer_service_role_category"); + createCustomerServiceRoleCategoryTable(); + } + seedDefaultCustomerServiceRoles(); + log.info("数据库初始化完成"); } catch (Exception e) { log.error("数据库初始化失败", e); @@ -129,6 +142,68 @@ public class DatabaseInitConfig { jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_knowledge_document_create_time ON knowledge_document (create_time DESC)"); } + private void createCustomerServiceRoleTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS customer_service_role ( + id BIGSERIAL PRIMARY KEY, + role_key VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + description TEXT, + prompt TEXT, + sort_order INTEGER DEFAULT 0 NOT NULL, + enabled BOOLEAN DEFAULT TRUE NOT NULL, + 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_customer_service_role_enabled ON customer_service_role (enabled, sort_order)"); + } + + private void createCustomerServiceRoleCategoryTable() { + String sql = """ + CREATE TABLE IF NOT EXISTS customer_service_role_category ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL, + category_id BIGINT NOT NULL, + create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + is_delete BOOLEAN DEFAULT FALSE NOT NULL, + UNIQUE (role_id, category_id) + ) + """; + jdbcTemplate.execute(sql); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_service_role_category_role ON customer_service_role_category (role_id)"); + jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_service_role_category_category ON customer_service_role_category (category_id)"); + } + + private void seedDefaultCustomerServiceRoles() { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM customer_service_role WHERE is_delete = false", + Integer.class); + if (count != null && count > 0) { + return; + } + + insertDefaultRole("general", "综合客服", "商品、订单、支付、物流、售后与财务", + "专业、耐心、简洁;根据用户问题在可用知识库范围内回答。", 10); + insertDefaultRole("after_sale", "售后客服", "退款、退换货、维修与投诉处理", + "先安抚用户,再确认订单、凭证、责任归属和处理进度。", 20); + insertDefaultRole("logistics", "物流客服", "发货时效、快递轨迹、签收异常", + "先定位订单和物流节点,再解释时效、异常原因和下一步处理。", 30); + insertDefaultRole("product", "商品客服", "规格、库存、搭配与购买建议", + "围绕商品规格、库存、适用场景和购买建议回答,不确定时说明依据不足。", 40); + insertDefaultRole("finance", "财务客服", "发票、对账、付款、退款入账与费用问题", + "严谨核对金额、单据、账户、发票抬头、税号和时间节点。", 50); + } + + private void insertDefaultRole(String roleKey, String name, String description, String prompt, int sortOrder) { + jdbcTemplate.update(""" + INSERT INTO customer_service_role (role_key, name, description, prompt, sort_order) + VALUES (?, ?, ?, ?, ?) + """, roleKey, name, description, prompt, sortOrder); + } + private void fixTagsDefaultValue() { try { // 检查当前默认值是否为数组 diff --git a/src/main/java/com/wok/supportbot/controller/AiController.java b/src/main/java/com/wok/supportbot/controller/AiController.java index f7fce42..e574808 100644 --- a/src/main/java/com/wok/supportbot/controller/AiController.java +++ b/src/main/java/com/wok/supportbot/controller/AiController.java @@ -16,6 +16,9 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import reactor.core.publisher.Flux; import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; @RestController @@ -115,11 +118,28 @@ public class AiController { * @return AI 回答 */ @GetMapping("/assistant_app/chat/rag/sync") - public String doChatWithRagSync(String message, String chatId, String rewriteStrategy) { + public String doChatWithRagSync(String message, String chatId, String rewriteStrategy, Long categoryId, String categoryIds) { // 如果未指定策略,默认使用 REWRITE String strategy = (rewriteStrategy != null && !rewriteStrategy.isEmpty()) ? rewriteStrategy : "REWRITE"; - return assistantApp.doChatWithRagStrategy(message, chatId, strategy); + List ids = parseCategoryIds(categoryIds); + if (ids.isEmpty() && categoryId != null) { + ids = List.of(categoryId); + } + return assistantApp.doChatWithRagStrategy(message, chatId, strategy, ids); + } + + private List parseCategoryIds(String categoryIds) { + if (categoryIds == null || categoryIds.isBlank()) { + return Collections.emptyList(); + } + return Arrays.stream(categoryIds.split(",")) + .map(String::trim) + .filter(item -> !item.isEmpty()) + .map(Long::valueOf) + .filter(id -> id > 0) + .distinct() + .toList(); } } diff --git a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java new file mode 100644 index 0000000..a73e855 --- /dev/null +++ b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java @@ -0,0 +1,53 @@ +package com.wok.supportbot.controller; + +import com.wok.supportbot.service.CustomerServiceRoleService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@RestController +public class CustomerServiceRoleController { + + @Autowired + private CustomerServiceRoleService customerServiceRoleService; + + @GetMapping("/role/list") + public ResponseEntity> listRoles() { + return ResponseEntity.ok(Map.of( + "success", true, + "data", customerServiceRoleService.listRoles() + )); + } + + @PutMapping("/role/{id}/categories") + public ResponseEntity> updateRoleCategories( + @PathVariable("id") Long roleId, + @RequestBody Map body) { + List categoryIds = parseCategoryIds(body.get("categoryIds")); + customerServiceRoleService.updateRoleCategories(roleId, categoryIds); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "更新成功" + )); + } + + private List parseCategoryIds(Object rawCategoryIds) { + if (!(rawCategoryIds instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(Objects::nonNull) + .map(item -> item instanceof Number number ? number.longValue() : Long.parseLong(item.toString())) + .filter(id -> id > 0) + .distinct() + .toList(); + } +} diff --git a/src/main/java/com/wok/supportbot/controller/DocumentController.java b/src/main/java/com/wok/supportbot/controller/DocumentController.java index c9d794d..aa8e712 100644 --- a/src/main/java/com/wok/supportbot/controller/DocumentController.java +++ b/src/main/java/com/wok/supportbot/controller/DocumentController.java @@ -10,9 +10,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; /** @@ -462,8 +464,12 @@ public class DocumentController { double similarityThreshold = body.get("similarityThreshold") != null ? ((Number) body.get("similarityThreshold")).doubleValue() : 0.5; Long categoryId = body.get("categoryId") != null ? ((Number) body.get("categoryId")).longValue() : null; + List categoryIds = parseCategoryIds(body.get("categoryIds")); + if (categoryIds.isEmpty() && categoryId != null) { + categoryIds = List.of(categoryId); + } - List results = documentService.searchDocuments(query, topK, similarityThreshold, categoryId); + List results = documentService.searchDocuments(query, topK, similarityThreshold, categoryIds); return ResponseEntity.ok(Map.of( "success", true, "data", results, @@ -477,6 +483,31 @@ public class DocumentController { } } + private List parseCategoryIds(Object rawCategoryIds) { + if (rawCategoryIds == null) { + return List.of(); + } + if (rawCategoryIds instanceof List list) { + return list.stream() + .filter(Objects::nonNull) + .map(item -> item instanceof Number number ? number.longValue() : Long.parseLong(item.toString())) + .filter(id -> id > 0) + .distinct() + .toList(); + } + String text = rawCategoryIds.toString(); + if (text.isBlank()) { + return List.of(); + } + return Arrays.stream(text.split(",")) + .map(String::trim) + .filter(item -> !item.isEmpty()) + .map(Long::parseLong) + .filter(id -> id > 0) + .distinct() + .toList(); + } + // ==================== 统计 ==================== /** diff --git a/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java b/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java new file mode 100644 index 0000000..3496cf0 --- /dev/null +++ b/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java @@ -0,0 +1,80 @@ +package com.wok.supportbot.service; + +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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +@Service +public class CustomerServiceRoleService { + + @Autowired + private JdbcTemplate jdbcTemplate; + + public List> listRoles() { + String roleSql = """ + SELECT id::text AS id, role_key, name, description, prompt, sort_order, enabled + FROM customer_service_role + WHERE is_delete = false AND enabled = true + ORDER BY sort_order ASC, id ASC + """; + List> roles = jdbcTemplate.queryForList(roleSql); + + String categorySql = """ + SELECT rc.role_id::text AS role_id, rc.category_id::text AS category_id, c.name AS category_name + FROM customer_service_role_category rc + LEFT JOIN knowledge_category c ON c.id = rc.category_id AND c.is_delete = false + WHERE rc.is_delete = false + ORDER BY rc.role_id ASC, rc.id ASC + """; + List> relations = jdbcTemplate.queryForList(categorySql); + + Map> categoryIdsByRole = new LinkedHashMap<>(); + Map>> categoriesByRole = new LinkedHashMap<>(); + for (Map relation : relations) { + String roleId = String.valueOf(relation.get("role_id")); + String categoryId = String.valueOf(relation.get("category_id")); + categoryIdsByRole.computeIfAbsent(roleId, key -> new ArrayList<>()).add(categoryId); + categoriesByRole.computeIfAbsent(roleId, key -> new ArrayList<>()).add(Map.of( + "id", categoryId, + "name", Objects.toString(relation.get("category_name"), "") + )); + } + + for (Map role : roles) { + String roleId = String.valueOf(role.get("id")); + role.put("categoryIds", categoryIdsByRole.getOrDefault(roleId, List.of())); + role.put("categories", categoriesByRole.getOrDefault(roleId, List.of())); + } + return roles; + } + + @Transactional(rollbackFor = Exception.class) + public void updateRoleCategories(Long roleId, List categoryIds) { + jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId); + + if (categoryIds == null || categoryIds.isEmpty()) { + return; + } + + List normalizedIds = categoryIds.stream() + .filter(Objects::nonNull) + .filter(id -> id > 0) + .distinct() + .toList(); + for (Long categoryId : normalizedIds) { + jdbcTemplate.update(""" + INSERT INTO customer_service_role_category (role_id, category_id, is_delete) + VALUES (?, ?, false) + ON CONFLICT (role_id, category_id) + DO UPDATE SET is_delete = false + """, roleId, categoryId); + } + } +} diff --git a/src/main/java/com/wok/supportbot/service/DocumentService.java b/src/main/java/com/wok/supportbot/service/DocumentService.java index 2e08c5f..7b2e08b 100644 --- a/src/main/java/com/wok/supportbot/service/DocumentService.java +++ b/src/main/java/com/wok/supportbot/service/DocumentService.java @@ -17,7 +17,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; -import org.springframework.ai.vectorstore.filter.Filter; +import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -472,15 +472,46 @@ public class DocumentService { * 语义搜索 */ public List searchDocuments(String query, int topK, double similarityThreshold, Long categoryId) { + List categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList(); + return searchDocuments(query, topK, similarityThreshold, categoryIds); + } + + /** + * 语义搜索(支持多个知识库分类过滤) + */ + public List searchDocuments(String query, int topK, double similarityThreshold, List categoryIds) { SearchRequest.Builder searchBuilder = SearchRequest.builder() .query(query) .topK(topK) .similarityThreshold(similarityThreshold); - // 如果指定了分类,添加过滤条件(当前 Spring AI 1.0.0-M6 的 filter 支持有限) - // 这里先不做分类过滤,后续升级 Spring AI 版本后再完善 + List categoryIdStrings = normalizeCategoryIds(categoryIds); + if (!categoryIdStrings.isEmpty()) { + FilterExpressionBuilder builder = new FilterExpressionBuilder(); + List values = categoryIdStrings.stream() + .map(value -> (Object) value) + .collect(Collectors.toList()); + searchBuilder.filterExpression(builder.in("categoryId", values).build()); + } - List results = pgVectorVectorStore.similaritySearch(searchBuilder.build()); + List results; + try { + results = pgVectorVectorStore.similaritySearch(searchBuilder.build()); + } catch (Exception e) { + if (categoryIdStrings.isEmpty()) { + throw e; + } + log.warn("向量检索分类过滤失败,改用本地 metadata 过滤兜底: categoryIds={}", categoryIdStrings, e); + results = pgVectorVectorStore.similaritySearch(SearchRequest.builder() + .query(query) + .topK(Math.max(topK * 5, 20)) + .similarityThreshold(similarityThreshold) + .build()) + .stream() + .filter(doc -> categoryIdStrings.contains(getStringFromMetadata(doc.getMetadata(), "categoryId"))) + .limit(topK) + .collect(Collectors.toList()); + } List searchResults = new ArrayList<>(); for (Document doc : results) { @@ -501,6 +532,18 @@ public class DocumentService { return searchResults; } + private List normalizeCategoryIds(List categoryIds) { + if (categoryIds == null || categoryIds.isEmpty()) { + return Collections.emptyList(); + } + return categoryIds.stream() + .filter(Objects::nonNull) + .filter(id -> id > 0) + .map(String::valueOf) + .distinct() + .collect(Collectors.toList()); + } + // ==================== 统计 ==================== /** diff --git a/src/main/resources/static/components/ChatPanel.js b/src/main/resources/static/components/ChatPanel.js index cbc39fb..159de4a 100644 --- a/src/main/resources/static/components/ChatPanel.js +++ b/src/main/resources/static/components/ChatPanel.js @@ -1,140 +1,362 @@ /** - * 💬 智能客服对话面板 + * 智能客服对话面板 */ -import { ref, nextTick } from 'vue' -import { chatSync, chatRagSync, chatSSEUrl } from '../js/api.js' +import { ref, computed, nextTick, onMounted } from 'vue' +import { chatSync, chatRagSync, chatSSEUrl, getRoleList, updateRoleCategories } from '../js/api.js' import { toast, readSSEStream } from '../js/utils.js' +import { store } from '../js/store.js' + +const FALLBACK_ROLE = { + id: '', + key: 'general', + name: '综合客服', + desc: '可检索全部知识库', + tone: '专业、耐心、简洁', + badge: '默认', + categoryIds: [] +} + +const QUICK_QUESTIONS = [ + '我的订单什么时候发货?', + '已经付款但订单状态没更新怎么办?', + '如何申请退款或退货?', + '物流显示签收但我没收到怎么办?', + '这个商品有哪些售后政策?' +] export default { template: ` -
-

💬 智能客服对话

-

基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆

- -
- - - - -
- -
- - -
- -
- 💡 当前已启用 RAG 知识库检索(策略:{{ ragStrategyNames[ragStrategy] || ragStrategy }}) -
- -
-
-
{{ m.role === 'user' ? '👤' : '🤖' }}
-
{{ m.content }}
+
+ + +
+
+
+

智能客服对话

+

{{ currentRole.desc }} · {{ currentRole.tone }}

+
+
+ {{ ragStatusText }} + +
+
+ +
+ +
+ +
+
+
{{ m.role === 'user' ? '我' : 'AI' }}
+
+
{{ m.content || (m.streaming ? '正在思考...' : '') }}
+
+ {{ m.time }} + + +
+
+
-
-
- - -
+ +
`, setup() { const chatId = ref('') const mode = ref('sync') + const selectedRole = ref('general') + const roles = ref([FALLBACK_ROLE]) const isRagMode = ref(false) const ragStrategy = ref('REWRITE') const userInput = ref('') + const lastUserInput = ref('') const isSending = ref(false) const messages = ref([ - { role: 'assistant', content: '您好!我是电商智能客服助手。\n可以帮您解答商品、订单、支付、物流和售后问题。\n\n💡 提示:右侧下拉可切换对话模式,切换新会话开始全新对话。\n📚 如需启用知识库检索,请勾选上方的"启用 RAG 知识库检索"选项。', streaming: false } + { + role: 'assistant', + content: '您好,我是电商智能客服助手。可以咨询商品、订单、支付、物流和售后问题;如果需要基于知识库回答,请先开启左侧 RAG 检索。', + streaming: false, + time: formatTime() + } ]) const msgArea = ref(null) - const ragStrategyNames = { - NONE: '无重写', REWRITE: '查询重写', TRANSLATION: '翻译扩展', - COMPRESSION: '查询压缩', MULTI_QUERY: '多路扩展' + const currentRole = computed(() => roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE) + const selectedCategoryIds = computed(() => currentRole.value.categoryIds || []) + const selectedCategoryNames = computed(() => { + const selected = new Set(selectedCategoryIds.value) + return store.categories + .filter(category => selected.has(String(category.id))) + .map(category => category.name) + }) + const roleKnowledgeScopeText = computed(() => { + if (!isRagMode.value) return '启用 RAG 后,会按当前客服勾选的知识库范围检索。' + if (selectedCategoryNames.value.length) return `当前客服可检索:${selectedCategoryNames.value.join('、')}。` + return '当前客服未绑定知识库,RAG 会检索全部知识库。' + }) + const shortChatId = computed(() => chatId.value ? chatId.value.replace(/^web_/, '') : '未创建') + const ragStatusText = computed(() => { + if (!isRagMode.value) return '普通对话' + const names = { + NONE: 'RAG:不重写', + REWRITE: 'RAG:查询重写', + TRANSLATION: 'RAG:翻译扩展', + COMPRESSION: 'RAG:查询压缩', + MULTI_QUERY: 'RAG:多路扩展' + } + return names[ragStrategy.value] || 'RAG 已启用' + }) + + function formatTime() { + return new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) } function newChatId() { - chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6) + chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) + } + + function selectRole(roleKey) { + selectedRole.value = roleKey + newChatId() + clearMessages(roleKey) + } + + function clearMessages(roleKey = selectedRole.value) { + const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE + messages.value = [{ + role: 'assistant', + content: `已切换到${role.name}。我会按“${role.tone}”的方式处理问题。`, + streaming: false, + time: formatTime() + }] + } + + function useQuickQuestion(question) { + userInput.value = question + send() + } + + async function loadRoles() { + try { + const json = await getRoleList() + if (json.success) { + roles.value = (json.data || []).map(role => ({ + id: role.id, + key: role.role_key || role.roleKey || String(role.id), + name: role.name, + desc: role.description || '', + tone: role.prompt || '专业、耐心、简洁', + badge: role.name ? role.name.slice(0, 2) : '角色', + categoryIds: (role.categoryIds || []).map(String) + })) + if (!roles.value.some(role => role.key === selectedRole.value)) { + selectedRole.value = roles.value[0]?.key || 'general' + } + } + } catch (e) { + toast('加载客服角色失败:' + e.message, 'error') + } + } + + async function toggleRoleCategory(categoryId, checked) { + const role = currentRole.value + if (!role.id) { + toast('当前角色未保存到数据库,不能配置知识库', 'error') + return + } + const current = new Set(role.categoryIds || []) + if (checked) { + current.add(categoryId) + } else { + current.delete(categoryId) + } + const nextIds = Array.from(current) + await updateRoleCategories(role.id, nextIds) + roles.value = roles.value.map(item => item.key === role.key ? { ...item, categoryIds: nextIds } : item) + toast('角色知识库范围已保存', 'success') } - function clearMessages() { - messages.value = [{ role: 'assistant', content: '已清屏,开始新的对话吧~', streaming: false }] + function buildRoleAwareMessage(text) { + const scope = selectedCategoryNames.value.length + ? `仅使用这些知识库范围内的信息:${selectedCategoryNames.value.join('、')}` + : '可使用全部知识库范围内的信息' + return `当前客服角色:${currentRole.value.name}。回复风格:${currentRole.value.tone}。知识库范围:${scope}。用户问题:${text}` + } + + async function scrollToBottom() { + await nextTick() + if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight } async function send() { const text = userInput.value.trim() if (!text || isSending.value) return + userInput.value = '' + lastUserInput.value = text isSending.value = true + messages.value.push({ role: 'user', content: text, streaming: false, time: formatTime() }) - // 添加用户消息 - messages.value.push({ role: 'user', content: text, streaming: false }) - - // 添加助手占位 - const assistantMsg = { role: 'assistant', content: '', streaming: true } + const assistantMsg = { role: 'assistant', content: '', streaming: true, time: formatTime() } messages.value.push(assistantMsg) + await scrollToBottom() const cid = chatId.value || ('web_' + Date.now()) chatId.value = cid + const requestText = buildRoleAwareMessage(text) try { if (isRagMode.value && mode.value === 'sync') { - // RAG 同步 - const result = await chatRagSync(text, cid, ragStrategy.value) - assistantMsg.content = result + assistantMsg.content = await chatRagSync(requestText, cid, ragStrategy.value, selectedCategoryIds.value) } else if (isRagMode.value && mode.value !== 'sync') { - assistantMsg.content = '⚠️ RAG 模式仅支持同步调用' + assistantMsg.content = '当前后端 RAG 接口只提供同步调用,请切换为“同步调用”后再试。' + assistantMsg.error = true toast('RAG 模式暂不支持流式调用', 'error') } else if (mode.value === 'sync') { - // 普通同步 - const result = await chatSync(text, cid) - assistantMsg.content = result + assistantMsg.content = await chatSync(requestText, cid) } else { - // SSE 流式 - const url = chatSSEUrl(text, cid, mode.value) - await readSSEStream(url, - (chunk) => { assistantMsg.content += chunk }, - () => {} - ) + const url = chatSSEUrl(requestText, cid, mode.value) + await readSSEStream(url, async (chunk) => { + assistantMsg.content += chunk + await scrollToBottom() + }, () => {}) } } catch (e) { assistantMsg.content = '请求失败:' + e.message + assistantMsg.error = true toast('对话失败:' + e.message, 'error') } finally { assistantMsg.streaming = false isSending.value = false - // 滚动到底部 - nextTick(() => { - if (msgArea.value) msgArea.value.scrollTop = msgArea.value.scrollHeight - }) + await scrollToBottom() + } + } + + function retryLast() { + if (!lastUserInput.value || isSending.value) return + userInput.value = lastUserInput.value + send() + } + + async function copyMessage(content) { + try { + await navigator.clipboard.writeText(content) + toast('已复制回复', 'success') + } catch (e) { + toast('复制失败', 'error') } } - // 初始化会话 ID - newChatId() + onMounted(() => { + newChatId() + store.loadCategories() + loadRoles() + }) return { - chatId, mode, isRagMode, ragStrategy, ragStrategyNames, - userInput, isSending, messages, msgArea, - newChatId, clearMessages, send + store, + roles, + quickQuestions: QUICK_QUESTIONS, + chatId, + mode, + selectedRole, + selectedCategoryIds, + isRagMode, + ragStrategy, + userInput, + isSending, + messages, + msgArea, + currentRole, + roleKnowledgeScopeText, + shortChatId, + ragStatusText, + newChatId, + selectRole, + clearMessages, + useQuickQuestion, + toggleRoleCategory, + send, + retryLast, + copyMessage } } } diff --git a/src/main/resources/static/css/main.css b/src/main/resources/static/css/main.css index ed808fc..ed9126f 100644 --- a/src/main/resources/static/css/main.css +++ b/src/main/resources/static/css/main.css @@ -140,3 +140,66 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(- /* ==================== 响应式 ==================== */ @media(max-width:768px) { .tabs { overflow-x:auto; } .tab-btn { padding:12px 16px; font-size:13px; } .stream-compare { grid-template-columns:1fr; } .stat-grid { grid-template-columns:1fr 1fr; } } + +/* ==================== Chat Panel ==================== */ +.chat-shell { display:grid; grid-template-columns:280px minmax(0, 1fr); gap:16px; min-height:680px; } +.chat-sidebar, .chat-main { background:var(--card); border:1px solid var(--border); border-radius:var(--radius); } +.chat-sidebar { padding:16px; display:flex; flex-direction:column; gap:18px; } +.agent-card { display:flex; align-items:center; gap:12px; padding-bottom:14px; border-bottom:1px solid var(--border); } +.agent-avatar { width:44px; height:44px; border-radius:8px; display:flex; align-items:center; justify-content:center; background:#111827; color:#fff; font-weight:800; letter-spacing:0; } +.agent-name { font-weight:700; } +.agent-status { margin-top:4px; color:var(--sub); font-size:12px; display:flex; align-items:center; gap:6px; } +.agent-status span { width:7px; height:7px; border-radius:999px; background:var(--success); display:inline-block; } +.side-section { display:flex; flex-direction:column; gap:8px; } +.side-label { font-size:12px; color:var(--sub); font-weight:700; } +.role-option { width:100%; border:1px solid var(--border); background:#fff; border-radius:8px; padding:10px; display:flex; gap:10px; align-items:flex-start; text-align:left; cursor:pointer; transition:all .2s; font-family:inherit; } +.role-option:hover, .role-option.active { border-color:var(--primary); background:#f8faff; } +.role-option strong { display:block; font-size:13px; color:var(--text); margin-bottom:3px; } +.role-option small { display:block; font-size:12px; color:var(--sub); line-height:1.4; } +.role-badge { flex:none; min-width:38px; padding:3px 6px; border-radius:6px; background:#eef2ff; color:var(--primary); font-size:11px; text-align:center; font-weight:700; } +.toggle-line { display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer; } +.toggle-line input { width:16px; height:16px; } +.chat-select { width:100%; min-width:0; } +.kb-check-list { max-height:156px; overflow-y:auto; border:1px solid var(--border); border-radius:8px; background:#fff; padding:6px; display:flex; flex-direction:column; gap:4px; } +.kb-check-item { display:flex; align-items:center; gap:8px; padding:7px 8px; border-radius:6px; font-size:13px; cursor:pointer; } +.kb-check-item:hover { background:#f8faff; } +.kb-check-item input { width:15px; height:15px; flex:none; } +.kb-empty { color:var(--sub); font-size:12px; padding:8px; } +.side-note { color:var(--sub); font-size:12px; line-height:1.5; } +.session-box { padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#f9fafb; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.side-actions { display:flex; gap:8px; } +.side-actions .btn { flex:1; justify-content:center; } +.chat-main { display:flex; flex-direction:column; min-width:0; overflow:hidden; } +.chat-header { padding:18px 20px; border-bottom:1px solid var(--border); display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } +.chat-header h2 { margin-bottom:6px; font-size:18px; } +.chat-header p { color:var(--sub); font-size:13px; } +.chat-meta { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; } +.chat-mode { min-width:140px; } +.rag-pill { display:inline-flex; align-items:center; min-height:34px; padding:0 10px; border-radius:999px; background:#f3f4f6; color:var(--sub); font-size:12px; font-weight:700; } +.rag-pill.on { background:#ecfdf5; color:#047857; } +.quick-row { padding:14px 20px 0; display:flex; gap:8px; flex-wrap:wrap; } +.quick-row button { border:1px solid var(--border); background:#fff; border-radius:999px; padding:7px 12px; font-size:12px; color:var(--text); cursor:pointer; transition:all .2s; } +.quick-row button:hover { border-color:var(--primary); color:var(--primary); background:#f8faff; } +.chat-msg-area { flex:1; height:auto; min-height:420px; margin:14px 20px; background:#f8fafc; } +.chat-msg-area .msg { max-width:78%; } +.chat-msg-area .msg-content { min-width:0; } +.chat-msg-area .msg-avatar { font-size:12px; font-weight:700; } +.msg-tools { margin-top:5px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--sub); } +.msg-tools button { border:none; background:none; color:var(--primary); cursor:pointer; font-size:11px; padding:0; } +.chat-composer { border-top:1px solid var(--border); padding:14px 20px; display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:10px; align-items:end; background:#fff; } +.chat-textarea { min-height:46px; max-height:140px; resize:vertical; padding:12px 14px; } +.send-btn { height:46px; min-width:92px; justify-content:center; } + +@media(max-width:960px) { + .chat-shell { grid-template-columns:1fr; } + .chat-sidebar { order:2; } + .chat-main { order:1; min-height:620px; } +} + +@media(max-width:640px) { + .chat-header { flex-direction:column; } + .chat-meta { justify-content:flex-start; } + .chat-msg-area .msg { max-width:94%; } + .chat-composer { grid-template-columns:1fr; } + .send-btn { width:100%; } +} diff --git a/src/main/resources/static/js/api.js b/src/main/resources/static/js/api.js index 9822006..056bc03 100644 --- a/src/main/resources/static/js/api.js +++ b/src/main/resources/static/js/api.js @@ -98,8 +98,10 @@ export function chatSync(message, chatId) { /** * RAG 同步对话 */ -export function chatRagSync(message, chatId, strategy) { - return fetch(API_BASE + `/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`) +export function chatRagSync(message, chatId, strategy, categoryIds) { + let url = API_BASE + `/ai/assistant_app/chat/rag/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}` + if (categoryIds && categoryIds.length) url += `&categoryIds=${encodeURIComponent(categoryIds.join(','))}` + return fetch(url) .then(res => res.text()) } @@ -117,6 +119,14 @@ export function chatSSEUrl(message, chatId, mode) { return API_BASE + `${pathMap[mode]}?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}` } +export function getRoleList() { + return getJSON('/role/list') +} + +export function updateRoleCategories(roleId, categoryIds) { + return putJSONWithBody(`/role/${roleId}/categories`, { categoryIds }) +} + // ==================== 商品信息提取 ==================== /** @@ -193,8 +203,9 @@ export function batchReprocessDocuments(ids) { /** * 语义搜索 */ -export function searchDocuments(query, topK, similarityThreshold, categoryId) { +export function searchDocuments(query, topK, similarityThreshold, categoryId, categoryIds) { const body = { query, topK, similarityThreshold } + if (categoryIds && categoryIds.length) body.categoryIds = categoryIds if (categoryId) body.categoryId = categoryId return postJSON('/document/search', body) }