diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatController.java b/backend/src/main/java/com/petshop/backend/controller/ChatController.java index 0cd88df3..f5dcb5fc 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatController.java @@ -12,6 +12,7 @@ import com.petshop.backend.repository.UserRepository; import com.petshop.backend.service.ChatAttachmentStorageService; import com.petshop.backend.service.ChatRealtimeService; import com.petshop.backend.service.ChatService; +import com.petshop.backend.service.OpenRouterAiService; import com.petshop.backend.util.AuthenticationHelper; import jakarta.validation.Valid; import org.springframework.core.io.Resource; @@ -32,15 +33,17 @@ public class ChatController { private final ChatService chatService; private final ChatRealtimeService chatRealtimeService; + private final OpenRouterAiService openRouterAiService; private final UserRepository userRepository; private final ChatAttachmentStorageService attachmentStorageService; private final MessageRepository messageRepository; public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService, - UserRepository userRepository, ChatAttachmentStorageService attachmentStorageService, + OpenRouterAiService openRouterAiService, UserRepository userRepository, ChatAttachmentStorageService attachmentStorageService, MessageRepository messageRepository) { this.chatService = chatService; this.chatRealtimeService = chatRealtimeService; + this.openRouterAiService = openRouterAiService; this.userRepository = userRepository; this.attachmentStorageService = attachmentStorageService; this.messageRepository = messageRepository; @@ -88,6 +91,7 @@ public class ChatController { MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request); chatRealtimeService.publishMessage(id, message); chatRealtimeService.publishConversationUpdate(id); + openRouterAiService.generateAndSendReply(id); return ResponseEntity.status(HttpStatus.CREATED).body(message); } @@ -101,6 +105,7 @@ public class ChatController { MessageResponse message = chatService.sendMessageWithAttachment(id, user.getId(), user.getRole(), file, content); chatRealtimeService.publishMessage(id, message); chatRealtimeService.publishConversationUpdate(id); + openRouterAiService.generateAndSendReply(id); return ResponseEntity.status(HttpStatus.CREATED).body(message); } diff --git a/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java b/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java index 5df48490..36b0469a 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java @@ -10,7 +10,6 @@ import com.petshop.backend.security.JwtUtil; import com.petshop.backend.service.ChatRealtimeService; import com.petshop.backend.service.ChatService; import com.petshop.backend.service.OpenRouterAiService; -import com.petshop.backend.entity.Conversation; import jakarta.validation.Valid; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; @@ -58,13 +57,7 @@ public class ChatWebSocketController { MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request); chatRealtimeService.publishMessage(id, message); chatRealtimeService.publishConversationUpdate(id); - - if (user.getRole() == User.Role.CUSTOMER) { - Conversation conversation = chatService.getConversationEntity(id); - if (conversation.getMode() == Conversation.ConversationMode.AUTOMATED) { - openRouterAiService.generateAndSendReply(id); - } - } + openRouterAiService.generateAndSendReply(id); } @MessageExceptionHandler({IllegalArgumentException.class, RuntimeException.class}) diff --git a/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java b/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java index ce0d413b..146f0229 100644 --- a/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java +++ b/backend/src/main/java/com/petshop/backend/dto/chat/MessageResponse.java @@ -8,6 +8,8 @@ public class MessageResponse { private Long id; private Long conversationId; private Long senderId; + private String senderRole; + private String senderDisplayName; private String senderAvatarUrl; private String content; private LocalDateTime timestamp; @@ -82,6 +84,22 @@ public class MessageResponse { this.senderId = senderId; } + public String getSenderRole() { + return senderRole; + } + + public void setSenderRole(String senderRole) { + this.senderRole = senderRole; + } + + public String getSenderDisplayName() { + return senderDisplayName; + } + + public void setSenderDisplayName(String senderDisplayName) { + this.senderDisplayName = senderDisplayName; + } + public String getContent() { return content; } diff --git a/backend/src/main/java/com/petshop/backend/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index 9cc00823..5b347cf5 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -24,6 +24,7 @@ import java.util.stream.Collectors; @Service public class ChatService { + private static final String BOT_USERNAME = "ai.bot"; private final ConversationRepository conversationRepository; private final MessageRepository messageRepository; @@ -160,12 +161,13 @@ public class ChatService { @Transactional public MessageResponse saveBotMessage(Long conversationId, String content) { - Conversation conversation = conversationRepository.findById(conversationId) + conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); - + User botUser = getBotUser(); + Message message = new Message(); message.setConversationId(conversationId); - message.setSenderId(101L); + message.setSenderId(botUser.getId()); message.setContent(content); message.setIsRead(false); message = messageRepository.save(message); @@ -289,14 +291,60 @@ public class ChatService { private MessageResponse toMessageResponse(Message message) { MessageResponse response = MessageResponse.fromEntity(message); - userRepository.findById(message.getSenderId()).ifPresent(user -> { - if (avatarStorageService.hasAvatar(user)) { - response.setSenderAvatarUrl(avatarStorageService.toOwnerAvatarUrl(user)); - } - }); + userRepository.findById(message.getSenderId()) + .ifPresentOrElse( + user -> populateSenderMetadata(response, user), + () -> { + response.setSenderRole("UNKNOWN"); + response.setSenderDisplayName("Unknown"); + } + ); return response; } + private void populateSenderMetadata(MessageResponse response, User user) { + response.setSenderRole(resolveSenderRole(user)); + response.setSenderDisplayName(resolveSenderDisplayName(user)); + if (avatarStorageService.hasAvatar(user)) { + response.setSenderAvatarUrl(avatarStorageService.toOwnerAvatarUrl(user)); + } + } + + private String resolveSenderRole(User user) { + if (BOT_USERNAME.equals(user.getUsername())) { + return "BOT"; + } + return user.getRole() != null ? user.getRole().name() : "UNKNOWN"; + } + + private String resolveSenderDisplayName(User user) { + if (BOT_USERNAME.equals(user.getUsername())) { + return "AI Bot"; + } + if (user.getFullName() != null && !user.getFullName().isBlank()) { + return user.getFullName(); + } + StringBuilder displayName = new StringBuilder(); + if (user.getFirstName() != null && !user.getFirstName().isBlank()) { + displayName.append(user.getFirstName().trim()); + } + if (user.getLastName() != null && !user.getLastName().isBlank()) { + if (!displayName.isEmpty()) { + displayName.append(' '); + } + displayName.append(user.getLastName().trim()); + } + if (!displayName.isEmpty()) { + return displayName.toString(); + } + return user.getUsername() != null && !user.getUsername().isBlank() ? user.getUsername() : "Unknown"; + } + + private User getBotUser() { + return userRepository.findByUsername(BOT_USERNAME) + .orElseThrow(() -> new ResourceNotFoundException("Bot user not found")); + } + public boolean hasConversationAccess(Long conversationId, Long userId, User.Role role) { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); diff --git a/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java b/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java index e9e622cf..4d56bdc1 100644 --- a/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java +++ b/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java @@ -1,90 +1,167 @@ package com.petshop.backend.service; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.petshop.backend.entity.Conversation; import com.petshop.backend.entity.Message; +import com.petshop.backend.entity.User; import com.petshop.backend.repository.MessageRepository; -import org.springframework.stereotype.Service; +import com.petshop.backend.repository.UserRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; @Service public class OpenRouterAiService { + private static final Logger log = LoggerFactory.getLogger(OpenRouterAiService.class); + private static final String BOT_USERNAME = "ai.bot"; - @Value("${openrouter.api-key}") + @Value("${openrouter.api-key:}") private String apiKey; - @Value("${openrouter.model}") + @Value("${openrouter.model:mistralai/mistral-7b-instruct:free}") private String model; + private final String openRouterUrl = "https://openrouter.ai/api/v1/chat/completions"; - private final ChatService chatService; private final ChatRealtimeService chatRealtimeService; private final MessageRepository messageRepository; + private final UserRepository userRepository; private final ObjectMapper objectMapper; private final HttpClient httpClient; - public OpenRouterAiService(ChatService chatService, ChatRealtimeService chatRealtimeService, MessageRepository messageRepository, ObjectMapper objectMapper) { + public OpenRouterAiService( + ChatService chatService, + ChatRealtimeService chatRealtimeService, + MessageRepository messageRepository, + UserRepository userRepository, + ObjectMapper objectMapper + ) { this.chatService = chatService; this.chatRealtimeService = chatRealtimeService; this.messageRepository = messageRepository; + this.userRepository = userRepository; this.objectMapper = objectMapper; - this.httpClient = HttpClient.newHttpClient(); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); } public void generateAndSendReply(Long conversationId) { - CompletableFuture.runAsync(() -> { - try { - List history = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); - - List> messages = history.stream().map(msg -> { - String role = (msg.getSenderId() != null && msg.getSenderId() == 101L) ? "assistant" : "user"; - return Map.of("role", role, "content", msg.getContent() != null ? msg.getContent() : ""); - }).collect(Collectors.toList()); - - messages.add(0, Map.of("role", "system", "content", "You are a helpful pet shop assistant. Provide concise and friendly answers. Do not output markdown, just plain text.")); + CompletableFuture.runAsync(() -> generateReply(conversationId)); + } - Map requestBody = Map.of( - "model", model, - "messages", messages - ); - - String requestJson = objectMapper.writeValueAsString(requestBody); - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(openRouterUrl)) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + apiKey) - .POST(HttpRequest.BodyPublishers.ofString(requestJson)) - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() == 200) { - Map responseMap = objectMapper.readValue(response.body(), Map.class); - List> choices = (List>) responseMap.get("choices"); - if (choices != null && !choices.isEmpty()) { - Map choice = choices.get(0); - Map message = (Map) choice.get("message"); - String replyContent = message.get("content"); - - var messageResponse = chatService.saveBotMessage(conversationId, replyContent); - chatRealtimeService.publishMessage(conversationId, messageResponse); - chatRealtimeService.publishConversationUpdate(conversationId); - } - } else { - System.err.println("OpenRouter API Error: " + response.statusCode() + " " + response.body()); - } - } catch (Exception e) { - e.printStackTrace(); + private void generateReply(Long conversationId) { + try { + if (apiKey == null || apiKey.isBlank()) { + log.debug("Skipping OpenRouter reply for conversation {} because no API key is configured", conversationId); + return; } - }); + if (model == null || model.isBlank()) { + log.warn("Skipping OpenRouter reply for conversation {} because no model is configured", conversationId); + return; + } + + Conversation conversation = chatService.getConversationEntity(conversationId); + if (conversation.getStatus() == Conversation.ConversationStatus.CLOSED) { + return; + } + if (conversation.getHumanRequestedAt() != null) { + return; + } + if (conversation.getMode() != Conversation.ConversationMode.AUTOMATED) { + return; + } + + User botUser = userRepository.findByUsername(BOT_USERNAME) + .orElseThrow(() -> new IllegalStateException("Bot user not found")); + + List history = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); + if (history.isEmpty()) { + return; + } + + Message lastMessage = history.get(history.size() - 1); + String lastContent = normalizeContent(lastMessage.getContent()); + if (lastContent.isBlank()) { + return; + } + if (lastMessage.getSenderId() != null && lastMessage.getSenderId().equals(botUser.getId())) { + return; + } + + User lastSender = userRepository.findById(lastMessage.getSenderId()).orElse(null); + if (lastSender == null || lastSender.getRole() != User.Role.CUSTOMER) { + return; + } + + List> messages = new ArrayList<>(); + messages.add(Map.of( + "role", "system", + "content", "You are a helpful pet shop assistant. Provide concise and friendly answers. Do not output markdown, just plain text." + )); + + for (Message message : history) { + messages.add(Map.of( + "role", resolveRole(message, botUser.getId()), + "content", normalizeContent(message.getContent()) + )); + } + + Map requestBody = Map.of( + "model", model, + "messages", messages + ); + + String requestJson = objectMapper.writeValueAsString(requestBody); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(openRouterUrl)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + apiKey) + .timeout(Duration.ofSeconds(30)) + .POST(HttpRequest.BodyPublishers.ofString(requestJson)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + log.warn("OpenRouter API returned {} for conversation {}", response.statusCode(), conversationId); + return; + } + + JsonNode responseNode = objectMapper.readTree(response.body()); + String replyContent = responseNode.path("choices").path(0).path("message").path("content").asText(""); + replyContent = normalizeContent(replyContent); + if (replyContent.isBlank()) { + return; + } + + var messageResponse = chatService.saveBotMessage(conversationId, replyContent); + chatRealtimeService.publishMessage(conversationId, messageResponse); + chatRealtimeService.publishConversationUpdate(conversationId); + } catch (Exception ex) { + log.error("Failed to generate OpenRouter reply for conversation {}", conversationId, ex); + } + } + + private String resolveRole(Message message, Long botUserId) { + if (message.getSenderId() != null && message.getSenderId().equals(botUserId)) { + return "assistant"; + } + return "user"; + } + + private String normalizeContent(String content) { + return content == null ? "" : content.trim(); } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 644bd23f..adafd20f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -72,5 +72,5 @@ logging: deserialization: fail-on-unknown-properties: false openrouter: - api-key: ${OPENROUTER_API_KEY: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} + api-key: ${OPENROUTER_API_KEY:} model: ${OPENROUTER_MODEL:mistralai/mistral-7b-instruct:free} diff --git a/backend/src/main/resources/db/migration/V2__seed_data.sql b/backend/src/main/resources/db/migration/V2__seed_data.sql index 4d4503c2..aab02cc1 100644 --- a/backend/src/main/resources/db/migration/V2__seed_data.sql +++ b/backend/src/main/resources/db/migration/V2__seed_data.sql @@ -155,8 +155,7 @@ INSERT INTO users (id, username, password, email, firstName, lastName, fullName, (97, 'alex.murray', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.murray@gmail.com', 'Alex', 'Murray', 'Alex Murray', '403-730-0097', 'https://images.petshop.local/users/097.webp', 'CUSTOMER', 'CUSTOMER', NULL, 4, 1, 0), (98, 'alex.freeman', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.freeman@gmail.com', 'Alex', 'Freeman', 'Alex Freeman', '403-730-0098', 'https://images.petshop.local/users/098.webp', 'CUSTOMER', 'CUSTOMER', NULL, 0, 1, 0), (99, 'alex.wells', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.wells@gmail.com', 'Alex', 'Wells', 'Alex Wells', '403-730-0099', 'https://images.petshop.local/users/099.webp', 'CUSTOMER', 'CUSTOMER', NULL, 0, 1, 0), -(100, 'alex.webb', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.webb@gmail.com', 'Alex', 'Webb', 'Alex Webb', '403-730-0100', 'https://images.petshop.local/users/100.webp', 'CUSTOMER', 'CUSTOMER', NULL, 0, 1, 0), -(101, 'ai.bot', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'bot@petshop.com', 'AI', 'Bot', 'AI Bot', '000-000-0000', 'https://images.petshop.local/users/bot.webp', 'STAFF', 'CUSTOMER_SERVICE', NULL, 0, 1, 0); +(100, 'alex.webb', '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', 'alex.webb@gmail.com', 'Alex', 'Webb', 'Alex Webb', '403-730-0100', 'https://images.petshop.local/users/100.webp', 'CUSTOMER', 'CUSTOMER', NULL, 0, 1, 0); INSERT INTO supplier (supId, supCompany, supContactFirstName, supContactLastName, supEmail, supPhone) VALUES (1, 'PetFood Inc', 'Robert', 'King', 'contact@petfood.com', '403-601-1001'), diff --git a/backend/src/main/resources/db/migration/V3__seed_openrouter_bot.sql b/backend/src/main/resources/db/migration/V3__seed_openrouter_bot.sql new file mode 100644 index 00000000..cf32a653 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__seed_openrouter_bot.sql @@ -0,0 +1,21 @@ +INSERT INTO users (username, password, email, firstName, lastName, fullName, phone, avatarUrl, role, staffRole, primaryStoreId, loyaltyPoints, active, tokenVersion) +SELECT 'ai.bot', + '$2a$10$mE0D/HrnCuqFeEqMy0NJwuy2jkoRYjQ7GrKcc/7QQ0r2AqnZTvyGq', + 'bot@petshop.com', + 'AI', + 'Bot', + 'AI Bot', + '000-000-0000', + 'https://images.petshop.local/users/bot.webp', + 'STAFF', + 'CUSTOMER_SERVICE', + NULL, + 0, + 1, + 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 + FROM users + WHERE username = 'ai.bot' +); diff --git a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java index 6d5c6407..5f214731 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java +++ b/desktop/src/main/java/org/example/petshopdesktop/api/dto/chat/MessageResponse.java @@ -6,6 +6,8 @@ public class MessageResponse { private Long id; private Long conversationId; private Long senderId; + private String senderRole; + private String senderDisplayName; private String senderAvatarUrl; private String content; private LocalDateTime timestamp; @@ -38,6 +40,22 @@ public class MessageResponse { this.senderId = senderId; } + public String getSenderRole() { + return senderRole; + } + + public void setSenderRole(String senderRole) { + this.senderRole = senderRole; + } + + public String getSenderDisplayName() { + return senderDisplayName; + } + + public void setSenderDisplayName(String senderDisplayName) { + this.senderDisplayName = senderDisplayName; + } + public String getSenderAvatarUrl() { return senderAvatarUrl; } diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java index 02d05a93..61300b30 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/ChatController.java @@ -403,6 +403,11 @@ public class ChatController { } private String resolveAuthorLabel(MessageResponse message) { + if ("BOT".equalsIgnoreCase(message.getSenderRole())) { + return message.getSenderDisplayName() != null && !message.getSenderDisplayName().isBlank() + ? message.getSenderDisplayName() + : "AI Bot"; + } Long currentUserId = UserSession.getInstance().getUserId(); if (message.getSenderId() != null && message.getSenderId().equals(currentUserId)) { return "You";