- Build of the Chat Client using Vue.js

- Accompanying css
- Views to serve the Chat Client
- first test version of the TRACIE_SELECTION_SPECIALIST
- ESS Implemented.
This commit is contained in:
Josako
2025-06-12 18:21:51 +02:00
parent 67ceb57b79
commit 5f1a5711f6
22 changed files with 2723 additions and 533 deletions

View File

@@ -0,0 +1,523 @@
// Import all components
import { TypingIndicator } from '/static/assets/js/components/TypingIndicator.js';
import { FormField } from '/static/assets/js/components/FormField.js';
import { DynamicForm } from '/static/assets/js/components/DynamicForm.js';
import { ChatMessage } from '/static/assets/js/components/ChatMessage.js';
import { MessageHistory } from '/static/assets/js/components/MessageHistory.js';
import { ChatInput } from '/static/assets/js/components/ChatInput.js';
import { ProgressTracker } from '/static/assets/js/components/ProgressTracker.js';
// Main Chat Application
export const ChatApp = {
name: 'ChatApp',
components: {
TypingIndicator,
FormField,
DynamicForm,
ChatMessage,
MessageHistory,
ChatInput
},
data() {
// Maak een lokale kopie van de chatConfig om undefined errors te voorkomen
const chatConfig = window.chatConfig || {};
const settings = chatConfig.settings || {};
return {
// Base template data (keeping existing functionality)
explanation: chatConfig.explanation || '',
// Chat-specific data
currentMessage: '',
allMessages: [],
isTyping: false,
isLoading: false,
isSubmittingForm: false,
messageIdCounter: 1,
formValues: {},
// API prefix voor endpoints
apiPrefix: chatConfig.apiPrefix || '',
// Configuration from Flask/server
conversationId: chatConfig.conversationId || 'default',
userId: chatConfig.userId || null,
userName: chatConfig.userName || '',
// Settings met standaard waarden en overschreven door server config
settings: {
maxMessageLength: settings.maxMessageLength || 2000,
allowFileUpload: settings.allowFileUpload === true,
allowVoiceMessage: settings.allowVoiceMessage === true,
autoScroll: settings.autoScroll === true
},
// UI state
isMobile: window.innerWidth <= 768,
showSidebar: window.innerWidth > 768,
// Advanced features
messageSearch: '',
filteredMessages: [],
isSearching: false
};
},
computed: {
// Keep existing computed from base.html
compiledExplanation() {
if (typeof marked === 'function') {
return marked(this.explanation);
} else if (marked && typeof marked.parse === 'function') {
return marked.parse(this.explanation);
} else {
console.error('Marked library not properly loaded');
return this.explanation;
}
},
displayMessages() {
return this.isSearching ? this.filteredMessages : this.allMessages;
},
hasMessages() {
return this.allMessages.length > 0;
}
},
mounted() {
this.initializeChat();
this.setupEventListeners();
},
beforeUnmount() {
this.cleanup();
},
methods: {
// Initialization
initializeChat() {
console.log('Initializing chat application...');
// Load historical messages from server
this.loadHistoricalMessages();
// Add welcome message if no history
if (this.allMessages.length === 0) {
this.addWelcomeMessage();
}
// Focus input after initialization
this.$nextTick(() => {
this.focusChatInput();
});
},
loadHistoricalMessages() {
// Veilige toegang tot messages met fallback
const chatConfig = window.chatConfig || {};
const historicalMessages = chatConfig.messages || [];
if (historicalMessages.length > 0) {
this.allMessages = historicalMessages.map(msg => {
// Zorg voor een correct geformatteerde bericht-object
return {
id: this.messageIdCounter++,
content: typeof msg === 'string' ? msg : msg.content || '',
sender: msg.sender || 'ai',
type: msg.type || 'text',
timestamp: msg.timestamp || new Date().toISOString(),
formData: msg.formData || null,
status: msg.status || 'delivered'
};
});
console.log(`Loaded ${this.allMessages.length} historical messages`);
}
},
addWelcomeMessage() {
this.addMessage(
'Hallo! Ik ben je AI assistant. Vraag gerust om een formulier zoals "contactformulier" of "bestelformulier"!',
'ai',
'text'
);
},
setupEventListeners() {
// Window resize listener
window.addEventListener('resize', this.handleResize);
// Keyboard shortcuts
document.addEventListener('keydown', this.handleGlobalKeydown);
},
cleanup() {
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('keydown', this.handleGlobalKeydown);
},
// Message management
addMessage(content, sender, type = 'text', formData = null) {
const message = {
id: this.messageIdCounter++,
content,
sender,
type,
formData,
timestamp: new Date().toISOString(),
status: sender === 'user' ? 'sent' : 'delivered'
};
this.allMessages.push(message);
// Initialize form values if it's a form
if (type === 'form' && formData) {
this.$set(this.formValues, message.id, {});
formData.fields.forEach(field => {
this.$set(this.formValues[message.id], field.name, field.defaultValue || '');
});
}
// Update search results if searching
if (this.isSearching) {
this.performSearch();
}
return message;
},
updateCurrentMessage(value) {
this.currentMessage = value;
},
// Message sending
async sendMessage() {
const text = this.currentMessage.trim();
if (!text || this.isLoading) return;
console.log('Sending message:', text);
// Add user message
const userMessage = this.addMessage(text, 'user', 'text');
this.currentMessage = '';
// Show typing and loading state
this.isTyping = true;
this.isLoading = true;
try {
const response = await this.callAPI('/api/send_message', {
message: text,
conversation_id: this.conversationId,
user_id: this.userId
});
// Hide typing indicator
this.isTyping = false;
// Mark user message as delivered
userMessage.status = 'delivered';
// Add AI response
if (response.type === 'form') {
this.addMessage('', 'ai', 'form', response.formData);
} else {
// Voeg het bericht toe met task_id voor tracking - initieel leeg
const aiMessage = this.addMessage(
'',
'ai',
'text'
);
// Voeg task_id toe als beschikbaar
if (response.task_id) {
console.log('Monitoring Task ID: ', response.task_id);
aiMessage.taskId = response.task_id;
}
}
} catch (error) {
console.error('Error sending message:', error);
this.isTyping = false;
// Mark user message as failed
userMessage.status = 'failed';
this.addMessage(
'Sorry, er ging iets mis bij het verzenden van je bericht. Probeer het opnieuw.',
'ai',
'error'
);
} finally {
this.isLoading = false;
}
},
// Form handling
async submitForm(formData, messageId) {
this.isSubmittingForm = true;
console.log('Submitting form:', formData.title, this.formValues[messageId]);
try {
const response = await this.callAPI('/api/submit_form', {
formData: this.formValues[messageId],
formType: formData.title,
conversation_id: this.conversationId,
user_id: this.userId
});
if (response.success) {
this.addMessage(
`${response.message || 'Formulier succesvol verzonden!'}`,
'ai',
'text'
);
// Remove the form message
this.removeMessage(messageId);
} else {
this.addMessage(
`❌ Er ging iets mis: ${response.error || 'Onbekende fout'}`,
'ai',
'text'
);
}
} catch (error) {
console.error('Error submitting form:', error);
this.addMessage(
'Sorry, er ging iets mis bij het verzenden van het formulier. Probeer het opnieuw.',
'ai',
'text'
);
} finally {
this.isSubmittingForm = false;
}
},
// Message actions
retryMessage(messageId) {
const message = this.allMessages.find(m => m.id === messageId);
if (message && message.status === 'failed') {
// Retry sending the message
this.currentMessage = message.content;
this.removeMessage(messageId);
this.sendMessage();
}
},
removeMessage(messageId) {
const index = this.allMessages.findIndex(m => m.id === messageId);
if (index !== -1) {
this.allMessages.splice(index, 1);
// Verwijder ook eventuele formuliergegevens
if (this.formValues[messageId]) {
delete this.formValues[messageId];
}
}
},
// File handling
async handleFileUpload(file) {
console.log('Uploading file:', file.name);
// Add file message
const fileMessage = this.addMessage('', 'user', 'file', {
fileName: file.name,
fileSize: this.formatFileSize(file.size),
fileType: file.type
});
try {
// TODO: Implement actual file upload
// const response = await this.uploadFile(file);
// fileMessage.fileUrl = response.url;
// Simulate file upload
setTimeout(() => {
fileMessage.fileUrl = URL.createObjectURL(file);
fileMessage.status = 'delivered';
}, 1000);
} catch (error) {
console.error('Error uploading file:', error);
fileMessage.status = 'failed';
}
},
async handleVoiceRecord(audioBlob) {
console.log('Processing voice recording');
// Add voice message
const voiceMessage = this.addMessage('', 'user', 'voice', {
audioBlob,
duration: '00:05' // TODO: Calculate actual duration
});
// TODO: Send to speech-to-text service
// const transcription = await this.transcribeAudio(audioBlob);
// this.currentMessage = transcription;
// this.sendMessage();
},
// Search functionality
performSearch() {
if (!this.messageSearch.trim()) {
this.isSearching = false;
this.filteredMessages = [];
return;
}
this.isSearching = true;
const query = this.messageSearch.toLowerCase();
this.filteredMessages = this.allMessages.filter(message =>
message.content &&
message.content.toLowerCase().includes(query)
);
},
clearSearch() {
this.messageSearch = '';
this.isSearching = false;
this.filteredMessages = [];
},
// Event handlers
handleResize() {
this.isMobile = window.innerWidth <= 768;
this.showSidebar = window.innerWidth > 768;
},
handleGlobalKeydown(event) {
// Ctrl/Cmd + K for search
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
this.focusSearch();
}
// Escape to clear search
if (event.key === 'Escape' && this.isSearching) {
this.clearSearch();
}
},
// Utility methods
async callAPI(endpoint, data) {
// Gebruik de API prefix uit de lokale variabele
const fullEndpoint = this.apiPrefix + '/chat' + endpoint;
console.log('Calling API with prefix:', {
prefix: this.apiPrefix,
endpoint: endpoint,
fullEndpoint: fullEndpoint
});
const response = await fetch(fullEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
focusChatInput() {
this.$refs.chatInput?.focusInput();
},
focusSearch() {
this.$refs.searchInput?.focus();
},
handleSpecialistError(errorData) {
console.error('Specialist error:', errorData);
// Als we willen kunnen we hier nog extra logica toevoegen, zoals statistieken bijhouden of centraal loggen
},
},
template: `
<div class="chat-app-container">
<!-- Message History - takes available space -->
<message-history
:messages="displayMessages"
:is-typing="isTyping"
:form-values="formValues"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="true"
@submit-form="submitForm"
@specialist-error="handleSpecialistError"
ref="messageHistory"
class="chat-messages-area"
></message-history>
<!-- Chat Input - to the bottom -->
<chat-input
:current-message="currentMessage"
:is-loading="isLoading"
:max-length="2000"
:allow-file-upload="true"
:allow-voice-message="false"
@send-message="sendMessage"
@update-message="updateCurrentMessage"
@upload-file="handleFileUpload"
@record-voice="handleVoiceRecord"
ref="chatInput"
class="chat-input-area"
></chat-input>
</div>
`
};
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
console.log('Initializing Chat Application');
// Get access to the existing Vue app instance
if (window.__vueApp) {
// Register ALL components globally
window.__vueApp.component('TypingIndicator', TypingIndicator);
window.__vueApp.component('FormField', FormField);
window.__vueApp.component('DynamicForm', DynamicForm);
window.__vueApp.component('ChatMessage', ChatMessage);
window.__vueApp.component('MessageHistory', MessageHistory);
window.__vueApp.component('ChatInput', ChatInput);
window.__vueApp.component('ProgressTracker', ProgressTracker);
console.log('All chat components registered with existing Vue instance');
// Register the ChatApp component
window.__vueApp.component('ChatApp', ChatApp);
console.log('ChatApp component registered with existing Vue instance');
// Mount the Vue app
window.__vueApp.mount('#app');
console.log('Vue app mounted with chat components');
} else {
console.error('No existing Vue instance found on window.__vueApp');
}
});

View File

@@ -0,0 +1,132 @@
// static/js/components/ChatInput.js
export const ChatInput = {
name: 'ChatInput',
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
},
},
emits: ['send-message', 'update-message'],
data() {
return {
localMessage: this.currentMessage,
};
},
computed: {
characterCount() {
return this.localMessage.length;
},
isOverLimit() {
return this.characterCount > this.maxLength;
},
canSend() {
return this.localMessage.trim() &&
!this.isLoading &&
!this.isOverLimit;
}
},
watch: {
currentMessage(newVal) {
this.localMessage = newVal;
},
localMessage(newVal) {
this.$emit('update-message', newVal);
this.autoResize();
}
},
mounted() {
this.autoResize();
},
methods: {
handleKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendMessage();
} else if (event.key === 'Escape') {
this.localMessage = '';
}
},
sendMessage() {
if (this.canSend) {
this.$emit('send-message');
}
},
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();
}
},
template: `
<div class="chat-input-container">
<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">
<!-- Send button -->
<button
@click="sendMessage"
class="send-btn"
:disabled="!canSend"
:title="isOverLimit ? 'Bericht te lang' : 'Bericht verzenden'"
>
<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,240 @@
export const ChatMessage = {
name: 'ChatMessage',
props: {
message: {
type: Object,
required: true,
validator: (message) => {
return message.id && message.content !== undefined && message.sender && message.type;
}
},
formValues: {
type: Object,
default: () => ({})
},
isSubmittingForm: {
type: Boolean,
default: false
},
apiPrefix: {
type: String,
default: ''
}
},
emits: ['submit-form', 'image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'],
data() {
return {
isEditing: false,
editedContent: ''
};
},
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,
...eventData
});
},
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>');
},
startEdit() {
this.editedContent = this.message.content;
this.isEditing = true;
},
saveEdit() {
// Implementatie van bewerkingen zou hier komen
this.message.content = this.editedContent;
this.isEditing = false;
},
cancelEdit() {
this.isEditing = false;
this.editedContent = '';
},
submitForm() {
this.$emit('submit-form', this.message.formData, this.message.id);
},
removeMessage() {
// Dit zou een event moeten triggeren naar de parent component
},
reactToMessage(emoji) {
// Implementatie van reacties zou hier komen
},
getMessageClass() {
if (this.message.type === 'form') {
return 'form-message';
}
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">
<!-- 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>
<!-- Edit mode -->
<div v-if="isEditing" class="edit-mode">
<textarea
v-model="editedContent"
class="edit-textarea"
rows="3"
@keydown.enter.ctrl="saveEdit"
@keydown.escape="cancelEdit"
></textarea>
<div class="edit-actions">
<button @click="saveEdit" class="btn-small btn-primary">Opslaan</button>
<button @click="cancelEdit" class="btn-small btn-secondary">Annuleren</button>
</div>
</div>
<!-- View mode -->
<div v-else>
<div
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>
<!-- Dynamic forms -->
<template v-if="message.type === 'form'">
<dynamic-form
:form-data="message.formData"
:form-values="formValues[message.id] || {}"
:is-submitting="isSubmittingForm"
@submit="submitForm"
@cancel="removeMessage"
></dynamic-form>
</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,110 @@
export const DynamicForm = {
name: 'DynamicForm',
props: {
formData: {
type: Object,
required: true,
validator: (formData) => {
return formData.title && formData.fields && Array.isArray(formData.fields);
}
},
formValues: {
type: Object,
required: true
},
isSubmitting: {
type: Boolean,
default: false
}
},
emits: ['submit', 'cancel'],
methods: {
handleSubmit() {
// Basic validation
const requiredFields = this.formData.fields.filter(field => field.required);
const missingFields = requiredFields.filter(field => {
const value = this.formValues[field.name];
return !value || (typeof value === 'string' && !value.trim());
});
if (missingFields.length > 0) {
const fieldNames = missingFields.map(f => f.label).join(', ');
alert(`De volgende velden zijn verplicht: ${fieldNames}`);
return;
}
this.$emit('submit');
},
handleCancel() {
this.$emit('cancel');
},
updateFieldValue(fieldName, value) {
// Emit an update for reactive binding
this.$emit('update-field', fieldName, value);
}
},
template: `
<div class="dynamic-form">
<div class="form-title">{{ formData.title }}</div>
<div v-if="formData.description" class="form-description">
{{ formData.description }}
</div>
<form @submit.prevent="handleSubmit" novalidate>
<form-field
v-for="field in formData.fields"
:key="field.name"
:field="field"
:model-value="formValues[field.name]"
@update:model-value="formValues[field.name] = $event"
></form-field>
<div class="form-actions">
<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>
<!-- Optional reset button -->
<button
v-if="formData.showReset"
type="reset"
class="btn btn-outline"
@click="$emit('reset')"
:disabled="isSubmitting"
>
Reset
</button>
</div>
<!-- Progress indicator for multi-step forms -->
<div v-if="formData.steps && formData.currentStep" class="form-progress">
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: (formData.currentStep / formData.steps * 100) + '%' }"
></div>
</div>
<small>Stap {{ formData.currentStep }} van {{ formData.steps }}</small>
</div>
</form>
</div>
`
};

View File

@@ -0,0 +1,179 @@
export const FormField = {
name: 'FormField',
props: {
field: {
type: Object,
required: true,
validator: (field) => {
return field.name && field.type && field.label;
}
},
modelValue: {
default: ''
}
},
emits: ['update:modelValue'],
computed: {
value: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
}
},
methods: {
handleFileUpload(event) {
const file = event.target.files[0];
if (file) {
this.value = file;
}
}
},
template: `
<div class="form-field">
<label>
{{ field.label }}
<span v-if="field.required" class="required">*</span>
</label>
<!-- Text/Email/Tel inputs -->
<input
v-if="['text', 'email', 'tel', 'url', 'password'].includes(field.type)"
:type="field.type"
v-model="value"
:required="field.required"
:placeholder="field.placeholder || ''"
:maxlength="field.maxLength"
:minlength="field.minLength"
>
<!-- Number input -->
<input
v-if="field.type === 'number'"
type="number"
v-model.number="value"
:required="field.required"
:min="field.min"
:max="field.max"
:step="field.step || 1"
:placeholder="field.placeholder || ''"
>
<!-- Date input -->
<input
v-if="field.type === 'date'"
type="date"
v-model="value"
:required="field.required"
:min="field.min"
:max="field.max"
>
<!-- Time input -->
<input
v-if="field.type === 'time'"
type="time"
v-model="value"
:required="field.required"
>
<!-- File input -->
<input
v-if="field.type === 'file'"
type="file"
@change="handleFileUpload"
:required="field.required"
:accept="field.accept"
:multiple="field.multiple"
>
<!-- Select dropdown -->
<select
v-if="field.type === 'select'"
v-model="value"
:required="field.required"
>
<option value="">{{ field.placeholder || 'Kies een optie' }}</option>
<option
v-for="option in field.options"
:key="option.value || option"
:value="option.value || option"
>
{{ option.label || option }}
</option>
</select>
<!-- Radio buttons -->
<div v-if="field.type === 'radio'" class="radio-group">
<label
v-for="option in field.options"
:key="option.value || option"
class="radio-label"
>
<input
type="radio"
:value="option.value || option"
v-model="value"
:required="field.required"
>
<span>{{ option.label || option }}</span>
</label>
</div>
<!-- Checkboxes -->
<div v-if="field.type === 'checkbox'" class="checkbox-group">
<label
v-for="option in field.options"
:key="option.value || option"
class="checkbox-label"
>
<input
type="checkbox"
:value="option.value || option"
v-model="value"
>
<span>{{ option.label || option }}</span>
</label>
</div>
<!-- Single checkbox -->
<label v-if="field.type === 'single-checkbox'" class="checkbox-label">
<input
type="checkbox"
v-model="value"
:required="field.required"
>
<span>{{ field.checkboxText || field.label }}</span>
</label>
<!-- Textarea -->
<textarea
v-if="field.type === 'textarea'"
v-model="value"
:required="field.required"
:rows="field.rows || 3"
:placeholder="field.placeholder || ''"
:maxlength="field.maxLength"
></textarea>
<!-- Range slider -->
<div v-if="field.type === 'range'" class="range-field">
<input
type="range"
v-model.number="value"
:min="field.min || 0"
:max="field.max || 100"
:step="field.step || 1"
>
<span class="range-value">{{ value }}</span>
</div>
<!-- Help text -->
<small v-if="field.helpText" class="help-text">
{{ field.helpText }}
</small>
</div>
`
};

View File

@@ -0,0 +1,147 @@
export const MessageHistory = {
name: 'MessageHistory',
props: {
messages: {
type: Array,
required: true,
default: () => []
},
isTyping: {
type: Boolean,
default: false
},
formValues: {
type: Object,
default: () => ({})
},
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');
}
},
handleSubmitForm(formData, messageId) {
this.$emit('submit-form', formData, messageId);
},
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"
:form-values="formValues"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
@submit-form="handleSubmitForm"
@image-loaded="handleImageLoaded"
></chat-message>
</template>
</template>
<!-- Typing indicator -->
<typing-indicator v-if="isTyping"></typing-indicator>
</div>
</div>
`,
};

View File

@@ -0,0 +1,309 @@
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 && 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
this.$emit('specialist-complete', {
answer: data.result.answer,
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>
`
};