Compare commits
25 Commits
feature/Ad
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 92edbeacb2 | |||
|
|
30bfecc135 | ||
|
|
2c8347c91b | ||
|
|
fe9fc047ff | ||
|
|
0f8bda0aef | ||
|
|
bab9e89117 | ||
|
|
e25698d6cf | ||
|
|
e30fe7807c | ||
|
|
94b805e0eb | ||
|
|
9b86a220b1 | ||
|
|
5a5d6b03af | ||
|
|
b1d8c9a17d | ||
|
|
14273b8a70 | ||
|
|
5e25216b66 | ||
|
|
d68dfde52a | ||
|
|
4bc2292c4c | ||
|
|
f10bb6f395 | ||
|
|
0d3c3949de | ||
|
|
25adb4213b | ||
|
|
73125887a3 | ||
|
|
c29ed37c09 | ||
|
|
9b1f9e8a3b | ||
|
|
e167df3032 | ||
|
|
20fb2eee70 | ||
|
|
3815399a7e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -58,3 +58,5 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc
|
||||
/docker/build_logs/
|
||||
/content/.Ulysses-Group.plist
|
||||
/content/.Ulysses-Settings.plist
|
||||
/.python-version
|
||||
/q
|
||||
|
||||
@@ -36,7 +36,10 @@ def get_default_chat_customisation(tenant_customisation=None):
|
||||
'ai_message_text_color': '#212529',
|
||||
'human_message_background': '#212529',
|
||||
'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
|
||||
|
||||
@@ -92,6 +92,13 @@ class EveAINoActiveLicense(EveAIException):
|
||||
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):
|
||||
"""Raised when a catalog cannot be found"""
|
||||
|
||||
|
||||
@@ -35,13 +35,14 @@ def is_valid_tenant(tenant_id):
|
||||
if tenant_id == 1: # The 'root' tenant, is always valid
|
||||
return True
|
||||
tenant = Tenant.query.get(tenant_id)
|
||||
Database(tenant).switch_schema()
|
||||
|
||||
if tenant is None:
|
||||
raise EveAITenantNotFound()
|
||||
elif tenant.type == 'Inactive':
|
||||
raise EveAITenantInvalid(tenant_id)
|
||||
else:
|
||||
current_date = dt.now(tz=tz.utc).date()
|
||||
Database(str(tenant_id)).switch_schema()
|
||||
# TODO -> Check vervangen door Active License Period!
|
||||
# active_license = (License.query.filter_by(tenant_id=tenant_id)
|
||||
# .filter(and_(License.start_date <= current_date,
|
||||
|
||||
@@ -87,6 +87,21 @@ configuration:
|
||||
description: "Human Message Inactive Text Color"
|
||||
type: "color"
|
||||
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:
|
||||
author: "Josako"
|
||||
date_added: "2024-06-06"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"dist/chat-client.js": "dist/chat-client.f7134231.js",
|
||||
"dist/chat-client.css": "dist/chat-client.99e10656.css",
|
||||
"dist/chat-client.js": "dist/chat-client.825210dd.js",
|
||||
"dist/chat-client.css": "dist/chat-client.568d7be7.css",
|
||||
"dist/main.js": "dist/main.6a617099.js",
|
||||
"dist/main.css": "dist/main.06893f70.css"
|
||||
"dist/main.css": "dist/main.7182aac3.css"
|
||||
}
|
||||
@@ -10,11 +10,22 @@ task_description: >
|
||||
€€€{history}€€€
|
||||
|
||||
(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:
|
||||
- Affirmative answers: e.g. Yes, OK, Sure, Of Course
|
||||
- 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 there’s 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}!
|
||||
|
||||
|
||||
@@ -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/),
|
||||
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
|
||||
|
||||
Release date:
|
||||
Release date: 2025-11-13
|
||||
|
||||
### Added
|
||||
- human_message_inactive_text_color added to Tenant Make configuration options, to allow for customisation of inactive messages in the chat client.
|
||||
|
||||
30
eveai_app/static/assets/css/eveai-content-viewer.css
Normal file
30
eveai_app/static/assets/css/eveai-content-viewer.css
Normal 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);
|
||||
}
|
||||
@@ -313,5 +313,21 @@ input[type="radio"] {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
597
eveai_app/static/assets/css/nucleo-icons.css
Normal file
597
eveai_app/static/assets/css/nucleo-icons.css
Normal 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 */
|
||||
135
eveai_app/static/assets/css/nucleo-svg.css
Normal file
135
eveai_app/static/assets/css/nucleo-svg.css
Normal 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);
|
||||
}
|
||||
@@ -363,50 +363,67 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Zoek buttons in de volledige formulier context (niet alleen de container)
|
||||
const form = document.getElementById(`${containerId}-form`) || document.querySelector('form');
|
||||
const buttons = form ? form.querySelectorAll('button[onclick*="handleListViewAction"]') :
|
||||
document.querySelectorAll('button[onclick*="handleListViewAction"]');
|
||||
// Zoek buttons zo dicht mogelijk bij de tabel met gegeven containerId
|
||||
// 1. Probeer eerst een expliciet formulier met id="<table_id>-form"
|
||||
let scopeEl = document.getElementById(`${containerId}-form`);
|
||||
|
||||
// 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);
|
||||
|
||||
buttons.forEach(button => {
|
||||
// Parse the onclick attribute to get the action value and requiresSelection parameter
|
||||
const onclickAttr = button.getAttribute('onclick');
|
||||
const match = onclickAttr.match(/handleListViewAction\('([^']+)',\s*(true|false)\)/i);
|
||||
if (match) {
|
||||
const actionValue = match[1];
|
||||
const requiresSelection = match[2].toLowerCase() === 'true';
|
||||
const onclickAttr = button.getAttribute('onclick') || '';
|
||||
console.log('ListView _updateActionButtons: inspect button onclickAttr =', onclickAttr);
|
||||
|
||||
// Direct toepassen van requiresSelection-check
|
||||
if (requiresSelection) {
|
||||
// Controleer of er een geselecteerde rij is
|
||||
const isDisabled = !instance.selectedRow;
|
||||
button.disabled = isDisabled;
|
||||
// Robuustere regex: sta spaties toe en zowel enkele als dubbele quotes
|
||||
const match = onclickAttr.match(/handleListViewAction\s*\(\s*['"]([^'"\\]+)['"]\s*,\s*(true|false)/i);
|
||||
if (!match) {
|
||||
console.warn('ListView _updateActionButtons: geen match gevonden voor handleListViewAction-pattern op deze button');
|
||||
return;
|
||||
}
|
||||
|
||||
// Voeg/verwijder disabled class voor styling
|
||||
if (isDisabled) {
|
||||
button.classList.add('disabled');
|
||||
} else {
|
||||
button.classList.remove('disabled');
|
||||
}
|
||||
const actionValue = match[1];
|
||||
const requiresSelection = match[2].toLowerCase() === 'true';
|
||||
|
||||
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)
|
||||
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;
|
||||
console.log(`Button ${actionValue} updated: 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)
|
||||
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
|
||||
if (isDisabled) {
|
||||
button.classList.add('disabled');
|
||||
} else {
|
||||
button.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block 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 %}
|
||||
<div class="container-fluid px-0">
|
||||
@@ -31,11 +31,6 @@
|
||||
Configuration
|
||||
</a>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -48,17 +43,17 @@
|
||||
{% endfor %}
|
||||
|
||||
<!-- Overview Section -->
|
||||
{# <div class="row mb-4">#}
|
||||
{# <div class="col-12">#}
|
||||
{# <div class="card">#}
|
||||
{# <div class="card-body">#}
|
||||
{# <div class="specialist-overview" id="specialist-svg">#}
|
||||
{# <img src="{{ svg_path }}" alt="Specialist Overview" class="w-100">#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# <div class="row mb-4">#}
|
||||
{# <div class="col-12">#}
|
||||
{# <div class="card">#}
|
||||
{# <div class="card-body">#}
|
||||
{# <div class="specialist-overview" id="specialist-svg">#}
|
||||
{# <img src="{{ svg_path }}" alt="Specialist Overview" class="w-100">#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
{# </div>#}
|
||||
</div>
|
||||
|
||||
<!-- Configuration Tab -->
|
||||
@@ -72,29 +67,6 @@
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</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>
|
||||
@@ -105,299 +77,8 @@
|
||||
</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 %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ 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 %}
|
||||
|
||||
257
eveai_app/templates/interaction/tune_specialist.html
Normal file
257
eveai_app/templates/interaction/tune_specialist.html
Normal 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 %}
|
||||
@@ -11,18 +11,9 @@
|
||||
{{ form.hidden_tag() }}
|
||||
{% set disabled_fields = [] %}
|
||||
{% set exclude_fields = [] %}
|
||||
{% for field in form.get_static_fields() %}
|
||||
{% for field in form %}
|
||||
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||
{% endfor %}
|
||||
<!-- Render Dynamic Fields -->
|
||||
{% for collection_name, fields in form.get_dynamic_fields().items() %}
|
||||
{% if fields|length > 0 %}
|
||||
<h4 class="mt-4">{{ collection_name }}</h4>
|
||||
{% endif %}
|
||||
{% for field in fields %}
|
||||
{{ render_field(field, disabled_fields, exclude_fields) }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-primary">Register Tenant Make</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -119,7 +119,7 @@ def edit_catalog(catalog_id):
|
||||
catalog = Catalog.query.get_or_404(catalog_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)
|
||||
if request.method == 'POST' and form.validate_on_submit():
|
||||
form.populate_obj(catalog)
|
||||
@@ -190,7 +190,7 @@ def edit_processor(processor_id):
|
||||
processor.catalog = None
|
||||
|
||||
# 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)
|
||||
form.add_dynamic_fields("configuration", full_config, processor.configuration)
|
||||
@@ -290,7 +290,7 @@ def edit_retriever(retriever_id):
|
||||
retriever = Retriever.query.get_or_404(retriever_id)
|
||||
|
||||
# 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)
|
||||
form.add_dynamic_fields("configuration", retriever_config, retriever.configuration)
|
||||
@@ -575,7 +575,7 @@ def edit_document(document_id):
|
||||
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||
def edit_document_version(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)
|
||||
catalog_id = doc_vers.document.catalog_id
|
||||
|
||||
@@ -96,14 +96,42 @@ class OrderedListField(TextAreaField):
|
||||
|
||||
|
||||
class DynamicFormBase(FlaskForm):
|
||||
def __init__(self, formdata=None, *args, **kwargs):
|
||||
# Belangrijk: formdata doorgeven aan FlaskForm zodat WTForms POST-data kan binden
|
||||
super(DynamicFormBase, self).__init__(formdata=formdata, *args, **kwargs)
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Base class voor dynamische formulieren.
|
||||
|
||||
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
|
||||
self.dynamic_fields = {}
|
||||
# Store formdata for later use
|
||||
self.formdata = formdata
|
||||
self.raw_formdata = request.form.to_dict()
|
||||
|
||||
# Bepaal effectieve formdata voor intern gebruik.
|
||||
# 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):
|
||||
"""Create validators based on field definition"""
|
||||
|
||||
@@ -165,8 +165,8 @@ class EditEveAIAgentForm(BaseEditComponentForm):
|
||||
|
||||
|
||||
class EditEveAITaskForm(BaseEditComponentForm):
|
||||
task_description = StringField('Task Description', validators=[Optional()])
|
||||
expected_outcome = StringField('Expected Outcome', validators=[Optional()])
|
||||
task_description = TextAreaField('Task Description', validators=[Optional()])
|
||||
expected_outcome = TextAreaField('Expected Outcome', validators=[Optional()])
|
||||
|
||||
|
||||
class EditEveAIToolForm(BaseEditComponentForm):
|
||||
|
||||
@@ -199,7 +199,7 @@ def specialist():
|
||||
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||
def edit_specialist(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)
|
||||
form.add_dynamic_fields("configuration", specialist_config, specialist.configuration)
|
||||
@@ -282,26 +282,44 @@ def edit_specialist(specialist_id):
|
||||
else:
|
||||
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',
|
||||
form=form,
|
||||
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,
|
||||
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'])
|
||||
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||
def specialists():
|
||||
@@ -323,6 +341,8 @@ def handle_specialist_selection():
|
||||
|
||||
if action == "edit_specialist":
|
||||
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":
|
||||
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):
|
||||
task = EveAITask.query.get_or_404(task_id) if task_id else EveAITask()
|
||||
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():
|
||||
try:
|
||||
@@ -705,7 +725,7 @@ def specialist_magic_link():
|
||||
def edit_specialist_magic_link(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
|
||||
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
|
||||
specialist = Specialist.query.get_or_404(specialist_ml.specialist_id)
|
||||
|
||||
@@ -38,6 +38,7 @@ def get_specialists_list_view():
|
||||
# Action definitions
|
||||
actions = [
|
||||
{'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': 'create_specialist', 'text': 'Register Specialist', 'class': 'btn-success', 'position': 'right', 'requiresSelection': False}
|
||||
]
|
||||
|
||||
@@ -145,7 +145,7 @@ def edit_partner_service(partner_service_id):
|
||||
partner_service = PartnerService.query.get_or_404(partner_service_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.type_version)
|
||||
form.add_dynamic_fields("configuration", partner_service_config, partner_service.configuration)
|
||||
|
||||
@@ -12,7 +12,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from common.models.user import User, ConsentStatus
|
||||
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 eveai_app.views.security_forms import SetPasswordForm, ResetPasswordForm, ForgotPasswordForm
|
||||
from common.extensions import db
|
||||
@@ -46,6 +46,14 @@ def login():
|
||||
user = User.query.filter_by(email=form.email.data).first()
|
||||
if user is None or not verify_and_update_password(form.password.data, user):
|
||||
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)
|
||||
except EveAIException as e:
|
||||
flash(f'Failed to login user: {str(e)}', 'danger')
|
||||
|
||||
@@ -177,7 +177,7 @@ def validate_make_name(form, field):
|
||||
raise ValidationError(f'A Make with name "{field.data}" already exists. Choose another name.')
|
||||
|
||||
|
||||
class TenantMakeForm(DynamicFormBase):
|
||||
class TenantMakeForm(FlaskForm):
|
||||
name = StringField('Name', validators=[DataRequired(), Length(max=50), validate_make_name])
|
||||
description = TextAreaField('Description', validators=[Optional()])
|
||||
active = BooleanField('Active', validators=[Optional()], default=True)
|
||||
|
||||
@@ -262,6 +262,25 @@ def user():
|
||||
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'])
|
||||
@roles_accepted('Super User', 'Tenant Admin', 'Partner Admin')
|
||||
def edit_user(user_id):
|
||||
@@ -269,10 +288,8 @@ def edit_user(user_id):
|
||||
form = EditUserForm(obj=user)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Populate the user with form data
|
||||
user.first_name = form.first_name.data
|
||||
user.last_name = form.last_name.data
|
||||
user.valid_to = form.valid_to.data
|
||||
# Vul het user-object met veilige velden uit het formulier
|
||||
_populate_user_from_form(form, user)
|
||||
user.updated_at = dt.now(tz.utc)
|
||||
|
||||
# Update roles
|
||||
@@ -577,8 +594,6 @@ def delete_tenant_project(tenant_project_id):
|
||||
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||
def tenant_make():
|
||||
form = TenantMakeForm()
|
||||
customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION")
|
||||
default_customisation_options = create_default_config_from_type_config(customisation_config["configuration"])
|
||||
|
||||
if form.validate_on_submit():
|
||||
tenant_id = session['tenant']['id']
|
||||
@@ -595,7 +610,7 @@ def tenant_make():
|
||||
flash('Tenant Make successfully added!', 'success')
|
||||
current_app.logger.info(f'Tenant Make {new_tenant_make.name}, id {new_tenant_make.id} successfully added '
|
||||
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))
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
@@ -621,8 +636,10 @@ def edit_tenant_make(tenant_make_id):
|
||||
# Get the tenant make or return 404
|
||||
tenant_make = TenantMake.query.get_or_404(tenant_make_id)
|
||||
|
||||
# Create form instance with the tenant make
|
||||
form = EditTenantMakeForm(request.form, obj=tenant_make)
|
||||
# Create form instance with the 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
|
||||
if request.method == 'GET':
|
||||
@@ -756,7 +773,7 @@ def edit_consent_version(consent_version_id):
|
||||
cv = ConsentVersion.query.get_or_404(consent_version_id)
|
||||
|
||||
# Create form instance with the tenant make
|
||||
form = EditConsentVersionForm(request.form, obj=cv)
|
||||
form = EditConsentVersionForm(obj=cv)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Update basic fields
|
||||
|
||||
@@ -9,14 +9,23 @@
|
||||
--message-bot-bg: #f8f9fa;
|
||||
--border-radius: 8px;
|
||||
--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 {
|
||||
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;
|
||||
height: calc(var(--vvh, 1vh) * 100);
|
||||
height: calc(var(--safe-vh, var(--vvh, 1vh)) * 100);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -86,7 +95,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: auto; /* prefer dynamic viewport on desktop */
|
||||
height: auto; /* desktop: dynamische hoogte, op mobiel overschreven */
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
@@ -96,8 +105,29 @@
|
||||
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 {
|
||||
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 */
|
||||
|
||||
@@ -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;
|
||||
@@ -1,38 +1,106 @@
|
||||
active_text_color<template>
|
||||
<div class="chat-app-container">
|
||||
<!-- Message History - takes available space -->
|
||||
<message-history
|
||||
:messages="displayMessages"
|
||||
:is-typing="isTyping"
|
||||
:is-submitting-form="isSubmittingForm"
|
||||
:api-prefix="apiPrefix"
|
||||
:auto-scroll="true"
|
||||
@specialist-error="handleSpecialistError"
|
||||
@specialist-complete="handleSpecialistComplete"
|
||||
ref="messageHistory"
|
||||
class="chat-messages-area"
|
||||
></message-history>
|
||||
<!-- Desktop layout: huidige gedrag behouden -->
|
||||
<div v-if="!isMobileFallback" class="chat-desktop-layout">
|
||||
<message-history
|
||||
:messages="displayMessages"
|
||||
:is-typing="isTyping"
|
||||
:is-submitting-form="isSubmittingForm"
|
||||
:api-prefix="apiPrefix"
|
||||
:auto-scroll="true"
|
||||
@specialist-error="handleSpecialistError"
|
||||
@specialist-complete="handleSpecialistComplete"
|
||||
ref="messageHistory"
|
||||
class="chat-messages-area"
|
||||
></message-history>
|
||||
|
||||
<!-- Chat Input - to the bottom -->
|
||||
<chat-input
|
||||
:current-message="currentMessage"
|
||||
:is-loading="isLoading"
|
||||
:max-length="2000"
|
||||
:allow-file-upload="true"
|
||||
:allow-voice-message="false"
|
||||
:form-data="currentInputFormData"
|
||||
: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"
|
||||
></chat-input>
|
||||
<chat-input
|
||||
:current-message="currentMessage"
|
||||
:is-loading="isLoading"
|
||||
:max-length="2000"
|
||||
:allow-file-upload="true"
|
||||
:allow-voice-message="false"
|
||||
: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"
|
||||
></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
|
||||
@@ -60,6 +128,9 @@ import ProgressTracker from './ProgressTracker.vue';
|
||||
import LanguageSelector from './LanguageSelector.vue';
|
||||
import ChatInput from './ChatInput.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 { createLanguageProvider, LANGUAGE_PROVIDER_KEY } from '../js/services/LanguageProvider.js';
|
||||
@@ -77,7 +148,10 @@ export default {
|
||||
MessageHistory,
|
||||
ProgressTracker,
|
||||
ChatInput,
|
||||
ContentModal
|
||||
ContentModal,
|
||||
SideBarLogo,
|
||||
MobileTabBar,
|
||||
SideBarMobileSetup
|
||||
},
|
||||
|
||||
setup() {
|
||||
@@ -111,6 +185,7 @@ export default {
|
||||
return {
|
||||
// Tenant info
|
||||
tenantName: tenantMake.name || 'EveAI',
|
||||
tenantSubtitle: tenantMake.subtitle || '',
|
||||
tenantLogoUrl: tenantMake.logo_url || '',
|
||||
|
||||
// Taal gerelateerde data
|
||||
@@ -147,10 +222,13 @@ export default {
|
||||
autoScroll: settings.autoScroll === true
|
||||
},
|
||||
|
||||
// UI state
|
||||
isMobile: window.innerWidth <= 768,
|
||||
// UI state (fallback flags voor oudere logica)
|
||||
isMobileFallback: window.innerWidth <= 768,
|
||||
showSidebar: window.innerWidth > 768,
|
||||
|
||||
// Mobile tab state
|
||||
activeTabId: 'chat',
|
||||
|
||||
// Advanced features
|
||||
messageSearch: '',
|
||||
filteredMessages: [],
|
||||
@@ -193,16 +271,57 @@ export default {
|
||||
return this.supportedLanguages.filter(lang =>
|
||||
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() {
|
||||
this.initializeChat();
|
||||
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() {
|
||||
this.cleanup();
|
||||
|
||||
if (this.globalTabListener) {
|
||||
document.removeEventListener('evie-chat-set-tab', this.globalTabListener);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -453,11 +572,13 @@ export default {
|
||||
}
|
||||
});
|
||||
|
||||
// Window resize listener
|
||||
window.addEventListener('resize', () => {
|
||||
this.isMobile = window.innerWidth <= 768;
|
||||
// Window resize listener voor fallback flags
|
||||
this.handleResize = () => {
|
||||
this.isMobileFallback = window.innerWidth <= 768;
|
||||
this.showSidebar = window.innerWidth > 768;
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
},
|
||||
|
||||
cleanup() {
|
||||
@@ -516,6 +637,12 @@ export default {
|
||||
this.isLoading = false;
|
||||
},
|
||||
|
||||
handleLanguageChangedFromSetup(newLanguage) {
|
||||
// Update lokale taalstate; verdere effecten worden opgepikt door
|
||||
// bestaande global listener en LanguageProvider / chatConfig.
|
||||
this.currentLanguage = newLanguage;
|
||||
},
|
||||
|
||||
// UI helpers
|
||||
scrollToBottom() {
|
||||
if (this.$refs.messageHistory) {
|
||||
@@ -560,7 +687,7 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
/* height: 100%; avoided to let flex sizing control height */
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
max-width: 1000px;
|
||||
@@ -571,6 +698,84 @@ export default {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0; /* ensure child can scroll */
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
:is-latest-ai-message="true"
|
||||
:is-in-input-area="true"
|
||||
@image-loaded="handleImageLoaded"
|
||||
@specialist-complete="$emit('specialist-complete', $event)"
|
||||
@specialist-error="$emit('specialist-error', $event)"
|
||||
@specialist-complete="handleSpecialistCompleteFromActiveMessage"
|
||||
@specialist-error="handleSpecialistErrorFromActiveMessage"
|
||||
></chat-message>
|
||||
</div>
|
||||
|
||||
@@ -183,22 +183,22 @@ export default {
|
||||
watch: {
|
||||
formData: {
|
||||
handler(newFormData, oldFormData) {
|
||||
console.log('ChatInput formData changed:', newFormData);
|
||||
console.log('🧐 [ChatInput] formData changed:', newFormData);
|
||||
|
||||
if (!newFormData) {
|
||||
console.log('FormData is null of undefined');
|
||||
console.log('🧐 [ChatInput] formData is null of undefined');
|
||||
this.formValues = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// Controleer of velden aanwezig zijn
|
||||
if (!newFormData.fields) {
|
||||
console.error('FormData bevat geen velden!', newFormData);
|
||||
console.error('🧐 [ChatInput] formData bevat geen velden!', newFormData);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Velden in formData:', newFormData.fields);
|
||||
console.log('Aantal velden:', Array.isArray(newFormData.fields)
|
||||
console.log('🧐 [ChatInput] velden in formData:', newFormData.fields);
|
||||
console.log('🧐 [ChatInput] aantal velden:', Array.isArray(newFormData.fields)
|
||||
? newFormData.fields.length
|
||||
: Object.keys(newFormData.fields).length);
|
||||
|
||||
@@ -206,7 +206,7 @@ export default {
|
||||
this.initFormValues();
|
||||
|
||||
// Log de geïnitialiseerde waarden
|
||||
console.log('Formulierwaarden geïnitialiseerd:', this.formValues);
|
||||
console.log('🧐 [ChatInput] formulierwaarden geïnitialiseerd:', this.formValues);
|
||||
},
|
||||
immediate: true,
|
||||
deep: true
|
||||
@@ -251,6 +251,15 @@ export default {
|
||||
window.removeEventListener('resize', this.autoResize);
|
||||
},
|
||||
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) {
|
||||
if (event.detail && event.detail.language) {
|
||||
this.translatePlaceholder(event.detail.language);
|
||||
@@ -452,6 +461,7 @@ export default {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Input veld en knoppen */
|
||||
|
||||
@@ -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 {
|
||||
max-height: 33vh; /* fallback */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain; /* prevent scroll chaining to parent */
|
||||
-webkit-overflow-scrolling: touch; /* iOS smooth inertia */
|
||||
max-height: 33vh; /* fallback */
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain; /* prevent scroll chaining to parent */
|
||||
-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.sticky-area .message-content {
|
||||
max-height: 50vh; /* fallback */
|
||||
max-height: 50vh; /* fallback */
|
||||
}
|
||||
|
||||
@supports (max-height: 1svh) {
|
||||
.message .message-content { max-height: 33svh; }
|
||||
.message.input-area .message-content,
|
||||
.message.sticky-area .message-content { max-height: 50svh; }
|
||||
.message .message-content {
|
||||
/* Gebruik veilige viewporthoogte die door useChatViewport gezet wordt */
|
||||
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>
|
||||
50
eveai_chat_client/static/assets/vue-components/ChatRoot.vue
Normal file
50
eveai_chat_client/static/assets/vue-components/ChatRoot.vue
Normal 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>
|
||||
@@ -37,7 +37,7 @@ export default {
|
||||
const source = (this.template || '');
|
||||
|
||||
// 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 = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
@@ -62,8 +62,17 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
emitClick(kind) {
|
||||
if (kind === 'dpa') this.$emit('open-dpa');
|
||||
if (kind === 'terms') this.$emit('open-terms');
|
||||
// Debug logging to trace click events for consent links
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
501
eveai_chat_client/static/assets/vue-components/CoreChatApp.vue
Normal file
501
eveai_chat_client/static/assets/vue-components/CoreChatApp.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -19,7 +19,7 @@
|
||||
:field-id="field.id || field.name"
|
||||
:model-value="localFormValues[field.id || field.name]"
|
||||
@update:model-value="updateFieldValue(field.id || field.name, $event)"
|
||||
@open-privacy-modal="openPrivacyModal"
|
||||
@open-dpa-modal="openDpaModal"
|
||||
@open-terms-modal="openTermsModal"
|
||||
@keydown-enter="handleEnterKey"
|
||||
/>
|
||||
@@ -32,7 +32,7 @@
|
||||
:field-id="fieldId"
|
||||
:model-value="localFormValues[fieldId]"
|
||||
@update:model-value="updateFieldValue(fieldId, $event)"
|
||||
@open-privacy-modal="openPrivacyModal"
|
||||
@open-dpa-modal="openDpaModal"
|
||||
@open-terms-modal="openTermsModal"
|
||||
@keydown-enter="handleEnterKey"
|
||||
/>
|
||||
@@ -199,12 +199,19 @@ export default {
|
||||
// Basic validation - check required fields
|
||||
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)) {
|
||||
// Valideer array-gebaseerde velden
|
||||
this.formData.fields.forEach(field => {
|
||||
const fieldId = field.id || field.name;
|
||||
const value = this.localFormValues[fieldId];
|
||||
|
||||
// Basis required-validatie
|
||||
if (field.required) {
|
||||
const value = this.localFormValues[fieldId];
|
||||
// Voor boolean velden is false een geldige waarde
|
||||
if (field.type === 'boolean') {
|
||||
// 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 {
|
||||
// Valideer object-gebaseerde velden
|
||||
Object.entries(this.formData.fields).forEach(([fieldId, field]) => {
|
||||
const value = this.localFormValues[fieldId];
|
||||
|
||||
if (field.required) {
|
||||
const value = this.localFormValues[fieldId];
|
||||
// Voor boolean velden is false een geldige waarde
|
||||
if (field.type === 'boolean') {
|
||||
// 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
|
||||
titleDisplayMode() {
|
||||
@@ -479,11 +512,13 @@ export default {
|
||||
},
|
||||
|
||||
// Modal handling methods
|
||||
openPrivacyModal() {
|
||||
openDpaModal() {
|
||||
console.log('[DynamicForm] openDpaModal called');
|
||||
this.loadContent('dpa');
|
||||
},
|
||||
|
||||
openTermsModal() {
|
||||
console.log('[DynamicForm] openTermsModal called');
|
||||
this.loadContent('terms');
|
||||
},
|
||||
|
||||
@@ -505,6 +540,8 @@ export default {
|
||||
const title = contentType === 'dpa' ? 'Data Privacy Agreement' : 'Terms & Conditions';
|
||||
const contentUrl = `${this.apiPrefix}/${contentType}`;
|
||||
|
||||
console.log('[DynamicForm] Loading content from:', contentUrl);
|
||||
|
||||
// Use the composable to show modal and load content
|
||||
await this.contentModal.showModal({
|
||||
title: title,
|
||||
@@ -514,11 +551,19 @@ export default {
|
||||
|
||||
// Handle Enter key press in form fields
|
||||
handleEnterKey(event) {
|
||||
console.log('DynamicForm: Enter event received, emitting form-enter-pressed');
|
||||
console.log('DynamicForm: Enter event received');
|
||||
// Prevent default form submission
|
||||
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
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
:template="texts.consentRich"
|
||||
:aria-privacy="texts.ariaPrivacy || 'Open dpa statement in a dialog'"
|
||||
:aria-terms="texts.ariaTerms || 'Open terms and conditions in a dialog'"
|
||||
@open-privacy="openPrivacyModal"
|
||||
@open-dpa="openDpaModal"
|
||||
@open-terms="openTermsModal"
|
||||
/>
|
||||
<span v-if="field.required" class="required" style="color: #d93025; margin-left: 2px;">*</span>
|
||||
@@ -234,7 +234,7 @@ export default {
|
||||
texts() {
|
||||
// Validate that consentRich exists and includes both required tags; otherwise fallback to English base
|
||||
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);
|
||||
|
||||
// 1) Prefer backend-provided rich string on the field's meta (already localized)
|
||||
@@ -331,10 +331,12 @@ export default {
|
||||
this.value = file;
|
||||
}
|
||||
},
|
||||
openPrivacyModal() {
|
||||
openDpaModal() {
|
||||
console.log('[FormField] openDpaModal emitting open-dpa-modal');
|
||||
this.$emit('open-dpa-modal');
|
||||
},
|
||||
openTermsModal() {
|
||||
console.log('[FormField] openTermsModal emitting open-terms-modal');
|
||||
this.$emit('open-terms-modal');
|
||||
},
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
:api-prefix="apiPrefix"
|
||||
:is-latest-ai-message="isLatestAiMessage(message)"
|
||||
@image-loaded="handleImageLoaded"
|
||||
@specialist-complete="$emit('specialist-complete', $event)"
|
||||
@specialist-error="$emit('specialist-error', $event)"
|
||||
@specialist-complete="handleSpecialistCompleteFromMessage"
|
||||
@specialist-error="handleSpecialistErrorFromMessage"
|
||||
></chat-message>
|
||||
</template>
|
||||
</template>
|
||||
@@ -173,6 +173,15 @@ export default {
|
||||
if (this._resizeObserver) this._resizeObserver.disconnect();
|
||||
},
|
||||
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) {
|
||||
// Controleer of dit het eerste bericht is in een gesprek met maar één bericht
|
||||
if (this.messages.length === 1 && this.messages[0].sender === 'ai') {
|
||||
|
||||
@@ -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>
|
||||
@@ -5,22 +5,11 @@
|
||||
:make-name="tenantMake.name"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import SideBarLogo from './SideBarLogo.vue';
|
||||
import LanguageSelector from './LanguageSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
tenantMake: {
|
||||
@@ -49,45 +38,21 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['language-changed']);
|
||||
|
||||
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);
|
||||
};
|
||||
// Mobile header toont enkel het logo; taalkeuze gebeurt via de Setup-tab.
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* allow wrapping to next line on narrow screens */
|
||||
padding: 10px 15px;
|
||||
background: var(--sidebar-background);
|
||||
color: var(--sidebar-color);
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
min-height: 60px;
|
||||
max-width: 100%; /* never exceed viewport width */
|
||||
overflow: hidden; /* clip any accidental overflow */
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Mobile logo container - meer specifieke styling */
|
||||
@@ -129,34 +94,6 @@ const handleLanguageChange = (newLanguage) => {
|
||||
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 (max-width: 768px) {
|
||||
|
||||
113
eveai_chat_client/static/assets/vue-components/MobileTabBar.vue
Normal file
113
eveai_chat_client/static/assets/vue-components/MobileTabBar.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -44,6 +44,11 @@
|
||||
--human-message-background: {{ customisation.human_message_background|default('#ffffff') }};
|
||||
--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>
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ def translate():
|
||||
}), 500
|
||||
|
||||
|
||||
@chat_bp.route('/privacy', methods=['GET'])
|
||||
@chat_bp.route('/dpa', methods=['GET'])
|
||||
def privacy_statement():
|
||||
"""
|
||||
Public AJAX endpoint for dpa statement content
|
||||
|
||||
@@ -10,7 +10,6 @@ import '../../../eveai_chat_client/static/assets/css/form-message.css';
|
||||
// Dependencies
|
||||
import { createApp, version } from 'vue';
|
||||
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 { 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 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 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
|
||||
window.addEventListener('error', function(event) {
|
||||
@@ -48,9 +52,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialiseer sidebar (vervangt fillSidebarExplanation en initializeLanguageSelector)
|
||||
initializeSidebar();
|
||||
|
||||
// Initialiseer mobile header
|
||||
initializeMobileHeader();
|
||||
|
||||
// Initialiseer chat app (simpel)
|
||||
initializeChatApp();
|
||||
});
|
||||
@@ -118,85 +119,8 @@ function initializeSidebar() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialiseert de mobile header component
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
// initializeMobileHeader is verwijderd; de mobiele header wordt nu volledig
|
||||
// binnen ChatApp.vue beheerd.
|
||||
|
||||
/**
|
||||
* Initialiseert de chat app (Vue component)
|
||||
@@ -209,29 +133,45 @@ function initializeChatApp() {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ChatApp) {
|
||||
throw new Error('🚨 [CRITICAL ERROR] ChatApp component niet gevonden');
|
||||
}
|
||||
|
||||
// Extra verificatie dat alle sub-componenten beschikbaar zijn
|
||||
if (!Components.MessageHistory || !Components.ChatInput ||
|
||||
!Components.TypingIndicator || !Components.ChatMessage) {
|
||||
console.warn('⚠️ [WARN] Niet alle benodigde sub-componenten zijn geladen!');
|
||||
}
|
||||
|
||||
// Maak props voor de component
|
||||
const props = {
|
||||
// Maak props voor de shells / CoreChatApp
|
||||
const baseProps = {
|
||||
apiPrefix: window.chatConfig.apiPrefix || '',
|
||||
conversationId: window.chatConfig.conversationId || 'default',
|
||||
userId: window.chatConfig.userId || null,
|
||||
userName: window.chatConfig.userName || '',
|
||||
initialLanguage: window.chatConfig.language || 'nl',
|
||||
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
|
||||
const app = createApp(ChatApp, props);
|
||||
// Bepaal shell-type: expliciete config heeft voorrang, anders breakpoint
|
||||
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
|
||||
app.provide('sseConfig', {
|
||||
|
||||
15
nginx/frontend_src/js/vueuse-setup.js
Normal file
15
nginx/frontend_src/js/vueuse-setup.js
Normal 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,
|
||||
});
|
||||
}
|
||||
68
nginx/package-lock.json
generated
68
nginx/package-lock.json
generated
@@ -6,12 +6,13 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"animejs": "^4.0.2",
|
||||
"bootstrap": "^5.3.6",
|
||||
"datatables.net": "^2.3.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"jquery": "^3.7.1",
|
||||
"marked": "^16.0.0",
|
||||
"marked": "16.3.0",
|
||||
"nouislider": "^15.8.1",
|
||||
"parallax": "^0.0.0",
|
||||
"prismjs": "^1.30.0",
|
||||
@@ -22,7 +23,6 @@
|
||||
"vue": "^3.5.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/reporter-bundle-analyzer": "^2.15.2",
|
||||
"@parcel/transformer-sass": "^2.15.2",
|
||||
"@parcel/transformer-vue": "^2.15.2",
|
||||
"parcel": "^2.15.2"
|
||||
@@ -1360,26 +1360,6 @@
|
||||
"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": {
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/reporter-cli/-/reporter-cli-2.16.0.tgz",
|
||||
@@ -2722,6 +2702,12 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"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": {
|
||||
"version": "3.5.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz",
|
||||
@@ -2822,6 +2808,44 @@
|
||||
"integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==",
|
||||
"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": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"tabulator-tables": "^6.3.1",
|
||||
"typed.js": "^2.1.0",
|
||||
"vanilla-jsoneditor": "^3.5.0",
|
||||
"vue": "^3.5.17"
|
||||
"vue": "^3.5.17",
|
||||
"@vueuse/core": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/transformer-sass": "^2.15.2",
|
||||
|
||||
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
0
scripts/git/__init__.py
Normal file
0
scripts/git/__init__.py
Normal file
0
scripts/git/commands/__init__.py
Normal file
0
scripts/git/commands/__init__.py
Normal file
109
scripts/git/commands/bugfix.py
Normal file
109
scripts/git/commands/bugfix.py
Normal 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
|
||||
|
||||
112
scripts/git/commands/feature.py
Normal file
112
scripts/git/commands/feature.py
Normal 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
|
||||
|
||||
154
scripts/git/commands/hotfix.py
Normal file
154
scripts/git/commands/hotfix.py
Normal 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
|
||||
|
||||
142
scripts/git/commands/release.py
Normal file
142
scripts/git/commands/release.py
Normal 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
|
||||
|
||||
30
scripts/git/commands/status.py
Normal file
30
scripts/git/commands/status.py
Normal 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
|
||||
|
||||
0
scripts/git/core/__init__.py
Normal file
0
scripts/git/core/__init__.py
Normal file
154
scripts/git/core/config.py
Normal file
154
scripts/git/core/config.py
Normal 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
116
scripts/git/core/git_api.py
Normal 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
43
scripts/git/core/hooks.py
Normal 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}"
|
||||
)
|
||||
61
scripts/git/core/output.py
Normal file
61
scripts/git/core/output.py
Normal 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
10
scripts/git/gitflow
Executable 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
154
scripts/git/gitflow.py
Normal 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())
|
||||
Reference in New Issue
Block a user