25 Commits

Author SHA1 Message Date
Josako
4338f09f5c Changelog update for 2.3.8-alfa 2025-06-26 16:00:51 +02:00
Josako
53e32a67bd - Remove welcome message from tenant make customisation
- Add possibility to add allowed_languages to tenant make
2025-06-26 15:52:10 +02:00
Josako
fda267b479 - Introduction of the Automatic HTML Processor
- Translation Service improvement
- Enable activation / deactivation of Processors
- Renew API-keys for Mistral (leading to workspaces)
- Align all Document views to use of a session catalog
- Allow for different processors for the same file type
2025-06-26 14:38:40 +02:00
Josako
f5c9542a49 - Introducing translation service prompts
- Ensure Traicie Role Definition Specialist complies to latest technical requirements
- Ensure that empty historical messages do not cause a crash in eveai_client
- take into account empty customisation options
- make was not processed in the system dynamic attribute tenant_make
- ensure only relevant makes are shown when creating magic links
- refresh partner info when editing or adding Partner Services$
2025-06-24 14:15:36 +02:00
Josako
043cea45f2 Changelog update for 2.3.7 2025-06-23 11:51:52 +02:00
Josako
7b87880045 - Full Traicie Selection Specialist Flow implemented
- Added Specialist basics for handling phases and automatically transferring data between state and output
- Added QR-code generation for Magic Links
2025-06-23 11:46:56 +02:00
Josako
5b2c04501c - logging improvement and simplification (no more graylog)
- Traicie Selection Specialist Round Trip
- Session improvements + debugging enabled
- Tone of Voice & Langauge Level definitions introduced
2025-06-20 07:58:06 +02:00
Josako
babcd6ec04 Changelog update for 2.3.6-alfa 2025-06-16 11:10:59 +02:00
Josako
71adf64668 - Verbeterde versie Selectie Specialist - voor demo (1.2) 2025-06-16 11:06:20 +02:00
Josako
dbea41451a - Aanpassingen aan opbouw specialist historiek
- Nieuwe versie van de selectie specialist "Fake it till you Make it" ;-)
2025-06-15 18:31:13 +02:00
Josako
82e25b356c Chat client changes
- Form values shown correct in MessageHistory of Chat client
- Improements to CSS
- Move css en js to assets directory
- Introduce better Personal Contact Form & Professional Contact Form
- Start working on actual Selection Specialist
2025-06-15 05:25:00 +02:00
Josako
3c7460f741 Form in ChatInput are displayed correctly! 2025-06-13 20:30:56 +02:00
Josako
2835486599 Eerste goed werkende versie van een formulier in de chat input. 2025-06-13 17:27:49 +02:00
Josako
f1c60f9574 tussentijdse status voor significante wijzigingen. Bezig aan creatie Dynamic Form in de chat client. 2025-06-13 14:19:05 +02:00
Josako
b326c0c6f2 Chat Client changes:
- maximum width for input and message history
- ensure good display for sidebar explanation
2025-06-13 00:56:22 +02:00
Josako
5f1a5711f6 - Build of the Chat Client using Vue.js
- Accompanying css
- Views to serve the Chat Client
- first test version of the TRACIE_SELECTION_SPECIALIST
- ESS Implemented.
2025-06-12 18:21:51 +02:00
Josako
67ceb57b79 - Changelog to 2.3.5-alfa 2025-06-10 20:57:07 +02:00
Josako
23b49516cb - Create framework for chat-client, including logo, explanatory text, color settings, ...
- remove allowed_langages from tenant
- Correct bugs in Tenant, TenantMake, SpecialistMagicLink
- Change chat client customisation elements
2025-06-10 20:52:01 +02:00
Josako
9cc266b97f - Corrections to tenant, catalog, and tenant_make
- Clean-up of tenant elements
- ensure the chat_client get's it's initial call rifht.
2025-06-10 16:10:08 +02:00
Josako
3f77871c4f - Add a default make to the tenant
- Add a make to the SpecialistMagicLink
2025-06-09 18:13:38 +02:00
Josako
199cf94cf2 - Changed label for specialist_name to chatbot name ==> more logical
- Bug in unique name for catalogs
2025-06-09 16:06:41 +02:00
Josako
c4dcd6a0d3 - Add a new 'system' type to dynamic forms, first one defined = 'tenant_make'
- Add active field to Specialist model
- Improve Specialists view
- Propagate make for Role Definition Specialist to Selection Specialist (make is defined at the role level)
- Ensure a make with a given name can only be defined once
2025-06-09 11:06:36 +02:00
Josako
43ee9139d6 Changelog for version 2.3.3-alfa 2025-06-07 11:18:05 +02:00
Josako
8f45005713 - Bug fixes:
- Catalog Name Unique Constraint
  - Selection constraint to view processed document
  - remove tab from tenant overview
2025-06-07 11:14:23 +02:00
Josako
bc1626c4ff - Initialisation of the EveAI Chat Client.
- Introduction of Tenant Makes
2025-06-06 16:42:24 +02:00
150 changed files with 9494 additions and 948 deletions

19
.aiignore Normal file
View File

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

4
.gitignore vendored
View File

@@ -53,3 +53,7 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc
/docker/grafana/data/ /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
View 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

View File

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

View File

@@ -3,7 +3,6 @@ from langchain.callbacks.base import BaseCallbackHandler
from typing import Dict, Any, List from typing import Dict, Any, List
from langchain.schema import LLMResult from langchain.schema import LLMResult
from common.utils.business_event_context import current_event from common.utils.business_event_context import current_event
from flask import current_app
class LLMMetricsHandler(BaseCallbackHandler): class LLMMetricsHandler(BaseCallbackHandler):

View File

@@ -0,0 +1,47 @@
import time
from langchain.callbacks.base import BaseCallbackHandler
from typing import Dict, Any, List
from langchain.schema import LLMResult
from common.utils.business_event_context import current_event
class PersistentLLMMetricsHandler(BaseCallbackHandler):
"""Metrics handler that allows metrics to be retrieved from within any call. In case metrics are required for other
purposes than business event logging."""
def __init__(self):
self.total_tokens: int = 0
self.prompt_tokens: int = 0
self.completion_tokens: int = 0
self.start_time: float = 0
self.end_time: float = 0
self.total_time: float = 0
def reset(self):
self.total_tokens = 0
self.prompt_tokens = 0
self.completion_tokens = 0
self.start_time = 0
self.end_time = 0
self.total_time = 0
def on_llm_start(self, serialized: Dict[str, Any], prompts: List[str], **kwargs: Any) -> None:
self.start_time = time.time()
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
self.end_time = time.time()
self.total_time = self.end_time - self.start_time
usage = response.llm_output.get('token_usage', {})
self.prompt_tokens += usage.get('prompt_tokens', 0)
self.completion_tokens += usage.get('completion_tokens', 0)
self.total_tokens = self.prompt_tokens + self.completion_tokens
def get_metrics(self) -> Dict[str, int | float]:
return {
'total_tokens': self.total_tokens,
'prompt_tokens': self.prompt_tokens,
'completion_tokens': self.completion_tokens,
'time_elapsed': self.total_time,
'interaction_type': 'LLM',
}

View File

@@ -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")
@@ -34,6 +34,7 @@ class Processor(db.Model):
catalog_id = db.Column(db.Integer, db.ForeignKey('catalog.id'), nullable=True) catalog_id = db.Column(db.Integer, db.ForeignKey('catalog.id'), nullable=True)
type = db.Column(db.String(50), nullable=False) type = db.Column(db.String(50), nullable=False)
sub_file_type = db.Column(db.String(50), nullable=True) sub_file_type = db.Column(db.String(50), nullable=True)
active = db.Column(db.Boolean, nullable=True, default=True)
# Tuning enablers # Tuning enablers
tuning = db.Column(db.Boolean, nullable=True, default=False) tuning = db.Column(db.Boolean, nullable=True, default=False)

View File

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

View File

@@ -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,43 @@ 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)
allowed_languages = db.Column(ARRAY(sa.String(2)), 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'}
@@ -281,3 +320,25 @@ class SpecialistMagicLinkTenant(db.Model):
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)
class TranslationCache(db.Model):
__bind_key__ = 'public'
__table_args__ = {'schema': 'public'}
cache_key = db.Column(db.String(16), primary_key=True)
source_text = db.Column(db.Text, nullable=False)
translated_text = db.Column(db.Text, nullable=False)
source_language = db.Column(db.String(2), nullable=False)
target_language = db.Column(db.String(2), nullable=False)
context = db.Column(db.Text, nullable=True)
# Translation cost
prompt_tokens = db.Column(db.Integer, nullable=False)
completion_tokens = db.Column(db.Integer, nullable=False)
# Tracking
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'), nullable=True)
last_used_at = db.Column(db.DateTime, nullable=True)

View File

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

View File

@@ -0,0 +1,43 @@
import xxhash
import json
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from common.langchain.persistent_llm_metrics_handler import PersistentLLMMetricsHandler
from common.utils.model_utils import get_template, replace_variable_in_template
class TranslationService:
def __init__(self, tenant_id):
self.tenant_id = tenant_id
def translate_text(self, text_to_translate: str, target_lang: str, source_lang: str = None, context: str = None) -> tuple[
str, dict[str, int | float]]:
prompt_params = {
"text_to_translate": text_to_translate,
"target_lang": target_lang,
}
if context:
template, llm = get_template("translation_with_context")
prompt_params["context"] = context
else:
template, llm = get_template("translation_without_context")
# Add a metrics handler to capture usage
metrics_handler = PersistentLLMMetricsHandler()
existing_callbacks = llm.callbacks
llm.callbacks = existing_callbacks + [metrics_handler]
translation_prompt = ChatPromptTemplate.from_template(template)
setup = RunnablePassthrough()
chain = (setup | translation_prompt | llm | StrOutputParser())
translation = chain.invoke(prompt_params)
metrics = metrics_handler.get_metrics()
return translation, metrics

View File

@@ -7,7 +7,7 @@ from flask import current_app
from common.utils.cache.base import CacheHandler, CacheKey from common.utils.cache.base import CacheHandler, CacheKey
from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \ from config.type_defs import agent_types, task_types, tool_types, specialist_types, retriever_types, prompt_types, \
catalog_types, partner_service_types, processor_types 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)

156
common/utils/cache/translation_cache.py vendored Normal file
View File

@@ -0,0 +1,156 @@
import json
from typing import Dict, Any, Optional
from datetime import datetime as dt, timezone as tz
import xxhash
from flask import current_app
from sqlalchemy import and_
from sqlalchemy.inspection import inspect
from common.utils.cache.base import CacheHandler, T
from common.extensions import db
from common.models.user import TranslationCache
from common.services.utils.translation_services import TranslationService
from flask_security import current_user
class TranslationCacheHandler(CacheHandler[TranslationCache]):
"""Handles caching of translations with fallback to database and external translation service"""
handler_name = 'translation_cache'
def __init__(self, region):
super().__init__(region, 'translation')
self.configure_keys('hash_key')
def _to_cache_data(self, instance: TranslationCache) -> Dict[str, Any]:
"""Convert TranslationCache instance to cache data using SQLAlchemy inspection"""
if not instance:
return {}
mapper = inspect(TranslationCache)
data = {}
for column in mapper.columns:
value = getattr(instance, column.name)
# Handle date serialization
if isinstance(value, dt):
data[column.name] = value.isoformat()
else:
data[column.name] = value
return data
def _from_cache_data(self, data: Dict[str, Any], **kwargs) -> TranslationCache:
if not data:
return None
# Create a new TranslationCache instance
translation = TranslationCache()
mapper = inspect(TranslationCache)
# Set all attributes dynamically
for column in mapper.columns:
if column.name in data:
value = data[column.name]
# Handle date deserialization
if column.name.endswith('_date') and value:
if isinstance(value, str):
value = dt.fromisoformat(value).date()
setattr(translation, column.name, value)
return translation
def _should_cache(self, value: TranslationCache) -> bool:
"""Validate if the translation should be cached"""
return value is not None and value.cache_key is not None
def get_translation(self, text: str, target_lang: str, source_lang:str=None, context: str=None) -> Optional[TranslationCache]:
"""
Get the translation for a text in a specific language
Args:
text: The text to be translated
target_lang: The target language for the translation
source_lang: The source language of the text to be translated
context: Optional context for the translation
Returns:
TranslationCache instance if found, None otherwise
"""
def creator_func(text: str, target_lang: str, source_lang: str=None, context: str=None) -> Optional[TranslationCache]:
# Generate cache key based on inputs
cache_key = self._generate_cache_key(text, target_lang, source_lang, context)
# Check if translation already exists in database
existing_translation = db.session.query(TranslationCache).filter_by(cache_key=cache_key).first()
if existing_translation:
# Update last used timestamp
existing_translation.last_used_at = dt.now(tz=tz.utc)
db.session.commit()
return existing_translation
# Translation not found in DB, need to create it
# Initialize translation service
translation_service = TranslationService(getattr(current_app, 'tenant_id', None))
# Get the translation and metrics
translated_text, metrics = translation_service.translate_text(
text_to_translate=text,
target_lang=target_lang,
source_lang=source_lang,
context=context
)
# Create new translation cache record
new_translation = TranslationCache(
cache_key=cache_key,
source_text=text,
translated_text=translated_text,
source_language=source_lang or 'auto',
target_language=target_lang,
context=context,
prompt_tokens=metrics.get('prompt_tokens', 0),
completion_tokens=metrics.get('completion_tokens', 0),
created_at=dt.now(tz=tz.utc),
created_by=getattr(current_user, 'id', None) if 'current_user' in globals() else None,
updated_at=dt.now(tz=tz.utc),
updated_by=getattr(current_user, 'id', None) if 'current_user' in globals() else None,
last_used_at=dt.now(tz=tz.utc)
)
# Save to database
db.session.add(new_translation)
db.session.commit()
return new_translation
return self.get(creator_func, text=text, target_lang=target_lang, source_lang=source_lang, context=context)
def invalidate_tenant_translations(self, tenant_id: int):
"""Invalidate cached translations for specific tenant"""
self.invalidate(tenant_id=tenant_id)
def _generate_cache_key(self, text: str, target_lang: str, source_lang: str = None, context: str = None) -> str:
"""Generate cache key for a translation"""
cache_data = {
"text": text.strip(),
"target_lang": target_lang.lower(),
"source_lang": source_lang.lower() if source_lang else None,
"context": context.strip() if context else None,
}
cache_string = json.dumps(cache_data, sort_keys=True, ensure_ascii=False)
return xxhash.xxh64(cache_string.encode('utf-8')).hexdigest()
def register_translation_cache_handlers(cache_manager) -> None:
"""Register translation cache handlers with cache manager"""
cache_manager.register_handler(
TranslationCacheHandler,
'eveai_model' # Use existing eveai_model region
)

View File

@@ -0,0 +1,60 @@
import json
"""
Utility functions for chat customization.
"""
from flask import current_app
def get_default_chat_customisation(tenant_customisation=None):
"""
Get chat customization options with default values for missing options.
Args:
tenant_customisation (dict or str, optional): The tenant's customization options.
Defaults to None. Can be a dict or a JSON string.
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()
# Convert JSON string to dict if needed
if isinstance(tenant_customisation, str):
try:
tenant_customisation = json.loads(tenant_customisation)
current_app.logger.debug(f"Converted JSON string to dict: {tenant_customisation}")
except json.JSONDecodeError as e:
current_app.logger.error(f"Error parsing JSON customisation: {e}")
return default_customisation
# Update with tenant customization
current_app.logger.debug(f"Tenant customisation - in default creation: {tenant_customisation}")
if tenant_customisation:
for key, value in tenant_customisation.items():
if key in customisation:
customisation[key] = value
return customisation

View File

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

View File

@@ -3,7 +3,7 @@ from datetime import datetime as dt, timezone as tz
from sqlalchemy import desc from sqlalchemy import desc
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from common.models.document import Document, DocumentVersion, Catalog from common.models.document import Document, DocumentVersion, Catalog, Processor
from common.extensions import db, minio_client from common.extensions import db, minio_client
from common.utils.celery_utils import current_celery from common.utils.celery_utils import current_celery
from flask import current_app from flask import current_app
@@ -11,6 +11,7 @@ import requests
from urllib.parse import urlparse, unquote, urlunparse, parse_qs from urllib.parse import urlparse, unquote, urlunparse, parse_qs
import os import os
from config.type_defs.processor_types import PROCESSOR_TYPES
from .config_field_types import normalize_json_field from .config_field_types import normalize_json_field
from .eveai_exceptions import (EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType, from .eveai_exceptions import (EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType,
EveAIInvalidCatalog, EveAIInvalidDocument, EveAIInvalidDocumentVersion, EveAIException) EveAIInvalidCatalog, EveAIInvalidDocument, EveAIInvalidDocumentVersion, EveAIException)
@@ -469,3 +470,15 @@ def lookup_document(tenant_id: int, lookup_criteria: dict, metadata_type: str) -
"Error during document lookup", "Error during document lookup",
status_code=500 status_code=500
) )
def is_file_type_supported_by_catalog(catalog_id, file_type):
processors = Processor.query.filter_by(catalog_id=catalog_id).filter_by(active=True).all()
supported_file_types = []
for processor in processors:
processor_file_types = PROCESSOR_TYPES[processor.type]['file_types']
file_types = [f.strip() for f in processor_file_types.split(",")]
supported_file_types.extend(file_types)
if file_type not in supported_file_types:
raise EveAIUnsupportedFileType()

View File

@@ -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] = ""

View File

@@ -34,7 +34,25 @@ class EveAIDoubleURLException(EveAIException):
class EveAIUnsupportedFileType(EveAIException): class EveAIUnsupportedFileType(EveAIException):
"""Raised when an invalid file type is provided""" """Raised when an invalid file type is provided"""
def __init__(self, message="Filetype is not supported", status_code=400, payload=None): def __init__(self, message="Filetype is not supported by current active processors", status_code=400, payload=None):
super().__init__(message, status_code, payload)
class EveAINoProcessorFound(EveAIException):
"""Raised when no processor is found for a given file type"""
def __init__(self, catalog_id, file_type, file_subtype, status_code=400, payload=None):
message = f"No active processor found for catalog {catalog_id} with file type {file_type} and subtype {file_subtype}"
super().__init__(message, status_code, payload)
class EveAINoContentFound(EveAIException):
"""Raised when no content is found for a given document"""
def __init__(self, document_id, document_version_id, status_code=400, payload=None):
self.document_id = document_id
self.document_version_id = document_version_id
message = f"No content found while processing Document with ID {document_id} and version {document_version_id}."
super().__init__(message, status_code, payload) super().__init__(message, status_code, payload)
@@ -248,3 +266,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)

View File

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

View File

@@ -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, youve adapted to changing trends, from remote work to
AI-driven sourcing. Youre more than a recruiter—youre 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"

View File

@@ -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 = ['', '$']
@@ -75,10 +156,7 @@ class Config(object):
# supported LLMs # supported LLMs
# SUPPORTED_EMBEDDINGS = ['openai.text-embedding-3-small', 'openai.text-embedding-3-large', 'mistral.mistral-embed'] # SUPPORTED_EMBEDDINGS = ['openai.text-embedding-3-small', 'openai.text-embedding-3-large', 'mistral.mistral-embed']
SUPPORTED_EMBEDDINGS = ['mistral.mistral-embed'] SUPPORTED_EMBEDDINGS = ['mistral.mistral-embed']
SUPPORTED_LLMS = ['openai.gpt-4o', 'openai.gpt-4o-mini', SUPPORTED_LLMS = ['mistral.mistral-large-latest', 'mistral.mistral-medium_latest', 'mistral.mistral-small-latest']
'mistral.mistral-large-latest', 'mistral.mistral-medium_latest', 'mistral.mistral-small-latest']
ANTHROPIC_LLM_VERSIONS = {'claude-3-5-sonnet': 'claude-3-5-sonnet-20240620', }
# Annotation text chunk length # Annotation text chunk length
ANNOTATION_TEXT_CHUNK_LENGTH = 10000 ANNOTATION_TEXT_CHUNK_LENGTH = 10000
@@ -107,6 +185,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 +272,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'

View File

@@ -0,0 +1,63 @@
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
metadata:
author: "Josako"
date_added: "2024-06-06"
changes: "Initial version"
description: "Parameters allowing to customise the chat client"

View File

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

View File

@@ -0,0 +1,14 @@
version: "1.0.0"
name: "HTML Processor"
file_types: "html"
description: "A processor for HTML files, driven by AI"
configuration:
custom_instructions:
name: "Custom Instructions"
description: "Some custom instruction to guide our AI agent in parsing your HTML file"
type: "text"
required: false
metadata:
author: "Josako"
date_added: "2025-06-25"
description: "A processor for HTML files, driven by AI"

View File

@@ -0,0 +1,30 @@
version: "1.0.0"
content: |
You are a top administrative assistant specialized in transforming given HTML into markdown formatted files. The
generated files will be used to generate embeddings in a RAG-system.
# Best practices are:
- Respect wordings and language(s) used in the HTML.
- The following items need to be considered: headings, paragraphs, listed items (numbered or not) and tables. Images can be neglected.
- Sub-headers can be used as lists. This is true when a header is followed by a series of sub-headers without content (paragraphs or listed items). Present those sub-headers as a list.
- Be careful of encoding of the text. Everything needs to be human readable.
You only return relevant information, and filter out non-relevant information, such as:
- information found in menu bars, sidebars, footers or headers
- information in forms, buttons
Process the file or text carefully, and take a stepped approach. The resulting markdown should be the result of the
processing of the complete input html file. Answer with the pure markdown, without any other text.
{custom_instructions}
HTML to be processed is in between triple backquotes.
```{html}```
llm_model: "mistral.mistral-small-latest"
metadata:
author: "Josako"
date_added: "2025-06-25"
description: "An aid in transforming HTML-based inputs to markdown, fully automatic"
changes: "Initial version"

View File

@@ -0,0 +1,15 @@
version: "1.0.0"
content: >
You are a top translator. We need you to translate {text_to_translate} into {target_language}, taking into account
this context:
{context}
I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer
without further interpretation. If more than one option is available, present me with the most probable one.
llm_model: "mistral.ministral-8b-latest"
metadata:
author: "Josako"
date_added: "2025-06-23"
description: "An assistant to translate given a context."
changes: "Initial version"

View File

@@ -0,0 +1,12 @@
version: "1.0.0"
content: >
You are a top translator. We need you to translate {text_to_translate} into {target_language}.
I only want you to return the translation. No explanation, no options. I need to be able to directly use your answer
without further interpretation. If more than one option is available, present me with the most probable one.
llm_model: "mistral.ministral-8b-latest"
metadata:
author: "Josako"
date_added: "2025-06-23"
description: "An assistant to translate without context."
changes: "Initial version"

View File

@@ -0,0 +1,46 @@
type: "PERSONAL_CONTACT_FORM"
version: "1.0.0"
name: "Personal Contact Form"
icon: "person"
fields:
name:
name: "Name"
description: "Your name"
type: "str"
required: true
email:
name: "Email"
type: "str"
description: "Your Name"
required: true
phone:
name: "Phone Number"
type: "str"
description: "Your Phone Number"
context: "Een kleine test om te zien of we context kunnen doorgeven en tonen"
required: true
address:
name: "Address"
type: "string"
description: "Your Address"
required: false
zip:
name: "Postal Code"
type: "string"
description: "Postal Code"
required: false
city:
name: "City"
type: "string"
description: "City"
required: false
country:
name: "Country"
type: "string"
description: "Country"
required: false
consent:
name: "Consent"
type: "boolean"
description: "Consent"
required: true

View File

@@ -0,0 +1,55 @@
type: "PROFESSIONAL_CONTACT_FORM"
version: "1.0.0"
name: "Professional Contact Form"
icon: "account_circle"
fields:
name:
name: "Name"
description: "Your name"
type: "str"
required: true
email:
name: "Email"
type: "str"
description: "Your Name"
required: true
phone:
name: "Phone Number"
type: "str"
description: "Your Phone Number"
required: true
company:
name: "Company Name"
type: "str"
description: "Company Name"
required: true
job_title:
name: "Job Title"
type: "str"
description: "Job Title"
required: false
address:
name: "Address"
type: "str"
description: "Your Address"
required: false
zip:
name: "Postal Code"
type: "str"
description: "Postal Code"
required: false
city:
name: "City"
type: "str"
description: "City"
required: false
country:
name: "Country"
type: "str"
description: "Country"
required: false
consent:
name: "Consent"
type: "bool"
description: "Consent"
required: true

View File

@@ -1,4 +1,4 @@
version: "1.1.0" version: "1.2.0"
name: "Traicie Role Definition Specialist" 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"

View File

@@ -0,0 +1,50 @@
version: "1.3.0"
name: "Traicie Role Definition Specialist"
framework: "crewai"
partner: "traicie"
chat: false
configuration: {}
arguments:
role_name:
name: "Role Name"
description: "The name of the role that is being processed. Will be used to create a selection specialist"
type: "str"
required: true
specialist_name:
name: "Chatbot Name"
description: "The name of the chatbot."
type: "str"
required: true
make:
name: "Make"
description: "The make for which the role is defined and the selection specialist is created"
type: "system"
system_name: "tenant_make"
required: true
role_reference:
name: "Role Reference"
description: "A customer reference to the role"
type: "str"
required: false
vacancy_text:
name: "vacancy_text"
type: "text"
description: "The Vacancy Text"
required: true
results:
competencies:
name: "competencies"
type: "List[str, str]"
description: "List of vacancy competencies and their descriptions"
required: false
agents:
- type: "TRAICIE_HR_BP_AGENT"
version: "1.0"
tasks:
- type: "TRAICIE_GET_COMPETENCIES_TASK"
version: "1.1"
metadata:
author: "Josako"
date_added: "2025-05-27"
changes: "Added a make to be specified (a selection specialist now is created in context of a make"
description: "Assistant to create a new Vacancy based on Vacancy Text"

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
version: "1.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"

View File

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

View File

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

View File

@@ -0,0 +1,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"

View File

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

View File

@@ -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"
},
} }

View File

@@ -0,0 +1,7 @@
# Catalog Types
CUSTOMISATION_TYPES = {
"CHAT_CLIENT_CUSTOMISATION": {
"name": "Chat Client Customisation",
"description": "Parameters allowing to customise the chat client",
},
}

View File

@@ -24,5 +24,10 @@ PROCESSOR_TYPES = {
"name": "DOCX Processor", "name": "DOCX Processor",
"description": "A processor for DOCX files", "description": "A processor for DOCX files",
"file_types": "docx", "file_types": "docx",
} },
"AUTOMAGIC_HTML_PROCESSOR": {
"name": "AutoMagic HTML Processor",
"description": "A processor for HTML files, driven by AI",
"file_types": "html, htm",
},
} }

View File

@@ -28,4 +28,12 @@ PROMPT_TYPES = {
"name": "transcript", "name": "transcript",
"description": "An assistant to transform a transcript to markdown.", "description": "An assistant to transform a transcript to markdown.",
}, },
"translation_with_context": {
"name": "translation_with_context",
"description": "An assistant to translate text with context",
},
"translation_without_context": {
"name": "translation_without_context",
"description": "An assistant to translate text without context",
},
} }

View File

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

View File

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

View File

@@ -5,6 +5,90 @@ 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.8-alfa]
### Added
- Translation Service
- Automagic HTML Processor
- Allowed languages defined at level of Tenant Make
### Changed
- For changes in existing functionality.
- Allow to activate / de-activate Processors
- Align all document views with session catalog
- Allow different processor types to handle the same file types
- Remove welcome message from tenant_make customisation, add to specialist configuration
### Deprecated
- For soon-to-be removed features.
### Removed
- For now removed features.
### Fixed
- Adapt TRAICIE_ROLE_DEFINITION_SPECIALIST to latest requirements
- Allow for empty historical messages
- Ensure client can cope with empty customisation options
- Ensure only tenant-defined makes are selectable throughout the application
- Refresh partner info when adding Partner Services
### Security
- In case of vulnerabilities.
## [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 +113,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 +132,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 +189,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

View File

@@ -24,7 +24,7 @@ x-common-variables: &common-variables
FLOWER_PASSWORD: 'Jungles' FLOWER_PASSWORD: 'Jungles'
OPENAI_API_KEY: 'sk-proj-8R0jWzwjL7PeoPyMhJTZT3BlbkFJLb6HfRB2Hr9cEVFWEhU7' OPENAI_API_KEY: 'sk-proj-8R0jWzwjL7PeoPyMhJTZT3BlbkFJLb6HfRB2Hr9cEVFWEhU7'
GROQ_API_KEY: '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' GROQ_API_KEY: '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'
MISTRAL_API_KEY: 'jGDc6fkCbt0iOC0jQsbuZhcjLWBPGc2b' MISTRAL_API_KEY: '0f4ZiQ1kIpgIKTHX8d0a8GOD2vAgVqEn'
ANTHROPIC_API_KEY: 'sk-ant-api03-c2TmkzbReeGhXBO5JxNH6BJNylRDonc9GmZd0eRbrvyekec2' ANTHROPIC_API_KEY: 'sk-ant-api03-c2TmkzbReeGhXBO5JxNH6BJNylRDonc9GmZd0eRbrvyekec2'
JWT_SECRET_KEY: 'bsdMkmQ8ObfMD52yAFg4trrvjgjMhuIqg2fjDpD/JqvgY0ccCcmlsEnVFmR79WPiLKEA3i8a5zmejwLZKl4v9Q==' JWT_SECRET_KEY: 'bsdMkmQ8ObfMD52yAFg4trrvjgjMhuIqg2fjDpD/JqvgY0ccCcmlsEnVFmR79WPiLKEA3i8a5zmejwLZKl4v9Q=='
API_ENCRYPTION_KEY: 'xfF5369IsredSrlrYZqkM9ZNrfUASYYS6TCcAR9UKj4=' API_ENCRYPTION_KEY: 'xfF5369IsredSrlrYZqkM9ZNrfUASYYS6TCcAR9UKj4='
@@ -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

View File

@@ -26,7 +26,7 @@ x-common-variables: &common-variables
REDIS_PORT: '6379' REDIS_PORT: '6379'
FLOWER_USER: 'Felucia' FLOWER_USER: 'Felucia'
FLOWER_PASSWORD: 'Jungles' FLOWER_PASSWORD: 'Jungles'
MISTRAL_API_KEY: 'Vkwgr67vUs6ScKmcFF2QVw7uHKgq0WEN' MISTRAL_API_KEY: 'qunKSaeOkFfLteNiUO77RCsXXSLK65Ec'
JWT_SECRET_KEY: '7e9c8b3a215f4d6e90712c5d8f3b97a60e482c15f39a7d68bcd45910ef23a784' JWT_SECRET_KEY: '7e9c8b3a215f4d6e90712c5d8f3b97a60e482c15f39a7d68bcd45910ef23a784'
API_ENCRYPTION_KEY: 'kJ7N9p3IstyRGkluYTryM8ZMnfUBSXWR3TCfDG9VLc4=' API_ENCRYPTION_KEY: 'kJ7N9p3IstyRGkluYTryM8ZMnfUBSXWR3TCfDG9VLc4='
MINIO_ENDPOINT: minio:9000 MINIO_ENDPOINT: minio:9000
@@ -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:

View File

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

View 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
View File

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

View File

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

View File

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

View File

@@ -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)
@@ -154,8 +201,3 @@ def register_cache_handlers(app):
register_specialist_cache_handlers(cache_manager) register_specialist_cache_handlers(cache_manager)
from common.utils.cache.license_cache import register_license_cache_handlers from common.utils.cache.license_cache import register_license_cache_handlers
register_license_cache_handlers(cache_manager) register_license_cache_handlers(cache_manager)

View File

@@ -4,17 +4,19 @@
{% block title %}Document Versions{% endblock %} {% block title %}Document Versions{% endblock %}
{% 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 <b>{{ document }}</b>{% 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", "File Type", "File Size", "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>

View File

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

View File

@@ -4,14 +4,13 @@
{% block title %}Documents{% endblock %} {% block title %}Documents{% endblock %}
{% block content_title %}Documents{% endblock %} {% block content_title %}Documents{% endblock %}
{% block content_description %}View Documents for Tenant{% endblock %} {% block content_description %}View Documents for Catalog <b>{% if session.catalog_name %}{{ session.catalog_name }}{% else %}No Catalog{% endif %}</b>{% endblock %}
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% endblock %} {% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% endblock %}
{% block content %} {% block content %}
<!-- Filter Form --> <!-- Filter Form -->
{% set filter_form %} {% set filter_form %}
<form method="GET" action="{{ url_for('document_bp.documents') }}"> <form method="GET" action="{{ url_for('document_bp.documents') }}">
{{ render_filter_field('catalog_id', 'Catalog', filter_options['catalog_id'], filters.get('catalog_id', [])) }}
{{ render_filter_field('validity', 'Validity', filter_options['validity'], filters.get('validity', [])) }} {{ render_filter_field('validity', 'Validity', filter_options['validity'], filters.get('validity', [])) }}
<button type="submit" class="btn btn-primary">Apply Filters</button> <button type="submit" class="btn btn-primary">Apply Filters</button>
@@ -27,7 +26,6 @@
headers=[ headers=[
{"text": "ID", "sort": "id"}, {"text": "ID", "sort": "id"},
{"text": "Name", "sort": "name"}, {"text": "Name", "sort": "name"},
{"text": "Catalog", "sort": "catalog_name"},
{"text": "Valid From", "sort": "valid_from"}, {"text": "Valid From", "sort": "valid_from"},
{"text": "Valid To", "sort": "valid_to"} {"text": "Valid To", "sort": "valid_to"}
], ],

View File

@@ -4,7 +4,7 @@
{% block title %}Edit Processor{% endblock %} {% block title %}Edit Processor{% endblock %}
{% block content_title %}Edit Processor{% endblock %} {% block content_title %}Edit Processor{% endblock %}
{% block content_description %}Edit a Processor (for a Catalog){% endblock %} {% block content_description %}Edit Processor for Catalog <b>{% if session.catalog_name %}{{ session.catalog_name }}{% else %}No Catalog{% endif %}</b>{% endblock %}
{% block content %} {% block content %}
<form method="post"> <form method="post">

View File

@@ -4,7 +4,7 @@
{% block title %}Edit Retriever{% endblock %} {% block title %}Edit Retriever{% endblock %}
{% block content_title %}Edit Retriever{% endblock %} {% block content_title %}Edit Retriever{% endblock %}
{% block content_description %}Edit a Retriever (for a Catalog){% endblock %} {% block content_description %}Edit a Retriever for catalog <b>{% if session.catalog_name %}{{ session.catalog_name }}{% else %}No Catalog{% endif %}</b>{% endblock %}
{% block content %} {% block content %}
<form method="post"> <form method="post">

View File

@@ -4,7 +4,7 @@
{% block title %}Processor Registration{% endblock %} {% block title %}Processor Registration{% endblock %}
{% block content_title %}Register Processor{% endblock %} {% block content_title %}Register Processor{% endblock %}
{% block content_description %}Define a new processor (for a catalog){% endblock %} {% block content_description %}Define a new processor for Catalog <b>{% if session.catalog_name %}{{ session.catalog_name }}{% else %}No Catalog{% endif %}</b>{% endblock %}
{% block content %} {% block content %}
<form method="post"> <form method="post">

View File

@@ -4,13 +4,13 @@
{% block title %}Processors{% endblock %} {% block title %}Processors{% endblock %}
{% block content_title %}Processors{% endblock %} {% block content_title %}Processors{% endblock %}
{% block content_description %}View Processors for Tenant{% endblock %} {% block content_description %}View Processors for Catalog <b>{% if session.catalog_name %}{{ session.catalog_name }}{% else %}No Catalog{% endif %}</b>{% endblock %}
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% 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_processor_selection') }}" id="processorsForm"> <form method="POST" action="{{ url_for('document_bp.handle_processor_selection') }}" id="processorsForm">
{{ render_selectable_table(headers=["Processor ID", "Name", "Type", "Catalog ID"], rows=rows, selectable=True, id="retrieversTable") }} {{ render_selectable_table(headers=["Processor ID", "Name", "Type", "Active"], rows=rows, selectable=True, id="retrieversTable") }}
<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_processor" class="btn btn-primary" onclick="return validateTableSelection('processorsForm')">Edit Processor</button> <button type="submit" name="action" value="edit_processor" class="btn btn-primary" onclick="return validateTableSelection('processorsForm')">Edit Processor</button>

View File

@@ -4,7 +4,7 @@
{% block title %}Retriever Registration{% endblock %} {% block title %}Retriever Registration{% endblock %}
{% block content_title %}Register Retriever{% endblock %} {% block content_title %}Register Retriever{% endblock %}
{% block content_description %}Define a new retriever (for a catalog){% endblock %} {% block content_description %}Define a new retriever for Catalog <b>{% if session.catalog_name %}{{ session.catalog_name }}{% else %}No Catalog{% endif %}</b>{% endblock %}
{% block content %} {% block content %}
<form method="post"> <form method="post">

View File

@@ -4,13 +4,13 @@
{% block title %}Retrievers{% endblock %} {% block title %}Retrievers{% endblock %}
{% block content_title %}Retrievers{% endblock %} {% block content_title %}Retrievers{% endblock %}
{% block content_description %}View Retrievers for Tenant{% endblock %} {% block content_description %}View Retrievers for Catalog <b>{% if session.catalog_name %}{{ session.catalog_name }}{% else %}No Catalog{% endif %}</b>{% endblock %}
{% block content_class %}<div class="col-xl-12 col-lg-5 col-md-7 mx-auto"></div>{% 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_retriever_selection') }}" id="retrieversForm"> <form method="POST" action="{{ url_for('document_bp.handle_retriever_selection') }}" id="retrieversForm">
{{ render_selectable_table(headers=["Retriever ID", "Name", "Type", "Catalog ID"], rows=rows, selectable=True, id="retrieversTable") }} {{ render_selectable_table(headers=["Retriever ID", "Name", "Type"], rows=rows, selectable=True, id="retrieversTable") }}
<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_retriever" class="btn btn-primary" onclick="return validateTableSelection('retrieversForm')">Edit Retriever</button> <button type="submit" name="action" value="edit_retriever" class="btn btn-primary" onclick="return validateTableSelection('retrieversForm')">Edit Retriever</button>

View File

@@ -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() %}

View File

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

View File

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

View File

@@ -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']},
]) }} ]) }}

View 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 %}

View 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 %}

View 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 %}

View File

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

View File

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

View File

@@ -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)
@@ -62,15 +71,6 @@ class ProcessorForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)]) name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()]) description = TextAreaField('Description', validators=[Optional()])
# Catalog for the Retriever
catalog = QuerySelectField(
'Catalog ID',
query_factory=lambda: Catalog.query.all(),
allow_blank=True,
get_label='name',
validators=[DataRequired()],
)
# Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config) # Select Field for Catalog Type (Uses the CATALOG_TYPES defined in config)
type = SelectField('Processor Type', validators=[DataRequired()]) type = SelectField('Processor Type', validators=[DataRequired()])
@@ -80,6 +80,7 @@ class ProcessorForm(FlaskForm):
default=2000) default=2000)
max_chunk_size = IntegerField('Maximum Chunk Size (3000)', validators=[NumberRange(min=0), Optional()], max_chunk_size = IntegerField('Maximum Chunk Size (3000)', validators=[NumberRange(min=0), Optional()],
default=3000) default=3000)
active = BooleanField('Active', default=True)
tuning = BooleanField('Enable Embedding Tuning', default=False) tuning = BooleanField('Enable Embedding Tuning', default=False)
# Metadata fields # Metadata fields
@@ -99,14 +100,6 @@ class EditProcessorForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired(), Length(max=50)]) name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()]) description = TextAreaField('Description', validators=[Optional()])
# Catalog for the Retriever
catalog = QuerySelectField(
'Catalog ID',
query_factory=lambda: Catalog.query.all(),
allow_blank=True,
get_label='name',
validators=[Optional()],
)
type = StringField('Processor Type', validators=[DataRequired()], render_kw={'readonly': True}) type = StringField('Processor Type', validators=[DataRequired()], render_kw={'readonly': True})
sub_file_type = StringField('Sub File Type', validators=[Optional(), Length(max=50)]) sub_file_type = StringField('Sub File Type', validators=[Optional(), Length(max=50)])
@@ -115,6 +108,7 @@ class EditProcessorForm(DynamicFormBase):
default=2000) default=2000)
max_chunk_size = IntegerField('Maximum Chunk Size (3000)', validators=[NumberRange(min=0), Optional()], max_chunk_size = IntegerField('Maximum Chunk Size (3000)', validators=[NumberRange(min=0), Optional()],
default=3000) default=3000)
active = BooleanField('Active', default=True)
tuning = BooleanField('Enable Embedding Tuning', default=False) tuning = BooleanField('Enable Embedding Tuning', default=False)
# Metadata fields # Metadata fields
@@ -125,14 +119,7 @@ class EditProcessorForm(DynamicFormBase):
class RetrieverForm(FlaskForm): class RetrieverForm(FlaskForm):
name = StringField('Name', validators=[DataRequired(), Length(max=50)]) name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()]) description = TextAreaField('Description', validators=[Optional()])
# Catalog for the Retriever
catalog = QuerySelectField(
'Catalog ID',
query_factory=lambda: Catalog.query.all(),
allow_blank=True,
get_label='name',
validators=[Optional()],
)
# Select Field for Retriever Type (Uses the RETRIEVER_TYPES defined in config) # Select Field for Retriever Type (Uses the RETRIEVER_TYPES defined in config)
type = SelectField('Retriever Type', validators=[DataRequired()]) type = SelectField('Retriever Type', validators=[DataRequired()])
tuning = BooleanField('Enable Tuning', default=False) tuning = BooleanField('Enable Tuning', default=False)
@@ -151,14 +138,7 @@ class RetrieverForm(FlaskForm):
class EditRetrieverForm(DynamicFormBase): class EditRetrieverForm(DynamicFormBase):
name = StringField('Name', validators=[DataRequired(), Length(max=50)]) name = StringField('Name', validators=[DataRequired(), Length(max=50)])
description = TextAreaField('Description', validators=[Optional()]) description = TextAreaField('Description', validators=[Optional()])
# Catalog for the Retriever
catalog = QuerySelectField(
'Catalog ID',
query_factory=lambda: Catalog.query.all(),
allow_blank=True,
get_label='name',
validators=[Optional()],
)
# Select Field for Retriever Type (Uses the RETRIEVER_TYPES defined in config) # Select Field for Retriever Type (Uses the RETRIEVER_TYPES defined in config)
type = StringField('Processor Type', validators=[DataRequired()], render_kw={'readonly': True}) type = StringField('Processor Type', validators=[DataRequired()], render_kw={'readonly': True})
tuning = BooleanField('Enable Tuning', default=False) tuning = BooleanField('Enable Tuning', default=False)
@@ -181,7 +161,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 +181,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')

View File

@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime as dt, timezone as tz
from flask import request, render_template, session from flask import request, render_template, session, current_app
from sqlalchemy import desc, asc, or_, and_, cast, Integer from sqlalchemy import desc, asc, or_, and_, cast, Integer
from common.models.document import Document, Catalog from common.models.document import Document, Catalog
from common.utils.filtered_list_view import FilteredListView from common.utils.filtered_list_view import FilteredListView
@@ -7,31 +7,19 @@ from common.utils.view_assistants import prepare_table_for_macro
class DocumentListView(FilteredListView): class DocumentListView(FilteredListView):
allowed_filters = ['catalog_id', 'validity'] allowed_filters = ['validity']
allowed_sorts = ['id', 'name', 'catalog_name', 'valid_from', 'valid_to'] allowed_sorts = ['id', 'name', 'valid_from', 'valid_to']
def get_query(self): def get_query(self):
return Document.query.join(Catalog).add_columns( catalog_id = session.get('catalog_id')
Document.id, current_app.logger.debug(f"Catalog ID: {catalog_id}")
Document.name, return Document.query.filter_by(catalog_id=catalog_id)
Catalog.name.label('catalog_name'),
Document.valid_from,
Document.valid_to
)
def apply_filters(self, query): def apply_filters(self, query):
filters = request.args.to_dict(flat=False) filters = request.args.to_dict(flat=False)
if 'catalog_id' in filters:
catalog_ids = filters['catalog_id']
if catalog_ids:
# Convert catalog_ids to a list of integers
catalog_ids = [int(cid) for cid in catalog_ids if cid.isdigit()]
if catalog_ids:
query = query.filter(Document.catalog_id.in_(catalog_ids))
if 'validity' in filters: if 'validity' in filters:
now = datetime.utcnow().date() now = dt.now(tz.utc).date()
if 'valid' in filters['validity']: if 'valid' in filters['validity']:
query = query.filter( query = query.filter(
and_( and_(
@@ -47,10 +35,7 @@ class DocumentListView(FilteredListView):
sort_order = request.args.get('sort_order', 'asc') sort_order = request.args.get('sort_order', 'asc')
if sort_by in self.allowed_sorts: if sort_by in self.allowed_sorts:
if sort_by == 'catalog_name': column = getattr(Document, sort_by)
column = Catalog.name
else:
column = getattr(Document, sort_by)
if sort_order == 'asc': if sort_order == 'asc':
query = query.order_by(asc(column)) query = query.order_by(asc(column))
@@ -61,42 +46,39 @@ class DocumentListView(FilteredListView):
def get(self): def get(self):
query = self.get_query() query = self.get_query()
query = self.apply_filters(query) # query = self.apply_filters(query)
query = self.apply_sorting(query) # query = self.apply_sorting(query)
pagination = self.paginate(query) pagination = self.paginate(query)
def format_date(date): def format_date(date):
if isinstance(date, datetime): if isinstance(date, dt):
return date.strftime('%Y-%m-%d') return date.strftime('%Y-%m-%d')
elif isinstance(date, str): elif isinstance(date, str):
return date return date
else: else:
return '' return ''
current_app.logger.debug(f"Items retrieved: {pagination.items}")
rows = [ rows = [
[ [
{'value': item.id, 'class': '', 'type': 'text'}, {'value': item.id, 'class': '', 'type': 'text'},
{'value': item.name, 'class': '', 'type': 'text'}, {'value': item.name, 'class': '', 'type': 'text'},
{'value': item.catalog_name, 'class': '', 'type': 'text'},
{'value': format_date(item.valid_from), 'class': '', 'type': 'text'}, {'value': format_date(item.valid_from), 'class': '', 'type': 'text'},
{'value': format_date(item.valid_to), 'class': '', 'type': 'text'} {'value': format_date(item.valid_to), 'class': '', 'type': 'text'}
] for item in pagination.items ] for item in pagination.items
] ]
catalogs = Catalog.query.all()
context = { context = {
'rows': rows, 'rows': rows,
'pagination': pagination, 'pagination': pagination,
'filters': request.args.to_dict(flat=False), 'filters': request.args.to_dict(flat=False),
'sort_by': request.args.get('sort_by', 'id'), 'sort_by': request.args.get('sort_by', 'id'),
'sort_order': request.args.get('sort_order', 'asc'), 'sort_order': request.args.get('sort_order', 'asc'),
'filter_options': self.get_filter_options(catalogs) 'filter_options': self.get_filter_options()
} }
return render_template(self.template, **context) return render_template(self.template, **context)
def get_filter_options(self, catalogs): def get_filter_options(self):
return { return {
'catalog_id': [(str(cat.id), cat.name) for cat in catalogs],
'validity': [('valid', 'Valid'), ('all', 'All')] 'validity': [('valid', 'Valid'), ('all', 'All')]
} }

View File

@@ -16,7 +16,7 @@ from common.extensions import db, cache_manager, minio_client
from common.models.interaction import Specialist, SpecialistRetriever from common.models.interaction import Specialist, SpecialistRetriever
from common.utils.document_utils import create_document_stack, start_embedding_task, process_url, \ from common.utils.document_utils import create_document_stack, start_embedding_task, process_url, \
edit_document, \ edit_document, \
edit_document_version, refresh_document, clean_url edit_document_version, refresh_document, clean_url, is_file_type_supported_by_catalog
from common.utils.dynamic_field_utils import create_default_config_from_type_config from common.utils.dynamic_field_utils import create_default_config_from_type_config
from common.utils.eveai_exceptions import EveAIInvalidLanguageException, EveAIUnsupportedFileType, \ from common.utils.eveai_exceptions import EveAIInvalidLanguageException, EveAIUnsupportedFileType, \
EveAIDoubleURLException, EveAIException EveAIDoubleURLException, EveAIException
@@ -110,7 +110,6 @@ def handle_catalog_selection():
current_app.logger.info(f'Setting session catalog to {catalog.name}') current_app.logger.info(f'Setting session catalog to {catalog.name}')
session['catalog_id'] = catalog_id session['catalog_id'] = catalog_id
session['catalog_name'] = catalog.name session['catalog_name'] = catalog.name
current_app.logger.info(f'Finished setting session catalog to {catalog.name}')
elif action == 'edit_catalog': elif action == 'edit_catalog':
return redirect(prefixed_url_for('document_bp.edit_catalog', catalog_id=catalog_id)) return redirect(prefixed_url_for('document_bp.edit_catalog', catalog_id=catalog_id))
@@ -157,7 +156,7 @@ def processor():
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
new_processor = Processor() new_processor = Processor()
form.populate_obj(new_processor) form.populate_obj(new_processor)
new_processor.catalog_id = form.catalog.data.id new_processor.catalog_id = session.get('catalog_id')
processor_config = cache_manager.processors_config_cache.get_config(new_processor.type) processor_config = cache_manager.processors_config_cache.get_config(new_processor.type)
new_processor.configuration = create_default_config_from_type_config( new_processor.configuration = create_default_config_from_type_config(
processor_config["configuration"]) processor_config["configuration"])
@@ -204,9 +203,6 @@ def edit_processor(processor_id):
form.populate_obj(processor) form.populate_obj(processor)
processor.configuration = form.get_dynamic_data('configuration') processor.configuration = form.get_dynamic_data('configuration')
# Update catalog relationship
processor.catalog_id = form.catalog.data.id if form.catalog.data else None
# Update logging information # Update logging information
update_logging_information(processor, dt.now(tz.utc)) update_logging_information(processor, dt.now(tz.utc))
@@ -235,14 +231,19 @@ def processors():
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)
query = Processor.query.order_by(Processor.id) catalog_id = session.get('catalog_id', None)
if not catalog_id:
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
return redirect(prefixed_url_for('document_bp.catalogs'))
query = Processor.query.filter_by(catalog_id=catalog_id).order_by(Processor.id)
pagination = query.paginate(page=page, per_page=per_page) pagination = query.paginate(page=page, per_page=per_page)
the_processors = pagination.items the_processors = pagination.items
# prepare table data # prepare table data
rows = prepare_table_for_macro(the_processors, rows = prepare_table_for_macro(the_processors,
[('id', ''), ('name', ''), ('type', ''), ('catalog_id', '')]) [('id', ''), ('name', ''), ('type', ''), ('active', '')])
# Render the catalogs in a template # Render the catalogs in a template
return render_template('document/processors.html', rows=rows, pagination=pagination) return render_template('document/processors.html', rows=rows, pagination=pagination)
@@ -272,7 +273,7 @@ def retriever():
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
new_retriever = Retriever() new_retriever = Retriever()
form.populate_obj(new_retriever) form.populate_obj(new_retriever)
new_retriever.catalog_id = form.catalog.data.id new_retriever.catalog_id = session.get('catalog_id')
new_retriever.type_version = cache_manager.retrievers_version_tree_cache.get_latest_version( new_retriever.type_version = cache_manager.retrievers_version_tree_cache.get_latest_version(
new_retriever.type) new_retriever.type)
@@ -301,12 +302,6 @@ def edit_retriever(retriever_id):
# Get the retriever or return 404 # Get the retriever or return 404
retriever = Retriever.query.get_or_404(retriever_id) retriever = Retriever.query.get_or_404(retriever_id)
if retriever.catalog_id:
# If catalog_id is just an ID, fetch the Catalog object
retriever.catalog = Catalog.query.get(retriever.catalog_id)
else:
retriever.catalog = None
# Create form instance with the retriever # Create form instance with the retriever
form = EditRetrieverForm(request.form, obj=retriever) form = EditRetrieverForm(request.form, obj=retriever)
@@ -319,9 +314,6 @@ def edit_retriever(retriever_id):
form.populate_obj(retriever) form.populate_obj(retriever)
retriever.configuration = form.get_dynamic_data('configuration') retriever.configuration = form.get_dynamic_data('configuration')
# Update catalog relationship
retriever.catalog_id = form.catalog.data.id if form.catalog.data else None
# Update logging information # Update logging information
update_logging_information(retriever, dt.now(tz.utc)) update_logging_information(retriever, dt.now(tz.utc))
@@ -350,14 +342,19 @@ def retrievers():
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)
query = Retriever.query.order_by(Retriever.id) catalog_id = session.get('catalog_id', None)
if not catalog_id:
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
return redirect(prefixed_url_for('document_bp.catalogs'))
query = Retriever.query.filter_by(catalog_id=catalog_id).order_by(Retriever.id)
pagination = query.paginate(page=page, per_page=per_page) pagination = query.paginate(page=page, per_page=per_page)
the_retrievers = pagination.items the_retrievers = pagination.items
# prepare table data # prepare table data
rows = prepare_table_for_macro(the_retrievers, rows = prepare_table_for_macro(the_retrievers,
[('id', ''), ('name', ''), ('type', ''), ('catalog_id', '')]) [('id', ''), ('name', ''), ('type', '')])
# Render the catalogs in a template # Render the catalogs in a template
return render_template('document/retrievers.html', rows=rows, pagination=pagination) return render_template('document/retrievers.html', rows=rows, pagination=pagination)
@@ -400,6 +397,8 @@ def add_document():
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
extension = filename.rsplit('.', 1)[1].lower() extension = filename.rsplit('.', 1)[1].lower()
is_file_type_supported_by_catalog(catalog_id, extension)
catalog_properties = form.get_dynamic_data("tagging_fields") catalog_properties = form.get_dynamic_data("tagging_fields")
api_input = { api_input = {
@@ -451,6 +450,8 @@ def add_url():
file_content, filename, extension = process_url(url, tenant_id) file_content, filename, extension = process_url(url, tenant_id)
is_file_type_supported_by_catalog(catalog_id, extension)
catalog_properties = {} catalog_properties = {}
full_config = cache_manager.catalogs_config_cache.get_config(catalog.type) full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
document_version_configurations = full_config['document_version_configurations'] document_version_configurations = full_config['document_version_configurations']
@@ -489,6 +490,11 @@ def add_url():
@document_bp.route('/documents', methods=['GET', 'POST']) @document_bp.route('/documents', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def documents(): def documents():
catalog_id = session.get('catalog_id', None)
if not catalog_id:
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
return redirect(prefixed_url_for('document_bp.catalogs'))
view = DocumentListView(Document, 'document/documents.html', per_page=10) view = DocumentListView(Document, 'document/documents.html', per_page=10)
return view.get() return view.get()
@@ -609,7 +615,7 @@ def edit_document_version_view(document_version_id):
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def document_versions(document_id): def document_versions(document_id):
doc = Document.query.get_or_404(document_id) doc = Document.query.get_or_404(document_id)
doc_desc = f'Document {doc.name}' doc_desc = f'{doc.name}'
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)
@@ -621,8 +627,7 @@ def document_versions(document_id):
pagination = query.paginate(page=page, per_page=per_page, error_out=False) pagination = query.paginate(page=page, per_page=per_page, error_out=False)
doc_langs = pagination.items doc_langs = pagination.items
rows = prepare_table_for_macro(doc_langs, [('id', ''), ('url', ''), rows = prepare_table_for_macro(doc_langs, [('id', ''), ('file_type', ''), ('file_size', ''),
('object_name', ''), ('file_type', ''),
('processing', ''), ('processing_started_at', ''), ('processing', ''), ('processing_started_at', ''),
('processing_finished_at', ''), ('processing_error', '')]) ('processing_finished_at', ''), ('processing_error', '')])

View File

@@ -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.id, 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.
@@ -309,6 +328,16 @@ class DynamicFormBase(FlaskForm):
initial_data: Optional initial data for the fields initial_data: Optional initial data for the fields
""" """
current_app.logger.debug(f"Adding dynamic fields for collection {collection_name} with config: {config}") current_app.logger.debug(f"Adding dynamic fields for collection {collection_name} with config: {config}")
if isinstance(initial_data, str):
try:
initial_data = json.loads(initial_data)
except (json.JSONDecodeError, TypeError):
current_app.logger.error(f"Invalid JSON in initial_data: {initial_data}")
initial_data = {}
elif initial_data is None:
initial_data = {}
# Store the full configuration for later use in get_list_type_configs_js # Store the full configuration for later use in get_list_type_configs_js
if not hasattr(self, '_full_configs'): if not hasattr(self, '_full_configs'):
self._full_configs = {} self._full_configs = {}
@@ -356,11 +385,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 +402,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 +445,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}")
@@ -552,7 +591,10 @@ class DynamicFormBase(FlaskForm):
except Exception as e: except Exception as e:
current_app.logger.error(f"Error converting initial data to patterns: {e}") current_app.logger.error(f"Error converting initial data to patterns: {e}")
elif isinstance(field, DateField): elif isinstance(field, DateField):
data[original_field_name] = field.data.isoformat() if field.data:
data[original_field_name] = field.data.isoformat()
else:
data[original_field_name] = None
else: else:
data[original_field_name] = field.data data[original_field_name] = field.data
return data return data
@@ -603,7 +645,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

View File

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

View File

@@ -1,3 +1,4 @@
from flask import session
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import (StringField, BooleanField, SelectField, TextAreaField) from wtforms import (StringField, BooleanField, SelectField, TextAreaField)
from wtforms.fields.datetime import DateField from wtforms.fields.datetime import DateField
@@ -8,6 +9,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 +26,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 +37,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 +50,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 +152,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 +163,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 +181,11 @@ 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_id = session.get('tenant').get('id')
tenant_makes = TenantMake.query.filter_by(tenant_id=tenant_id).all()
self.tenant_make_id.choices = [(0, 'None')] + [(make.id, make.name) for make in tenant_makes]

View File

@@ -162,6 +162,7 @@ def specialist():
new_specialist.type = form.type.data new_specialist.type = 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,15 @@ 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
specialist = Specialist.query.get(new_specialist_magic_link.specialist_id)
make_id = specialist.configuration.get('make', None)
current_app.logger.debug(f"make_id defined in specialist: {make_id}")
if make_id:
new_specialist_magic_link.tenant_make_id = make_id
elif session.get('tenant').get('default_tenant_make_id'):
new_specialist_magic_link.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 +742,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))

View File

@@ -62,6 +62,7 @@ def edit_partner(partner_id):
update_logging_information(partner, dt.now(tz.utc)) update_logging_information(partner, dt.now(tz.utc))
db.session.commit() db.session.commit()
flash('Partner updated successfully.', 'success') flash('Partner updated successfully.', 'success')
refresh_session_partner(partner.id)
return redirect( return redirect(
prefixed_url_for('partner_bp.edit_partner', prefixed_url_for('partner_bp.edit_partner',
partner_id=partner.id)) # Assuming there's a user profile view to redirect to partner_id=partner.id)) # Assuming there's a user profile view to redirect to
@@ -197,6 +198,7 @@ def edit_partner_service(partner_service_id):
db.session.commit() db.session.commit()
flash('Partner Service updated successfully.', 'success') flash('Partner Service updated successfully.', 'success')
current_app.logger.info(f"Partner Service {partner_service.name} updated successfully! ") current_app.logger.info(f"Partner Service {partner_service.name} updated successfully! ")
refresh_session_partner(partner_id)
except SQLAlchemyError as e: except SQLAlchemyError as e:
db.session.rollback() db.session.rollback()
flash(f'Failed to update Partner Service: {str(e)}', 'danger') flash(f'Failed to update Partner Service: {str(e)}', 'danger')
@@ -339,4 +341,7 @@ def add_partner_service_for_tenant(partner_service_id):
return redirect(prefixed_url_for('partner_bp.partner_services')) return redirect(prefixed_url_for('partner_bp.partner_services'))
def refresh_session_partner(partner_id):
if session.get('partner', None):
if partner_id == session['partner']['id']:
session['partner'] = Partner.query.get_or_404(partner_id).to_dict()

View File

@@ -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,51 @@ 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)])
allowed_languages = SelectMultipleField('Allowed Languages', choices=[], validators=[Optional()])
def __init__(self, *args, **kwargs):
super(TenantMakeForm, self).__init__(*args, **kwargs)
# Initialiseer de taalopties met taalcodes en vlaggen
lang_details = current_app.config['SUPPORTED_LANGUAGE_DETAILS']
self.allowed_languages.choices = [(details['iso 639-1'], f"{details['flag']} {details['iso 639-1']}")
for name, details in lang_details.items()]
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)])
allowed_languages = SelectMultipleField('Allowed Languages', choices=[], validators=[Optional()])
def __init__(self, *args, **kwargs):
super(EditTenantMakeForm, self).__init__(*args, **kwargs)
# Initialiseer de taalopties met taalcodes en vlaggen
lang_details = current_app.config['SUPPORTED_LANGUAGE_DETAILS']
self.allowed_languages.choices = [(details['iso 639-1'], f"{details['flag']} {details['iso 639-1']}")
for name, details in lang_details.items()]

View File

@@ -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,141 @@ 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)
# Verwerk allowed_languages als array
new_tenant_make.allowed_languages = form.allowed_languages.data if form.allowed_languages.data else None
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)
# Initialiseer de allowed_languages selectie met huidige waarden
if tenant_make.allowed_languages:
form.allowed_languages.data = tenant_make.allowed_languages
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")
# Verwerk allowed_languages als array
tenant_make.allowed_languages = form.allowed_languages.data if form.allowed_languages.data else None
# 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)

View File

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

View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More