From dbe1cd14339873e0a7071c38e8aa3beeacdbcc20 Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 12 May 2026 01:02:12 +0200 Subject: [PATCH 1/2] Add "Clear Chat" feature with backend support and markdown parsing - Implemented a "Clear Chat" button in the UI to reset chat history and personality selection. - Backend enhancements include a `DELETE /api/v1/chat/{sessionId}` endpoint for clearing chat history while preserving the session. - Added markdown rendering for chat messages using Marked.js. - Updated related tests and README documentation to reflect changes. --- README.md | 23 +++++++++---- .../{ => controller}/BifrostController.java | 20 ++++++++--- .../projectbifrost/domain/ChatSession.java | 4 +++ .../projectbifrost/service/ChatService.java | 4 +++ .../storage/ChatSessionStorage.java | 7 ++++ src/main/resources/static/app.js | 33 ++++++++++++++++++- src/main/resources/static/index.html | 2 ++ src/main/resources/static/style.css | 15 +++++++++ .../controller/BifrostControllerTest.java | 14 +++++++- .../service/ChatServiceTest.java | 27 +++++++++++++++ .../storage/ChatSessionStorageTest.java | 21 ++++++++++++ 11 files changed, 157 insertions(+), 13 deletions(-) rename src/main/java/org/example/projectbifrost/{ => controller}/BifrostController.java (54%) diff --git a/README.md b/README.md index 4192e94..7724cd2 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 | --- @@ -115,29 +118,35 @@ 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 --- ## đŸ› ïž Techstack - **Backend**: Spring Boot 4.0.6, Java 25 -- **Frontend**: Vanilla JavaScript, HTML, CSS +- **Frontend**: Vanilla JavaScript, Marked.js (Markdown parsing för snygg formatering av meddelanden), HTML, CSS - **API**: OpenRouter (LLM) - **Resilience**: Resilience4j - **Testning**: JUnit 5, AssertJ, Mockito, WireMock @@ -151,6 +160,8 @@ ChatSessionStorage (In-memory cache) ./mvnw test ``` +Testsvit inkluderar enhetstester för storage, service, och controllerns endpoints + --- ## ⚠ Viktigt diff --git a/src/main/java/org/example/projectbifrost/BifrostController.java b/src/main/java/org/example/projectbifrost/controller/BifrostController.java similarity index 54% rename from src/main/java/org/example/projectbifrost/BifrostController.java rename to src/main/java/org/example/projectbifrost/controller/BifrostController.java index 517a1f6..796a08b 100644 --- a/src/main/java/org/example/projectbifrost/BifrostController.java +++ b/src/main/java/org/example/projectbifrost/controller/BifrostController.java @@ -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 @@ -21,14 +22,23 @@ public BifrostController(ChatService chatService) { @PostMapping("/v1/chat") - public String sendChatRequest(@Valid @RequestBody ChatRequestDTO dto) { + public ResponseEntity 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 getChatHistory(@PathVariable String sessionId) { + ChatSession session = chatService.getSessionHistory(sessionId); + return ResponseEntity.ok(session); + } + + @DeleteMapping("/v1/chat/{sessionId}") + public ResponseEntity clearChatHistory(@PathVariable String sessionId) { + log.info("Clearing chat history for session: {}", maskSessionId(sessionId)); + chatService.clearChatHistory(sessionId); + return ResponseEntity.noContent().build(); } //Mask when logging seesionId diff --git a/src/main/java/org/example/projectbifrost/domain/ChatSession.java b/src/main/java/org/example/projectbifrost/domain/ChatSession.java index be8a9da..6af19bb 100644 --- a/src/main/java/org/example/projectbifrost/domain/ChatSession.java +++ b/src/main/java/org/example/projectbifrost/domain/ChatSession.java @@ -25,4 +25,8 @@ public class ChatSession { public void addMessage(ChatMessage message) { chatHistory.add(message); } + + public void clearChatHistory() { + chatHistory.clear(); // keeps sessionId + } } diff --git a/src/main/java/org/example/projectbifrost/service/ChatService.java b/src/main/java/org/example/projectbifrost/service/ChatService.java index 49523db..6549195 100644 --- a/src/main/java/org/example/projectbifrost/service/ChatService.java +++ b/src/main/java/org/example/projectbifrost/service/ChatService.java @@ -138,4 +138,8 @@ public ChatSession getSessionHistory(String sessionId) { return chatSessionStorage.getOrCreateChatSession(sessionId); } + public void clearChatHistory(String sessionId) { + chatSessionStorage.clearSessionHistory(sessionId); + } + } diff --git a/src/main/java/org/example/projectbifrost/storage/ChatSessionStorage.java b/src/main/java/org/example/projectbifrost/storage/ChatSessionStorage.java index 65fedf8..ed0051c 100644 --- a/src/main/java/org/example/projectbifrost/storage/ChatSessionStorage.java +++ b/src/main/java/org/example/projectbifrost/storage/ChatSessionStorage.java @@ -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 diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index cbbaf5a..d23d3d4 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -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') @@ -97,6 +98,32 @@ async function sendMessage() { } } +async function clearChat() { + 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); + // ── 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 @@ -109,7 +136,11 @@ 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'; + // Tolka markdown till HTML + contentDiv.innerHTML = window.marked.parse(text); + msgDiv.appendChild(contentDiv); } else { msgDiv.textContent = text; } diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 14d2374..32a5d00 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -5,6 +5,7 @@ Bifrost AI | Mythological Gateway + @@ -59,6 +60,7 @@

Asgard AI

+
diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css index aca6497..bc74496 100644 --- a/src/main/resources/static/style.css +++ b/src/main/resources/static/style.css @@ -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 { diff --git a/src/test/java/org/example/projectbifrost/controller/BifrostControllerTest.java b/src/test/java/org/example/projectbifrost/controller/BifrostControllerTest.java index 8dfe58e..65c4bf3 100644 --- a/src/test/java/org/example/projectbifrost/controller/BifrostControllerTest.java +++ b/src/test/java/org/example/projectbifrost/controller/BifrostControllerTest.java @@ -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; @@ -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; @@ -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); + } } diff --git a/src/test/java/org/example/projectbifrost/service/ChatServiceTest.java b/src/test/java/org/example/projectbifrost/service/ChatServiceTest.java index 5547dbf..38a700f 100644 --- a/src/test/java/org/example/projectbifrost/service/ChatServiceTest.java +++ b/src/test/java/org/example/projectbifrost/service/ChatServiceTest.java @@ -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); + } } diff --git a/src/test/java/org/example/projectbifrost/storage/ChatSessionStorageTest.java b/src/test/java/org/example/projectbifrost/storage/ChatSessionStorageTest.java index e13e64f..39c57d9 100644 --- a/src/test/java/org/example/projectbifrost/storage/ChatSessionStorageTest.java +++ b/src/test/java/org/example/projectbifrost/storage/ChatSessionStorageTest.java @@ -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; @@ -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); + } } From 2f44b2952dde4706d6e82138e92a1d484d2d180a Mon Sep 17 00:00:00 2001 From: Caroline Nordbrandt Date: Tue, 12 May 2026 01:50:39 +0200 Subject: [PATCH 2/2] Add XSS protection and enhance UI interactivity - Integrated DOMPurify to sanitize AI-generated markdown and prevent XSS attacks. - Enhanced UI by disabling "Clear Chat" button during active responses to avoid race conditions. - Updated README to document added XSS protection. --- README.md | 3 ++- src/main/resources/static/app.js | 14 ++++++++++++-- src/main/resources/static/index.html | 6 +++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7724cd2..76a9064 100644 --- a/README.md +++ b/README.md @@ -140,13 +140,14 @@ ChatSessionStorage (In-memory cache) - 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, Marked.js (Markdown parsing för snygg formatering av meddelanden), 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 diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index d23d3d4..d579926 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -99,6 +99,7 @@ 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 { @@ -138,8 +139,10 @@ function appendMessage(role, name, text) { msgDiv.appendChild(label); const contentDiv = document.createElement('div'); contentDiv.className = 'markdown-body'; - // Tolka markdown till HTML - contentDiv.innerHTML = window.marked.parse(text); + //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); } else { msgDiv.textContent = text; @@ -153,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) { diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 32a5d00..77dd586 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -5,7 +5,11 @@ Bifrost AI | Mythological Gateway - + + + +