Chat client changes

- Form values shown correct in MessageHistory of Chat client
- Improements to CSS
- Move css en js to assets directory
- Introduce better Personal Contact Form & Professional Contact Form
- Start working on actual Selection Specialist
This commit is contained in:
Josako
2025-06-15 05:25:00 +02:00
parent 3c7460f741
commit 82e25b356c
27 changed files with 1077 additions and 161 deletions

View File

@@ -0,0 +1,337 @@
// static/js/components/ChatInput.js
// Importeer de IconManager (als module systeem wordt gebruikt)
// Anders moet je ervoor zorgen dat MaterialIconManager.js eerder wordt geladen
// en iconManager beschikbaar is via window.iconManager
// Voeg stylesheet toe voor ChatInput-specifieke stijlen
const addStylesheet = () => {
if (!document.querySelector('link[href*="chat-input.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/static/assets/css/chat-input.css';
document.head.appendChild(link);
}
};
// Laad de stylesheet
addStylesheet();
export const ChatInput = {
name: 'ChatInput',
components: {
'dynamic-form': window.DynamicForm
},
created() {
// Als module systeem wordt gebruikt:
// import { iconManager } from './MaterialIconManager.js';
// Anders gebruiken we window.iconManager als het beschikbaar is:
if (window.iconManager && this.formData && this.formData.icon) {
window.iconManager.ensureIconsLoaded({}, [this.formData.icon]);
}
},
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'],
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();
}
},
data() {
return {
localMessage: this.currentMessage,
formValues: {}
};
},
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';
}
},
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));
}
},
methods: {
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));
}
}
},
template: `
<div class="chat-input-container">
<!-- Dynamisch toevoegen van Material Symbols Outlined voor iconen -->
<div v-if="formData && formData.icon" class="material-icons-container">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
</div>
<!-- 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="placeholder"
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>
`
};

View File

@@ -0,0 +1,318 @@
// Voeg stylesheets toe voor formulier en chat berichten weergave
const addStylesheets = () => {
// Formulier stylesheet
if (!document.querySelector('link[href*="form-message.css"]')) {
const formLink = document.createElement('link');
formLink.rel = 'stylesheet';
formLink.href = '/static/assets/css/form-message.css';
document.head.appendChild(formLink);
}
// Chat bericht stylesheet
if (!document.querySelector('link[href*="chat-message.css"]')) {
const chatLink = document.createElement('link');
chatLink.rel = 'stylesheet';
chatLink.href = '/static/assets/css/chat-message.css';
document.head.appendChild(chatLink);
}
// Material Icons font stylesheet
if (!document.querySelector('link[href*="Material+Symbols+Outlined"]')) {
const iconLink = document.createElement('link');
iconLink.rel = 'stylesheet';
iconLink.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0';
document.head.appendChild(iconLink);
}
};
// Laad de stylesheets
addStylesheets();
export const ChatMessage = {
name: 'ChatMessage',
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: ''
}
},
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);
}
},
watch: {
'message.formData.icon': {
handler(newIcon) {
if (newIcon && window.iconManager) {
window.iconManager.loadIcon(newIcon);
}
},
immediate: true
}
},
emits: ['image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'],
data() {
return {
formVisible: true
};
},
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;
}
},
methods: {
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}`;
}
},
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>
</td>
</tr>
</tbody>
</table>
</div>
<!-- View mode -->
<div>
<div
v-if="message.content"
v-html="formatMessage(message.content)"
class="message-text"
></div>
<!-- Debug info -->
<div v-if="false" class="debug-info">
Content: {{ message.content ? message.content.length + ' chars' : 'empty' }}
</div>
</div>
</div>
</template>
<!-- Image messages -->
<template v-if="message.type === 'image'">
<div class="message-content">
<img
:src="message.imageUrl"
:alt="message.alt || 'Afbeelding'"
class="message-image"
@load="$emit('image-loaded')"
>
<div v-if="message.caption" class="image-caption">
{{ message.caption }}
</div>
</div>
</template>
<!-- File messages -->
<template v-if="message.type === 'file'">
<div class="message-content">
<div class="file-attachment">
<div class="file-icon">📎</div>
<div class="file-info">
<div class="file-name">{{ message.fileName }}</div>
<div class="file-size">{{ message.fileSize }}</div>
</div>
<a
:href="message.fileUrl"
download
class="file-download"
title="Download"
>
⬇️
</a>
</div>
</div>
</template>
<!-- System messages -->
<template v-if="message.type === 'system'">
<div class="system-message">
<span class="system-icon"></span>
{{ message.content }}
</div>
</template>
<!-- Error messages -->
<template v-if="message.type === 'error'">
<div class="error-message">
<span class="error-icon">⚠️</span>
{{ message.content }}
<button
v-if="message.retryable"
@click="$emit('retry-message', message.id)"
class="retry-btn"
>
Probeer opnieuw
</button>
</div>
</template>
<!-- Message reactions -->
<div v-if="message.reactions && message.reactions.length" class="message-reactions">
<span
v-for="reaction in message.reactions"
:key="reaction.emoji"
class="reaction"
@click="reactToMessage(reaction.emoji)"
>
{{ reaction.emoji }} {{ reaction.count }}
</span>
</div>
</div>
`
};

View File

@@ -0,0 +1,244 @@
export const DynamicForm = {
name: 'DynamicForm',
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);
}
},
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 }
};
},
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
}
},
methods: {
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) {
const fieldNames = missingFields.join(', ');
alert(`De volgende velden zijn verplicht: ${fieldNames}`);
return;
}
this.$emit('submit', this.localFormValues);
},
handleCancel() {
this.$emit('cancel');
},
updateFieldValue(fieldId, value) {
this.localFormValues[fieldId] = value;
}
},
template: `
<div class="dynamic-form" :class="{ 'read-only': readOnly }">
<div class="form-header" v-if="formData.title || formData.name || formData.icon" style="margin-bottom: 20px; display: flex; align-items: center;">
<div class="form-icon" v-if="formData.icon" style="margin-right: 10px; display: flex; align-items: center;">
<span class="material-symbols-outlined" style="font-size: 24px; color: #4285f4;">{{ formData.icon }}</span>
</div>
<div>
<div class="form-title" style="font-weight: bold; font-size: 1.2em; color: #333;">{{ formData.title || formData.name }}</div>
<div v-if="formData.description" class="form-description" style="margin-top: 5px; color: #666; font-size: 0.9em;">{{ formData.description }}</div>
</div>
</div>
<div v-if="readOnly" class="form-readonly" style="display: grid; grid-template-columns: 35% 65%; gap: 8px; width: 100%;">
<!-- Array-based fields -->
<template v-if="Array.isArray(formData.fields)">
<template v-for="field in formData.fields" :key="field.id || field.name">
<div class="field-label" style="font-weight: 500; color: #555; padding: 4px 0;">{{ field.name }}:</div>
<div class="field-value" style="padding: 4px 0;">
<template v-if="field.type === 'enum' && (field.allowedValues || field.allowed_values)">
{{ localFormValues[field.id || field.name] || field.default || '-' }}
</template>
<template v-else-if="field.type === 'boolean'">
{{ localFormValues[field.id || field.name] ? 'Ja' : 'Nee' }}
</template>
<template v-else-if="field.type === 'text'">
<div class="text-value" style="white-space: pre-wrap;">{{ localFormValues[field.id || field.name] || '-' }}</div>
</template>
<template v-else>
{{ localFormValues[field.id || field.name] || field.default || '-' }}
</template>
</div>
</template>
</template>
<!-- Object-based fields -->
<template v-else>
<template v-for="(field, fieldId) in formData.fields" :key="fieldId">
<div class="field-label" style="font-weight: 500; color: #555; padding: 4px 0;">{{ field.name }}:</div>
<div class="field-value" style="padding: 4px 0;">
<template v-if="field.type === 'enum' && (field.allowedValues || field.allowed_values)">
{{ localFormValues[fieldId] || field.default || '-' }}
</template>
<template v-else-if="field.type === 'boolean'">
{{ localFormValues[fieldId] ? 'Ja' : 'Nee' }}
</template>
<template v-else-if="field.type === 'text'">
<div class="text-value" style="white-space: pre-wrap;">{{ localFormValues[fieldId] || '-' }}</div>
</template>
<template v-else>
{{ localFormValues[fieldId] || field.default || '-' }}
</template>
</div>
</template>
</template>
</div>
<form v-else @submit.prevent="handleSubmit" novalidate>
<div class="form-fields" style="margin-top: 10px;">
<template v-if="Array.isArray(formData.fields)">
<form-field
v-for="field in formData.fields"
:key="field.id || field.name"
:field-id="field.id || field.name"
:field="field"
:model-value="localFormValues[field.id || field.name]"
@update:model-value="localFormValues[field.id || field.name] = $event"
></form-field>
</template>
<template v-else>
<form-field
v-for="(field, fieldId) in formData.fields"
:key="fieldId"
:field-id="fieldId"
:field="field"
:model-value="localFormValues[fieldId]"
@update:model-value="localFormValues[fieldId] = $event"
></form-field>
</template>
</div>
<div class="form-actions" v-if="!hideActions">
<button
type="submit"
class="btn btn-primary"
:disabled="isSubmitting"
:class="{ 'loading': isSubmitting }"
>
<span v-if="isSubmitting" class="spinner"></span>
{{ isSubmitting ? 'Verzenden...' : (formData.submitText || 'Versturen') }}
</button>
<button
type="button"
class="btn btn-secondary"
@click="handleCancel"
:disabled="isSubmitting"
>
{{ formData.cancelText || 'Annuleren' }}
</button>
</div>
</form>
</div>
`
};

View File

@@ -0,0 +1,174 @@
export const FormField = {
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',
'boolean': 'checkbox'
};
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;
}
}
},
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%;">
<!-- 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; resize: vertical; box-sizing: border-box;"
></textarea>
<!-- Dropdown (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; background-color: white; box-sizing: border-box;"
>
<option v-if="!field.required" value="">Selecteer een optie</option>
<option
v-for="option in (field.allowedValues || field.allowed_values || [])"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Debug info voor select field -->
<div v-if="fieldType === 'select' && (!(field.allowedValues || field.allowed_values) || (field.allowedValues || field.allowed_values).length === 0)"
style="color: #d93025; font-size: 0.85em; margin-top: 4px;">
Geen opties beschikbaar voor dit veld.
</div>
</div>
<!-- Toggle switch voor boolean velden, met speciale layout voor deze velden -->
<div v-if="fieldType === 'checkbox'" style="grid-column: 1 / span 2; display: flex; align-items: center;">
<div class="toggle-switch" style="position: relative; display: inline-block; width: 50px; height: 24px;">
<input
:id="fieldId"
type="checkbox"
v-model="value"
:required="field.required"
:title="description"
style="opacity: 0; width: 0; height: 0;"
>
<span
class="toggle-slider"
style="position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 24px;"
:style="{ backgroundColor: value ? '#4CAF50' : '#ccc' }"
@click="value = !value"
>
<span
class="toggle-knob"
style="position: absolute; content: ''; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%;"
:style="{ transform: value ? 'translateX(26px)' : 'translateX(0)' }"
></span>
</span>
</div>
<label :for="fieldId" class="checkbox-label" style="margin-left: 10px; cursor: pointer;">
{{ field.name }}
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
<span class="checkbox-description" style="display: block; font-size: 0.85em; color: #666;">
{{ field.description || '' }}
</span>
</label>
</div>
</div>
`
};

View File

@@ -0,0 +1,59 @@
// static/js/components/FormMessage.js
export const FormMessage = {
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
};
});
}
},
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>
`
};

View File

@@ -0,0 +1,65 @@
// static/js/components/MaterialIconManager.js
/**
* Een hulpklasse om Material Symbols Outlined iconen te beheren
* en dynamisch toe te voegen aan de pagina indien nodig.
*/
export const MaterialIconManager = {
name: 'MaterialIconManager',
data() {
return {
loadedIconSets: [],
defaultOptions: {
opsz: 24, // Optimale grootte: 24px
wght: 400, // Gewicht: normaal
FILL: 0, // Vulling: niet gevuld
GRAD: 0 // Kleurverloop: geen
}
};
},
methods: {
/**
* Zorgt ervoor dat de Material Symbols Outlined stijlbladen zijn geladen
* @param {Object} options - Opties voor het icoon (opsz, wght, FILL, GRAD)
* @param {Array} iconNames - Optionele lijst met specifieke iconen om te laden
*/
ensureIconsLoaded(options = {}, iconNames = []) {
const opts = { ...this.defaultOptions, ...options };
const styleUrl = this.buildStyleUrl(opts, iconNames);
// Controleer of deze specifieke set al is geladen
if (!this.loadedIconSets.includes(styleUrl)) {
this.loadStylesheet(styleUrl);
this.loadedIconSets.push(styleUrl);
}
},
/**
* Bouwt de URL voor het stijlblad
*/
buildStyleUrl(options, iconNames = []) {
let url = `https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@${options.opsz},${options.wght},${options.FILL},${options.GRAD}`;
// Voeg specifieke iconNames toe als deze zijn opgegeven
if (iconNames.length > 0) {
url += `&icon_names=${iconNames.join(',')}`;
}
return url;
},
/**
* Laadt een stijlblad dynamisch
*/
loadStylesheet(url) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
document.head.appendChild(link);
console.log(`Material Symbols Outlined geladen: ${url}`);
}
}
};
// Singleton instantie om te gebruiken in de hele applicatie
export const iconManager = new Vue(MaterialIconManager);

View File

@@ -0,0 +1,139 @@
export const MessageHistory = {
name: 'MessageHistory',
props: {
messages: {
type: Array,
required: true,
default: () => []
},
isTyping: {
type: Boolean,
default: false
},
isSubmittingForm: {
type: Boolean,
default: false
},
apiPrefix: {
type: String,
default: ''
},
autoScroll: {
type: Boolean,
default: true
}
},
emits: ['submit-form', 'load-more', 'specialist-complete', 'specialist-error'],
data() {
return {
isAtBottom: true,
unreadCount: 0
};
},
mounted() {
this.scrollToBottom();
this.setupScrollListener();
},
updated() {
if (this.autoScroll && this.isAtBottom) {
this.$nextTick(() => this.scrollToBottom());
}
},
methods: {
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)
);
},
},
beforeUnmount() {
// Cleanup scroll listener
const container = this.$refs.messagesContainer;
if (container) {
container.removeEventListener('scroll', this.handleScroll);
}
},
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>
`,
};

View File

@@ -0,0 +1,311 @@
export const ProgressTracker = {
name: 'ProgressTracker',
props: {
taskId: {
type: String,
required: true
},
apiPrefix: {
type: String,
default: ''
}
},
emits: ['specialist-complete', 'progress-update', 'specialist-error'],
data() {
return {
isExpanded: false,
progressLines: [],
eventSource: null,
isCompleted: false,
lastLine: '',
error: null,
connecting: true,
finalAnswer: null,
hasError: false
};
},
computed: {
progressEndpoint() {
return `${this.apiPrefix}/chat/api/task_progress/${this.taskId}`;
},
displayLines() {
return this.isExpanded ? this.progressLines : [
this.lastLine || 'Verbinden met taak...'
];
}
},
mounted() {
this.connectToEventSource();
},
beforeUnmount() {
this.disconnectEventSource();
},
methods: {
connectToEventSource() {
try {
this.connecting = true;
this.error = null;
// Sluit eventuele bestaande verbinding
this.disconnectEventSource();
// Maak nieuwe SSE verbinding
this.eventSource = new EventSource(this.progressEndpoint);
// Algemene event handler
this.eventSource.onmessage = (event) => {
this.handleProgressUpdate(event);
};
// Specifieke event handlers per type
this.eventSource.addEventListener('progress', (event) => {
this.handleProgressUpdate(event, 'progress');
});
this.eventSource.addEventListener('EveAI Specialist Complete', (event) => {
console.log('Received EveAI Specialist Complete event');
this.handleProgressUpdate(event, 'EveAI Specialist Complete');
});
this.eventSource.addEventListener('error', (event) => {
this.handleError(event);
});
// Status handlers
this.eventSource.onopen = () => {
this.connecting = false;
};
this.eventSource.onerror = (error) => {
console.error('SSE Connection error:', error);
this.error = 'Verbindingsfout. Probeer het later opnieuw.';
this.connecting = false;
// Probeer opnieuw te verbinden na 3 seconden
setTimeout(() => {
if (!this.isCompleted && this.progressLines.length === 0) {
this.connectToEventSource();
}
}, 3000);
};
} catch (err) {
console.error('Error setting up event source:', err);
this.error = 'Kan geen verbinding maken met de voortgangsupdates.';
this.connecting = false;
}
},
disconnectEventSource() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
},
handleProgressUpdate(event, eventType = null) {
try {
const update = JSON.parse(event.data);
// Controleer op verschillende typen updates
const processingType = update.processing_type;
const data = update.data || {};
// Process based on processing type
let message = this.formatProgressMessage(processingType, data);
// Alleen bericht toevoegen als er daadwerkelijk een bericht is
if (message) {
this.progressLines.push(message);
this.lastLine = message;
}
// Emit progress update voor parent component
this.$emit('progress-update', {
processingType,
data,
message
});
// Handle completion and errors
if (processingType === 'EveAI Specialist Complete') {
console.log('Processing EveAI Specialist Complete:', data);
this.handleSpecialistComplete(data);
} else if (processingType === 'EveAI Specialist Error') {
this.handleSpecialistError(data);
} else if (processingType === 'Task Complete' || processingType === 'Task Error') {
this.isCompleted = true;
this.disconnectEventSource();
}
// Scroll automatisch naar beneden als uitgevouwen
if (this.isExpanded) {
this.$nextTick(() => {
const container = this.$refs.progressContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
} catch (err) {
console.error('Error parsing progress update:', err, event.data);
}
},
formatProgressMessage(processingType, data) {
// Lege data dictionary - toon enkel processing type
if (!data || Object.keys(data).length === 0) {
return processingType;
}
// Specifiek bericht als er een message field is
if (data.message) {
return data.message;
}
// Processing type met name veld als dat bestaat
if (data.name) {
return `${processingType}: ${data.name}`;
}
// Stap informatie
if (data.step) {
return `Stap ${data.step}: ${data.description || ''}`;
}
// Voor EveAI Specialist Complete - geen progress message
if (processingType === 'EveAI Specialist Complete') {
return null;
}
// Default: processing type + eventueel data als string
return processingType;
},
handleSpecialistComplete(data) {
this.isCompleted = true;
this.disconnectEventSource();
// Debug logging
console.log('Specialist Complete Data:', data);
// Extract answer from data.result.answer
if (data.result) {
if (data.result.answer) {
this.finalAnswer = data.result.answer;
console.log('Final Answer:', this.finalAnswer);
// Direct update van de parent message als noodoplossing
try {
if (this.$parent && this.$parent.message) {
console.log('Direct update parent message');
this.$parent.message.content = data.result.answer;
}
} catch(err) {
console.error('Error updating parent message:', err);
}
}
// Emit event to parent met alle relevante data inclusief form_request
this.$emit('specialist-complete', {
answer: data.result.answer || '',
form_request: data.result.form_request, // Voeg form_request toe
result: data.result,
interactionId: data.interaction_id,
taskId: this.taskId
});
} else {
console.error('Missing result.answer in specialist complete data:', data);
}
},
handleSpecialistError(data) {
this.isCompleted = true;
this.hasError = true;
this.disconnectEventSource();
// Zet gebruiksvriendelijke foutmelding
const errorMessage = "We could not process your request. Please try again later.";
this.error = errorMessage;
// Log de werkelijke fout voor debug doeleinden
if (data.Error) {
console.error('Specialist Error:', data.Error);
}
// Emit error event naar parent
this.$emit('specialist-error', {
message: errorMessage,
originalError: data.Error,
taskId: this.taskId
});
},
handleError(event) {
console.error('SSE Error event:', event);
this.error = 'Er is een fout opgetreden bij het verwerken van updates.';
// Probeer parse van foutgegevens
try {
const errorData = JSON.parse(event.data);
if (errorData && errorData.message) {
this.error = errorData.message;
}
} catch (err) {
// Blijf bij algemene foutmelding als parsing mislukt
}
},
toggleExpand() {
this.isExpanded = !this.isExpanded;
if (this.isExpanded) {
this.$nextTick(() => {
const container = this.$refs.progressContainer;
if (container) {
container.scrollTop = container.scrollHeight;
}
});
}
}
},
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>
`
};

View File

@@ -0,0 +1,10 @@
export const TypingIndicator = {
name: 'TypingIndicator',
template: `
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
`
};