Web and AI chat
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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<AiChatResponse> 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<Pet> 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."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public class ChatController {
|
||||
}
|
||||
|
||||
@PostMapping("/conversations")
|
||||
@PreAuthorize("hasRole('CUSTOMER')")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<ConversationResponse> createConversation(@Valid @RequestBody ConversationRequest request) {
|
||||
User user = getCurrentUser();
|
||||
ConversationResponse response = chatService.createConversation(user.getId(), request);
|
||||
@@ -139,7 +139,7 @@ public class ChatController {
|
||||
}
|
||||
|
||||
@PostMapping("/conversations/{id}/request-human")
|
||||
@PreAuthorize("hasRole('CUSTOMER')")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<ConversationResponse> requestHumanTakeover(@PathVariable Long id) {
|
||||
User user = getCurrentUser();
|
||||
ConversationResponse conversation = chatService.requestHumanTakeover(id, user.getId(), user.getRole());
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.petshop.backend.dto.ai;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class AiChatRequest {
|
||||
|
||||
private String message;
|
||||
private List<ChatHistoryItem> history;
|
||||
|
||||
public AiChatRequest() {
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public List<ChatHistoryItem> getHistory() {
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
public void setHistory(List<ChatHistoryItem> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,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);
|
||||
@@ -210,7 +206,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");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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<AiChatRequest.ChatHistoryItem> history, String userMessage, List<Pet> 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)
|
||||
.header("HTTP-Referer", "https://petshop.local")
|
||||
.header("X-Title", "Leon's Pet Store AI Assistant")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> 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 (RuntimeException e) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to call OpenRouter API: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSystemPrompt(List<Pet> 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<AiChatRequest.ChatHistoryItem> 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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user