Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
19 changes: 14 additions & 5 deletions src/main/java/org/example/projectbifrost/BifrostController.java
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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

{
}
Original file line number Diff line number Diff line change
@@ -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<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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<ApiErrorResponse> 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)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
log.error("Unexpected error: ", ex);
return new ResponseEntity<>(
new ApiErrorResponse(LocalDateTime.now(), 500, "An unexpected error occurred"),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
14 changes: 12 additions & 2 deletions src/main/java/org/example/projectbifrost/service/ChatService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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());
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.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();

Expand Down
40 changes: 30 additions & 10 deletions src/main/resources/static/app.js
Original file line number Diff line number Diff line change
@@ -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'),
Expand All @@ -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; //
});
});

Expand All @@ -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;
Expand All @@ -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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── 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') {
Expand Down