diff --git a/pom.xml b/pom.xml index c589688..2dd870a 100644 --- a/pom.xml +++ b/pom.xml @@ -91,6 +91,10 @@ testcontainers-junit-jupiter test + + org.springframework.boot + spring-boot-starter-validation + diff --git a/src/main/java/org/example/projectbifrost/BifrostController.java b/src/main/java/org/example/projectbifrost/BifrostController.java index f6ac043..b643380 100644 --- a/src/main/java/org/example/projectbifrost/BifrostController.java +++ b/src/main/java/org/example/projectbifrost/BifrostController.java @@ -1,16 +1,18 @@ package org.example.projectbifrost; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; import org.example.projectbifrost.domain.ChatSession; import org.example.projectbifrost.dto.ChatRequestDTO; import org.example.projectbifrost.service.ChatService; import org.slf4j.Logger; import org.springframework.web.bind.annotation.*; +@Slf4j @RestController @RequestMapping("/api") public class BifrostController { - private static final Logger logger = org.slf4j.LoggerFactory.getLogger(BifrostController.class); private final ChatService chatService; @@ -20,16 +22,23 @@ public BifrostController(ChatService chatService) { @GetMapping("/bifrost") public String bifrost() { - return "Welcome to Bifrost, the gateway to the realms!"; } + return "Welcome to Bifrost, the gateway to the realms!"; + } @PostMapping("/v1/chat") - public String sendChatRequest(@RequestBody ChatRequestDTO dto) { - logger.info("Received chat request: Personality={}, Message={}, SessionId={}", dto.personality(), dto.message(), dto.sessionId()); - return chatService.sendRequestToLLM(dto); + public String sendChatRequest(@Valid @RequestBody ChatRequestDTO dto) { + log.info("Received chat request: Personality={}, SessionId={}", dto.personality(), maskSessionId(dto.sessionId())); + return chatService.sendRequestToLLM(dto); } @GetMapping("/v1/chat/{sessionId}") public ChatSession getChatHistory(@PathVariable String sessionId) { return chatService.getSessionHistory(sessionId); } + + //Mask when logging seesionId + private String maskSessionId(String sessionId) { + if (sessionId == null || sessionId.length() < 6) return "***"; + return "***" + sessionId.substring(sessionId.length() - 4); + } } diff --git a/src/main/java/org/example/projectbifrost/exception/ApiErrorResponse.java b/src/main/java/org/example/projectbifrost/exception/ApiErrorResponse.java new file mode 100644 index 0000000..75a28db --- /dev/null +++ b/src/main/java/org/example/projectbifrost/exception/ApiErrorResponse.java @@ -0,0 +1,10 @@ +package org.example.projectbifrost.exception; + +import java.time.LocalDateTime; + +public record ApiErrorResponse(LocalDateTime timestamp, + int status, + String message) // ← Detailed message for error + +{ +} diff --git a/src/main/java/org/example/projectbifrost/exception/GlobalExceptionHandler.java b/src/main/java/org/example/projectbifrost/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c762af0 --- /dev/null +++ b/src/main/java/org/example/projectbifrost/exception/GlobalExceptionHandler.java @@ -0,0 +1,81 @@ +package org.example.projectbifrost.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.ErrorResponse; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.time.LocalDateTime; + +@ControllerAdvice +@Slf4j //Structured logging +public class GlobalExceptionHandler { + + @ExceptionHandler(LLMException.class) + public ResponseEntity handleLLMException(LLMException ex) { + HttpStatus status; + try { + status = HttpStatus.valueOf(ex.getStatusCode()); //Get the error from OpenRouter + } catch (IllegalArgumentException e) { + status = HttpStatus.INTERNAL_SERVER_ERROR; + } + + log.warn("LLM Error [{}]: {}", status.value(), ex.getMessage()); + + return new ResponseEntity<>( + new ApiErrorResponse(LocalDateTime.now(), status.value(), ex.getMessage()), + status + ); + } + + @ExceptionHandler(ResourceAccessException.class) + public ResponseEntity handleTimeoutException(ResourceAccessException ex) { + log.warn("Timeout connecting to LLM: {}", ex.getMessage()); + return new ResponseEntity<>( + new ApiErrorResponse(LocalDateTime.now(), 504, "Connection timeout. The Gods are taking too long to respond."), + HttpStatus.GATEWAY_TIMEOUT + ); + } + + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + log.warn("Validation error - {} field(s) invalid", ex.getBindingResult().getErrorCount()); + return new ResponseEntity<>( + new ApiErrorResponse(LocalDateTime.now(), 400, "Invalid request - check your input"), + HttpStatus.BAD_REQUEST + ); + } + + @ExceptionHandler(org.springframework.http.converter.HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadableMessage(org.springframework.http.converter.HttpMessageNotReadableException ex) { + log.warn("Malformed request body: {}", ex.getMostSpecificCause().getMessage()); + return new ResponseEntity<>( + new ApiErrorResponse(LocalDateTime.now(), HttpStatus.BAD_REQUEST.value(), "Malformed request body"), + HttpStatus.BAD_REQUEST + ); + } + + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneralException(Exception ex) { + //If Spring has something to say about an error(e.g 404), use it! + if (ex instanceof ErrorResponse er) { + var status = er.getStatusCode(); + String message = ex.getMessage() != null ? ex.getMessage() : "Request failed"; + return ResponseEntity.status(status).body( + new ApiErrorResponse(LocalDateTime.now(), status.value(), message) + ); + } + log.error("Unexpected error: ", ex); + return new ResponseEntity<>( + new ApiErrorResponse(LocalDateTime.now(), 500, "An unexpected error occurred"), + HttpStatus.INTERNAL_SERVER_ERROR + ); + } +} diff --git a/src/main/java/org/example/projectbifrost/exception/LLMException.java b/src/main/java/org/example/projectbifrost/exception/LLMException.java new file mode 100644 index 0000000..03aeee6 --- /dev/null +++ b/src/main/java/org/example/projectbifrost/exception/LLMException.java @@ -0,0 +1,15 @@ +package org.example.projectbifrost.exception; + +import lombok.Getter; + +@Getter +public class LLMException extends RuntimeException { + private final int statusCode; + private final String model; + + public LLMException(String message, String model, int statusCode) { + super(message); + this.statusCode = statusCode; + this.model = model; + } +} diff --git a/src/main/java/org/example/projectbifrost/service/ChatService.java b/src/main/java/org/example/projectbifrost/service/ChatService.java index e9ddd4d..a2236e8 100644 --- a/src/main/java/org/example/projectbifrost/service/ChatService.java +++ b/src/main/java/org/example/projectbifrost/service/ChatService.java @@ -5,8 +5,11 @@ import org.example.projectbifrost.dto.ChatRequestDTO; import org.example.projectbifrost.dto.OpenRouterRequestDTO; import org.example.projectbifrost.dto.OpenRouterResponseDTO; +import org.example.projectbifrost.exception.LLMException; import org.example.projectbifrost.storage.ChatSessionStorage; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; @@ -51,12 +54,19 @@ public String sendRequestToLLM(ChatRequestDTO dto) { OpenRouterResponseDTO result = restClient.post() .uri("/chat/completions")//Start of URI configured in RestClientConfiguration.java - .body(openRouterRequest) //Send JSON-body of messages and model + .body(openRouterRequest) //Send JSON body of messages and model .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + throw new LLMException("The Gods are silent", model, response.getStatusCode().value()); + }) .body(OpenRouterResponseDTO.class); if (result == null || result.choices() == null || result.choices().isEmpty()) { - throw new RuntimeException("Received empty or invalid response from LLM"); + throw new LLMException( + "The Gods sent an empty omen (Invalid response from LLM)", + model, + HttpStatus.BAD_GATEWAY.value() + ); } String content = result.choices().getFirst().message().content(); diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index ec75622..71fb9b4 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -1,8 +1,9 @@ const chatState = { sessionId: crypto.randomUUID(), - isWaiting: false + isWaiting: false //Flag for waiting for response, to prevent multiple sends }; +//Save all HTML-elements to reduce boilerplate const dom = { personality: document.getElementById('personality-select'), input: document.getElementById('user-input'), @@ -16,9 +17,11 @@ const dom = { dom.chips.forEach(chip => { chip.addEventListener('click', () => { + //Remove "active" from all chips dom.chips.forEach(c => c.classList.remove('active')); + //Add "active" to the chip klicked on chip.classList.add('active'); - dom.personality.value = chip.dataset.god; + dom.personality.value = chip.dataset.god; // }); }); @@ -38,9 +41,10 @@ async function sendMessage() { appendMessage('user', null, message); dom.input.value = ''; - setLoading(true); + setLoading(true); //Show text from HTML in waiting for response try { + //AbortController, set a timer for 15 sek while waiting for response const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); let response; @@ -52,27 +56,43 @@ async function sendMessage() { body: JSON.stringify({ personality: selectedPersonality, message: message, - sessionId: chatState.sessionId + sessionId: chatState.sessionId //Randomized id }), signal: controller.signal }); } finally { - clearTimeout(timeoutId); + clearTimeout(timeoutId); //If a response was given, turn of timer } - if (!response.ok) throw new Error("Divine connection lost."); + if (!response.ok) { + let errorMessage = 'The Gods are Silent'; + try { + const errorData = await response.json(); //Read JSON-error from @ControllerAdvice + errorMessage = errorData.message || errorMessage; + } catch { + errorMessage = 'Divine connection lost'; + } + throw new Error(errorMessage); + } - const aiText = await response.text(); + const aiText = await response.text(); //If ok, read response as text from ai appendMessage('assistant', godName, aiText); } catch (error) { - appendMessage('assistant', 'System', "Error: " + error.message); + //Themed timeout message + const errorMsg = error.name === 'AbortError' + ? 'The Gods took too long to respond...' + : error.message; + appendMessage('assistant', 'System', errorMsg); } finally { - setLoading(false); + setLoading(false); //Hide loading indicator weather success or not } } +// ── UI helpers ──────────────────────────────────────────────────────── + +//Display and format messages in chat window, with different styling for user and assistant. Also scrolls to bottom when new message is added function appendMessage(role, name, text) { - const msgDiv = document.createElement('div'); + const msgDiv = document.createElement('div');//Create a new HTML element in memory msgDiv.className = `message ${role}`; if (role === 'assistant') {