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') {