diff --git a/src/main/java/com/wok/supportbot/app/AssistantApp.java b/src/main/java/com/wok/supportbot/app/AssistantApp.java index b32431c..4bdf89c 100644 --- a/src/main/java/com/wok/supportbot/app/AssistantApp.java +++ b/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 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 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 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 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 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 categoryIds) { - List 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 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 doChatWithRagStrategyByStream(String message, String chatId, String strategy, + List 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 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 doChatWithMultiQueryRagByStream(String message, String chatId, List 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 categoryIds) { - var retrieverBuilder = VectorStoreDocumentRetriever.builder() - .vectorStore(pgVectorVectorStore) - .similarityThreshold(0.0) - .topK(topK); + private String buildMultiQueryRagSystem(String message, List categoryIds, String systemPrompt) { + List 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 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 retrieveMultiQueryDocs(String message, List categoryIds) { + // 1. 扩展为多个语义不同的查询(已配置 includeOriginal,含原始查询) + List 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 merged = new LinkedHashMap<>(); + for (String query : expandedQueries) { + if (!StringUtils.hasText(query) || merged.size() >= maxDocs) { + continue; + } + List 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 命中的文档片段(含 metadata:documentId/title/sourceName/chunkIndex/distance) + */ + public List retrieveRagSources(String message, String chatId, String strategy, List categoryIds) { + if (!StringUtils.hasText(message)) { + return Collections.emptyList(); + } + if ("MULTI_QUERY".equalsIgnoreCase(strategy)) { + return retrieveMultiQueryDocs(message, categoryIds); + } + String rewritten = rewriteQuery(message, chatId, strategy); + List 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 categoryIds) { + return ragSearchRequestBuilder(topK, categoryIds).build(); + } + + /** + * 带查询文本的检索请求,供手动向量检索(多路查询)使用。 + * QuestionAnswerAdvisor 会自行设置查询文本,故那条路径不需要此重载。 + */ + private SearchRequest buildRagSearchRequest(int topK, List 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 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 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 categoryIds) { List 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 normalizeCategoryIds(List 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(); diff --git a/src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java b/src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java index c1047de..9703460 100644 --- a/src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java +++ b/src/main/java/com/wok/supportbot/chatmemory/DatabaseChatMemory.java @@ -35,9 +35,14 @@ public class DatabaseChatMemory implements ChatMemory { @Override public List get(String conversationId) { + return get(conversationId, 10); + } + + public List get(String conversationId, int lastN) { LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(ChatMessage::getConversationId, conversationId) - .orderByDesc(ChatMessage::getCreateTime); + .orderByDesc(ChatMessage::getCreateTime) + .last(lastN > 0, "LIMIT " + lastN); List chatMessages = chatMessageRepository.list(queryWrapper); diff --git a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java b/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java index 0fa075b..214dabd 100644 --- a/src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java +++ b/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); - } } diff --git a/src/main/java/com/wok/supportbot/config/RoleAccessConfig.java b/src/main/java/com/wok/supportbot/config/RoleAccessConfig.java new file mode 100644 index 0000000..b7faf30 --- /dev/null +++ b/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; +} diff --git a/src/main/java/com/wok/supportbot/controller/AiController.java b/src/main/java/com/wok/supportbot/controller/AiController.java index e574808..883028b 100644 --- a/src/main/java/com/wok/supportbot/controller/AiController.java +++ b/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 doChatWithLoveAppSSE(String message, String chatId) { - return assistantApp.doChatByStream(message, chatId); + public Flux 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> doChatWithAssistantAppServerSentEvent(String message, String chatId) { - return assistantApp.doChatByStream(message, chatId) + public Flux> 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.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 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 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 cats = resolveCategoryIds(scope, categoryId, categoryIds); + List docs = assistantApp.retrieveRagSources(message, chatId, normalizeStrategy(rewriteStrategy), cats); + List> out = new ArrayList<>(); + for (Document doc : docs) { + Map meta = doc.getMetadata(); + Map 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 resolveCategoryIds(RoleScope scope, Long categoryId, String categoryIds) { + if (scope.hasRole()) { + return scope.categoryIds(); + } List 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 parseCategoryIds(String categoryIds) { @@ -142,4 +312,7 @@ public class AiController { .distinct() .toList(); } + + private record AccountRoleContext(Long accountId, Long roleId) { + } } diff --git a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java b/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java index 4037c04..7b5b457 100644 --- a/src/main/java/com/wok/supportbot/controller/AiModelConfigController.java +++ b/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(); } } diff --git a/src/main/java/com/wok/supportbot/controller/ConversationController.java b/src/main/java/com/wok/supportbot/controller/ConversationController.java index d80ce44..de1c08a 100644 --- a/src/main/java/com/wok/supportbot/controller/ConversationController.java +++ b/src/main/java/com/wok/supportbot/controller/ConversationController.java @@ -33,9 +33,11 @@ public class ConversationController { public ResponseEntity> 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 result = conversationService.listConversations(page, size, keyword); + Map result = conversationService.listConversations(page, size, keyword, accountId, roleId); Map 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> truncateConversation( + @PathVariable("id") String conversationId, + @RequestBody Map 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() + )); + } + } + /** * 获取会话详情 * diff --git a/src/main/java/com/wok/supportbot/controller/CustomerAccountController.java b/src/main/java/com/wok/supportbot/controller/CustomerAccountController.java new file mode 100644 index 0000000..9e7620d --- /dev/null +++ b/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> listAccounts() { + return ResponseEntity.ok(Map.of( + "success", true, + "data", customerAccountService.listAccounts() + )); + } + + @GetMapping("/account/all") + public ResponseEntity> listAllAccounts() { + return ResponseEntity.ok(Map.of( + "success", true, + "data", customerAccountService.listAccounts(true) + )); + } + + @PostMapping("/account") + public ResponseEntity> createAccount(@RequestBody Map 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> updateAccount(@PathVariable("id") Long accountId, @RequestBody Map 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> 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; + } +} diff --git a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java b/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java index a73e855..6845f01 100644 --- a/src/main/java/com/wok/supportbot/controller/CustomerServiceRoleController.java +++ b/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> listAllRoles() { + return ResponseEntity.ok(Map.of( + "success", true, + "data", customerServiceRoleService.listRoles(true) + )); + } + + @PostMapping("/role") + public ResponseEntity> createRole(@RequestBody Map 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> updateRole(@PathVariable("id") Long roleId, @RequestBody Map 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> 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> 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; + } } diff --git a/src/main/java/com/wok/supportbot/service/ConversationService.java b/src/main/java/com/wok/supportbot/service/ConversationService.java index f4f37c3..356d0fd 100644 --- a/src/main/java/com/wok/supportbot/service/ConversationService.java +++ b/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 listConversations(int page, int size, String keyword) { + return listConversations(page, size, keyword, null, null); + } + + public Map 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 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 matchedIds = jdbcTemplate.queryForList(matchSql, String.class, - "%" + keyword + "%", "%" + keyword + "%"); - - if (matchedIds.isEmpty()) { - // 无匹配结果,返回空列表 - Map 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 listParams = new ArrayList<>(params); + listParams.add(size); + listParams.add((page - 1) * size); List> records = jdbcTemplate.queryForList(listSql, - size, (page - 1) * size); + listParams.toArray()); // 格式化结果 List> formattedRecords = new ArrayList<>(); for (Map record : records) { Map 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> 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 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 detail, String conversationId) { + List> 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 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")); + } + // ==================== 会话消息 ==================== /** diff --git a/src/main/java/com/wok/supportbot/service/CustomerAccountService.java b/src/main/java/com/wok/supportbot/service/CustomerAccountService.java new file mode 100644 index 0000000..ca35b28 --- /dev/null +++ b/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> listAccounts() { + return listAccounts(false); + } + + public List> 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> 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 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; + } + } +} diff --git a/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java b/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java index 3496cf0..4168f48 100644 --- a/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java +++ b/src/main/java/com/wok/supportbot/service/CustomerServiceRoleService.java @@ -18,12 +18,17 @@ public class CustomerServiceRoleService { private JdbcTemplate jdbcTemplate; public List> listRoles() { - String roleSql = """ - SELECT id::text AS id, role_key, name, description, prompt, sort_order, enabled - FROM customer_service_role - WHERE is_delete = false AND enabled = true - ORDER BY sort_order ASC, id ASC - """; + return listRoles(false); + } + + /** + * @param includeDisabled true=包含已停用角色(管理页用);false=仅启用角色(对话页用) + */ + public List> 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> 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> 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 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 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(); + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 12d794a..963913b 100644 --- a/src/main/resources/application.yml +++ b/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: diff --git a/src/main/resources/static/components/AccountManager.js b/src/main/resources/static/components/AccountManager.js new file mode 100644 index 0000000..215b842 --- /dev/null +++ b/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: ` +
+

账号管理

+

+ 为每个账号绑定一个默认角色。对话页选择账号后,系统会按账号绑定的角色回答,并把会话历史归属到该账号。 +

+ +
+
+ + + + +
+
+ + + +
+
+ +
暂无账号
+
+ + + + + + + + + + + + + + + + + + + +
账号说明绑定角色状态操作
+ {{ account.name }} +
{{ account.account_key }}
+
{{ account.description || '-' }}{{ account.role_name || '未绑定' }}{{ account.enabled === false ? '停用' : '启用' }} + + +
+
+
+ `, + 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 } + } +} diff --git a/src/main/resources/static/components/CategoryManager.js b/src/main/resources/static/components/CategoryManager.js index 1f1f5ab..6353725 100644 --- a/src/main/resources/static/components/CategoryManager.js +++ b/src/main/resources/static/components/CategoryManager.js @@ -9,13 +9,13 @@ import { toast } from '../js/utils.js' export default { template: `
-

🏷️ 分类管理

+

分类管理

- - + +
暂无分类
diff --git a/src/main/resources/static/components/ChatPanel.js b/src/main/resources/static/components/ChatPanel.js index 159de4a..f17a934 100644 --- a/src/main/resources/static/components/ChatPanel.js +++ b/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: `
-
客服角色
- -
- -
-
知识库范围
- - + + -
- -
暂无知识库分类
-
-

{{ roleKnowledgeScopeText }}

-
-
当前会话
-
{{ shortChatId }}
-
- - +
+
客服助手
+
+
-
-

智能客服对话

-

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

-
-
- {{ ragStatusText }} - +
+

{{ currentRole.name }}

+
+ {{ ragStatusText }} + ·{{ selectedCategoryNames.length ? selectedCategoryNames.join('、') : '全部知识库' }} +
+
@@ -112,29 +90,46 @@ export default {
-
{{ m.role === 'user' ? '我' : 'AI' }}
-
{{ m.content || (m.streaming ? '正在思考...' : '') }}
-
- {{ m.time }} - - -
+ +
- - +
+ + +
@@ -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 } } } diff --git a/src/main/resources/static/components/ConversationManager.js b/src/main/resources/static/components/ConversationManager.js index b194abb..aa0428d 100644 --- a/src/main/resources/static/components/ConversationManager.js +++ b/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: `
-

💬 会话列表

+

会话列表

@@ -34,8 +34,16 @@ export default {
- - + + + +
@@ -44,6 +52,8 @@ export default { 会话ID + 账号 + 角色 消息数 最后消息时间 最后消息预览 @@ -52,12 +62,14 @@ export default { - 暂无会话记录 + 暂无会话记录 {{ c.conversationId }} + {{ c.accountName || '-' }} + {{ c.roleName || '-' }} {{ c.messageCount }} {{ formatDate(c.lastMessageTime) }} @@ -89,9 +101,11 @@ export default {