From fbc9f44ac8e3650ed67ce30783cf62772d2f1d38 Mon Sep 17 00:00:00 2001 From: Josako Date: Mon, 30 Jun 2025 14:20:17 +0200 Subject: [PATCH] - Translations completed for Front-End, Configs (e.g. Forms) and free text. - Allowed_languages and default_language now part of Tenant Make iso Tenant - Introduction of Translation into Traicie Selection Specialist --- app/eveai_chat_client/templates/chat.html | 6 - common/models/user.py | 9 +- common/services/utils/translation_services.py | 125 +++++++--- common/utils/cache/regions.py | 2 +- common/utils/cache/translation_cache.py | 107 +++++++-- common/utils/chat_utils.py | 1 - common/utils/security.py | 1 - config/config.py | 6 +- .../translation_with_context/1.0.0.yaml | 13 +- .../translation_without_context/1.0.0.yaml | 9 +- .../globals/PERSONAL_CONTACT_FORM/1.0.0.yaml | 7 +- .../PROFESSIONAL_CONTACT_FORM/1.0.0.yaml | 5 + eveai_app/__init__.py | 12 +- eveai_app/views/basic_forms.py | 1 - eveai_app/views/basic_views.py | 1 - eveai_app/views/dynamic_form_base.py | 3 - eveai_app/views/user_forms.py | 15 +- eveai_app/views/user_views.py | 8 +- eveai_chat_client/__init__.py | 2 + eveai_chat_client/static/assets/css/chat.css | 119 +++++++++- .../static/assets/css/language-selector.css | 50 ++++ .../static/assets/js/chat-app.js | 218 +++++++++++++++++- .../static/assets/js/components/ChatInput.js | 99 ++++++-- .../assets/js/components/ChatMessage.js | 19 ++ .../assets/js/components/LanguageSelector.js | 118 ++++++++++ .../assets/js/components/MessageHistory.js | 83 ++++++- .../static/assets/js/translation.js | 63 +++++ eveai_chat_client/templates/base.html | 118 ++-------- eveai_chat_client/templates/chat.html | 7 +- eveai_chat_client/views/chat_views.py | 95 +++++++- eveai_chat_workers/__init__.py | 2 + .../TRAICIE_SELECTION_SPECIALIST/1_3.py | 26 ++- ...remove_not_null_constraint_from_source_.py | 36 +++ ...8a_move_default_language_to_tenant_make.py | 40 ++++ 34 files changed, 1206 insertions(+), 220 deletions(-) delete mode 100644 app/eveai_chat_client/templates/chat.html create mode 100644 eveai_chat_client/static/assets/css/language-selector.css create mode 100644 eveai_chat_client/static/assets/js/components/LanguageSelector.js create mode 100644 eveai_chat_client/static/assets/js/translation.js create mode 100644 migrations/public/versions/057fb975f0e3_remove_not_null_constraint_from_source_.py create mode 100644 migrations/public/versions/bb67d16f428a_move_default_language_to_tenant_make.py diff --git a/app/eveai_chat_client/templates/chat.html b/app/eveai_chat_client/templates/chat.html deleted file mode 100644 index 07f3f52..0000000 --- a/app/eveai_chat_client/templates/chat.html +++ /dev/null @@ -1,6 +0,0 @@ - -{% extends "base.html" %} - -{% block title %}{{ tenant_make.name|default('EveAI') }} - AI Chat{% endblock %} - -{% block head %} diff --git a/common/models/user.py b/common/models/user.py index 7c895a1..aa0ad27 100644 --- a/common/models/user.py +++ b/common/models/user.py @@ -26,9 +26,6 @@ class Tenant(db.Model): timezone = db.Column(db.String(50), nullable=True, default='UTC') type = db.Column(db.String(20), nullable=True, server_default='Active') - # language information - default_language = db.Column(db.String(2), nullable=True) - # Entitlements currency = db.Column(db.String(20), nullable=True) storage_dirty = db.Column(db.Boolean, nullable=True, default=False) @@ -61,7 +58,6 @@ class Tenant(db.Model): 'website': self.website, 'timezone': self.timezone, 'type': self.type, - 'default_language': self.default_language, 'currency': self.currency, 'default_tenant_make_id': self.default_tenant_make_id, } @@ -186,6 +182,7 @@ class TenantMake(db.Model): active = db.Column(db.Boolean, nullable=False, default=True) website = db.Column(db.String(255), nullable=True) logo_url = db.Column(db.String(255), nullable=True) + default_language = db.Column(db.String(2), nullable=True) allowed_languages = db.Column(ARRAY(sa.String(2)), nullable=True) # Chat customisation options @@ -209,6 +206,8 @@ class TenantMake(db.Model): 'website': self.website, 'logo_url': self.logo_url, 'chat_customisation_options': self.chat_customisation_options, + 'allowed_languages': self.allowed_languages, + 'default_language': self.default_language, } @@ -327,7 +326,7 @@ class TranslationCache(db.Model): cache_key = db.Column(db.String(16), primary_key=True) source_text = db.Column(db.Text, nullable=False) translated_text = db.Column(db.Text, nullable=False) - source_language = db.Column(db.String(2), nullable=False) + source_language = db.Column(db.String(2), nullable=True) target_language = db.Column(db.String(2), nullable=False) context = db.Column(db.Text, nullable=True) diff --git a/common/services/utils/translation_services.py b/common/services/utils/translation_services.py index d35fae0..0ef3186 100644 --- a/common/services/utils/translation_services.py +++ b/common/services/utils/translation_services.py @@ -1,43 +1,108 @@ -import xxhash import json +from typing import Dict, Any, Optional +from common.extensions import cache_manager +from common.utils.business_event import BusinessEvent +from common.utils.business_event_context import current_event -from langchain_core.output_parsers import StrOutputParser -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnablePassthrough +class TranslationServices: -from common.langchain.persistent_llm_metrics_handler import PersistentLLMMetricsHandler -from common.utils.model_utils import get_template, replace_variable_in_template + @staticmethod + def translate_config(config_data: Dict[str, Any], field_config: str, target_language: str, source_language: Optional[str] = None, context: Optional[str] = None) -> Dict[str, Any]: + """ + Vertaalt een configuratie op basis van een veld-configuratie. -class TranslationService: - def __init__(self, tenant_id): - self.tenant_id = tenant_id + Args: + config_data: Een dictionary of JSON (die dan wordt geconverteerd naar een dictionary) met configuratiegegevens + field_config: De naam van een veld-configuratie (bijv. 'fields') + target_language: De taal waarnaar vertaald moet worden + source_language: Optioneel, de brontaal van de configuratie + context: Optioneel, een specifieke context voor de vertaling - def translate_text(self, text_to_translate: str, target_lang: str, source_lang: str = None, context: str = None) -> tuple[ - str, dict[str, int | float]]: - prompt_params = { - "text_to_translate": text_to_translate, - "target_lang": target_lang, - } - if context: - template, llm = get_template("translation_with_context") - prompt_params["context"] = context - else: - template, llm = get_template("translation_without_context") + Returns: + Een dictionary met de vertaalde configuratie + """ + # Zorg ervoor dat we een dictionary hebben + if isinstance(config_data, str): + config_data = json.loads(config_data) - # Add a metrics handler to capture usage + # Maak een kopie van de originele data om te wijzigen + translated_config = config_data.copy() - metrics_handler = PersistentLLMMetricsHandler() - existing_callbacks = llm.callbacks - llm.callbacks = existing_callbacks + [metrics_handler] + # Haal type en versie op voor de Business Event span + config_type = config_data.get('type', 'Unknown') + config_version = config_data.get('version', 'Unknown') + span_name = f"{config_type}-{config_version}-{field_config}" - translation_prompt = ChatPromptTemplate.from_template(template) + # Start een Business Event context + with BusinessEvent('Config Translation Service', 0): + with current_event.create_span(span_name): + # Controleer of de gevraagde veld-configuratie bestaat + if field_config in config_data: + fields = config_data[field_config] - setup = RunnablePassthrough() + # Haal description uit metadata voor context als geen context is opgegeven + description_context = "" + if not context and 'metadata' in config_data and 'description' in config_data['metadata']: + description_context = config_data['metadata']['description'] - chain = (setup | translation_prompt | llm | StrOutputParser()) + # Loop door elk veld in de configuratie + for field_name, field_data in fields.items(): + # Vertaal name als het bestaat en niet leeg is + if 'name' in field_data and field_data['name']: + # Gebruik context indien opgegeven, anders description_context + field_context = context if context else description_context + translated_name = cache_manager.translation_cache.get_translation( + text=field_data['name'], + target_lang=target_language, + source_lang=source_language, + context=field_context + ) + if translated_name: + translated_config[field_config][field_name]['name'] = translated_name.translated_text - translation = chain.invoke(prompt_params) + if 'title' in field_data and field_data['title']: + # Gebruik context indien opgegeven, anders description_context + field_context = context if context else description_context + translated_title = cache_manager.translation_cache.get_translation( + text=field_data['title'], + target_lang=target_language, + source_lang=source_language, + context=field_context + ) + if translated_title: + translated_config[field_config][field_name]['title'] = translated_title.translated_text - metrics = metrics_handler.get_metrics() + # Vertaal description als het bestaat en niet leeg is + if 'description' in field_data and field_data['description']: + # Gebruik context indien opgegeven, anders description_context + field_context = context if context else description_context + translated_desc = cache_manager.translation_cache.get_translation( + text=field_data['description'], + target_lang=target_language, + source_lang=source_language, + context=field_context + ) + if translated_desc: + translated_config[field_config][field_name]['description'] = translated_desc.translated_text - return translation, metrics \ No newline at end of file + # Vertaal context als het bestaat en niet leeg is + if 'context' in field_data and field_data['context']: + translated_ctx = cache_manager.translation_cache.get_translation( + text=field_data['context'], + target_lang=target_language, + source_lang=source_language, + context=context + ) + if translated_ctx: + translated_config[field_config][field_name]['context'] = translated_ctx.translated_text + + return translated_config + + @staticmethod + def translate(text: str, target_language: str, source_language: Optional[str] = None, + context: Optional[str] = None)-> str: + with BusinessEvent('Translation Service', 0): + with current_event.create_span('Translation'): + translation_cache = cache_manager.translation_cache.get_translation(text, target_language, + source_language, context) + return translation_cache.translated_text \ No newline at end of file diff --git a/common/utils/cache/regions.py b/common/utils/cache/regions.py index 4b51149..cfe48e0 100644 --- a/common/utils/cache/regions.py +++ b/common/utils/cache/regions.py @@ -42,7 +42,7 @@ def create_cache_regions(app): # Region for model-related caching (ModelVariables etc) model_region = make_region(name='eveai_model').configure( 'dogpile.cache.redis', - arguments=redis_config, + arguments={**redis_config, 'db': 6}, replace_existing_backend=True ) regions['eveai_model'] = model_region diff --git a/common/utils/cache/translation_cache.py b/common/utils/cache/translation_cache.py index 5e1e409..e9d1d5f 100644 --- a/common/utils/cache/translation_cache.py +++ b/common/utils/cache/translation_cache.py @@ -1,19 +1,25 @@ import json +import re from typing import Dict, Any, Optional from datetime import datetime as dt, timezone as tz import xxhash from flask import current_app -from sqlalchemy import and_ +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import RunnablePassthrough from sqlalchemy.inspection import inspect +from common.langchain.persistent_llm_metrics_handler import PersistentLLMMetricsHandler +from common.utils.business_event_context import current_event from common.utils.cache.base import CacheHandler, T from common.extensions import db from common.models.user import TranslationCache -from common.services.utils.translation_services import TranslationService from flask_security import current_user +from common.utils.model_utils import get_template + class TranslationCacheHandler(CacheHandler[TranslationCache]): """Handles caching of translations with fallback to database and external translation service""" @@ -62,13 +68,33 @@ class TranslationCacheHandler(CacheHandler[TranslationCache]): setattr(translation, column.name, value) + current_app.logger.debug(f"Translation Cache Retrieved: {translation}") + metrics = { + 'total_tokens': translation.prompt_tokens + translation.completion_tokens, + 'prompt_tokens': translation.prompt_tokens, + 'completion_tokens': translation.completion_tokens, + 'time_elapsed': 0, + 'interaction_type': 'LLM' + } + current_event.log_llm_metrics(metrics) + return translation - def _should_cache(self, value: TranslationCache) -> bool: + def _should_cache(self, value) -> bool: """Validate if the translation should be cached""" - return value is not None and value.cache_key is not None + if value is None: + return False - def get_translation(self, text: str, target_lang: str, source_lang:str=None, context: str=None) -> Optional[TranslationCache]: + # Handle both TranslationCache objects and serialized data (dict) + if isinstance(value, TranslationCache): + return value.cache_key is not None + elif isinstance(value, dict): + return value.get('cache_key') is not None + + return False + + def get_translation(self, text: str, target_lang: str, source_lang: str = None, context: str = None) -> Optional[ + TranslationCache]: """ Get the translation for a text in a specific language @@ -81,26 +107,33 @@ class TranslationCacheHandler(CacheHandler[TranslationCache]): Returns: TranslationCache instance if found, None otherwise """ + if not context: + context = 'No context provided.' + current_app.logger.debug(f"Getting translation for text: {text[:10]}..., target_lang: {target_lang}, source_lang: {source_lang}, context: {context[:10]}...") - def creator_func(text: str, target_lang: str, source_lang: str=None, context: str=None) -> Optional[TranslationCache]: - # Generate cache key based on inputs - cache_key = self._generate_cache_key(text, target_lang, source_lang, context) - + def creator_func(hash_key: str) -> Optional[TranslationCache]: # Check if translation already exists in database - existing_translation = db.session.query(TranslationCache).filter_by(cache_key=cache_key).first() + existing_translation = db.session.query(TranslationCache).filter_by(cache_key=hash_key).first() if existing_translation: # Update last used timestamp existing_translation.last_used_at = dt.now(tz=tz.utc) + metrics = { + 'total_tokens': existing_translation.prompt_tokens + existing_translation.completion_tokens, + 'prompt_tokens': existing_translation.prompt_tokens, + 'completion_tokens': existing_translation.completion_tokens, + 'time_elapsed': 0, + 'interaction_type': 'LLM' + } + current_app.logger.debug(f"Found existing translation in DB: {existing_translation.cache_key}") + current_app.logger.debug(f"Metrics: {metrics}") + current_event.log_llm_metrics(metrics) db.session.commit() return existing_translation # Translation not found in DB, need to create it - # Initialize translation service - translation_service = TranslationService(getattr(current_app, 'tenant_id', None)) - # Get the translation and metrics - translated_text, metrics = translation_service.translate_text( + translated_text, metrics = self.translate_text( text_to_translate=text, target_lang=target_lang, source_lang=source_lang, @@ -109,10 +142,10 @@ class TranslationCacheHandler(CacheHandler[TranslationCache]): # Create new translation cache record new_translation = TranslationCache( - cache_key=cache_key, + cache_key=hash_key, source_text=text, translated_text=translated_text, - source_language=source_lang or 'auto', + source_language=source_lang, target_language=target_lang, context=context, prompt_tokens=metrics.get('prompt_tokens', 0), @@ -130,7 +163,12 @@ class TranslationCacheHandler(CacheHandler[TranslationCache]): return new_translation - return self.get(creator_func, text=text, target_lang=target_lang, source_lang=source_lang, context=context) + # Generate the hash key using your existing method + hash_key = self._generate_cache_key(text, target_lang, source_lang, context) + current_app.logger.debug(f"Generated hash key: {hash_key}") + + # Pass the hash_key to the get method + return self.get(creator_func, hash_key=hash_key) def invalidate_tenant_translations(self, tenant_id: int): """Invalidate cached translations for specific tenant""" @@ -148,6 +186,41 @@ class TranslationCacheHandler(CacheHandler[TranslationCache]): cache_string = json.dumps(cache_data, sort_keys=True, ensure_ascii=False) return xxhash.xxh64(cache_string.encode('utf-8')).hexdigest() + def translate_text(self, text_to_translate: str, target_lang: str, source_lang: str = None, context: str = None) \ + -> tuple[str, dict[str, int | float]]: + target_language = current_app.config['SUPPORTED_LANGUAGE_ISO639_1_LOOKUP'][target_lang] + current_app.logger.debug(f"Target language: {target_language}") + prompt_params = { + "text_to_translate": text_to_translate, + "target_language": target_language, + } + if context: + template, llm = get_template("translation_with_context") + prompt_params["context"] = context + else: + template, llm = get_template("translation_without_context") + + # Add a metrics handler to capture usage + + metrics_handler = PersistentLLMMetricsHandler() + existing_callbacks = llm.callbacks + llm.callbacks = existing_callbacks + [metrics_handler] + + translation_prompt = ChatPromptTemplate.from_template(template) + + setup = RunnablePassthrough() + + chain = (setup | translation_prompt | llm | StrOutputParser()) + + translation = chain.invoke(prompt_params) + + # Remove double square brackets from translation + translation = re.sub(r'\[\[(.*?)\]\]', r'\1', translation) + + metrics = metrics_handler.get_metrics() + + return translation, metrics + def register_translation_cache_handlers(cache_manager) -> None: """Register translation cache handlers with cache manager""" cache_manager.register_handler( diff --git a/common/utils/chat_utils.py b/common/utils/chat_utils.py index 4a3fe32..b8a0cd2 100644 --- a/common/utils/chat_utils.py +++ b/common/utils/chat_utils.py @@ -31,7 +31,6 @@ def get_default_chat_customisation(tenant_customisation=None): 'markdown_background_color': 'transparent', 'markdown_text_color': '#ffffff', 'sidebar_markdown': '', - 'welcome_message': 'Hello! How can I help you today?', } # If no tenant customization is provided, return the defaults diff --git a/common/utils/security.py b/common/utils/security.py index fd23676..b8946fc 100644 --- a/common/utils/security.py +++ b/common/utils/security.py @@ -12,7 +12,6 @@ from datetime import datetime as dt, timezone as tz def set_tenant_session_data(sender, user, **kwargs): tenant = Tenant.query.filter_by(id=user.tenant_id).first() session['tenant'] = tenant.to_dict() - session['default_language'] = tenant.default_language partner = Partner.query.filter_by(tenant_id=user.tenant_id).first() if partner: session['partner'] = partner.to_dict() diff --git a/config/config.py b/config/config.py index f85a6fc..72f2d16 100644 --- a/config/config.py +++ b/config/config.py @@ -66,7 +66,6 @@ class Config(object): MAX_CONTENT_LENGTH = 50 * 1024 * 1024 # supported languages - SUPPORTED_LANGUAGES = ['en', 'fr', 'nl', 'de', 'es', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi'] SUPPORTED_LANGUAGE_DETAILS = { "English": { "iso 639-1": "en", @@ -148,7 +147,10 @@ class Config(object): }, } + # Afgeleide taalconstanten + SUPPORTED_LANGUAGES = [lang_details["iso 639-1"] for lang_details in SUPPORTED_LANGUAGE_DETAILS.values()] SUPPORTED_LANGUAGES_FULL = list(SUPPORTED_LANGUAGE_DETAILS.keys()) + SUPPORTED_LANGUAGE_ISO639_1_LOOKUP = {lang_details["iso 639-1"]: lang_name for lang_name, lang_details in SUPPORTED_LANGUAGE_DETAILS.items()} # supported currencies SUPPORTED_CURRENCIES = ['€', '$'] @@ -293,6 +295,8 @@ class DevConfig(Config): CHAT_WORKER_CACHE_URL = f'{REDIS_BASE_URI}/4' # specialist execution pub/sub Redis Settings SPECIALIST_EXEC_PUBSUB = f'{REDIS_BASE_URI}/5' + # eveai_model cache Redis setting + MODEL_CACHE_URL = f'{REDIS_BASE_URI}/6' # Unstructured settings diff --git a/config/prompts/globals/translation_with_context/1.0.0.yaml b/config/prompts/globals/translation_with_context/1.0.0.yaml index b7c5be5..21fd5eb 100644 --- a/config/prompts/globals/translation_with_context/1.0.0.yaml +++ b/config/prompts/globals/translation_with_context/1.0.0.yaml @@ -1,9 +1,16 @@ version: "1.0.0" content: > - You are a top translator. We need you to translate {text_to_translate} into {target_language}, taking into account - this context: + You are a top translator. We need you to translate (in between triple quotes) - {context} + '''{text_to_translate}''' + + into '{target_language}', taking + into account this context: + + '{context}' + + Do not translate text in between double square brackets, as these are names or terms that need to remain intact. + Remove the triple quotes in your translation! I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer without further interpretation. If more than one option is available, present me with the most probable one. diff --git a/config/prompts/globals/translation_without_context/1.0.0.yaml b/config/prompts/globals/translation_without_context/1.0.0.yaml index 08d2990..6071261 100644 --- a/config/prompts/globals/translation_without_context/1.0.0.yaml +++ b/config/prompts/globals/translation_without_context/1.0.0.yaml @@ -1,6 +1,13 @@ version: "1.0.0" content: > - You are a top translator. We need you to translate {text_to_translate} into {target_language}. + You are a top translator. We need you to translate (in between triple quotes) + + '''{text_to_translate}''' + + into '{target_language}'. + + Do not translate text in between double square brackets, as these are names or terms that need to remain intact. + Remove the triple quotes in your translation! I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer without further interpretation. If more than one option is available, present me with the most probable one. diff --git a/config/specialist_forms/globals/PERSONAL_CONTACT_FORM/1.0.0.yaml b/config/specialist_forms/globals/PERSONAL_CONTACT_FORM/1.0.0.yaml index b7f5b5f..3f20fd9 100644 --- a/config/specialist_forms/globals/PERSONAL_CONTACT_FORM/1.0.0.yaml +++ b/config/specialist_forms/globals/PERSONAL_CONTACT_FORM/1.0.0.yaml @@ -8,6 +8,7 @@ fields: description: "Your name" type: "str" required: true +# It is possible to also add a field 'context'. It allows you to provide an elaborate piece of information. email: name: "Email" type: "str" @@ -17,7 +18,6 @@ fields: name: "Phone Number" type: "str" description: "Your Phone Number" - context: "Een kleine test om te zien of we context kunnen doorgeven en tonen" required: true address: name: "Address" @@ -44,3 +44,8 @@ fields: type: "boolean" description: "Consent" required: true +metadata: + author: "Josako" + date_added: "2025-06-18" + changes: "Initial Version" + description: "Personal Contact Form" diff --git a/config/specialist_forms/globals/PROFESSIONAL_CONTACT_FORM/1.0.0.yaml b/config/specialist_forms/globals/PROFESSIONAL_CONTACT_FORM/1.0.0.yaml index cbcf484..b894cb3 100644 --- a/config/specialist_forms/globals/PROFESSIONAL_CONTACT_FORM/1.0.0.yaml +++ b/config/specialist_forms/globals/PROFESSIONAL_CONTACT_FORM/1.0.0.yaml @@ -53,3 +53,8 @@ fields: type: "bool" description: "Consent" required: true +metadata: + author: "Josako" + date_added: "2025-06-18" + changes: "Initial Version" + description: "Professional Contact Form" diff --git a/eveai_app/__init__.py b/eveai_app/__init__.py index 54bb883..7258713 100644 --- a/eveai_app/__init__.py +++ b/eveai_app/__init__.py @@ -106,20 +106,20 @@ def create_app(config_file=None): from flask_login import current_user import datetime - app.logger.debug(f"Before request - URL: {request.url}") - app.logger.debug(f"Before request - Session permanent: {session.permanent}") + # app.logger.debug(f"Before request - URL: {request.url}") + # app.logger.debug(f"Before request - Session permanent: {session.permanent}") # Log session expiry tijd als deze bestaat if current_user.is_authenticated: # Controleer of sessie permanent is (nodig voor PERMANENT_SESSION_LIFETIME) if not session.permanent: session.permanent = True - app.logger.debug("Session marked as permanent (enables 60min timeout)") + # app.logger.debug("Session marked as permanent (enables 60min timeout)") # Log wanneer sessie zou verlopen - if '_permanent' in session: - expires_at = datetime.datetime.now() + app.permanent_session_lifetime - app.logger.debug(f"Session will expire at: {expires_at} (60 min from now)") + # if '_permanent' in session: + # expires_at = datetime.datetime.now() + app.permanent_session_lifetime + # app.logger.debug(f"Session will expire at: {expires_at} (60 min from now)") @app.route('/debug/session') def debug_session(): diff --git a/eveai_app/views/basic_forms.py b/eveai_app/views/basic_forms.py index 7fdc3c3..5f50c68 100644 --- a/eveai_app/views/basic_forms.py +++ b/eveai_app/views/basic_forms.py @@ -16,7 +16,6 @@ class SessionDefaultsForm(FlaskForm): # Tenant Defaults tenant_name = StringField('Tenant Name', validators=[DataRequired()]) - default_language = SelectField('Default Language', choices=[], validators=[DataRequired()]) # Partner Defaults partner_name = StringField('Partner Name', validators=[DataRequired()]) diff --git a/eveai_app/views/basic_views.py b/eveai_app/views/basic_views.py index 3f91cef..eefba77 100644 --- a/eveai_app/views/basic_views.py +++ b/eveai_app/views/basic_views.py @@ -59,7 +59,6 @@ def session_defaults(): form = SessionDefaultsForm() if form.validate_on_submit(): - session['default_language'] = form.default_language.data if form.catalog.data: catalog_id = int(form.catalog.data) catalog = tenant_session.query(Catalog).get(catalog_id) diff --git a/eveai_app/views/dynamic_form_base.py b/eveai_app/views/dynamic_form_base.py index cb135ec..fa6b5bf 100644 --- a/eveai_app/views/dynamic_form_base.py +++ b/eveai_app/views/dynamic_form_base.py @@ -453,9 +453,6 @@ class DynamicFormBase(FlaskForm): else: render_kw['class'] = 'color-field' - - current_app.logger.debug(f"render_kw for {full_field_name}: {render_kw}") - # Create the field field_kwargs.update({ 'label': label, diff --git a/eveai_app/views/user_forms.py b/eveai_app/views/user_forms.py index 6fa7b7d..4eadc45 100644 --- a/eveai_app/views/user_forms.py +++ b/eveai_app/views/user_forms.py @@ -18,8 +18,6 @@ class TenantForm(FlaskForm): code = StringField('Code', validators=[DataRequired()], render_kw={'readonly': True}) type = SelectField('Tenant Type', validators=[Optional()], default='Active') website = StringField('Website', validators=[DataRequired(), Length(max=255)]) - # language fields - default_language = SelectField('Default Language', choices=[], validators=[DataRequired()]) # invoicing fields currency = SelectField('Currency', choices=[], validators=[DataRequired()]) # Timezone @@ -32,8 +30,6 @@ class TenantForm(FlaskForm): def __init__(self, *args, **kwargs): super(TenantForm, self).__init__(*args, **kwargs) - # initialise language fields - self.default_language.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']] # initialise currency field self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']] # initialise timezone @@ -53,8 +49,6 @@ class EditTenantForm(FlaskForm): code = StringField('Code', validators=[DataRequired()], render_kw={'readonly': True}) type = SelectField('Tenant Type', validators=[Optional()], default='Active') website = StringField('Website', validators=[DataRequired(), Length(max=255)]) - # language fields - default_language = SelectField('Default Language', choices=[], validators=[DataRequired()]) # invoicing fields currency = SelectField('Currency', choices=[], validators=[DataRequired()]) # Timezone @@ -69,8 +63,6 @@ class EditTenantForm(FlaskForm): def __init__(self, *args, **kwargs): super(EditTenantForm, self).__init__(*args, **kwargs) - # initialise language fields - self.default_language.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']] # initialise currency field self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']] # initialise timezone @@ -212,14 +204,17 @@ class EditTenantMakeForm(DynamicFormBase): active = BooleanField('Active', validators=[Optional()], default=True) website = StringField('Website', validators=[DataRequired(), Length(max=255)]) logo_url = StringField('Logo URL', validators=[Optional(), Length(max=255)]) + default_language = SelectField('Default Language', choices=[], validators=[DataRequired()]) allowed_languages = SelectMultipleField('Allowed Languages', choices=[], validators=[Optional()]) def __init__(self, *args, **kwargs): super(EditTenantMakeForm, self).__init__(*args, **kwargs) # Initialiseer de taalopties met taalcodes en vlaggen lang_details = current_app.config['SUPPORTED_LANGUAGE_DETAILS'] - self.allowed_languages.choices = [(details['iso 639-1'], f"{details['flag']} {details['iso 639-1']}") - for name, details in lang_details.items()] + choices = [(details['iso 639-1'], f"{details['flag']} {details['iso 639-1']}") + for name, details in lang_details.items()] + self.allowed_languages.choices = choices + self.default_language.choices = choices diff --git a/eveai_app/views/user_views.py b/eveai_app/views/user_views.py index 5810587..d68be50 100644 --- a/eveai_app/views/user_views.py +++ b/eveai_app/views/user_views.py @@ -309,7 +309,6 @@ def handle_tenant_selection(): # set tenant information in the session session['tenant'] = the_tenant.to_dict() - session['default_language'] = the_tenant.default_language # remove catalog-related items from the session session.pop('catalog_id', None) session.pop('catalog_name', None) @@ -706,8 +705,9 @@ def edit_tenant_make(tenant_make_id): form = EditTenantMakeForm(request.form, obj=tenant_make) # Initialiseer de allowed_languages selectie met huidige waarden - if tenant_make.allowed_languages: - form.allowed_languages.data = tenant_make.allowed_languages + if request.method == 'GET': + if tenant_make.allowed_languages: + form.allowed_languages.data = tenant_make.allowed_languages customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION") form.add_dynamic_fields("configuration", customisation_config, tenant_make.chat_customisation_options) @@ -717,7 +717,9 @@ def edit_tenant_make(tenant_make_id): form.populate_obj(tenant_make) tenant_make.chat_customisation_options = form.get_dynamic_data("configuration") # Verwerk allowed_languages als array + current_app.logger.debug(f"Allowed languages: {form.allowed_languages.data}") tenant_make.allowed_languages = form.allowed_languages.data if form.allowed_languages.data else None + current_app.logger.debug(f"Updated allowed languages: {tenant_make.allowed_languages}") # Update logging information update_logging_information(tenant_make, dt.now(tz.utc)) diff --git a/eveai_chat_client/__init__.py b/eveai_chat_client/__init__.py index 7a3df0e..8a818e0 100644 --- a/eveai_chat_client/__init__.py +++ b/eveai_chat_client/__init__.py @@ -112,3 +112,5 @@ def register_cache_handlers(app): register_config_cache_handlers(cache_manager) from common.utils.cache.crewai_processed_config_cache import register_specialist_cache_handlers register_specialist_cache_handlers(cache_manager) + from common.utils.cache.translation_cache import register_translation_cache_handlers + register_translation_cache_handlers(cache_manager) diff --git a/eveai_chat_client/static/assets/css/chat.css b/eveai_chat_client/static/assets/css/chat.css index b97d2fc..f16f9b8 100644 --- a/eveai_chat_client/static/assets/css/chat.css +++ b/eveai_chat_client/static/assets/css/chat.css @@ -11,10 +11,85 @@ --spacing: 16px; } -* { - box-sizing: border-box; - margin: 0; - padding: 0; +/* App container layout */ +.app-container { + display: flex; + height: 100vh; + width: 100%; +} + +/* Sidebar styling */ +.sidebar { + width: 300px; + background-color: var(--sidebar-background); + padding: 20px; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.sidebar-logo { + text-align: center; + margin-bottom: 20px; +} + +.sidebar-logo img { + max-width: 100%; + max-height: 100px; +} + +.sidebar-make-name { + font-size: 24px; + font-weight: bold; + margin-bottom: 20px; + text-align: center; +} + +.sidebar-explanation { + margin-top: 20px; + overflow-y: auto; + background-color: var(--markdown-background-color); + color: var(--markdown-text-color); + padding: 10px; + border-radius: 5px; +} + +/* Ensure all elements in the markdown content inherit the text color */ +.sidebar-explanation * { + color: inherit; +} + +/* Style links in the markdown content */ +.sidebar-explanation a { + color: var(--primary-color); + text-decoration: underline; +} + +/* Style lists in markdown content */ +.sidebar-explanation ul, +.sidebar-explanation ol { + padding-left: 20px; + margin: 10px 0; +} + +.sidebar-explanation li { + margin-bottom: 5px; +} + +.sidebar-explanation ul li { + list-style-type: disc; +} + +.sidebar-explanation ol li { + list-style-type: decimal; +} + +.content-area { + flex: 1; + background: linear-gradient(135deg, var(--gradient-start-color), var(--gradient-end-color)); + overflow-y: auto; + display: flex; + flex-direction: column; } body { @@ -35,11 +110,14 @@ body { .chat-container { display: flex; height: 100%; + flex: 1; + flex-direction: column; + min-height: 0; } .sidebar { width: 280px; - background-color: var(--sidebar-color); + background-color: var(--sidebar-background); border-right: 1px solid rgba(0,0,0,0.1); display: flex; flex-direction: column; @@ -98,6 +176,35 @@ body { border-bottom: 1px solid rgba(0,0,0,0.1); } +/* Indicator voor taalwijziging */ +.language-change-indicator { + background-color: rgba(var(--primary-color-rgb, 0, 123, 255), 0.2); + color: white; + padding: 5px 8px; + margin-bottom: 10px; + border-radius: 4px; + font-size: 0.9em; + text-align: center; + animation: fadeInOut 3s ease-in-out; +} + +@keyframes fadeInOut { + 0% { opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { opacity: 0; } +} + +.language-change-indicator.success { + background: #d4edda; + color: #155724; +} + +.language-change-indicator.error { + background: #f8d7da; + color: #721c24; +} + /* .chat-messages wordt nu gedefinieerd in chat-components.css */ /* .message wordt nu gedefinieerd in chat-components.css */ @@ -164,4 +271,4 @@ body { /* .btn-primary wordt nu gedefinieerd in chat-components.css */ -/* Responsieve design regels worden nu gedefinieerd in chat-components.css */ \ No newline at end of file +/* Responsieve design regels worden nu gedefinieerd in chat-components.css */ diff --git a/eveai_chat_client/static/assets/css/language-selector.css b/eveai_chat_client/static/assets/css/language-selector.css new file mode 100644 index 0000000..8776443 --- /dev/null +++ b/eveai_chat_client/static/assets/css/language-selector.css @@ -0,0 +1,50 @@ +/* Styling voor de taalselector */ + +.sidebar-language-section { + padding: 10px 15px; + margin-bottom: 15px; +} + +#language-selector-container { + display: flex; + flex-direction: column; + padding: 10px; + background-color: rgba(255, 255, 255, 0.1); + border-radius: 5px; + margin: 10px 0; +} + +#language-selector-container label { + margin-bottom: 5px; + color: var(--sidebar-color); + font-size: 0.9rem; + font-weight: 500; +} + +.language-selector { + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background-color: rgba(0, 0, 0, 0.2); + color: var(--sidebar-color); + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; +} + +.language-selector:hover { + background-color: rgba(0, 0, 0, 0.3); +} + +.language-selector:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.3); +} + +.language-selector option { + background-color: #2c3e50; + color: white; + padding: 8px; +} diff --git a/eveai_chat_client/static/assets/js/chat-app.js b/eveai_chat_client/static/assets/js/chat-app.js index f0413a3..0fe302c 100644 --- a/eveai_chat_client/static/assets/js/chat-app.js +++ b/eveai_chat_client/static/assets/js/chat-app.js @@ -5,6 +5,7 @@ import { DynamicForm } from '/static/assets/js/components/DynamicForm.js'; import { ChatMessage } from '/static/assets/js/components/ChatMessage.js'; import { MessageHistory } from '/static/assets/js/components/MessageHistory.js'; import { ProgressTracker } from '/static/assets/js/components/ProgressTracker.js'; +import { LanguageSelector } from '/static/assets/js/components/LanguageSelector.js'; // Maak componenten globaal beschikbaar voordat andere componenten worden geladen window.DynamicForm = DynamicForm; @@ -33,9 +34,15 @@ export const ChatApp = { // Maak een lokale kopie van de chatConfig om undefined errors te voorkomen const chatConfig = window.chatConfig || {}; const settings = chatConfig.settings || {}; + const initialLanguage = chatConfig.language || 'nl'; + const originalExplanation = chatConfig.explanation || ''; return { - // Base template data (keeping existing functionality) + // Taal gerelateerde data + currentLanguage: '', + supportedLanguageDetails: chatConfig.supportedLanguageDetails || {}, + allowedLanguages: chatConfig.allowedLanguages || ['nl', 'en', 'fr', 'de'], + originalExplanation: originalExplanation, explanation: chatConfig.explanation || '', // Chat-specific data @@ -164,6 +171,14 @@ export const ChatApp = { // Keyboard shortcuts document.addEventListener('keydown', this.handleGlobalKeydown); + + // Luister naar taalwijzigingen via custom events + document.addEventListener('language-changed', (event) => { + if (event.detail && event.detail.language) { + console.log('ChatApp received language-changed event:', event.detail.language); + this.handleLanguageChange(event.detail.language); + } + }); }, cleanup() { @@ -171,6 +186,113 @@ export const ChatApp = { document.removeEventListener('keydown', this.handleGlobalKeydown); }, + // Taal gerelateerde functionaliteit + handleLanguageChange(newLanguage) { + if (this.currentLanguage !== newLanguage) { + console.log(`ChatApp: Taal gewijzigd van ${this.currentLanguage} naar ${newLanguage}`); + this.currentLanguage = newLanguage; + + // Vertaal de sidebar + this.translateSidebar(newLanguage); + + // Sla de taalvoorkeur op voor toekomstige API calls + this.storeLanguagePreference(newLanguage); + + // Stuur language-changed event voor andere componenten (zoals ChatInput) + // Dit wordt gedaan via het event systeem, waardoor we geen directe referentie nodig hebben + const event = new CustomEvent('language-changed', { + detail: { language: newLanguage } + }); + document.dispatchEvent(event); + } + }, + + // Maak de handleLanguageChange methode toegankelijk van buitenaf + // Deze functie wordt opgeroepen door het externe LanguageSelector component + __handleExternalLanguageChange(newLanguage) { + this.handleLanguageChange(newLanguage); + }, + + storeLanguagePreference(language) { + // Sla op in localStorage voor persistentie + localStorage.setItem('preferredLanguage', language); + + // Update chatConfig voor toekomstige API calls + if (window.chatConfig) { + window.chatConfig.language = language; + } + + console.log(`Taalvoorkeur opgeslagen: ${language}`); + }, + + async translateSidebar(language) { + console.log(`Sidebar wordt vertaald naar: ${language}`); + + // Haal de originele tekst op + const originalText = this.originalExplanation || this.explanation; + + try { + // Controleer of TranslationClient beschikbaar is + if (!window.TranslationClient || typeof window.TranslationClient.translate !== 'function') { + console.error('TranslationClient.translate is niet beschikbaar'); + this.showTranslationIndicator(language, 'Vertaling niet beschikbaar', false); + return; + } + + // Toon loading indicator + this.showTranslationIndicator(language, 'Bezig met vertalen...'); + console.log('API prefix voor vertaling:', this.apiPrefix); + + // Gebruik TranslationClient met de juiste parameters + const response = await window.TranslationClient.translate( + originalText, + language, + null, // source_lang (auto-detect) + 'sidebar_explanation', // context + this.apiPrefix // API prefix voor tenant routing + ); + + if (response.success) { + // Update de explanation variabele + console.log('Translated text: ' + response.translated_text); + this.explanation = response.translated_text; + + // 1. Update de Vue instance + if (window.__vueApp && window.__vueApp._instance) { + window.__vueApp._instance.proxy.explanation = response.translated_text; + } + + // 2. Update direct het DOM-element via marked voor onmiddellijke weergave + const sidebarElement = document.querySelector('.sidebar-explanation'); + if (sidebarElement) { + console.log('DOM-element gevonden, directe update toepassen'); + // Gebruik de marked library om de markdown naar HTML te converteren + let htmlContent; + if (typeof marked === 'function') { + htmlContent = marked(response.translated_text); + } else if (marked && typeof marked.parse === 'function') { + htmlContent = marked.parse(response.translated_text); + } else { + htmlContent = response.translated_text; + } + + // Update de inhoud direct + sidebarElement.innerHTML = htmlContent; + } else { + console.error('Sidebar explanation element niet gevonden in DOM'); + } + + this.showTranslationIndicator(language, 'Vertaling voltooid!', true); + } else { + console.error('Vertaling mislukt:', response.error); + this.showTranslationIndicator(language, 'Vertaling mislukt', false); + } + } catch (error) { + console.error('Fout bij vertalen sidebar:', error); + this.showTranslationIndicator(language, 'Vertaling mislukt', false); + } + }, + // Message management addMessage(content, sender, type = 'text', formData = null, formValues = null) { const message = { @@ -206,6 +328,36 @@ export const ChatApp = { return message; }, + showTranslationIndicator(language, message, success = null) { + const explanationElement = document.querySelector('.sidebar-explanation'); + if (explanationElement) { + // Verwijder eventuele bestaande indicators + const existingIndicator = explanationElement.querySelector('.language-change-indicator'); + if (existingIndicator) { + existingIndicator.remove(); + } + + // Voeg nieuwe indicator toe + const indicator = document.createElement('div'); + indicator.className = 'language-change-indicator'; + if (success === true) indicator.classList.add('success'); + if (success === false) indicator.classList.add('error'); + + indicator.innerHTML = `${message}`; + explanationElement.prepend(indicator); + + // Verwijder na 3 seconden, behalve bij loading + if (success !== null) { + setTimeout(() => { + if (explanationElement.contains(indicator)) { + indicator.remove(); + } + }, 3000); + } + } + }, + + // Helper functie om formulierdata toe te voegen aan bestaande berichten attachFormDataToMessage(messageId, formData, formValues) { const message = this.allMessages.find(m => m.id === messageId); @@ -243,7 +395,8 @@ export const ChatApp = { const apiData = { message: text, conversation_id: this.conversationId, - user_id: this.userId + user_id: this.userId, + language: this.currentLanguage }; const response = await this.callAPI('/api/send_message', apiData); @@ -662,6 +815,7 @@ const initializeApp = () => { window.__vueApp.component('MessageHistory', MessageHistory); window.__vueApp.component('ChatInput', ChatInput); window.__vueApp.component('ProgressTracker', ProgressTracker); + // NB: LanguageSelector wordt niet globaal geregistreerd omdat deze apart gemonteerd wordt console.log('All chat components registered with existing Vue instance'); // Register the ChatApp component @@ -677,5 +831,63 @@ const initializeApp = () => { } }; +// Functie om LanguageSelector toe te voegen aan sidebar +const mountLanguageSelector = () => { + const container = document.getElementById('language-selector-container'); + if (container) { + // Maak een eenvoudige Vue app die alleen de LanguageSelector component mount + const app = Vue.createApp({ + components: { LanguageSelector }, + data() { + return { + currentLanguage: window.chatConfig?.language || 'nl', + supportedLanguageDetails: window.chatConfig?.supportedLanguageDetails || {}, + allowedLanguages: window.chatConfig?.allowedLanguages || ['nl', 'en', 'fr', 'de'] + }; + }, + methods: { + handleLanguageChange(newLanguage) { + console.log(`LanguageSelector: Taal gewijzigd naar ${newLanguage}`); + + // Gebruik ALLEEN de custom event benadering + const event = new CustomEvent('language-changed', { + detail: { language: newLanguage } + }); + document.dispatchEvent(event); + } + }, + template: ` + + ` + }); + + app.component('LanguageSelector', LanguageSelector); + app.mount('#language-selector-container'); + console.log('Language selector mounted in sidebar'); + } else { + console.warn('Language selector container not found'); + } +}; + // Initialize app when DOM is ready -document.addEventListener('DOMContentLoaded', initializeApp); \ No newline at end of file +document.addEventListener('DOMContentLoaded', () => { + console.log('DOM content loaded, initializing application...'); + + // Eerst de hoofdapplicatie initialiseren + initializeApp(); + + // Dan de taal selector monteren met een kleine vertraging + // om er zeker van te zijn dat de container is aangemaakt + setTimeout(() => { + try { + mountLanguageSelector(); + } catch (e) { + console.error('Fout bij het monteren van de taal selector:', e); + } + }, 200); +}); \ No newline at end of file diff --git a/eveai_chat_client/static/assets/js/components/ChatInput.js b/eveai_chat_client/static/assets/js/components/ChatInput.js index 705181a..96936e5 100644 --- a/eveai_chat_client/static/assets/js/components/ChatInput.js +++ b/eveai_chat_client/static/assets/js/components/ChatInput.js @@ -4,20 +4,20 @@ // Anders moet je ervoor zorgen dat MaterialIconManager.js eerder wordt geladen // en iconManager beschikbaar is via window.iconManager - // Voeg stylesheet toe voor ChatInput-specifieke stijlen - const addStylesheet = () => { - if (!document.querySelector('link[href*="chat-input.css"]')) { - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = '/static/assets/css/chat-input.css'; - document.head.appendChild(link); - } - }; +// Voeg stylesheet toe voor ChatInput-specifieke stijlen +const addStylesheet = () => { + if (!document.querySelector('link[href*="chat-input.css"]')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/static/assets/css/chat-input.css'; + document.head.appendChild(link); + } +}; - // Laad de stylesheet - addStylesheet(); +// Laad de stylesheet +addStylesheet(); - export const ChatInput = { +export const ChatInput = { name: 'ChatInput', components: { 'dynamic-form': window.DynamicForm @@ -29,6 +29,22 @@ if (window.iconManager && this.formData && this.formData.icon) { window.iconManager.ensureIconsLoaded({}, [this.formData.icon]); } + + // Maak een benoemde handler voor betere cleanup + this.languageChangeHandler = (event) => { + if (event.detail && event.detail.language) { + this.handleLanguageChange(event); + } + }; + + // Luister naar taalwijzigingen + document.addEventListener('language-changed', this.languageChangeHandler); + }, + beforeUnmount() { + // Verwijder event listener bij unmount met de benoemde handler + if (this.languageChangeHandler) { + document.removeEventListener('language-changed', this.languageChangeHandler); + } }, props: { currentMessage: { @@ -41,7 +57,7 @@ }, placeholder: { type: String, - default: 'Typ je bericht hier... (Enter om te verzenden, Shift+Enter voor nieuwe regel)' + default: 'Typ je bericht hier... - Enter om te verzenden, Shift+Enter voor nieuwe regel' }, maxLength: { type: Number, @@ -103,7 +119,10 @@ data() { return { localMessage: this.currentMessage, - formValues: {} + formValues: {}, + translatedPlaceholder: this.placeholder, + isTranslating: false, + languageChangeHandler: null }; }, computed: { @@ -150,6 +169,56 @@ } }, methods: { + handleLanguageChange(event) { + if (event.detail && event.detail.language) { + this.translatePlaceholder(event.detail.language); + } + }, + + async translatePlaceholder(language) { + // Voorkom dubbele vertaling + if (this.isTranslating) { + console.log('Placeholder vertaling al bezig, overslaan...'); + return; + } + + // Zet de vertaling vlag + this.isTranslating = true; + + // Gebruik de originele placeholder als basis voor vertaling + const originalText = this.placeholder; + + try { + // Controleer of TranslationClient beschikbaar is + if (!window.TranslationClient || typeof window.TranslationClient.translate !== 'function') { + console.error('TranslationClient.translate is niet beschikbaar voor placeholder'); + return; + } + + // Gebruik TranslationClient zonder UI indicator + const apiPrefix = window.chatConfig?.apiPrefix || ''; + const response = await window.TranslationClient.translate( + originalText, + language, + null, // source_lang (auto-detect) + 'chat_input_placeholder', // context + apiPrefix // API prefix voor tenant routing + ); + + if (response.success) { + // Update de placeholder + this.translatedPlaceholder = response.translated_text; + } else { + console.error('Vertaling placeholder mislukt:', response.error); + } + } catch (error) { + console.error('Fout bij vertalen placeholder:', error); + } finally { + // Reset de vertaling vlag + this.isTranslating = false; + } + }, + initFormValues() { if (this.formData && this.formData.fields) { console.log('Initializing form values for fields:', this.formData.fields); @@ -300,7 +369,7 @@ ref="messageInput" v-model="localMessage" @keydown="handleKeydown" - :placeholder="placeholder" + :placeholder="translatedPlaceholder" rows="1" :disabled="isLoading" :maxlength="maxLength" diff --git a/eveai_chat_client/static/assets/js/components/ChatMessage.js b/eveai_chat_client/static/assets/js/components/ChatMessage.js index 5d96d69..f3a6a0c 100644 --- a/eveai_chat_client/static/assets/js/components/ChatMessage.js +++ b/eveai_chat_client/static/assets/js/components/ChatMessage.js @@ -52,6 +52,11 @@ export const ChatMessage = { if (window.iconManager && this.message.formData && this.message.formData.icon) { window.iconManager.loadIcon(this.message.formData.icon); } + + // Sla de originele inhoud op voor het eerste bericht als we in een conversatie zitten met slechts één bericht + if (this.message.sender === 'ai' && !this.message.originalContent) { + this.message.originalContent = this.message.content; + } }, watch: { 'message.formData.icon': { @@ -69,6 +74,14 @@ export const ChatMessage = { formVisible: true }; }, + mounted() { + // Luister naar taalwijzigingen + document.addEventListener('language-changed', this.handleLanguageChange); + }, + beforeUnmount() { + // Verwijder event listener bij verwijderen component + document.removeEventListener('language-changed', this.handleLanguageChange); + }, computed: { hasFormData() { return this.message.formData && @@ -80,6 +93,12 @@ export const ChatMessage = { } }, methods: { + async handleLanguageChange(event) { + // Controleer of dit het eerste bericht is in een gesprek met maar één bericht + // Dit wordt al afgehandeld door MessageHistory component, dus we hoeven hier niets te doen + // De implementatie hiervan blijft in MessageHistory om dubbele vertaling te voorkomen + }, + handleSpecialistError(eventData) { console.log('ChatMessage received specialist-error event:', eventData); diff --git a/eveai_chat_client/static/assets/js/components/LanguageSelector.js b/eveai_chat_client/static/assets/js/components/LanguageSelector.js new file mode 100644 index 0000000..f386d19 --- /dev/null +++ b/eveai_chat_client/static/assets/js/components/LanguageSelector.js @@ -0,0 +1,118 @@ + +export const LanguageSelector = { + name: 'LanguageSelector', + props: { + initialLanguage: { + type: String, + default: 'nl' + }, + supportedLanguageDetails: { + type: Object, + default: () => ({}) + }, + allowedLanguages: { + type: Array, + default: () => ['nl', 'en', 'fr', 'de'] + } + }, + computed: { + availableLanguages() { + // Maak een array van taalobjecten op basis van de toegestane talen + // en de ondersteunde taaldetails + const languages = []; + + // Als er geen toegestane talen zijn of de lijst is leeg, gebruik een standaardlijst + const languagesToUse = (this.allowedLanguages && this.allowedLanguages.length > 0) + ? this.allowedLanguages + : ['nl', 'en', 'fr', 'de']; + + // Loop door alle ondersteunde taaldetails + if (this.supportedLanguageDetails && Object.keys(this.supportedLanguageDetails).length > 0) { + // Vind talen die overeenkomen met toegestane taalcodes + for (const [langName, langDetails] of Object.entries(this.supportedLanguageDetails)) { + const isoCode = langDetails['iso 639-1']; + if (languagesToUse.includes(isoCode)) { + languages.push({ + code: isoCode, + name: langName, + flag: langDetails.flag || '' + }); + } + } + } else { + // Fallback als er geen taaldetails beschikbaar zijn + const defaultLanguages = { + 'nl': { name: 'Nederlands', flag: '🇳🇱' }, + 'en': { name: 'English', flag: '🇬🇧' }, + 'fr': { name: 'Français', flag: '🇫🇷' }, + 'de': { name: 'Deutsch', flag: '🇩🇪' } + }; + + languagesToUse.forEach(code => { + if (defaultLanguages[code]) { + languages.push({ + code: code, + name: defaultLanguages[code].code, + flag: defaultLanguages[code].flag + }); + } + }); + } + + console.log('LanguageSelector: availableLanguages computed:', languages); + return languages; + } + }, + emits: ['language-changed'], + data() { + return { + selectedLanguage: this.initialLanguage, + currentLanguage: this.initialLanguage + }; + }, + mounted() { + console.log('LanguageSelector mounted with:', { + initialLanguage: this.initialLanguage, + selectedLanguage: this.selectedLanguage, + availableLanguages: this.availableLanguages + }); + + // Stuur het language-changed event uit met de initiële taal + console.log(`LanguageSelector: Emitting language-changed event for initial language ${this.initialLanguage}`); + this.$emit('language-changed', this.initialLanguage); + }, + methods: { + changeLanguage(languageCode) { + console.log(`LanguageSelector: changeLanguage called with ${languageCode}, current: ${this.currentLanguage}`); + + if (this.currentLanguage !== languageCode) { + console.log(`LanguageSelector: Emitting language-changed event for ${languageCode}`); + this.currentLanguage = languageCode; + this.$emit('language-changed', languageCode); + } else { + console.log(`LanguageSelector: No change needed, already ${languageCode}`); + } + } + }, + template: ` +
+ +
+ +
+
+ ` +}; \ No newline at end of file diff --git a/eveai_chat_client/static/assets/js/components/MessageHistory.js b/eveai_chat_client/static/assets/js/components/MessageHistory.js index ec6ee63..92add92 100644 --- a/eveai_chat_client/static/assets/js/components/MessageHistory.js +++ b/eveai_chat_client/static/assets/js/components/MessageHistory.js @@ -27,12 +27,21 @@ export const MessageHistory = { data() { return { isAtBottom: true, - unreadCount: 0 + unreadCount: 0, + originalFirstMessage: null, + isTranslating: false, // Vlag om dubbele vertaling te voorkomen + languageChangeHandler: null // Referentie voor cleanup }; }, mounted() { this.scrollToBottom(); this.setupScrollListener(); + this.listenForLanguageChanges(); + + // Sla de originele inhoud van het eerste bericht op als er maar één bericht is + if (this.messages.length === 1 && this.messages[0].sender === 'ai') { + this.originalFirstMessage = this.messages[0].content; + } }, updated() { if (this.autoScroll && this.isAtBottom) { @@ -40,6 +49,73 @@ export const MessageHistory = { } }, methods: { + listenForLanguageChanges() { + // Maak een benoemde handler voor cleanup + this.languageChangeHandler = (event) => { + if (event.detail && event.detail.language) { + this.translateFirstMessageIfNeeded(event.detail.language); + } + }; + + document.addEventListener('language-changed', this.languageChangeHandler); + }, + + async translateFirstMessageIfNeeded(language) { + // Voorkom dubbele vertaling + if (this.isTranslating) { + console.log('Vertaling al bezig, overslaan...'); + return; + } + + // Alleen vertalen als er precies één bericht is en het is van de AI + if (this.messages.length === 1 && this.messages[0].sender === 'ai') { + const firstMessage = this.messages[0]; + + // Controleer of we een origineel bericht hebben om te vertalen + const contentToTranslate = this.originalFirstMessage || firstMessage.content; + + // Sla het originele bericht op als we dat nog niet hebben gedaan + if (!this.originalFirstMessage) { + this.originalFirstMessage = contentToTranslate; + } + + // Zet de vertaling vlag + this.isTranslating = true; + + try { + // Controleer of de vertaalclient beschikbaar is + if (!window.TranslationClient || typeof window.TranslationClient.translate !== 'function') { + console.error('TranslationClient.translate is niet beschikbaar'); + return; + } + + console.log(`Vertalen van eerste bericht naar ${language}`); + + // Vertaal het bericht met de juiste context + const response = await window.TranslationClient.translate( + contentToTranslate, + language, + null, // source_lang (auto-detect) + 'first_message', // context + this.apiPrefix // API prefix voor tenant routing + ); + + if (response.success) { + console.log('Vertaling van eerste bericht voltooid:', response.translated_text); + // Update het bericht zonder een indicator te tonen + firstMessage.content = response.translated_text; + } else { + console.error('Vertaling van eerste bericht mislukt:', response.error); + } + } catch (error) { + console.error('Fout bij het vertalen van eerste bericht:', error); + } finally { + // Reset de vertaling vlag + this.isTranslating = false; + } + } + }, + scrollToBottom() { const container = this.$refs.messagesContainer; if (container) { @@ -98,6 +174,11 @@ export const MessageHistory = { if (container) { container.removeEventListener('scroll', this.handleScroll); } + + // Cleanup language change listener + if (this.languageChangeHandler) { + document.removeEventListener('language-changed', this.languageChangeHandler); + } }, template: `
diff --git a/eveai_chat_client/static/assets/js/translation.js b/eveai_chat_client/static/assets/js/translation.js new file mode 100644 index 0000000..21ebe6e --- /dev/null +++ b/eveai_chat_client/static/assets/js/translation.js @@ -0,0 +1,63 @@ +/** + * EveAI Vertaal API Client + * Functies voor het vertalen van tekst via de EveAI API + */ + +const TranslationClient = { + /** + * Vertaalt een tekst naar de opgegeven doeltaal + * + * @param {string} text - De te vertalen tekst + * @param {string} targetLang - ISO 639-1 taalcode van de doeltaal + * @param {string|null} sourceLang - (Optioneel) ISO 639-1 taalcode van de brontaal + * @param {string|null} context - (Optioneel) Context voor de vertaling + * @param {string|null} apiPrefix - (Optioneel) API prefix voor tenant routing + * @returns {Promise} - Een promise met het vertaalresultaat + */ + translate: async function(text, targetLang, sourceLang = null, context = null, apiPrefix = '') { + try { + // Voorbereiding van de aanvraagdata + const requestData = { + text: text, + target_lang: targetLang + }; + + // Voeg optionele parameters toe indien aanwezig + if (sourceLang) requestData.source_lang = sourceLang; + if (context) requestData.context = context; + + // Bouw de juiste endpoint URL met prefix + const endpoint = `${apiPrefix}/chat/api/translate`; + console.log(`Vertaling aanvragen op endpoint: ${endpoint}`); + + // Doe het API-verzoek + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestData) + }); + + // Controleer of het verzoek succesvol was + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Onbekende fout bij vertalen'); + } + + // Verwerk het resultaat + return await response.json(); + + } catch (error) { + console.error('Vertaalfout:', error); + throw error; + } + } +}; + +// Maak TranslationClient globaal beschikbaar +window.TranslationClient = TranslationClient; + +// Geen auto-initialisatie meer nodig +// De Vue-based LanguageSelector component neemt deze taak over +console.log('TranslationClient geladen en klaar voor gebruik'); diff --git a/eveai_chat_client/templates/base.html b/eveai_chat_client/templates/base.html index 2a4110c..eceebb7 100644 --- a/eveai_chat_client/templates/base.html +++ b/eveai_chat_client/templates/base.html @@ -7,13 +7,17 @@ + - - + + + + + {% block head %}{% endblock %} -
+
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/eveai_chat_client/views/chat_views.py b/eveai_chat_client/views/chat_views.py index dd4fb90..96cf118 100644 --- a/eveai_chat_client/views/chat_views.py +++ b/eveai_chat_client/views/chat_views.py @@ -1,3 +1,4 @@ +import json import uuid from flask import Blueprint, render_template, request, session, current_app, jsonify, Response, stream_with_context from sqlalchemy.exc import SQLAlchemyError @@ -6,9 +7,12 @@ from common.extensions import db from common.models.user import Tenant, SpecialistMagicLinkTenant, TenantMake from common.models.interaction import SpecialistMagicLink, Specialist, ChatSession, Interaction from common.services.interaction.specialist_services import SpecialistServices +from common.utils.business_event import BusinessEvent +from common.utils.business_event_context import current_event from common.utils.database import Database from common.utils.chat_utils import get_default_chat_customisation from common.utils.execution_progress import ExecutionProgressTracker +from common.extensions import cache_manager chat_bp = Blueprint('chat_bp', __name__, url_prefix='/chat') @@ -103,13 +107,20 @@ def chat(magic_link_code): "auto_scroll": True } + specialist_config = specialist.configuration + if isinstance(specialist_config, str): + specialist_config = json.loads(specialist_config) + + welcome_message = specialist_config.get('welcome_message', 'Hello! How can I help you today?') + return render_template('chat.html', tenant=tenant, tenant_make=tenant_make, specialist=specialist, customisation=customisation, - messages=[customisation['welcome_message']], - settings=settings + messages=[welcome_message], + settings=settings, + config=current_app.config ) except Exception as e: @@ -149,6 +160,11 @@ def send_message(): if form_values: specialist_args['form_values'] = form_values + # Add language to specialist arguments if present + user_language = data.get('language') + if user_language: + specialist_args['language'] = user_language + current_app.logger.debug(f"Sending message to specialist: {specialist_id} for tenant {tenant_id}\n" f" with args: {specialist_args}\n" f"with session ID: {chat_session_id}") @@ -255,3 +271,78 @@ def task_progress_stream(task_id): current_app.logger.error(f"Failed to start progress stream: {str(e)}") return jsonify({'error': str(e)}), 500 +@chat_bp.route('/api/translate', methods=['POST']) +def translate(): + """ + API endpoint om tekst te vertalen naar een doeltaal + + Parameters (JSON): + - text: de tekst die moet worden vertaald + - target_lang: de ISO 639-1 taalcode waarnaar moet worden vertaald + - source_lang: (optioneel) de ISO 639-1 taalcode van de brontaal + - context: (optioneel) context voor de vertaling + + Returns: + JSON met vertaalde tekst + """ + try: + tenant_id = session.get('tenant', {}).get('id') + with BusinessEvent('Client Translation Service', tenant_id): + with current_event.create_span('Front-End Translation'): + data = request.json + + # Valideer vereiste parameters + if not data or 'text' not in data or 'target_lang' not in data: + return jsonify({ + 'success': False, + 'error': 'Required parameters missing: text and/or target_lang' + }), 400 + + text = data.get('text') + target_lang = data.get('target_lang') + source_lang = data.get('source_lang') + context = data.get('context') + + # Controleer of tekst niet leeg is + if not text.strip(): + return jsonify({ + 'success': False, + 'error': 'Text to translate cannot be empty' + }), 400 + + # Haal tenant_id uit sessie + tenant_id = session.get('tenant', {}).get('id') + if not tenant_id: + current_app.logger.error("No tenant ID found in session") + # Fallback naar huidige app tenant_id + tenant_id = getattr(current_app, 'tenant_id', None) + + # Haal vertaling op (uit cache of genereer nieuw) + translation = cache_manager.translation_cache.get_translation( + text=text, + target_lang=target_lang, + source_lang=source_lang, + context=context + ) + + if not translation: + return jsonify({ + 'success': False, + 'error': 'No translation found in cache. Please try again later.' + }), 500 + + # Retourneer het resultaat + return jsonify({ + 'success': True, + 'translated_text': translation.translated_text, + 'source_language': translation.source_language, + 'target_language': translation.target_language + }) + + except Exception as e: + current_app.logger.error(f"Error translating: {str(e)}", exc_info=True) + return jsonify({ + 'success': False, + 'error': f"Error translating: {str(e)}" + }), 500 + diff --git a/eveai_chat_workers/__init__.py b/eveai_chat_workers/__init__.py index 5cfb2b4..21f563a 100644 --- a/eveai_chat_workers/__init__.py +++ b/eveai_chat_workers/__init__.py @@ -52,6 +52,8 @@ def register_cache_handlers(app): register_specialist_cache_handlers(cache_manager) from eveai_chat_workers.chat_session_cache import register_chat_session_cache_handlers register_chat_session_cache_handlers(cache_manager) + from common.utils.cache.translation_cache import register_translation_cache_handlers + register_translation_cache_handlers(cache_manager) app, celery = create_app() diff --git a/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_3.py b/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_3.py index f3a5486..d231145 100644 --- a/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_3.py +++ b/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_3.py @@ -12,6 +12,7 @@ from sqlalchemy.exc import SQLAlchemyError from common.extensions import db from common.models.user import Tenant from common.models.interaction import Specialist +from common.services.utils.translation_services import TranslationServices from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem from eveai_chat_workers.outputs.traicie.knockout_questions.knockout_questions_v1_0 import KOQuestions, KOQuestion from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor @@ -177,8 +178,15 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor): "fields": fields, } + answer = f"Let's start our selection process by asking you a few important questions." + + if arguments.language != 'en': + TranslationServices.translate_config(ko_form, "fields", arguments.language) + TranslationServices.translate(answer, arguments.language) + + results = SpecialistResult.create_for_type(self.type, self.type_version, - answer=f"We starten met een aantal KO Criteria vragen", + answer=answer, form_request=ko_form, phase="ko_question_evaluation") @@ -208,15 +216,27 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor): break if evaluation == "negative": + answer = (f"We hebben de antwoorden op onze eerste vragen verwerkt. Je voldoet jammer genoeg niet aan de " + f"minimale vereisten voor deze job.") + if arguments.language != 'nl': + answer = TranslationServices.translate(answer, arguments.language) + results = SpecialistResult.create_for_type(self.type, self.type_version, - answer=f"We hebben de antwoorden op de KO criteria verwerkt. Je voldoet jammer genoeg niet aan de minimale vereisten voor deze job.", + answer=answer, form_request=None, phase="no_valid_candidate") else: + answer = (f"We hebben de antwoorden op de KO criteria verwerkt. Je bent een geschikte kandidaat. " + f"Ben je bereid je contactgegevens door te geven, zodat we je kunnen contacteren voor een verder " + f"gesprek?") # Check if answers to questions are positive contact_form = cache_manager.specialist_forms_config_cache.get_config("PERSONAL_CONTACT_FORM", "1.0") + if arguments.language != 'nl': + answer = TranslationServices.translate(answer, arguments.language) + if arguments.language != 'en': + contact_form = TranslationServices.translate_config(contact_form, "fields", arguments.language) results = SpecialistResult.create_for_type(self.type, self.type_version, - answer=f"We hebben de antwoorden op de KO criteria verwerkt. Je bent een geschikte kandidaat. Kan je je contactegevens doorgeven?", + answer=answer, form_request=contact_form, phase="personal_contact_data") diff --git a/migrations/public/versions/057fb975f0e3_remove_not_null_constraint_from_source_.py b/migrations/public/versions/057fb975f0e3_remove_not_null_constraint_from_source_.py new file mode 100644 index 0000000..b78ed79 --- /dev/null +++ b/migrations/public/versions/057fb975f0e3_remove_not_null_constraint_from_source_.py @@ -0,0 +1,36 @@ +"""Remove not null constraint from source language in TranslationCache + +Revision ID: 057fb975f0e3 +Revises: bb67d16f428a +Create Date: 2025-06-27 13:09:28.171399 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '057fb975f0e3' +down_revision = 'bb67d16f428a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('translation_cache', schema=None) as batch_op: + batch_op.alter_column('source_language', + existing_type=sa.VARCHAR(length=2), + nullable=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('translation_cache', schema=None) as batch_op: + batch_op.alter_column('source_language', + existing_type=sa.VARCHAR(length=2), + nullable=False) + + # ### end Alembic commands ### diff --git a/migrations/public/versions/bb67d16f428a_move_default_language_to_tenant_make.py b/migrations/public/versions/bb67d16f428a_move_default_language_to_tenant_make.py new file mode 100644 index 0000000..e57d32b --- /dev/null +++ b/migrations/public/versions/bb67d16f428a_move_default_language_to_tenant_make.py @@ -0,0 +1,40 @@ +"""Move default language to Tenant Make + +Revision ID: bb67d16f428a +Revises: e47dc002b678 +Create Date: 2025-06-27 06:38:35.092499 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'bb67d16f428a' +down_revision = 'e47dc002b678' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tenant', schema=None) as batch_op: + batch_op.drop_column('allowed_languages') + batch_op.drop_column('default_language') + + with op.batch_alter_table('tenant_make', schema=None) as batch_op: + batch_op.add_column(sa.Column('default_language', sa.String(length=2), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tenant_make', schema=None) as batch_op: + batch_op.drop_column('default_language') + + with op.batch_alter_table('tenant', schema=None) as batch_op: + batch_op.add_column(sa.Column('default_language', sa.VARCHAR(length=2), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('allowed_languages', postgresql.ARRAY(sa.VARCHAR(length=2)), autoincrement=False, nullable=True)) + + # ### end Alembic commands ###