Browse Source

样式调整,角色账号权限初始化提交

master
pyx 2 days ago
parent
commit
9482a9e573
  1. 453
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  2. 7
      src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java
  3. 252
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  4. 23
      src/main/java/com/wok/supportbot/config/RoleAccessConfig.java
  5. 217
      src/main/java/com/wok/supportbot/controller/AiController.java
  6. 7
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  7. 33
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  8. 89
      src/main/java/com/wok/supportbot/controller/CustomerAccountController.java
  9. 62
      src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java
  10. 163
      src/main/java/com/wok/supportbot/service/ConversationService.java
  11. 119
      src/main/java/com/wok/supportbot/service/CustomerAccountService.java
  12. 132
      src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java
  13. 3
      src/main/resources/application.yml
  14. 155
      src/main/resources/static/components/AccountManager.js
  15. 6
      src/main/resources/static/components/CategoryManager.js
  16. 355
      src/main/resources/static/components/ChatPanel.js
  17. 57
      src/main/resources/static/components/ConversationManager.js
  18. 8
      src/main/resources/static/components/DocDetail.js
  19. 18
      src/main/resources/static/components/DocList.js
  20. 16
      src/main/resources/static/components/DocSearch.js
  21. 2
      src/main/resources/static/components/DocStats.js
  22. 42
      src/main/resources/static/components/DocUpload.js
  23. 63
      src/main/resources/static/components/MessageSources.js
  24. 80
      src/main/resources/static/components/ProductPanel.js
  25. 181
      src/main/resources/static/components/RoleManager.js
  26. 253
      src/main/resources/static/css/main.css
  27. 4
      src/main/resources/static/index.html
  28. 161
      src/main/resources/static/js/api.js
  29. 37
      src/main/resources/static/js/app.js
  30. 21
      src/main/resources/static/js/utils.js

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

@ -1,8 +1,8 @@
package com.wok.supportbot.app;
import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.advisor.ReReadingAdvisor;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.rag.preretrieval.CompressionQueryRewriter;
import com.wok.supportbot.rag.preretrieval.MultiQueryExpanderRewriter;
import com.wok.supportbot.rag.preretrieval.RewriteQueryRewriter;
@ -11,104 +11,154 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.rag.preretrieval.query.expansion.MultiQueryExpander;
import org.springframework.ai.rag.preretrieval.query.transformation.QueryTransformer;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.rag.preretrieval.query.expansion.MultiQueryExpander;
import org.springframework.ai.rag.preretrieval.query.transformation.QueryTransformer;
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.Filter;
import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
import java.util.stream.Collectors;
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
/**
* 智能客服应用 - 支持多提供商动态切换
* 通过 ChatModelFactory DB 活跃配置获取 ChatModel
* @Classname AssistantApp
* @Description
* @Version 1.0.0
* @Date 2025/06/27 14:11
* @Author lyx
*/
@Component
@Slf4j
public class AssistantApp {
/** ChatMemory context 参数 key(与 BaseChatMemoryAdvisor.getConversationId 内部一致) */
private static final String CHAT_MEMORY_CONVERSATION_ID_KEY = "chat_memory_conversation_id";
private static final String CHAT_MEMORY_RETRIEVE_SIZE_KEY = "chat_memory_retrieve_size";
@Resource
private VectorStore pgVectorVectorStore;
private final ChatModelFactory chatModelFactory;
private final DatabaseChatMemory chatMemory;
private final ChatClient chatClient;
/** ChatClient 缓存:key = appType,避免每次调用重复构建 */
private final ConcurrentHashMap<String, ChatClient> chatClientCache = new ConcurrentHashMap<>();
private final DatabaseChatMemory chatMemory;
private static final String SYSTEM_PROMPT = "你是一名智能客服助手,负责解答用户问题。" +
"请主动引导用户提供关键信息,并尽量在不转人工的情况下解决问题。保持专业、耐心、礼貌。";
public AssistantApp(ChatModelFactory chatModelFactory, DatabaseChatMemory chatMemory) {
this.chatModelFactory = chatModelFactory;
this.chatMemory = chatMemory;
}
/**
* 获取指定应用类型的 ChatClient带缓存
* 初始化 ChatClient
*
* @param dashscopeChatModel
*/
private ChatClient getChatClient(String appType) {
return chatClientCache.computeIfAbsent(appType, type -> {
ChatModel chatModel = chatModelFactory.getChatModel(type);
return ChatClient.builder(chatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
new MyLoggerAdvisor()
)
.build();
});
public AssistantApp(ChatModel dashscopeChatModel, DatabaseChatMemory chatMemory) {
// 初始化基于文件的对话记忆
//String fileDir = System.getProperty("user.dir") + "/tmp/chat-memory";
//ChatMemory chatMemory = new FileBasedChatMemory(fileDir);
// 初始化基于内存的对话记忆
// ChatMemory chatMemory = new InMemoryChatMemory();
this.chatMemory = chatMemory;
chatClient = ChatClient.builder(dashscopeChatModel)
.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
// 自定义日志 Advisor可按需开启
new MyLoggerAdvisor()
// 自定义推理增强 Advisor可按需开启
//,new ReReadingAdvisor()
)
.build();
}
/**
* 清除 ChatClient 缓存配置变更时调用
* AI 基础对话支持多轮对话记忆
*
* @param message
* @param chatId
* @return
*/
public void clearCache() {
chatClientCache.clear();
log.info("AssistantApp ChatClient 缓存已清除");
public String doChat(String message, String chatId) {
return doChat(message, chatId, null);
}
/**
* AI 基础对话支持多轮对话记忆
* AI 基础对话支持多轮对话记忆 + 角色系统提示词
*
* @param message 用户消息保持原样不做包装避免污染会话记忆
* @param chatId 会话ID
* @param systemPrompt 角色人设/风格作为系统提示词叠加在基础提示词之上为空则仅用基础提示词
* @return AI 回答
*/
public String doChat(String message, String chatId) {
ChatResponse chatResponse = getChatClient("CHAT")
public String doChat(String message, String chatId, String systemPrompt) {
ChatClient.ChatClientRequestSpec spec = chatClient
.prompt()
.user(message)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.call()
.chatResponse();
.advisors(s -> s.param(CONVERSATION_ID, chatId));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
ChatResponse chatResponse = spec.call().chatResponse();
return chatResponse.getResult().getOutput().getText();
}
/**
* 组合系统提示词基础客服提示词 + 角色人设
* 角色人设作为附加段落叠加既保留客服基线约束又让角色风格生效
*/
private String effectiveSystem(String rolePrompt) {
if (!StringUtils.hasText(rolePrompt)) {
return SYSTEM_PROMPT;
}
return SYSTEM_PROMPT + "\n\n【当前角色设定】\n" + rolePrompt;
}
/**
* AI 基础对话支持多轮对话记忆SSE 流式传输
*
* @param message
* @param chatId
* @return
*/
public Flux<String> doChatByStream(String message, String chatId) {
return getChatClient("CHAT")
return doChatByStream(message, chatId, null);
}
/**
* AI 基础对话多轮记忆 + 角色系统提示词SSE 流式传输
*
* @param message 用户消息保持原样
* @param chatId 会话ID
* @param systemPrompt 角色人设/风格作为系统提示词叠加为空则仅用基础提示词
* @return 流式回答
*/
public Flux<String> doChatByStream(String message, String chatId, String systemPrompt) {
ChatClient.ChatClientRequestSpec spec = chatClient
.prompt()
.user(message)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.stream()
.content();
.advisors(s -> s.param(CONVERSATION_ID, chatId));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
return spec.stream().content();
}
// AI 恋爱知识库问答功能
@Resource
RewriteQueryRewriter rewriteQueryRewriter;
@Resource
@ -118,20 +168,25 @@ public class AssistantApp {
@Resource
TranslationQueryRewriter translationQueryRewriter;
/**
* RAG 知识库进行对话
*
* @param message
* @param chatId
* @return
*/
public String doChatWithRag(String message, String chatId) {
// 在预检索阶段系统接收用户的原始查询通过查询转换和查询扩展等方法对其进行优化输出增强的用户查询
// String rewrittenMessage = translationQueryRewriter.doQueryRewrite(message);
String rewrittenMessage = rewriteQueryRewriter.doQueryRewrite(message);
var ragAdvisor = buildRagAdvisor(4, Collections.emptyList());
ChatResponse chatResponse = getChatClient("CHAT")
ChatResponse chatResponse = chatClient
.prompt()
.user(rewrittenMessage)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.advisors(ragAdvisor)
.advisors(spec -> spec.param(CONVERSATION_ID, chatId))
// 应用 RAG 知识库问答
.advisors(buildRetrievalAdvisor(4, Collections.emptyList()))
.call()
.chatResponse();
return chatResponse.getResult().getOutput().getText();
@ -139,6 +194,11 @@ public class AssistantApp {
/**
* RAG 知识库进行对话支持动态选择查询重写策略
*
* @param message 用户消息
* @param chatId 会话ID
* @param strategy 查询重写策略NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY
* @return AI 回答
*/
public String doChatWithRagStrategy(String message, String chatId, String strategy) {
return doChatWithRagStrategy(message, chatId, strategy, Collections.emptyList());
@ -150,86 +210,196 @@ public class AssistantApp {
}
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds) {
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRag(message, chatId, categoryIds);
}
return doChatWithRagStrategy(message, chatId, strategy, categoryIds, null);
}
String rewrittenMessage = message;
if (strategy != null && !strategy.isEmpty()) {
switch (strategy.toUpperCase()) {
case "REWRITE":
rewrittenMessage = rewriteQueryRewriter.doQueryRewrite(message);
break;
case "TRANSLATION":
rewrittenMessage = translationQueryRewriter.doQueryRewrite(message);
break;
case "COMPRESSION":
rewrittenMessage = compressionQueryRewriter.doQueryRewrite(message, java.util.Collections.emptyList());
break;
case "NONE":
default:
rewrittenMessage = message;
break;
}
public String doChatWithRagStrategy(String message, String chatId, String strategy, List<Long> categoryIds, String systemPrompt) {
// 对于 MULTI_QUERY 策略需要使用特殊的处理方式
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRag(message, chatId, categoryIds, systemPrompt);
}
var ragAdvisor = buildRagAdvisor(4, categoryIds);
// 其他策略单查询处理
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
ChatResponse chatResponse = getChatClient("CHAT")
ChatClient.ChatClientRequestSpec spec = chatClient
.prompt()
.user(rewrittenMessage)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.advisors(ragAdvisor)
.call()
.chatResponse();
.advisors(s -> s.param(CONVERSATION_ID, chatId))
// 应用 RAG 知识库问答
.advisors(buildRetrievalAdvisor(4, categoryIds));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
ChatResponse chatResponse = spec.call().chatResponse();
return chatResponse.getResult().getOutput().getText();
}
/**
* 使用多路查询扩展的 RAG 对话
* 根据策略对查询做预检索改写MULTI_QUERY 不走此方法
*/
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds) {
List<String> expandedQueries = multiQueryExpanderRewriter.doQueryRewrite(message);
log.info("多路查询扩展结果: {}", expandedQueries);
String primaryQuery = expandedQueries.isEmpty() ? message : expandedQueries.get(0);
private String rewriteQuery(String message, String chatId, String strategy) {
if (strategy == null || strategy.isEmpty()) {
return message;
}
switch (strategy.toUpperCase()) {
case "REWRITE":
return rewriteQueryRewriter.doQueryRewrite(message);
case "TRANSLATION":
return translationQueryRewriter.doQueryRewrite(message);
case "COMPRESSION":
// 查询压缩需要对话历史从会话记忆中取最近若干条传入
// 利用多轮上下文把指代不清的追问补全为独立查询
List<Message> history = chatMemory.get(chatId, 10);
return compressionQueryRewriter.doQueryRewrite(message, history);
case "NONE":
default:
return message;
}
}
var ragAdvisor = buildRagAdvisor(8, categoryIds);
/**
* RAG 知识库进行对话支持查询重写策略SSE 流式传输
*
* @param message 用户消息
* @param chatId 会话ID
* @param strategy 查询重写策略NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY
* @param categoryIds 知识库分类过滤
* @param systemPrompt 角色人设/风格
* @return 流式回答
*/
public Flux<String> doChatWithRagStrategyByStream(String message, String chatId, String strategy,
List<Long> categoryIds, String systemPrompt) {
// 对于 MULTI_QUERY 策略需要先手动检索合并再流式生成
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return doChatWithMultiQueryRagByStream(message, chatId, categoryIds, systemPrompt);
}
String rewrittenMessage = rewriteQuery(message, chatId, strategy);
ChatResponse chatResponse = getChatClient("CHAT")
ChatClient.ChatClientRequestSpec spec = chatClient
.prompt()
.user(primaryQuery)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.advisors(ragAdvisor)
.user(rewrittenMessage)
.advisors(s -> s.param(CONVERSATION_ID, chatId))
.advisors(buildRetrievalAdvisor(4, categoryIds));
if (StringUtils.hasText(systemPrompt)) {
spec = spec.system(effectiveSystem(systemPrompt));
}
return spec.stream().content();
}
/**
* 使用多路查询扩展的 RAG 对话
* 将原始查询扩展为多个语义不同的查询分别检索后按文档ID去重合并
* 再把合并后的资料注入 system 提示词不污染用户消息与会话记忆
*
* @param message 用户消息保持原样
* @param chatId 会话ID
* @param categoryIds 知识库分类过滤
* @param systemPrompt 角色人设/风格
* @return AI 回答
*/
private String doChatWithMultiQueryRag(String message, String chatId, List<Long> categoryIds, String systemPrompt) {
String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt);
ChatResponse chatResponse = chatClient
.prompt()
.system(ragSystem)
.user(message)
.advisors(s -> s.param(CONVERSATION_ID, chatId))
.call()
.chatResponse();
return chatResponse.getResult().getOutput().getText();
}
private Flux<String> doChatWithMultiQueryRagByStream(String message, String chatId, List<Long> categoryIds, String systemPrompt) {
String ragSystem = buildMultiQueryRagSystem(message, categoryIds, systemPrompt);
return chatClient
.prompt()
.system(ragSystem)
.user(message)
.advisors(s -> s.param(CONVERSATION_ID, chatId))
.stream()
.content();
}
/**
* 构建 RAG 检索增强 Advisor
* 多路查询扩展 + 检索合并组合出注入了知识库资料的系统提示词
* 将原始查询扩展为多个语义不同的查询分别检索后按文档ID去重合并
* 资料注入 system不污染用户消息与会话记忆供同步与流式两条路径复用
*/
private RetrievalAugmentationAdvisor buildRagAdvisor(int topK, List<Long> categoryIds) {
var retrieverBuilder = VectorStoreDocumentRetriever.builder()
.vectorStore(pgVectorVectorStore)
.similarityThreshold(0.0)
.topK(topK);
private String buildMultiQueryRagSystem(String message, List<Long> categoryIds, String systemPrompt) {
List<Document> mergedDocs = retrieveMultiQueryDocs(message, categoryIds);
log.info("多路检索合并后文档数: {}", mergedDocs.size());
// 拼接资料为上下文组合系统提示词
String context = mergedDocs.stream()
.map(Document::getText)
.filter(StringUtils::hasText)
.collect(Collectors.joining("\n\n---\n\n"));
return buildRagSystemPrompt(systemPrompt, context);
}
List<Object> values = normalizeCategoryIds(categoryIds).stream()
.map(value -> (Object) value)
.toList();
if (!values.isEmpty()) {
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
retrieverBuilder.filterExpression(filterBuilder.in("categoryId", values).build());
/**
* 多路查询扩展 + 检索去重合并返回命中的文档 RAG 回答与"引用来源"复用
*/
private List<Document> retrieveMultiQueryDocs(String message, List<Long> categoryIds) {
// 1. 扩展为多个语义不同的查询已配置 includeOriginal含原始查询
List<String> expandedQueries = multiQueryExpanderRewriter.doQueryRewrite(message);
if (expandedQueries == null || expandedQueries.isEmpty()) {
expandedQueries = List.of(message);
}
log.info("多路查询扩展结果: {}", expandedQueries);
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(retrieverBuilder.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.build())
.build();
// 2. 对每个查询分别检索按文档ID去重合并封顶 maxDocs避免上下文膨胀
final int maxDocs = 8;
Map<String, Document> merged = new LinkedHashMap<>();
for (String query : expandedQueries) {
if (!StringUtils.hasText(query) || merged.size() >= maxDocs) {
continue;
}
List<Document> docs = pgVectorVectorStore.similaritySearch(buildRagSearchRequest(4, categoryIds, query));
if (docs == null) {
continue;
}
for (Document doc : docs) {
if (merged.size() >= maxDocs) {
break;
}
merged.putIfAbsent(doc.getId(), doc);
}
}
return new ArrayList<>(merged.values());
}
/**
* 检索本次问题命中的知识库片段不生成回答用于在回答下方展示"引用来源"
* 复用与 RAG 回答完全相同的查询改写策略与分类范围确保来源即答案所依据的片段
*
* @param strategy 查询重写策略与回答保持一致
* @return 命中的文档片段 metadatadocumentId/title/sourceName/chunkIndex/distance
*/
public List<Document> retrieveRagSources(String message, String chatId, String strategy, List<Long> categoryIds) {
if (!StringUtils.hasText(message)) {
return Collections.emptyList();
}
if ("MULTI_QUERY".equalsIgnoreCase(strategy)) {
return retrieveMultiQueryDocs(message, categoryIds);
}
String rewritten = rewriteQuery(message, chatId, strategy);
List<Document> docs = pgVectorVectorStore.similaritySearch(buildRagSearchRequest(4, categoryIds, rewritten));
return docs != null ? docs : Collections.emptyList();
}
/**
* 组合 RAG 场景的系统提示词基础提示词 + 角色人设 + 检索到的资料
* 资料为空时不声称依据资料避免诱导模型编造
*/
private String buildRagSystemPrompt(String rolePrompt, String context) {
String base = effectiveSystem(rolePrompt);
if (!StringUtils.hasText(context)) {
return base;
}
return base + "\n\n请优先依据以下知识库资料回答用户问题;若资料不足以回答,请如实说明,不要编造。\n\n【知识库资料】\n" + context;
}
private SearchRequest buildRagSearchRequest(int topK, Long categoryId) {
@ -238,17 +408,55 @@ public class AssistantApp {
}
private SearchRequest buildRagSearchRequest(int topK, List<Long> categoryIds) {
return ragSearchRequestBuilder(topK, categoryIds).build();
}
/**
* 带查询文本的检索请求供手动向量检索多路查询使用
* QuestionAnswerAdvisor 会自行设置查询文本故那条路径不需要此重载
*/
private SearchRequest buildRagSearchRequest(int topK, List<Long> categoryIds, String query) {
SearchRequest.Builder builder = ragSearchRequestBuilder(topK, categoryIds);
if (StringUtils.hasText(query)) {
builder.query(query);
}
return builder.build();
}
private SearchRequest.Builder ragSearchRequestBuilder(int topK, List<Long> categoryIds) {
SearchRequest.Builder builder = SearchRequest.builder()
.similarityThreshold(0.0)
.topK(topK);
Filter.Expression filterExpression = buildCategoryFilterExpression(categoryIds);
if (filterExpression != null) {
builder.filterExpression(filterExpression);
}
return builder;
}
private Advisor buildRetrievalAdvisor(int topK, List<Long> categoryIds) {
Filter.Expression filterExpression = buildCategoryFilterExpression(categoryIds);
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(new VectorStoreDocumentRetriever(
pgVectorVectorStore,
0.0,
topK,
() -> filterExpression))
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false)
.build())
.build();
}
private Filter.Expression buildCategoryFilterExpression(List<Long> categoryIds) {
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());
if (values.isEmpty()) {
return null;
}
return builder.build();
FilterExpressionBuilder filterBuilder = new FilterExpressionBuilder();
return filterBuilder.in("categoryId", values).build();
}
private List<String> normalizeCategoryIds(List<Long> categoryIds) {
@ -270,25 +478,32 @@ public class AssistantApp {
/**
* RAG 知识库进行对话(另外一种使用方式)
*
* @param message
* @param chatId
* @return
*/
public String doChatWithRagEnhance(String message, String chatId) {
var ragAdvisor = RetrievalAugmentationAdvisor.builder()
Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
// todo 不生效
//.queryTransformers(queryTransformers)
//.queryExpander(multiQueryExpander)
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(pgVectorVectorStore)
.similarityThreshold(0.5)
.topK(4)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false)
.allowEmptyContext(false) // 不允许模型在没有找到相关文档的情况下也生成回答
.build())
.build();
ChatResponse chatResponse = getChatClient("CHAT")
ChatResponse chatResponse = chatClient
.prompt()
.user(message)
.advisors(spec -> spec.param(CHAT_MEMORY_CONVERSATION_ID_KEY, chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY, 10))
.advisors(ragAdvisor)
.advisors(spec -> spec.param(CONVERSATION_ID, chatId))
// 应用 RAG 知识库问答
.advisors(retrievalAugmentationAdvisor)
.call()
.chatResponse();
return chatResponse.getResult().getOutput().getText();

7
src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java

@ -35,9 +35,14 @@ public class DatabaseChatMemory implements ChatMemory {
@Override
public List<Message> get(String conversationId) {
return get(conversationId, 10);
}
public List<Message> get(String conversationId, int lastN) {
LambdaQueryWrapper<ChatMessage> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ChatMessage::getConversationId, conversationId)
.orderByDesc(ChatMessage::getCreateTime);
.orderByDesc(ChatMessage::getCreateTime)
.last(lastN > 0, "LIMIT " + lastN);
List<ChatMessage> chatMessages = chatMessageRepository.list(queryWrapper);

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

@ -3,7 +3,6 @@ package com.wok.supportbot.config;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
@ -18,19 +17,6 @@ public class DatabaseInitConfig {
@Autowired
private JdbcTemplate jdbcTemplate;
/** 从 application.yml 注入当前大模型配置,用于 seed 默认数据 */
@Value("${spring.ai.dashscope.api-key:}")
private String dashscopeApiKey;
@Value("${spring.ai.dashscope.chat.options.model:qwen-turbo}")
private String chatModelName;
@Value("${spring.ai.dashscope.chat.options.temperature:0.7}")
private Double chatTemperature;
@Value("${spring.ai.dashscope.embedding.options.model:text-embedding-v2}")
private String embeddingModelName;
@PostConstruct
public void init() {
try {
@ -64,6 +50,9 @@ public class DatabaseInitConfig {
if (!roleTableExists) {
log.info("创建客服角色表 customer_service_role");
createCustomerServiceRoleTable();
} else {
// 清理已废弃的 model per-role 模型功能已撤除模型统一由模型配置中心管理
dropRoleModelColumn();
}
boolean roleCategoryTableExists = checkTableExists("customer_service_role_category");
@ -71,17 +60,21 @@ public class DatabaseInitConfig {
log.info("创建客服角色知识库关联表 customer_service_role_category");
createCustomerServiceRoleCategoryTable();
}
seedDefaultCustomerServiceRoles();
// 检查 ai_model_config 表是否存在
boolean aiModelConfigTableExists = checkTableExists("ai_model_config");
if (!aiModelConfigTableExists) {
log.info("创建 AI 模型配置表 ai_model_config");
createAiModelConfigTable();
// seed 默认配置 application.yml 读取
seedDefaultAiModelConfigs();
boolean accountTableExists = checkTableExists("customer_account");
if (!accountTableExists) {
log.info("创建客服账号表 customer_account");
createCustomerAccountTable();
}
boolean conversationSessionTableExists = checkTableExists("conversation_session");
if (!conversationSessionTableExists) {
log.info("创建会话归属表 conversation_session");
createConversationSessionTable();
}
syncDefaultCustomerServiceRoles();
syncDefaultCustomerAccounts();
log.info("数据库初始化完成");
} catch (Exception e) {
log.error("数据库初始化失败", e);
@ -200,33 +193,116 @@ public class DatabaseInitConfig {
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;
}
private void createCustomerAccountTable() {
String sql = """
CREATE TABLE IF NOT EXISTS customer_account (
id BIGSERIAL PRIMARY KEY,
account_key VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
description TEXT,
role_id BIGINT,
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_account_role ON customer_account (role_id)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_customer_account_enabled ON customer_account (enabled, id)");
}
private void createConversationSessionTable() {
String sql = """
CREATE TABLE IF NOT EXISTS conversation_session (
conversation_id VARCHAR(64) PRIMARY KEY,
account_id BIGINT,
role_id BIGINT,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""";
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_role ON conversation_session (role_id)");
}
private void syncDefaultCustomerServiceRoles() {
upsertDefaultRole("general", "客服", "用户咨询、业务办理、常见问题、问题受理与进度说明",
"""
职责回答用户关于业务办理服务流程常见问题问题受理和进度说明的问题
要求优先依据知识库回答资料不足时说明无法确认并引导用户补充必要信息涉及财务行政制度细节时不要猜测应提示转对应角色处理
""", 10);
upsertDefaultRole("finance", "财务", "付款、退款、发票、对账、报销、结算与费用规则",
"""
职责回答付款退款发票对账报销结算费用规则相关问题
要求金额账户票据时间节点必须严谨知识库没有依据时不得编造政策需要用户提供单号金额日期发票抬头等关键信息
""", 20);
upsertDefaultRole("administration", "行政", "办公制度、行政流程、资产、会议、考勤、用章、采购与后勤",
"""
职责回答办公制度行政流程资产会议考勤入职用章采购后勤等问题
要求优先依据公司制度和流程文件回答涉及审批权限特殊例外或未覆盖场景时提示按制度提交申请或联系行政负责人
""", 30);
retireObsoleteDefaultRoles();
}
insertDefaultRole("general", "综合客服", "商品、订单、支付、物流、售后与财务",
"专业、耐心、简洁;根据用户问题在可用知识库范围内回答。", 10);
insertDefaultRole("after_sale", "售后客服", "退款、退换货、维修与投诉处理",
"先安抚用户,再确认订单、凭证、责任归属和处理进度。", 20);
insertDefaultRole("logistics", "物流客服", "发货时效、快递轨迹、签收异常",
"先定位订单和物流节点,再解释时效、异常原因和下一步处理。", 30);
insertDefaultRole("product", "商品客服", "规格、库存、搭配与购买建议",
"围绕商品规格、库存、适用场景和购买建议回答,不确定时说明依据不足。", 40);
insertDefaultRole("finance", "财务客服", "发票、对账、付款、退款入账与费用问题",
"严谨核对金额、单据、账户、发票抬头、税号和时间节点。", 50);
private void syncDefaultCustomerAccounts() {
upsertDefaultAccount("service", "客服账号", "默认客服账号", "general");
upsertDefaultAccount("finance", "财务账号", "默认财务账号", "finance");
upsertDefaultAccount("administration", "行政账号", "默认行政账号", "administration");
}
private void insertDefaultRole(String roleKey, String name, String description, String prompt, int sortOrder) {
private void upsertDefaultRole(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 (?, ?, ?, ?, ?)
ON CONFLICT (role_key)
DO UPDATE SET name = EXCLUDED.name,
description = EXCLUDED.description,
prompt = EXCLUDED.prompt,
sort_order = EXCLUDED.sort_order,
enabled = true,
is_delete = false,
update_time = CURRENT_TIMESTAMP
""", roleKey, name, description, prompt, sortOrder);
}
private void upsertDefaultAccount(String accountKey, String name, String description, String roleKey) {
Long roleId = jdbcTemplate.queryForObject("""
SELECT id FROM customer_service_role
WHERE role_key = ? AND is_delete = false
LIMIT 1
""", Long.class, roleKey);
jdbcTemplate.update("""
INSERT INTO customer_account (account_key, name, description, role_id)
VALUES (?, ?, ?, ?)
ON CONFLICT (account_key)
DO UPDATE SET name = EXCLUDED.name,
description = EXCLUDED.description,
role_id = EXCLUDED.role_id,
enabled = true,
is_delete = false,
update_time = CURRENT_TIMESTAMP
""", accountKey, name, description, roleId);
}
private void retireObsoleteDefaultRoles() {
jdbcTemplate.update("""
UPDATE customer_service_role
SET is_delete = true, enabled = false, update_time = CURRENT_TIMESTAMP
WHERE role_key IN ('after_sale', 'logistics', 'product')
""");
jdbcTemplate.update("""
UPDATE customer_service_role_category
SET is_delete = true
WHERE role_id IN (
SELECT id FROM customer_service_role
WHERE role_key IN ('after_sale', 'logistics', 'product')
)
""");
}
private void fixTagsDefaultValue() {
try {
// 检查当前默认值是否为数组
@ -243,6 +319,18 @@ public class DatabaseInitConfig {
}
}
/**
* 清理已废弃的 customer_service_role.model per-role 模型功能已撤除
* 幂等DROP COLUMN IF EXISTS列不存在时不做任何事
*/
private void dropRoleModelColumn() {
try {
jdbcTemplate.execute("ALTER TABLE customer_service_role DROP COLUMN IF EXISTS model");
} catch (Exception e) {
log.warn("清理 model 列时出错", e);
}
}
/**
* 自动添加 content_hash 二期去重功能新增字段
*/
@ -259,88 +347,4 @@ public class DatabaseInitConfig {
log.warn("添加 content_hash 列时出错", e);
}
}
/**
* 创建 AI 模型配置表
*/
private void createAiModelConfigTable() {
String sql = """
CREATE TABLE IF NOT EXISTS ai_model_config (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
app_type VARCHAR(50) NOT NULL,
provider VARCHAR(50) NOT NULL DEFAULT 'dashscope',
api_key VARCHAR(512) NOT NULL,
model_name VARCHAR(100) NOT NULL,
temperature DOUBLE PRECISION DEFAULT 0.7,
max_tokens INTEGER DEFAULT 2000,
base_url VARCHAR(512),
extra_config JSONB DEFAULT '{}' NOT NULL,
is_active BOOLEAN DEFAULT FALSE NOT NULL,
priority INTEGER DEFAULT 0 NOT NULL,
description TEXT,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
is_delete BOOLEAN DEFAULT FALSE NOT NULL
)
""";
jdbcTemplate.execute(sql);
// 创建索引
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_ai_model_config_app_type ON ai_model_config (app_type)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_ai_model_config_is_active ON ai_model_config (is_active)");
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_ai_model_config_provider ON ai_model_config (provider)");
}
/**
* 种子数据 application.yml 读取当前大模型配置写入 ai_model_config
* CHAT / PRODUCT_EXTRACT / EMBEDDING / RAG_REWRITE 各创建一条默认配置
*/
private void seedDefaultAiModelConfigs() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM ai_model_config WHERE is_delete = false",
Integer.class);
if (count != null && count > 0) {
return;
}
String maskedKey = dashscopeApiKey.length() > 8
? dashscopeApiKey.substring(0, 4) + "****" + dashscopeApiKey.substring(dashscopeApiKey.length() - 4)
: "****";
// CHAT - 智能客服对话默认激活
insertDefaultModelConfig("智能客服对话-默认", "CHAT", "dashscope",
dashscopeApiKey, chatModelName, chatTemperature, 2000,
true, 100, "默认对话模型配置(来自 application.yml)");
// PRODUCT_EXTRACT - 商品信息抽取
insertDefaultModelConfig("商品信息抽取-默认", "PRODUCT_EXTRACT", "dashscope",
dashscopeApiKey, chatModelName, 0.3, 2000,
true, 90, "商品信息结构化抽取模型配置");
// EMBEDDING - 向量化
insertDefaultModelConfig("文本向量化-默认", "EMBEDDING", "dashscope",
dashscopeApiKey, embeddingModelName, null, null,
true, 80, "文本向量化 Embedding 模型配置");
// RAG_REWRITE - RAG 查询重写
insertDefaultModelConfig("RAG查询重写-默认", "RAG_REWRITE", "dashscope",
dashscopeApiKey, chatModelName, 0.5, 1000,
true, 70, "RAG 预检索查询重写模型配置");
log.info("已种子化默认 AI 模型配置(API Key: {})", maskedKey);
}
/**
* 插入一条默认模型配置
*/
private void insertDefaultModelConfig(String name, String appType, String provider,
String apiKey, String modelName, Double temperature,
Integer maxTokens, boolean isActive, int priority,
String description) {
jdbcTemplate.update("""
INSERT INTO ai_model_config (name, app_type, provider, api_key, model_name, temperature, max_tokens, is_active, priority, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", name, appType, provider, apiKey, modelName, temperature, maxTokens, isActive, priority, description);
}
}

23
src/main/java/com/wok/supportbot/config/RoleAccessConfig.java

@ -0,0 +1,23 @@
package com.wok.supportbot.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 客服角色访问控制配置
*/
@Component
@ConfigurationProperties(prefix = "knowledge.role")
@EnableConfigurationProperties(RoleAccessConfig.class)
@Data
public class RoleAccessConfig {
/**
* 严格隔离模式
* false默认角色未绑定任何知识库分类时可检索全部知识库适合客服这类通用角色
* true角色未绑定分类时禁止检索任何知识库内容每个角色必须显式授权杜绝意外越权
*/
private boolean strictIsolation = false;
}

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

@ -3,12 +3,18 @@ package com.wok.supportbot.controller;
import cn.hutool.json.JSONUtil;
import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.app.ProductInfoApp;
import com.wok.supportbot.config.RoleAccessConfig;
import com.wok.supportbot.entity.ProductInfo;
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.RoleScope;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.document.Document;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@ -16,9 +22,13 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@RestController
@ -29,6 +39,14 @@ public class AiController {
private AssistantApp assistantApp;
@Resource
private ProductInfoApp productInfoApp;
@Resource
private CustomerServiceRoleService customerServiceRoleService;
@Resource
private CustomerAccountService customerAccountService;
@Resource
private ConversationService conversationService;
@Resource
private RoleAccessConfig roleAccessConfig;
/**
@ -46,13 +64,18 @@ public class AiController {
/**
* 同步调用 AI 智能客服应用
*
* @param message
* @param chatId
* @return
* @param message 用户消息
* @param chatId 会话ID
* @param roleId 客服角色ID可选命中角色时由服务端强制套用该角色人设
* @param systemPrompt 角色人设兜底仅在未命中角色时生效
* @return AI 回答
*/
@GetMapping("/assistant_app/chat/sync")
public String doChatWithAssistantAppSync(String message, String chatId) {
return assistantApp.doChat(message, chatId);
public String doChatWithAssistantAppSync(String message, String chatId, Long roleId, Long accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChat(message, chatId, resolveSystemPrompt(scope, systemPrompt));
}
/**
@ -64,8 +87,11 @@ public class AiController {
* @return
*/
@GetMapping(value = "/assistant_app/chat/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> doChatWithLoveAppSSE(String message, String chatId) {
return assistantApp.doChatByStream(message, chatId);
public Flux<String> doChatWithLoveAppSSE(String message, String chatId, Long roleId, Long accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt));
}
/**
@ -77,8 +103,11 @@ public class AiController {
* @return
*/
@GetMapping(value = "/assistant_app/chat/server_sent_event")
public Flux<ServerSentEvent<String>> doChatWithAssistantAppServerSentEvent(String message, String chatId) {
return assistantApp.doChatByStream(message, chatId)
public Flux<ServerSentEvent<String>> doChatWithAssistantAppServerSentEvent(String message, String chatId, Long roleId, Long accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
return assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt))
.map(chunk -> ServerSentEvent.<String>builder()
.data(chunk)
.build());
@ -93,11 +122,14 @@ public class AiController {
* @return
*/
@GetMapping(value = "/assistant_app/chat/sse_emitter")
public SseEmitter doChatWithAssistantAppServerSseEmitter(String message, String chatId) {
public SseEmitter doChatWithAssistantAppServerSseEmitter(String message, String chatId, Long roleId, Long accountId, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
// 创建一个超时时间较长的 SseEmitter
SseEmitter sseEmitter = new SseEmitter(180000L); // 3 分钟超时
// 获取 Flux 响应式数据流并且直接通过订阅推送给 SseEmitter
assistantApp.doChatByStream(message, chatId)
assistantApp.doChatByStream(message, chatId, resolveSystemPrompt(scope, systemPrompt))
.subscribe(chunk -> {
try {
sseEmitter.send(chunk);
@ -112,22 +144,160 @@ public class AiController {
/**
* RAG 知识库同步对话支持查询重写策略
*
* @param message 用户消息
* @param chatId 会话ID
* @param message 用户消息
* @param chatId 会话ID
* @param rewriteStrategy 查询重写策略可选NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY默认为 REWRITE
* @param roleId 客服角色ID可选命中角色时由服务端强制限定检索范围与人设客户端无法跨域
* @param categoryId 单个分类过滤仅未命中角色时生效
* @param categoryIds 多个分类过滤逗号分隔仅未命中角色时生效
* @param systemPrompt 角色人设兜底仅未命中角色时生效
* @return AI 回答
*/
@GetMapping("/assistant_app/chat/rag/sync")
public String doChatWithRagSync(String message, String chatId, String rewriteStrategy, Long categoryId, String categoryIds) {
// 如果未指定策略默认使用 REWRITE
String strategy = (rewriteStrategy != null && !rewriteStrategy.isEmpty())
? rewriteStrategy
: "REWRITE";
public String doChatWithRagSync(String message, String chatId, String rewriteStrategy, Long roleId, Long accountId, Long categoryId, String categoryIds, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
String sys = resolveSystemPrompt(scope, systemPrompt);
// 严格隔离未授权任何知识库的角色退回普通对话绝不检索 KB
if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return assistantApp.doChat(message, chatId, sys);
}
return assistantApp.doChatWithRagStrategy(
message, chatId, normalizeStrategy(rewriteStrategy),
resolveCategoryIds(scope, categoryId, categoryIds), sys);
}
/**
* RAG 知识库流式对话SSE支持查询重写策略
*
* @param message 用户消息
* @param chatId 会话ID
* @param rewriteStrategy 查询重写策略可选NONE/REWRITE/TRANSLATION/COMPRESSION/MULTI_QUERY默认为 REWRITE
* @param roleId 客服角色ID可选命中角色时由服务端强制限定检索范围与人设客户端无法跨域
* @param categoryId 单个分类过滤仅未命中角色时生效
* @param categoryIds 多个分类过滤逗号分隔仅未命中角色时生效
* @param systemPrompt 角色人设兜底仅未命中角色时生效
* @return 流式 AI 回答
*/
@GetMapping(value = "/assistant_app/chat/rag/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> doChatWithRagSSE(String message, String chatId, String rewriteStrategy, Long roleId, Long accountId, Long categoryId, String categoryIds, String systemPrompt) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
bindConversation(chatId, context);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
String sys = resolveSystemPrompt(scope, systemPrompt);
// 严格隔离未授权任何知识库的角色退回普通流式对话绝不检索 KB
if (isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return assistantApp.doChatByStream(message, chatId, sys);
}
return assistantApp.doChatWithRagStrategyByStream(
message, chatId, normalizeStrategy(rewriteStrategy),
resolveCategoryIds(scope, categoryId, categoryIds), sys);
}
/**
* RAG 引用来源返回本次问题命中的知识库片段不生成回答供回答下方展示来源
* 复用与 RAG 回答相同的角色范围分类过滤与查询改写策略确保来源即答案所依据的片段
*/
@GetMapping("/assistant_app/rag/sources")
public Map<String, Object> getRagSources(String message, String chatId, String rewriteStrategy,
Long roleId, Long accountId, Long categoryId, String categoryIds) {
AccountRoleContext context = resolveAccountRole(accountId, roleId);
RoleScope scope = customerServiceRoleService.getRoleScope(context.roleId());
if (message == null || message.isBlank() || isKbDenied(scope) || shouldBypassKnowledgeRetrieval(message)) {
return Map.of("success", true, "data", List.of());
}
List<Long> cats = resolveCategoryIds(scope, categoryId, categoryIds);
List<Document> docs = assistantApp.retrieveRagSources(message, chatId, normalizeStrategy(rewriteStrategy), cats);
List<Map<String, Object>> out = new ArrayList<>();
for (Document doc : docs) {
Map<String, Object> meta = doc.getMetadata();
Map<String, Object> item = new LinkedHashMap<>();
item.put("documentId", meta.get("documentId"));
item.put("title", meta.get("title"));
item.put("sourceName", meta.get("sourceName"));
item.put("chunkIndex", meta.get("chunkIndex"));
item.put("score", meta.get("distance"));
String text = doc.getText();
item.put("snippet", text != null && text.length() > 160 ? text.substring(0, 160) + "…" : text);
out.add(item);
}
return Map.of("success", true, "data", out);
}
/**
* 严格隔离模式下命中角色但未绑定任何知识库分类 拒绝检索任何 KB
* 非严格模式下永远返回 false未绑定 = 可检索全部沿用通用角色语义
*/
private boolean isKbDenied(RoleScope scope) {
return roleAccessConfig.isStrictIsolation() && scope.hasRole() && scope.categoryIds().isEmpty();
}
/**
* 寒暄感谢告别这类短消息没有知识库检索意图直接走普通对话
* 否则向量库会被迫召回一个最相似但实际无关的片段导致回答下方出现误导性来源
*/
private boolean shouldBypassKnowledgeRetrieval(String message) {
if (!StringUtils.hasText(message)) {
return true;
}
String normalized = message.trim()
.toLowerCase(Locale.ROOT)
.replaceAll("[\\s,。!?!?,.;;::、~~…]+", "");
if (normalized.isEmpty()) {
return true;
}
if (normalized.length() > 12) {
return false;
}
return List.of(
"你好", "您好", "hello", "hi", "哈喽", "嗨", "在吗",
"早上好", "上午好", "中午好", "下午好", "晚上好",
"谢谢", "感谢", "多谢", "好的", "好", "嗯", "嗯嗯",
"再见", "拜拜", "辛苦了"
).contains(normalized);
}
/** 未指定策略时默认 REWRITE。 */
private String normalizeStrategy(String rewriteStrategy) {
return (rewriteStrategy != null && !rewriteStrategy.isEmpty()) ? rewriteStrategy : "REWRITE";
}
/** 命中角色时用角色人设,否则用客户端兜底人设。 */
private AccountRoleContext resolveAccountRole(Long accountId, Long fallbackRoleId) {
AccountScope accountScope = customerAccountService.getAccountScope(accountId);
Long effectiveRoleId = accountScope.hasAccount() && accountScope.roleId() != null
? accountScope.roleId()
: fallbackRoleId;
Long effectiveAccountId = accountScope.hasAccount() ? accountScope.accountId() : null;
return new AccountRoleContext(effectiveAccountId, effectiveRoleId);
}
private void bindConversation(String chatId, AccountRoleContext context) {
conversationService.bindConversation(chatId, context.accountId(), context.roleId());
}
private String resolveSystemPrompt(RoleScope scope, String fallbackSystemPrompt) {
if (scope.hasRole() && StringUtils.hasText(scope.systemPrompt())) {
return scope.systemPrompt();
}
return fallbackSystemPrompt;
}
/**
* 解析最终的检索分类范围
* 命中角色时强制使用角色绑定的分类忽略客户端传入防止越权跨域
* 未命中角色时沿用客户端传入的分类便于直接调试/无角色调用
*/
private List<Long> resolveCategoryIds(RoleScope scope, Long categoryId, String categoryIds) {
if (scope.hasRole()) {
return scope.categoryIds();
}
List<Long> ids = parseCategoryIds(categoryIds);
if (ids.isEmpty() && categoryId != null) {
ids = List.of(categoryId);
return List.of(categoryId);
}
return assistantApp.doChatWithRagStrategy(message, chatId, strategy, ids);
return ids;
}
private List<Long> parseCategoryIds(String categoryIds) {
@ -142,4 +312,7 @@ public class AiController {
.distinct()
.toList();
}
private record AccountRoleContext(Long accountId, Long roleId) {
}
}

7
src/main/java/com/wok/supportbot/controller/AiModelConfigController.java

@ -1,6 +1,5 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.app.AssistantApp;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.entity.AiModelConfig;
import com.wok.supportbot.service.AiModelConfigService;
@ -23,9 +22,6 @@ public class AiModelConfigController {
@Autowired
private ChatModelFactory chatModelFactory;
@Autowired
private AssistantApp assistantApp;
// ==================== 分页列表 ====================
/**
@ -268,11 +264,10 @@ public class AiModelConfigController {
// ==================== 缓存刷新 ====================
/**
* 刷新 ChatModel ChatClient 缓存
* 刷新 ChatModel 缓存
* 在配置变更增删改激活后调用确保下次对话使用最新配置
*/
private void refreshCache() {
chatModelFactory.clearCache();
assistantApp.clearCache();
}
}

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

@ -33,9 +33,11 @@ public class ConversationController {
public ResponseEntity<Map<String, Object>> listConversations(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword) {
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long accountId,
@RequestParam(required = false) Long roleId) {
try {
Map<String, Object> result = conversationService.listConversations(page, size, keyword);
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId);
Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true);
data.put("data", result.get("records"));
@ -54,6 +56,33 @@ public class ConversationController {
// ==================== 会话详情 ====================
/**
* 截断会话删除指定用户消息及其之后的全部消息用于"编辑历史消息并重发"
*
* @param conversationId 会话ID
* @param body { userTurn: 第几条用户消息1-based }
* @return 逻辑删除的消息条数
*/
@PostMapping("/conversation/{id}/truncate")
public ResponseEntity<Map<String, Object>> truncateConversation(
@PathVariable("id") String conversationId,
@RequestBody Map<String, Object> body) {
try {
Object raw = body.get("userTurn");
int userTurn = raw instanceof Number number ? number.intValue() : Integer.parseInt(String.valueOf(raw));
int deleted = conversationService.truncateFromUserTurn(conversationId, userTurn);
return ResponseEntity.ok(Map.of(
"success", true,
"deletedMessages", deleted
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "截断失败:" + e.getMessage()
));
}
}
/**
* 获取会话详情
*

89
src/main/java/com/wok/supportbot/controller/CustomerAccountController.java

@ -0,0 +1,89 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.service.CustomerAccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
public class CustomerAccountController {
@Autowired
private CustomerAccountService customerAccountService;
@GetMapping("/account/list")
public ResponseEntity<Map<String, Object>> listAccounts() {
return ResponseEntity.ok(Map.of(
"success", true,
"data", customerAccountService.listAccounts()
));
}
@GetMapping("/account/all")
public ResponseEntity<Map<String, Object>> listAllAccounts() {
return ResponseEntity.ok(Map.of(
"success", true,
"data", customerAccountService.listAccounts(true)
));
}
@PostMapping("/account")
public ResponseEntity<Map<String, Object>> createAccount(@RequestBody Map<String, Object> body) {
try {
customerAccountService.createAccount(
str(body.get("accountKey")), str(body.get("name")), str(body.get("description")),
longOrNull(body.get("roleId")), boolOrNull(body.get("enabled")));
return ResponseEntity.ok(Map.of("success", true, "message", "账号创建成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "创建账号失败:" + e.getMessage()));
}
}
@PutMapping("/account/{id}")
public ResponseEntity<Map<String, Object>> updateAccount(@PathVariable("id") Long accountId, @RequestBody Map<String, Object> body) {
try {
customerAccountService.updateAccount(accountId,
str(body.get("name")), str(body.get("description")),
longOrNull(body.get("roleId")), boolOrNull(body.get("enabled")));
return ResponseEntity.ok(Map.of("success", true, "message", "账号更新成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "更新账号失败:" + e.getMessage()));
}
}
@DeleteMapping("/account/{id}")
public ResponseEntity<Map<String, Object>> deleteAccount(@PathVariable("id") Long accountId) {
try {
customerAccountService.deleteAccount(accountId);
return ResponseEntity.ok(Map.of("success", true, "message", "账号删除成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "删除账号失败:" + e.getMessage()));
}
}
private static String str(Object v) {
return v == null ? null : v.toString();
}
private static Long longOrNull(Object v) {
if (v == null || v.toString().isBlank()) {
return null;
}
return v instanceof Number number ? number.longValue() : Long.parseLong(v.toString());
}
private static Boolean boolOrNull(Object v) {
if (v instanceof Boolean b) {
return b;
}
return v != null ? Boolean.parseBoolean(v.toString()) : null;
}
}

62
src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java

@ -3,8 +3,10 @@ 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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@ -27,6 +29,51 @@ public class CustomerServiceRoleController {
));
}
/**
* 管理用列出全部角色含已停用
*/
@GetMapping("/role/all")
public ResponseEntity<Map<String, Object>> listAllRoles() {
return ResponseEntity.ok(Map.of(
"success", true,
"data", customerServiceRoleService.listRoles(true)
));
}
@PostMapping("/role")
public ResponseEntity<Map<String, Object>> createRole(@RequestBody Map<String, Object> body) {
try {
customerServiceRoleService.createRole(
str(body.get("roleKey")), str(body.get("name")), str(body.get("description")),
str(body.get("prompt")), intOrNull(body.get("sortOrder")), boolOrNull(body.get("enabled")));
return ResponseEntity.ok(Map.of("success", true, "message", "角色创建成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "创建角色失败:" + e.getMessage()));
}
}
@PutMapping("/role/{id}")
public ResponseEntity<Map<String, Object>> updateRole(@PathVariable("id") Long roleId, @RequestBody Map<String, Object> body) {
try {
customerServiceRoleService.updateRole(roleId,
str(body.get("name")), str(body.get("description")), str(body.get("prompt")),
intOrNull(body.get("sortOrder")), boolOrNull(body.get("enabled")));
return ResponseEntity.ok(Map.of("success", true, "message", "角色更新成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "更新角色失败:" + e.getMessage()));
}
}
@DeleteMapping("/role/{id}")
public ResponseEntity<Map<String, Object>> deleteRole(@PathVariable("id") Long roleId) {
try {
customerServiceRoleService.deleteRole(roleId);
return ResponseEntity.ok(Map.of("success", true, "message", "角色删除成功"));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of("success", false, "message", "删除角色失败:" + e.getMessage()));
}
}
@PutMapping("/role/{id}/categories")
public ResponseEntity<Map<String, Object>> updateRoleCategories(
@PathVariable("id") Long roleId,
@ -50,4 +97,19 @@ public class CustomerServiceRoleController {
.distinct()
.toList();
}
private static String str(Object v) {
return v == null ? null : v.toString();
}
private static Integer intOrNull(Object v) {
return v instanceof Number number ? number.intValue() : null;
}
private static Boolean boolOrNull(Object v) {
if (v instanceof Boolean b) {
return b;
}
return v != null ? Boolean.parseBoolean(v.toString()) : null;
}
}

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

@ -1,7 +1,6 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.dao.ChatMessageMapper;
import com.wok.supportbot.entity.ChatMessage;
import lombok.extern.slf4j.Slf4j;
@ -10,7 +9,6 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* 会话管理服务
@ -37,65 +35,90 @@ public class ConversationService {
* @return 分页会话列表
*/
public Map<String, Object> listConversations(int page, int size, String keyword) {
return listConversations(page, size, keyword, null, null);
}
public Map<String, Object> listConversations(int page, int size, String keyword, Long accountId, Long roleId) {
// 构建基础 SQL 条件
StringBuilder whereClause = new StringBuilder("WHERE is_delete = false ");
StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false ");
List<Object> params = new ArrayList<>();
if (keyword != null && !keyword.isEmpty()) {
// 先找出包含关键词的 conversation_id
String matchSql = "SELECT DISTINCT conversation_id FROM chat_message " +
"WHERE is_delete = false AND (conversation_id ILIKE ? OR content ILIKE ?)";
List<String> matchedIds = jdbcTemplate.queryForList(matchSql, String.class,
"%" + keyword + "%", "%" + keyword + "%");
if (matchedIds.isEmpty()) {
// 无匹配结果返回空列表
Map<String, Object> emptyResult = new LinkedHashMap<>();
emptyResult.put("records", List.of());
emptyResult.put("total", 0L);
emptyResult.put("page", page);
emptyResult.put("size", size);
emptyResult.put("pages", 0L);
return emptyResult;
}
// 构建 IN 子句
String inClause = matchedIds.stream()
.map(id -> "'" + id.replace("'", "''") + "'")
.collect(Collectors.joining(","));
whereClause.append(" AND conversation_id IN (").append(inClause).append(")");
whereClause.append("""
AND (cm1.conversation_id ILIKE ?
OR cm1.content ILIKE ?
OR ca.name ILIKE ?
OR ca.account_key ILIKE ?
OR csr.name ILIKE ?)
""");
String like = "%" + keyword + "%";
params.add(like);
params.add(like);
params.add(like);
params.add(like);
params.add(like);
}
if (accountId != null && accountId > 0) {
whereClause.append(" AND cs.account_id = ? ");
params.add(accountId);
}
if (roleId != null && roleId > 0) {
whereClause.append(" AND cs.role_id = ? ");
params.add(roleId);
}
// 查询总会话数
String countSql = "SELECT COUNT(DISTINCT conversation_id) FROM chat_message " + whereClause;
String countSql = """
SELECT COUNT(DISTINCT cm1.conversation_id)
FROM chat_message cm1
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
""" + whereClause;
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
if (total == null) total = 0L;
// 查询会话列表带统计信息
String listSql = """
SELECT
conversation_id,
cm1.conversation_id,
cs.account_id::text AS account_id,
ca.name AS account_name,
ca.account_key,
cs.role_id::text AS role_id,
csr.name AS role_name,
COUNT(*) as message_count,
MIN(create_time) as first_message_time,
MAX(create_time) as last_message_time,
MIN(cm1.create_time) as first_message_time,
MAX(cm1.create_time) as last_message_time,
(SELECT content FROM chat_message cm2
WHERE cm2.conversation_id = cm1.conversation_id AND cm2.is_delete = false
ORDER BY cm2.create_time DESC LIMIT 1) as last_message_preview
FROM chat_message cm1
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
""" + whereClause + """
GROUP BY conversation_id
GROUP BY cm1.conversation_id, cs.account_id, ca.name, ca.account_key, cs.role_id, csr.name
ORDER BY last_message_time DESC
LIMIT ? OFFSET ?
""";
List<Object> listParams = new ArrayList<>(params);
listParams.add(size);
listParams.add((page - 1) * size);
List<Map<String, Object>> records = jdbcTemplate.queryForList(listSql,
size, (page - 1) * size);
listParams.toArray());
// 格式化结果
List<Map<String, Object>> formattedRecords = new ArrayList<>();
for (Map<String, Object> record : records) {
Map<String, Object> formatted = new LinkedHashMap<>();
formatted.put("conversationId", record.get("conversation_id"));
formatted.put("accountId", record.get("account_id"));
formatted.put("accountName", record.get("account_name"));
formatted.put("accountKey", record.get("account_key"));
formatted.put("roleId", record.get("role_id"));
formatted.put("roleName", record.get("role_name"));
formatted.put("messageCount", ((Number) record.get("message_count")).intValue());
formatted.put("firstMessageTime", record.get("first_message_time"));
formatted.put("lastMessageTime", record.get("last_message_time"));
@ -114,6 +137,55 @@ public class ConversationService {
return result;
}
public void bindConversation(String conversationId, Long accountId, Long roleId) {
if (conversationId == null || conversationId.isBlank()) {
return;
}
Long normalizedAccountId = accountId != null && accountId > 0 ? accountId : null;
Long normalizedRoleId = roleId != null && roleId > 0 ? roleId : null;
jdbcTemplate.update("""
INSERT INTO conversation_session (conversation_id, account_id, role_id)
VALUES (?, ?, ?)
ON CONFLICT (conversation_id)
DO UPDATE SET account_id = EXCLUDED.account_id,
role_id = EXCLUDED.role_id,
update_time = CURRENT_TIMESTAMP
""", conversationId, normalizedAccountId, normalizedRoleId);
}
/**
* 截断会话删除"第 userTurn 条用户消息"及其之后的所有消息逻辑删除
* 用于"编辑历史消息并重发"清掉发错的那条用户消息连同其后的问答
* 使重发时的对话记忆DatabaseChatMemory is_delete=false 取最近 N 保持干净不被污染
*
* @param conversationId 会话ID
* @param userTurn 第几条用户消息1-based仅统计 USER 类型前端问候语等本地消息不计
* @return 实际逻辑删除的消息条数
*/
public int truncateFromUserTurn(String conversationId, int userTurn) {
if (conversationId == null || conversationId.isBlank() || userTurn < 1) {
return 0;
}
// 雪花 ID 单调递增 id 升序即时间顺序定位第 userTurn 条用户消息
List<Map<String, Object>> userMessages = jdbcTemplate.queryForList("""
SELECT id FROM chat_message
WHERE conversation_id = ? AND message_type = 'USER' AND is_delete = false
ORDER BY id ASC
""", conversationId);
if (userMessages.size() < userTurn) {
return 0;
}
long cutId = ((Number) userMessages.get(userTurn - 1).get("id")).longValue();
// 逻辑删除该条用户消息及其之后的所有消息
int deleted = jdbcTemplate.update("""
UPDATE chat_message
SET is_delete = true, update_time = CURRENT_TIMESTAMP
WHERE conversation_id = ? AND is_delete = false AND id >= ?
""", conversationId, cutId);
log.info("截断会话: conversationId={}, 从第{}条用户消息(id={})起删除{}条", conversationId, userTurn, cutId, deleted);
return deleted;
}
// ==================== 会话详情 ====================
/**
@ -147,6 +219,7 @@ public class ConversationService {
Map<String, Object> detail = new LinkedHashMap<>();
detail.put("conversationId", conversationId);
appendConversationOwner(detail, conversationId);
detail.put("messageCount", ((Number) stats.get("message_count")).intValue());
detail.put("userMessageCount", ((Number) stats.get("user_message_count")).intValue());
detail.put("assistantMessageCount", ((Number) stats.get("assistant_message_count")).intValue());
@ -156,6 +229,34 @@ public class ConversationService {
return detail;
}
private void appendConversationOwner(Map<String, Object> detail, String conversationId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT cs.account_id::text AS account_id,
ca.name AS account_name,
ca.account_key,
cs.role_id::text AS role_id,
csr.name AS role_name
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
WHERE cs.conversation_id = ?
""", conversationId);
if (rows.isEmpty()) {
detail.put("accountId", null);
detail.put("accountName", null);
detail.put("accountKey", null);
detail.put("roleId", null);
detail.put("roleName", null);
return;
}
Map<String, Object> owner = rows.get(0);
detail.put("accountId", owner.get("account_id"));
detail.put("accountName", owner.get("account_name"));
detail.put("accountKey", owner.get("account_key"));
detail.put("roleId", owner.get("role_id"));
detail.put("roleName", owner.get("role_name"));
}
// ==================== 会话消息 ====================
/**

119
src/main/java/com/wok/supportbot/service/CustomerAccountService.java

@ -0,0 +1,119 @@
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.List;
import java.util.Map;
import java.util.Objects;
@Service
public class CustomerAccountService {
@Autowired
private JdbcTemplate jdbcTemplate;
public List<Map<String, Object>> listAccounts() {
return listAccounts(false);
}
public List<Map<String, Object>> listAccounts(boolean includeDisabled) {
String sql = """
SELECT a.id::text AS id,
a.account_key,
a.name,
a.description,
a.role_id::text AS role_id,
r.name AS role_name,
a.enabled,
a.create_time,
a.update_time
FROM customer_account a
LEFT JOIN customer_service_role r ON r.id = a.role_id AND r.is_delete = false
WHERE a.is_delete = false
""" + (includeDisabled ? "" : "AND a.enabled = true ") + """
ORDER BY a.id ASC
""";
return jdbcTemplate.queryForList(sql);
}
@Transactional(rollbackFor = Exception.class)
public void createAccount(String accountKey, String name, String description, Long roleId, Boolean enabled) {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("账号名称不能为空");
}
String key = (accountKey == null || accountKey.trim().isEmpty())
? "account_" + System.currentTimeMillis()
: accountKey.trim();
jdbcTemplate.update("""
INSERT INTO customer_account (account_key, name, description, role_id, enabled)
VALUES (?, ?, ?, ?, ?)
""",
key, name.trim(), description, normalizeRoleId(roleId),
enabled != null ? enabled : true);
}
@Transactional(rollbackFor = Exception.class)
public void updateAccount(Long accountId, String name, String description, Long roleId, Boolean enabled) {
if (accountId == null || accountId <= 0) {
throw new IllegalArgumentException("账号ID无效");
}
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("账号名称不能为空");
}
jdbcTemplate.update("""
UPDATE customer_account
SET name = ?, description = ?, role_id = ?, enabled = ?, update_time = CURRENT_TIMESTAMP
WHERE id = ? AND is_delete = false
""",
name.trim(), description, normalizeRoleId(roleId),
enabled != null ? enabled : true,
accountId);
}
@Transactional(rollbackFor = Exception.class)
public void deleteAccount(Long accountId) {
if (accountId == null || accountId <= 0) {
return;
}
jdbcTemplate.update("""
UPDATE customer_account
SET is_delete = true, enabled = false, update_time = CURRENT_TIMESTAMP
WHERE id = ?
""", accountId);
}
public AccountScope getAccountScope(Long accountId) {
if (accountId == null || accountId <= 0) {
return AccountScope.empty();
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, name, role_id
FROM customer_account
WHERE id = ? AND is_delete = false AND enabled = true
""", accountId);
if (rows.isEmpty()) {
return AccountScope.empty();
}
Map<String, Object> row = rows.get(0);
Long roleId = row.get("role_id") == null ? null : ((Number) row.get("role_id")).longValue();
return new AccountScope(true, ((Number) row.get("id")).longValue(), Objects.toString(row.get("name"), ""), roleId);
}
private Long normalizeRoleId(Long roleId) {
return roleId != null && roleId > 0 ? roleId : null;
}
public record AccountScope(boolean present, Long accountId, String name, Long roleId) {
public static AccountScope empty() {
return new AccountScope(false, null, "", null);
}
public boolean hasAccount() {
return present;
}
}
}

132
src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java

@ -18,12 +18,17 @@ public class CustomerServiceRoleService {
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
""";
return listRoles(false);
}
/**
* @param includeDisabled true=包含已停用角色管理页用false=仅启用角色对话页用
*/
public List<Map<String, Object>> listRoles(boolean includeDisabled) {
String roleSql = "SELECT id::text AS id, role_key, name, description, prompt, sort_order, enabled "
+ "FROM customer_service_role WHERE is_delete = false "
+ (includeDisabled ? "" : "AND enabled = true ")
+ "ORDER BY sort_order ASC, id ASC";
List<Map<String, Object>> roles = jdbcTemplate.queryForList(roleSql);
String categorySql = """
@ -77,4 +82,119 @@ public class CustomerServiceRoleService {
""", roleId, categoryId);
}
}
/**
* 新增角色role_key 缺省时自动生成
*/
@Transactional(rollbackFor = Exception.class)
public void createRole(String roleKey, String name, String description, String prompt, Integer sortOrder, Boolean enabled) {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("角色名称不能为空");
}
String key = (roleKey == null || roleKey.trim().isEmpty())
? "role_" + System.currentTimeMillis()
: roleKey.trim();
jdbcTemplate.update("""
INSERT INTO customer_service_role (role_key, name, description, prompt, sort_order, enabled)
VALUES (?, ?, ?, ?, ?, ?)
""",
key, name.trim(), description, prompt,
sortOrder != null ? sortOrder : 0,
enabled != null ? enabled : true);
}
/**
* 编辑角色基本信息不含知识库分类分类走 {@link #updateRoleCategories}
*/
@Transactional(rollbackFor = Exception.class)
public void updateRole(Long roleId, String name, String description, String prompt, Integer sortOrder, Boolean enabled) {
if (roleId == null || roleId <= 0) {
throw new IllegalArgumentException("角色ID无效");
}
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("角色名称不能为空");
}
jdbcTemplate.update("""
UPDATE customer_service_role
SET name = ?, description = ?, prompt = ?, sort_order = ?, enabled = ?, update_time = CURRENT_TIMESTAMP
WHERE id = ? AND is_delete = false
""",
name.trim(), description, prompt,
sortOrder != null ? sortOrder : 0,
enabled != null ? enabled : true,
roleId);
}
/**
* 逻辑删除角色并连带逻辑删除其知识库分类关联
*/
@Transactional(rollbackFor = Exception.class)
public void deleteRole(Long roleId) {
if (roleId == null || roleId <= 0) {
return;
}
jdbcTemplate.update("UPDATE customer_service_role SET is_delete = true, update_time = CURRENT_TIMESTAMP WHERE id = ?", roleId);
jdbcTemplate.update("UPDATE customer_service_role_category SET is_delete = true WHERE role_id = ?", roleId);
}
/**
* 服务端解析角色的知识库范围与人设
* 用于对话时强制约束角色只能检索其绑定分类下的内容由后端决定客户端无法越权跨域
*
* @param roleId 角色ID
* @return 角色范围角色不存在或被禁用时返回 {@link RoleScope#empty()}
*/
public RoleScope getRoleScope(Long roleId) {
if (roleId == null || roleId <= 0) {
return RoleScope.empty();
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT name, prompt FROM customer_service_role WHERE id = ? AND is_delete = false AND enabled = true",
roleId);
if (rows.isEmpty()) {
return RoleScope.empty();
}
String name = Objects.toString(rows.get(0).get("name"), "");
String prompt = Objects.toString(rows.get(0).get("prompt"), "");
List<Long> categoryIds = jdbcTemplate.queryForList(
"SELECT category_id FROM customer_service_role_category WHERE role_id = ? AND is_delete = false",
Long.class, roleId);
return new RoleScope(true, name, prompt, categoryIds);
}
/**
* 角色范围是否命中角色角色人设可检索的知识库分类
*/
public record RoleScope(boolean present, String name, String prompt, List<Long> categoryIds) {
public static RoleScope empty() {
return new RoleScope(false, "", "", List.of());
}
public boolean hasRole() {
return present;
}
/**
* 组合角色系统提示词当前客服角色 + 人设都为空时返回 null退回基础提示词
*/
public String systemPrompt() {
if (!present) {
return null;
}
String trimmedName = name == null ? "" : name.trim();
String trimmedPrompt = prompt == null ? "" : prompt.trim();
if (trimmedName.isEmpty() && trimmedPrompt.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder();
if (!trimmedName.isEmpty()) {
sb.append("当前客服角色:").append(trimmedName).append("。");
}
if (!trimmedPrompt.isEmpty()) {
sb.append(trimmedPrompt);
}
return sb.toString();
}
}
}

3
src/main/resources/application.yml

@ -72,6 +72,9 @@ knowledge:
min-chunk-size-chars: 10
max-num-chunks: 5000
keep-separator: true
role:
# 严格隔离:true=角色未绑定知识库分类时禁止检索任何内容;false(默认)=可检索全部知识库
strict-isolation: false
# ==================== Knife4j API 文档配置 ====================
springdoc:

155
src/main/resources/static/components/AccountManager.js

@ -0,0 +1,155 @@
/**
* 账号管理
* 管理对话账号并为账号绑定默认客服角色
*/
import { ref, reactive, onMounted } from 'vue'
import { getAllAccounts, createAccount, updateAccount, deleteAccount, getRoleList } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>账号管理</h2>
<p style="font-size:12px;color:var(--sub);margin:4px 0 12px;">
为每个账号绑定一个默认角色对话页选择账号后系统会按账号绑定的角色回答并把会话历史归属到该账号
</p>
<div style="border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:16px;">
<div class="input-row">
<input class="input" v-model="form.name" placeholder="账号名称 *">
<input class="input" v-model="form.description" placeholder="账号说明(可选)">
<select class="select" v-model="form.roleId">
<option value="">未绑定角色</option>
<option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option>
</select>
<label class="toggle-line"><input type="checkbox" v-model="form.enabled"><span>启用</span></label>
</div>
<div class="input-row" style="margin-top:8px;">
<button class="btn btn-success" @click="submit">{{ editingId ? '保存修改' : '新增账号' }}</button>
<button v-if="editingId" class="btn btn-outline btn-sm" @click="resetForm">取消编辑</button>
<button class="btn btn-outline btn-sm" @click="reload">刷新</button>
</div>
</div>
<div v-if="!accounts.length" style="font-size:13px;color:var(--sub);">暂无账号</div>
<div v-else style="overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th>账号</th>
<th>说明</th>
<th>绑定角色</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="account in accounts" :key="account.id">
<td>
<strong>{{ account.name }}</strong>
<div style="font-size:11px;color:var(--sub);">{{ account.account_key }}</div>
</td>
<td>{{ account.description || '-' }}</td>
<td>{{ account.role_name || '未绑定' }}</td>
<td>{{ account.enabled === false ? '停用' : '启用' }}</td>
<td>
<button class="btn btn-outline btn-sm" @click="startEdit(account)">编辑</button>
<button class="btn btn-danger btn-sm" @click="remove(account)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
`,
setup() {
const accounts = ref([])
const roles = ref([])
const editingId = ref('')
const form = reactive({ name: '', description: '', roleId: '', enabled: true })
async function loadRoles() {
const json = await getRoleList()
if (json.success) {
roles.value = json.data || []
}
}
async function reload() {
try {
const json = await getAllAccounts()
if (json.success) {
accounts.value = json.data || []
}
} catch (e) {
toast('加载账号失败:' + e.message, 'error')
}
}
function resetForm() {
editingId.value = ''
form.name = ''
form.description = ''
form.roleId = ''
form.enabled = true
}
function startEdit(account) {
editingId.value = account.id
form.name = account.name || ''
form.description = account.description || ''
form.roleId = account.role_id || ''
form.enabled = account.enabled !== false
}
async function submit() {
if (!form.name.trim()) {
toast('请输入账号名称', 'error')
return
}
const payload = {
name: form.name,
description: form.description,
roleId: form.roleId || null,
enabled: form.enabled
}
try {
const json = editingId.value
? await updateAccount(editingId.value, payload)
: await createAccount(payload)
if (json.success) {
toast(editingId.value ? '账号已更新' : '账号已创建', 'success')
resetForm()
reload()
} else {
toast(json.message || '操作失败', 'error')
}
} catch (e) {
toast('操作失败:' + e.message, 'error')
}
}
async function remove(account) {
if (!confirm(`确定删除账号「${account.name}」?历史会话记录会保留归属信息。`)) return
try {
const json = await deleteAccount(account.id)
if (json.success) {
toast('账号已删除', 'success')
if (editingId.value === account.id) resetForm()
reload()
} else {
toast(json.message || '删除失败', 'error')
}
} catch (e) {
toast('删除失败:' + e.message, 'error')
}
}
onMounted(() => {
loadRoles()
reload()
})
return { accounts, roles, editingId, form, reload, resetForm, startEdit, submit, remove }
}
}

6
src/main/resources/static/components/CategoryManager.js

@ -9,13 +9,13 @@ import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>🏷 分类管理</h2>
<h2>分类管理</h2>
<div class="input-row">
<input class="input" v-model="name" placeholder="分类名称">
<input class="input" v-model="description" placeholder="分类描述(可选)">
<input class="input input-sm" v-model.number="sortOrder" placeholder="排序" type="number">
<button class="btn btn-success" @click="create"> 创建分类</button>
<button class="btn btn-outline btn-sm" @click="store.loadCategories()">🔄 刷新</button>
<button class="btn btn-success" @click="create">创建分类</button>
<button class="btn btn-outline btn-sm" @click="store.loadCategories()">刷新</button>
</div>
<div v-if="store.categories.length === 0" style="margin-top:12px;font-size:13px;">暂无分类</div>

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

@ -2,29 +2,31 @@
* 智能客服对话面板
*/
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 { chatSync, chatRagSync, chatSSEUrl, chatRagSSEUrl, getRoleList, getAccountList, truncateConversation, ragSources } from '../js/api.js'
import { toast, readSSEStream, renderMarkdown } from '../js/utils.js'
import { store } from '../js/store.js'
import MessageSources from './MessageSources.js'
const FALLBACK_ROLE = {
id: '',
key: 'general',
name: '综合客服',
name: '客服',
desc: '可检索全部知识库',
tone: '专业、耐心、简洁',
tone: '用户咨询、业务办理、常见问题、问题受理与进度说明',
badge: '默认',
categoryIds: []
}
const QUICK_QUESTIONS = [
'我的订单什么时候发货?',
'已经付款但订单状态没更新怎么办?',
'如何申请退款或退货?',
'物流显示签收但我没收到怎么办?',
'这个商品有哪些售后政策?'
'我的问题应该找哪个部门处理?',
'如何办理业务申请?',
'流程进度一直没更新怎么办?',
'费用报销需要准备哪些材料?',
'办公用品或资产怎么申请?'
]
export default {
components: { 'message-sources': MessageSources },
template: `
<div class="chat-shell">
<aside class="chat-sidebar">
@ -37,73 +39,49 @@ export default {
</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>
<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 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 class="side-section side-section-grow">
<div class="side-label">客服助手</div>
<div class="assistant-list">
<button
v-for="role in roles"
:key="role.key"
:class="['assistant-card', selectedRole === role.key ? 'active' : '']"
@click="selectRole(role.key)"
>
<span class="assistant-avatar-sm">{{ role.badge }}</span>
<span class="assistant-meta">
<strong>{{ role.name }}</strong>
<small>{{ role.desc }}</small>
</span>
</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 class="chat-title">
<h2>{{ currentRole.name }}</h2>
<div class="chat-subline">
<span :class="['rag-dot', isRagMode ? 'on' : '']"></span>{{ ragStatusText }}
<span class="dot-sep">·</span>{{ selectedCategoryNames.length ? selectedCategoryNames.join('') : '' }}
</div>
</div>
<select class="select chat-mode" v-model="mode">
<option value="sync">同步调用</option>
<option value="sse">SSE 流式</option>
<option value="sse2">ServerSentEvent</option>
<option value="sse3">SseEmitter</option>
</select>
</header>
<div class="quick-row" v-if="messages.length <= 1">
@ -112,29 +90,46 @@ export default {
<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>
<template v-if="editingIndex === i">
<textarea class="textarea edit-textarea" v-model="editingText" rows="2"
@keydown.enter.exact.prevent="submitEdit(i)"></textarea>
<div class="edit-actions">
<button class="btn btn-primary btn-sm" @click="submitEdit(i)" :disabled="isSending || !editingText.trim()">重新发送</button>
<button class="btn btn-outline btn-sm" @click="cancelEdit">取消</button>
<span>将清除此条之后的全部对话并重发</span>
</div>
</template>
<template v-else>
<div v-if="m.role === 'assistant' && m.content" class="msg-bubble markdown-body" v-html="renderMd(m.content)"></div>
<div v-else-if="m.role === 'assistant'" class="msg-bubble"><span class="thinking">正在思考</span></div>
<div v-else class="msg-bubble">{{ m.content }}</div>
<message-sources v-if="m.role === 'assistant' && m.sources && m.sources.length" :sources="m.sources"></message-sources>
<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.role === 'assistant' && m.content && !m.streaming" @click="regenerate(i)">重新生成</button>
<button v-if="m.role === 'user' && !m.streaming" @click="startEditMessage(i)">编辑</button>
<button v-if="m.error" @click="retryLast">重试</button>
</div>
</template>
</div>
</div>
</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>
<div class="composer-box">
<textarea
class="chat-textarea"
v-model="userInput"
placeholder="输入问题,Enter 发送,Shift + Enter 换行"
@keydown.enter.exact.prevent="send"
:disabled="isSending"
></textarea>
<button class="send-btn" @click="send" :disabled="isSending || !userInput.trim()">
{{ isSending ? '发送中' : '发送' }}
</button>
</div>
</footer>
</section>
</div>
@ -142,6 +137,8 @@ export default {
setup() {
const chatId = ref('')
const mode = ref('sync')
const selectedAccount = ref('')
const accounts = ref([])
const selectedRole = ref('general')
const roles = ref([FALLBACK_ROLE])
const isRagMode = ref(false)
@ -152,14 +149,22 @@ export default {
const messages = ref([
{
role: 'assistant',
content: '您好,我是电商智能客服助手。可以咨询商品、订单、支付、物流和售后问题;如果需要基于知识库回答,请先开启左侧 RAG 检索。',
content: '您好,我是智能客服助手。可以咨询客服、财务、行政相关问题;如果需要基于知识库回答,请先在右侧开启 RAG 检索。',
streaming: false,
time: formatTime()
}
])
const msgArea = ref(null)
const editingIndex = ref(-1)
const editingText = ref('')
const currentRole = computed(() => roles.value.find(role => role.key === selectedRole.value) || roles.value[0] || FALLBACK_ROLE)
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 selectedCategoryIds = computed(() => currentRole.value.categoryIds || [])
const selectedCategoryNames = computed(() => {
const selected = new Set(selectedCategoryIds.value)
@ -167,12 +172,6 @@ export default {
.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 = {
@ -193,12 +192,35 @@ export default {
chatId.value = 'web_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8)
}
// RAG 默认策略:角色有授权知识库分类时默认开启检索,客服无需手动判断(仍可手动覆盖)
function syncRagDefault() {
isRagMode.value = selectedCategoryIds.value.length > 0
}
function selectRole(roleKey) {
const role = roles.value.find(item => item.key === roleKey)
// 双向绑定:选助手时自动同步到绑定该角色的账号;没有对应账号则进入"无账号"模式
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
syncRagDefault()
newChatId()
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
}
syncRagDefault()
newChatId()
clearMessages()
}
function clearMessages(roleKey = selectedRole.value) {
const role = roles.value.find(item => item.key === roleKey) || FALLBACK_ROLE
messages.value = [{
@ -230,35 +252,36 @@ export default {
if (!roles.value.some(role => role.key === selectedRole.value)) {
selectedRole.value = roles.value[0]?.key || 'general'
}
syncRagDefault()
}
} catch (e) {
toast('加载客服角色失败:' + e.message, 'error')
}
}
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)
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')
}
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 buildRoleAwareMessage(text) {
const scope = selectedCategoryNames.value.length
? `仅使用这些知识库范围内的信息:${selectedCategoryNames.value.join('、')}`
: '可使用全部知识库范围内的信息'
return `当前客服角色:${currentRole.value.name}。回复风格:${currentRole.value.tone}。知识库范围:${scope}。用户问题:${text}`
function currentRoleId() {
// 角色人设与知识库范围统一由服务端依据 roleId 套用,这里仅取出当前角色ID。
// 空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() {
@ -275,30 +298,42 @@ export default {
isSending.value = true
messages.value.push({ role: 'user', content: text, streaming: false, time: formatTime() })
const assistantMsg = { role: 'assistant', content: '', streaming: true, time: formatTime() }
const assistantMsg = { role: 'assistant', content: '', streaming: true, time: formatTime(), sources: [] }
messages.value.push(assistantMsg)
await scrollToBottom()
const cid = chatId.value || ('web_' + Date.now())
chatId.value = cid
const requestText = buildRoleAwareMessage(text)
// 发送用户原始问题(不再包装角色信息,避免污染会话记忆);
// 角色人设与知识库范围由服务端依据 roleId 强制套用。
const roleId = currentRoleId()
const accountId = currentAccountId()
try {
if (isRagMode.value && mode.value === 'sync') {
assistantMsg.content = await chatRagSync(requestText, cid, ragStrategy.value, selectedCategoryIds.value)
} else if (isRagMode.value && mode.value !== 'sync') {
assistantMsg.content = '当前后端 RAG 接口只提供同步调用,请切换为“同步调用”后再试。'
assistantMsg.error = true
toast('RAG 模式暂不支持流式调用', 'error')
assistantMsg.content = await chatRagSync(text, cid, ragStrategy.value, roleId, accountId)
} else if (isRagMode.value) {
const url = chatRagSSEUrl(text, cid, ragStrategy.value, roleId, accountId)
await readSSEStream(url, async (chunk) => {
assistantMsg.content += chunk
await scrollToBottom()
}, () => {})
} else if (mode.value === 'sync') {
assistantMsg.content = await chatSync(requestText, cid)
assistantMsg.content = await chatSync(text, cid, roleId, accountId)
} else {
const url = chatSSEUrl(requestText, cid, mode.value)
const url = chatSSEUrl(text, cid, mode.value, roleId, accountId)
await readSSEStream(url, async (chunk) => {
assistantMsg.content += chunk
await scrollToBottom()
}, () => {})
}
// RAG 模式:回答完成后拉取本次命中的知识库片段,挂到该条回答下方
if (isRagMode.value) {
try {
const sj = await ragSources(text, cid, ragStrategy.value, roleId, accountId)
if (sj && sj.success) assistantMsg.sources = sj.data || []
} catch (_) { /* 来源获取失败不影响主回答 */ }
}
} catch (e) {
assistantMsg.content = '请求失败:' + e.message
assistantMsg.error = true
@ -316,6 +351,67 @@ export default {
send()
}
function startEditMessage(i) {
if (isSending.value) return
editingIndex.value = i
editingText.value = messages.value[i].content || ''
}
function cancelEdit() {
editingIndex.value = -1
editingText.value = ''
}
async function submitEdit(i) {
const text = editingText.value.trim()
if (!text || isSending.value) return
// 这是第几条用户消息(1-based,忽略问候语 / AI 消息)
let userTurn = 0
for (let j = 0; j <= i; j++) {
if (messages.value[j].role === 'user') userTurn++
}
// 先让后端清掉这条用户消息及其之后的全部消息(修复对话记忆,避免污染上下文)
try {
const json = await truncateConversation(chatId.value, userTurn)
if (json && json.success === false) {
toast(json.message || '截断失败', 'error')
return
}
} catch (e) {
toast('截断失败:' + e.message, 'error')
return
}
// 切掉本地的本条及其之后消息,再用新内容重发
messages.value = messages.value.slice(0, i)
editingIndex.value = -1
editingText.value = ''
userInput.value = text
await send()
}
async function regenerate(i) {
if (isSending.value) return
// 找到这条 AI 回答对应的上一条用户消息
let userIndex = -1
for (let j = i - 1; j >= 0; j--) {
if (messages.value[j].role === 'user') { userIndex = j; break }
}
if (userIndex < 0) return
const text = messages.value[userIndex].content
let userTurn = 0
for (let j = 0; j <= userIndex; j++) {
if (messages.value[j].role === 'user') userTurn++
}
// 截断该用户消息及其之后(含本条回答),用相同问题重新生成
try {
const json = await truncateConversation(chatId.value, userTurn)
if (json && json.success === false) { toast(json.message || '重新生成失败', 'error'); return }
} catch (e) { toast('重新生成失败:' + e.message, 'error'); return }
messages.value = messages.value.slice(0, userIndex)
userInput.value = text
await send()
}
async function copyMessage(content) {
try {
await navigator.clipboard.writeText(content)
@ -328,17 +424,19 @@ export default {
onMounted(() => {
newChatId()
store.loadCategories()
loadRoles()
loadRoles().then(loadAccounts)
})
return {
store,
accounts,
selectedAccount,
roles,
quickQuestions: QUICK_QUESTIONS,
chatId,
mode,
selectedRole,
selectedCategoryIds,
selectedCategoryNames,
isRagMode,
ragStrategy,
userInput,
@ -346,17 +444,22 @@ export default {
messages,
msgArea,
currentRole,
roleKnowledgeScopeText,
shortChatId,
ragStatusText,
selectAccount,
newChatId,
selectRole,
clearMessages,
useQuickQuestion,
toggleRoleCategory,
send,
retryLast,
copyMessage
copyMessage,
renderMd: renderMarkdown,
editingIndex,
editingText,
startEditMessage,
cancelEdit,
submitEdit,
regenerate
}
}
}

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

@ -3,13 +3,13 @@
* 展示会话列表消息详情删除导出功能
*/
import { ref } from 'vue'
import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation } from '../js/api.js'
import { listConversations, deleteConversation, getConversationMessages, getConversationStats, exportConversation, getAccountList, getRoleList } from '../js/api.js'
import { toast, formatDate } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>💬 会话列表</h2>
<h2>会话列表</h2>
<!-- 统计卡片 -->
<div class="stat-grid" v-if="stats" style="margin-bottom:16px;">
@ -34,8 +34,16 @@ export default {
<!-- 搜索栏 -->
<div class="input-row">
<input type="text" class="input" v-model="keyword" placeholder="搜索会话ID或消息内容..." @keyup.enter="load(1)">
<button class="btn btn-primary btn-sm" @click="load(1)">🔍 搜索</button>
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button>
<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>
<select class="select" v-model="roleFilter">
<option value="">全部角色</option>
<option v-for="role in roles" :key="role.id" :value="role.id">{{ role.name }}</option>
</select>
<button class="btn btn-primary btn-sm" @click="load(1)">搜索</button>
<button class="btn btn-outline btn-sm" @click="load()">刷新</button>
</div>
<!-- 会话列表表格 -->
@ -44,6 +52,8 @@ export default {
<thead>
<tr>
<th>会话ID</th>
<th>账号</th>
<th>角色</th>
<th>消息数</th>
<th>最后消息时间</th>
<th>最后消息预览</th>
@ -52,12 +62,14 @@ export default {
</thead>
<tbody>
<tr v-if="conversations.length === 0">
<td colspan="5" style="text-align:center;color:var(--sub);">暂无会话记录</td>
<td colspan="7" style="text-align:center;color:var(--sub);">暂无会话记录</td>
</tr>
<tr v-for="c in conversations" :key="c.conversationId">
<td>
<code style="font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;">{{ c.conversationId }}</code>
</td>
<td>{{ c.accountName || '-' }}</td>
<td>{{ c.roleName || '-' }}</td>
<td>{{ c.messageCount }}</td>
<td>{{ formatDate(c.lastMessageTime) }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
@ -89,9 +101,11 @@ export default {
<div class="modal-overlay" :class="{ active: messageModal.visible }" @click.self="closeMessageModal">
<div class="modal-box" style="max-width:900px;">
<button class="modal-close" @click="closeMessageModal">×</button>
<h2>💬 会话消息详情</h2>
<h2>会话消息详情</h2>
<p style="font-size:13px;color:var(--sub);margin-bottom:12px;">
会话ID: <code>{{ messageModal.conversationId }}</code>
<span v-if="messageModal.accountName"> · 账号{{ messageModal.accountName }}</span>
<span v-if="messageModal.roleName"> · 角色{{ messageModal.roleName }}</span>
· {{ messageModal.messages.length }} 条消息
</p>
@ -121,19 +135,30 @@ export default {
const pages = ref(1)
const total = ref(0)
const keyword = ref('')
const accountFilter = ref('')
const roleFilter = ref('')
const accounts = ref([])
const roles = ref([])
const stats = ref(null)
// 消息详情弹窗
const messageModal = ref({
visible: false,
conversationId: '',
accountName: '',
roleName: '',
messages: []
})
async function load(p = 1) {
page.value = p
try {
const json = await listConversations(p, 10, keyword.value || undefined)
const json = await listConversations(
p, 10,
keyword.value || undefined,
accountFilter.value || undefined,
roleFilter.value || undefined
)
if (json.success) {
conversations.value = json.data || []
total.value = json.total || 0
@ -146,6 +171,16 @@ export default {
}
}
async function loadFilters() {
try {
const [accountJson, roleJson] = await Promise.all([getAccountList(), getRoleList()])
if (accountJson.success) accounts.value = accountJson.data || []
if (roleJson.success) roles.value = roleJson.data || []
} catch (e) {
console.error('加载筛选项失败', e)
}
}
async function loadStats() {
try {
const json = await getConversationStats()
@ -163,6 +198,9 @@ export default {
if (json.success) {
messageModal.value.conversationId = conversationId
messageModal.value.messages = json.data || []
const current = conversations.value.find(item => item.conversationId === conversationId)
messageModal.value.accountName = current?.accountName || ''
messageModal.value.roleName = current?.roleName || ''
messageModal.value.visible = true
} else {
toast(json.message || '加载消息失败', 'error')
@ -175,6 +213,8 @@ export default {
function closeMessageModal() {
messageModal.value.visible = false
messageModal.value.conversationId = ''
messageModal.value.accountName = ''
messageModal.value.roleName = ''
messageModal.value.messages = []
}
@ -213,11 +253,12 @@ export default {
}
// 初始加载
loadFilters()
load()
loadStats()
return {
conversations, page, pages, total, keyword, stats, messageModal,
conversations, page, pages, total, keyword, accountFilter, roleFilter, accounts, roles, stats, messageModal,
load, loadStats, viewMessages, closeMessageModal, remove, downloadExport, formatDate
}
}

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

@ -10,7 +10,7 @@ export default {
<div :class="['modal-overlay', store.detailModal.visible ? 'active' : '']" @click.self="store.closeDetail()">
<div class="modal-box">
<button class="modal-close" @click="store.closeDetail()">&times;</button>
<h2>📄 文档详情</h2>
<h2>文档详情</h2>
<template v-if="store.detailModal.doc">
<div style="margin-bottom:16px;">
@ -24,10 +24,10 @@ export default {
<div class="result-item"><div class="label">分类</div><div class="value">{{ store.getCategoryName(store.detailModal.doc.categoryId) }}</div></div>
<div class="result-item"><div class="label">创建时间</div><div class="value">{{ formatDate(store.detailModal.doc.createTime) }}</div></div>
</div>
<h3>📄 原文内容</h3>
<div style="background:#f9fafb;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>
<h3>原文内容</h3>
<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>
<h3>🧩 分块详情{{ store.detailModal.chunks.length }} </h3>
<h3>分块详情{{ store.detailModal.chunks.length }} </h3>
<div v-for="(chunk, i) in store.detailModal.chunks" :key="i" class="search-result">
<div class="meta">#{{ i + 1 }} {{ getKeywords(chunk) ? '| 关键词: ' + getKeywords(chunk) : '' }}</div>

18
src/main/resources/static/components/DocList.js

@ -9,7 +9,7 @@ import { toast, formatDate } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>📋 文档列表</h2>
<h2>文档列表</h2>
<div class="input-row">
<select class="select" v-model="filterCategory" @change="load()">
<option value="">全部分类</option>
@ -17,17 +17,17 @@ export default {
</select>
<select class="select" v-model="filterStatus" @change="load()">
<option value="">全部状态</option>
<option value="READY"> 已完成</option>
<option value="PROCESSING"> 处理中</option>
<option value="FAILED"> 失败</option>
<option value="READY">已完成</option>
<option value="PROCESSING">处理中</option>
<option value="FAILED">失败</option>
</select>
<button class="btn btn-outline btn-sm" @click="load()">🔄 刷新</button>
<button class="btn btn-outline btn-sm" @click="load()">刷新</button>
<!-- 批量操作按钮 -->
<template v-if="selectedIds.size > 0">
<span style="font-size:12px;color:var(--primary);font-weight:600;">已选 {{ selectedIds.size }} </span>
<button class="btn btn-danger btn-sm" @click="batchRemove">🗑 批量删除</button>
<button class="btn btn-warn btn-sm" @click="batchReprocess">🔄 批量重新处理</button>
<span style="font-size:12px;color:var(--text);font-weight:600;">已选 {{ selectedIds.size }} </span>
<button class="btn btn-danger btn-sm" @click="batchRemove">批量删除</button>
<button class="btn btn-warn btn-sm" @click="batchReprocess">批量重新处理</button>
<button class="btn btn-outline btn-sm" @click="clearSelection">取消选择</button>
</template>
</div>
@ -50,7 +50,7 @@ export default {
<tr v-if="documents.length === 0">
<td colspan="8" style="text-align:center;color:var(--sub);">暂无文档</td>
</tr>
<tr v-for="d in documents" :key="d.id" :style="selectedIds.has(d.id) ? 'background:#eef2ff;' : ''">
<tr v-for="d in documents" :key="d.id" :style="selectedIds.has(d.id) ? 'background:#f3f4f6;' : ''">
<td><input type="checkbox" :checked="selectedIds.has(d.id)" @change="toggleSelect(d.id)" style="cursor:pointer;"></td>
<td>{{ d.id }}</td>
<td>

16
src/main/resources/static/components/DocSearch.js

@ -8,16 +8,22 @@ import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>🔍 语义搜索测试</h2>
<h2>语义搜索测试</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入查询语句测试知识库检索效果</p>
<div class="input-row">
<input class="input" v-model="query" placeholder="输入查询,如:Spring Boot 是什么?" @keydown.enter="search">
<input class="input input-sm" v-model.number="topK" placeholder="TopK" type="number" min="1" max="20">
<input class="input input-sm" v-model.number="threshold" placeholder="阈值" type="number" min="0" max="1" step="0.1">
<button class="btn btn-primary" @click="search" :disabled="isSearching">{{ isSearching ? '⏳ 搜索中...' : '🔍 搜索' }}</button>
<label class="input-field-sm">
<span>返回条数</span>
<input class="input input-sm" v-model.number="topK" type="number" min="1" max="20">
</label>
<label class="input-field-sm">
<span>相似度阈值</span>
<input class="input input-sm" v-model.number="threshold" type="number" min="0" max="1" step="0.1">
</label>
<button class="btn btn-primary" @click="search" :disabled="isSearching">{{ isSearching ? '搜索中...' : '搜索' }}</button>
</div>
<div v-if="isSearching" style="text-align:center;padding:20px;color:var(--sub);"> 搜索中...</div>
<div v-if="isSearching" style="text-align:center;padding:20px;color:var(--sub);">搜索中...</div>
<div v-else-if="searched && results.length === 0" style="text-align:center;padding:20px;color:var(--sub);">未找到相关结果</div>

2
src/main/resources/static/components/DocStats.js

@ -8,7 +8,7 @@ import { formatDate } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>📊 知识库概览</h2>
<h2>知识库概览</h2>
<div class="stat-grid">
<div class="stat-card">
<div class="number">{{ store.stats?.totalDocuments || 0 }}</div>

42
src/main/resources/static/components/DocUpload.js

@ -21,11 +21,11 @@ const MAX_FILE_SIZE = 50 * 1024 * 1024
export default {
template: `
<div class="card">
<h2>📤 文档上传</h2>
<h2>文档上传</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:16px;">上传文档到 RAG 知识库自动分词 向量化 存入 PGVector</p>
<!-- 上传元信息 -->
<div class="input-row" style="padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;">
<div class="input-row" style="padding:12px;background:#fafafa;border-radius:8px;border:1px solid var(--border);margin-bottom:16px;">
<select class="select" v-model="uploadCategory">
<option value="">选择分类可选</option>
<option v-for="c in store.categories" :key="c.id" :value="c.id">{{ c.name }}</option>
@ -38,7 +38,7 @@ export default {
<button class="btn btn-outline btn-sm" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '▼ 隐藏高级设置' : '▶ 高级设置(分块参数)' }}
</button>
<div v-show="showAdvanced" style="margin-top:8px;padding:12px;background:#f9fafb;border-radius:8px;border:1px solid var(--border);">
<div v-show="showAdvanced" style="margin-top:8px;padding:12px;background:#fafafa;border-radius:8px;border:1px solid var(--border);">
<p style="font-size:12px;color:var(--sub);margin-bottom:8px;">留空则使用全局配置分块大小 200 Token重叠 100 Token</p>
<div class="input-row" style="margin-bottom:0;">
<input class="input input-sm" v-model.number="chunkSizeOverride" placeholder="分块大小" type="number" min="50" max="2000">
@ -52,7 +52,7 @@ export default {
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:8px;">
<button v-for="t in subTabs" :key="t.key"
:class="['btn', 'btn-sm', activeSubTab === t.key ? 'btn-primary' : '']"
@click="activeSubTab = t.key">{{ t.icon }} {{ t.label }}</button>
@click="activeSubTab = t.key">{{ t.label }}</button>
</div>
<!-- 上传进度条 -->
@ -67,12 +67,12 @@ export default {
<div v-show="activeSubTab === 'file'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/file</code><span style="font-size:12px;color:var(--sub);">Tika </span></div>
<div class="upload-zone" @click="fileInput && fileInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'file')">
<div class="icon">📎</div><p>点击或拖拽上传,支持多文件(PDF / Word / Excel / PPT / TXT </p>
<p>点击或拖拽上传支持多文件PDF / Word / Excel / PPT / TXT </p>
<input type="file" ref="fileInput" style="display:none" multiple accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.html,.xml,.rtf" @change="handleFileSelect($event, 'file')">
</div>
<div v-html="fileInfo.file" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.file" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.file }}</div>
<button class="btn btn-primary" @click="doUpload('file')" :disabled="getButtonState('file')">🚀 上传并向量化</button>
<div v-if="validationErrors.file" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.file }}</div>
<button class="btn btn-primary" @click="doUpload('file')" :disabled="getButtonState('file')">上传并向量化</button>
<div v-html="results.file"></div>
</div>
@ -81,8 +81,8 @@ export default {
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/string</code><span style="font-size:12px;color:var(--sub);"></span></div>
<input class="input" v-model="stringTitle" placeholder="文档标题" style="margin-bottom:8px;">
<textarea class="textarea" v-model="stringContent" placeholder="输入要加入知识库的文本内容...
例如公司退换货政策商品FAQ物流说明"></textarea>
<button class="btn btn-primary" style="margin-top:12px;" @click="doUpload('string')">🚀 上传并向量化</button>
例如客服常见问题财务报销制度行政办公流程"></textarea>
<button class="btn btn-primary" style="margin-top:12px;" @click="doUpload('string')">上传并向量化</button>
<div v-html="results.string"></div>
</div>
@ -90,12 +90,12 @@ export default {
<div v-show="activeSubTab === 'markdown'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/markdown</code></div>
<div class="upload-zone" @click="$refs.mdInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'markdown')">
<div class="icon">📑</div><p>Markdown .md</p>
<p>点击或拖拽上传支持多文件Markdown .md</p>
<input type="file" ref="mdInput" style="display:none" accept=".md" multiple @change="handleFileSelect($event, 'markdown')">
</div>
<div v-html="fileInfo.markdown" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.markdown" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.markdown }}</div>
<button class="btn btn-primary" @click="doUpload('markdown')" :disabled="getButtonState('markdown')">🚀 上传并向量化</button>
<div v-if="validationErrors.markdown" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.markdown }}</div>
<button class="btn btn-primary" @click="doUpload('markdown')" :disabled="getButtonState('markdown')">上传并向量化</button>
<div v-html="results.markdown"></div>
</div>
@ -103,12 +103,12 @@ export default {
<div v-show="activeSubTab === 'jsonBasic'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/basic</code><span style="font-size:12px;color:var(--sub);"></span></div>
<div class="upload-zone" @click="$refs.jsonBInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonBasic')">
<div class="icon">📋</div><p>JSON .json</p>
<p>点击或拖拽上传支持多文件JSON .json</p>
<input type="file" ref="jsonBInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonBasic')">
</div>
<div v-html="fileInfo.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.jsonBasic }}</div>
<button class="btn btn-primary" @click="doUpload('jsonBasic')" :disabled="getButtonState('jsonBasic')">🚀 上传并向量化</button>
<div v-if="validationErrors.jsonBasic" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.jsonBasic }}</div>
<button class="btn btn-primary" @click="doUpload('jsonBasic')" :disabled="getButtonState('jsonBasic')">上传并向量化</button>
<div v-html="results.jsonBasic"></div>
</div>
@ -116,13 +116,13 @@ export default {
<div v-show="activeSubTab === 'jsonFields'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/fields</code><span style="font-size:12px;color:var(--sub);"></span></div>
<div class="upload-zone" @click="$refs.jsonFInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonFields')">
<div class="icon">🔑</div><p>JSON .json</p>
<p>点击或拖拽上传支持多文件JSON .json</p>
<input type="file" ref="jsonFInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonFields')">
</div>
<div v-html="fileInfo.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.jsonFields }}</div>
<div v-if="validationErrors.jsonFields" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.jsonFields }}</div>
<input class="input" v-model="jsonFieldsStr" placeholder="输入要提取的字段名(逗号分隔),如:title,description,content" style="margin-bottom:12px;">
<button class="btn btn-primary" @click="doUpload('jsonFields')" :disabled="getButtonState('jsonFields')">🚀 上传并向量化</button>
<button class="btn btn-primary" @click="doUpload('jsonFields')" :disabled="getButtonState('jsonFields')">上传并向量化</button>
<div v-html="results.jsonFields"></div>
</div>
@ -130,13 +130,13 @@ export default {
<div v-show="activeSubTab === 'jsonPointer'">
<div class="endpoint-info"><span class="badge badge-post">POST</span><code style="font-size:13px;">/upload/json/pointer</code><span style="font-size:12px;color:var(--sub);">JSON Pointer </span></div>
<div class="upload-zone" @click="$refs.jsonPInput.click()" @dragover.prevent="$event.currentTarget.classList.add('drag-over')" @dragleave="$event.currentTarget.classList.remove('drag-over')" @drop.prevent="handleDrop($event, 'jsonPointer')">
<div class="icon">📍</div><p>JSON .json</p>
<p>点击或拖拽上传支持多文件JSON .json</p>
<input type="file" ref="jsonPInput" style="display:none" accept=".json" multiple @change="handleFileSelect($event, 'jsonPointer')">
</div>
<div v-html="fileInfo.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--sub);"></div>
<div v-if="validationErrors.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--danger);"> {{ validationErrors.jsonPointer }}</div>
<div v-if="validationErrors.jsonPointer" style="margin-bottom:8px;font-size:13px;color:var(--danger);">{{ validationErrors.jsonPointer }}</div>
<input class="input" v-model="jsonPointerStr" placeholder="输入 JSON Pointer 路径,如:/data/items 或 /products" style="margin-bottom:12px;">
<button class="btn btn-primary" @click="doUpload('jsonPointer')" :disabled="getButtonState('jsonPointer')">🚀 上传并向量化</button>
<button class="btn btn-primary" @click="doUpload('jsonPointer')" :disabled="getButtonState('jsonPointer')">上传并向量化</button>
<div v-html="results.jsonPointer"></div>
</div>
</div>

63
src/main/resources/static/components/MessageSources.js

@ -0,0 +1,63 @@
/**
* 引用来源
* 展示一条 AI 回答所依据的知识库片段按文档归并点击可打开文档详情弹窗
* 仅在开启 RAG 检索且命中片段时出现
*/
import { computed } from 'vue'
import { store } from '../js/store.js'
export default {
props: {
sources: { type: Array, default: () => [] }
},
setup(props) {
// 按文档归并:同一文档的多个片段合并到一张卡片下
const grouped = computed(() => {
const map = new Map()
for (const s of props.sources) {
const key = s.documentId || s.title || s.sourceName || 'unknown'
if (!map.has(key)) {
map.set(key, {
documentId: s.documentId,
title: s.title || s.sourceName || ('文档 ' + (s.documentId || '')),
chunks: []
})
}
map.get(key).chunks.push(s)
}
return Array.from(map.values())
})
// distance(余弦距离,越小越相关)→ 0-100 的相关度
function relevance(score) {
if (score == null) return null
return Math.round(Math.max(0, Math.min(1, 1 - Number(score))) * 100)
}
function openDoc(documentId) {
if (documentId) store.openDetail(documentId)
}
return { grouped, relevance, openDoc }
},
template: `
<div class="msg-sources">
<div class="msg-sources-head">
<span>引用来源</span>
<span>{{ grouped.length }} 个文档 · {{ sources.length }} 个片段</span>
</div>
<div v-for="(g, gi) in grouped" :key="gi"
:class="['source-doc', g.documentId ? 'clickable' : '']"
@click="openDoc(g.documentId)">
<div class="source-doc-head">
<span class="source-doc-title">{{ g.title }}</span>
<span v-if="g.documentId" class="source-open">详情 </span>
</div>
<div v-for="(c, ci) in g.chunks" :key="ci" class="source-chunk">
<span v-if="relevance(c.score) !== null" class="source-rel">相关度 {{ relevance(c.score) }}%</span>
<span class="source-snippet">{{ c.snippet }}</span>
</div>
</div>
</div>
`
}

80
src/main/resources/static/components/ProductPanel.js

@ -1,80 +0,0 @@
/**
* 🏷 商品信息结构化提取面板
*/
import { ref } from 'vue'
import { extractProduct } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<div class="endpoint-info">
<span class="badge badge-get">GET</span>
<code style="font-size:13px;">/ai/product_info_app/chat/sync</code>
</div>
<h2>🏷 商品信息结构化提取</h2>
<p style="color:var(--sub);font-size:13px;margin-bottom:12px;">输入商品描述文本AI 自动提取标题描述价格评分评论数品牌分类</p>
<textarea class="textarea" v-model="content" placeholder="请输入商品网页内容或描述文本...
例如Apple iPhone 15 Pro Max 256GB 原色钛金属售价 ¥9999京东好评率98%累计2.5+评价品牌Apple属于智能手机分类..."></textarea>
<div class="input-row" style="margin-top:12px;">
<button class="btn btn-primary" @click="extract" :disabled="isExtracting">{{ isExtracting ? '⏳ 提取中...' : '🔍 提取商品信息' }}</button>
<button class="btn btn-outline btn-sm" @click="content=''">清空</button>
<button class="btn btn-outline btn-sm" @click="fillExample">填入示例</button>
</div>
<div v-if="result" style="margin-top:16px;">
<h3>📊 提取结果</h3>
<div class="result-grid">
<div class="result-item" v-for="f in fields" :key="f.key">
<div class="label">{{ f.label }}</div>
<div class="value">{{ result[f.key] !== null && result[f.key] !== undefined ? result[f.key] : '—' }}</div>
</div>
</div>
<div class="result-json" style="margin-top:12px;">{{ jsonText }}</div>
</div>
</div>
`,
setup() {
const content = ref('')
const result = ref(null)
const jsonText = ref('')
const isExtracting = ref(false)
const fields = [
{ key: 'title', label: '商品标题' },
{ key: 'description', label: '描述' },
{ key: 'price', label: '价格' },
{ key: 'rating', label: '评分' },
{ key: 'reviewCount', label: '评论数' },
{ key: 'brand', label: '品牌' },
{ key: 'category', label: '分类' }
]
function fillExample() {
content.value = 'Apple iPhone 15 Pro Max 256GB 原色钛金属,售价 ¥9999,京东好评率98%,累计2.5万+评价,品牌Apple,属于智能手机分类'
}
async function extract() {
if (!content.value.trim()) {
toast('请输入商品描述内容', 'error')
return
}
isExtracting.value = true
try {
const text = await extractProduct(content.value)
const data = JSON.parse(text)
result.value = data
jsonText.value = JSON.stringify(data, null, 2)
toast('商品信息提取成功!', 'success')
} catch (e) {
toast('提取失败:' + e.message, 'error')
} finally {
isExtracting.value = false
}
}
return { content, result, jsonText, isExtracting, fields, extract, fillExample }
}
}

181
src/main/resources/static/components/RoleManager.js

@ -0,0 +1,181 @@
/**
* 🎭 客服角色管理
* 新增/编辑/删除角色并为角色授权可检索的知识库分类
* 对话页只能选择这里配置好的角色知识库范围由服务端按角色强制限定无法跨域
*/
import { ref, reactive, onMounted } from 'vue'
import { store } from '../js/store.js'
import { getAllRoles, createRole, updateRole, deleteRole, updateRoleCategories } from '../js/api.js'
import { toast } from '../js/utils.js'
export default {
template: `
<div class="card">
<h2>客服角色管理</h2>
<p style="font-size:12px;color:var(--sub);margin:4px 0 12px;">
为角色设定人设并授权其可检索的知识库分类对话页只能选择这里配置好的角色检索范围由服务端按角色强制限定无法跨域
</p>
<!-- 新增 / 编辑 表单 -->
<div style="border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:16px;">
<div class="input-row">
<input class="input" v-model="form.name" placeholder="角色名称 *">
<input class="input" v-model="form.description" placeholder="职责描述(可选)">
<input class="input input-sm" v-model.number="form.sortOrder" type="number" placeholder="排序">
<label class="toggle-line"><input type="checkbox" v-model="form.enabled"><span>启用</span></label>
</div>
<textarea class="textarea" v-model="form.prompt" rows="2"
placeholder="角色人设 / 风格提示词,例如:先判断问题归属,再引导用户补充必要信息,并按制度或流程说明下一步。"
style="margin-top:8px;width:100%;"></textarea>
<div class="input-row" style="margin-top:8px;">
<button class="btn btn-success" @click="submit">{{ editingId ? '保存修改' : '新增角色' }}</button>
<button v-if="editingId" class="btn btn-outline btn-sm" @click="resetForm">取消编辑</button>
<button class="btn btn-outline btn-sm" @click="reload">刷新</button>
</div>
</div>
<!-- 角色列表 -->
<div v-if="!roles.length" style="font-size:13px;color:var(--sub);">暂无角色</div>
<div v-else>
<div v-for="role in roles" :key="role.id"
style="padding:12px;border:1px solid var(--border);border-radius:8px;margin-bottom:10px;"
:style="role.enabled ? '' : 'opacity:.6;'">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;">
<div>
<strong style="font-size:14px;">{{ role.name }}</strong>
<span v-if="!role.enabled" style="font-size:11px;color:#b91c1c;border:1px solid #fca5a5;border-radius:4px;padding:1px 6px;margin-left:6px;">已停用</span>
<div style="font-size:12px;color:var(--sub);margin-top:2px;">{{ role.description || '—' }}</div>
</div>
<div style="display:flex;gap:6px;flex-shrink:0;">
<button class="btn btn-outline btn-sm" @click="startEdit(role)">编辑</button>
<button class="btn btn-danger btn-sm" @click="remove(role)">删除</button>
</div>
</div>
<div v-if="role.prompt" style="font-size:12px;color:var(--text);margin-top:6px;background:var(--bg);border-radius:6px;padding:6px 8px;">
人设{{ role.prompt }}
</div>
<div style="margin-top:8px;font-size:12px;">
<span style="color:var(--sub);">可检索知识库</span>
<label v-for="c in store.categories" :key="c.id"
style="display:inline-flex;align-items:center;gap:4px;margin:2px 10px 2px 0;cursor:pointer;">
<input type="checkbox"
:checked="role.categoryIds.includes(String(c.id))"
@change="toggleCategory(role, String(c.id), $event.target.checked)">
<span>{{ c.name }}</span>
</label>
<span v-if="!store.categories.length" style="color:var(--sub);">暂无知识库分类</span>
<span v-if="store.categories.length && !role.categoryIds.length" style="color:var(--sub);">
未授权 = 严格模式下不可检索任何知识库普通模式下可检索全部
</span>
</div>
</div>
</div>
</div>
`,
setup() {
const roles = ref([])
const editingId = ref('')
const form = reactive({ name: '', description: '', prompt: '', sortOrder: 0, enabled: true })
async function reload() {
try {
const json = await getAllRoles()
if (json.success) {
roles.value = (json.data || []).map(r => ({
...r,
categoryIds: (r.categoryIds || []).map(String)
}))
}
} catch (e) {
toast('加载角色失败:' + e.message, 'error')
}
}
function resetForm() {
editingId.value = ''
form.name = ''
form.description = ''
form.prompt = ''
form.sortOrder = 0
form.enabled = true
}
function startEdit(role) {
editingId.value = role.id
form.name = role.name || ''
form.description = role.description || ''
form.prompt = role.prompt || ''
form.sortOrder = Number(role.sort_order || 0)
form.enabled = role.enabled !== false
}
async function submit() {
if (!form.name.trim()) {
toast('请输入角色名称', 'error')
return
}
const payload = {
name: form.name,
description: form.description,
prompt: form.prompt,
sortOrder: form.sortOrder,
enabled: form.enabled
}
try {
const json = editingId.value
? await updateRole(editingId.value, payload)
: await createRole(payload)
if (json.success) {
toast(editingId.value ? '角色已更新' : '角色已创建', 'success')
resetForm()
reload()
} else {
toast(json.message || '操作失败', 'error')
}
} catch (e) {
toast('操作失败:' + e.message, 'error')
}
}
async function remove(role) {
if (!confirm(`确定删除角色「${role.name}」?删除后该角色及其知识库授权将一并移除。`)) return
try {
const json = await deleteRole(role.id)
if (json.success) {
toast('角色已删除', 'success')
if (editingId.value === role.id) resetForm()
reload()
} else {
toast(json.message || '删除失败', 'error')
}
} catch (e) {
toast('删除失败:' + e.message, 'error')
}
}
async function toggleCategory(role, categoryId, checked) {
const set = new Set(role.categoryIds || [])
if (checked) set.add(categoryId)
else set.delete(categoryId)
const next = Array.from(set)
try {
const json = await updateRoleCategories(role.id, next)
if (json.success) {
role.categoryIds = next
toast('知识库授权已保存', 'success')
} else {
toast(json.message || '保存失败', 'error')
}
} catch (e) {
toast('保存失败:' + e.message, 'error')
}
}
onMounted(() => {
store.loadCategories()
reload()
})
return { roles, editingId, form, store, reload, resetForm, startEdit, submit, remove, toggleCategory }
}
}

253
src/main/resources/static/css/main.css

@ -1,11 +1,24 @@
/* ==================== CSS 变量 ==================== */
:root { --bg:#f0f2f5; --card:#fff; --primary:#6366f1; --primary2:#8b5cf6; --success:#10b981; --warn:#f59e0b; --danger:#ef4444; --text:#1f2937; --sub:#6b7280; --border:#e5e7eb; --radius:10px; }
:root {
--bg:#f5f6f8; --card:#ffffff; --surface:#fafbfc;
--primary:#111827; --primary-d:#030712; --primary2:#374151;
--success:#10b981; --warn:#f59e0b; --danger:#ef4444;
--text:#1f2937; --sub:#6b7280; --muted:#9ca3af;
--border:#e7e9ee; --border-strong:#d1d5db;
--radius:12px; --radius-sm:8px; --radius-lg:16px;
--shadow-sm:0 1px 2px rgba(16,24,40,.04), 0 1px 3px rgba(16,24,40,.06);
--shadow:0 2px 8px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.04);
--shadow-lg:0 10px 30px rgba(16,24,40,.10);
--ring:0 0 0 3px rgba(17,24,39,.12);
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(--bg); color:var(--text); min-height:100vh; }
html, body { height:100%; }
body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif; background:var(--bg); color:var(--text); overflow:hidden; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; line-height:1.55; }
#app { height:100vh; display:flex; flex-direction:column; min-height:0; }
/* ==================== 顶部导航 ==================== */
.topbar { background:linear-gradient(135deg, var(--primary) 0%, var(--primary2) 100%); color:#fff; padding:0 24px; height:56px; display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:100; }
.topbar { background:#1f2937; color:#fff; padding:0 24px; height:56px; display:flex; align-items:center; gap:12px; flex:none; z-index:100; }
.topbar .logo { font-size:20px; font-weight:700; }
.topbar .ver { font-size:12px; opacity:.7; margin-left:4px; }
.topbar .links { margin-left:auto; display:flex; gap:12px; }
@ -13,36 +26,41 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.topbar .links a:hover { opacity:1; }
/* ==================== Tab 导航 ==================== */
.tabs { display:flex; gap:0; background:var(--card); border-bottom:1px solid var(--border); padding:0 24px; position:sticky; top:56px; z-index:99; }
.tabs { display:flex; gap:0; background:var(--card); border-bottom:1px solid var(--border); padding:0 24px; flex:none; z-index:99; }
.tab-btn { padding:14px 24px; border:none; background:none; font-size:14px; cursor:pointer; border-bottom:3px solid transparent; color:var(--sub); transition:all .2s; font-family:inherit; display:flex; align-items:center; gap:6px; }
.tab-btn:hover { color:var(--text); }
.tab-btn.active { color:var(--primary); border-bottom-color:var(--primary); font-weight:600; }
.tab-icon { font-size:18px; }
/* ==================== 内容区 ==================== */
.main { max-width:1400px; margin:0 auto; padding:20px; }
.main { max-width:1400px; width:100%; margin:0 auto; padding:20px; flex:1; min-height:0; overflow-y:auto; }
/* 聊天 Tab:圣杯布局,内容撑满视口、外层不滚动 */
.main.main-chat { overflow:hidden; padding:16px; display:flex; }
.chat-tab-pane { flex:1; min-width:0; min-height:0; display:flex; }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px);} to{opacity:1;transform:translateY(0);} }
/* ==================== 卡片 ==================== */
.card { background:var(--card); border-radius:var(--radius); padding:20px; margin-bottom:16px; border:1px solid var(--border); }
.card h2 { font-size:16px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
.card h3 { font-size:14px; margin-bottom:8px; color:var(--sub); }
.card { background:var(--card); border-radius:var(--radius); padding:22px; margin-bottom:16px; border:1px solid var(--border); }
.card h2 { font-size:16px; font-weight:600; margin-bottom:16px; display:flex; align-items:center; gap:8px; }
.card h3 { font-size:13px; font-weight:600; margin-bottom:8px; color:var(--sub); }
/* ==================== 表单 ==================== */
.input-row { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
.input, .textarea, .select { padding:10px 14px; border:1px solid var(--border); border-radius:8px; font-size:14px; font-family:inherit; outline:none; transition:border-color .2s; }
.input:focus, .textarea:focus, .select:focus { border-color:var(--primary); }
.input:focus, .textarea:focus, .select:focus { border-color:var(--primary); box-shadow:var(--ring); }
.input { flex:1; min-width:200px; }
.textarea { width:100%; resize:vertical; min-height:120px; }
.select { min-width:160px; }
.input-sm { width:120px; flex:none; }
.input-field-sm { display:flex; flex-direction:column; gap:4px; flex:none; }
.input-field-sm span { font-size:12px; color:var(--sub); font-weight:600; }
.btn { padding:10px 20px; border:none; border-radius:8px; font-size:14px; cursor:pointer; font-family:inherit; font-weight:500; transition:all .2s; display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
.btn:disabled { opacity:.5; cursor:not-allowed; }
.btn-primary { background:var(--primary); color:#fff; }
.btn-primary:hover:not(:disabled) { background:#4f46e5; }
.btn-primary:hover:not(:disabled) { background:#000; }
.btn-purple { background:var(--primary2); color:#fff; }
.btn-purple:hover:not(:disabled) { background:#7c3aed; }
.btn-purple:hover:not(:disabled) { background:#1f2937; }
.btn-success { background:var(--success); color:#fff; }
.btn-outline { background:#fff; color:var(--primary); border:1px solid var(--primary); }
.btn-sm { padding:6px 12px; font-size:12px; }
@ -50,28 +68,24 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.btn-warn { background:var(--warn); color:#fff; }
/* ==================== 消息区 ==================== */
.msg-area { border:1px solid var(--border); border-radius:var(--radius); height:400px; overflow-y:auto; padding:16px; background:#fafbfc; margin-bottom:12px; display:flex; flex-direction:column; gap:10px; }
.msg { display:flex; gap:10px; max-width:85%; animation: fadeIn .3s; }
.msg-area { border:1px solid var(--border); border-radius:var(--radius); height:400px; overflow-y:auto; padding:16px; background:#fff; margin-bottom:12px; display:flex; flex-direction:column; gap:10px; }
.msg { display:flex; gap:12px; max-width:88%; animation: fadeIn .25s; }
.msg.user { align-self:flex-end; flex-direction:row-reverse; }
.msg.assistant { align-self:flex-start; }
.msg-avatar { width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:16px; flex-shrink:0; }
.msg.user .msg-avatar { background:var(--primary); color:#fff; }
.msg.assistant .msg-avatar { background:var(--success); color:#fff; }
.msg-bubble { padding:10px 14px; border-radius:12px; line-height:1.6; font-size:14px; word-break:break-word; white-space:pre-wrap; }
.msg.user .msg-bubble { background:var(--primary); color:#fff; border-bottom-right-radius:4px; }
.msg.assistant .msg-bubble { background:#fff; border:1px solid var(--border); border-bottom-left-radius:4px; }
.msg.streaming .msg-bubble { border-color:var(--primary); box-shadow:0 0 0 1px var(--primary); }
.msg-bubble { line-height:1.65; font-size:15px; word-break:break-word; white-space:pre-wrap; }
.msg.user .msg-bubble { background:#f4f4f5; color:var(--text); padding:10px 14px; border-radius:14px; }
.msg.assistant .msg-bubble { background:transparent; padding:1px 0; }
/* ==================== 商品信息展示 ==================== */
.result-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:12px; margin-top:12px; }
.result-item { background:#f9fafb; padding:12px 16px; border-radius:8px; border:1px solid var(--border); }
.result-item { background:#fafafa; padding:12px 16px; border-radius:10px; border:1px solid var(--border); }
.result-item .label { font-size:12px; color:var(--sub); margin-bottom:4px; }
.result-item .value { font-size:14px; font-weight:500; }
.result-json { background:#1e293b; color:#e2e8f0; padding:16px; border-radius:8px; font-family:'Fira Code',monospace; font-size:13px; white-space:pre-wrap; overflow-x:auto; }
/* ==================== 文件上传区 ==================== */
.upload-zone { border:2px dashed var(--border); border-radius:var(--radius); padding:32px; text-align:center; transition:all .2s; cursor:pointer; margin-bottom:12px; }
.upload-zone:hover, .upload-zone.drag-over { border-color:var(--primary); background:#eef2ff; }
.upload-zone:hover, .upload-zone.drag-over { border-color:var(--primary); background:#f3f4f6; }
.upload-zone p { color:var(--sub); }
.upload-zone .icon { font-size:40px; margin-bottom:8px; }
@ -89,9 +103,11 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
/* ==================== 表格样式 ==================== */
.data-table { width:100%; border-collapse:collapse; font-size:13px; }
.data-table th { background:#f9fafb; padding:10px 12px; text-align:left; font-weight:600; border-bottom:2px solid var(--border); white-space:nowrap; }
.data-table td { padding:10px 12px; border-bottom:1px solid var(--border); vertical-align:middle; }
.data-table tr:hover { background:#f9fafb; }
.data-table th { background:#fafafa; padding:11px 12px; text-align:left; font-weight:600; font-size:12px; color:var(--sub); border-bottom:1px solid var(--border); white-space:nowrap; }
.data-table td { padding:11px 12px; border-bottom:1px solid var(--border); vertical-align:middle; }
.data-table tr:hover { background:#fafafa; }
.data-table td .btn { margin:2px 0; }
.data-table td .btn + .btn { margin-left:6px; }
.data-table .status-ready { color:var(--success); font-weight:600; }
.data-table .status-processing { color:var(--warn); font-weight:600; }
.data-table .status-failed { color:var(--danger); font-weight:600; }
@ -105,9 +121,9 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
/* ==================== 统计卡片 ==================== */
.stat-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:12px; margin-bottom:16px; }
.stat-card { background:#f9fafb; border-radius:8px; padding:16px; text-align:center; border:1px solid var(--border); }
.stat-card .number { font-size:28px; font-weight:700; color:var(--primary); }
.stat-card .label { font-size:12px; color:var(--sub); margin-top:4px; }
.stat-card { background:#fafafa; border-radius:10px; padding:18px 16px; text-align:center; border:1px solid var(--border); }
.stat-card .number { font-size:26px; font-weight:700; color:var(--text); letter-spacing:-.01em; }
.stat-card .label { font-size:12px; color:var(--muted); margin-top:5px; }
/* ==================== 弹窗 ==================== */
.modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,.5); z-index:200; display:none; align-items:center; justify-content:center; }
@ -118,11 +134,11 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
.modal-close:hover { color:var(--text); }
/* ==================== 分类标签 ==================== */
.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#eef2ff; color:var(--primary); }
.category-tag { display:inline-block; padding:2px 8px; border-radius:12px; font-size:11px; background:#f3f4f6; color:var(--text); }
/* ==================== 搜索结果 ==================== */
.search-result { background:#f9fafb; border-radius:8px; padding:12px; margin-bottom:8px; border:1px solid var(--border); }
.search-result .score { font-size:12px; color:var(--success); font-weight:600; }
.search-result { background:#fafafa; border-radius:10px; padding:12px 14px; margin-bottom:8px; border:1px solid var(--border); }
.search-result .score { font-size:12px; color:var(--sub); font-weight:600; }
.search-result .meta { font-size:12px; color:var(--sub); margin-top:4px; }
.search-result .content { font-size:13px; margin-top:6px; line-height:1.5; }
@ -134,72 +150,137 @@ body { font-family: -apple-system,"Microsoft YaHei",sans-serif; background:var(-
/* ==================== 消息列表样式(会话管理) ==================== */
.msg-item { border:1px solid var(--border); }
.msg-item.msg-user { background:#f0f9ff; border-left:3px solid var(--primary); }
.msg-item.msg-assistant { background:#f0fdf4; border-left:3px solid var(--success); }
.msg-item.msg-system { background:#f9fafb; border-left:3px solid var(--sub); }
.msg-item.msg-user { background:#fafafa; border-left:3px solid #111827; }
.msg-item.msg-assistant { background:#fff; border-left:3px solid var(--border-strong); }
.msg-item.msg-system { background:#fafafa; border-left:3px solid var(--muted); }
/* ==================== 响应式 ==================== */
@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-shell { display:grid; grid-template-columns:260px minmax(0, 1fr); gap:16px; flex:1; min-width:0; min-height:0; }
.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; }
.chat-sidebar { padding:16px; display:flex; flex-direction:column; gap:20px; overflow:hidden; min-height:0; }
.agent-card { display:flex; align-items:center; gap:12px; }
.agent-avatar { width:40px; height:40px; border-radius:10px; display:flex; align-items:center; justify-content:center; background:#4f46e5; color:#fff; font-weight:700; font-size:14px; }
.agent-name { font-weight:600; font-size:14px; }
.agent-status { margin-top:3px; color:var(--muted); font-size:12px; display:flex; align-items:center; gap:6px; }
.agent-status span { width:6px; height:6px; 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; }
.side-label { font-size:12px; color:var(--muted); font-weight:500; }
.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-main { display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; }
.chat-header { padding:18px 24px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; gap:16px; }
.chat-title h2 { font-size:16px; font-weight:600; }
.chat-subline { margin-top:5px; color:var(--muted); font-size:12px; display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
.chat-subline .dot-sep { color:var(--border-strong); }
.rag-dot { width:7px; height:7px; border-radius:999px; background:var(--muted); display:inline-block; }
.rag-dot.on { background:var(--success); }
.chat-mode { min-width:130px; font-size:13px; }
.quick-row { padding:18px 24px 0; display:flex; gap:8px; flex-wrap:wrap; }
.quick-row button { border:none; background:#f7f7f8; border-radius:999px; padding:7px 14px; font-size:13px; color:var(--sub); cursor:pointer; transition:all .15s; }
.quick-row button:hover { background:#ececee; color:var(--text); }
.chat-msg-area { flex:1; height:auto; min-height:0; margin:0; border:none; border-radius:0; padding:24px; background:#fff; gap:18px; }
.chat-msg-area .msg { max-width:100%; }
.chat-msg-area .msg.user { max-width:80%; }
.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; }
}
.edit-textarea { width:100%; min-width:min(260px, 100%); }
.edit-actions { display:flex; align-items:center; gap:8px; margin-top:8px; flex-wrap:wrap; }
.edit-actions span { font-size:12px; color:var(--muted); }
.msg-tools { margin-top:7px; display:flex; align-items:center; gap:4px; font-size:12px; color:var(--muted); flex-wrap:wrap; opacity:0; transition:opacity .15s; }
.msg:hover .msg-tools, .msg.streaming .msg-tools { opacity:1; }
.msg-tools span { margin-right:4px; }
.msg-tools button { border:none; background:transparent; color:var(--muted); cursor:pointer; font-size:12px; padding:2px 7px; border-radius:6px; line-height:1.4; transition:all .15s; }
.msg-tools button:hover { background:#f4f4f5; color:var(--text); }
.msg-tools button:focus-visible { outline:none; box-shadow:var(--ring); }
.chat-composer { border-top:1px solid var(--border); padding:16px 24px 20px; background:#fff; }
.composer-box { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; border:1px solid var(--border); border-radius:14px; padding:8px 8px 8px 14px; transition:border-color .15s; }
.composer-box:focus-within { border-color:var(--text); }
.chat-textarea { border:none; outline:none; background:transparent; resize:none; min-height:40px; max-height:160px; padding:8px 0; font-size:15px; font-family:inherit; line-height:1.5; }
.send-btn { height:40px; min-width:72px; border:none; border-radius:10px; background:var(--text); color:#fff; font-size:14px; font-weight:500; cursor:pointer; transition:opacity .15s; }
.send-btn:hover:not(:disabled) { opacity:.85; }
.send-btn:disabled { opacity:.35; cursor:not-allowed; }
@media(max-width:860px) {
.main.main-chat { overflow-y:auto; display:block; }
.chat-tab-pane { display:block; }
.chat-shell { grid-template-columns:1fr; height:auto; min-height:0; }
.chat-sidebar { order:1; overflow:visible; }
.chat-main { order:2; min-height:560px; }
.chat-msg-area { min-height:360px; }
}
@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%; }
.chat-header { flex-direction:column; align-items:flex-start; }
.chat-msg-area .msg, .chat-msg-area .msg.user { max-width:100%; }
.quick-row { padding:14px 16px 0; }
.chat-msg-area { padding:16px; }
.chat-composer { padding:12px 16px 16px; }
}
/* ==================== 滚动条 ==================== */
* { scrollbar-width:thin; scrollbar-color:#cbd0d8 transparent; }
::-webkit-scrollbar { width:10px; height:10px; }
::-webkit-scrollbar-thumb { background:#cbd0d8; border-radius:999px; border:2px solid transparent; background-clip:content-box; }
::-webkit-scrollbar-thumb:hover { background:#aab0bb; background-clip:content-box; }
::-webkit-scrollbar-track { background:transparent; }
/* ==================== Markdown 渲染(AI 回复) ==================== */
.msg-bubble.markdown-body { white-space:normal; }
.markdown-body { font-size:14px; line-height:1.7; word-break:break-word; }
.markdown-body > *:first-child { margin-top:0; }
.markdown-body > *:last-child { margin-bottom:0; }
.markdown-body p { margin:8px 0; }
.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4 { margin:14px 0 8px; line-height:1.3; font-weight:700; }
.markdown-body h1 { font-size:20px; } .markdown-body h2 { font-size:18px; } .markdown-body h3 { font-size:16px; } .markdown-body h4 { font-size:14px; }
.markdown-body ul,.markdown-body ol { margin:8px 0; padding-left:22px; }
.markdown-body li { margin:3px 0; }
.markdown-body li>p { margin:2px 0; }
.markdown-body a { color:var(--primary); text-decoration:none; }
.markdown-body a:hover { text-decoration:underline; }
.markdown-body code { font-family:'Fira Code',ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.88em; background:#f1f1f2; color:#374151; padding:.12em .4em; border-radius:5px; }
.markdown-body pre { background:#1e293b; color:#e2e8f0; padding:14px 16px; border-radius:10px; overflow-x:auto; margin:10px 0; }
.markdown-body pre code { background:none; color:inherit; padding:0; font-size:13px; }
.markdown-body blockquote { margin:10px 0; padding:6px 14px; border-left:2px solid var(--border-strong); background:#fafafa; color:var(--sub); border-radius:0 8px 8px 0; }
.markdown-body table { border-collapse:collapse; margin:10px 0; font-size:13px; width:100%; }
.markdown-body th,.markdown-body td { border:1px solid var(--border); padding:6px 10px; text-align:left; }
.markdown-body th { background:#f3f4f6; font-weight:600; }
.markdown-body hr { border:none; border-top:1px solid var(--border); margin:14px 0; }
.markdown-body img { max-width:100%; border-radius:8px; }
/* 思考中光标 */
.msg .thinking { color:var(--sub); }
.msg .thinking::after { content:'▋'; margin-left:2px; animation:blink 1s steps(2) infinite; }
@keyframes blink { 0%,50%{opacity:1;} 51%,100%{opacity:0;} }
/* ==================== 对话页 · 助手卡片(左) ==================== */
.side-section-grow { flex:1; min-height:0; }
.assistant-list { display:flex; flex-direction:column; gap:4px; overflow-y:auto; padding-right:2px; margin:0 -6px; }
.assistant-card { width:100%; border:none; background:transparent; border-radius:9px; padding:9px 10px; display:flex; gap:10px; align-items:center; text-align:left; cursor:pointer; transition:background .12s; font-family:inherit; }
.assistant-card:hover { background:#f4f4f5; }
.assistant-card.active { background:#f4f4f5; }
.assistant-avatar-sm { flex:none; width:30px; height:30px; border-radius:8px; display:flex; align-items:center; justify-content:center; font-size:12px; font-weight:600; color:#fff; background:#0d9488; }
.assistant-list .assistant-card:nth-child(6n+1) .assistant-avatar-sm { background:#0d9488; }
.assistant-list .assistant-card:nth-child(6n+2) .assistant-avatar-sm { background:#ea580c; }
.assistant-list .assistant-card:nth-child(6n+3) .assistant-avatar-sm { background:#2563eb; }
.assistant-list .assistant-card:nth-child(6n+4) .assistant-avatar-sm { background:#16a34a; }
.assistant-list .assistant-card:nth-child(6n+5) .assistant-avatar-sm { background:#db2777; }
.assistant-list .assistant-card:nth-child(6n+6) .assistant-avatar-sm { background:#0891b2; }
.assistant-meta { min-width:0; display:flex; flex-direction:column; gap:1px; }
.assistant-meta strong { font-size:13px; font-weight:600; color:var(--text); }
.assistant-meta small { font-size:12px; color:var(--muted); line-height:1.4; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* ==================== 引用来源(回答下方) ==================== */
.msg-sources { margin-top:14px; padding-top:12px; border-top:1px solid var(--border); }
.msg-sources-head { font-size:12px; font-weight:500; color:var(--muted); margin-bottom:10px; display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.source-doc { border:none; border-radius:0; background:transparent; padding:0; margin-bottom:12px; }
.source-doc:last-child { margin-bottom:0; }
.source-doc.clickable { cursor:pointer; }
.source-doc.clickable:hover .source-doc-title { color:var(--primary); }
.source-doc-head { display:flex; align-items:center; gap:8px; margin-bottom:5px; flex-wrap:wrap; }
.source-doc-title { font-size:13px; font-weight:600; color:var(--text); word-break:break-word; min-width:0; transition:color .15s; }
.source-open { font-size:11px; color:var(--muted); flex:none; }
.source-chunk { font-size:12px; line-height:1.6; color:var(--sub); border-left:2px solid var(--border); padding-left:10px; margin-top:4px; }
.source-rel { display:inline-block; font-size:11px; color:var(--muted); margin-right:6px; font-weight:600; }
.source-snippet { color:var(--sub); }

4
src/main/resources/static/index.html

@ -8,7 +8,9 @@
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js"
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js",
"marked": "https://cdn.jsdelivr.net/npm/marked@12/+esm",
"dompurify": "https://cdn.jsdelivr.net/npm/dompurify@3/+esm"
}
}
</script>

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

@ -89,52 +89,123 @@ async function postForm(path, formData) {
/**
* 同步对话
* @param {string} message 用户消息原样发送不做包装
* @param {string} chatId 会话ID
* @param {string} [roleId] 客服角色ID命中后由服务端套用该角色人设
*/
export function chatSync(message, chatId) {
return fetch(API_BASE + `/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`)
.then(res => res.text())
export function chatSync(message, chatId, roleId, accountId) {
let url = API_BASE + `/ai/assistant_app/chat/sync?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`
if (roleId) url += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) url += `&accountId=${encodeURIComponent(accountId)}`
return fetch(url).then(res => res.text())
}
/**
* RAG 同步对话
* 知识库范围由服务端依据 roleId 强制限定前端无需也无法跨域指定分类
* @param {string} message 用户消息
* @param {string} chatId 会话ID
* @param {string} strategy 查询重写策略
* @param {string} [roleId] 客服角色ID
*/
export function chatRagSync(message, chatId, strategy, categoryIds) {
export function chatRagSync(message, chatId, strategy, roleId, accountId) {
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())
if (roleId) url += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) url += `&accountId=${encodeURIComponent(accountId)}`
return fetch(url).then(res => res.text())
}
/**
* 获取 SSE 流式对话 URL
* 获取普通 SSE 流式对话 URL
* @param {string} message
* @param {string} chatId
* @param {'sse'|'sse2'|'sse3'} mode
* @param {string} [roleId]
* @returns {string}
*/
export function chatSSEUrl(message, chatId, mode) {
export function chatSSEUrl(message, chatId, mode, roleId, accountId) {
const pathMap = {
sse: '/ai/assistant_app/chat/sse',
sse2: '/ai/assistant_app/chat/server_sent_event',
sse3: '/ai/assistant_app/chat/sse_emitter'
}
return API_BASE + `${pathMap[mode]}?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`
let url = API_BASE + `${pathMap[mode]}?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}`
if (roleId) url += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) url += `&accountId=${encodeURIComponent(accountId)}`
return url
}
/**
* 获取 RAG 流式对话 URL知识库范围由服务端依据 roleId 强制限定
* @param {string} message
* @param {string} chatId
* @param {string} strategy
* @param {string} [roleId]
* @returns {string}
*/
export function chatRagSSEUrl(message, chatId, strategy, roleId, accountId) {
let url = API_BASE + `/ai/assistant_app/chat/rag/sse?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`
if (roleId) url += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) url += `&accountId=${encodeURIComponent(accountId)}`
return url
}
/**
* 获取本次 RAG 回答命中的知识库片段引用来源
*/
export function ragSources(message, chatId, strategy, roleId, accountId) {
let path = `/ai/assistant_app/rag/sources?message=${encodeURIComponent(message)}&chatId=${encodeURIComponent(chatId)}&rewriteStrategy=${encodeURIComponent(strategy)}`
if (roleId) path += `&roleId=${encodeURIComponent(roleId)}`
if (accountId) path += `&accountId=${encodeURIComponent(accountId)}`
return getJSON(path)
}
export function getRoleList() {
return getJSON('/role/list')
}
/** 管理用:列出全部角色(含已停用) */
export function getAllRoles() {
return getJSON('/role/all')
}
/** 新增角色 */
export function createRole(role) {
return postJSON('/role', role)
}
/** 编辑角色基本信息 */
export function updateRole(roleId, role) {
return putJSONWithBody(`/role/${roleId}`, role)
}
/** 删除角色(逻辑删除) */
export function deleteRole(roleId) {
return deleteJSON(`/role/${roleId}`)
}
export function updateRoleCategories(roleId, categoryIds) {
return putJSONWithBody(`/role/${roleId}/categories`, { categoryIds })
}
// ==================== 商品信息提取 ====================
export function getAccountList() {
return getJSON('/account/list')
}
export function getAllAccounts() {
return getJSON('/account/all')
}
/**
* 提取商品信息
*/
export function extractProduct(content) {
return fetch(API_BASE + `/ai/product_info_app/chat/sync?message=${encodeURIComponent(content)}`)
.then(res => res.text())
export function createAccount(account) {
return postJSON('/account', account)
}
export function updateAccount(accountId, account) {
return putJSONWithBody(`/account/${accountId}`, account)
}
export function deleteAccount(accountId) {
return deleteJSON(`/account/${accountId}`)
}
// ==================== 文档管理 ====================
@ -354,9 +425,11 @@ export function deleteCategory(id) {
/**
* 会话列表分页
*/
export function listConversations(page = 1, size = 10, keyword) {
export function listConversations(page = 1, size = 10, keyword, accountId, roleId) {
let path = `/conversation/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (accountId) path += `&accountId=${encodeURIComponent(accountId)}`
if (roleId) path += `&roleId=${encodeURIComponent(roleId)}`
return getJSON(path)
}
@ -396,55 +469,9 @@ export function getConversationStats() {
return getJSON('/conversation/stats')
}
// ==================== 模型配置管理 ====================
/**
* 模型配置列表分页
*/
export function listModelConfigs(page = 1, size = 10, appType) {
let path = `/model-config/list?page=${page}&size=${size}`
if (appType) path += `&appType=${encodeURIComponent(appType)}`
return getJSON(path)
}
/**
* 模型配置详情
*/
export function getModelConfigDetail(id) {
return getJSON(`/model-config/${id}`)
}
/**
* 获取指定类型的活跃模型配置
*/
export function getActiveModelConfig(appType) {
return getJSON(`/model-config/active/${encodeURIComponent(appType)}`)
}
/**
* 新建模型配置
*/
export function createModelConfig(data) {
return postJSON('/model-config', data)
}
/**
* 更新模型配置
*/
export function updateModelConfig(id, data) {
return putJSONWithBody(`/model-config/${id}`, data)
}
/**
* 激活模型配置
*/
export function activateModelConfig(id) {
return putJSON(`/model-config/${id}/activate`)
}
/**
* 删除模型配置
* 截断会话删除第 userTurn 条用户消息及其之后的全部消息编辑重发用
*/
export function deleteModelConfig(id) {
return deleteJSON(`/model-config/${id}`)
export function truncateConversation(conversationId, userTurn) {
return postJSON(`/conversation/${conversationId}/truncate`, { userTurn })
}

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

@ -7,15 +7,15 @@ import { store } from './store.js'
// 导入组件
import ChatPanel from '../components/ChatPanel.js'
import ProductPanel from '../components/ProductPanel.js'
import DocStats from '../components/DocStats.js'
import DocSearch from '../components/DocSearch.js'
import CategoryManager from '../components/CategoryManager.js'
import AccountManager from '../components/AccountManager.js'
import RoleManager from '../components/RoleManager.js'
import DocList from '../components/DocList.js'
import DocUpload from '../components/DocUpload.js'
import DocDetail from '../components/DocDetail.js'
import ConversationManager from '../components/ConversationManager.js'
import ModelConfigManager from '../components/ModelConfigManager.js'
const app = createApp({
setup() {
@ -27,6 +27,8 @@ const app = createApp({
if (tab === 'document') {
store.loadCategories()
store.loadStats()
} else if (tab === 'settings') {
store.loadCategories()
}
}
@ -46,33 +48,25 @@ const app = createApp({
<button :class="['tab-btn', activeTab === 'chat' ? 'active' : '']" @click="switchTab('chat')">
<span class="tab-icon">💬</span>
</button>
<button :class="['tab-btn', activeTab === 'product' ? 'active' : '']" @click="switchTab('product')">
<span class="tab-icon">🏷</span>
</button>
<button :class="['tab-btn', activeTab === 'document' ? 'active' : '']" @click="switchTab('document')">
<span class="tab-icon">📄</span>
</button>
<button :class="['tab-btn', activeTab === 'conversation' ? 'active' : '']" @click="switchTab('conversation')">
<span class="tab-icon">💬</span>
</button>
<button :class="['tab-btn', activeTab === 'modelConfig' ? 'active' : '']" @click="switchTab('modelConfig')">
<span class="tab-icon"></span>
<button :class="['tab-btn', activeTab === 'settings' ? 'active' : '']" @click="switchTab('settings')">
<span class="tab-icon"></span>
</button>
</div>
<!-- 内容区 -->
<div class="main">
<div class="main" :class="{ 'main-chat': activeTab === 'chat' }">
<!-- Tab 1: 智能客服对话 -->
<div v-if="activeTab === 'chat'" style="animation: fadeIn .3s ease;">
<div v-if="activeTab === 'chat'" class="chat-tab-pane" style="animation: fadeIn .3s ease;">
<chat-panel></chat-panel>
</div>
<!-- Tab 2: 商品信息提取 -->
<div v-if="activeTab === 'product'" style="animation: fadeIn .3s ease;">
<product-panel></product-panel>
</div>
<!-- Tab 3: 知识库文档管理 -->
<!-- Tab 2: 知识库文档管理 -->
<div v-if="activeTab === 'document'" style="animation: fadeIn .3s ease;">
<doc-stats></doc-stats>
<doc-search></doc-search>
@ -81,14 +75,15 @@ const app = createApp({
<doc-upload></doc-upload>
</div>
<!-- Tab 4: 会话管理 -->
<!-- Tab 3: 会话管理 -->
<div v-if="activeTab === 'conversation'" style="animation: fadeIn .3s ease;">
<conversation-manager></conversation-manager>
</div>
<!-- Tab 5: 模型配置管理 -->
<div v-if="activeTab === 'modelConfig'" style="animation: fadeIn .3s ease;">
<model-config-manager></model-config-manager>
<!-- Tab 4: 系统设置 -->
<div v-if="activeTab === 'settings'" style="animation: fadeIn .3s ease;">
<account-manager></account-manager>
<role-manager></role-manager>
</div>
<!-- 文档详情弹窗 -->
@ -102,14 +97,14 @@ const app = createApp({
// 注册组件
app.component('chat-panel', ChatPanel)
app.component('product-panel', ProductPanel)
app.component('doc-stats', DocStats)
app.component('doc-search', DocSearch)
app.component('category-manager', CategoryManager)
app.component('account-manager', AccountManager)
app.component('role-manager', RoleManager)
app.component('doc-list', DocList)
app.component('doc-upload', DocUpload)
app.component('doc-detail', DocDetail)
app.component('conversation-manager', ConversationManager)
app.component('model-config-manager', ModelConfigManager)
app.mount('#app')

21
src/main/resources/static/js/utils.js

@ -1,7 +1,12 @@
/**
* 工具函数模块
* 提供 Toast 提示格式化SSE 流式读取等通用能力
* 提供 Toast 提示格式化SSE 流式读取Markdown 渲染等通用能力
*/
import { marked } from 'marked'
import DOMPurify from 'dompurify'
// 单行换行也生效、启用 GitHub 风格 Markdown(表格/任务列表等)
marked.setOptions({ breaks: true, gfm: true })
// API 基址:空字符串 = 同源部署,由 Spring Boot 直接服务前端
export const API_BASE = ''
@ -97,3 +102,17 @@ export async function readSSEStream(url, onChunk, onDone) {
}
if (onDone) onDone()
}
// ==================== Markdown 渲染 ====================
/**
* Markdown 文本渲染为安全的 HTMLmarked 解析 + DOMPurify 消毒
* 仅用于 AI 回复展示用户输入不要走这里避免 XSS
* @param {string} text Markdown 原文
* @returns {string} 消毒后的 HTML 字符串
*/
export function renderMarkdown(text) {
if (!text) return ''
const html = marked.parse(String(text))
return DOMPurify.sanitize(html)
}
Loading…
Cancel
Save