tussentijdse status voor significante wijzigingen. Bezig aan creatie Dynamic Form in de chat client.

This commit is contained in:
Josako
2025-06-13 14:19:05 +02:00
parent b326c0c6f2
commit f1c60f9574
17 changed files with 1012 additions and 255 deletions

View File

@@ -7,7 +7,7 @@ from flask import current_app
from common.utils.cache.base import CacheHandler, CacheKey from common.utils.cache.base import CacheHandler, CacheKey
from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \ from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \
catalog_types, partner_service_types, processor_types, customisation_types catalog_types, partner_service_types, processor_types, customisation_types, specialist_form_types
def is_major_minor(version: str) -> bool: def is_major_minor(version: str) -> bool:
@@ -478,6 +478,14 @@ CustomisationConfigCacheHandler, CustomisationConfigVersionTreeCacheHandler, Cus
) )
) )
SpecialistFormConfigCacheHandler, SpecialistFormConfigVersionTreeCacheHandler, SpecialistFormConfigTypesCacheHandler = (
create_config_cache_handlers(
config_type='specialist_forms',
config_dir='config/specialist_forms',
types_module=specialist_form_types.SPECIALIST_FORM_TYPES
)
)
def register_config_cache_handlers(cache_manager) -> None: def register_config_cache_handlers(cache_manager) -> None:
cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config') cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config')
@@ -513,6 +521,9 @@ def register_config_cache_handlers(cache_manager) -> None:
cache_manager.register_handler(CustomisationConfigCacheHandler, 'eveai_config') cache_manager.register_handler(CustomisationConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(CustomisationConfigTypesCacheHandler, 'eveai_config') cache_manager.register_handler(CustomisationConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(CustomisationConfigVersionTreeCacheHandler, 'eveai_config') cache_manager.register_handler(CustomisationConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.register_handler(SpecialistFormConfigCacheHandler, 'eveai_config')
cache_manager.register_handler(SpecialistFormConfigTypesCacheHandler, 'eveai_config')
cache_manager.register_handler(SpecialistFormConfigVersionTreeCacheHandler, 'eveai_config')
cache_manager.agents_config_cache.set_version_tree_cache(cache_manager.agents_version_tree_cache) cache_manager.agents_config_cache.set_version_tree_cache(cache_manager.agents_version_tree_cache)
cache_manager.tasks_config_cache.set_version_tree_cache(cache_manager.tasks_version_tree_cache) cache_manager.tasks_config_cache.set_version_tree_cache(cache_manager.tasks_version_tree_cache)
@@ -524,3 +535,4 @@ def register_config_cache_handlers(cache_manager) -> None:
cache_manager.processors_config_cache.set_version_tree_cache(cache_manager.processors_version_tree_cache) cache_manager.processors_config_cache.set_version_tree_cache(cache_manager.processors_version_tree_cache)
cache_manager.partner_services_config_cache.set_version_tree_cache(cache_manager.partner_services_version_tree_cache) cache_manager.partner_services_config_cache.set_version_tree_cache(cache_manager.partner_services_version_tree_cache)
cache_manager.customisations_config_cache.set_version_tree_cache(cache_manager.customisations_version_tree_cache) cache_manager.customisations_config_cache.set_version_tree_cache(cache_manager.customisations_version_tree_cache)
cache_manager.specialist_forms_config_cache.set_version_tree_cache(cache_manager.specialist_forms_version_tree_cache)

View File

@@ -67,7 +67,89 @@ class Config(object):
MAX_CONTENT_LENGTH = 50 * 1024 * 1024 MAX_CONTENT_LENGTH = 50 * 1024 * 1024
# supported languages # supported languages
SUPPORTED_LANGUAGES = ['en', 'fr', 'nl', 'de', 'es'] SUPPORTED_LANGUAGES = ['en', 'fr', 'nl', 'de', 'es', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi']
SUPPORTED_LANGUAGE_DETAILS = {
"English": {
"iso 639-1": "en",
"iso 639-2": "eng",
"iso 639-3": "eng",
"flag": "🇬🇧"
},
"French": {
"iso 639-1": "fr",
"iso 639-2": "fre", # of 'fra'
"iso 639-3": "fra",
"flag": "🇫🇷"
},
"German": {
"iso 639-1": "de",
"iso 639-2": "ger", # of 'deu'
"iso 639-3": "deu",
"flag": "🇩🇪"
},
"Spanish": {
"iso 639-1": "es",
"iso 639-2": "spa",
"iso 639-3": "spa",
"flag": "🇪🇸"
},
"Italian": {
"iso 639-1": "it",
"iso 639-2": "ita",
"iso 639-3": "ita",
"flag": "🇮🇹"
},
"Portuguese": {
"iso 639-1": "pt",
"iso 639-2": "por",
"iso 639-3": "por",
"flag": "🇵🇹"
},
"Dutch": {
"iso 639-1": "nl",
"iso 639-2": "dut", # of 'nld'
"iso 639-3": "nld",
"flag": "🇳🇱"
},
"Russian": {
"iso 639-1": "ru",
"iso 639-2": "rus",
"iso 639-3": "rus",
"flag": "🇷🇺"
},
"Chinese": {
"iso 639-1": "zh",
"iso 639-2": "chi", # of 'zho'
"iso 639-3": "zho",
"flag": "🇨🇳"
},
"Japanese": {
"iso 639-1": "ja",
"iso 639-2": "jpn",
"iso 639-3": "jpn",
"flag": "🇯🇵"
},
"Korean": {
"iso 639-1": "ko",
"iso 639-2": "kor",
"iso 639-3": "kor",
"flag": "🇰🇷"
},
"Arabic": {
"iso 639-1": "ar",
"iso 639-2": "ara",
"iso 639-3": "ara",
"flag": "🇸🇦"
},
"Hindi": {
"iso 639-1": "hi",
"iso 639-2": "hin",
"iso 639-3": "hin",
"flag": "🇮🇳"
},
}
SUPPORTED_LANGUAGES_Full = list(SUPPORTED_LANGUAGE_DETAILS.keys())
# supported currencies # supported currencies
SUPPORTED_CURRENCIES = ['', '$'] SUPPORTED_CURRENCIES = ['', '$']

View File

@@ -0,0 +1,40 @@
version: "1.0.0"
name: "Personal Contact Form"
icon: "call"
fields:
name:
name: "Name"
description: "Your name"
type: "str"
required: true
email:
name: "Email"
type: "str"
description: "Your Name"
required: true
phone:
name: "Phone Number"
type: "str"
description: "Your Phone Number"
required: true
Address:
name: "Address"
type: "text"
description: "Your Address"
required: false
status:
name: "Marital Status"
type: "enum"
description: "Your Marital Status"
required: false
default: "single"
allowed_values:
- "single"
- "married"
- "divorced"
can_contact:
name: "Allow Contact"
type: "boolean"
description: "Allow us to contact you?"
required: true
default: false

View File

@@ -0,0 +1,7 @@
# Specialist Form Types
SPECIALIST_FORM_TYPES = {
"PERSONAL_CONTACT_FORM": {
"name": "Contact Form",
"description": "A form for entering your personal contact details",
},
}

View File

@@ -408,22 +408,6 @@
} }
/* Edit mode styling */
.edit-mode .edit-textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
resize: vertical;
min-height: 60px;
font-family: inherit;
margin-bottom: 8px;
}
.edit-actions {
display: flex;
gap: 8px;
}
.btn-small { .btn-small {
padding: 4px 12px; padding: 4px 12px;

View File

@@ -0,0 +1,75 @@
/* Stijlen voor het FormMessage component in chatberichten */
.form-message {
background-color: #f5f8ff;
border: 1px solid #e1e8f5;
border-radius: 8px;
padding: 12px;
margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.form-message-header {
display: flex;
align-items: center;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid #e1e8f5;
}
.form-message-icon {
font-size: 16px;
margin-right: 8px;
color: #4a6fa5;
}
.form-message-title {
font-weight: 600;
color: #3a5a80;
font-size: 0.95rem;
}
.form-message-fields {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-message-field {
display: flex;
flex-wrap: wrap;
}
.field-message-label {
flex: 0 0 120px;
font-weight: 500;
color: #4a6fa5;
font-size: 0.85rem;
padding-right: 8px;
}
.field-message-value {
flex: 1;
min-width: 0;
font-size: 0.9rem;
color: #333;
word-break: break-word;
}
.field-message-value.text-value {
white-space: pre-wrap;
}
@media (max-width: 576px) {
.form-message-field {
flex-direction: column;
}
.field-message-label {
flex: 0 0 100%;
margin-bottom: 2px;
}
.field-message-value {
padding-left: 8px;
}
}

View File

@@ -0,0 +1,175 @@
/* Dynamisch formulier stijlen */
.dynamic-form-container {
margin-bottom: 15px;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
background-color: #f9f9f9;
}
.dynamic-form {
padding: 15px;
}
.form-header {
display: flex;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #e0e0e0;
}
.form-icon {
margin-right: 10px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #555;
}
.form-title {
font-size: 1.2rem;
font-weight: 600;
color: #333;
}
.form-fields {
display: grid;
grid-template-columns: 1fr;
gap: 15px;
margin-bottom: 20px;
}
@media (min-width: 768px) {
.form-fields {
grid-template-columns: repeat(2, 1fr);
}
}
.form-field {
margin-bottom: 5px;
}
.form-field label {
display: block;
margin-bottom: 6px;
font-weight: 500;
font-size: 0.9rem;
color: #555;
}
.form-field input,
.form-field select,
.form-field textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
background-color: #fff;
}
.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
.form-field textarea {
min-height: 80px;
resize: vertical;
}
.checkbox-container {
display: flex;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: auto;
margin-right: 8px;
}
.checkbox-text {
font-size: 0.9rem;
color: #555;
}
.field-description {
display: block;
margin-top: 5px;
font-size: 0.8rem;
color: #777;
line-height: 1.4;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 10px;
}
.form-toggle-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
color: #555;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.form-toggle-btn:hover {
background-color: #f0f0f0;
}
.form-toggle-btn.active {
color: #4a90e2;
background-color: rgba(74, 144, 226, 0.1);
}
.required {
color: #e53935;
margin-left: 2px;
}
/* Read-only form styling */
.form-readonly {
padding: 10px 0;
}
.form-field-readonly {
display: flex;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.field-label {
flex: 0 0 30%;
font-weight: 500;
color: #555;
padding-right: 10px;
}
.field-value {
flex: 1;
word-break: break-word;
}
.text-value {
white-space: pre-wrap;
}

View File

@@ -36,6 +36,7 @@ export const ChatApp = {
isSubmittingForm: false, isSubmittingForm: false,
messageIdCounter: 1, messageIdCounter: 1,
formValues: {}, formValues: {},
currentInputFormData: null,
// API prefix voor endpoints // API prefix voor endpoints
apiPrefix: chatConfig.apiPrefix || '', apiPrefix: chatConfig.apiPrefix || '',
@@ -298,6 +299,53 @@ export const ChatApp = {
} }
}, },
async submitFormFromInput(formValues) {
this.isSubmittingForm = true;
if (!this.currentInputFormData) {
console.error('No form data available');
return;
}
console.log('Submitting form from input:', this.currentInputFormData.title, formValues);
try {
const response = await this.callAPI('/api/submit_form', {
formData: formValues,
formType: this.currentInputFormData.title,
conversation_id: this.conversationId,
user_id: this.userId
});
if (response.success) {
this.addMessage(
`${response.message || 'Formulier succesvol verzonden!'}`,
'ai',
'text'
);
// Wis het huidige formulier
this.currentInputFormData = null;
} 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 // Message actions
retryMessage(messageId) { retryMessage(messageId) {
@@ -387,6 +435,56 @@ export const ChatApp = {
this.filteredMessages = []; this.filteredMessages = [];
}, },
// Event handlers voor specialist events
handleSpecialistComplete(eventData) {
console.log('ChatApp received specialist-complete:', eventData);
// Als er een form_request is, voeg deze toe als nieuw bericht
if (eventData.form_request) {
console.log('Adding form request as new message:', eventData.form_request);
// Converteer de form_request naar het verwachte formaat
const formData = this.convertFormRequest(eventData.form_request);
// Voeg het formulier toe als een nieuw AI bericht
this.addMessage('', 'ai', 'form', formData);
}
},
handleSpecialistError(eventData) {
console.log('ChatApp received specialist-error:', eventData);
// Voeg foutbericht toe
this.addMessage(
eventData.message || 'Er is een fout opgetreden bij het verwerken van uw verzoek.',
'ai',
'error'
);
},
// Helper methode om form_request te converteren naar het verwachte formaat
convertFormRequest(formRequest) {
console.log('Converting form request:', formRequest);
// Converteer de fields van object naar array formaat
const fieldsArray = Object.entries(formRequest.fields || {}).map(([fieldId, fieldDef]) => ({
id: fieldId,
name: fieldDef.name,
type: fieldDef.type,
description: fieldDef.description,
required: fieldDef.required || false,
defaultValue: fieldDef.default || '',
allowedValues: fieldDef.allowed_values || null
}));
return {
title: formRequest.name,
icon: formRequest.icon || 'form',
version: formRequest.version || '1.0',
fields: fieldsArray
};
},
// Event handlers // Event handlers
handleResize() { handleResize() {
this.isMobile = window.innerWidth <= 768; this.isMobile = window.innerWidth <= 768;
@@ -450,11 +548,54 @@ export const ChatApp = {
this.$refs.searchInput?.focus(); this.$refs.searchInput?.focus();
}, },
handleSpecialistError(errorData) { handleSpecialistComplete(eventData) {
console.error('Specialist error:', errorData); console.log('ChatApp received specialist-complete:', eventData);
// Als we willen kunnen we hier nog extra logica toevoegen, zoals statistieken bijhouden of centraal loggen
// Als er een form_request is, stuur deze naar de ChatInput component
if (eventData.form_request) {
console.log('Providing form request to ChatInput:', eventData.form_request);
// Converteer de form_request naar het verwachte formaat
const formData = this.convertFormRequest(eventData.form_request);
// Update de currentInputFormData voor ChatInput
this.currentInputFormData = formData;
}
}, },
handleSpecialistError(eventData) {
console.log('ChatApp received specialist-error:', eventData);
// Voeg foutbericht toe
this.addMessage(
eventData.message || 'Er is een fout opgetreden bij het verwerken van uw verzoek.',
'ai',
'error'
);
},
// Helper methode om form_request te converteren naar het verwachte formaat
convertFormRequest(formRequest) {
console.log('Converting form request:', formRequest);
// Converteer de fields van object naar array formaat
const fieldsArray = Object.entries(formRequest.fields || {}).map(([fieldId, fieldDef]) => ({
id: fieldId,
name: fieldDef.name,
type: fieldDef.type,
description: fieldDef.description,
required: fieldDef.required || false,
defaultValue: fieldDef.default || '',
allowedValues: fieldDef.allowed_values || null
}));
return {
title: formRequest.name,
icon: formRequest.icon || 'form',
version: formRequest.version || '1.0',
fields: fieldsArray
};
},
}, },
template: ` template: `
@@ -469,6 +610,7 @@ export const ChatApp = {
:auto-scroll="true" :auto-scroll="true"
@submit-form="submitForm" @submit-form="submitForm"
@specialist-error="handleSpecialistError" @specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="messageHistory" ref="messageHistory"
class="chat-messages-area" class="chat-messages-area"
></message-history> ></message-history>
@@ -480,10 +622,12 @@ export const ChatApp = {
:max-length="2000" :max-length="2000"
:allow-file-upload="true" :allow-file-upload="true"
:allow-voice-message="false" :allow-voice-message="false"
:form-data="currentInputFormData"
@send-message="sendMessage" @send-message="sendMessage"
@update-message="updateCurrentMessage" @update-message="updateCurrentMessage"
@upload-file="handleFileUpload" @upload-file="handleFileUpload"
@record-voice="handleVoiceRecord" @record-voice="handleVoiceRecord"
@submit-form="submitFormFromInput"
ref="chatInput" ref="chatInput"
class="chat-input-area" class="chat-input-area"
></chat-input> ></chat-input>
@@ -493,12 +637,21 @@ export const ChatApp = {
}; };
// Initialize app when DOM is ready // Zorg ervoor dat alle componenten correct geïnitialiseerd zijn voordat ze worden gebruikt
document.addEventListener('DOMContentLoaded', () => { const initializeApp = () => {
console.log('Initializing Chat Application'); console.log('Initializing Chat Application');
// Get access to the existing Vue app instance // Get access to the existing Vue app instance
if (window.__vueApp) { if (window.__vueApp) {
// Zorg ervoor dat alle componenten globaal beschikbaar zijn via window
window.TypingIndicator = TypingIndicator;
window.FormField = FormField;
window.DynamicForm = DynamicForm;
window.ChatMessage = ChatMessage;
window.MessageHistory = MessageHistory;
window.ChatInput = ChatInput;
window.ProgressTracker = ProgressTracker;
// Register ALL components globally // Register ALL components globally
window.__vueApp.component('TypingIndicator', TypingIndicator); window.__vueApp.component('TypingIndicator', TypingIndicator);
window.__vueApp.component('FormField', FormField); window.__vueApp.component('FormField', FormField);
@@ -520,4 +673,7 @@ document.addEventListener('DOMContentLoaded', () => {
} else { } else {
console.error('No existing Vue instance found on window.__vueApp'); console.error('No existing Vue instance found on window.__vueApp');
} }
}); };
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', initializeApp);

View File

@@ -2,6 +2,9 @@
export const ChatInput = { export const ChatInput = {
name: 'ChatInput', name: 'ChatInput',
components: {
'dynamic-form': window.__vueApp ? DynamicForm : null
},
props: { props: {
currentMessage: { currentMessage: {
type: String, type: String,
@@ -19,11 +22,38 @@ export const ChatInput = {
type: Number, type: Number,
default: 2000 default: 2000
}, },
formData: {
type: Object,
default: null
},
},
emits: ['send-message', 'update-message', 'submit-form'],
watch: {
formData: {
handler(newFormData) {
console.log('ChatInput received formData:', newFormData);
if (newFormData) {
this.formValues = {}; // Reset formulierwaarden
this.showForm = true;
} else {
this.showForm = false;
}
},
immediate: true
},
currentMessage(newVal) {
this.localMessage = newVal;
},
localMessage(newVal) {
this.$emit('update-message', newVal);
this.autoResize();
}
}, },
emits: ['send-message', 'update-message'],
data() { data() {
return { return {
localMessage: this.currentMessage, localMessage: this.currentMessage,
formValues: {},
showForm: false
}; };
}, },
computed: { computed: {
@@ -35,19 +65,21 @@ export const ChatInput = {
return this.characterCount > this.maxLength; 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() { canSend() {
return this.localMessage.trim() && const hasValidForm = this.showForm && this.formData && this.validateForm();
!this.isLoading && const hasValidMessage = this.localMessage.trim() && !this.isOverLimit;
!this.isOverLimit;
} return (!this.isLoading) && (hasValidForm || hasValidMessage);
}, },
watch: {
currentMessage(newVal) { sendButtonText() {
this.localMessage = newVal; return this.showForm ? 'Verstuur formulier' : 'Verstuur bericht';
},
localMessage(newVal) {
this.$emit('update-message', newVal);
this.autoResize();
} }
}, },
mounted() { mounted() {
@@ -64,11 +96,51 @@ export const ChatInput = {
}, },
sendMessage() { sendMessage() {
if (this.canSend) { if (!this.canSend) return;
if (this.showForm && this.formData) {
// Valideer het formulier
if (this.validateForm()) {
// Verstuur het formulier
this.$emit('submit-form', this.formValues);
this.formValues = {};
// Reset het formulier na verzenden
this.showForm = false;
}
} else if (this.localMessage.trim()) {
// Verstuur normaal bericht
this.$emit('send-message'); this.$emit('send-message');
} }
}, },
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() { autoResize() {
this.$nextTick(() => { this.$nextTick(() => {
const textarea = this.$refs.messageInput; const textarea = this.$refs.messageInput;
@@ -86,10 +158,44 @@ export const ChatInput = {
clearInput() { clearInput() {
this.localMessage = ''; this.localMessage = '';
this.focusInput(); this.focusInput();
},
toggleForm() {
this.showForm = !this.showForm;
if (!this.showForm) {
this.focusInput();
}
},
submitForm() {
if (this.canSubmitForm) {
this.$emit('submit-form', { ...this.formValues });
this.showForm = false;
this.focusInput();
}
},
cancelForm() {
this.showForm = false;
this.focusInput();
},
updateFormValues(newValues) {
this.formValues = { ...newValues };
} }
}, },
template: ` template: `
<div class="chat-input-container"> <div class="chat-input-container">
<div v-if="formData && showForm" class="dynamic-form-container">
<dynamic-form
:form-data="formData"
:form-values="formValues"
:is-submitting="isLoading"
:hide-actions="true"
@update:form-values="updateFormValues"
></dynamic-form>
</div>
<div class="chat-input"> <div class="chat-input">
<!-- Main input area --> <!-- Main input area -->
<div class="input-main"> <div class="input-main">
@@ -113,12 +219,25 @@ export const ChatInput = {
<!-- Input actions --> <!-- Input actions -->
<div class="input-actions"> <div class="input-actions">
<!-- Formulier toggle knop -->
<button
v-if="hasFormData"
@click="toggleForm"
class="form-toggle-btn"
:disabled="isLoading"
:class="{ 'active': showForm }"
:title="showForm ? 'Verberg formulier' : 'Toon formulier'"
>
<i class="material-icons">description</i>
</button>
<!-- Send button --> <!-- Send button -->
<button <button
@click="sendMessage" @click="sendMessage"
class="send-btn" class="send-btn"
:class="{ 'form-mode': showForm && formData }"
:disabled="!canSend" :disabled="!canSend"
:title="isOverLimit ? 'Bericht te lang' : 'Bericht verzenden'" :title="showForm ? 'Verstuur formulier' : 'Verstuur bericht'"
> >
<span v-if="isLoading" class="loading-spinner">⏳</span> <span v-if="isLoading" class="loading-spinner">⏳</span>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg v-else width="20" height="20" viewBox="0 0 24 24" fill="currentColor">

View File

@@ -24,8 +24,6 @@ export const ChatMessage = {
emits: ['submit-form', 'image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'], emits: ['submit-form', 'image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'],
data() { data() {
return { return {
isEditing: false,
editedContent: ''
}; };
}, },
methods: { methods: {
@@ -75,22 +73,6 @@ export const ChatMessage = {
.replace(/\n/g, '<br>'); .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() { submitForm() {
this.$emit('submit-form', this.message.formData, this.message.id); this.$emit('submit-form', this.message.formData, this.message.id);
}, },
@@ -125,23 +107,8 @@ export const ChatMessage = {
@specialist-error="handleSpecialistError" @specialist-error="handleSpecialistError"
></progress-tracker> ></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 --> <!-- View mode -->
<div v-else> <div>
<div <div
v-html="formatMessage(message.content)" v-html="formatMessage(message.content)"
class="message-text" class="message-text"

View File

@@ -5,64 +5,173 @@ export const DynamicForm = {
type: Object, type: Object,
required: true, required: true,
validator: (formData) => { validator: (formData) => {
return formData.title && formData.fields && Array.isArray(formData.fields); return formData && formData.title && formData.fields &&
(Array.isArray(formData.fields) || typeof formData.fields === 'object');
} }
}, },
formValues: { formValues: {
type: Object, type: Object,
required: true default: () => ({})
}, },
isSubmitting: { isSubmitting: {
type: Boolean, type: Boolean,
default: false 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) {
this.localFormValues = { ...newValues };
},
deep: true
},
localFormValues: {
handler(newValues) {
this.$emit('update:formValues', newValues);
},
deep: true
} }
}, },
emits: ['submit', 'cancel'],
methods: { methods: {
handleSubmit() { handleSubmit() {
// Basic validation // Basic validation
const requiredFields = this.formData.fields.filter(field => field.required); const missingFields = [];
const missingFields = requiredFields.filter(field => {
const value = this.formValues[field.name]; if (Array.isArray(this.formData.fields)) {
return !value || (typeof value === 'string' && !value.trim()); // 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) { if (missingFields.length > 0) {
const fieldNames = missingFields.map(f => f.label).join(', '); const fieldNames = missingFields.join(', ');
alert(`De volgende velden zijn verplicht: ${fieldNames}`); alert(`De volgende velden zijn verplicht: ${fieldNames}`);
return; return;
} }
this.$emit('submit'); this.$emit('submit', this.localFormValues);
}, },
handleCancel() { handleCancel() {
this.$emit('cancel'); this.$emit('cancel');
}, },
updateFieldValue(fieldName, value) { updateFieldValue(fieldId, value) {
// Emit an update for reactive binding this.localFormValues[fieldId] = value;
this.$emit('update-field', fieldName, value);
} }
}, },
template: ` template: `
<div class="dynamic-form"> <div class="dynamic-form" :class="{ 'read-only': readOnly }">
<div class="form-header" v-if="formData.title || formData.icon">
<div class="form-icon" v-if="formData.icon">
<i class="material-icons">{{ formData.icon }}</i>
</div>
<div class="form-title">{{ formData.title }}</div> <div class="form-title">{{ formData.title }}</div>
<div v-if="formData.description" class="form-description">
{{ formData.description }}
</div> </div>
<form @submit.prevent="handleSubmit" novalidate> <div v-if="readOnly" class="form-readonly">
<!-- Array-based fields -->
<template v-if="Array.isArray(formData.fields)">
<div v-for="field in formData.fields" :key="field.id || field.name" class="form-field-readonly">
<div class="field-label">{{ field.name }}:</div>
<div class="field-value">
<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">{{ localFormValues[field.id || field.name] || '-' }}</div>
</template>
<template v-else>
{{ localFormValues[field.id || field.name] || field.default || '-' }}
</template>
</div>
</div>
</template>
<!-- Object-based fields -->
<template v-else>
<div v-for="(field, fieldId) in formData.fields" :key="fieldId" class="form-field-readonly">
<div class="field-label">{{ field.name }}:</div>
<div class="field-value">
<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">{{ localFormValues[fieldId] || '-' }}</div>
</template>
<template v-else>
{{ localFormValues[fieldId] || field.default || '-' }}
</template>
</div>
</div>
</template>
</div>
<form v-else @submit.prevent="handleSubmit" novalidate>
<div class="form-fields">
<template v-if="Array.isArray(formData.fields)">
<form-field <form-field
v-for="field in formData.fields" v-for="field in formData.fields"
:key="field.name" :key="field.id || field.name"
:field-id="field.id || field.name"
:field="field" :field="field"
:model-value="formValues[field.name]" :model-value="localFormValues[field.id || field.name]"
@update:model-value="formValues[field.name] = $event" @update:model-value="localFormValues[field.id || field.name] = $event"
></form-field> ></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"> <div class="form-actions" v-if="!hideActions">
<button <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
@@ -81,28 +190,6 @@ export const DynamicForm = {
> >
{{ formData.cancelText || 'Annuleren' }} {{ formData.cancelText || 'Annuleren' }}
</button> </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> </div>
</form> </form>
</div> </div>

View File

@@ -5,22 +5,53 @@ export const FormField = {
type: Object, type: Object,
required: true, required: true,
validator: (field) => { validator: (field) => {
return field.name && field.type && field.label; return field.name && field.type;
} }
}, },
fieldId: {
type: String,
required: true
},
modelValue: { modelValue: {
default: '' default: null
} }
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
computed: { computed: {
value: { value: {
get() { 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; return this.modelValue;
}, },
set(value) { set(value) {
this.$emit('update:modelValue', value); 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: { methods: {
@@ -33,147 +64,83 @@ export const FormField = {
}, },
template: ` template: `
<div class="form-field"> <div class="form-field">
<label> <label :for="fieldId">
{{ field.label }} {{ field.name }}
<span v-if="field.required" class="required">*</span> <span v-if="field.required" class="required">*</span>
</label> </label>
<!-- Text/Email/Tel inputs --> <!-- Tekstinvoer (string/str) -->
<input <input
v-if="['text', 'email', 'tel', 'url', 'password'].includes(field.type)" v-if="fieldType === 'text'"
:type="field.type" :id="fieldId"
type="text"
v-model="value" v-model="value"
:required="field.required" :required="field.required"
:placeholder="field.placeholder || ''" :placeholder="field.placeholder || ''"
:maxlength="field.maxLength" :title="description"
:minlength="field.minLength"
> >
<!-- Number input --> <!-- Numerieke invoer (int/float) -->
<input <input
v-if="field.type === 'number'" v-if="fieldType === 'number'"
:id="fieldId"
type="number" type="number"
v-model.number="value" v-model.number="value"
:required="field.required" :required="field.required"
:min="field.min" :step="stepValue"
:max="field.max"
:step="field.step || 1"
:placeholder="field.placeholder || ''" :placeholder="field.placeholder || ''"
:title="description"
> >
<!-- Date input --> <!-- Tekstvlak (text) -->
<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 <textarea
v-if="field.type === 'textarea'" v-if="fieldType === 'textarea'"
:id="fieldId"
v-model="value" v-model="value"
:required="field.required" :required="field.required"
:rows="field.rows || 3" :rows="field.rows || 3"
:placeholder="field.placeholder || ''" :placeholder="field.placeholder || ''"
:maxlength="field.maxLength" :title="description"
></textarea> ></textarea>
<!-- Range slider --> <!-- Dropdown (enum) -->
<div v-if="field.type === 'range'" class="range-field"> <select
<input v-if="fieldType === 'select'"
type="range" :id="fieldId"
v-model.number="value" v-model="value"
:min="field.min || 0" :required="field.required"
:max="field.max || 100" :title="description"
:step="field.step || 1"
> >
<span class="range-value">{{ value }}</span> <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)" class="field-error">
Geen opties beschikbaar voor dit veld.
<pre style="font-size: 10px; color: #999;">{{ JSON.stringify(field, null, 2) }}</pre>
</div> </div>
<!-- Help text --> <!-- Checkbox (boolean) -->
<small v-if="field.helpText" class="help-text"> <div v-if="fieldType === 'checkbox'" class="checkbox-container">
{{ field.helpText }} <label class="checkbox-label">
</small> <input
:id="fieldId"
type="checkbox"
v-model="value"
:required="field.required"
:title="description"
>
<span class="checkbox-text">{{ field.description || 'Ja' }}</span>
</label>
</div>
<!-- Geen beschrijving meer tonen, alleen als tooltip die al gedefinieerd is in de inputs -->
</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

@@ -134,6 +134,8 @@ export const MessageHistory = {
:api-prefix="apiPrefix" :api-prefix="apiPrefix"
@submit-form="handleSubmitForm" @submit-form="handleSubmitForm"
@image-loaded="handleImageLoaded" @image-loaded="handleImageLoaded"
@specialist-complete="$emit('specialist-complete', $event)"
@specialist-error="$emit('specialist-error', $event)"
></chat-message> ></chat-message>
</template> </template>
</template> </template>

View File

@@ -204,9 +204,10 @@ export const ProgressTracker = {
console.error('Error updating parent message:', err); console.error('Error updating parent message:', err);
} }
// Emit event to parent met alle relevante data // Emit event to parent met alle relevante data inclusief form_request
this.$emit('specialist-complete', { this.$emit('specialist-complete', {
answer: data.result.answer, answer: data.result.answer,
form_request: data.result.form_request, // Voeg form_request toe
result: data.result, result: data.result,
interactionId: data.interaction_id, interactionId: data.interaction_id,
taskId: this.taskId taskId: this.taskId

View File

@@ -1,4 +1,4 @@
from typing import Dict, Any from typing import Dict, Any, Optional
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from eveai_chat_workers.retrievers.retriever_typing import RetrieverArguments from eveai_chat_workers.retrievers.retriever_typing import RetrieverArguments
from common.extensions import cache_manager from common.extensions import cache_manager
@@ -21,6 +21,16 @@ class SpecialistArguments(BaseModel):
"extra": "allow" "extra": "allow"
} }
# Structural optional fields available for all specialists
question: Optional[str] = Field(
None,
description="Optional question directed to the specialist"
)
form_values: Optional[Dict[str, Any]] = Field(
None,
description="Optional form values filled by the user, keyed by field name"
)
@model_validator(mode='after') @model_validator(mode='after')
def validate_required_arguments(self) -> 'SpecialistArguments': def validate_required_arguments(self) -> 'SpecialistArguments':
"""Validate that all required arguments for this specialist type are present""" """Validate that all required arguments for this specialist type are present"""
@@ -91,6 +101,16 @@ class SpecialistResult(BaseModel):
"extra": "allow" "extra": "allow"
} }
# Structural optional fields available for all specialists
answer: Optional[str] = Field(
None,
description="Optional textual answer from the specialist"
)
form_request: Optional[Dict[str, Any]] = Field(
None,
description="Optional form definition to request user input"
)
@model_validator(mode='after') @model_validator(mode='after')
def validate_required_results(self) -> 'SpecialistResult': def validate_required_results(self) -> 'SpecialistResult':
"""Validate that all required result fields for this specialist type are present""" """Validate that all required result fields for this specialist type are present"""

View File

@@ -17,6 +17,7 @@ from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, S
from eveai_chat_workers.outputs.traicie.competencies.competencies_v1_1 import Competencies from eveai_chat_workers.outputs.traicie.competencies.competencies_v1_1 import Competencies
from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState
from common.services.interaction.specialist_services import SpecialistServices from common.services.interaction.specialist_services import SpecialistServices
from common.extensions import cache_manager
class SpecialistExecutor(CrewAIBaseSpecialistExecutor): class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
@@ -84,15 +85,18 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
# results.competencies = flow_state.competencies # results.competencies = flow_state.competencies
# self.create_selection_specialist(arguments, flow_state.competencies) # self.create_selection_specialist(arguments, flow_state.competencies)
for i in range(5): for i in range(3):
sleep(3) sleep(1)
self.ept.send_update(self.task_id, "Traicie Selection Specialist Processing", {"name": f"Processing Iteration {i}"}) self.ept.send_update(self.task_id, "Traicie Selection Specialist Processing", {"name": f"Processing Iteration {i}"})
# flow_results = asyncio.run(self.flow.kickoff_async(inputs=arguments.model_dump())) # flow_results = asyncio.run(self.flow.kickoff_async(inputs=arguments.model_dump()))
# flow_state = self.flow.state # flow_state = self.flow.state
# results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version) # results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version)
contact_form = cache_manager.specialist_forms_config_cache.get_config("PERSONAL_CONTACT_FORM", "1.0")
current_app.logger.debug(f"Contact form: {contact_form}")
results = SpecialistResult.create_for_type(self.type, self.type_version, results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=f"Antwoord op uw vraag: {arguments.question}") answer=f"Antwoord op uw vraag: {arguments.question}",
form_request=contact_form)
self.log_tuning(f"Traicie Selection Specialist execution ended", {"Results": results.model_dump()}) self.log_tuning(f"Traicie Selection Specialist execution ended", {"Results": results.model_dump()})