Browse Source

初次提交

master
pyx 10 hours ago
parent
commit
dd103639f8
  1. 52
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  2. 75
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  3. 24
      src/main/java/com/wok/supportbot/controller/AiController.java
  4. 53
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  5. 33
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  6. 80
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
  7. 51
      src/main/java/com/wok/supportbot/service/DocumentService.java
  8. 382
      src/main/resources/static/components/ChatPanel.js
  9. 63
      src/main/resources/static/css/main.css
  10. 17
      src/main/resources/static/js/api.js

52
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<Long> categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList();
return doChatWithRagStrategy(message, chatId, strategy, categoryIds);
}
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> 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<Long> categoryIds) {
// 执行多路查询扩展得到多个查询文本
List<String> 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<Long> categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList();
return buildRagSearchRequest(topK, categoryIds);
}
private SearchRequest buildRagSearchRequest(int topK, List<Long> categoryIds) {
SearchRequest.Builder builder = SearchRequest.builder()
.similarityThreshold(0.0)
.topK(topK);
List<Object> 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<String> normalizeCategoryIds(List<Long> 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<QueryTransformer> queryTransformers;
@Autowired

75
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 {
// 检查当前默认值是否为数组

24
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<Long> ids = parseCategoryIds(categoryIds);
if (ids.isEmpty() && categoryId != null) {
ids = List.of(categoryId);
}
return assistantApp.doChatWithRagStrategy(message, chatId, strategy, ids);
}
private List<Long> 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();
}
}

53
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<Map<String, Object>> listRoles() {
return ResponseEntity.ok(Map.of(
"success", true,
"data", customerServiceRoleService.listRoles()
));
}
@PutMapping("/role/{id}/categories")
public ResponseEntity<Map<String, Object>> updateRoleCategories(
@PathVariable("id") Long roleId,
@RequestBody Map<String, Object> body) {
List<Long> categoryIds = parseCategoryIds(body.get("categoryIds"));
customerServiceRoleService.updateRoleCategories(roleId, categoryIds);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "更新成功"
));
}
private List<Long> 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();
}
}

33
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<Long> categoryIds = parseCategoryIds(body.get("categoryIds"));
if (categoryIds.isEmpty() && categoryId != null) {
categoryIds = List.of(categoryId);
}
List<SearchResult> results = documentService.searchDocuments(query, topK, similarityThreshold, categoryId);
List<SearchResult> 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<Long> 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();
}
// ==================== 统计 ====================
/**

80
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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> relations = jdbcTemplate.queryForList(categorySql);
Map<String, List<String>> categoryIdsByRole = new LinkedHashMap<>();
Map<String, List<Map<String, Object>>> categoriesByRole = new LinkedHashMap<>();
for (Map<String, Object> 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<String, Object> 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<Long> categoryIds) {
jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId);
if (categoryIds == null || categoryIds.isEmpty()) {
return;
}
List<Long> 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);
}
}
}

51
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<SearchResult> searchDocuments(String query, int topK, double similarityThreshold, Long categoryId) {
List<Long> categoryIds = categoryId != null ? List.of(categoryId) : Collections.emptyList();
return searchDocuments(query, topK, similarityThreshold, categoryIds);
}
/**
* 语义搜索支持多个知识库分类过滤
*/
public List<SearchResult> searchDocuments(String query, int topK, double similarityThreshold, List<Long> categoryIds) {
SearchRequest.Builder searchBuilder = SearchRequest.builder()
.query(query)
.topK(topK)
.similarityThreshold(similarityThreshold);
// 如果指定了分类添加过滤条件当前 Spring AI 1.0.0-M6 filter 支持有限
// 这里先不做分类过滤后续升级 Spring AI 版本后再完善
List<String> categoryIdStrings = normalizeCategoryIds(categoryIds);
if (!categoryIdStrings.isEmpty()) {
FilterExpressionBuilder builder = new FilterExpressionBuilder();
List<Object> values = categoryIdStrings.stream()
.map(value -> (Object) value)
.collect(Collectors.toList());
searchBuilder.filterExpression(builder.in("categoryId", values).build());
}
List<Document> results = pgVectorVectorStore.similaritySearch(searchBuilder.build());
List<Document> 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<SearchResult> searchResults = new ArrayList<>();
for (Document doc : results) {
@ -501,6 +532,18 @@ public class DocumentService {
return searchResults;
}
private List<String> normalizeCategoryIds(List<Long> 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());
}
// ==================== 统计 ====================
/**

382
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: `
<div class="card">
<h2>💬 智能客服对话</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">基于通义千问 · 电商客服场景 · 支持多轮对话上下文记忆</p>
<div class="input-row">
<input class="input input-sm" v-model="chatId" placeholder="会话ID">
<select class="select" v-model="mode">
<option value="sync">🔵 同步调用</option>
<option value="sse">🟢 SSE 流式 (Flux)</option>
<option value="sse2">🟡 ServerSentEvent</option>
<option value="sse3">🟣 SseEmitter</option>
</select>
<button class="btn btn-outline btn-sm" @click="newChatId">🔄 新会话</button>
<button class="btn btn-outline btn-sm" @click="clearMessages">🗑 清屏</button>
</div>
<div class="input-row" style="margin-top:8px;padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;">
<input type="checkbox" v-model="isRagMode" style="width:16px;height:16px;cursor:pointer;">
<span style="font-weight:600;color:var(--primary);">📚 启用 RAG 知识库检索</span>
</label>
<select class="select" v-model="ragStrategy" v-show="isRagMode" style="margin-left:auto;">
<option value="NONE">无重写</option>
<option value="REWRITE">查询重写</option>
<option value="TRANSLATION">翻译扩展</option>
<option value="COMPRESSION">查询压缩</option>
<option value="MULTI_QUERY">多路扩展</option>
</select>
</div>
<div v-show="isRagMode" style="margin-bottom:12px;padding:8px 12px;background:#eef2ff;border-radius:6px;font-size:12px;color:var(--primary);border-left:3px solid var(--primary);">
💡 当前已启用 RAG 知识库检索策略<strong>{{ ragStrategyNames[ragStrategy] || ragStrategy }}</strong>
</div>
<div class="msg-area" ref="msgArea">
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']">
<div class="msg-avatar">{{ m.role === 'user' ? '👤' : '🤖' }}</div>
<div class="msg-bubble">{{ m.content }}</div>
<div class="chat-shell">
<aside class="chat-sidebar">
<div class="agent-card">
<div class="agent-avatar">SB</div>
<div>
<div class="agent-name">Support Bot</div>
<div class="agent-status"><span></span>线</div>
</div>
</div>
<div class="side-section">
<div class="side-label">客服角色</div>
<button
v-for="role in roles"
:key="role.key"
:class="['role-option', selectedRole === role.key ? 'active' : '']"
@click="selectRole(role.key)"
>
<span class="role-badge">{{ role.badge }}</span>
<span>
<strong>{{ role.name }}</strong>
<small>{{ role.desc }}</small>
</span>
</button>
</div>
<div class="side-section">
<div class="side-label">知识库范围</div>
<label class="toggle-line">
<input type="checkbox" v-model="isRagMode">
<span>启用 RAG 检索</span>
</label>
<select class="select chat-select" v-model="ragStrategy" :disabled="!isRagMode">
<option value="NONE">不重写查询</option>
<option value="REWRITE">查询重写</option>
<option value="TRANSLATION">翻译扩展</option>
<option value="COMPRESSION">查询压缩</option>
<option value="MULTI_QUERY">多路扩展</option>
</select>
<div class="kb-check-list">
<label v-for="c in store.categories" :key="c.id" class="kb-check-item">
<input
type="checkbox"
:checked="selectedCategoryIds.includes(String(c.id))"
@change="toggleRoleCategory(String(c.id), $event.target.checked)"
>
<span>{{ c.name }}</span>
</label>
<div v-if="!store.categories.length" class="kb-empty">暂无知识库分类</div>
</div>
<p class="side-note">{{ roleKnowledgeScopeText }}</p>
</div>
<div class="side-section">
<div class="side-label">当前会话</div>
<div class="session-box" :title="chatId">{{ shortChatId }}</div>
<div class="side-actions">
<button class="btn btn-outline btn-sm" @click="newChatId">新会话</button>
<button class="btn btn-outline btn-sm" @click="clearMessages">清屏</button>
</div>
</div>
</aside>
<section class="chat-main">
<header class="chat-header">
<div>
<h2>智能客服对话</h2>
<p>{{ currentRole.desc }} · {{ currentRole.tone }}</p>
</div>
<div class="chat-meta">
<span :class="['rag-pill', isRagMode ? 'on' : '']">{{ ragStatusText }}</span>
<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>
</header>
<div class="quick-row" v-if="messages.length <= 1">
<button v-for="q in quickQuestions" :key="q" @click="useQuickQuestion(q)">{{ q }}</button>
</div>
<div class="msg-area chat-msg-area" ref="msgArea">
<div v-for="(m, i) in messages" :key="i" :class="['msg', m.role, m.streaming ? 'streaming' : '']">
<div class="msg-avatar">{{ m.role === 'user' ? '我' : 'AI' }}</div>
<div class="msg-content">
<div class="msg-bubble">{{ m.content || (m.streaming ? '正在思考...' : '') }}</div>
<div class="msg-tools">
<span>{{ m.time }}</span>
<button v-if="m.role === 'assistant' && m.content" @click="copyMessage(m.content)">复制</button>
<button v-if="m.error" @click="retryLast">重试</button>
</div>
</div>
</div>
</div>
</div>
<div class="input-row">
<input class="input" v-model="userInput" placeholder="输入问题,Enter 发送..." @keydown.enter="send" :disabled="isSending">
<button class="btn btn-primary" @click="send" :disabled="isSending">📨 发送</button>
</div>
<footer class="chat-composer">
<textarea
class="textarea chat-textarea"
v-model="userInput"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
@keydown.enter.exact.prevent="send"
:disabled="isSending"
></textarea>
<button class="btn btn-primary send-btn" @click="send" :disabled="isSending || !userInput.trim()">
{{ isSending ? '发送中' : '发送' }}
</button>
</footer>
</section>
</div>
`,
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
}
}
}

63
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%; }
}

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

Loading…
Cancel
Save