From 50150b22b7765c48e4144f1809d61a8dc63dcd08 Mon Sep 17 00:00:00 2001 From: augmentedpotato Date: Fri, 10 Apr 2026 07:34:53 -0600 Subject: [PATCH] Web and AI chat --- backend/docker-compose.yml | 2 + .../backend/controller/AiChatController.java | 84 ++ .../backend/controller/ChatController.java | 4 +- .../petshop/backend/dto/ai/AiChatRequest.java | 61 ++ .../backend/dto/ai/AiChatResponse.java | 54 ++ .../petshop/backend/service/ChatService.java | 6 +- .../backend/service/OpenRouterService.java | 144 ++++ backend/src/main/resources/application.yml | 7 + web/app/ai-chat/page.js | 562 +++++++++++++ web/app/chat/page.js | 770 ++++++++++++++++++ web/app/globals.css | 3 + web/components/Navigation.js | 1 + 12 files changed, 1691 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/java/com/petshop/backend/controller/AiChatController.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/ai/AiChatRequest.java create mode 100644 backend/src/main/java/com/petshop/backend/dto/ai/AiChatResponse.java create mode 100644 backend/src/main/java/com/petshop/backend/service/OpenRouterService.java create mode 100644 web/app/ai-chat/page.js create mode 100644 web/app/chat/page.js 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..0cd88df3 100644 --- a/backend/src/main/java/com/petshop/backend/controller/ChatController.java +++ b/backend/src/main/java/com/petshop/backend/controller/ChatController.java @@ -55,7 +55,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); @@ -139,7 +139,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/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/service/ChatService.java b/backend/src/main/java/com/petshop/backend/service/ChatService.java index 41d2dfd6..e17f550c 100644 --- a/backend/src/main/java/com/petshop/backend/service/ChatService.java +++ b/backend/src/main/java/com/petshop/backend/service/ChatService.java @@ -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"); } 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..e0fee426 --- /dev/null +++ b/backend/src/main/java/com/petshop/backend/service/OpenRouterService.java @@ -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 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) + .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 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 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..1c9a800e 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} 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... +
+ )} + +
+