15 Commits

Author SHA1 Message Date
Josako
babcd6ec04 Changelog update for 2.3.6-alfa 2025-06-16 11:10:59 +02:00
Josako
71adf64668 - Verbeterde versie Selectie Specialist - voor demo (1.2) 2025-06-16 11:06:20 +02:00
Josako
dbea41451a - Aanpassingen aan opbouw specialist historiek
- Nieuwe versie van de selectie specialist "Fake it till you Make it" ;-)
2025-06-15 18:31:13 +02:00
Josako
82e25b356c 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
2025-06-15 05:25:00 +02:00
Josako
3c7460f741 Form in ChatInput are displayed correctly! 2025-06-13 20:30:56 +02:00
Josako
2835486599 Eerste goed werkende versie van een formulier in de chat input. 2025-06-13 17:27:49 +02:00
Josako
f1c60f9574 tussentijdse status voor significante wijzigingen. Bezig aan creatie Dynamic Form in de chat client. 2025-06-13 14:19:05 +02:00
Josako
b326c0c6f2 Chat Client changes:
- maximum width for input and message history
- ensure good display for sidebar explanation
2025-06-13 00:56:22 +02:00
Josako
5f1a5711f6 - 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.
2025-06-12 18:21:51 +02:00
Josako
67ceb57b79 - Changelog to 2.3.5-alfa 2025-06-10 20:57:07 +02:00
Josako
23b49516cb - Create framework for chat-client, including logo, explanatory text, color settings, ...
- remove allowed_langages from tenant
- Correct bugs in Tenant, TenantMake, SpecialistMagicLink
- Change chat client customisation elements
2025-06-10 20:52:01 +02:00
Josako
9cc266b97f - Corrections to tenant, catalog, and tenant_make
- Clean-up of tenant elements
- ensure the chat_client get's it's initial call rifht.
2025-06-10 16:10:08 +02:00
Josako
3f77871c4f - Add a default make to the tenant
- Add a make to the SpecialistMagicLink
2025-06-09 18:13:38 +02:00
Josako
199cf94cf2 - Changed label for specialist_name to chatbot name ==> more logical
- Bug in unique name for catalogs
2025-06-09 16:06:41 +02:00
Josako
c4dcd6a0d3 - Add a new 'system' type to dynamic forms, first one defined = 'tenant_make'
- Add active field to Specialist model
- Improve Specialists view
- Propagate make for Role Definition Specialist to Selection Specialist (make is defined at the role level)
- Ensure a make with a given name can only be defined once
2025-06-09 11:06:36 +02:00
66 changed files with 6108 additions and 600 deletions

19
.aiignore Normal file
View File

@@ -0,0 +1,19 @@
# An .aiignore file follows the same syntax as a .gitignore file.
# .gitignore documentation: https://git-scm.com/docs/gitignore
# you can ignore files
.DS_Store
*.log
*.tmp
# or folders
dist/
build/
out/
nginx/node_modules/
nginx/static/
db_backups/
docker/eveai_logs/
docker/logs/
docker/minio/

4
.gitignore vendored
View File

@@ -53,3 +53,7 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc
/docker/grafana/data/
/temp_requirements/
/nginx/node_modules/
/nginx/static/assets/css/chat.css
/nginx/static/assets/css/chat-components.css
/nginx/static/assets/js/components/
/nginx/static/assets/js/chat-app.js

View File

@@ -0,0 +1,6 @@
<!-- chat.html -->
{% extends "base.html" %}
{% block title %}{{ tenant_make.name|default('EveAI') }} - AI Chat{% endblock %}
{% block head %}

View File

@@ -1,7 +1,7 @@
from sqlalchemy.dialects.postgresql import JSONB
from ..extensions import db
from .user import User, Tenant
from .user import User, Tenant, TenantMake
from .document import Embedding, Retriever
@@ -29,6 +29,7 @@ class Specialist(db.Model):
tuning = db.Column(db.Boolean, nullable=True, default=False)
configuration = db.Column(JSONB, nullable=True)
arguments = db.Column(JSONB, nullable=True)
active = db.Column(db.Boolean, nullable=True, default=True)
# Relationship to retrievers through the association table
retrievers = db.relationship('SpecialistRetriever', backref='specialist', lazy=True,
@@ -44,6 +45,21 @@ class Specialist(db.Model):
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey(User.id))
def __repr__(self):
return f"<Specialist {self.id}: {self.name}>"
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'type': self.type,
'type_version': self.type_version,
'configuration': self.configuration,
'arguments': self.arguments,
'active': self.active,
}
class EveAIAsset(db.Model):
id = db.Column(db.Integer, primary_key=True)
@@ -222,6 +238,7 @@ class SpecialistMagicLink(db.Model):
name = db.Column(db.String(50), nullable=False)
description = db.Column(db.Text, nullable=True)
specialist_id = db.Column(db.Integer, db.ForeignKey(Specialist.id, ondelete='CASCADE'), nullable=False)
tenant_make_id = db.Column(db.Integer, db.ForeignKey(TenantMake.id, ondelete='CASCADE'), nullable=True)
magic_link_code = db.Column(db.String(55), nullable=False, unique=True)
valid_from = db.Column(db.DateTime, nullable=True)
@@ -236,3 +253,14 @@ class SpecialistMagicLink(db.Model):
def __repr__(self):
return f"<SpecialistMagicLink {self.specialist_id} {self.magic_link_code}>"
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'magic_link_code': self.magic_link_code,
'valid_from': self.valid_from,
'valid_to': self.valid_to,
'specialist_args': self.specialist_args,
}

View File

@@ -28,17 +28,19 @@ class Tenant(db.Model):
# language information
default_language = db.Column(db.String(2), nullable=True)
allowed_languages = db.Column(ARRAY(sa.String(2)), nullable=True)
# Entitlements
currency = db.Column(db.String(20), nullable=True)
storage_dirty = db.Column(db.Boolean, nullable=True, default=False)
default_tenant_make_id = db.Column(db.Integer, db.ForeignKey('public.tenant_make.id'), nullable=True)
# Relations
users = db.relationship('User', backref='tenant')
domains = db.relationship('TenantDomain', backref='tenant')
licenses = db.relationship('License', back_populates='tenant')
license_usages = db.relationship('LicenseUsage', backref='tenant')
tenant_makes = db.relationship('TenantMake', backref='tenant', foreign_keys='TenantMake.tenant_id')
default_tenant_make = db.relationship('TenantMake', foreign_keys=[default_tenant_make_id], uselist=False)
@property
def current_license(self):
@@ -60,8 +62,8 @@ class Tenant(db.Model):
'timezone': self.timezone,
'type': self.type,
'default_language': self.default_language,
'allowed_languages': self.allowed_languages,
'currency': self.currency,
'default_tenant_make_id': self.default_tenant_make_id,
}
@@ -179,7 +181,7 @@ class TenantMake(db.Model):
id = db.Column(db.Integer, primary_key=True)
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
name = db.Column(db.String(50), nullable=False)
name = db.Column(db.String(50), nullable=False, unique=True)
description = db.Column(db.Text, nullable=True)
active = db.Column(db.Boolean, nullable=False, default=True)
website = db.Column(db.String(255), nullable=True)
@@ -194,6 +196,20 @@ class TenantMake(db.Model):
updated_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now(), onupdate=db.func.now())
updated_by = db.Column(db.Integer, db.ForeignKey('public.user.id'))
def __repr__(self):
return f"<TenantMake {self.id} for tenant {self.tenant_id}: {self.name}>"
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'description': self.description,
'active': self.active,
'website': self.website,
'logo_url': self.logo_url,
'chat_customisation_options': self.chat_customisation_options,
}
class Partner(db.Model):
__bind_key__ = 'public'

View File

@@ -220,3 +220,18 @@ class SpecialistServices:
db.session.add(tool)
current_app.logger.info(f"Created tool {tool.id} of type {tool_type}")
return tool
@staticmethod
def get_specialist_system_field(specialist_id, config_name, system_name):
"""Get the value of a system field in a specialist's configuration. Returns the actual value, or None."""
specialist = Specialist.query.get(specialist_id)
if not specialist:
raise ValueError(f"Specialist with ID {specialist_id} not found")
config = cache_manager.specialists_config_cache.get_config(specialist.type, specialist.type_version)
if not config:
raise ValueError(f"No configuration found for {specialist.type} version {specialist.version}")
potential_field = config.get(config_name, None)
if potential_field:
if potential_field.type == 'system' and potential_field.system_name == system_name:
return specialist.configuration.get(config_name, None)
return None

View File

@@ -7,7 +7,7 @@ from flask import current_app
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, \
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:
@@ -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:
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(CustomisationConfigTypesCacheHandler, '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.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.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.specialist_forms_config_cache.set_version_tree_cache(cache_manager.specialist_forms_version_tree_cache)

View File

@@ -5,11 +5,11 @@ Utility functions for chat customization.
def get_default_chat_customisation(tenant_customisation=None):
"""
Get chat customization options with default values for missing options.
Args:
tenant_customization (dict, optional): The tenant's customization options.
Defaults to None.
Returns:
dict: A dictionary containing all customization options with default values
for any missing options.
@@ -21,22 +21,25 @@ def get_default_chat_customisation(tenant_customisation=None):
'background_color': '#ffffff',
'text_color': '#212529',
'sidebar_color': '#f8f9fa',
'logo_url': None,
'sidebar_text': None,
'sidebar_background': '#2c3e50',
'gradient_start_color': '#f5f7fa',
'gradient_end_color': '#c3cfe2',
'markdown_background_color': 'transparent',
'markdown_text_color': '#ffffff',
'sidebar_markdown': '',
'welcome_message': 'Hello! How can I help you today?',
'team_info': []
}
# If no tenant customization is provided, return the defaults
if tenant_customisation is None:
return default_customisation
# Start with the default customization
customisation = default_customisation.copy()
# Update with tenant customization
for key, value in tenant_customisation.items():
if key in customisation:
customisation[key] = value
return customisation
return customisation

View File

@@ -1,196 +0,0 @@
from datetime import datetime as dt, timezone as tz
from typing import Optional, Dict, Any
from flask import current_app
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db, cache_manager
from common.models.interaction import (
Specialist, EveAIAgent, EveAITask, EveAITool
)
from common.utils.model_logging_utils import set_logging_information, update_logging_information
def initialize_specialist(specialist_id: int, specialist_type: str, specialist_version: str):
"""
Initialize an agentic specialist by creating all its components based on configuration.
Args:
specialist_id: ID of the specialist to initialize
specialist_type: Type of the specialist
specialist_version: Version of the specialist type to use
Raises:
ValueError: If specialist not found or invalid configuration
SQLAlchemyError: If database operations fail
"""
config = cache_manager.specialists_config_cache.get_config(specialist_type, specialist_version)
if not config:
raise ValueError(f"No configuration found for {specialist_type} version {specialist_version}")
if config['framework'] == 'langchain':
pass # Langchain does not require additional items to be initialized. All configuration is in the specialist.
specialist = Specialist.query.get(specialist_id)
if not specialist:
raise ValueError(f"Specialist with ID {specialist_id} not found")
if config['framework'] == 'crewai':
initialize_crewai_specialist(specialist, config)
def initialize_crewai_specialist(specialist: Specialist, config: Dict[str, Any]):
timestamp = dt.now(tz=tz.utc)
try:
# Initialize agents
if 'agents' in config:
for agent_config in config['agents']:
_create_agent(
specialist_id=specialist.id,
agent_type=agent_config['type'],
agent_version=agent_config['version'],
name=agent_config.get('name'),
description=agent_config.get('description'),
timestamp=timestamp
)
# Initialize tasks
if 'tasks' in config:
for task_config in config['tasks']:
_create_task(
specialist_id=specialist.id,
task_type=task_config['type'],
task_version=task_config['version'],
name=task_config.get('name'),
description=task_config.get('description'),
timestamp=timestamp
)
# Initialize tools
if 'tools' in config:
for tool_config in config['tools']:
_create_tool(
specialist_id=specialist.id,
tool_type=tool_config['type'],
tool_version=tool_config['version'],
name=tool_config.get('name'),
description=tool_config.get('description'),
timestamp=timestamp
)
db.session.commit()
current_app.logger.info(f"Successfully initialized crewai specialist {specialist.id}")
except SQLAlchemyError as e:
db.session.rollback()
current_app.logger.error(f"Database error initializing crewai specialist {specialist.id}: {str(e)}")
raise
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error initializing crewai specialist {specialist.id}: {str(e)}")
raise
def _create_agent(
specialist_id: int,
agent_type: str,
agent_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAIAgent:
"""Create an agent with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get agent configuration from cache
agent_config = cache_manager.agents_config_cache.get_config(agent_type, agent_version)
agent = EveAIAgent(
specialist_id=specialist_id,
name=name or agent_config.get('name', agent_type),
description=description or agent_config.get('metadata').get('description', ''),
type=agent_type,
type_version=agent_version,
role=None,
goal=None,
backstory=None,
tuning=False,
configuration=None,
arguments=None
)
set_logging_information(agent, timestamp)
db.session.add(agent)
current_app.logger.info(f"Created agent {agent.id} of type {agent_type}")
return agent
def _create_task(
specialist_id: int,
task_type: str,
task_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAITask:
"""Create a task with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get task configuration from cache
task_config = cache_manager.tasks_config_cache.get_config(task_type, task_version)
task = EveAITask(
specialist_id=specialist_id,
name=name or task_config.get('name', task_type),
description=description or task_config.get('metadata').get('description', ''),
type=task_type,
type_version=task_version,
task_description=None,
expected_output=None,
tuning=False,
configuration=None,
arguments=None,
context=None,
asynchronous=False,
)
set_logging_information(task, timestamp)
db.session.add(task)
current_app.logger.info(f"Created task {task.id} of type {task_type}")
return task
def _create_tool(
specialist_id: int,
tool_type: str,
tool_version: str,
name: Optional[str] = None,
description: Optional[str] = None,
timestamp: Optional[dt] = None
) -> EveAITool:
"""Create a tool with the given configuration."""
if timestamp is None:
timestamp = dt.now(tz=tz.utc)
# Get tool configuration from cache
tool_config = cache_manager.tools_config_cache.get_config(tool_type, tool_version)
tool = EveAITool(
specialist_id=specialist_id,
name=name or tool_config.get('name', tool_type),
description=description or tool_config.get('metadata').get('description', ''),
type=tool_type,
type_version=tool_version,
tuning=False,
configuration=None,
arguments=None,
)
set_logging_information(tool, timestamp)
db.session.add(tool)
current_app.logger.info(f"Created tool {tool.id} of type {tool_type}")
return tool

View File

@@ -67,7 +67,89 @@ class Config(object):
MAX_CONTENT_LENGTH = 50 * 1024 * 1024
# 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 = ['', '$']

View File

@@ -26,9 +26,34 @@ configuration:
description: "Sidebar Color"
type: "color"
required: false
"sidebar_text":
name: "Sidebar Text"
description: "Text to be shown in the sidebar"
"sidebar_background":
name: "Sidebar Background"
description: "Sidebar Background Color"
type: "color"
required: false
"markdown_background_color":
name: "Markdown Background"
description: "Markdown Background Color"
type: "color"
required: false
"markdown_text_color":
name: "Markdown Text"
description: "Markdown Text Color"
type: "color"
required: false
"gradient_start_color":
name: "Gradient Start Color"
description: "Start Color for the gradient in the Chat Area"
type: "color"
required: false
"gradient_end_color":
name: "Gradient End Color"
description: "End Color for the gradient in the Chat Area"
type: "color"
required: false
"sidebar_markdown":
name: "Sidebar Markdown"
description: "Sidebar Markdown-formatted Text"
type: "text"
required: false
"welcome_message":

View File

@@ -0,0 +1,46 @@
type: "PERSONAL_CONTACT_FORM"
version: "1.0.0"
name: "Personal Contact Form"
icon: "person"
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"
context: "Een kleine test om te zien of we context kunnen doorgeven en tonen"
required: true
address:
name: "Address"
type: "string"
description: "Your Address"
required: false
zip:
name: "Postal Code"
type: "string"
description: "Postal Code"
required: false
city:
name: "City"
type: "string"
description: "City"
required: false
country:
name: "Country"
type: "string"
description: "Country"
required: false
consent:
name: "Consent"
type: "boolean"
description: "Consent"
required: true

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

@@ -1,4 +1,4 @@
version: "1.1.0"
version: "1.2.0"
name: "Traicie Role Definition Specialist"
framework: "crewai"
partner: "traicie"
@@ -11,9 +11,9 @@ arguments:
type: "str"
required: true
specialist_name:
name: "Specialist Name"
description: "The name the specialist will be called upon"
type: str
name: "Chatbot Name"
description: "The name of the chatbot."
type: "str"
required: true
role_reference:
name: "Role Reference"

View File

@@ -0,0 +1,50 @@
version: "1.3.0"
name: "Traicie Role Definition Specialist"
framework: "crewai"
partner: "traicie"
chat: false
configuration: {}
arguments:
role_name:
name: "Role Name"
description: "The name of the role that is being processed. Will be used to create a selection specialist"
type: "str"
required: true
specialist_name:
name: "Chatbot Name"
description: "The name of the chatbot."
type: "str"
required: true
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
role_reference:
name: "Role Reference"
description: "A customer reference to the role"
type: "str"
required: false
vacancy_text:
name: "vacancy_text"
type: "text"
description: "The Vacancy Text"
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: "Added a make to be specified (a selection specialist now is created in context of a make"
description: "Assistant to create a new Vacancy based on Vacancy Text"

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

@@ -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

@@ -0,0 +1,30 @@
version: "1.0.0"
name: "KO Criteria Interview Definition"
task_description: >
In context of a vacancy in your company {tenant_name}, you are provided with a set of competencies. (both description
and title). The competencies are in between triple backquotes. You need to prepare for the interviews,
and are to provide for each of these ko criteria:
- A question to ask the recruitment candidate describing the context of the competency. Use your experience to not
just ask a closed question, but a question from which you can indirectly derive a positive or negative qualification of
the competency based on the answer of the candidate.
Apply the following tone of voice in both questions and answers: {tone_of_voice}
Apply the following language level in both questions and answers: {language_level}
Use {language} as language for both questions and answers.
```{competencies}```
{custom_description}
expected_output: >
For each of the ko criteria, you provide:
- the exact title in the original language
- the question
- a set of answers, with for each answer an indication if it is the correct answer, or a false response.
{custom_expected_output}
metadata:
author: "Josako"
date_added: "2025-06-15"
description: "A Task to define interview Q&A from given KO Criteria"
changes: "Initial Version"

View File

@@ -0,0 +1,34 @@
version: "1.0.0"
name: "KO Criteria Interview Definition"
task_description: >
In context of a vacancy in your company {tenant_name}, you are provided with a set of knock-out criteria
(both description and title). The criteria are in between triple backquotes.You need to prepare for the interviews,
and are to provide for each of these ko criteria:
- A question to ask the recruitment candidate describing the context of the ko criterium. Use your experience to not
just ask a closed question, but a question from which you can indirectly derive a positive or negative qualification
of the criterium based on the answer of the candidate.
- A set of max 5 answers on that question, from the candidates perspective. One of the answers will result in a
positive evaluation of the criterium, the other ones in a negative evaluation. Mark each of the answers as positive
or negative.
Describe the answers from the perspective of the candidate. Be sure to include all necessary aspects in you answers.
Apply the following tone of voice in both questions and answers: {tone_of_voice}
Apply the following language level in both questions and answers: {language_level}
Use {language} as language for both questions and answers.
```{ko_criteria}```
{custom_description}
expected_output: >
For each of the ko criteria, you provide:
- the exact title as specified in the original language
- the question in {language}
- a set of answers, with for each answer an indication if it is the correct answer, or a false response. In {language}.
{custom_expected_output}
metadata:
author: "Josako"
date_added: "2025-06-15"
description: "A Task to define interview Q&A from given KO Criteria"
changes: "Initial Version"

View File

@@ -0,0 +1,11 @@
# Specialist Form Types
SPECIALIST_FORM_TYPES = {
"PERSONAL_CONTACT_FORM": {
"name": "Personal Contact Form",
"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

@@ -5,6 +5,37 @@ All notable changes to EveAI will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.3.6-alfa]
### Added
- Full Chat Client functionaltiy, including Forms, ESS, theming
- First Demo version of Traicie Selection Specialist
## [2.3.5-alfa]
### Added
- Chat Client Initialisation (based on SpecialistMagicLink code)
- Definition of framework for the chat_client (using vue.js)
### Changed
- Remove AllowedLanguages from Tenant
- Remove Tenant URL (now in Make)
- Adapt chat client customisation options
### Fixed
- Several Bugfixes to administrative app
## [2.3.4-alfa]
### Added
- Introduction of Tenant Make
- Introduction of 'system' type for dynamic attributes
- Introduce Tenant Make to Traicie Specialists
### Changed
- Enable Specialist 'activation' / 'deactivation'
- Unique constraints introduced for Catalog Name (tenant level) and make name (public level)
## [2.3.3-alfa]
### Added

View File

@@ -115,15 +115,41 @@ echo "Set COMPOSE_FILE to $COMPOSE_FILE"
echo "Set EVEAI_VERSION to $VERSION"
echo "Set DOCKER_ACCOUNT to $DOCKER_ACCOUNT"
# Define aliases for common Docker commands
alias docker-compose="docker compose -f $COMPOSE_FILE"
alias dc="docker compose -f $COMPOSE_FILE"
alias dcup="docker compose -f $COMPOSE_FILE up -d --remove-orphans"
alias dcdown="docker compose -f $COMPOSE_FILE down"
alias dcps="docker compose -f $COMPOSE_FILE ps"
alias dclogs="docker compose -f $COMPOSE_FILE logs"
alias dcpull="docker compose -f $COMPOSE_FILE pull"
alias dcrefresh="docker compose -f $COMPOSE_FILE pull && docker compose -f $COMPOSE_FILE up -d --remove-orphans"
docker-compose() {
docker compose -f $COMPOSE_FILE "$@"
}
dc() {
docker compose -f $COMPOSE_FILE "$@"
}
dcup() {
docker compose -f $COMPOSE_FILE up -d --remove-orphans "$@"
}
dcdown() {
docker compose -f $COMPOSE_FILE down "$@"
}
dcps() {
docker compose -f $COMPOSE_FILE ps "$@"
}
dclogs() {
docker compose -f $COMPOSE_FILE logs "$@"
}
dcpull() {
docker compose -f $COMPOSE_FILE pull "$@"
}
dcrefresh() {
docker compose -f $COMPOSE_FILE pull && docker compose -f $COMPOSE_FILE up -d --remove-orphans "$@"
}
# Exporteer de functies zodat ze beschikbaar zijn in andere scripts
export -f docker-compose dc dcup dcdown dcps dclogs dcpull dcrefresh
echo "Docker environment switched to $ENVIRONMENT with version $VERSION"
echo "You can now use 'docker-compose', 'dc', 'dcup', 'dcdown', 'dcps', 'dclogs', 'dcpull' or 'dcrefresh' commands"

9
docker/rebuild_chat_client.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
source ./docker_env_switch.sh dev
source .env
dcdown eveai_chat_client nginx
./update_chat_client_statics.sh
./build_and_push_eveai.sh -b nginx
dcup eveai_chat_client nginx

View File

@@ -0,0 +1,49 @@
#!/bin/bash
# Script to copy eveai_chat_client/static files to nginx/static
# without overwriting existing files
SRC_DIR="../eveai_chat_client/static/assets"
DEST_DIR="../nginx/static/assets"
# Check if source directory exists
if [ ! -d "$SRC_DIR" ]; then
echo "Error: Source directory $SRC_DIR does not exist!"
exit 1
fi
# Create destination directory if it doesn't exist
if [ ! -d "$DEST_DIR" ]; then
echo "Destination directory $DEST_DIR does not exist. Creating it..."
mkdir -p "$DEST_DIR"
fi
# Function to recursively copy files without overwriting
copy_without_overwrite() {
local src=$1
local dest=$2
# Loop through all items in source directory
for item in "$src"/*; do
# Get the filename from the path
base_name=$(basename "$item")
# If it's a directory, create it in the destination and recurse
if [ -d "$item" ]; then
if [ ! -d "$dest/$base_name" ]; then
echo "Creating directory: $dest/$base_name"
mkdir -p "$dest/$base_name"
fi
copy_without_overwrite "$item" "$dest/$base_name"
else
# If it's a file and doesn't exist in the destination, copy it
cp "$item" "$dest/$base_name"
fi
done
}
# Start the copy process
echo "Starting to copy files from $SRC_DIR to $DEST_DIR..."
copy_without_overwrite "$SRC_DIR" "$DEST_DIR"
echo "Copy completed!"

View File

@@ -10,7 +10,7 @@
{% block content %}
<div class="container">
<form method="POST" action="{{ url_for('interaction_bp.handle_specialist_selection') }}" id="specialistsForm">
{{ render_selectable_table(headers=["Specialist ID", "Name", "Type"], rows=rows, selectable=True, id="specialistsTable") }}
{{ render_selectable_table(headers=["Specialist ID", "Name", "Type", "Type Version", "Active"], rows=rows, selectable=True, id="specialistsTable") }}
<div class="form-group mt-3 d-flex justify-content-between">
<div>
<button type="submit" name="action" value="edit_specialist" class="btn btn-primary" onclick="return validateTableSelection('specialistsForm')">Edit Specialist</button>

View File

@@ -8,7 +8,19 @@
{% endmacro %}
{% macro render_field_content(field, disabled=False, readonly=False, class='') %}
{% if field.type == 'BooleanField' %}
{# Check if this is a hidden input field, if so, render only the field without label #}
{{ debug_to_console("Field Class: ", field.widget.__class__.__name__) }}
{% if field.widget.__class__.__name__ == 'HiddenInput' %}
{{ debug_to_console("Hidden Field: ", "Detected") }}
{{ field(class="form-control " + class, disabled=disabled, readonly=readonly) }}
{% if field.errors %}
<div class="invalid-feedback d-block">
{% for error in field.errors %}
{{ error }}
{% endfor %}
</div>
{% endif %}
{% elif field.type == 'BooleanField' %}
<div class="form-group">
<div class="form-check form-switch">
{{ field(class="form-check-input " + class, disabled=disabled, readonly=readonly, required=False) }}

View File

@@ -1,4 +1,4 @@
from flask import session
from flask import session, current_app
from flask_security import current_user
from flask_wtf import FlaskForm
from wtforms import StringField, SelectField
@@ -36,7 +36,7 @@ class SessionDefaultsForm(FlaskForm):
else:
self.partner_name.data = ""
self.default_language.choices = [(lang, lang.lower()) for lang in
session.get('tenant').get('allowed_languages')]
current_app.config['SUPPORTED_LANGUAGES']]
self.default_language.data = session.get('default_language')
# Get a new session for catalog queries

View File

@@ -6,6 +6,7 @@ from wtforms.validators import DataRequired, Length, Optional, URL, ValidationEr
from flask_wtf.file import FileField, FileRequired
import json
from wtforms.widgets.core import HiddenInput
from wtforms_sqlalchemy.fields import QuerySelectField
from common.extensions import cache_manager
@@ -20,7 +21,7 @@ from .dynamic_form_base import DynamicFormBase
def validate_catalog_name(form, field):
# Controleer of een catalog met deze naam al bestaat
existing_catalog = Catalog.query.filter_by(name=field.data).first()
if existing_catalog:
if existing_catalog and (not hasattr(form, 'id') or form.id.data != existing_catalog.id):
raise ValidationError(f'A Catalog with name "{field.data}" already exists. Choose another name.')
@@ -48,6 +49,7 @@ class CatalogForm(FlaskForm):
class EditCatalogForm(DynamicFormBase):
id = IntegerField('ID', widget=HiddenInput())
name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_catalog_name])
description = TextAreaField('Description', validators=[Optional()])
@@ -188,7 +190,7 @@ class AddDocumentForm(DynamicFormBase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.language.choices = [(language, language) for language in
session.get('tenant').get('allowed_languages')]
current_app.config['SUPPORTED_LANGUAGES']]
if not self.language.data:
self.language.data = session.get('tenant').get('default_language')
@@ -208,7 +210,7 @@ class AddURLForm(DynamicFormBase):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.language.choices = [(language, language) for language in
session.get('tenant').get('allowed_languages')]
current_app.config['SUPPORTED_LANGUAGES']]
if not self.language.data:
self.language.data = session.get('tenant').get('default_language')

View File

@@ -3,12 +3,14 @@ from datetime import date
from flask_wtf import FlaskForm
from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField,
validators, ValidationError)
from flask import current_app, request
from flask import current_app, request, session
import json
from wtforms.fields.choices import SelectField
from wtforms.fields.datetime import DateField
from wtforms.fields.simple import ColorField
from common.models.user import TenantMake
from common.utils.config_field_types import TaggingFields, json_to_patterns, patterns_to_json
@@ -300,6 +302,22 @@ class DynamicFormBase(FlaskForm):
except Exception as e:
raise ValidationError(f"Invalid ordered list: {str(e)}")
def _get_system_field(self, system_name):
"""Get the field class and kwargs for a system field. Add system field cases as you need them."""
field_class = None
extra_classes = ''
field_kwargs = {}
match system_name:
case 'tenant_make':
field_class = SelectField
tenant_id = session.get('tenant').get('id')
makes = TenantMake.query.filter_by(tenant_id=tenant_id).all()
choices = [(make.name, make.name) for make in makes]
extra_classes = ''
field_kwargs = {'choices': choices}
return field_class, extra_classes, field_kwargs
def add_dynamic_fields(self, collection_name, config, initial_data=None):
"""Add dynamic fields to the form based on the configuration.
@@ -357,11 +375,12 @@ class DynamicFormBase(FlaskForm):
extra_classes = ['monospace-text', 'pattern-input']
field_kwargs = {}
elif field_type == 'ordered_list':
current_app.logger.debug(f"Adding ordered list field for {full_field_name}")
field_class = OrderedListField
extra_classes = ''
list_type = field_def.get('list_type', '')
field_kwargs = {'list_type': list_type}
elif field_type == 'system':
field_class, extra_classes, field_kwargs = self._get_system_field(field_def.get('system_name', ''))
else:
extra_classes = ''
field_class = {

View File

@@ -8,6 +8,7 @@ from wtforms_sqlalchemy.fields import QuerySelectMultipleField
from common.models.document import Retriever
from common.models.interaction import EveAITool, Specialist
from common.models.user import TenantMake
from common.extensions import cache_manager
from common.utils.form_assistants import validate_json
@@ -24,6 +25,7 @@ def get_tools():
class SpecialistForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()])
retrievers = QuerySelectMultipleField(
'Retrievers',
@@ -34,7 +36,7 @@ class SpecialistForm(FlaskForm):
)
type = SelectField('Specialist Type', validators=[DataRequired()])
active = BooleanField('Active', validators=[Optional()], default=True)
tuning = BooleanField('Enable Specialist Tuning', default=False)
def __init__(self, *args, **kwargs):
@@ -47,6 +49,7 @@ class SpecialistForm(FlaskForm):
class EditSpecialistForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired()])
description = TextAreaField('Description', validators=[Optional()])
active = BooleanField('Active', validators=[Optional()], default=True)
retrievers = QuerySelectMultipleField(
'Retrievers',
@@ -148,7 +151,7 @@ class SpecialistMagicLinkForm(FlaskForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
specialists = Specialist.query.all()
# Dynamically populate the 'type' field using the constructor
# Dynamically populate the specialist field
self.specialist_id.choices = [(specialist.id, specialist.name) for specialist in specialists]
@@ -159,6 +162,7 @@ class EditSpecialistMagicLinkForm(DynamicFormBase):
render_kw={'readonly': True})
specialist_id = IntegerField('Specialist', validators=[DataRequired()], render_kw={'readonly': True})
specialist_name = StringField('Specialist Name', validators=[DataRequired()], render_kw={'readonly': True})
tenant_make_id = SelectField('Tenant Make', validators=[Optional()], coerce=int)
valid_from = DateField('Valid From', id='form-control datepicker', validators=[Optional()])
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
@@ -174,5 +178,10 @@ class EditSpecialistMagicLinkForm(DynamicFormBase):
else:
self.specialist_name.data = ''
# Dynamically populate the tenant_make field with None as first option
tenant_makes = TenantMake.query.all()
self.tenant_make_id.choices = [(0, 'None')] + [(make.id, make.name) for make in tenant_makes]

View File

@@ -162,6 +162,7 @@ def specialist():
new_specialist.type = form.type.data
new_specialist.type_version = cache_manager.specialists_version_tree_cache.get_latest_version(
new_specialist.type)
new_specialist.active = form.active.data
new_specialist.tuning = form.tuning.data
set_logging_information(new_specialist, dt.now(tz.utc))
@@ -231,6 +232,7 @@ def edit_specialist(specialist_id):
specialist.name = form.name.data
specialist.description = form.description.data
specialist.tuning = form.tuning.data
specialist.active = form.active.data
# Update the configuration dynamic fields
specialist.configuration = form.get_dynamic_data("configuration")
@@ -297,7 +299,7 @@ def specialists():
# prepare table data
rows = prepare_table_for_macro(the_specialists,
[('id', ''), ('name', ''), ('type', '')])
[('id', ''), ('name', ''), ('type', ''), ('type_version', ''), ('active', ''),])
# Render the catalogs in a template
return render_template('interaction/specialists.html', rows=rows, pagination=pagination)
@@ -689,7 +691,7 @@ def specialist_magic_link():
try:
new_specialist_magic_link = SpecialistMagicLink()
# Populate fields individually instead of using populate_obj (gives problem with QueryMultipleSelectField)
# Populate fields individually instead of using populate_obj
form.populate_obj(new_specialist_magic_link)
set_logging_information(new_specialist_magic_link, dt.now(tz.utc))
@@ -699,6 +701,14 @@ def specialist_magic_link():
new_spec_ml_tenant.magic_link_code = new_specialist_magic_link.magic_link_code
new_spec_ml_tenant.tenant_id = tenant_id
# Define the make valid for this magic link
make_id = SpecialistServices.get_specialist_system_field(new_specialist_magic_link.specialist_id,
"make", "tenant_make")
if make_id:
new_spec_ml_tenant.tenant_make_id = make_id
elif session.get('tenant').get('default_tenant_make_id'):
new_spec_ml_tenant.tenant_make_id = session.get('tenant').get('default_tenant_make_id')
db.session.add(new_specialist_magic_link)
db.session.add(new_spec_ml_tenant)
@@ -731,12 +741,23 @@ def edit_specialist_magic_link(specialist_magic_link_id):
form.add_dynamic_fields("arguments", specialist_config, specialist_ml.specialist_args)
# Set the tenant_make_id default value
if request.method == 'GET':
if specialist_ml.tenant_make_id is None:
form.tenant_make_id.data = 0
else:
form.tenant_make_id.data = specialist_ml.tenant_make_id
if form.validate_on_submit():
# Update the basic fields
form.populate_obj(specialist_ml)
# Update the arguments dynamic fields
specialist_ml.specialist_args = form.get_dynamic_data("arguments")
# Handle the tenant_make_id special case (0 = None)
if form.tenant_make_id.data == 0:
specialist_ml.tenant_make_id = None
# Update logging information
update_logging_information(specialist_ml, dt.now(tz.utc))

View File

@@ -2,10 +2,12 @@ from flask import current_app, session
from flask_wtf import FlaskForm
from wtforms import (StringField, BooleanField, SubmitField, EmailField, IntegerField, DateField,
SelectField, SelectMultipleField, FieldList, FormField, TextAreaField)
from wtforms.validators import DataRequired, Length, Email, NumberRange, Optional
from wtforms.validators import DataRequired, Length, Email, NumberRange, Optional, ValidationError
import pytz
from flask_security import current_user
from wtforms.widgets.core import HiddenInput
from common.models.user import TenantMake
from common.services.user import UserServices
from config.type_defs.service_types import SERVICE_TYPES
from eveai_app.views.dynamic_form_base import DynamicFormBase
@@ -18,7 +20,6 @@ class TenantForm(FlaskForm):
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
# language fields
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
allowed_languages = SelectMultipleField('Allowed Languages', choices=[], validators=[DataRequired()])
# invoicing fields
currency = SelectField('Currency', choices=[], validators=[DataRequired()])
# Timezone
@@ -33,13 +34,56 @@ class TenantForm(FlaskForm):
super(TenantForm, self).__init__(*args, **kwargs)
# initialise language fields
self.default_language.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']]
self.allowed_languages.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']]
# initialise currency field
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
# initialise timezone
self.timezone.choices = [(tz, tz) for tz in pytz.common_timezones]
# Initialize fallback algorithms
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
# Initialize default tenant make choices
tenant_id = session.get('tenant', {}).get('id') if 'tenant' in session else None
# Show field only for Super Users with partner in session
if not current_user.has_roles('Super User') or 'partner' not in session:
self._fields.pop('assign_to_partner', None)
class EditTenantForm(FlaskForm):
id = IntegerField('ID', widget=HiddenInput())
name = StringField('Name', validators=[DataRequired(), Length(max=80)])
code = StringField('Code', validators=[DataRequired()], render_kw={'readonly': True})
type = SelectField('Tenant Type', validators=[Optional()], default='Active')
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
# language fields
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
# invoicing fields
currency = SelectField('Currency', choices=[], validators=[DataRequired()])
# Timezone
timezone = SelectField('Timezone', choices=[], validators=[DataRequired()])
# Default tenant make
default_tenant_make_id = SelectField('Default Tenant Make', choices=[], validators=[Optional()])
# For Super Users only - Allow to assign the tenant to the partner
assign_to_partner = BooleanField('Assign to Partner', default=False)
# Embedding variables
submit = SubmitField('Submit')
def __init__(self, *args, **kwargs):
super(EditTenantForm, self).__init__(*args, **kwargs)
# initialise language fields
self.default_language.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']]
# initialise currency field
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
# initialise timezone
self.timezone.choices = [(tz, tz) for tz in pytz.common_timezones]
# Initialize fallback algorithms
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
# Initialize default tenant make choices
tenant_id = self.id.data
if tenant_id:
tenant_makes = TenantMake.query.filter_by(tenant_id=tenant_id, active=True).all()
self.default_tenant_make_id.choices = [(str(make.id), make.name) for make in tenant_makes]
# Add empty choice
self.default_tenant_make_id.choices.insert(0, ('', 'Geen'))
# Show field only for Super Users with partner in session
if not current_user.has_roles('Super User') or 'partner' not in session:
self._fields.pop('assign_to_partner', None)
@@ -132,8 +176,30 @@ class EditTenantProjectForm(FlaskForm):
self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()]
def validate_make_name(form, field):
# Check if tenant_make already exists in the database
existing_make = TenantMake.query.filter_by(name=field.data).first()
if existing_make:
current_app.logger.debug(f'Existing make: {existing_make.id}')
current_app.logger.debug(f'Form has id: {hasattr(form, 'id')}')
if hasattr(form, 'id'):
current_app.logger.debug(f'Form has id: {form.id.data}')
if existing_make:
if not hasattr(form, 'id') or form.id.data != existing_make.id:
raise ValidationError(f'A Make with name "{field.data}" already exists. Choose another name.')
class TenantMakeForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_make_name])
description = TextAreaField('Description', validators=[Optional()])
active = BooleanField('Active', validators=[Optional()], default=True)
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
logo_url = StringField('Logo URL', validators=[Optional(), Length(max=255)])
class EditTenantMakeForm(DynamicFormBase):
id = IntegerField('ID', widget=HiddenInput())
name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_make_name])
description = TextAreaField('Description', validators=[Optional()])
active = BooleanField('Active', validators=[Optional()], default=True)
website = StringField('Website', validators=[DataRequired(), Length(max=255)])

View File

@@ -1,3 +1,4 @@
import json
import uuid
from datetime import datetime as dt, timezone as tz
from flask import request, redirect, flash, render_template, Blueprint, session, current_app
@@ -11,7 +12,7 @@ from common.utils.dynamic_field_utils import create_default_config_from_type_con
from common.utils.security_utils import send_confirmation_email, send_reset_email
from config.type_defs.service_types import SERVICE_TYPES
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
TenantProjectForm, EditTenantProjectForm, TenantMakeForm
TenantProjectForm, EditTenantProjectForm, TenantMakeForm, EditTenantForm, EditTenantMakeForm
from common.utils.database import Database
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
from common.utils.simple_encryption import generate_api_key
@@ -111,12 +112,18 @@ def tenant():
@roles_accepted('Super User', 'Partner Admin')
def edit_tenant(tenant_id):
tenant = Tenant.query.get_or_404(tenant_id) # This will return a 404 if no tenant is found
form = TenantForm(obj=tenant)
form = EditTenantForm(obj=tenant)
if form.validate_on_submit():
# Populate the tenant with form data
form.populate_obj(tenant)
# Convert default_tenant_make_id to integer if not empty
if form.default_tenant_make_id.data:
tenant.default_tenant_make_id = int(form.default_tenant_make_id.data)
else:
tenant.default_tenant_make_id = None
db.session.commit()
flash('Tenant updated successfully.', 'success')
if session.get('tenant'):
@@ -460,8 +467,18 @@ def edit_tenant_domain(tenant_domain_id):
def tenant_overview():
tenant_id = session['tenant']['id']
tenant = Tenant.query.get_or_404(tenant_id)
form = TenantForm(obj=tenant)
return render_template('user/tenant_overview.html', form=form)
form = EditTenantForm(obj=tenant)
# Zet de waarde van default_tenant_make_id
if tenant.default_tenant_make_id:
form.default_tenant_make_id.data = str(tenant.default_tenant_make_id)
# Haal de naam van de default make op als deze bestaat
default_make_name = None
if tenant.default_tenant_make:
default_make_name = tenant.default_tenant_make.name
return render_template('user/tenant_overview.html', form=form, default_make_name=default_make_name)
@user_bp.route('/tenant_project', methods=['GET', 'POST'])
@@ -627,15 +644,17 @@ def delete_tenant_project(tenant_project_id):
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tenant_make():
form = TenantMakeForm()
customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION")
default_customisation_options = create_default_config_from_type_config(customisation_config["configuration"])
form.add_dynamic_fields("configuration", customisation_config, default_customisation_options)
if form.validate_on_submit():
tenant_id = session['tenant']['id']
new_tenant_make = TenantMake()
form.populate_obj(new_tenant_make)
new_tenant_make.tenant_id = tenant_id
customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION")
new_tenant_make.chat_customisation_options = create_default_config_from_type_config(
customisation_config["configuration"])
form.add_dynamic_fields("configuration", customisation_config, new_tenant_make.chat_customisation_options)
customisation_options = form.get_dynamic_data("configuration")
new_tenant_make.chat_customisation_options = json.dumps(customisation_options)
set_logging_information(new_tenant_make, dt.now(tz.utc))
try:
@@ -660,7 +679,8 @@ def tenant_makes():
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
query = TenantMake.query.order_by(TenantMake.id)
tenant_id = session['tenant']['id']
query = TenantMake.query.filter_by(tenant_id=tenant_id).order_by(TenantMake.id)
pagination = query.paginate(page=page, per_page=per_page)
tenant_makes = pagination.items
@@ -681,7 +701,7 @@ def edit_tenant_make(tenant_make_id):
tenant_make = TenantMake.query.get_or_404(tenant_make_id)
# Create form instance with the tenant make
form = TenantMakeForm(request.form, obj=tenant_make)
form = EditTenantMakeForm(request.form, obj=tenant_make)
customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION")
form.add_dynamic_fields("configuration", customisation_config, tenant_make.chat_customisation_options)
@@ -724,6 +744,28 @@ def handle_tenant_make_selection():
if action == 'edit_tenant_make':
return redirect(prefixed_url_for('user_bp.edit_tenant_make', tenant_make_id=tenant_make_id))
elif action == 'set_as_default':
# Set this make as the default for the tenant
tenant_id = session['tenant']['id']
tenant = Tenant.query.get(tenant_id)
tenant.default_tenant_make_id = tenant_make_id
try:
db.session.commit()
flash(f'Default tenant make updated successfully.', 'success')
# Update session data if necessary
if 'tenant' in session:
session['tenant'] = tenant.to_dict()
return None
return None
except SQLAlchemyError as e:
db.session.rollback()
flash(f'Failed to update default tenant make. Error: {str(e)}', 'danger')
current_app.logger.error(f'Failed to update default tenant make. Error: {str(e)}')
return redirect(prefixed_url_for('user_bp.tenant_makes'))
return None
def reset_uniquifier(user):
security.datastore.set_uniquifier(user)

View File

@@ -1,6 +1,7 @@
import logging
import os
from flask import Flask, jsonify
from flask import Flask, jsonify, request
from werkzeug.middleware.proxy_fix import ProxyFix
import logging.config
@@ -74,6 +75,13 @@ def create_app(config_file=None):
app.logger.info(f"EveAI Chat Client Started Successfully (PID: {os.getpid()})")
app.logger.info("-------------------------------------------------------------------------------------------------")
# @app.before_request
# def app_before_request():
# app.logger.debug(f'App before request: {request.path} ===== Method: {request.method} =====')
# app.logger.debug(f'Full URL: {request.url}')
# app.logger.debug(f'Endpoint: {request.endpoint}')
return app

View File

@@ -0,0 +1,825 @@
/* Chat App Container Layout */
.chat-app-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
min-height: 0; /* Belangrijk voor flexbox overflow */
padding: 20px; /* Algemene padding voor alle kanten */
box-sizing: border-box;
}
/* Gemeenschappelijke container voor consistente breedte */
.chat-component-container {
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto;
display: flex;
flex-direction: column;
flex: 1; /* Neemt beschikbare verticale ruimte in */
}
/* Message Area - neemt alle beschikbare ruimte */
.chat-messages-area {
flex: 1; /* Neemt alle beschikbare ruimte */
overflow: hidden; /* Voorkomt dat het groter wordt dan container */
display: flex;
flex-direction: column;
min-height: 0; /* Belangrijk voor nested flexbox */
margin-bottom: 20px; /* Ruimte tussen messages en input */
border-radius: 15px;
background: rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
align-self: center; /* Extra centrering in flexbox context */
}
/* Chat Input - altijd onderaan */
.chat-input-area {
flex: none; /* Neemt alleen benodigde ruimte */
border-radius: 15px;
background: rgba(255,255,255,0.15);
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.2);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
z-index: 10;
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
align-self: center; /* Extra centrering in flexbox context */
}
/* Zorg dat de MessageHistory container ook flexbox gebruikt */
.message-history-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 20px; /* Interne padding voor MessageHistory */
box-sizing: border-box;
width: 100%;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding-right: 10px; /* Ruimte voor scrollbar */
margin-right: -10px; /* Compenseer voor scrollbar */
scroll-behavior: smooth;
}
/* Chat Input styling */
.chat-input-container {
width: 100%;
position: relative;
padding: 20px; /* Interne padding voor ChatInput */
box-sizing: border-box;
max-width: 1000px; /* Optimale breedte */
margin-left: auto;
margin-right: auto; /* Horizontaal centreren */
}
.chat-input {
display: flex;
align-items: flex-end;
gap: 12px;
padding: 20px;
background: white;
border-radius: 15px;
box-shadow: 0 2px 15px rgba(0,0,0,0.1);
border: 1px solid rgba(0,0,0,0.05);
}
.input-main {
flex: 1;
position: relative;
}
.message-input {
width: 100%;
min-height: 45px;
max-height: 120px;
padding: 12px 18px;
border: 1px solid #ddd;
border-radius: 25px;
resize: none;
font-family: inherit;
font-size: 14px;
line-height: 1.4;
outline: none;
transition: all 0.2s ease;
box-sizing: border-box;
}
.message-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.message-input.over-limit {
border-color: #dc3545;
background-color: rgba(220, 53, 69, 0.05);
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
}
.send-btn {
width: 45px;
height: 45px;
border: none;
border-radius: 50%;
background: var(--primary-color);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.2s ease;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.send-btn:hover:not(:disabled) {
background: var(--secondary-color);
transform: scale(1.05);
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.send-btn:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* Character counter */
.character-counter {
position: absolute;
bottom: -25px;
right: 15px;
font-size: 12px;
color: #666;
padding: 2px 6px;
background: rgba(255,255,255,0.9);
border-radius: 10px;
backdrop-filter: blur(5px);
}
.character-counter.over-limit {
color: #dc3545;
font-weight: bold;
background: rgba(220, 53, 69, 0.1);
}
/* Loading spinner */
.loading-spinner {
font-size: 16px;
animation: spin 1s linear infinite;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.chat-app-container {
padding: 10px; /* Kleinere padding op mobiel */
}
.chat-messages-area {
margin-bottom: 15px;
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
.chat-input-area {
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
.message-history-container {
padding: 15px;
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
.chat-input-container {
padding: 15px;
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
.chat-input {
padding: 15px;
gap: 10px;
}
.action-btn {
width: 40px;
height: 40px;
font-size: 16px;
}
.message-input {
font-size: 16px; /* Voorkomt zoom op iOS */
padding: 10px 15px;
min-height: 40px;
}
.chat-component-container {
max-width: 100%; /* Op mobiel volledige breedte gebruiken */
}
}
/* Extra small screens */
@media (max-width: 480px) {
.chat-app-container {
padding: 8px;
}
.chat-messages-area {
margin-bottom: 12px;
}
.message-history-container {
padding: 12px;
}
.chat-input-container {
padding: 12px;
}
}
/* Loading states */
.chat-input.loading .message-input {
opacity: 0.7;
}
.chat-input.loading .action-btn {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* Scrollbar styling voor webkit browsers */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: rgba(0,0,0,0.1);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.3);
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: rgba(0,0,0,0.5);
}
/* Verberg lege message bubbles tot er inhoud is */
.message-text:empty {
display: none;
}
.progress-tracker .status-icon.error {
color: #f44336;
}
.progress-tracker.error .progress-header {
background-color: rgba(244, 67, 54, 0.1);
border-color: #f44336;
}
/* Zorg dat de progress tracker goed wordt weergegeven in een lege message bubble */
.message-content:has(.message-text:empty) .message-progress {
margin-bottom: 0;
}
/* Verberg de message content container als er geen inhoud is en de verwerking bezig is */
.message-content:has(.message-text:empty):not(:has(.message-progress.completed)):not(:has(.message-progress.error)) {
background: transparent;
box-shadow: none;
border: none;
padding: 0;
margin: 0;
}
/* Focus binnen ChatInput voor toegankelijkheid */
.chat-input:focus-within {
box-shadow: 0 2px 20px rgba(0, 123, 255, 0.2);
border-color: rgba(0, 123, 255, 0.3);
}
/* Smooth transitions */
.chat-messages-area,
.chat-input-area {
transition: all 0.3s ease;
}
.chat-messages-area:hover,
.chat-input-area:hover {
box-shadow: 0 6px 25px rgba(0,0,0,0.15);
}
/* Message Bubbles Styling - Aangepast voor werkelijke template structuur */
/* Basis message container */
.message {
display: flex;
margin-bottom: 16px;
padding: 0 20px;
animation: messageSlideIn 0.3s ease-out;
clear: both;
}
/* User message alignment - rechts uitgelijnd */
.message.user {
justify-content: flex-end;
}
/* AI/Bot message alignment - links uitgelijnd */
.message.ai,
.message.bot {
justify-content: flex-start;
}
/* Message content wrapper - dit wordt de bubble */
.message-content {
max-width: 70%;
padding: 12px 16px;
border-radius: 18px;
word-wrap: break-word;
position: relative;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: all 0.2s ease;
display: inline-block;
}
/* User message bubble styling */
.message.user .message-content {
background: rgba(0, 0, 0, 0.1);
color: white;
border-bottom-right-radius: 4px;
}
/* AI/Bot message bubble styling */
.message.ai .message-content,
.message.bot .message-content {
background: rgba(255, 255, 255, 0.1);
color: #212529;
border-bottom-left-radius: 4px;
margin-right: 60px;
}
/* Message text content */
.message-text {
line-height: 1.4;
font-size: 14px;
margin-bottom: 6px;
}
.message-text p {
margin: 0;
}
.message-text p + p {
margin-top: 8px;
}
.btn-small {
padding: 4px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
}
/* Special message types */
/* Form messages */
.form-message {
justify-content: center;
margin: 20px 0;
}
.form-message .message-content {
max-width: 90%;
background: white;
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
/* System messages */
.system-message {
text-align: center;
background: rgba(108, 117, 125, 0.1);
color: #6c757d;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
margin: 10px auto;
max-width: 80%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.system-icon {
font-size: 14px;
}
/* Error messages */
.error-message {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 8px;
padding: 12px 16px;
margin: 10px auto;
max-width: 80%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.error-icon {
font-size: 16px;
color: #dc3545;
}
.retry-btn {
background: #dc3545;
color: white;
border: none;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s ease;
}
.retry-btn:hover {
background: #c82333;
}
/* Message reactions */
.message-reactions {
display: flex;
gap: 4px;
margin-top: 8px;
flex-wrap: wrap;
}
.reaction {
background: rgba(0,0,0,0.05);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 12px;
padding: 2px 8px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.reaction:hover {
background: rgba(0,0,0,0.1);
transform: scale(1.05);
}
/* Image and file messages */
.message-image {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: transform 0.2s ease;
}
.message-image:hover {
transform: scale(1.02);
}
.image-caption {
font-size: 13px;
margin-bottom: 6px;
opacity: 0.9;
}
.file-attachment {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(0,0,0,0.03);
border-radius: 8px;
margin-bottom: 8px;
}
.file-icon {
font-size: 24px;
}
.file-info {
flex: 1;
}
.file-name {
font-weight: 500;
margin-bottom: 2px;
}
.file-size {
font-size: 12px;
opacity: 0.7;
}
.file-download {
font-size: 20px;
text-decoration: none;
cursor: pointer;
transition: transform 0.2s ease;
}
.file-download:hover {
transform: scale(1.1);
}
/* Hover effects voor message bubbles */
.message-content:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
/* Bestaande animation en date-separator blijven hetzelfde */
@keyframes messageSlideIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Empty state styling - blijft hetzelfde */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #6c757d;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 18px;
font-weight: 500;
margin-bottom: 8px;
}
.empty-subtext {
font-size: 14px;
opacity: 0.8;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.message {
padding: 0 15px;
}
.message-content {
max-width: 85%;
padding: 10px 14px;
font-size: 14px;
}
.message.user .message-content {
margin-left: 40px;
}
.message.ai .message-content,
.message.bot .message-content {
margin-right: 40px;
}
}
@media (max-width: 480px) {
.message {
padding: 0 10px;
}
.message-content {
max-width: 90%;
margin-left: 20px !important;
margin-right: 20px !important;
}
}
/* Progress Tracker Styling */
.progress-tracker {
margin: 8px 0;
border-radius: 8px;
background: #f8f9fa;
overflow: hidden;
transition: all 0.3s ease;
font-size: 13px;
}
.progress-tracker.expanded {
max-height: 200px;
}
.progress-tracker.completed {
background: rgba(155, 255, 155, 0.1);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
cursor: pointer;
background: rgba(0,0,0,0.02);
border-bottom: 1px solid transparent;
transition: all 0.2s ease;
}
.progress-header:hover {
background: rgba(0,0,0,0.05);
}
.progress-tracker.expanded .progress-header {
border-bottom-color: #e9ecef;
}
.progress-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: #495057;
}
.status-icon {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
position: relative;
}
.status-icon.completed {
background: #28a745;
color: white;
font-size: 8px;
line-height: 12px;
text-align: center;
}
.status-icon.in-progress {
background: #007bff;
animation: pulse 1.5s infinite;
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #f3f3f3;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.progress-toggle {
color: #6c757d;
font-size: 14px;
transition: transform 0.2s ease;
}
.progress-tracker.expanded .progress-toggle {
transform: rotate(180deg);
}
.progress-error {
padding: 8px 12px;
color: #721c24;
background: #f8d7da;
border-top: 1px solid #f5c6cb;
font-size: 12px;
}
.progress-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.progress-tracker.expanded .progress-content {
max-height: 150px;
overflow-y: auto;
}
.progress-content.single-line {
max-height: 30px;
overflow: hidden;
padding: 8px 12px;
}
.progress-line {
padding: 4px 12px;
border-bottom: 1px solid rgba(0,0,0,0.05);
color: #6c757d;
line-height: 1.3;
}
.progress-line:last-child {
border-bottom: none;
}
/* Animaties */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* Integratie met message bubbles */
.message.ai .progress-tracker,
.message.bot .progress-tracker {
margin-bottom: 8px;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.progress-tracker {
font-size: 12px;
}
.progress-header {
padding: 6px 10px;
}
.progress-line {
padding: 3px 10px;
}
.progress-content.single-line {
padding: 6px 10px;
}
}

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

@@ -98,17 +98,9 @@ body {
border-bottom: 1px solid rgba(0,0,0,0.1);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing);
}
/* .chat-messages wordt nu gedefinieerd in chat-components.css */
.message {
margin-bottom: var(--spacing);
max-width: 80%;
clear: both;
}
/* .message wordt nu gedefinieerd in chat-components.css */
.user-message {
float: right;
@@ -118,11 +110,7 @@ body {
float: left;
}
.message-content {
padding: 12px 16px;
border-radius: var(--border-radius);
display: inline-block;
}
/* .message-content wordt nu gedefinieerd in chat-components.css */
.user-message .message-content {
background-color: var(--message-user-bg);
@@ -134,11 +122,7 @@ body {
color: var(--text-color);
}
.chat-input-container {
padding: var(--spacing);
border-top: 1px solid rgba(0,0,0,0.1);
display: flex;
}
/* .chat-input-container wordt nu gedefinieerd in chat-components.css */
#chat-input {
flex: 1;
@@ -150,44 +134,7 @@ body {
margin-right: 8px;
}
#send-button {
padding: 0 24px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
cursor: pointer;
}
/* Loading indicator */
.typing-indicator {
display: flex;
align-items: center;
}
.typing-indicator span {
height: 8px;
width: 8px;
background-color: rgba(0,0,0,0.3);
border-radius: 50%;
display: inline-block;
margin-right: 4px;
animation: typing 1.5s infinite ease-in-out;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0% { transform: scale(1); }
50% { transform: scale(1.5); }
100% { transform: scale(1); }
}
/* .typing-indicator en bijbehorende animaties worden nu gedefinieerd in chat-components.css */
/* Error page styles */
.error-container {
@@ -215,30 +162,6 @@ body {
margin-top: 1.5rem;
}
.btn-primary {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
text-decoration: none;
}
/* .btn-primary wordt nu gedefinieerd in chat-components.css */
/* Responsive design */
@media (max-width: 768px) {
.chat-container {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
max-height: 30%;
border-right: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
}
.message {
max-width: 90%;
}
}
/* Responsieve design regels worden nu gedefinieerd in chat-components.css */

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

@@ -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

@@ -0,0 +1,679 @@
// Import all components
import { TypingIndicator } from '/static/assets/js/components/TypingIndicator.js';
import { FormField } from '/static/assets/js/components/FormField.js';
import { DynamicForm } from '/static/assets/js/components/DynamicForm.js';
import { ChatMessage } from '/static/assets/js/components/ChatMessage.js';
import { MessageHistory } from '/static/assets/js/components/MessageHistory.js';
import { ProgressTracker } from '/static/assets/js/components/ProgressTracker.js';
// Maak componenten globaal beschikbaar voordat andere componenten worden geladen
window.DynamicForm = DynamicForm;
window.FormField = FormField;
window.TypingIndicator = TypingIndicator;
window.ChatMessage = ChatMessage;
window.MessageHistory = MessageHistory;
window.ProgressTracker = ProgressTracker;
// Nu kunnen we ChatInput importeren nadat de benodigde componenten globaal beschikbaar zijn
import { ChatInput } from '/static/assets/js/components/ChatInput.js';
// Main Chat Application
export const ChatApp = {
name: 'ChatApp',
components: {
TypingIndicator,
FormField,
DynamicForm,
ChatMessage,
MessageHistory,
ChatInput
},
data() {
// Maak een lokale kopie van de chatConfig om undefined errors te voorkomen
const chatConfig = window.chatConfig || {};
const settings = chatConfig.settings || {};
return {
// Base template data (keeping existing functionality)
explanation: chatConfig.explanation || '',
// Chat-specific data
currentMessage: '',
allMessages: [],
isTyping: false,
isLoading: false,
isSubmittingForm: false,
messageIdCounter: 1,
formValues: {},
currentInputFormData: null,
// API prefix voor endpoints
apiPrefix: chatConfig.apiPrefix || '',
// Configuration from Flask/server
conversationId: chatConfig.conversationId || 'default',
userId: chatConfig.userId || null,
userName: chatConfig.userName || '',
// Settings met standaard waarden en overschreven door server config
settings: {
maxMessageLength: settings.maxMessageLength || 2000,
allowFileUpload: settings.allowFileUpload === true,
allowVoiceMessage: settings.allowVoiceMessage === true,
autoScroll: settings.autoScroll === true
},
// UI state
isMobile: window.innerWidth <= 768,
showSidebar: window.innerWidth > 768,
// Advanced features
messageSearch: '',
filteredMessages: [],
isSearching: false
};
},
computed: {
// Keep existing computed from base.html
compiledExplanation() {
if (typeof marked === 'function') {
return marked(this.explanation);
} else if (marked && typeof marked.parse === 'function') {
return marked.parse(this.explanation);
} else {
console.error('Marked library not properly loaded');
return this.explanation;
}
},
displayMessages() {
return this.isSearching ? this.filteredMessages : this.allMessages;
},
hasMessages() {
return this.allMessages.length > 0;
}
},
mounted() {
this.initializeChat();
this.setupEventListeners();
},
beforeUnmount() {
this.cleanup();
},
methods: {
// Initialization
initializeChat() {
console.log('Initializing chat application...');
// Load historical messages from server
this.loadHistoricalMessages();
// Add welcome message if no history
if (this.allMessages.length === 0) {
this.addWelcomeMessage();
}
// Focus input after initialization
this.$nextTick(() => {
this.focusChatInput();
});
},
loadHistoricalMessages() {
// Veilige toegang tot messages met fallback
const chatConfig = window.chatConfig || {};
const historicalMessages = chatConfig.messages || [];
if (historicalMessages.length > 0) {
this.allMessages = historicalMessages.map(msg => {
// Zorg voor een correct geformatteerde bericht-object
return {
id: this.messageIdCounter++,
content: typeof msg === 'string' ? msg : msg.content || '',
sender: msg.sender || 'ai',
type: msg.type || 'text',
timestamp: msg.timestamp || new Date().toISOString(),
formData: msg.formData || null,
status: msg.status || 'delivered'
};
});
console.log(`Loaded ${this.allMessages.length} historical messages`);
}
},
addWelcomeMessage() {
this.addMessage(
'Hallo! Ik ben je AI assistant. Vraag gerust om een formulier zoals "contactformulier" of "bestelformulier"!',
'ai',
'text'
);
},
setupEventListeners() {
// Window resize listener
window.addEventListener('resize', this.handleResize);
// Keyboard shortcuts
document.addEventListener('keydown', this.handleGlobalKeydown);
},
cleanup() {
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('keydown', this.handleGlobalKeydown);
},
// Message management
addMessage(content, sender, type = 'text', formData = null, formValues = null) {
const message = {
id: this.messageIdCounter++,
content,
sender,
type,
formData,
formValues,
timestamp: new Date().toISOString(),
status: sender === 'user' ? 'sent' : 'delivered'
};
this.allMessages.push(message);
// Initialize form values if it's a form and no values were provided
if (type === 'form' && formData && !formValues) {
// Vue 3 compatibele manier om reactieve objecten bij te werken
this.formValues[message.id] = {};
formData.fields.forEach(field => {
const fieldName = field.name || field.id;
if (fieldName) {
this.formValues[message.id][fieldName] = field.defaultValue || '';
}
});
}
// Update search results if searching
if (this.isSearching) {
this.performSearch();
}
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) {
this.currentMessage = value;
},
// Message sending (alleen voor gewone tekstberichten, geen formulieren)
async sendMessage() {
const text = this.currentMessage.trim();
// Controleer of we kunnen verzenden
if (!text || this.isLoading) return;
console.log('Sending text message:', text);
// Add user message
const userMessage = this.addMessage(text, 'user', 'text');
// Wis input
this.currentMessage = '';
// Show typing and loading state
this.isTyping = true;
this.isLoading = true;
try {
// Verzamel gegevens voor de API call
const apiData = {
message: text,
conversation_id: this.conversationId,
user_id: this.userId
};
const response = await this.callAPI('/api/send_message', apiData);
// Hide typing indicator
this.isTyping = false;
// Mark user message as delivered
userMessage.status = 'delivered';
// Add AI response
if (response.type === 'form') {
this.addMessage('', 'ai', 'form', response.formData);
} else {
// Voeg het bericht toe met task_id voor tracking - initieel leeg
const aiMessage = this.addMessage(
'',
'ai',
'text'
);
// Voeg task_id toe als beschikbaar
if (response.task_id) {
console.log('Monitoring Task ID: ', response.task_id);
aiMessage.taskId = response.task_id;
}
}
} catch (error) {
console.error('Error sending message:', error);
this.isTyping = false;
// Mark user message as failed
userMessage.status = 'failed';
this.addMessage(
'Sorry, er ging iets mis bij het verzenden van je bericht. Probeer het opnieuw.',
'ai',
'error'
);
} finally {
this.isLoading = false;
}
},
async submitFormFromInput(formValues) {
this.isSubmittingForm = true;
if (!this.currentInputFormData) {
console.error('No form data available');
return;
}
console.log('Form values received:', formValues);
console.log('Current input form data:', this.currentInputFormData);
try {
// Maak een user message met formuliergegevens én eventuele tekst
const userMessage = this.addMessage(
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,
user_id: this.userId,
form_values: formValues // Voeg formuliergegevens toe aan API call
};
// Verstuur bericht naar de API
const response = await this.callAPI('/api/send_message', apiData);
// Verberg de typing indicator
this.isTyping = false;
// Markeer het gebruikersbericht als afgeleverd
userMessage.status = 'delivered';
// Voeg AI response toe met task_id voor tracking
const aiMessage = this.addMessage(
'',
'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) {
console.error('Error submitting form:', error);
this.addMessage(
'Sorry, er ging iets mis bij het verzenden van het formulier. Probeer het opnieuw.',
'ai',
'text'
);
// Wis ook hier het formulier na een fout
this.currentInputFormData = null;
} finally {
this.isSubmittingForm = false;
this.isLoading = false;
}
},
// Message actions
retryMessage(messageId) {
const message = this.allMessages.find(m => m.id === messageId);
if (message && message.status === 'failed') {
// Retry sending the message
this.currentMessage = message.content;
this.removeMessage(messageId);
this.sendMessage();
}
},
removeMessage(messageId) {
const index = this.allMessages.findIndex(m => m.id === messageId);
if (index !== -1) {
this.allMessages.splice(index, 1);
// Verwijder ook eventuele formuliergegevens
if (this.formValues[messageId]) {
delete this.formValues[messageId];
}
}
},
// File handling
async handleFileUpload(file) {
console.log('Uploading file:', file.name);
// Add file message
const fileMessage = this.addMessage('', 'user', 'file', {
fileName: file.name,
fileSize: this.formatFileSize(file.size),
fileType: file.type
});
try {
// TODO: Implement actual file upload
// const response = await this.uploadFile(file);
// fileMessage.fileUrl = response.url;
// Simulate file upload
setTimeout(() => {
fileMessage.fileUrl = URL.createObjectURL(file);
fileMessage.status = 'delivered';
}, 1000);
} catch (error) {
console.error('Error uploading file:', error);
fileMessage.status = 'failed';
}
},
async handleVoiceRecord(audioBlob) {
console.log('Processing voice recording');
// Add voice message
const voiceMessage = this.addMessage('', 'user', 'voice', {
audioBlob,
duration: '00:05' // TODO: Calculate actual duration
});
// TODO: Send to speech-to-text service
// const transcription = await this.transcribeAudio(audioBlob);
// this.currentMessage = transcription;
// this.sendMessage();
},
// Search functionality
performSearch() {
if (!this.messageSearch.trim()) {
this.isSearching = false;
this.filteredMessages = [];
return;
}
this.isSearching = true;
const query = this.messageSearch.toLowerCase();
this.filteredMessages = this.allMessages.filter(message =>
message.content &&
message.content.toLowerCase().includes(query)
);
},
clearSearch() {
this.messageSearch = '';
this.isSearching = false;
this.filteredMessages = [];
},
// Event handlers voor specialist events
handleSpecialistComplete(eventData) {
console.log('ChatApp received specialist-complete:', eventData);
// Als er een form_request is, toon deze in de ChatInput component
if (eventData.form_request) {
console.log('Setting form request in ChatInput:', eventData.form_request);
try {
// Converteer de form_request naar het verwachte formaat
const formData = this.convertFormRequest(eventData.form_request);
// Stel het formulier in als currentInputFormData in plaats van als bericht toe te voegen
if (formData && formData.title && formData.fields) {
this.currentInputFormData = formData;
} else {
console.error('Invalid form data after conversion:', formData);
}
} catch (err) {
console.error('Error processing form request:', err);
}
}
},
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);
if (!formRequest) {
console.error('Geen geldig formRequest ontvangen');
return null;
}
// Controleer of fields een object is voordat we converteren
let fieldsArray;
if (formRequest.fields && typeof formRequest.fields === 'object' && !Array.isArray(formRequest.fields)) {
// Converteer de fields van object naar array formaat
fieldsArray = Object.entries(formRequest.fields).map(([fieldId, fieldDef]) => ({
id: fieldId,
name: fieldDef.name || fieldId, // Gebruik fieldId als fallback
type: fieldDef.type || 'text', // Standaard naar text
description: fieldDef.description || '',
required: fieldDef.required || false,
default: fieldDef.default || '',
allowedValues: fieldDef.allowed_values || null,
context: fieldDef.context || null
}));
} else if (Array.isArray(formRequest.fields)) {
// Als het al een array is, zorg dat alle velden correct zijn
fieldsArray = formRequest.fields.map(field => ({
id: field.id || field.name,
name: field.name || field.id,
type: field.type || 'text',
description: field.description || '',
required: field.required || false,
default: field.default || field.defaultValue || '',
allowedValues: field.allowed_values || field.allowedValues || null,
context: field.context || null
}));
} else {
// Fallback naar lege array als er geen velden zijn
console.warn('Formulier heeft geen geldige velden');
fieldsArray = [];
}
return {
title: formRequest.name || formRequest.title || 'Formulier',
description: formRequest.description || '',
icon: formRequest.icon || 'form',
version: formRequest.version || '1.0',
fields: fieldsArray
};
},
// Event handlers
handleResize() {
this.isMobile = window.innerWidth <= 768;
this.showSidebar = window.innerWidth > 768;
},
handleGlobalKeydown(event) {
// Ctrl/Cmd + K for search
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
this.focusSearch();
}
// Escape to clear search
if (event.key === 'Escape' && this.isSearching) {
this.clearSearch();
}
},
// Utility methods
async callAPI(endpoint, data) {
// Gebruik de API prefix uit de lokale variabele
const fullEndpoint = this.apiPrefix + '/chat' + endpoint;
console.log('Calling API with prefix:', {
prefix: this.apiPrefix,
endpoint: endpoint,
fullEndpoint: fullEndpoint
});
const response = await fetch(fullEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
focusChatInput() {
this.$refs.chatInput?.focusInput();
},
focusSearch() {
this.$refs.searchInput?.focus();
},
},
template: `
<div class="chat-app-container">
<!-- Message History - takes available space -->
<message-history
:messages="displayMessages"
:is-typing="isTyping"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="true"
@specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="messageHistory"
class="chat-messages-area"
></message-history>
<!-- Chat Input - to the bottom -->
<chat-input
:current-message="currentMessage"
:is-loading="isLoading"
:max-length="2000"
:allow-file-upload="true"
:allow-voice-message="false"
:form-data="currentInputFormData"
@send-message="sendMessage"
@update-message="updateCurrentMessage"
@upload-file="handleFileUpload"
@record-voice="handleVoiceRecord"
@submit-form="submitFormFromInput"
ref="chatInput"
class="chat-input-area"
></chat-input>
</div>
`
};
// Zorg ervoor dat alle componenten correct geïnitialiseerd zijn voordat ze worden gebruikt
const initializeApp = () => {
console.log('Initializing Chat Application');
// ChatInput wordt pas op dit punt globaal beschikbaar gemaakt
// omdat het afhankelijk is van andere componenten
window.ChatInput = ChatInput;
// Get access to the existing Vue app instance
if (window.__vueApp) {
// Register ALL components globally
window.__vueApp.component('TypingIndicator', TypingIndicator);
window.__vueApp.component('FormField', FormField);
window.__vueApp.component('DynamicForm', DynamicForm);
window.__vueApp.component('ChatMessage', ChatMessage);
window.__vueApp.component('MessageHistory', MessageHistory);
window.__vueApp.component('ChatInput', ChatInput);
window.__vueApp.component('ProgressTracker', ProgressTracker);
console.log('All chat components registered with existing Vue instance');
// Register the ChatApp component
window.__vueApp.component('ChatApp', ChatApp);
console.log('ChatApp component registered with existing Vue instance');
// Mount the Vue app
window.__vueApp.mount('#app');
console.log('Vue app mounted with chat components');
} else {
console.error('No existing Vue instance found on window.__vueApp');
}
};
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', initializeApp);

View File

@@ -0,0 +1,337 @@
// static/js/components/ChatInput.js
// Importeer de IconManager (als module systeem wordt gebruikt)
// Anders moet je ervoor zorgen dat MaterialIconManager.js eerder wordt geladen
// en iconManager beschikbaar is via window.iconManager
// Voeg stylesheet toe voor ChatInput-specifieke stijlen
const addStylesheet = () => {
if (!document.querySelector('link[href*="chat-input.css"]')) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/static/assets/css/chat-input.css';
document.head.appendChild(link);
}
};
// Laad de stylesheet
addStylesheet();
export const ChatInput = {
name: 'ChatInput',
components: {
'dynamic-form': window.DynamicForm
},
created() {
// Als module systeem wordt gebruikt:
// import { iconManager } from './MaterialIconManager.js';
// Anders gebruiken we window.iconManager als het beschikbaar is:
if (window.iconManager && this.formData && this.formData.icon) {
window.iconManager.ensureIconsLoaded({}, [this.formData.icon]);
}
},
props: {
currentMessage: {
type: String,
default: ''
},
isLoading: {
type: Boolean,
default: false
},
placeholder: {
type: String,
default: 'Typ je bericht hier... (Enter om te verzenden, Shift+Enter voor nieuwe regel)'
},
maxLength: {
type: Number,
default: 2000
},
formData: {
type: Object,
default: null
},
},
emits: ['send-message', 'update-message', 'submit-form'],
watch: {
'formData.icon': {
handler(newIcon) {
if (newIcon && window.iconManager) {
window.iconManager.ensureIconsLoaded({}, [newIcon]);
}
},
immediate: true
},
formData: {
handler(newFormData, oldFormData) {
console.log('ChatInput formData changed:', newFormData);
if (!newFormData) {
console.log('FormData is null of undefined');
this.formValues = {};
return;
}
// Controleer of velden aanwezig zijn
if (!newFormData.fields) {
console.error('FormData bevat geen velden!', newFormData);
return;
}
console.log('Velden in formData:', newFormData.fields);
console.log('Aantal velden:', Array.isArray(newFormData.fields)
? newFormData.fields.length
: Object.keys(newFormData.fields).length);
// Initialiseer formulierwaarden
this.initFormValues();
// Log de geïnitialiseerde waarden
console.log('Formulierwaarden geïnitialiseerd:', this.formValues);
},
immediate: true,
deep: true
},
currentMessage(newVal) {
this.localMessage = newVal;
},
localMessage(newVal) {
this.$emit('update-message', newVal);
this.autoResize();
}
},
data() {
return {
localMessage: this.currentMessage,
formValues: {}
};
},
computed: {
characterCount() {
return this.localMessage.length;
},
isOverLimit() {
return this.characterCount > this.maxLength;
},
hasFormData() {
return this.formData && this.formData.fields &&
((Array.isArray(this.formData.fields) && this.formData.fields.length > 0) ||
(typeof this.formData.fields === 'object' && Object.keys(this.formData.fields).length > 0));
},
canSend() {
const hasValidForm = this.formData && this.validateForm();
const hasValidMessage = this.localMessage.trim() && !this.isOverLimit;
// We kunnen nu verzenden als er een geldig formulier OF een geldig bericht is
// Bij een formulier is aanvullende tekst optioneel
return (!this.isLoading) && (hasValidForm || hasValidMessage);
},
hasFormDataToSend() {
return this.formData && this.validateForm();
},
sendButtonText() {
if (this.isLoading) {
return 'Verzenden...';
}
return this.formData ? 'Verstuur formulier' : 'Verstuur bericht';
}
},
mounted() {
this.autoResize();
// Debug informatie over formData bij initialisatie
console.log('ChatInput mounted, formData:', this.formData);
if (this.formData) {
console.log('FormData bij mount:', JSON.stringify(this.formData));
}
},
methods: {
initFormValues() {
if (this.formData && this.formData.fields) {
console.log('Initializing form values for fields:', this.formData.fields);
this.formValues = {};
// Verwerk array van velden
if (Array.isArray(this.formData.fields)) {
this.formData.fields.forEach(field => {
const fieldId = field.id || field.name;
if (fieldId) {
this.formValues[fieldId] = field.default !== undefined ? field.default : '';
}
});
}
// Verwerk object van velden
else if (typeof this.formData.fields === 'object') {
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
this.formValues[fieldId] = field.default !== undefined ? field.default : '';
});
}
console.log('Initialized form values:', this.formValues);
}
},
handleKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.sendMessage();
} else if (event.key === 'Escape') {
this.localMessage = '';
}
},
sendMessage() {
if (!this.canSend) return;
// Bij een formulier gaan we het formulier en optioneel bericht combineren
if (this.formData) {
// Valideer het formulier
if (this.validateForm()) {
// Verstuur het formulier, eventueel met aanvullende tekst
this.$emit('submit-form', this.formValues);
}
} else if (this.localMessage.trim()) {
// Verstuur normaal bericht zonder formulier
this.$emit('send-message');
}
},
getFormValuesForSending() {
// Geeft de huidige formulierwaarden terug voor verzending
return this.formValues;
},
// Reset het formulier en de waarden
resetForm() {
this.formValues = {};
this.initFormValues();
},
// Annuleer het formulier (wordt momenteel niet gebruikt)
cancelForm() {
this.formValues = {};
// We sturen geen emit meer, maar het kan nuttig zijn om in de toekomst te hebben
},
validateForm() {
if (!this.formData || !this.formData.fields) return false;
// Controleer of alle verplichte velden zijn ingevuld
let missingFields = [];
if (Array.isArray(this.formData.fields)) {
missingFields = this.formData.fields.filter(field => {
if (!field.required) return false;
const fieldId = field.id || field.name;
const value = this.formValues[fieldId];
return value === undefined || value === null || (typeof value === 'string' && !value.trim());
});
} else {
// Voor object-gebaseerde velden
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
if (field.required) {
const value = this.formValues[fieldId];
if (value === undefined || value === null || (typeof value === 'string' && !value.trim())) {
missingFields.push(field);
}
}
});
}
return missingFields.length === 0;
},
autoResize() {
this.$nextTick(() => {
const textarea = this.$refs.messageInput;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
});
},
focusInput() {
this.$refs.messageInput?.focus();
},
clearInput() {
this.localMessage = '';
this.focusInput();
},
updateFormValues(newValues) {
// Controleer of er daadwerkelijk iets is veranderd om recursieve updates te voorkomen
if (JSON.stringify(newValues) !== JSON.stringify(this.formValues)) {
this.formValues = JSON.parse(JSON.stringify(newValues));
}
}
},
template: `
<div class="chat-input-container">
<!-- Dynamisch toevoegen van Material Symbols Outlined voor iconen -->
<div v-if="formData && formData.icon" class="material-icons-container">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0" />
</div>
<!-- Dynamisch formulier container -->
<div v-if="formData" class="dynamic-form-container">
<!-- De titel wordt in DynamicForm weergegeven en niet hier om dubbele titels te voorkomen -->
<div v-if="!formData.fields" style="color: red; padding: 10px;">Fout: Geen velden gevonden in formulier</div>
<dynamic-form
v-if="formData && formData.fields"
:form-data="formData"
:form-values="formValues"
:is-submitting="isLoading"
:hide-actions="true"
@update:form-values="updateFormValues"
></dynamic-form>
<!-- Geen extra knoppen meer onder het formulier, alles gaat via de hoofdverzendknop -->
</div>
<div class="chat-input">
<!-- Main input area -->
<div class="input-main">
<textarea
ref="messageInput"
v-model="localMessage"
@keydown="handleKeydown"
:placeholder="placeholder"
rows="1"
:disabled="isLoading"
:maxlength="maxLength"
class="message-input"
:class="{ 'over-limit': isOverLimit }"
></textarea>
<!-- Character counter -->
<div v-if="maxLength" class="character-counter" :class="{ 'over-limit': isOverLimit }">
{{ characterCount }}/{{ maxLength }}
</div>
</div>
<!-- Input actions -->
<div class="input-actions">
<!-- Universele verzendknop (voor zowel berichten als formulieren) -->
<button
@click="sendMessage"
class="send-btn"
:class="{ 'form-mode': formData }"
:disabled="!canSend"
:title="formData ? 'Verstuur formulier' : 'Verstuur bericht'"
>
<span v-if="isLoading" class="loading-spinner">⏳</span>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
</svg>
</button>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,318 @@
// Voeg stylesheets toe voor formulier en chat berichten weergave
const addStylesheets = () => {
// Formulier stylesheet
if (!document.querySelector('link[href*="form-message.css"]')) {
const formLink = document.createElement('link');
formLink.rel = 'stylesheet';
formLink.href = '/static/assets/css/form-message.css';
document.head.appendChild(formLink);
}
// Chat bericht stylesheet
if (!document.querySelector('link[href*="chat-message.css"]')) {
const chatLink = document.createElement('link');
chatLink.rel = 'stylesheet';
chatLink.href = '/static/assets/css/chat-message.css';
document.head.appendChild(chatLink);
}
// Material Icons font stylesheet
if (!document.querySelector('link[href*="Material+Symbols+Outlined"]')) {
const iconLink = document.createElement('link');
iconLink.rel = 'stylesheet';
iconLink.href = 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0';
document.head.appendChild(iconLink);
}
};
// Laad de stylesheets
addStylesheets();
export const ChatMessage = {
name: 'ChatMessage',
props: {
message: {
type: Object,
required: true,
validator: (message) => {
return message.id && message.content !== undefined && message.sender && message.type;
}
},
isSubmittingForm: {
type: Boolean,
default: false
},
apiPrefix: {
type: String,
default: ''
}
},
created() {
// Zorg ervoor dat het icoon geladen wordt als iconManager beschikbaar is
if (window.iconManager && this.message.formData && this.message.formData.icon) {
window.iconManager.loadIcon(this.message.formData.icon);
}
},
watch: {
'message.formData.icon': {
handler(newIcon) {
if (newIcon && window.iconManager) {
window.iconManager.loadIcon(newIcon);
}
},
immediate: true
}
},
emits: ['image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'],
data() {
return {
formVisible: true
};
},
computed: {
hasFormData() {
return this.message.formData &&
((Array.isArray(this.message.formData.fields) && this.message.formData.fields.length > 0) ||
(typeof this.message.formData.fields === 'object' && Object.keys(this.message.formData.fields).length > 0));
},
hasFormValues() {
return this.message.formValues && Object.keys(this.message.formValues).length > 0;
}
},
methods: {
handleSpecialistError(eventData) {
console.log('ChatMessage received specialist-error event:', eventData);
// Creëer een error message met correcte styling
this.message.type = 'error';
this.message.content = eventData.message || 'Er is een fout opgetreden bij het verwerken van uw verzoek.';
this.message.retryable = true;
this.message.error = true; // Voeg error flag toe voor styling
// Bubble up naar parent component voor verdere afhandeling
this.$emit('specialist-error', {
messageId: this.message.id,
...eventData
});
},
handleSpecialistComplete(eventData) {
console.log('ChatMessage received specialist-complete event:', eventData);
// Update de inhoud van het bericht met het antwoord
if (eventData.answer) {
console.log('Updating message content with answer:', eventData.answer);
this.message.content = eventData.answer;
} else {
console.error('No answer in specialist-complete event data');
}
// Bubble up naar parent component voor eventuele verdere afhandeling
this.$emit('specialist-complete', {
messageId: this.message.id,
answer: eventData.answer,
form_request: eventData.form_request, // Wordt nu door ChatApp verwerkt
result: eventData.result,
interactionId: eventData.interactionId,
taskId: eventData.taskId
});
},
formatMessage(content) {
if (!content) return '';
// Enhanced markdown-like formatting
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
.replace(/\n/g, '<br>');
},
removeMessage() {
// Dit zou een event moeten triggeren naar de parent component
},
reactToMessage(emoji) {
// Implementatie van reacties zou hier komen
},
getMessageClass() {
return `message ${this.message.sender}`;
}
},
template: `
<div :class="getMessageClass()" :data-message-id="message.id">
<!-- Normal text messages -->
<template v-if="message.type === 'text'">
<div class="message-content" style="width: 100%;">
<!-- Voortgangstracker voor AI berichten met task_id - NU BINNEN DE BUBBLE -->
<progress-tracker
v-if="message.sender === 'ai' && message.taskId"
:task-id="message.taskId"
:api-prefix="apiPrefix"
class="message-progress"
@specialist-complete="handleSpecialistComplete"
@specialist-error="handleSpecialistError"
></progress-tracker>
<!-- Form data display if available (alleen in user messages) -->
<div v-if="message.formValues && message.sender === 'user'" class="form-display user-form-values">
<dynamic-form
:form-data="message.formData"
:form-values="message.formValues"
:read-only="true"
hide-actions
class="message-form user-form"
></dynamic-form>
</div>
<!-- Formulier in AI berichten -->
<div v-if="message.formData && message.sender === 'ai'" class="form-display ai-form-values" style="margin-top: 15px;">
<!-- Dynamisch toevoegen van Material Symbols Outlined voor iconen -->
<table class="form-result-table">
<thead v-if="message.formData.title || message.formData.name || message.formData.icon">
<tr>
<th colspan="2">
<div class="form-header">
<span v-if="message.formData.icon" class="material-symbols-outlined">{{ message.formData.icon }}</span>
<span>{{ message.formData.title || message.formData.name }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(field, fieldId) in message.formData.fields" :key="fieldId">
<td class="field-label">{{ field.name }}:</td>
<td class="field-value">
<input
v-if="field.type === 'str' || field.type === 'string' || field.type === 'int' || field.type === 'integer' || field.type === 'float'"
:type="field.type === 'int' || field.type === 'integer' || field.type === 'float' ? 'number' : 'text'"
:placeholder="field.placeholder || ''"
class="form-input"
>
<textarea
v-else-if="field.type === 'text'"
:placeholder="field.placeholder || ''"
:rows="field.rows || 3"
class="form-textarea"
></textarea>
<select
v-else-if="field.type === 'enum'"
class="form-select"
>
<option value="">Selecteer een optie</option>
<option
v-for="option in (field.allowedValues || field.allowed_values || [])"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<div v-else-if="field.type === 'boolean'" class="toggle-switch">
<input
type="checkbox"
class="toggle-input"
>
<span class="toggle-slider">
<span class="toggle-knob"></span>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- View mode -->
<div>
<div
v-if="message.content"
v-html="formatMessage(message.content)"
class="message-text"
></div>
<!-- Debug info -->
<div v-if="false" class="debug-info">
Content: {{ message.content ? message.content.length + ' chars' : 'empty' }}
</div>
</div>
</div>
</template>
<!-- Image messages -->
<template v-if="message.type === 'image'">
<div class="message-content">
<img
:src="message.imageUrl"
:alt="message.alt || 'Afbeelding'"
class="message-image"
@load="$emit('image-loaded')"
>
<div v-if="message.caption" class="image-caption">
{{ message.caption }}
</div>
</div>
</template>
<!-- File messages -->
<template v-if="message.type === 'file'">
<div class="message-content">
<div class="file-attachment">
<div class="file-icon">📎</div>
<div class="file-info">
<div class="file-name">{{ message.fileName }}</div>
<div class="file-size">{{ message.fileSize }}</div>
</div>
<a
:href="message.fileUrl"
download
class="file-download"
title="Download"
>
⬇️
</a>
</div>
</div>
</template>
<!-- System messages -->
<template v-if="message.type === 'system'">
<div class="system-message">
<span class="system-icon"></span>
{{ message.content }}
</div>
</template>
<!-- Error messages -->
<template v-if="message.type === 'error'">
<div class="error-message">
<span class="error-icon">⚠️</span>
{{ message.content }}
<button
v-if="message.retryable"
@click="$emit('retry-message', message.id)"
class="retry-btn"
>
Probeer opnieuw
</button>
</div>
</template>
<!-- Message reactions -->
<div v-if="message.reactions && message.reactions.length" class="message-reactions">
<span
v-for="reaction in message.reactions"
:key="reaction.emoji"
class="reaction"
@click="reactToMessage(reaction.emoji)"
>
{{ reaction.emoji }} {{ reaction.count }}
</span>
</div>
</div>
`
};

View File

@@ -0,0 +1,250 @@
export const DynamicForm = {
name: 'DynamicForm',
created() {
// Zorg ervoor dat het icoon geladen wordt als iconManager beschikbaar is
if (window.iconManager && this.formData && this.formData.icon) {
window.iconManager.loadIcon(this.formData.icon);
}
},
props: {
formData: {
type: Object,
required: true,
validator: (formData) => {
// Controleer eerst of formData een geldig object is
if (!formData || typeof formData !== 'object') {
console.error('FormData is niet een geldig object');
return false;
}
// Controleer of er een titel of naam is
if (!formData.title && !formData.name) {
console.error('FormData heeft geen title of name');
return false;
}
// Controleer of er velden zijn
if (!formData.fields) {
console.error('FormData heeft geen fields eigenschap');
return false;
}
// Controleer of velden een array of object zijn
if (!Array.isArray(formData.fields) && typeof formData.fields !== 'object') {
console.error('FormData.fields is geen array of object');
return false;
}
console.log('FormData is geldig:', formData);
return true;
}
},
formValues: {
type: Object,
default: () => ({})
},
isSubmitting: {
type: Boolean,
default: false
},
readOnly: {
type: Boolean,
default: false
},
hideActions: {
type: Boolean,
default: false
}
},
emits: ['submit', 'cancel', 'update:formValues'],
data() {
return {
localFormValues: { ...this.formValues }
};
},
watch: {
formValues: {
handler(newValues) {
// Gebruik een vlag om recursieve updates te voorkomen
if (JSON.stringify(newValues) !== JSON.stringify(this.localFormValues)) {
this.localFormValues = JSON.parse(JSON.stringify(newValues));
}
},
deep: true
},
localFormValues: {
handler(newValues) {
// Gebruik een vlag om recursieve updates te voorkomen
if (JSON.stringify(newValues) !== JSON.stringify(this.formValues)) {
this.$emit('update:formValues', JSON.parse(JSON.stringify(newValues)));
}
},
deep: true
},
'formData.icon': {
handler(newIcon) {
if (newIcon && window.iconManager) {
window.iconManager.loadIcon(newIcon);
}
},
immediate: true
}
},
methods: {
handleSubmit() {
// Basic validation
const missingFields = [];
if (Array.isArray(this.formData.fields)) {
// Valideer array-gebaseerde velden
this.formData.fields.forEach(field => {
const fieldId = field.id || field.name;
if (field.required) {
const value = this.localFormValues[fieldId];
if (value === undefined || value === null ||
(typeof value === 'string' && !value.trim()) ||
(Array.isArray(value) && value.length === 0)) {
missingFields.push(field.name);
}
}
});
} else {
// Valideer object-gebaseerde velden
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
if (field.required) {
const value = this.localFormValues[fieldId];
if (value === undefined || value === null ||
(typeof value === 'string' && !value.trim()) ||
(Array.isArray(value) && value.length === 0)) {
missingFields.push(field.name);
}
}
});
};
if (missingFields.length > 0) {
const fieldNames = missingFields.join(', ');
alert(`De volgende velden zijn verplicht: ${fieldNames}`);
return;
}
this.$emit('submit', this.localFormValues);
},
handleCancel() {
this.$emit('cancel');
},
updateFieldValue(fieldId, value) {
this.localFormValues[fieldId] = value;
}
},
template: `
<div class="dynamic-form" :class="{ 'read-only': readOnly }">
<div class="form-header" v-if="formData.title || formData.name || formData.icon" style="margin-bottom: 20px; display: flex; align-items: center;">
<div class="form-icon" v-if="formData.icon" style="margin-right: 10px; display: flex; align-items: center;">
<span class="material-symbols-outlined" style="font-size: 24px; color: #4285f4;">{{ formData.icon }}</span>
</div>
<div>
<div class="form-title" style="font-weight: bold; font-size: 1.2em; color: #333;">{{ formData.title || formData.name }}</div>
<div v-if="formData.description" class="form-description" style="margin-top: 5px; color: #666; font-size: 0.9em;">{{ formData.description }}</div>
</div>
</div>
<div v-if="readOnly" class="form-readonly" style="display: grid; grid-template-columns: 35% 65%; gap: 8px; width: 100%;">
<!-- Array-based fields -->
<template v-if="Array.isArray(formData.fields)">
<template v-for="field in formData.fields" :key="field.id || field.name">
<div class="field-label" style="font-weight: 500; color: #555; padding: 4px 0;">{{ field.name }}:</div>
<div class="field-value" style="padding: 4px 0;">
<template v-if="field.type === 'enum' && (field.allowedValues || field.allowed_values)">
{{ localFormValues[field.id || field.name] || field.default || '-' }}
</template>
<template v-else-if="field.type === 'options' && (field.allowedValues || field.allowed_values)">
{{ localFormValues[field.id || field.name] || field.default || '-' }}
</template>
<template v-else-if="field.type === 'boolean'">
{{ localFormValues[field.id || field.name] ? 'Ja' : 'Nee' }}
</template>
<template v-else-if="field.type === 'text'">
<div class="text-value" style="white-space: pre-wrap;">{{ localFormValues[field.id || field.name] || '-' }}</div>
</template>
<template v-else>
{{ localFormValues[field.id || field.name] || field.default || '-' }}
</template>
</div>
</template>
</template>
<!-- Object-based fields -->
<template v-else>
<template v-for="(field, fieldId) in formData.fields" :key="fieldId">
<div class="field-label" style="font-weight: 500; color: #555; padding: 4px 0;">{{ field.name }}:</div>
<div class="field-value" style="padding: 4px 0;">
<template v-if="field.type === 'enum' && (field.allowedValues || field.allowed_values)">
{{ localFormValues[fieldId] || field.default || '-' }}
</template>
<template v-else-if="field.type === 'options' && (field.allowedValues || field.allowed_values)">
{{ localFormValues[fieldId] || field.default || '-' }}
</template>
<template v-else-if="field.type === 'boolean'">
{{ localFormValues[fieldId] ? 'Ja' : 'Nee' }}
</template>
<template v-else-if="field.type === 'text'">
<div class="text-value" style="white-space: pre-wrap;">{{ localFormValues[fieldId] || '-' }}</div>
</template>
<template v-else>
{{ localFormValues[fieldId] || field.default || '-' }}
</template>
</div>
</template>
</template>
</div>
<form v-else @submit.prevent="handleSubmit" novalidate>
<div class="form-fields" style="margin-top: 10px;">
<template v-if="Array.isArray(formData.fields)">
<form-field
v-for="field in formData.fields"
:key="field.id || field.name"
:field-id="field.id || field.name"
:field="field"
:model-value="localFormValues[field.id || field.name]"
@update:model-value="localFormValues[field.id || field.name] = $event"
></form-field>
</template>
<template v-else>
<form-field
v-for="(field, fieldId) in formData.fields"
:key="fieldId"
:field-id="fieldId"
:field="field"
:model-value="localFormValues[fieldId]"
@update:model-value="localFormValues[fieldId] = $event"
></form-field>
</template>
</div>
<div class="form-actions" v-if="!hideActions">
<button
type="submit"
class="btn btn-primary"
:disabled="isSubmitting"
:class="{ 'loading': isSubmitting }"
>
<span v-if="isSubmitting" class="spinner"></span>
{{ isSubmitting ? 'Verzenden...' : (formData.submitText || 'Versturen') }}
</button>
<button
type="button"
class="btn btn-secondary"
@click="handleCancel"
:disabled="isSubmitting"
>
{{ formData.cancelText || 'Annuleren' }}
</button>
</div>
</form>
</div>
`
};

View File

@@ -0,0 +1,213 @@
export const FormField = {
name: 'FormField',
props: {
field: {
type: Object,
required: true,
validator: (field) => {
return field.name && field.type;
}
},
fieldId: {
type: String,
required: true
},
modelValue: {
default: null
}
},
emits: ['update:modelValue'],
computed: {
value: {
get() {
// Gebruik default waarde als modelValue undefined is
if (this.modelValue === undefined || this.modelValue === null) {
if (this.field.type === 'boolean') {
return this.field.default === true;
}
return this.field.default !== undefined ? this.field.default : '';
}
return this.modelValue;
},
set(value) {
// Voorkom emit als de waarde niet is veranderd
if (JSON.stringify(value) !== JSON.stringify(this.modelValue)) {
this.$emit('update:modelValue', value);
}
}
},
fieldType() {
// Map Python types naar HTML input types
const typeMap = {
'str': 'text',
'string': 'text',
'int': 'number',
'integer': 'number',
'float': 'number',
'text': 'textarea',
'enum': 'select',
'options': 'radio',
'boolean': 'checkbox'
};
return typeMap[this.field.type] || this.field.type;
},
stepValue() {
return this.field.type === 'float' ? 'any' : 1;
},
description() {
return this.field.description || '';
}
},
methods: {
handleFileUpload(event) {
const file = event.target.files[0];
if (file) {
this.value = file;
}
}
},
template: `
<div class="form-field" style="margin-bottom: 15px; display: grid; grid-template-columns: 35% 65%; align-items: center;">
<!-- Label voor alle velden behalve boolean/checkbox die een speciale behandeling krijgen -->
<label v-if="fieldType !== 'checkbox'" :for="fieldId" style="margin-right: 10px; font-weight: 500;">
{{ field.name }}
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
</label>
<!-- Container voor input velden -->
<div style="width: 100%;">
<!-- Context informatie indien aanwezig -->
<div v-if="field.context" class="field-context" style="margin-bottom: 8px; font-size: 0.9em; color: #666; background-color: #f8f9fa; padding: 8px; border-radius: 4px; border-left: 3px solid #4285f4;">
{{ field.context }}
</div>
<!-- Tekstinvoer (string/str) -->
<input
v-if="fieldType === 'text'"
:id="fieldId"
type="text"
v-model="value"
:required="field.required"
:placeholder="field.placeholder || ''"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- Numerieke invoer (int/float) -->
<input
v-if="fieldType === 'number'"
:id="fieldId"
type="number"
v-model.number="value"
:required="field.required"
:step="stepValue"
:placeholder="field.placeholder || ''"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; box-sizing: border-box;"
>
<!-- Tekstvlak (text) -->
<textarea
v-if="fieldType === 'textarea'"
:id="fieldId"
v-model="value"
:required="field.required"
:rows="field.rows || 3"
:placeholder="field.placeholder || ''"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; resize: vertical; box-sizing: border-box;"
></textarea>
<!-- Dropdown (enum) -->
<select
v-if="fieldType === 'select'"
:id="fieldId"
v-model="value"
:required="field.required"
:title="description"
style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid #ddd; background-color: white; box-sizing: border-box;"
>
<option v-if="!field.required" value="">Selecteer een optie</option>
<option
v-for="option in (field.allowedValues || field.allowed_values || [])"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Debug info voor select field -->
<div v-if="fieldType === 'select' && (!(field.allowedValues || field.allowed_values) || (field.allowedValues || field.allowed_values).length === 0)"
style="color: #d93025; font-size: 0.85em; margin-top: 4px;">
Geen opties beschikbaar voor dit veld.
</div>
<!-- Radio buttons (options) -->
<div v-if="fieldType === 'radio'" class="radio-options">
<div v-for="option in (field.allowedValues || field.allowed_values || [])"
:key="option"
class="radio-option"
style="margin-bottom: 8px;">
<div style="display: flex; align-items: center;">
<input
type="radio"
:id="fieldId + '_' + option"
:name="fieldId"
:value="option"
v-model="value"
:required="field.required"
style="margin-right: 8px;"
>
<label :for="fieldId + '_' + option" style="cursor: pointer; margin-bottom: 0;">
{{ option }}
</label>
</div>
</div>
<!-- Debug info voor radio options -->
<div v-if="!(field.allowedValues || field.allowed_values) || (field.allowedValues || field.allowed_values).length === 0"
style="color: #d93025; font-size: 0.85em; margin-top: 4px;">
Geen opties beschikbaar voor dit veld.
</div>
</div>
</div>
<!-- Toggle switch voor boolean velden, met speciale layout voor deze velden -->
<div v-if="fieldType === 'checkbox'" style="grid-column: 1 / span 2;">
<!-- Context informatie indien aanwezig -->
<div v-if="field.context" class="field-context" style="margin-bottom: 8px; font-size: 0.9em; color: #666; background-color: #f8f9fa; padding: 8px; border-radius: 4px; border-left: 3px solid #4285f4;">
{{ field.context }}
</div>
<div style="display: flex; align-items: center;">
<div class="toggle-switch" style="position: relative; display: inline-block; width: 50px; height: 24px;">
<input
:id="fieldId"
type="checkbox"
v-model="value"
:required="field.required"
:title="description"
style="opacity: 0; width: 0; height: 0;"
>
<span
class="toggle-slider"
style="position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 24px;"
:style="{ backgroundColor: value ? '#4CAF50' : '#ccc' }"
@click="value = !value"
>
<span
class="toggle-knob"
style="position: absolute; content: ''; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%;"
:style="{ transform: value ? 'translateX(26px)' : 'translateX(0)' }"
></span>
</span>
</div>
<label :for="fieldId" class="checkbox-label" style="margin-left: 10px; cursor: pointer;">
{{ field.name }}
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
<span class="checkbox-description" style="display: block; font-size: 0.85em; color: #666;">
{{ field.description || '' }}
</span>
</label>
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,59 @@
// static/js/components/FormMessage.js
export const FormMessage = {
name: 'FormMessage',
props: {
formData: {
type: Object,
required: true
},
formValues: {
type: Object,
required: true
}
},
computed: {
hasFormData() {
return this.formData && this.formData.fields && Object.keys(this.formData.fields).length > 0;
},
formattedFields() {
if (!this.hasFormData) return [];
return Object.entries(this.formData.fields).map(([fieldId, field]) => {
let displayValue = this.formValues[fieldId] || '';
// Format different field types
if (field.type === 'boolean') {
displayValue = displayValue ? 'Ja' : 'Nee';
} else if (field.type === 'enum' && !displayValue && field.default) {
displayValue = field.default;
} else if (field.type === 'text') {
// Voor tekstgebieden, behoud witruimte
// De CSS zal dit tonen met white-space: pre-wrap
}
return {
id: fieldId,
name: field.name,
value: displayValue || '-',
type: field.type
};
});
}
},
template: `
<div v-if="hasFormData" class="form-message">
<div v-if="formData.name" class="form-message-header">
<i v-if="formData.icon" class="material-icons form-message-icon">{{ formData.icon }}</i>
<span class="form-message-title">{{ formData.name }}</span>
</div>
<div class="form-message-fields">
<div v-for="field in formattedFields" :key="field.id" class="form-message-field">
<div class="field-message-label">{{ field.name }}:</div>
<div class="field-message-value" :class="{'text-value': field.type === 'text'}">{{ field.value }}</div>
</div>
</div>
</div>
`
};

View File

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

View File

@@ -0,0 +1,139 @@
export const MessageHistory = {
name: 'MessageHistory',
props: {
messages: {
type: Array,
required: true,
default: () => []
},
isTyping: {
type: Boolean,
default: false
},
isSubmittingForm: {
type: Boolean,
default: false
},
apiPrefix: {
type: String,
default: ''
},
autoScroll: {
type: Boolean,
default: true
}
},
emits: ['submit-form', 'load-more', 'specialist-complete', 'specialist-error'],
data() {
return {
isAtBottom: true,
unreadCount: 0
};
},
mounted() {
this.scrollToBottom();
this.setupScrollListener();
},
updated() {
if (this.autoScroll && this.isAtBottom) {
this.$nextTick(() => this.scrollToBottom());
}
},
methods: {
scrollToBottom() {
const container = this.$refs.messagesContainer;
if (container) {
container.scrollTop = container.scrollHeight;
this.isAtBottom = true;
this.showScrollButton = false;
this.unreadCount = 0;
}
},
setupScrollListener() {
const container = this.$refs.messagesContainer;
if (!container) return;
container.addEventListener('scroll', this.handleScroll);
},
handleScroll() {
const container = this.$refs.messagesContainer;
if (!container) return;
const threshold = 100; // pixels from bottom
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
this.isAtBottom = isNearBottom;
// Load more messages when scrolled to top
if (container.scrollTop === 0) {
this.$emit('load-more');
}
},
handleImageLoaded() {
// Auto-scroll when images load to maintain position
if (this.isAtBottom) {
this.$nextTick(() => this.scrollToBottom());
}
},
searchMessages(query) {
// Simple message search
if (!query.trim()) return this.messages;
const searchTerm = query.toLowerCase();
return this.messages.filter(message =>
message.content &&
message.content.toLowerCase().includes(searchTerm)
);
},
},
beforeUnmount() {
// Cleanup scroll listener
const container = this.$refs.messagesContainer;
if (container) {
container.removeEventListener('scroll', this.handleScroll);
}
},
template: `
<div class="message-history-container">
<!-- Messages container -->
<div class="chat-messages" ref="messagesContainer">
<!-- Loading indicator for load more -->
<div v-if="$slots.loading" class="load-more-indicator">
<slot name="loading"></slot>
</div>
<!-- Empty state -->
<div v-if="messages.length === 0" class="empty-state">
<div class="empty-icon">💬</div>
<div class="empty-text">Nog geen berichten</div>
<div class="empty-subtext">Start een gesprek door een bericht te typen!</div>
</div>
<!-- Message list -->
<template v-else>
<!-- Messages -->
<template v-for="(message, index) in messages" :key="message.id">
<!-- The actual message -->
<chat-message
:message="message"
:is-submitting-form="isSubmittingForm"
:api-prefix="apiPrefix"
@image-loaded="handleImageLoaded"
@specialist-complete="$emit('specialist-complete', $event)"
@specialist-error="$emit('specialist-error', $event)"
></chat-message>
</template>
</template>
<!-- Typing indicator -->
<typing-indicator v-if="isTyping"></typing-indicator>
</div>
</div>
`,
};

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
// static/js/iconManager.js
/**
* Een eenvoudige standalone icon manager voor Material Symbols Outlined
* Deze kan direct worden gebruikt zonder Vue
*/
window.iconManager = {
loadedIcons: [],
/**
* Laadt een Material Symbols Outlined icoon als het nog niet is geladen
* @param {string} iconName - Naam van het icoon
* @param {Object} options - Opties voor het icoon (opsz, wght, FILL, GRAD)
*/
loadIcon: function(iconName, options = {}) {
if (!iconName) return;
if (this.loadedIcons.includes(iconName)) {
return; // Icoon is al geladen
}
const defaultOptions = {
opsz: 24,
wght: 400,
FILL: 0,
GRAD: 0
};
const opts = { ...defaultOptions, ...options };
// Genereer unieke ID voor het stylesheet element
const styleId = `material-symbols-${iconName}`;
// Controleer of het stylesheet al bestaat
if (!document.getElementById(styleId)) {
const link = document.createElement('link');
link.id = styleId;
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@${opts.opsz},${opts.wght},${opts.FILL},${opts.GRAD}&icon_names=${iconName}`;
document.head.appendChild(link);
console.log(`Material Symbol geladen: ${iconName}`);
this.loadedIcons.push(iconName);
}
},
/**
* Laadt een set van Material Symbols Outlined iconen
* @param {Array} iconNames - Array met icoonnamen
* @param {Object} options - Opties voor de iconen
*/
loadIcons: function(iconNames, options = {}) {
if (!iconNames || !Array.isArray(iconNames) || iconNames.length === 0) {
return;
}
// Filter alleen iconen die nog niet zijn geladen
const newIcons = iconNames.filter(icon => !this.loadedIcons.includes(icon));
if (newIcons.length === 0) {
return; // Alle iconen zijn al geladen
}
const defaultOptions = {
opsz: 24,
wght: 400,
FILL: 0,
GRAD: 0
};
const opts = { ...defaultOptions, ...options };
// Genereer unieke ID voor het stylesheet element
const styleId = `material-symbols-set-${newIcons.join('-')}`;
// Controleer of het stylesheet al bestaat
if (!document.getElementById(styleId)) {
const link = document.createElement('link');
link.id = styleId;
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@${opts.opsz},${opts.wght},${opts.FILL},${opts.GRAD}&icon_names=${newIcons.join(',')}`;
document.head.appendChild(link);
console.log(`Material Symbols geladen: ${newIcons.join(', ')}`);
// Voeg de nieuwe iconen toe aan de geladen lijst
this.loadedIcons.push(...newIcons);
}
}
};
// Functie om iconManager toe te voegen aan het DynamicForm component
function initDynamicFormWithIcons() {
if (window.DynamicForm) {
const originalCreated = window.DynamicForm.created || function() {};
window.DynamicForm.created = function() {
// Roep de oorspronkelijke created methode aan als die bestond
originalCreated.call(this);
// Laad het icoon als het beschikbaar is
if (this.formData && this.formData.icon) {
window.iconManager.loadIcon(this.formData.icon);
}
};
// Voeg watcher toe voor formData.icon
if (!window.DynamicForm.watch) {
window.DynamicForm.watch = {};
}
window.DynamicForm.watch['formData.icon'] = {
handler: function(newIcon) {
if (newIcon) {
window.iconManager.loadIcon(newIcon);
}
},
immediate: true
};
console.log('DynamicForm is uitgebreid met iconManager functionaliteit');
} else {
console.warn('DynamicForm component is niet beschikbaar. iconManager kan niet worden toegevoegd.');
}
}
// Probeer het DynamicForm component te initialiseren zodra het document geladen is
document.addEventListener('DOMContentLoaded', function() {
// Wacht een korte tijd om er zeker van te zijn dat DynamicForm is geladen
setTimeout(initDynamicFormWithIcons, 100);
});
// Als DynamicForm al beschikbaar is, initialiseer direct
if (window.DynamicForm) {
initDynamicFormWithIcons();
}

View File

@@ -4,28 +4,173 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}EveAI Chat{% endblock %}</title>
<!-- CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/chat.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/chat.css') }}">
<!-- Vue.js -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Markdown parser for explanation text -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Custom theme colors from tenant settings -->
<style>
:root {
--primary-color: {{ customization.primary_color|default('#007bff') }};
--secondary-color: {{ customization.secondary_color|default('#6c757d') }};
--background-color: {{ customization.background_color|default('#ffffff') }};
--text-color: {{ customization.text_color|default('#212529') }};
--sidebar-color: {{ customization.sidebar_color|default('#f8f9fa') }};
--primary-color: {{ customisation.primary_color|default('#007bff') }};
--secondary-color: {{ customisation.secondary_color|default('#6c757d') }};
--background-color: {{ customisation.background_color|default('#ffffff') }};
--text-color: {{ customisation.text_color|default('#212529') }};
--sidebar-color: {{ customisation.sidebar_color|default('#f8f9fa') }};
--sidebar-background: {{ customisation.sidebar_background|default('#2c3e50') }};
--gradient-start-color: {{ customisation.gradient_start_color|default('#f5f7fa') }};
--gradient-end-color: {{ customisation.gradient_end_color|default('#c3cfe2') }};
--markdown-background-color: {{ customisation.markdown_background_color|default('transparent') }};
--markdown-text-color: {{ customisation.markdown_text_color|default('#ffffff') }};
}
body, html {
margin: 0;
padding: 0;
height: 100%;
font-family: Arial, sans-serif;
}
.app-container {
display: flex;
height: 100vh;
width: 100%;
}
.sidebar {
width: 300px;
background-color: var(--sidebar-background);
color: white;
padding: 20px;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-logo {
text-align: center;
margin-bottom: 20px;
}
.sidebar-logo img {
max-width: 100%;
max-height: 100px;
}
.sidebar-make-name {
font-size: 24px;
font-weight: bold;
margin-bottom: 20px;
text-align: center;
}
.sidebar-explanation {
margin-top: 20px;
overflow-y: auto;
background-color: var(--markdown-background-color);
color: var(--markdown-text-color);
padding: 10px;
border-radius: 5px;
}
/* Ensure all elements in the markdown content inherit the text color */
.sidebar-explanation * {
color: inherit;
}
/* Style links in the markdown content */
.sidebar-explanation a {
color: var(--primary-color);
text-decoration: underline;
}
/* Style lists in markdown content */
.sidebar-explanation ul,
.sidebar-explanation ol {
padding-left: 20px;
margin: 10px 0;
}
.sidebar-explanation li {
margin-bottom: 5px;
}
.sidebar-explanation ul li {
list-style-type: disc;
}
.sidebar-explanation ol li {
list-style-type: decimal;
}
.content-area {
flex: 1;
background: linear-gradient(135deg, var(--gradient-start-color), var(--gradient-end-color));
overflow-y: auto;
display: flex;
flex-direction: column;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
</style>
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
<div id="app" class="app-container">
<!-- Left sidebar - never changes -->
<div class="sidebar">
<div class="sidebar-logo">
<img src="{{ tenant_make.logo_url|default('') }}" alt="{{ tenant_make.name|default('Logo') }}">
</div>
<div class="sidebar-make-name">
{{ tenant_make.name|default('') }}
</div>
<div class="sidebar-explanation" v-html="compiledExplanation"></div>
</div>
<!-- Right content area - contains the chat client -->
<div class="content-area">
<div class="chat-container">
{% block content %}{% endblock %}
</div>
</div>
</div>
<script>
// Create Vue app and make it available globally
window.__vueApp = Vue.createApp({
data() {
return {
explanation: `{{ customisation.sidebar_markdown|default('') }}`
}
},
computed: {
compiledExplanation: function() {
// Handle different versions of the marked library
if (typeof marked === 'function') {
return marked(this.explanation);
} else if (marked && typeof marked.parse === 'function') {
return marked.parse(this.explanation);
} else {
console.error('Marked library not properly loaded');
return this.explanation; // Fallback to raw text
}
}
}
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>
</html>

View File

@@ -1,214 +1,43 @@
<!-- chat.html - Clean componentized template -->
{% extends "base.html" %}
{% block title %}Chat{% endblock %}
{% block title %}{{ tenant_make.name|default('EveAI') }} - AI Chat{% endblock %}
{% block head %}
<!-- Chat specific CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='assets/css/chat-components.css') }}">
<!-- Pass server data to JavaScript -->
<script>
// Definieer chatConfig voordat componenten worden geladen
window.chatConfig = {
explanation: `{{ customisation.sidebar_markdown|default('') }}`,
conversationId: '{{ conversation_id|default("default") }}',
messages: {{ messages|tojson|safe }},
settings: {
maxMessageLength: {{ settings.max_message_length|default(2000) }},
allowFileUpload: {{ settings.allow_file_upload|default('true')|lower }},
allowVoiceMessage: {{ settings.allow_voice_message|default('false')|lower }},
autoScroll: {{ settings.auto_scroll|default('true')|lower }},
allowReactions: {{ settings.allow_reactions|default('true')|lower }}
},
apiPrefix: '{{ request.headers.get("X-Forwarded-Prefix", "") }}'
};
// Debug info om te controleren of chatConfig correct is ingesteld
console.log('Chat configuration initialized:', window.chatConfig);
</script>
{% endblock %}
{% block content %}
<div class="chat-container">
<!-- Left sidebar with customizable content -->
<div class="sidebar">
{% if customisation.logo_url %}
<div class="logo">
<img src="{{ customisation.logo_url }}" alt="{{ tenant.name }} Logo">
</div>
{% endif %}
<div class="sidebar-content">
{% if customisation.sidebar_text %}
<div class="sidebar-text">
{{ customisation.sidebar_text|safe }}
</div>
{% endif %}
{% if customisation.team_info %}
<div class="team-info">
<h3>Team</h3>
<div class="team-members">
{% for member in customisation.team_info %}
<div class="team-member">
{% if member.avatar %}
<img src="{{ member.avatar }}" alt="{{ member.name }}">
{% endif %}
<div class="member-info">
<h4>{{ member.name }}</h4>
<p>{{ member.role }}</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Main chat area -->
<div class="chat-main">
<div class="chat-header">
<h1>{{ specialist.name }}</h1>
</div>
<div class="chat-messages" id="chat-messages">
<!-- Messages will be added here dynamically -->
{% if customisation.welcome_message %}
<div class="message bot-message">
<div class="message-content">{{ customisation.welcome_message|safe }}</div>
</div>
{% else %}
<div class="message bot-message">
<div class="message-content">Hello! How can I help you today?</div>
</div>
{% endif %}
</div>
<div class="chat-input-container">
<textarea id="chat-input" placeholder="Type your message here..."></textarea>
<button id="send-button">Send</button>
</div>
</div>
</div>
<!-- Gebruik het ChatApp component -->
<chat-app>
</chat-app>
{% endblock %}
{% block scripts %}
<script>
// Store session information
const sessionInfo = {
tenantId: {{ tenant.id }},
specialistId: {{ specialist.id }},
chatSessionId: "{{ session.chat_session_id }}"
};
// Chat functionality
document.addEventListener('DOMContentLoaded', function() {
const chatInput = document.getElementById('chat-input');
const sendButton = document.getElementById('send-button');
const chatMessages = document.getElementById('chat-messages');
let currentTaskId = null;
let pollingInterval = null;
// Function to add a message to the chat
function addMessage(message, isUser = false) {
const messageDiv = document.createElement('div');
messageDiv.className = isUser ? 'message user-message' : 'message bot-message';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.innerHTML = message;
messageDiv.appendChild(contentDiv);
chatMessages.appendChild(messageDiv);
// Scroll to bottom
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Function to send a message
function sendMessage() {
const message = chatInput.value.trim();
if (!message) return;
// Add user message to chat
addMessage(message, true);
// Clear input
chatInput.value = '';
// Add loading indicator
const loadingDiv = document.createElement('div');
loadingDiv.className = 'message bot-message loading';
loadingDiv.innerHTML = '<div class="message-content"><div class="typing-indicator"><span></span><span></span><span></span></div></div>';
chatMessages.appendChild(loadingDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
// Send message to server
fetch('/api/send_message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: message,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'processing') {
currentTaskId = data.task_id;
// Start polling for results
if (pollingInterval) clearInterval(pollingInterval);
pollingInterval = setInterval(checkTaskStatus, 1000);
} else {
// Remove loading indicator
chatMessages.removeChild(loadingDiv);
// Show error if any
if (data.error) {
addMessage(`Error: ${data.error}`);
}
}
})
.catch(error => {
// Remove loading indicator
chatMessages.removeChild(loadingDiv);
addMessage(`Error: ${error.message}`);
});
}
// Function to check task status
function checkTaskStatus() {
if (!currentTaskId) return;
fetch(`/api/check_status?task_id=${currentTaskId}`)
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// Remove loading indicator
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) chatMessages.removeChild(loadingDiv);
// Add bot response
addMessage(data.answer);
// Clear polling
clearInterval(pollingInterval);
currentTaskId = null;
} else if (data.status === 'error') {
// Remove loading indicator
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) chatMessages.removeChild(loadingDiv);
// Show error
addMessage(`Error: ${data.message}`);
// Clear polling
clearInterval(pollingInterval);
currentTaskId = null;
}
// If status is 'pending', continue polling
})
.catch(error => {
// Remove loading indicator
const loadingDiv = document.querySelector('.loading');
if (loadingDiv) chatMessages.removeChild(loadingDiv);
addMessage(`Error checking status: ${error.message}`);
// Clear polling
clearInterval(pollingInterval);
currentTaskId = null;
});
}
// Event listeners
sendButton.addEventListener('click', sendMessage);
chatInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
});
</script>
<!-- Import components and main app -->
<!-- Alle componenten worden geladen met absolute paden vanaf /static/ -->
<script type="module" src="{{ url_for('static', filename='assets/js/chat-app.js') }}"></script>
{% endblock %}

View File

@@ -1,15 +1,35 @@
import uuid
from flask import Blueprint, render_template, request, session, current_app, jsonify, abort
from flask import Blueprint, render_template, request, session, current_app, jsonify, Response, stream_with_context
from sqlalchemy.exc import SQLAlchemyError
from common.extensions import db
from common.models.user import Tenant, SpecialistMagicLinkTenant
from common.models.user import Tenant, SpecialistMagicLinkTenant, TenantMake
from common.models.interaction import SpecialistMagicLink, Specialist, ChatSession, Interaction
from common.services.interaction.specialist_services import SpecialistServices
from common.utils.database import Database
from common.utils.chat_utils import get_default_chat_customisation
from common.utils.execution_progress import ExecutionProgressTracker
chat_bp = Blueprint('chat_bp', __name__, url_prefix='/chat')
@chat_bp.before_request
def log_before_request():
current_app.logger.debug(f'Before request: {request.path} =====================================')
@chat_bp.after_request
def log_after_request(response):
return response
# @chat_bp.before_request
# def before_request():
# try:
# mw_before_request()
# except Exception as e:
# current_app.logger.error(f'Error switching schema in Document Blueprint: {e}')
# raise
chat_bp = Blueprint('chat', __name__)
@chat_bp.route('/')
def index():
@@ -31,14 +51,12 @@ def chat(magic_link_code):
current_app.logger.error(f"Invalid magic link code: {magic_link_code}")
return render_template('error.html', message="Invalid magic link code.")
tenant_id = magic_link_tenant.tenant_id
# Get tenant information
tenant_id = magic_link_tenant.tenant_id
tenant = Tenant.query.get(tenant_id)
if not tenant:
current_app.logger.error(f"Tenant not found for ID: {tenant_id}")
return render_template('error.html', message="Tenant not found.")
# Switch to tenant schema
Database(tenant_id).switch_schema()
@@ -48,6 +66,12 @@ def chat(magic_link_code):
current_app.logger.error(f"Specialist magic link not found in tenant schema: {tenant_id}")
return render_template('error.html', message="Specialist configuration not found.")
# Get relevant TenantMake
tenant_make = TenantMake.query.get(specialist_ml.tenant_make_id)
if not tenant_make:
current_app.logger.error(f"Tenant make not found: {specialist_ml.tenant_make_id}")
return render_template('error.html', message="Tenant make not found.")
# Get specialist details
specialist = Specialist.query.get(specialist_ml.specialist_id)
if not specialist:
@@ -55,21 +79,32 @@ def chat(magic_link_code):
return render_template('error.html', message="Specialist not found.")
# Store necessary information in session
session['tenant_id'] = tenant_id
session['specialist_id'] = specialist_ml.specialist_id
session['specialist_args'] = specialist_ml.specialist_args or {}
session['magic_link_code'] = magic_link_code
session['tenant'] = tenant.to_dict()
session['specialist'] = specialist.to_dict()
session['magic_link'] = specialist_ml.to_dict()
session['tenant_make'] = tenant_make.to_dict()
session['chat_session_id'] = SpecialistServices.start_session()
# Get customisation options with defaults
customisation = get_default_chat_customisation(tenant.chat_customisation_options)
customisation = get_default_chat_customisation(tenant_make.chat_customisation_options)
# Start a new chat session
session['chat_session_id'] = SpecialistServices.start_session()
return render_template('chat.html',
tenant=tenant,
specialist=specialist,
customisation=customisation)
# Define settings for the client
settings = {
"max_message_length": 2000,
"auto_scroll": True
}
return render_template('chat.html',
tenant=tenant,
tenant_make=tenant_make,
specialist=specialist,
customisation=customisation,
messages=[customisation['welcome_message']],
settings=settings
)
except Exception as e:
current_app.logger.error(f"Error in chat view: {str(e)}", exc_info=True)
@@ -82,15 +117,17 @@ def send_message():
"""
try:
data = request.json
message = data.get('message')
message = data.get('message', '')
form_values = data.get('form_values', {})
if not message:
return jsonify({'error': 'No message provided'}), 400
# Controleer of er ofwel een bericht of formuliergegevens zijn
if not message and not form_values:
return jsonify({'error': 'No message or form data provided'}), 400
tenant_id = session.get('tenant_id')
specialist_id = session.get('specialist_id')
tenant_id = session['tenant']['id']
specialist_id = session['specialist']['id']
chat_session_id = session.get('chat_session_id')
specialist_args = session.get('specialist_args', {})
specialist_args = session['magic_link'].get('specialist_args', {})
if not all([tenant_id, specialist_id, chat_session_id]):
return jsonify({'error': 'Session expired or invalid'}), 400
@@ -99,7 +136,16 @@ def send_message():
Database(tenant_id).switch_schema()
# Add user message to specialist arguments
specialist_args['user_message'] = 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"
f" with args: {specialist_args}\n"
f"with session ID: {chat_session_id}")
# Execute specialist
result = SpecialistServices.execute_specialist(
@@ -110,12 +156,16 @@ def send_message():
user_timezone=data.get('timezone', 'UTC')
)
current_app.logger.debug(f"Specialist execution result: {result}")
# Store the task ID for polling
session['current_task_id'] = result['task_id']
return jsonify({
'status': 'processing',
'task_id': result['task_id']
'task_id': result['task_id'],
'content': 'Verwerking gestart...',
'type': 'text'
})
except Exception as e:
@@ -168,3 +218,34 @@ def check_status():
except Exception as e:
current_app.logger.error(f"Error checking status: {str(e)}", exc_info=True)
return jsonify({'error': str(e)}), 500
@chat_bp.route('/api/task_progress/<task_id>')
def task_progress_stream(task_id):
"""
Server-Sent Events endpoint voor realtime voortgangsupdates
"""
current_app.logger.debug(f"Streaming updates for task ID: {task_id}")
try:
tracker = ExecutionProgressTracker()
def generate():
try:
for update in tracker.get_updates(task_id):
current_app.logger.debug(f"Progress update: {update}")
yield update
except Exception as e:
current_app.logger.error(f"Progress stream error: {str(e)}")
yield f"data: {{'error': '{str(e)}'}}\n\n"
return Response(
stream_with_context(generate()),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
)
except Exception as e:
current_app.logger.error(f"Failed to start progress stream: {str(e)}")
return jsonify({'error': str(e)}), 500

View File

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

View File

@@ -64,22 +64,29 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
# Retrieve history
self._cached_session = cache_manager.chat_session_cache.get_cached_session(self.session_id)
# Format history for the prompt
self._formatted_history = "\n\n".join([
f"HUMAN:\n{interaction.specialist_results.get('detailed_query')}\n\n"
f"AI:\n{interaction.specialist_results.get('rag_output').get('answer')}"
for interaction in self._cached_session.interactions
])
self._formatted_history = self._generate_formatted_history()
@property
def formatted_history(self) -> str:
if not self._formatted_history:
self._formatted_history = "\n\n".join([
f"HUMAN:\n{interaction.specialist_results.get('detailed_query')}\n\n"
f"AI:\n{interaction.specialist_results.get('rag_output').get('answer', '')}"
for interaction in self._cached_session.interactions
])
self._formatted_history = self._generate_formatted_history()
return self._formatted_history
def _generate_formatted_history(self) -> str:
"""Generate the formatted history string from cached session interactions."""
return "\n\n".join([
"\n\n".join([
f"HUMAN:\n"
f"{interaction.specialist_results['detailed_query']}"
if interaction.specialist_results.get('detailed_query') else "",
f"{interaction.specialist_arguments.get('form_values')}"
if interaction.specialist_arguments.get('form_values') else "",
f"AI:\n{interaction.specialist_results['answer']}"
if interaction.specialist_results.get('answer') else ""
]).strip()
for interaction in self._cached_session.interactions
])
def _add_task_agent(self, task_name: str, agent_name: str):
self._task_agents[task_name.lower()] = agent_name

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 eveai_chat_workers.retrievers.retriever_typing import RetrieverArguments
from common.extensions import cache_manager
@@ -21,6 +21,16 @@ class SpecialistArguments(BaseModel):
"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')
def validate_required_arguments(self) -> 'SpecialistArguments':
"""Validate that all required arguments for this specialist type are present"""
@@ -91,6 +101,13 @@ class SpecialistResult(BaseModel):
"extra": "allow"
}
# Structural optional fields available for all specialists
answer: Optional[str] = Field(None, description="Optional textual answer from the specialist")
detailed_query: Optional[str] = Field(None, description="Optional detailed query for the specialist")
form_request: Optional[Dict[str, Any]] = Field(None, description="Optional form definition to request user input")
phase: Optional[str] = Field(None, description="Phase of the specialist's workflow")
citations: Optional[Dict[str, Any]] = Field(None, description="Citations for the specialist's answer")
@model_validator(mode='after')
def validate_required_results(self) -> 'SpecialistResult':
"""Validate that all required result fields for this specialist type are present"""

View File

@@ -0,0 +1,198 @@
import asyncio
import json
from os import wait
from typing import Optional, List
from crewai.flow.flow import start, listen, and_
from flask import current_app
from pydantic import BaseModel, Field
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
class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
"""
type: TRAICIE_ROLE_DEFINITION_SPECIALIST
type_version: 1.0
Traicie Role Definition 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_ROLE_DEFINITION_SPECIALIST"
@property
def type_version(self) -> str:
return "1.3"
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 Role Definition 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)
self.log_tuning(f"Traicie Role Definition 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,
"make": arguments.make,
}
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.1",
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, "TRAICIE_SELECTION_SPECIALIST", "1.0")
class RoleDefinitionSpecialistInput(BaseModel):
role_name: str = Field(..., alias="role_name")
role_reference: Optional[str] = Field(..., alias="role_reference")
vacancy_text: Optional[str] = Field(None, alias="vacancy_text")
class RoleDefinitionSpecialistResult(SpecialistResult):
competencies: Optional[List[ListItem]] = None
class RoleDefFlowState(EveAIFlowState):
"""Flow state for Traicie Role Definition specialist that automatically updates from task outputs"""
input: Optional[RoleDefinitionSpecialistInput] = None
competencies: Optional[List[ListItem]] = None
class RoleDefinitionFlow(EveAICrewAIFlow[RoleDefFlowState]):
def __init__(self,
specialist_executor: CrewAIBaseSpecialistExecutor,
role_definitiion_crew: EveAICrewAICrew,
**kwargs):
super().__init__(specialist_executor, "Traicie Role Definition Specialist Flow", **kwargs)
self.specialist_executor = specialist_executor
self.role_definition_crew = role_definitiion_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

View File

@@ -0,0 +1,208 @@
import asyncio
import json
from os import wait
from typing import Optional, List
from time import sleep
from crewai.flow.flow import start, listen, and_
from flask import current_app
from pydantic import BaseModel, Field
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 RoleDefinitionSpecialistInput(BaseModel):
role_name: str = Field(..., alias="role_name")
role_reference: Optional[str] = Field(..., alias="role_reference")
vacancy_text: Optional[str] = Field(None, alias="vacancy_text")
class RoleDefinitionSpecialistResult(SpecialistResult):
competencies: Optional[List[ListItem]] = None
class RoleDefFlowState(EveAIFlowState):
"""Flow state for Traicie Role Definition specialist that automatically updates from task outputs"""
input: Optional[RoleDefinitionSpecialistInput] = None
competencies: Optional[List[ListItem]] = None
class RoleDefinitionFlow(EveAICrewAIFlow[RoleDefFlowState]):
def __init__(self,
specialist_executor: CrewAIBaseSpecialistExecutor,
role_definitiion_crew: EveAICrewAICrew,
**kwargs):
super().__init__(specialist_executor, "Traicie Role Definition Specialist Flow", **kwargs)
self.specialist_executor = specialist_executor
self.role_definition_crew = role_definitiion_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

View File

@@ -0,0 +1,306 @@
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", {})
current_app.logger.debug(f"Arguments: {arguments.model_dump()}")
current_app.logger.debug(f"Formatted Context: {formatted_context}")
current_app.logger.debug(f"Formatted History: {self._formatted_history}")
current_app.logger.debug(f"Cached Chat Session: {self._cached_session}")
# 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}"})
if not self._cached_session.interactions:
specialist_phase = "initial"
else:
specialist_phase = self._cached_session.interactions[-1].specialist_results.get('phase', 'initial')
results = None
match specialist_phase:
case "initial":
ko_form = form_definition = {
"type": "KO_CRITERIA_FORM",
"version": "1.0.0",
"name": "KO Criteria Form",
"icon": "verified",
"fields": {
"weekend_werk": {
"name": "Weekend Werk",
"description": "Werken in het weekend",
"context": "Ben je bereid om in het weekend te werken?",
"type": "options",
"required": True,
"allowed_values": ["Ja, geen probleem", "Nee, liever niet"]
},
"fysisch_werk": {
"name": "Fysische Activiteit",
"description": "Fysisch werken",
"context": "In onze winkels is het belangrijk dat je 8u kan rechtstaan in een iets koeler omgeving. Is dit voor jou haalbaar?",
"type": "options",
"required": True,
"allowed_values": ["Ja, prima haalbaar", "Neen, mogelijks een probleem"]
},
"nabijheid_werk": {
"name": "Nabijheid Werk",
"description": "Afstand Woon-Werk",
"context": "We hebben gemerkt dat tevreden collegas in de buurt van de winkel wonen. Hoe ver wil jij je verplaatsen?",
"type": "options",
"required": True,
"allowed_values": ["Meer dan 15 km", "Minder dan 15 km"]
},
}
}
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=f"We starten met een aantal KO Criteria vragen",
form_request=ko_form,
phase="ko_questions")
case "ko_questions":
contact_form = cache_manager.specialist_forms_config_cache.get_config("PERSONAL_CONTACT_FORM", "1.0")
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=f"We hebben de antwoorden op de KO criteria verwerkt. Je bent een geschikte kandidaat. Kan je je contactegevens doorgeven?",
form_request=contact_form,
phase="personal_contact_data")
case "personal_contact_data":
results = SpecialistResult.create_for_type(self.type, self.type_version,
answer=f"We hebben de contactgegevens verwerkt. We nemen zo snel mogelijk contact met je op.",
phase="candidate_selected")
self.log_tuning(f"Traicie Selection Specialist execution ended", {"Results": results.model_dump() if results else "No info"})
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

View File

@@ -1,5 +1,6 @@
from datetime import datetime as dt, timezone as tz
from typing import Dict, Any, Optional
import traceback
from flask import current_app
from sqlalchemy.exc import SQLAlchemyError
@@ -240,8 +241,9 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict
# Get specialist from database
specialist = Specialist.query.get_or_404(specialist_id)
except Exception as e:
stacktrace = traceback.format_exc()
ept.send_update(task_id, "EveAI Specialist Error", {'Error': str(e)})
current_app.logger.error(f'execute_specialist: Error executing specialist: {e}')
current_app.logger.error(f'execute_specialist: Error executing specialist: {e}\n{stacktrace}')
raise
with BusinessEvent("Execute Specialist",
@@ -272,7 +274,8 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict
retriever_args=raw_arguments.get('retriever_arguments', {})
)
except ValueError as e:
current_app.logger.error(f'execute_specialist: Error preparing arguments: {e}')
stacktrace = traceback.format_exc()
current_app.logger.error(f'execute_specialist: Error preparing arguments: {e}\n{stacktrace}')
raise
# Create new interaction record
@@ -289,7 +292,8 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict
event.update_attribute('interaction_id', new_interaction.id)
except SQLAlchemyError as e:
current_app.logger.error(f'execute_specialist: Error creating interaction: {e}')
stacktrace = traceback.format_exc()
current_app.logger.error(f'execute_specialist: Error creating interaction: {e}\n{stacktrace}')
raise
with current_event.create_span("Specialist invocation"):
@@ -314,7 +318,8 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict
db.session.add(new_interaction)
db.session.commit()
except SQLAlchemyError as e:
current_app.logger.error(f'execute_specialist: Error updating interaction: {e}')
stacktrace = traceback.format_exc()
current_app.logger.error(f'execute_specialist: Error updating interaction: {e}\n{stacktrace}')
raise
# Now that we have a complete interaction with an answer, add it to the cache
@@ -330,14 +335,16 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict
return response
except Exception as e:
stacktrace = traceback.format_exc()
ept.send_update(task_id, "EveAI Specialist Error", {'Error': str(e)})
current_app.logger.error(f'execute_specialist: Error executing specialist: {e}')
current_app.logger.error(f'execute_specialist: Error executing specialist: {e}\n{stacktrace}')
new_interaction.processing_error = str(e)[:255]
try:
db.session.add(new_interaction)
db.session.commit()
except SQLAlchemyError as e:
current_app.logger.error(f'execute_specialist: Error updating interaction: {e}')
stacktrace = traceback.format_exc()
current_app.logger.error(f'execute_specialist: Error updating interaction: {e}\n{stacktrace}')
raise

View File

@@ -0,0 +1,35 @@
"""Add default TenantMake to Tenant model
Revision ID: 83d4e90f87c6
Revises: f40d16a0965a
Create Date: 2025-06-09 15:42:51.503696
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '83d4e90f87c6'
down_revision = 'f40d16a0965a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.add_column(sa.Column('default_tenant_make_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key(None, 'tenant_make', ['default_tenant_make_id'], ['id'], referent_schema='public')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('default_tenant_make_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,31 @@
"""Make TenantMake name unique
Revision ID: f40d16a0965a
Revises: 200bda7f5251
Create Date: 2025-06-09 06:15:56.791634
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f40d16a0965a'
down_revision = '200bda7f5251'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant_make', schema=None) as batch_op:
batch_op.create_unique_constraint(None, ['name'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('tenant_make', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='unique')
# ### end Alembic commands ###

View File

@@ -0,0 +1,30 @@
"""Add tenant_make reference to SpecialistMagicLink
Revision ID: 2b6ae6cc923e
Revises: a179785e5362
Create Date: 2025-06-09 15:59:39.157066
"""
from alembic import op
import sqlalchemy as sa
import pgvector
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '2b6ae6cc923e'
down_revision = 'a179785e5362'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('specialist_magic_link', sa.Column('tenant_make_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'specialist_magic_link', 'tenant_make', ['tenant_make_id'], ['id'], referent_schema='public', ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

View File

@@ -0,0 +1,29 @@
"""Add active field to Specialist
Revision ID: a179785e5362
Revises: c71facc0ce7e
Create Date: 2025-06-09 08:30:18.532600
"""
from alembic import op
import sqlalchemy as sa
import pgvector
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'a179785e5362'
down_revision = 'c71facc0ce7e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('specialist', sa.Column('active', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('specialist', 'active')
# ### end Alembic commands ###

View File

@@ -82,7 +82,6 @@ def initialize_default_tenant():
'website': 'https://www.askeveai.com',
'timezone': 'UTC',
'default_language': 'en',
'allowed_languages': ['en', 'fr', 'nl', 'de', 'es'],
'type': 'Active',
'currency': '',
'created_at': dt.now(tz.utc),