diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index cd2af889..d8cce2ef 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -28,6 +28,8 @@ services: JWT_SECRET: change_me_please_this_secret_key_is_long_enough_for_jwt_hmac_sha256 STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} + OPENROUTER_MODEL: ${OPENROUTER_MODEL:-openai/gpt-oss-120b:free} ports: - "8080:8080" depends_on: diff --git a/backend/src/main/java/com/petshop/backend/controller/AiChatController.java b/backend/src/main/java/com/petshop/backend/controller/AiChatController.java new file mode 100644 index 00000000..34de297c --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/controller/AiChatController.java @@ -0,0 +1,84 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.ai.AiChatRequest; +import com.petshop.backend.dto.ai.AiChatResponse; +import com.petshop.backend.entity.Pet; +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.PetRepository; +import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.service.OpenRouterService; +import com.petshop.backend.util.AuthenticationHelper; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; + +@RestController +@RequestMapping("/api/v1/ai-chat") +public class AiChatController { + + private final OpenRouterService openRouterService; + private final PetRepository petRepository; + private final UserRepository userRepository; + + public AiChatController(OpenRouterService openRouterService, + PetRepository petRepository, + UserRepository userRepository) { + this.openRouterService = openRouterService; + this.petRepository = petRepository; + this.userRepository = userRepository; + } + + private User getCurrentUser() { + try { + return AuthenticationHelper.getAuthenticatedUser(userRepository); + } + + catch (RuntimeException ex) { + throw new UsernameNotFoundException(ex.getMessage(), ex); + } + } + + @PostMapping("/message") + @PreAuthorize("isAuthenticated()") + public ResponseEntity sendMessage(@Valid @RequestBody AiChatRequest request) { + if (request.getMessage() == null || request.getMessage().isBlank()) { + return ResponseEntity.badRequest().body(AiChatResponse.fail("Message cannot be empty")); + } + + User user = getCurrentUser(); + + List userPets; + try { + userPets = petRepository.findAllByOwner_IdOrderByPetNameAsc(user.getId()); + } + + catch (Exception e) { + userPets = Collections.emptyList(); + } + + try { + String aiReply = openRouterService.chat( + request.getHistory(), + request.getMessage(), + userPets + ); + + return ResponseEntity.ok(AiChatResponse.ok(aiReply)); + } + + catch (IllegalStateException e) { + + return ResponseEntity.status(503).body(AiChatResponse.fail("AI service is not configured. Please contact support.")); + } + + catch (Exception e) { + + return ResponseEntity.status(502).body(AiChatResponse.fail("AI service is temporarily unavailable. Please try again later.")); + } + } +} 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 076f76cf..5c7d87de 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; @@ -55,7 +58,7 @@ public class ChatController { } @PostMapping("/conversations") - @PreAuthorize("hasRole('CUSTOMER')") + @PreAuthorize("isAuthenticated()") public ResponseEntity createConversation(@Valid @RequestBody ConversationRequest request) { User user = getCurrentUser(); ConversationResponse response = chatService.createConversation(user.getId(), request); @@ -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, message.getId()); 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, message.getId()); return ResponseEntity.status(HttpStatus.CREATED).body(message); } @@ -139,7 +144,7 @@ public class ChatController { } @PostMapping("/conversations/{id}/request-human") - @PreAuthorize("hasRole('CUSTOMER')") + @PreAuthorize("isAuthenticated()") public ResponseEntity requestHumanTakeover(@PathVariable Long id) { User user = getCurrentUser(); ConversationResponse conversation = chatService.requestHumanTakeover(id, user.getId(), user.getRole()); 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 ed0a3718..4bee487c 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java @@ -9,6 +9,7 @@ import com.petshop.backend.security.AppPrincipal; 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 jakarta.validation.Valid; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; @@ -27,6 +28,7 @@ import java.util.Map; public class ChatWebSocketController { private final ChatService chatService; + private final OpenRouterAiService openRouterAiService; private final ChatRealtimeService chatRealtimeService; private final UserRepository userRepository; private final JwtUtil jwtUtil; @@ -37,8 +39,10 @@ public class ChatWebSocketController { ChatRealtimeService chatRealtimeService, UserRepository userRepository, JwtUtil jwtUtil, - WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor + WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor, + OpenRouterAiService openRouterAiService ) { + this.openRouterAiService = openRouterAiService; this.chatService = chatService; this.chatRealtimeService = chatRealtimeService; this.userRepository = userRepository; @@ -53,6 +57,7 @@ public class ChatWebSocketController { MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request); chatRealtimeService.publishMessage(id, message); chatRealtimeService.publishConversationUpdate(id); + openRouterAiService.generateAndSendReply(id, message.getId()); } @MessageExceptionHandler({IllegalArgumentException.class, RuntimeException.class}) diff --git a/backend/src/main/java/com/petshop/backend/dto/ai/AiChatRequest.java b/backend/src/main/java/com/petshop/backend/dto/ai/AiChatRequest.java new file mode 100644 index 00000000..b043b27c --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/ai/AiChatRequest.java @@ -0,0 +1,61 @@ +package com.petshop.backend.dto.ai; + +import java.util.List; + +public class AiChatRequest { + + private String message; + private List history; + + public AiChatRequest() { + } + + public String getMessage() { + + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public List getHistory() { + + return history; + } + + public void setHistory(List history) { + this.history = history; + } + + public static class ChatHistoryItem { + private String role; + private String content; + + public ChatHistoryItem() { + } + + public ChatHistoryItem(String role, String content) { + this.role = role; + this.content = content; + } + + public String getRole() { + + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getContent() { + + return content; + } + + public void setContent(String content) { + this.content = content; + } + } +} diff --git a/backend/src/main/java/com/petshop/backend/dto/ai/AiChatResponse.java b/backend/src/main/java/com/petshop/backend/dto/ai/AiChatResponse.java new file mode 100644 index 00000000..05f310b4 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/dto/ai/AiChatResponse.java @@ -0,0 +1,54 @@ +package com.petshop.backend.dto.ai; + +public class AiChatResponse { + + private String message; + private boolean success; + private String error; + + public AiChatResponse() { + } + + public static AiChatResponse ok(String message) { + AiChatResponse r = new AiChatResponse(); + r.message = message; + r.success = true; + + return r; + } + + public static AiChatResponse fail(String error) { + AiChatResponse r = new AiChatResponse(); + r.success = false; + r.error = error; + + return r; + } + + public String getMessage() { + + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public boolean isSuccess() { + + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getError() { + + return error; + } + + public void setError(String error) { + this.error = error; + } +} 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 41d2dfd6..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; @@ -48,10 +49,6 @@ public class ChatService { User user = userRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("User not found")); - if (user.getRole() != User.Role.CUSTOMER) { - throw new AccessDeniedException("Only customers can start new conversations"); - } - Conversation conversation = new Conversation(); conversation.setCustomerId(userId); conversation.setStatus(Conversation.ConversationStatus.OPEN); @@ -93,6 +90,12 @@ public class ChatService { .collect(Collectors.toList()); } + + public Conversation getConversationEntity(Long id) { + return conversationRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); + } + public ConversationResponse getConversation(Long conversationId, Long userId, User.Role role) { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); @@ -155,6 +158,23 @@ public class ChatService { return toMessageResponse(message); } + + @Transactional + public MessageResponse saveBotMessage(Long conversationId, String content) { + conversationRepository.findById(conversationId) + .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); + User botUser = getBotUser(); + + Message message = new Message(); + message.setConversationId(conversationId); + message.setSenderId(botUser.getId()); + message.setContent(content); + message.setIsRead(false); + message = messageRepository.save(message); + + return toMessageResponse(message); + } + @Transactional public MessageResponse sendMessageWithAttachment(Long conversationId, Long userId, User.Role role, MultipartFile file, String content) { Conversation conversation = conversationRepository.findById(conversationId) @@ -210,7 +230,7 @@ public class ChatService { throw new AccessDeniedException("Conversation is closed"); } - if (role != User.Role.CUSTOMER || !hasConversationAccess(conversation, userId, role)) { + if (!hasConversationAccess(conversation, userId, role)) { throw new AccessDeniedException("You can only request human takeover for your own conversations"); } @@ -271,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 new file mode 100644 index 00000000..026b9a3f --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/OpenRouterAiService.java @@ -0,0 +1,190 @@ +package com.petshop.backend.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +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 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; + +@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:}") + private String apiKey; + + @Value("${openrouter.model:openai/gpt-oss-120b: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, + UserRepository userRepository + ) { + this.chatService = chatService; + this.chatRealtimeService = chatRealtimeService; + this.messageRepository = messageRepository; + this.userRepository = userRepository; + this.objectMapper = JsonMapper.builder().findAndAddModules().build(); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + public void generateAndSendReply(Long conversationId, Long triggerMessageId) { + CompletableFuture.runAsync(() -> generateReply(conversationId, triggerMessageId)); + } + + private void generateReply(Long conversationId, Long triggerMessageId) { + try { + if (triggerMessageId == null) { + return; + } + 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")); + + Message triggerMessage = messageRepository.findById(triggerMessageId).orElse(null); + if (triggerMessage == null || !conversationId.equals(triggerMessage.getConversationId())) { + return; + } + + List history = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); + if (history.isEmpty()) { + return; + } + + Message lastMessage = history.get(history.size() - 1); + if (!triggerMessageId.equals(lastMessage.getId())) { + return; + } + 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; + } + + Conversation latestConversation = chatService.getConversationEntity(conversationId); + if (latestConversation.getStatus() == Conversation.ConversationStatus.CLOSED + || latestConversation.getHumanRequestedAt() != null + || latestConversation.getMode() != Conversation.ConversationMode.AUTOMATED) { + return; + } + + List latestHistory = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); + if (latestHistory.isEmpty() || !triggerMessageId.equals(latestHistory.get(latestHistory.size() - 1).getId())) { + 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/java/com/petshop/backend/service/OpenRouterService.java b/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java new file mode 100644 index 00000000..6f851bfd --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java @@ -0,0 +1,137 @@ +package com.petshop.backend.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.petshop.backend.dto.ai.AiChatRequest; +import com.petshop.backend.entity.Pet; +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.List; + +@Service +public class OpenRouterService { + + private static final String OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; + + @Value("${openrouter.api-key:}") + private String apiKey; + + @Value("${openrouter.model:meta-llama/llama-3.3-70b-instruct:free}") + private String model; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build(); + + public String chat(List history, String userMessage, List userPets) { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("OpenRouter API key is not configured"); + } + + try { + String systemPrompt = buildSystemPrompt(userPets); + String requestBody = buildRequestBody(systemPrompt, history, userMessage); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(OPENROUTER_URL)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + apiKey) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .timeout(Duration.ofSeconds(60)) + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new RuntimeException("OpenRouter API returned status " + response.statusCode() + ": " + response.body()); + } + + return extractContent(response.body()); + } + + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("AI request was interrupted", e); + } + catch (Exception e) { + throw new RuntimeException("Failed to call OpenRouter API: " + e.getMessage(), e); + } + } + + private String buildSystemPrompt(List userPets) { + StringBuilder sb = new StringBuilder(); + sb.append("You are a helpful AI assistant for Leon's Pet Store, specializing in pet ownership and adoption advice. "); + sb.append("You help customers with pet care requirements, supplies (food, habitat, grooming, etc.), "); + sb.append("veterinary and grooming appointments, and general pet-related questions. "); + sb.append("You can recommend pets based on the user's experience level (beginner, intermediate, expert). "); + sb.append("Always be friendly, informative, and focused on pet-related topics.\n\n"); + + if (userPets != null && !userPets.isEmpty()) { + sb.append("The user currently owns the following pets:\n"); + for (Pet pet : userPets) { + sb.append("- ").append(pet.getPetName()); + sb.append(" (").append(pet.getPetSpecies()); + + if (pet.getPetBreed() != null && !pet.getPetBreed().isBlank()) { + sb.append(", ").append(pet.getPetBreed()); + } + + if (pet.getPetAge() != null) { + sb.append(", ").append(pet.getPetAge()).append(" year(s) old"); + } + sb.append(")\n"); + } + sb.append("Use this information to personalize your responses when relevant.\n"); + } + + else { + sb.append("The user does not currently own any pets registered in the system.\n"); + } + + return sb.toString(); + } + + private String buildRequestBody(String systemPrompt, List history, String userMessage) throws Exception { + ObjectNode root = objectMapper.createObjectNode(); + root.put("model", model); + + ArrayNode messages = root.putArray("messages"); + + ObjectNode systemMsg = messages.addObject(); + systemMsg.put("role", "system"); + systemMsg.put("content", systemPrompt); + + if (history != null) { + for (AiChatRequest.ChatHistoryItem item : history) { + ObjectNode msg = messages.addObject(); + msg.put("role", item.getRole()); + msg.put("content", item.getContent()); + } + } + + ObjectNode userMsg = messages.addObject(); + userMsg.put("role", "user"); + userMsg.put("content", userMessage); + + return objectMapper.writeValueAsString(root); + } + + private String extractContent(String responseBody) throws Exception { + JsonNode root = objectMapper.readTree(responseBody); + JsonNode choices = root.path("choices"); + if (choices.isArray() && choices.size() > 0) { + return choices.get(0).path("message").path("content").asText(); + } + + throw new RuntimeException("No content in OpenRouter response: " + responseBody); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 97096d7d..8db2e0af 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -2,6 +2,9 @@ spring: application: name: petshop-backend + config: + import: optional:file:.env[.properties] + servlet: multipart: enabled: true @@ -53,6 +56,10 @@ jwt: stripe: secret-key: ${STRIPE_SECRET_KEY:} +openrouter: + api-key: ${OPENROUTER_API_KEY:} + model: ${OPENROUTER_MODEL:openai/gpt-oss-120b:free} + logging: level: com.petshop: ${LOG_LEVEL:INFO} @@ -63,4 +70,4 @@ logging: serialization: write-dates-as-timestamps: false deserialization: - fail-on-unknown-properties: false \ No newline at end of file + fail-on-unknown-properties: false 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/backend/src/main/resources/dev/final-target/final_target_seed.sql b/backend/src/main/resources/dev/final-target/final_target_seed.sql index f1db3380..447f1dad 100644 --- a/backend/src/main/resources/dev/final-target/final_target_seed.sql +++ b/backend/src/main/resources/dev/final-target/final_target_seed.sql @@ -153,7 +153,8 @@ 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', 2, 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', 3, 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', 1, 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', 2, 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', 2, 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); INSERT INTO supplier (supId, supCompany, supContactFirstName, supContactLastName, supEmail, supPhone) VALUES (1, 'PetFood Inc', 'Robert', 'King', 'contact@petfood.com', '403-601-1001'), 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"; diff --git a/desktop/src/main/java/org/example/petshopdesktop/controllers/SaleController.java b/desktop/src/main/java/org/example/petshopdesktop/controllers/SaleController.java index 212b5762..7edc9c9a 100644 --- a/desktop/src/main/java/org/example/petshopdesktop/controllers/SaleController.java +++ b/desktop/src/main/java/org/example/petshopdesktop/controllers/SaleController.java @@ -150,12 +150,16 @@ public class SaleController { } private void setupTables() { - tvCart.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + tvCart.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); tvCart.setFixedCellSize(34); colCartProduct.setCellValueFactory(new PropertyValueFactory<>("prodName")); colCartQty.setCellValueFactory(new PropertyValueFactory<>("quantity")); colCartUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice")); colCartTotal.setCellValueFactory(new PropertyValueFactory<>("total")); + colCartProduct.setMinWidth(190); + colCartQty.setMinWidth(70); + colCartUnitPrice.setMinWidth(90); + colCartTotal.setMinWidth(90); TableViewSupport.applyCurrencyColumn(colCartUnitPrice); TableViewSupport.applyCurrencyColumn(colCartTotal); tvCart.setItems(cartItems); @@ -171,6 +175,14 @@ public class SaleController { colSaleUnitPrice.setCellValueFactory(new PropertyValueFactory<>("unitPrice")); colSaleTotal.setCellValueFactory(new PropertyValueFactory<>("total")); colSalePaymentType.setCellValueFactory(new PropertyValueFactory<>("paymentMethod")); + colSaleId.setMinWidth(50); + colSaleDate.setMinWidth(150); + colEmployeeName.setMinWidth(150); + colServiceProduct.setMinWidth(260); + colSaleQuantity.setMinWidth(55); + colSaleUnitPrice.setMinWidth(100); + colSaleTotal.setMinWidth(100); + colSalePaymentType.setMinWidth(95); TableViewSupport.applyCurrencyColumn(colSaleUnitPrice); TableViewSupport.applyCurrencyColumn(colSaleTotal); @@ -179,6 +191,12 @@ public class SaleController { TableViewSupport.installDoubleClickAction(tvSales, selected -> openSaleDetailDialog(selected.getSaleId())); txtSearch.textProperty().addListener((obs, oldVal, newVal) -> applySalesFilter(newVal)); + tvSales.widthProperty().addListener((obs, oldWidth, newWidth) -> updateSalesColumnWidths(newWidth.doubleValue())); + tvCart.widthProperty().addListener((obs, oldWidth, newWidth) -> updateCartColumnWidths(newWidth.doubleValue())); + Platform.runLater(() -> { + updateSalesColumnWidths(tvSales.getWidth()); + updateCartColumnWidths(tvCart.getWidth()); + }); } private void setupCreateSale() { @@ -543,6 +561,58 @@ public class SaleController { lblCartTotal.setText(currency.format(total)); } + private void updateSalesColumnWidths(double tableWidth) { + double available = Math.max(tableWidth - 28.0, 0.0); + double baseWidth = 1125.0; + if (available <= 0) { + return; + } + + if (available <= baseWidth) { + colSaleId.setPrefWidth(60.0); + colSaleDate.setPrefWidth(170.0); + colEmployeeName.setPrefWidth(160.0); + colServiceProduct.setPrefWidth(320.0); + colSaleQuantity.setPrefWidth(70.0); + colSaleUnitPrice.setPrefWidth(115.0); + colSaleTotal.setPrefWidth(120.0); + colSalePaymentType.setPrefWidth(110.0); + return; + } + + double extra = available - baseWidth; + colSaleId.setPrefWidth(60.0); + colSaleDate.setPrefWidth(170.0 + extra * 0.18); + colEmployeeName.setPrefWidth(160.0 + extra * 0.18); + colServiceProduct.setPrefWidth(320.0 + extra * 0.42); + colSaleQuantity.setPrefWidth(70.0); + colSaleUnitPrice.setPrefWidth(115.0 + extra * 0.08); + colSaleTotal.setPrefWidth(120.0 + extra * 0.08); + colSalePaymentType.setPrefWidth(110.0 + extra * 0.06); + } + + private void updateCartColumnWidths(double tableWidth) { + double available = Math.max(tableWidth - 28.0, 0.0); + double baseWidth = 640.0; + if (available <= 0) { + return; + } + + if (available <= baseWidth) { + colCartProduct.setPrefWidth(310.0); + colCartQty.setPrefWidth(90.0); + colCartUnitPrice.setPrefWidth(120.0); + colCartTotal.setPrefWidth(120.0); + return; + } + + double extra = available - baseWidth; + colCartProduct.setPrefWidth(310.0 + extra * 0.55); + colCartQty.setPrefWidth(90.0); + colCartUnitPrice.setPrefWidth(120.0 + extra * 0.2); + colCartTotal.setPrefWidth(120.0 + extra * 0.25); + } + private void setCreateSaleControlsDisabled(boolean disabled) { cbProduct.setDisable(disabled); spQuantity.setDisable(disabled); diff --git a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/sale-view.fxml b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/sale-view.fxml index 947c1d42..c1c0c118 100644 --- a/desktop/src/main/resources/org/example/petshopdesktop/modelviews/sale-view.fxml +++ b/desktop/src/main/resources/org/example/petshopdesktop/modelviews/sale-view.fxml @@ -10,171 +10,185 @@ + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - diff --git a/web/app/ai-chat/page.js b/web/app/ai-chat/page.js new file mode 100644 index 00000000..dc892c74 --- /dev/null +++ b/web/app/ai-chat/page.js @@ -0,0 +1,562 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; + +const API_BASE = ""; + +function AiChatPage() { + const { user, token, loading: authLoading } = useAuth(); + const router = useRouter(); + + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const [switchingToHuman, setSwitchingToHuman] = useState(false); + const [humanRequested, setHumanRequested] = useState(false); + + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (!authLoading && !user) { + router.push("/login?next=" + encodeURIComponent("/ai-chat")); + } + }, [authLoading, user, router]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + const buildHistory = useCallback(() => { + + return messages.map((m) => ({ + role: m.role, + content: m.content, + })); + }, [messages]); + + async function handleSend(e) { + e?.preventDefault(); + const text = input.trim(); + if (!text || sending) return; + + setInput(""); + setError(null); + setSending(true); + + const userMsg = { role: "user", content: text, id: Date.now() }; + setMessages((prev) => [...prev, userMsg]); + + try { + const res = await fetch(`${API_BASE}/api/v1/ai-chat/message`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + message: text, + history: buildHistory(), + }), + }); + + if (res.status === 401 || res.status === 403) { + router.push("/login?next=" + encodeURIComponent("/ai-chat")); + return; + } + + const data = await res.json(); + + if (!res.ok || !data.success) { + setError(data.error || "Failed to get a response. Please try again."); + return; + } + + const aiMsg = { role: "assistant", content: data.message, id: Date.now() + 1 }; + setMessages((prev) => [...prev, aiMsg]); + } catch { + setError("Network error. Please check your connection and try again."); + } finally { + setSending(false); + inputRef.current?.focus(); + } + } + + async function handleHumanChat() { + if (switchingToHuman || !token) return; + setSwitchingToHuman(true); + setError(null); + + try { + const res = await fetch(`${API_BASE}/api/v1/chat/conversations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + message: "Hi, I'd like to speak with a real support agent.", + }), + }); + + if (res.status === 401) { + router.push("/login?next=" + encodeURIComponent("/ai-chat")); + return; + } + + const data = await res.json().catch(() => null); + + if (!res.ok) { + setError(data?.message || "Could not connect to live support. Please try again."); + return; + } + + const conv = data; + + //Flag conversation staff, also should notify them + await fetch(`${API_BASE}/api/v1/chat/conversations/${conv.id}/request-human`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + }); + + setHumanRequested(true); + router.push(`/chat?id=${conv.id}`); + } catch { + setError("Network error. Could not connect to live support."); + } finally { + setSwitchingToHuman(false); + } + } + + function handleKeyDown(e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + } + + if (authLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (!user) return null; + + return ( +
+
+

AI Pet Assistant

+

+ Ask me anything about pet care, adoption advice, or your pets! +

+
+
+ +
+
+
+
+
🐾
+
+
Leon's Pet Assistant
+
+ Online +
+
+
+ +
+ +
+ {messages.length === 0 && ( +
+
🐾
+

+ Hello{user.fullName ? `, ${user.fullName.split(" ")[0]}` : ""}! I'm your pet care assistant. + Ask me about pet recommendations, care tips, supplies, or anything pet-related! +

+
+ )} + + {messages.map((msg) => ( +
+ {msg.role === "assistant" && ( +
🐾
+ )} +
+ {msg.content.split("\n").map((line, i) => ( + + {line} + {i < msg.content.split("\n").length - 1 &&
} +
+ ))} +
+ {msg.role === "user" && ( +
+ {user.fullName ? user.fullName.charAt(0).toUpperCase() : "U"} +
+ )} +
+ ))} + + {sending && ( +
+
🐾
+
+ + + +
+
+ )} + +
+
+ + {error && ( +
+ {error} + +
+ )} + + {humanRequested && ( +
+ Connected to live support! Redirecting to chat... +
+ )} + +
+