Merge branch 'release/3.1.1-alfa'

This commit is contained in:
Josako
2025-09-22 14:57:24 +02:00
17 changed files with 487 additions and 267 deletions

View File

@@ -1,4 +1,6 @@
import json
import copy
import re
from typing import Dict, Any, Optional
from flask import session
@@ -50,8 +52,8 @@ class TranslationServices:
if isinstance(config_data, str):
config_data = json.loads(config_data)
# Maak een kopie van de originele data om te wijzigen
translated_config = config_data.copy()
# Maak een deep copy van de originele data om te wijzigen en input-mutatie te vermijden
translated_config = copy.deepcopy(config_data)
# Haal type en versie op voor de Business Event span
config_type = config_data.get('type', 'Unknown')
@@ -65,71 +67,124 @@ class TranslationServices:
if not context and 'metadata' in config_data and 'description' in config_data['metadata']:
description_context = config_data['metadata']['description']
# Hulpfuncties
def is_nonempty_str(val: Any) -> bool:
return isinstance(val, str) and val.strip() != ''
def safe_translate(text: str, ctx: Optional[str]):
try:
res = cache_manager.translation_cache.get_translation(
text=text,
target_lang=target_language,
source_lang=source_language,
context=ctx
)
return res.translated_text if res else None
except Exception as e:
if current_event:
current_event.log_error('translation_error', {
'tenant_id': tenant_id,
'config_type': config_type,
'config_version': config_version,
'field_config': field_config,
'error': str(e)
})
return None
tag_pair_pattern = re.compile(r'<([a-zA-Z][\w-]*)>[\s\S]*?<\/\1>')
def extract_tag_counts(text: str) -> Dict[str, int]:
counts: Dict[str, int] = {}
for m in tag_pair_pattern.finditer(text or ''):
tag = m.group(1)
counts[tag] = counts.get(tag, 0) + 1
return counts
def tags_valid(source: str, translated: str) -> bool:
return extract_tag_counts(source) == extract_tag_counts(translated)
# Counters
meta_consentRich_translated_count = 0
meta_aria_translated_count = 0
meta_inline_tags_invalid_after_translation_count = 0
# 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
# Vertaal name als het bestaat en niet leeg is (alleen strings)
if 'name' in field_data and is_nonempty_str(field_data['name']):
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
t = safe_translate(field_data['name'], field_context)
if t:
translated_config[field_config][field_name]['name'] = t
if 'title' in field_data and field_data['title']:
# Gebruik context indien opgegeven, anders description_context
if 'title' in field_data and is_nonempty_str(field_data.get('title')):
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
t = safe_translate(field_data['title'], field_context)
if t:
translated_config[field_config][field_name]['title'] = t
# 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
if 'description' in field_data and is_nonempty_str(field_data.get('description')):
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
t = safe_translate(field_data['description'], field_context)
if t:
translated_config[field_config][field_name]['description'] = t
# 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
if 'context' in field_data and is_nonempty_str(field_data.get('context')):
t = safe_translate(field_data['context'], context)
if t:
translated_config[field_config][field_name]['context'] = t
# vertaal allowed values als het veld bestaat en de waarden niet leeg zijn.
if 'allowed_values' in field_data and field_data['allowed_values']:
# vertaal allowed_values als het veld bestaat en waarden niet leeg zijn (alleen string-items)
if 'allowed_values' in field_data and isinstance(field_data['allowed_values'], list) and field_data['allowed_values']:
translated_allowed_values = []
for allowed_value in field_data['allowed_values']:
translated_allowed_value = cache_manager.translation_cache.get_translation(
text=allowed_value,
target_lang=target_language,
source_lang=source_language,
context=context
)
translated_allowed_values.append(translated_allowed_value.translated_text)
if is_nonempty_str(allowed_value):
t = safe_translate(allowed_value, context)
translated_allowed_values.append(t if t else allowed_value)
else:
translated_allowed_values.append(allowed_value)
if translated_allowed_values:
translated_config[field_config][field_name]['allowed_values'] = translated_allowed_values
# Vertaal meta.consentRich en meta.aria*
meta = field_data.get('meta')
if isinstance(meta, dict):
# consentRich
if is_nonempty_str(meta.get('consentRich')):
consent_ctx = (context if context else description_context) or ''
consent_ctx = f"Consent rich text with inline tags. Keep tag names intact and translate only inner text. {consent_ctx}".strip()
t = safe_translate(meta['consentRich'], consent_ctx)
if t and tags_valid(meta['consentRich'], t):
translated_config[field_config][field_name].setdefault('meta', {})['consentRich'] = t
meta_consentRich_translated_count += 1
else:
if t and not tags_valid(meta['consentRich'], t) and current_event:
src_counts = extract_tag_counts(meta['consentRich'])
dst_counts = extract_tag_counts(t)
current_event.log_error('inline_tags_validation_failed', {
'tenant_id': tenant_id,
'config_type': config_type,
'config_version': config_version,
'field_config': field_config,
'field_name': field_name,
'target_language': target_language,
'source_tag_counts': src_counts,
'translated_tag_counts': dst_counts
})
meta_inline_tags_invalid_after_translation_count += 1
# fallback: keep original (already in deep copy)
# aria*
for k, v in list(meta.items()):
if isinstance(k, str) and k.startswith('aria') and is_nonempty_str(v):
aria_ctx = (context if context else description_context) or ''
aria_ctx = f"ARIA label for accessibility. Short, imperative, descriptive. Form '{config_type} {config_version}', field '{field_name}'. {aria_ctx}".strip()
t2 = safe_translate(v, aria_ctx)
if t2:
translated_config[field_config][field_name].setdefault('meta', {})[k] = t2
meta_aria_translated_count += 1
return translated_config
@staticmethod

View File

@@ -9,8 +9,11 @@ content: >
'{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!
These are best practices you should follow:
- Do not translate text in between double square brackets, as these are names or terms that need to remain intact. Remove the square brackets in the translation!
- We use inline tags (Custom HTML/XML-like tags). Ensure the tags themself are not translated and remain intact in the translation. The text inbetween the tags should be translated. e.g. "<terms_and_conditions>Terms & Conditions</terms_and_conditions>" translates in Dutch to <terms_and_conditions>Gebruiksvoorwaarden</terms_and_conditions>
- 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.

View File

@@ -6,8 +6,11 @@ content: >
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!
These are best practices you should follow:
- Do not translate text in between double square brackets, as these are names or terms that need to remain intact. Remove the square brackets in the translation!
- We use inline tags (Custom HTML/XML-like tags). Ensure the tags themself are not translated and remain intact in the translation. The text inbetween the tags should be translated. e.g. "<terms_and_conditions>Terms & Conditions</terms_and_conditions>" translates in Dutch to <terms_and_conditions>Gebruiksvoorwaarden</terms_and_conditions>
- 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.

View File

@@ -24,6 +24,11 @@ fields:
type: "boolean"
description: "Consent"
required: true
meta:
kind: "consent"
consentRich: "Ik Agree with the <terms>Terms and Conditions</terms> and the <privacy>Privacy Statement</privacy> of Ask Eve AI"
ariaPrivacy: "Open privacyverklaring in a modal dialog"
ariaTerms: "Open algemene voorwaarden in a modal dialog"
metadata:
author: "Josako"
date_added: "2025-07-29"

View File

@@ -5,6 +5,17 @@ All notable changes to EveAI will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.1.1-alfa]
### Fixed
- TRA-76 - Send Button color changes implemented
- TRA-72 - Translation of privacy statement and T&C
- TRA-73 - Strange characters in Tenant Make Name
- TRA-77 - Adapted Scroll behavior for Chat Client in Message History
### Security
- In case of vulnerabilities.
## [3.1.0-alfa]
### Added

View File

@@ -101,6 +101,19 @@ erDiagram
int updated_by FK
}
EVE_AI_DATA_CAPSULE {
int id PK
int chat_session_id FK
string type
string type_version
jsonb configuration
jsonb data
datetime created_at
int created_by FK
datetime updated_at
int updated_by FK
}
DISPATCHER {
int id PK
string name
@@ -188,24 +201,28 @@ erDiagram
%% Main conversation flow
USER ||--o{ CHAT_SESSION : "has many"
CHAT_SESSION ||--o{ INTERACTION : "has many"
CHAT_SESSION ||--o{ EVE_AI_DATA_CAPSULE : "has many"
SPECIALIST ||--o{ INTERACTION : "processes"
%% Specialist composition (EveAI components)
SPECIALIST ||--o{ EVE_AI_AGENT : "has many"
SPECIALIST ||--o{ EVE_AI_TASK : "has many"
SPECIALIST ||--o{ EVE_AI_TOOL : "has many"
%% Specialist connections
SPECIALIST ||--o{ SPECIALIST_RETRIEVER : "uses retrievers"
RETRIEVER ||--o{ SPECIALIST_RETRIEVER : "used by specialists"
SPECIALIST ||--o{ SPECIALIST_DISPATCHER : "uses dispatchers"
DISPATCHER ||--o{ SPECIALIST_DISPATCHER : "used by specialists"
%% Interaction results
INTERACTION ||--o{ INTERACTION_EMBEDDING : "references embeddings"
EMBEDDING ||--o{ INTERACTION_EMBEDDING : "used in interactions"
%% Magic links for specialist access
SPECIALIST ||--o{ SPECIALIST_MAGIC_LINK : "has magic links"
TENANT_MAKE ||--o{ SPECIALIST_MAGIC_LINK : "branded links"
TENANT_MAKE ||--o{ SPECIALIST_MAGIC_LINK : "branded links"
%% User relationships for audit trails
USER ||--o{ EVE_AI_DATA_CAPSULE : "created/updated by"

View File

@@ -1,45 +1,3 @@
/* Chat App Container Layout */
.chat-app-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 0; /* Belangrijk voor flexbox overflow */
padding: 20px; /* Algemene padding voor alle kanten */
box-sizing: border-box;
}
/* Gemeenschappelijke container voor consistente breedte */
.chat-component-container {
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
flex: 1; /* Neemt beschikbare verticale ruimte in */
}
/* Message Area - neemt alle beschikbare ruimte */
.chat-messages-area {
flex: 1; /* Neemt alle beschikbare ruimte */
overflow: hidden; /* Voorkomt dat het groter wordt dan container */
display: flex;
flex-direction: column;
min-height: 0; /* Belangrijk voor nested flexbox */
margin-bottom: 20px; /* Ruimte tussen messages en input */
border-radius: 15px;
background: var(--history-background);
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
align-self: center; /* Extra centrering in flexbox context */
}
/* Chat Input - altijd onderaan */
.chat-input-area {
flex: none; /* Neemt alleen benodigde ruimte */
@@ -56,14 +14,6 @@
align-self: center; /* Extra centrering in flexbox context */
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding-right: 10px; /* Ruimte voor scrollbar */
margin-right: -10px; /* Compenseer voor scrollbar */
scroll-behavior: smooth;
}
/* Chat Input styling */
.chat-input {

View File

@@ -99,15 +99,6 @@ body {
width: 100%;
}
/* Chat layout */
.chat-container {
display: flex;
height: 100%;
flex: 1;
flex-direction: column;
min-height: 0;
}
.sidebar {
width: 280px;
background-color: var(--sidebar-background);

View File

@@ -549,20 +549,40 @@ export default {
</script>
<style scoped>
.chat-app-container {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
min-height: 0;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
padding: 20px;
box-sizing: border-box;
}
.chat-messages-area {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
margin-bottom: 20px; /* Ruimte tussen messages en input */
border-radius: 15px;
background: var(--history-background);
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
align-self: center; /* Extra centrering in flexbox context */
}
.chat-input-area {
flex-shrink: 0;
flex: 0 0 auto;
}
/* Responsive adjustments */

View File

@@ -6,7 +6,7 @@
<!-- EveAI Logo voor AI berichten - links boven, half buiten de bubbel -->
<img
v-if="message.sender === 'ai'"
:src="staticUrl('assets/img/eveai_logo.svg')"
:src="staticUrl('/assets/img/eveai_logo.svg')"
alt="EveAI"
class="ai-message-logo"
/>
@@ -105,7 +105,7 @@
<!-- EveAI Logo voor AI berichten - links boven, half buiten de bubbel -->
<img
v-if="message.sender === 'ai'"
:src="staticUrl('assets/img/eveai_logo.svg')"
:src="staticUrl('/assets/img/eveai_logo.svg')"
alt="EveAI"
class="ai-message-logo"
/>
@@ -124,7 +124,7 @@
<!-- EveAI Logo voor AI berichten - links boven, half buiten de bubbel -->
<img
v-if="message.sender === 'ai'"
:src="staticUrl('assets/img/eveai_logo.svg')"
:src="staticUrl('/assets/img/eveai_logo.svg')"
alt="EveAI"
class="ai-message-logo"
/>

View File

@@ -0,0 +1,94 @@
<template>
<span class="consent-rich-text">
<template v-for="(node, idx) in nodes" :key="idx">
<component
v-if="node.type !== 'text'"
:is="linkTag"
:href="linkTag === 'a' ? '#' : undefined"
class="consent-link"
:aria-label="node.aria"
role="button"
tabindex="0"
@click.prevent="emitClick(node.type)"
@keydown.enter.prevent="emitClick(node.type)"
@keydown.space.prevent="emitClick(node.type)"
>{{ node.label }}</component>
<span v-else>{{ node.text }}</span>
</template>
</span>
</template>
<script>
export default {
name: 'ConsentRichText',
props: {
template: { type: String, required: true },
asButton: { type: Boolean, default: false },
ariaPrivacy: { type: String, default: 'Open privacy statement in a dialog' },
ariaTerms: { type: String, default: 'Open terms and conditions in a dialog' }
},
emits: ['open-privacy', 'open-terms'],
computed: {
linkTag() {
return this.asButton ? 'button' : 'a';
},
nodes() {
// Parse only allowed tags <privacy>...</privacy> and <terms>...</terms>
const source = (this.template || '');
// 2) parse only allowed tags <privacy>...</privacy> and <terms>...</terms>
const pattern = /<(privacy|terms)>([\s\S]*?)<\/\1>/gi;
const out = [];
let lastIndex = 0;
let match;
while ((match = pattern.exec(source)) !== null) {
const [full, tag, label] = match;
const start = match.index;
if (start > lastIndex) {
out.push({ type: 'text', text: source.slice(lastIndex, start) });
}
out.push({
type: tag, // 'privacy' | 'terms'
label: (label || '').trim(),
aria: tag === 'privacy' ? this.ariaPrivacy : this.ariaTerms
});
lastIndex = start + full.length;
}
if (lastIndex < source.length) {
out.push({ type: 'text', text: source.slice(lastIndex) });
}
return out;
}
},
methods: {
emitClick(kind) {
if (kind === 'privacy') this.$emit('open-privacy');
if (kind === 'terms') this.$emit('open-terms');
}
}
};
</script>
<style scoped>
.consent-link {
color: #007bff;
text-decoration: underline;
cursor: pointer;
transition: color 0.2s ease;
}
.consent-link:hover {
color: #0056b3;
}
.consent-link:focus {
outline: 2px solid #007bff;
outline-offset: 2px;
border-radius: 2px;
}
button.consent-link {
background: none;
border: none;
padding: 0;
color: #007bff;
text-decoration: underline;
}
</style>

View File

@@ -104,14 +104,16 @@
>
<!-- Regular checkbox label -->
<span v-if="!isConsentField" class="checkbox-text">{{ field.name }}</span>
<!-- Consent field with privacy and terms links -->
<span v-else class="checkbox-text consent-text">
{{ texts.consentPrefix }}
<a href="#" @click="openPrivacyModal" class="consent-link">{{ texts.privacyLink }}</a>
{{ texts.consentMiddle }}
<a href="#" @click="openTermsModal" class="consent-link">{{ texts.termsLink }}</a>
{{ texts.consentSuffix }}
</span>
<!-- Consent field with privacy and terms links (rich, multilingual) -->
<ConsentRichText
v-else
class="checkbox-text consent-text"
:template="texts.consentRich"
:aria-privacy="texts.ariaPrivacy || 'Open privacy statement in a dialog'"
:aria-terms="texts.ariaTerms || 'Open terms and conditions in a dialog'"
@open-privacy="openPrivacyModal"
@open-terms="openTermsModal"
/>
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
</label>
</div>
@@ -180,9 +182,11 @@
<script>
import { useComponentTranslations } from '../js/services/LanguageProvider.js';
import ConsentRichText from './ConsentRichText.vue';
export default {
name: 'FormField',
components: { ConsentRichText },
props: {
field: {
type: Object,
@@ -201,13 +205,11 @@ export default {
},
emits: ['update:modelValue', 'open-privacy-modal', 'open-terms-modal', 'keydown-enter'],
setup() {
// Consent text constants (English base)
// Consent text constants (English base) - rich template
const consentTexts = {
consentPrefix: "I agree with the",
consentMiddle: "and",
consentSuffix: "of AskEveAI",
privacyLink: "privacy statement",
termsLink: "terms and conditions"
consentRich: "I agree with the <privacy>privacy statement</privacy> and the <terms>terms and conditions</terms>",
ariaPrivacy: 'Open privacy statement in a dialog',
ariaTerms: 'Open terms and conditions in a dialog'
};
try {
@@ -230,24 +232,36 @@ export default {
},
computed: {
texts() {
// Robust consent texts that always return valid values
// Use translated texts if available and valid, otherwise use fallback
if (this.translatedTexts && typeof this.translatedTexts === 'object') {
const translated = this.translatedTexts;
// Check if translated texts have all required properties
if (translated.consentPrefix && translated.consentMiddle && translated.consentSuffix &&
translated.privacyLink && translated.termsLink) {
return translated;
}
// Validate that consentRich exists and includes both required tags; otherwise fallback to English base
const hasValidRich = (t) => t && typeof t.consentRich === 'string'
&& /<privacy>[\s\S]*?<\/privacy>/.test(t.consentRich)
&& /<terms>[\s\S]*?<\/terms>/.test(t.consentRich);
// 1) Prefer backend-provided rich string on the field's meta (already localized)
const meta = this.field && this.field.meta ? this.field.meta : (this.field.i18n || null);
if (meta && typeof meta.consentRich === 'string' && hasValidRich(meta)) {
return {
consentRich: meta.consentRich,
ariaPrivacy: meta.ariaPrivacy || this.fallbackTexts.ariaPrivacy,
ariaTerms: meta.ariaTerms || this.fallbackTexts.ariaTerms
};
}
// 2) Otherwise, use client-side translated texts if available and valid
if (this.translatedTexts && typeof this.translatedTexts === 'object' && hasValidRich(this.translatedTexts)) {
return this.translatedTexts;
}
// Fallback to English texts
return this.fallbackTexts || {
consentPrefix: "I agree with the",
consentMiddle: "and",
consentSuffix: "of AskEveAI",
privacyLink: "privacy statement",
termsLink: "terms and conditions"
// 3) Fallback to English texts (rich template)
if (this.fallbackTexts && hasValidRich(this.fallbackTexts)) {
return this.fallbackTexts;
}
// 4) Ultimate fallback (should not happen): provide a safe default
return {
consentRich: "I agree with the <privacy>privacy statement</privacy> and the <terms>terms and conditions</terms>",
ariaPrivacy: 'Open privacy statement in a dialog',
ariaTerms: 'Open terms and conditions in a dialog'
};
},
value: {
@@ -317,12 +331,10 @@ export default {
this.value = file;
}
},
openPrivacyModal(event) {
event.preventDefault();
openPrivacyModal() {
this.$emit('open-privacy-modal');
},
openTermsModal(event) {
event.preventDefault();
openTermsModal() {
this.$emit('open-terms-modal');
},

View File

@@ -9,13 +9,6 @@
<!-- Messages wrapper for bottom alignment -->
<div class="messages-wrapper">
<!-- Empty state (only show when no messages) -->
<div v-if="normalMessages.length === 0" class="empty-state">
<div class="empty-icon">💬</div>
<div class="empty-text">Nog geen berichten</div>
<div class="empty-subtext">Start een gesprek door een bericht te typen!</div>
</div>
<!-- Normal message list (excluding temporarily positioned AI messages) -->
<template v-if="normalMessages.length > 0">
<!-- Messages -->
@@ -87,7 +80,8 @@ export default {
isAtBottom: true,
showScrollButton: false,
unreadCount: 0,
languageChangeHandler: null
languageChangeHandler: null,
_prevSnapshot: { length: 0, firstId: null, lastId: null },
};
},
computed: {
@@ -98,29 +92,41 @@ export default {
},
watch: {
messages: {
handler(newMessages, oldMessages) {
const hasNewMessages = newMessages.length > (oldMessages?.length || 0);
// Always auto-scroll when new messages are added (regardless of current scroll position)
if (this.autoScroll && hasNewMessages) {
// Double $nextTick for better DOM update synchronization
this.$nextTick(() => {
this.$nextTick(() => {
this.scrollToBottom(true);
});
});
async handler(newMessages, oldMessages) {
const prev = this._prevSnapshot || { length: 0, firstId: null, lastId: null };
const curr = this.makeSnapshot(newMessages);
const container = this.$refs.messagesContainer;
const lengthIncreased = curr.length > prev.length;
const lengthDecreased = curr.length < prev.length; // reset/trim
const appended = lengthIncreased && curr.lastId !== prev.lastId;
const prepended = lengthIncreased && curr.firstId !== prev.firstId && curr.lastId === prev.lastId;
const mutatedLastSameLength = curr.length === prev.length && curr.lastId === prev.lastId;
if (prepended && container) {
// Load-more bovenaan: positie behouden
const before = container.scrollHeight;
await this.nextFrame();
await this.nextFrame();
this.preserveScrollOnPrepend(before);
} else if (appended) {
// Nieuw bericht onderaan: altijd naar beneden (eis)
await this.scrollToBottom(true, { smooth: true, retries: 2 });
} else if (mutatedLastSameLength) {
// Laatste bericht groeit (AI streaming/media). Alleen sticky als we al onderaan waren of autoScroll actief is
if (this.autoScroll || this.isAtBottom) {
await this.scrollToBottom(false, { smooth: true, retries: 2 });
}
} else if (lengthDecreased && this.autoScroll) {
// Lijst verkort (reset): terug naar onderen
await this.scrollToBottom(true, { smooth: false, retries: 2 });
}
this._prevSnapshot = curr;
},
deep: true,
immediate: false
immediate: false,
},
isTyping(newVal) {
if (newVal && this.autoScroll) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
}
},
created() {
// Maak een benoemde handler voor betere cleanup
@@ -135,25 +141,36 @@ export default {
},
mounted() {
this.setupScrollListener();
// Initial scroll to bottom
if (this.autoScroll) {
this.$nextTick(() => {
this.scrollToBottom();
});
this.$nextTick(() => this.scrollToBottom(true, { smooth: false, retries: 2 }));
}
this._onResize = this.debounce(() => {
if (this.autoScroll || this.isAtBottom) {
this.scrollToBottom(false, { smooth: true, retries: 1 });
}
}, 150);
window.addEventListener('resize', this._onResize);
const wrapper = this.$el.querySelector('.messages-wrapper');
if (window.ResizeObserver && wrapper) {
this._resizeObserver = new ResizeObserver(() => {
if (this.autoScroll || this.isAtBottom) {
this.scrollToBottom(false, { smooth: true, retries: 1 });
}
});
this._resizeObserver.observe(wrapper);
}
},
beforeUnmount() {
// Cleanup scroll listener
const container = this.$refs.messagesContainer;
if (container) {
container.removeEventListener('scroll', this.handleScroll);
}
// Cleanup language change listener
if (container) container.removeEventListener('scroll', this.handleScroll);
if (this.languageChangeHandler) {
document.removeEventListener('language-changed', this.languageChangeHandler);
document.removeEventListener('language-changed', this.languageChangeHandler);
}
if (this._onResize) window.removeEventListener('resize', this._onResize);
if (this._resizeObserver) this._resizeObserver.disconnect();
},
methods: {
async handleLanguageChange(event) {
@@ -197,17 +214,52 @@ export default {
}
},
scrollToBottom(force = false) {
async nextFrame() {
await this.$nextTick();
await new Promise(r => requestAnimationFrame(r));
},
async scrollToBottom(force = false, { smooth = true, retries = 2 } = {}) {
const container = this.$refs.messagesContainer;
if (container) {
// Use requestAnimationFrame for better timing
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
this.isAtBottom = true;
this.showScrollButton = false;
this.unreadCount = 0;
});
if (!container) return;
const doScroll = (instant = false) => {
const behavior = instant ? 'auto' : (smooth ? 'smooth' : 'auto');
container.scrollTo({ top: container.scrollHeight, behavior });
};
// Wacht enkele frames om late layout/afbeeldingen te vangen
for (let i = 0; i < retries; i++) {
await this.nextFrame();
}
// Probeer smooth naar onder
doScroll(false);
// Forceer desnoods na nog een frame een harde correctie
if (force) {
await this.nextFrame();
container.scrollTop = container.scrollHeight;
}
this.isAtBottom = true;
this.showScrollButton = false;
this.unreadCount = 0;
},
makeSnapshot(list) {
const length = list.length;
const firstId = length ? list[0].id : null;
const lastId = length ? list[length - 1].id : null;
return { length, firstId, lastId };
},
preserveScrollOnPrepend(beforeHeight) {
const container = this.$refs.messagesContainer;
if (!container) return;
const afterHeight = container.scrollHeight;
const delta = afterHeight - beforeHeight;
container.scrollTop = container.scrollTop + delta;
},
setupScrollListener() {
@@ -217,19 +269,21 @@ export default {
container.addEventListener('scroll', this.handleScroll);
},
debounce(fn, wait = 150) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), wait);
};
},
handleScroll() {
const container = this.$refs.messagesContainer;
if (!container) return;
const threshold = 50; // Reduced threshold for better detection
const threshold = 80; // was 50
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
this.isAtBottom = isNearBottom;
// Load more messages when scrolled to top
if (container.scrollTop === 0) {
this.$emit('load-more');
}
if (container.scrollTop === 0) this.$emit('load-more');
},
handleImageLoaded() {
@@ -274,35 +328,39 @@ export default {
<style scoped>
.message-history-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 20px; /* Interne padding voor MessageHistory */
box-sizing: border-box;
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
overflow: hidden;
display: flex;
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0; /* Laat kinderen scrollen */
padding: 20px;
box-sizing: border-box;
width: 100%;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
/* overflow: hidden; // mag weg of blijven; met stap 1 clipt dit niet meer */
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 10px;
scroll-behavior: smooth;
/* Bottom-aligned messages implementation */
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100%;
flex: 1;
overflow-y: auto;
scrollbar-gutter: stable both-edges; /* houdt ruimte vrij voor scrollbar */
padding-right: 0; /* haal de hack weg */
margin-right: 0;
/* Belangrijk: haal min-height: 100% weg en vervang */
/* min-height: 100%; */
min-height: 0; /* toestaan dat het kind krimpt voor overflow */
-webkit-overflow-scrolling: touch; /* betere iOS scroll */
}
.messages-wrapper {
display: flex;
flex-direction: column;
justify-content: flex-end;
min-height: 100%;
gap: 10px; /* Space between messages */
}

View File

@@ -14,7 +14,7 @@
<div class="progress-title">
<!-- Evie working animatie tijdens processing -->
<img v-if="isProcessing"
:src="staticUrl('assets/img/evie_working.webp')"
:src="staticUrl('/assets/img/evie_working.webp')"
alt="Bezig met verwerken..."
class="progress-icon working-animation">
@@ -55,7 +55,7 @@
<!-- Alleen Evie animatie voor "No Information" tijdens processing -->
<div v-else-if="shouldShowProgressIconOnly" class="progress-icon-only">
<img :src="staticUrl('assets/img/evie_working.webp')"
<img :src="staticUrl('/assets/img/evie_working.webp')"
alt="Bezig met verwerken..."
class="working-animation-only">
</div>

View File

@@ -9,10 +9,10 @@
<script>
// Definieer chatConfig voordat componenten worden geladen
window.chatConfig = {
explanation: `{{ customisation.sidebar_markdown|default('') }}`,
progress_tracker_insights: `{{ customisation.progress_tracker_insights|default('No Information') }}`,
form_title_display: `{{ customisation.form_title_display|default('Full Title') }}`,
conversationId: '{{ conversation_id|default("default") }}',
explanation: {{ customisation.sidebar_markdown|default('')|tojson }},
progress_tracker_insights: {{ customisation.progress_tracker_insights|default('No Information')|tojson }},
form_title_display: {{ customisation.form_title_display|default('Full Title')|tojson }},
conversationId: {{ conversation_id|default('default')|tojson }},
messages: {{ messages|tojson|safe }},
settings: {
maxMessageLength: {{ settings.max_message_length|default(2000) }},
@@ -22,15 +22,15 @@
allowReactions: {{ settings.allow_reactions|default('true')|lower }}
},
apiPrefix: '/chat-client/chat',
language: '{{ session.magic_link.specialist_args.language|default("en") }}',
language: {{ session.magic_link.specialist_args.language|default('en')|tojson }},
supportedLanguageDetails: {{ config.SUPPORTED_LANGUAGE_DETAILS|tojson|safe }},
allowedLanguages: {{ tenant_make.allowed_languages|tojson|safe }},
tenantMake: {
name: "{{ tenant_make.name|default('EveAI') }}",
logo_url: "{{ tenant_make.logo_url|default('') }}"
},
tenantMake: {{ {
'name': tenant_make.name or 'EveAI',
'logo_url': tenant_make.logo_url or ''
}|tojson|safe }},
// Environment-aware static base provided by Flask's overridden url_for
staticBase: '{{ static_url }}'
staticBase: {{ static_url|tojson }}
};
// Debug info om te controleren of chatConfig correct is ingesteld

View File

@@ -10,8 +10,8 @@
try { globalThis.staticUrl = window.staticUrl; } catch (e) {}
} else {
// Prefer runtime chatConfig.staticBase; else fallback to server-provided base or default
var serverStaticBase = '{{ static_url|default("") }}' || '';
if (!serverStaticBase) { serverStaticBase = '{{ url_for("static", filename="") }}'; }
var serverStaticBase = {{ static_url|default('')|tojson }} || '';
if (!serverStaticBase) { serverStaticBase = {{ url_for('static', filename='')|tojson }}; }
var base = (window.chatConfig && window.chatConfig.staticBase) ? window.chatConfig.staticBase : (serverStaticBase || '/static/');
var normalizedBase = String(base).replace(/\/+$/, '/');
window.staticUrl = function(path) {
@@ -34,19 +34,19 @@
window.chatConfig.supportedLanguages = [
{% for lang_code in config.SUPPORTED_LANGUAGES %}
{
code: "{{ lang_code }}",
name: "{{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['iso 639-1'] }}",
flag: "{{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['flag'] }}"
code: {{ lang_code|tojson }},
name: {{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['iso 639-1']|tojson }},
flag: {{ config.SUPPORTED_LANGUAGE_DETAILS[config.SUPPORTED_LANGUAGES_FULL[loop.index0]]['flag']|tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
// Voeg tenantMake toe aan chatConfig als die nog niet bestaat
if (!window.chatConfig.tenantMake) {
window.chatConfig.tenantMake = {
name: "{{ tenant_make.name|default('EveAI') }}",
logo_url: "{{ tenant_make.logo_url|default('') }}"
};
window.chatConfig.tenantMake = {{ {
'name': tenant_make.name or 'EveAI',
'logo_url': tenant_make.logo_url or ''
}|tojson|safe }};
}
console.log('Taalinstellingen toegevoegd aan chatConfig');

View File

@@ -89,18 +89,19 @@
/* General Styles */
.chat-container {
display: flex;
flex-direction: column;
height: 75vh;
/*max-height: 100vh;*/
max-width: 600px;
margin: auto;
border: 1px solid #ccc;
border-radius: 8px;
overflow: hidden;
background-color: var(--chat-bg);
font-family: var(--font-family); /* Apply the default font family */
color: var(--font-color); /* Apply the default font color */
display: flex;
flex-direction: column;
height: 75vh;
min-height: 0;
/*max-height: 100vh;*/
max-width: 600px;
margin: auto;
border: 1px solid #ccc;
border-radius: 8px;
overflow: hidden;
background-color: var(--chat-bg);
font-family: var(--font-family); /* Apply the default font family */
color: var(--font-color); /* Apply the default font color */
}
.disclaimer {