Compare commits
21 Commits
v2.3.2-alf
...
v2.3.7-alf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
043cea45f2 | ||
|
|
7b87880045 | ||
|
|
5b2c04501c | ||
|
|
babcd6ec04 | ||
|
|
71adf64668 | ||
|
|
dbea41451a | ||
|
|
82e25b356c | ||
|
|
3c7460f741 | ||
|
|
2835486599 | ||
|
|
f1c60f9574 | ||
|
|
b326c0c6f2 | ||
|
|
5f1a5711f6 | ||
|
|
67ceb57b79 | ||
|
|
23b49516cb | ||
|
|
9cc266b97f | ||
|
|
3f77871c4f | ||
|
|
199cf94cf2 | ||
|
|
c4dcd6a0d3 | ||
|
|
43ee9139d6 | ||
|
|
8f45005713 | ||
|
|
bc1626c4ff |
19
.aiignore
Normal file
19
.aiignore
Normal 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
4
.gitignore
vendored
@@ -53,3 +53,7 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc
|
|||||||
/docker/grafana/data/
|
/docker/grafana/data/
|
||||||
/temp_requirements/
|
/temp_requirements/
|
||||||
/nginx/node_modules/
|
/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
|
||||||
|
|||||||
67
README.md.k8s-logging
Normal file
67
README.md.k8s-logging
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Kubernetes Logging Upgrade
|
||||||
|
|
||||||
|
## Overzicht
|
||||||
|
Deze instructies beschrijven hoe je alle services moet bijwerken om de nieuwe logging configuratie te gebruiken die zowel compatibel is met traditionele bestandsgebaseerde logging (voor ontwikkeling/test) als met Kubernetes (voor productie).
|
||||||
|
|
||||||
|
## Stappen voor elke service
|
||||||
|
|
||||||
|
Pas de volgende wijzigingen toe in elk van de volgende services:
|
||||||
|
|
||||||
|
- eveai_app
|
||||||
|
- eveai_workers
|
||||||
|
- eveai_api
|
||||||
|
- eveai_chat_client
|
||||||
|
- eveai_chat_workers
|
||||||
|
- eveai_beat
|
||||||
|
- eveai_entitlements
|
||||||
|
|
||||||
|
### 1. Update de imports
|
||||||
|
|
||||||
|
Verander:
|
||||||
|
```python
|
||||||
|
from config.logging_config import LOGGING
|
||||||
|
```
|
||||||
|
|
||||||
|
Naar:
|
||||||
|
```python
|
||||||
|
from config.logging_config import configure_logging
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Update de logging configuratie
|
||||||
|
|
||||||
|
Verander:
|
||||||
|
```python
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
Naar:
|
||||||
|
```python
|
||||||
|
configure_logging()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dockerfile Aanpassingen
|
||||||
|
|
||||||
|
Voeg de volgende regels toe aan je Dockerfile voor elke service om de Kubernetes-specifieke logging afhankelijkheden te installeren (alleen voor productie):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Alleen voor productie (Kubernetes) builds
|
||||||
|
COPY requirements-k8s.txt /app/
|
||||||
|
RUN if [ "$ENVIRONMENT" = "production" ]; then pip install -r requirements-k8s.txt; fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kubernetes Deployment
|
||||||
|
|
||||||
|
Zorg ervoor dat je Kubernetes deployment manifests de volgende omgevingsvariabele bevatten:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
env:
|
||||||
|
- name: FLASK_ENV
|
||||||
|
value: "production"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Voordelen
|
||||||
|
|
||||||
|
1. De code detecteert automatisch of deze in Kubernetes draait
|
||||||
|
2. In ontwikkeling/test omgevingen blijft alles naar bestanden schrijven
|
||||||
|
3. In Kubernetes gaan logs naar stdout/stderr in JSON-formaat
|
||||||
|
4. Geen wijzigingen nodig in bestaande logger code in de applicatie
|
||||||
6
app/eveai_chat_client/templates/chat.html
Normal file
6
app/eveai_chat_client/templates/chat.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- chat.html -->
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ tenant_make.name|default('EveAI') }} - AI Chat{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
@@ -8,7 +8,7 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
class Catalog(db.Model):
|
class Catalog(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
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)
|
description = db.Column(db.Text, nullable=True)
|
||||||
type = db.Column(db.String(50), nullable=False, default="STANDARD_CATALOG")
|
type = db.Column(db.String(50), nullable=False, default="STANDARD_CATALOG")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from ..extensions import db
|
from ..extensions import db
|
||||||
from .user import User, Tenant
|
from .user import User, Tenant, TenantMake
|
||||||
from .document import Embedding, Retriever
|
from .document import Embedding, Retriever
|
||||||
|
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ class Specialist(db.Model):
|
|||||||
tuning = db.Column(db.Boolean, nullable=True, default=False)
|
tuning = db.Column(db.Boolean, nullable=True, default=False)
|
||||||
configuration = db.Column(JSONB, nullable=True)
|
configuration = db.Column(JSONB, nullable=True)
|
||||||
arguments = 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
|
# Relationship to retrievers through the association table
|
||||||
retrievers = db.relationship('SpecialistRetriever', backref='specialist', lazy=True,
|
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_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))
|
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):
|
class EveAIAsset(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -222,6 +238,7 @@ class SpecialistMagicLink(db.Model):
|
|||||||
name = db.Column(db.String(50), nullable=False)
|
name = db.Column(db.String(50), nullable=False)
|
||||||
description = db.Column(db.Text, nullable=True)
|
description = db.Column(db.Text, nullable=True)
|
||||||
specialist_id = db.Column(db.Integer, db.ForeignKey(Specialist.id, ondelete='CASCADE'), nullable=False)
|
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)
|
magic_link_code = db.Column(db.String(55), nullable=False, unique=True)
|
||||||
|
|
||||||
valid_from = db.Column(db.DateTime, nullable=True)
|
valid_from = db.Column(db.DateTime, nullable=True)
|
||||||
@@ -236,3 +253,14 @@ class SpecialistMagicLink(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<SpecialistMagicLink {self.specialist_id} {self.magic_link_code}>"
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import date
|
|||||||
|
|
||||||
from common.extensions import db
|
from common.extensions import db
|
||||||
from flask_security import UserMixin, RoleMixin
|
from flask_security import UserMixin, RoleMixin
|
||||||
from sqlalchemy.dialects.postgresql import ARRAY
|
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from common.models.entitlements import License
|
from common.models.entitlements import License
|
||||||
@@ -28,17 +28,19 @@ class Tenant(db.Model):
|
|||||||
|
|
||||||
# language information
|
# language information
|
||||||
default_language = db.Column(db.String(2), nullable=True)
|
default_language = db.Column(db.String(2), nullable=True)
|
||||||
allowed_languages = db.Column(ARRAY(sa.String(2)), nullable=True)
|
|
||||||
|
|
||||||
# Entitlements
|
# Entitlements
|
||||||
currency = db.Column(db.String(20), nullable=True)
|
currency = db.Column(db.String(20), nullable=True)
|
||||||
storage_dirty = db.Column(db.Boolean, nullable=True, default=False)
|
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
|
# Relations
|
||||||
users = db.relationship('User', backref='tenant')
|
users = db.relationship('User', backref='tenant')
|
||||||
domains = db.relationship('TenantDomain', backref='tenant')
|
domains = db.relationship('TenantDomain', backref='tenant')
|
||||||
licenses = db.relationship('License', back_populates='tenant')
|
licenses = db.relationship('License', back_populates='tenant')
|
||||||
license_usages = db.relationship('LicenseUsage', backref='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
|
@property
|
||||||
def current_license(self):
|
def current_license(self):
|
||||||
@@ -60,8 +62,8 @@ class Tenant(db.Model):
|
|||||||
'timezone': self.timezone,
|
'timezone': self.timezone,
|
||||||
'type': self.type,
|
'type': self.type,
|
||||||
'default_language': self.default_language,
|
'default_language': self.default_language,
|
||||||
'allowed_languages': self.allowed_languages,
|
|
||||||
'currency': self.currency,
|
'currency': self.currency,
|
||||||
|
'default_tenant_make_id': self.default_tenant_make_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -173,6 +175,42 @@ class TenantProject(db.Model):
|
|||||||
return f"<TenantProject {self.id}: {self.name}>"
|
return f"<TenantProject {self.id}: {self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TenantMake(db.Model):
|
||||||
|
__bind_key__ = 'public'
|
||||||
|
__table_args__ = {'schema': 'public'}
|
||||||
|
|
||||||
|
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, 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)
|
||||||
|
logo_url = db.Column(db.String(255), nullable=True)
|
||||||
|
|
||||||
|
# Chat customisation options
|
||||||
|
chat_customisation_options = db.Column(JSONB, nullable=True)
|
||||||
|
|
||||||
|
# Versioning Information
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, server_default=db.func.now())
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey('public.user.id'), nullable=True)
|
||||||
|
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):
|
class Partner(db.Model):
|
||||||
__bind_key__ = 'public'
|
__bind_key__ = 'public'
|
||||||
__table_args__ = {'schema': 'public'}
|
__table_args__ = {'schema': 'public'}
|
||||||
@@ -279,5 +317,3 @@ class SpecialistMagicLinkTenant(db.Model):
|
|||||||
|
|
||||||
magic_link_code = db.Column(db.String(55), primary_key=True)
|
magic_link_code = db.Column(db.String(55), primary_key=True)
|
||||||
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
|
tenant_id = db.Column(db.Integer, db.ForeignKey('public.tenant.id'), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -220,3 +220,18 @@ class SpecialistServices:
|
|||||||
db.session.add(tool)
|
db.session.add(tool)
|
||||||
current_app.logger.info(f"Created tool {tool.id} of type {tool_type}")
|
current_app.logger.info(f"Created tool {tool.id} of type {tool_type}")
|
||||||
return tool
|
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
|
||||||
|
|||||||
27
common/utils/cache/config_cache.py
vendored
27
common/utils/cache/config_cache.py
vendored
@@ -7,7 +7,7 @@ from flask import current_app
|
|||||||
|
|
||||||
from common.utils.cache.base import CacheHandler, CacheKey
|
from common.utils.cache.base import CacheHandler, CacheKey
|
||||||
from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \
|
from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \
|
||||||
catalog_types, partner_service_types, processor_types
|
catalog_types, partner_service_types, processor_types, customisation_types, specialist_form_types
|
||||||
|
|
||||||
|
|
||||||
def is_major_minor(version: str) -> bool:
|
def is_major_minor(version: str) -> bool:
|
||||||
@@ -463,7 +463,6 @@ ProcessorConfigCacheHandler, ProcessorConfigVersionTreeCacheHandler, ProcessorCo
|
|||||||
types_module=processor_types.PROCESSOR_TYPES
|
types_module=processor_types.PROCESSOR_TYPES
|
||||||
))
|
))
|
||||||
|
|
||||||
# Add to common/utils/cache/config_cache.py
|
|
||||||
PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, PartnerServiceConfigTypesCacheHandler = (
|
PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, PartnerServiceConfigTypesCacheHandler = (
|
||||||
create_config_cache_handlers(
|
create_config_cache_handlers(
|
||||||
config_type='partner_services',
|
config_type='partner_services',
|
||||||
@@ -471,6 +470,22 @@ PartnerServiceConfigCacheHandler, PartnerServiceConfigVersionTreeCacheHandler, P
|
|||||||
types_module=partner_service_types.PARTNER_SERVICE_TYPES
|
types_module=partner_service_types.PARTNER_SERVICE_TYPES
|
||||||
))
|
))
|
||||||
|
|
||||||
|
CustomisationConfigCacheHandler, CustomisationConfigVersionTreeCacheHandler, CustomisationConfigTypesCacheHandler = (
|
||||||
|
create_config_cache_handlers(
|
||||||
|
config_type='customisations',
|
||||||
|
config_dir='config/customisations',
|
||||||
|
types_module=customisation_types.CUSTOMISATION_TYPES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
SpecialistFormConfigCacheHandler, SpecialistFormConfigVersionTreeCacheHandler, SpecialistFormConfigTypesCacheHandler = (
|
||||||
|
create_config_cache_handlers(
|
||||||
|
config_type='specialist_forms',
|
||||||
|
config_dir='config/specialist_forms',
|
||||||
|
types_module=specialist_form_types.SPECIALIST_FORM_TYPES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def register_config_cache_handlers(cache_manager) -> None:
|
def register_config_cache_handlers(cache_manager) -> None:
|
||||||
cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config')
|
cache_manager.register_handler(AgentConfigCacheHandler, 'eveai_config')
|
||||||
@@ -503,6 +518,12 @@ def register_config_cache_handlers(cache_manager) -> None:
|
|||||||
cache_manager.register_handler(PartnerServiceConfigCacheHandler, 'eveai_config')
|
cache_manager.register_handler(PartnerServiceConfigCacheHandler, 'eveai_config')
|
||||||
cache_manager.register_handler(PartnerServiceConfigTypesCacheHandler, 'eveai_config')
|
cache_manager.register_handler(PartnerServiceConfigTypesCacheHandler, 'eveai_config')
|
||||||
cache_manager.register_handler(PartnerServiceConfigVersionTreeCacheHandler, 'eveai_config')
|
cache_manager.register_handler(PartnerServiceConfigVersionTreeCacheHandler, 'eveai_config')
|
||||||
|
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.agents_config_cache.set_version_tree_cache(cache_manager.agents_version_tree_cache)
|
||||||
cache_manager.tasks_config_cache.set_version_tree_cache(cache_manager.tasks_version_tree_cache)
|
cache_manager.tasks_config_cache.set_version_tree_cache(cache_manager.tasks_version_tree_cache)
|
||||||
@@ -513,3 +534,5 @@ def register_config_cache_handlers(cache_manager) -> None:
|
|||||||
cache_manager.catalogs_config_cache.set_version_tree_cache(cache_manager.catalogs_version_tree_cache)
|
cache_manager.catalogs_config_cache.set_version_tree_cache(cache_manager.catalogs_version_tree_cache)
|
||||||
cache_manager.processors_config_cache.set_version_tree_cache(cache_manager.processors_version_tree_cache)
|
cache_manager.processors_config_cache.set_version_tree_cache(cache_manager.processors_version_tree_cache)
|
||||||
cache_manager.partner_services_config_cache.set_version_tree_cache(cache_manager.partner_services_version_tree_cache)
|
cache_manager.partner_services_config_cache.set_version_tree_cache(cache_manager.partner_services_version_tree_cache)
|
||||||
|
cache_manager.customisations_config_cache.set_version_tree_cache(cache_manager.customisations_version_tree_cache)
|
||||||
|
cache_manager.specialist_forms_config_cache.set_version_tree_cache(cache_manager.specialist_forms_version_tree_cache)
|
||||||
|
|||||||
45
common/utils/chat_utils.py
Normal file
45
common/utils/chat_utils.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
# Default customization options
|
||||||
|
default_customisation = {
|
||||||
|
'primary_color': '#007bff',
|
||||||
|
'secondary_color': '#6c757d',
|
||||||
|
'background_color': '#ffffff',
|
||||||
|
'text_color': '#212529',
|
||||||
|
'sidebar_color': '#f8f9fa',
|
||||||
|
'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?',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -21,7 +21,7 @@ class TaggingField(BaseModel):
|
|||||||
@field_validator('type', mode='before')
|
@field_validator('type', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_type(cls, v: str) -> str:
|
def validate_type(cls, v: str) -> str:
|
||||||
valid_types = ['string', 'integer', 'float', 'date', 'enum']
|
valid_types = ['string', 'integer', 'float', 'date', 'enum', 'color']
|
||||||
if v not in valid_types:
|
if v not in valid_types:
|
||||||
raise ValueError(f'type must be one of {valid_types}')
|
raise ValueError(f'type must be one of {valid_types}')
|
||||||
return v
|
return v
|
||||||
@@ -243,7 +243,7 @@ class ArgumentDefinition(BaseModel):
|
|||||||
@field_validator('type')
|
@field_validator('type')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_type(cls, v: str) -> str:
|
def validate_type(cls, v: str) -> str:
|
||||||
valid_types = ['string', 'integer', 'float', 'date', 'enum']
|
valid_types = ['string', 'integer', 'float', 'date', 'enum', 'color']
|
||||||
if v not in valid_types:
|
if v not in valid_types:
|
||||||
raise ValueError(f'type must be one of {valid_types}')
|
raise ValueError(f'type must be one of {valid_types}')
|
||||||
return v
|
return v
|
||||||
@@ -256,7 +256,8 @@ class ArgumentDefinition(BaseModel):
|
|||||||
'integer': NumericConstraint,
|
'integer': NumericConstraint,
|
||||||
'float': NumericConstraint,
|
'float': NumericConstraint,
|
||||||
'date': DateConstraint,
|
'date': DateConstraint,
|
||||||
'enum': EnumConstraint
|
'enum': EnumConstraint,
|
||||||
|
'color': StringConstraint
|
||||||
}
|
}
|
||||||
|
|
||||||
expected_type = expected_constraint_types.get(self.type)
|
expected_type = expected_constraint_types.get(self.type)
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ def create_default_config_from_type_config(type_config):
|
|||||||
default_config[field_name] = 0
|
default_config[field_name] = 0
|
||||||
elif field_type == "boolean":
|
elif field_type == "boolean":
|
||||||
default_config[field_name] = False
|
default_config[field_name] = False
|
||||||
|
elif field_type == "color":
|
||||||
|
default_config[field_name] = "#000000"
|
||||||
else:
|
else:
|
||||||
default_config[field_name] = ""
|
default_config[field_name] = ""
|
||||||
|
|
||||||
|
|||||||
@@ -248,3 +248,14 @@ class EveAIPendingLicensePeriod(EveAIException):
|
|||||||
message = f"Basic Fee Payment has not been received yet. Please ensure payment has been made, and please wait for payment to be processed."
|
message = f"Basic Fee Payment has not been received yet. Please ensure payment has been made, and please wait for payment to be processed."
|
||||||
super().__init__(message, status_code, payload)
|
super().__init__(message, status_code, payload)
|
||||||
|
|
||||||
|
|
||||||
|
class EveAISpecialistExecutionError(EveAIException):
|
||||||
|
"""Raised when an error occurs during specialist execution"""
|
||||||
|
|
||||||
|
def __init__(self, tenant_id, specialist_id, session_id, details, status_code=400, payload=None):
|
||||||
|
message = (f"Error during specialist {specialist_id} execution \n"
|
||||||
|
f"with Session ID {session_id} \n"
|
||||||
|
f"for Tenant {tenant_id}. \n"
|
||||||
|
f"Details: {details} \n"
|
||||||
|
f"The System Administrator has been notified. Please try again later.")
|
||||||
|
super().__init__(message, status_code, payload)
|
||||||
|
|||||||
@@ -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
|
|
||||||
25
config/agents/traicie/TRAICIE_RECRUITER_AGENT/1.0.0.yaml
Normal file
25
config/agents/traicie/TRAICIE_RECRUITER_AGENT/1.0.0.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
version: "1.0.0"
|
||||||
|
name: "Traicie Recruiter"
|
||||||
|
role: >
|
||||||
|
You are an Expert Recruiter working for {tenant_name}
|
||||||
|
{custom_role}
|
||||||
|
goal: >
|
||||||
|
As an expert recruiter, you identify, attract, and secure top talent by building genuine relationships, deeply
|
||||||
|
understanding business needs, and ensuring optimal alignment between candidate potential and organizational goals
|
||||||
|
, while championing diversity, culture fit, and long-term retention.
|
||||||
|
{custom_goal}
|
||||||
|
backstory: >
|
||||||
|
You started your career in a high-pressure agency setting, where you quickly learned the art of fast-paced hiring and
|
||||||
|
relationship building. Over the years, you moved in-house, partnering closely with business leaders to shape
|
||||||
|
recruitment strategies that go beyond filling roles—you focus on finding the right people to drive growth and culture.
|
||||||
|
With a strong grasp of both tech and non-tech profiles, you’ve adapted to changing trends, from remote work to
|
||||||
|
AI-driven sourcing. You’re more than a recruiter—you’re a trusted advisor, a brand ambassador, and a connector of
|
||||||
|
people and purpose.
|
||||||
|
{custom_backstory}
|
||||||
|
full_model_name: "mistral.magistral-medium-latest"
|
||||||
|
temperature: 0.3
|
||||||
|
metadata:
|
||||||
|
author: "Josako"
|
||||||
|
date_added: "2025-06-18"
|
||||||
|
description: "Traicie Recruiter Agent"
|
||||||
|
changes: "Initial version"
|
||||||
103
config/config.py
103
config/config.py
@@ -12,10 +12,7 @@ class Config(object):
|
|||||||
DEBUG = False
|
DEBUG = False
|
||||||
DEVELOPMENT = False
|
DEVELOPMENT = False
|
||||||
SECRET_KEY = environ.get('SECRET_KEY')
|
SECRET_KEY = environ.get('SECRET_KEY')
|
||||||
SESSION_COOKIE_SECURE = False
|
|
||||||
SESSION_COOKIE_HTTPONLY = True
|
|
||||||
COMPONENT_NAME = environ.get('COMPONENT_NAME')
|
COMPONENT_NAME = environ.get('COMPONENT_NAME')
|
||||||
SESSION_KEY_PREFIX = f'{COMPONENT_NAME}_'
|
|
||||||
|
|
||||||
# Database Settings
|
# Database Settings
|
||||||
DB_HOST = environ.get('DB_HOST')
|
DB_HOST = environ.get('DB_HOST')
|
||||||
@@ -44,8 +41,6 @@ class Config(object):
|
|||||||
# SECURITY_POST_CHANGE_VIEW = '/admin/login'
|
# SECURITY_POST_CHANGE_VIEW = '/admin/login'
|
||||||
# SECURITY_BLUEPRINT_NAME = 'security_bp'
|
# SECURITY_BLUEPRINT_NAME = 'security_bp'
|
||||||
SECURITY_PASSWORD_SALT = environ.get('SECURITY_PASSWORD_SALT')
|
SECURITY_PASSWORD_SALT = environ.get('SECURITY_PASSWORD_SALT')
|
||||||
REMEMBER_COOKIE_SAMESITE = 'strict'
|
|
||||||
SESSION_COOKIE_SAMESITE = 'Lax'
|
|
||||||
SECURITY_CONFIRMABLE = True
|
SECURITY_CONFIRMABLE = True
|
||||||
SECURITY_TRACKABLE = True
|
SECURITY_TRACKABLE = True
|
||||||
SECURITY_PASSWORD_COMPLEXITY_CHECKER = 'zxcvbn'
|
SECURITY_PASSWORD_COMPLEXITY_CHECKER = 'zxcvbn'
|
||||||
@@ -56,6 +51,10 @@ class Config(object):
|
|||||||
SECURITY_EMAIL_SUBJECT_PASSWORD_NOTICE = 'Your Password Has Been Reset'
|
SECURITY_EMAIL_SUBJECT_PASSWORD_NOTICE = 'Your Password Has Been Reset'
|
||||||
SECURITY_EMAIL_PLAINTEXT = False
|
SECURITY_EMAIL_PLAINTEXT = False
|
||||||
SECURITY_EMAIL_HTML = True
|
SECURITY_EMAIL_HTML = True
|
||||||
|
SECURITY_SESSION_PROTECTION = 'basic' # of 'basic' als 'strong' problemen geeft
|
||||||
|
SECURITY_REMEMBER_TOKEN_VALIDITY = timedelta(minutes=60) # Zelfde als session lifetime
|
||||||
|
SECURITY_AUTO_LOGIN_AFTER_CONFIRM = True
|
||||||
|
SECURITY_AUTO_LOGIN_AFTER_RESET = True
|
||||||
|
|
||||||
# Ensure Flask-Security-Too is handling CSRF tokens when behind a proxy
|
# Ensure Flask-Security-Too is handling CSRF tokens when behind a proxy
|
||||||
SECURITY_CSRF_PROTECT_MECHANISMS = ['session']
|
SECURITY_CSRF_PROTECT_MECHANISMS = ['session']
|
||||||
@@ -67,7 +66,89 @@ class Config(object):
|
|||||||
MAX_CONTENT_LENGTH = 50 * 1024 * 1024
|
MAX_CONTENT_LENGTH = 50 * 1024 * 1024
|
||||||
|
|
||||||
# supported languages
|
# supported languages
|
||||||
SUPPORTED_LANGUAGES = ['en', 'fr', 'nl', 'de', 'es']
|
SUPPORTED_LANGUAGES = ['en', 'fr', 'nl', 'de', 'es', 'it', 'pt', 'ru', 'zh', 'ja', 'ko', 'ar', 'hi']
|
||||||
|
SUPPORTED_LANGUAGE_DETAILS = {
|
||||||
|
"English": {
|
||||||
|
"iso 639-1": "en",
|
||||||
|
"iso 639-2": "eng",
|
||||||
|
"iso 639-3": "eng",
|
||||||
|
"flag": "🇬🇧"
|
||||||
|
},
|
||||||
|
"French": {
|
||||||
|
"iso 639-1": "fr",
|
||||||
|
"iso 639-2": "fre", # of 'fra'
|
||||||
|
"iso 639-3": "fra",
|
||||||
|
"flag": "🇫🇷"
|
||||||
|
},
|
||||||
|
"German": {
|
||||||
|
"iso 639-1": "de",
|
||||||
|
"iso 639-2": "ger", # of 'deu'
|
||||||
|
"iso 639-3": "deu",
|
||||||
|
"flag": "🇩🇪"
|
||||||
|
},
|
||||||
|
"Spanish": {
|
||||||
|
"iso 639-1": "es",
|
||||||
|
"iso 639-2": "spa",
|
||||||
|
"iso 639-3": "spa",
|
||||||
|
"flag": "🇪🇸"
|
||||||
|
},
|
||||||
|
"Italian": {
|
||||||
|
"iso 639-1": "it",
|
||||||
|
"iso 639-2": "ita",
|
||||||
|
"iso 639-3": "ita",
|
||||||
|
"flag": "🇮🇹"
|
||||||
|
},
|
||||||
|
"Portuguese": {
|
||||||
|
"iso 639-1": "pt",
|
||||||
|
"iso 639-2": "por",
|
||||||
|
"iso 639-3": "por",
|
||||||
|
"flag": "🇵🇹"
|
||||||
|
},
|
||||||
|
"Dutch": {
|
||||||
|
"iso 639-1": "nl",
|
||||||
|
"iso 639-2": "dut", # of 'nld'
|
||||||
|
"iso 639-3": "nld",
|
||||||
|
"flag": "🇳🇱"
|
||||||
|
},
|
||||||
|
"Russian": {
|
||||||
|
"iso 639-1": "ru",
|
||||||
|
"iso 639-2": "rus",
|
||||||
|
"iso 639-3": "rus",
|
||||||
|
"flag": "🇷🇺"
|
||||||
|
},
|
||||||
|
"Chinese": {
|
||||||
|
"iso 639-1": "zh",
|
||||||
|
"iso 639-2": "chi", # of 'zho'
|
||||||
|
"iso 639-3": "zho",
|
||||||
|
"flag": "🇨🇳"
|
||||||
|
},
|
||||||
|
"Japanese": {
|
||||||
|
"iso 639-1": "ja",
|
||||||
|
"iso 639-2": "jpn",
|
||||||
|
"iso 639-3": "jpn",
|
||||||
|
"flag": "🇯🇵"
|
||||||
|
},
|
||||||
|
"Korean": {
|
||||||
|
"iso 639-1": "ko",
|
||||||
|
"iso 639-2": "kor",
|
||||||
|
"iso 639-3": "kor",
|
||||||
|
"flag": "🇰🇷"
|
||||||
|
},
|
||||||
|
"Arabic": {
|
||||||
|
"iso 639-1": "ar",
|
||||||
|
"iso 639-2": "ara",
|
||||||
|
"iso 639-3": "ara",
|
||||||
|
"flag": "🇸🇦"
|
||||||
|
},
|
||||||
|
"Hindi": {
|
||||||
|
"iso 639-1": "hi",
|
||||||
|
"iso 639-2": "hin",
|
||||||
|
"iso 639-3": "hin",
|
||||||
|
"flag": "🇮🇳"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES_Full = list(SUPPORTED_LANGUAGE_DETAILS.keys())
|
||||||
|
|
||||||
# supported currencies
|
# supported currencies
|
||||||
SUPPORTED_CURRENCIES = ['€', '$']
|
SUPPORTED_CURRENCIES = ['€', '$']
|
||||||
@@ -107,6 +188,15 @@ class Config(object):
|
|||||||
PERMANENT_SESSION_LIFETIME = timedelta(minutes=60)
|
PERMANENT_SESSION_LIFETIME = timedelta(minutes=60)
|
||||||
SESSION_REFRESH_EACH_REQUEST = True
|
SESSION_REFRESH_EACH_REQUEST = True
|
||||||
|
|
||||||
|
SESSION_COOKIE_NAME = f'{COMPONENT_NAME}_session'
|
||||||
|
SESSION_COOKIE_DOMAIN = None # Laat Flask dit automatisch bepalen
|
||||||
|
SESSION_COOKIE_PATH = '/'
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_SECURE = False # True voor production met HTTPS
|
||||||
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
||||||
|
REMEMBER_COOKIE_SAMESITE = 'strict'
|
||||||
|
SESSION_KEY_PREFIX = f'{COMPONENT_NAME}_'
|
||||||
|
|
||||||
# JWT settings
|
# JWT settings
|
||||||
JWT_SECRET_KEY = environ.get('JWT_SECRET_KEY')
|
JWT_SECRET_KEY = environ.get('JWT_SECRET_KEY')
|
||||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) # Set token expiry to 1 hour
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=1) # Set token expiry to 1 hour
|
||||||
@@ -185,6 +275,7 @@ class DevConfig(Config):
|
|||||||
# Define the nginx prefix used for the specific apps
|
# Define the nginx prefix used for the specific apps
|
||||||
EVEAI_APP_LOCATION_PREFIX = '/admin'
|
EVEAI_APP_LOCATION_PREFIX = '/admin'
|
||||||
EVEAI_CHAT_LOCATION_PREFIX = '/chat'
|
EVEAI_CHAT_LOCATION_PREFIX = '/chat'
|
||||||
|
CHAT_CLIENT_PREFIX = 'chat-client/chat/'
|
||||||
|
|
||||||
# file upload settings
|
# file upload settings
|
||||||
# UPLOAD_FOLDER = '/app/tenant_files'
|
# UPLOAD_FOLDER = '/app/tenant_files'
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
version: "1.0.0"
|
||||||
|
name: "Chat Client Customisation"
|
||||||
|
configuration:
|
||||||
|
"primary_color":
|
||||||
|
name: "Primary Color"
|
||||||
|
description: "Primary Color"
|
||||||
|
type: "color"
|
||||||
|
required: false
|
||||||
|
"secondary_color":
|
||||||
|
name: "Secondary Color"
|
||||||
|
description: "Secondary Color"
|
||||||
|
type: "color"
|
||||||
|
required: false
|
||||||
|
"background_color":
|
||||||
|
name: "Background Color"
|
||||||
|
description: "Background Color"
|
||||||
|
type: "color"
|
||||||
|
required: false
|
||||||
|
"text_color":
|
||||||
|
name: "Text Color"
|
||||||
|
description: "Text Color"
|
||||||
|
type: "color"
|
||||||
|
required: false
|
||||||
|
"sidebar_color":
|
||||||
|
name: "Sidebar Color"
|
||||||
|
description: "Sidebar Color"
|
||||||
|
type: "color"
|
||||||
|
required: false
|
||||||
|
"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":
|
||||||
|
name: "Welcome Message"
|
||||||
|
description: "Text to be shown as Welcome"
|
||||||
|
type: "text"
|
||||||
|
required: false
|
||||||
|
metadata:
|
||||||
|
author: "Josako"
|
||||||
|
date_added: "2024-06-06"
|
||||||
|
changes: "Initial version"
|
||||||
|
description: "Parameters allowing to customise the chat client"
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from datetime import datetime as dt, timezone as tz
|
from datetime import datetime as dt, timezone as tz
|
||||||
|
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from graypy import GELFUDPHandler
|
|
||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
|
|
||||||
# Graylog configuration
|
|
||||||
GRAYLOG_HOST = os.environ.get('GRAYLOG_HOST', 'localhost')
|
|
||||||
GRAYLOG_PORT = int(os.environ.get('GRAYLOG_PORT', 12201))
|
|
||||||
env = os.environ.get('FLASK_ENV', 'development')
|
env = os.environ.get('FLASK_ENV', 'development')
|
||||||
|
|
||||||
|
|
||||||
@@ -144,23 +142,6 @@ class TuningFormatter(logging.Formatter):
|
|||||||
return formatted_msg
|
return formatted_msg
|
||||||
|
|
||||||
|
|
||||||
class GraylogFormatter(logging.Formatter):
|
|
||||||
"""Maintains existing Graylog formatting while adding tuning fields"""
|
|
||||||
|
|
||||||
def format(self, record):
|
|
||||||
if getattr(record, 'is_tuning_log', False):
|
|
||||||
# Add tuning-specific fields to Graylog
|
|
||||||
record.tuning_fields = {
|
|
||||||
'is_tuning_log': True,
|
|
||||||
'tuning_type': record.tuning_type,
|
|
||||||
'tenant_id': record.tenant_id,
|
|
||||||
'catalog_id': record.catalog_id,
|
|
||||||
'specialist_id': record.specialist_id,
|
|
||||||
'retriever_id': record.retriever_id,
|
|
||||||
'processor_id': record.processor_id,
|
|
||||||
'session_id': record.session_id,
|
|
||||||
}
|
|
||||||
return super().format(record)
|
|
||||||
|
|
||||||
class TuningLogger:
|
class TuningLogger:
|
||||||
"""Helper class to manage tuning logs with consistent structure"""
|
"""Helper class to manage tuning logs with consistent structure"""
|
||||||
@@ -177,10 +158,10 @@ class TuningLogger:
|
|||||||
specialist_id: Optional specialist ID for context
|
specialist_id: Optional specialist ID for context
|
||||||
retriever_id: Optional retriever ID for context
|
retriever_id: Optional retriever ID for context
|
||||||
processor_id: Optional processor ID for context
|
processor_id: Optional processor ID for context
|
||||||
session_id: Optional session ID for context and log file naming
|
session_id: Optional session ID for context
|
||||||
log_file: Optional custom log file name to use
|
log_file: Optional custom log file name (ignored - all logs go to tuning.log)
|
||||||
"""
|
"""
|
||||||
|
# Always use the standard tuning logger
|
||||||
self.logger = logging.getLogger(logger_name)
|
self.logger = logging.getLogger(logger_name)
|
||||||
self.tenant_id = tenant_id
|
self.tenant_id = tenant_id
|
||||||
self.catalog_id = catalog_id
|
self.catalog_id = catalog_id
|
||||||
@@ -188,63 +169,8 @@ class TuningLogger:
|
|||||||
self.retriever_id = retriever_id
|
self.retriever_id = retriever_id
|
||||||
self.processor_id = processor_id
|
self.processor_id = processor_id
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.log_file = log_file
|
|
||||||
# Determine whether to use a session-specific logger
|
|
||||||
if session_id:
|
|
||||||
# Create a unique logger name for this session
|
|
||||||
session_logger_name = f"{logger_name}_{session_id}"
|
|
||||||
self.logger = logging.getLogger(session_logger_name)
|
|
||||||
|
|
||||||
# If this logger doesn't have handlers yet, configure it
|
def log_tuning(self, tuning_type: str, message: str, data=None, level=logging.DEBUG):
|
||||||
if not self.logger.handlers:
|
|
||||||
# Determine log file path
|
|
||||||
if not log_file and session_id:
|
|
||||||
log_file = f"logs/tuning_{session_id}.log"
|
|
||||||
elif not log_file:
|
|
||||||
log_file = "logs/tuning.log"
|
|
||||||
|
|
||||||
# Configure the logger
|
|
||||||
self._configure_session_logger(log_file)
|
|
||||||
else:
|
|
||||||
# Use the standard tuning logger
|
|
||||||
self.logger = logging.getLogger(logger_name)
|
|
||||||
|
|
||||||
def _configure_session_logger(self, log_file):
|
|
||||||
"""Configure a new session-specific logger with appropriate handlers"""
|
|
||||||
# Create and configure a file handler
|
|
||||||
file_handler = logging.handlers.RotatingFileHandler(
|
|
||||||
filename=log_file,
|
|
||||||
maxBytes=1024 * 1024 * 3, # 3MB
|
|
||||||
backupCount=3
|
|
||||||
)
|
|
||||||
file_handler.setFormatter(TuningFormatter())
|
|
||||||
file_handler.setLevel(logging.DEBUG)
|
|
||||||
|
|
||||||
# Add the file handler to the logger
|
|
||||||
self.logger.addHandler(file_handler)
|
|
||||||
|
|
||||||
# Add Graylog handler in production
|
|
||||||
env = os.environ.get('FLASK_ENV', 'development')
|
|
||||||
if env == 'production':
|
|
||||||
try:
|
|
||||||
graylog_handler = GELFUDPHandler(
|
|
||||||
host=GRAYLOG_HOST,
|
|
||||||
port=GRAYLOG_PORT,
|
|
||||||
debugging_fields=True
|
|
||||||
)
|
|
||||||
graylog_handler.setFormatter(GraylogFormatter())
|
|
||||||
self.logger.addHandler(graylog_handler)
|
|
||||||
except Exception as e:
|
|
||||||
# Fall back to just file logging if Graylog setup fails
|
|
||||||
fallback_logger = logging.getLogger('eveai_app')
|
|
||||||
fallback_logger.warning(f"Failed to set up Graylog handler: {str(e)}")
|
|
||||||
|
|
||||||
# Set logger level and disable propagation
|
|
||||||
self.logger.setLevel(logging.DEBUG)
|
|
||||||
self.logger.propagate = False
|
|
||||||
|
|
||||||
|
|
||||||
def log_tuning(self, tuning_type: str, message: str, data=None, level=logging.DEBUG):
|
|
||||||
"""Log a tuning event with structured data"""
|
"""Log a tuning event with structured data"""
|
||||||
try:
|
try:
|
||||||
# Create a standard LogRecord for tuning
|
# Create a standard LogRecord for tuning
|
||||||
@@ -275,13 +201,82 @@ def log_tuning(self, tuning_type: str, message: str, data=None, level=logging.DE
|
|||||||
self.logger.handle(record)
|
self.logger.handle(record)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
fallback_logger = logging.getLogger('eveai_workers')
|
print(f"Failed to log tuning message: {str(e)}")
|
||||||
fallback_logger.exception(f"Failed to log tuning message: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
# Set the custom log record factory
|
# Set the custom log record factory
|
||||||
logging.setLogRecordFactory(TuningLogRecord)
|
logging.setLogRecordFactory(TuningLogRecord)
|
||||||
|
|
||||||
|
def configure_logging():
|
||||||
|
"""Configure logging based on environment
|
||||||
|
|
||||||
|
When running in Kubernetes, directs logs to stdout in JSON format
|
||||||
|
Otherwise uses file-based logging for development/testing
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verkrijg het absolute pad naar de logs directory
|
||||||
|
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
logs_dir = os.path.join(base_dir, 'logs')
|
||||||
|
|
||||||
|
# Zorg ervoor dat de logs directory bestaat met de juiste permissies
|
||||||
|
if not os.path.exists(logs_dir):
|
||||||
|
try:
|
||||||
|
os.makedirs(logs_dir, exist_ok=True)
|
||||||
|
print(f"Logs directory aangemaakt op: {logs_dir}")
|
||||||
|
except (IOError, PermissionError) as e:
|
||||||
|
print(f"WAARSCHUWING: Kan logs directory niet aanmaken: {e}")
|
||||||
|
print(f"Logs worden mogelijk niet correct geschreven!")
|
||||||
|
|
||||||
|
# Check if running in Kubernetes
|
||||||
|
in_kubernetes = os.environ.get('KUBERNETES_SERVICE_HOST') is not None
|
||||||
|
|
||||||
|
# Controleer of de pythonjsonlogger pakket beschikbaar is als we in Kubernetes zijn
|
||||||
|
if in_kubernetes:
|
||||||
|
try:
|
||||||
|
import pythonjsonlogger.jsonlogger
|
||||||
|
has_json_logger = True
|
||||||
|
except ImportError:
|
||||||
|
print("WAARSCHUWING: python-json-logger pakket is niet geïnstalleerd.")
|
||||||
|
print("Voer 'pip install python-json-logger>=2.0.7' uit om JSON logging in te schakelen.")
|
||||||
|
print("Terugvallen op standaard logging formaat.")
|
||||||
|
has_json_logger = False
|
||||||
|
in_kubernetes = False # Fall back to standard logging
|
||||||
|
else:
|
||||||
|
has_json_logger = False
|
||||||
|
|
||||||
|
# Apply the configuration
|
||||||
|
logging_config = dict(LOGGING)
|
||||||
|
|
||||||
|
# Wijzig de json_console handler om terug te vallen op console als pythonjsonlogger niet beschikbaar is
|
||||||
|
if not has_json_logger and 'json_console' in logging_config['handlers']:
|
||||||
|
# Vervang json_console handler door een console handler met standaard formatter
|
||||||
|
logging_config['handlers']['json_console']['formatter'] = 'standard'
|
||||||
|
|
||||||
|
# In Kubernetes, conditionally modify specific loggers to use JSON console output
|
||||||
|
# This preserves the same logger names but changes where/how they log
|
||||||
|
if in_kubernetes:
|
||||||
|
for logger_name in logging_config['loggers']:
|
||||||
|
if logger_name: # Skip the root logger
|
||||||
|
logging_config['loggers'][logger_name]['handlers'] = ['json_console']
|
||||||
|
|
||||||
|
# Controleer of de logs directory schrijfbaar is voordat we de configuratie toepassen
|
||||||
|
logs_dir = os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs')
|
||||||
|
if os.path.exists(logs_dir) and not os.access(logs_dir, os.W_OK):
|
||||||
|
print(f"WAARSCHUWING: Logs directory bestaat maar is niet schrijfbaar: {logs_dir}")
|
||||||
|
print("Logs worden mogelijk niet correct geschreven!")
|
||||||
|
|
||||||
|
logging.config.dictConfig(logging_config)
|
||||||
|
logging.info(f"Logging configured. Environment: {'Kubernetes' if in_kubernetes else 'Development/Testing'}")
|
||||||
|
logging.info(f"Logs directory: {logs_dir}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error configuring logging: {str(e)}")
|
||||||
|
print("Gedetailleerde foutinformatie:")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
# Fall back to basic configuration
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
'version': 1,
|
||||||
@@ -290,7 +285,7 @@ LOGGING = {
|
|||||||
'file_app': {
|
'file_app': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/eveai_app.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_app.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -298,15 +293,15 @@ LOGGING = {
|
|||||||
'file_workers': {
|
'file_workers': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/eveai_workers.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_workers.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
},
|
},
|
||||||
'file_chat': {
|
'file_chat_client': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/eveai_chat.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_chat_client.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -314,7 +309,7 @@ LOGGING = {
|
|||||||
'file_chat_workers': {
|
'file_chat_workers': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/eveai_chat_workers.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_chat_workers.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -322,7 +317,7 @@ LOGGING = {
|
|||||||
'file_api': {
|
'file_api': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/eveai_api.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_api.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -330,7 +325,7 @@ LOGGING = {
|
|||||||
'file_beat': {
|
'file_beat': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/eveai_beat.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_beat.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -338,7 +333,7 @@ LOGGING = {
|
|||||||
'file_entitlements': {
|
'file_entitlements': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/eveai_entitlements.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'eveai_entitlements.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -346,7 +341,7 @@ LOGGING = {
|
|||||||
'file_sqlalchemy': {
|
'file_sqlalchemy': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/sqlalchemy.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'sqlalchemy.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -354,7 +349,7 @@ LOGGING = {
|
|||||||
'file_security': {
|
'file_security': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/security.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'security.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -362,7 +357,7 @@ LOGGING = {
|
|||||||
'file_rag_tuning': {
|
'file_rag_tuning': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/rag_tuning.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'rag_tuning.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -370,7 +365,7 @@ LOGGING = {
|
|||||||
'file_embed_tuning': {
|
'file_embed_tuning': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/embed_tuning.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'embed_tuning.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -378,7 +373,7 @@ LOGGING = {
|
|||||||
'file_business_events': {
|
'file_business_events': {
|
||||||
'level': 'INFO',
|
'level': 'INFO',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/business_events.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'business_events.log'),
|
||||||
'maxBytes': 1024 * 1024 * 1, # 1MB
|
'maxBytes': 1024 * 1024 * 1, # 1MB
|
||||||
'backupCount': 2,
|
'backupCount': 2,
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
@@ -388,100 +383,104 @@ LOGGING = {
|
|||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'formatter': 'standard',
|
'formatter': 'standard',
|
||||||
},
|
},
|
||||||
|
'json_console': {
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'level': 'INFO',
|
||||||
|
'formatter': 'json',
|
||||||
|
'stream': 'ext://sys.stdout',
|
||||||
|
},
|
||||||
'tuning_file': {
|
'tuning_file': {
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'class': 'logging.handlers.RotatingFileHandler',
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
'filename': 'logs/tuning.log',
|
'filename': os.path.join(os.path.abspath(os.path.dirname(os.path.dirname(__file__))), 'logs', 'tuning.log'),
|
||||||
'maxBytes': 1024 * 1024 * 3, # 3MB
|
'maxBytes': 1024 * 1024 * 3, # 3MB
|
||||||
'backupCount': 3,
|
'backupCount': 3,
|
||||||
'formatter': 'tuning',
|
'formatter': 'tuning',
|
||||||
},
|
},
|
||||||
'graylog': {
|
|
||||||
'level': 'DEBUG',
|
|
||||||
'class': 'graypy.GELFUDPHandler',
|
|
||||||
'host': GRAYLOG_HOST,
|
|
||||||
'port': GRAYLOG_PORT,
|
|
||||||
'debugging_fields': True,
|
|
||||||
'formatter': 'graylog'
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'formatters': {
|
'formatters': {
|
||||||
'standard': {
|
'standard': {
|
||||||
'format': '%(asctime)s [%(levelname)s] %(name)s (%(component)s) [%(module)s:%(lineno)d]: %(message)s',
|
'format': '%(asctime)s [%(levelname)s] %(name)s (%(component)s) [%(module)s:%(lineno)d]: %(message)s',
|
||||||
'datefmt': '%Y-%m-%d %H:%M:%S'
|
'datefmt': '%Y-%m-%d %H:%M:%S'
|
||||||
},
|
},
|
||||||
'graylog': {
|
|
||||||
'format': '[%(levelname)s] %(name)s (%(component)s) [%(module)s:%(lineno)d in %(funcName)s] '
|
|
||||||
'[Thread: %(threadName)s]: %(message)s',
|
|
||||||
'datefmt': '%Y-%m-%d %H:%M:%S',
|
|
||||||
'()': GraylogFormatter
|
|
||||||
},
|
|
||||||
'tuning': {
|
'tuning': {
|
||||||
'()': TuningFormatter,
|
'()': TuningFormatter,
|
||||||
'datefmt': '%Y-%m-%d %H:%M:%S UTC'
|
'datefmt': '%Y-%m-%d %H:%M:%S UTC'
|
||||||
|
},
|
||||||
|
'json': {
|
||||||
|
'format': '%(message)s',
|
||||||
|
'class': 'logging.Formatter' if not 'pythonjsonlogger' in sys.modules else 'pythonjsonlogger.jsonlogger.JsonFormatter',
|
||||||
|
'json_default': lambda obj: str(obj) if isinstance(obj, (dt, Exception)) else None,
|
||||||
|
'json_ensure_ascii': False,
|
||||||
|
'rename_fields': {
|
||||||
|
'asctime': 'timestamp',
|
||||||
|
'levelname': 'severity'
|
||||||
|
},
|
||||||
|
'timestamp': True,
|
||||||
|
'datefmt': '%Y-%m-%dT%H:%M:%S.%fZ'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'loggers': {
|
'loggers': {
|
||||||
'eveai_app': { # logger for the eveai_app
|
'eveai_app': { # logger for the eveai_app
|
||||||
'handlers': ['file_app', 'graylog', ] if env == 'production' else ['file_app', ],
|
'handlers': ['file_app'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'eveai_workers': { # logger for the eveai_workers
|
'eveai_workers': { # logger for the eveai_workers
|
||||||
'handlers': ['file_workers', 'graylog', ] if env == 'production' else ['file_workers', ],
|
'handlers': ['file_workers'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'eveai_chat': { # logger for the eveai_chat
|
'eveai_chat_client': { # logger for the eveai_chat
|
||||||
'handlers': ['file_chat', 'graylog', ] if env == 'production' else ['file_chat', ],
|
'handlers': ['file_chat_client'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'eveai_chat_workers': { # logger for the eveai_chat_workers
|
'eveai_chat_workers': { # logger for the eveai_chat_workers
|
||||||
'handlers': ['file_chat_workers', 'graylog', ] if env == 'production' else ['file_chat_workers', ],
|
'handlers': ['file_chat_workers'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'eveai_api': { # logger for the eveai_chat_workers
|
'eveai_api': { # logger for the eveai_api
|
||||||
'handlers': ['file_api', 'graylog', ] if env == 'production' else ['file_api', ],
|
'handlers': ['file_api'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'eveai_beat': { # logger for the eveai_beat
|
'eveai_beat': { # logger for the eveai_beat
|
||||||
'handlers': ['file_beat', 'graylog', ] if env == 'production' else ['file_beat', ],
|
'handlers': ['file_beat'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'eveai_entitlements': { # logger for the eveai_entitlements
|
'eveai_entitlements': { # logger for the eveai_entitlements
|
||||||
'handlers': ['file_entitlements', 'graylog', ] if env == 'production' else ['file_entitlements', ],
|
'handlers': ['file_entitlements'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'sqlalchemy.engine': { # logger for the sqlalchemy
|
'sqlalchemy.engine': { # logger for the sqlalchemy
|
||||||
'handlers': ['file_sqlalchemy', 'graylog', ] if env == 'production' else ['file_sqlalchemy', ],
|
'handlers': ['file_sqlalchemy'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'security': { # logger for the security
|
'security': { # logger for the security
|
||||||
'handlers': ['file_security', 'graylog', ] if env == 'production' else ['file_security', ],
|
'handlers': ['file_security'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
'business_events': {
|
'business_events': {
|
||||||
'handlers': ['file_business_events', 'graylog'],
|
'handlers': ['file_business_events'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
# Single tuning logger
|
# Single tuning logger
|
||||||
'tuning': {
|
'tuning': {
|
||||||
'handlers': ['tuning_file', 'graylog'] if env == 'production' else ['tuning_file'],
|
'handlers': ['tuning_file'],
|
||||||
'level': 'DEBUG',
|
'level': 'DEBUG',
|
||||||
'propagate': False,
|
'propagate': False,
|
||||||
},
|
},
|
||||||
'': { # root logger
|
'': { # root logger
|
||||||
'handlers': ['console'],
|
'handlers': ['console'] if os.environ.get('KUBERNETES_SERVICE_HOST') is None else ['json_console'],
|
||||||
'level': 'WARNING', # Set higher level for root to minimize noise
|
'level': 'WARNING', # Set higher level for root to minimize noise
|
||||||
'propagate': False
|
'propagate': False
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
version: "1.1.0"
|
version: "1.2.0"
|
||||||
name: "Traicie Role Definition Specialist"
|
name: "Traicie Role Definition Specialist"
|
||||||
framework: "crewai"
|
framework: "crewai"
|
||||||
partner: "traicie"
|
partner: "traicie"
|
||||||
@@ -11,9 +11,9 @@ arguments:
|
|||||||
type: "str"
|
type: "str"
|
||||||
required: true
|
required: true
|
||||||
specialist_name:
|
specialist_name:
|
||||||
name: "Specialist Name"
|
name: "Chatbot Name"
|
||||||
description: "The name the specialist will be called upon"
|
description: "The name of the chatbot."
|
||||||
type: str
|
type: "str"
|
||||||
required: true
|
required: true
|
||||||
role_reference:
|
role_reference:
|
||||||
name: "Role Reference"
|
name: "Role Reference"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -88,7 +88,13 @@ arguments:
|
|||||||
type: "str"
|
type: "str"
|
||||||
description: "The language (2-letter code) used to start the conversation"
|
description: "The language (2-letter code) used to start the conversation"
|
||||||
required: true
|
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:
|
results:
|
||||||
competencies:
|
competencies:
|
||||||
name: "competencies"
|
name: "competencies"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
version: "1.3.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_RECRUITER"
|
||||||
|
version: "1.0"
|
||||||
|
tasks:
|
||||||
|
- type: "TRAICIE_KO_CRITERIA_INTERVIEW_DEFINITION"
|
||||||
|
version: "1.0"
|
||||||
|
metadata:
|
||||||
|
author: "Josako"
|
||||||
|
date_added: "2025-06-16"
|
||||||
|
changes: "Realising the actual interaction with the LLM"
|
||||||
|
description: "Assistant to create a new Vacancy based on Vacancy Text"
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
version: "1.3.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_RECRUITER_AGENT"
|
||||||
|
version: "1.0"
|
||||||
|
tasks:
|
||||||
|
- type: "TRAICIE_KO_CRITERIA_INTERVIEW_DEFINITION_TASK"
|
||||||
|
version: "1.0"
|
||||||
|
metadata:
|
||||||
|
author: "Josako"
|
||||||
|
date_added: "2025-06-18"
|
||||||
|
changes: "Add make to the selection specialist"
|
||||||
|
description: "Assistant to create a new Vacancy based on Vacancy Text"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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 short question to ask the recruitment candidate describing the context of the ko criterium. Use your experience to
|
||||||
|
ask a question that enables us to verify compliancy to the criterium.
|
||||||
|
- A set of 2 short answers to that question, from the candidates perspective. One of the answers will result in a
|
||||||
|
positive evaluation of the criterium, the other one 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}
|
||||||
|
Use the following description to understand tone of voice:
|
||||||
|
|
||||||
|
{tone_of_voice_context}
|
||||||
|
|
||||||
|
Apply the following language level in both questions and answers: {language_level}
|
||||||
|
|
||||||
|
Use {language} as language for both questions and answers.
|
||||||
|
Use the following description to understand language_level:
|
||||||
|
|
||||||
|
{language_level_context}
|
||||||
|
|
||||||
|
```{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 positive answer, resulting in a positive evaluation of the criterium. In {language}.
|
||||||
|
- a negative answer, resulting in a negative evaluation of the criterium. 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"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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. The competencies provided should be
|
||||||
|
handled as knock-out criteria.
|
||||||
|
For each of the knock-out criteria, you need to define
|
||||||
|
|
||||||
|
- A short (1 sentence), closed-ended question (Yes / No) to ask the recruitment candidate. Use your experience to ask a question that
|
||||||
|
enables us to verify compliancy to the criterium.
|
||||||
|
- A set of 2 short answers (1 small sentence each) to that question (positive answer / negative answer), from the
|
||||||
|
candidates perspective.
|
||||||
|
The positive answer will result in a positive evaluation of the criterium, the negative answer in a negative evaluation
|
||||||
|
of the criterium. Try to avoid just using Yes / No as positive and negative answers.
|
||||||
|
|
||||||
|
Apply the following tone of voice in both questions and answers: {tone_of_voice}, i.e. {tone_of_voice_context}
|
||||||
|
|
||||||
|
Apply the following language level in both questions and answers: {language_level}, i.e. {language_level_context}
|
||||||
|
|
||||||
|
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 positive answer, resulting in a positive evaluation of the criterium. In {language}.
|
||||||
|
- a negative answer, resulting in a negative evaluation of the criterium. In {language}.
|
||||||
|
{custom_expected_output}
|
||||||
|
metadata:
|
||||||
|
author: "Josako"
|
||||||
|
date_added: "2025-06-20"
|
||||||
|
description: "A Task to define interview Q&A from given KO Criteria"
|
||||||
|
changes: "Improvement to ensure closed-ended questions and short descriptions"
|
||||||
@@ -32,5 +32,10 @@ AGENT_TYPES = {
|
|||||||
"name": "Traicie HR BP Agent",
|
"name": "Traicie HR BP Agent",
|
||||||
"description": "An HR Business Partner Agent",
|
"description": "An HR Business Partner Agent",
|
||||||
"partner": "traicie"
|
"partner": "traicie"
|
||||||
}
|
},
|
||||||
|
"TRAICIE_RECRUITER_AGENT": {
|
||||||
|
"name": "Traicie Recruiter Agent",
|
||||||
|
"description": "An Senior Recruiter Agent",
|
||||||
|
"partner": "traicie"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
7
config/type_defs/customisation_types.py
Normal file
7
config/type_defs/customisation_types.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Catalog Types
|
||||||
|
CUSTOMISATION_TYPES = {
|
||||||
|
"CHAT_CLIENT_CUSTOMISATION": {
|
||||||
|
"name": "Chat Client Customisation",
|
||||||
|
"description": "Parameters allowing to customise the chat client",
|
||||||
|
},
|
||||||
|
}
|
||||||
11
config/type_defs/specialist_form_types.py
Normal file
11
config/type_defs/specialist_form_types.py
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -41,5 +41,10 @@ TASK_TYPES = {
|
|||||||
"name": "Traicie Get KO Criteria",
|
"name": "Traicie Get KO Criteria",
|
||||||
"description": "A Task to get KO Criteria from a Vacancy Text",
|
"description": "A Task to get KO Criteria from a Vacancy Text",
|
||||||
"partner": "traicie"
|
"partner": "traicie"
|
||||||
|
},
|
||||||
|
"TRAICIE_KO_CRITERIA_INTERVIEW_DEFINITION_TASK": {
|
||||||
|
"name": "Traicie KO Criteria Interview Definition",
|
||||||
|
"description": "A Task to define KO Criteria questions to be used during the interview",
|
||||||
|
"partner": "traicie"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,60 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.3.7-alfa]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Basic Base Specialist additions for handling phases and transferring data between state and output
|
||||||
|
- Introduction of URL and QR-code for MagicLink
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Logging improvement & simplification (remove Graylog)
|
||||||
|
- Traicie Selection Specialist v1.3 - full roundtrip & full process
|
||||||
|
|
||||||
|
## [2.3.6-alfa]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Full Chat Client functionality, 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
|
||||||
|
- Add Tenant Make
|
||||||
|
- Add Chat Client customisation options to Tenant Make
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Catalog name must be unique to avoid mistakes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ensure document version is selected in UI before trying to view it.
|
||||||
|
- Remove obsolete tab from tenant overview
|
||||||
|
|
||||||
## [2.3.2-alfa]
|
## [2.3.2-alfa]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -29,18 +83,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Role Definition Specialist creates Selection Specialist from generated competencies
|
- Role Definition Specialist creates Selection Specialist from generated competencies
|
||||||
- Improvements to Selection Specialist (Agent definition to be started)
|
- Improvements to Selection Specialist (Agent definition to be started)
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
- For soon-to-be removed features.
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- For now removed features.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- For any bug fixes.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- In case of vulnerabilities.
|
|
||||||
|
|
||||||
## [2.3.0-alfa]
|
## [2.3.0-alfa]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -60,7 +102,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Introduction of ChatSession (Specialist Execution) follow-up in administrative interface
|
- Introduction of ChatSession (Specialist Execution) follow-up in administrative interface
|
||||||
- Introduce npm for javascript libraries usage and optimisations
|
- Introduce npm for javascript libraries usage and optimisations
|
||||||
- Introduction of new top bar in administrative interface to show session defaults (removing old navbar buttons)
|
- Introduction of new top bar in administrative interface to show session defaults (removing old navbar buttons)
|
||||||
-
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Add 'Register'-button to list views, replacing register menu-items
|
- Add 'Register'-button to list views, replacing register menu-items
|
||||||
@@ -118,9 +159,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Fixed
|
### Fixed
|
||||||
- Set default language when registering Documents or URLs.
|
- Set default language when registering Documents or URLs.
|
||||||
|
|
||||||
### Security
|
|
||||||
- In case of vulnerabilities.
|
|
||||||
|
|
||||||
## [2.1.0-alfa]
|
## [2.1.0-alfa]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- eveai_app
|
- eveai_app
|
||||||
- eveai_api
|
- eveai_api
|
||||||
|
- eveai_chat_client
|
||||||
networks:
|
networks:
|
||||||
- eveai-network
|
- eveai-network
|
||||||
|
|
||||||
@@ -177,6 +178,44 @@ services:
|
|||||||
# networks:
|
# networks:
|
||||||
# - eveai-network
|
# - eveai-network
|
||||||
|
|
||||||
|
eveai_chat_client:
|
||||||
|
image: josakola/eveai_chat_client:latest
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: ./docker/eveai_chat_client/Dockerfile
|
||||||
|
platforms:
|
||||||
|
- linux/amd64
|
||||||
|
- linux/arm64
|
||||||
|
ports:
|
||||||
|
- 5004:5004
|
||||||
|
expose:
|
||||||
|
- 8000
|
||||||
|
environment:
|
||||||
|
<<: *common-variables
|
||||||
|
COMPONENT_NAME: eveai_chat_client
|
||||||
|
volumes:
|
||||||
|
- ../eveai_chat_client:/app/eveai_chat_client
|
||||||
|
- ../common:/app/common
|
||||||
|
- ../config:/app/config
|
||||||
|
- ../scripts:/app/scripts
|
||||||
|
- ../patched_packages:/app/patched_packages
|
||||||
|
- ./eveai_logs:/app/logs
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz/ready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 1s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- eveai-network
|
||||||
|
|
||||||
eveai_chat_workers:
|
eveai_chat_workers:
|
||||||
image: josakola/eveai_chat_workers:latest
|
image: josakola/eveai_chat_workers:latest
|
||||||
build:
|
build:
|
||||||
@@ -441,4 +480,3 @@ volumes:
|
|||||||
#secrets:
|
#secrets:
|
||||||
# db-password:
|
# db-password:
|
||||||
# file: ./db/password.txt
|
# file: ./db/password.txt
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- eveai_app
|
- eveai_app
|
||||||
- eveai_api
|
- eveai_api
|
||||||
|
- eveai_chat_client
|
||||||
networks:
|
networks:
|
||||||
- eveai-network
|
- eveai-network
|
||||||
restart: "no"
|
restart: "no"
|
||||||
@@ -106,6 +107,33 @@ services:
|
|||||||
- eveai-network
|
- eveai-network
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
eveai_chat_client:
|
||||||
|
image: josakola/eveai_chat_client:${EVEAI_VERSION:-latest}
|
||||||
|
ports:
|
||||||
|
- 5004:5004
|
||||||
|
expose:
|
||||||
|
- 8000
|
||||||
|
environment:
|
||||||
|
<<: *common-variables
|
||||||
|
COMPONENT_NAME: eveai_chat_client
|
||||||
|
volumes:
|
||||||
|
- eveai_logs:/app/logs
|
||||||
|
- crewai_storage:/app/crewai_storage
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:5004/healthz/ready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- eveai-network
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
eveai_chat_workers:
|
eveai_chat_workers:
|
||||||
image: josakola/eveai_chat_workers:${EVEAI_VERSION:-latest}
|
image: josakola/eveai_chat_workers:${EVEAI_VERSION:-latest}
|
||||||
expose:
|
expose:
|
||||||
|
|||||||
@@ -115,15 +115,41 @@ echo "Set COMPOSE_FILE to $COMPOSE_FILE"
|
|||||||
echo "Set EVEAI_VERSION to $VERSION"
|
echo "Set EVEAI_VERSION to $VERSION"
|
||||||
echo "Set DOCKER_ACCOUNT to $DOCKER_ACCOUNT"
|
echo "Set DOCKER_ACCOUNT to $DOCKER_ACCOUNT"
|
||||||
|
|
||||||
# Define aliases for common Docker commands
|
docker-compose() {
|
||||||
alias docker-compose="docker compose -f $COMPOSE_FILE"
|
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"
|
dc() {
|
||||||
alias dcps="docker compose -f $COMPOSE_FILE ps"
|
docker compose -f $COMPOSE_FILE "$@"
|
||||||
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"
|
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 "Docker environment switched to $ENVIRONMENT with version $VERSION"
|
||||||
echo "You can now use 'docker-compose', 'dc', 'dcup', 'dcdown', 'dcps', 'dclogs', 'dcpull' or 'dcrefresh' commands"
|
echo "You can now use 'docker-compose', 'dc', 'dcup', 'dcdown', 'dcps', 'dclogs', 'dcpull' or 'dcrefresh' commands"
|
||||||
72
docker/eveai_chat_client/Dockerfile
Normal file
72
docker/eveai_chat_client/Dockerfile
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
ARG PYTHON_VERSION=3.12.7
|
||||||
|
FROM python:${PYTHON_VERSION}-slim as base
|
||||||
|
|
||||||
|
# Prevents Python from writing pyc files.
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
# Keeps Python from buffering stdout and stderr to avoid situations where
|
||||||
|
# the application crashes without emitting any logs due to buffering.
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Create directory for patched packages and set permissions
|
||||||
|
RUN mkdir -p /app/patched_packages && \
|
||||||
|
chmod 777 /app/patched_packages
|
||||||
|
|
||||||
|
# Ensure patches are applied to the application.
|
||||||
|
ENV PYTHONPATH=/app/patched_packages:$PYTHONPATH
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create a non-privileged user that the app will run under.
|
||||||
|
# See https://docs.docker.com/go/dockerfile-user-best-practices/
|
||||||
|
ARG UID=10001
|
||||||
|
RUN adduser \
|
||||||
|
--disabled-password \
|
||||||
|
--gecos "" \
|
||||||
|
--home "/nonexistent" \
|
||||||
|
--shell "/bin/bash" \
|
||||||
|
--no-create-home \
|
||||||
|
--uid "${UID}" \
|
||||||
|
appuser
|
||||||
|
|
||||||
|
# Install necessary packages and build tools
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
gcc \
|
||||||
|
postgresql-client \
|
||||||
|
curl \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create logs directory and set permissions
|
||||||
|
RUN mkdir -p /app/logs && chown -R appuser:appuser /app/logs
|
||||||
|
|
||||||
|
# Download dependencies as a separate step to take advantage of Docker's caching.
|
||||||
|
# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds.
|
||||||
|
# Leverage a bind mount to requirements.txt to avoid having to copy them into
|
||||||
|
# into this layer.
|
||||||
|
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN python -m pip install -r /app/requirements.txt
|
||||||
|
|
||||||
|
# Copy the source code into the container.
|
||||||
|
COPY eveai_chat_client /app/eveai_chat_client
|
||||||
|
COPY common /app/common
|
||||||
|
COPY config /app/config
|
||||||
|
COPY scripts /app/scripts
|
||||||
|
COPY patched_packages /app/patched_packages
|
||||||
|
COPY content /app/content
|
||||||
|
|
||||||
|
# Set permissions for scripts
|
||||||
|
RUN chmod 777 /app/scripts/entrypoint.sh && \
|
||||||
|
chmod 777 /app/scripts/start_eveai_chat_client.sh
|
||||||
|
|
||||||
|
# Set ownership of the application directory to the non-privileged user
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Expose the port that the application listens on.
|
||||||
|
EXPOSE 5004
|
||||||
|
|
||||||
|
# Set entrypoint and command
|
||||||
|
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
|
||||||
|
CMD ["/app/scripts/start_eveai_chat_client.sh"]
|
||||||
9
docker/rebuild_chat_client.sh
Executable file
9
docker/rebuild_chat_client.sh
Executable 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
|
||||||
49
docker/update_chat_client_statics.sh
Executable file
49
docker/update_chat_client_statics.sh
Executable 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!"
|
||||||
@@ -12,7 +12,7 @@ import logging.config
|
|||||||
from common.models.user import TenantDomain
|
from common.models.user import TenantDomain
|
||||||
from common.utils.cors_utils import get_allowed_origins
|
from common.utils.cors_utils import get_allowed_origins
|
||||||
from common.utils.database import Database
|
from common.utils.database import Database
|
||||||
from config.logging_config import LOGGING
|
from config.logging_config import configure_logging
|
||||||
from .api.document_api import document_ns
|
from .api.document_api import document_ns
|
||||||
from .api.auth import auth_ns
|
from .api.auth import auth_ns
|
||||||
from .api.specialist_execution_api import specialist_execution_ns
|
from .api.specialist_execution_api import specialist_execution_ns
|
||||||
@@ -40,7 +40,7 @@ def create_app(config_file=None):
|
|||||||
app.celery = make_celery(app.name, app.config)
|
app.celery = make_celery(app.name, app.config)
|
||||||
init_celery(app.celery, app)
|
init_celery(app.celery, app)
|
||||||
|
|
||||||
logging.config.dictConfig(LOGGING)
|
configure_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
logger.info("eveai_api starting up")
|
logger.info("eveai_api starting up")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import common.models.interaction
|
|||||||
import common.models.entitlements
|
import common.models.entitlements
|
||||||
import common.models.document
|
import common.models.document
|
||||||
from common.utils.startup_eveai import perform_startup_actions
|
from common.utils.startup_eveai import perform_startup_actions
|
||||||
from config.logging_config import LOGGING
|
from config.logging_config import configure_logging
|
||||||
from common.utils.security import set_tenant_session_data
|
from common.utils.security import set_tenant_session_data
|
||||||
from common.utils.errors import register_error_handlers
|
from common.utils.errors import register_error_handlers
|
||||||
from common.utils.celery_utils import make_celery, init_celery
|
from common.utils.celery_utils import make_celery, init_celery
|
||||||
@@ -47,8 +47,16 @@ def create_app(config_file=None):
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logging.config.dictConfig(LOGGING)
|
# Configureer logging op basis van de omgeving (K8s of traditioneel)
|
||||||
logger = logging.getLogger(__name__)
|
try:
|
||||||
|
configure_logging()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
# Test dat logging werkt
|
||||||
|
logger.debug("Logging test in eveai_app")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Critical Error Initialising Error: {str(e)}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
logger.info("eveai_app starting up")
|
logger.info("eveai_app starting up")
|
||||||
|
|
||||||
@@ -92,6 +100,45 @@ def create_app(config_file=None):
|
|||||||
# app.logger.debug(f"Before request - Session data: {session}")
|
# app.logger.debug(f"Before request - Session data: {session}")
|
||||||
# app.logger.debug(f"Before request - Request headers: {request.headers}")
|
# app.logger.debug(f"Before request - Request headers: {request.headers}")
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
from flask import session, request
|
||||||
|
from flask_login import current_user
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
app.logger.debug(f"Before request - URL: {request.url}")
|
||||||
|
app.logger.debug(f"Before request - Session permanent: {session.permanent}")
|
||||||
|
|
||||||
|
# Log session expiry tijd als deze bestaat
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
# Controleer of sessie permanent is (nodig voor PERMANENT_SESSION_LIFETIME)
|
||||||
|
if not session.permanent:
|
||||||
|
session.permanent = True
|
||||||
|
app.logger.debug("Session marked as permanent (enables 60min timeout)")
|
||||||
|
|
||||||
|
# Log wanneer sessie zou verlopen
|
||||||
|
if '_permanent' in session:
|
||||||
|
expires_at = datetime.datetime.now() + app.permanent_session_lifetime
|
||||||
|
app.logger.debug(f"Session will expire at: {expires_at} (60 min from now)")
|
||||||
|
|
||||||
|
@app.route('/debug/session')
|
||||||
|
def debug_session():
|
||||||
|
from flask import session
|
||||||
|
from flask_security import current_user
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
info = {
|
||||||
|
'session_permanent': session.permanent,
|
||||||
|
'session_lifetime_minutes': app.permanent_session_lifetime.total_seconds() / 60,
|
||||||
|
'session_refresh_enabled': app.config.get('SESSION_REFRESH_EACH_REQUEST'),
|
||||||
|
'current_time': datetime.datetime.now().isoformat(),
|
||||||
|
'session_data_keys': list(session.keys())
|
||||||
|
}
|
||||||
|
return jsonify(info)
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Not authenticated'})
|
||||||
|
|
||||||
# Register template filters
|
# Register template filters
|
||||||
register_filters(app)
|
register_filters(app)
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,18 @@
|
|||||||
|
|
||||||
{% block content_title %}Document Versions{% endblock %}
|
{% block content_title %}Document Versions{% endblock %}
|
||||||
{% block content_description %}View Versions for {{ document }}{% endblock %}
|
{% block content_description %}View Versions for {{ document }}{% endblock %}
|
||||||
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto">{% endblock %}
|
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<form method="POST" action="{{ url_for('document_bp.handle_document_version_selection') }}">
|
<form method="POST" action="{{ url_for('document_bp.handle_document_version_selection') }}" id="documentVersionsForm">
|
||||||
{{ render_selectable_table(headers=["ID", "URL", "Object Name", "File Type", "Process.", "Proces. Start", "Proces. Finish", "Proces. Error"], rows=rows, selectable=True, id="versionsTable") }}
|
{{ render_selectable_table(headers=["ID", "URL", "Object Name", "File Type", "Process.", "Proces. Start", "Proces. Finish", "Proces. Error"], rows=rows, selectable=True, id="versionsTable") }}
|
||||||
<div class="form-group mt-3">
|
<div class="form-group mt-3 d-flex justify-content-between">
|
||||||
<button type="submit" name="action" value="edit_document_version" class="btn btn-primary">Edit Document Version</button>
|
<div>
|
||||||
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger">View Processed Document</button>
|
<button type="submit" name="action" value="edit_document_version" class="btn btn-primary" onclick="return validateTableSelection('documentVersionsForm')">Edit Document Version</button>
|
||||||
<button type="submit" name="action" value="process_document_version" class="btn btn-danger">Process Document Version</button>
|
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">View Processed Document</button>
|
||||||
|
<button type="submit" name="action" value="process_document_version" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">Process Document Version</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
<div class="form-group mt-3 d-flex justify-content-between">
|
<div class="form-group mt-3 d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" name="action" value="edit_document_version" class="btn btn-primary" onclick="return validateTableSelection('documentVersionsForm')">Edit Document Version</button>
|
<button type="submit" name="action" value="edit_document_version" class="btn btn-primary" onclick="return validateTableSelection('documentVersionsForm')">Edit Document Version</button>
|
||||||
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger">View Processed Document</button>
|
<button type="submit" name="action" value="view_document_version_markdown" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">View Processed Document</button>
|
||||||
<button type="submit" name="action" value="process_document_version" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">Process Document Version</button>
|
<button type="submit" name="action" value="process_document_version" class="btn btn-danger" onclick="return validateTableSelection('documentVersionsForm')">Process Document Version</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,11 +9,30 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
{% set disabled_fields = ['magic_link_code'] %}
|
{% set disabled_fields = ['magic_link_code', 'chat_client_url', 'qr_code_url'] %}
|
||||||
{% set exclude_fields = [] %}
|
{% set exclude_fields = [] %}
|
||||||
<!-- Render Static Fields -->
|
<!-- Render Static Fields -->
|
||||||
{% for field in form.get_static_fields() %}
|
{% for field in form.get_static_fields() %}
|
||||||
{{ render_field(field, disabled_fields, exclude_fields) }}
|
{% if field.name == 'qr_code_url' and field.data %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ field.id }}">{{ field.label.text }}</label>
|
||||||
|
<div style="max-width: 200px;">
|
||||||
|
<img src="{{ field.data }}" alt="QR Code" class="img-fluid">
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="{{ field.name }}" value="{{ field.data|e }}">
|
||||||
|
</div>
|
||||||
|
{% elif field.name == 'chat_client_url' %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ field.id }}" class="form-label">{{ field.label.text }}</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" value="{{ field.data }}" id="{{ field.id }}" readonly>
|
||||||
|
<a href="{{ field.data }}" class="btn btn-primary" target="_blank">Open link</a>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="{{ field.name }}" value="{{ field.data|e }}">
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<!-- Render Dynamic Fields -->
|
<!-- Render Dynamic Fields -->
|
||||||
{% for collection_name, fields in form.get_dynamic_fields().items() %}
|
{% for collection_name, fields in form.get_dynamic_fields().items() %}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<form method="POST" action="{{ url_for('interaction_bp.handle_specialist_selection') }}" id="specialistsForm">
|
<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 class="form-group mt-3 d-flex justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" name="action" value="edit_specialist" class="btn btn-primary" onclick="return validateTableSelection('specialistsForm')">Edit Specialist</button>
|
<button type="submit" name="action" value="edit_specialist" class="btn btn-primary" onclick="return validateTableSelection('specialistsForm')">Edit Specialist</button>
|
||||||
|
|||||||
@@ -8,7 +8,19 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro render_field_content(field, disabled=False, readonly=False, class='') %}
|
{% 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-group">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
{{ field(class="form-check-input " + class, disabled=disabled, readonly=readonly, required=False) }}
|
{{ field(class="form-check-input " + class, disabled=disabled, readonly=readonly, required=False) }}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
{'name': 'Tenant Overview', 'url': '/user/tenant_overview', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Tenant Overview', 'url': '/user/tenant_overview', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
{'name': 'Edit Tenant', 'url': '/user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Edit Tenant', 'url': '/user/tenant/' ~ session['tenant'].get('id'), 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
{'name': 'Tenant Domains', 'url': '/user/view_tenant_domains', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Tenant Domains', 'url': '/user/view_tenant_domains', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
|
{'name': 'Tenant Makes', 'url': '/user/tenant_makes', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
{'name': 'Tenant Projects', 'url': '/user/tenant_projects', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Tenant Projects', 'url': '/user/tenant_projects', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
{'name': 'Users', 'url': '/user/view_users', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
{'name': 'Users', 'url': '/user/view_users', 'roles': ['Super User', 'Partner Admin', 'Tenant Admin']},
|
||||||
]) }}
|
]) }}
|
||||||
|
|||||||
33
eveai_app/templates/user/edit_tenant_make.html
Normal file
33
eveai_app/templates/user/edit_tenant_make.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% from "macros.html" import render_field %}
|
||||||
|
|
||||||
|
{% block title %}Edit Tenant Make{% endblock %}
|
||||||
|
|
||||||
|
{% block content_title %}Edit Tenant Make{% endblock %}
|
||||||
|
{% block content_description %}Edit a Tenant Make.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
{% set disabled_fields = [] %}
|
||||||
|
{% set exclude_fields = [] %}
|
||||||
|
<!-- Render Static Fields -->
|
||||||
|
{% for field in form.get_static_fields() %}
|
||||||
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
|
{% endfor %}
|
||||||
|
<!-- Render Dynamic Fields -->
|
||||||
|
{% for collection_name, fields in form.get_dynamic_fields().items() %}
|
||||||
|
{% if fields|length > 0 %}
|
||||||
|
<h4 class="mt-4">{{ collection_name }}</h4>
|
||||||
|
{% endif %}
|
||||||
|
{% for field in fields %}
|
||||||
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-primary">Save Tenant Make</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content_footer %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
32
eveai_app/templates/user/tenant_make.html
Normal file
32
eveai_app/templates/user/tenant_make.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% from "macros.html" import render_field %}
|
||||||
|
|
||||||
|
{% block title %}Tenant Make Registration{% endblock %}
|
||||||
|
|
||||||
|
{% block content_title %}Register Tenant Make{% endblock %}
|
||||||
|
{% block content_description %}Define a new tenant make{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
{% set disabled_fields = [] %}
|
||||||
|
{% set exclude_fields = [] %}
|
||||||
|
{% for field in form.get_static_fields() %}
|
||||||
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
|
{% endfor %}
|
||||||
|
<!-- Render Dynamic Fields -->
|
||||||
|
{% for collection_name, fields in form.get_dynamic_fields().items() %}
|
||||||
|
{% if fields|length > 0 %}
|
||||||
|
<h4 class="mt-4">{{ collection_name }}</h4>
|
||||||
|
{% endif %}
|
||||||
|
{% for field in fields %}
|
||||||
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn btn-primary">Register Tenant Make</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content_footer %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
26
eveai_app/templates/user/tenant_makes.html
Normal file
26
eveai_app/templates/user/tenant_makes.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% from 'macros.html' import render_selectable_table, render_pagination %}
|
||||||
|
|
||||||
|
{% block title %}Tenant Makes{% endblock %}
|
||||||
|
|
||||||
|
{% block content_title %}Tenant Makes{% endblock %}
|
||||||
|
{% block content_description %}View Tenant Makes for Tenant{% endblock %}
|
||||||
|
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<form method="POST" action="{{ url_for('user_bp.handle_tenant_make_selection') }}" id="tenantMakesForm">
|
||||||
|
{{ render_selectable_table(headers=["Tenant Make ID", "Name", "Website", "Active"], rows=rows, selectable=True, id="tenantMakesTable") }}
|
||||||
|
<div class="form-group mt-3 d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<button type="submit" name="action" value="edit_tenant_make" class="btn btn-primary" onclick="return validateTableSelection('tenantMakesForm')">Edit Tenant Make</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" name="action" value="create_tenant_make" class="btn btn-success">Register Tenant Make</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content_footer %}
|
||||||
|
{{ render_pagination(pagination, "user_bp.tenant_makes") }}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% from "macros.html" import render_field, render_included_field %}
|
{% from "macros.html" import render_field, render_included_field, debug_to_console %}
|
||||||
|
|
||||||
{% block title %}Tenant Overview{% endblock %}
|
{% block title %}Tenant Overview{% endblock %}
|
||||||
|
|
||||||
@@ -9,162 +9,23 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<!-- Main Tenant Information -->
|
{% set disabled_fields = [] %}
|
||||||
{% set main_fields = ['name', 'code', 'website', 'default_language', 'allowed_languages', 'type'] %}
|
|
||||||
{% for field in form %}
|
{% for field in form %}
|
||||||
{{ render_included_field(field, disabled_fields=main_fields, include_fields=main_fields) }}
|
{{ debug_to_console('field to disable', field.name) }}
|
||||||
|
{{ debug_to_console('field type to disable', field.type) }}
|
||||||
|
{% if field.name != 'csrf_token' and field.type != 'HiddenField' %}
|
||||||
|
{% set disabled_fields = disabled_fields + [field.name] %}
|
||||||
|
{{ debug_to_console('disable', '!') }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ debug_to_console('disabled_fields', disabled_fields) }}
|
||||||
|
{% set exclude_fields = [] %}
|
||||||
|
{% for field in form %}
|
||||||
|
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<!-- Nav Tabs -->
|
|
||||||
<div class="row mt-5">
|
|
||||||
<div class="col-lg-12">
|
|
||||||
<div class="nav-wrapper position-relative end-0">
|
|
||||||
<ul class="nav nav-pills nav-fill p-1" role="tablist">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#license-info-tab" role="tab" aria-controls="license-info" aria-selected="false">
|
|
||||||
License Information
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="tab-content tab-space">
|
|
||||||
<!-- License Information Tab -->
|
|
||||||
<div class="tab-pane fade" id="license-info-tab" role="tabpanel">
|
|
||||||
{% set license_fields = ['currency', 'usage_email', ] %}
|
|
||||||
{% for field in form %}
|
|
||||||
{{ render_included_field(field, disabled_fields=license_fields, include_fields=license_fields) }}
|
|
||||||
{% endfor %}
|
|
||||||
<!-- Register API Key Button -->
|
|
||||||
<button type="button" class="btn btn-primary" onclick="generateNewChatApiKey()">Register Chat API Key</button>
|
|
||||||
<button type="button" class="btn btn-primary" onclick="generateNewApiKey()">Register API Key</button>
|
|
||||||
<!-- API Key Display Field -->
|
|
||||||
<div id="chat-api-key-field" style="display:none;">
|
|
||||||
<label for="chat-api-key">Chat API Key:</label>
|
|
||||||
<input type="text" id="chat-api-key" class="form-control" readonly>
|
|
||||||
<button type="button" id="copy-chat-button" class="btn btn-primary">Copy to Clipboard</button>
|
|
||||||
<p id="copy-chat-message" style="display:none;color:green;">Chat API key copied to clipboard</p>
|
|
||||||
</div>
|
|
||||||
<div id="api-key-field" style="display:none;">
|
|
||||||
<label for="api-key">API Key:</label>
|
|
||||||
<input type="text" id="api-key" class="form-control" readonly>
|
|
||||||
<button type="button" id="copy-api-button" class="btn btn-primary">Copy to Clipboard</button>
|
|
||||||
<p id="copy-message" style="display:none;color:green;">API key copied to clipboard</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
{% block content_footer %}
|
{% block content_footer %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script>
|
|
||||||
// Function to generate a new Chat API Key
|
|
||||||
function generateNewChatApiKey() {
|
|
||||||
generateApiKey('/admin/user/generate_chat_api_key', '#chat-api-key', '#chat-api-key-field');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to generate a new general API Key
|
|
||||||
function generateNewApiKey() {
|
|
||||||
generateApiKey('/admin/user/generate_api_api_key', '#api-key', '#api-key-field');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reusable function to handle API key generation
|
|
||||||
function generateApiKey(url, inputSelector, fieldSelector) {
|
|
||||||
$.ajax({
|
|
||||||
url: url,
|
|
||||||
type: 'POST',
|
|
||||||
contentType: 'application/json',
|
|
||||||
success: function(response) {
|
|
||||||
$(inputSelector).val(response.api_key);
|
|
||||||
$(fieldSelector).show();
|
|
||||||
},
|
|
||||||
error: function(error) {
|
|
||||||
alert('Error generating new API key: ' + error.responseText);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to copy text to clipboard
|
|
||||||
function copyToClipboard(selector, messageSelector) {
|
|
||||||
const element = document.querySelector(selector);
|
|
||||||
if (element) {
|
|
||||||
const text = element.value;
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard.writeText(text).then(function() {
|
|
||||||
showCopyMessage(messageSelector);
|
|
||||||
}).catch(function(error) {
|
|
||||||
alert('Failed to copy text: ' + error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
fallbackCopyToClipboard(text, messageSelector);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error('Element not found for selector:', selector);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback method for copying text to clipboard
|
|
||||||
function fallbackCopyToClipboard(text, messageSelector) {
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = text;
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.focus();
|
|
||||||
textArea.select();
|
|
||||||
try {
|
|
||||||
document.execCommand('copy');
|
|
||||||
showCopyMessage(messageSelector);
|
|
||||||
} catch (err) {
|
|
||||||
alert('Fallback: Oops, unable to copy', err);
|
|
||||||
}
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to show copy confirmation message
|
|
||||||
function showCopyMessage(messageSelector) {
|
|
||||||
const message = document.querySelector(messageSelector);
|
|
||||||
if (message) {
|
|
||||||
message.style.display = 'block';
|
|
||||||
setTimeout(function() {
|
|
||||||
message.style.display = 'none';
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners for copy buttons
|
|
||||||
document.getElementById('copy-chat-button').addEventListener('click', function() {
|
|
||||||
copyToClipboard('#chat-api-key', '#copy-chat-message');
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('copy-api-button').addEventListener('click', function() {
|
|
||||||
copyToClipboard('#api-key', '#copy-message');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
// JavaScript to detect user's timezone
|
|
||||||
document.addEventListener('DOMContentLoaded', (event) => {
|
|
||||||
// Detect timezone
|
|
||||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
||||||
|
|
||||||
// Send timezone to the server via a POST request
|
|
||||||
fetch('/set_user_timezone', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ timezone: userTimezone })
|
|
||||||
}).then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('Timezone sent to server successfully');
|
|
||||||
} else {
|
|
||||||
console.error('Failed to send timezone to server');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from flask import session
|
from flask import session, current_app
|
||||||
from flask_security import current_user
|
from flask_security import current_user
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, SelectField
|
from wtforms import StringField, SelectField
|
||||||
@@ -36,7 +36,7 @@ class SessionDefaultsForm(FlaskForm):
|
|||||||
else:
|
else:
|
||||||
self.partner_name.data = ""
|
self.partner_name.data = ""
|
||||||
self.default_language.choices = [(lang, lang.lower()) for lang in
|
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')
|
self.default_language.data = session.get('default_language')
|
||||||
|
|
||||||
# Get a new session for catalog queries
|
# Get a new session for catalog queries
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from wtforms.validators import DataRequired, Length, Optional, URL, ValidationEr
|
|||||||
from flask_wtf.file import FileField, FileRequired
|
from flask_wtf.file import FileField, FileRequired
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from wtforms.widgets.core import HiddenInput
|
||||||
from wtforms_sqlalchemy.fields import QuerySelectField
|
from wtforms_sqlalchemy.fields import QuerySelectField
|
||||||
|
|
||||||
from common.extensions import cache_manager
|
from common.extensions import cache_manager
|
||||||
@@ -17,8 +18,15 @@ from config.type_defs.processor_types import PROCESSOR_TYPES
|
|||||||
from .dynamic_form_base import DynamicFormBase
|
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 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.')
|
||||||
|
|
||||||
|
|
||||||
class CatalogForm(FlaskForm):
|
class CatalogForm(FlaskForm):
|
||||||
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
|
name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_catalog_name])
|
||||||
description = TextAreaField('Description', validators=[Optional()])
|
description = TextAreaField('Description', validators=[Optional()])
|
||||||
|
|
||||||
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)
|
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)
|
||||||
@@ -41,7 +49,8 @@ class CatalogForm(FlaskForm):
|
|||||||
|
|
||||||
|
|
||||||
class EditCatalogForm(DynamicFormBase):
|
class EditCatalogForm(DynamicFormBase):
|
||||||
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
|
id = IntegerField('ID', widget=HiddenInput())
|
||||||
|
name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_catalog_name])
|
||||||
description = TextAreaField('Description', validators=[Optional()])
|
description = TextAreaField('Description', validators=[Optional()])
|
||||||
|
|
||||||
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)
|
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)
|
||||||
@@ -181,7 +190,7 @@ class AddDocumentForm(DynamicFormBase):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.language.choices = [(language, language) for language in
|
self.language.choices = [(language, language) for language in
|
||||||
session.get('tenant').get('allowed_languages')]
|
current_app.config['SUPPORTED_LANGUAGES']]
|
||||||
if not self.language.data:
|
if not self.language.data:
|
||||||
self.language.data = session.get('tenant').get('default_language')
|
self.language.data = session.get('tenant').get('default_language')
|
||||||
|
|
||||||
@@ -201,7 +210,7 @@ class AddURLForm(DynamicFormBase):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.language.choices = [(language, language) for language in
|
self.language.choices = [(language, language) for language in
|
||||||
session.get('tenant').get('allowed_languages')]
|
current_app.config['SUPPORTED_LANGUAGES']]
|
||||||
if not self.language.data:
|
if not self.language.data:
|
||||||
self.language.data = session.get('tenant').get('default_language')
|
self.language.data = session.get('tenant').get('default_language')
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ from datetime import date
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField,
|
from wtforms import (IntegerField, FloatField, BooleanField, StringField, TextAreaField, FileField,
|
||||||
validators, ValidationError)
|
validators, ValidationError)
|
||||||
from flask import current_app, request
|
from flask import current_app, request, session
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from wtforms.fields.choices import SelectField
|
from wtforms.fields.choices import SelectField
|
||||||
from wtforms.fields.datetime import DateField
|
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
|
from common.utils.config_field_types import TaggingFields, json_to_patterns, patterns_to_json
|
||||||
|
|
||||||
|
|
||||||
@@ -299,6 +302,22 @@ class DynamicFormBase(FlaskForm):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValidationError(f"Invalid ordered list: {str(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):
|
def add_dynamic_fields(self, collection_name, config, initial_data=None):
|
||||||
"""Add dynamic fields to the form based on the configuration.
|
"""Add dynamic fields to the form based on the configuration.
|
||||||
|
|
||||||
@@ -356,11 +375,12 @@ class DynamicFormBase(FlaskForm):
|
|||||||
extra_classes = ['monospace-text', 'pattern-input']
|
extra_classes = ['monospace-text', 'pattern-input']
|
||||||
field_kwargs = {}
|
field_kwargs = {}
|
||||||
elif field_type == 'ordered_list':
|
elif field_type == 'ordered_list':
|
||||||
current_app.logger.debug(f"Adding ordered list field for {full_field_name}")
|
|
||||||
field_class = OrderedListField
|
field_class = OrderedListField
|
||||||
extra_classes = ''
|
extra_classes = ''
|
||||||
list_type = field_def.get('list_type', '')
|
list_type = field_def.get('list_type', '')
|
||||||
field_kwargs = {'list_type': 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:
|
else:
|
||||||
extra_classes = ''
|
extra_classes = ''
|
||||||
field_class = {
|
field_class = {
|
||||||
@@ -372,6 +392,7 @@ class DynamicFormBase(FlaskForm):
|
|||||||
'text': TextAreaField,
|
'text': TextAreaField,
|
||||||
'date': DateField,
|
'date': DateField,
|
||||||
'file': FileField,
|
'file': FileField,
|
||||||
|
'color': ColorField,
|
||||||
}.get(field_type, StringField)
|
}.get(field_type, StringField)
|
||||||
field_kwargs = {}
|
field_kwargs = {}
|
||||||
|
|
||||||
@@ -414,6 +435,14 @@ class DynamicFormBase(FlaskForm):
|
|||||||
render_kw['data-bs-toggle'] = 'tooltip'
|
render_kw['data-bs-toggle'] = 'tooltip'
|
||||||
render_kw['data-bs-placement'] = 'right'
|
render_kw['data-bs-placement'] = 'right'
|
||||||
|
|
||||||
|
# Add special styling for color fields to make them more compact and visible
|
||||||
|
if field_type == 'color':
|
||||||
|
render_kw['style'] = 'width: 100px; height: 40px;'
|
||||||
|
if 'class' in render_kw:
|
||||||
|
render_kw['class'] = f"{render_kw['class']} color-field"
|
||||||
|
else:
|
||||||
|
render_kw['class'] = 'color-field'
|
||||||
|
|
||||||
|
|
||||||
current_app.logger.debug(f"render_kw for {full_field_name}: {render_kw}")
|
current_app.logger.debug(f"render_kw for {full_field_name}: {render_kw}")
|
||||||
|
|
||||||
@@ -603,7 +632,7 @@ def validate_tagging_fields(form, field):
|
|||||||
raise ValidationError(f"Field {field_name} missing required 'type' property")
|
raise ValidationError(f"Field {field_name} missing required 'type' property")
|
||||||
|
|
||||||
# Validate type
|
# Validate type
|
||||||
if field_def['type'] not in ['string', 'integer', 'float', 'date', 'enum']:
|
if field_def['type'] not in ['string', 'integer', 'float', 'date', 'enum', 'color']:
|
||||||
raise ValidationError(f"Field {field_name} has invalid type: {field_def['type']}")
|
raise ValidationError(f"Field {field_name} has invalid type: {field_def['type']}")
|
||||||
|
|
||||||
# Validate enum fields have allowed_values
|
# Validate enum fields have allowed_values
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ def view_usages():
|
|||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get('page', 1, type=int)
|
||||||
per_page = request.args.get('per_page', 10, type=int)
|
per_page = request.args.get('per_page', 10, type=int)
|
||||||
|
|
||||||
|
if not session.get('tenant', None):
|
||||||
|
flash('You can only view usage for a Tenant. Select a Tenant to continue!', 'danger')
|
||||||
|
return redirect(prefixed_url_for('user_bp.select_tenant'))
|
||||||
|
|
||||||
tenant_id = session.get('tenant').get('id')
|
tenant_id = session.get('tenant').get('id')
|
||||||
query = LicenseUsage.query.filter_by(tenant_id=tenant_id).order_by(desc(LicenseUsage.id))
|
query = LicenseUsage.query.filter_by(tenant_id=tenant_id).order_by(desc(LicenseUsage.id))
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from wtforms_sqlalchemy.fields import QuerySelectMultipleField
|
|||||||
|
|
||||||
from common.models.document import Retriever
|
from common.models.document import Retriever
|
||||||
from common.models.interaction import EveAITool, Specialist
|
from common.models.interaction import EveAITool, Specialist
|
||||||
|
from common.models.user import TenantMake
|
||||||
from common.extensions import cache_manager
|
from common.extensions import cache_manager
|
||||||
from common.utils.form_assistants import validate_json
|
from common.utils.form_assistants import validate_json
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ def get_tools():
|
|||||||
|
|
||||||
class SpecialistForm(FlaskForm):
|
class SpecialistForm(FlaskForm):
|
||||||
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
|
name = StringField('Name', validators=[DataRequired(), Length(max=50)])
|
||||||
|
description = TextAreaField('Description', validators=[Optional()])
|
||||||
|
|
||||||
retrievers = QuerySelectMultipleField(
|
retrievers = QuerySelectMultipleField(
|
||||||
'Retrievers',
|
'Retrievers',
|
||||||
@@ -34,7 +36,7 @@ class SpecialistForm(FlaskForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
type = SelectField('Specialist Type', validators=[DataRequired()])
|
type = SelectField('Specialist Type', validators=[DataRequired()])
|
||||||
|
active = BooleanField('Active', validators=[Optional()], default=True)
|
||||||
tuning = BooleanField('Enable Specialist Tuning', default=False)
|
tuning = BooleanField('Enable Specialist Tuning', default=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -47,6 +49,7 @@ class SpecialistForm(FlaskForm):
|
|||||||
class EditSpecialistForm(DynamicFormBase):
|
class EditSpecialistForm(DynamicFormBase):
|
||||||
name = StringField('Name', validators=[DataRequired()])
|
name = StringField('Name', validators=[DataRequired()])
|
||||||
description = TextAreaField('Description', validators=[Optional()])
|
description = TextAreaField('Description', validators=[Optional()])
|
||||||
|
active = BooleanField('Active', validators=[Optional()], default=True)
|
||||||
|
|
||||||
retrievers = QuerySelectMultipleField(
|
retrievers = QuerySelectMultipleField(
|
||||||
'Retrievers',
|
'Retrievers',
|
||||||
@@ -148,7 +151,7 @@ class SpecialistMagicLinkForm(FlaskForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
specialists = Specialist.query.all()
|
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]
|
self.specialist_id.choices = [(specialist.id, specialist.name) for specialist in specialists]
|
||||||
|
|
||||||
|
|
||||||
@@ -159,6 +162,9 @@ class EditSpecialistMagicLinkForm(DynamicFormBase):
|
|||||||
render_kw={'readonly': True})
|
render_kw={'readonly': True})
|
||||||
specialist_id = IntegerField('Specialist', validators=[DataRequired()], 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})
|
specialist_name = StringField('Specialist Name', validators=[DataRequired()], render_kw={'readonly': True})
|
||||||
|
chat_client_url = StringField('Chat Client URL', validators=[Optional()], render_kw={'readonly': True})
|
||||||
|
qr_code_url = StringField('QR Code', validators=[Optional()], 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_from = DateField('Valid From', id='form-control datepicker', validators=[Optional()])
|
||||||
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
|
valid_to = DateField('Valid To', id='form-control datepicker', validators=[Optional()])
|
||||||
|
|
||||||
@@ -174,5 +180,10 @@ class EditSpecialistMagicLinkForm(DynamicFormBase):
|
|||||||
else:
|
else:
|
||||||
self.specialist_name.data = ''
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ def specialist():
|
|||||||
new_specialist.type = form.type.data
|
new_specialist.type = form.type.data
|
||||||
new_specialist.type_version = cache_manager.specialists_version_tree_cache.get_latest_version(
|
new_specialist.type_version = cache_manager.specialists_version_tree_cache.get_latest_version(
|
||||||
new_specialist.type)
|
new_specialist.type)
|
||||||
|
new_specialist.active = form.active.data
|
||||||
new_specialist.tuning = form.tuning.data
|
new_specialist.tuning = form.tuning.data
|
||||||
|
|
||||||
set_logging_information(new_specialist, dt.now(tz.utc))
|
set_logging_information(new_specialist, dt.now(tz.utc))
|
||||||
@@ -231,6 +232,7 @@ def edit_specialist(specialist_id):
|
|||||||
specialist.name = form.name.data
|
specialist.name = form.name.data
|
||||||
specialist.description = form.description.data
|
specialist.description = form.description.data
|
||||||
specialist.tuning = form.tuning.data
|
specialist.tuning = form.tuning.data
|
||||||
|
specialist.active = form.active.data
|
||||||
# Update the configuration dynamic fields
|
# Update the configuration dynamic fields
|
||||||
specialist.configuration = form.get_dynamic_data("configuration")
|
specialist.configuration = form.get_dynamic_data("configuration")
|
||||||
|
|
||||||
@@ -297,7 +299,7 @@ def specialists():
|
|||||||
|
|
||||||
# prepare table data
|
# prepare table data
|
||||||
rows = prepare_table_for_macro(the_specialists,
|
rows = prepare_table_for_macro(the_specialists,
|
||||||
[('id', ''), ('name', ''), ('type', '')])
|
[('id', ''), ('name', ''), ('type', ''), ('type_version', ''), ('active', ''),])
|
||||||
|
|
||||||
# Render the catalogs in a template
|
# Render the catalogs in a template
|
||||||
return render_template('interaction/specialists.html', rows=rows, pagination=pagination)
|
return render_template('interaction/specialists.html', rows=rows, pagination=pagination)
|
||||||
@@ -689,7 +691,7 @@ def specialist_magic_link():
|
|||||||
try:
|
try:
|
||||||
new_specialist_magic_link = SpecialistMagicLink()
|
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)
|
form.populate_obj(new_specialist_magic_link)
|
||||||
|
|
||||||
set_logging_information(new_specialist_magic_link, dt.now(tz.utc))
|
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.magic_link_code = new_specialist_magic_link.magic_link_code
|
||||||
new_spec_ml_tenant.tenant_id = tenant_id
|
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_specialist_magic_link)
|
||||||
db.session.add(new_spec_ml_tenant)
|
db.session.add(new_spec_ml_tenant)
|
||||||
|
|
||||||
@@ -731,12 +741,73 @@ def edit_specialist_magic_link(specialist_magic_link_id):
|
|||||||
|
|
||||||
form.add_dynamic_fields("arguments", specialist_config, specialist_ml.specialist_args)
|
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
|
||||||
|
|
||||||
|
# Set the chat client URL
|
||||||
|
tenant_id = session.get('tenant').get('id')
|
||||||
|
chat_client_prefix = current_app.config.get('CHAT_CLIENT_PREFIX', 'chat_client/chat/')
|
||||||
|
base_url = request.url_root
|
||||||
|
magic_link_code = specialist_ml.magic_link_code
|
||||||
|
|
||||||
|
# Parse the URL om poortinformatie te behouden als deze afwijkt van de standaard
|
||||||
|
url_parts = request.url.split('/')
|
||||||
|
host_port = url_parts[2] # Dit bevat zowel hostname als poort indien aanwezig
|
||||||
|
|
||||||
|
# Generate the full URL for chat client with magic link code
|
||||||
|
chat_client_url = f"{request.scheme}://{host_port}/{chat_client_prefix}{magic_link_code}"
|
||||||
|
form.chat_client_url.data = chat_client_url
|
||||||
|
|
||||||
|
# Generate QR code as data URI for direct embedding in HTML
|
||||||
|
try:
|
||||||
|
import qrcode
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Generate QR code as PNG for better compatibility
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=1,
|
||||||
|
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||||
|
box_size=10,
|
||||||
|
border=4
|
||||||
|
)
|
||||||
|
qr.add_data(chat_client_url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
|
||||||
|
# Generate PNG image in memory
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
img_data = buffer.getvalue()
|
||||||
|
|
||||||
|
# Create data URI for direct embedding in HTML
|
||||||
|
img_base64 = base64.b64encode(img_data).decode('utf-8')
|
||||||
|
data_uri = f"data:image/png;base64,{img_base64}"
|
||||||
|
|
||||||
|
# Store the data URI in the form data
|
||||||
|
form.qr_code_url.data = data_uri
|
||||||
|
|
||||||
|
current_app.logger.debug(f"QR code generated successfully for {magic_link_code}")
|
||||||
|
current_app.logger.debug(f"QR code data URI starts with: {data_uri[:50]}...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Failed to generate QR code: {str(e)}")
|
||||||
|
form.qr_code_url.data = "Error generating QR code"
|
||||||
|
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
# Update the basic fields
|
# Update the basic fields
|
||||||
form.populate_obj(specialist_ml)
|
form.populate_obj(specialist_ml)
|
||||||
# Update the arguments dynamic fields
|
# Update the arguments dynamic fields
|
||||||
specialist_ml.specialist_args = form.get_dynamic_data("arguments")
|
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
|
||||||
update_logging_information(specialist_ml, dt.now(tz.utc))
|
update_logging_information(specialist_ml, dt.now(tz.utc))
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ from flask import current_app, session
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import (StringField, BooleanField, SubmitField, EmailField, IntegerField, DateField,
|
from wtforms import (StringField, BooleanField, SubmitField, EmailField, IntegerField, DateField,
|
||||||
SelectField, SelectMultipleField, FieldList, FormField, TextAreaField)
|
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
|
import pytz
|
||||||
from flask_security import current_user
|
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 common.services.user import UserServices
|
||||||
from config.type_defs.service_types import SERVICE_TYPES
|
from config.type_defs.service_types import SERVICE_TYPES
|
||||||
|
from eveai_app.views.dynamic_form_base import DynamicFormBase
|
||||||
|
|
||||||
|
|
||||||
class TenantForm(FlaskForm):
|
class TenantForm(FlaskForm):
|
||||||
@@ -17,7 +20,6 @@ class TenantForm(FlaskForm):
|
|||||||
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
|
website = StringField('Website', validators=[DataRequired(), Length(max=255)])
|
||||||
# language fields
|
# language fields
|
||||||
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
|
default_language = SelectField('Default Language', choices=[], validators=[DataRequired()])
|
||||||
allowed_languages = SelectMultipleField('Allowed Languages', choices=[], validators=[DataRequired()])
|
|
||||||
# invoicing fields
|
# invoicing fields
|
||||||
currency = SelectField('Currency', choices=[], validators=[DataRequired()])
|
currency = SelectField('Currency', choices=[], validators=[DataRequired()])
|
||||||
# Timezone
|
# Timezone
|
||||||
@@ -32,13 +34,56 @@ class TenantForm(FlaskForm):
|
|||||||
super(TenantForm, self).__init__(*args, **kwargs)
|
super(TenantForm, self).__init__(*args, **kwargs)
|
||||||
# initialise language fields
|
# initialise language fields
|
||||||
self.default_language.choices = [(lang, lang.lower()) for lang in current_app.config['SUPPORTED_LANGUAGES']]
|
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
|
# initialise currency field
|
||||||
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
|
self.currency.choices = [(curr, curr) for curr in current_app.config['SUPPORTED_CURRENCIES']]
|
||||||
# initialise timezone
|
# initialise timezone
|
||||||
self.timezone.choices = [(tz, tz) for tz in pytz.common_timezones]
|
self.timezone.choices = [(tz, tz) for tz in pytz.common_timezones]
|
||||||
# Initialize fallback algorithms
|
# Initialize fallback algorithms
|
||||||
self.type.choices = [(t, t) for t in current_app.config['TENANT_TYPES']]
|
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
|
# Show field only for Super Users with partner in session
|
||||||
if not current_user.has_roles('Super User') or 'partner' not in session:
|
if not current_user.has_roles('Super User') or 'partner' not in session:
|
||||||
self._fields.pop('assign_to_partner', None)
|
self._fields.pop('assign_to_partner', None)
|
||||||
@@ -131,4 +176,35 @@ class EditTenantProjectForm(FlaskForm):
|
|||||||
self.services.choices = [(key, value['description']) for key, value in SERVICE_TYPES.items()]
|
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), 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)])
|
||||||
|
logo_url = StringField('Logo URL', validators=[Optional(), Length(max=255)])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime as dt, timezone as tz
|
from datetime import datetime as dt, timezone as tz
|
||||||
from flask import request, redirect, flash, render_template, Blueprint, session, current_app
|
from flask import request, redirect, flash, render_template, Blueprint, session, current_app
|
||||||
@@ -5,12 +6,13 @@ from flask_security import roles_accepted, current_user
|
|||||||
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
||||||
import ast
|
import ast
|
||||||
|
|
||||||
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant
|
from common.models.user import User, Tenant, Role, TenantDomain, TenantProject, PartnerTenant, TenantMake
|
||||||
from common.extensions import db, security, minio_client, simple_encryption
|
from common.extensions import db, security, minio_client, simple_encryption, cache_manager
|
||||||
|
from common.utils.dynamic_field_utils import create_default_config_from_type_config
|
||||||
from common.utils.security_utils import send_confirmation_email, send_reset_email
|
from common.utils.security_utils import send_confirmation_email, send_reset_email
|
||||||
from config.type_defs.service_types import SERVICE_TYPES
|
from config.type_defs.service_types import SERVICE_TYPES
|
||||||
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
|
from .user_forms import TenantForm, CreateUserForm, EditUserForm, TenantDomainForm, TenantSelectionForm, \
|
||||||
TenantProjectForm, EditTenantProjectForm
|
TenantProjectForm, EditTenantProjectForm, TenantMakeForm, EditTenantForm, EditTenantMakeForm
|
||||||
from common.utils.database import Database
|
from common.utils.database import Database
|
||||||
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
|
from common.utils.view_assistants import prepare_table_for_macro, form_validation_failed
|
||||||
from common.utils.simple_encryption import generate_api_key
|
from common.utils.simple_encryption import generate_api_key
|
||||||
@@ -110,12 +112,18 @@ def tenant():
|
|||||||
@roles_accepted('Super User', 'Partner Admin')
|
@roles_accepted('Super User', 'Partner Admin')
|
||||||
def edit_tenant(tenant_id):
|
def edit_tenant(tenant_id):
|
||||||
tenant = Tenant.query.get_or_404(tenant_id) # This will return a 404 if no tenant is found
|
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():
|
if form.validate_on_submit():
|
||||||
# Populate the tenant with form data
|
# Populate the tenant with form data
|
||||||
form.populate_obj(tenant)
|
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()
|
db.session.commit()
|
||||||
flash('Tenant updated successfully.', 'success')
|
flash('Tenant updated successfully.', 'success')
|
||||||
if session.get('tenant'):
|
if session.get('tenant'):
|
||||||
@@ -459,8 +467,18 @@ def edit_tenant_domain(tenant_domain_id):
|
|||||||
def tenant_overview():
|
def tenant_overview():
|
||||||
tenant_id = session['tenant']['id']
|
tenant_id = session['tenant']['id']
|
||||||
tenant = Tenant.query.get_or_404(tenant_id)
|
tenant = Tenant.query.get_or_404(tenant_id)
|
||||||
form = TenantForm(obj=tenant)
|
form = EditTenantForm(obj=tenant)
|
||||||
return render_template('user/tenant_overview.html', form=form)
|
|
||||||
|
# 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'])
|
@user_bp.route('/tenant_project', methods=['GET', 'POST'])
|
||||||
@@ -622,6 +640,133 @@ def delete_tenant_project(tenant_project_id):
|
|||||||
return redirect(prefixed_url_for('user_bp.tenant_projects'))
|
return redirect(prefixed_url_for('user_bp.tenant_projects'))
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route('/tenant_make', methods=['GET', 'POST'])
|
||||||
|
@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_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:
|
||||||
|
db.session.add(new_tenant_make)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Tenant Make successfully added!', 'success')
|
||||||
|
current_app.logger.info(f'Tenant Make {new_tenant_make.name} successfully added for tenant {tenant_id}!')
|
||||||
|
# Enable step 2 of creation of retriever - add configuration of the retriever (dependent on type)
|
||||||
|
return redirect(prefixed_url_for('user_bp.tenant_makes', tenant_make_id=new_tenant_make.id))
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Failed to add Tenant Make. Error: {e}', 'danger')
|
||||||
|
current_app.logger.error(f'Failed to add Tenant Make {new_tenant_make.name}'
|
||||||
|
f'for tenant {tenant_id}. Error: {str(e)}')
|
||||||
|
|
||||||
|
return render_template('user/tenant_make.html', form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route('/tenant_makes', methods=['GET', 'POST'])
|
||||||
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
|
def tenant_makes():
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 10, type=int)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# prepare table data
|
||||||
|
rows = prepare_table_for_macro(tenant_makes,
|
||||||
|
[('id', ''), ('name', ''), ('website', ''), ('active', '')])
|
||||||
|
|
||||||
|
# Render the tenant makes in a template
|
||||||
|
return render_template('user/tenant_makes.html', rows=rows, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route('/tenant_make/<int:tenant_make_id>', methods=['GET', 'POST'])
|
||||||
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
|
def edit_tenant_make(tenant_make_id):
|
||||||
|
"""Edit an existing tenant make configuration."""
|
||||||
|
# Get the tenant make or return 404
|
||||||
|
tenant_make = TenantMake.query.get_or_404(tenant_make_id)
|
||||||
|
|
||||||
|
# Create form instance with the 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)
|
||||||
|
|
||||||
|
if form.validate_on_submit():
|
||||||
|
# Update basic fields
|
||||||
|
form.populate_obj(tenant_make)
|
||||||
|
tenant_make.chat_customisation_options = form.get_dynamic_data("configuration")
|
||||||
|
|
||||||
|
# Update logging information
|
||||||
|
update_logging_information(tenant_make, dt.now(tz.utc))
|
||||||
|
|
||||||
|
# Save changes to database
|
||||||
|
try:
|
||||||
|
db.session.add(tenant_make)
|
||||||
|
db.session.commit()
|
||||||
|
flash('Tenant Make updated successfully!', 'success')
|
||||||
|
current_app.logger.info(f'Tenant Make {tenant_make.id} updated successfully')
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(f'Failed to update tenant make. Error: {str(e)}', 'danger')
|
||||||
|
current_app.logger.error(f'Failed to update tenant make {tenant_make_id}. Error: {str(e)}')
|
||||||
|
return render_template('user/edit_tenant_make.html', form=form, tenant_make_id=tenant_make_id)
|
||||||
|
|
||||||
|
return redirect(prefixed_url_for('user_bp.tenant_makes'))
|
||||||
|
else:
|
||||||
|
form_validation_failed(request, form)
|
||||||
|
|
||||||
|
return render_template('user/edit_tenant_make.html', form=form, tenant_make_id=tenant_make_id)
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route('/handle_tenant_make_selection', methods=['POST'])
|
||||||
|
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||||
|
def handle_tenant_make_selection():
|
||||||
|
action = request.form['action']
|
||||||
|
if action == 'create_tenant_make':
|
||||||
|
return redirect(prefixed_url_for('user_bp.tenant_make'))
|
||||||
|
tenant_make_identification = request.form.get('selected_row')
|
||||||
|
tenant_make_id = ast.literal_eval(tenant_make_identification).get('value')
|
||||||
|
|
||||||
|
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):
|
def reset_uniquifier(user):
|
||||||
security.datastore.set_uniquifier(user)
|
security.datastore.set_uniquifier(user)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from flask import Flask
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from common.utils.celery_utils import make_celery, init_celery
|
from common.utils.celery_utils import make_celery, init_celery
|
||||||
from config.logging_config import LOGGING
|
from config.logging_config import configure_logging
|
||||||
from config.config import get_config
|
from config.config import get_config
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ def create_app(config_file=None):
|
|||||||
case _:
|
case _:
|
||||||
app.config.from_object(get_config('dev'))
|
app.config.from_object(get_config('dev'))
|
||||||
|
|
||||||
logging.config.dictConfig(LOGGING)
|
configure_logging()
|
||||||
|
|
||||||
register_extensions(app)
|
register_extensions(app)
|
||||||
|
|
||||||
|
|||||||
114
eveai_chat_client/__init__.py
Normal file
114
eveai_chat_client/__init__.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
from common.extensions import (db, bootstrap, cors, csrf, session,
|
||||||
|
minio_client, simple_encryption, metrics, cache_manager, content_manager)
|
||||||
|
from common.models.user import Tenant, SpecialistMagicLinkTenant
|
||||||
|
from common.utils.startup_eveai import perform_startup_actions
|
||||||
|
from config.logging_config import configure_logging
|
||||||
|
from eveai_chat_client.utils.errors import register_error_handlers
|
||||||
|
from common.utils.celery_utils import make_celery, init_celery
|
||||||
|
from common.utils.template_filters import register_filters
|
||||||
|
from config.config import get_config
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(config_file=None):
|
||||||
|
app = Flask(__name__, static_url_path='/static')
|
||||||
|
|
||||||
|
# Ensure all necessary headers are handled
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
||||||
|
|
||||||
|
environment = os.getenv('FLASK_ENV', 'development')
|
||||||
|
|
||||||
|
match environment:
|
||||||
|
case 'development':
|
||||||
|
app.config.from_object(get_config('dev'))
|
||||||
|
case 'production':
|
||||||
|
app.config.from_object(get_config('prod'))
|
||||||
|
case _:
|
||||||
|
app.config.from_object(get_config('dev'))
|
||||||
|
|
||||||
|
app.config['SESSION_KEY_PREFIX'] = 'eveai_chat_client_'
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(app.instance_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.info("eveai_chat_client starting up")
|
||||||
|
|
||||||
|
# Register extensions
|
||||||
|
register_extensions(app)
|
||||||
|
|
||||||
|
# Configure CSRF protection
|
||||||
|
app.config['WTF_CSRF_CHECK_DEFAULT'] = False # Disable global CSRF protection
|
||||||
|
app.config['WTF_CSRF_TIME_LIMIT'] = None # Remove time limit for CSRF tokens
|
||||||
|
|
||||||
|
app.celery = make_celery(app.name, app.config)
|
||||||
|
init_celery(app.celery, app)
|
||||||
|
|
||||||
|
# Register Blueprints
|
||||||
|
register_blueprints(app)
|
||||||
|
|
||||||
|
# Register Error Handlers
|
||||||
|
register_error_handlers(app)
|
||||||
|
|
||||||
|
# Register Cache Handlers
|
||||||
|
register_cache_handlers(app)
|
||||||
|
|
||||||
|
# Debugging settings
|
||||||
|
if app.config['DEBUG'] is True:
|
||||||
|
app.logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
# Register template filters
|
||||||
|
register_filters(app)
|
||||||
|
|
||||||
|
# Perform startup actions such as cache invalidation
|
||||||
|
perform_startup_actions(app)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def register_extensions(app):
|
||||||
|
db.init_app(app)
|
||||||
|
bootstrap.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
cors.init_app(app)
|
||||||
|
simple_encryption.init_app(app)
|
||||||
|
session.init_app(app)
|
||||||
|
minio_client.init_app(app)
|
||||||
|
cache_manager.init_app(app)
|
||||||
|
metrics.init_app(app)
|
||||||
|
content_manager.init_app(app)
|
||||||
|
|
||||||
|
|
||||||
|
def register_blueprints(app):
|
||||||
|
from .views.chat_views import chat_bp
|
||||||
|
app.register_blueprint(chat_bp)
|
||||||
|
from .views.error_views import error_bp
|
||||||
|
app.register_blueprint(error_bp)
|
||||||
|
from .views.healthz_views import healthz_bp
|
||||||
|
app.register_blueprint(healthz_bp)
|
||||||
|
|
||||||
|
|
||||||
|
def register_cache_handlers(app):
|
||||||
|
from common.utils.cache.config_cache import register_config_cache_handlers
|
||||||
|
register_config_cache_handlers(cache_manager)
|
||||||
|
from common.utils.cache.crewai_processed_config_cache import register_specialist_cache_handlers
|
||||||
|
register_specialist_cache_handlers(cache_manager)
|
||||||
825
eveai_chat_client/static/assets/css/chat-components.css
Normal file
825
eveai_chat_client/static/assets/css/chat-components.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
eveai_chat_client/static/assets/css/chat-input.css
Normal file
120
eveai_chat_client/static/assets/css/chat-input.css
Normal 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;
|
||||||
|
}
|
||||||
161
eveai_chat_client/static/assets/css/chat-message.css
Normal file
161
eveai_chat_client/static/assets/css/chat-message.css
Normal 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;
|
||||||
|
}
|
||||||
167
eveai_chat_client/static/assets/css/chat.css
Normal file
167
eveai_chat_client/static/assets/css/chat.css
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/* Base styles */
|
||||||
|
:root {
|
||||||
|
--primary-color: #007bff;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--background-color: #ffffff;
|
||||||
|
--text-color: #212529;
|
||||||
|
--sidebar-color: #f8f9fa;
|
||||||
|
--message-user-bg: #e9f5ff;
|
||||||
|
--message-bot-bg: #f8f9fa;
|
||||||
|
--border-radius: 8px;
|
||||||
|
--spacing: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat layout */
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background-color: var(--sidebar-color);
|
||||||
|
border-right: 1px solid rgba(0,0,0,0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--spacing);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
margin-bottom: var(--spacing);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-text {
|
||||||
|
margin-bottom: var(--spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-info {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: var(--spacing);
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-member {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-member img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
padding: var(--spacing);
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .chat-messages wordt nu gedefinieerd in chat-components.css */
|
||||||
|
|
||||||
|
/* .message wordt nu gedefinieerd in chat-components.css */
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-message {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .message-content wordt nu gedefinieerd in chat-components.css */
|
||||||
|
|
||||||
|
.user-message .message-content {
|
||||||
|
background-color: var(--message-user-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-message .message-content {
|
||||||
|
background-color: var(--message-bot-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .chat-input-container wordt nu gedefinieerd in chat-components.css */
|
||||||
|
|
||||||
|
#chat-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.2);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
resize: none;
|
||||||
|
height: 60px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .typing-indicator en bijbehorende animaties worden nu gedefinieerd in chat-components.css */
|
||||||
|
|
||||||
|
/* Error page styles */
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-box {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-actions {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .btn-primary wordt nu gedefinieerd in chat-components.css */
|
||||||
|
|
||||||
|
/* Responsieve design regels worden nu gedefinieerd in chat-components.css */
|
||||||
91
eveai_chat_client/static/assets/css/form-message.css
Normal file
91
eveai_chat_client/static/assets/css/form-message.css
Normal 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%;
|
||||||
|
}
|
||||||
175
eveai_chat_client/static/assets/css/form.css
Normal file
175
eveai_chat_client/static/assets/css/form.css
Normal 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;
|
||||||
|
}
|
||||||
679
eveai_chat_client/static/assets/js/chat-app.js
Normal file
679
eveai_chat_client/static/assets/js/chat-app.js
Normal 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);
|
||||||
337
eveai_chat_client/static/assets/js/components/ChatInput.js
Normal file
337
eveai_chat_client/static/assets/js/components/ChatInput.js
Normal 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>
|
||||||
|
`
|
||||||
|
};
|
||||||
318
eveai_chat_client/static/assets/js/components/ChatMessage.js
Normal file
318
eveai_chat_client/static/assets/js/components/ChatMessage.js
Normal 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>
|
||||||
|
`
|
||||||
|
};
|
||||||
250
eveai_chat_client/static/assets/js/components/DynamicForm.js
Normal file
250
eveai_chat_client/static/assets/js/components/DynamicForm.js
Normal 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>
|
||||||
|
`
|
||||||
|
};
|
||||||
213
eveai_chat_client/static/assets/js/components/FormField.js
Normal file
213
eveai_chat_client/static/assets/js/components/FormField.js
Normal 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>
|
||||||
|
`
|
||||||
|
};
|
||||||
59
eveai_chat_client/static/assets/js/components/FormMessage.js
Normal file
59
eveai_chat_client/static/assets/js/components/FormMessage.js
Normal 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>
|
||||||
|
`
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
139
eveai_chat_client/static/assets/js/components/MessageHistory.js
Normal file
139
eveai_chat_client/static/assets/js/components/MessageHistory.js
Normal 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>
|
||||||
|
`,
|
||||||
|
};
|
||||||
311
eveai_chat_client/static/assets/js/components/ProgressTracker.js
Normal file
311
eveai_chat_client/static/assets/js/components/ProgressTracker.js
Normal 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>
|
||||||
|
`
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
`
|
||||||
|
};
|
||||||
135
eveai_chat_client/static/assets/js/iconManager.js
Normal file
135
eveai_chat_client/static/assets/js/iconManager.js
Normal 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();
|
||||||
|
}
|
||||||
176
eveai_chat_client/templates/base.html
Normal file
176
eveai_chat_client/templates/base.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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='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: {{ 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 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>
|
||||||
43
eveai_chat_client/templates/chat.html
Normal file
43
eveai_chat_client/templates/chat.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<!-- chat.html - Clean componentized template -->
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% 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 %}
|
||||||
|
<!-- Gebruik het ChatApp component -->
|
||||||
|
<chat-app>
|
||||||
|
</chat-app>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<!-- 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 %}
|
||||||
15
eveai_chat_client/templates/error.html
Normal file
15
eveai_chat_client/templates/error.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Error{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="error-box">
|
||||||
|
<h1>Oops! Something went wrong</h1>
|
||||||
|
<p class="error-message">{{ message }}</p>
|
||||||
|
<div class="error-actions">
|
||||||
|
<a href="/" class="btn-primary">Go to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
1
eveai_chat_client/utils/__init__.py
Normal file
1
eveai_chat_client/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Utils package for eveai_chat_client
|
||||||
85
eveai_chat_client/utils/errors.py
Normal file
85
eveai_chat_client/utils/errors.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import traceback
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
from flask import render_template, request, jsonify, redirect, current_app, flash
|
||||||
|
|
||||||
|
from common.utils.eveai_exceptions import EveAINoSessionTenant
|
||||||
|
|
||||||
|
|
||||||
|
def not_found_error(error):
|
||||||
|
current_app.logger.error(f"Not Found Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="Page not found."), 404
|
||||||
|
|
||||||
|
|
||||||
|
def internal_server_error(error):
|
||||||
|
current_app.logger.error(f"Internal Server Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="Internal server error."), 500
|
||||||
|
|
||||||
|
|
||||||
|
def not_authorised_error(error):
|
||||||
|
current_app.logger.error(f"Not Authorised Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="Not authorized."), 401
|
||||||
|
|
||||||
|
|
||||||
|
def access_forbidden(error):
|
||||||
|
current_app.logger.error(f"Access Forbidden: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="Access forbidden."), 403
|
||||||
|
|
||||||
|
|
||||||
|
def key_error_handler(error):
|
||||||
|
current_app.logger.error(f"Key Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="An unexpected error occurred."), 500
|
||||||
|
|
||||||
|
|
||||||
|
def attribute_error_handler(error):
|
||||||
|
"""Handle AttributeError exceptions."""
|
||||||
|
error_msg = str(error)
|
||||||
|
current_app.logger.error(f"AttributeError: {error_msg}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="An application error occurred."), 500
|
||||||
|
|
||||||
|
|
||||||
|
def no_tenant_selected_error(error):
|
||||||
|
"""Handle errors when no tenant is selected in the current session."""
|
||||||
|
current_app.logger.error(f"No Session Tenant Error: {error}")
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="Session expired. Please use a valid magic link."), 401
|
||||||
|
|
||||||
|
|
||||||
|
def general_exception(e):
|
||||||
|
current_app.logger.error(f"Unhandled Exception: {e}", exc_info=True)
|
||||||
|
return render_template('error.html', message="An application error occurred."), 500
|
||||||
|
|
||||||
|
|
||||||
|
def template_not_found_error(error):
|
||||||
|
"""Handle Jinja2 TemplateNotFound exceptions."""
|
||||||
|
current_app.logger.error(f'Template not found: {error.name}')
|
||||||
|
current_app.logger.error(f'Search Paths: {current_app.jinja_loader.list_templates()}')
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="Template not found."), 404
|
||||||
|
|
||||||
|
|
||||||
|
def template_syntax_error(error):
|
||||||
|
"""Handle Jinja2 TemplateSyntaxError exceptions."""
|
||||||
|
current_app.logger.error(f'Template syntax error: {error.message}')
|
||||||
|
current_app.logger.error(f'In template {error.filename}, line {error.lineno}')
|
||||||
|
current_app.logger.error(traceback.format_exc())
|
||||||
|
return render_template('error.html', message="Template syntax error."), 500
|
||||||
|
|
||||||
|
|
||||||
|
def register_error_handlers(app):
|
||||||
|
app.register_error_handler(404, not_found_error)
|
||||||
|
app.register_error_handler(500, internal_server_error)
|
||||||
|
app.register_error_handler(401, not_authorised_error)
|
||||||
|
app.register_error_handler(403, not_authorised_error)
|
||||||
|
app.register_error_handler(EveAINoSessionTenant, no_tenant_selected_error)
|
||||||
|
app.register_error_handler(KeyError, key_error_handler)
|
||||||
|
app.register_error_handler(AttributeError, attribute_error_handler)
|
||||||
|
app.register_error_handler(jinja2.TemplateNotFound, template_not_found_error)
|
||||||
|
app.register_error_handler(jinja2.TemplateSyntaxError, template_syntax_error)
|
||||||
|
app.register_error_handler(Exception, general_exception)
|
||||||
1
eveai_chat_client/views/__init__.py
Normal file
1
eveai_chat_client/views/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Views package for eveai_chat_client
|
||||||
251
eveai_chat_client/views/chat_views.py
Normal file
251
eveai_chat_client/views/chat_views.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import uuid
|
||||||
|
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, 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.route('/')
|
||||||
|
def index():
|
||||||
|
customisation = get_default_chat_customisation()
|
||||||
|
return render_template('error.html', message="Please use a valid magic link to access the chat.",
|
||||||
|
customisation=customisation)
|
||||||
|
|
||||||
|
|
||||||
|
@chat_bp.route('/<magic_link_code>')
|
||||||
|
def chat(magic_link_code):
|
||||||
|
"""
|
||||||
|
Main chat interface accessed via magic link
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Find the tenant using the magic link code
|
||||||
|
magic_link_tenant = SpecialistMagicLinkTenant.query.filter_by(magic_link_code=magic_link_code).first()
|
||||||
|
|
||||||
|
if not magic_link_tenant:
|
||||||
|
current_app.logger.error(f"Invalid magic link code: {magic_link_code}")
|
||||||
|
return render_template('error.html', message="Invalid magic link code.")
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# Get specialist magic link details from tenant schema
|
||||||
|
specialist_ml = SpecialistMagicLink.query.filter_by(magic_link_code=magic_link_code).first()
|
||||||
|
if not specialist_ml:
|
||||||
|
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:
|
||||||
|
current_app.logger.error(f"Specialist not found: {specialist_ml.specialist_id}")
|
||||||
|
return render_template('error.html', message="Specialist not found.")
|
||||||
|
|
||||||
|
# Store necessary information in session
|
||||||
|
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_make.chat_customisation_options)
|
||||||
|
|
||||||
|
# Start a new chat session
|
||||||
|
session['chat_session_id'] = SpecialistServices.start_session()
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
return render_template('error.html', message="An error occurred while setting up the chat.")
|
||||||
|
|
||||||
|
@chat_bp.route('/api/send_message', methods=['POST'])
|
||||||
|
def send_message():
|
||||||
|
"""
|
||||||
|
API endpoint to send a message to the specialist
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
message = data.get('message', '')
|
||||||
|
form_values = data.get('form_values', {})
|
||||||
|
|
||||||
|
# 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['tenant']['id']
|
||||||
|
specialist_id = session['specialist']['id']
|
||||||
|
chat_session_id = session.get('chat_session_id')
|
||||||
|
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
|
||||||
|
|
||||||
|
# Switch to tenant schema
|
||||||
|
Database(tenant_id).switch_schema()
|
||||||
|
|
||||||
|
# Add user message to specialist arguments
|
||||||
|
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(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
specialist_id=specialist_id,
|
||||||
|
specialist_arguments=specialist_args,
|
||||||
|
session_id=chat_session_id,
|
||||||
|
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'],
|
||||||
|
'content': 'Verwerking gestart...',
|
||||||
|
'type': 'text'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Error sending message: {str(e)}", exc_info=True)
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
@chat_bp.route('/api/check_status', methods=['GET'])
|
||||||
|
def check_status():
|
||||||
|
"""
|
||||||
|
API endpoint to check the status of a task
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
task_id = request.args.get('task_id') or session.get('current_task_id')
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
return jsonify({'error': 'No task ID provided'}), 400
|
||||||
|
|
||||||
|
tenant_id = session.get('tenant_id')
|
||||||
|
if not tenant_id:
|
||||||
|
return jsonify({'error': 'Session expired or invalid'}), 400
|
||||||
|
|
||||||
|
# Switch to tenant schema
|
||||||
|
Database(tenant_id).switch_schema()
|
||||||
|
|
||||||
|
# Check task status using Celery
|
||||||
|
task_result = current_app.celery.AsyncResult(task_id)
|
||||||
|
|
||||||
|
if task_result.state == 'PENDING':
|
||||||
|
return jsonify({'status': 'pending'})
|
||||||
|
elif task_result.state == 'SUCCESS':
|
||||||
|
result = task_result.result
|
||||||
|
|
||||||
|
# Format the response
|
||||||
|
specialist_result = result.get('result', {})
|
||||||
|
response = {
|
||||||
|
'status': 'success',
|
||||||
|
'answer': specialist_result.get('answer', ''),
|
||||||
|
'citations': specialist_result.get('citations', []),
|
||||||
|
'insufficient_info': specialist_result.get('insufficient_info', False),
|
||||||
|
'interaction_id': result.get('interaction_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(task_result.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
24
eveai_chat_client/views/error_views.py
Normal file
24
eveai_chat_client/views/error_views.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from flask import Blueprint, render_template
|
||||||
|
|
||||||
|
error_bp = Blueprint('error', __name__)
|
||||||
|
|
||||||
|
@error_bp.route('/error')
|
||||||
|
def error_page():
|
||||||
|
"""
|
||||||
|
Generic error page
|
||||||
|
"""
|
||||||
|
return render_template('error.html', message="An error occurred.")
|
||||||
|
|
||||||
|
@error_bp.app_errorhandler(404)
|
||||||
|
def page_not_found(e):
|
||||||
|
"""
|
||||||
|
Handle 404 errors
|
||||||
|
"""
|
||||||
|
return render_template('error.html', message="Page not found."), 404
|
||||||
|
|
||||||
|
@error_bp.app_errorhandler(500)
|
||||||
|
def internal_server_error(e):
|
||||||
|
"""
|
||||||
|
Handle 500 errors
|
||||||
|
"""
|
||||||
|
return render_template('error.html', message="Internal server error."), 500
|
||||||
17
eveai_chat_client/views/healthz_views.py
Normal file
17
eveai_chat_client/views/healthz_views.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import Blueprint, jsonify
|
||||||
|
|
||||||
|
healthz_bp = Blueprint('healthz', __name__)
|
||||||
|
|
||||||
|
@healthz_bp.route('/healthz/ready')
|
||||||
|
def ready():
|
||||||
|
"""
|
||||||
|
Health check endpoint for readiness probe
|
||||||
|
"""
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
@healthz_bp.route('/healthz/live')
|
||||||
|
def live():
|
||||||
|
"""
|
||||||
|
Health check endpoint for liveness probe
|
||||||
|
"""
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
|
|
||||||
from common.utils.celery_utils import make_celery, init_celery
|
from common.utils.celery_utils import make_celery, init_celery
|
||||||
from common.extensions import db, cache_manager
|
from common.extensions import db, cache_manager
|
||||||
from config.logging_config import LOGGING
|
from config.logging_config import configure_logging
|
||||||
from config.config import get_config
|
from config.config import get_config
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ def create_app(config_file=None):
|
|||||||
case _:
|
case _:
|
||||||
app.config.from_object(get_config('dev'))
|
app.config.from_object(get_config('dev'))
|
||||||
|
|
||||||
logging.config.dictConfig(LOGGING)
|
configure_logging()
|
||||||
|
|
||||||
app.logger.info('Starting up eveai_chat_workers...')
|
app.logger.info('Starting up eveai_chat_workers...')
|
||||||
register_extensions(app)
|
register_extensions(app)
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
LANGUAGE_LEVEL = [
|
||||||
|
{
|
||||||
|
"name": "Basic",
|
||||||
|
"description": "Short, simple sentences. Minimal jargon. Lots of visual and concrete language.",
|
||||||
|
"cefr_level": "A2 - B1",
|
||||||
|
"ideal_audience": "Manual laborers, entry-level roles, newcomers with another native language"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Standard",
|
||||||
|
"description": "Clear spoken language. Well-formulated without difficult words.",
|
||||||
|
"cefr_level": "B2",
|
||||||
|
"ideal_audience": "Retail, administration, logistics, early-career professionals"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Professional",
|
||||||
|
"description": "Business language with technical terms where needed. More complex sentence structures.",
|
||||||
|
"cefr_level": "C1",
|
||||||
|
"ideal_audience": "Management, HR, technical profiles"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
TONE_OF_VOICE = [
|
||||||
|
{
|
||||||
|
"name": "Professional & Neutral",
|
||||||
|
"description": "Business-like, clear, to the point. Focused on facts.",
|
||||||
|
"when_to_use": "Corporate jobs, legal roles, formal sectors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Warm & Empathetic",
|
||||||
|
"description": "Human, compassionate, reassuring.",
|
||||||
|
"when_to_use": "Healthcare, education, HR, social professions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Energetic & Enthusiastic",
|
||||||
|
"description": "Upbeat, persuasive, motivating.",
|
||||||
|
"when_to_use": "Sales, marketing, hospitality, start-ups"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Accessible & Informal",
|
||||||
|
"description": "Casual, approachable, friendly, and human.",
|
||||||
|
"when_to_use": "Youth-focused, entry-level, retail, creative sectors"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Expert & Trustworthy",
|
||||||
|
"description": "Calm authority, advisory tone, knowledgeable.",
|
||||||
|
"when_to_use": "IT, engineering, consultancy, medical profiles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "No-nonsense & Goal-driven",
|
||||||
|
"description": "Direct, efficient, pragmatic.",
|
||||||
|
"when_to_use": "Technical, logistics, blue-collar jobs, production environments"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -2,6 +2,7 @@ from typing import List, Optional
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem
|
from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem
|
||||||
|
|
||||||
|
|
||||||
# class BehaviouralCompetence(BaseModel):
|
# class BehaviouralCompetence(BaseModel):
|
||||||
# title: str = Field(..., description="The title of the behavioural competence.")
|
# title: str = Field(..., description="The title of the behavioural competence.")
|
||||||
# description: Optional[str] = Field(None, description="The description of the behavioural competence.")
|
# description: Optional[str] = Field(None, description="The description of the behavioural competence.")
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from eveai_chat_workers.outputs.globals.basic_types.list_item import ListItem
|
||||||
|
|
||||||
|
class KOQuestion(BaseModel):
|
||||||
|
title: str = Field(..., description="The title of the knockout criterium.")
|
||||||
|
question: str = Field(..., description="The corresponding question asked to the candidate.")
|
||||||
|
answer_positive: Optional[str] = Field(None, description="The answer to the question, resulting in a positive outcome.")
|
||||||
|
answer_negative: Optional[str] = Field(None, description="The answer to the question, resulting in a negative outcome.")
|
||||||
|
|
||||||
|
class KOQuestions(BaseModel):
|
||||||
|
ko_questions: List[KOQuestion] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="KO Questions and answers."
|
||||||
|
)
|
||||||
@@ -4,7 +4,8 @@ from typing import Dict, Any, List
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
from common.extensions import cache_manager
|
from common.extensions import cache_manager
|
||||||
from common.models.interaction import SpecialistRetriever
|
from common.models.interaction import SpecialistRetriever, Specialist
|
||||||
|
from common.models.user import Tenant
|
||||||
from common.utils.execution_progress import ExecutionProgressTracker
|
from common.utils.execution_progress import ExecutionProgressTracker
|
||||||
from config.logging_config import TuningLogger
|
from config.logging_config import TuningLogger
|
||||||
from eveai_chat_workers.retrievers.base import BaseRetriever
|
from eveai_chat_workers.retrievers.base import BaseRetriever
|
||||||
@@ -17,7 +18,9 @@ class BaseSpecialistExecutor(ABC):
|
|||||||
|
|
||||||
def __init__(self, tenant_id: int, specialist_id: int, session_id: str, task_id: str):
|
def __init__(self, tenant_id: int, specialist_id: int, session_id: str, task_id: str):
|
||||||
self.tenant_id = tenant_id
|
self.tenant_id = tenant_id
|
||||||
|
self.tenant = Tenant.query.get_or_404(tenant_id)
|
||||||
self.specialist_id = specialist_id
|
self.specialist_id = specialist_id
|
||||||
|
self.specialist = Specialist.query.get_or_404(specialist_id)
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
self.tuning = False
|
self.tuning = False
|
||||||
@@ -96,6 +99,37 @@ class BaseSpecialistExecutor(ABC):
|
|||||||
def update_progress(self, processing_type, data) -> None:
|
def update_progress(self, processing_type, data) -> None:
|
||||||
self.ept.send_update(self.task_id, processing_type, data)
|
self.ept.send_update(self.task_id, processing_type, data)
|
||||||
|
|
||||||
|
def _replace_system_variables(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Replace all system variables in the text with their corresponding values.
|
||||||
|
System variables are in the format 'tenant_<attribute_name>'
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text containing system variables to replace
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The text with all system variables replaced
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
from common.utils.model_utils import replace_variable_in_template
|
||||||
|
|
||||||
|
# Find all tenant_* variables and replace them with tenant attribute values
|
||||||
|
# Format of variables: tenant_name, tenant_code, etc.
|
||||||
|
result = text
|
||||||
|
|
||||||
|
# Get all attributes of the tenant object
|
||||||
|
tenant_attrs = vars(self.tenant)
|
||||||
|
|
||||||
|
# Replace all tenant_* variables
|
||||||
|
for attr_name, attr_value in tenant_attrs.items():
|
||||||
|
variable = f"tenant_{attr_name}"
|
||||||
|
if variable in result:
|
||||||
|
result = replace_variable_in_template(result, variable, str(attr_value))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def execute_specialist(self, arguments: SpecialistArguments) -> SpecialistResult:
|
def execute_specialist(self, arguments: SpecialistArguments) -> SpecialistResult:
|
||||||
"""Execute the specialist's logic"""
|
"""Execute the specialist's logic"""
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
|
|||||||
def __init__(self, tenant_id: int, specialist_id: int, session_id: str, task_id):
|
def __init__(self, tenant_id: int, specialist_id: int, session_id: str, task_id):
|
||||||
super().__init__(tenant_id, specialist_id, session_id, task_id)
|
super().__init__(tenant_id, specialist_id, session_id, task_id)
|
||||||
|
|
||||||
# Check and load the specialist
|
|
||||||
self.specialist = Specialist.query.get_or_404(specialist_id)
|
|
||||||
# Set the specific configuration for the SPIN Specialist
|
|
||||||
# self.specialist_configuration = json.loads(self.specialist.configuration)
|
|
||||||
self.tuning = self.specialist.tuning
|
self.tuning = self.specialist.tuning
|
||||||
# Initialize retrievers
|
# Initialize retrievers
|
||||||
self.retrievers = self._initialize_retrievers()
|
self.retrievers = self._initialize_retrievers()
|
||||||
@@ -54,32 +50,44 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
|
|||||||
self._task_pydantic_outputs: Dict[str, Type[BaseModel]] = {}
|
self._task_pydantic_outputs: Dict[str, Type[BaseModel]] = {}
|
||||||
self._task_state_names: Dict[str, str] = {}
|
self._task_state_names: Dict[str, str] = {}
|
||||||
|
|
||||||
# Processed configurations
|
# State-Result relations (for adding / restoring information to / from history
|
||||||
|
self._state_result_relations: Dict[str, str] = {}
|
||||||
|
|
||||||
|
# Process configurations
|
||||||
self._config = cache_manager.crewai_processed_config_cache.get_specialist_config(tenant_id, specialist_id)
|
self._config = cache_manager.crewai_processed_config_cache.get_specialist_config(tenant_id, specialist_id)
|
||||||
self._config_task_agents()
|
self._config_task_agents()
|
||||||
self._config_pydantic_outputs()
|
self._config_pydantic_outputs()
|
||||||
self._instantiate_crew_assets()
|
self._instantiate_crew_assets()
|
||||||
self._instantiate_specialist()
|
self._instantiate_specialist()
|
||||||
|
self._config_state_result_relations()
|
||||||
|
|
||||||
# Retrieve history
|
# Retrieve history
|
||||||
self._cached_session = cache_manager.chat_session_cache.get_cached_session(self.session_id)
|
self._cached_session = cache_manager.chat_session_cache.get_cached_session(self.session_id)
|
||||||
|
self._restore_state_from_history()
|
||||||
# Format history for the prompt
|
# Format history for the prompt
|
||||||
self._formatted_history = "\n\n".join([
|
self._formatted_history = self._generate_formatted_history()
|
||||||
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
|
|
||||||
])
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def formatted_history(self) -> str:
|
def formatted_history(self) -> str:
|
||||||
if not self._formatted_history:
|
if not self._formatted_history:
|
||||||
self._formatted_history = "\n\n".join([
|
self._formatted_history = self._generate_formatted_history()
|
||||||
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
|
|
||||||
])
|
|
||||||
return self._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):
|
def _add_task_agent(self, task_name: str, agent_name: str):
|
||||||
self._task_agents[task_name.lower()] = agent_name
|
self._task_agents[task_name.lower()] = agent_name
|
||||||
|
|
||||||
@@ -103,6 +111,19 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
|
|||||||
"""Configure the task pydantic outputs by adding task-output combinations. Use _add_pydantic_output()"""
|
"""Configure the task pydantic outputs by adding task-output combinations. Use _add_pydantic_output()"""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _add_state_result_relation(self, state_name: str, result_name: str = None):
|
||||||
|
"""Add a state-result relation to the specialist. This is used to add information to the history
|
||||||
|
If result_name is None, the state name is used as the result name. (default behavior)
|
||||||
|
"""
|
||||||
|
if not result_name:
|
||||||
|
result_name = state_name
|
||||||
|
self._state_result_relations[state_name] = result_name
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _config_state_result_relations(self):
|
||||||
|
"""Configure the state-result relations by adding state-result combinations. Use _add_state_result_relation()"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def task_pydantic_outputs(self):
|
def task_pydantic_outputs(self):
|
||||||
return self._task_pydantic_outputs
|
return self._task_pydantic_outputs
|
||||||
@@ -120,7 +141,9 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
|
|||||||
for agent in self.specialist.agents:
|
for agent in self.specialist.agents:
|
||||||
agent_config = cache_manager.agents_config_cache.get_config(agent.type, agent.type_version)
|
agent_config = cache_manager.agents_config_cache.get_config(agent.type, agent.type_version)
|
||||||
agent_role = agent_config.get('role', '').replace('{custom_role}', agent.role or '')
|
agent_role = agent_config.get('role', '').replace('{custom_role}', agent.role or '')
|
||||||
|
agent_role = self._replace_system_variables(agent_role)
|
||||||
agent_goal = agent_config.get('goal', '').replace('{custom_goal}', agent.goal or '')
|
agent_goal = agent_config.get('goal', '').replace('{custom_goal}', agent.goal or '')
|
||||||
|
agent_goal = self._replace_system_variables(agent_goal)
|
||||||
agent_backstory = agent_config.get('backstory', '').replace('{custom_backstory}', agent.backstory or '')
|
agent_backstory = agent_config.get('backstory', '').replace('{custom_backstory}', agent.backstory or '')
|
||||||
agent_full_model_name = agent_config.get('full_model_name', 'mistral.mistral-large-latest')
|
agent_full_model_name = agent_config.get('full_model_name', 'mistral.mistral-large-latest')
|
||||||
agent_temperature = agent_config.get('temperature', 0.3)
|
agent_temperature = agent_config.get('temperature', 0.3)
|
||||||
@@ -145,6 +168,7 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
|
|||||||
task_config = cache_manager.tasks_config_cache.get_config(task.type, task.type_version)
|
task_config = cache_manager.tasks_config_cache.get_config(task.type, task.type_version)
|
||||||
task_description = (task_config.get('task_description', '')
|
task_description = (task_config.get('task_description', '')
|
||||||
.replace('{custom_description}', task.task_description or ''))
|
.replace('{custom_description}', task.task_description or ''))
|
||||||
|
task_description = self._replace_system_variables(task_description)
|
||||||
task_expected_output = (task_config.get('expected_output', '')
|
task_expected_output = (task_config.get('expected_output', '')
|
||||||
.replace('{custom_expected_output}', task.expected_output or ''))
|
.replace('{custom_expected_output}', task.expected_output or ''))
|
||||||
# dynamically build the arguments
|
# dynamically build the arguments
|
||||||
@@ -154,9 +178,12 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
|
|||||||
"verbose": task.tuning
|
"verbose": task.tuning
|
||||||
}
|
}
|
||||||
task_name = task.type.lower()
|
task_name = task.type.lower()
|
||||||
|
current_app.logger.debug(f"Task {task_name} is getting processed")
|
||||||
if task_name in self._task_pydantic_outputs:
|
if task_name in self._task_pydantic_outputs:
|
||||||
task_kwargs["output_pydantic"] = self._task_pydantic_outputs[task_name]
|
task_kwargs["output_pydantic"] = self._task_pydantic_outputs[task_name]
|
||||||
|
current_app.logger.debug(f"Task {task_name} has an output pydantic: {self._task_pydantic_outputs[task_name]}")
|
||||||
if task_name in self._task_agents:
|
if task_name in self._task_agents:
|
||||||
|
current_app.logger.debug(f"Task {task_name} has an agent: {self._task_agents[task_name]}")
|
||||||
task_kwargs["agent"] = self._agents[self._task_agents[task_name]]
|
task_kwargs["agent"] = self._agents[self._task_agents[task_name]]
|
||||||
|
|
||||||
# Instantiate the task with dynamic arguments
|
# Instantiate the task with dynamic arguments
|
||||||
@@ -321,6 +348,27 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
|
|||||||
|
|
||||||
return formatted_context, citations
|
return formatted_context, citations
|
||||||
|
|
||||||
|
def _update_specialist_results(self, specialist_results: SpecialistResult) -> SpecialistResult:
|
||||||
|
"""Update the specialist results with the latest state information"""
|
||||||
|
update_data = {}
|
||||||
|
state_dict = self.flow.state.model_dump()
|
||||||
|
for state_name, result_name in self._state_result_relations.items():
|
||||||
|
if state_name in state_dict and state_dict[state_name] is not None:
|
||||||
|
update_data[result_name] = state_dict[state_name]
|
||||||
|
|
||||||
|
return specialist_results.model_copy(update=update_data)
|
||||||
|
|
||||||
|
def _restore_state_from_history(self):
|
||||||
|
"""Restore the state from the history"""
|
||||||
|
if not self._cached_session.interactions:
|
||||||
|
return
|
||||||
|
last_interaction = self._cached_session.interactions[-1]
|
||||||
|
if not last_interaction.specialist_results:
|
||||||
|
return
|
||||||
|
for state_name, result_name in self._state_result_relations.items():
|
||||||
|
if result_name in last_interaction.specialist_results:
|
||||||
|
setattr(self.flow.state, state_name, last_interaction.specialist_results[result_name])
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def execute(self, arguments: SpecialistArguments, formatted_context: str, citations: List[int]) -> SpecialistResult:
|
def execute(self, arguments: SpecialistArguments, formatted_context: str, citations: List[int]) -> SpecialistResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@@ -347,8 +395,10 @@ class CrewAIBaseSpecialistExecutor(BaseSpecialistExecutor):
|
|||||||
"detailed_query": detailed_query,
|
"detailed_query": detailed_query,
|
||||||
"citations": citations,
|
"citations": citations,
|
||||||
}
|
}
|
||||||
final_result = result.model_copy(update=modified_result)
|
intermediate_result = result.model_copy(update=modified_result)
|
||||||
else:
|
else:
|
||||||
final_result = self.execute(arguments, "", [])
|
intermediate_result = self.execute(arguments, "", [])
|
||||||
|
|
||||||
|
final_result = self._update_specialist_results(intermediate_result)
|
||||||
|
|
||||||
return final_result
|
return final_result
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Dict, Any
|
from typing import Dict, Any, Optional
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, model_validator
|
||||||
from eveai_chat_workers.retrievers.retriever_typing import RetrieverArguments
|
from eveai_chat_workers.retrievers.retriever_typing import RetrieverArguments
|
||||||
from common.extensions import cache_manager
|
from common.extensions import cache_manager
|
||||||
@@ -21,6 +21,16 @@ class SpecialistArguments(BaseModel):
|
|||||||
"extra": "allow"
|
"extra": "allow"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Structural optional fields available for all specialists
|
||||||
|
question: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional question directed to the specialist"
|
||||||
|
)
|
||||||
|
form_values: Optional[Dict[str, Any]] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional form values filled by the user, keyed by field name"
|
||||||
|
)
|
||||||
|
|
||||||
@model_validator(mode='after')
|
@model_validator(mode='after')
|
||||||
def validate_required_arguments(self) -> 'SpecialistArguments':
|
def validate_required_arguments(self) -> 'SpecialistArguments':
|
||||||
"""Validate that all required arguments for this specialist type are present"""
|
"""Validate that all required arguments for this specialist type are present"""
|
||||||
@@ -91,6 +101,13 @@ class SpecialistResult(BaseModel):
|
|||||||
"extra": "allow"
|
"extra": "allow"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Structural optional fields available for all specialists
|
||||||
|
answer: Optional[str] = Field(None, description="Optional textual answer from the specialist")
|
||||||
|
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')
|
@model_validator(mode='after')
|
||||||
def validate_required_results(self) -> 'SpecialistResult':
|
def validate_required_results(self) -> 'SpecialistResult':
|
||||||
"""Validate that all required result fields for this specialist type are present"""
|
"""Validate that all required result fields for this specialist type are present"""
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def type_version(self) -> str:
|
def type_version(self) -> str:
|
||||||
return "1.1"
|
return "1.2"
|
||||||
|
|
||||||
def _config_task_agents(self):
|
def _config_task_agents(self):
|
||||||
self._add_task_agent("traicie_get_competencies_task", "traicie_hr_bp_agent")
|
self._add_task_agent("traicie_get_competencies_task", "traicie_hr_bp_agent")
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
NEW_SPECIALIST_TYPE = "TRAICIE_SELECTION_SPECIALIST"
|
||||||
|
NEW_SPECIALIST_TYPE_VERSION = "1.3"
|
||||||
|
|
||||||
|
|
||||||
|
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=NEW_SPECIALIST_TYPE,
|
||||||
|
type_version=NEW_SPECIALIST_TYPE_VERSION,
|
||||||
|
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, NEW_SPECIALIST_TYPE, NEW_SPECIALIST_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
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
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_SELECTION_SPECIALIST
|
||||||
|
type_version: 1.0
|
||||||
|
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.0"
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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, "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
|
||||||
@@ -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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user