Merge branch 'release/v3.1.36-beta'

This commit is contained in:
Josako
2025-12-02 13:08:59 +01:00
24 changed files with 1705 additions and 247 deletions

View File

@@ -36,7 +36,10 @@ def get_default_chat_customisation(tenant_customisation=None):
'ai_message_text_color': '#212529',
'human_message_background': '#212529',
'human_message_text_color': '#ffffff',
'human_message_inactive_text_color': '#808080'
'human_message_inactive_text_color': '#808080',
'tab_background': '#0a0a0a',
'tab_icon_active_color': '#ffffff',
'tab_icon_inactive_color': '#f0f0f0',
}
# If no tenant customization is provided, return the defaults

View File

@@ -87,6 +87,21 @@ configuration:
description: "Human Message Inactive Text Color"
type: "color"
required: false
tab_background:
name: "Tab Background Color"
description: "Tab Background Color"
type: "color"
required: false
tab_icon_active_color:
name: "Tab Icon Active Color"
description: "Tab Icon Active Color"
type: "color"
required: false
tab_icon_inactive_color:
name: "Tab Icon Inactive Color"
description: "Tab Icon Inactive Color"
type: "color"
required: false
metadata:
author: "Josako"
date_added: "2024-06-06"

View File

@@ -1,6 +1,6 @@
{
"dist/chat-client.js": "dist/chat-client.f8ee4d5a.js",
"dist/chat-client.css": "dist/chat-client.2fffefae.css",
"dist/chat-client.js": "dist/chat-client.825210dd.js",
"dist/chat-client.css": "dist/chat-client.568d7be7.css",
"dist/main.js": "dist/main.6a617099.js",
"dist/main.css": "dist/main.7182aac3.css"
}

View File

@@ -10,11 +10,22 @@ task_description: >
€€€{history}€€€
(In this history, user interactions are preceded by 'HUMAN', and your interactions with 'AI'.)
Take into account the last question asked by the you, the AI.
Check if the user has given an affirmative answer or not.
Check if the user has given an affirmative answer to that last question or not.
Please note that this answer can be very short:
- Affirmative answers: e.g. Yes, OK, Sure, Of Course
- Negative answers: e.g. No, not really, No, I'd rather not.
Also note that users may use emoticons, emojis, or other symbols to express their affirmative answers.
- Affirmative answers: e.g. 👍🏼 , 👌🏼 , ☺️
- Negative answers: e.g. 👎🏼 , 🙅🏼 , 😒
Finally, users may use a direct answer to the last question asked:
Example 1:
- Question: "Do you have any other questions, or shall we start the interview to see if theres a match with the job?"
- Affirmative Answer: "Start the interview" or "Start please"
Example 2:
- Question: "Is there anything still on your mind, or shall we begin the conversation to explore the match?"
- Affirmative Answer: "Let's start exploring" or "Let's go"
Please consider that the answer will be given in {language}!

View File

@@ -5,9 +5,24 @@ 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.36-beta
Release date: 2025-12-02
### Added
- Refactoring of the chat client to use tabs for the active conversation, history and settings.
- Introduction of shells for Mobile and Desktop clients, allowing for additional shells like plugins to be added in the future.
### Fixed
- TRA-89—Problem solved where connection could get lost in sync between client and backend
- TRA-98—End user could continue without accepting dpa & terms
- TRA-96—Multiple-choice questions in the mobile client not scrolling → Solved by introducing a new client layout
- TRA-101—DPA-link was not working
- TRA-102—Wrong responses when looking for affirmative answers.
## 3.1.26-beta
Release date:
Release date: 2025-11-26
### Changed
- Introduction of vueuse/core in the chat client, to ensure abstraction of ui behaviour for different mobile devices.

View File

@@ -21,7 +21,9 @@
/* App container layout */
.app-container {
display: flex;
/* Use visual viewport variable when available */
/* Op desktop gebruiken we de veilige viewporthoogte direct; op mobiel
laten we html/body de hoogte bepalen en neemt de app-container
eenvoudig 100% daarvan in via de media query verderop. */
min-height: 0;
height: calc(var(--safe-vh, var(--vvh, 1vh)) * 100);
width: 100%;
@@ -93,7 +95,7 @@
display: flex;
flex-direction: column;
min-height: 0;
height: auto; /* prefer dynamic viewport on desktop */
height: auto; /* desktop: dynamische hoogte, op mobiel overschreven */
}
.chat-container {
@@ -103,6 +105,26 @@
min-height: 0; /* laat kinderen (ChatApp) krimpen */
}
/* Op mobiel sluiten we de volledige content-kolom strak aan op de veilige
viewporthoogte zodat alleen de chatcontent zelf kan scrollen en niet de
gehele pagina wanneer het toetsenbord opent. */
@media (max-width: 768px) {
.app-container {
height: 100%;
}
.content-area {
height: 100%;
overflow: hidden;
}
.chat-container {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
}
html, body {
height: calc(var(--safe-vh, var(--vvh, 1vh)) * 100);
min-height: 0;

View File

@@ -1,38 +1,106 @@
active_text_color<template>
<div class="chat-app-container">
<!-- Message History - takes available space -->
<message-history
:messages="displayMessages"
:is-typing="isTyping"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="true"
@specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="messageHistory"
class="chat-messages-area"
></message-history>
<!-- Desktop layout: huidige gedrag behouden -->
<div v-if="!isMobileFallback" class="chat-desktop-layout">
<message-history
:messages="displayMessages"
:is-typing="isTyping"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="true"
@specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="messageHistory"
class="chat-messages-area"
></message-history>
<!-- Chat Input - to the bottom -->
<chat-input
:current-message="currentMessage"
:is-loading="isLoading"
:max-length="2000"
:allow-file-upload="true"
:allow-voice-message="false"
:form-data="currentInputFormData"
:active-ai-message="activeAiMessage"
:api-prefix="apiPrefix"
@send-message="sendMessage"
@update-message="updateCurrentMessage"
@upload-file="handleFileUpload"
@record-voice="handleVoiceRecord"
@submit-form="submitFormFromInput"
@specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="chatInput"
class="chat-input-area"
></chat-input>
<chat-input
:current-message="currentMessage"
:is-loading="isLoading"
:max-length="2000"
:allow-file-upload="true"
:allow-voice-message="false"
:form-data="currentInputFormData"
:active-ai-message="activeAiMessage"
:api-prefix="apiPrefix"
@send-message="sendMessage"
@update-message="updateCurrentMessage"
@upload-file="handleFileUpload"
@record-voice="handleVoiceRecord"
@submit-form="submitFormFromInput"
@specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="chatInput"
class="chat-input-area"
></chat-input>
</div>
<!-- Mobiele layout met tabs in de header -->
<div v-else class="chat-mobile-layout">
<header class="chat-mobile-header">
<div class="chat-mobile-header-left">
<SideBarLogo
:logo-url="tenantLogoUrl"
:make-name="tenantName"
/>
</div>
<div class="chat-mobile-header-right">
<MobileTabBar
v-model="activeTabId"
:tabs="mobileTabs"
placement="header"
/>
</div>
</header>
<div class="chat-mobile-content" :class="`tab-${activeTabId}`">
<message-history
v-if="activeTabId === 'history'"
:messages="displayMessages"
:is-typing="isTyping"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="true"
@specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="messageHistory"
class="chat-messages-area"
></message-history>
<chat-input
v-else-if="activeTabId === 'chat'"
:current-message="currentMessage"
:is-loading="isLoading"
:max-length="settings.maxMessageLength"
:allow-file-upload="settings.allowFileUpload"
:allow-voice-message="settings.allowVoiceMessage"
:form-data="currentInputFormData"
:active-ai-message="activeAiMessage"
:api-prefix="apiPrefix"
@send-message="sendMessage"
@update-message="updateCurrentMessage"
@upload-file="handleFileUpload"
@record-voice="handleVoiceRecord"
@submit-form="submitFormFromInput"
@specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="chatInput"
class="chat-input-area tab-chat-input"
></chat-input>
<SideBarMobileSetup
v-else-if="activeTabId === 'setup'"
:tenant-make="{ name: tenantName, subtitle: tenantSubtitle }"
:explanation-text="originalExplanation"
:initial-language="currentLanguage"
:current-language="currentLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
:api-prefix="apiPrefix"
@language-changed="handleLanguageChangedFromSetup"
/>
</div>
</div>
<!-- Content Modal - positioned at ChatApp level -->
<content-modal
@@ -60,6 +128,9 @@ import ProgressTracker from './ProgressTracker.vue';
import LanguageSelector from './LanguageSelector.vue';
import ChatInput from './ChatInput.vue';
import ContentModal from './ContentModal.vue';
import SideBarLogo from './SideBarLogo.vue';
import MobileTabBar from './MobileTabBar.vue';
import SideBarMobileSetup from './SideBarMobileSetup.vue';
// Import language provider
import { createLanguageProvider, LANGUAGE_PROVIDER_KEY } from '../js/services/LanguageProvider.js';
@@ -77,7 +148,10 @@ export default {
MessageHistory,
ProgressTracker,
ChatInput,
ContentModal
ContentModal,
SideBarLogo,
MobileTabBar,
SideBarMobileSetup
},
setup() {
@@ -90,7 +164,7 @@ export default {
// Creëer en provide content modal
const contentModal = provideContentModal();
// Provide aan alle child components
provide(LANGUAGE_PROVIDER_KEY, languageProvider);
@@ -111,6 +185,7 @@ export default {
return {
// Tenant info
tenantName: tenantMake.name || 'EveAI',
tenantSubtitle: tenantMake.subtitle || '',
tenantLogoUrl: tenantMake.logo_url || '',
// Taal gerelateerde data
@@ -147,10 +222,13 @@ export default {
autoScroll: settings.autoScroll === true
},
// UI state
isMobile: window.innerWidth <= 768,
// UI state (fallback flags voor oudere logica)
isMobileFallback: window.innerWidth <= 768,
showSidebar: window.innerWidth > 768,
// Mobile tab state
activeTabId: 'chat',
// Advanced features
messageSearch: '',
filteredMessages: [],
@@ -193,16 +271,57 @@ export default {
return this.supportedLanguages.filter(lang =>
this.allowedLanguages.includes(lang.code)
);
},
mobileTabs() {
return [
{
id: 'chat',
iconName: 'chat',
label: 'Chat'
},
{
id: 'history',
iconName: 'history',
label: 'Historiek'
},
{
id: 'setup',
iconName: 'settings',
label: 'Setup'
}
];
}
},
mounted() {
this.initializeChat();
this.setupEventListeners();
// Stel initiale actieve tab in (optioneel via config)
const defaultTab = (window.chatConfig && window.chatConfig.defaultTab) || 'chat';
if (this.mobileTabs.find(t => t.id === defaultTab)) {
this.activeTabId = defaultTab;
}
// Luister naar globale events om tab te wisselen
this.globalTabListener = (event) => {
const tabId = event?.detail?.tabId;
if (!tabId) return;
if (this.mobileTabs.find(t => t.id === tabId)) {
this.activeTabId = tabId;
}
};
document.addEventListener('evie-chat-set-tab', this.globalTabListener);
},
beforeUnmount() {
this.cleanup();
if (this.globalTabListener) {
document.removeEventListener('evie-chat-set-tab', this.globalTabListener);
}
},
methods: {
@@ -453,11 +572,13 @@ export default {
}
});
// Window resize listener
window.addEventListener('resize', () => {
this.isMobile = window.innerWidth <= 768;
// Window resize listener voor fallback flags
this.handleResize = () => {
this.isMobileFallback = window.innerWidth <= 768;
this.showSidebar = window.innerWidth > 768;
});
};
window.addEventListener('resize', this.handleResize);
},
cleanup() {
@@ -516,6 +637,12 @@ export default {
this.isLoading = false;
},
handleLanguageChangedFromSetup(newLanguage) {
// Update lokale taalstate; verdere effecten worden opgepikt door
// bestaande global listener en LanguageProvider / chatConfig.
this.currentLanguage = newLanguage;
},
// UI helpers
scrollToBottom() {
if (this.$refs.messageHistory) {
@@ -560,7 +687,7 @@ export default {
display: flex;
flex-direction: column;
flex: 1;
/* height: 100%; avoided to let flex sizing control height */
height: 100%;
width: 100%;
min-height: 0;
max-width: 1000px;
@@ -571,6 +698,84 @@ export default {
overflow: hidden;
}
.chat-mobile-layout {
display: flex;
flex-direction: column;
/* Binnen SafeViewport vertrouwen we op de hoogte van de bovenliggende
containers (html/body/app-container). Deze layout moet zich daaraan
aanpassen en niet opnieuw zelf een safe-vh berekening doen, om
dubbele afrondingsfouten en extra scrollruimte te vermijden. */
flex: 1 1 auto;
height: 100%;
min-height: 0;
}
.chat-mobile-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Mobiele header met logo links en tabs rechts */
.chat-mobile-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
padding: 6px 8px;
background: var(--tab-background, #0a0a0a);
}
.chat-mobile-header-left {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 56px; /* Zorg dat logo altijd minstens vierkant kan tonen */
margin-right: 4px;
}
.chat-mobile-header-right {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
}
/* Specifieke layout voor de chat-tab: inputblok onderaan */
.chat-mobile-content.tab-chat {
display: flex;
flex-direction: column;
}
.tab-chat-input {
margin-top: auto;
flex-shrink: 0;
/* Iets meer visuele marge boven de onderrand, maar nog steeds rekening houden met safe inset */
padding-bottom: calc(6px + var(--safe-bottom-inset, 0px));
}
/* Wanneer het toetsenbord open is (gedetecteerd door useChatViewport via
de body-klasse chat-keyboard-open), willen we geen extra grote
safe-bottom-inset meer onder de input. Dan sluiten we zo veel mogelijk
aan tegen de visuele viewport en houden we alleen een kleine vaste marge. */
body.chat-keyboard-open .tab-chat-input {
padding-bottom: 6px;
}
@media (max-width: 768px) {
.chat-app-container {
/* Minder padding op mobiel zodat de tabbar binnen de viewport valt */
padding: 8px 8px 0 8px;
}
}
.chat-messages-area {
flex: 1;
min-height: 0; /* ensure child can scroll */

View File

@@ -11,8 +11,8 @@
:is-latest-ai-message="true"
:is-in-input-area="true"
@image-loaded="handleImageLoaded"
@specialist-complete="$emit('specialist-complete', $event)"
@specialist-error="$emit('specialist-error', $event)"
@specialist-complete="handleSpecialistCompleteFromActiveMessage"
@specialist-error="handleSpecialistErrorFromActiveMessage"
></chat-message>
</div>
@@ -183,22 +183,22 @@ export default {
watch: {
formData: {
handler(newFormData, oldFormData) {
console.log('ChatInput formData changed:', newFormData);
console.log('🧐 [ChatInput] formData changed:', newFormData);
if (!newFormData) {
console.log('FormData is null of undefined');
console.log('🧐 [ChatInput] formData is null of undefined');
this.formValues = {};
return;
}
// Controleer of velden aanwezig zijn
if (!newFormData.fields) {
console.error('FormData bevat geen velden!', newFormData);
console.error('🧐 [ChatInput] formData bevat geen velden!', newFormData);
return;
}
console.log('Velden in formData:', newFormData.fields);
console.log('Aantal velden:', Array.isArray(newFormData.fields)
console.log('🧐 [ChatInput] velden in formData:', newFormData.fields);
console.log('🧐 [ChatInput] aantal velden:', Array.isArray(newFormData.fields)
? newFormData.fields.length
: Object.keys(newFormData.fields).length);
@@ -206,7 +206,7 @@ export default {
this.initFormValues();
// Log de geïnitialiseerde waarden
console.log('Formulierwaarden geïnitialiseerd:', this.formValues);
console.log('🧐 [ChatInput] formulierwaarden geïnitialiseerd:', this.formValues);
},
immediate: true,
deep: true
@@ -251,6 +251,15 @@ export default {
window.removeEventListener('resize', this.autoResize);
},
methods: {
handleSpecialistCompleteFromActiveMessage(eventData) {
console.log('🧐 [ChatInput] specialist-complete ontvangen van actieve ChatMessage, bubbelt naar parent:', eventData);
this.$emit('specialist-complete', eventData);
},
handleSpecialistErrorFromActiveMessage(eventData) {
console.log('🧐 [ChatInput] specialist-error ontvangen van actieve ChatMessage, bubbelt naar parent:', eventData);
this.$emit('specialist-error', eventData);
},
handleLanguageChange(event) {
if (event.detail && event.detail.language) {
this.translatePlaceholder(event.detail.language);
@@ -452,6 +461,7 @@ export default {
transition: opacity 0.2s ease-in-out;
margin-left: auto;
margin-right: auto;
margin-bottom: 20px;
}
/* Input veld en knoppen */

View File

@@ -784,21 +784,32 @@ export default {
}
}
/* Bubble height constraints and inner scroll containment (apply on all viewports) */
/* Bubble height constraints en inner scroll containment.
Fallback gebruikt klassieke vh-units; de @supports-blok hieronder
schakelt over naar SafeViewport via var(--safe-vh) wanneer mogelijk. */
.message .message-content {
max-height: 33vh; /* fallback */
overflow-y: auto;
overscroll-behavior: contain; /* prevent scroll chaining to parent */
-webkit-overflow-scrolling: touch; /* iOS smooth inertia */
max-height: 33vh; /* fallback */
overflow-y: auto;
overscroll-behavior: contain; /* prevent scroll chaining to parent */
-webkit-overflow-scrolling: touch; /* iOS smooth inertia */
}
/* Active contexts (input area or sticky area): allow up to half viewport */
/* Active contexts (input area of sticky area): mogen meer hoogte innemen */
.message.input-area .message-content,
.message.sticky-area .message-content {
max-height: 50vh; /* fallback */
max-height: 50vh; /* fallback */
}
@supports (max-height: 1svh) {
.message .message-content { max-height: 33svh; }
.message.input-area .message-content,
.message.sticky-area .message-content { max-height: 50svh; }
.message .message-content {
/* Gebruik veilige viewporthoogte die door useChatViewport gezet wordt */
max-height: calc(var(--safe-vh, 1vh) * 33);
}
.message.input-area .message-content,
.message.sticky-area .message-content {
/* In de input-/sticky-area mag de bubbel ruimer zijn */
max-height: calc(var(--safe-vh, 1vh) * 60);
}
}
</style>

View File

@@ -1,13 +1,50 @@
<template>
<!--
ChatRoot
--------
Root-component voor de chatclient. Deze component zorgt ervoor dat er
altijd precies één SafeViewport-wrapper is rond de gekozen Shell
(DesktopChatShell, MobileChatShell, ...).
De daadwerkelijke shell-component en zijn props worden vanuit
chat-client.js doorgegeven via de props shellComponent en shellProps.
-->
<SafeViewport>
<ChatApp />
<component :is="shellComponent" v-bind="shellProps" />
</SafeViewport>
</template>
<script setup>
// ChatRoot.vue
// Kleine root-component die de ChatApp binnen de SafeViewport wrapper rendert.
import ChatApp from './ChatApp.vue';
import { computed } from 'vue';
import SafeViewport from './SafeViewport.vue';
import DesktopChatShell from './DesktopChatShell.vue';
import MobileChatShell from './MobileChatShell.vue';
const props = defineProps({
shellComponent: {
type: [Object, Function, String],
default: null
},
shellProps: {
type: Object,
default: () => ({})
}
});
// Fallbacks voor het geval chat-client.js geen shellComponent meegeeft.
const resolvedShellComponent = computed(() => {
if (props.shellComponent) {
return props.shellComponent;
}
// Eenvoudige fallback: gebruik DesktopShell op brede schermen,
// MobileShell op smalle schermen.
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
return MobileChatShell;
}
return DesktopChatShell;
});
const shellComponent = resolvedShellComponent;
const shellProps = props.shellProps;
</script>

View File

@@ -37,7 +37,7 @@ export default {
const source = (this.template || '');
// 2) parse only allowed tags <dpa>...</dpa> and <terms>...</terms>
const pattern = /<(privacy|terms)>([\s\S]*?)<\/\1>/gi;
const pattern = /<(dpa|terms)>([\s\S]*?)<\/\1>/gi;
const out = [];
let lastIndex = 0;
let match;
@@ -62,8 +62,17 @@ export default {
},
methods: {
emitClick(kind) {
if (kind === 'dpa') this.$emit('open-dpa');
if (kind === 'terms') this.$emit('open-terms');
// Debug logging to trace click events for consent links
console.log('[ConsentRichText] emitClick called with kind =', kind);
if (kind === 'dpa') {
console.log('[ConsentRichText] Emitting open-dpa event');
this.$emit('open-dpa');
}
if (kind === 'terms') {
console.log('[ConsentRichText] Emitting open-terms event');
this.$emit('open-terms');
}
}
}
};

View File

@@ -0,0 +1,501 @@
<template>
<!--
CoreChatApp
------------
Deze component bevat alle kernlogica en state van de chat, maar legt zelf
geen definitieve layout op. In plaats daarvan levert hij named slots aan
waar Shells (Desktop/Mobile/Plugin) hun eigen opbouw kunnen definieren.
Beschikbare slots:
- history: weergave van de berichtenhistoriek
- active-message-input: actieve AI-boodschap + invoergebied
- setup: configuratiepaneel (uitleg, taal, ...)
-->
<div
class="core-chat-app"
@specialist-complete="handleSpecialistComplete"
@specialist-error="handleSpecialistError"
>
<!-- History-paneel (optioneel) -->
<slot
name="history"
:messages="displayMessages"
:is-typing="isTyping"
:is-submitting-form="isSubmittingForm"
:on-specialist-complete="handleSpecialistComplete"
:on-specialist-error="handleSpecialistError"
/>
<!-- Actieve boodschap + invoer (optioneel) -->
<slot
name="active-message-input"
:active-ai-message="activeAiMessage"
:form-data="currentInputFormData"
:current-message="currentMessage"
:is-loading="isLoading"
:on-send-message="sendMessage"
:on-update-message="updateCurrentMessage"
:on-submit-form="submitFormFromInput"
:on-upload-file="handleFileUpload"
:on-record-voice="handleVoiceRecord"
:on-specialist-complete="handleSpecialistComplete"
:on-specialist-error="handleSpecialistError"
:form-values="formValues"
/>
<!-- Setup-paneel (optioneel) -->
<slot
name="setup"
:tenant-name="tenantName"
:tenant-subtitle="tenantSubtitle"
:explanation-text="originalExplanation"
:current-language="currentLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
:api-prefix="apiPrefix"
:on-language-changed="handleLanguageChangedFromSetup"
/>
<!-- Content modal op Core-niveau zodat alle shells deze kunnen gebruiken -->
<ContentModal
:show="contentModal.modalState.show"
:title="contentModal.modalState.title"
:content="contentModal.modalState.content"
:version="contentModal.modalState.version"
:loading="contentModal.modalState.loading"
:error="contentModal.modalState.error"
:error-message="contentModal.modalState.errorMessage"
@close="contentModal.hideModal"
@retry="contentModal.retryLoad"
/>
</div>
</template>
<script>
import MessageHistory from './MessageHistory.vue';
import ChatInput from './ChatInput.vue';
import ContentModal from './ContentModal.vue';
import TypingIndicator from './TypingIndicator.vue';
import DynamicForm from './DynamicForm.vue';
import ChatMessage from './ChatMessage.vue';
import ProgressTracker from './ProgressTracker.vue';
import { provide } from 'vue';
import { provideContentModal } from '../js/composables/useContentModal.js';
import { createLanguageProvider, LANGUAGE_PROVIDER_KEY } from '../js/services/LanguageProvider.js';
export default {
name: 'CoreChatApp',
components: {
MessageHistory,
ChatInput,
ContentModal,
TypingIndicator,
DynamicForm,
ChatMessage,
ProgressTracker
},
props: {
apiPrefix: {
type: String,
default: ''
},
conversationId: {
type: String,
default: 'default'
},
userId: {
type: [String, Number, null],
default: null
},
userName: {
type: String,
default: ''
},
initialLanguage: {
type: String,
default: 'en'
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
// De volledige chatConfig kan optioneel meegegeven worden voor
// backwards compatibility; CoreChatApp leest daaruit wat hij nodig heeft.
chatConfig: {
type: Object,
default: () => (window.chatConfig || {})
}
},
setup(props) {
const contentModal = provideContentModal();
// LanguageProvider opnieuw centraliseren rond CoreChatApp zodat alle
// child-componenten (ChatInput, SideBarExplanation, ...) veilig
// useLanguageProvider kunnen gebruiken, ongeacht de gebruikte Shell.
const initialLanguage = props.initialLanguage || 'nl';
const apiPrefix = props.apiPrefix || '';
const languageProvider = createLanguageProvider(initialLanguage, apiPrefix);
provide(LANGUAGE_PROVIDER_KEY, languageProvider);
return {
contentModal
};
},
data() {
const chatConfig = this.chatConfig || window.chatConfig || {};
const settings = chatConfig.settings || {};
const initialLanguage = chatConfig.language || this.initialLanguage || 'en';
const originalExplanation = chatConfig.explanation || '';
const tenantMake = chatConfig.tenantMake || {};
return {
// Tenant info
tenantName: tenantMake.name || 'EveAI',
tenantSubtitle: tenantMake.subtitle || '',
// Taal gerelateerde data
currentLanguage: initialLanguage,
supportedLanguageDetailsInternal: chatConfig.supportedLanguageDetails || this.supportedLanguageDetails,
allowedLanguagesInternal: chatConfig.allowedLanguages || this.allowedLanguages,
originalExplanation,
// Chat-specific data
currentMessage: '',
allMessages: [],
isTyping: false,
isLoading: false,
isSubmittingForm: false,
messageIdCounter: 1,
formValues: {},
currentInputFormData: null,
// Configuration from server
settings: {
maxMessageLength: settings.maxMessageLength || 2000,
allowFileUpload: settings.allowFileUpload === true,
allowVoiceMessage: settings.allowVoiceMessage === true,
autoScroll: settings.autoScroll === true
},
// Search state (voor history-tab)
messageSearch: '',
filteredMessages: [],
isSearching: false
};
},
computed: {
displayMessages() {
return this.isSearching ? this.filteredMessages : this.allMessages;
},
activeAiMessage() {
return this.allMessages.find(msg => msg.isTemporarilyAtBottom);
},
hasMessages() {
return this.allMessages.length > 0;
}
},
mounted() {
this.initializeChat();
this.setupEventListeners();
},
beforeUnmount() {
this.cleanup();
},
methods: {
// Initialisatie
initializeChat() {
this.loadHistoricalMessages();
if (this.allMessages.length === 0) {
this.addWelcomeMessage();
}
},
loadHistoricalMessages() {
const chatConfig = this.chatConfig || window.chatConfig || {};
const historicalMessages = chatConfig.messages || [];
if (historicalMessages.length > 0) {
this.allMessages = historicalMessages
.filter(msg => msg !== null && msg !== undefined)
.map(msg => ({
id: this.messageIdCounter++,
content: typeof msg === 'string' ? msg : (msg.content || ''),
sender: msg.sender || 'ai',
type: msg.type || 'text',
timestamp: msg.timestamp || new Date().toISOString(),
formData: msg.formData || null,
status: msg.status || 'delivered'
}));
}
},
async addWelcomeMessage() {
this.isTyping = true;
this.isLoading = true;
try {
const response = await fetch(`${this.apiPrefix}/api/send_message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Initialize',
language: this.currentLanguage,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.task_id) {
const placeholderMessage = {
id: this.messageIdCounter++,
content: 'Bezig met laden...',
sender: 'ai',
type: 'text',
timestamp: new Date().toISOString(),
taskId: data.task_id,
status: 'processing',
isTemporarilyAtBottom: true
};
this.allMessages.push(placeholderMessage);
}
} catch (error) {
console.error('Error sending initialize message:', error);
this.addMessage({
content: 'Er is een fout opgetreden bij het initialiseren van de chat.',
sender: 'ai',
type: 'error'
});
} finally {
this.isTyping = false;
this.isLoading = false;
}
},
// Messages
addMessage(messageData) {
const message = {
id: this.messageIdCounter++,
content: messageData.content || '',
sender: messageData.sender || 'user',
type: messageData.type || 'text',
timestamp: messageData.timestamp || new Date().toISOString(),
formData: messageData.formData || null,
formValues: messageData.formValues || null,
status: messageData.status || 'delivered'
};
this.allMessages.push(message);
return message;
},
async sendMessage() {
if (!this.currentMessage.trim() && !this.currentInputFormData) {
return;
}
// Eerdere placeholder AI-berichten terugplaatsen in de flow
this.repositionLatestAiMessage();
const userMessage = this.addMessage({
content: this.currentMessage,
sender: 'user',
formData: this.currentInputFormData,
formValues: this.formValues
});
const messageToSend = this.currentMessage;
const formValuesToSend = { ...this.formValues };
this.currentMessage = '';
this.formValues = {};
this.currentInputFormData = null;
this.isTyping = true;
this.isLoading = true;
try {
const response = await fetch(`${this.apiPrefix}/api/send_message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: messageToSend,
form_values: Object.keys(formValuesToSend).length > 0 ? formValuesToSend : undefined,
language: this.currentLanguage,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.task_id) {
const placeholderMessage = {
id: this.messageIdCounter++,
sender: 'ai',
type: 'text',
timestamp: new Date().toISOString(),
taskId: data.task_id,
status: 'processing',
isTemporarilyAtBottom: true
};
this.allMessages.push(placeholderMessage);
}
} catch (error) {
console.error('Error sending message:', error);
this.addMessage({
content: 'Er is een fout opgetreden bij het verzenden van het bericht.',
sender: 'ai',
type: 'error'
});
} finally {
this.isTyping = false;
this.isLoading = false;
}
},
repositionLatestAiMessage() {
const aiMessage = this.allMessages.find(msg =>
msg.sender === 'ai' && msg.taskId && msg.isTemporarilyAtBottom
);
if (aiMessage) {
aiMessage.isTemporarilyAtBottom = false;
}
},
updateCurrentMessage(message) {
this.currentMessage = message;
},
submitFormFromInput(formValues) {
this.formValues = formValues;
this.sendMessage();
},
handleFileUpload(file) {
console.log('File upload:', file);
},
handleVoiceRecord(audioData) {
console.log('Voice record:', audioData);
},
// Specialist events
handleSpecialistComplete(eventData) {
console.log('🧐 [CoreChatApp] specialist-complete ontvangen:', eventData);
const messageIndex = this.allMessages.findIndex(msg => {
const matches = msg.taskId === eventData.taskId;
console.log('🧐 [CoreChatApp] zoeken naar bericht voor specialist-complete:', {
messageId: msg.id,
messageTaskId: msg.taskId,
eventTaskId: eventData.taskId,
matches
});
return matches;
});
if (messageIndex !== -1) {
console.log('🧐 [CoreChatApp] gevonden bericht voor specialist-complete:', this.allMessages[messageIndex]);
this.allMessages[messageIndex].content = eventData.answer;
this.allMessages[messageIndex].status = 'completed';
if (eventData.form_request) {
console.log('🧐 [CoreChatApp] form_request ontvangen, wordt ingesteld als currentInputFormData:', eventData.form_request);
this.currentInputFormData = eventData.form_request;
} else {
console.log('🧐 [CoreChatApp] geen form_request ontvangen, formulier wordt gewist');
this.currentInputFormData = null;
if (this.formValues) {
this.formValues = {};
}
}
} else {
console.warn('🧐 [CoreChatApp] geen bericht gevonden voor specialist-complete met taskId:', eventData.taskId);
}
this.isTyping = false;
this.isLoading = false;
console.log('🧐 [CoreChatApp] currentInputFormData na complete:', this.currentInputFormData);
},
handleSpecialistError(eventData) {
const messageIndex = this.allMessages.findIndex(msg => msg.taskId === eventData.taskId);
if (messageIndex !== -1) {
this.allMessages[messageIndex].content = eventData.message || 'Er is een fout opgetreden.';
this.allMessages[messageIndex].type = 'error';
this.allMessages[messageIndex].status = 'error';
}
this.isTyping = false;
this.isLoading = false;
},
// Event listeners voor taal & resize
setupEventListeners() {
this._languageChangeHandler = (event) => {
if (event.detail && event.detail.language) {
this.currentLanguage = event.detail.language;
}
};
document.addEventListener('language-changed', this._languageChangeHandler);
this._resizeHandler = () => {
// Placeholder voor toekomstige shell-specifieke gedrag; Core hoeft
// hier in principe niets layout-gerelateerd te doen.
};
window.addEventListener('resize', this._resizeHandler);
},
cleanup() {
if (this._languageChangeHandler) {
document.removeEventListener('language-changed', this._languageChangeHandler);
}
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
}
},
handleLanguageChangedFromSetup(newLanguage) {
this.currentLanguage = newLanguage;
}
}
};
</script>
<style scoped>
.core-chat-app {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,172 @@
<template>
<!--
DesktopChatShell
-----------------
Shell voor brede schermen (desktop/tablet-landscape) die de CoreChatApp
in een klassieke layout toont: history boven, invoer onder.
Let op: de linker Sidebar wordt nog steeds apart gemount op
#sidebar-container door chat-client.js. Deze shell is uitsluitend
verantwoordelijk voor de rechter chatkolom.
-->
<div class="desktop-chat-shell">
<CoreChatApp
:api-prefix="apiPrefix"
:conversation-id="conversationId"
:user-id="userId"
:user-name="userName"
:initial-language="initialLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
>
<!-- History-paneel -->
<template #history="historyProps">
<MessageHistory
:messages="historyProps.messages"
:is-typing="historyProps.isTyping"
:is-submitting-form="historyProps.isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="settings.autoScroll"
@specialist-error="event => {
console.log('🧐 [DesktopChatShell] specialist-error vanuit history ontvangen:', event);
(historyProps.onSpecialistError || handleSpecialistError)(event);
}"
@specialist-complete="event => {
console.log('🧐 [DesktopChatShell] specialist-complete vanuit history ontvangen:', event);
(historyProps.onSpecialistComplete || handleSpecialistComplete)(event);
}"
ref="messageHistory"
class="chat-messages-area"
/>
</template>
<!-- Actieve boodschap + invoer -->
<template #active-message-input="inputProps">
<ChatInput
:current-message="inputProps.currentMessage"
:is-loading="inputProps.isLoading"
:max-length="settings.maxMessageLength"
:allow-file-upload="settings.allowFileUpload"
:allow-voice-message="settings.allowVoiceMessage"
:form-data="inputProps.formData"
:active-ai-message="inputProps.activeAiMessage"
:api-prefix="apiPrefix"
@send-message="inputProps.onSendMessage"
@update-message="inputProps.onUpdateMessage"
@upload-file="inputProps.onUploadFile"
@record-voice="inputProps.onRecordVoice"
@submit-form="inputProps.onSubmitForm"
@specialist-error="event => {
console.log('🧐 [DesktopChatShell] specialist-error vanuit ChatInput ontvangen:', event);
(inputProps.onSpecialistError || handleSpecialistError)(event);
}"
@specialist-complete="event => {
console.log('🧐 [DesktopChatShell] specialist-complete vanuit ChatInput ontvangen:', event);
(inputProps.onSpecialistComplete || handleSpecialistComplete)(event);
}"
ref="chatInput"
class="chat-input-area"
/>
</template>
</CoreChatApp>
</div>
</template>
<script>
import CoreChatApp from './CoreChatApp.vue';
import MessageHistory from './MessageHistory.vue';
import ChatInput from './ChatInput.vue';
export default {
name: 'DesktopChatShell',
components: {
CoreChatApp,
MessageHistory,
ChatInput
},
props: {
apiPrefix: {
type: String,
default: ''
},
conversationId: {
type: String,
default: 'default'
},
userId: {
type: [String, Number, null],
default: null
},
userName: {
type: String,
default: ''
},
initialLanguage: {
type: String,
default: 'en'
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
settings: {
type: Object,
default: () => ({
maxMessageLength: 2000,
allowFileUpload: true,
allowVoiceMessage: false,
autoScroll: true
})
}
},
methods: {
handleSpecialistComplete(eventData) {
// Wordt binnen CoreChatApp afgehandeld; deze shell kan in de toekomst
// aanvullende UI-reacties toevoegen.
this.$emit('specialist-complete', eventData);
},
handleSpecialistError(eventData) {
this.$emit('specialist-error', eventData);
}
}
};
</script>
<style scoped>
.desktop-chat-shell {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.chat-messages-area {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
margin-bottom: 20px;
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;
margin-left: auto;
margin-right: auto;
align-self: center;
}
.chat-input-area {
flex-shrink: 0;
flex: 0 0 auto;
}
</style>

View File

@@ -19,7 +19,7 @@
:field-id="field.id || field.name"
:model-value="localFormValues[field.id || field.name]"
@update:model-value="updateFieldValue(field.id || field.name, $event)"
@open-privacy-modal="openPrivacyModal"
@open-dpa-modal="openDpaModal"
@open-terms-modal="openTermsModal"
@keydown-enter="handleEnterKey"
/>
@@ -32,7 +32,7 @@
:field-id="fieldId"
:model-value="localFormValues[fieldId]"
@update:model-value="updateFieldValue(fieldId, $event)"
@open-privacy-modal="openPrivacyModal"
@open-dpa-modal="openDpaModal"
@open-terms-modal="openTermsModal"
@keydown-enter="handleEnterKey"
/>
@@ -199,12 +199,19 @@ export default {
// Basic validation - check required fields
const missingFields = [];
// Extra consent-validatie: detecteer consent velden en controleer of alle consents geaccepteerd zijn.
// We maken dit toekomstvast voor meerdere consent-velden.
let hasConsentField = false;
let allConsentsAccepted = true;
if (Array.isArray(this.formData.fields)) {
// Valideer array-gebaseerde velden
this.formData.fields.forEach(field => {
const fieldId = field.id || field.name;
const value = this.localFormValues[fieldId];
// Basis required-validatie
if (field.required) {
const value = this.localFormValues[fieldId];
// Voor boolean velden is false een geldige waarde
if (field.type === 'boolean') {
// Boolean velden zijn altijd geldig als ze een boolean waarde hebben
@@ -220,12 +227,21 @@ export default {
}
}
}
// Consent-detectie en -validatie (ongeacht required-vlag)
if (field.type === 'boolean' && field.meta && field.meta.kind === 'consent') {
hasConsentField = true;
if (value !== true) {
allConsentsAccepted = false;
}
}
});
} else {
// Valideer object-gebaseerde velden
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
const value = this.localFormValues[fieldId];
if (field.required) {
const value = this.localFormValues[fieldId];
// Voor boolean velden is false een geldige waarde
if (field.type === 'boolean') {
// Boolean velden zijn altijd geldig als ze een boolean waarde hebben
@@ -241,10 +257,27 @@ export default {
}
}
}
// Consent-detectie en -validatie (ongeacht required-vlag)
if (field.type === 'boolean' && field.meta && field.meta.kind === 'consent') {
hasConsentField = true;
if (value !== true) {
allConsentsAccepted = false;
}
}
});
}
return missingFields.length === 0;
const isBaseValid = missingFields.length === 0;
if (!hasConsentField) {
// Geen speciale consentvelden: behoud bestaand gedrag
return isBaseValid;
}
// Als er één of meer consentvelden zijn, zijn we alleen geldig als
// zowel de basisvalidatie als alle consents geaccepteerd zijn.
return isBaseValid && allConsentsAccepted;
},
// Title display mode configuration
titleDisplayMode() {
@@ -479,11 +512,13 @@ export default {
},
// Modal handling methods
openPrivacyModal() {
openDpaModal() {
console.log('[DynamicForm] openDpaModal called');
this.loadContent('dpa');
},
openTermsModal() {
console.log('[DynamicForm] openTermsModal called');
this.loadContent('terms');
},
@@ -504,6 +539,8 @@ export default {
async loadContent(contentType) {
const title = contentType === 'dpa' ? 'Data Privacy Agreement' : 'Terms & Conditions';
const contentUrl = `${this.apiPrefix}/${contentType}`;
console.log('[DynamicForm] Loading content from:', contentUrl);
// Use the composable to show modal and load content
await this.contentModal.showModal({
@@ -514,11 +551,19 @@ export default {
// Handle Enter key press in form fields
handleEnterKey(event) {
console.log('DynamicForm: Enter event received, emitting form-enter-pressed');
console.log('DynamicForm: Enter event received');
// Prevent default form submission
event.preventDefault();
// Emit event to parent (ChatInput) to trigger send
this.$emit('form-enter-pressed');
// Alleen submit toelaten als het formulier (inclusief consentvelden)
// geldig is. Hiermee worden keyboard-shortcuts uitgeschakeld zolang
// consent niet is gegeven of andere vereiste velden ontbreken.
if (this.isFormValid && !this.isSubmittingForm && !this.isSubmitting) {
// Emit event to parent (ChatInput) to trigger send
this.$emit('form-enter-pressed');
} else {
console.log('DynamicForm: Enter ignored because form is not valid or is submitting');
}
},
// Focus management - auto-focus on first form field

View File

@@ -111,7 +111,7 @@
:template="texts.consentRich"
:aria-privacy="texts.ariaPrivacy || 'Open dpa statement in a dialog'"
:aria-terms="texts.ariaTerms || 'Open terms and conditions in a dialog'"
@open-privacy="openPrivacyModal"
@open-dpa="openDpaModal"
@open-terms="openTermsModal"
/>
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
@@ -234,7 +234,7 @@ export default {
texts() {
// 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)
&& /<dpa>[\s\S]*?<\/dpa>/.test(t.consentRich)
&& /<terms>[\s\S]*?<\/terms>/.test(t.consentRich);
// 1) Prefer backend-provided rich string on the field's meta (already localized)
@@ -331,10 +331,12 @@ export default {
this.value = file;
}
},
openPrivacyModal() {
openDpaModal() {
console.log('[FormField] openDpaModal emitting open-dpa-modal');
this.$emit('open-dpa-modal');
},
openTermsModal() {
console.log('[FormField] openTermsModal emitting open-terms-modal');
this.$emit('open-terms-modal');
},

View File

@@ -20,8 +20,8 @@
:api-prefix="apiPrefix"
:is-latest-ai-message="isLatestAiMessage(message)"
@image-loaded="handleImageLoaded"
@specialist-complete="$emit('specialist-complete', $event)"
@specialist-error="$emit('specialist-error', $event)"
@specialist-complete="handleSpecialistCompleteFromMessage"
@specialist-error="handleSpecialistErrorFromMessage"
></chat-message>
</template>
</template>
@@ -173,6 +173,15 @@ export default {
if (this._resizeObserver) this._resizeObserver.disconnect();
},
methods: {
handleSpecialistCompleteFromMessage(eventData) {
console.log('🧐 [MessageHistory] specialist-complete ontvangen van ChatMessage, bubbelt naar parent:', eventData);
this.$emit('specialist-complete', eventData);
},
handleSpecialistErrorFromMessage(eventData) {
console.log('🧐 [MessageHistory] specialist-error ontvangen van ChatMessage, bubbelt naar parent:', eventData);
this.$emit('specialist-error', eventData);
},
async handleLanguageChange(event) {
// Controleer of dit het eerste bericht is in een gesprek met maar één bericht
if (this.messages.length === 1 && this.messages[0].sender === 'ai') {

View File

@@ -0,0 +1,278 @@
<template>
<!--
MobileChatShell
----------------
Shell voor mobiele schermen. Biedt een header met logo + tabbar en toont
per tab een deel van de CoreChatApp:
- chat: actieve AI-boodschap + invoer
- history: volledige berichtenhistoriek
- setup: taalkeuze + uitleg
-->
<div class="mobile-chat-shell">
<header class="chat-mobile-header">
<div class="chat-mobile-header-left">
<SideBarLogo :logo-url="tenantLogoUrl" :make-name="tenantName" />
</div>
<div class="chat-mobile-header-right">
<MobileTabBar
v-model="activeTabId"
:tabs="mobileTabs"
placement="header"
/>
</div>
</header>
<div class="chat-mobile-content" :class="`tab-${activeTabId}`">
<CoreChatApp
:api-prefix="apiPrefix"
:conversation-id="conversationId"
:user-id="userId"
:user-name="userName"
:initial-language="initialLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
>
<!-- Historiek-tab -->
<template #history="historyProps">
<MessageHistory
v-if="activeTabId === 'history'"
:messages="historyProps.messages"
:is-typing="historyProps.isTyping"
:is-submitting-form="historyProps.isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="true"
@specialist-error="event => {
console.log('🧐 [MobileChatShell] specialist-error vanuit history ontvangen:', event);
(historyProps.onSpecialistError || handleSpecialistError)(event);
}"
@specialist-complete="event => {
console.log('🧐 [MobileChatShell] specialist-complete vanuit history ontvangen:', event);
(historyProps.onSpecialistComplete || handleSpecialistComplete)(event);
}"
ref="messageHistory"
class="chat-messages-area"
/>
</template>
<!-- Chat-tab: actieve boodschap + input -->
<template #active-message-input="inputProps">
<ChatInput
v-if="activeTabId === 'chat'"
:current-message="inputProps.currentMessage"
:is-loading="inputProps.isLoading"
:max-length="settings.maxMessageLength"
:allow-file-upload="settings.allowFileUpload"
:allow-voice-message="settings.allowVoiceMessage"
:form-data="inputProps.formData"
:active-ai-message="inputProps.activeAiMessage"
:api-prefix="apiPrefix"
@send-message="inputProps.onSendMessage"
@update-message="inputProps.onUpdateMessage"
@upload-file="inputProps.onUploadFile"
@record-voice="inputProps.onRecordVoice"
@submit-form="inputProps.onSubmitForm"
@specialist-error="event => {
console.log('🧐 [MobileChatShell] specialist-error vanuit ChatInput ontvangen:', event);
(inputProps.onSpecialistError || handleSpecialistError)(event);
}"
@specialist-complete="event => {
console.log('🧐 [MobileChatShell] specialist-complete vanuit ChatInput ontvangen:', event);
(inputProps.onSpecialistComplete || handleSpecialistComplete)(event);
}"
ref="chatInput"
class="chat-input-area tab-chat-input"
/>
</template>
<!-- Setup-tab -->
<template #setup="setupProps">
<SideBarMobileSetup
v-if="activeTabId === 'setup'"
:tenant-make="{ name: tenantName, subtitle: tenantSubtitle }"
:explanation-text="setupProps.explanationText || explanationText"
:initial-language="initialLanguage"
:current-language="setupProps.currentLanguage || currentLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
:api-prefix="apiPrefix"
@language-changed="handleLanguageChangedFromSetup"
/>
</template>
</CoreChatApp>
</div>
</div>
</template>
<script>
import CoreChatApp from './CoreChatApp.vue';
import MessageHistory from './MessageHistory.vue';
import ChatInput from './ChatInput.vue';
import SideBarLogo from './SideBarLogo.vue';
import MobileTabBar from './MobileTabBar.vue';
import SideBarMobileSetup from './SideBarMobileSetup.vue';
export default {
name: 'MobileChatShell',
components: {
CoreChatApp,
MessageHistory,
ChatInput,
SideBarLogo,
MobileTabBar,
SideBarMobileSetup
},
props: {
apiPrefix: {
type: String,
default: ''
},
conversationId: {
type: String,
default: 'default'
},
userId: {
type: [String, Number, null],
default: null
},
userName: {
type: String,
default: ''
},
initialLanguage: {
type: String,
default: 'en'
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
tenantName: {
type: String,
default: 'EveAI'
},
tenantSubtitle: {
type: String,
default: ''
},
tenantLogoUrl: {
type: String,
default: ''
},
explanationText: {
type: String,
default: ''
},
settings: {
type: Object,
default: () => ({
maxMessageLength: 2000,
allowFileUpload: true,
allowVoiceMessage: false
})
}
},
data() {
return {
activeTabId: 'chat',
currentLanguage: this.initialLanguage || 'en'
};
},
computed: {
mobileTabs() {
return [
{ id: 'chat', iconName: 'chat', label: 'Chat' },
{ id: 'history', iconName: 'history', label: 'Historiek' },
{ id: 'setup', iconName: 'settings', label: 'Setup' }
];
}
},
mounted() {
// Initiale tab kan later uit config komen (defaultTab), voorlopig chat.
},
methods: {
handleSpecialistComplete(eventData) {
this.$emit('specialist-complete', eventData);
},
handleSpecialistError(eventData) {
this.$emit('specialist-error', eventData);
},
handleLanguageChangedFromSetup(newLanguage) {
this.currentLanguage = newLanguage;
this.$emit('language-changed', newLanguage);
}
}
};
</script>
<style scoped>
.mobile-chat-shell {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.chat-mobile-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
padding: 6px 8px;
background: var(--tab-background, #0a0a0a);
}
.chat-mobile-header-left {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 56px;
margin-right: 4px;
}
.chat-mobile-header-right {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
}
.chat-mobile-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.chat-mobile-content.tab-chat {
display: flex;
flex-direction: column;
}
.tab-chat-input {
margin-top: auto;
flex-shrink: 0;
padding-bottom: calc(6px + var(--safe-bottom-inset, 0px));
}
body.chat-keyboard-open .tab-chat-input {
padding-bottom: 6px;
}
</style>

View File

@@ -5,22 +5,11 @@
:make-name="tenantMake.name"
class="mobile-logo"
/>
<LanguageSelector
:initial-language="initialLanguage"
:current-language="currentLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
@language-changed="handleLanguageChange"
class="mobile-language-selector"
/>
</div>
</template>
<script setup>
import { ref } from 'vue';
import SideBarLogo from './SideBarLogo.vue';
import LanguageSelector from './LanguageSelector.vue';
const props = defineProps({
tenantMake: {
@@ -49,45 +38,21 @@ const props = defineProps({
}
});
const emit = defineEmits(['language-changed']);
const currentLanguage = ref(props.initialLanguage);
const handleLanguageChange = (newLanguage) => {
currentLanguage.value = newLanguage;
// Emit to parent
emit('language-changed', newLanguage);
// Global event for backward compatibility
const globalEvent = new CustomEvent('language-changed', {
detail: { language: newLanguage }
});
document.dispatchEvent(globalEvent);
// Update chatConfig
if (window.chatConfig) {
window.chatConfig.language = newLanguage;
}
// Save preference
localStorage.setItem('preferredLanguage', newLanguage);
};
// Mobile header toont enkel het logo; taalkeuze gebeurt via de Setup-tab.
</script>
<style scoped>
.mobile-header {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
flex-wrap: wrap; /* allow wrapping to next line on narrow screens */
padding: 10px 15px;
background: var(--sidebar-background);
color: var(--sidebar-color);
border-bottom: 1px solid rgba(0,0,0,0.1);
min-height: 60px;
max-width: 100%; /* never exceed viewport width */
overflow: hidden; /* clip any accidental overflow */
max-width: 100%;
overflow: hidden;
}
/* Mobile logo container - meer specifieke styling */
@@ -129,34 +94,6 @@ const handleLanguageChange = (newLanguage) => {
justify-content: center !important;
}
/* Mobile language selector styling */
.mobile-language-selector {
flex-shrink: 1;
min-width: 0; /* allow selector area to shrink */
}
.mobile-language-selector :deep(.language-selector) {
margin: 0;
}
.mobile-language-selector :deep(label) {
display: none; /* Hide label in mobile header */
}
.mobile-language-selector :deep(.language-select) {
padding: 6px 10px;
font-size: 0.85rem;
min-width: 0; /* allow the select to shrink */
max-width: 100%; /* never exceed container width */
margin: 0;
}
/* Extra constraints on ultra-small screens */
@media (max-width: 360px) {
.mobile-language-selector :deep(.language-select) {
max-width: 60vw; /* avoid pushing beyond viewport */
}
}
/* Media queries voor responsiviteit */
@media (max-width: 768px) {

View File

@@ -0,0 +1,113 @@
<template>
<nav
class="mobile-tab-bar"
:class="{ 'mobile-tab-bar--header': placement === 'header' }"
>
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="tab-button"
:class="{ 'is-active': tab.id === modelValue }"
@click="$emit('update:modelValue', tab.id)"
>
<span class="material-symbols-outlined" :class="`icon-${tab.iconName}`">
{{ tab.iconName }}
</span>
<span v-if="showLabels" class="tab-label">{{ tab.label }}</span>
</button>
</nav>
</template>
<script setup>
import { computed, watch } from 'vue';
import { useIconManager } from '../js/composables/useIconManager.js';
const props = defineProps({
tabs: {
type: Array,
default: () => []
},
modelValue: {
type: String,
default: ''
},
placement: {
type: String,
default: 'bottom' // 'bottom' | 'header'
},
showLabels: {
type: Boolean,
default: false
}
});
defineEmits(['update:modelValue']);
const { loadIcons } = useIconManager();
const iconNames = computed(() => props.tabs.map(t => t.iconName).filter(Boolean));
watch(iconNames, (names) => {
if (names && names.length) {
loadIcons(names);
}
}, { immediate: true });
</script>
<style scoped>
.mobile-tab-bar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-around;
padding: 6px 8px;
padding-bottom: calc(6px + var(--safe-bottom-inset, 0px));
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: var(--tab-background, #0a0a0a);
}
.mobile-tab-bar--header {
/* In de header geen extra safe-area padding en geen border-top */
padding: 4px 4px;
padding-bottom: 4px;
border-top: none;
}
.tab-button {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4px 0;
border: none;
background: transparent;
color: var(--tab-icon-inactive-color, rgba(240, 240, 240, 0.7));
font-size: 11px;
cursor: pointer;
}
.tab-button .material-symbols-outlined {
font-size: 22px;
line-height: 1;
}
.tab-button.is-active {
color: var(--tab-icon-active-color, #ffffff);
}
.tab-button.is-active .material-symbols-outlined {
font-variation-settings:
'FILL' 1,
'wght' 500,
'GRAD' 0,
'opsz' 24;
}
@media (min-width: 769px) {
.mobile-tab-bar {
display: none;
}
}
</style>

View File

@@ -24,5 +24,8 @@ useChatViewport();
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="sidebar-mobile-setup">
<SideBarMakeName
:make-name="tenantMake.name"
:subtitle="tenantMake.subtitle"
class="setup-make-name"
/>
<LanguageSelector
:initial-language="initialLanguage"
:current-language="currentLanguageInternal"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
@language-changed="handleLanguageChange"
class="setup-language-selector"
/>
<SideBarExplanation
:original-text="explanationText"
:current-language="currentLanguageInternal"
:api-prefix="apiPrefix"
class="setup-explanation"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import SideBarMakeName from './SideBarMakeName.vue';
import LanguageSelector from './LanguageSelector.vue';
import SideBarExplanation from './SideBarExplanation.vue';
const props = defineProps({
tenantMake: {
type: Object,
default: () => ({
name: '',
subtitle: ''
})
},
explanationText: {
type: String,
default: ''
},
initialLanguage: {
type: String,
default: 'en'
},
currentLanguage: {
type: String,
default: 'en'
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
apiPrefix: {
type: String,
default: ''
}
});
const emit = defineEmits(['language-changed']);
const currentLanguageInternal = ref(props.currentLanguage || props.initialLanguage);
watch(
() => props.currentLanguage,
(newVal) => {
if (newVal && newVal !== currentLanguageInternal.value) {
currentLanguageInternal.value = newVal;
}
}
);
const handleLanguageChange = (newLanguage) => {
currentLanguageInternal.value = newLanguage;
emit('language-changed', newLanguage);
};
</script>
<style scoped>
.sidebar-mobile-setup {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px 8px 16px 8px;
gap: 8px;
}
.setup-make-name {
padding-bottom: 4px;
}
.setup-language-selector {
flex-shrink: 0;
border-radius: 12px;
background: var(--sidebar-background, rgba(255, 255, 255, 0.02));
padding: 8px;
}
.setup-explanation {
flex: 1 1 auto;
border-radius: 12px;
padding: 8px;
overflow-y: auto;
}
@media (min-width: 769px) {
.sidebar-mobile-setup {
display: none;
}
}
</style>

View File

@@ -44,6 +44,11 @@
--human-message-background: {{ customisation.human_message_background|default('#ffffff') }};
--human-message-text-color: {{ customisation.human_message_text_color|default('#212529') }};
/* Mobe Tab Bar Colors */
--tab-background: {{ customisation.tab_background|default('#0a0a0a') }};
--tab-icon-active-color: {{ customisation.tab_icon_active_color|default('#ffffff') }};
--tab-icon-inactive-color: {{ customisation.tab_icon_inactive_color|default('#f0f0f0') }};
}
</style>

View File

@@ -385,7 +385,7 @@ def translate():
}), 500
@chat_bp.route('/privacy', methods=['GET'])
@chat_bp.route('/dpa', methods=['GET'])
def privacy_statement():
"""
Public AJAX endpoint for dpa statement content

View File

@@ -27,10 +27,11 @@ console.log('Components loaded:', Object.keys(Components));
// Import specifieke componenten
import LanguageSelector from '../../../eveai_chat_client/static/assets/vue-components/LanguageSelector.vue';
import ChatApp from '../../../eveai_chat_client/static/assets/vue-components/ChatApp.vue';
// import ChatApp from '../../../eveai_chat_client/static/assets/vue-components/ChatApp.vue';
import ChatRoot from '../../../eveai_chat_client/static/assets/vue-components/ChatRoot.vue';
import DesktopChatShell from '../../../eveai_chat_client/static/assets/vue-components/DesktopChatShell.vue';
import MobileChatShell from '../../../eveai_chat_client/static/assets/vue-components/MobileChatShell.vue';
import SideBar from '../../../eveai_chat_client/static/assets/vue-components/SideBar.vue';
import MobileHeader from '../../../eveai_chat_client/static/assets/vue-components/MobileHeader.vue';
// VueUse-setup voor de chatclient (maakt composables beschikbaar via window.VueUse)
import './vueuse-setup.js';
@@ -51,9 +52,6 @@ document.addEventListener('DOMContentLoaded', function() {
// Initialiseer sidebar (vervangt fillSidebarExplanation en initializeLanguageSelector)
initializeSidebar();
// Initialiseer mobile header
initializeMobileHeader();
// Initialiseer chat app (simpel)
initializeChatApp();
});
@@ -121,85 +119,8 @@ function initializeSidebar() {
}
}
/**
* Initialiseert de mobile header component
*/
function initializeMobileHeader() {
const container = document.getElementById('mobile-header-container');
if (!container) {
console.error('#mobile-header-container niet gevonden');
return;
}
try {
// Maak props voor de component
const props = {
tenantMake: {
name: window.chatConfig.tenantMake?.name || '',
logo_url: window.chatConfig.tenantMake?.logo_url || '',
subtitle: window.chatConfig.tenantMake?.subtitle || ''
},
initialLanguage: window.chatConfig.language || 'nl',
supportedLanguageDetails: window.chatConfig.supportedLanguageDetails || {},
allowedLanguages: window.chatConfig.allowedLanguages || ['nl', 'en', 'fr', 'de'],
apiPrefix: window.chatConfig.apiPrefix || ''
};
// Mount de component
const app = createApp(MobileHeader, props);
// Create and provide LanguageProvider for mobile header components
const initialLanguage = window.chatConfig?.language || 'nl';
const apiPrefix = window.chatConfig?.apiPrefix || '';
const languageProvider = createLanguageProvider(initialLanguage, apiPrefix);
app.provide(LANGUAGE_PROVIDER_KEY, languageProvider);
// Error handler
app.config.errorHandler = (err, vm, info) => {
console.error('🚨 [Vue Error in MobileHeader]', err);
console.error('Component:', vm);
console.error('Error Info:', info);
};
const mountedApp = app.mount(container);
// Dynamisch de headerhoogte doorgeven aan CSS
const updateHeaderHeightVar = () => {
const isMobile = window.matchMedia('(max-width: 768px)').matches;
if (!isMobile) {
document.documentElement.style.removeProperty('--mobile-header-height');
return;
}
const h = container.offsetHeight || 60; // fallback
document.documentElement.style.setProperty('--mobile-header-height', `${h}px`);
};
// Initieel instellen en bij gebeurtenissen herberekenen
requestAnimationFrame(updateHeaderHeightVar);
window.addEventListener('resize', updateHeaderHeightVar);
// Listen to language change events and update the mobile header's language provider
const languageChangeHandler = (event) => {
if (event.detail && event.detail.language) {
console.log('MobileHeader: Received language change event:', event.detail.language);
languageProvider.setLanguage(event.detail.language);
// taalwissel kan headerhoogte veranderen
requestAnimationFrame(updateHeaderHeightVar);
}
};
document.addEventListener('language-changed', languageChangeHandler);
// Store the handler for cleanup if needed
mountedApp._languageChangeHandler = languageChangeHandler;
mountedApp._updateHeaderHeightVar = updateHeaderHeightVar;
console.log('✅ MobileHeader component successfully mounted with LanguageProvider en dynamische headerhoogte');
return mountedApp;
} catch (error) {
console.error('🚨 [CRITICAL ERROR] Bij initialiseren mobile header:', error);
}
}
// initializeMobileHeader is verwijderd; de mobiele header wordt nu volledig
// binnen ChatApp.vue beheerd.
/**
* Initialiseert de chat app (Vue component)
@@ -212,27 +133,43 @@ function initializeChatApp() {
}
try {
if (!ChatApp) {
throw new Error('🚨 [CRITICAL ERROR] ChatApp component niet gevonden');
}
// Extra verificatie dat alle sub-componenten beschikbaar zijn
if (!Components.MessageHistory || !Components.ChatInput ||
!Components.TypingIndicator || !Components.ChatMessage) {
console.warn('⚠️ [WARN] Niet alle benodigde sub-componenten zijn geladen!');
}
// Maak props voor de component
const props = {
// Maak props voor de shells / CoreChatApp
const baseProps = {
apiPrefix: window.chatConfig.apiPrefix || '',
conversationId: window.chatConfig.conversationId || 'default',
userId: window.chatConfig.userId || null,
userName: window.chatConfig.userName || '',
initialLanguage: window.chatConfig.language || 'nl',
supportedLanguageDetails: window.chatConfig.supportedLanguageDetails || {},
allowedLanguages: window.chatConfig.allowedLanguages || ['nl', 'en', 'fr', 'de']
allowedLanguages: window.chatConfig.allowedLanguages || ['nl', 'en', 'fr', 'de'],
tenantName: (window.chatConfig.tenantMake && window.chatConfig.tenantMake.name) || 'EveAI',
tenantSubtitle: (window.chatConfig.tenantMake && window.chatConfig.tenantMake.subtitle) || '',
tenantLogoUrl: (window.chatConfig.tenantMake && window.chatConfig.tenantMake.logo_url) || '',
explanationText: window.chatConfig.explanation || '',
settings: window.chatConfig.settings || {}
};
// Bepaal shell-type: expliciete config heeft voorrang, anders breakpoint
const layoutMode = window.chatConfig.layoutMode || 'auto';
const isMobileBreakpoint = window.innerWidth <= 768;
let ShellComponent;
if (layoutMode === 'desktop') {
ShellComponent = DesktopChatShell;
} else if (layoutMode === 'mobile') {
ShellComponent = MobileChatShell;
} else {
ShellComponent = isMobileBreakpoint ? MobileChatShell : DesktopChatShell;
}
const props = { shellComponent: ShellComponent, shellProps: baseProps };
// Mount de component via ChatRoot zodat SafeViewport de layout kan beheren
const app = createApp(ChatRoot, props);