- introductie van vue files - bijna werkende versie van eveai_chat_client.

This commit is contained in:
Josako
2025-07-18 20:32:55 +02:00
parent 11b1d548bd
commit b60600e9f6
77 changed files with 47785 additions and 970 deletions

View File

@@ -1,5 +1,12 @@
// Import all components via barrel export
import { TypingIndicator, FormField, DynamicForm, ChatMessage, MessageHistory, ProgressTracker, LanguageSelector, ChatInput } from './components/index.js';
// Import all components as Vue SFCs
import TypingIndicator from '../vue-components/TypingIndicator.vue';
import FormField from '../vue-components/FormField.vue';
import DynamicForm from '../vue-components/DynamicForm.vue';
import ChatMessage from '../vue-components/ChatMessage.vue';
import MessageHistory from '../vue-components/MessageHistory.vue';
import ProgressTracker from '../vue-components/ProgressTracker.vue';
import LanguageSelector from '../vue-components/LanguageSelector.vue';
import ChatInput from '../vue-components/ChatInput.vue';
// Main Chat Application
// Main Chat Application - geëxporteerd als module

View File

@@ -12,72 +12,6 @@ export const ChatInput = {
components: {
'dynamic-form': DynamicForm
},
// Static method for direct rendering
renderComponent(container, props, app) {
console.log('🔍 [DEBUG] ChatInput.renderComponent() aangeroepen');
console.log('🔍 [DEBUG] ChatInput container:', container);
console.log('🔍 [DEBUG] ChatInput props:', props);
console.log('🔍 [DEBUG] ChatInput app:', app);
if (!container) {
console.error('Container element niet gevonden voor ChatInput');
return null;
}
// Controleer de globale dependencies
console.log('🔍 [DEBUG] Global dependencies check:');
console.log('- window.Vue:', typeof window.Vue);
if (window.Vue) {
console.log('- window.Vue.createApp:', typeof window.Vue.createApp);
console.log('- window.Vue.version:', window.Vue.version);
}
console.log('🔍 [DEBUG] ChatInput container gevonden, Vue app aan het initialiseren');
try {
// We controleren het app object
if (!app) {
console.error('🚨 [ERROR] Geen Vue app object ontvangen');
return null;
}
// Check of we een correcte Vue app hebben of we moeten er een maken
if (typeof app.mount !== 'function') {
console.log('🔍 [DEBUG] Ontvangen app heeft geen mount functie, dit is mogelijk een config object');
// Controleer of window.Vue beschikbaar is
if (!window.Vue || typeof window.Vue.createApp !== 'function') {
console.error('🚨 [ERROR] window.Vue.createApp is niet beschikbaar');
return null;
}
// Maak een nieuwe Vue app met het ChatInput component en de props
console.log('🔍 [DEBUG] Nieuwe Vue app aanmaken met ChatInput component');
try {
app = window.Vue.createApp(ChatInput, props);
console.log('🔍 [DEBUG] Nieuwe app aangemaakt:', app);
} catch (createError) {
console.error('🚨 [ERROR] Fout bij aanmaken Vue app:', createError);
// Probeer een alternatieve aanpak zonder importreferenties
console.log('🔍 [DEBUG] Alternatieve aanpak proberen...');
const componentCopy = JSON.parse(JSON.stringify(ChatInput));
app = window.Vue.createApp(componentCopy, props);
}
}
// Stel een fallback DOM in voor het geval mounten mislukt
container.innerHTML = `<div class="chat-input-loading">Chat input laden...</div>`;
// Nu kunnen we de app mounten
console.log('🔍 [DEBUG] App.mount aanroepen op container');
const instance = app.mount(container);
console.log('🔍 [DEBUG] ChatInput component succesvol gemount, instance:', instance);
return instance;
} catch (error) {
console.error('🚨 [ERROR] Fout bij mounten ChatInput component:', error);
console.error('Error stack:', error.stack);
return null;
}
},
// Gebruik de IconManagerMixin om automatisch iconen te laden
mixins: [IconManagerMixin],
created() {

View File

@@ -7,52 +7,6 @@ import { ProgressTracker } from './ProgressTracker.js';
export const ChatMessage = {
name: 'ChatMessage',
// Static method for direct rendering
renderComponent(container, props, app) {
console.log('🔍 [DEBUG] ChatMessage.renderComponent() aangeroepen');
console.log('🔍 [DEBUG] ChatMessage container:', container);
console.log('🔍 [DEBUG] ChatMessage props:', props);
if (!container) {
console.error('Container element niet gevonden voor ChatMessage');
return null;
}
console.log('🔍 [DEBUG] ChatMessage container gevonden, Vue app aan het initialiseren');
try {
// We controleren het app object
if (!app) {
console.error('🚨 [ERROR] Geen Vue app object ontvangen');
return null;
}
// Check of we een correcte Vue app hebben of we moeten er een maken
if (typeof app.mount !== 'function') {
console.log('🔍 [DEBUG] Ontvangen app heeft geen mount functie, dit is mogelijk een config object');
// Controleer of window.Vue beschikbaar is
if (!window.Vue || typeof window.Vue.createApp !== 'function') {
console.error('🚨 [ERROR] window.Vue.createApp is niet beschikbaar');
return null;
}
// Maak een nieuwe Vue app met het ChatMessage component en de props
console.log('🔍 [DEBUG] Nieuwe Vue app aanmaken met ChatMessage component');
app = window.Vue.createApp(ChatMessage, props);
console.log('🔍 [DEBUG] Nieuwe app aangemaakt:', app);
}
// Nu kunnen we de app mounten
console.log('🔍 [DEBUG] App.mount aanroepen op container');
const instance = app.mount(container);
console.log('🔍 [DEBUG] ChatMessage component succesvol gemount');
return instance;
} catch (error) {
console.error('🚨 [ERROR] Fout bij mounten ChatMessage component:', error);
console.error('Error stack:', error.stack);
return null;
}
},
components: {
'dynamic-form': DynamicForm,
'progress-tracker': ProgressTracker

View File

@@ -1,26 +1,5 @@
export const DynamicForm = {
name: 'DynamicForm',
// Static method for direct rendering
renderComponent(container, props, app) {
console.log('🔍 [DEBUG] DynamicForm.renderComponent() aangeroepen');
if (!container) {
console.error('Container element niet gevonden voor DynamicForm');
return null;
}
console.log('🔍 [DEBUG] DynamicForm container gevonden, Vue app aanmaken');
try {
// Maak een nieuwe Vue app instantie met dit component
const instance = app.mount(container);
console.log('🔍 [DEBUG] DynamicForm component succesvol gemount');
return instance;
} catch (error) {
console.error('🚨 [ERROR] Fout bij mounten DynamicForm component:', error);
return null;
}
},
created() {
// Zorg ervoor dat het icoon geladen wordt als iconManager beschikbaar is
if (window.iconManager && this.formData && this.formData.icon) {

View File

@@ -1,26 +1,5 @@
export const FormField = {
name: 'FormField',
// Static method for direct rendering
renderComponent(container, props, app) {
console.log('🔍 [DEBUG] FormField.renderComponent() aangeroepen');
if (!container) {
console.error('Container element niet gevonden voor FormField');
return null;
}
console.log('🔍 [DEBUG] FormField container gevonden, Vue app aanmaken');
try {
// Maak een nieuwe Vue app instantie met dit component
const instance = app.mount(container);
console.log('🔍 [DEBUG] FormField component succesvol gemount');
return instance;
} catch (error) {
console.error('🚨 [ERROR] Fout bij mounten FormField component:', error);
return null;
}
},
props: {
field: {
type: Object,

View File

@@ -2,52 +2,6 @@
export const FormMessage = {
name: 'FormMessage',
// Static method for direct rendering
renderComponent(container, props, app) {
console.log('🔍 [DEBUG] FormMessage.renderComponent() aangeroepen');
console.log('🔍 [DEBUG] FormMessage container:', container);
console.log('🔍 [DEBUG] FormMessage props:', props);
if (!container) {
console.error('Container element niet gevonden voor FormMessage');
return null;
}
console.log('🔍 [DEBUG] FormMessage container gevonden, Vue app aan het initialiseren');
try {
// We controleren het app object
if (!app) {
console.error('🚨 [ERROR] Geen Vue app object ontvangen');
return null;
}
// Check of we een correcte Vue app hebben of we moeten er een maken
if (typeof app.mount !== 'function') {
console.log('🔍 [DEBUG] Ontvangen app heeft geen mount functie, dit is mogelijk een config object');
// Controleer of window.Vue beschikbaar is
if (!window.Vue || typeof window.Vue.createApp !== 'function') {
console.error('🚨 [ERROR] window.Vue.createApp is niet beschikbaar');
return null;
}
// Maak een nieuwe Vue app met het FormMessage component en de props
console.log('🔍 [DEBUG] Nieuwe Vue app aanmaken met FormMessage component');
app = window.Vue.createApp(FormMessage, props);
console.log('🔍 [DEBUG] Nieuwe app aangemaakt:', app);
}
// Nu kunnen we de app mounten
console.log('🔍 [DEBUG] App.mount aanroepen op container');
const instance = app.mount(container);
console.log('🔍 [DEBUG] FormMessage component succesvol gemount');
return instance;
} catch (error) {
console.error('🚨 [ERROR] Fout bij mounten FormMessage component:', error);
console.error('Error stack:', error.stack);
return null;
}
},
props: {
formData: {
type: Object,

View File

@@ -26,16 +26,13 @@ export const LanguageSelector = {
};
},
mounted() {
// console.log('🔍 [DEBUG] LanguageSelector mounted');
// console.log('🔍 [DEBUG] Props:', {
// initialLanguage: this.initialLanguage,
// currentLanguage: this.currentLanguage,
// supportedLanguageDetails: this.supportedLanguageDetails,
// allowedLanguages: this.allowedLanguages
// });
// Render the component
this.renderComponent();
console.log('🔍 [DEBUG] LanguageSelector mounted with Vue template');
console.log('🔍 [DEBUG] Props:', {
initialLanguage: this.initialLanguage,
currentLanguage: this.currentLanguage,
supportedLanguageDetails: this.supportedLanguageDetails,
allowedLanguages: this.allowedLanguages
});
// Emit initial language
this.$emit('language-changed', this.selectedLanguage);
@@ -102,48 +99,9 @@ export const LanguageSelector = {
});
document.dispatchEvent(event);
}
},
renderComponent() {
// We gaan direct de container manipuleren
const container = document.getElementById('language-selector-container');
if (!container) {
console.error('Container niet gevonden voor LanguageSelector');
return;
}
const availableLanguages = this.getAvailableLanguages();
// console.log('🔍 [DEBUG] Available languages:', availableLanguages);
const optionsHtml = availableLanguages.map(lang =>
`<option value="${lang.code}" ${lang.code === this.selectedLanguage ? 'selected' : ''}>${lang.flag} ${lang.name}</option>`
).join('');
container.innerHTML = `
<div class="language-selector">
<label for="language-select">Taal / Language:</label>
<div class="select-wrapper">
<select id="language-select" class="language-select">
${optionsHtml}
</select>
</div>
</div>
`;
// Add event listener
const selectElement = container.querySelector('#language-select');
if (selectElement) {
selectElement.addEventListener('change', (e) => {
this.changeLanguage(e.target.value);
});
}
// console.log('🔍 [DEBUG] Component rendered successfully');
}
}
},
// Stap 1: Ondersteun zowel template-based rendering als manual rendering
// Door beide methoden te ondersteunen, werkt het component in beide scenario's
template: `
<div class="language-selector">
<label for="language-select">Taal / Language:</label>
@@ -153,10 +111,5 @@ export const LanguageSelector = {
</select>
</div>
</div>
`,
// Minimale render functie als fallback
render() {
return document.createElement('div');
}
`
};

View File

@@ -227,26 +227,4 @@
</div>
</div>
`
};
// Statische renderComponent methode voor het MessageHistory object
MessageHistory.renderComponent = function(container, props, app) {
console.log('🔍 [DEBUG] MessageHistory.renderComponent() aangeroepen als statische methode');
if (!container) {
console.error('Container element niet gevonden voor MessageHistory');
return null;
}
console.log('🔍 [DEBUG] MessageHistory container gevonden, Vue app aanmaken');
try {
// Maak een nieuwe Vue app instantie met dit component
const instance = app.mount(container);
console.log('🔍 [DEBUG] MessageHistory component succesvol gemount');
return instance;
} catch (error) {
console.error('🚨 [ERROR] Fout bij mounten MessageHistory component:', error);
return null;
}
};

View File

@@ -308,26 +308,4 @@ export const ProgressTracker = {
</div>
</div>
`
};
// Voeg de renderComponent toe als statische methode op het ProgressTracker object
ProgressTracker.renderComponent = function(container, props, app) {
console.log('🔍 [DEBUG] ProgressTracker.renderComponent() aangeroepen');
if (!container) {
console.error('Container element niet gevonden voor ProgressTracker');
return null;
}
console.log('🔍 [DEBUG] ProgressTracker container gevonden, Vue app aanmaken');
try {
// Maak een nieuwe Vue app instantie met dit component
const instance = app.mount(container);
console.log('🔍 [DEBUG] ProgressTracker component succesvol gemount');
return instance;
} catch (error) {
console.error('🚨 [ERROR] Fout bij mounten ProgressTracker component:', error);
return null;
}
};

View File

@@ -21,26 +21,4 @@ export const TypingIndicator = {
<div v-if="showText" class="typing-text">{{ text }}</div>
</div>
`
};
// Voeg statische renderComponent methode toe aan het TypingIndicator object
TypingIndicator.renderComponent = function(container, props, app) {
console.log('🔍 [DEBUG] TypingIndicator.renderComponent() aangeroepen als statische methode');
if (!container) {
console.error('Container element niet gevonden voor TypingIndicator');
return null;
}
console.log('🔍 [DEBUG] TypingIndicator container gevonden, Vue app aanmaken');
try {
// Maak een nieuwe Vue app instantie met dit component
const instance = app.mount(container);
console.log('🔍 [DEBUG] TypingIndicator component succesvol gemount');
return instance;
} catch (error) {
console.error('🚨 [ERROR] Fout bij mounten TypingIndicator component:', error);
return null;
}
};

View File

@@ -0,0 +1,512 @@
<template>
<div class="chat-input-container">
<!-- Material Icons worden nu globaal geladen in scripts.html -->
<!-- Dynamisch formulier container -->
<div v-if="formData" class="dynamic-form-container">
<!-- De titel wordt in DynamicForm weergegeven en niet hier om dubbele titels te voorkomen -->
<div v-if="!formData.fields" style="color: red; padding: 10px;">Fout: Geen velden gevonden in formulier</div>
<dynamic-form
v-if="formData && formData.fields"
:form-data="formData"
:form-values="formValues"
:is-submitting="isLoading"
:hide-actions="true"
@update:form-values="updateFormValues"
></dynamic-form>
<!-- Geen extra knoppen meer onder het formulier, alles gaat via de hoofdverzendknop -->
</div>
<div class="chat-input">
<!-- Main input area -->
<div class="input-main">
<textarea
ref="messageInput"
v-model="localMessage"
@keydown="handleKeydown"
:placeholder="translatedPlaceholder"
rows="1"
:disabled="isLoading"
:maxlength="maxLength"
class="message-input"
:class="{ 'over-limit': isOverLimit }"
></textarea>
<!-- Character counter -->
<div v-if="maxLength" class="character-counter" :class="{ 'over-limit': isOverLimit }">
{{ characterCount }}/{{ maxLength }}
</div>
</div>
<!-- Input actions -->
<div class="input-actions">
<!-- Universele verzendknop (voor zowel berichten als formulieren) -->
<button
@click="sendMessage"
class="send-btn"
:class="{ 'form-mode': formData }"
:disabled="!canSend"
:title="formData ? 'Verstuur formulier' : 'Verstuur bericht'"
>
<span v-if="isLoading" class="loading-spinner"></span>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<script>
// Importeer de benodigde componenten
import DynamicForm from './DynamicForm.vue';
import { IconManagerMixin } from '../js/iconManager.js';
export default {
name: 'ChatInput',
components: {
'dynamic-form': DynamicForm
},
// Gebruik de IconManagerMixin om automatisch iconen te laden
mixins: [IconManagerMixin],
props: {
currentMessage: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: 'Typ je bericht hier... - Enter om te verzenden, Shift+Enter voor nieuwe regel'
},
maxLength: {
type: Number,
default: 2000
},
formData: {
type: Object,
default: null
},
},
emits: ['send-message', 'update-message', 'submit-form'],
data() {
return {
localMessage: this.currentMessage,
formValues: {},
translatedPlaceholder: this.placeholder,
isTranslating: false,
languageChangeHandler: null
};
},
computed: {
characterCount() {
return this.localMessage.length;
},
isOverLimit() {
return this.characterCount > this.maxLength;
},
hasFormData() {
return this.formData && this.formData.fields &&
((Array.isArray(this.formData.fields) && this.formData.fields.length > 0) ||
(typeof this.formData.fields === 'object' && Object.keys(this.formData.fields).length > 0));
},
canSend() {
const hasValidForm = this.formData && this.validateForm();
const hasValidMessage = this.localMessage.trim() && !this.isOverLimit;
// We kunnen nu verzenden als er een geldig formulier OF een geldig bericht is
// Bij een formulier is aanvullende tekst optioneel
return (!this.isLoading) && (hasValidForm || hasValidMessage);
},
hasFormDataToSend() {
return this.formData && this.validateForm();
},
sendButtonText() {
if (this.isLoading) {
return 'Verzenden...';
}
return this.formData ? 'Verstuur formulier' : 'Verstuur bericht';
}
},
watch: {
'formData.icon': {
handler(newIcon) {
if (newIcon && window.iconManager) {
window.iconManager.ensureIconsLoaded({}, [newIcon]);
}
},
immediate: true
},
formData: {
handler(newFormData, oldFormData) {
console.log('ChatInput formData changed:', newFormData);
if (!newFormData) {
console.log('FormData is null of undefined');
this.formValues = {};
return;
}
// Controleer of velden aanwezig zijn
if (!newFormData.fields) {
console.error('FormData bevat geen velden!', newFormData);
return;
}
console.log('Velden in formData:', newFormData.fields);
console.log('Aantal velden:', Array.isArray(newFormData.fields)
? newFormData.fields.length
: Object.keys(newFormData.fields).length);
// Initialiseer formulierwaarden
this.initFormValues();
// Log de geïnitialiseerde waarden
console.log('Formulierwaarden geïnitialiseerd:', this.formValues);
},
immediate: true,
deep: true
},
currentMessage(newVal) {
this.localMessage = newVal;
},
localMessage(newVal) {
this.$emit('update-message', newVal);
this.autoResize();
}
},
created() {
// Als er een formData.icon is, wordt deze automatisch geladen via IconManagerMixin
// Geen expliciete window.iconManager calls meer nodig
// Maak een benoemde handler voor betere cleanup
this.languageChangeHandler = (event) => {
if (event.detail && event.detail.language) {
this.handleLanguageChange(event);
}
};
// Luister naar taalwijzigingen
document.addEventListener('language-changed', this.languageChangeHandler);
},
mounted() {
this.autoResize();
// Debug informatie over formData bij initialisatie
console.log('ChatInput mounted, formData:', this.formData);
if (this.formData) {
console.log('FormData bij mount:', JSON.stringify(this.formData));
}
},
beforeUnmount() {
// Verwijder event listener bij unmount met de benoemde handler
if (this.languageChangeHandler) {
document.removeEventListener('language-changed', this.languageChangeHandler);
}
},
methods: {
handleLanguageChange(event) {
if (event.detail && event.detail.language) {
this.translatePlaceholder(event.detail.language);
}
},
async translatePlaceholder(language) {
// Voorkom dubbele vertaling
if (this.isTranslating) {
console.log('Placeholder vertaling al bezig, overslaan...');
return;
}
// Zet de vertaling vlag
this.isTranslating = true;
// Gebruik de originele placeholder als basis voor vertaling
const originalText = this.placeholder;
try {
// Controleer of TranslationClient beschikbaar is
if (!window.TranslationClient || typeof window.TranslationClient.translate !== 'function') {
console.error('TranslationClient.translate is niet beschikbaar voor placeholder');
return;
}
// Gebruik TranslationClient zonder UI indicator
const apiPrefix = window.chatConfig?.apiPrefix || '';
const response = await window.TranslationClient.translate(
originalText,
language,
null, // source_lang (auto-detect)
'chat_input_placeholder', // context
apiPrefix // API prefix voor tenant routing
);
if (response.success) {
// Update de placeholder
this.translatedPlaceholder = response.translated_text;
} else {
console.error('Vertaling placeholder mislukt:', response.error);
}
} catch (error) {
console.error('Fout bij vertalen placeholder:', error);
} finally {
// Reset de vertaling vlag
this.isTranslating = false;
}
},
initFormValues() {
if (this.formData && this.formData.fields) {
console.log('Initializing form values for fields:', this.formData.fields);
this.formValues = {};
// Verwerk array van velden
if (Array.isArray(this.formData.fields)) {
this.formData.fields.forEach(field => {
const fieldId = field.id || field.name;
if (fieldId) {
this.formValues[fieldId] = field.default !== undefined ? field.default : '';
}
});
}
// Verwerk object van velden
else if (typeof this.formData.fields === 'object') {
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
this.formValues[fieldId] = field.default !== undefined ? field.default : '';
});
}
console.log('Initialized form values:', this.formValues);
}
},
handleKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendMessage();
} else if (event.key === 'Escape') {
this.localMessage = '';
}
},
sendMessage() {
if (!this.canSend) return;
// Bij een formulier gaan we het formulier en optioneel bericht combineren
if (this.formData) {
// Valideer het formulier
if (this.validateForm()) {
// Verstuur het formulier, eventueel met aanvullende tekst
this.$emit('submit-form', this.formValues);
}
} else if (this.localMessage.trim()) {
// Verstuur normaal bericht zonder formulier
this.$emit('send-message');
}
},
getFormValuesForSending() {
// Geeft de huidige formulierwaarden terug voor verzending
return this.formValues;
},
// Reset het formulier en de waarden
resetForm() {
this.formValues = {};
this.initFormValues();
},
// Annuleer het formulier (wordt momenteel niet gebruikt)
cancelForm() {
this.formValues = {};
// We sturen geen emit meer, maar het kan nuttig zijn om in de toekomst te hebben
},
validateForm() {
if (!this.formData || !this.formData.fields) return false;
// Controleer of alle verplichte velden zijn ingevuld
let missingFields = [];
if (Array.isArray(this.formData.fields)) {
missingFields = this.formData.fields.filter(field => {
if (!field.required) return false;
const fieldId = field.id || field.name;
const value = this.formValues[fieldId];
return value === undefined || value === null || (typeof value === 'string' && !value.trim());
});
} else {
// Voor object-gebaseerde velden
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
if (field.required) {
const value = this.formValues[fieldId];
if (value === undefined || value === null || (typeof value === 'string' && !value.trim())) {
missingFields.push(field);
}
}
});
}
return missingFields.length === 0;
},
autoResize() {
this.$nextTick(() => {
const textarea = this.$refs.messageInput;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
});
},
focusInput() {
this.$refs.messageInput?.focus();
},
clearInput() {
this.localMessage = '';
this.focusInput();
},
updateFormValues(newValues) {
// Controleer of er daadwerkelijk iets is veranderd om recursieve updates te voorkomen
if (JSON.stringify(newValues) !== JSON.stringify(this.formValues)) {
this.formValues = JSON.parse(JSON.stringify(newValues));
}
}
}
};
</script>
<style scoped>
/* ChatInput component styling */
/* Algemene container */
.chat-input-container {
width: 100%;
padding: 10px;
background-color: #fff;
border-top: 1px solid #e0e0e0;
font-family: Arial, sans-serif;
font-size: 14px;
}
/* Input veld en knoppen */
.chat-input {
display: flex;
align-items: flex-end;
gap: 10px;
}
.input-main {
flex: 1;
position: relative;
}
.message-input {
width: 100%;
min-height: 40px;
padding: 10px 40px 10px 15px;
border: 1px solid #ddd;
border-radius: 20px;
resize: none;
outline: none;
transition: border-color 0.2s;
font-family: Arial, sans-serif;
font-size: 14px;
}
.message-input:focus {
border-color: #0084ff;
}
.message-input.over-limit {
border-color: #ff4d4f;
}
/* Character counter */
.character-counter {
position: absolute;
right: 10px;
bottom: 10px;
font-size: 12px;
color: #999;
}
.character-counter.over-limit {
color: #ff4d4f;
}
/* Input actions */
.input-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Verzendknop */
.send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background-color: #0084ff;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
}
.send-btn:hover {
background-color: #0077e6;
}
.send-btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.send-btn.form-mode {
background-color: #4caf50;
}
.send-btn.form-mode:hover {
background-color: #43a047;
}
/* Loading spinner */
.loading-spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Formulier in chat input */
.dynamic-form-container {
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px 15px 5px 15px;
position: relative;
background-color: #f9f9f9;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
font-family: Arial, sans-serif;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,454 @@
<template>
<div :class="getMessageClass()" :data-message-id="message.id">
<!-- Normal text messages -->
<template v-if="message.type === 'text'">
<div class="message-content" style="width: 100%;">
<!-- Voortgangstracker voor AI berichten met task_id - NU BINNEN DE BUBBLE -->
<progress-tracker
v-if="message.sender === 'ai' && message.taskId"
:task-id="message.taskId"
:api-prefix="apiPrefix"
class="message-progress"
@specialist-complete="handleSpecialistComplete"
@specialist-error="handleSpecialistError"
></progress-tracker>
<!-- Form data display if available (alleen in user messages) -->
<div v-if="message.formValues && message.sender === 'user'" class="form-display user-form-values">
<dynamic-form
:form-data="message.formData"
:form-values="message.formValues"
:read-only="true"
hide-actions
class="message-form user-form"
></dynamic-form>
</div>
<!-- Formulier in AI berichten -->
<div v-if="message.formData && message.sender === 'ai'" class="form-display ai-form-values" style="margin-top: 15px;">
<!-- Dynamisch toevoegen van Material Symbols Outlined voor iconen -->
<table class="form-result-table">
<thead v-if="message.formData.title || message.formData.name || message.formData.icon">
<tr>
<th colspan="2">
<div class="form-header">
<span v-if="message.formData.icon" class="material-symbols-outlined">{{ message.formData.icon }}</span>
<span>{{ message.formData.title || message.formData.name }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(field, fieldId) in message.formData.fields" :key="fieldId">
<td class="field-label">{{ field.name }}:</td>
<td class="field-value">
<input
v-if="field.type === 'str' || field.type === 'string' || field.type === 'int' || field.type === 'integer' || field.type === 'float'"
:type="field.type === 'int' || field.type === 'integer' || field.type === 'float' ? 'number' : 'text'"
:placeholder="field.placeholder || ''"
class="form-input"
>
<textarea
v-else-if="field.type === 'text'"
:placeholder="field.placeholder || ''"
:rows="field.rows || 3"
class="form-textarea"
></textarea>
<select
v-else-if="field.type === 'enum'"
class="form-select"
>
<option value="">Selecteer een optie</option>
<option
v-for="option in (field.allowedValues || field.allowed_values || [])"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="toggle-switch">
<input type="checkbox" class="toggle-input">
<span class="toggle-slider">
<span class="toggle-knob"></span>
</span>
</div>
<input
v-else
type="text"
:placeholder="field.placeholder || ''"
class="form-input"
>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Bericht tekst -->
<div v-if="message.content" class="message-text" v-html="formatMessage(message.content)"></div>
</div>
</template>
<!-- Error messages -->
<template v-else-if="message.type === 'error'">
<div class="message-content error-content">
<div class="form-error">
{{ message.content }}
</div>
<button v-if="message.retryable" @click="$emit('retry-message', message.id)" class="retry-btn">
Opnieuw proberen
</button>
</div>
</template>
<!-- Other message types -->
<template v-else>
<div class="message-content">
<div class="message-text" v-html="formatMessage(message.content)"></div>
</div>
</template>
</div>
</template>
<script>
// Import benodigde componenten
import DynamicForm from './DynamicForm.vue';
import ProgressTracker from './ProgressTracker.vue';
export default {
name: 'ChatMessage',
components: {
'dynamic-form': DynamicForm,
'progress-tracker': ProgressTracker
},
props: {
message: {
type: Object,
required: true,
validator: (message) => {
return message.id && message.content !== undefined && message.sender && message.type;
}
},
isSubmittingForm: {
type: Boolean,
default: false
},
apiPrefix: {
type: String,
default: ''
}
},
emits: ['image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'],
data() {
return {
formVisible: true
};
},
created() {
// Zorg ervoor dat het icoon geladen wordt als iconManager beschikbaar is
if (window.iconManager && this.message.formData && this.message.formData.icon) {
window.iconManager.loadIcon(this.message.formData.icon);
}
// Sla de originele inhoud op voor het eerste bericht als we in een conversatie zitten met slechts één bericht
if (this.message.sender === 'ai' && !this.message.originalContent) {
this.message.originalContent = this.message.content;
}
},
mounted() {
// Luister naar taalwijzigingen
document.addEventListener('language-changed', this.handleLanguageChange);
},
beforeUnmount() {
// Verwijder event listener bij verwijderen component
document.removeEventListener('language-changed', this.handleLanguageChange);
},
computed: {
hasFormData() {
return this.message.formData &&
((Array.isArray(this.message.formData.fields) && this.message.formData.fields.length > 0) ||
(typeof this.message.formData.fields === 'object' && Object.keys(this.message.formData.fields).length > 0));
},
hasFormValues() {
return this.message.formValues && Object.keys(this.message.formValues).length > 0;
}
},
watch: {
'message.formData.icon': {
handler(newIcon) {
if (newIcon && window.iconManager) {
window.iconManager.loadIcon(newIcon);
}
},
immediate: true
}
},
methods: {
async handleLanguageChange(event) {
// Controleer of dit het eerste bericht is in een gesprek met maar één bericht
// Dit wordt al afgehandeld door MessageHistory component, dus we hoeven hier niets te doen
// De implementatie hiervan blijft in MessageHistory om dubbele vertaling te voorkomen
},
handleSpecialistError(eventData) {
console.log('ChatMessage received specialist-error event:', eventData);
// Creëer een error message met correcte styling
this.message.type = 'error';
this.message.content = eventData.message || 'Er is een fout opgetreden bij het verwerken van uw verzoek.';
this.message.retryable = true;
this.message.error = true; // Voeg error flag toe voor styling
// Bubble up naar parent component voor verdere afhandeling
this.$emit('specialist-error', {
messageId: this.message.id,
...eventData
});
},
handleSpecialistComplete(eventData) {
console.log('ChatMessage received specialist-complete event:', eventData);
// Update de inhoud van het bericht met het antwoord
if (eventData.answer) {
console.log('Updating message content with answer:', eventData.answer);
this.message.content = eventData.answer;
} else {
console.error('No answer in specialist-complete event data');
}
// Bubble up naar parent component voor eventuele verdere afhandeling
this.$emit('specialist-complete', {
messageId: this.message.id,
answer: eventData.answer,
form_request: eventData.form_request, // Wordt nu door ChatApp verwerkt
result: eventData.result,
interactionId: eventData.interactionId,
taskId: eventData.taskId
});
},
formatMessage(content) {
if (!content) return '';
// Enhanced markdown-like formatting
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/\n/g, '<br>');
},
removeMessage() {
// Dit zou een event moeten triggeren naar de parent component
},
reactToMessage(emoji) {
// Implementatie van reacties zou hier komen
},
getMessageClass() {
return `message ${this.message.sender}`;
}
}
};
</script>
<style scoped>
/* chat-message.css */
/* Algemene styling voor berichten */
.message {
max-width: 90%;
margin-bottom: 15px;
width: auto;
}
.message.user {
margin-left: auto;
}
.message.ai {
margin-right: auto;
}
.message-content {
width: 100%;
font-family: Arial, sans-serif;
font-size: 14px;
}
/* Formulier styling */
.form-display {
margin: 15px 0;
border-radius: 8px;
background-color: rgba(245, 245, 245, 0.7);
padding: 15px;
border: 1px solid #e0e0e0;
font-family: inherit;
}
/* Tabel styling voor formulieren */
.form-result-table {
width: 100%;
border-collapse: collapse;
font-family: inherit;
}
.form-result-table th {
padding: 8px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
font-weight: 600;
font-family: Arial, sans-serif;
font-size: 14px;
}
.form-result-table td {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-family: Arial, sans-serif;
font-size: 14px;
}
.form-result-table td:first-child {
font-weight: 500;
width: 35%;
}
/* Styling voor formulier invoervelden */
.form-result-table input.form-input,
.form-result-table textarea.form-textarea,
.form-result-table select.form-select {
width: 100%;
padding: 6px;
border-radius: 4px;
border: 1px solid #ddd;
font-family: Arial, sans-serif;
font-size: 14px;
background-color: white;
}
.form-result-table textarea.form-textarea {
resize: vertical;
min-height: 60px;
}
/* Styling voor tabel cellen */
.form-result-table .field-label {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-weight: 500;
width: 35%;
vertical-align: top;
}
.form-result-table .field-value {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
}
/* Toggle Switch styling */
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.toggle-input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.toggle-knob {
position: absolute;
content: '';
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
/* Material icon styling */
.material-symbols-outlined {
vertical-align: middle;
margin-right: 8px;
font-size: 20px;
}
.form-header {
display: flex;
align-items: center;
padding: 8px;
border-bottom: 1px solid #e0e0e0;
}
/* Zorgt dat het lettertype consistent is */
.message-text {
font-family: Arial, sans-serif;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
}
/* Form error styling */
.form-error {
color: red;
padding: 10px;
font-family: Arial, sans-serif;
font-size: 14px;
}
.error-content {
background-color: #ffebee;
border: 1px solid #f44336;
border-radius: 4px;
padding: 10px;
}
.retry-btn {
margin-top: 10px;
padding: 8px 16px;
background-color: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.retry-btn:hover {
background-color: #d32f2f;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.message {
max-width: 95%;
}
.form-result-table td:first-child {
width: 40%;
}
}
</style>

View File

@@ -0,0 +1,501 @@
<template>
<div class="dynamic-form-container">
<div class="dynamic-form">
<!-- Form header with icon and title -->
<div v-if="formData.title || formData.name || formData.icon" class="form-header">
<div v-if="formData.icon" class="form-icon">
<span class="material-symbols-outlined">{{ formData.icon }}</span>
</div>
<div class="form-title">{{ formData.title || formData.name }}</div>
</div>
<!-- Form fields -->
<div class="form-fields">
<template v-if="Array.isArray(formData.fields)">
<form-field
v-for="field in formData.fields"
:key="field.id || field.name"
:field="field"
:field-id="field.id || field.name"
:model-value="localFormValues[field.id || field.name]"
@update:model-value="updateFieldValue(field.id || field.name, $event)"
/>
</template>
<template v-else-if="typeof formData.fields === 'object'">
<form-field
v-for="(field, fieldId) in formData.fields"
:key="fieldId"
:field="field"
:field-id="fieldId"
:model-value="localFormValues[fieldId]"
@update:model-value="updateFieldValue(fieldId, $event)"
/>
</template>
</div>
<!-- Form actions (only show if not hidden and not read-only) -->
<div v-if="!hideActions && !readOnly" class="form-actions">
<button
type="button"
@click="handleCancel"
class="btn btn-secondary"
:disabled="isSubmitting"
>
Annuleren
</button>
<button
type="button"
@click="handleSubmit"
class="btn btn-primary"
:disabled="isSubmitting || !isFormValid"
>
<span v-if="isSubmitting">Verzenden...</span>
<span v-else>Versturen</span>
</button>
</div>
<!-- Read-only form display -->
<div v-if="readOnly" class="form-readonly">
<div
v-for="(field, fieldId) in getFieldsForDisplay()"
:key="fieldId"
class="form-field-readonly"
>
<div class="field-label">{{ field.name }}:</div>
<div class="field-value" :class="{'text-value': field.type === 'text'}">
{{ formatFieldValue(fieldId, field) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import FormField from './FormField.vue';
export default {
name: 'DynamicForm',
components: {
'form-field': FormField
},
props: {
formData: {
type: Object,
required: true,
validator: (formData) => {
// Controleer eerst of formData een geldig object is
if (!formData || typeof formData !== 'object') {
console.error('FormData is niet een geldig object');
return false;
}
// Controleer of er een titel of naam is
if (!formData.title && !formData.name) {
console.error('FormData heeft geen title of name');
return false;
}
// Controleer of er velden zijn
if (!formData.fields) {
console.error('FormData heeft geen fields eigenschap');
return false;
}
// Controleer of velden een array of object zijn
if (!Array.isArray(formData.fields) && typeof formData.fields !== 'object') {
console.error('FormData.fields is geen array of object');
return false;
}
console.log('FormData is geldig:', formData);
return true;
}
},
formValues: {
type: Object,
default: () => ({})
},
isSubmitting: {
type: Boolean,
default: false
},
readOnly: {
type: Boolean,
default: false
},
hideActions: {
type: Boolean,
default: false
}
},
emits: ['submit', 'cancel', 'update:formValues'],
data() {
return {
localFormValues: { ...this.formValues }
};
},
computed: {
isFormValid() {
// Basic validation - check required fields
const missingFields = [];
if (Array.isArray(this.formData.fields)) {
// Valideer array-gebaseerde velden
this.formData.fields.forEach(field => {
const fieldId = field.id || field.name;
if (field.required) {
const value = this.localFormValues[fieldId];
if (value === undefined || value === null ||
(typeof value === 'string' && !value.trim()) ||
(Array.isArray(value) && value.length === 0)) {
missingFields.push(field.name);
}
}
});
} else {
// Valideer object-gebaseerde velden
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
if (field.required) {
const value = this.localFormValues[fieldId];
if (value === undefined || value === null ||
(typeof value === 'string' && !value.trim()) ||
(Array.isArray(value) && value.length === 0)) {
missingFields.push(field.name);
}
}
});
}
return missingFields.length === 0;
}
},
watch: {
formValues: {
handler(newValues) {
// Gebruik een vlag om recursieve updates te voorkomen
if (JSON.stringify(newValues) !== JSON.stringify(this.localFormValues)) {
this.localFormValues = JSON.parse(JSON.stringify(newValues));
}
},
deep: true
},
localFormValues: {
handler(newValues) {
// Gebruik een vlag om recursieve updates te voorkomen
if (JSON.stringify(newValues) !== JSON.stringify(this.formValues)) {
this.$emit('update:formValues', JSON.parse(JSON.stringify(newValues)));
}
},
deep: true
},
'formData.icon': {
handler(newIcon) {
if (newIcon && window.iconManager) {
window.iconManager.loadIcon(newIcon);
}
},
immediate: true
}
},
created() {
// Zorg ervoor dat het icoon geladen wordt als iconManager beschikbaar is
if (window.iconManager && this.formData && this.formData.icon) {
window.iconManager.loadIcon(this.formData.icon);
}
},
methods: {
updateFieldValue(fieldId, value) {
// Update lokale waarde
this.localFormValues = {
...this.localFormValues,
[fieldId]: value
};
},
handleSubmit() {
// Basic validation
const missingFields = [];
if (Array.isArray(this.formData.fields)) {
// Valideer array-gebaseerde velden
this.formData.fields.forEach(field => {
const fieldId = field.id || field.name;
if (field.required) {
const value = this.localFormValues[fieldId];
if (value === undefined || value === null ||
(typeof value === 'string' && !value.trim()) ||
(Array.isArray(value) && value.length === 0)) {
missingFields.push(field.name);
}
}
});
} else {
// Valideer object-gebaseerde velden
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
if (field.required) {
const value = this.localFormValues[fieldId];
if (value === undefined || value === null ||
(typeof value === 'string' && !value.trim()) ||
(Array.isArray(value) && value.length === 0)) {
missingFields.push(field.name);
}
}
});
}
if (missingFields.length > 0) {
alert(`De volgende velden zijn verplicht: ${missingFields.join(', ')}`);
return;
}
// Emit submit event
this.$emit('submit', this.localFormValues);
},
handleCancel() {
this.$emit('cancel');
},
getFieldsForDisplay() {
// Voor read-only weergave
if (Array.isArray(this.formData.fields)) {
const fieldsObj = {};
this.formData.fields.forEach(field => {
const fieldId = field.id || field.name;
fieldsObj[fieldId] = field;
});
return fieldsObj;
}
return this.formData.fields;
},
formatFieldValue(fieldId, field) {
const value = this.localFormValues[fieldId];
if (value === undefined || value === null) {
return '-';
}
// Format different field types
if (field.type === 'boolean') {
return value ? 'Ja' : 'Nee';
} else if (field.type === 'enum' && !value && field.default) {
return field.default;
}
return value.toString();
}
}
};
</script>
<style scoped>
/* Dynamisch formulier stijlen */
.dynamic-form-container {
margin-bottom: 15px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
background-color: #f9f9f9;
}
.dynamic-form {
padding: 15px;
}
.form-header {
display: flex;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #e0e0e0;
}
.form-icon {
margin-right: 10px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #555;
}
.form-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
.form-fields {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
margin-bottom: 20px;
}
@media (min-width: 768px) {
.form-fields {
grid-template-columns: repeat(2, 1fr);
}
}
.form-field {
margin-bottom: 5px;
}
.form-field label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 0.9rem;
color: #555;
}
.form-field input,
.form-field select,
.form-field textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
background-color: #fff;
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.form-field textarea {
min-height: 80px;
resize: vertical;
}
.checkbox-container {
display: flex;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
.checkbox-text {
font-size: 0.9rem;
color: #555;
}
.field-description {
display: block;
margin-top: 5px;
font-size: 0.8rem;
color: #777;
line-height: 1.4;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 10px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s;
}
.btn-primary {
background-color: #4a90e2;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #357abd;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #545b62;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-toggle-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
color: #555;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.form-toggle-btn:hover {
background-color: #f0f0f0;
}
.form-toggle-btn.active {
color: #4a90e2;
background-color: rgba(74, 144, 226, 0.1);
}
.required {
color: #e53935;
margin-left: 2px;
}
/* Read-only form styling */
.form-readonly {
padding: 10px 0;
}
.form-field-readonly {
display: flex;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.field-label {
flex: 0 0 30%;
font-weight: 500;
color: #555;
padding-right: 10px;
}
.field-value {
flex: 1;
word-break: break-word;
}
.text-value {
white-space: pre-wrap;
}
</style>

View File

@@ -0,0 +1,328 @@
<template>
<div class="form-field" style="margin-bottom: 15px; display: grid; grid-template-columns: 35% 65%; align-items: center;">
<!-- Label voor alle velden behalve boolean/checkbox die een speciale behandeling krijgen -->
<label v-if="fieldType !== 'checkbox'" :for="fieldId" style="margin-right: 10px; font-weight: 500;">
{{ field.name }}
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
</label>
<!-- Container voor input velden -->
<div style="width: 100%;">
<!-- Context informatie indien aanwezig -->
<div v-if="field.context" class="field-context" style="margin-bottom: 8px; font-size: 0.9em; color: #666; background-color: #f8f9fa; padding: 8px; border-radius: 4px; border-left: 3px solid #4285f4;">
{{ field.context }}
</div>
<!-- Tekstinvoer (string/str) -->
<input
v-if="fieldType === 'text'"
:id="fieldId"
type="text"
v-model="value"
:required="field.required"
:placeholder="field.placeholder || ''"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- Numerieke invoer (int/float) -->
<input
v-if="fieldType === 'number'"
:id="fieldId"
type="number"
v-model.number="value"
:required="field.required"
:step="stepValue"
:placeholder="field.placeholder || ''"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- Tekstvlak (text) -->
<textarea
v-if="fieldType === 'textarea'"
:id="fieldId"
v-model="value"
:required="field.required"
:rows="field.rows || 3"
:placeholder="field.placeholder || ''"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box; resize: vertical;"
></textarea>
<!-- Selectielijst (enum) -->
<select
v-if="fieldType === 'select'"
:id="fieldId"
v-model="value"
:required="field.required"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<option value="">Selecteer een optie</option>
<option
v-for="option in (field.allowedValues || field.allowed_values || [])"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Radio buttons (options) -->
<div v-if="fieldType === 'radio'" class="radio-group" style="display: flex; flex-direction: column; gap: 8px;">
<label
v-for="option in (field.allowedValues || field.allowed_values || [])"
:key="option"
class="radio-label"
style="display: flex; align-items: center; cursor: pointer;"
>
<input
type="radio"
:name="fieldId"
:value="option"
v-model="value"
:required="field.required"
style="margin-right: 8px;"
>
<span>{{ option }}</span>
</label>
</div>
<!-- Checkbox (boolean) -->
<div v-if="fieldType === 'checkbox'" class="checkbox-container" style="display: flex; align-items: center;">
<label class="checkbox-label" style="display: flex; align-items: center; cursor: pointer;">
<input
type="checkbox"
:id="fieldId"
v-model="value"
:required="field.required"
style="margin-right: 8px;"
>
<span class="checkbox-text">{{ field.name }}</span>
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
</label>
</div>
<!-- Bestandsupload -->
<input
v-if="fieldType === 'file'"
:id="fieldId"
type="file"
@change="handleFileUpload"
:required="field.required"
:accept="field.accept || '*'"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- Datum invoer -->
<input
v-if="fieldType === 'date'"
:id="fieldId"
type="date"
v-model="value"
:required="field.required"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- Tijd invoer -->
<input
v-if="fieldType === 'time'"
:id="fieldId"
type="time"
v-model="value"
:required="field.required"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- Email invoer -->
<input
v-if="fieldType === 'email'"
:id="fieldId"
type="email"
v-model="value"
:required="field.required"
:placeholder="field.placeholder || ''"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- URL invoer -->
<input
v-if="fieldType === 'url'"
:id="fieldId"
type="url"
v-model="value"
:required="field.required"
:placeholder="field.placeholder || ''"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- Beschrijving/help tekst -->
<small v-if="description" class="field-description" style="display: block; margin-top: 5px; font-size: 0.8rem; color: #777; line-height: 1.4;">
{{ description }}
</small>
</div>
</div>
</template>
<script>
export default {
name: 'FormField',
props: {
field: {
type: Object,
required: true,
validator: (field) => {
return field.name && field.type;
}
},
fieldId: {
type: String,
required: true
},
modelValue: {
default: null
}
},
emits: ['update:modelValue'],
computed: {
value: {
get() {
// Gebruik default waarde als modelValue undefined is
if (this.modelValue === undefined || this.modelValue === null) {
if (this.field.type === 'boolean') {
return this.field.default === true;
}
return this.field.default !== undefined ? this.field.default : '';
}
return this.modelValue;
},
set(value) {
// Voorkom emit als de waarde niet is veranderd
if (JSON.stringify(value) !== JSON.stringify(this.modelValue)) {
this.$emit('update:modelValue', value);
}
}
},
fieldType() {
// Map Python types naar HTML input types
const typeMap = {
'str': 'text',
'string': 'text',
'int': 'number',
'integer': 'number',
'float': 'number',
'text': 'textarea',
'enum': 'select',
'options': 'radio',
'boolean': 'checkbox',
'file': 'file',
'date': 'date',
'time': 'time',
'email': 'email',
'url': 'url'
};
return typeMap[this.field.type] || this.field.type;
},
stepValue() {
return this.field.type === 'float' ? 'any' : 1;
},
description() {
return this.field.description || '';
}
},
methods: {
handleFileUpload(event) {
const file = event.target.files[0];
if (file) {
this.value = file;
}
}
}
};
</script>
<style scoped>
.form-field {
margin-bottom: 15px;
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
outline: none;
border-color: #4a90e2 !important;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.radio-label {
display: flex;
align-items: center;
cursor: pointer;
font-size: 0.9rem;
color: #555;
}
.checkbox-container {
display: flex;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
}
.checkbox-text {
font-size: 0.9rem;
color: #555;
}
.field-description {
display: block;
margin-top: 5px;
font-size: 0.8rem;
color: #777;
line-height: 1.4;
}
.field-context {
margin-bottom: 8px;
font-size: 0.9em;
color: #666;
background-color: #f8f9fa;
padding: 8px;
border-radius: 4px;
border-left: 3px solid #4285f4;
}
.required {
color: #d93025;
margin-left: 2px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.form-field {
display: block !important;
grid-template-columns: none !important;
}
.form-field label {
margin-bottom: 8px;
display: block;
}
}
</style>

View File

@@ -0,0 +1,286 @@
<template>
<div v-if="hasFormData" class="form-message">
<div v-if="formData.name" class="form-message-header">
<i v-if="formData.icon" class="material-icons form-message-icon">{{ formData.icon }}</i>
<span class="form-message-title">{{ formData.name }}</span>
</div>
<div class="form-message-fields">
<div v-for="field in formattedFields" :key="field.id" class="form-message-field">
<div class="field-message-label">{{ field.name }}:</div>
<div class="field-message-value" :class="{'text-value': field.type === 'text'}">{{ field.value }}</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'FormMessage',
props: {
formData: {
type: Object,
required: true
},
formValues: {
type: Object,
required: true
}
},
computed: {
hasFormData() {
return this.formData && this.formData.fields && Object.keys(this.formData.fields).length > 0;
},
formattedFields() {
if (!this.hasFormData) return [];
return Object.entries(this.formData.fields).map(([fieldId, field]) => {
let displayValue = this.formValues[fieldId] || '';
// Format different field types
if (field.type === 'boolean') {
displayValue = displayValue ? 'Ja' : 'Nee';
} else if (field.type === 'enum' && !displayValue && field.default) {
displayValue = field.default;
} else if (field.type === 'text') {
// Voor tekstgebieden, behoud witruimte
// De CSS zal dit tonen met white-space: pre-wrap
}
return {
id: fieldId,
name: field.name,
value: displayValue || '-',
type: field.type
};
});
}
}
};
</script>
<style scoped>
/* Styling voor formulier in berichten */
.form-message {
margin-bottom: 12px;
border-radius: 8px;
background-color: rgba(245, 245, 245, 0.7);
padding: 12px;
border: 1px solid #e0e0e0;
}
.form-message-header {
display: flex;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
}
.form-message-icon {
margin-right: 8px;
font-size: 18px;
color: #555;
}
.form-message-title {
font-weight: 600;
color: #333;
font-size: 1em;
}
.form-message-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-message-field {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 4px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.form-message-field:last-child {
border-bottom: none;
}
.field-message-label {
font-weight: 500;
color: #555;
flex: 0 0 40%;
padding-right: 10px;
word-break: break-word;
}
.field-message-value {
flex: 1;
color: #333;
word-break: break-word;
text-align: right;
}
.field-message-value.text-value {
white-space: pre-wrap;
text-align: left;
}
/* Styling voor verschillende message contexten */
.message.user .form-message {
background-color: rgba(255, 255, 255, 0.1);
}
.message.ai .form-message {
background-color: rgba(245, 245, 250, 0.7);
}
/* Algemene form display styling */
.form-display {
margin-bottom: 10px;
border-radius: 8px;
padding: 12px;
background-color: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.user-form-values {
background-color: rgba(0, 123, 255, 0.05);
}
/* Speciale styling voor read-only formulieren in user messages */
.user-form .form-field {
margin-bottom: 6px !important;
}
.user-form .field-label {
font-weight: 500 !important;
color: #555 !important;
padding: 2px 0 !important;
}
.user-form .field-value {
padding: 2px 0 !important;
}
/* Schakel hover effecten uit voor read-only formulieren */
.read-only .form-field:hover {
background-color: transparent;
}
/* Subtiele scheiding tussen velden */
.dynamic-form.read-only .form-fields {
border-top: 1px solid rgba(0, 0, 0, 0.05);
margin-top: 10px;
padding-top: 8px;
}
/* Verklein vorm titels in berichten */
.message-form .form-title {
font-size: 1em !important;
}
.message-form .form-description {
font-size: 0.85em !important;
}
.form-readonly {
width: 100%;
}
.form-readonly .field-label {
font-weight: 500;
color: #555;
}
.form-readonly .field-value {
word-break: break-word;
}
.form-readonly .text-value {
white-space: pre-wrap;
}
/* Algemene styling verbetering voor berichten */
.message-text {
white-space: pre-wrap;
word-break: break-word;
}
.message-content {
max-width: 100%;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.form-message-field {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.field-message-label {
flex: none;
padding-right: 0;
}
.field-message-value {
text-align: left;
}
.form-message {
padding: 10px;
}
.form-message-title {
font-size: 0.9em;
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.form-message {
background-color: rgba(40, 40, 40, 0.7);
border-color: #555;
}
.form-message-header {
border-bottom-color: #555;
}
.form-message-title {
color: #e0e0e0;
}
.field-message-label {
color: #ccc;
}
.field-message-value {
color: #e0e0e0;
}
.form-message-field {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.form-message {
border: 2px solid #000;
background-color: #fff;
}
.form-message-title,
.field-message-label,
.field-message-value {
color: #000;
}
.form-message-header {
border-bottom: 2px solid #000;
}
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div class="language-selector">
<label for="language-select">Taal / Language:</label>
<div class="select-wrapper">
<select
id="language-select"
v-model="selectedLanguage"
@change="changeLanguage(selectedLanguage)"
class="language-select"
>
<option
v-for="lang in getAvailableLanguages()"
:key="lang.code"
:value="lang.code"
>
{{ lang.flag }} {{ lang.name }}
</option>
</select>
</div>
</div>
</template>
<script>
export default {
name: 'LanguageSelector',
props: {
initialLanguage: {
type: String,
default: 'nl'
},
currentLanguage: {
type: String,
default: null
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
},
data() {
const startLanguage = this.currentLanguage || this.initialLanguage;
return {
selectedLanguage: startLanguage,
internalCurrentLanguage: startLanguage
};
},
mounted() {
console.log('🔍 [DEBUG] LanguageSelector mounted with Vue SFC');
console.log('🔍 [DEBUG] Props:', {
initialLanguage: this.initialLanguage,
currentLanguage: this.currentLanguage,
supportedLanguageDetails: this.supportedLanguageDetails,
allowedLanguages: this.allowedLanguages
});
// Emit initial language
this.$emit('language-changed', this.selectedLanguage);
// DOM event
const event = new CustomEvent('vue:language-changed', {
detail: { language: this.selectedLanguage }
});
document.dispatchEvent(event);
},
methods: {
getAvailableLanguages() {
const languages = [];
const languagesToUse = (this.allowedLanguages && this.allowedLanguages.length > 0)
? this.allowedLanguages
: ['nl', 'en', 'fr', 'de'];
if (this.supportedLanguageDetails && Object.keys(this.supportedLanguageDetails).length > 0) {
for (const [langName, langDetails] of Object.entries(this.supportedLanguageDetails)) {
const isoCode = langDetails['iso 639-1'];
if (languagesToUse.includes(isoCode)) {
languages.push({
code: isoCode,
name: langName,
flag: langDetails.flag || ''
});
}
}
} else {
const defaultLanguages = {
'nl': { name: 'Nederlands', flag: '🇳🇱' },
'en': { name: 'English', flag: '🇬🇧' },
'fr': { name: 'Français', flag: '🇫🇷' },
'de': { name: 'Deutsch', flag: '🇩🇪' }
};
languagesToUse.forEach(code => {
if (defaultLanguages[code]) {
languages.push({
code: code,
name: defaultLanguages[code].name,
flag: defaultLanguages[code].flag
});
}
});
}
return languages;
},
changeLanguage(languageCode) {
console.log(`LanguageSelector: changeLanguage called with ${languageCode}`);
if (this.internalCurrentLanguage !== languageCode) {
this.internalCurrentLanguage = languageCode;
this.selectedLanguage = languageCode;
// Vue component emit
this.$emit('language-changed', languageCode);
// DOM event
const event = new CustomEvent('vue:language-changed', {
detail: { language: languageCode }
});
document.dispatchEvent(event);
}
}
}
};
</script>
<style scoped>
/* Styling voor de taalselector */
.sidebar-language-section {
padding: 10px 15px;
margin-bottom: 15px;
}
#language-selector-container {
display: flex;
flex-direction: column;
padding: 10px;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 5px;
margin: 10px 0;
}
#language-selector-container label {
margin-bottom: 5px;
color: var(--sidebar-color);
font-size: 0.9rem;
font-weight: 500;
}
.language-selector {
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.2);
color: var(--sidebar-color);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
}
.language-selector:hover {
background-color: rgba(0, 0, 0, 0.3);
}
.language-selector:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.3);
}
.language-selector option {
background-color: #2c3e50;
color: white;
padding: 8px;
}
.select-wrapper {
position: relative;
}
.language-select {
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.2);
color: var(--sidebar-color);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
}
.language-select:hover {
background-color: rgba(0, 0, 0, 0.3);
}
.language-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(var(--primary-color-rgb), 0.3);
}
.language-select option {
background-color: #2c3e50;
color: white;
padding: 8px;
}
</style>

View File

@@ -0,0 +1,316 @@
<template>
<div class="message-history-container">
<!-- Messages container -->
<div class="chat-messages" ref="messagesContainer">
<!-- Loading indicator for load more -->
<div v-if="$slots.loading" class="load-more-indicator">
<slot name="loading"></slot>
</div>
<!-- Empty state -->
<div v-if="messages.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>
<!-- Message list -->
<template v-else>
<!-- Messages -->
<template v-for="(message, index) in messages" :key="message.id">
<!-- The actual message -->
<chat-message
:message="message"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
@image-loaded="handleImageLoaded"
@specialist-complete="$emit('specialist-complete', $event)"
@specialist-error="$emit('specialist-error', $event)"
></chat-message>
</template>
</template>
<!-- Typing indicator -->
<typing-indicator v-if="isTyping"></typing-indicator>
</div>
</div>
</template>
<script>
import ChatMessage from './ChatMessage.vue';
import TypingIndicator from './TypingIndicator.vue';
export default {
name: 'MessageHistory',
components: {
'chat-message': ChatMessage,
'typing-indicator': TypingIndicator
},
props: {
messages: {
type: Array,
default: () => []
},
isTyping: {
type: Boolean,
default: false
},
isSubmittingForm: {
type: Boolean,
default: false
},
apiPrefix: {
type: String,
default: ''
},
autoScroll: {
type: Boolean,
default: true
}
},
emits: ['load-more', 'specialist-complete', 'specialist-error'],
data() {
return {
isAtBottom: true,
showScrollButton: false,
unreadCount: 0,
languageChangeHandler: null
};
},
watch: {
messages: {
handler(newMessages, oldMessages) {
// Auto-scroll when new messages are added
if (this.autoScroll && newMessages.length > (oldMessages?.length || 0)) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
},
deep: true
},
isTyping(newVal) {
if (newVal && this.autoScroll) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
}
},
created() {
// Maak een benoemde handler voor betere cleanup
this.languageChangeHandler = (event) => {
if (event.detail && event.detail.language) {
this.handleLanguageChange(event);
}
};
// Luister naar taalwijzigingen
document.addEventListener('language-changed', this.languageChangeHandler);
},
mounted() {
this.setupScrollListener();
// Initial scroll to bottom
if (this.autoScroll) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
},
beforeUnmount() {
// Cleanup scroll listener
const container = this.$refs.messagesContainer;
if (container) {
container.removeEventListener('scroll', this.handleScroll);
}
// Cleanup language change listener
if (this.languageChangeHandler) {
document.removeEventListener('language-changed', this.languageChangeHandler);
}
},
methods: {
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') {
const firstMessage = this.messages[0];
// Controleer of we een originele inhoud hebben om te vertalen
if (firstMessage.originalContent) {
console.log('Vertaling van eerste AI bericht naar:', event.detail.language);
try {
// Controleer of TranslationClient beschikbaar is
if (!window.TranslationClient || typeof window.TranslationClient.translate !== 'function') {
console.error('TranslationClient.translate is niet beschikbaar');
return;
}
// Gebruik TranslationClient
const response = await window.TranslationClient.translate(
firstMessage.originalContent,
event.detail.language,
null, // source_lang (auto-detect)
'chat_message', // context
this.apiPrefix // API prefix voor tenant routing
);
if (response.success) {
// Update de inhoud van het bericht
firstMessage.content = response.translated_text;
console.log('Eerste bericht succesvol vertaald');
} else {
console.error('Vertaling mislukt:', response.error);
}
} catch (error) {
console.error('Fout bij vertalen eerste bericht:', error);
}
}
}
},
scrollToBottom() {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
this.isAtBottom = true;
this.showScrollButton = false;
this.unreadCount = 0;
}
},
setupScrollListener() {
const container = this.$refs.messagesContainer;
if (!container) return;
container.addEventListener('scroll', this.handleScroll);
},
handleScroll() {
const container = this.$refs.messagesContainer;
if (!container) return;
const threshold = 100; // pixels from bottom
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');
}
},
handleImageLoaded() {
// Auto-scroll when images load to maintain position
if (this.isAtBottom) {
this.$nextTick(() => this.scrollToBottom());
}
},
searchMessages(query) {
// Simple message search
if (!query.trim()) return this.messages;
const searchTerm = query.toLowerCase();
return this.messages.filter(message =>
message.content &&
message.content.toLowerCase().includes(searchTerm)
);
}
}
};
</script>
<style scoped>
.message-history-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 10px;
scroll-behavior: smooth;
}
.load-more-indicator {
text-align: center;
padding: 10px;
color: #666;
font-size: 0.9rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: #666;
padding: 40px 20px;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 8px;
color: #333;
}
.empty-subtext {
font-size: 0.9rem;
color: #666;
max-width: 300px;
line-height: 1.4;
}
/* Scrollbar styling */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.chat-messages {
padding: 8px;
}
.empty-state {
padding: 20px 16px;
}
.empty-icon {
font-size: 2.5rem;
}
.empty-text {
font-size: 1.1rem;
}
}
</style>

View File

@@ -0,0 +1,445 @@
<template>
<div class="progress-tracker" :class="{ 'expanded': isExpanded, 'completed': isCompleted && !hasError, 'error': error || hasError }">
<div
class="progress-header"
@click="toggleExpand"
:title="isExpanded ? 'Inklappen' : 'Uitklappen voor volledige voortgang'"
>
<div class="progress-title">
<span v-if="connecting" class="spinner"></span>
<span v-else-if="error" class="status-icon error"></span>
<span v-else-if="isCompleted" class="status-icon completed"></span>
<span v-else class="status-icon in-progress"></span>
<span v-if="error">Fout bij verwerking</span>
<span v-else-if="isCompleted">Verwerking voltooid</span>
<span v-else>Bezig met redeneren...</span>
</div>
<div class="progress-toggle">
{{ isExpanded ? '▲' : '▼' }}
</div>
</div>
<div v-if="error" class="progress-error">
{{ error }}
</div>
<div
ref="progressContainer"
class="progress-content"
:class="{ 'single-line': !isExpanded }"
>
<div
v-for="(line, index) in displayLines"
:key="index"
class="progress-line"
>
{{ line }}
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ProgressTracker',
props: {
taskId: {
type: String,
required: true
},
apiPrefix: {
type: String,
default: ''
}
},
emits: ['specialist-complete', 'specialist-error'],
data() {
return {
isExpanded: false,
isCompleted: false,
hasError: false,
connecting: true,
error: null,
progressLines: [],
eventSource: null
};
},
computed: {
displayLines() {
if (this.isExpanded) {
return this.progressLines;
} else {
// Show only the last line when collapsed
return this.progressLines.length > 0 ? [this.progressLines[this.progressLines.length - 1]] : [];
}
}
},
mounted() {
this.connectToProgressStream();
},
beforeUnmount() {
this.disconnectEventSource();
},
methods: {
connectToProgressStream() {
if (!this.taskId) {
console.error('Geen task ID beschikbaar voor progress tracking');
return;
}
console.log('Connecting to progress stream for task:', this.taskId);
// Construct the SSE URL
const baseUrl = window.location.origin;
const sseUrl = `${baseUrl}${this.apiPrefix}/api/progress/${this.taskId}`;
console.log('SSE URL:', sseUrl);
try {
this.eventSource = new EventSource(sseUrl);
this.eventSource.onopen = () => {
console.log('Progress stream connected');
this.connecting = false;
};
this.eventSource.onmessage = (event) => {
this.handleProgressUpdate(event);
};
this.eventSource.onerror = (event) => {
console.error('Progress stream error:', event);
this.handleError(event);
};
// Listen for specific event types
this.eventSource.addEventListener('progress', (event) => {
this.handleProgressUpdate(event);
});
this.eventSource.addEventListener('complete', (event) => {
this.handleSpecialistComplete(event);
});
this.eventSource.addEventListener('error', (event) => {
this.handleSpecialistError(event);
});
} catch (error) {
console.error('Failed to create EventSource:', error);
this.error = 'Kan geen verbinding maken met de voortgangsstream.';
this.connecting = false;
}
},
disconnectEventSource() {
if (this.eventSource) {
console.log('Disconnecting progress stream');
this.eventSource.close();
this.eventSource = null;
}
},
handleProgressUpdate(event) {
try {
const data = JSON.parse(event.data);
console.log('Progress update:', data);
if (data.message) {
this.progressLines.push(data.message);
// Auto-scroll to bottom if expanded
if (this.isExpanded) {
this.$nextTick(() => {
const container = this.$refs.progressContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
}
} catch (error) {
console.error('Error parsing progress data:', error);
}
},
handleSpecialistComplete(event) {
console.log('Specialist complete event:', event);
try {
const data = JSON.parse(event.data);
console.log('Specialist complete data:', data);
this.isCompleted = true;
this.connecting = false;
this.disconnectEventSource();
// Emit the complete event to parent
if (data.result && data.result.answer) {
this.$emit('specialist-complete', {
answer: data.result.answer,
form_request: data.result.form_request,
result: data.result,
interactionId: data.interaction_id,
taskId: this.taskId
});
} else {
console.error('Missing result.answer in specialist complete data:', data);
}
} catch (error) {
console.error('Error parsing specialist complete data:', error);
this.handleSpecialistError({ data: JSON.stringify({ Error: 'Failed to parse completion data' }) });
}
},
handleSpecialistError(event) {
console.log('Specialist error event:', event);
try {
const data = JSON.parse(event.data);
console.log('Specialist error data:', data);
this.isCompleted = true;
this.hasError = true;
this.connecting = false;
this.disconnectEventSource();
// Set user-friendly error message
const errorMessage = "We could not process your request. Please try again later.";
this.error = errorMessage;
// Log the actual error for debug purposes
if (data.Error) {
console.error('Specialist Error:', data.Error);
}
// Emit error event to parent
this.$emit('specialist-error', {
message: errorMessage,
originalError: data.Error,
taskId: this.taskId
});
} catch (error) {
console.error('Error parsing specialist error data:', error);
this.error = 'Er is een onbekende fout opgetreden.';
this.isCompleted = true;
this.hasError = true;
this.connecting = false;
this.disconnectEventSource();
}
},
handleError(event) {
console.error('SSE Error event:', event);
this.error = 'Er is een fout opgetreden bij het verwerken van updates.';
this.connecting = false;
// Try to parse error data
try {
const errorData = JSON.parse(event.data);
if (errorData && errorData.message) {
this.error = errorData.message;
}
} catch (err) {
// Keep generic error message if parsing fails
}
},
toggleExpand() {
this.isExpanded = !this.isExpanded;
if (this.isExpanded) {
this.$nextTick(() => {
const container = this.$refs.progressContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
}
}
};
</script>
<style scoped>
.progress-tracker {
border: 1px solid #e0e0e0;
border-radius: 8px;
margin: 10px 0;
background-color: #f9f9f9;
font-family: Arial, sans-serif;
font-size: 14px;
transition: all 0.3s ease;
}
.progress-tracker.expanded {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.progress-tracker.completed {
border-color: #4caf50;
background-color: #f1f8e9;
}
.progress-tracker.error {
border-color: #f44336;
background-color: #ffebee;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
cursor: pointer;
border-bottom: 1px solid #e0e0e0;
background-color: #fff;
border-radius: 8px 8px 0 0;
transition: background-color 0.2s ease;
}
.progress-header:hover {
background-color: #f5f5f5;
}
.progress-title {
display: flex;
align-items: center;
font-weight: 500;
color: #333;
}
.status-icon {
margin-right: 8px;
font-weight: bold;
font-size: 16px;
}
.status-icon.completed {
color: #4caf50;
}
.status-icon.error {
color: #f44336;
}
.status-icon.in-progress {
color: #2196f3;
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #f3f3f3;
border-top: 2px solid #2196f3;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.progress-toggle {
color: #666;
font-size: 12px;
transition: transform 0.2s ease;
}
.progress-tracker.expanded .progress-toggle {
transform: rotate(180deg);
}
.progress-error {
padding: 10px 15px;
background-color: #ffcdd2;
color: #c62828;
border-top: 1px solid #e0e0e0;
font-size: 13px;
}
.progress-content {
max-height: 200px;
overflow-y: auto;
padding: 10px 15px;
background-color: #fff;
border-radius: 0 0 8px 8px;
transition: max-height 0.3s ease;
}
.progress-content.single-line {
max-height: 40px;
overflow: hidden;
}
.progress-line {
padding: 2px 0;
color: #555;
font-size: 13px;
line-height: 1.4;
word-break: break-word;
}
.progress-line:last-child {
font-weight: 500;
color: #333;
}
/* Scrollbar styling */
.progress-content::-webkit-scrollbar {
width: 4px;
}
.progress-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 2px;
}
.progress-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 2px;
}
.progress-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.progress-header {
padding: 10px 12px;
}
.progress-content {
padding: 8px 12px;
max-height: 150px;
}
.progress-content.single-line {
max-height: 35px;
}
.progress-line {
font-size: 12px;
}
}
/* Animation for new progress lines */
.progress-line {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div v-if="showText" class="typing-text">{{ text }}</div>
</div>
</template>
<script>
export default {
name: 'TypingIndicator',
props: {
showText: {
type: Boolean,
default: false
},
text: {
type: String,
default: 'Bezig met typen...'
}
},
methods: {
// Andere methoden kunnen hier staan
}
};
</script>
<style scoped>
.typing-indicator {
display: flex;
align-items: center;
padding: 10px 15px;
margin: 10px 0;
background-color: #f0f0f0;
border-radius: 18px;
max-width: 80px;
width: fit-content;
}
.typing-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #999;
margin: 0 2px;
animation: typing-bounce 1.4s infinite ease-in-out;
}
.typing-dot:nth-child(1) {
animation-delay: -0.32s;
}
.typing-dot:nth-child(2) {
animation-delay: -0.16s;
}
.typing-dot:nth-child(3) {
animation-delay: 0s;
}
.typing-text {
margin-left: 10px;
font-size: 0.9rem;
color: #666;
font-style: italic;
}
@keyframes typing-bounce {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Alternative pulse animation */
@keyframes typing-pulse {
0%, 60%, 100% {
transform: initial;
opacity: 0.4;
}
30% {
transform: scale(1.2);
opacity: 1;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.typing-indicator {
padding: 8px 12px;
margin: 8px 0;
}
.typing-dot {
width: 6px;
height: 6px;
}
.typing-text {
font-size: 0.8rem;
margin-left: 8px;
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.typing-indicator {
background-color: #2a2a2a;
}
.typing-dot {
background-color: #ccc;
}
.typing-text {
color: #aaa;
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.typing-indicator {
border: 1px solid #333;
}
.typing-dot {
background-color: #000;
}
.typing-text {
color: #000;
}
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.typing-dot {
animation: none;
opacity: 0.7;
}
.typing-indicator {
opacity: 0.8;
}
}
</style>

View File

@@ -0,0 +1,15 @@
// Vue Components Barrel Export
// Export all Vue Single File Components
export { default as LanguageSelector } from './LanguageSelector.vue';
export { default as ChatInput } from './ChatInput.vue';
export { default as MessageHistory } from './MessageHistory.vue';
export { default as ChatMessage } from './ChatMessage.vue';
export { default as TypingIndicator } from './TypingIndicator.vue';
export { default as ProgressTracker } from './ProgressTracker.vue';
export { default as DynamicForm } from './DynamicForm.vue';
export { default as FormField } from './FormField.vue';
export { default as FormMessage } from './FormMessage.vue';
// Log successful loading
console.log('Vue components loaded successfully from barrel export');