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
|
||||
|
||||
@@ -24,7 +24,6 @@ class Database:
|
||||
"""
|
||||
schema = session.info.get("tenant_schema")
|
||||
if schema:
|
||||
current_app.logger.debug(f"DBCTX tx_begin schema={schema}")
|
||||
try:
|
||||
connection.exec_driver_sql(f'SET LOCAL search_path TO "{schema}", public')
|
||||
# Optional visibility/logging for debugging
|
||||
|
||||
@@ -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,14 +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)
|
||||
# Use the tenant_id (schema name), not the Tenant object, to switch schema
|
||||
Database(tenant_id).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,15 +0,0 @@
|
||||
version: "1.0.0"
|
||||
content: |
|
||||
Classify the prompt you receive from an end user, according to the following information:
|
||||
|
||||
{user_action_classes}
|
||||
|
||||
Use the CLASS DESCRIPTION to identify the CLASS of the question asked. Return the value of CLASS. If the prompt doesn't correspond to any CLASS DESCRIPTION, return NONE. No layout is required.
|
||||
|
||||
llm_model: "mistral.mistral-small-latest"
|
||||
temperature: 0.7
|
||||
metadata:
|
||||
author: "Josako"
|
||||
date_added: "2025-11-14"
|
||||
description: "Assistant to classify user intent"
|
||||
changes: "Initial version"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -9,12 +9,6 @@
|
||||
{% block content %}
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{{ form.hidden_tag() }}
|
||||
{# Debug: render CSRF veld expliciet om aanwezigheid in de DOM te garanderen #}
|
||||
{% if form.csrf_token %}{{ form.csrf_token }}{% endif %}
|
||||
<script>
|
||||
// Client-side debug: bevestig dat het CSRF veld in de DOM staat
|
||||
console.debug('[add_document] CSRF present in DOM?', !!document.querySelector('input[name="csrf_token"]'));
|
||||
</script>
|
||||
{% set disabled_fields = [] %}
|
||||
{% set exclude_fields = [] %}
|
||||
{% for field in form.get_static_fields() %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -14,15 +14,6 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -6,7 +6,6 @@ from flask_security import roles_accepted, current_user
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import aliased
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.datastructures import CombinedMultiDict
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import requests
|
||||
from requests.exceptions import SSLError, HTTPError
|
||||
@@ -120,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)
|
||||
@@ -191,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)
|
||||
@@ -291,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)
|
||||
@@ -355,17 +354,7 @@ def handle_retriever_selection():
|
||||
@document_bp.route('/add_document', methods=['GET', 'POST'])
|
||||
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||
def add_document():
|
||||
# Log vroege request-info om uploadproblemen te diagnosticeren
|
||||
try:
|
||||
current_app.logger.debug(
|
||||
f"[add_document] method={request.method}, content_type={request.content_type}, "
|
||||
f"files_keys={list(request.files.keys())}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Bind expliciet zowel form- als file-data aan de form (belangrijk voor FileField & CSRF)
|
||||
form = AddDocumentForm(CombinedMultiDict([request.form, request.files]))
|
||||
form = AddDocumentForm(request.form)
|
||||
catalog_id = session.get('catalog_id', None)
|
||||
if catalog_id is None:
|
||||
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
|
||||
@@ -375,38 +364,6 @@ def add_document():
|
||||
if catalog.configuration and len(catalog.configuration) > 0:
|
||||
form.add_dynamic_fields("tagging_fields", catalog.configuration)
|
||||
|
||||
current_app.logger.debug("In Add Document")
|
||||
|
||||
# Extra debug logging om CSRF/payload te controleren
|
||||
try:
|
||||
current_app.logger.debug(
|
||||
f"[add_document] request.form keys: {list(request.form.keys())}"
|
||||
)
|
||||
current_app.logger.debug(
|
||||
f"[add_document] csrf_token in form? {request.form.get('csrf_token') is not None}"
|
||||
)
|
||||
try:
|
||||
has_csrf_field = hasattr(form, 'csrf_token')
|
||||
current_app.logger.debug(
|
||||
f"[add_document] form has csrf field? {has_csrf_field}"
|
||||
)
|
||||
if has_csrf_field:
|
||||
# Let op: we loggen geen tokenwaarde om lekken te vermijden; enkel aanwezigheid
|
||||
current_app.logger.debug(
|
||||
"[add_document] form.csrf_token field is present on form object"
|
||||
)
|
||||
# Bevestig of de CSRF-waarde effectief in de form is gebonden
|
||||
try:
|
||||
current_app.logger.debug(
|
||||
f"[add_document] csrf bound? data_present={bool(form.csrf_token.data)} field_name={getattr(form.csrf_token, 'name', None)}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
current_app.logger.info(f'Adding Document for {catalog_id}')
|
||||
@@ -443,25 +400,6 @@ def add_document():
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error adding document: {str(e)}')
|
||||
flash('An error occurred while adding the document.', 'danger')
|
||||
else:
|
||||
# Toon en log validatiefouten als de submit faalt
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
current_app.logger.warning(
|
||||
f"[add_document] form validation failed. errors={getattr(form, 'errors', {})}"
|
||||
)
|
||||
current_app.logger.debug(
|
||||
f"[add_document] request.files keys after validation: {list(request.files.keys())}"
|
||||
)
|
||||
current_app.logger.debug(
|
||||
f"[add_document] request.form keys after validation: {list(request.form.keys())}"
|
||||
)
|
||||
current_app.logger.debug(
|
||||
f"[add_document] csrf_token in form after validation? {request.form.get('csrf_token') is not None}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
form_validation_failed(request, form)
|
||||
|
||||
return render_template('document/add_document.html', form=form)
|
||||
|
||||
@@ -469,16 +407,7 @@ def add_document():
|
||||
@document_bp.route('/add_url', methods=['GET', 'POST'])
|
||||
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||
def add_url():
|
||||
# Log vroege request-info om submitproblemen te diagnosticeren
|
||||
try:
|
||||
current_app.logger.debug(
|
||||
f"[add_url] method={request.method}, content_type={request.content_type}, files_keys={list(request.files.keys())}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Bind expliciet zowel form- als file-data (consistentie en duidelijkheid)
|
||||
form = AddURLForm(CombinedMultiDict([request.form, request.files]))
|
||||
form = AddURLForm(request.form)
|
||||
catalog_id = session.get('catalog_id', None)
|
||||
if catalog_id is None:
|
||||
flash('You need to set a Session Catalog before adding Documents or URLs', 'warning')
|
||||
@@ -488,15 +417,6 @@ def add_url():
|
||||
if catalog.configuration and len(catalog.configuration) > 0:
|
||||
form.add_dynamic_fields("tagging_fields", catalog.configuration)
|
||||
url=""
|
||||
# Kleine debug om te zien of CSRF aan de form gebonden is
|
||||
try:
|
||||
if hasattr(form, 'csrf_token'):
|
||||
current_app.logger.debug(
|
||||
f"[add_url] csrf bound? data_present={bool(form.csrf_token.data)} field_name={getattr(form.csrf_token, 'name', None)}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if form.validate_on_submit():
|
||||
try:
|
||||
tenant_id = session['tenant']['id']
|
||||
@@ -542,15 +462,6 @@ def add_url():
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error adding document: {str(e)}')
|
||||
flash('An error occurred while adding the document.', 'danger')
|
||||
else:
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
current_app.logger.warning(
|
||||
f"[add_url] form validation failed. errors={getattr(form, 'errors', {})}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
form_validation_failed(request, form)
|
||||
|
||||
return render_template('document/add_url.html', form=form)
|
||||
|
||||
@@ -664,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')
|
||||
|
||||
@@ -88,13 +88,13 @@ class BaseUserForm(FlaskForm):
|
||||
last_name = StringField('Last Name', validators=[DataRequired(), Length(max=80)])
|
||||
valid_to = DateField('Valid to', id='form-control datepicker', validators=[Optional()])
|
||||
tenant_id = IntegerField('Tenant ID', validators=[NumberRange(min=0)])
|
||||
selected_role_ids = SelectMultipleField('Roles', coerce=int)
|
||||
roles = SelectMultipleField('Roles', coerce=int)
|
||||
is_primary_contact = BooleanField('Primary Contact')
|
||||
is_financial_contact = BooleanField('Financial Contact')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(BaseUserForm, self).__init__(*args, **kwargs)
|
||||
self.selected_role_ids.choices = UserServices.get_assignable_roles()
|
||||
self.roles.choices = UserServices.get_assignable_roles()
|
||||
|
||||
|
||||
class CreateUserForm(BaseUserForm):
|
||||
|
||||
@@ -217,15 +217,21 @@ def user():
|
||||
if form.validate_on_submit():
|
||||
current_app.logger.info(f"Adding User for tenant {session['tenant']['id']} ")
|
||||
|
||||
new_user = User()
|
||||
form.populate_obj(new_user)
|
||||
new_user = User(user_name=form.user_name.data,
|
||||
email=form.email.data,
|
||||
first_name=form.first_name.data,
|
||||
last_name=form.last_name.data,
|
||||
valid_to=form.valid_to.data,
|
||||
tenant_id=form.tenant_id.data,
|
||||
fs_uniquifier=uuid.uuid4().hex,
|
||||
)
|
||||
|
||||
timestamp = dt.now(tz.utc)
|
||||
new_user.created_at = timestamp
|
||||
new_user.updated_at = timestamp
|
||||
|
||||
# Add roles
|
||||
for role_id in form.selected_role_ids.data:
|
||||
for role_id in form.roles.data:
|
||||
the_role = Role.query.get(role_id)
|
||||
new_user.roles.append(the_role)
|
||||
|
||||
@@ -256,22 +262,39 @@ 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):
|
||||
user = User.query.get_or_404(user_id) # This will return a 404 if no user is found
|
||||
tenant_id = session.get('tenant').get('id')
|
||||
form = EditUserForm(obj=user)
|
||||
|
||||
if form.validate_on_submit():
|
||||
# Populate the user with form data
|
||||
form.populate_obj(user)
|
||||
timestamp = dt.now(tz.utc)
|
||||
user.updated_at = timestamp
|
||||
# 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
|
||||
current_roles = set(role.id for role in user.roles)
|
||||
selected_roles = set(form.selected_role_ids.data)
|
||||
selected_roles = set(form.roles.data)
|
||||
if UserServices.validate_role_assignments(selected_roles):
|
||||
# Add new roles
|
||||
for role_id in selected_roles - current_roles:
|
||||
@@ -297,7 +320,7 @@ def edit_user(user_id):
|
||||
else:
|
||||
form_validation_failed(request, form)
|
||||
|
||||
form.selected_role_ids.data = [role.id for role in user.roles]
|
||||
form.roles.data = [role.id for role in user.roles]
|
||||
return render_template('user/edit_user.html', form=form, user_id=user_id)
|
||||
|
||||
|
||||
@@ -571,12 +594,8 @@ def delete_tenant_project(tenant_project_id):
|
||||
@roles_accepted('Super User', 'Partner Admin', 'Tenant Admin')
|
||||
def tenant_make():
|
||||
form = TenantMakeForm()
|
||||
current_app.logger.debug(f"ìn tenant_make view")
|
||||
# customisation_config = cache_manager.customisations_config_cache.get_config("CHAT_CLIENT_CUSTOMISATION")
|
||||
# default_customisation_options = create_default_config_from_type_config(customisation_config["configuration"])
|
||||
|
||||
if form.validate_on_submit():
|
||||
current_app.logger.debug(f"in tenant_make form validate")
|
||||
tenant_id = session['tenant']['id']
|
||||
new_tenant_make = TenantMake()
|
||||
form.populate_obj(new_tenant_make)
|
||||
@@ -591,15 +610,13 @@ 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()
|
||||
flash(f'Failed to add Tenant Make. Error: {e}', 'danger')
|
||||
current_app.logger.error(f'Failed to add Tenant Make {new_tenant_make.name}'
|
||||
f'for tenant {tenant_id}. Error: {str(e)}')
|
||||
else:
|
||||
flash('Please fill in all required fields.', 'information')
|
||||
|
||||
return render_template('user/tenant_make.html', form=form)
|
||||
|
||||
@@ -619,8 +636,10 @@ def edit_tenant_make(tenant_make_id):
|
||||
# Get the tenant make or return 404
|
||||
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':
|
||||
@@ -754,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
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
INSUFFICIENT_INFORMATION_MESSAGES = [
|
||||
"I'm afraid I don't have enough information to answer that properly. Feel free to ask something else!",
|
||||
"There isn’t enough data available right now to give you a clear answer. You're welcome to rephrase or ask a different question.",
|
||||
"Sorry, I can't provide a complete answer based on the current information. Would you like to try asking something else?",
|
||||
"I don’t have enough details to give you a confident answer. You can always ask another question if you’d like.",
|
||||
"Unfortunately, I can’t answer that accurately with the information at hand. Please feel free to ask something else.",
|
||||
"That’s a great question, but I currently lack the necessary information to respond properly. Want to ask something different?",
|
||||
"I wish I could help more, but the data I have isn't sufficient to answer this. You’re welcome to explore other questions.",
|
||||
"There’s not enough context for me to provide a good answer. Don’t hesitate to ask another question if you'd like!",
|
||||
"I'm not able to give a definitive answer to that. Perhaps try a different question or angle?",
|
||||
"Thanks for your question. At the moment, I can’t give a solid answer — but I'm here if you want to ask something else!"
|
||||
]
|
||||
@@ -19,7 +19,19 @@ from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpec
|
||||
from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments
|
||||
from eveai_chat_workers.outputs.globals.rag.rag_v1_0 import RAGOutput
|
||||
from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState
|
||||
from eveai_chat_workers.definitions.messages.globals.rag_messages import INSUFFICIENT_INFORMATION_MESSAGES
|
||||
|
||||
INSUFFICIENT_INFORMATION_MESSAGES = [
|
||||
"I'm afraid I don't have enough information to answer that properly. Feel free to ask something else!",
|
||||
"There isn’t enough data available right now to give you a clear answer. You're welcome to rephrase or ask a different question.",
|
||||
"Sorry, I can't provide a complete answer based on the current information. Would you like to try asking something else?",
|
||||
"I don’t have enough details to give you a confident answer. You can always ask another question if you’d like.",
|
||||
"Unfortunately, I can’t answer that accurately with the information at hand. Please feel free to ask something else.",
|
||||
"That’s a great question, but I currently lack the necessary information to respond properly. Want to ask something different?",
|
||||
"I wish I could help more, but the data I have isn't sufficient to answer this. You’re welcome to explore other questions.",
|
||||
"There’s not enough context for me to provide a good answer. Don’t hesitate to ask another question if you'd like!",
|
||||
"I'm not able to give a definitive answer to that. Perhaps try a different question or angle?",
|
||||
"Thanks for your question. At the moment, I can’t give a solid answer — but I'm here if you want to ask something else!"
|
||||
]
|
||||
|
||||
class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
|
||||
"""
|
||||
|
||||
@@ -87,6 +87,7 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor):
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def execute_initial_state(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult:
|
||||
self.log_tuning("Traicie KO Criteria Interview Definition Specialist initial_state_execution started", {})
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from eveai_chat_workers.outputs.traicie.knockout_questions.knockout_questions_v1
|
||||
from eveai_chat_workers.specialists.crewai_base_classes import EveAICrewAICrew, EveAICrewAIFlow, EveAIFlowState
|
||||
from eveai_chat_workers.specialists.crewai_base_specialist import CrewAIBaseSpecialistExecutor
|
||||
from eveai_chat_workers.specialists.specialist_typing import SpecialistResult, SpecialistArguments
|
||||
from eveai_chat_workers.definitions.messages.globals.rag_messages import INSUFFICIENT_INFORMATION_MESSAGES
|
||||
|
||||
INITIALISATION_MESSAGES = [
|
||||
"Great! Let's see if this job might be a match for you by going through a few questions.",
|
||||
@@ -86,6 +85,18 @@ TRY_TO_START_SELECTION_QUESTIONS = [
|
||||
"Understood! However, we can't proceed without initiating the process. Would you like to start it now after all?",
|
||||
"We appreciate your honesty. Just to clarify: the process only continues if we begin the selection. Shall we go ahead?"
|
||||
]
|
||||
INSUFFICIENT_INFORMATION_MESSAGES = [
|
||||
"I'm afraid I don't have enough information to answer that properly. Feel free to ask something else!",
|
||||
"There isn’t enough data available right now to give you a clear answer. You're welcome to rephrase or ask a different question.",
|
||||
"Sorry, I can't provide a complete answer based on the current information. Would you like to try asking something else?",
|
||||
"I don’t have enough details to give you a confident answer. You can always ask another question if you’d like.",
|
||||
"Unfortunately, I can’t answer that accurately with the information at hand. Please feel free to ask something else.",
|
||||
"That’s a great question, but I currently lack the necessary information to respond properly. Want to ask something different?",
|
||||
"I wish I could help more, but the data I have isn't sufficient to answer this. You’re welcome to explore other questions.",
|
||||
"There’s not enough context for me to provide a good answer. Don’t hesitate to ask another question if you'd like!",
|
||||
"I'm not able to give a definitive answer to that. Perhaps try a different question or angle?",
|
||||
"Thanks for your question. At the moment, I can’t give a solid answer — but I'm here if you want to ask something else!"
|
||||
]
|
||||
KO_CRITERIA_NOT_MET_MESSAGES = [
|
||||
"Thank you for your answers. Based on your responses, we won't be moving forward with this particular role. We do encourage you to keep an eye on our website for future opportunities.",
|
||||
"We appreciate the time you took to answer our questions. At this point, we won’t be proceeding with your application, but feel free to check our website regularly for new vacancies.",
|
||||
|
||||
@@ -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