- 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:
132
eveai_chat_client/static/js/components/ChatInput.js
Normal file
132
eveai_chat_client/static/js/components/ChatInput.js
Normal 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>
|
||||
`
|
||||
};
|
||||
240
eveai_chat_client/static/js/components/ChatMessage.js
Normal file
240
eveai_chat_client/static/js/components/ChatMessage.js
Normal 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>
|
||||
`
|
||||
};
|
||||
110
eveai_chat_client/static/js/components/DynamicForm.js
Normal file
110
eveai_chat_client/static/js/components/DynamicForm.js
Normal 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>
|
||||
`
|
||||
};
|
||||
179
eveai_chat_client/static/js/components/FormField.js
Normal file
179
eveai_chat_client/static/js/components/FormField.js
Normal 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>
|
||||
`
|
||||
};
|
||||
147
eveai_chat_client/static/js/components/MessageHistory.js
Normal file
147
eveai_chat_client/static/js/components/MessageHistory.js
Normal 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>
|
||||
`,
|
||||
};
|
||||
309
eveai_chat_client/static/js/components/ProgressTracker.js
Normal file
309
eveai_chat_client/static/js/components/ProgressTracker.js
Normal 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>
|
||||
`
|
||||
};
|
||||
10
eveai_chat_client/static/js/components/TypingIndicator.js
Normal file
10
eveai_chat_client/static/js/components/TypingIndicator.js
Normal 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>
|
||||
`
|
||||
};
|
||||
Reference in New Issue
Block a user