Chat client changes

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

View File

@@ -1,6 +1,7 @@
type: "PERSONAL_CONTACT_FORM"
version: "1.0.0" version: "1.0.0"
name: "Personal Contact Form" name: "Personal Contact Form"
icon: "call" icon: "person"
fields: fields:
name: name:
name: "Name" name: "Name"
@@ -17,24 +18,28 @@ fields:
type: "str" type: "str"
description: "Your Phone Number" description: "Your Phone Number"
required: true required: true
Address: address:
name: "Address" name: "Address"
type: "text" type: "string"
description: "Your Address" description: "Your Address"
required: false required: false
status: zip:
name: "Marital Status" name: "Postal Code"
type: "enum" type: "string"
description: "Your Marital Status" description: "Postal Code"
required: false required: false
default: "single" city:
allowed_values: name: "City"
- "single" type: "string"
- "married" description: "City"
- "divorced" required: false
can_contact: country:
name: "Allow Contact" name: "Country"
type: "string"
description: "Country"
required: false
consent:
name: "Consent"
type: "boolean" type: "boolean"
description: "Allow us to contact you?" description: "Consent"
required: true required: true
default: false

View File

@@ -0,0 +1,55 @@
type: "PROFESSIONAL_CONTACT_FORM"
version: "1.0.0"
name: "Professional Contact Form"
icon: "account_circle"
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
company:
name: "Company Name"
type: "str"
description: "Company Name"
required: true
job_title:
name: "Job Title"
type: "str"
description: "Job Title"
required: false
address:
name: "Address"
type: "str"
description: "Your Address"
required: false
zip:
name: "Postal Code"
type: "str"
description: "Postal Code"
required: false
city:
name: "City"
type: "str"
description: "City"
required: false
country:
name: "Country"
type: "str"
description: "Country"
required: false
consent:
name: "Consent"
type: "bool"
description: "Consent"
required: true

View File

@@ -0,0 +1,120 @@
version: "1.1.0"
name: "Traicie Selection Specialist"
framework: "crewai"
partner: "traicie"
chat: false
configuration:
name:
name: "Name"
description: "The name the specialist is called upon."
type: "str"
required: true
role_reference:
name: "Role Reference"
description: "A customer reference to the role"
type: "str"
required: false
make:
name: "Make"
description: "The make for which the role is defined and the selection specialist is created"
type: "system"
system_name: "tenant_make"
required: true
competencies:
name: "Competencies"
description: "An ordered list of competencies."
type: "ordered_list"
list_type: "competency_details"
required: true
tone_of_voice:
name: "Tone of Voice"
description: "The tone of voice the specialist uses to communicate"
type: "enum"
allowed_values: ["Professional & Neutral", "Warm & Empathetic", "Energetic & Enthusiastic", "Accessible & Informal", "Expert & Trustworthy", "No-nonsense & Goal-driven"]
default: "Professional & Neutral"
required: true
language_level:
name: "Language Level"
description: "Language level to be used when communicating, relating to CEFR levels"
type: "enum"
allowed_values: ["Basic", "Standard", "Professional"]
default: "Standard"
required: true
welcome_message:
name: "Welcome Message"
description: "Introductory text given by the specialist - but translated according to Tone of Voice, Language Level and Starting Language"
type: "text"
required: false
closing_message:
name: "Closing Message"
description: "Closing message given by the specialist - but translated according to Tone of Voice, Language Level and Starting Language"
type: "text"
required: false
competency_details:
title:
name: "Title"
description: "Competency Title"
type: "str"
required: true
description:
name: "Description"
description: "Description (in context of the role) of the competency"
type: "text"
required: true
is_knockout:
name: "KO"
description: "Defines if the competency is a knock-out criterium"
type: "boolean"
required: true
default: false
assess:
name: "Assess"
description: "Indication if this competency is to be assessed"
type: "boolean"
required: true
default: true
arguments:
region:
name: "Region"
type: "str"
description: "The region of the specific vacancy"
required: false
working_schedule:
name: "Work Schedule"
type: "str"
description: "The work schedule or employment type of the specific vacancy"
required: false
start_date:
name: "Start Date"
type: "date"
description: "The start date of the specific vacancy"
required: false
language:
name: "Language"
type: "str"
description: "The language (2-letter code) used to start the conversation"
required: true
interaction_mode:
name: "Interaction Mode"
type: "enum"
description: "The interaction mode the specialist will start working in."
allowed_values: ["Job Application", "Seduction"]
default: "Job Application"
required: true
results:
competencies:
name: "competencies"
type: "List[str, str]"
description: "List of vacancy competencies and their descriptions"
required: false
agents:
- type: "TRAICIE_HR_BP_AGENT"
version: "1.0"
tasks:
- type: "TRAICIE_GET_COMPETENCIES_TASK"
version: "1.1"
metadata:
author: "Josako"
date_added: "2025-05-27"
changes: "Add make to the selection specialist"
description: "Assistant to create a new Vacancy based on Vacancy Text"

View File

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

View File

@@ -3,7 +3,7 @@
# Script to copy eveai_chat_client/static files to nginx/static # Script to copy eveai_chat_client/static files to nginx/static
# without overwriting existing files # without overwriting existing files
SRC_DIR="../eveai_chat_client/static" SRC_DIR="../eveai_chat_client/static/assets"
DEST_DIR="../nginx/static/assets" DEST_DIR="../nginx/static/assets"
# Check if source directory exists # Check if source directory exists

View File

@@ -32,7 +32,6 @@
border-radius: 15px; border-radius: 15px;
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
box-shadow: 0 4px 20px rgba(0,0,0,0.1); box-shadow: 0 4px 20px rgba(0,0,0,0.1);
width: 100%; width: 100%;
max-width: 1000px; /* Optimale breedte */ max-width: 1000px; /* Optimale breedte */
@@ -377,7 +376,7 @@
/* User message bubble styling */ /* User message bubble styling */
.message.user .message-content { .message.user .message-content {
background: linear-gradient(135deg, #007bff, #0056b3); background: rgba(0, 0, 0, 0.1);
color: white; color: white;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
} }
@@ -385,9 +384,8 @@
/* AI/Bot message bubble styling */ /* AI/Bot message bubble styling */
.message.ai .message-content, .message.ai .message-content,
.message.bot .message-content { .message.bot .message-content {
background: #f8f9fa; background: rgba(255, 255, 255, 0.1);
color: #212529; color: #212529;
border: 1px solid #e9ecef;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
margin-right: 60px; margin-right: 60px;
} }
@@ -670,7 +668,6 @@
/* Progress Tracker Styling */ /* Progress Tracker Styling */
.progress-tracker { .progress-tracker {
margin: 8px 0; margin: 8px 0;
border: 1px solid #e9ecef;
border-radius: 8px; border-radius: 8px;
background: #f8f9fa; background: #f8f9fa;
overflow: hidden; overflow: hidden;
@@ -683,8 +680,7 @@
} }
.progress-tracker.completed { .progress-tracker.completed {
border-color: #28a745; background: rgba(155, 255, 155, 0.1);
background: #d4edda;
} }
.progress-header { .progress-header {

View File

@@ -0,0 +1,120 @@
/* ChatInput component styling */
/* Algemene container */
.chat-input-container {
width: 100%;
padding: 10px;
background-color: #fff;
border-top: 1px solid #e0e0e0;
font-family: Arial, sans-serif;
font-size: 14px;
}
/* Input veld en knoppen */
.chat-input {
display: flex;
align-items: flex-end;
gap: 10px;
}
.input-main {
flex: 1;
position: relative;
}
.message-input {
width: 100%;
min-height: 40px;
padding: 10px 40px 10px 15px;
border: 1px solid #ddd;
border-radius: 20px;
resize: none;
outline: none;
transition: border-color 0.2s;
font-family: Arial, sans-serif;
font-size: 14px;
}
.message-input:focus {
border-color: #0084ff;
}
.message-input.over-limit {
border-color: #ff4d4f;
}
/* Character counter */
.character-counter {
position: absolute;
right: 10px;
bottom: 10px;
font-size: 12px;
color: #999;
}
.character-counter.over-limit {
color: #ff4d4f;
}
/* Input actions */
.input-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Verzendknop */
.send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background-color: #0084ff;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.2s;
}
.send-btn:hover {
background-color: #0077e6;
}
.send-btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.send-btn.form-mode {
background-color: #4caf50;
}
.send-btn.form-mode:hover {
background-color: #43a047;
}
/* Loading spinner */
.loading-spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Formulier in chat input */
.dynamic-form-container {
margin-bottom: 10px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px 15px 5px 15px;
position: relative;
background-color: #f9f9f9;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
font-family: Arial, sans-serif;
font-size: 14px;
}

View File

@@ -0,0 +1,161 @@
/* chat-message.css */
/* Algemene styling voor berichten */
.message {
max-width: 90%;
margin-bottom: 15px;
width: auto;
}
.message.user {
margin-left: auto;
}
.message.ai {
margin-right: auto;
}
.message-content {
width: 100%;
font-family: Arial, sans-serif;
font-size: 14px;
}
/* Formulier styling */
.form-display {
margin: 15px 0;
border-radius: 8px;
background-color: rgba(245, 245, 245, 0.7);
padding: 15px;
border: 1px solid #e0e0e0;
font-family: inherit;
}
/* Tabel styling voor formulieren */
.form-result-table {
width: 100%;
border-collapse: collapse;
font-family: inherit;
}
.form-result-table th {
padding: 8px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
font-weight: 600;
font-family: Arial, sans-serif;
font-size: 14px;
}
.form-result-table td {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-family: Arial, sans-serif;
font-size: 14px;
}
.form-result-table td:first-child {
font-weight: 500;
width: 35%;
}
/* Styling voor formulier invoervelden */
.form-result-table input.form-input,
.form-result-table textarea.form-textarea,
.form-result-table select.form-select {
width: 100%;
padding: 6px;
border-radius: 4px;
border: 1px solid #ddd;
font-family: Arial, sans-serif;
font-size: 14px;
background-color: white;
}
.form-result-table textarea.form-textarea {
resize: vertical;
min-height: 60px;
}
/* Styling voor tabel cellen */
.form-result-table .field-label {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
font-weight: 500;
width: 35%;
vertical-align: top;
}
.form-result-table .field-value {
padding: 8px;
border-bottom: 1px solid #f0f0f0;
vertical-align: top;
}
/* Toggle Switch styling */
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.toggle-input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.toggle-knob {
position: absolute;
content: '';
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
/* Material icon styling */
.material-symbols-outlined {
vertical-align: middle;
margin-right: 8px;
font-size: 20px;
}
.form-header {
display: flex;
align-items: center;
padding: 8px;
border-bottom: 1px solid #e0e0e0;
}
/* Zorgt dat het lettertype consistent is */
.message-text {
font-family: Arial, sans-serif;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
}
/* Form error styling */
.form-error {
color: red;
padding: 10px;
font-family: Arial, sans-serif;
font-size: 14px;
}

View File

@@ -0,0 +1,91 @@
/* Styling voor formulier in berichten */
.message .form-display {
margin-bottom: 12px;
border-radius: 8px;
background-color: rgba(245, 245, 245, 0.7);
padding: 12px;
border: 1px solid #e0e0e0;
}
.message.user .form-display {
background-color: rgba(255, 255, 255, 0.1);
}
.message.ai .form-display {
background-color: rgba(245, 245, 250, 0.7);
}
/* Styling voor formulieren in berichten */
.form-display {
margin-bottom: 10px;
border-radius: 8px;
padding: 12px;
background-color: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.user-form-values {
background-color: rgba(0, 123, 255, 0.05);
}
/* Speciale styling voor read-only formulieren in user messages */
.user-form .form-field {
margin-bottom: 6px !important;
}
.user-form .field-label {
font-weight: 500 !important;
color: #555 !important;
padding: 2px 0 !important;
}
.user-form .field-value {
padding: 2px 0 !important;
}
/* Schakel hover effecten uit voor read-only formulieren */
.read-only .form-field:hover {
background-color: transparent;
}
/* Subtiele scheiding tussen velden */
.dynamic-form.read-only .form-fields {
border-top: 1px solid rgba(0, 0, 0, 0.05);
margin-top: 10px;
padding-top: 8px;
}
/* Verklein vorm titels in berichten */
.message-form .form-title {
font-size: 1em !important;
}
.message-form .form-description {
font-size: 0.85em !important;
}
.form-readonly {
width: 100%;
}
.form-readonly .field-label {
font-weight: 500;
color: #555;
}
.form-readonly .field-value {
word-break: break-word;
}
.form-readonly .text-value {
white-space: pre-wrap;
}
/* Algemene styling verbetering voor berichten */
.message-text {
white-space: pre-wrap;
word-break: break-word;
}
.message-content {
max-width: 100%;
}

View File

@@ -170,21 +170,22 @@ export const ChatApp = {
}, },
// Message management // Message management
addMessage(content, sender, type = 'text', formData = null) { addMessage(content, sender, type = 'text', formData = null, formValues = null) {
const message = { const message = {
id: this.messageIdCounter++, id: this.messageIdCounter++,
content, content,
sender, sender,
type, type,
formData, formData,
formValues,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: sender === 'user' ? 'sent' : 'delivered' status: sender === 'user' ? 'sent' : 'delivered'
}; };
this.allMessages.push(message); this.allMessages.push(message);
// Initialize form values if it's a form // Initialize form values if it's a form and no values were provided
if (type === 'form' && formData) { if (type === 'form' && formData && !formValues) {
// Vue 3 compatibele manier om reactieve objecten bij te werken // Vue 3 compatibele manier om reactieve objecten bij te werken
this.formValues[message.id] = {}; this.formValues[message.id] = {};
formData.fields.forEach(field => { formData.fields.forEach(field => {
@@ -203,19 +204,32 @@ export const ChatApp = {
return message; return message;
}, },
// Helper functie om formulierdata toe te voegen aan bestaande berichten
attachFormDataToMessage(messageId, formData, formValues) {
const message = this.allMessages.find(m => m.id === messageId);
if (message) {
message.formData = formData;
message.formValues = formValues;
}
},
updateCurrentMessage(value) { updateCurrentMessage(value) {
this.currentMessage = value; this.currentMessage = value;
}, },
// Message sending // Message sending (alleen voor gewone tekstberichten, geen formulieren)
async sendMessage() { async sendMessage() {
const text = this.currentMessage.trim(); const text = this.currentMessage.trim();
// Controleer of we kunnen verzenden
if (!text || this.isLoading) return; if (!text || this.isLoading) return;
console.log('Sending message:', text); console.log('Sending text message:', text);
// Add user message // Add user message
const userMessage = this.addMessage(text, 'user', 'text'); const userMessage = this.addMessage(text, 'user', 'text');
// Wis input
this.currentMessage = ''; this.currentMessage = '';
// Show typing and loading state // Show typing and loading state
@@ -223,11 +237,14 @@ export const ChatApp = {
this.isLoading = true; this.isLoading = true;
try { try {
const response = await this.callAPI('/api/send_message', { // Verzamel gegevens voor de API call
const apiData = {
message: text, message: text,
conversation_id: this.conversationId, conversation_id: this.conversationId,
user_id: this.userId user_id: this.userId
}); };
const response = await this.callAPI('/api/send_message', apiData);
// Hide typing indicator // Hide typing indicator
this.isTyping = false; this.isTyping = false;
@@ -278,35 +295,61 @@ export const ChatApp = {
return; return;
} }
console.log('Submitting form from input:', this.currentInputFormData.title, formValues); console.log('Form values received:', formValues);
console.log('Current input form data:', this.currentInputFormData);
try { try {
const response = await this.callAPI('/api/submit_form', { // Maak een user message met formuliergegevens én eventuele tekst
formData: formValues, const userMessage = this.addMessage(
formType: this.currentInputFormData.title, this.currentMessage.trim(), // Voeg tekst toe als die er is
'user',
'text'
);
// Voeg formuliergegevens toe aan het bericht
userMessage.formData = this.currentInputFormData;
userMessage.formValues = formValues;
// Reset het tekstbericht
this.currentMessage = '';
this.$emit('update-message', '');
// Toon laad-indicator
this.isTyping = true;
this.isLoading = true;
// Verzamel gegevens voor de API call
const apiData = {
message: userMessage.content,
conversation_id: this.conversationId, conversation_id: this.conversationId,
user_id: this.userId user_id: this.userId,
}); form_values: formValues // Voeg formuliergegevens toe aan API call
};
if (response.success) { // Verstuur bericht naar de API
this.addMessage( const response = await this.callAPI('/api/send_message', apiData);
`${response.message || 'Formulier succesvol verzonden!'}`,
'ai',
'text'
);
// Wis het huidige formulier (ongeacht of het succesvol was of niet) // Verberg de typing indicator
this.currentInputFormData = null; this.isTyping = false;
} else {
this.addMessage( // Markeer het gebruikersbericht als afgeleverd
`❌ Er ging iets mis: ${response.error || 'Onbekende fout'}`, userMessage.status = 'delivered';
'ai',
'text' // Voeg AI response toe met task_id voor tracking
); const aiMessage = this.addMessage(
// Wis ook hier het formulier na een fout '',
this.currentInputFormData = null; 'ai',
'text'
);
if (response.task_id) {
console.log('Monitoring Task ID: ', response.task_id);
aiMessage.taskId = response.task_id;
} }
// Reset formulier na succesvolle verzending
this.currentInputFormData = null;
} catch (error) { } catch (error) {
console.error('Error submitting form:', error); console.error('Error submitting form:', error);
this.addMessage( this.addMessage(
@@ -318,6 +361,7 @@ export const ChatApp = {
this.currentInputFormData = null; this.currentInputFormData = null;
} finally { } finally {
this.isSubmittingForm = false; this.isSubmittingForm = false;
this.isLoading = false;
} }
}, },

View File

@@ -125,9 +125,15 @@
const hasValidForm = this.formData && this.validateForm(); const hasValidForm = this.formData && this.validateForm();
const hasValidMessage = this.localMessage.trim() && !this.isOverLimit; const hasValidMessage = this.localMessage.trim() && !this.isOverLimit;
// We kunnen nu verzenden als er een geldig formulier OF een geldig bericht is
// Bij een formulier is aanvullende tekst optioneel
return (!this.isLoading) && (hasValidForm || hasValidMessage); return (!this.isLoading) && (hasValidForm || hasValidMessage);
}, },
hasFormDataToSend() {
return this.formData && this.validateForm();
},
sendButtonText() { sendButtonText() {
if (this.isLoading) { if (this.isLoading) {
return 'Verzenden...'; return 'Verzenden...';
@@ -181,20 +187,31 @@
sendMessage() { sendMessage() {
if (!this.canSend) return; if (!this.canSend) return;
// Bij een formulier gaan we het formulier en optioneel bericht combineren
if (this.formData) { if (this.formData) {
// Valideer het formulier // Valideer het formulier
if (this.validateForm()) { if (this.validateForm()) {
// Verstuur het formulier // Verstuur het formulier, eventueel met aanvullende tekst
this.$emit('submit-form', this.formValues); this.$emit('submit-form', this.formValues);
this.formValues = {};
} }
} else if (this.localMessage.trim()) { } else if (this.localMessage.trim()) {
// Verstuur normaal bericht // Verstuur normaal bericht zonder formulier
this.$emit('send-message'); this.$emit('send-message');
} }
}, },
// Deze methode houden we voor de volledigheid, maar zal niet meer direct worden aangeroepen getFormValuesForSending() {
// Geeft de huidige formulierwaarden terug voor verzending
return this.formValues;
},
// Reset het formulier en de waarden
resetForm() {
this.formValues = {};
this.initFormValues();
},
// Annuleer het formulier (wordt momenteel niet gebruikt)
cancelForm() { cancelForm() {
this.formValues = {}; this.formValues = {};
// We sturen geen emit meer, maar het kan nuttig zijn om in de toekomst te hebben // We sturen geen emit meer, maar het kan nuttig zijn om in de toekomst te hebben
@@ -261,7 +278,7 @@
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
</div> </div>
<!-- Dynamisch formulier container --> <!-- Dynamisch formulier container -->
<div v-if="formData" class="dynamic-form-container" style="margin-bottom: 10px; border: 1px solid #ddd; border-radius: 8px; padding: 15px 15px 5px 15px; position: relative; background-color: #f9f9f9; box-shadow: 0 2px 4px rgba(0,0,0,0.05);"> <div v-if="formData" class="dynamic-form-container">
<!-- De titel wordt in DynamicForm weergegeven en niet hier om dubbele titels te voorkomen --> <!-- De titel wordt in DynamicForm weergegeven en niet hier om dubbele titels te voorkomen -->
<div v-if="!formData.fields" style="color: red; padding: 10px;">Fout: Geen velden gevonden in formulier</div> <div v-if="!formData.fields" style="color: red; padding: 10px;">Fout: Geen velden gevonden in formulier</div>
<dynamic-form <dynamic-form

View File

@@ -1,3 +1,33 @@
// Voeg stylesheets toe voor formulier en chat berichten weergave
const addStylesheets = () => {
// Formulier stylesheet
if (!document.querySelector('link[href*="form-message.css"]')) {
const formLink = document.createElement('link');
formLink.rel = 'stylesheet';
formLink.href = '/static/assets/css/form-message.css';
document.head.appendChild(formLink);
}
// Chat bericht stylesheet
if (!document.querySelector('link[href*="chat-message.css"]')) {
const chatLink = document.createElement('link');
chatLink.rel = 'stylesheet';
chatLink.href = '/static/assets/css/chat-message.css';
document.head.appendChild(chatLink);
}
// Material Icons font stylesheet
if (!document.querySelector('link[href*="Material+Symbols+Outlined"]')) {
const iconLink = document.createElement('link');
iconLink.rel = 'stylesheet';
iconLink.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0';
document.head.appendChild(iconLink);
}
};
// Laad de stylesheets
addStylesheets();
export const ChatMessage = { export const ChatMessage = {
name: 'ChatMessage', name: 'ChatMessage',
props: { props: {
@@ -17,11 +47,38 @@ export const ChatMessage = {
default: '' default: ''
} }
}, },
created() {
// Zorg ervoor dat het icoon geladen wordt als iconManager beschikbaar is
if (window.iconManager && this.message.formData && this.message.formData.icon) {
window.iconManager.loadIcon(this.message.formData.icon);
}
},
watch: {
'message.formData.icon': {
handler(newIcon) {
if (newIcon && window.iconManager) {
window.iconManager.loadIcon(newIcon);
}
},
immediate: true
}
},
emits: ['image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'], emits: ['image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'],
data() { data() {
return { return {
formVisible: true
}; };
}, },
computed: {
hasFormData() {
return this.message.formData &&
((Array.isArray(this.message.formData.fields) && this.message.formData.fields.length > 0) ||
(typeof this.message.formData.fields === 'object' && Object.keys(this.message.formData.fields).length > 0));
},
hasFormValues() {
return this.message.formValues && Object.keys(this.message.formValues).length > 0;
}
},
methods: { methods: {
handleSpecialistError(eventData) { handleSpecialistError(eventData) {
console.log('ChatMessage received specialist-error event:', eventData); console.log('ChatMessage received specialist-error event:', eventData);
@@ -90,7 +147,7 @@ export const ChatMessage = {
<div :class="getMessageClass()" :data-message-id="message.id"> <div :class="getMessageClass()" :data-message-id="message.id">
<!-- Normal text messages --> <!-- Normal text messages -->
<template v-if="message.type === 'text'"> <template v-if="message.type === 'text'">
<div class="message-content"> <div class="message-content" style="width: 100%;">
<!-- Voortgangstracker voor AI berichten met task_id - NU BINNEN DE BUBBLE --> <!-- Voortgangstracker voor AI berichten met task_id - NU BINNEN DE BUBBLE -->
<progress-tracker <progress-tracker
v-if="message.sender === 'ai' && message.taskId" v-if="message.sender === 'ai' && message.taskId"
@@ -101,9 +158,79 @@ export const ChatMessage = {
@specialist-error="handleSpecialistError" @specialist-error="handleSpecialistError"
></progress-tracker> ></progress-tracker>
<!-- Form data display if available (alleen in user messages) -->
<div v-if="message.formValues && message.sender === 'user'" class="form-display user-form-values">
<dynamic-form
:form-data="message.formData"
:form-values="message.formValues"
:read-only="true"
hide-actions
class="message-form user-form"
></dynamic-form>
</div>
<!-- Formulier in AI berichten -->
<div v-if="message.formData && message.sender === 'ai'" class="form-display ai-form-values" style="margin-top: 15px;">
<!-- Dynamisch toevoegen van Material Symbols Outlined voor iconen -->
<table class="form-result-table">
<thead v-if="message.formData.title || message.formData.name || message.formData.icon">
<tr>
<th colspan="2">
<div class="form-header">
<span v-if="message.formData.icon" class="material-symbols-outlined">{{ message.formData.icon }}</span>
<span>{{ message.formData.title || message.formData.name }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(field, fieldId) in message.formData.fields" :key="fieldId">
<td class="field-label">{{ field.name }}:</td>
<td class="field-value">
<input
v-if="field.type === 'str' || field.type === 'string' || field.type === 'int' || field.type === 'integer' || field.type === 'float'"
:type="field.type === 'int' || field.type === 'integer' || field.type === 'float' ? 'number' : 'text'"
:placeholder="field.placeholder || ''"
class="form-input"
>
<textarea
v-else-if="field.type === 'text'"
:placeholder="field.placeholder || ''"
:rows="field.rows || 3"
class="form-textarea"
></textarea>
<select
v-else-if="field.type === 'enum'"
class="form-select"
>
<option value="">Selecteer een optie</option>
<option
v-for="option in (field.allowedValues || field.allowed_values || [])"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="toggle-switch">
<input
type="checkbox"
class="toggle-input"
>
<span class="toggle-slider">
<span class="toggle-knob"></span>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- View mode --> <!-- View mode -->
<div> <div>
<div <div
v-if="message.content"
v-html="formatMessage(message.content)" v-html="formatMessage(message.content)"
class="message-text" class="message-text"
></div> ></div>

View File

@@ -151,12 +151,12 @@ export const DynamicForm = {
</div> </div>
</div> </div>
<div v-if="readOnly" class="form-readonly"> <div v-if="readOnly" class="form-readonly" style="display: grid; grid-template-columns: 35% 65%; gap: 8px; width: 100%;">
<!-- Array-based fields --> <!-- Array-based fields -->
<template v-if="Array.isArray(formData.fields)"> <template v-if="Array.isArray(formData.fields)">
<div v-for="field in formData.fields" :key="field.id || field.name" class="form-field-readonly"> <template v-for="field in formData.fields" :key="field.id || field.name">
<div class="field-label">{{ field.name }}:</div> <div class="field-label" style="font-weight: 500; color: #555; padding: 4px 0;">{{ field.name }}:</div>
<div class="field-value"> <div class="field-value" style="padding: 4px 0;">
<template v-if="field.type === 'enum' && (field.allowedValues || field.allowed_values)"> <template v-if="field.type === 'enum' && (field.allowedValues || field.allowed_values)">
{{ localFormValues[field.id || field.name] || field.default || '-' }} {{ localFormValues[field.id || field.name] || field.default || '-' }}
</template> </template>
@@ -164,19 +164,19 @@ export const DynamicForm = {
{{ localFormValues[field.id || field.name] ? 'Ja' : 'Nee' }} {{ localFormValues[field.id || field.name] ? 'Ja' : 'Nee' }}
</template> </template>
<template v-else-if="field.type === 'text'"> <template v-else-if="field.type === 'text'">
<div class="text-value">{{ localFormValues[field.id || field.name] || '-' }}</div> <div class="text-value" style="white-space: pre-wrap;">{{ localFormValues[field.id || field.name] || '-' }}</div>
</template> </template>
<template v-else> <template v-else>
{{ localFormValues[field.id || field.name] || field.default || '-' }} {{ localFormValues[field.id || field.name] || field.default || '-' }}
</template> </template>
</div> </div>
</div> </template>
</template> </template>
<!-- Object-based fields --> <!-- Object-based fields -->
<template v-else> <template v-else>
<div v-for="(field, fieldId) in formData.fields" :key="fieldId" class="form-field-readonly"> <template v-for="(field, fieldId) in formData.fields" :key="fieldId">
<div class="field-label">{{ field.name }}:</div> <div class="field-label" style="font-weight: 500; color: #555; padding: 4px 0;">{{ field.name }}:</div>
<div class="field-value"> <div class="field-value" style="padding: 4px 0;">
<template v-if="field.type === 'enum' && (field.allowedValues || field.allowed_values)"> <template v-if="field.type === 'enum' && (field.allowedValues || field.allowed_values)">
{{ localFormValues[fieldId] || field.default || '-' }} {{ localFormValues[fieldId] || field.default || '-' }}
</template> </template>
@@ -184,13 +184,13 @@ export const DynamicForm = {
{{ localFormValues[fieldId] ? 'Ja' : 'Nee' }} {{ localFormValues[fieldId] ? 'Ja' : 'Nee' }}
</template> </template>
<template v-else-if="field.type === 'text'"> <template v-else-if="field.type === 'text'">
<div class="text-value">{{ localFormValues[fieldId] || '-' }}</div> <div class="text-value" style="white-space: pre-wrap;">{{ localFormValues[fieldId] || '-' }}</div>
</template> </template>
<template v-else> <template v-else>
{{ localFormValues[fieldId] || field.default || '-' }} {{ localFormValues[fieldId] || field.default || '-' }}
</template> </template>
</div> </div>
</div> </template>
</template> </template>
</div> </div>

View File

@@ -1,11 +0,0 @@
/* Animation styles for ChatInput component */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Add more ChatInput-specific styles here */
.loading-spinner {
display: inline-block;
animation: spin 1s linear infinite;
}

View File

@@ -1,75 +0,0 @@
/* 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

@@ -117,10 +117,12 @@ def send_message():
""" """
try: try:
data = request.json data = request.json
message = data.get('message') message = data.get('message', '')
form_values = data.get('form_values', {})
if not message: # Controleer of er ofwel een bericht of formuliergegevens zijn
return jsonify({'error': 'No message provided'}), 400 if not message and not form_values:
return jsonify({'error': 'No message or form data provided'}), 400
tenant_id = session['tenant']['id'] tenant_id = session['tenant']['id']
specialist_id = session['specialist']['id'] specialist_id = session['specialist']['id']
@@ -134,7 +136,12 @@ def send_message():
Database(tenant_id).switch_schema() Database(tenant_id).switch_schema()
# Add user message to specialist arguments # Add user message to specialist arguments
specialist_args['question'] = message if message:
specialist_args['question'] = message
# Add form values to specialist arguments if present
if form_values:
specialist_args['form_values'] = form_values
current_app.logger.debug(f"Sending message to specialist: {specialist_id} for tenant {tenant_id}\n" current_app.logger.debug(f"Sending message to specialist: {specialist_id} for tenant {tenant_id}\n"
f" with args: {specialist_args}\n" f" with args: {specialist_args}\n"

View File

@@ -2,6 +2,7 @@ from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem
# class BehaviouralCompetence(BaseModel): # class BehaviouralCompetence(BaseModel):
# title: str = Field(..., description="The title of the behavioural competence.") # title: str = Field(..., description="The title of the behavioural competence.")
# description: Optional[str] = Field(None, description="The description of the behavioural competence.") # description: Optional[str] = Field(None, description="The description of the behavioural competence.")

View File

@@ -0,0 +1,254 @@
import asyncio
import json
from os import wait
from typing import Optional, List, Dict, Any
from datetime import date
from time import sleep
from crewai.flow.flow import start, listen, and_
from flask import current_app
from pydantic import BaseModel, Field, EmailStr
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db
from common.models.user import Tenant
from common.models.interaction import Specialist
from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem
from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor
from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments
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 common.services.interaction.specialist_services import SpecialistServices
from common.extensions import cache_manager
class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
"""
type: TRAICIE_SELECTION_SPECIALIST
type_version: 1.1
Traicie Selection Specialist Executor class
"""
def __init__(self, tenant_id, specialist_id, session_id, task_id, **kwargs):
self.role_definition_crew = None
super().__init__(tenant_id, specialist_id, session_id, task_id)
# Load the Tenant & set language
self.tenant = Tenant.query.get_or_404(tenant_id)
@property
def type(self) -> str:
return "TRAICIE_SELECTION_SPECIALIST"
@property
def type_version(self) -> str:
return "1.1"
def _config_task_agents(self):
self._add_task_agent("traicie_get_competencies_task", "traicie_hr_bp_agent")
def _config_pydantic_outputs(self):
self._add_pydantic_output("traicie_get_competencies_task", Competencies, "competencies")
def _instantiate_specialist(self):
verbose = self.tuning
role_definition_agents = [self.traicie_hr_bp_agent]
role_definition_tasks = [self.traicie_get_competencies_task]
self.role_definition_crew = EveAICrewAICrew(
self,
"Role Definition Crew",
agents=role_definition_agents,
tasks=role_definition_tasks,
verbose=verbose,
)
self.flow = RoleDefinitionFlow(
self,
self.role_definition_crew
)
def execute(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie Selection Specialist execution started", {})
# flow_inputs = {
# "vacancy_text": arguments.vacancy_text,
# "role_name": arguments.role_name,
# 'role_reference': arguments.role_reference,
# }
#
# flow_results = self.flow.kickoff(inputs=flow_inputs)
#
# flow_state = self.flow.state
#
# results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version)
# if flow_state.competencies:
# results.competencies = flow_state.competencies
# self.create_selection_specialist(arguments, flow_state.competencies)
for i in range(3):
sleep(1)
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_state = self.flow.state
# 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,
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()})
return results
def create_selection_specialist(self, arguments: SpecialistArguments, competencies: List[ListItem]):
"""This method creates a new TRAICIE_SELECTION_SPECIALIST specialist with the given competencies."""
current_app.logger.info(f"Creating selection with arguments: {arguments.model_dump()}")
selection_comptencies = []
for competency in competencies:
selection_competency = {
"title": competency.title,
"description": competency.description,
"assess": True,
"is_knockout": False,
}
selection_comptencies.append(selection_competency)
selection_config = {
"name": arguments.specialist_name,
"competencies": selection_comptencies,
"tone_of_voice": "Professional & Neutral",
"language_level": "Standard",
"role_reference": arguments.role_reference,
}
name = arguments.role_name
if len(name) > 50:
name = name[:47] + "..."
new_specialist = Specialist(
name=name,
description=f"Specialist for {arguments.role_name} role",
type="TRAICIE_SELECTION_SPECIALIST",
type_version="1.0",
tuning=False,
configuration=selection_config,
)
try:
db.session.add(new_specialist)
db.session.commit()
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f"Error creating selection specialist: {str(e)}")
raise e
SpecialistServices.initialize_specialist(new_specialist.id, self.type, self.type_version)
class SelectionSpecialistInput(BaseModel):
region: str = Field(..., alias="region")
working_schedule: Optional[str] = Field(..., alias="working_schedule")
start_date: Optional[date] = Field(None, alias="vacancy_text")
language: Optional[str] = Field(None, alias="language")
interaction_mode: Optional[str] = Field(None, alias="interaction_mode")
question: Optional[str] = Field(None, alias="question")
field_values: Optional[Dict[str, Any]] = Field(None, alias="field_values")
class SelectionSpecialistKOCriteriumScore(BaseModel):
criterium: Optional[str] = Field(None, alias="criterium")
answer: Optional[str] = Field(None, alias="answer")
score: Optional[int] = Field(None, alias="score")
class SelectionSpecialistCompetencyScore(BaseModel):
competency: Optional[str] = Field(None, alias="competency")
answer: Optional[str] = Field(None, alias="answer")
score: Optional[int] = Field(None, alias="score")
class PersonalContactData(BaseModel):
name: str = Field(..., description="Your name", alias="name")
email: EmailStr = Field(..., description="Your Name", alias="email")
phone: str = Field(..., description="Your Phone Number", alias="phone")
address: Optional[str] = Field(None, description="Your Address", alias="address")
zip: Optional[str] = Field(None, description="Postal Code", alias="zip")
city: Optional[str] = Field(None, description="City", alias="city")
country: Optional[str] = Field(None, description="Country", alias="country")
consent: bool = Field(..., description="Consent", alias="consent")
class SelectionSpecialistResult(SpecialistResult):
ko_criteria_scores: Optional[List[SelectionSpecialistKOCriteriumScore]] = Field(
None, alias="ko_criteria_scores"
)
competency_scores: Optional[List[SelectionSpecialistCompetencyScore]] = Field(
None, alias="competency_scores"
)
personal_contact_data: Optional[PersonalContactData] = Field(
None, alias="personal_contact_data"
)
class SelectionSpecialistFlowState(EveAIFlowState):
"""Flow state for Traicie Role Definition specialist that automatically updates from task outputs"""
input: Optional[SelectionSpecialistInput] = None
ko_criteria_scores: Optional[List[SelectionSpecialistKOCriteriumScore]] = Field(
None, alias="ko_criteria_scores"
)
competency_scores: Optional[List[SelectionSpecialistCompetencyScore]] = Field(
None, alias="competency_scores"
)
personal_contact_data: Optional[PersonalContactData] = Field(
None, alias="personal_contact_data"
)
phase: Optional[str] = Field(None, alias="phase")
interaction_mode: Optional[str] = Field(None, alias="mode")
class RoleDefinitionFlow(EveAICrewAIFlow[SelectionSpecialistFlowState]):
def __init__(self,
specialist_executor: CrewAIBaseSpecialistExecutor,
role_definition_crew: EveAICrewAICrew,
**kwargs):
super().__init__(specialist_executor, "Traicie Role Definition Specialist Flow", **kwargs)
self.specialist_executor = specialist_executor
self.role_definition_crew = role_definition_crew
self.exception_raised = False
@start()
def process_inputs(self):
return ""
@listen(process_inputs)
async def execute_role_definition (self):
inputs = self.state.input.model_dump()
try:
current_app.logger.debug("In execute_role_definition")
crew_output = await self.role_definition_crew.kickoff_async(inputs=inputs)
# Unfortunately, crew_output will only contain the output of the latest task.
# As we will only take into account the flow state, we need to ensure both competencies and criteria
# are copies to the flow state.
update = {}
for task in self.role_definition_crew.tasks:
current_app.logger.debug(f"Task {task.name} output:\n{task.output}")
if task.name == "traicie_get_competencies_task":
# update["competencies"] = task.output.pydantic.competencies
self.state.competencies = task.output.pydantic.competencies
# crew_output.pydantic = crew_output.pydantic.model_copy(update=update)
current_app.logger.debug(f"State after execute_role_definition: {self.state}")
current_app.logger.debug(f"State dump after execute_role_definition: {self.state.model_dump()}")
return crew_output
except Exception as e:
current_app.logger.error(f"CREW execute_role_definition Kickoff Error: {str(e)}")
self.exception_raised = True
raise e
async def kickoff_async(self, inputs=None):
current_app.logger.debug(f"Async kickoff {self.name}")
current_app.logger.debug(f"Inputs: {inputs}")
self.state.input = RoleDefinitionSpecialistInput.model_validate(inputs)
current_app.logger.debug(f"State: {self.state}")
result = await super().kickoff_async(inputs)
return self.state