diff --git a/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java b/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java index 451bb83..c4d19b6 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java +++ b/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java @@ -116,12 +116,8 @@ public class MessagingConfig { // Send success response to the now-authenticated session // locationTrackingEnabled: true = client should send position updates // appUserId: wird an den Client gesendet für Referenz - Map authResponse = Map.of( - "success", true, - "message", response.getMessage(), - "locationTrackingEnabled", true, - "appUserId", appUserId - ); + Map authResponse = Map.of("success", true, "message", response.getMessage(), + "locationTrackingEnabled", true, "appUserId", appUserId); byte[] responseBytes = objectMapper.writeValueAsBytes(authResponse); log.info("[Messaging] Sending auth response to appUserId: {}", appUserId); webSocketService.sendToClient(appUserId, "auth", responseBytes); diff --git a/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java b/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java index b2501f0..cb429ef 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java +++ b/src/main/java/de/assecutor/votianlt/messaging/MessagingPublisher.java @@ -1,10 +1,20 @@ package de.assecutor.votianlt.messaging; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import de.assecutor.votianlt.dto.JobWithRelatedDataDTO; +import de.assecutor.votianlt.model.CargoItem; +import de.assecutor.votianlt.model.task.BaseTask; +import de.assecutor.votianlt.model.task.ConfirmationTask; +import de.assecutor.votianlt.service.TranslationService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; +import java.util.List; + +// Force recompile /** * Publishing helper to send JSON payloads to clients via WebSocket. @@ -19,10 +29,13 @@ class MessagingPublisherImpl implements MessagingPublisher { private final WebSocketService webSocketService; private final ObjectMapper objectMapper; + private final TranslationService translationService; - public MessagingPublisherImpl(WebSocketService webSocketService, ObjectMapper objectMapper) { + public MessagingPublisherImpl(WebSocketService webSocketService, ObjectMapper objectMapper, + TranslationService translationService) { this.webSocketService = webSocketService; this.objectMapper = objectMapper; + this.translationService = translationService; } @Override @@ -37,20 +50,133 @@ class MessagingPublisherImpl implements MessagingPublisher { return; } - String json = objectMapper.writeValueAsString(payload); + // Verarbeite Payload und füge Übersetzungen hinzu wenn nötig + Object processedPayload = processPayloadWithTranslations(payload); + + String json = objectMapper.writeValueAsString(processedPayload); byte[] data = json.getBytes(StandardCharsets.UTF_8); - webSocketService.sendToClient(clientId, messageType, data) - .thenRun(() -> { - log.debug("[Messaging] Successfully sent {}/{} to client {}", messageType, clientId); - }) - .exceptionally(ex -> { - log.error("[Messaging] Failed to deliver to {}/{}: {}", clientId, messageType, ex.getMessage(), ex); - return null; - }); + webSocketService.sendToClient(clientId, messageType, data).thenRun(() -> { + log.debug("[Messaging] Successfully sent {}/{} to client {}", messageType, clientId); + }).exceptionally(ex -> { + log.error("[Messaging] Failed to deliver to {}/{}: {}", clientId, messageType, ex.getMessage(), ex); + return null; + }); } catch (Exception e) { log.error("[Messaging] Failed to publish to {}/{}: {}", clientId, messageType, e.getMessage(), e); } } -} + + /** + * Processes the payload and adds translations for job remarks, task + * descriptions/texts, and cargo item descriptions. + */ + private Object processPayloadWithTranslations(Object payload) { + try { + // Handle single JobWithRelatedDataDTO + if (payload instanceof JobWithRelatedDataDTO dto) { + return convertToTranslatedJson(dto); + } + + // Handle list of JobWithRelatedDataDTO + if (payload instanceof List list && !list.isEmpty() && list.get(0) instanceof JobWithRelatedDataDTO) { + @SuppressWarnings("unchecked") + List dtoList = (List) list; + return dtoList.stream().map(this::convertToTranslatedJson).toList(); + } + + return payload; + } catch (Exception e) { + log.warn("[Messaging] Failed to process translations: {}", e.getMessage()); + return payload; + } + } + + /** + * Converts JobWithRelatedDataDTO to JSON with translated fields. + */ + private ObjectNode convertToTranslatedJson(JobWithRelatedDataDTO dto) { + // Convert to JSON tree + ObjectNode root = objectMapper.valueToTree(dto); + + // Translate job remark + if (dto.getJob() != null && dto.getJob().getRemark() != null && !dto.getJob().getRemark().isBlank()) { + List translations = translationService + .translateToAllLanguages(dto.getJob().getRemark()); + root.withObject("job").set("remark", createTranslationArray(translations)); + } + + // Translate task descriptions, displayNames and button texts + if (dto.getTasks() != null && !dto.getTasks().isEmpty()) { + ArrayNode tasksNode = root.withArray("tasks"); + for (int i = 0; i < dto.getTasks().size(); i++) { + BaseTask task = dto.getTasks().get(i); + ObjectNode taskNode = (ObjectNode) tasksNode.get(i); + + // Translate description + if (task.getDescription() != null && !task.getDescription().isBlank()) { + List translations = translationService + .translateToAllLanguages(task.getDescription()); + taskNode.set("description", createTranslationArray(translations)); + } + + // Translate displayName + if (task.getDisplayName() != null && !task.getDisplayName().isBlank()) { + List translations = translationService + .translateToAllLanguages(task.getDisplayName()); + taskNode.set("displayName", createTranslationArray(translations)); + } + + // Translate buttonText in taskSpecificData for ConfirmationTask + if (task instanceof ConfirmationTask confirmationTask) { + if (confirmationTask.getButtonText() != null && !confirmationTask.getButtonText().isBlank()) { + // Translate buttonText at task level + List translations = translationService + .translateToAllLanguages(confirmationTask.getButtonText()); + taskNode.set("buttonText", createTranslationArray(translations)); + + // Also translate buttonText in taskSpecificData if present + if (taskNode.has("taskSpecificData")) { + ObjectNode taskSpecificData = (ObjectNode) taskNode.get("taskSpecificData"); + if (taskSpecificData.has("buttonText")) { + taskSpecificData.set("buttonText", createTranslationArray(translations)); + } + } + } + } + } + } + + // Translate cargo item descriptions + if (dto.getCargoItems() != null && !dto.getCargoItems().isEmpty()) { + ArrayNode cargoItemsNode = root.withArray("cargoItems"); + for (int i = 0; i < dto.getCargoItems().size(); i++) { + CargoItem item = dto.getCargoItems().get(i); + ObjectNode itemNode = (ObjectNode) cargoItemsNode.get(i); + + if (item.getDescription() != null && !item.getDescription().isBlank()) { + List translations = translationService + .translateToAllLanguages(item.getDescription()); + itemNode.set("description", createTranslationArray(translations)); + } + } + } + + return root; + } + + /** + * Creates a JSON array from translations. + */ + private ArrayNode createTranslationArray(List translations) { + ArrayNode array = objectMapper.createArrayNode(); + for (TranslationService.Translation t : translations) { + ObjectNode node = objectMapper.createObjectNode(); + node.put("language", t.language()); + node.put("text", t.text()); + array.add(node); + } + return array; + } +} \ No newline at end of file diff --git a/src/main/java/de/assecutor/votianlt/messaging/WebSocketService.java b/src/main/java/de/assecutor/votianlt/messaging/WebSocketService.java index e325b81..19320f0 100644 --- a/src/main/java/de/assecutor/votianlt/messaging/WebSocketService.java +++ b/src/main/java/de/assecutor/votianlt/messaging/WebSocketService.java @@ -131,8 +131,7 @@ public class WebSocketService extends TextWebSocketHandler { WebSocketSession session = clientSessions.get(clientId); if (session == null) { log.warn("[WebSocket] No session found for client {}", clientId); - return CompletableFuture - .failedFuture(new IOException("No WebSocket session for client: " + clientId)); + return CompletableFuture.failedFuture(new IOException("No WebSocket session for client: " + clientId)); } if (!session.isOpen()) { @@ -140,8 +139,7 @@ public class WebSocketService extends TextWebSocketHandler { // Session aus der Map entfernen clientSessions.remove(clientId); sessionToClient.remove(session.getId()); - return CompletableFuture - .failedFuture(new IOException("WebSocket session closed for client: " + clientId)); + return CompletableFuture.failedFuture(new IOException("WebSocket session closed for client: " + clientId)); } try { @@ -154,6 +152,7 @@ public class WebSocketService extends TextWebSocketHandler { String wireJson = objectMapper.writeValueAsString(wireMessage); log.info("[WebSocket OUT] {} to client {} (session open: {})", topic, clientId, session.isOpen()); + log.debug("[WebSocket OUT] {} -> {}", topic, wireJson); sendToSession(session, wireJson); log.debug("[WebSocket] Message sent successfully to client {}", clientId); diff --git a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java index 6071ac6..ec2a2a9 100644 --- a/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java +++ b/src/main/java/de/assecutor/votianlt/pages/base/ui/view/MainLayout.java @@ -73,15 +73,14 @@ public final class MainLayout extends AppLayout { // Always build the drawer; keep references and toggle visibility on attach and // after navigation headerRef = createHeader(); - + // Scroller für Navigation mit maximaler Höhe Component sideNav = createSideNav(); navRef = new Scroller(sideNav); - + userMenuRef = createUserMenu(); addToDrawer(headerRef, navRef, userMenuRef); - updateDrawerVisibility(); // Re-check on attach (new UI/session) and on every navigation cycle @@ -361,20 +360,12 @@ public final class MainLayout extends AppLayout { // Drawer-Layout anpassen nach kurzer Verzögerung ui.access(() -> { getElement().executeJs( - "setTimeout(() => {" + - " const drawer = this.shadowRoot?.querySelector('[part=drawer]');" + - " if (drawer) {" + - " drawer.style.display = 'flex';" + - " drawer.style.flexDirection = 'column';" + - " drawer.style.height = '100vh';" + - " const scroller = drawer.querySelector('vaadin-scroller');" + - " if (scroller) {" + - " scroller.style.flex = '1 1 auto';" + - " scroller.style.minHeight = '0';" + - " }" + - " }" + - "}, 100);" - ); + "setTimeout(() => {" + " const drawer = this.shadowRoot?.querySelector('[part=drawer]');" + + " if (drawer) {" + " drawer.style.display = 'flex';" + + " drawer.style.flexDirection = 'column';" + " drawer.style.height = '100vh';" + + " const scroller = drawer.querySelector('vaadin-scroller');" + " if (scroller) {" + + " scroller.style.flex = '1 1 auto';" + " scroller.style.minHeight = '0';" + + " }" + " }" + "}, 100);"); }); // Apply user's preferred language immediately after login diff --git a/src/main/java/de/assecutor/votianlt/service/TranslationService.java b/src/main/java/de/assecutor/votianlt/service/TranslationService.java new file mode 100644 index 0000000..d97fbf9 --- /dev/null +++ b/src/main/java/de/assecutor/votianlt/service/TranslationService.java @@ -0,0 +1,174 @@ +package de.assecutor.votianlt.service; + +import de.assecutor.votianlt.ai.service.LlmRestClient; +import de.assecutor.votianlt.model.Language; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Service for translating text into all available languages. Uses LLM API for + * translations with caching. + */ +@Service +@Slf4j +public class TranslationService { + + private static final int MAX_CACHE_SIZE = 1000; + + private final LlmRestClient llmRestClient; + + // Cache: Original Text -> List of all translations (language -> text) + private final Map> translationCache; + + public TranslationService(LlmRestClient llmRestClient) { + this.llmRestClient = llmRestClient; + // LinkedHashMap with accessOrder=true for LRU behavior + this.translationCache = new LinkedHashMap>(MAX_CACHE_SIZE, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry> eldest) { + if (size() > MAX_CACHE_SIZE) { + log.debug("[TranslationCache] Removing oldest entry, cache size: {}", size()); + return true; + } + return false; + } + }; + } + + /** + * Represents a translation with language code and text. + */ + public record Translation(String language, String text) { + } + + /** + * Returns all available languages for translation. + */ + public List getAvailableLanguages() { + return Arrays.asList(Language.DE, Language.EN, Language.FR, Language.ES, Language.TR, Language.PL, Language.RU, + Language.EE, Language.LV, Language.LT); + } + + /** + * Translates the given text into all available languages. Uses cache to avoid + * redundant LLM calls. + * + * @param text + * the text to translate + * @return list of translations for all available languages + */ + public List translateToAllLanguages(String text) { + if (text == null || text.isBlank()) { + return List.of(); + } + + // Check cache first + List cachedTranslations = translationCache.get(text); + if (cachedTranslations != null) { + log.debug("[TranslationCache] Cache hit for '{}'", text); + return cachedTranslations; + } + + // Cache miss - translate via LLM + log.debug("[TranslationCache] Cache miss for '{}', calling LLM", text); + List translations = translateViaLlm(text); + + // Store in cache + translationCache.put(text, translations); + log.debug("[TranslationCache] Stored translations for '{}', cache size: {}", text, translationCache.size()); + + return translations; + } + + /** + * Translates text to all languages via LLM. + * + * @param text + * the text to translate + * @return list of translations for all languages + */ + private List translateViaLlm(String text) { + List translations = new ArrayList<>(); + + // Translate to each language + for (Language lang : getAvailableLanguages()) { + String translatedText = translateTextToLanguage(text, lang); + translations.add(new Translation(lang.name().toLowerCase(), translatedText)); + } + + return translations; + } + + /** + * Translates a single text to the target language using LLM. + * + * @param text + * the text to translate + * @param targetLanguage + * the target language + * @return the translated text + */ + private String translateTextToLanguage(String text, Language targetLanguage) { + String languageName = getLanguageName(targetLanguage); + + String systemPrompt = "You are a professional translator. Translate the given text to " + languageName + + ". Return ONLY the translated text, nothing else. Do not add quotes or explanations."; + + try { + String result = llmRestClient.chat(systemPrompt, text, 0.3, 500); + + if (result != null && !result.isBlank()) { + // Clean up the response (remove quotes if present) + result = result.trim(); + if (result.startsWith("\"") && result.endsWith("\"")) { + result = result.substring(1, result.length() - 1); + } + return result.trim(); + } + } catch (Exception e) { + log.warn("Translation failed for {}: {}", languageName, e.getMessage()); + } + + // Fallback: return original text + return text; + } + + /** + * Returns the human-readable language name. + */ + private String getLanguageName(Language language) { + return switch (language) { + case DE -> "German"; + case EN -> "English"; + case FR -> "French"; + case ES -> "Spanish"; + case TR -> "Turkish"; + case PL -> "Polish"; + case RU -> "Russian"; + case EE -> "Estonian"; + case LV -> "Latvian"; + case LT -> "Lithuanian"; + }; + } + + /** + * Returns cache statistics for monitoring. + */ + public int getCacheSize() { + return translationCache.size(); + } + + /** + * Clears the translation cache. + */ + public void clearCache() { + translationCache.clear(); + log.info("[TranslationCache] Cache cleared"); + } +} \ No newline at end of file