Browse Source

智能客服支持角色知识库并修复 SDK 历史会话隔离

dev-mcp
pyx 4 weeks ago
parent
commit
1e0c9a7f11
  1. 22
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  2. 16
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  3. 20
      src/main/java/com/wok/supportbot/controller/AiController.java
  4. 6
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  5. 61
      src/main/java/com/wok/supportbot/service/ConversationService.java
  6. 47
      src/main/java/com/wok/supportbot/service/DocumentService.java
  7. 5
      src/main/resources/db/migration/V2__conversation_external_account_id.sql
  8. 2
      src/main/resources/init-database.sql
  9. 133
      src/main/resources/static/components/ChatPanel.js
  10. 24
      src/main/resources/static/components/ConversationManager.js
  11. 1
      src/main/resources/static/components/DocDetail.js
  12. 20
      src/main/resources/static/css/main.css
  13. 8
      src/main/resources/static/js/api.js
  14. 6
      src/main/resources/static/js/app.js
  15. 7
      src/main/resources/static/js/store.js
  16. 14
      src/main/resources/static/sdk/test.html

22
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -71,8 +71,7 @@ public class AssistantApp {
private final ConcurrentHashMap<String, ChatClient> chatClientCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, ChatClient> chatClientCache = new ConcurrentHashMap<>();
private static final String SYSTEM_PROMPT = "你是一名智能客服助手,负责解答用户问题。" +
"请主动引导用户提供关键信息,并尽量在不转人工的情况下解决问题。保持专业、耐心、礼貌。";
private static final String SYSTEM_PROMPT = "\u4f60\u662f\u83dc\u5927\u738b\u5185\u90e8\u77e5\u8bc6\u52a9\u624b\uff0c\u4e3b\u8981\u670d\u52a1\u5bf9\u8c61\u662f\u5ba2\u670d\u3001\u8d22\u52a1\u3001\u884c\u653f\u7b49\u5185\u90e8\u4eba\u5458\uff0c\u4e0d\u662f\u76f4\u63a5\u9762\u5411\u7ec8\u7aef\u5ba2\u6237\u7684\u673a\u5668\u4eba\u3002\n\n\u9ed8\u8ba4\u4e1a\u52a1\u4e0a\u4e0b\u6587\uff1a\n- \u7528\u6237\u672a\u7279\u522b\u8bf4\u660e\u65f6\uff0c\u5e73\u53f0\u3001APP\u3001\u540e\u53f0\u3001\u8ba2\u5355\u3001\u5ba2\u6237\u3001\u4f9b\u5e94\u5546\u5747\u9ed8\u8ba4\u6307\u83dc\u5927\u738b\u76f8\u5173\u7cfb\u7edf\u548c\u4e1a\u52a1\u3002\n- \u4e0d\u8981\u53cd\u95ee\u201c\u54ea\u4e2a\u5e73\u53f0/\u7cfb\u7edf\u201d\uff0c\u9664\u975e\u7528\u6237\u660e\u786e\u63d0\u5230\u5916\u90e8\u5e73\u53f0\u3001\u591a\u4e1a\u52a1\u7ebf\u51b2\u7a81\uff0c\u6216\u95ee\u9898\u672c\u8eab\u5fc5\u987b\u533a\u5206\u7cfb\u7edf\u3002\n\n\u56de\u7b54\u65b9\u5f0f\uff1a\n- \u5185\u90e8\u4eba\u5458\u8be2\u95ee\u5ba2\u6237\u54a8\u8be2\u7c7b\u95ee\u9898\u65f6\uff0c\u5148\u7ed9\u201c\u53ef\u8f6c\u8ff0\u7ed9\u5ba2\u6237\u7684\u7b54\u590d\u53e3\u5f84\u201d\uff0c\u518d\u7ed9\u5185\u90e8\u5904\u7406\u8981\u70b9\u3002\n- \u5bf9\u6d41\u7a0b\u7c7b\u95ee\u9898\uff0c\u4f18\u5148\u76f4\u63a5\u7ed9\u5165\u53e3\u3001\u6b65\u9aa4\u3001\u6761\u4ef6\u548c\u5f02\u5e38\u5904\u7406\uff0c\u4e0d\u8981\u4e00\u5f00\u59cb\u53ea\u8ffd\u95ee\u4fe1\u606f\u3002\n- \u53ea\u6709\u5728\u7f3a\u5c11\u8ba2\u5355\u53f7\u3001\u8ba2\u5355\u72b6\u6001\u3001\u53d1\u7968\u7c7b\u578b\u3001\u8d26\u53f7\u6743\u9650\u7b49\u4f1a\u5f71\u54cd\u529e\u7406\u7ed3\u679c\u7684\u5173\u952e\u4fe1\u606f\u65f6\uff0c\u624d\u8865\u5145\u9700\u8981\u786e\u8ba4\u7684\u4e8b\u9879\u3002\n- \u6d89\u53ca\u8d22\u52a1\u3001\u884c\u653f\u5236\u5ea6\u6216\u654f\u611f\u5185\u90e8\u4fe1\u606f\u65f6\uff0c\u53ea\u8f93\u51fa\u5f53\u524d\u89d2\u8272\u548c\u77e5\u8bc6\u5e93\u5141\u8bb8\u4f7f\u7528\u7684\u5185\u5bb9\uff1b\u8d44\u6599\u4e0d\u8db3\u65f6\u5982\u5b9e\u8bf4\u660e\uff0c\u4e0d\u8981\u7f16\u9020\u3002\n\n\u4fdd\u6301\u4e13\u4e1a\u3001\u7b80\u6d01\u3001\u53ef\u6267\u884c\u3002";
/** /**
* 初始化 ChatClient * 初始化 ChatClient
@ -393,14 +392,20 @@ public class AssistantApp {
* 多路查询扩展 + 检索去重合并返回命中的文档 RAG 回答与"引用来源"复用 * 多路查询扩展 + 检索去重合并返回命中的文档 RAG 回答与"引用来源"复用
*/ */
private List<Document> retrieveMultiQueryDocs(String message, List<Long> categoryIds) { private List<Document> retrieveMultiQueryDocs(String message, List<Long> categoryIds) {
// 1. 扩展为多个语义不同的查询已配置 includeOriginal含原始查询
List<String> expandedQueries = multiQueryExpanderRewriter.doQueryRewrite(message);
// 多路扩展依赖 RAG_REWRITE 模型扩展失败时退回原问题检索避免整次 RAG 不可用
List<String> expandedQueries;
try {
expandedQueries = multiQueryExpanderRewriter.doQueryRewrite(message);
} catch (Exception e) {
log.warn("多路查询扩展失败,降级为原始问题检索: {}", e.getMessage());
expandedQueries = List.of(message);
}
if (expandedQueries == null || expandedQueries.isEmpty()) { if (expandedQueries == null || expandedQueries.isEmpty()) {
expandedQueries = List.of(message); expandedQueries = List.of(message);
} }
log.info("多路查询扩展结果: {}", expandedQueries); log.info("多路查询扩展结果: {}", expandedQueries);
// 2. 对每个查询分别检索按文档ID去重合并封顶 maxDocs避免上下文膨胀
// 对每个查询分别检索按文档 ID 去重合并封顶 maxDocs避免上下文膨胀
final int maxDocs = 8; final int maxDocs = 8;
Map<String, Document> merged = new LinkedHashMap<>(); Map<String, Document> merged = new LinkedHashMap<>();
for (String query : expandedQueries) { for (String query : expandedQueries) {
@ -449,7 +454,12 @@ public class AssistantApp {
if (!StringUtils.hasText(context)) { if (!StringUtils.hasText(context)) {
return base; return base;
} }
return base + "\n\n请优先依据以下知识库资料回答用户问题;若资料不足以回答,请如实说明,不要编造。\n\n【知识库资料】\n" + context;
return base + "\n\n【RAG回答硬性规则】\n"
+ "- 只能依据下方【知识库资料】回答用户问题,不要调用模型自身记忆补充资料之外的内容。\n"
+ "- 回答诗词、制度、流程等具体内容时,必须从【知识库资料】中逐字引用或转述,不得使用未检索到的诗句、篇名、作者或步骤。\n"
+ "- 如果【知识库资料】中没有用户要求的内容,请明确说明未在当前知识库中检索到足够资料,并提示补充或调整知识库。\n"
+ "- 引用来源应能支撑回答内容;不要让回答内容和下方引用来源不一致。\n\n"
+ "【知识库资料】\n" + context;
} }
private SearchRequest buildRagSearchRequest(int topK, Long categoryId) { private SearchRequest buildRagSearchRequest(int topK, Long categoryId) {

16
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -80,6 +80,7 @@ public class DatabaseInitConfig {
log.info("创建会话归属表 conversation_session"); log.info("创建会话归属表 conversation_session");
createConversationSessionTable(); createConversationSessionTable();
} }
addConversationSessionExternalAccountIdColumn();
syncDefaultCustomerServiceRoles(); syncDefaultCustomerServiceRoles();
syncDefaultCustomerAccounts(); syncDefaultCustomerAccounts();
@ -307,6 +308,7 @@ public class DatabaseInitConfig {
CREATE TABLE IF NOT EXISTS conversation_session ( CREATE TABLE IF NOT EXISTS conversation_session (
conversation_id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) PRIMARY KEY,
account_id BIGINT, account_id BIGINT,
external_account_id VARCHAR(128),
role_id BIGINT, role_id BIGINT,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
@ -314,9 +316,23 @@ public class DatabaseInitConfig {
"""; """;
jdbcTemplate.execute(sql); jdbcTemplate.execute(sql);
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_account ON conversation_session (account_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_account ON conversation_session (account_id)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_external_account ON conversation_session (external_account_id)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_role ON conversation_session (role_id)"); jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_role ON conversation_session (role_id)");
} }
private void addConversationSessionExternalAccountIdColumn() {
try {
String checkSql = "SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'conversation_session' AND column_name = 'external_account_id'";
Integer count = jdbcTemplate.queryForObject(checkSql, Integer.class);
if (count != null && count == 0) {
jdbcTemplate.execute("ALTER TABLE conversation_session ADD COLUMN external_account_id VARCHAR(128)");
}
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_conversation_session_external_account ON conversation_session (external_account_id)");
} catch (Exception e) {
log.warn("add conversation_session.external_account_id failed: {}", e.getMessage());
}
}
private void syncDefaultCustomerServiceRoles() { private void syncDefaultCustomerServiceRoles() {
upsertDefaultRole("general", "客服", "用户咨询、业务办理、常见问题、问题受理与进度说明", upsertDefaultRole("general", "客服", "用户咨询、业务办理、常见问题、问题受理与进度说明",
""" """

20
src/main/java/com/wok/supportbot/controller/AiController.java

@ -3,8 +3,6 @@ package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp; import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.config.RoleAccessConfig; import com.wok.supportbot.config.RoleAccessConfig;
import com.wok.supportbot.service.ConversationService; import com.wok.supportbot.service.ConversationService;
import com.wok.supportbot.service.CustomerAccountService;
import com.wok.supportbot.service.CustomerAccountService.AccountScope;
import com.wok.supportbot.service.CustomerServiceRoleService; import com.wok.supportbot.service.CustomerServiceRoleService;
import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope; import com.wok.supportbot.service.CustomerServiceRoleService.RoleScope;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@ -39,8 +37,6 @@ public class AiController {
@Resource @Resource
private CustomerServiceRoleService customerServiceRoleService; private CustomerServiceRoleService customerServiceRoleService;
@Resource @Resource
private CustomerAccountService customerAccountService;
@Resource
private ConversationService conversationService; private ConversationService conversationService;
@Resource @Resource
private RoleAccessConfig roleAccessConfig; private RoleAccessConfig roleAccessConfig;
@ -258,16 +254,14 @@ public class AiController {
return (rewriteStrategy != null && !rewriteStrategy.isEmpty()) ? rewriteStrategy : "MULTI_QUERY"; return (rewriteStrategy != null && !rewriteStrategy.isEmpty()) ? rewriteStrategy : "MULTI_QUERY";
} }
/** 命中角色时用角色人设,否则用客户端兜底人设。 */
/**
* 命中角色时用 roleId 套用角色人设和知识库范围
* accountId SDK 宿主侧的外部用户标识后端不再创建或查询本地账号也不允许它覆盖 roleId
*/
private AccountRoleContext resolveAccountRole(String accountId, Long fallbackRoleId) { private AccountRoleContext resolveAccountRole(String accountId, Long fallbackRoleId) {
AccountScope accountScope = customerAccountService.getOrCreateAccountScope(accountId, fallbackRoleId);
Long effectiveRoleId = accountScope.hasAccount() && accountScope.roleId() != null
? accountScope.roleId()
: fallbackRoleId;
Long effectiveAccountId = accountScope.hasAccount() ? accountScope.accountId() : null;
return new AccountRoleContext(effectiveAccountId, effectiveRoleId);
String externalAccountId = StringUtils.hasText(accountId) ? accountId.trim() : null;
return new AccountRoleContext(externalAccountId, fallbackRoleId);
} }
private void bindConversation(String chatId, AccountRoleContext context) { private void bindConversation(String chatId, AccountRoleContext context) {
conversationService.bindConversation(chatId, context.accountId(), context.roleId()); conversationService.bindConversation(chatId, context.accountId(), context.roleId());
} }
@ -308,7 +302,7 @@ public class AiController {
.toList(); .toList();
} }
private record AccountRoleContext(Long accountId, Long roleId) {
private record AccountRoleContext(String accountId, Long roleId) {
} }
} }

6
src/main/java/com/wok/supportbot/controller/ConversationController.java

@ -37,7 +37,6 @@ public class ConversationController {
* @return 分页会话列表 * @return 分页会话列表
*/ */
@GetMapping("/conversation/list") @GetMapping("/conversation/list")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<Map<String, Object>> listConversations( public ResponseEntity<Map<String, Object>> listConversations(
@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size, @RequestParam(defaultValue = "10") int size,
@ -74,7 +73,6 @@ public class ConversationController {
* @return 逻辑删除的消息条数 * @return 逻辑删除的消息条数
*/ */
@PostMapping("/conversation/{id}/truncate") @PostMapping("/conversation/{id}/truncate")
@PreAuthorize("hasAnyRole('admin','cs_agent')")
public ResponseEntity<Map<String, Object>> truncateConversation( public ResponseEntity<Map<String, Object>> truncateConversation(
@PathVariable("id") String conversationId, @PathVariable("id") String conversationId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
@ -130,7 +128,6 @@ public class ConversationController {
* @return 消息列表 * @return 消息列表
*/ */
@GetMapping("/conversation/{id}/messages") @GetMapping("/conversation/{id}/messages")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<Map<String, Object>> getConversationMessages(@PathVariable("id") String conversationId) { public ResponseEntity<Map<String, Object>> getConversationMessages(@PathVariable("id") String conversationId) {
try { try {
List<Map<String, Object>> messages = conversationService.getConversationMessages(conversationId); List<Map<String, Object>> messages = conversationService.getConversationMessages(conversationId);
@ -156,7 +153,6 @@ public class ConversationController {
* @return 删除结果 * @return 删除结果
*/ */
@DeleteMapping("/conversation/{id}") @DeleteMapping("/conversation/{id}")
@PreAuthorize("hasAnyRole('admin','cs_agent')")
public ResponseEntity<Map<String, Object>> deleteConversation(@PathVariable("id") String conversationId) { public ResponseEntity<Map<String, Object>> deleteConversation(@PathVariable("id") String conversationId) {
try { try {
int count = conversationService.deleteConversation(conversationId); int count = conversationService.deleteConversation(conversationId);
@ -182,7 +178,6 @@ public class ConversationController {
* @return 格式化后的会话文本 * @return 格式化后的会话文本
*/ */
@GetMapping(value = "/conversation/{id}/export", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8") @GetMapping(value = "/conversation/{id}/export", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<String> exportConversation(@PathVariable("id") String conversationId) { public ResponseEntity<String> exportConversation(@PathVariable("id") String conversationId) {
try { try {
String content = conversationService.exportConversation(conversationId); String content = conversationService.exportConversation(conversationId);
@ -202,7 +197,6 @@ public class ConversationController {
* @return 统计信息 * @return 统计信息
*/ */
@GetMapping("/conversation/stats") @GetMapping("/conversation/stats")
@PreAuthorize("hasAnyRole('admin','cs_agent','viewer','kb_operator')")
public ResponseEntity<Map<String, Object>> getConversationStats() { public ResponseEntity<Map<String, Object>> getConversationStats() {
try { try {
Map<String, Object> stats = conversationService.getConversationStats(); Map<String, Object> stats = conversationService.getConversationStats();

61
src/main/java/com/wok/supportbot/service/ConversationService.java

@ -52,7 +52,6 @@ public class ConversationService {
* @param userId 系统用户IDnull 表示不过滤SDK 调用场景 * @param userId 系统用户IDnull 表示不过滤SDK 调用场景
*/ */
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId, Long userId) { public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId, Long userId) {
// 构建基础 SQL 条件
StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false "); StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false ");
List<Object> params = new ArrayList<>(); List<Object> params = new ArrayList<>();
@ -60,8 +59,7 @@ public class ConversationService {
whereClause.append(""" whereClause.append("""
AND (cm1.conversation_id ILIKE ? AND (cm1.conversation_id ILIKE ?
OR cm1.content ILIKE ? OR cm1.content ILIKE ?
OR ca.name ILIKE ?
OR ca.account_key ILIKE ?
OR cs.external_account_id ILIKE ?
OR csr.name ILIKE ?) OR csr.name ILIKE ?)
"""); """);
String like = "%" + keyword + "%"; String like = "%" + keyword + "%";
@ -69,19 +67,10 @@ public class ConversationService {
params.add(like); params.add(like);
params.add(like); params.add(like);
params.add(like); params.add(like);
params.add(like);
} }
if (accountId != null && !accountId.isBlank()) { if (accountId != null && !accountId.isBlank()) {
String normalizedAccountId = accountId.trim();
Long numericAccountId = toLong(normalizedAccountId);
if (numericAccountId != null && numericAccountId > 0) {
whereClause.append(" AND (cs.account_id = ? OR ca.account_key = ?) ");
params.add(numericAccountId);
params.add(normalizedAccountId);
} else {
whereClause.append(" AND ca.account_key = ? ");
params.add(normalizedAccountId);
}
whereClause.append(" AND cs.external_account_id = ? ");
params.add(accountId.trim());
} }
if (roleId != null && roleId > 0) { if (roleId != null && roleId > 0) {
whereClause.append(" AND cs.role_id = ? "); whereClause.append(" AND cs.role_id = ? ");
@ -93,24 +82,21 @@ public class ConversationService {
params.add(userId); params.add(userId);
} }
// 查询总会话数
String countSql = """ String countSql = """
SELECT COUNT(DISTINCT cm1.conversation_id) SELECT COUNT(DISTINCT cm1.conversation_id)
FROM chat_message cm1 FROM chat_message cm1
LEFT JOIN conversation_session cs ON cs.conversation_id = cm1.conversation_id LEFT JOIN conversation_session cs ON cs.conversation_id = cm1.conversation_id
LEFT JOIN customer_account ca ON ca.id = cs.account_id AND ca.is_delete = false
LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false
""" + whereClause; """ + whereClause;
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray()); Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
if (total == null) total = 0L; if (total == null) total = 0L;
// 查询会话列表带统计信息
String listSql = """ String listSql = """
SELECT SELECT
cm1.conversation_id, cm1.conversation_id,
cs.account_id::text AS account_id,
ca.name AS account_name,
ca.account_key,
cs.external_account_id AS account_id,
cs.external_account_id AS account_key,
NULL::text AS account_name,
cs.role_id::text AS role_id, cs.role_id::text AS role_id,
csr.name AS role_name, csr.name AS role_name,
COUNT(*) as message_count, COUNT(*) as message_count,
@ -121,10 +107,9 @@ public class ConversationService {
ORDER BY cm2.create_time DESC LIMIT 1) as last_message_preview ORDER BY cm2.create_time DESC LIMIT 1) as last_message_preview
FROM chat_message cm1 FROM chat_message cm1
LEFT JOIN conversation_session cs ON cs.conversation_id = cm1.conversation_id LEFT JOIN conversation_session cs ON cs.conversation_id = cm1.conversation_id
LEFT JOIN customer_account ca ON ca.id = cs.account_id AND ca.is_delete = false
LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false
""" + whereClause + """ """ + whereClause + """
GROUP BY cm1.conversation_id, cs.account_id, ca.name, ca.account_key, cs.role_id, csr.name
GROUP BY cm1.conversation_id, cs.external_account_id, cs.role_id, csr.name
ORDER BY last_message_time DESC ORDER BY last_message_time DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
"""; """;
@ -135,7 +120,6 @@ public class ConversationService {
List<Map<String, Object>> records = jdbcTemplate.queryForList(listSql, List<Map<String, Object>> records = jdbcTemplate.queryForList(listSql,
listParams.toArray()); listParams.toArray());
// 格式化结果
List<Map<String, Object>> formattedRecords = new ArrayList<>(); List<Map<String, Object>> formattedRecords = new ArrayList<>();
for (Map<String, Object> record : records) { for (Map<String, Object> record : records) {
Map<String, Object> formatted = new LinkedHashMap<>(); Map<String, Object> formatted = new LinkedHashMap<>();
@ -163,22 +147,22 @@ public class ConversationService {
return result; return result;
} }
public void bindConversation(String conversationId, Long accountId, Long roleId) {
public void bindConversation(String conversationId, String accountId, Long roleId) {
if (conversationId == null || conversationId.isBlank()) { if (conversationId == null || conversationId.isBlank()) {
return; return;
} }
Long normalizedAccountId = accountId != null && accountId > 0 ? accountId : null;
String externalAccountId = accountId != null && !accountId.isBlank() ? accountId.trim() : null;
Long normalizedRoleId = roleId != null && roleId > 0 ? roleId : null; Long normalizedRoleId = roleId != null && roleId > 0 ? roleId : null;
jdbcTemplate.update(""" jdbcTemplate.update("""
INSERT INTO conversation_session (conversation_id, account_id, role_id)
INSERT INTO conversation_session (conversation_id, external_account_id, role_id)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT (conversation_id) ON CONFLICT (conversation_id)
DO UPDATE SET account_id = COALESCE(EXCLUDED.account_id, conversation_session.account_id),
DO UPDATE SET external_account_id = EXCLUDED.external_account_id,
account_id = NULL,
role_id = COALESCE(EXCLUDED.role_id, conversation_session.role_id), role_id = COALESCE(EXCLUDED.role_id, conversation_session.role_id),
update_time = CURRENT_TIMESTAMP update_time = CURRENT_TIMESTAMP
""", conversationId, normalizedAccountId, normalizedRoleId);
""", conversationId, externalAccountId, normalizedRoleId);
} }
/** /**
* 截断会话删除"第 userTurn 条用户消息"及其之后的所有消息逻辑删除 * 截断会话删除"第 userTurn 条用户消息"及其之后的所有消息逻辑删除
* 用于"编辑历史消息并重发"清掉发错的那条用户消息连同其后的问答 * 用于"编辑历史消息并重发"清掉发错的那条用户消息连同其后的问答
@ -257,13 +241,12 @@ public class ConversationService {
private void appendConversationOwner(Map<String, Object> detail, String conversationId) { private void appendConversationOwner(Map<String, Object> detail, String conversationId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(""" List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT cs.account_id::text AS account_id,
ca.name AS account_name,
ca.account_key,
SELECT cs.external_account_id AS account_id,
cs.external_account_id AS account_key,
NULL::text AS account_name,
cs.role_id::text AS role_id, cs.role_id::text AS role_id,
csr.name AS role_name csr.name AS role_name
FROM conversation_session cs FROM conversation_session cs
LEFT JOIN customer_account ca ON ca.id = cs.account_id AND ca.is_delete = false
LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false
WHERE cs.conversation_id = ? WHERE cs.conversation_id = ?
""", conversationId); """, conversationId);
@ -282,7 +265,6 @@ public class ConversationService {
detail.put("roleId", owner.get("role_id")); detail.put("roleId", owner.get("role_id"));
detail.put("roleName", owner.get("role_name")); detail.put("roleName", owner.get("role_name"));
} }
// ==================== 会话消息 ==================== // ==================== 会话消息 ====================
/** /**
@ -462,14 +444,5 @@ public class ConversationService {
return stats; return stats;
} }
private static Long toLong(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return Long.valueOf(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
} }

47
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -296,9 +296,52 @@ public class DocumentService {
* 获取文档的所有分块 * 获取文档的所有分块
*/ */
public List<Map<String, Object>> getDocumentChunks(Long id) { public List<Map<String, Object>> getDocumentChunks(Long id) {
String documentId = String.valueOf(id);
String sql = "SELECT id::text as id, content, metadata, create_time FROM vector_store " + String sql = "SELECT id::text as id, content, metadata, create_time FROM vector_store " +
"WHERE metadata->>'documentId' = ? ORDER BY (metadata->>'chunkIndex')::int";
return jdbcTemplate.queryForList(sql, String.valueOf(id));
"WHERE metadata->>'documentId' = ? " +
" OR metadata->>'document_id' = ? " +
" OR metadata->>'docId' = ? " +
"ORDER BY " + chunkOrderSql();
List<Map<String, Object>> chunks = jdbcTemplate.queryForList(sql, documentId, documentId, documentId);
if (!chunks.isEmpty()) {
return chunks;
}
KnowledgeDocument doc = documentMapper.selectById(id);
if (doc == null) {
return chunks;
}
return getDocumentChunksByTitleFallback(doc);
}
private List<Map<String, Object>> getDocumentChunksByTitleFallback(KnowledgeDocument doc) {
List<String> filters = new ArrayList<>();
List<Object> params = new ArrayList<>();
if (doc.getSourceName() != null && !doc.getSourceName().isBlank()) {
filters.add("metadata->>'sourceName' = ?");
params.add(doc.getSourceName());
}
if (doc.getTitle() != null && !doc.getTitle().isBlank()) {
filters.add("metadata->>'title' = ?");
params.add(doc.getTitle());
}
if (filters.isEmpty()) {
return List.of();
}
String sql = "SELECT id::text as id, content, metadata, create_time FROM vector_store " +
"WHERE " + String.join(" OR ", filters) + " " +
"ORDER BY " + chunkOrderSql();
if (doc.getChunkCount() != null && doc.getChunkCount() > 0) {
sql += " LIMIT ?";
params.add(doc.getChunkCount());
}
return jdbcTemplate.queryForList(sql, params.toArray());
}
private String chunkOrderSql() {
return "CASE WHEN metadata->>'chunkIndex' ~ '^[0-9]+$' " +
"THEN (metadata->>'chunkIndex')::int ELSE 0 END ASC, create_time ASC, id::text ASC";
} }
/** /**

5
src/main/resources/db/migration/V2__conversation_external_account_id.sql

@ -0,0 +1,5 @@
ALTER TABLE conversation_session
ADD COLUMN IF NOT EXISTS external_account_id VARCHAR(128);
CREATE INDEX IF NOT EXISTS idx_conversation_session_external_account
ON conversation_session (external_account_id);

2
src/main/resources/init-database.sql

@ -240,12 +240,14 @@ ON CONFLICT (account_key) DO NOTHING;
CREATE TABLE IF NOT EXISTS conversation_session ( CREATE TABLE IF NOT EXISTS conversation_session (
conversation_id VARCHAR(64) PRIMARY KEY, conversation_id VARCHAR(64) PRIMARY KEY,
account_id BIGINT, account_id BIGINT,
external_account_id VARCHAR(128),
role_id BIGINT, role_id BIGINT,
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_conversation_session_account ON conversation_session (account_id); CREATE INDEX IF NOT EXISTS idx_conversation_session_account ON conversation_session (account_id);
CREATE INDEX IF NOT EXISTS idx_conversation_session_external_account ON conversation_session (external_account_id);
CREATE INDEX IF NOT EXISTS idx_conversation_session_role ON conversation_session (role_id); CREATE INDEX IF NOT EXISTS idx_conversation_session_role ON conversation_session (role_id);
COMMENT ON TABLE conversation_session IS '会话归属表'; COMMENT ON TABLE conversation_session IS '会话归属表';

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

@ -2,7 +2,7 @@
* 智能客服对话面板 * 智能客服对话面板
*/ */
import { ref, computed, nextTick, onMounted } from 'vue' import { ref, computed, nextTick, onMounted } from 'vue'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getAccountList, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js'
import { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getActiveModelConfig, truncateConversation, ragSources, submitFeedback as submitFeedbackApi } from '../js/api.js'
import { toast, readSSEStream, renderMarkdown } from '../js/utils.js' import { toast, readSSEStream, renderMarkdown } from '../js/utils.js'
import { store } from '../js/store.js' import { store } from '../js/store.js'
import MessageSources from './MessageSources.js' import MessageSources from './MessageSources.js'
@ -38,16 +38,6 @@ export default {
</div> </div>
</div> </div>
<div class="side-section">
<div class="side-label">对话账号</div>
<select class="select chat-select" v-model="selectedAccount" @change="selectAccount">
<option value="">未选择账号</option>
<option v-for="account in accounts" :key="account.id" :value="account.id">
{{ account.name }}{{ account.role_name ? ' / ' + account.role_name : ' / 未绑定角色' }}
</option>
</select>
</div>
<div class="side-section side-section-grow"> <div class="side-section side-section-grow">
<div class="side-label">客服助手</div> <div class="side-label">客服助手</div>
<div class="assistant-list"> <div class="assistant-list">
@ -77,6 +67,10 @@ export default {
</div> </div>
</div> </div>
<div class="chat-actions"> <div class="chat-actions">
<div class="model-chip" :class="{'model-chip-error': modelLoadError}" :title="modelTitle">
<span>调用模型</span>
<strong>{{ activeModelText }}</strong>
</div>
<label class="rag-toggle"> <label class="rag-toggle">
<input type="checkbox" v-model="isRagMode"> <input type="checkbox" v-model="isRagMode">
<span>RAG &#x68C0;&#x7D22;</span> <span>RAG &#x68C0;&#x7D22;</span>
@ -154,12 +148,12 @@ export default {
setup() { setup() {
const chatId = ref('') const chatId = ref('')
const mode = ref('sync') const mode = ref('sync')
const selectedAccount = ref('')
const accounts = ref([])
const selectedRole = ref('general') const selectedRole = ref('general')
const roles = ref([FALLBACK_ROLE]) const roles = ref([FALLBACK_ROLE])
const isRagMode = ref(false) const isRagMode = ref(false)
const ragStrategy = ref('MULTI_QUERY') const ragStrategy = ref('MULTI_QUERY')
const activeModel = ref(null)
const modelLoadError = ref('')
const userInput = ref('') const userInput = ref('')
const lastUserInput = ref('') const lastUserInput = ref('')
const isSending = ref(false) const isSending = ref(false)
@ -175,13 +169,7 @@ export default {
const editingIndex = ref(-1) const editingIndex = ref(-1)
const editingText = ref('') const editingText = ref('')
const currentAccount = computed(() => accounts.value.find(account => String(account.id) === String(selectedAccount.value)) || null)
const currentRole = computed(() => {
if (currentAccount.value && currentAccount.value.role_id) {
return roles.value.find(role => String(role.id) === String(currentAccount.value.role_id)) || roles.value[0] || FALLBACK_ROLE
}
return roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE
})
const currentRole = computed(() => roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE)
const selectedCategoryIds = computed(() => currentRole.value.categoryIds || []) const selectedCategoryIds = computed(() => currentRole.value.categoryIds || [])
const selectedCategoryNames = computed(() => { const selectedCategoryNames = computed(() => {
const selected = new Set(selectedCategoryIds.value) const selected = new Set(selectedCategoryIds.value)
@ -189,6 +177,24 @@ export default {
.filter(category => selected.has(String(category.id))) .filter(category => selected.has(String(category.id)))
.map(category => category.name) .map(category => category.name)
}) })
const activeModelText = computed(() => {
if (activeModel.value) {
const provider = providerLabel(activeModel.value.provider)
const modelName = activeModel.value.modelName || activeModel.value.model_name || '-'
return provider ? `${provider} / ${modelName}` : modelName
}
return modelLoadError.value ? '未配置' : '加载中'
})
const modelTitle = computed(() => {
if (activeModel.value) {
const model = activeModel.value
const name = model.name ? `${model.name}` : ''
const provider = providerLabel(model.provider)
const modelName = model.modelName || model.model_name || '-'
return `对话模型:${name}${provider ? provider + ' / ' : ''}${modelName}`
}
return modelLoadError.value || '正在加载对话模型'
})
const ragStatusText = computed(() => { const ragStatusText = computed(() => {
if (!isRagMode.value) return '普通对话' if (!isRagMode.value) return '普通对话'
const names = { const names = {
@ -212,34 +218,16 @@ export default {
function newChatId() { function newChatId() {
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8) chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
} }
function selectRole(roleKey) { function selectRole(roleKey) {
const role = roles.value.find(item => item.key === roleKey)
// 双向绑定:选助手时自动同步到绑定该角色的账号;没有对应账号则进入"无账号"模式
const boundAccount = role
? accounts.value.find(account => String(account.role_id) === String(role.id) && account.enabled !== false)
: null
selectedAccount.value = boundAccount ? String(boundAccount.id) : ''
selectedRole.value = roleKey selectedRole.value = roleKey
newChatId() newChatId()
clearMessages(roleKey) clearMessages(roleKey)
} }
function selectAccount() {
const account = currentAccount.value
if (account && account.role_id) {
const role = roles.value.find(item => String(item.id) === String(account.role_id))
if (role) selectedRole.value = role.key
}
newChatId()
clearMessages()
}
function clearMessages(roleKey = selectedRole.value) { function clearMessages(roleKey = selectedRole.value) {
const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE
messages.value = [{ messages.value = [{
role: 'assistant', role: 'assistant',
content: `已切换到${role.name}我会按“${role.tone}”的方式处理问题。`,
content: `已切换到${role.name}。请直接输入你的问题。`,
streaming: false, streaming: false,
time: formatTime() time: formatTime()
}] }]
@ -250,6 +238,34 @@ export default {
send() send()
} }
function providerLabel(provider) {
const map = {
dashscope: '通义千问',
openai: 'OpenAI',
volcengine: '豆包',
zhipu: '智谱',
baidu: '百度千帆',
other: '自定义'
}
return map[String(provider || '').toLowerCase()] || provider || ''
}
async function loadActiveModel() {
try {
const json = await getActiveModelConfig('CHAT')
if (json.success) {
activeModel.value = json.data
modelLoadError.value = ''
} else {
activeModel.value = null
modelLoadError.value = json.message || '对话模型未配置'
}
} catch (e) {
activeModel.value = null
modelLoadError.value = e.message || '模型加载失败'
}
}
async function loadRoles() { async function loadRoles() {
try { try {
const json = await getRoleList() const json = await getRoleList()
@ -259,7 +275,7 @@ export default {
key: role.role_key || role.roleKey || String(role.id), key: role.role_key || role.roleKey || String(role.id),
name: role.name, name: role.name,
desc: role.description || '', desc: role.description || '',
tone: role.prompt || '专业、耐心、简洁',
tone: role.description || '专业、耐心、简洁',
badge: role.name ? role.name.slice(0, 2) : '角色', badge: role.name ? role.name.slice(0, 2) : '角色',
categoryIds: (role.categoryIds || []).map(String) categoryIds: (role.categoryIds || []).map(String)
})) }))
@ -272,20 +288,6 @@ export default {
} }
} }
async function loadAccounts() {
try {
const json = await getAccountList()
if (json.success) {
accounts.value = json.data || []
if (!selectedAccount.value && accounts.value.length) {
selectedAccount.value = String(accounts.value[0].id)
selectAccount()
}
}
} catch (e) {
toast('加载账号失败:' + e.message, 'error')
}
}
function currentRoleId() { function currentRoleId() {
// 角色人设与知识库范围统一由服务端依据 roleId 套用,这里仅取出当前角色ID。 // 角色人设与知识库范围统一由服务端依据 roleId 套用,这里仅取出当前角色ID。
@ -293,9 +295,6 @@ export default {
return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : '' return currentRole.value && currentRole.value.id ? String(currentRole.value.id) : ''
} }
function currentAccountId() {
return currentAccount.value && currentAccount.value.id ? String(currentAccount.value.id) : ''
}
async function scrollToBottom() { async function scrollToBottom() {
await nextTick() await nextTick()
@ -320,21 +319,20 @@ export default {
// 发送用户原始问题(不再包装角色信息,避免污染会话记忆); // 发送用户原始问题(不再包装角色信息,避免污染会话记忆);
// 角色人设与知识库范围由服务端依据 roleId 强制套用。 // 角色人设与知识库范围由服务端依据 roleId 强制套用。
const roleId = currentRoleId() const roleId = currentRoleId()
const accountId = currentAccountId()
try { try {
if (isRagMode.value && mode.value === 'sync') { if (isRagMode.value && mode.value === 'sync') {
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId, accountId)
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId)
} else if (isRagMode.value) { } else if (isRagMode.value) {
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId, accountId)
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId)
await readSSEStream(url, async (chunk) => { await readSSEStream(url, async (chunk) => {
assistantMsg.content += chunk assistantMsg.content += chunk
await scrollToBottom() await scrollToBottom()
}, () => {}) }, () => {})
} else if (mode.value === 'sync') { } else if (mode.value === 'sync') {
assistantMsg.content = await chatSync(text, cid, roleId, accountId)
assistantMsg.content = await chatSync(text, cid, roleId)
} else { } else {
const url = chatSSEUrl(text, cid, mode.value, roleId, accountId)
const url = chatSSEUrl(text, cid, mode.value, roleId)
await readSSEStream(url, async (chunk) => { await readSSEStream(url, async (chunk) => {
assistantMsg.content += chunk assistantMsg.content += chunk
await scrollToBottom() await scrollToBottom()
@ -343,7 +341,7 @@ export default {
// RAG 模式:回答完成后拉取本次命中的知识库片段,挂到该条回答下方 // RAG 模式:回答完成后拉取本次命中的知识库片段,挂到该条回答下方
if (isRagMode.value) { if (isRagMode.value) {
try { try {
const sj = await ragSources(text, cid, ragStrategy.value, roleId, accountId)
const sj = await ragSources(text, cid, ragStrategy.value, roleId)
if (sj && sj.success) assistantMsg.sources = sj.data || [] if (sj && sj.success) assistantMsg.sources = sj.data || []
} catch (_) { /* 来源获取失败不影响主回答 */ } } catch (_) { /* 来源获取失败不影响主回答 */ }
} }
@ -461,13 +459,12 @@ export default {
onMounted(() => { onMounted(() => {
newChatId() newChatId()
store.loadCategories() store.loadCategories()
loadRoles().then(loadAccounts)
loadRoles()
loadActiveModel()
}) })
return { return {
store, store,
accounts,
selectedAccount,
roles, roles,
quickQuestions: QUICK_QUESTIONS, quickQuestions: QUICK_QUESTIONS,
chatId, chatId,
@ -476,13 +473,15 @@ export default {
selectedCategoryNames, selectedCategoryNames,
isRagMode, isRagMode,
ragStrategy, ragStrategy,
activeModelText,
modelTitle,
modelLoadError,
userInput, userInput,
isSending, isSending,
messages, messages,
msgArea, msgArea,
currentRole, currentRole,
ragStatusText, ragStatusText,
selectAccount,
newChatId, newChatId,
selectRole, selectRole,
clearMessages, clearMessages,

24
src/main/resources/static/components/ConversationManager.js

@ -3,7 +3,7 @@
* 展示会话列表消息详情删除导出功能 * 展示会话列表消息详情删除导出功能
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation, getAccountList, getRoleList } from '../js/api.js'
import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation, getRoleList } from '../js/api.js'
import { toast, formatDate } from '../js/utils.js' import { toast, formatDate } from '../js/utils.js'
export default { export default {
@ -34,10 +34,7 @@ export default {
<!-- 搜索栏 --> <!-- 搜索栏 -->
<div class="input-row"> <div class="input-row">
<input type="text" class="input" v-model="keyword" placeholder="搜索会话ID或消息内容..." @keyup.enter="load(1)"> <input type="text" class="input" v-model="keyword" placeholder="搜索会话ID或消息内容..." @keyup.enter="load(1)">
<select class="select" v-model="accountFilter">
<option value="">全部账号</option>
<option v-for="account in accounts" :key="account.id" :value="account.id">{{ account.name }}</option>
</select>
<input type="text" class="input" v-model="accountFilter" placeholder="外部用户ID(accountId)" @keyup.enter="load(1)">
<select class="select" v-model="roleFilter"> <select class="select" v-model="roleFilter">
<option value="">全部角色</option> <option value="">全部角色</option>
<option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option> <option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option>
@ -52,7 +49,7 @@ export default {
<thead> <thead>
<tr> <tr>
<th>会话ID</th> <th>会话ID</th>
<th>账号</th>
<th>外部用户ID</th>
<th>角色</th> <th>角色</th>
<th>消息数</th> <th>消息数</th>
<th>最后消息时间</th> <th>最后消息时间</th>
@ -68,7 +65,7 @@ export default {
<td> <td>
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.conversationId }}</code> <code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.conversationId }}</code>
</td> </td>
<td>{{ c.accountName || '-' }}</td>
<td>{{ c.accountId || '-' }}</td>
<td>{{ c.roleName || '-' }}</td> <td>{{ c.roleName || '-' }}</td>
<td>{{ c.messageCount }}</td> <td>{{ c.messageCount }}</td>
<td>{{ formatDate(c.lastMessageTime) }}</td> <td>{{ formatDate(c.lastMessageTime) }}</td>
@ -104,7 +101,6 @@ export default {
<h2>会话消息详情</h2> <h2>会话消息详情</h2>
<p style="font-size:13px;color:var(--sub);margin-bottom:12px;"> <p style="font-size:13px;color:var(--sub);margin-bottom:12px;">
会话ID: <code>{{ messageModal.conversationId }}</code> 会话ID: <code>{{ messageModal.conversationId }}</code>
<span v-if="messageModal.accountName"> · 账号{{ messageModal.accountName }}</span>
<span v-if="messageModal.roleName"> · 角色{{ messageModal.roleName }}</span> <span v-if="messageModal.roleName"> · 角色{{ messageModal.roleName }}</span>
· {{ messageModal.messages.length }} 条消息 · {{ messageModal.messages.length }} 条消息
</p> </p>
@ -135,9 +131,8 @@ export default {
const pages = ref(1) const pages = ref(1)
const total = ref(0) const total = ref(0)
const keyword = ref('') const keyword = ref('')
const accountFilter = ref('')
const roleFilter = ref('') const roleFilter = ref('')
const accounts = ref([])
const accountFilter = ref('')
const roles = ref([]) const roles = ref([])
const stats = ref(null) const stats = ref(null)
@ -145,7 +140,6 @@ export default {
const messageModal = ref({ const messageModal = ref({
visible: false, visible: false,
conversationId: '', conversationId: '',
accountName: '',
roleName: '', roleName: '',
messages: [] messages: []
}) })
@ -173,14 +167,12 @@ export default {
async function loadFilters() { async function loadFilters() {
try { try {
const [accountJson, roleJson] = await Promise.all([getAccountList(), getRoleList()])
if (accountJson.success) accounts.value = accountJson.data || []
const roleJson = await getRoleList()
if (roleJson.success) roles.value = roleJson.data || [] if (roleJson.success) roles.value = roleJson.data || []
} catch (e) { } catch (e) {
console.error('加载筛选项失败', e) console.error('加载筛选项失败', e)
} }
} }
async function loadStats() { async function loadStats() {
try { try {
const json = await getConversationStats() const json = await getConversationStats()
@ -199,7 +191,6 @@ export default {
messageModal.value.conversationId = conversationId messageModal.value.conversationId = conversationId
messageModal.value.messages = json.data || [] messageModal.value.messages = json.data || []
const current = conversations.value.find(item => item.conversationId === conversationId) const current = conversations.value.find(item => item.conversationId === conversationId)
messageModal.value.accountName = current?.accountName || ''
messageModal.value.roleName = current?.roleName || '' messageModal.value.roleName = current?.roleName || ''
messageModal.value.visible = true messageModal.value.visible = true
} else { } else {
@ -213,7 +204,6 @@ export default {
function closeMessageModal() { function closeMessageModal() {
messageModal.value.visible = false messageModal.value.visible = false
messageModal.value.conversationId = '' messageModal.value.conversationId = ''
messageModal.value.accountName = ''
messageModal.value.roleName = '' messageModal.value.roleName = ''
messageModal.value.messages = [] messageModal.value.messages = []
} }
@ -258,7 +248,7 @@ export default {
loadStats() loadStats()
return { return {
conversations, page, pages, total, keyword, accountFilter, roleFilter, accounts, roles, stats, messageModal,
conversations, page, pages, total, keyword, accountFilter, roleFilter, roles, stats, messageModal,
load, loadStats, viewMessages, closeMessageModal, remove, downloadExport, formatDate load, loadStats, viewMessages, closeMessageModal, remove, downloadExport, formatDate
} }
} }

1
src/main/resources/static/components/DocDetail.js

@ -28,6 +28,7 @@ export default {
<div style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:200px;overflow-y:auto;">{{ store.detailModal.doc.content || '-' }}</div> <div style="background:#fafafa;padding:12px;border-radius:8px;border:1px solid var(--border);font-size:13px;line-height:1.6;max-height:200px;overflow-y:auto;">{{ store.detailModal.doc.content || '-' }}</div>
</div> </div>
<h3>分块详情{{ store.detailModal.chunks.length }} </h3> <h3>分块详情{{ store.detailModal.chunks.length }} </h3>
<div v-if="store.detailModal.chunksError" style="margin:8px 0 12px;padding:10px 12px;border:1px solid #f59e0b;background:#fffbeb;color:#92400e;border-radius:8px;font-size:13px;">{{ store.detailModal.chunksError }}</div>
<div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result"> <div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result">
<div class="meta">#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}</div> <div class="meta">#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}</div>

20
src/main/resources/static/css/main.css

@ -32,8 +32,8 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
/* ==================== 侧边栏 ==================== */ /* ==================== 侧边栏 ==================== */
.sidebar { width:220px; flex:none; background:#1f2937; color:#e5e7eb; display:flex; flex-direction:column; transition:width .25s cubic-bezier(.4,0,.2,1); overflow:hidden; z-index:50; } .sidebar { width:220px; flex:none; background:#1f2937; color:#e5e7eb; display:flex; flex-direction:column; transition:width .25s cubic-bezier(.4,0,.2,1); overflow:hidden; z-index:50; }
.sidebar.collapsed { width:56px; } .sidebar.collapsed { width:56px; }
.sidebar-inner { display:flex; flex-direction:column; flex:1; min-height:0; overflow-y:auto; overflow-x:hidden; }
.sidebar-nav { flex:1; min-height:0; overflow-y:auto; padding:8px 0; }
.sidebar-inner { display:flex; flex-direction:column; flex:1; min-height:0; overflow:hidden; }
.sidebar-nav { flex:1; min-height:0; overflow-y:auto; overflow-x:hidden; padding:8px 0 12px; }
/* 菜单项 */ /* 菜单项 */
.menu-item { display:flex; align-items:center; gap:10px; width:100%; padding:10px 16px; border:none; background:none; color:#d1d5db; font-size:14px; cursor:pointer; transition:background .15s, color .15s; text-align:left; font-family:inherit; white-space:nowrap; position:relative; } .menu-item { display:flex; align-items:center; gap:10px; width:100%; padding:10px 16px; border:none; background:none; color:#d1d5db; font-size:14px; cursor:pointer; transition:background .15s, color .15s; text-align:left; font-family:inherit; white-space:nowrap; position:relative; }
@ -49,7 +49,7 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
/* 子菜单 */ /* 子菜单 */
.menu-child { padding-left:30px; font-size:13px; } .menu-child { padding-left:30px; font-size:13px; }
.submenu { overflow:hidden; transition:max-height .25s ease; max-height:0; } .submenu { overflow:hidden; transition:max-height .25s ease; max-height:0; }
.menu-group.open .submenu { max-height:300px; }
.menu-group.open .submenu { max-height:600px; }
/* 折叠按钮 */ /* 折叠按钮 */
.sidebar-collapse-btn { border:none; background:rgba(255,255,255,.06); color:#9ca3af; padding:10px; cursor:pointer; font-size:12px; text-align:center; transition:background .15s; flex:none; } .sidebar-collapse-btn { border:none; background:rgba(255,255,255,.06); color:#9ca3af; padding:10px; cursor:pointer; font-size:12px; text-align:center; transition:background .15s; flex:none; }
@ -228,6 +228,10 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
.rag-dot { width:7px; height:7px; border-radius:999px; background:var(--muted); display:inline-block; } .rag-dot { width:7px; height:7px; border-radius:999px; background:var(--muted); display:inline-block; }
.rag-dot.on { background:var(--success); } .rag-dot.on { background:var(--success); }
.chat-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; } .chat-actions { display:flex; align-items:center; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
.model-chip { height:40px; min-width:190px; max-width:280px; padding:0 12px; border:1px solid var(--border); border-radius:8px; display:flex; align-items:center; gap:8px; background:#fff; color:var(--text); font-size:12px; }
.model-chip span { color:var(--muted); white-space:nowrap; }
.model-chip strong { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px; font-weight:600; }
.model-chip-error strong { color:var(--warn); }
.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 { 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-toggle input { width:16px; height:16px; accent-color:var(--primary); }
.rag-strategy { min-width:112px; font-size:13px; } .rag-strategy { min-width:112px; font-size:13px; }
@ -274,11 +278,11 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
} }
/* ==================== 侧边栏暗色滚动条 ==================== */ /* ==================== 侧边栏暗色滚动条 ==================== */
.sidebar-inner { scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.15) transparent; }
.sidebar-inner::-webkit-scrollbar { width:6px; }
.sidebar-inner::-webkit-scrollbar-thumb { background:rgba(255,255,255,.15); border-radius:999px; }
.sidebar-inner::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.25); }
.sidebar-inner::-webkit-scrollbar-track { background:transparent; }
.sidebar-nav { scrollbar-width:thin; scrollbar-color:rgba(255,255,255,.15) transparent; }
.sidebar-nav::-webkit-scrollbar { width:6px; }
.sidebar-nav::-webkit-scrollbar-thumb { background:rgba(255,255,255,.15); border-radius:999px; }
.sidebar-nav::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.25); }
.sidebar-nav::-webkit-scrollbar-track { background:transparent; }
/* ==================== 滚动条 ==================== */ /* ==================== 滚动条 ==================== */
* { scrollbar-width:thin; scrollbar-color:#cbd0d8 transparent; } * { scrollbar-width:thin; scrollbar-color:#cbd0d8 transparent; }

8
src/main/resources/static/js/api.js

@ -34,9 +34,11 @@ async function postJSON(path, body) {
headers: { 'Content-Type': 'application/json', ...authHeaders() }, headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
if (res.status === 401) { handleUnauthorized(); throw new Error('401') }
if (res.status === 403) throw new Error('403')
return res.json()
const json = await res.json().catch(() => ({ message: res.statusText || `HTTP ${res.status}` }))
if (res.status === 401) { handleUnauthorized(); throw new Error(json.message || '未登录或登录已过期') }
if (res.status === 403) throw new Error(json.message || '权限不足')
if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`)
return json
} }
/** /**

6
src/main/resources/static/js/app.js

@ -13,7 +13,6 @@ import ChatPanel from '../components/ChatPanel.js'
import DocStats from '../components/DocStats.js' import DocStats from '../components/DocStats.js'
import DocSearch from '../components/DocSearch.js' import DocSearch from '../components/DocSearch.js'
import CategoryManager from '../components/CategoryManager.js' import CategoryManager from '../components/CategoryManager.js'
import AccountManager from '../components/AccountManager.js'
import RoleManager from '../components/RoleManager.js' import RoleManager from '../components/RoleManager.js'
import DocList from '../components/DocList.js' import DocList from '../components/DocList.js'
import DocUpload from '../components/DocUpload.js' import DocUpload from '../components/DocUpload.js'
@ -244,10 +243,6 @@ const app = createApp({
<dashboard-panel></dashboard-panel> <dashboard-panel></dashboard-panel>
</div> </div>
<!-- 系统设置 - 客服账号管理 -->
<div v-if="store.activePage === 'account'" class="page-pane" style="animation: fadeIn .3s ease;">
<account-manager></account-manager>
</div>
<!-- 系统设置 - 客服角色管理 --> <!-- 系统设置 - 客服角色管理 -->
<div v-if="store.activePage === 'role'" class="page-pane" style="animation: fadeIn .3s ease;"> <div v-if="store.activePage === 'role'" class="page-pane" style="animation: fadeIn .3s ease;">
@ -300,7 +295,6 @@ app.component('chat-panel', ChatPanel)
app.component('doc-stats', DocStats) app.component('doc-stats', DocStats)
app.component('doc-search', DocSearch) app.component('doc-search', DocSearch)
app.component('category-manager', CategoryManager) app.component('category-manager', CategoryManager)
app.component('account-manager', AccountManager)
app.component('role-manager', RoleManager) app.component('role-manager', RoleManager)
app.component('doc-list', DocList) app.component('doc-list', DocList)
app.component('doc-upload', DocUpload) app.component('doc-upload', DocUpload)

7
src/main/resources/static/js/store.js

@ -41,7 +41,6 @@ export const MENU_ITEMS = [
label: '系统设置', label: '系统设置',
icon: '⚙️', icon: '⚙️',
children: [ children: [
{ id: 'account', label: '客服账号管理', icon: '👤' },
{ id: 'role', label: '客服角色管理', icon: '🎭' }, { id: 'role', label: '客服角色管理', icon: '🎭' },
{ id: 'model-config', label: '模型配置', icon: '🤖' }, { id: 'model-config', label: '模型配置', icon: '🤖' },
{ id: 'sensitive', label: '敏感词管理', icon: '🛡️' }, { id: 'sensitive', label: '敏感词管理', icon: '🛡️' },
@ -120,7 +119,8 @@ export const store = reactive({
detailModal: { detailModal: {
visible: false, visible: false,
doc: null, doc: null,
chunks: []
chunks: [],
chunksError: ''
}, },
/** /**
@ -135,6 +135,7 @@ export const store = reactive({
if (!detailRes.success) return if (!detailRes.success) return
this.detailModal.doc = detailRes.data this.detailModal.doc = detailRes.data
this.detailModal.chunks = chunksRes.success ? (chunksRes.data || []) : [] this.detailModal.chunks = chunksRes.success ? (chunksRes.data || []) : []
this.detailModal.chunksError = chunksRes.success ? '' : (chunksRes.message || '分块详情加载失败')
this.detailModal.visible = true this.detailModal.visible = true
} catch (e) { } catch (e) {
console.error('加载文档详情失败', e) console.error('加载文档详情失败', e)
@ -148,6 +149,7 @@ export const store = reactive({
this.detailModal.visible = false this.detailModal.visible = false
this.detailModal.doc = null this.detailModal.doc = null
this.detailModal.chunks = [] this.detailModal.chunks = []
this.detailModal.chunksError = ''
}, },
// ==================== 导航状态 ==================== // ==================== 导航状态 ====================
@ -216,7 +218,6 @@ export const store = reactive({
case 'faq': case 'faq':
this.loadCategories() this.loadCategories()
break break
case 'account':
case 'role': case 'role':
this.loadCategories() this.loadCategories()
break break

14
src/main/resources/static/sdk/test.html

@ -107,7 +107,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
<div class="mapping-note"> <div class="mapping-note">
<b>📋 参数映射(SDK → 后端)</b><br> <b>📋 参数映射(SDK → 后端)</b><br>
integrateId → <b>roleId</b>(客服角色 ID)<br> integrateId → <b>roleId</b>(客服角色 ID)<br>
userId → <b>accountId</b>客户账号 ID)<br>
userId → <b>accountId</b>外部用户ID)<br>
chatId → <b>自动管理</b>(从 /conversation/list 获取或自动生成) chatId → <b>自动管理</b>(从 /conversation/list 获取或自动生成)
</div> </div>
@ -121,8 +121,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
<input type="text" id="cfg-domain" value="" placeholder="http://localhost:9090"> <input type="text" id="cfg-domain" value="" placeholder="http://localhost:9090">
</div> </div>
<div class="fg"> <div class="fg">
<label>userId → accountId(可选,客户账号 ID)</label>
<input type="text" id="cfg-uid" value="" placeholder="客户账号 ID(如 hanlin)">
<label>userId → accountId(可选,外部用户ID)</label>
<input type="text" id="cfg-uid" value="" placeholder="外部用户ID(如 hanlin)">
<div class="hint">对应后端 AiController 的 accountId 参数</div> <div class="hint">对应后端 AiController 的 accountId 参数</div>
</div> </div>
<div class="fg-row"> <div class="fg-row">
@ -200,7 +200,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
<div style="font-size:12px;color:#6B7280;line-height:1.6"> <div style="font-size:12px;color:#6B7280;line-height:1.6">
<b>💡 操作提示</b><br> <b>💡 操作提示</b><br>
1. integrateId 填写客服角色 ID(数字,对应后端 roleId)<br> 1. integrateId 填写客服角色 ID(数字,对应后端 roleId)<br>
2. userId 填写客户账号 ID(对应后端 accountId)<br>
2. userId 填写外部用户ID(对应后端 accountId)<br>
3. chatId 自动管理:从 /conversation/list 获取或自动生成<br> 3. chatId 自动管理:从 /conversation/list 获取或自动生成<br>
4. 对话历史从后端加载(点击头部时钟图标查看)<br> 4. 对话历史从后端加载(点击头部时钟图标查看)<br>
5. AI 回复支持 Markdown 渲染<br> 5. AI 回复支持 Markdown 渲染<br>
@ -343,7 +343,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
params.push(' integrateId: ' + iidLit + ', // 必传:客服角色 ID(对应后端 roleId)'); params.push(' integrateId: ' + iidLit + ', // 必传:客服角色 ID(对应后端 roleId)');
params.push(' requestDomain: ' + JSON.stringify(domain) + ', // 必传:后端 API 域名'); params.push(' requestDomain: ' + JSON.stringify(domain) + ', // 必传:后端 API 域名');
// 可选参数:仅输出与默认值不同的项 // 可选参数:仅输出与默认值不同的项
if (cfg.userId) params.push(' userId: ' + JSON.stringify(cfg.userId) + ', // 可选:客户账号 ID(对应后端 accountId)');
if (cfg.userId) params.push(' userId: ' + JSON.stringify(cfg.userId) + ', // 可选:外部用户ID(对应后端 accountId)');
if (cfg.title !== SDK_DEFAULTS.title) params.push(' title: ' + JSON.stringify(cfg.title) + ', // 弹窗标题'); if (cfg.title !== SDK_DEFAULTS.title) params.push(' title: ' + JSON.stringify(cfg.title) + ', // 弹窗标题');
if (cfg.primaryColor !== SDK_DEFAULTS.primaryColor) params.push(' primaryColor: ' + JSON.stringify(cfg.primaryColor) + ', // 主色调'); if (cfg.primaryColor !== SDK_DEFAULTS.primaryColor) params.push(' primaryColor: ' + JSON.stringify(cfg.primaryColor) + ', // 主色调');
if (cfg.position !== SDK_DEFAULTS.position) params.push(' position: ' + JSON.stringify(cfg.position) + ', // 悬浮按钮位置'); if (cfg.position !== SDK_DEFAULTS.position) params.push(' position: ' + JSON.stringify(cfg.position) + ', // 悬浮按钮位置');
@ -487,7 +487,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
async function test21(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/conversation/list?page=1&size=10&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (roleId='+cfg.integrateId+')...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 会话列表 total='+json.total,'log-line--pass');m('pass','通过');} async function test21(log,m){const cfg=getCfg();const url=cfg.requestDomain+'/conversation/list?page=1&size=10&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (roleId='+cfg.integrateId+')...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 会话列表 total='+json.total,'log-line--pass');m('pass','通过');}
async function test22(log,m){const cfg=getCfg();if(cfg.userId){const url=cfg.requestDomain+'/conversation/list?page=1&size=10&accountId='+encodeURIComponent(cfg.userId)+'&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (accountId='+cfg.userId+', roleId='+cfg.integrateId+')...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 按账号+角色过滤 total='+json.total,'log-line--pass');m('pass','通过');}else{log('⚠ 未设置 userId,跳过','log-line--warn');m('skip','未设置 userId');}}
async function test22(log,m){const cfg=getCfg();if(cfg.userId){const url=cfg.requestDomain+'/conversation/list?page=1&size=10&accountId='+encodeURIComponent(cfg.userId)+'&roleId='+cfg.integrateId;apiCalls++;log('请求会话列表 (accountId='+cfg.userId+', roleId='+cfg.integrateId+')...','log-line--info');let r;try{r=await fetch(url,{signal:AbortSignal.timeout(10000)});}catch(e){log('⚠ 请求失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){m('skip','HTTP '+r.status);return;}const json=await r.json();log('✅ 按外部用户+角色过滤 total='+json.total,'log-line--pass');m('pass','通过');}else{log('⚠ 未设置 userId,跳过','log-line--warn');m('skip','未设置 userId');}}
// ========== 参数映射验证 ========== // ========== 参数映射验证 ==========
async function test23(log,m){log('验证参数映射:integrateId→roleId, userId→accountId','log-line--info');const cfg=getCfg();const msg='test_mapping';const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent(msg)+'&chatId=verify_mapping&roleId='+cfg.integrateId+(cfg.userId?'&accountId='+encodeURIComponent(cfg.userId):'');log('请求 URL: '+url.substring(0,120)+'...','log-line--info');apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ fetch 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const text=await r.text();assert(text.length>0,'应有回复');log('✅ integrateId('+cfg.integrateId+')→roleId, userId('+(cfg.userId||'未设置')+')→accountId 映射正确','log-line--pass');log('AI 回复: '+text.substring(0,80),'log-line--info');m('pass','通过');} async function test23(log,m){log('验证参数映射:integrateId→roleId, userId→accountId','log-line--info');const cfg=getCfg();const msg='test_mapping';const url=cfg.requestDomain+'/ai/assistant_app/chat/sync?message='+encodeURIComponent(msg)+'&chatId=verify_mapping&roleId='+cfg.integrateId+(cfg.userId?'&accountId='+encodeURIComponent(cfg.userId):'');log('请求 URL: '+url.substring(0,120)+'...','log-line--info');apiCalls++;let r;try{r=await fetch(url,{signal:AbortSignal.timeout(15000)});}catch(e){log('⚠ fetch 失败','log-line--warn');m('skip','后端不可用');return;}if(!r.ok){log('⚠ HTTP '+r.status,'log-line--warn');m('skip','接口异常');return;}const text=await r.text();assert(text.length>0,'应有回复');log('✅ integrateId('+cfg.integrateId+')→roleId, userId('+(cfg.userId||'未设置')+')→accountId 映射正确','log-line--pass');log('AI 回复: '+text.substring(0,80),'log-line--info');m('pass','通过');}
@ -518,7 +518,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
[test19,'T19','多语言国际化','zh-CN / en','p2'], [test19,'T19','多语言国际化','zh-CN / en','p2'],
[test20,'T20','会话管理面板','UI + 样式','p2'], [test20,'T20','会话管理面板','UI + 样式','p2'],
[test21,'T21','API 会话列表','按 roleId 过滤','p2'], [test21,'T21','API 会话列表','按 roleId 过滤','p2'],
[test22,'T22','API 会话按账号过滤','accountId+roleId','p2'],
[test22,'T22','API 会话按外部用户过滤','accountId+roleId','p2'],
[test23,'T23','参数映射验证','integrateId→roleId, userId→accountId','p0'], [test23,'T23','参数映射验证','integrateId→roleId, userId→accountId','p0'],
]; ];
setEl('total-tests', tests.length); setEl('total-tests', tests.length);

Loading…
Cancel
Save