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
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# 🌉 Asgard AI - Speak to the Gods
Välkommen du modige äventyrare!
ProjectBifrost(Asgard AI) är en modern chatt-applikation där du kan kommunicera med fem olika nordiska gudar, var och en med sin egen unika personlighet. Systemet använder avancerad prompt-engineering och AI via OpenRouter för att ge autentiska svar.
## 🎯 Features
## 🎯 The Powers of Asgard

- 🗣️ **Fem gudomliga personligheter**: Odin, Loki, Thor, Freyja, och Heimdall - var och en med unika svarsstilar och personlighetsdrag (Prompt Engineering)
- 🗣️ **Fem gudomliga personligheter**: Odin, Loki, Thor, Freyja, och Heimdall, var och en med unika svarsstilar och personlighetsdrag (Prompt Engineering)
- ✉️ **Skicka meddelande**: Skriv ett meddelande och klicka "Send" eller tryck Enter. Systemet skickar en unik session ID, ditt meddelande, och den valda gudens personlighet till backend.
- 💬 **Chatthistorik**: Dina samtal sparas server-side och hämtas automatiskt vid sidomladdning via ditt sessions ID. Persistent chat-historik per session.
- 🗑️ **Clear Chat**: Rensa all chatthistorik med en knapp, återställer personlighetsval och visar bekräftelseruta
- 🛡️ **Resilience**: Circuit Breaker + Retry-logik för robust API-hantering
- ♿ **Accessible**: Nordisk-inspirerad design med responsiv layout och gud-chips för snabba val.
- ⚡ **Real-time Feedback**: Visar när gudarna "tänker" (typing indicator) och hanterar timeouts snyggt.
Expand Down Expand Up @@ -62,7 +64,7 @@ OPENROUTER_API_KEY=sk-ditt-hemliga-nyckel-här

### 3.5️⃣ (Valfritt) Välj en annan LLM-modell

Standard-modellen är `poolside/laguna-xs.2:free` (gratis). Du kan byta till en annan modell genom att redigera `src/main/resources/application.properties`:
Standard-modellen är `poolside/laguna-xs.2:free` (gratis). Du kan byta till en annan modell genom att redigera `src/main/resources/application.properties` till exempelvis:

```properties
openrouter.model=gpt-4o-mini
Expand Down Expand Up @@ -101,6 +103,7 @@ Där kan du se:
|-------|----------|-------------|
| POST | `/api/v1/chat` | Skicka meddelande till gud |
| GET | `/api/v1/chat/{sessionId}` | Hämta chat-historik |
| DELETE | `/api/v1/chat/{sessionId}` | Rensa chat-historik |

---

Expand All @@ -115,29 +118,36 @@ RestClient (OpenRouter API)
ChatSessionStorage (In-memory cache)
```
## 🗂️ Backend

- **Personality Enum**: Varje gud har en unik system prompt som injiceras tillsammans med meddelandena för att ge rätt personlighet
- **ChatSessionStorage**: In-memory cache med ConcurrentHashMap som sparar alla sessioner och deras meddelandehistorik
- **GlobalExceptionHandler**: Centraliserad felhantering med `@ControllerAdvice` som översätter exceptions till tematiska felmeddelanden (t.ex. "The Gods are silent" för service unavailable). Hanterar validering, timeouts, LLM-fel och malformad JSON
- **Message Flow**: POST `/api/v1/chat` validera → hämta/skapa session → bygg prompt-lista [system prompt + historik + nytt meddelande] → kalla LLM via OpenRouter → spara svar i session → returnera till frontend

**Resilience:**
- 🔄 **Retry**: Max 3 försök med exponential backoff
- 🚫 **Circuit Breaker**: Öppnas efter 50% fel-rate på 10 samtal

---

## 🧠 Frontend (JavaScript)

- 🪟 **Din väg över Bifrost**: Session ID genereras automatiskt via `crypto.randomUUID()` och sparas i `localStorage.bifrost_session_id`, så länge du inte tömmer webbläsarens cache
- ⚓ **Heimdall Vaktar**: Default personlighet sätts i HTML och JS (`dom.personality.value = 'HEIMDALL'`)
- ✉️ **Skicka meddelande**: JavaScript använder `fetch()` POST till `/api/v1/chat` med AbortController för 15-sekunders timeout. Vid error visas tematiskt meddelande från systemet (t.ex. "The Gods took too long to respond...")
- 📜 **Runor från Mnemosyne - Minnets gudinna**:
- Alla sessioner med meddelanden sparas **lokalt** i `ChatSessionStorage` (in-memory)
- Vid sidladdning hämtar JavaScript automatiskt samtida historik via `GET /api/v1/chat/{sessionId}`
- Om du laddar om sidan → samma session ID → all historik visas igen
- Sessions försvinner endast när Java-servern omstartas (in-memory)
- 🗑️ **Clear-knappen**: Rensar chatthistorik för sessionen via `DELETE /api/v1/chat/{sessionId}`, återställer personlighetsvalet till Heimdall, och visar bekräftelseruta före borttagning
- 🛡️ **XSS Protection**: All AI-genererad Markdown tvättas via DOMPurify innan rendering för att förhindra skadlig kodinjektion.

---

## 🛠️ Techstack

- **Backend**: Spring Boot 4.0.6, Java 25
- **Frontend**: Vanilla JavaScript, HTML, CSS
- **Frontend**: Vanilla JavaScript, Marked.js (Markdown parsing för snabb & snygg formatering av meddelanden), HTML, CSS
- **API**: OpenRouter (LLM)
- **Resilience**: Resilience4j
- **Testning**: JUnit 5, AssertJ, Mockito, WireMock
Expand All @@ -151,6 +161,8 @@ ChatSessionStorage (In-memory cache)
./mvnw test
```

Testsvit inkluderar enhetstester för storage, service, och controllerns endpoints

---

## ⚠️ Viktigt
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package org.example.projectbifrost;
package org.example.projectbifrost.controller;

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.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Slf4j
Expand All @@ -21,14 +22,23 @@ public BifrostController(ChatService chatService) {


@PostMapping("/v1/chat")
public String sendChatRequest(@Valid @RequestBody ChatRequestDTO dto) {
public ResponseEntity<String> sendChatRequest(@Valid @RequestBody ChatRequestDTO dto) {
log.info("Received chat request: Personality={}, SessionId={}", dto.personality(), maskSessionId(dto.sessionId()));
return chatService.chatWithLLM(dto);
String response = chatService.chatWithLLM(dto);
return ResponseEntity.ok(response);
}

@GetMapping("/v1/chat/{sessionId}")
public ChatSession getChatHistory(@PathVariable String sessionId) {
return chatService.getSessionHistory(sessionId);
public ResponseEntity<ChatSession> getChatHistory(@PathVariable String sessionId) {
ChatSession session = chatService.getSessionHistory(sessionId);
return ResponseEntity.ok(session);
}

@DeleteMapping("/v1/chat/{sessionId}")
public ResponseEntity<Void> clearChatHistory(@PathVariable String sessionId) {
log.info("Clearing chat history for session: {}", maskSessionId(sessionId));
chatService.clearChatHistory(sessionId);
return ResponseEntity.noContent().build();
}

//Mask when logging seesionId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@ public class ChatSession {
public void addMessage(ChatMessage message) {
chatHistory.add(message);
}

public void clearChatHistory() {
chatHistory.clear(); // keeps sessionId
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,8 @@ public ChatSession getSessionHistory(String sessionId) {
return chatSessionStorage.getOrCreateChatSession(sessionId);
}

public void clearChatHistory(String sessionId) {
chatSessionStorage.clearSessionHistory(sessionId);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ public ChatSession getOrCreateChatSession(String sessionId) {
);
}

public void clearSessionHistory(String sessionId) {
ChatSession session = sessionStorage.get(sessionId);
if (session != null) {
session.clearChatHistory();
}
}

/**
* Deletes a session by its ID.
* * Note: For production, an automatic cleanup (like a Cache with
Expand Down
43 changes: 42 additions & 1 deletion src/main/resources/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const dom = {
personality: document.getElementById('personality-select'),
input: document.getElementById('user-input'),
button: document.getElementById('send-button'),
clearBtn: document.getElementById('clear-button'),
window: document.getElementById('chat-window'),
typing: document.getElementById('typing-indicator'),
chips: document.querySelectorAll('.god-chip')
Expand Down Expand Up @@ -97,6 +98,33 @@ async function sendMessage() {
}
}

async function clearChat() {
if (chatState.isWaiting) return; //Block clearing if waiting for response, race conditions could occur
if (!confirm("Are you sure you want to clear your chat history?")) return;

try {
const response = await fetch(`/api/v1/chat/${chatState.sessionId}`, {
method: 'DELETE'
});

if (response.ok) {
dom.window.innerHTML = '';

//Reset to default chip selection
dom.chips.forEach(c => c.classList.remove('active'));
const defaultChip = document.querySelector('.god-chip[data-god="HEIMDALL"]');
if (defaultChip) defaultChip.classList.add('active');
dom.personality.value = 'HEIMDALL';
appendMessage('assistant', 'Heimdall', 'The runes are cast anew. The gods have forgotten your past whispers. A clean slate at the foot of Yggdrasil. Pick which god shall lead your path this time?');
}
} catch (err) {
console.error("Could not clear chat history:", err);
}
}

// Koppla knappen till funktionen
dom.clearBtn.addEventListener('click', clearChat);
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
Expand All @@ -109,7 +137,13 @@ function appendMessage(role, name, text) {
label.className = 'god-label';
label.textContent = name;
msgDiv.appendChild(label);
msgDiv.appendChild(document.createTextNode(text));
const contentDiv = document.createElement('div');
contentDiv.className = 'markdown-body';
//Render markdown to HTML
const rawHtml = window.marked.parse(text);
//Sanitize the HTML to prevent XSS attacks, then set it as content of the message
contentDiv.innerHTML = DOMPurify.sanitize(rawHtml);
msgDiv.appendChild(contentDiv);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
msgDiv.textContent = text;
}
Expand All @@ -122,6 +156,13 @@ function setLoading(active) {
chatState.isWaiting = active;
dom.typing.classList.toggle('hidden', !active);
dom.button.disabled = active;

//Deactivate clear button while waiting for response, to prevent race conditions
if (dom.clearBtn) {
dom.clearBtn.disabled = active;
dom.clearBtn.style.opacity = active ? "0.5" : "1"; // Valfritt: gör den lite genomskinlig
dom.clearBtn.style.cursor = active ? "not-allowed" : "pointer";
}
}

function getGodName(value) {
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bifrost AI | Mythological Gateway</title>
<link rel="stylesheet" href="style.css">

<!-- SRI (Subresource Integrity): Hash verification protects against CDN tampering.
If the script is modified, the hash won't match and it won't load. -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" integrity="sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js" integrity="sha384-cwS6YdhLI7XS60eoDiC+egV0qHp8zI+Cms46R0nbn8JrmoAzV9uFL60etMZhAnSu" crossorigin="anonymous"></script>
</head>
<body>

Expand Down Expand Up @@ -59,6 +64,7 @@ <h1>Asgard AI</h1>
<section class="input-area">
<textarea id="user-input" placeholder="Type your message to the Gods..." rows="1"></textarea>
<button id="send-button">Send ᚱ</button>
<button id="clear-button" title="Clear chat history">🗑️</button>
</section>
</main>

Expand Down
15 changes: 15 additions & 0 deletions src/main/resources/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,21 @@ button:disabled {
transform: none;
}

#clear-button {
padding: 0.75rem 1rem;
font-size: 1.1rem;
background: #1a1000;
border: 1px solid #c8973a66;
color: #c8973a88;
transition: all 0.2s;
}

#clear-button:hover {
background: #2a1a00;
border-color: var(--runic-gold);
color: var(--runic-gold);
}

/* ── Footer ── */

.rune-footer {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.example.projectbifrost.controller;

import org.example.projectbifrost.BifrostController;
import org.example.projectbifrost.dto.ChatRequestDTO;
import org.example.projectbifrost.service.ChatService;
import org.junit.jupiter.api.DisplayName;
Expand All @@ -12,7 +11,9 @@
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
Expand Down Expand Up @@ -63,4 +64,15 @@ void shouldReturn200ForGetHistory() throws Exception {
mockMvc.perform(get("/api/v1/chat/session123"))
.andExpect(status().isOk());
}

@Test
@DisplayName("Should return 204 No Content when clearing chat history")
void shouldReturn204ForClearHistory() throws Exception {
String sessionId = "session123";

mockMvc.perform(delete("/api/v1/chat/" + sessionId))
.andExpect(status().isNoContent());

verify(chatService).clearChatHistory(sessionId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,31 @@ void testCircuitBreakerLogic() throws InterruptedException {
.as("Circuit breaker should have recovered and call should succeed")
.isEqualTo("Success after recovery!");
}

@Test
@DisplayName("Should clear chat history for a session")
void testClearChatHistory() {
String sessionId = "history-clear-session";
String llmResponse = "Clear me!";

stubFor(post(urlEqualTo("/chat/completions"))
.willReturn(aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"choices\": [{\"message\": {\"content\": \"" + llmResponse + "\"}}]}")));

// Add messages to session
chatService.chatWithLLM(
new ChatRequestDTO(Personality.ODIN, "First message", sessionId)
);

ChatSession session = chatService.getSessionHistory(sessionId);
assertThat(session.getChatHistory()).hasSize(2);

// Clear the history
chatService.clearChatHistory(sessionId);

// Verify history is empty but session still exists
assertThat(session.getChatHistory()).isEmpty();
assertThat(session.getSessionId()).isEqualTo(sessionId);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.example.projectbifrost.storage;

import org.example.projectbifrost.domain.ChatMessage;
import org.example.projectbifrost.domain.ChatSession;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -56,4 +57,24 @@ void shouldDeleteSession() {

assertThat(newSession).isNotSameAs(oldSession);
}

@Test
@DisplayName("Should clear chat history while keeping the session")
void shouldClearSessionHistory() {
String sessionId = "history-session";
ChatSession session = storage.getOrCreateChatSession(sessionId);

// Add some messages to the history
session.addMessage(new ChatMessage("user", "Hello", "you"));
session.addMessage(new ChatMessage("assistant", "Hi there!", "LOKI"));

assertThat(session.getChatHistory()).hasSize(2);

// Clear the history
storage.clearSessionHistory(sessionId);

// History should be empty but session should still exist
assertThat(session.getChatHistory()).isEmpty();
assertThat(session.getSessionId()).isEqualTo(sessionId);
}
}
Loading