25 Commits

Author SHA1 Message Date
92edbeacb2 .gitignore adaptation for Linux 2025-12-29 09:44:09 +01:00
Josako
30bfecc135 Merge branch 'feature/Convert_Git_Flow_Process_to_own_scripts' into develop 2025-12-11 09:57:58 +01:00
Josako
2c8347c91b - Writing custom git flow scripts - finishing up without extensive testing 2025-12-11 09:47:19 +01:00
Josako
fe9fc047ff - Writing custom git flow scripts - a start 2025-12-11 09:27:21 +01:00
Josako
0f8bda0aef - Ensure users cannot login when their valid_to date is expired. 2025-12-08 16:54:59 +01:00
Josako
bab9e89117 Merge tag 'v3.1.36-beta' into develop
Tagging version v3.1.36-beta v3.1.36-beta
2025-12-02 13:08:59 +01:00
Josako
e25698d6cf Merge branch 'release/v3.1.36-beta' 2025-12-02 13:08:59 +01:00
Josako
e30fe7807c - Release Notes voor 3.1.36-beta 2025-12-02 13:08:39 +01:00
Josako
94b805e0eb - TRA-89 - Problem solved where connection could get lost in sync between client and backend
- TRA-98 - End user could continue without accepting dpa & terms
- TRA-96 - Multiple-choice questions in mobile client not scrolling -> Solved by introducing new client layout
- TRA-101 - DPA-link was not working
- TRA-102 - Wrong responses when looking for affirmative answers.
2025-12-02 12:15:50 +01:00
Josako
9b86a220b1 - Introduction of Shells for Mobile client and Desktop client. Extensible with additional shells in the future 2025-12-01 14:07:16 +01:00
Josako
5a5d6b03af Merge branch 'feature/Introduce_tabs_in_mobile_chat_client' into develop 2025-11-28 10:04:49 +01:00
Josako
b1d8c9a17d - Small changes to allow for keyboard input, not finished 2025-11-28 10:04:31 +01:00
Josako
14273b8a70 - Full implementation of tab bar next to logo in mobile client
- Customisation option in Tenant Make
- Splitting all controls in the newly created tabs
2025-11-27 11:32:46 +01:00
Josako
5e25216b66 Merge branch 'release/v3.1.26-beta' 2025-11-26 11:40:01 +01:00
Josako
d68dfde52a Merge tag 'v3.1.26-beta' into develop
Tagging version v3.1.26-beta v3.1.26-beta
2025-11-26 11:40:01 +01:00
Josako
4bc2292c4c - Release Notes for 3.1.26-beta 2025-11-26 11:39:05 +01:00
Josako
f10bb6f395 - TRA-99 Solved. Unable to create a new Tenant Make
- Generic improvement of initialisation of Dynamic Forms, ensuring correct form processing
2025-11-26 11:31:25 +01:00
Josako
0d3c3949de - Wrap client in @vueuse/core to abstract mobile client dimensions 2025-11-26 08:01:33 +01:00
Josako
25adb4213b Merge branch 'release/v3.1.24-beta' 2025-11-25 13:23:24 +01:00
Josako
73125887a3 Merge tag 'v3.1.24-beta' into develop
Tagging version v3.1.24-beta v3.1.24-beta
2025-11-25 13:23:24 +01:00
Josako
c29ed37c09 - Release notes for 3.1.24-beta 2025-11-25 13:22:21 +01:00
Josako
9b1f9e8a3b Merge branch 'release/v3.1.23-beta' 2025-11-25 13:16:11 +01:00
Josako
e167df3032 Merge tag 'v3.1.23-beta' into develop
Tagging version v3.1.23-beta v3.1.23-beta
2025-11-25 13:16:11 +01:00
Josako
20fb2eee70 - Correction of behaviour where boolean fields were not properly initialised
- Ensure that primary and financial contact fields are properly saved
2025-11-25 13:15:11 +01:00
Josako
3815399a7e - Specialist Tuning now in a separate editor
- typeBadge formatter completed
2025-11-24 15:54:47 +01:00
70 changed files with 4371 additions and 825 deletions

2
.gitignore vendored
View File

@@ -58,3 +58,5 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc
/docker/build_logs/ /docker/build_logs/
/content/.Ulysses-Group.plist /content/.Ulysses-Group.plist
/content/.Ulysses-Settings.plist /content/.Ulysses-Settings.plist
/.python-version
/q

View File

@@ -36,7 +36,10 @@ def get_default_chat_customisation(tenant_customisation=None):
'ai_message_text_color': '#212529', 'ai_message_text_color': '#212529',
'human_message_background': '#212529', 'human_message_background': '#212529',
'human_message_text_color': '#ffffff', 'human_message_text_color': '#ffffff',
'human_message_inactive_text_color': '#808080' 'human_message_inactive_text_color': '#808080',
'tab_background': '#0a0a0a',
'tab_icon_active_color': '#ffffff',
'tab_icon_inactive_color': '#f0f0f0',
} }
# If no tenant customization is provided, return the defaults # If no tenant customization is provided, return the defaults

View File

@@ -24,7 +24,6 @@ class Database:
""" """
schema = session.info.get("tenant_schema") schema = session.info.get("tenant_schema")
if schema: if schema:
current_app.logger.debug(f"DBCTX tx_begin schema={schema}")
try: try:
connection.exec_driver_sql(f'SET LOCAL search_path TO "{schema}", public') connection.exec_driver_sql(f'SET LOCAL search_path TO "{schema}", public')
# Optional visibility/logging for debugging # Optional visibility/logging for debugging

View File

@@ -92,6 +92,13 @@ class EveAINoActiveLicense(EveAIException):
super().__init__(message, status_code, payload) super().__init__(message, status_code, payload)
class EveAIUserExpired(EveAIException):
"""Raised when a user account is no longer valid (valid_to expired)"""
def __init__(self, message="Your account has expired", status_code=401, payload=None):
super().__init__(message, status_code, payload)
class EveAIInvalidCatalog(EveAIException): class EveAIInvalidCatalog(EveAIException):
"""Raised when a catalog cannot be found""" """Raised when a catalog cannot be found"""

View File

@@ -35,14 +35,14 @@ def is_valid_tenant(tenant_id):
if tenant_id == 1: # The 'root' tenant, is always valid if tenant_id == 1: # The 'root' tenant, is always valid
return True return True
tenant = Tenant.query.get(tenant_id) tenant = Tenant.query.get(tenant_id)
# Use the tenant_id (schema name), not the Tenant object, to switch schema
Database(tenant_id).switch_schema()
if tenant is None: if tenant is None:
raise EveAITenantNotFound() raise EveAITenantNotFound()
elif tenant.type == 'Inactive': elif tenant.type == 'Inactive':
raise EveAITenantInvalid(tenant_id) raise EveAITenantInvalid(tenant_id)
else: else:
current_date = dt.now(tz=tz.utc).date() current_date = dt.now(tz=tz.utc).date()
Database(str(tenant_id)).switch_schema()
# TODO -> Check vervangen door Active License Period! # TODO -> Check vervangen door Active License Period!
# active_license = (License.query.filter_by(tenant_id=tenant_id) # active_license = (License.query.filter_by(tenant_id=tenant_id)
# .filter(and_(License.start_date <= current_date, # .filter(and_(License.start_date <= current_date,

View File

@@ -87,6 +87,21 @@ configuration:
description: "Human Message Inactive Text Color" description: "Human Message Inactive Text Color"
type: "color" type: "color"
required: false required: false
tab_background:
name: "Tab Background Color"
description: "Tab Background Color"
type: "color"
required: false
tab_icon_active_color:
name: "Tab Icon Active Color"
description: "Tab Icon Active Color"
type: "color"
required: false
tab_icon_inactive_color:
name: "Tab Icon Inactive Color"
description: "Tab Icon Inactive Color"
type: "color"
required: false
metadata: metadata:
author: "Josako" author: "Josako"
date_added: "2024-06-06" date_added: "2024-06-06"

View File

@@ -1,15 +0,0 @@
version: "1.0.0"
content: |
Classify the prompt you receive from an end user, according to the following information:
{user_action_classes}
Use the CLASS DESCRIPTION to identify the CLASS of the question asked. Return the value of CLASS. If the prompt doesn't correspond to any CLASS DESCRIPTION, return NONE. No layout is required.
llm_model: "mistral.mistral-small-latest"
temperature: 0.7
metadata:
author: "Josako"
date_added: "2025-11-14"
description: "Assistant to classify user intent"
changes: "Initial version"

View File

@@ -1,6 +1,6 @@
{ {
"dist/chat-client.js": "dist/chat-client.f7134231.js", "dist/chat-client.js": "dist/chat-client.825210dd.js",
"dist/chat-client.css": "dist/chat-client.99e10656.css", "dist/chat-client.css": "dist/chat-client.568d7be7.css",
"dist/main.js": "dist/main.6a617099.js", "dist/main.js": "dist/main.6a617099.js",
"dist/main.css": "dist/main.06893f70.css" "dist/main.css": "dist/main.7182aac3.css"
} }

View File

@@ -10,11 +10,22 @@ task_description: >
€€€{history}€€€ €€€{history}€€€
(In this history, user interactions are preceded by 'HUMAN', and your interactions with 'AI'.) (In this history, user interactions are preceded by 'HUMAN', and your interactions with 'AI'.)
Take into account the last question asked by the you, the AI.
Check if the user has given an affirmative answer or not. Check if the user has given an affirmative answer to that last question or not.
Please note that this answer can be very short: Please note that this answer can be very short:
- Affirmative answers: e.g. Yes, OK, Sure, Of Course - Affirmative answers: e.g. Yes, OK, Sure, Of Course
- Negative answers: e.g. No, not really, No, I'd rather not. - Negative answers: e.g. No, not really, No, I'd rather not.
Also note that users may use emoticons, emojis, or other symbols to express their affirmative answers.
- Affirmative answers: e.g. 👍🏼 , 👌🏼 , ☺️
- Negative answers: e.g. 👎🏼 , 🙅🏼 , 😒
Finally, users may use a direct answer to the last question asked:
Example 1:
- Question: "Do you have any other questions, or shall we start the interview to see if theres a match with the job?"
- Affirmative Answer: "Start the interview" or "Start please"
Example 2:
- Question: "Is there anything still on your mind, or shall we begin the conversation to explore the match?"
- Affirmative Answer: "Let's start exploring" or "Let's go"
Please consider that the answer will be given in {language}! Please consider that the answer will be given in {language}!

View File

@@ -5,9 +5,49 @@ 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).
## 3.1.36-beta
Release date: 2025-12-02
### Added
- Refactoring of the chat client to use tabs for the active conversation, history and settings.
- Introduction of shells for Mobile and Desktop clients, allowing for additional shells like plugins to be added in the future.
### Fixed
- TRA-89—Problem solved where connection could get lost in sync between client and backend
- TRA-98—End user could continue without accepting dpa & terms
- TRA-96—Multiple-choice questions in the mobile client not scrolling → Solved by introducing a new client layout
- TRA-101—DPA-link was not working
- TRA-102—Wrong responses when looking for affirmative answers.
## 3.1.26-beta
Release date: 2025-11-26
### Changed
- Introduction of vueuse/core in the chat client, to ensure abstraction of ui behaviour for different mobile devices.
### Fixed
- TRA-99, fixed creation of a new Tenant Make.
- Improvement of DynamicFormBase to better align behaviour with the standard FlaskForm.
## 3.1.24-beta
Release date: 2025-11-25
### Changed
- Functionality to edit standard attributes of a specialist (e.g. name, description, retrievers, dynamic attributes) and deep tuning of a specialist are split in the administrative client.
### Fixed
- Ensure boolean fields are properly initialised when editing in the administrative client.
- Ensure Primary and Financial contact fields are properly displayed and saved in the user model.
### Security
- In case of vulnerabilities.
## 3.1.16-beta ## 3.1.16-beta
Release date: Release date: 2025-11-13
### Added ### Added
- human_message_inactive_text_color added to Tenant Make configuration options, to allow for customisation of inactive messages in the chat client. - human_message_inactive_text_color added to Tenant Make configuration options, to allow for customisation of inactive messages in the chat client.

View File

@@ -0,0 +1,30 @@
/* Content Viewer specific styles */
/* Ensure markdown text aligns left by default */
#content-document-viewer .markdown-body,
.markdown-body {
text-align: left;
}
/* Make the viewer pane scrollable with a responsive max height */
#content-document-viewer {
max-height: 60vh;
overflow: auto;
}
/* Optional readability improvements */
#content-document-viewer .markdown-body {
line-height: 1.6;
}
#content-document-viewer .markdown-body pre,
#content-document-viewer .markdown-body code {
white-space: pre-wrap;
}
/* Keep the viewer header visible while scrolling */
#content-viewer-section .card-header {
position: sticky;
top: 0;
z-index: 1;
background: var(--bs-body-bg, #fff);
}

View File

@@ -313,5 +313,21 @@ input[type="radio"] {
text-align: center !important; text-align: center !important;
} }
/* Custom badge background colors for type badges (agent/task/tool) */
.bg-purple {
background-color: #6f42c1 !important; /* Bootstrap purple */
color: #fff !important;
}
.bg-orange {
background-color: #fd7e14 !important; /* Bootstrap orange */
color: #fff !important;
}
.bg-teal {
background-color: #20c997 !important; /* Bootstrap teal */
color: #fff !important;
}

View File

@@ -0,0 +1,597 @@
/*--------------------------------
hermes-dashboard-icons Web Font - built using nucleoapp.com
License - nucleoapp.com/license/
-------------------------------- */
@font-face {
font-family: 'NucleoIcons';
src: url('../fonts/nucleo-icons.eot');
src: url('../fonts/nucleo-icons.eot') format('embedded-opentype'), url('../fonts/nucleo-icons.woff2') format('woff2'), url('../fonts/nucleo-icons.woff') format('woff'), url('../fonts/nucleo-icons.ttf') format('truetype'), url('../fonts/nucleo-icons.svg') format('svg');
font-weight: normal;
font-style: normal;
}
/*------------------------
base class definition
-------------------------*/
.ni {
display: inline-block;
font: normal normal normal 14px/1 NucleoIcons;
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/*------------------------
change icon size
-------------------------*/
.ni-lg {
font-size: 1.33333333em;
line-height: 0.75em;
vertical-align: -15%;
}
.ni-2x {
font-size: 2em;
}
.ni-3x {
font-size: 3em;
}
.ni-4x {
font-size: 4em;
}
.ni-5x {
font-size: 5em;
}
/*----------------------------------
add a square/circle background
-----------------------------------*/
.ni.square,
.ni.circle {
padding: 0.33333333em;
vertical-align: -16%;
background-color: #eee;
}
.ni.circle {
border-radius: 50%;
}
/*------------------------
list icons
-------------------------*/
.ni-ul {
padding-left: 0;
margin-left: 2.14285714em;
list-style-type: none;
}
.ni-ul>li {
position: relative;
}
.ni-ul>li>.ni {
position: absolute;
left: -1.57142857em;
top: 0.14285714em;
text-align: center;
}
.ni-ul>li>.ni.lg {
top: 0;
left: -1.35714286em;
}
.ni-ul>li>.ni.circle,
.ni-ul>li>.ni.square {
top: -0.19047619em;
left: -1.9047619em;
}
/*------------------------
spinning icons
-------------------------*/
.ni.spin {
-webkit-animation: nc-spin 2s infinite linear;
-moz-animation: nc-spin 2s infinite linear;
animation: nc-spin 2s infinite linear;
}
@-webkit-keyframes nc-spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@-moz-keyframes nc-spin {
0% {
-moz-transform: rotate(0deg);
}
100% {
-moz-transform: rotate(360deg);
}
}
@keyframes nc-spin {
0% {
-webkit-transform: rotate(0deg);
-moz-transform: rotate(0deg);
-ms-transform: rotate(0deg);
-o-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
-moz-transform: rotate(360deg);
-ms-transform: rotate(360deg);
-o-transform: rotate(360deg);
transform: rotate(360deg);
}
}
/*------------------------
rotated/flipped icons
-------------------------*/
.ni.rotate-90 {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1);
-webkit-transform: rotate(90deg);
-moz-transform: rotate(90deg);
-ms-transform: rotate(90deg);
-o-transform: rotate(90deg);
transform: rotate(90deg);
}
.ni.rotate-180 {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2);
-webkit-transform: rotate(180deg);
-moz-transform: rotate(180deg);
-ms-transform: rotate(180deg);
-o-transform: rotate(180deg);
transform: rotate(180deg);
}
.ni.rotate-270 {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3);
-webkit-transform: rotate(270deg);
-moz-transform: rotate(270deg);
-ms-transform: rotate(270deg);
-o-transform: rotate(270deg);
transform: rotate(270deg);
}
.ni.flip-y {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0);
-webkit-transform: scale(-1, 1);
-moz-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
-o-transform: scale(-1, 1);
transform: scale(-1, 1);
}
.ni.flip-x {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2);
-webkit-transform: scale(1, -1);
-moz-transform: scale(1, -1);
-ms-transform: scale(1, -1);
-o-transform: scale(1, -1);
transform: scale(1, -1);
}
/*------------------------
font icons
-------------------------*/
.ni-active-40::before {
content: "\ea02";
}
.ni-air-baloon::before {
content: "\ea03";
}
.ni-album-2::before {
content: "\ea04";
}
.ni-align-center::before {
content: "\ea05";
}
.ni-align-left-2::before {
content: "\ea06";
}
.ni-ambulance::before {
content: "\ea07";
}
.ni-app::before {
content: "\ea08";
}
.ni-archive-2::before {
content: "\ea09";
}
.ni-atom::before {
content: "\ea0a";
}
.ni-badge::before {
content: "\ea0b";
}
.ni-bag-17::before {
content: "\ea0c";
}
.ni-basket::before {
content: "\ea0d";
}
.ni-bell-55::before {
content: "\ea0e";
}
.ni-bold-down::before {
content: "\ea0f";
}
.ni-bold-left::before {
content: "\ea10";
}
.ni-bold-right::before {
content: "\ea11";
}
.ni-bold-up::before {
content: "\ea12";
}
.ni-bold::before {
content: "\ea13";
}
.ni-book-bookmark::before {
content: "\ea14";
}
.ni-books::before {
content: "\ea15";
}
.ni-box-2::before {
content: "\ea16";
}
.ni-briefcase-24::before {
content: "\ea17";
}
.ni-building::before {
content: "\ea18";
}
.ni-bulb-61::before {
content: "\ea19";
}
.ni-bullet-list-67::before {
content: "\ea1a";
}
.ni-bus-front-12::before {
content: "\ea1b";
}
.ni-button-pause::before {
content: "\ea1c";
}
.ni-button-play::before {
content: "\ea1d";
}
.ni-button-power::before {
content: "\ea1e";
}
.ni-calendar-grid-58::before {
content: "\ea1f";
}
.ni-camera-compact::before {
content: "\ea20";
}
.ni-caps-small::before {
content: "\ea21";
}
.ni-cart::before {
content: "\ea22";
}
.ni-chart-bar-32::before {
content: "\ea23";
}
.ni-chart-pie-35::before {
content: "\ea24";
}
.ni-chat-round::before {
content: "\ea25";
}
.ni-check-bold::before {
content: "\ea26";
}
.ni-circle-08::before {
content: "\ea27";
}
.ni-cloud-download-95::before {
content: "\ea28";
}
.ni-cloud-upload-96::before {
content: "\ea29";
}
.ni-compass-04::before {
content: "\ea2a";
}
.ni-controller::before {
content: "\ea2b";
}
.ni-credit-card::before {
content: "\ea2c";
}
.ni-curved-next::before {
content: "\ea2d";
}
.ni-delivery-fast::before {
content: "\ea2e";
}
.ni-diamond::before {
content: "\ea2f";
}
.ni-email-83::before {
content: "\ea30";
}
.ni-fat-add::before {
content: "\ea31";
}
.ni-fat-delete::before {
content: "\ea32";
}
.ni-fat-remove::before {
content: "\ea33";
}
.ni-favourite-28::before {
content: "\ea34";
}
.ni-folder-17::before {
content: "\ea35";
}
.ni-glasses-2::before {
content: "\ea36";
}
.ni-hat-3::before {
content: "\ea37";
}
.ni-headphones::before {
content: "\ea38";
}
.ni-html5::before {
content: "\ea39";
}
.ni-istanbul::before {
content: "\ea3a";
}
.ni-key-25::before {
content: "\ea3b";
}
.ni-laptop::before {
content: "\ea3c";
}
.ni-like-2::before {
content: "\ea3d";
}
.ni-lock-circle-open::before {
content: "\ea3e";
}
.ni-map-big::before {
content: "\ea3f";
}
.ni-mobile-button::before {
content: "\ea40";
}
.ni-money-coins::before {
content: "\ea41";
}
.ni-note-03::before {
content: "\ea42";
}
.ni-notification-70::before {
content: "\ea43";
}
.ni-palette::before {
content: "\ea44";
}
.ni-paper-diploma::before {
content: "\ea45";
}
.ni-pin-3::before {
content: "\ea46";
}
.ni-planet::before {
content: "\ea47";
}
.ni-ruler-pencil::before {
content: "\ea48";
}
.ni-satisfied::before {
content: "\ea49";
}
.ni-scissors::before {
content: "\ea4a";
}
.ni-send::before {
content: "\ea4b";
}
.ni-settings-gear-65::before {
content: "\ea4c";
}
.ni-settings::before {
content: "\ea4d";
}
.ni-single-02::before {
content: "\ea4e";
}
.ni-single-copy-04::before {
content: "\ea4f";
}
.ni-sound-wave::before {
content: "\ea50";
}
.ni-spaceship::before {
content: "\ea51";
}
.ni-square-pin::before {
content: "\ea52";
}
.ni-support-16::before {
content: "\ea53";
}
.ni-tablet-button::before {
content: "\ea54";
}
.ni-tag::before {
content: "\ea55";
}
.ni-tie-bow::before {
content: "\ea56";
}
.ni-time-alarm::before {
content: "\ea57";
}
.ni-trophy::before {
content: "\ea58";
}
.ni-tv-2::before {
content: "\ea59";
}
.ni-umbrella-13::before {
content: "\ea5a";
}
.ni-user-run::before {
content: "\ea5b";
}
.ni-vector::before {
content: "\ea5c";
}
.ni-watch-time::before {
content: "\ea5d";
}
.ni-world::before {
content: "\ea5e";
}
.ni-zoom-split-in::before {
content: "\ea5f";
}
.ni-collection::before {
content: "\ea60";
}
.ni-image::before {
content: "\ea61";
}
.ni-shop::before {
content: "\ea62";
}
.ni-ungroup::before {
content: "\ea63";
}
.ni-world-2::before {
content: "\ea64";
}
.ni-ui-04::before {
content: "\ea65";
}
/* all icon font classes list here */

View File

@@ -0,0 +1,135 @@
/* Generated using nucleoapp.com */
/* --------------------------------
Icon colors
-------------------------------- */
.icon {
display: inline-block;
/* icon primary color */
color: #111111;
height: 1em;
width: 1em;
}
.icon use {
/* icon secondary color - fill */
fill: #7ea6f6;
}
.icon.icon-outline use {
/* icon secondary color - stroke */
stroke: #7ea6f6;
}
/* --------------------------------
Change icon size
-------------------------------- */
.icon-xs {
height: 0.5em;
width: 0.5em;
}
.icon-sm {
height: 0.8em;
width: 0.8em;
}
.icon-lg {
height: 1.6em;
width: 1.6em;
}
.icon-xl {
height: 2em;
width: 2em;
}
/* --------------------------------
Align icon and text
-------------------------------- */
.icon-text-aligner {
/* add this class to parent element that contains icon + text */
display: flex;
align-items: center;
}
.icon-text-aligner .icon {
color: inherit;
margin-right: 0.4em;
}
.icon-text-aligner .icon use {
color: inherit;
fill: currentColor;
}
.icon-text-aligner .icon.icon-outline use {
stroke: currentColor;
}
/* --------------------------------
Icon reset values - used to enable color customizations
-------------------------------- */
.icon {
fill: currentColor;
stroke: none;
}
.icon.icon-outline {
fill: none;
stroke: currentColor;
}
.icon use {
stroke: none;
}
.icon.icon-outline use {
fill: none;
}
/* --------------------------------
Stroke effects - Nucleo outline icons
- 16px icons -> up to 1px stroke (16px outline icons do not support stroke changes)
- 24px, 32px icons -> up to 2px stroke
- 48px, 64px icons -> up to 4px stroke
-------------------------------- */
.icon-outline.icon-stroke-1 {
stroke-width: 1px;
}
.icon-outline.icon-stroke-2 {
stroke-width: 2px;
}
.icon-outline.icon-stroke-3 {
stroke-width: 3px;
}
.icon-outline.icon-stroke-4 {
stroke-width: 4px;
}
.icon-outline.icon-stroke-1 use,
.icon-outline.icon-stroke-3 use {
-webkit-transform: translateX(0.5px) translateY(0.5px);
-moz-transform: translateX(0.5px) translateY(0.5px);
-ms-transform: translateX(0.5px) translateY(0.5px);
-o-transform: translateX(0.5px) translateY(0.5px);
transform: translateX(0.5px) translateY(0.5px);
}

View File

@@ -9,12 +9,6 @@
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{# Debug: render CSRF veld expliciet om aanwezigheid in de DOM te garanderen #}
{% if form.csrf_token %}{{ form.csrf_token }}{% endif %}
<script>
// Client-side debug: bevestig dat het CSRF veld in de DOM staat
console.debug('[add_document] CSRF present in DOM?', !!document.querySelector('input[name="csrf_token"]'));
</script>
{% set disabled_fields = [] %} {% set disabled_fields = [] %}
{% set exclude_fields = [] %} {% set exclude_fields = [] %}
{% for field in form.get_static_fields() %} {% for field in form.get_static_fields() %}

View File

@@ -363,50 +363,67 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
// Zoek buttons in de volledige formulier context (niet alleen de container) // Zoek buttons zo dicht mogelijk bij de tabel met gegeven containerId
const form = document.getElementById(`${containerId}-form`) || document.querySelector('form'); // 1. Probeer eerst een expliciet formulier met id="<table_id>-form"
const buttons = form ? form.querySelectorAll('button[onclick*="handleListViewAction"]') : let scopeEl = document.getElementById(`${containerId}-form`);
document.querySelectorAll('button[onclick*="handleListViewAction"]');
// 2. Zo niet, gebruik de dichtstbijzijnde .container rond de tabel zelf
if (!scopeEl) {
const tableEl = document.getElementById(containerId);
if (tableEl) {
scopeEl = tableEl.closest('.container') || tableEl.parentElement;
}
}
// 3. Als er nog steeds geen scope is, val dan terug op de hele document-body
const buttons = (scopeEl || document.body)
.querySelectorAll('button[onclick*="handleListViewAction"]');
console.log(`Updating buttons voor ${containerId}, ${buttons.length} buttons gevonden, selectedRow:`, instance.selectedRow); console.log(`Updating buttons voor ${containerId}, ${buttons.length} buttons gevonden, selectedRow:`, instance.selectedRow);
buttons.forEach(button => { buttons.forEach(button => {
// Parse the onclick attribute to get the action value and requiresSelection parameter // Parse the onclick attribute to get the action value and requiresSelection parameter
const onclickAttr = button.getAttribute('onclick'); const onclickAttr = button.getAttribute('onclick') || '';
const match = onclickAttr.match(/handleListViewAction\('([^']+)',\s*(true|false)\)/i); console.log('ListView _updateActionButtons: inspect button onclickAttr =', onclickAttr);
if (match) {
const actionValue = match[1];
const requiresSelection = match[2].toLowerCase() === 'true';
// Direct toepassen van requiresSelection-check // Robuustere regex: sta spaties toe en zowel enkele als dubbele quotes
if (requiresSelection) { const match = onclickAttr.match(/handleListViewAction\s*\(\s*['"]([^'"\\]+)['"]\s*,\s*(true|false)/i);
// Controleer of er een geselecteerde rij is if (!match) {
const isDisabled = !instance.selectedRow; console.warn('ListView _updateActionButtons: geen match gevonden voor handleListViewAction-pattern op deze button');
button.disabled = isDisabled; return;
}
// Voeg/verwijder disabled class voor styling const actionValue = match[1];
if (isDisabled) { const requiresSelection = match[2].toLowerCase() === 'true';
button.classList.add('disabled');
} else {
button.classList.remove('disabled');
}
console.log(`Button ${actionValue} updated: disabled=${isDisabled}`); // Direct toepassen van requiresSelection-check
if (requiresSelection) {
// Controleer of er een geselecteerde rij is
const isDisabled = !instance.selectedRow;
button.disabled = isDisabled;
// Voeg/verwijder disabled class voor styling
if (isDisabled) {
button.classList.add('disabled');
} else {
button.classList.remove('disabled');
} }
// Backup check op basis van actions in config (voor achterwaartse compatibiliteit) console.log(`Button ${actionValue} updated: disabled=${isDisabled}`);
const action = instance.config.actions.find(a => a.value === actionValue); }
if (action && action.requiresSelection === true && !requiresSelection) {
// Ook controleren op basis van action config
const isDisabled = !instance.selectedRow;
button.disabled = isDisabled;
// Voeg/verwijder disabled class voor styling // Backup check op basis van actions in config (voor achterwaartse compatibiliteit)
if (isDisabled) { const action = instance.config.actions.find(a => a.value === actionValue);
button.classList.add('disabled'); if (action && action.requiresSelection === true && !requiresSelection) {
} else { // Ook controleren op basis van action config
button.classList.remove('disabled'); const isDisabled = !instance.selectedRow;
} button.disabled = isDisabled;
// Voeg/verwijder disabled class voor styling
if (isDisabled) {
button.classList.add('disabled');
} else {
button.classList.remove('disabled');
} }
} }
}); });

View File

@@ -4,7 +4,7 @@
{% block title %}Edit Specialist{% endblock %} {% block title %}Edit Specialist{% endblock %}
{% block content_title %}Edit Specialist{% endblock %} {% block content_title %}Edit Specialist{% endblock %}
{% block content_description %}Edit a Specialist and its components{% endblock %} {% block content_description %}Edit a Specialist{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid px-0"> <div class="container-fluid px-0">
@@ -31,11 +31,6 @@
Configuration Configuration
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link mb-0 px-0 py-1" data-bs-toggle="tab" href="#components-tab" role="tab">
Components
</a>
</li>
</ul> </ul>
</div> </div>
@@ -48,17 +43,17 @@
{% endfor %} {% endfor %}
<!-- Overview Section --> <!-- Overview Section -->
{# <div class="row mb-4">#} {# <div class="row mb-4">#}
{# <div class="col-12">#} {# <div class="col-12">#}
{# <div class="card">#} {# <div class="card">#}
{# <div class="card-body">#} {# <div class="card-body">#}
{# <div class="specialist-overview" id="specialist-svg">#} {# <div class="specialist-overview" id="specialist-svg">#}
{# <img src="{{ svg_path }}" alt="Specialist Overview" class="w-100">#} {# <img src="{{ svg_path }}" alt="Specialist Overview" class="w-100">#}
{# </div>#} {# </div>#}
{# </div>#} {# </div>#}
{# </div>#} {# </div>#}
{# </div>#} {# </div>#}
{# </div>#} {# </div>#}
</div> </div>
<!-- Configuration Tab --> <!-- Configuration Tab -->
@@ -72,29 +67,6 @@
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
</div> </div>
<!-- Components Tab (Unified list view) -->
<div class="tab-pane fade" id="components-tab" role="tabpanel">
<div class="card">
<div class="card-body">
<div class="container">
<input type="hidden" id="{{ components_table_id }}-selected-row" name="selected_row" value="">
<input type="hidden" id="{{ components_table_id }}-action" name="action" value="">
<div id="{{ components_table_id }}" class="tabulator-list-view"></div>
<div class="row mt-3">
<div class="col-12">
<button type="button"
data-table-id="{{ components_table_id }}"
onclick="handleListViewAction('edit_component', true, event)"
class="btn btn-primary requires-selection" disabled>
Edit
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -105,299 +77,8 @@
</div> </div>
</div> </div>
<!-- Component Editor Modal (intentionally placed outside the main form to avoid nested forms) -->
<div class="modal fade" id="componentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="componentModalLabel">Edit Component</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="componentModalBody">
<!-- Partial form will be injected here -->
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{{ super() }} {{ super() }}
<script src="{{ url_for('static', filename='assets/js/eveai-list-view.js') }}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const componentModalEl = document.getElementById('componentModal');
const componentModalBody = document.getElementById('componentModalBody');
const componentModalLabel = document.getElementById('componentModalLabel');
let componentModal;
// Initialize the combined components list using EveAI.ListView
function initComponentsList() {
function tryInit() {
if (window.EveAI && window.EveAI.ListView && typeof window.EveAI.ListView.initialize === 'function') {
const cfg = {
data: {{ components_data | tojson }},
columns: {{ components_columns | tojson }},
initialSort: {{ components_initial_sort | tojson }},
index: {{ components_index | tojson }},
actions: {{ components_actions | tojson }},
tableHeight: {{ components_table_height|default(600) }},
selectable: true
};
// Resolve string formatters (e.g., 'typeBadge') to functions before initializing Tabulator
if (window.EveAI && window.EveAI.ListView && window.EveAI.ListView.formatters && Array.isArray(cfg.columns)) {
cfg.columns = cfg.columns.map(col => {
if (typeof col.formatter === 'string' && window.EveAI.ListView.formatters[col.formatter]) {
return { ...col, formatter: window.EveAI.ListView.formatters[col.formatter] };
}
return col;
});
}
const table = window.EveAI.ListView.initialize('{{ components_table_id }}', cfg);
// Expose for quick debugging
window.__componentsTable = table;
// Fallback: ensure instance registry is populated even if the module didn't store it yet
window.EveAI.ListView.instances = window.EveAI.ListView.instances || {};
if (!window.EveAI.ListView.instances['{{ components_table_id }}'] && table) {
window.EveAI.ListView.instances['{{ components_table_id }}'] = {
table: table,
config: cfg,
selectedRow: null
};
}
// Ensure single-click selects exactly one row (defensive)
if (table && typeof table.on === 'function') {
try {
table.on('rowClick', function(e, row){ row.getTable().deselectRow(); row.select(); });
table.on('cellClick', function(e, cell){ const r = cell.getRow(); r.getTable().deselectRow(); r.select(); });
table.on('rowSelectionChanged', function(data, rows){
const inst = window.EveAI.ListView.instances['{{ components_table_id }}'];
if (inst) { inst.selectedRow = rows.length ? rows[0].getData() : null; }
if (typeof window.EveAI.ListView.updateActionButtons === 'function') {
window.EveAI.ListView.updateActionButtons('{{ components_table_id }}');
}
});
} catch (err) { console.warn('Could not attach selection handlers:', err); }
}
// Register embedded action handler for this table
window.EveAI.ListView.embeddedHandlers = window.EveAI.ListView.embeddedHandlers || {};
window.EveAI.ListView.embeddedHandlers['{{ components_table_id }}'] = function(action, row, tableId){
if (action !== 'edit_component' || !row) return;
const id = row.id;
const type = row.type_name; // 'agent' | 'task' | 'tool'
// Build edit URL from server-side templates with placeholder 0
const editUrls = {
agent: "{{ prefixed_url_for('interaction_bp.edit_agent', agent_id=0) }}",
task: "{{ prefixed_url_for('interaction_bp.edit_task', task_id=0) }}",
tool: "{{ prefixed_url_for('interaction_bp.edit_tool', tool_id=0) }}",
};
const url = (editUrls[type] || '').replace('/0', `/${id}`);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(resp => { if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return resp.text(); })
.then(html => {
componentModalBody.innerHTML = html;
componentModalLabel.textContent = `Edit ${type.charAt(0).toUpperCase()+type.slice(1)}`;
componentModalEl.dataset.componentType = type;
componentModalEl.dataset.componentId = id;
// Helper to open modal once Bootstrap is available
function openModal() {
try {
if (!componentModal) componentModal = new bootstrap.Modal(componentModalEl);
componentModal.show();
} catch (e) {
console.error('Failed to open Bootstrap modal:', e);
alert('Kan de editor niet openen (Bootstrap modal ontbreekt).');
}
}
if (window.bootstrap && typeof bootstrap.Modal === 'function') {
openModal();
} else {
// Fallback: laad Bootstrap bundle dynamisch en open daarna
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js';
script.onload = () => openModal();
script.onerror = () => {
console.error('Kon Bootstrap bundle niet laden');
alert('Kan de editor niet openen omdat Bootstrap JS ontbreekt.');
};
document.head.appendChild(script);
}
})
.catch(err => {
console.error('Error loading editor:', err);
alert('Error loading editor. Please try again.');
});
};
} else {
setTimeout(tryInit, 100);
}
}
tryInit();
}
initComponentsList();
// Note: we removed the modal footer submit button. The partial provides its own buttons.
// Submissions are intercepted via the submit listener on componentModalBody below.
// Refresh the components list data after a successful save
function refreshComponentsData() {
const url = "{{ prefixed_url_for('interaction_bp.specialist_components_data', specialist_id=specialist_id) }}";
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' }})
.then(resp => resp.json())
.then(payload => {
const instance = window.EveAI.ListView.instances['{{ components_table_id }}'];
if (instance && instance.table && Array.isArray(payload.data)) {
instance.table.setData(payload.data);
}
})
.catch(err => console.error('Failed to refresh components data', err));
}
// Intercept native form submit events within the modal (handles Enter key too)
componentModalBody.addEventListener('submit', function(e) {
const formEl = e.target.closest('#componentEditForm');
if (!formEl) return; // Not our form
e.preventDefault();
const formData = new FormData(formEl);
const componentType = componentModalEl.dataset.componentType;
const componentId = componentModalEl.dataset.componentId;
// Build robust, prefix-aware absolute save URL from server-side templates
const saveUrls = {
agent: "{{ prefixed_url_for('interaction_bp.save_agent', agent_id=0) }}",
task: "{{ prefixed_url_for('interaction_bp.save_task', task_id=0) }}",
tool: "{{ prefixed_url_for('interaction_bp.save_tool', tool_id=0) }}",
};
const urlTemplate = saveUrls[componentType];
const saveUrl = urlTemplate.replace('/0', `/${componentId}`);
fetch(saveUrl, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(async resp => {
const ct = resp.headers.get('Content-Type') || '';
if (resp.ok) {
// Expect JSON success
let data = null;
try { data = await resp.json(); } catch (_) {}
if (data && data.success) {
componentModal.hide();
refreshComponentsData();
return;
}
throw new Error(data && data.message ? data.message : 'Save failed');
}
// For validation errors (400), server returns HTML partial -> replace modal body
if (resp.status === 400 && ct.includes('text/html')) {
const html = await resp.text();
componentModalBody.innerHTML = html;
return;
}
// Other errors
let message = 'Save failed';
if (ct.includes('application/json')) {
try {
const data = await resp.json();
if (data && data.message) message = data.message;
} catch (_) {}
}
throw new Error(message + ` (HTTP ${resp.status})`);
})
.catch(err => {
console.error('Error saving component:', err);
alert(err.message || 'Error saving component');
});
});
});
</script>
<style>
.tab-pane .card {
margin-bottom: 1rem;
}
.nav-link.component-agent,
.nav-link.component-task,
.nav-link.component-tool {
color: white !important;
}
/* Add new CSS for normal tabs */
.nav-link {
color: #344767 !important; /* Default dark color */
}
/* Style for active tabs */
.nav-link.active {
background-color: #5e72e4 !important; /* Primary blue color */
color: white !important;
font-weight: 600;
box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* Style for disabled tabs */
.nav-link.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.component-agent {
background-color: #9c27b0 !important; /* Purple */
color: white !important;
}
.component-task {
background-color: #ff9800 !important; /* Orange */
color: white !important;
}
.component-tool {
background-color: #009688 !important; /* Teal */
color: white !important;
}
/* Lighter background versions for the tab content */
.tab-pane.component-agent-bg {
background-color: rgba(156, 39, 176, 0.2); /* Light purple */
}
.tab-pane.component-task-bg {
background-color: rgba(255, 152, 0, 0.2); /* Light orange */
}
.tab-pane.component-tool-bg {
background-color: rgba(0, 150, 136, 0.2); /* Light teal */
}
/* Add some padding to the tab content */
.tab-pane {
padding: 15px;
border-radius: 0.5rem;
}
.specialist-overview {
width: 100%;
height: auto;
min-height: 200px;
display: flex;
justify-content: center;
align-items: center;
}
.specialist-overview svg {
width: 100%;
height: auto;
max-height: 400px; /* Adjust as needed */
}
</style>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,257 @@
{% extends 'base.html' %}
{% from "macros.html" import render_field, render_selectable_table %}
{% block title %}Tune Specialist{% endblock %}
{% block content_title %}Tune Specialist{% endblock %}
{% block content_description %}Edit agents, tasks and tools for specialist <b>{{ specialist.name }}</b> (ID: {{ specialist.id }}) <br>
({{ specialist.type }} - {{ specialist.type_version }}){% endblock %}
{% block content %}
<div class="container-fluid px-0">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<a href="{{ prefixed_url_for('interaction_bp.specialists', for_redirect=True) }}" class="btn btn-outline-secondary btn-sm">Back to Specialists</a>
<a href="{{ prefixed_url_for('interaction_bp.edit_specialist', specialist_id=specialist.id, for_redirect=True) }}" class="btn btn-outline-primary btn-sm">Edit Specialist Metadata</a>
</div>
</div>
<div class="card">
<div class="card-body">
<h6 class="card-title mb-3">Components</h6>
{# Gebruik de generieke EveAI list view template zodat we dezelfde
Tabulator + EveAI.ListView infrastructuur hergebruiken als elders.
We mappen hier de component-variabelen naar de namen die
eveai_list_view.html verwacht. #}
{% with
title=components_title,
description=components_description,
data=components_data,
columns=components_columns,
actions=components_actions,
initial_sort=components_initial_sort,
table_id=components_table_id,
table_height=components_table_height
%}
{% include 'eveai_list_view.html' %}
{% endwith %}
</div>
</div>
</div>
</div>
</div>
<!-- Component Editor Modal (intentionally placed outside the main content to avoid nested forms) -->
<div class="modal fade" id="componentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="componentModalLabel">Edit Component</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="componentModalBody">
<!-- Partial form will be injected here -->
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
const componentModalEl = document.getElementById('componentModal');
const componentModalBody = document.getElementById('componentModalBody');
const componentModalLabel = document.getElementById('componentModalLabel');
let componentModal;
const tableId = '{{ components_table_id }}';
// Initialiseer de specialist components tabel via de generieke EveAI.ListView
function initializeComponentsTable(retriesLeft = 50) {
if (!window.EveAI || !window.EveAI.ListView || !window.EveAI.ListView.initialize) {
if (retriesLeft <= 0) {
console.error('EveAI.ListView.initialize niet beschikbaar voor components tabel');
return;
}
return setTimeout(() => initializeComponentsTable(retriesLeft - 1), 100);
}
// Bouw dezelfde configuratie op als in list_view.html, maar dan voor de components
const tableConfig = {
data: {{ components_data | tojson }},
columns: {{ components_columns | tojson }},
initialSort: {{ components_initial_sort | tojson }},
actions: {{ components_actions | tojson }},
tableHeight: {{ components_table_height|default(600) }},
selectable: true
};
const tabulatorTable = window.EveAI.ListView.initialize(tableId, tableConfig);
if (!tabulatorTable) {
console.error('Kon de Tabulator tabel voor specialist components niet initialiseren');
return;
}
console.log('Specialist components Tabulator tabel succesvol geïnitialiseerd');
}
// Registreer de pagina-specifieke embedded handler voor de actie 'edit_component'
function registerEmbeddedHandler(retriesLeft = 50) {
if (!window.EveAI || !window.EveAI.ListView || !window.EveAI.ListView.instances) {
if (retriesLeft <= 0) {
console.error('EveAI.ListView.instances niet beschikbaar voor components tabel');
return;
}
return setTimeout(() => registerEmbeddedHandler(retriesLeft - 1), 100);
}
const instances = window.EveAI.ListView.instances;
if (!instances[tableId] || !instances[tableId].table) {
if (retriesLeft <= 0) {
console.error(`Geen EveAI.ListView instance gevonden voor tabel ${tableId}`);
return;
}
return setTimeout(() => registerEmbeddedHandler(retriesLeft - 1), 100);
}
window.EveAI.ListView.embeddedHandlers = window.EveAI.ListView.embeddedHandlers || {};
window.EveAI.ListView.embeddedHandlers[tableId] = function(action, row, tId) {
if (action !== 'edit_component' || !row) return;
const id = row.id;
const type = row.type_name; // 'agent' | 'task' | 'tool'
// Build edit URL from server-side templates with placeholder 0
const editUrls = {
agent: "{{ prefixed_url_for('interaction_bp.edit_agent', agent_id=0) }}",
task: "{{ prefixed_url_for('interaction_bp.edit_task', task_id=0) }}",
tool: "{{ prefixed_url_for('interaction_bp.edit_tool', tool_id=0) }}",
};
const url = (editUrls[type] || '').replace('/0', `/${id}`);
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(resp => { if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return resp.text(); })
.then(html => {
componentModalBody.innerHTML = html;
componentModalLabel.textContent = `Edit ${type.charAt(0).toUpperCase()+type.slice(1)}`;
componentModalEl.dataset.componentType = type;
componentModalEl.dataset.componentId = id;
// Helper om modal te openen zodra Bootstrap beschikbaar is
function openModal() {
try {
if (!componentModal) componentModal = new bootstrap.Modal(componentModalEl);
componentModal.show();
} catch (e) {
console.error('Failed to open Bootstrap modal:', e);
alert('Kan de editor niet openen (Bootstrap modal ontbreekt).');
}
}
if (window.bootstrap && typeof bootstrap.Modal === 'function') {
openModal();
} else {
// Fallback: laad Bootstrap bundle dynamisch en open daarna
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js';
script.onload = () => openModal();
script.onerror = () => {
console.error('Kon Bootstrap bundle niet laden');
alert('Kan de editor niet openen omdat Bootstrap JS ontbreekt.');
};
document.head.appendChild(script);
}
})
.catch(err => {
console.error('Error loading editor:', err);
alert('Error loading editor. Please try again.');
});
};
}
// Eerst de tabel initialiseren, daarna de embedded handler registreren
initializeComponentsTable();
registerEmbeddedHandler();
// Refresh de components list data na een succesvolle save
function refreshComponentsData() {
const url = "{{ prefixed_url_for('interaction_bp.specialist_components_data', specialist_id=specialist_id) }}";
fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' }})
.then(resp => resp.json())
.then(payload => {
if (!window.EveAI || !window.EveAI.ListView || !window.EveAI.ListView.instances) return;
const instance = window.EveAI.ListView.instances[tableId];
if (instance && instance.table && Array.isArray(payload.data)) {
instance.table.setData(payload.data);
}
})
.catch(err => console.error('Failed to refresh components data', err));
}
// Intercepteer form submits binnen de modal (ook Enter-key)
componentModalBody.addEventListener('submit', function(e) {
const formEl = e.target.closest('#componentEditForm');
if (!formEl) return; // Niet ons form
e.preventDefault();
const formData = new FormData(formEl);
const componentType = componentModalEl.dataset.componentType;
const componentId = componentModalEl.dataset.componentId;
// Bouw prefix-bewuste absolute save URL op basis van server-side templates
const saveUrls = {
agent: "{{ prefixed_url_for('interaction_bp.save_agent', agent_id=0) }}",
task: "{{ prefixed_url_for('interaction_bp.save_task', task_id=0) }}",
tool: "{{ prefixed_url_for('interaction_bp.save_tool', tool_id=0) }}",
};
const urlTemplate = saveUrls[componentType];
const saveUrl = urlTemplate.replace('/0', `/${componentId}`);
fetch(saveUrl, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(async resp => {
const ct = resp.headers.get('Content-Type') || '';
if (resp.ok) {
// Verwacht JSON succes
let data = null;
try { data = await resp.json(); } catch (_) {}
if (data && data.success) {
if (componentModal) {
componentModal.hide();
}
refreshComponentsData();
return;
}
throw new Error(data && data.message ? data.message : 'Save failed');
}
// Validatiefouten (400) → server stuurt HTML partial terug
if (resp.status === 400 && ct.includes('text/html')) {
const html = await resp.text();
componentModalBody.innerHTML = html;
return;
}
// Andere fouten
let message = 'Save failed';
if (ct.includes('application/json')) {
try {
const data = await resp.json();
if (data && data.message) message = data.message;
} catch (_) {}
}
throw new Error(message + ` (HTTP ${resp.status})`);
})
.catch(err => {
console.error('Error saving component:', err);
alert(err.message || 'Error saving component');
});
});
});
</script>
{% endblock %}

View File

@@ -14,15 +14,6 @@
{% for field in form %} {% for field in form %}
{{ render_field(field, disabled_fields, exclude_fields) }} {{ render_field(field, disabled_fields, exclude_fields) }}
{% endfor %} {% 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> <button type="submit" class="btn btn-primary">Register Tenant Make</button>
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,6 @@ from flask_security import roles_accepted, current_user
from sqlalchemy import desc from sqlalchemy import desc
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.datastructures import CombinedMultiDict
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
import requests import requests
from requests.exceptions import SSLError, HTTPError from requests.exceptions import SSLError, HTTPError
@@ -120,7 +119,7 @@ def edit_catalog(catalog_id):
catalog = Catalog.query.get_or_404(catalog_id) catalog = Catalog.query.get_or_404(catalog_id)
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
form = EditCatalogForm(request.form, obj=catalog) form = EditCatalogForm(obj=catalog)
full_config = cache_manager.catalogs_config_cache.get_config(catalog.type) full_config = cache_manager.catalogs_config_cache.get_config(catalog.type)
if request.method == 'POST' and form.validate_on_submit(): if request.method == 'POST' and form.validate_on_submit():
form.populate_obj(catalog) form.populate_obj(catalog)
@@ -191,7 +190,7 @@ def edit_processor(processor_id):
processor.catalog = None processor.catalog = None
# Create form instance with the processor # Create form instance with the processor
form = EditProcessorForm(request.form, obj=processor) form = EditProcessorForm(obj=processor)
full_config = cache_manager.processors_config_cache.get_config(processor.type) full_config = cache_manager.processors_config_cache.get_config(processor.type)
form.add_dynamic_fields("configuration", full_config, processor.configuration) form.add_dynamic_fields("configuration", full_config, processor.configuration)
@@ -291,7 +290,7 @@ def edit_retriever(retriever_id):
retriever = Retriever.query.get_or_404(retriever_id) retriever = Retriever.query.get_or_404(retriever_id)
# Create form instance with the retriever # Create form instance with the retriever
form = EditRetrieverForm(request.form, obj=retriever) form = EditRetrieverForm(obj=retriever)
retriever_config = cache_manager.retrievers_config_cache.get_config(retriever.type, retriever.type_version) retriever_config = cache_manager.retrievers_config_cache.get_config(retriever.type, retriever.type_version)
form.add_dynamic_fields("configuration", retriever_config, retriever.configuration) form.add_dynamic_fields("configuration", retriever_config, retriever.configuration)
@@ -355,17 +354,7 @@ def handle_retriever_selection():
@document_bp.route('/add_document', methods=['GET', 'POST']) @document_bp.route('/add_document', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def add_document(): def add_document():
# Log vroege request-info om uploadproblemen te diagnosticeren form = AddDocumentForm(request.form)
try:
current_app.logger.debug(
f"[add_document] method={request.method}, content_type={request.content_type}, "
f"files_keys={list(request.files.keys())}"
)
except Exception:
pass
# Bind expliciet zowel form- als file-data aan de form (belangrijk voor FileField & CSRF)
form = AddDocumentForm(CombinedMultiDict([request.form, request.files]))
catalog_id = session.get('catalog_id', None) catalog_id = session.get('catalog_id', None)
if catalog_id is None: if catalog_id is None:
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning') flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
@@ -375,38 +364,6 @@ def add_document():
if catalog.configuration and len(catalog.configuration) > 0: if catalog.configuration and len(catalog.configuration) > 0:
form.add_dynamic_fields("tagging_fields", catalog.configuration) form.add_dynamic_fields("tagging_fields", catalog.configuration)
current_app.logger.debug("In Add Document")
# Extra debug logging om CSRF/payload te controleren
try:
current_app.logger.debug(
f"[add_document] request.form keys: {list(request.form.keys())}"
)
current_app.logger.debug(
f"[add_document] csrf_token in form? {request.form.get('csrf_token') is not None}"
)
try:
has_csrf_field = hasattr(form, 'csrf_token')
current_app.logger.debug(
f"[add_document] form has csrf field? {has_csrf_field}"
)
if has_csrf_field:
# Let op: we loggen geen tokenwaarde om lekken te vermijden; enkel aanwezigheid
current_app.logger.debug(
"[add_document] form.csrf_token field is present on form object"
)
# Bevestig of de CSRF-waarde effectief in de form is gebonden
try:
current_app.logger.debug(
f"[add_document] csrf bound? data_present={bool(form.csrf_token.data)} field_name={getattr(form.csrf_token, 'name', None)}"
)
except Exception:
pass
except Exception:
pass
except Exception:
pass
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
current_app.logger.info(f'Adding Document for {catalog_id}') current_app.logger.info(f'Adding Document for {catalog_id}')
@@ -443,25 +400,6 @@ def add_document():
except Exception as e: except Exception as e:
current_app.logger.error(f'Error adding document: {str(e)}') current_app.logger.error(f'Error adding document: {str(e)}')
flash('An error occurred while adding the document.', 'danger') flash('An error occurred while adding the document.', 'danger')
else:
# Toon en log validatiefouten als de submit faalt
if request.method == 'POST':
try:
current_app.logger.warning(
f"[add_document] form validation failed. errors={getattr(form, 'errors', {})}"
)
current_app.logger.debug(
f"[add_document] request.files keys after validation: {list(request.files.keys())}"
)
current_app.logger.debug(
f"[add_document] request.form keys after validation: {list(request.form.keys())}"
)
current_app.logger.debug(
f"[add_document] csrf_token in form after validation? {request.form.get('csrf_token') is not None}"
)
except Exception:
pass
form_validation_failed(request, form)
return render_template('document/add_document.html', form=form) return render_template('document/add_document.html', form=form)
@@ -469,16 +407,7 @@ def add_document():
@document_bp.route('/add_url', methods=['GET', 'POST']) @document_bp.route('/add_url', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def add_url(): def add_url():
# Log vroege request-info om submitproblemen te diagnosticeren form = AddURLForm(request.form)
try:
current_app.logger.debug(
f"[add_url] method={request.method}, content_type={request.content_type}, files_keys={list(request.files.keys())}"
)
except Exception:
pass
# Bind expliciet zowel form- als file-data (consistentie en duidelijkheid)
form = AddURLForm(CombinedMultiDict([request.form, request.files]))
catalog_id = session.get('catalog_id', None) catalog_id = session.get('catalog_id', None)
if catalog_id is None: if catalog_id is None:
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning') flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
@@ -488,15 +417,6 @@ def add_url():
if catalog.configuration and len(catalog.configuration) > 0: if catalog.configuration and len(catalog.configuration) > 0:
form.add_dynamic_fields("tagging_fields", catalog.configuration) form.add_dynamic_fields("tagging_fields", catalog.configuration)
url="" url=""
# Kleine debug om te zien of CSRF aan de form gebonden is
try:
if hasattr(form, 'csrf_token'):
current_app.logger.debug(
f"[add_url] csrf bound? data_present={bool(form.csrf_token.data)} field_name={getattr(form.csrf_token, 'name', None)}"
)
except Exception:
pass
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
tenant_id = session['tenant']['id'] tenant_id = session['tenant']['id']
@@ -542,15 +462,6 @@ def add_url():
except Exception as e: except Exception as e:
current_app.logger.error(f'Error adding document: {str(e)}') current_app.logger.error(f'Error adding document: {str(e)}')
flash('An error occurred while adding the document.', 'danger') flash('An error occurred while adding the document.', 'danger')
else:
if request.method == 'POST':
try:
current_app.logger.warning(
f"[add_url] form validation failed. errors={getattr(form, 'errors', {})}"
)
except Exception:
pass
form_validation_failed(request, form)
return render_template('document/add_url.html', form=form) return render_template('document/add_url.html', form=form)
@@ -664,7 +575,7 @@ def edit_document(document_id):
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def edit_document_version(document_version_id): def edit_document_version(document_version_id):
doc_vers = DocumentVersion.query.get_or_404(document_version_id) doc_vers = DocumentVersion.query.get_or_404(document_version_id)
form = EditDocumentVersionForm(request.form, obj=doc_vers) form = EditDocumentVersionForm(obj=doc_vers)
doc_vers = DocumentVersion.query.get_or_404(document_version_id) doc_vers = DocumentVersion.query.get_or_404(document_version_id)
catalog_id = doc_vers.document.catalog_id catalog_id = doc_vers.document.catalog_id

View File

@@ -96,14 +96,42 @@ class OrderedListField(TextAreaField):
class DynamicFormBase(FlaskForm): class DynamicFormBase(FlaskForm):
def __init__(self, formdata=None, *args, **kwargs): def __init__(self, *args, **kwargs):
# Belangrijk: formdata doorgeven aan FlaskForm zodat WTForms POST-data kan binden """Base class voor dynamische formulieren.
super(DynamicFormBase, self).__init__(formdata=formdata, *args, **kwargs)
Belangrijk ontwerpprincipe:
- We laten "normaal" FlaskForm-gedrag intact. Dat betekent dat als een view
geen expliciete ``formdata=`` meegeeft, FlaskForm zelf beslist of en hoe
``request.form`` wordt gebruikt (inclusief CSRF-handling).
- Als een view wel expliciet ``formdata=`` meegeeft, wordt dat gewoon door de
superklasse afgehandeld.
Hierdoor hoeven views DynamicFormBase niet anders te behandelen dan een
standaard FlaskForm; dynamische velden zijn een extra laag bovenop het
standaard gedrag.
"""
# Laat FlaskForm alle standaard initialisatielogica uitvoeren
super(DynamicFormBase, self).__init__(*args, **kwargs)
# Maps collection names to lists of field names # Maps collection names to lists of field names
self.dynamic_fields = {} self.dynamic_fields = {}
# Store formdata for later use
self.formdata = formdata # Bepaal effectieve formdata voor intern gebruik.
self.raw_formdata = request.form.to_dict() # In de meeste gevallen is dat bij POST gewoon request.form; bij GET is er
# doorgaans geen formdata en vertrouwen we op object-binding en defaults.
if request.method == 'POST':
self.formdata = request.form
# Bewaar een eenvoudige dict-weergave voor hulplogica zoals
# get_dynamic_data (bijvoorbeeld voor BooleanFields)
try:
self.raw_formdata = request.form.to_dict(flat=False)
except TypeError:
# Fallback voor oudere/afwijkende MultiDict-implementaties
self.raw_formdata = request.form.to_dict()
else:
self.formdata = None
self.raw_formdata = {}
def _create_field_validators(self, field_def): def _create_field_validators(self, field_def):
"""Create validators based on field definition""" """Create validators based on field definition"""

View File

@@ -165,8 +165,8 @@ class EditEveAIAgentForm(BaseEditComponentForm):
class EditEveAITaskForm(BaseEditComponentForm): class EditEveAITaskForm(BaseEditComponentForm):
task_description = StringField('Task Description', validators=[Optional()]) task_description = TextAreaField('Task Description', validators=[Optional()])
expected_outcome = StringField('Expected Outcome', validators=[Optional()]) expected_outcome = TextAreaField('Expected Outcome', validators=[Optional()])
class EditEveAIToolForm(BaseEditComponentForm): class EditEveAIToolForm(BaseEditComponentForm):

View File

@@ -199,7 +199,7 @@ def specialist():
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def edit_specialist(specialist_id): def edit_specialist(specialist_id):
specialist = Specialist.query.get_or_404(specialist_id) specialist = Specialist.query.get_or_404(specialist_id)
form = EditSpecialistForm(request.form, obj=specialist) form = EditSpecialistForm(obj=specialist)
specialist_config = cache_manager.specialists_config_cache.get_config(specialist.type, specialist.type_version) specialist_config = cache_manager.specialists_config_cache.get_config(specialist.type, specialist.type_version)
form.add_dynamic_fields("configuration", specialist_config, specialist.configuration) form.add_dynamic_fields("configuration", specialist_config, specialist.configuration)
@@ -282,26 +282,44 @@ def edit_specialist(specialist_id):
else: else:
form_validation_failed(request, form) form_validation_failed(request, form)
# Build combined components list view config for embedding
from eveai_app.views.list_views.interaction_list_views import get_specialist_components_list_view
components_config = get_specialist_components_list_view(specialist)
return render_template('interaction/edit_specialist.html', return render_template('interaction/edit_specialist.html',
form=form, form=form,
specialist_id=specialist_id, specialist_id=specialist_id,
components_title=components_config.get('title'),
components_data=components_config.get('data'),
components_columns=components_config.get('columns'),
components_actions=components_config.get('actions'),
components_initial_sort=components_config.get('initial_sort'),
components_table_id=components_config.get('table_id'),
components_table_height=components_config.get('table_height'),
components_description=components_config.get('description'),
components_index=components_config.get('index'),
prefixed_url_for=prefixed_url_for, prefixed_url_for=prefixed_url_for,
svg_path=svg_path, ) svg_path=svg_path, )
@interaction_bp.route('/tune_specialist/<int:specialist_id>', methods=['GET'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tune_specialist(specialist_id):
"""Standalone tuning view voor het beheren van agents, tasks en tools van een specialist.
Deze view biedt dezelfde component-editor functionaliteit die voorheen in de
Components-tab van ``edit_specialist`` zat, maar dan als aparte pagina.
"""
specialist = Specialist.query.get_or_404(specialist_id)
# Gebruikt dezelfde gecombineerde componenten-configuratie als de voormalige tab
from eveai_app.views.list_views.interaction_list_views import get_specialist_components_list_view
components_config = get_specialist_components_list_view(specialist)
return render_template(
'interaction/tune_specialist.html',
specialist=specialist,
specialist_id=specialist_id,
components_title=components_config.get('title'),
components_data=components_config.get('data'),
components_columns=components_config.get('columns'),
components_actions=components_config.get('actions'),
components_initial_sort=components_config.get('initial_sort'),
components_table_id=components_config.get('table_id'),
components_table_height=components_config.get('table_height'),
components_description=components_config.get('description'),
components_index=components_config.get('index'),
prefixed_url_for=prefixed_url_for,
)
@interaction_bp.route('/specialists', methods=['GET', 'POST']) @interaction_bp.route('/specialists', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def specialists(): def specialists():
@@ -323,6 +341,8 @@ def handle_specialist_selection():
if action == "edit_specialist": if action == "edit_specialist":
return redirect(prefixed_url_for('interaction_bp.edit_specialist', specialist_id=specialist_id, for_redirect=True)) return redirect(prefixed_url_for('interaction_bp.edit_specialist', specialist_id=specialist_id, for_redirect=True))
elif action == "tune_specialist":
return redirect(prefixed_url_for('interaction_bp.tune_specialist', specialist_id=specialist_id, for_redirect=True))
elif action == "execute_specialist": elif action == "execute_specialist":
return redirect(prefixed_url_for('interaction_bp.execute_specialist', specialist_id=specialist_id, for_redirect=True)) return redirect(prefixed_url_for('interaction_bp.execute_specialist', specialist_id=specialist_id, for_redirect=True))
@@ -431,7 +451,7 @@ def edit_task(task_id):
def save_task(task_id): def save_task(task_id):
task = EveAITask.query.get_or_404(task_id) if task_id else EveAITask() task = EveAITask.query.get_or_404(task_id) if task_id else EveAITask()
tenant_id = session.get('tenant').get('id') tenant_id = session.get('tenant').get('id')
form = EditEveAITaskForm(formdata=request.form, obj=task) # Bind explicit formdata form = EditEveAITaskForm(obj=task) # Bind explicit formdata
if form.validate_on_submit(): if form.validate_on_submit():
try: try:
@@ -705,7 +725,7 @@ def specialist_magic_link():
def edit_specialist_magic_link(specialist_magic_link_id): def edit_specialist_magic_link(specialist_magic_link_id):
specialist_ml = SpecialistMagicLink.query.get_or_404(specialist_magic_link_id) specialist_ml = SpecialistMagicLink.query.get_or_404(specialist_magic_link_id)
# We need to pass along the extra kwarg specialist_id, as this id is required to initialize the form # We need to pass along the extra kwarg specialist_id, as this id is required to initialize the form
form = EditSpecialistMagicLinkForm(request.form, obj=specialist_ml, specialist_id=specialist_ml.specialist_id) form = EditSpecialistMagicLinkForm(obj=specialist_ml, specialist_id=specialist_ml.specialist_id)
# Find the Specialist type and type_version to enable to retrieve the arguments # Find the Specialist type and type_version to enable to retrieve the arguments
specialist = Specialist.query.get_or_404(specialist_ml.specialist_id) specialist = Specialist.query.get_or_404(specialist_ml.specialist_id)

View File

@@ -38,6 +38,7 @@ def get_specialists_list_view():
# Action definitions # Action definitions
actions = [ actions = [
{'value': 'edit_specialist', 'text': 'Edit Specialist', 'class': 'btn-primary', 'requiresSelection': True}, {'value': 'edit_specialist', 'text': 'Edit Specialist', 'class': 'btn-primary', 'requiresSelection': True},
{'value': 'tune_specialist', 'text': 'Tune Specialist', 'class': 'btn-secondary', 'requiresSelection': True},
{'value': 'execute_specialist', 'text': 'Execute Specialist', 'class': 'btn-primary', 'requiresSelection': True}, {'value': 'execute_specialist', 'text': 'Execute Specialist', 'class': 'btn-primary', 'requiresSelection': True},
{'value': 'create_specialist', 'text': 'Register Specialist', 'class': 'btn-success', 'position': 'right', 'requiresSelection': False} {'value': 'create_specialist', 'text': 'Register Specialist', 'class': 'btn-success', 'position': 'right', 'requiresSelection': False}
] ]

View File

@@ -145,7 +145,7 @@ def edit_partner_service(partner_service_id):
partner_service = PartnerService.query.get_or_404(partner_service_id) partner_service = PartnerService.query.get_or_404(partner_service_id)
partner_id = session['partner']['id'] partner_id = session['partner']['id']
form = EditPartnerServiceForm(request.form, obj=partner_service) form = EditPartnerServiceForm(obj=partner_service)
partner_service_config = cache_manager.partner_services_config_cache.get_config(partner_service.type, partner_service_config = cache_manager.partner_services_config_cache.get_config(partner_service.type,
partner_service.type_version) partner_service.type_version)
form.add_dynamic_fields("configuration", partner_service_config, partner_service.configuration) form.add_dynamic_fields("configuration", partner_service_config, partner_service.configuration)

View File

@@ -12,7 +12,7 @@ from sqlalchemy.exc import SQLAlchemyError
from common.models.user import User, ConsentStatus from common.models.user import User, ConsentStatus
from common.services.user import TenantServices, UserServices from common.services.user import TenantServices, UserServices
from common.utils.eveai_exceptions import EveAIException, EveAINoActiveLicense from common.utils.eveai_exceptions import EveAIException, EveAINoActiveLicense, EveAIUserExpired
from common.utils.nginx_utils import prefixed_url_for from common.utils.nginx_utils import prefixed_url_for
from eveai_app.views.security_forms import SetPasswordForm, ResetPasswordForm, ForgotPasswordForm from eveai_app.views.security_forms import SetPasswordForm, ResetPasswordForm, ForgotPasswordForm
from common.extensions import db from common.extensions import db
@@ -46,6 +46,14 @@ def login():
user = User.query.filter_by(email=form.email.data).first() user = User.query.filter_by(email=form.email.data).first()
if user is None or not verify_and_update_password(form.password.data, user): if user is None or not verify_and_update_password(form.password.data, user):
raise EveAIException('Invalid email or password') raise EveAIException('Invalid email or password')
# Check if the user's account is still valid based on valid_to
today = dt.now(tz=tz.utc).date()
if user.valid_to is not None and today > user.valid_to:
current_app.logger.warning(
f"Login blocked for expired user {user.id} ({user.email}); "
f"today={today}, valid_to={user.valid_to}"
)
raise EveAIUserExpired()
is_valid_tenant(user.tenant_id) is_valid_tenant(user.tenant_id)
except EveAIException as e: except EveAIException as e:
flash(f'Failed to login user: {str(e)}', 'danger') flash(f'Failed to login user: {str(e)}', 'danger')

View File

@@ -88,13 +88,13 @@ class BaseUserForm(FlaskForm):
last_name = StringField('Last Name', validators=[DataRequired(), Length(max=80)]) last_name = StringField('Last Name', validators=[DataRequired(), Length(max=80)])
valid_to = DateField('Valid to', id='form-control datepicker', validators=[Optional()]) valid_to = DateField('Valid to', id='form-control datepicker', validators=[Optional()])
tenant_id = IntegerField('Tenant ID', validators=[NumberRange(min=0)]) tenant_id = IntegerField('Tenant ID', validators=[NumberRange(min=0)])
selected_role_ids = SelectMultipleField('Roles', coerce=int) roles = SelectMultipleField('Roles', coerce=int)
is_primary_contact = BooleanField('Primary Contact') is_primary_contact = BooleanField('Primary Contact')
is_financial_contact = BooleanField('Financial Contact') is_financial_contact = BooleanField('Financial Contact')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(BaseUserForm, self).__init__(*args, **kwargs) super(BaseUserForm, self).__init__(*args, **kwargs)
self.selected_role_ids.choices = UserServices.get_assignable_roles() self.roles.choices = UserServices.get_assignable_roles()
class CreateUserForm(BaseUserForm): class CreateUserForm(BaseUserForm):

View File

@@ -217,15 +217,21 @@ def user():
if form.validate_on_submit(): if form.validate_on_submit():
current_app.logger.info(f"Adding User for tenant {session['tenant']['id']} ") current_app.logger.info(f"Adding User for tenant {session['tenant']['id']} ")
new_user = User() new_user = User(user_name=form.user_name.data,
form.populate_obj(new_user) email=form.email.data,
first_name=form.first_name.data,
last_name=form.last_name.data,
valid_to=form.valid_to.data,
tenant_id=form.tenant_id.data,
fs_uniquifier=uuid.uuid4().hex,
)
timestamp = dt.now(tz.utc) timestamp = dt.now(tz.utc)
new_user.created_at = timestamp new_user.created_at = timestamp
new_user.updated_at = timestamp new_user.updated_at = timestamp
# Add roles # Add roles
for role_id in form.selected_role_ids.data: for role_id in form.roles.data:
the_role = Role.query.get(role_id) the_role = Role.query.get(role_id)
new_user.roles.append(the_role) new_user.roles.append(the_role)
@@ -256,22 +262,39 @@ def user():
return render_template('user/user.html', form=form) return render_template('user/user.html', form=form)
def _populate_user_from_form(form: EditUserForm, user: User) -> None:
"""Vul het User-object met veilige velden uit het formulier.
Let op:
- Relaties zoals ``roles`` worden hier bewust NIET gezet.
- Systeemvelden / read-only velden (tenant_id, confirmed_at, login_count, ...)
laten we hier ongemoeid.
"""
# Basisgegevens
user.first_name = form.first_name.data
user.last_name = form.last_name.data
user.valid_to = form.valid_to.data
# Contact-flags
user.is_primary_contact = form.is_primary_contact.data
user.is_financial_contact = form.is_financial_contact.data
@user_bp.route('/user/<int:user_id>', methods=['GET', 'POST']) @user_bp.route('/user/<int:user_id>', methods=['GET', 'POST'])
@roles_accepted('Super User', 'Tenant Admin', 'Partner Admin') @roles_accepted('Super User', 'Tenant Admin', 'Partner Admin')
def edit_user(user_id): def edit_user(user_id):
user = User.query.get_or_404(user_id) # This will return a 404 if no user is found user = User.query.get_or_404(user_id) # This will return a 404 if no user is found
tenant_id = session.get('tenant').get('id')
form = EditUserForm(obj=user) form = EditUserForm(obj=user)
if form.validate_on_submit(): if form.validate_on_submit():
# Populate the user with form data # Vul het user-object met veilige velden uit het formulier
form.populate_obj(user) _populate_user_from_form(form, user)
timestamp = dt.now(tz.utc) user.updated_at = dt.now(tz.utc)
user.updated_at = timestamp
# Update roles # Update roles
current_roles = set(role.id for role in user.roles) current_roles = set(role.id for role in user.roles)
selected_roles = set(form.selected_role_ids.data) selected_roles = set(form.roles.data)
if UserServices.validate_role_assignments(selected_roles): if UserServices.validate_role_assignments(selected_roles):
# Add new roles # Add new roles
for role_id in selected_roles - current_roles: for role_id in selected_roles - current_roles:
@@ -297,7 +320,7 @@ def edit_user(user_id):
else: else:
form_validation_failed(request, form) form_validation_failed(request, form)
form.selected_role_ids.data = [role.id for role in user.roles] form.roles.data = [role.id for role in user.roles]
return render_template('user/edit_user.html', form=form, user_id=user_id) return render_template('user/edit_user.html', form=form, user_id=user_id)
@@ -571,12 +594,8 @@ def delete_tenant_project(tenant_project_id):
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin') @roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
def tenant_make(): def tenant_make():
form = TenantMakeForm() form = TenantMakeForm()
current_app.logger.debug(f"ìn tenant_make view")
# customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION")
# default_customisation_options = create_default_config_from_type_config(customisation_config["configuration"])
if form.validate_on_submit(): if form.validate_on_submit():
current_app.logger.debug(f"in tenant_make form validate")
tenant_id = session['tenant']['id'] tenant_id = session['tenant']['id']
new_tenant_make = TenantMake() new_tenant_make = TenantMake()
form.populate_obj(new_tenant_make) form.populate_obj(new_tenant_make)
@@ -591,15 +610,13 @@ def tenant_make():
flash('Tenant Make successfully added!', 'success') flash('Tenant Make successfully added!', 'success')
current_app.logger.info(f'Tenant Make {new_tenant_make.name}, id {new_tenant_make.id} successfully added ' current_app.logger.info(f'Tenant Make {new_tenant_make.name}, id {new_tenant_make.id} successfully added '
f'for tenant {tenant_id}!') f'for tenant {tenant_id}!')
# Enable step 2 of creation of retriever - add configuration of the retriever (dependent on type) # Enable step 2 of creation of make - add configuration of the retriever (dependent on type)
return redirect(prefixed_url_for('user_bp.edit_tenant_make', tenant_make_id=new_tenant_make.id, for_redirect=True)) return redirect(prefixed_url_for('user_bp.edit_tenant_make', tenant_make_id=new_tenant_make.id, for_redirect=True))
except SQLAlchemyError as e: except SQLAlchemyError as e:
db.session.rollback() db.session.rollback()
flash(f'Failed to add Tenant Make. Error: {e}', 'danger') flash(f'Failed to add Tenant Make. Error: {e}', 'danger')
current_app.logger.error(f'Failed to add Tenant Make {new_tenant_make.name}' current_app.logger.error(f'Failed to add Tenant Make {new_tenant_make.name}'
f'for tenant {tenant_id}. Error: {str(e)}') f'for tenant {tenant_id}. Error: {str(e)}')
else:
flash('Please fill in all required fields.', 'information')
return render_template('user/tenant_make.html', form=form) return render_template('user/tenant_make.html', form=form)
@@ -619,8 +636,10 @@ def edit_tenant_make(tenant_make_id):
# Get the tenant make or return 404 # Get the tenant make or return 404
tenant_make = TenantMake.query.get_or_404(tenant_make_id) tenant_make = TenantMake.query.get_or_404(tenant_make_id)
# Create form instance with the tenant make # Create form instance with the tenant make.
form = EditTenantMakeForm(request.form, obj=tenant_make) # Dankzij DynamicFormBase wordt formdata nu op standaard FlaskForm-manier
# afgehandeld en is geen expliciete formdata=request.form meer nodig.
form = EditTenantMakeForm(obj=tenant_make)
# Initialiseer de allowed_languages selectie met huidige waarden # Initialiseer de allowed_languages selectie met huidige waarden
if request.method == 'GET': if request.method == 'GET':
@@ -754,7 +773,7 @@ def edit_consent_version(consent_version_id):
cv = ConsentVersion.query.get_or_404(consent_version_id) cv = ConsentVersion.query.get_or_404(consent_version_id)
# Create form instance with the tenant make # Create form instance with the tenant make
form = EditConsentVersionForm(request.form, obj=cv) form = EditConsentVersionForm(obj=cv)
if form.validate_on_submit(): if form.validate_on_submit():
# Update basic fields # Update basic fields

View File

@@ -9,14 +9,23 @@
--message-bot-bg: #f8f9fa; --message-bot-bg: #f8f9fa;
--border-radius: 8px; --border-radius: 8px;
--spacing: 16px; --spacing: 16px;
/* Nieuwe, veilige viewport-variabelen voor mobiele lay-out
- --vvh blijft als fallback uit viewport.js
- --safe-vh wordt gezet door useChatViewport()
- --safe-bottom-inset wordt gebruikt voor ondermarge bij de chat input */
--safe-vh: var(--vvh, 1vh);
--safe-bottom-inset: 0px;
} }
/* App container layout */ /* App container layout */
.app-container { .app-container {
display: flex; display: flex;
/* Use visual viewport variable when available */ /* Op desktop gebruiken we de veilige viewporthoogte direct; op mobiel
laten we html/body de hoogte bepalen en neemt de app-container
eenvoudig 100% daarvan in via de media query verderop. */
min-height: 0; min-height: 0;
height: calc(var(--vvh, 1vh) * 100); height: calc(var(--safe-vh, var(--vvh, 1vh)) * 100);
width: 100%; width: 100%;
} }
@@ -86,7 +95,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
height: auto; /* prefer dynamic viewport on desktop */ height: auto; /* desktop: dynamische hoogte, op mobiel overschreven */
} }
.chat-container { .chat-container {
@@ -96,8 +105,29 @@
min-height: 0; /* laat kinderen (ChatApp) krimpen */ min-height: 0; /* laat kinderen (ChatApp) krimpen */
} }
/* Op mobiel sluiten we de volledige content-kolom strak aan op de veilige
viewporthoogte zodat alleen de chatcontent zelf kan scrollen en niet de
gehele pagina wanneer het toetsenbord opent. */
@media (max-width: 768px) {
.app-container {
height: 100%;
}
.content-area {
height: 100%;
overflow: hidden;
}
.chat-container {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
}
html, body { html, body {
height: calc(var(--vvh, 1vh) * 100); min-height: 0; height: calc(var(--safe-vh, var(--vvh, 1vh)) * 100);
min-height: 0;
} }
/* Base reset & overflow control */ /* Base reset & overflow control */

View File

@@ -0,0 +1,176 @@
// useChatViewport.js
// Centrale viewport-/keyboard-laag voor de chat client.
// - Gebruikt visualViewport + VueUse (via window.VueUse) om veilige hoogte
// en keyboard-status te bepalen
// - Stelt CSS-variabelen --safe-vh en --safe-bottom-inset in op <html>
// - Beheert body-klassen zoals chat-keyboard-open en ios-safari
import { ref, computed, watchEffect, onMounted, onBeforeUnmount } from 'vue';
// Haal VueUse-composables uit window.VueUse, die in de chat-bundel
// worden geïnitialiseerd via nginx/frontend_src/js/vueuse-setup.js.
// Als ze ontbreken (bv. in een testsituatie), vallen we terug op
// eenvoudige lokale shims.
const vueUse = (typeof window !== 'undefined' && window.VueUse) || {};
let { useEventListener, useWindowSize } = vueUse;
// Fallback-shim voor useEventListener
if (!useEventListener) {
useEventListener = (target, event, handler, options) => {
if (!target || !target.addEventListener) return () => {};
const opts = options || { passive: true };
target.addEventListener(event, handler, opts);
return () => target.removeEventListener(event, handler, opts);
};
}
// Fallback-shim voor useWindowSize
if (!useWindowSize) {
useWindowSize = () => {
const height = ref(typeof window !== 'undefined' ? window.innerHeight : 0);
if (typeof window !== 'undefined') {
window.addEventListener('resize', () => {
height.value = window.innerHeight;
}, { passive: true });
}
return { height };
};
}
function detectIosSafari() {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || navigator.vendor || (window && window.opera) || '';
const isIOS = /iP(ad|hone|od)/.test(ua);
const isSafari = /Safari/.test(ua) && !/Chrome|CriOS|FxiOS/.test(ua);
return isIOS && isSafari;
}
// Drempel voor het detecteren van keyboard-open op basis van hoogteverschil
const KEYBOARD_DELTA_THRESHOLD = 120; // px, afgestemd op bestaande viewport.js heuristiek
export function useChatViewport() {
const isClient = typeof window !== 'undefined' && typeof document !== 'undefined';
const safeHeight = ref(isClient ? window.innerHeight : 0);
const baselineHeight = ref(null); // baseline voor keyboard-detectie
const keyboardOpen = ref(false);
const safeBottomInset = ref(0);
const { height: windowHeight } = useWindowSize();
const isIosSafari = ref(isClient ? detectIosSafari() : false);
const isMobile = computed(() => {
if (!isClient) return false;
// Houd de definitie in lijn met bestaande CSS (max-width: 768px)
return window.innerWidth <= 768;
});
function applyToCss() {
if (!isClient) return;
const root = document.documentElement;
const vhUnit = (safeHeight.value || windowHeight.value || 0) / 100;
if (vhUnit > 0) {
root.style.setProperty('--safe-vh', `${vhUnit}px`);
}
root.style.setProperty('--safe-bottom-inset', `${safeBottomInset.value || 0}px`);
// Body-klassen
document.body.classList.toggle('chat-keyboard-open', !!keyboardOpen.value);
if (isIosSafari.value) {
root.classList.add('ios-safari');
} else {
root.classList.remove('ios-safari');
}
}
function updateFromVisualViewport() {
if (!isClient) return;
const vv = window.visualViewport;
const currentHeight = vv ? vv.height : window.innerHeight;
safeHeight.value = currentHeight;
// Bepaal een eenvoudige bottom-inset: verschil tussen layout- en visual viewport
const layoutHeight = window.innerHeight || currentHeight;
const inset = Math.max(0, layoutHeight - currentHeight);
safeBottomInset.value = inset;
// Keyboard-detectie: vergelijk met baseline
if (baselineHeight.value == null) {
baselineHeight.value = currentHeight;
}
const delta = (baselineHeight.value || currentHeight) - currentHeight;
keyboardOpen.value = delta > KEYBOARD_DELTA_THRESHOLD;
}
onMounted(() => {
if (!isClient) return;
// Initiale meting
updateFromVisualViewport();
applyToCss();
const vv = window.visualViewport;
// Luister naar relevante events via VueUse
const stopResize = vv
? useEventListener(vv, 'resize', () => {
updateFromVisualViewport();
applyToCss();
})
: useEventListener(window, 'resize', () => {
updateFromVisualViewport();
applyToCss();
});
const stopScroll = vv
? useEventListener(vv, 'scroll', () => {
updateFromVisualViewport();
applyToCss();
})
: () => {};
const stopOrientation = useEventListener(window, 'orientationchange', () => {
// Bij oriëntatie-wijziging baseline resetten
baselineHeight.value = null;
updateFromVisualViewport();
applyToCss();
});
onBeforeUnmount(() => {
stopResize && stopResize();
stopScroll && stopScroll();
stopOrientation && stopOrientation();
});
});
// Reageer ook op generieke windowHeight veranderingen (fallback)
watchEffect(() => {
if (!isClient) return;
if (!window.visualViewport) {
safeHeight.value = windowHeight.value;
safeBottomInset.value = 0;
keyboardOpen.value = false;
applyToCss();
}
});
// Initiale toepassing voor SSR / eerste render
if (isClient) {
applyToCss();
}
return {
safeHeight,
safeBottomInset,
keyboardOpen,
isMobile,
isIosSafari,
};
}
export default useChatViewport;

View File

@@ -1,38 +1,106 @@
active_text_color<template> active_text_color<template>
<div class="chat-app-container"> <div class="chat-app-container">
<!-- Message History - takes available space --> <!-- Desktop layout: huidige gedrag behouden -->
<message-history <div v-if="!isMobileFallback" class="chat-desktop-layout">
:messages="displayMessages" <message-history
:is-typing="isTyping" :messages="displayMessages"
:is-submitting-form="isSubmittingForm" :is-typing="isTyping"
:api-prefix="apiPrefix" :is-submitting-form="isSubmittingForm"
:auto-scroll="true" :api-prefix="apiPrefix"
@specialist-error="handleSpecialistError" :auto-scroll="true"
@specialist-complete="handleSpecialistComplete" @specialist-error="handleSpecialistError"
ref="messageHistory" @specialist-complete="handleSpecialistComplete"
class="chat-messages-area" ref="messageHistory"
></message-history> class="chat-messages-area"
></message-history>
<!-- Chat Input - to the bottom --> <chat-input
<chat-input :current-message="currentMessage"
:current-message="currentMessage" :is-loading="isLoading"
:is-loading="isLoading" :max-length="2000"
:max-length="2000" :allow-file-upload="true"
:allow-file-upload="true" :allow-voice-message="false"
:allow-voice-message="false" :form-data="currentInputFormData"
:form-data="currentInputFormData" :active-ai-message="activeAiMessage"
:active-ai-message="activeAiMessage" :api-prefix="apiPrefix"
:api-prefix="apiPrefix" @send-message="sendMessage"
@send-message="sendMessage" @update-message="updateCurrentMessage"
@update-message="updateCurrentMessage" @upload-file="handleFileUpload"
@upload-file="handleFileUpload" @record-voice="handleVoiceRecord"
@record-voice="handleVoiceRecord" @submit-form="submitFormFromInput"
@submit-form="submitFormFromInput" @specialist-error="handleSpecialistError"
@specialist-error="handleSpecialistError" @specialist-complete="handleSpecialistComplete"
@specialist-complete="handleSpecialistComplete" ref="chatInput"
ref="chatInput" class="chat-input-area"
class="chat-input-area" ></chat-input>
></chat-input> </div>
<!-- Mobiele layout met tabs in de header -->
<div v-else class="chat-mobile-layout">
<header class="chat-mobile-header">
<div class="chat-mobile-header-left">
<SideBarLogo
:logo-url="tenantLogoUrl"
:make-name="tenantName"
/>
</div>
<div class="chat-mobile-header-right">
<MobileTabBar
v-model="activeTabId"
:tabs="mobileTabs"
placement="header"
/>
</div>
</header>
<div class="chat-mobile-content" :class="`tab-${activeTabId}`">
<message-history
v-if="activeTabId === '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
v-else-if="activeTabId === 'chat'"
:current-message="currentMessage"
:is-loading="isLoading"
:max-length="settings.maxMessageLength"
:allow-file-upload="settings.allowFileUpload"
:allow-voice-message="settings.allowVoiceMessage"
:form-data="currentInputFormData"
:active-ai-message="activeAiMessage"
:api-prefix="apiPrefix"
@send-message="sendMessage"
@update-message="updateCurrentMessage"
@upload-file="handleFileUpload"
@record-voice="handleVoiceRecord"
@submit-form="submitFormFromInput"
@specialist-error="handleSpecialistError"
@specialist-complete="handleSpecialistComplete"
ref="chatInput"
class="chat-input-area tab-chat-input"
></chat-input>
<SideBarMobileSetup
v-else-if="activeTabId === 'setup'"
:tenant-make="{ name: tenantName, subtitle: tenantSubtitle }"
:explanation-text="originalExplanation"
:initial-language="currentLanguage"
:current-language="currentLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
:api-prefix="apiPrefix"
@language-changed="handleLanguageChangedFromSetup"
/>
</div>
</div>
<!-- Content Modal - positioned at ChatApp level --> <!-- Content Modal - positioned at ChatApp level -->
<content-modal <content-modal
@@ -60,6 +128,9 @@ import ProgressTracker from './ProgressTracker.vue';
import LanguageSelector from './LanguageSelector.vue'; import LanguageSelector from './LanguageSelector.vue';
import ChatInput from './ChatInput.vue'; import ChatInput from './ChatInput.vue';
import ContentModal from './ContentModal.vue'; import ContentModal from './ContentModal.vue';
import SideBarLogo from './SideBarLogo.vue';
import MobileTabBar from './MobileTabBar.vue';
import SideBarMobileSetup from './SideBarMobileSetup.vue';
// Import language provider // Import language provider
import { createLanguageProvider, LANGUAGE_PROVIDER_KEY } from '../js/services/LanguageProvider.js'; import { createLanguageProvider, LANGUAGE_PROVIDER_KEY } from '../js/services/LanguageProvider.js';
@@ -77,7 +148,10 @@ export default {
MessageHistory, MessageHistory,
ProgressTracker, ProgressTracker,
ChatInput, ChatInput,
ContentModal ContentModal,
SideBarLogo,
MobileTabBar,
SideBarMobileSetup
}, },
setup() { setup() {
@@ -90,7 +164,7 @@ export default {
// Creëer en provide content modal // Creëer en provide content modal
const contentModal = provideContentModal(); const contentModal = provideContentModal();
// Provide aan alle child components // Provide aan alle child components
provide(LANGUAGE_PROVIDER_KEY, languageProvider); provide(LANGUAGE_PROVIDER_KEY, languageProvider);
@@ -111,6 +185,7 @@ export default {
return { return {
// Tenant info // Tenant info
tenantName: tenantMake.name || 'EveAI', tenantName: tenantMake.name || 'EveAI',
tenantSubtitle: tenantMake.subtitle || '',
tenantLogoUrl: tenantMake.logo_url || '', tenantLogoUrl: tenantMake.logo_url || '',
// Taal gerelateerde data // Taal gerelateerde data
@@ -147,10 +222,13 @@ export default {
autoScroll: settings.autoScroll === true autoScroll: settings.autoScroll === true
}, },
// UI state // UI state (fallback flags voor oudere logica)
isMobile: window.innerWidth <= 768, isMobileFallback: window.innerWidth <= 768,
showSidebar: window.innerWidth > 768, showSidebar: window.innerWidth > 768,
// Mobile tab state
activeTabId: 'chat',
// Advanced features // Advanced features
messageSearch: '', messageSearch: '',
filteredMessages: [], filteredMessages: [],
@@ -193,16 +271,57 @@ export default {
return this.supportedLanguages.filter(lang => return this.supportedLanguages.filter(lang =>
this.allowedLanguages.includes(lang.code) this.allowedLanguages.includes(lang.code)
); );
},
mobileTabs() {
return [
{
id: 'chat',
iconName: 'chat',
label: 'Chat'
},
{
id: 'history',
iconName: 'history',
label: 'Historiek'
},
{
id: 'setup',
iconName: 'settings',
label: 'Setup'
}
];
} }
}, },
mounted() { mounted() {
this.initializeChat(); this.initializeChat();
this.setupEventListeners(); this.setupEventListeners();
// Stel initiale actieve tab in (optioneel via config)
const defaultTab = (window.chatConfig && window.chatConfig.defaultTab) || 'chat';
if (this.mobileTabs.find(t => t.id === defaultTab)) {
this.activeTabId = defaultTab;
}
// Luister naar globale events om tab te wisselen
this.globalTabListener = (event) => {
const tabId = event?.detail?.tabId;
if (!tabId) return;
if (this.mobileTabs.find(t => t.id === tabId)) {
this.activeTabId = tabId;
}
};
document.addEventListener('evie-chat-set-tab', this.globalTabListener);
}, },
beforeUnmount() { beforeUnmount() {
this.cleanup(); this.cleanup();
if (this.globalTabListener) {
document.removeEventListener('evie-chat-set-tab', this.globalTabListener);
}
}, },
methods: { methods: {
@@ -453,11 +572,13 @@ export default {
} }
}); });
// Window resize listener // Window resize listener voor fallback flags
window.addEventListener('resize', () => { this.handleResize = () => {
this.isMobile = window.innerWidth <= 768; this.isMobileFallback = window.innerWidth <= 768;
this.showSidebar = window.innerWidth > 768; this.showSidebar = window.innerWidth > 768;
}); };
window.addEventListener('resize', this.handleResize);
}, },
cleanup() { cleanup() {
@@ -516,6 +637,12 @@ export default {
this.isLoading = false; this.isLoading = false;
}, },
handleLanguageChangedFromSetup(newLanguage) {
// Update lokale taalstate; verdere effecten worden opgepikt door
// bestaande global listener en LanguageProvider / chatConfig.
this.currentLanguage = newLanguage;
},
// UI helpers // UI helpers
scrollToBottom() { scrollToBottom() {
if (this.$refs.messageHistory) { if (this.$refs.messageHistory) {
@@ -560,7 +687,7 @@ export default {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
/* height: 100%; avoided to let flex sizing control height */ height: 100%;
width: 100%; width: 100%;
min-height: 0; min-height: 0;
max-width: 1000px; max-width: 1000px;
@@ -571,6 +698,84 @@ export default {
overflow: hidden; overflow: hidden;
} }
.chat-mobile-layout {
display: flex;
flex-direction: column;
/* Binnen SafeViewport vertrouwen we op de hoogte van de bovenliggende
containers (html/body/app-container). Deze layout moet zich daaraan
aanpassen en niet opnieuw zelf een safe-vh berekening doen, om
dubbele afrondingsfouten en extra scrollruimte te vermijden. */
flex: 1 1 auto;
height: 100%;
min-height: 0;
}
.chat-mobile-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Mobiele header met logo links en tabs rechts */
.chat-mobile-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
padding: 6px 8px;
background: var(--tab-background, #0a0a0a);
}
.chat-mobile-header-left {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 56px; /* Zorg dat logo altijd minstens vierkant kan tonen */
margin-right: 4px;
}
.chat-mobile-header-right {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
}
/* Specifieke layout voor de chat-tab: inputblok onderaan */
.chat-mobile-content.tab-chat {
display: flex;
flex-direction: column;
}
.tab-chat-input {
margin-top: auto;
flex-shrink: 0;
/* Iets meer visuele marge boven de onderrand, maar nog steeds rekening houden met safe inset */
padding-bottom: calc(6px + var(--safe-bottom-inset, 0px));
}
/* Wanneer het toetsenbord open is (gedetecteerd door useChatViewport via
de body-klasse chat-keyboard-open), willen we geen extra grote
safe-bottom-inset meer onder de input. Dan sluiten we zo veel mogelijk
aan tegen de visuele viewport en houden we alleen een kleine vaste marge. */
body.chat-keyboard-open .tab-chat-input {
padding-bottom: 6px;
}
@media (max-width: 768px) {
.chat-app-container {
/* Minder padding op mobiel zodat de tabbar binnen de viewport valt */
padding: 8px 8px 0 8px;
}
}
.chat-messages-area { .chat-messages-area {
flex: 1; flex: 1;
min-height: 0; /* ensure child can scroll */ min-height: 0; /* ensure child can scroll */

View File

@@ -11,8 +11,8 @@
:is-latest-ai-message="true" :is-latest-ai-message="true"
:is-in-input-area="true" :is-in-input-area="true"
@image-loaded="handleImageLoaded" @image-loaded="handleImageLoaded"
@specialist-complete="$emit('specialist-complete', $event)" @specialist-complete="handleSpecialistCompleteFromActiveMessage"
@specialist-error="$emit('specialist-error', $event)" @specialist-error="handleSpecialistErrorFromActiveMessage"
></chat-message> ></chat-message>
</div> </div>
@@ -183,22 +183,22 @@ export default {
watch: { watch: {
formData: { formData: {
handler(newFormData, oldFormData) { handler(newFormData, oldFormData) {
console.log('ChatInput formData changed:', newFormData); console.log('🧐 [ChatInput] formData changed:', newFormData);
if (!newFormData) { if (!newFormData) {
console.log('FormData is null of undefined'); console.log('🧐 [ChatInput] formData is null of undefined');
this.formValues = {}; this.formValues = {};
return; return;
} }
// Controleer of velden aanwezig zijn // Controleer of velden aanwezig zijn
if (!newFormData.fields) { if (!newFormData.fields) {
console.error('FormData bevat geen velden!', newFormData); console.error('🧐 [ChatInput] formData bevat geen velden!', newFormData);
return; return;
} }
console.log('Velden in formData:', newFormData.fields); console.log('🧐 [ChatInput] velden in formData:', newFormData.fields);
console.log('Aantal velden:', Array.isArray(newFormData.fields) console.log('🧐 [ChatInput] aantal velden:', Array.isArray(newFormData.fields)
? newFormData.fields.length ? newFormData.fields.length
: Object.keys(newFormData.fields).length); : Object.keys(newFormData.fields).length);
@@ -206,7 +206,7 @@ export default {
this.initFormValues(); this.initFormValues();
// Log de geïnitialiseerde waarden // Log de geïnitialiseerde waarden
console.log('Formulierwaarden geïnitialiseerd:', this.formValues); console.log('🧐 [ChatInput] formulierwaarden geïnitialiseerd:', this.formValues);
}, },
immediate: true, immediate: true,
deep: true deep: true
@@ -251,6 +251,15 @@ export default {
window.removeEventListener('resize', this.autoResize); window.removeEventListener('resize', this.autoResize);
}, },
methods: { methods: {
handleSpecialistCompleteFromActiveMessage(eventData) {
console.log('🧐 [ChatInput] specialist-complete ontvangen van actieve ChatMessage, bubbelt naar parent:', eventData);
this.$emit('specialist-complete', eventData);
},
handleSpecialistErrorFromActiveMessage(eventData) {
console.log('🧐 [ChatInput] specialist-error ontvangen van actieve ChatMessage, bubbelt naar parent:', eventData);
this.$emit('specialist-error', eventData);
},
handleLanguageChange(event) { handleLanguageChange(event) {
if (event.detail && event.detail.language) { if (event.detail && event.detail.language) {
this.translatePlaceholder(event.detail.language); this.translatePlaceholder(event.detail.language);
@@ -452,6 +461,7 @@ export default {
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
margin-bottom: 20px;
} }
/* Input veld en knoppen */ /* Input veld en knoppen */

View File

@@ -784,21 +784,32 @@ export default {
} }
} }
/* Bubble height constraints and inner scroll containment (apply on all viewports) */ /* Bubble height constraints en inner scroll containment.
Fallback gebruikt klassieke vh-units; de @supports-blok hieronder
schakelt over naar SafeViewport via var(--safe-vh) wanneer mogelijk. */
.message .message-content { .message .message-content {
max-height: 33vh; /* fallback */ max-height: 33vh; /* fallback */
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; /* prevent scroll chaining to parent */ overscroll-behavior: contain; /* prevent scroll chaining to parent */
-webkit-overflow-scrolling: touch; /* iOS smooth inertia */ -webkit-overflow-scrolling: touch; /* iOS smooth inertia */
} }
/* Active contexts (input area or sticky area): allow up to half viewport */
/* Active contexts (input area of sticky area): mogen meer hoogte innemen */
.message.input-area .message-content, .message.input-area .message-content,
.message.sticky-area .message-content { .message.sticky-area .message-content {
max-height: 50vh; /* fallback */ max-height: 50vh; /* fallback */
} }
@supports (max-height: 1svh) { @supports (max-height: 1svh) {
.message .message-content { max-height: 33svh; } .message .message-content {
.message.input-area .message-content, /* Gebruik veilige viewporthoogte die door useChatViewport gezet wordt */
.message.sticky-area .message-content { max-height: 50svh; } max-height: calc(var(--safe-vh, 1vh) * 33);
}
.message.input-area .message-content,
.message.sticky-area .message-content {
/* In de input-/sticky-area mag de bubbel ruimer zijn */
max-height: calc(var(--safe-vh, 1vh) * 60);
}
} }
</style> </style>

View File

@@ -0,0 +1,50 @@
<template>
<!--
ChatRoot
--------
Root-component voor de chatclient. Deze component zorgt ervoor dat er
altijd precies één SafeViewport-wrapper is rond de gekozen Shell
(DesktopChatShell, MobileChatShell, ...).
De daadwerkelijke shell-component en zijn props worden vanuit
chat-client.js doorgegeven via de props shellComponent en shellProps.
-->
<SafeViewport>
<component :is="shellComponent" v-bind="shellProps" />
</SafeViewport>
</template>
<script setup>
import { computed } from 'vue';
import SafeViewport from './SafeViewport.vue';
import DesktopChatShell from './DesktopChatShell.vue';
import MobileChatShell from './MobileChatShell.vue';
const props = defineProps({
shellComponent: {
type: [Object, Function, String],
default: null
},
shellProps: {
type: Object,
default: () => ({})
}
});
// Fallbacks voor het geval chat-client.js geen shellComponent meegeeft.
const resolvedShellComponent = computed(() => {
if (props.shellComponent) {
return props.shellComponent;
}
// Eenvoudige fallback: gebruik DesktopShell op brede schermen,
// MobileShell op smalle schermen.
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
return MobileChatShell;
}
return DesktopChatShell;
});
const shellComponent = resolvedShellComponent;
const shellProps = props.shellProps;
</script>

View File

@@ -37,7 +37,7 @@ export default {
const source = (this.template || ''); const source = (this.template || '');
// 2) parse only allowed tags <dpa>...</dpa> and <terms>...</terms> // 2) parse only allowed tags <dpa>...</dpa> and <terms>...</terms>
const pattern = /<(privacy|terms)>([\s\S]*?)<\/\1>/gi; const pattern = /<(dpa|terms)>([\s\S]*?)<\/\1>/gi;
const out = []; const out = [];
let lastIndex = 0; let lastIndex = 0;
let match; let match;
@@ -62,8 +62,17 @@ export default {
}, },
methods: { methods: {
emitClick(kind) { emitClick(kind) {
if (kind === 'dpa') this.$emit('open-dpa'); // Debug logging to trace click events for consent links
if (kind === 'terms') this.$emit('open-terms'); console.log('[ConsentRichText] emitClick called with kind =', kind);
if (kind === 'dpa') {
console.log('[ConsentRichText] Emitting open-dpa event');
this.$emit('open-dpa');
}
if (kind === 'terms') {
console.log('[ConsentRichText] Emitting open-terms event');
this.$emit('open-terms');
}
} }
} }
}; };

View File

@@ -0,0 +1,501 @@
<template>
<!--
CoreChatApp
------------
Deze component bevat alle kernlogica en state van de chat, maar legt zelf
geen definitieve layout op. In plaats daarvan levert hij named slots aan
waar Shells (Desktop/Mobile/Plugin) hun eigen opbouw kunnen definieren.
Beschikbare slots:
- history: weergave van de berichtenhistoriek
- active-message-input: actieve AI-boodschap + invoergebied
- setup: configuratiepaneel (uitleg, taal, ...)
-->
<div
class="core-chat-app"
@specialist-complete="handleSpecialistComplete"
@specialist-error="handleSpecialistError"
>
<!-- History-paneel (optioneel) -->
<slot
name="history"
:messages="displayMessages"
:is-typing="isTyping"
:is-submitting-form="isSubmittingForm"
:on-specialist-complete="handleSpecialistComplete"
:on-specialist-error="handleSpecialistError"
/>
<!-- Actieve boodschap + invoer (optioneel) -->
<slot
name="active-message-input"
:active-ai-message="activeAiMessage"
:form-data="currentInputFormData"
:current-message="currentMessage"
:is-loading="isLoading"
:on-send-message="sendMessage"
:on-update-message="updateCurrentMessage"
:on-submit-form="submitFormFromInput"
:on-upload-file="handleFileUpload"
:on-record-voice="handleVoiceRecord"
:on-specialist-complete="handleSpecialistComplete"
:on-specialist-error="handleSpecialistError"
:form-values="formValues"
/>
<!-- Setup-paneel (optioneel) -->
<slot
name="setup"
:tenant-name="tenantName"
:tenant-subtitle="tenantSubtitle"
:explanation-text="originalExplanation"
:current-language="currentLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
:api-prefix="apiPrefix"
:on-language-changed="handleLanguageChangedFromSetup"
/>
<!-- Content modal op Core-niveau zodat alle shells deze kunnen gebruiken -->
<ContentModal
:show="contentModal.modalState.show"
:title="contentModal.modalState.title"
:content="contentModal.modalState.content"
:version="contentModal.modalState.version"
:loading="contentModal.modalState.loading"
:error="contentModal.modalState.error"
:error-message="contentModal.modalState.errorMessage"
@close="contentModal.hideModal"
@retry="contentModal.retryLoad"
/>
</div>
</template>
<script>
import MessageHistory from './MessageHistory.vue';
import ChatInput from './ChatInput.vue';
import ContentModal from './ContentModal.vue';
import TypingIndicator from './TypingIndicator.vue';
import DynamicForm from './DynamicForm.vue';
import ChatMessage from './ChatMessage.vue';
import ProgressTracker from './ProgressTracker.vue';
import { provide } from 'vue';
import { provideContentModal } from '../js/composables/useContentModal.js';
import { createLanguageProvider, LANGUAGE_PROVIDER_KEY } from '../js/services/LanguageProvider.js';
export default {
name: 'CoreChatApp',
components: {
MessageHistory,
ChatInput,
ContentModal,
TypingIndicator,
DynamicForm,
ChatMessage,
ProgressTracker
},
props: {
apiPrefix: {
type: String,
default: ''
},
conversationId: {
type: String,
default: 'default'
},
userId: {
type: [String, Number, null],
default: null
},
userName: {
type: String,
default: ''
},
initialLanguage: {
type: String,
default: 'en'
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
// De volledige chatConfig kan optioneel meegegeven worden voor
// backwards compatibility; CoreChatApp leest daaruit wat hij nodig heeft.
chatConfig: {
type: Object,
default: () => (window.chatConfig || {})
}
},
setup(props) {
const contentModal = provideContentModal();
// LanguageProvider opnieuw centraliseren rond CoreChatApp zodat alle
// child-componenten (ChatInput, SideBarExplanation, ...) veilig
// useLanguageProvider kunnen gebruiken, ongeacht de gebruikte Shell.
const initialLanguage = props.initialLanguage || 'nl';
const apiPrefix = props.apiPrefix || '';
const languageProvider = createLanguageProvider(initialLanguage, apiPrefix);
provide(LANGUAGE_PROVIDER_KEY, languageProvider);
return {
contentModal
};
},
data() {
const chatConfig = this.chatConfig || window.chatConfig || {};
const settings = chatConfig.settings || {};
const initialLanguage = chatConfig.language || this.initialLanguage || 'en';
const originalExplanation = chatConfig.explanation || '';
const tenantMake = chatConfig.tenantMake || {};
return {
// Tenant info
tenantName: tenantMake.name || 'EveAI',
tenantSubtitle: tenantMake.subtitle || '',
// Taal gerelateerde data
currentLanguage: initialLanguage,
supportedLanguageDetailsInternal: chatConfig.supportedLanguageDetails || this.supportedLanguageDetails,
allowedLanguagesInternal: chatConfig.allowedLanguages || this.allowedLanguages,
originalExplanation,
// Chat-specific data
currentMessage: '',
allMessages: [],
isTyping: false,
isLoading: false,
isSubmittingForm: false,
messageIdCounter: 1,
formValues: {},
currentInputFormData: null,
// Configuration from server
settings: {
maxMessageLength: settings.maxMessageLength || 2000,
allowFileUpload: settings.allowFileUpload === true,
allowVoiceMessage: settings.allowVoiceMessage === true,
autoScroll: settings.autoScroll === true
},
// Search state (voor history-tab)
messageSearch: '',
filteredMessages: [],
isSearching: false
};
},
computed: {
displayMessages() {
return this.isSearching ? this.filteredMessages : this.allMessages;
},
activeAiMessage() {
return this.allMessages.find(msg => msg.isTemporarilyAtBottom);
},
hasMessages() {
return this.allMessages.length > 0;
}
},
mounted() {
this.initializeChat();
this.setupEventListeners();
},
beforeUnmount() {
this.cleanup();
},
methods: {
// Initialisatie
initializeChat() {
this.loadHistoricalMessages();
if (this.allMessages.length === 0) {
this.addWelcomeMessage();
}
},
loadHistoricalMessages() {
const chatConfig = this.chatConfig || window.chatConfig || {};
const historicalMessages = chatConfig.messages || [];
if (historicalMessages.length > 0) {
this.allMessages = historicalMessages
.filter(msg => msg !== null && msg !== undefined)
.map(msg => ({
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'
}));
}
},
async addWelcomeMessage() {
this.isTyping = true;
this.isLoading = true;
try {
const response = await fetch(`${this.apiPrefix}/api/send_message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Initialize',
language: this.currentLanguage,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.task_id) {
const placeholderMessage = {
id: this.messageIdCounter++,
content: 'Bezig met laden...',
sender: 'ai',
type: 'text',
timestamp: new Date().toISOString(),
taskId: data.task_id,
status: 'processing',
isTemporarilyAtBottom: true
};
this.allMessages.push(placeholderMessage);
}
} catch (error) {
console.error('Error sending initialize message:', error);
this.addMessage({
content: 'Er is een fout opgetreden bij het initialiseren van de chat.',
sender: 'ai',
type: 'error'
});
} finally {
this.isTyping = false;
this.isLoading = false;
}
},
// Messages
addMessage(messageData) {
const message = {
id: this.messageIdCounter++,
content: messageData.content || '',
sender: messageData.sender || 'user',
type: messageData.type || 'text',
timestamp: messageData.timestamp || new Date().toISOString(),
formData: messageData.formData || null,
formValues: messageData.formValues || null,
status: messageData.status || 'delivered'
};
this.allMessages.push(message);
return message;
},
async sendMessage() {
if (!this.currentMessage.trim() && !this.currentInputFormData) {
return;
}
// Eerdere placeholder AI-berichten terugplaatsen in de flow
this.repositionLatestAiMessage();
const userMessage = this.addMessage({
content: this.currentMessage,
sender: 'user',
formData: this.currentInputFormData,
formValues: this.formValues
});
const messageToSend = this.currentMessage;
const formValuesToSend = { ...this.formValues };
this.currentMessage = '';
this.formValues = {};
this.currentInputFormData = null;
this.isTyping = true;
this.isLoading = true;
try {
const response = await fetch(`${this.apiPrefix}/api/send_message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: messageToSend,
form_values: Object.keys(formValuesToSend).length > 0 ? formValuesToSend : undefined,
language: this.currentLanguage,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.task_id) {
const placeholderMessage = {
id: this.messageIdCounter++,
sender: 'ai',
type: 'text',
timestamp: new Date().toISOString(),
taskId: data.task_id,
status: 'processing',
isTemporarilyAtBottom: true
};
this.allMessages.push(placeholderMessage);
}
} catch (error) {
console.error('Error sending message:', error);
this.addMessage({
content: 'Er is een fout opgetreden bij het verzenden van het bericht.',
sender: 'ai',
type: 'error'
});
} finally {
this.isTyping = false;
this.isLoading = false;
}
},
repositionLatestAiMessage() {
const aiMessage = this.allMessages.find(msg =>
msg.sender === 'ai' && msg.taskId && msg.isTemporarilyAtBottom
);
if (aiMessage) {
aiMessage.isTemporarilyAtBottom = false;
}
},
updateCurrentMessage(message) {
this.currentMessage = message;
},
submitFormFromInput(formValues) {
this.formValues = formValues;
this.sendMessage();
},
handleFileUpload(file) {
console.log('File upload:', file);
},
handleVoiceRecord(audioData) {
console.log('Voice record:', audioData);
},
// Specialist events
handleSpecialistComplete(eventData) {
console.log('🧐 [CoreChatApp] specialist-complete ontvangen:', eventData);
const messageIndex = this.allMessages.findIndex(msg => {
const matches = msg.taskId === eventData.taskId;
console.log('🧐 [CoreChatApp] zoeken naar bericht voor specialist-complete:', {
messageId: msg.id,
messageTaskId: msg.taskId,
eventTaskId: eventData.taskId,
matches
});
return matches;
});
if (messageIndex !== -1) {
console.log('🧐 [CoreChatApp] gevonden bericht voor specialist-complete:', this.allMessages[messageIndex]);
this.allMessages[messageIndex].content = eventData.answer;
this.allMessages[messageIndex].status = 'completed';
if (eventData.form_request) {
console.log('🧐 [CoreChatApp] form_request ontvangen, wordt ingesteld als currentInputFormData:', eventData.form_request);
this.currentInputFormData = eventData.form_request;
} else {
console.log('🧐 [CoreChatApp] geen form_request ontvangen, formulier wordt gewist');
this.currentInputFormData = null;
if (this.formValues) {
this.formValues = {};
}
}
} else {
console.warn('🧐 [CoreChatApp] geen bericht gevonden voor specialist-complete met taskId:', eventData.taskId);
}
this.isTyping = false;
this.isLoading = false;
console.log('🧐 [CoreChatApp] currentInputFormData na complete:', this.currentInputFormData);
},
handleSpecialistError(eventData) {
const messageIndex = this.allMessages.findIndex(msg => msg.taskId === eventData.taskId);
if (messageIndex !== -1) {
this.allMessages[messageIndex].content = eventData.message || 'Er is een fout opgetreden.';
this.allMessages[messageIndex].type = 'error';
this.allMessages[messageIndex].status = 'error';
}
this.isTyping = false;
this.isLoading = false;
},
// Event listeners voor taal & resize
setupEventListeners() {
this._languageChangeHandler = (event) => {
if (event.detail && event.detail.language) {
this.currentLanguage = event.detail.language;
}
};
document.addEventListener('language-changed', this._languageChangeHandler);
this._resizeHandler = () => {
// Placeholder voor toekomstige shell-specifieke gedrag; Core hoeft
// hier in principe niets layout-gerelateerd te doen.
};
window.addEventListener('resize', this._resizeHandler);
},
cleanup() {
if (this._languageChangeHandler) {
document.removeEventListener('language-changed', this._languageChangeHandler);
}
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler);
}
},
handleLanguageChangedFromSetup(newLanguage) {
this.currentLanguage = newLanguage;
}
}
};
</script>
<style scoped>
.core-chat-app {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
</style>

View File

@@ -0,0 +1,172 @@
<template>
<!--
DesktopChatShell
-----------------
Shell voor brede schermen (desktop/tablet-landscape) die de CoreChatApp
in een klassieke layout toont: history boven, invoer onder.
Let op: de linker Sidebar wordt nog steeds apart gemount op
#sidebar-container door chat-client.js. Deze shell is uitsluitend
verantwoordelijk voor de rechter chatkolom.
-->
<div class="desktop-chat-shell">
<CoreChatApp
:api-prefix="apiPrefix"
:conversation-id="conversationId"
:user-id="userId"
:user-name="userName"
:initial-language="initialLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
>
<!-- History-paneel -->
<template #history="historyProps">
<MessageHistory
:messages="historyProps.messages"
:is-typing="historyProps.isTyping"
:is-submitting-form="historyProps.isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="settings.autoScroll"
@specialist-error="event => {
console.log('🧐 [DesktopChatShell] specialist-error vanuit history ontvangen:', event);
(historyProps.onSpecialistError || handleSpecialistError)(event);
}"
@specialist-complete="event => {
console.log('🧐 [DesktopChatShell] specialist-complete vanuit history ontvangen:', event);
(historyProps.onSpecialistComplete || handleSpecialistComplete)(event);
}"
ref="messageHistory"
class="chat-messages-area"
/>
</template>
<!-- Actieve boodschap + invoer -->
<template #active-message-input="inputProps">
<ChatInput
:current-message="inputProps.currentMessage"
:is-loading="inputProps.isLoading"
:max-length="settings.maxMessageLength"
:allow-file-upload="settings.allowFileUpload"
:allow-voice-message="settings.allowVoiceMessage"
:form-data="inputProps.formData"
:active-ai-message="inputProps.activeAiMessage"
:api-prefix="apiPrefix"
@send-message="inputProps.onSendMessage"
@update-message="inputProps.onUpdateMessage"
@upload-file="inputProps.onUploadFile"
@record-voice="inputProps.onRecordVoice"
@submit-form="inputProps.onSubmitForm"
@specialist-error="event => {
console.log('🧐 [DesktopChatShell] specialist-error vanuit ChatInput ontvangen:', event);
(inputProps.onSpecialistError || handleSpecialistError)(event);
}"
@specialist-complete="event => {
console.log('🧐 [DesktopChatShell] specialist-complete vanuit ChatInput ontvangen:', event);
(inputProps.onSpecialistComplete || handleSpecialistComplete)(event);
}"
ref="chatInput"
class="chat-input-area"
/>
</template>
</CoreChatApp>
</div>
</template>
<script>
import CoreChatApp from './CoreChatApp.vue';
import MessageHistory from './MessageHistory.vue';
import ChatInput from './ChatInput.vue';
export default {
name: 'DesktopChatShell',
components: {
CoreChatApp,
MessageHistory,
ChatInput
},
props: {
apiPrefix: {
type: String,
default: ''
},
conversationId: {
type: String,
default: 'default'
},
userId: {
type: [String, Number, null],
default: null
},
userName: {
type: String,
default: ''
},
initialLanguage: {
type: String,
default: 'en'
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
settings: {
type: Object,
default: () => ({
maxMessageLength: 2000,
allowFileUpload: true,
allowVoiceMessage: false,
autoScroll: true
})
}
},
methods: {
handleSpecialistComplete(eventData) {
// Wordt binnen CoreChatApp afgehandeld; deze shell kan in de toekomst
// aanvullende UI-reacties toevoegen.
this.$emit('specialist-complete', eventData);
},
handleSpecialistError(eventData) {
this.$emit('specialist-error', eventData);
}
}
};
</script>
<style scoped>
.desktop-chat-shell {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.chat-messages-area {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
margin-bottom: 20px;
border-radius: 15px;
background: var(--history-background);
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
width: 100%;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
align-self: center;
}
.chat-input-area {
flex-shrink: 0;
flex: 0 0 auto;
}
</style>

View File

@@ -19,7 +19,7 @@
:field-id="field.id || field.name" :field-id="field.id || field.name"
:model-value="localFormValues[field.id || field.name]" :model-value="localFormValues[field.id || field.name]"
@update:model-value="updateFieldValue(field.id || field.name, $event)" @update:model-value="updateFieldValue(field.id || field.name, $event)"
@open-privacy-modal="openPrivacyModal" @open-dpa-modal="openDpaModal"
@open-terms-modal="openTermsModal" @open-terms-modal="openTermsModal"
@keydown-enter="handleEnterKey" @keydown-enter="handleEnterKey"
/> />
@@ -32,7 +32,7 @@
:field-id="fieldId" :field-id="fieldId"
:model-value="localFormValues[fieldId]" :model-value="localFormValues[fieldId]"
@update:model-value="updateFieldValue(fieldId, $event)" @update:model-value="updateFieldValue(fieldId, $event)"
@open-privacy-modal="openPrivacyModal" @open-dpa-modal="openDpaModal"
@open-terms-modal="openTermsModal" @open-terms-modal="openTermsModal"
@keydown-enter="handleEnterKey" @keydown-enter="handleEnterKey"
/> />
@@ -199,12 +199,19 @@ export default {
// Basic validation - check required fields // Basic validation - check required fields
const missingFields = []; const missingFields = [];
// Extra consent-validatie: detecteer consent velden en controleer of alle consents geaccepteerd zijn.
// We maken dit toekomstvast voor meerdere consent-velden.
let hasConsentField = false;
let allConsentsAccepted = true;
if (Array.isArray(this.formData.fields)) { if (Array.isArray(this.formData.fields)) {
// Valideer array-gebaseerde velden // Valideer array-gebaseerde velden
this.formData.fields.forEach(field => { this.formData.fields.forEach(field => {
const fieldId = field.id || field.name; const fieldId = field.id || field.name;
const value = this.localFormValues[fieldId];
// Basis required-validatie
if (field.required) { if (field.required) {
const value = this.localFormValues[fieldId];
// Voor boolean velden is false een geldige waarde // Voor boolean velden is false een geldige waarde
if (field.type === 'boolean') { if (field.type === 'boolean') {
// Boolean velden zijn altijd geldig als ze een boolean waarde hebben // Boolean velden zijn altijd geldig als ze een boolean waarde hebben
@@ -220,12 +227,21 @@ export default {
} }
} }
} }
// Consent-detectie en -validatie (ongeacht required-vlag)
if (field.type === 'boolean' && field.meta && field.meta.kind === 'consent') {
hasConsentField = true;
if (value !== true) {
allConsentsAccepted = false;
}
}
}); });
} else { } else {
// Valideer object-gebaseerde velden // Valideer object-gebaseerde velden
Object.entries(this.formData.fields).forEach(([fieldId, field]) => { Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
const value = this.localFormValues[fieldId];
if (field.required) { if (field.required) {
const value = this.localFormValues[fieldId];
// Voor boolean velden is false een geldige waarde // Voor boolean velden is false een geldige waarde
if (field.type === 'boolean') { if (field.type === 'boolean') {
// Boolean velden zijn altijd geldig als ze een boolean waarde hebben // Boolean velden zijn altijd geldig als ze een boolean waarde hebben
@@ -241,10 +257,27 @@ export default {
} }
} }
} }
// Consent-detectie en -validatie (ongeacht required-vlag)
if (field.type === 'boolean' && field.meta && field.meta.kind === 'consent') {
hasConsentField = true;
if (value !== true) {
allConsentsAccepted = false;
}
}
}); });
} }
return missingFields.length === 0; const isBaseValid = missingFields.length === 0;
if (!hasConsentField) {
// Geen speciale consentvelden: behoud bestaand gedrag
return isBaseValid;
}
// Als er één of meer consentvelden zijn, zijn we alleen geldig als
// zowel de basisvalidatie als alle consents geaccepteerd zijn.
return isBaseValid && allConsentsAccepted;
}, },
// Title display mode configuration // Title display mode configuration
titleDisplayMode() { titleDisplayMode() {
@@ -479,11 +512,13 @@ export default {
}, },
// Modal handling methods // Modal handling methods
openPrivacyModal() { openDpaModal() {
console.log('[DynamicForm] openDpaModal called');
this.loadContent('dpa'); this.loadContent('dpa');
}, },
openTermsModal() { openTermsModal() {
console.log('[DynamicForm] openTermsModal called');
this.loadContent('terms'); this.loadContent('terms');
}, },
@@ -504,6 +539,8 @@ export default {
async loadContent(contentType) { async loadContent(contentType) {
const title = contentType === 'dpa' ? 'Data Privacy Agreement' : 'Terms & Conditions'; const title = contentType === 'dpa' ? 'Data Privacy Agreement' : 'Terms & Conditions';
const contentUrl = `${this.apiPrefix}/${contentType}`; const contentUrl = `${this.apiPrefix}/${contentType}`;
console.log('[DynamicForm] Loading content from:', contentUrl);
// Use the composable to show modal and load content // Use the composable to show modal and load content
await this.contentModal.showModal({ await this.contentModal.showModal({
@@ -514,11 +551,19 @@ export default {
// Handle Enter key press in form fields // Handle Enter key press in form fields
handleEnterKey(event) { handleEnterKey(event) {
console.log('DynamicForm: Enter event received, emitting form-enter-pressed'); console.log('DynamicForm: Enter event received');
// Prevent default form submission // Prevent default form submission
event.preventDefault(); event.preventDefault();
// Emit event to parent (ChatInput) to trigger send
this.$emit('form-enter-pressed'); // Alleen submit toelaten als het formulier (inclusief consentvelden)
// geldig is. Hiermee worden keyboard-shortcuts uitgeschakeld zolang
// consent niet is gegeven of andere vereiste velden ontbreken.
if (this.isFormValid && !this.isSubmittingForm && !this.isSubmitting) {
// Emit event to parent (ChatInput) to trigger send
this.$emit('form-enter-pressed');
} else {
console.log('DynamicForm: Enter ignored because form is not valid or is submitting');
}
}, },
// Focus management - auto-focus on first form field // Focus management - auto-focus on first form field

View File

@@ -111,7 +111,7 @@
:template="texts.consentRich" :template="texts.consentRich"
:aria-privacy="texts.ariaPrivacy || 'Open dpa statement in a dialog'" :aria-privacy="texts.ariaPrivacy || 'Open dpa statement in a dialog'"
:aria-terms="texts.ariaTerms || 'Open terms and conditions in a dialog'" :aria-terms="texts.ariaTerms || 'Open terms and conditions in a dialog'"
@open-privacy="openPrivacyModal" @open-dpa="openDpaModal"
@open-terms="openTermsModal" @open-terms="openTermsModal"
/> />
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span> <span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
@@ -234,7 +234,7 @@ export default {
texts() { texts() {
// Validate that consentRich exists and includes both required tags; otherwise fallback to English base // Validate that consentRich exists and includes both required tags; otherwise fallback to English base
const hasValidRich = (t) => t && typeof t.consentRich === 'string' const hasValidRich = (t) => t && typeof t.consentRich === 'string'
&& /<privacy>[\s\S]*?<\/privacy>/.test(t.consentRich) && /<dpa>[\s\S]*?<\/dpa>/.test(t.consentRich)
&& /<terms>[\s\S]*?<\/terms>/.test(t.consentRich); && /<terms>[\s\S]*?<\/terms>/.test(t.consentRich);
// 1) Prefer backend-provided rich string on the field's meta (already localized) // 1) Prefer backend-provided rich string on the field's meta (already localized)
@@ -331,10 +331,12 @@ export default {
this.value = file; this.value = file;
} }
}, },
openPrivacyModal() { openDpaModal() {
console.log('[FormField] openDpaModal emitting open-dpa-modal');
this.$emit('open-dpa-modal'); this.$emit('open-dpa-modal');
}, },
openTermsModal() { openTermsModal() {
console.log('[FormField] openTermsModal emitting open-terms-modal');
this.$emit('open-terms-modal'); this.$emit('open-terms-modal');
}, },

View File

@@ -20,8 +20,8 @@
:api-prefix="apiPrefix" :api-prefix="apiPrefix"
:is-latest-ai-message="isLatestAiMessage(message)" :is-latest-ai-message="isLatestAiMessage(message)"
@image-loaded="handleImageLoaded" @image-loaded="handleImageLoaded"
@specialist-complete="$emit('specialist-complete', $event)" @specialist-complete="handleSpecialistCompleteFromMessage"
@specialist-error="$emit('specialist-error', $event)" @specialist-error="handleSpecialistErrorFromMessage"
></chat-message> ></chat-message>
</template> </template>
</template> </template>
@@ -173,6 +173,15 @@ export default {
if (this._resizeObserver) this._resizeObserver.disconnect(); if (this._resizeObserver) this._resizeObserver.disconnect();
}, },
methods: { methods: {
handleSpecialistCompleteFromMessage(eventData) {
console.log('🧐 [MessageHistory] specialist-complete ontvangen van ChatMessage, bubbelt naar parent:', eventData);
this.$emit('specialist-complete', eventData);
},
handleSpecialistErrorFromMessage(eventData) {
console.log('🧐 [MessageHistory] specialist-error ontvangen van ChatMessage, bubbelt naar parent:', eventData);
this.$emit('specialist-error', eventData);
},
async handleLanguageChange(event) { async handleLanguageChange(event) {
// Controleer of dit het eerste bericht is in een gesprek met maar één bericht // Controleer of dit het eerste bericht is in een gesprek met maar één bericht
if (this.messages.length === 1 && this.messages[0].sender === 'ai') { if (this.messages.length === 1 && this.messages[0].sender === 'ai') {

View File

@@ -0,0 +1,278 @@
<template>
<!--
MobileChatShell
----------------
Shell voor mobiele schermen. Biedt een header met logo + tabbar en toont
per tab een deel van de CoreChatApp:
- chat: actieve AI-boodschap + invoer
- history: volledige berichtenhistoriek
- setup: taalkeuze + uitleg
-->
<div class="mobile-chat-shell">
<header class="chat-mobile-header">
<div class="chat-mobile-header-left">
<SideBarLogo :logo-url="tenantLogoUrl" :make-name="tenantName" />
</div>
<div class="chat-mobile-header-right">
<MobileTabBar
v-model="activeTabId"
:tabs="mobileTabs"
placement="header"
/>
</div>
</header>
<div class="chat-mobile-content" :class="`tab-${activeTabId}`">
<CoreChatApp
:api-prefix="apiPrefix"
:conversation-id="conversationId"
:user-id="userId"
:user-name="userName"
:initial-language="initialLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
>
<!-- Historiek-tab -->
<template #history="historyProps">
<MessageHistory
v-if="activeTabId === 'history'"
:messages="historyProps.messages"
:is-typing="historyProps.isTyping"
:is-submitting-form="historyProps.isSubmittingForm"
:api-prefix="apiPrefix"
:auto-scroll="true"
@specialist-error="event => {
console.log('🧐 [MobileChatShell] specialist-error vanuit history ontvangen:', event);
(historyProps.onSpecialistError || handleSpecialistError)(event);
}"
@specialist-complete="event => {
console.log('🧐 [MobileChatShell] specialist-complete vanuit history ontvangen:', event);
(historyProps.onSpecialistComplete || handleSpecialistComplete)(event);
}"
ref="messageHistory"
class="chat-messages-area"
/>
</template>
<!-- Chat-tab: actieve boodschap + input -->
<template #active-message-input="inputProps">
<ChatInput
v-if="activeTabId === 'chat'"
:current-message="inputProps.currentMessage"
:is-loading="inputProps.isLoading"
:max-length="settings.maxMessageLength"
:allow-file-upload="settings.allowFileUpload"
:allow-voice-message="settings.allowVoiceMessage"
:form-data="inputProps.formData"
:active-ai-message="inputProps.activeAiMessage"
:api-prefix="apiPrefix"
@send-message="inputProps.onSendMessage"
@update-message="inputProps.onUpdateMessage"
@upload-file="inputProps.onUploadFile"
@record-voice="inputProps.onRecordVoice"
@submit-form="inputProps.onSubmitForm"
@specialist-error="event => {
console.log('🧐 [MobileChatShell] specialist-error vanuit ChatInput ontvangen:', event);
(inputProps.onSpecialistError || handleSpecialistError)(event);
}"
@specialist-complete="event => {
console.log('🧐 [MobileChatShell] specialist-complete vanuit ChatInput ontvangen:', event);
(inputProps.onSpecialistComplete || handleSpecialistComplete)(event);
}"
ref="chatInput"
class="chat-input-area tab-chat-input"
/>
</template>
<!-- Setup-tab -->
<template #setup="setupProps">
<SideBarMobileSetup
v-if="activeTabId === 'setup'"
:tenant-make="{ name: tenantName, subtitle: tenantSubtitle }"
:explanation-text="setupProps.explanationText || explanationText"
:initial-language="initialLanguage"
:current-language="setupProps.currentLanguage || currentLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
:api-prefix="apiPrefix"
@language-changed="handleLanguageChangedFromSetup"
/>
</template>
</CoreChatApp>
</div>
</div>
</template>
<script>
import CoreChatApp from './CoreChatApp.vue';
import MessageHistory from './MessageHistory.vue';
import ChatInput from './ChatInput.vue';
import SideBarLogo from './SideBarLogo.vue';
import MobileTabBar from './MobileTabBar.vue';
import SideBarMobileSetup from './SideBarMobileSetup.vue';
export default {
name: 'MobileChatShell',
components: {
CoreChatApp,
MessageHistory,
ChatInput,
SideBarLogo,
MobileTabBar,
SideBarMobileSetup
},
props: {
apiPrefix: {
type: String,
default: ''
},
conversationId: {
type: String,
default: 'default'
},
userId: {
type: [String, Number, null],
default: null
},
userName: {
type: String,
default: ''
},
initialLanguage: {
type: String,
default: 'en'
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
tenantName: {
type: String,
default: 'EveAI'
},
tenantSubtitle: {
type: String,
default: ''
},
tenantLogoUrl: {
type: String,
default: ''
},
explanationText: {
type: String,
default: ''
},
settings: {
type: Object,
default: () => ({
maxMessageLength: 2000,
allowFileUpload: true,
allowVoiceMessage: false
})
}
},
data() {
return {
activeTabId: 'chat',
currentLanguage: this.initialLanguage || 'en'
};
},
computed: {
mobileTabs() {
return [
{ id: 'chat', iconName: 'chat', label: 'Chat' },
{ id: 'history', iconName: 'history', label: 'Historiek' },
{ id: 'setup', iconName: 'settings', label: 'Setup' }
];
}
},
mounted() {
// Initiale tab kan later uit config komen (defaultTab), voorlopig chat.
},
methods: {
handleSpecialistComplete(eventData) {
this.$emit('specialist-complete', eventData);
},
handleSpecialistError(eventData) {
this.$emit('specialist-error', eventData);
},
handleLanguageChangedFromSetup(newLanguage) {
this.currentLanguage = newLanguage;
this.$emit('language-changed', newLanguage);
}
}
};
</script>
<style scoped>
.mobile-chat-shell {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.chat-mobile-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-direction: row;
align-items: stretch;
justify-content: space-between;
padding: 6px 8px;
background: var(--tab-background, #0a0a0a);
}
.chat-mobile-header-left {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-start;
min-width: 56px;
margin-right: 4px;
}
.chat-mobile-header-right {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
}
.chat-mobile-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.chat-mobile-content.tab-chat {
display: flex;
flex-direction: column;
}
.tab-chat-input {
margin-top: auto;
flex-shrink: 0;
padding-bottom: calc(6px + var(--safe-bottom-inset, 0px));
}
body.chat-keyboard-open .tab-chat-input {
padding-bottom: 6px;
}
</style>

View File

@@ -5,22 +5,11 @@
:make-name="tenantMake.name" :make-name="tenantMake.name"
class="mobile-logo" class="mobile-logo"
/> />
<LanguageSelector
:initial-language="initialLanguage"
:current-language="currentLanguage"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
@language-changed="handleLanguageChange"
class="mobile-language-selector"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue';
import SideBarLogo from './SideBarLogo.vue'; import SideBarLogo from './SideBarLogo.vue';
import LanguageSelector from './LanguageSelector.vue';
const props = defineProps({ const props = defineProps({
tenantMake: { tenantMake: {
@@ -49,45 +38,21 @@ const props = defineProps({
} }
}); });
const emit = defineEmits(['language-changed']); // Mobile header toont enkel het logo; taalkeuze gebeurt via de Setup-tab.
const currentLanguage = ref(props.initialLanguage);
const handleLanguageChange = (newLanguage) => {
currentLanguage.value = newLanguage;
// Emit to parent
emit('language-changed', newLanguage);
// Global event for backward compatibility
const globalEvent = new CustomEvent('language-changed', {
detail: { language: newLanguage }
});
document.dispatchEvent(globalEvent);
// Update chatConfig
if (window.chatConfig) {
window.chatConfig.language = newLanguage;
}
// Save preference
localStorage.setItem('preferredLanguage', newLanguage);
};
</script> </script>
<style scoped> <style scoped>
.mobile-header { .mobile-header {
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
align-items: center; align-items: center;
flex-wrap: wrap; /* allow wrapping to next line on narrow screens */
padding: 10px 15px; padding: 10px 15px;
background: var(--sidebar-background); background: var(--sidebar-background);
color: var(--sidebar-color); color: var(--sidebar-color);
border-bottom: 1px solid rgba(0,0,0,0.1); border-bottom: 1px solid rgba(0,0,0,0.1);
min-height: 60px; min-height: 60px;
max-width: 100%; /* never exceed viewport width */ max-width: 100%;
overflow: hidden; /* clip any accidental overflow */ overflow: hidden;
} }
/* Mobile logo container - meer specifieke styling */ /* Mobile logo container - meer specifieke styling */
@@ -129,34 +94,6 @@ const handleLanguageChange = (newLanguage) => {
justify-content: center !important; justify-content: center !important;
} }
/* Mobile language selector styling */
.mobile-language-selector {
flex-shrink: 1;
min-width: 0; /* allow selector area to shrink */
}
.mobile-language-selector :deep(.language-selector) {
margin: 0;
}
.mobile-language-selector :deep(label) {
display: none; /* Hide label in mobile header */
}
.mobile-language-selector :deep(.language-select) {
padding: 6px 10px;
font-size: 0.85rem;
min-width: 0; /* allow the select to shrink */
max-width: 100%; /* never exceed container width */
margin: 0;
}
/* Extra constraints on ultra-small screens */
@media (max-width: 360px) {
.mobile-language-selector :deep(.language-select) {
max-width: 60vw; /* avoid pushing beyond viewport */
}
}
/* Media queries voor responsiviteit */ /* Media queries voor responsiviteit */
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@@ -0,0 +1,113 @@
<template>
<nav
class="mobile-tab-bar"
:class="{ 'mobile-tab-bar--header': placement === 'header' }"
>
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="tab-button"
:class="{ 'is-active': tab.id === modelValue }"
@click="$emit('update:modelValue', tab.id)"
>
<span class="material-symbols-outlined" :class="`icon-${tab.iconName}`">
{{ tab.iconName }}
</span>
<span v-if="showLabels" class="tab-label">{{ tab.label }}</span>
</button>
</nav>
</template>
<script setup>
import { computed, watch } from 'vue';
import { useIconManager } from '../js/composables/useIconManager.js';
const props = defineProps({
tabs: {
type: Array,
default: () => []
},
modelValue: {
type: String,
default: ''
},
placement: {
type: String,
default: 'bottom' // 'bottom' | 'header'
},
showLabels: {
type: Boolean,
default: false
}
});
defineEmits(['update:modelValue']);
const { loadIcons } = useIconManager();
const iconNames = computed(() => props.tabs.map(t => t.iconName).filter(Boolean));
watch(iconNames, (names) => {
if (names && names.length) {
loadIcons(names);
}
}, { immediate: true });
</script>
<style scoped>
.mobile-tab-bar {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-around;
padding: 6px 8px;
padding-bottom: calc(6px + var(--safe-bottom-inset, 0px));
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: var(--tab-background, #0a0a0a);
}
.mobile-tab-bar--header {
/* In de header geen extra safe-area padding en geen border-top */
padding: 4px 4px;
padding-bottom: 4px;
border-top: none;
}
.tab-button {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4px 0;
border: none;
background: transparent;
color: var(--tab-icon-inactive-color, rgba(240, 240, 240, 0.7));
font-size: 11px;
cursor: pointer;
}
.tab-button .material-symbols-outlined {
font-size: 22px;
line-height: 1;
}
.tab-button.is-active {
color: var(--tab-icon-active-color, #ffffff);
}
.tab-button.is-active .material-symbols-outlined {
font-variation-settings:
'FILL' 1,
'wght' 500,
'GRAD' 0,
'opsz' 24;
}
@media (min-width: 769px) {
.mobile-tab-bar {
display: none;
}
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="safe-viewport-wrapper">
<slot />
</div>
</template>
<script setup>
// SafeViewport.vue
// Wrapper-component die de chatapplicatie omhult en de viewport-/keyboard-logica
// initialiseert via de useChatViewport composable.
// Belangrijk: de composable zelf leeft onder nginx/frontend_src/js zodat hij
// binnen dezelfde npm-package valt als de bundel (en zo @vueuse/core kan resolven).
// Daarom gebruiken we hier een relatieve pad vanuit de Vue-component naar die map.
import useChatViewport from '../js/composables/useChatViewport.js';
// Initialiseer de viewport-logica zodra deze wrapper instantiëert.
useChatViewport();
</script>
<style scoped>
.safe-viewport-wrapper {
height: 100%;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div class="sidebar-mobile-setup">
<SideBarMakeName
:make-name="tenantMake.name"
:subtitle="tenantMake.subtitle"
class="setup-make-name"
/>
<LanguageSelector
:initial-language="initialLanguage"
:current-language="currentLanguageInternal"
:supported-language-details="supportedLanguageDetails"
:allowed-languages="allowedLanguages"
@language-changed="handleLanguageChange"
class="setup-language-selector"
/>
<SideBarExplanation
:original-text="explanationText"
:current-language="currentLanguageInternal"
:api-prefix="apiPrefix"
class="setup-explanation"
/>
</div>
</template>
<script setup>
import { ref, watch } from 'vue';
import SideBarMakeName from './SideBarMakeName.vue';
import LanguageSelector from './LanguageSelector.vue';
import SideBarExplanation from './SideBarExplanation.vue';
const props = defineProps({
tenantMake: {
type: Object,
default: () => ({
name: '',
subtitle: ''
})
},
explanationText: {
type: String,
default: ''
},
initialLanguage: {
type: String,
default: 'en'
},
currentLanguage: {
type: String,
default: 'en'
},
supportedLanguageDetails: {
type: Object,
default: () => ({})
},
allowedLanguages: {
type: Array,
default: () => ['nl', 'en', 'fr', 'de']
},
apiPrefix: {
type: String,
default: ''
}
});
const emit = defineEmits(['language-changed']);
const currentLanguageInternal = ref(props.currentLanguage || props.initialLanguage);
watch(
() => props.currentLanguage,
(newVal) => {
if (newVal && newVal !== currentLanguageInternal.value) {
currentLanguageInternal.value = newVal;
}
}
);
const handleLanguageChange = (newLanguage) => {
currentLanguageInternal.value = newLanguage;
emit('language-changed', newLanguage);
};
</script>
<style scoped>
.sidebar-mobile-setup {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px 8px 16px 8px;
gap: 8px;
}
.setup-make-name {
padding-bottom: 4px;
}
.setup-language-selector {
flex-shrink: 0;
border-radius: 12px;
background: var(--sidebar-background, rgba(255, 255, 255, 0.02));
padding: 8px;
}
.setup-explanation {
flex: 1 1 auto;
border-radius: 12px;
padding: 8px;
overflow-y: auto;
}
@media (min-width: 769px) {
.sidebar-mobile-setup {
display: none;
}
}
</style>

View File

@@ -44,6 +44,11 @@
--human-message-background: {{ customisation.human_message_background|default('#ffffff') }}; --human-message-background: {{ customisation.human_message_background|default('#ffffff') }};
--human-message-text-color: {{ customisation.human_message_text_color|default('#212529') }}; --human-message-text-color: {{ customisation.human_message_text_color|default('#212529') }};
/* Mobe Tab Bar Colors */
--tab-background: {{ customisation.tab_background|default('#0a0a0a') }};
--tab-icon-active-color: {{ customisation.tab_icon_active_color|default('#ffffff') }};
--tab-icon-inactive-color: {{ customisation.tab_icon_inactive_color|default('#f0f0f0') }};
} }
</style> </style>

View File

@@ -385,7 +385,7 @@ def translate():
}), 500 }), 500
@chat_bp.route('/privacy', methods=['GET']) @chat_bp.route('/dpa', methods=['GET'])
def privacy_statement(): def privacy_statement():
""" """
Public AJAX endpoint for dpa statement content Public AJAX endpoint for dpa statement content

View File

@@ -1,12 +0,0 @@
INSUFFICIENT_INFORMATION_MESSAGES = [
"I'm afraid I don't have enough information to answer that properly. Feel free to ask something else!",
"There isnt enough data available right now to give you a clear answer. You're welcome to rephrase or ask a different question.",
"Sorry, I can't provide a complete answer based on the current information. Would you like to try asking something else?",
"I dont have enough details to give you a confident answer. You can always ask another question if youd like.",
"Unfortunately, I cant answer that accurately with the information at hand. Please feel free to ask something else.",
"Thats a great question, but I currently lack the necessary information to respond properly. Want to ask something different?",
"I wish I could help more, but the data I have isn't sufficient to answer this. Youre welcome to explore other questions.",
"Theres not enough context for me to provide a good answer. Dont hesitate to ask another question if you'd like!",
"I'm not able to give a definitive answer to that. Perhaps try a different question or angle?",
"Thanks for your question. At the moment, I cant give a solid answer — but I'm here if you want to ask something else!"
]

View File

@@ -19,7 +19,19 @@ from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpec
from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments
from eveai_chat_workers.outputs.globals.rag.rag_v1_0 import RAGOutput from eveai_chat_workers.outputs.globals.rag.rag_v1_0 import RAGOutput
from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState
from eveai_chat_workers.definitions.messages.globals.rag_messages import INSUFFICIENT_INFORMATION_MESSAGES
INSUFFICIENT_INFORMATION_MESSAGES = [
"I'm afraid I don't have enough information to answer that properly. Feel free to ask something else!",
"There isnt enough data available right now to give you a clear answer. You're welcome to rephrase or ask a different question.",
"Sorry, I can't provide a complete answer based on the current information. Would you like to try asking something else?",
"I dont have enough details to give you a confident answer. You can always ask another question if youd like.",
"Unfortunately, I cant answer that accurately with the information at hand. Please feel free to ask something else.",
"Thats a great question, but I currently lack the necessary information to respond properly. Want to ask something different?",
"I wish I could help more, but the data I have isn't sufficient to answer this. Youre welcome to explore other questions.",
"Theres not enough context for me to provide a good answer. Dont hesitate to ask another question if you'd like!",
"I'm not able to give a definitive answer to that. Perhaps try a different question or angle?",
"Thanks for your question. At the moment, I cant give a solid answer — but I'm here if you want to ask something else!"
]
class SpecialistExecutor(CrewAIBaseSpecialistExecutor): class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
""" """

View File

@@ -87,6 +87,7 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
return results return results
def execute_initial_state(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult: def execute_initial_state(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
self.log_tuning("Traicie KO Criteria Interview Definition Specialist initial_state_execution started", {}) self.log_tuning("Traicie KO Criteria Interview Definition Specialist initial_state_execution started", {})

View File

@@ -25,7 +25,6 @@ from eveai_chat_workers.outputs.traicie.knockout_questions.knockout_questions_v1
from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState
from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor
from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments
from eveai_chat_workers.definitions.messages.globals.rag_messages import INSUFFICIENT_INFORMATION_MESSAGES
INITIALISATION_MESSAGES = [ INITIALISATION_MESSAGES = [
"Great! Let's see if this job might be a match for you by going through a few questions.", "Great! Let's see if this job might be a match for you by going through a few questions.",
@@ -86,6 +85,18 @@ TRY_TO_START_SELECTION_QUESTIONS = [
"Understood! However, we can't proceed without initiating the process. Would you like to start it now after all?", "Understood! However, we can't proceed without initiating the process. Would you like to start it now after all?",
"We appreciate your honesty. Just to clarify: the process only continues if we begin the selection. Shall we go ahead?" "We appreciate your honesty. Just to clarify: the process only continues if we begin the selection. Shall we go ahead?"
] ]
INSUFFICIENT_INFORMATION_MESSAGES = [
"I'm afraid I don't have enough information to answer that properly. Feel free to ask something else!",
"There isnt enough data available right now to give you a clear answer. You're welcome to rephrase or ask a different question.",
"Sorry, I can't provide a complete answer based on the current information. Would you like to try asking something else?",
"I dont have enough details to give you a confident answer. You can always ask another question if youd like.",
"Unfortunately, I cant answer that accurately with the information at hand. Please feel free to ask something else.",
"Thats a great question, but I currently lack the necessary information to respond properly. Want to ask something different?",
"I wish I could help more, but the data I have isn't sufficient to answer this. Youre welcome to explore other questions.",
"Theres not enough context for me to provide a good answer. Dont hesitate to ask another question if you'd like!",
"I'm not able to give a definitive answer to that. Perhaps try a different question or angle?",
"Thanks for your question. At the moment, I cant give a solid answer — but I'm here if you want to ask something else!"
]
KO_CRITERIA_NOT_MET_MESSAGES = [ KO_CRITERIA_NOT_MET_MESSAGES = [
"Thank you for your answers. Based on your responses, we won't be moving forward with this particular role. We do encourage you to keep an eye on our website for future opportunities.", "Thank you for your answers. Based on your responses, we won't be moving forward with this particular role. We do encourage you to keep an eye on our website for future opportunities.",
"We appreciate the time you took to answer our questions. At this point, we wont be proceeding with your application, but feel free to check our website regularly for new vacancies.", "We appreciate the time you took to answer our questions. At this point, we wont be proceeding with your application, but feel free to check our website regularly for new vacancies.",

View File

@@ -10,7 +10,6 @@ import '../../../eveai_chat_client/static/assets/css/form-message.css';
// Dependencies // Dependencies
import { createApp, version } from 'vue'; import { createApp, version } from 'vue';
import { marked } from 'marked'; import { marked } from 'marked';
import { FormField } from '../../../../../../../../../Users/josako/Library/Application Support/JetBrains/PyCharm2025.1/scratches/old js files/FormField.js';
// Import LanguageProvider for sidebar translation support // Import LanguageProvider for sidebar translation support
import { createLanguageProvider, LANGUAGE_PROVIDER_KEY } from '../../../eveai_chat_client/static/assets/js/services/LanguageProvider.js'; import { createLanguageProvider, LANGUAGE_PROVIDER_KEY } from '../../../eveai_chat_client/static/assets/js/services/LanguageProvider.js';
@@ -28,9 +27,14 @@ console.log('Components loaded:', Object.keys(Components));
// Import specifieke componenten // Import specifieke componenten
import LanguageSelector from '../../../eveai_chat_client/static/assets/vue-components/LanguageSelector.vue'; import LanguageSelector from '../../../eveai_chat_client/static/assets/vue-components/LanguageSelector.vue';
import ChatApp from '../../../eveai_chat_client/static/assets/vue-components/ChatApp.vue'; // import ChatApp from '../../../eveai_chat_client/static/assets/vue-components/ChatApp.vue';
import ChatRoot from '../../../eveai_chat_client/static/assets/vue-components/ChatRoot.vue';
import DesktopChatShell from '../../../eveai_chat_client/static/assets/vue-components/DesktopChatShell.vue';
import MobileChatShell from '../../../eveai_chat_client/static/assets/vue-components/MobileChatShell.vue';
import SideBar from '../../../eveai_chat_client/static/assets/vue-components/SideBar.vue'; import SideBar from '../../../eveai_chat_client/static/assets/vue-components/SideBar.vue';
import MobileHeader from '../../../eveai_chat_client/static/assets/vue-components/MobileHeader.vue';
// VueUse-setup voor de chatclient (maakt composables beschikbaar via window.VueUse)
import './vueuse-setup.js';
// Globale Vue error tracking // Globale Vue error tracking
window.addEventListener('error', function(event) { window.addEventListener('error', function(event) {
@@ -48,9 +52,6 @@ document.addEventListener('DOMContentLoaded', function() {
// Initialiseer sidebar (vervangt fillSidebarExplanation en initializeLanguageSelector) // Initialiseer sidebar (vervangt fillSidebarExplanation en initializeLanguageSelector)
initializeSidebar(); initializeSidebar();
// Initialiseer mobile header
initializeMobileHeader();
// Initialiseer chat app (simpel) // Initialiseer chat app (simpel)
initializeChatApp(); initializeChatApp();
}); });
@@ -118,85 +119,8 @@ function initializeSidebar() {
} }
} }
/** // initializeMobileHeader is verwijderd; de mobiele header wordt nu volledig
* Initialiseert de mobile header component // binnen ChatApp.vue beheerd.
*/
function initializeMobileHeader() {
const container = document.getElementById('mobile-header-container');
if (!container) {
console.error('#mobile-header-container niet gevonden');
return;
}
try {
// Maak props voor de component
const props = {
tenantMake: {
name: window.chatConfig.tenantMake?.name || '',
logo_url: window.chatConfig.tenantMake?.logo_url || '',
subtitle: window.chatConfig.tenantMake?.subtitle || ''
},
initialLanguage: window.chatConfig.language || 'nl',
supportedLanguageDetails: window.chatConfig.supportedLanguageDetails || {},
allowedLanguages: window.chatConfig.allowedLanguages || ['nl', 'en', 'fr', 'de'],
apiPrefix: window.chatConfig.apiPrefix || ''
};
// Mount de component
const app = createApp(MobileHeader, props);
// Create and provide LanguageProvider for mobile header components
const initialLanguage = window.chatConfig?.language || 'nl';
const apiPrefix = window.chatConfig?.apiPrefix || '';
const languageProvider = createLanguageProvider(initialLanguage, apiPrefix);
app.provide(LANGUAGE_PROVIDER_KEY, languageProvider);
// Error handler
app.config.errorHandler = (err, vm, info) => {
console.error('🚨 [Vue Error in MobileHeader]', err);
console.error('Component:', vm);
console.error('Error Info:', info);
};
const mountedApp = app.mount(container);
// Dynamisch de headerhoogte doorgeven aan CSS
const updateHeaderHeightVar = () => {
const isMobile = window.matchMedia('(max-width: 768px)').matches;
if (!isMobile) {
document.documentElement.style.removeProperty('--mobile-header-height');
return;
}
const h = container.offsetHeight || 60; // fallback
document.documentElement.style.setProperty('--mobile-header-height', `${h}px`);
};
// Initieel instellen en bij gebeurtenissen herberekenen
requestAnimationFrame(updateHeaderHeightVar);
window.addEventListener('resize', updateHeaderHeightVar);
// Listen to language change events and update the mobile header's language provider
const languageChangeHandler = (event) => {
if (event.detail && event.detail.language) {
console.log('MobileHeader: Received language change event:', event.detail.language);
languageProvider.setLanguage(event.detail.language);
// taalwissel kan headerhoogte veranderen
requestAnimationFrame(updateHeaderHeightVar);
}
};
document.addEventListener('language-changed', languageChangeHandler);
// Store the handler for cleanup if needed
mountedApp._languageChangeHandler = languageChangeHandler;
mountedApp._updateHeaderHeightVar = updateHeaderHeightVar;
console.log('✅ MobileHeader component successfully mounted with LanguageProvider en dynamische headerhoogte');
return mountedApp;
} catch (error) {
console.error('🚨 [CRITICAL ERROR] Bij initialiseren mobile header:', error);
}
}
/** /**
* Initialiseert de chat app (Vue component) * Initialiseert de chat app (Vue component)
@@ -209,29 +133,45 @@ function initializeChatApp() {
} }
try { try {
if (!ChatApp) {
throw new Error('🚨 [CRITICAL ERROR] ChatApp component niet gevonden');
}
// Extra verificatie dat alle sub-componenten beschikbaar zijn // Extra verificatie dat alle sub-componenten beschikbaar zijn
if (!Components.MessageHistory || !Components.ChatInput || if (!Components.MessageHistory || !Components.ChatInput ||
!Components.TypingIndicator || !Components.ChatMessage) { !Components.TypingIndicator || !Components.ChatMessage) {
console.warn('⚠️ [WARN] Niet alle benodigde sub-componenten zijn geladen!'); console.warn('⚠️ [WARN] Niet alle benodigde sub-componenten zijn geladen!');
} }
// Maak props voor de component // Maak props voor de shells / CoreChatApp
const props = { const baseProps = {
apiPrefix: window.chatConfig.apiPrefix || '', apiPrefix: window.chatConfig.apiPrefix || '',
conversationId: window.chatConfig.conversationId || 'default', conversationId: window.chatConfig.conversationId || 'default',
userId: window.chatConfig.userId || null, userId: window.chatConfig.userId || null,
userName: window.chatConfig.userName || '', userName: window.chatConfig.userName || '',
initialLanguage: window.chatConfig.language || 'nl', initialLanguage: window.chatConfig.language || 'nl',
supportedLanguageDetails: window.chatConfig.supportedLanguageDetails || {}, supportedLanguageDetails: window.chatConfig.supportedLanguageDetails || {},
allowedLanguages: window.chatConfig.allowedLanguages || ['nl', 'en', 'fr', 'de'] allowedLanguages: window.chatConfig.allowedLanguages || ['nl', 'en', 'fr', 'de'],
tenantName: (window.chatConfig.tenantMake && window.chatConfig.tenantMake.name) || 'EveAI',
tenantSubtitle: (window.chatConfig.tenantMake && window.chatConfig.tenantMake.subtitle) || '',
tenantLogoUrl: (window.chatConfig.tenantMake && window.chatConfig.tenantMake.logo_url) || '',
explanationText: window.chatConfig.explanation || '',
settings: window.chatConfig.settings || {}
}; };
// Mount de component met alle nodige componenten // Bepaal shell-type: expliciete config heeft voorrang, anders breakpoint
const app = createApp(ChatApp, props); const layoutMode = window.chatConfig.layoutMode || 'auto';
const isMobileBreakpoint = window.innerWidth <= 768;
let ShellComponent;
if (layoutMode === 'desktop') {
ShellComponent = DesktopChatShell;
} else if (layoutMode === 'mobile') {
ShellComponent = MobileChatShell;
} else {
ShellComponent = isMobileBreakpoint ? MobileChatShell : DesktopChatShell;
}
const props = { shellComponent: ShellComponent, shellProps: baseProps };
// Mount de component via ChatRoot zodat SafeViewport de layout kan beheren
const app = createApp(ChatRoot, props);
// SSE verbinding configuratie - injecteren in ChatApp component // SSE verbinding configuratie - injecteren in ChatApp component
app.provide('sseConfig', { app.provide('sseConfig', {

View File

@@ -0,0 +1,15 @@
// vueuse-setup.js
// Setup-module voor VueUse in de chat client.
// - Importeert benodigde composables uit '@vueuse/core'
// - Maakt ze beschikbaar via window.VueUse zodat static assets
// onder eveai_chat_client/static ze kunnen gebruiken zonder
// directe npm-imports.
import { useEventListener, useWindowSize } from '@vueuse/core';
if (typeof window !== 'undefined') {
window.VueUse = Object.assign({}, window.VueUse, {
useEventListener,
useWindowSize,
});
}

View File

@@ -6,12 +6,13 @@
"": { "": {
"dependencies": { "dependencies": {
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@vueuse/core": "^14.0.0",
"animejs": "^4.0.2", "animejs": "^4.0.2",
"bootstrap": "^5.3.6", "bootstrap": "^5.3.6",
"datatables.net": "^2.3.1", "datatables.net": "^2.3.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"marked": "^16.0.0", "marked": "16.3.0",
"nouislider": "^15.8.1", "nouislider": "^15.8.1",
"parallax": "^0.0.0", "parallax": "^0.0.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
@@ -22,7 +23,6 @@
"vue": "^3.5.17" "vue": "^3.5.17"
}, },
"devDependencies": { "devDependencies": {
"@parcel/reporter-bundle-analyzer": "^2.15.2",
"@parcel/transformer-sass": "^2.15.2", "@parcel/transformer-sass": "^2.15.2",
"@parcel/transformer-vue": "^2.15.2", "@parcel/transformer-vue": "^2.15.2",
"parcel": "^2.15.2" "parcel": "^2.15.2"
@@ -1360,26 +1360,6 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/@parcel/reporter-bundle-analyzer": {
"version": "2.16.0",
"resolved": "https://registry.npmjs.org/@parcel/reporter-bundle-analyzer/-/reporter-bundle-analyzer-2.16.0.tgz",
"integrity": "sha512-81EazkM7YjeFvzRRlKKV8kmLfLXfWlfLqlpC8jFRXthC43aZtdqMnzRgi9Q+6FIB7Ft3vnMXZPqRs4cn7+XgUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@parcel/plugin": "2.16.0",
"@parcel/utils": "2.16.0",
"nullthrows": "^1.1.1"
},
"engines": {
"node": ">= 16.0.0",
"parcel": "^2.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@parcel/reporter-cli": { "node_modules/@parcel/reporter-cli": {
"version": "2.16.0", "version": "2.16.0",
"resolved": "https://registry.npmjs.org/@parcel/reporter-cli/-/reporter-cli-2.16.0.tgz", "resolved": "https://registry.npmjs.org/@parcel/reporter-cli/-/reporter-cli-2.16.0.tgz",
@@ -2722,6 +2702,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.21", "version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz",
@@ -2822,6 +2808,44 @@
"integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.0.0.tgz",
"integrity": "sha512-d6tKRWkZE8IQElX2aHBxXOMD478fHIYV+Dzm2y9Ag122ICBpNKtGICiXKOhWU3L1kKdttDD9dCMS4bGP3jhCTQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.0.0",
"@vueuse/shared": "14.0.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.0.0.tgz",
"integrity": "sha512-6yoGqbJcMldVCevkFiHDBTB1V5Hq+G/haPlGIuaFZHpXC0HADB0EN1ryQAAceiW+ryS3niUwvdFbGiqHqBrfVA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.0.0.tgz",
"integrity": "sha512-mTCA0uczBgurRlwVaQHfG0Ja7UdGe4g9mwffiJmvLiTtp1G4AQyIjej6si/k8c8pUwTfVpNufck+23gXptPAkw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",

View File

@@ -14,7 +14,8 @@
"tabulator-tables": "^6.3.1", "tabulator-tables": "^6.3.1",
"typed.js": "^2.1.0", "typed.js": "^2.1.0",
"vanilla-jsoneditor": "^3.5.0", "vanilla-jsoneditor": "^3.5.0",
"vue": "^3.5.17" "vue": "^3.5.17",
"@vueuse/core": "^14.0.0"
}, },
"devDependencies": { "devDependencies": {
"@parcel/transformer-sass": "^2.15.2", "@parcel/transformer-sass": "^2.15.2",

0
scripts/__init__.py Normal file
View File

0
scripts/git/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from ..core import config, git_api, hooks, output
def _bugfix_branch_name(name: str) -> str:
return f"{config.CONFIG.bugfix_prefix}{name}"
def handle_bugfix_start(args) -> int:
name: str = args.name
cfg = config.CONFIG
try:
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
branch_name = _bugfix_branch_name(name)
output.info(f"Aanmaken van bugfix branch '{branch_name}' vanaf '{cfg.develop_branch}'")
git_api.create_branch(branch_name, cfg.develop_branch)
output.success(f"Bugfix branch '{branch_name}' is aangemaakt en gecheckt out.")
# Hooks na succesvol aanmaken van een bugfix branch
hooks.run_hooks(
"bugfix_start",
{
"branch": branch_name,
"base_branch": cfg.develop_branch,
},
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1
def handle_bugfix_finish(args) -> int:
cfg = config.CONFIG
name: str | None = args.name
try:
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
if name is None:
current = git_api.get_current_branch()
if current.startswith(cfg.bugfix_prefix):
bugfix_branch = current
else:
branches = git_api.list_local_branches_with_prefix(cfg.bugfix_prefix)
if not branches:
raise git_api.GitError(
"Er zijn geen lokale bugfix branches gevonden. "
"Maak eerst een bugfix branch aan of geef een naam op."
)
output.heading("Beschikbare bugfix branches")
for b in branches:
output.plain(f"- {b}")
raise git_api.GitError(
"Je zit niet op een bugfix branch. Kies een van de bovenstaande namen "
"en voer het commando opnieuw uit, bv.: gitflow bugfix finish <naam-zonder-prefix>."
)
else:
bugfix_branch = _bugfix_branch_name(name)
git_api.ensure_not_behind_remote(bugfix_branch, cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
output.info(f"Mergen van '{bugfix_branch}' naar '{cfg.develop_branch}'")
git_api.checkout_branch(cfg.develop_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_feature:
merge_args.append("--no-ff")
merge_args.append(bugfix_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
output.success(f"Bugfix branch '{bugfix_branch}' is gemerged naar '{cfg.develop_branch}'.")
# Optionele cleanup
if cfg.delete_bugfix_after_finish:
output.info(f"Opruimen van lokale bugfix branch '{bugfix_branch}'")
try:
git_api._run_git(["branch", "-d", bugfix_branch]) # type: ignore[attr-defined]
except git_api.GitError as exc:
output.warning(f"Kon bugfix branch '{bugfix_branch}' niet verwijderen: {exc}")
# Hooks na succesvolle bugfix-finish
hooks.run_hooks(
"bugfix_finish",
{
"branch": bugfix_branch,
"base_branch": cfg.develop_branch,
},
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from ..core import config, git_api, hooks, output
def _feature_branch_name(name: str) -> str:
return f"{config.CONFIG.feature_prefix}{name}"
def handle_feature_start(args) -> int:
name: str = args.name
cfg = config.CONFIG
try:
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
branch_name = _feature_branch_name(name)
output.info(f"Aanmaken van feature branch '{branch_name}' vanaf '{cfg.develop_branch}'")
git_api.create_branch(branch_name, cfg.develop_branch)
output.success(f"Feature branch '{branch_name}' is aangemaakt en gecheckt out.")
# Hooks na succesvol aanmaken van een feature branch
hooks.run_hooks(
"feature_start",
{
"branch": branch_name,
"base_branch": cfg.develop_branch,
},
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1
def handle_feature_finish(args) -> int:
cfg = config.CONFIG
name: str | None = args.name
try:
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
if name is None:
current = git_api.get_current_branch()
if current.startswith(cfg.feature_prefix):
feature_branch = current
else:
# Geen naam en we zitten niet op een feature-branch: toon een lijst
# met beschikbare feature-branches om de gebruiker te helpen kiezen.
branches = git_api.list_local_branches_with_prefix(cfg.feature_prefix)
if not branches:
raise git_api.GitError(
"Er zijn geen lokale feature branches gevonden. "
"Maak eerst een feature branch aan of geef een naam op."
)
output.heading("Beschikbare feature branches")
for b in branches:
output.plain(f"- {b}")
raise git_api.GitError(
"Je zit niet op een feature branch. Kies een van de bovenstaande namen "
"en voer het commando opnieuw uit, bv.: gitflow feature finish <naam-zonder-prefix>."
)
else:
feature_branch = _feature_branch_name(name)
# Zorg dat betrokken branches niet achterlopen op remote
git_api.ensure_not_behind_remote(feature_branch, cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
output.info(f"Mergen van '{feature_branch}' naar '{cfg.develop_branch}'")
git_api.checkout_branch(cfg.develop_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_feature:
merge_args.append("--no-ff")
merge_args.append(feature_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
output.success(f"Feature branch '{feature_branch}' is gemerged naar '{cfg.develop_branch}'.")
# Optionele cleanup van de feature branch
if cfg.delete_feature_after_finish:
output.info(f"Opruimen van lokale feature branch '{feature_branch}'")
try:
git_api._run_git(["branch", "-d", feature_branch]) # type: ignore[attr-defined]
except git_api.GitError as exc:
output.warning(f"Kon feature branch '{feature_branch}' niet verwijderen: {exc}")
# Hooks na succesvolle feature-finish
hooks.run_hooks(
"feature_finish",
{
"branch": feature_branch,
"base_branch": cfg.develop_branch,
},
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1

View File

@@ -0,0 +1,154 @@
from __future__ import annotations
from ..core import config, git_api, hooks, output
def _hotfix_branch_name(name: str) -> str:
return f"{config.CONFIG.hotfix_prefix}{name}"
def _ensure_version(name: str | None) -> str:
# Voor hotfix kunnen we dezelfde prompt gebruiken indien geen naam/versie is opgegeven
if name:
return name
output.info("Geen hotfix-naam/versie opgegeven. Gelieve een identificatie in te geven (bijv. 1.2.1 of short-name):")
entered = input("Hotfix: ").strip()
if not entered:
raise git_api.GitError("Geen hotfix-naam/versie opgegeven.")
return entered
def handle_hotfix_start(args) -> int:
cfg = config.CONFIG
try:
raw_name: str = args.name
name = _ensure_version(raw_name)
branch_name = _hotfix_branch_name(name)
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.main_branch, cfg.remote_name)
output.info(f"Aanmaken van hotfix branch '{branch_name}' vanaf '{cfg.main_branch}'")
git_api.create_branch(branch_name, cfg.main_branch)
output.success(f"Hotfix branch '{branch_name}' is aangemaakt en gecheckt out.")
# Hooks na succesvol aanmaken van een hotfix branch
hooks.run_hooks(
"hotfix_start",
{
"branch": branch_name,
"base_branch": cfg.main_branch,
"version": name,
},
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1
def handle_hotfix_finish(args) -> int:
cfg = config.CONFIG
try:
name_arg = getattr(args, "name", None)
env_arg = getattr(args, "env", None)
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
if name_arg:
name = name_arg
hotfix_branch = _hotfix_branch_name(name)
else:
current = git_api.get_current_branch()
prefix = cfg.hotfix_prefix
if current.startswith(prefix):
name = current[len(prefix) :]
hotfix_branch = current
else:
# Toon lijst van beschikbare hotfix branches
branches = git_api.list_local_branches_with_prefix(cfg.hotfix_prefix)
if not branches:
raise git_api.GitError(
"Er zijn geen lokale hotfix branches gevonden. "
"Maak eerst een hotfix branch aan of geef een naam op."
)
output.heading("Beschikbare hotfix branches")
for b in branches:
output.plain(f"- {b}")
raise git_api.GitError(
"Je zit niet op een hotfix branch. Kies een van de bovenstaande namen "
"en voer het commando opnieuw uit, bv.: gitflow hotfix finish <naam-of-versie>."
)
git_api.ensure_not_behind_remote(hotfix_branch, cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.main_branch, cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
# Bepaal omgeving en tagnaam
env = env_arg or cfg.hotfix_default_env
suffix = cfg.environments.get(env, "")
base_tag = cfg.tag_format.format(version=name)
tag_name = f"{base_tag}{suffix}"
# Merge naar main
output.info(f"Mergen van '{hotfix_branch}' naar '{cfg.main_branch}' en tag '{tag_name}' aanmaken")
git_api.checkout_branch(cfg.main_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_hotfix:
merge_args.append("--no-ff")
merge_args.append(hotfix_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge van hotfix naar main is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
git_api._run_git(["tag", tag_name]) # type: ignore[attr-defined]
# Merge naar develop
output.info(f"Mergen van '{hotfix_branch}' naar '{cfg.develop_branch}'")
git_api.checkout_branch(cfg.develop_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_hotfix:
merge_args.append("--no-ff")
merge_args.append(hotfix_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge van hotfix naar develop is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
output.success(
f"Hotfix '{name}' is voltooid: gemerged naar '{cfg.main_branch}' en '{cfg.develop_branch}' en getagd als '{tag_name}'."
)
# Optionele cleanup
if cfg.delete_hotfix_after_finish:
output.info(f"Opruimen van lokale hotfix branch '{hotfix_branch}'")
try:
git_api._run_git(["branch", "-d", hotfix_branch]) # type: ignore[attr-defined]
except git_api.GitError as exc:
output.warning(f"Kon hotfix branch '{hotfix_branch}' niet verwijderen: {exc}")
# Hooks na succesvolle hotfix-finish
hooks.run_hooks(
"hotfix_finish",
{
"branch": hotfix_branch,
"base_branch": cfg.main_branch,
"version": name,
"env": env,
"tag": tag_name,
},
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1

View File

@@ -0,0 +1,142 @@
from __future__ import annotations
from ..core import config, git_api, hooks, output
def _release_branch_name(version: str) -> str:
return f"{config.CONFIG.release_prefix}{version}"
def _ensure_version(version: str | None) -> str:
if version:
return version
# Eenvoudige interactieve prompt; later kunnen we validatie uitbreiden
output.info("Geen versie opgegeven. Gelieve een versie in te geven (bijv. 1.2.0):")
entered = input("Versie: ").strip()
if not entered:
raise git_api.GitError("Geen versie opgegeven.")
return entered
def handle_release_start(args) -> int:
cfg = config.CONFIG
try:
version = _ensure_version(getattr(args, "version", None))
branch_name = _release_branch_name(version)
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
output.info(f"Aanmaken van release branch '{branch_name}' vanaf '{cfg.develop_branch}'")
git_api.create_branch(branch_name, cfg.develop_branch)
output.success(f"Release branch '{branch_name}' is aangemaakt en gecheckt out.")
# Hooks na succesvol aanmaken van een release branch
hooks.run_hooks(
"release_start",
{
"branch": branch_name,
"base_branch": cfg.develop_branch,
"version": version,
},
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1
def handle_release_finish(args) -> int:
cfg = config.CONFIG
try:
version_arg = getattr(args, "version", None)
env_arg = getattr(args, "env", None)
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
if version_arg:
version = version_arg
release_branch = _release_branch_name(version)
else:
# Probeer huidige branch te gebruiken
current = git_api.get_current_branch()
prefix = cfg.release_prefix
if current.startswith(prefix):
version = current[len(prefix) :]
release_branch = current
else:
# Geen logische branch, vraag versie interactief
version = _ensure_version(None)
release_branch = _release_branch_name(version)
# Zorg dat betrokken branches niet achterlopen
git_api.ensure_not_behind_remote(release_branch, cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.main_branch, cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
# Bepaal omgeving en uiteindelijke tagnaam
env = env_arg or cfg.release_default_env
suffix = cfg.environments.get(env, "")
base_tag = cfg.tag_format.format(version=version)
tag_name = f"{base_tag}{suffix}"
# Merge naar main
output.info(f"Mergen van '{release_branch}' naar '{cfg.main_branch}' en tag '{tag_name}' aanmaken")
git_api.checkout_branch(cfg.main_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_release:
merge_args.append("--no-ff")
merge_args.append(release_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge naar main is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
# Tag aanmaken op main
git_api._run_git(["tag", tag_name]) # type: ignore[attr-defined]
# Merge terug naar develop
output.info(f"Mergen van '{release_branch}' terug naar '{cfg.develop_branch}'")
git_api.checkout_branch(cfg.develop_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_release:
merge_args.append("--no-ff")
merge_args.append(release_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge naar develop is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
output.success(
f"Release '{version}' is voltooid: gemerged naar '{cfg.main_branch}' en '{cfg.develop_branch}' en getagd als '{tag_name}'."
)
# Optionele cleanup van release branch
if cfg.delete_release_after_finish:
output.info(f"Opruimen van lokale release branch '{release_branch}'")
try:
git_api._run_git(["branch", "-d", release_branch]) # type: ignore[attr-defined]
except git_api.GitError as exc:
output.warning(f"Kon release branch '{release_branch}' niet verwijderen: {exc}")
# Hooks na succesvolle release-finish
hooks.run_hooks(
"release_finish",
{
"branch": release_branch,
"base_branch": cfg.main_branch,
"version": version,
"env": env,
"tag": tag_name,
},
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from ..core import config, git_api, output
def handle_status(args) -> int: # noqa: ARG001 - argparse API
"""Toon huidige branch en eenvoudige status-info."""
try:
branch = git_api.get_current_branch()
except git_api.GitError as exc:
output.error(str(exc))
return 1
clean = git_api.is_clean_working_tree()
output.heading("Repo status")
output.plain(f"Huidige branch : {branch}")
output.plain(f"Working tree : {'clean' if clean else 'NIET clean'}")
# Optionele remote-checks
cfg = config.CONFIG
for important_branch in {cfg.main_branch, cfg.develop_branch, branch}:
try:
git_api.ensure_not_behind_remote(important_branch, cfg.remote_name)
except git_api.GitError as exc:
output.warning(str(exc))
return 0

View File

154
scripts/git/core/config.py Normal file
View File

@@ -0,0 +1,154 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List
import os
try: # yaml is optioneel; bij ontbreken vallen we terug op defaults
import yaml # type: ignore[import]
except Exception: # pragma: no cover - defensieve fallback
yaml = None
@dataclass
class GitFlowConfig:
main_branch: str = "main"
develop_branch: str = "develop"
remote_name: str = "origin"
feature_prefix: str = "feature/"
bugfix_prefix: str = "bugfix/"
hotfix_prefix: str = "hotfix/"
release_prefix: str = "release/"
tag_format: str = "v{version}"
# Merge-strategie kan later per actie configureerbaar worden
use_no_ff_for_feature: bool = True
use_no_ff_for_release: bool = True
use_no_ff_for_hotfix: bool = True
# Globale dry-run vlag: wanneer True worden muterende git-acties niet
# echt uitgevoerd, maar enkel gelogd.
dry_run: bool = False
# Hooks per event (feature_start, feature_finish, ...)
hooks: Dict[str, List[str]] = field(default_factory=dict)
# Cleanup-instellingen: lokale branches verwijderen na succesvolle finish
delete_feature_after_finish: bool = False
delete_bugfix_after_finish: bool = False
delete_release_after_finish: bool = False
delete_hotfix_after_finish: bool = False
# Omgevingen voor tagging (suffixen) en defaults
environments: Dict[str, str] = field(
default_factory=lambda: {
"test": "-test",
"staging": "-staging",
"production": "",
}
)
release_default_env: str = "production"
hotfix_default_env: str = "production"
CONFIG = GitFlowConfig()
def load_config() -> None:
"""Laad configuratie.
We lezen optioneel `scripts/git/gitflow.yaml` in en overschrijven
velden in `CONFIG` op basis van de inhoud. Als het bestand of de
yaml-lib ontbreekt, blijven de defaults gelden.
"""
if yaml is None:
return None
# Zoek het config-bestand relatief t.o.v. de huidige werkdirectory.
# We gaan ervan uit dat gitflow vanuit de projectroot draait (wrapper).
cfg_path = Path("scripts") / "git" / "gitflow.yaml"
if not cfg_path.is_file():
return None
with cfg_path.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
# Eenvoudige mapping: alleen bekende keys worden overgenomen.
if not isinstance(data, dict):
return None
# Basisvelden
CONFIG.main_branch = str(data.get("main_branch", CONFIG.main_branch))
CONFIG.develop_branch = str(data.get("develop_branch", CONFIG.develop_branch))
CONFIG.remote_name = str(data.get("remote_name", CONFIG.remote_name))
# Prefixes
prefixes = data.get("branch_prefixes") or {}
if isinstance(prefixes, dict):
CONFIG.feature_prefix = str(prefixes.get("feature", CONFIG.feature_prefix))
CONFIG.bugfix_prefix = str(prefixes.get("bugfix", CONFIG.bugfix_prefix))
CONFIG.hotfix_prefix = str(prefixes.get("hotfix", CONFIG.hotfix_prefix))
CONFIG.release_prefix = str(prefixes.get("release", CONFIG.release_prefix))
# Merge-strategieën
strategies = data.get("merge_strategies") or {}
if isinstance(strategies, dict):
CONFIG.use_no_ff_for_feature = strategies.get(
"feature_finish", "no-ff" if CONFIG.use_no_ff_for_feature else "ff"
) == "no-ff"
CONFIG.use_no_ff_for_release = strategies.get(
"release_finish", "no-ff" if CONFIG.use_no_ff_for_release else "ff"
) == "no-ff"
CONFIG.use_no_ff_for_hotfix = strategies.get(
"hotfix_finish", "no-ff" if CONFIG.use_no_ff_for_hotfix else "ff"
) == "no-ff"
# Tag-format
if "tag_format" in data:
CONFIG.tag_format = str(data["tag_format"])
# Cleanup-instellingen
cleanup = data.get("cleanup") or {}
if isinstance(cleanup, dict):
CONFIG.delete_feature_after_finish = bool(
cleanup.get("delete_feature_after_finish", CONFIG.delete_feature_after_finish)
)
CONFIG.delete_bugfix_after_finish = bool(
cleanup.get("delete_bugfix_after_finish", CONFIG.delete_bugfix_after_finish)
)
CONFIG.delete_release_after_finish = bool(
cleanup.get("delete_release_after_finish", CONFIG.delete_release_after_finish)
)
CONFIG.delete_hotfix_after_finish = bool(
cleanup.get("delete_hotfix_after_finish", CONFIG.delete_hotfix_after_finish)
)
# Hooks
hooks = data.get("hooks") or {}
if isinstance(hooks, dict):
normalized: Dict[str, List[str]] = {}
for key, value in hooks.items():
if isinstance(value, list):
normalized[key] = [str(cmd) for cmd in value]
elif isinstance(value, str):
normalized[key] = [value]
CONFIG.hooks = normalized
# Omgevingen en defaults
envs = data.get("environments") or {}
if isinstance(envs, dict):
CONFIG.environments = {str(k): str(v) for k, v in envs.items()}
if "release_default_env" in data:
CONFIG.release_default_env = str(data["release_default_env"])
if "hotfix_default_env" in data:
CONFIG.hotfix_default_env = str(data["hotfix_default_env"])
# Dry-run kan ook via config gezet worden, maar CLI-flag heeft prioriteit;
# daarom overschrijven we hier niet expliciet als het al gezet is.
if "dry_run" in data and not CONFIG.dry_run:
CONFIG.dry_run = bool(data["dry_run"])
return None

116
scripts/git/core/git_api.py Normal file
View File

@@ -0,0 +1,116 @@
from __future__ import annotations
import subprocess
from typing import Iterable
from . import config, output
class GitError(RuntimeError):
pass
def _run_git(args: Iterable[str], *, capture_output: bool = False, check: bool = True) -> subprocess.CompletedProcess:
cmd = ["git", *args]
# Dry-run ondersteuning: voer geen echte git-commando's uit, maar log enkel
# wat er zou gebeuren en geef een "geslaagde" CompletedProcess terug.
if config.CONFIG.dry_run:
output.info(f"[DRY-RUN] zou uitvoeren: {' '.join(cmd)}")
return subprocess.CompletedProcess(
cmd,
0,
stdout="" if capture_output else None,
stderr="",
)
result = subprocess.run(
cmd,
text=True,
capture_output=capture_output,
check=False,
)
if check and result.returncode != 0:
stderr = result.stderr.strip() if result.stderr else ""
raise GitError(f"git {' '.join(args)} failed ({result.returncode}): {stderr}")
return result
def get_current_branch() -> str:
result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], capture_output=True)
return (result.stdout or "").strip()
def is_clean_working_tree() -> bool:
result = _run_git(["status", "--porcelain"], capture_output=True)
return (result.stdout or "").strip() == ""
def ensure_clean_working_tree() -> None:
if not is_clean_working_tree():
raise GitError(
"Working tree is niet clean. Commit of stash je wijzigingen voor je deze actie uitvoert."
)
def ensure_branch_exists(branch: str) -> None:
try:
_run_git(["show-ref", "--verify", f"refs/heads/{branch}"], capture_output=True)
except GitError as exc:
raise GitError(f"Branch '{branch}' bestaat niet lokaal.") from exc
def checkout_branch(branch: str) -> None:
_run_git(["checkout", branch])
def create_branch(branch: str, base: str) -> None:
_run_git(["checkout", base])
_run_git(["checkout", "-b", branch])
def fetch_remote(remote: str) -> None:
_run_git(["fetch", remote])
def ensure_not_behind_remote(branch: str, remote: str) -> None:
"""Controleer of branch niet achterloopt op remote.
We gebruiken `git rev-list --left-right --count local...remote` om
ahead/behind te bepalen.
"""
remote_ref = f"{remote}/{branch}"
try:
result = _run_git(
["rev-list", "--left-right", "--count", f"{branch}...{remote_ref}"],
capture_output=True,
)
except GitError:
# Geen tracking remote; in die gevallen doen we geen check.
return
output_str = (result.stdout or "").strip()
if not output_str:
return
ahead_str, behind_str = output_str.split()
ahead = int(ahead_str)
behind = int(behind_str)
if behind > 0:
raise GitError(
f"Branch '{branch}' loopt {behind} commit(s) achter op {remote_ref}. "
f"Doe eerst een 'git pull --ff-only' of werk te wijzigingen lokaal bij."
)
def list_local_branches_with_prefix(prefix: str) -> list[str]:
"""Geef een gesorteerde lijst van lokale branches die met het prefix starten."""
result = _run_git(
["for-each-ref", "--format=%(refname:short)", "refs/heads"],
capture_output=True,
)
lines = (result.stdout or "").splitlines()
branches = sorted(b for b in (ln.strip() for ln in lines) if b.startswith(prefix))
return branches

43
scripts/git/core/hooks.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import os
import shlex
import subprocess
from typing import Dict
from . import config, output
def run_hooks(event: str, context: Dict[str, str] | None = None) -> None:
"""Voer alle geconfigureerde hooks voor een gegeven event uit.
Hooks worden gedefinieerd in `CONFIG.hooks[event]` als een lijst van
shell-commando's. We geven context door via environment-variabelen.
"""
hooks = config.CONFIG.hooks.get(event) or []
if not hooks:
return
base_env = os.environ.copy()
base_env.setdefault("GITFLOW_EVENT", event)
if context:
for key, value in context.items():
base_env[f"GITFLOW_{key.upper()}"] = value
for cmd in hooks:
# Eenvoudige logging
output.info(f"[HOOK {event}] {cmd}")
if config.CONFIG.dry_run:
output.info("[DRY-RUN] Hook niet echt uitgevoerd.")
continue
# Gebruik shlex.split zodat eenvoudige strings netjes opgesplitst worden.
args = shlex.split(cmd)
result = subprocess.run(args, env=base_env, text=True)
if result.returncode != 0:
raise RuntimeError(
f"Hook voor event '{event}' faalde met exitcode {result.returncode}: {cmd}"
)

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import sys
class _Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
def _print(message: str, *, prefix: str = "", color: str | None = None, stream=None) -> None:
if stream is None:
stream = sys.stdout
text = f"{prefix} {message}" if prefix else message
if color:
text = f"{color}{text}{_Colors.RESET}"
print(text, file=stream)
def info(message: str) -> None:
_print(message, prefix="", color=_Colors.BLUE)
def success(message: str) -> None:
_print(message, prefix="", color=_Colors.GREEN)
def warning(message: str) -> None:
_print(message, prefix="⚠️", color=_Colors.YELLOW, stream=sys.stderr)
def error(message: str) -> None:
_print(message, prefix="", color=_Colors.RED, stream=sys.stderr)
def heading(message: str) -> None:
_print(message, prefix="", color=_Colors.BOLD)
def plain(message: str) -> None:
_print(message)
class Notifier:
"""Abstractielaag voor toekomstige auditieve output.
Voor nu enkel console-notificaties; later kan dit uitgebreid
worden met TTS, systeemmeldingen, ...
"""
@staticmethod
def notify_event(event: str, detail: str | None = None) -> None: # pragma: no cover - placeholder
if detail:
info(f"[{event}] {detail}")
else:
info(f"[{event}]")

10
scripts/git/gitflow Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Kleine wrapper zodat je gewoon `scripts/git/gitflow ...` kunt aanroepen
# zonder expliciet `python` te typen.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
cd "${PROJECT_ROOT}" || exit 1
exec python -m scripts.git.gitflow "$@"

154
scripts/git/gitflow.py Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python
"""Git Flow helper CLI for this repository.
Eerste versie:
- status
- feature start/finish
- bugfix start/finish
Andere flows (release/hotfix, hooks, enz.) volgen later.
"""
from __future__ import annotations
import argparse
import sys
from .core import config as cfg
from .core import output
from .commands import status as status_cmd
from .commands import feature as feature_cmd
from .commands import bugfix as bugfix_cmd
from .commands import release as release_cmd
from .commands import hotfix as hotfix_cmd
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="gitflow",
description="Git Flow helper voor deze repo",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Toon welke git-commando's uitgevoerd zouden worden, zonder echt te veranderen",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# status
status_parser = subparsers.add_parser("status", help="Toon huidige branch en repo-status")
status_parser.set_defaults(func=status_cmd.handle_status)
# feature
feature_parser = subparsers.add_parser("feature", help="Feature branches beheren")
feature_sub = feature_parser.add_subparsers(dest="action", required=True)
feature_start = feature_sub.add_parser("start", help="Start een nieuwe feature branch vanaf develop")
feature_start.add_argument("name", help="Naam van de feature (zonder prefix)")
feature_start.set_defaults(func=feature_cmd.handle_feature_start)
feature_finish = feature_sub.add_parser("finish", help="Merge een feature branch terug naar develop")
feature_finish.add_argument(
"name",
nargs="?",
help="Naam van de feature (zonder prefix). Laat leeg om huidige branch te gebruiken.",
)
feature_finish.set_defaults(func=feature_cmd.handle_feature_finish)
# bugfix
bugfix_parser = subparsers.add_parser("bugfix", help="Bugfix branches beheren (op develop)")
bugfix_sub = bugfix_parser.add_subparsers(dest="action", required=True)
bugfix_start = bugfix_sub.add_parser("start", help="Start een nieuwe bugfix branch vanaf develop")
bugfix_start.add_argument("name", help="Naam van de bugfix (zonder prefix)")
bugfix_start.set_defaults(func=bugfix_cmd.handle_bugfix_start)
bugfix_finish = bugfix_sub.add_parser("finish", help="Merge een bugfix branch terug naar develop")
bugfix_finish.add_argument(
"name",
nargs="?",
help="Naam van de bugfix (zonder prefix). Laat leeg om huidige branch te gebruiken.",
)
bugfix_finish.set_defaults(func=bugfix_cmd.handle_bugfix_finish)
# release
release_parser = subparsers.add_parser("release", help="Release branches beheren (main <-> develop)")
release_sub = release_parser.add_subparsers(dest="action", required=True)
release_start = release_sub.add_parser("start", help="Start een nieuwe release branch vanaf develop")
release_start.add_argument(
"version",
nargs="?",
help="Versienummer (bijv. 1.2.0). Laat leeg om interactief in te geven.",
)
release_start.set_defaults(func=release_cmd.handle_release_start)
release_finish = release_sub.add_parser("finish", help="Voltooi een release: merge naar main en develop + tag")
release_finish.add_argument(
"version",
nargs="?",
help="Versienummer (bijv. 1.2.0). Laat leeg om af te leiden van de huidige branch of interactief te vragen.",
)
release_finish.add_argument(
"--env",
dest="env",
help="Omgeving voor tagging (bijv. test, staging, production). Laat leeg voor default uit config.",
)
release_finish.set_defaults(func=release_cmd.handle_release_finish)
# hotfix
hotfix_parser = subparsers.add_parser("hotfix", help="Hotfix branches beheren (vanaf main)")
hotfix_sub = hotfix_parser.add_subparsers(dest="action", required=True)
hotfix_start = hotfix_sub.add_parser("start", help="Start een nieuwe hotfix branch vanaf main")
hotfix_start.add_argument("name", help="Naam of versie van de hotfix (zonder prefix)")
hotfix_start.set_defaults(func=hotfix_cmd.handle_hotfix_start)
hotfix_finish = hotfix_sub.add_parser("finish", help="Voltooi een hotfix: merge naar main en develop + tag")
hotfix_finish.add_argument(
"name",
nargs="?",
help="Naam of versie van de hotfix (zonder prefix). Laat leeg om huidige branch te gebruiken.",
)
hotfix_finish.add_argument(
"--env",
dest="env",
help="Omgeving voor tagging (bijv. test, staging, production). Laat leeg voor default uit config.",
)
hotfix_finish.set_defaults(func=hotfix_cmd.handle_hotfix_finish)
return parser
def main(argv: list[str] | None = None) -> int:
if argv is None:
argv = sys.argv[1:]
parser = _build_parser()
try:
args = parser.parse_args(argv)
except SystemExit as exc: # argparse gebruikt SystemExit
return exc.code
# Dry-run vlag doorgeven aan configuratie *voor* we commands uitvoeren
cfg.CONFIG.dry_run = bool(getattr(args, "dry_run", False))
cfg.load_config() # later kan dit config-bestand inladen en overrides toepassen
func = getattr(args, "func", None)
if func is None:
parser.print_help()
return 1
try:
return func(args)
except KeyboardInterrupt:
output.error("Afgebroken door gebruiker (Ctrl+C)")
return 130
if __name__ == "__main__": # pragma: no cover - directe CLI entry
raise SystemExit(main())