diff --git a/pom.xml b/pom.xml index ab5d8b5..43bb9ca 100644 --- a/pom.xml +++ b/pom.xml @@ -83,6 +83,14 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 + + org.springframework.boot + spring-boot-starter-amqp + + + com.fasterxml.jackson.core + jackson-databind + diff --git a/snails-chat/src/main/java/com/hanserwei/chat/config/ChatClientConfiguration.java b/snails-chat/src/main/java/com/hanserwei/chat/config/ChatClientConfiguration.java index 6c5b481..e19ddd3 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/config/ChatClientConfiguration.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/config/ChatClientConfiguration.java @@ -4,10 +4,10 @@ import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel; import com.alibaba.cloud.ai.memory.redis.BaseRedisChatMemoryRepository; import com.alibaba.cloud.ai.memory.redis.LettuceRedisChatMemoryRepository; import com.hanserwei.chat.tools.AiDBTools; +import com.hanserwei.chat.tools.SendMQMessageTools; import jakarta.annotation.Resource; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor; -import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.beans.factory.annotation.Value; @@ -31,6 +31,9 @@ public class ChatClientConfiguration { private DashScopeChatModel dashScopeChatModel; @Resource private AiDBTools aiDBTools; + + @Value("classpath:prompt/aiAssistant.st") + private org.springframework.core.io.Resource aiAssistantResource; @Bean public BaseRedisChatMemoryRepository redisChatMemoryRepository() { @@ -52,10 +55,11 @@ public class ChatClientConfiguration { } @Bean - public ChatClient dashScopeChatClient(ChatMemory chatMemory) { + public ChatClient dashScopeChatClient(ChatMemory chatMemory, SendMQMessageTools sendMQMessageTools) { return ChatClient.builder(dashScopeChatModel) - .defaultTools(aiDBTools) - .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build(), new SimpleLoggerAdvisor()) + .defaultTools(aiDBTools, sendMQMessageTools) + .defaultSystem(aiAssistantResource) + .defaultAdvisors(PromptChatMemoryAdvisor.builder(chatMemory).build()) .build(); } -} +} \ No newline at end of file diff --git a/snails-chat/src/main/java/com/hanserwei/chat/config/RabbitMQConfig.java b/snails-chat/src/main/java/com/hanserwei/chat/config/RabbitMQConfig.java new file mode 100644 index 0000000..76aee93 --- /dev/null +++ b/snails-chat/src/main/java/com/hanserwei/chat/config/RabbitMQConfig.java @@ -0,0 +1,37 @@ +package com.hanserwei.chat.config; + +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.DirectExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMQConfig { + + @Bean + public DirectExchange directExchange() { + return new DirectExchange("chat.exchange"); + } + + @Bean + public Queue queue() { + return new Queue("chat.queue"); + } + + @Bean + public Binding binding() { + return BindingBuilder.bind(queue()) + .to(directExchange()) + .with("chat.routing.key"); + } + + @Bean + public MessageConverter messageConverter() { + return new Jackson2JsonMessageConverter(); + } + +} diff --git a/snails-chat/src/main/java/com/hanserwei/chat/consumer/RabbitMQConsumer.java b/snails-chat/src/main/java/com/hanserwei/chat/consumer/RabbitMQConsumer.java new file mode 100644 index 0000000..2fadac3 --- /dev/null +++ b/snails-chat/src/main/java/com/hanserwei/chat/consumer/RabbitMQConsumer.java @@ -0,0 +1,30 @@ +package com.hanserwei.chat.consumer; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +public class RabbitMQConsumer { + + @RabbitListener(queues = "chat.queue") + public void receiveMessage(Object message) { + String messageText = extractMessage(message); + log.info("Received message text:\n{}", messageText); + } + + private String extractMessage(Object message) { + return switch (message) { + case null -> ""; + case String str -> str; + case byte[] body -> new String(body, StandardCharsets.UTF_8); + case Message amqpMessage -> new String(amqpMessage.getBody(), StandardCharsets.UTF_8); + default -> String.valueOf(message); + }; + + } +} diff --git a/snails-chat/src/main/java/com/hanserwei/chat/controller/AiChatController.java b/snails-chat/src/main/java/com/hanserwei/chat/controller/AiChatController.java index 1256208..1a45978 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/controller/AiChatController.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/controller/AiChatController.java @@ -2,7 +2,10 @@ package com.hanserwei.chat.controller; import com.hanserwei.chat.model.dto.ChatMessageDTO; import com.hanserwei.chat.model.vo.AIResponse; +import com.hanserwei.chat.tools.SendMQMessageTools; +import com.hanserwei.chat.utils.ConversationContext; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.http.MediaType; @@ -12,15 +15,26 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import reactor.core.publisher.Flux; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j @RestController @RequestMapping("/ai") public class AiChatController { @Resource private ChatClient dashScopeChatClient; + @Resource + private SendMQMessageTools sendMQMessageTools; + + private static final Pattern PHONE_PATTERN = Pattern.compile("(? chatWithAi(@RequestBody ChatMessageDTO chatMessageDTO) { + log.info("会话ID:{}", chatMessageDTO.getConversionId()); + ConversationContext.setConversationId(chatMessageDTO.getConversionId()); + triggerSendMessageIfPhonePresent(chatMessageDTO); return dashScopeChatClient.prompt() .user(chatMessageDTO.getMessage()) @@ -29,8 +43,26 @@ public class AiChatController { .chatResponse() .mapNotNull(chatResponse -> AIResponse.builder() .v(chatResponse.getResult().getOutput().getText()) - .build()); - + .build()) + .contextWrite(ctx -> ConversationContext.withConversationId(chatMessageDTO.getConversionId())) + .doFinally(signalType -> ConversationContext.clear()); } + private void triggerSendMessageIfPhonePresent(ChatMessageDTO chatMessageDTO) { + String message = chatMessageDTO.getMessage(); + if (message == null || message.isEmpty()) { + return; + } + + Matcher matcher = PHONE_PATTERN.matcher(message); + if (!matcher.find()) { + return; + } + + String phoneNumber = matcher.group(1); + log.info("检测到手机号:{},会话ID:{}", phoneNumber, chatMessageDTO.getConversionId()); + sendMQMessageTools.sendMQMessage(phoneNumber) + .doOnError(error -> log.error("触发发送消息工具失败,手机号:{},错误:{}", phoneNumber, error.getMessage(), error)) + .subscribe(result -> log.info("已触发发送消息工具,手机号:{},结果:{}", phoneNumber, result)); + } } diff --git a/snails-chat/src/main/java/com/hanserwei/chat/publisher/RabbitMQPublisher.java b/snails-chat/src/main/java/com/hanserwei/chat/publisher/RabbitMQPublisher.java new file mode 100644 index 0000000..bcf0cb9 --- /dev/null +++ b/snails-chat/src/main/java/com/hanserwei/chat/publisher/RabbitMQPublisher.java @@ -0,0 +1,29 @@ +package com.hanserwei.chat.publisher; + +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class RabbitMQPublisher { + + @Resource + private RabbitTemplate rabbitTemplate; + + /** + * 发送消息 + * + * @param exchange 交换机 + * @param routingKey 路由键 + * @param message 消息 + */ + public void send(String exchange, String routingKey, Object message) { + try { + rabbitTemplate.convertAndSend(exchange, routingKey, message); + } catch (Exception e) { + log.error("RabbitMQ发送消息失败:{}", e.getMessage()); + } + } +} diff --git a/snails-chat/src/main/java/com/hanserwei/chat/tools/AiDBTools.java b/snails-chat/src/main/java/com/hanserwei/chat/tools/AiDBTools.java index 193640c..1b5706b 100644 --- a/snails-chat/src/main/java/com/hanserwei/chat/tools/AiDBTools.java +++ b/snails-chat/src/main/java/com/hanserwei/chat/tools/AiDBTools.java @@ -4,12 +4,14 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.hanserwei.chat.domain.dataobject.User; import com.hanserwei.chat.service.UserService; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import java.util.List; +@Slf4j @Component public class AiDBTools { @@ -18,21 +20,25 @@ public class AiDBTools { @Tool(name = "findAll", description = "查询所有用户") public List findAll() { + log.info("AiDBTools: findAll"); return userService.list(); } @Tool(name = "findAllByIdIn", description = "根据id列表查询用户") public List findAllByIdIn(@ToolParam(description = "用户id列表") List ids) { + log.info("AiDBTools: findAllByIdIn"); return userService.listByIds(ids); } @Tool(name = "findById", description = "根据id查询用户") public User findById(Long id) { + log.info("AiDBTools: findById"); return userService.getById(id); } @Tool(name = "findByName", description = "根据名称查询用户") public User findByName(String name) { + log.info("AiDBTools: findByName"); LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(User.class) .eq(User::getName, name); return userService.getOne(queryWrapper); @@ -40,6 +46,7 @@ public class AiDBTools { @Tool(name = "findByNameLike", description = "根据名称模糊查询用户") public List findByNameLike(String name) { + log.info("AiDBTools: findByNameLike"); LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(User.class) .like(User::getName, name); return userService.list(queryWrapper); @@ -47,6 +54,7 @@ public class AiDBTools { @Tool(name = "findByAge", description = "根据年龄查询用户") public List findByAge(Integer age) { + log.info("AiDBTools: findByAge"); LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(User.class) .eq(User::getAge, age); return userService.list(queryWrapper); @@ -54,6 +62,7 @@ public class AiDBTools { @Tool(name = "findByAgeBetween", description = "根据年龄范围查询用户") public List findByAgeBetween(Integer start, Integer end) { + log.info("AiDBTools: findByAgeBetween"); LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(User.class) .between(User::getAge, start, end); return userService.list(queryWrapper); @@ -68,6 +77,7 @@ public class AiDBTools { name (String), email (String), 和 age (Integer)。 """) public void insert(@ToolParam(description = "用户对象") User user) { + log.info("AiDBTools: insert"); userService.save(user); } @@ -77,11 +87,13 @@ public class AiDBTools { 并携带要修改的字段,例如 name (String), email (String), 或 age (Integer)。 """) public void update(@ToolParam(description = "用户对象") User user) { + log.info("AiDBTools: update"); userService.updateById(user); } @Tool(name = "delete", description = "删除用户") public void delete(Long id) { + log.info("AiDBTools: delete"); userService.removeById(id); } diff --git a/snails-chat/src/main/java/com/hanserwei/chat/tools/SendMQMessageTools.java b/snails-chat/src/main/java/com/hanserwei/chat/tools/SendMQMessageTools.java new file mode 100644 index 0000000..650a6c4 --- /dev/null +++ b/snails-chat/src/main/java/com/hanserwei/chat/tools/SendMQMessageTools.java @@ -0,0 +1,105 @@ +package com.hanserwei.chat.tools; + +import com.hanserwei.chat.publisher.RabbitMQPublisher; +import com.hanserwei.chat.utils.ConversationContext; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.messages.AbstractMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class SendMQMessageTools { + + @Resource + private RabbitMQPublisher rabbitMQPublisher; + @Resource + private ChatMemory chatMemory; + + private static final int HISTORY_LIMIT = 10; + + /** + * 发送RabbitMQ消息 + * + * @param phoneNumber 手机号码 + */ + @Tool(name = "SendMessage", + description = "当用户留下手机号码时,立即调用此工具发送消息通知管理员。手机号码应该是11位数字。") + public Mono sendMQMessage( + @ToolParam(description = "用户提供的手机号码,必须是11位数字") String phoneNumber + ) { + return ConversationContext.getConversationIdMono() + .defaultIfEmpty(ConversationContext.getConversationId()) + .map(conversationId -> { + String exchange = "chat.exchange"; + String routingKey = "chat.routing.key"; + String recentConversation = buildRecentConversation(conversationId); + String message = "用户留了手机号:" + phoneNumber + ",会话ID:" + conversationId + recentConversation; + + log.info("SendMQMessageTools 发送消息: {}", message); + rabbitMQPublisher.send(exchange, routingKey, message); + + // 确保日志能被记录 + log.info("SendMQMessageTools 消息发送完成"); + return "消息发送成功"; + }); + } + + private String buildRecentConversation(Long conversationId) { + if (conversationId == null) { + return "。最近对话:暂无记录"; + } + + List history = null; + try { + history = chatMemory.get(String.valueOf(conversationId)); + } catch (Exception ex) { + log.warn("获取会话{}历史记录失败: {}", conversationId, ex.getMessage(), ex); + } + + if (history == null || history.isEmpty()) { + return "。最近对话:暂无记录"; + } + + int skip = Math.max(0, history.size() - HISTORY_LIMIT); + String recent = history.stream() + .skip(skip) + .map(SendMQMessageTools::formatMessage) + .filter(Objects::nonNull) + .collect(Collectors.joining("\n")); + + if (recent.isEmpty()) { + return "。最近对话:暂无记录"; + } + + return "。最近对话:\n" + recent; + } + + private static String formatMessage(Message message) { + if (message == null) { + return null; + } + + MessageType messageType = message.getMessageType(); + String role = messageType != null ? messageType.name() : "UNKNOWN"; + String text = (message instanceof AbstractMessage abstractMessage) + ? abstractMessage.getText() + : message.toString(); + + if (text == null || text.isBlank()) { + return null; + } + + return role + ": " + text.strip(); + } +} diff --git a/snails-chat/src/main/java/com/hanserwei/chat/utils/ConversationContext.java b/snails-chat/src/main/java/com/hanserwei/chat/utils/ConversationContext.java new file mode 100644 index 0000000..5afa02c --- /dev/null +++ b/snails-chat/src/main/java/com/hanserwei/chat/utils/ConversationContext.java @@ -0,0 +1,63 @@ +package com.hanserwei.chat.utils; + +import reactor.core.publisher.Mono; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +/** + * 会话上下文工具类,用于在线程本地存储中保存会话ID + * 同时支持Reactor Context,以支持响应式编程中的上下文传递 + */ +public class ConversationContext { + + private static final ThreadLocal CONVERSATION_ID_HOLDER = new ThreadLocal<>(); + + // Reactor Context key + private static final String CONVERSATION_ID_KEY = "conversationId"; + + /** + * 设置当前线程的会话ID + * @param conversationId 会话ID + */ + public static void setConversationId(Long conversationId) { + CONVERSATION_ID_HOLDER.set(conversationId); + } + + /** + * 获取当前线程的会话ID + * @return 会话ID + */ + public static Long getConversationId() { + // 首先尝试从ThreadLocal获取 + return CONVERSATION_ID_HOLDER.get(); + } + + /** + * 从Reactor Context中获取会话ID + * @return 包含会话ID的Mono + */ + public static Mono getConversationIdMono() { + return Mono.deferContextual(ctx -> { + if (ctx.hasKey(CONVERSATION_ID_KEY)) { + return Mono.just(ctx.get(CONVERSATION_ID_KEY)); + } + return Mono.empty(); + }); + } + + /** + * 在Reactor Context中设置会话ID + * @param conversationId 会话ID + * @return Context + */ + public static Context withConversationId(Long conversationId) { + return Context.of(CONVERSATION_ID_KEY, conversationId); + } + + /** + * 清理当前线程的会话ID + */ + public static void clear() { + CONVERSATION_ID_HOLDER.remove(); + } +} \ No newline at end of file diff --git a/snails-chat/src/main/resources/config/application.yml b/snails-chat/src/main/resources/config/application.yml index 04dbb8f..a4b6d2b 100644 --- a/snails-chat/src/main/resources/config/application.yml +++ b/snails-chat/src/main/resources/config/application.yml @@ -26,6 +26,15 @@ spring: max-wait: 10000 min-idle: 10 time-between-eviction-runs: 10000 + rabbitmq: + host: localhost + port: 5672 + username: admin + password: admin123 + virtual-host: /snailsAi + listener: + simple: + prefetch: 1 datasource: driver-class-name: org.postgresql.Driver url: jdbc:postgresql://localhost:5432/postgres?serverTimezone=Asia/Shanghai diff --git a/snails-chat/src/main/resources/prompt/aiAssistant.st b/snails-chat/src/main/resources/prompt/aiAssistant.st new file mode 100644 index 0000000..b76c229 --- /dev/null +++ b/snails-chat/src/main/resources/prompt/aiAssistant.st @@ -0,0 +1,23 @@ +### 🎯 **角色与目标** + +你是一个**专业、友好且信息准确**的智能客服AI助手,负责解答用户关于HanserUniverse公司的常见问题。你的核心目标是**高效解决疑问**,并在每次互动后**引导用户留下手机号码进行下一步的深度咨询**。 + +公司核心业务: 我们是一家专注于智能家居产品研发与销售的科技公司。 + +使命: 致力于通过新技术 / 优质服务 / 专业内容提升用户的生活质量/学习效率/工作效率。 +### 📜 **基本指令与约束** + +1. **回答模式:** 始终以**清晰、简洁、礼貌**的方式回答用户的问题。 +2. **强制性附加句:** 无论你回答了什么问题,在**每个回答的末尾**,你必须**完全、准确地附加**以下这句话: + > **"如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。"** +3. **手机号识别与工具调用:** + * 当用户输入的文本中包含一个**有效的手机号码**时(通常是11位数字,识别时请保持一定的容错性),你必须**立即调用SendMessage工具**,并将手机号作为参数传递。 + * **给用户的反馈:** 工具调用成功后,给用户回复一个简短的确认信息,例如:"感谢您留下手机号码,我们已收到您的信息,专业的客服人员将在24小时内与您联系,请保持手机畅通。" + +### 💡 **工作流程示例** + +| 步骤 | 用户输入 | AI助手的行为 | +| :--- | :--- | :--- | +| **1 (常规问题)** | "你们产品的价格是多少?" | 1. 回答价格信息。
2. **附加句:** "如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。" | +| **2 (留号码)** | "好的,这是我的手机号:13800001234" | 1. **识别**到手机号。
2. **调用工具:** `SendMessage`
3. **回复确认:** "感谢您留下手机号码,我们已收到您的信息,专业的客服人员将在24小时内与您联系,请保持手机畅通。" | +| **3 (追问)** | "你们的退货政策呢?" | 1. 回答退货政策。
2. **附加句:** "如果想要了解更多信息可以留下电话号码,会有专门的客服人员与您联系。" | \ No newline at end of file