- Introduce cache busting (to circumvent aggressive caching on iOS - but ideal in other contexts as well)

- Change the build process to allow cache busting
- Optimisations to the build process
- Several improvements of UI geared towards mobile experience
-
This commit is contained in:
Josako
2025-09-25 17:28:01 +02:00
parent cc47ce2d32
commit 16ce59ae98
32 changed files with 1538 additions and 899 deletions

1
.gitignore vendored
View File

@@ -55,3 +55,4 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc
/nginx/node_modules/ /nginx/node_modules/
/nginx/.parcel-cache/ /nginx/.parcel-cache/
/nginx/static/ /nginx/static/
/docker/build_logs/

View File

@@ -0,0 +1,45 @@
import json
import os
from functools import lru_cache
from typing import Dict
# Default manifest path inside app images; override with env
DEFAULT_MANIFEST_PATH = os.environ.get(
'EVEAI_STATIC_MANIFEST_PATH',
'/app/config/static-manifest/manifest.json'
)
@lru_cache(maxsize=1)
def _load_manifest(manifest_path: str = DEFAULT_MANIFEST_PATH) -> Dict[str, str]:
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
return {}
def resolve_asset(logical_path: str, manifest_path: str = DEFAULT_MANIFEST_PATH) -> str:
"""
Map a logical asset path (e.g. 'dist/chat-client.js') to the hashed path
found in the Parcel manifest. If not found or manifest missing, return the
original logical path for graceful fallback.
"""
if not logical_path:
return logical_path
manifest = _load_manifest(manifest_path)
# Try several key variants as Parcel manifests may use different keys
candidates = [
logical_path,
logical_path.lstrip('/'),
logical_path.replace('static/', ''),
logical_path.replace('dist/', ''),
]
for key in candidates:
if key in manifest:
return manifest[key]
return logical_path

View File

@@ -107,6 +107,33 @@ def get_pagination_html(pagination, endpoint, **kwargs):
return Markup(''.join(html)) return Markup(''.join(html))
def asset_url(logical_path: str):
"""
Resolve an asset logical path to a hashed URL using Parcel manifest when available.
Fallback to the original logical path under /static/ if manifest is missing.
Examples:
- asset_url('dist/chat-client.js') -> '/static/dist/chat-client.abc123.js'
- asset_url('dist/chat-client.css') -> '/static/dist/chat-client.def456.css'
"""
if not logical_path:
return logical_path
try:
from common.utils.asset_manifest import resolve_asset
resolved = resolve_asset(logical_path)
if not resolved:
return f"/static/{logical_path.lstrip('/')}"
# If resolved is already an absolute URL starting with /static or http(s), return as is
if resolved.startswith('/static/') or resolved.startswith('http://') or resolved.startswith('https://'):
return resolved
# If it starts with 'dist/', prefix /static/
if resolved.startswith('dist/'):
return '/static/' + resolved
# Otherwise, best effort: ensure it lives under /static/
return '/static/' + resolved.lstrip('/')
except Exception:
return f"/static/{logical_path.lstrip('/')}"
def register_filters(app): def register_filters(app):
""" """
Registers custom filters with the Flask app. Registers custom filters with the Flask app.
@@ -123,4 +150,5 @@ def register_filters(app):
app.jinja_env.globals['prefixed_url_for'] = prefixed_url_for app.jinja_env.globals['prefixed_url_for'] = prefixed_url_for
app.jinja_env.globals['get_pagination_html'] = get_pagination_html app.jinja_env.globals['get_pagination_html'] = get_pagination_html
app.jinja_env.globals['get_base_background_color'] = get_base_background_color app.jinja_env.globals['get_base_background_color'] = get_base_background_color
app.jinja_env.globals['asset_url'] = asset_url

View File

@@ -0,0 +1,6 @@
{
"dist/chat-client.js": "dist/chat-client.25888758.js",
"dist/chat-client.css": "dist/chat-client.eef0ef31.css",
"dist/main.js": "dist/main.f3dde0f6.js",
"dist/main.css": "dist/main.c40e57ad.css"
}

View File

@@ -1,7 +1,49 @@
#!/bin/bash #!/bin/bash
# Exit on any error # Safer bash: we manage errors manually (no -e) but detect pipeline failures
set -e set -o pipefail
# Quiet mode default; enable verbose with --verbose
QUIET=${QUIET:-true}
# Parse --verbose early (we'll reparse fully later as well)
for arg in "$@"; do
if [[ "$arg" == "--verbose" ]]; then QUIET=false; fi
done
# Per-run logs directory
RUN_TS=$(date +%Y%m%d_%H%M%S)
LOG_DIR="./build_logs/$RUN_TS"
mkdir -p "$LOG_DIR"
# Error aggregation
ERRORS=()
ERROR_LINES=()
EXIT_CODE=0
# Helper: run_quiet SERVICE STEP -- CMD ARGS...
run_quiet() {
local SERVICE="$1"; shift
local STEP="$1"; shift
# Expect a literal "--" separator before the command
if [[ "$1" == "--" ]]; then shift; fi
local LOG_FILE="$LOG_DIR/${SERVICE}.${STEP}.log"
if [[ "$QUIET" == "true" ]]; then
"$@" > /dev/null 2> >(tee -a "$LOG_FILE" >&2)
else
"$@" > >(tee -a "$LOG_FILE") 2> >(tee -a "$LOG_FILE" >&2)
fi
local RC=$?
echo "$LOG_FILE" > "$LOG_DIR/.last_${SERVICE}_${STEP}.path"
return $RC
}
record_error() {
local SERVICE="$1"; local STEP="$2"; local MESSAGE="$3"; local LOG_FILE="$4"
ERRORS+=("$SERVICE|$STEP|$LOG_FILE|$MESSAGE")
ERROR_LINES+=("$MESSAGE")
EXIT_CODE=1
}
source ./podman_env_switch.sh dev source ./podman_env_switch.sh dev
@@ -39,7 +81,7 @@ BASE_ONLY=""
# Function to display usage information # Function to display usage information
usage() { usage() {
echo "Usage: $0 [-b|-p|-bb|--base-only] [--no-cache] [--progress=plain] [--debug] [service1 service2 ...]" echo "Usage: $0 [-b|-p|-bb|--base-only] [--no-cache] [--progress=plain] [--debug] [--verbose] [service1 service2 ...]"
echo " -b: Build only" echo " -b: Build only"
echo " -p: Push only" echo " -p: Push only"
echo " -bb: Build base image (in addition to services)" echo " -bb: Build base image (in addition to services)"
@@ -47,6 +89,7 @@ usage() {
echo " --no-cache: Perform a clean build without using cache" echo " --no-cache: Perform a clean build without using cache"
echo " --progress=plain: Show detailed progress of the build" echo " --progress=plain: Show detailed progress of the build"
echo " --debug: Enable debug mode for the build" echo " --debug: Enable debug mode for the build"
echo " --verbose: Show full output of build/push (default is quiet; logs always saved under ./build_logs/<timestamp>)"
echo " If no option is provided, both build and push will be performed." echo " If no option is provided, both build and push will be performed."
echo " If no services are specified, all eveai_ services and nginx will be processed." echo " If no services are specified, all eveai_ services and nginx will be processed."
echo " All images are built for AMD64 platform (compatible with both x86_64 and Apple Silicon via emulation)." echo " All images are built for AMD64 platform (compatible with both x86_64 and Apple Silicon via emulation)."
@@ -55,6 +98,10 @@ usage() {
# Parse command-line options # Parse command-line options
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case $1 in case $1 in
--verbose)
QUIET=false
shift
;;
-b) -b)
ACTION="build" ACTION="build"
shift shift
@@ -96,13 +143,13 @@ done
# Function to build base image # Function to build base image
build_base_image() { build_base_image() {
echo "🏗️ Building base image..." echo "🏗️ Building base image... =============================================================="
local BASE_IMAGE_NAME="$REGISTRY/$ACCOUNT/eveai-base:$TAG" local BASE_IMAGE_NAME="$REGISTRY/$ACCOUNT/eveai-base:$TAG"
echo "Building base image for platform: $PLATFORM" echo "Building base image for platform: $PLATFORM"
echo "Base image tag: $BASE_IMAGE_NAME" echo "Base image tag: $BASE_IMAGE_NAME"
podman build \ run_quiet base build -- podman build \
--platform "$PLATFORM" \ --platform "$PLATFORM" \
$NO_CACHE \ $NO_CACHE \
$PROGRESS \ $PROGRESS \
@@ -111,10 +158,22 @@ build_base_image() {
-t "$BASE_IMAGE_NAME" \ -t "$BASE_IMAGE_NAME" \
-f Dockerfile.base \ -f Dockerfile.base \
.. ..
if [ $? -ne 0 ]; then
LOG_FILE="$LOG_DIR/base.build.log"
echo "❌ Failed to build base image"
record_error base build "❌ Failed to build base image" "$LOG_FILE"
return 1
fi
if [ "$ACTION" = "push" ] || [ "$ACTION" = "both" ]; then if [ "$ACTION" = "push" ] || [ "$ACTION" = "both" ]; then
echo "Pushing base image to registry..." echo "Pushing base image to registry..."
podman push "$BASE_IMAGE_NAME" run_quiet base push -- podman push "$BASE_IMAGE_NAME"
if [ $? -ne 0 ]; then
LOG_FILE="$LOG_DIR/base.push.log"
echo "❌ Failed to push base image"
record_error base push "❌ Failed to push base image" "$LOG_FILE"
return 1
fi
fi fi
echo "✅ Base image built successfully" echo "✅ Base image built successfully"
@@ -132,7 +191,7 @@ should_build_base() {
# Function to build and/or push a service # Function to build and/or push a service
process_service() { process_service() {
local SERVICE="$1" local SERVICE="$1"
echo "Processing $SERVICE..." echo "Processing $SERVICE... =================================================================="
# Extract the build context and dockerfile from the compose file # Extract the build context and dockerfile from the compose file
CONTEXT=$(yq e ".services.$SERVICE.build.context" compose_dev.yaml) CONTEXT=$(yq e ".services.$SERVICE.build.context" compose_dev.yaml)
@@ -160,8 +219,8 @@ process_service() {
# Build and/or push based on ACTION # Build and/or push based on ACTION
if [ "$ACTION" = "build" ]; then if [ "$ACTION" = "build" ]; then
echo "Building $SERVICE for $PLATFORM..." echo "🛠️ Building $SERVICE for $PLATFORM..."
podman build \ run_quiet "$SERVICE" build -- podman build \
--platform "$PLATFORM" \ --platform "$PLATFORM" \
$NO_CACHE \ $NO_CACHE \
$PROGRESS \ $PROGRESS \
@@ -170,26 +229,27 @@ process_service() {
-t "$REGISTRY_IMAGE_NAME" \ -t "$REGISTRY_IMAGE_NAME" \
-f "$CONTEXT/$DOCKERFILE" \ -f "$CONTEXT/$DOCKERFILE" \
"$CONTEXT" "$CONTEXT"
if [ $? -ne 0 ]; then
LOG_FILE="$LOG_DIR/${SERVICE}.build.log"
echo "❌ Failed to build $SERVICE"
record_error "$SERVICE" build "❌ Failed to build $SERVICE" "$LOG_FILE"
return 1
fi
elif [ "$ACTION" = "push" ]; then elif [ "$ACTION" = "push" ]; then
echo "Building and pushing $SERVICE for $PLATFORM..." echo "📤 Pushing $SERVICE to registry..."
podman build \ run_quiet "$SERVICE" push -- podman push "$REGISTRY_IMAGE_NAME"
--platform "$PLATFORM" \ if [ $? -ne 0 ]; then
$NO_CACHE \ LOG_FILE="$LOG_DIR/${SERVICE}.push.log"
$PROGRESS \ echo "❌ Failed to push $SERVICE"
$DEBUG \ record_error "$SERVICE" push "❌ Failed to push $SERVICE" "$LOG_FILE"
-t "$LOCAL_IMAGE_NAME" \ return 1
-t "$REGISTRY_IMAGE_NAME" \ fi
-f "$CONTEXT/$DOCKERFILE" \
"$CONTEXT"
echo "Pushing $SERVICE to registry..."
podman push "$REGISTRY_IMAGE_NAME"
else else
# Both build and push # Both build and push
echo "Building $SERVICE for $PLATFORM..." echo "🛠️ Building $SERVICE for $PLATFORM..."
podman build \ run_quiet "$SERVICE" build -- podman build \
--platform "$PLATFORM" \ --platform "$PLATFORM" \
$NO_CACHE \ $NO_CACHE \
$PROGRESS \ $PROGRESS \
@@ -198,9 +258,21 @@ process_service() {
-t "$REGISTRY_IMAGE_NAME" \ -t "$REGISTRY_IMAGE_NAME" \
-f "$CONTEXT/$DOCKERFILE" \ -f "$CONTEXT/$DOCKERFILE" \
"$CONTEXT" "$CONTEXT"
if [ $? -ne 0 ]; then
LOG_FILE="$LOG_DIR/${SERVICE}.build.log"
echo "❌ Failed to build $SERVICE"
record_error "$SERVICE" build "❌ Failed to build $SERVICE" "$LOG_FILE"
return 1
fi
echo "Pushing $SERVICE to registry..." echo "📤 Pushing $SERVICE to registry..."
podman push "$REGISTRY_IMAGE_NAME" run_quiet "$SERVICE" push -- podman push "$REGISTRY_IMAGE_NAME"
if [ $? -ne 0 ]; then
LOG_FILE="$LOG_DIR/${SERVICE}.push.log"
echo "❌ Failed to push $SERVICE"
record_error "$SERVICE" push "❌ Failed to push $SERVICE" "$LOG_FILE"
return 1
fi
fi fi
} }
@@ -231,22 +303,87 @@ fi
echo "Using simplified AMD64-only approach for maximum compatibility..." echo "Using simplified AMD64-only approach for maximum compatibility..."
echo "Images will be tagged as: $REGISTRY/$ACCOUNT/[service]:$TAG" echo "Images will be tagged as: $REGISTRY/$ACCOUNT/[service]:$TAG"
# Reorder to ensure nginx builds before eveai_* if both are present
HAS_NGINX=false
HAS_APPS=false
for S in "${SERVICES[@]}"; do
if [[ "$S" == "nginx" ]]; then HAS_NGINX=true; fi
if [[ "$S" == eveai_* ]]; then HAS_APPS=true; fi
done
if $HAS_NGINX && $HAS_APPS; then
ORDERED_SERVICES=("nginx")
for S in "${SERVICES[@]}"; do
if [[ "$S" != "nginx" ]]; then ORDERED_SERVICES+=("$S"); fi
done
SERVICES=("${ORDERED_SERVICES[@]}")
fi
# Loop through services # Loop through services
for SERVICE in "${SERVICES[@]}"; do for SERVICE in "${SERVICES[@]}"; do
if [[ "$SERVICE" == "nginx" ]]; then if [[ "$SERVICE" == "nginx" ]]; then
./copy_specialist_svgs.sh ../config ../nginx/static/assets 2>/dev/null || echo "Warning: copy_specialist_svgs.sh not found or failed" run_quiet nginx copy-specialist-svgs -- ./copy_specialist_svgs.sh ../config ../nginx/static/assets
if [ $? -ne 0 ]; then
LOG_FILE="$LOG_DIR/nginx.copy-specialist-svgs.log"
echo "⚠️ copy_specialist_svgs.sh not found or failed"
record_error nginx copy-specialist-svgs "⚠️ copy_specialist_svgs.sh not found or failed" "$LOG_FILE"
fi
run_quiet nginx rebuild-chat-client -- ./rebuild_chat_client.sh
if [ $? -ne 0 ]; then
LOG_FILE="$LOG_DIR/nginx.rebuild-chat-client.log"
echo "❌ rebuild_chat_client.sh failed"
record_error nginx rebuild-chat-client "❌ rebuild_chat_client.sh failed" "$LOG_FILE"
fi
MANIFEST_SRC="../nginx/static/dist/manifest.json"
MANIFEST_DST_DIR="../config/static-manifest"
MANIFEST_DST="$MANIFEST_DST_DIR/manifest.json"
if [ ! -f "$MANIFEST_SRC" ]; then
if $HAS_NGINX; then
echo "⚠️ manifest.json not found at $MANIFEST_SRC yet. nginx should be built first in this run."
else
echo "❌ manifest.json not found at $MANIFEST_SRC. Please build nginx (assets) first."
exit 1
fi
fi
mkdir -p "$MANIFEST_DST_DIR"
if [ -f "$MANIFEST_SRC" ]; then
cp -f "$MANIFEST_SRC" "$MANIFEST_DST"
echo "📄 Staged manifest at $MANIFEST_DST"
fi
fi fi
if [[ "$SERVICE" == "nginx" || "$SERVICE" == eveai_* || "$SERVICE" == "prometheus" || "$SERVICE" == "grafana" ]]; then if [[ "$SERVICE" == "nginx" || "$SERVICE" == eveai_* || "$SERVICE" == "prometheus" || "$SERVICE" == "grafana" ]]; then
if process_service "$SERVICE"; then if process_service "$SERVICE"; then
echo "✅ Successfully processed $SERVICE" echo "✅ Successfully processed $SERVICE"
else else
echo "❌ Failed to process $SERVICE" echo "❌ Failed to process $SERVICE"
ERROR_LINES+=("❌ Failed to process $SERVICE")
EXIT_CODE=1
fi fi
else else
echo "⏭️ Skipping $SERVICE as it's not nginx, prometheus, grafana or doesn't start with eveai_" echo "⏭️ Skipping $SERVICE as it's not nginx, prometheus, grafana or doesn't start with eveai_"
fi fi
done done
echo -e "\033[32m✅ All specified services processed successfully!\033[0m" if [ ${#ERRORS[@]} -eq 0 ]; then
echo -e "\033[32m📦 Images are available locally and in registry\033[0m" echo -e "\033[32m✅ All specified services processed successfully!\033[0m"
echo -e "\033[32m📦 Images are available locally and in registry\033[0m"
else
echo -e "\033[31m❌ One or more errors occurred during build/push\033[0m"
# Reprint short failure lines (your concise messages)
for LINE in "${ERROR_LINES[@]}"; do
echo "$LINE"
done
echo ""
echo "Details (see logs for full output):"
for ITEM in "${ERRORS[@]}"; do
SERVICE_STEP_MSG_LOG=$(echo "$ITEM")
IFS='|' read -r SVC STEP LOGFILE MSG <<< "$SERVICE_STEP_MSG_LOG"
echo "- Service: $SVC | Step: $STEP"
echo " ↳ Log: $LOGFILE"
done
EXIT_CODE=1
fi
# Always print finished timestamp
echo -e "\033[32m🕐 Finished at $(date +"%d/%m/%Y %H:%M:%S")\033[0m" echo -e "\033[32m🕐 Finished at $(date +"%d/%m/%Y %H:%M:%S")\033[0m"
exit $EXIT_CODE

View File

@@ -3,4 +3,3 @@ FROM registry.ask-eve-ai-local.com/josakola/eveai-base:latest
# Copy the service-specific source code into the container. # Copy the service-specific source code into the container.
COPY eveai_chat_client /app/eveai_chat_client COPY eveai_chat_client /app/eveai_chat_client
COPY content /app/content COPY content /app/content

View File

@@ -1,6 +1,9 @@
# Use the official Nginx image as the base image # Use the official Nginx image as the base image
FROM nginx:latest ARG TARGETPLATFORM
FROM --platform=$TARGETPLATFORM nginx:latest
# Ensure we use user root
USER root
# Copy the custom Nginx configuration file into the container # Copy the custom Nginx configuration file into the container
COPY ../../nginx/nginx.conf /etc/nginx/nginx.conf COPY ../../nginx/nginx.conf /etc/nginx/nginx.conf

View File

@@ -1,20 +1,12 @@
#!/bin/bash #!/bin/bash
cd /Volumes/OWC4M2_1/Development/Josako/EveAI/TBD/docker
source ./docker_env_switch.sh dev
echo "Copying client images" echo "Copying client images"
cp -fv ../eveai_chat_client/static/assets/img/* ../nginx/static/assets/img cp -fv ../eveai_chat_client/static/assets/img/* ../nginx/static/assets/img
pcdown
cd ../nginx cd ../nginx
npm run clean npm run clean
npm run build npm run build
cd ../docker cd ../docker/
./build_and_push_eveai.sh -b nginx
pcup

View File

@@ -0,0 +1,106 @@
# Cache busting, builds en distributie (Parcel v2 + manifest)
Dit document beschrijft hoe cache busting nu structureel is geïmplementeerd met Parcel v2 (filename hashing) en hoe je builds en distributie uitvoert in de verschillende omgevingen.
## Overzicht van de aanpak
- Parcel v2 bouwt de frontend bundles met content-hashes in de bestandsnamen (bijv. `/static/dist/chat-client.abcd123.js`).
- We bouwen via minimale HTML entries (frontend_src/entries/*.html) die de JS importeren; dit dwingt Parcel tot consistente hashing en CSS-extractie.
- Na de build schrijven we een `manifest.json` in `nginx/static/dist/` met een mapping van logische namen naar gehashte paden.
- Templates gebruiken nu `asset_url('dist/chat-client.js|css')` om automatisch naar de juiste gehashte bestanden te verwijzen.
- Nginx (DEV/TEST) is ingesteld om voor `/static/dist/` lange cache headers te sturen: `Cache-Control: public, max-age=31536000, immutable`.
- HTML krijgt geen agressieve caching via Nginx; de browser ontdekt bij volgende request de nieuwe gehashte asset-URLs.
- In STAGING/PROD (Ingress + Bunny) volg je dezelfde principes; CDN ziet unieke URLs en cachet langdurig, zonder purges te vereisen.
## Relevante bestanden die zijn aangepast/toegevoegd
- `nginx/package.json`
- DevDependency: `@parcel/reporter-bundle-manifest` toegevoegd.
- `postbuild` script toegevoegd (optioneel; puur informatief).
- `nginx/.parcelrc`
- Parcel reporter geconfigureerd zodat `manifest.json` wordt weggeschreven.
- `common/utils/template_filters.py`
- Nieuwe Jinja global `asset_url(logical_path)` geregistreerd.
- `eveai_chat_client/asset_manifest.py`
- Hulpfuncties om manifest in te lezen en paden te resolven met caching.
- `eveai_chat_client/templates/base.html` en `templates/scripts.html`
- CSS/JS referenties omgezet naar `{{ asset_url('dist/chat-client.css|js') }}`.
- `nginx/nginx.conf`
- Extra location voor `/static/dist/` met lange cache headers.
## Build uitvoeren (lokaal of in CI)
1. Ga naar de `nginx` directory.
2. Installeer dependencies (eenmalig of na wijzigingen):
- `npm install`
3. Run build:
- `npm run clean`
- `npm run build`
Resultaat:
- Bundles met content-hash in `nginx/static/dist/`
- `nginx/static/dist/manifest.json` aanwezig
Tip: De build wordt ook aangeroepen door het bestaande script `docker/rebuild_chat_client.sh`.
## rebuild_chat_client.sh
Script: `docker/rebuild_chat_client.sh`
- Kopieert client images naar `nginx/static/assets/img`.
- Voert vervolgens `npm run clean` en `npm run build` uit in `nginx/`.
- Bouwt en pusht daarna de `nginx` container.
Door de nieuwe Parcel configuratie zal de build automatisch `manifest.json` genereren en gehashte assets outputten. Er zijn geen extra stappen nodig in dit script.
## Hoe de templates de juiste bestanden vinden
- De Jinja global `asset_url()` zoekt in `manifest.json` de gehashte bestandsnaam op basis van een logische naam.
- Voorbeelden in templates:
- CSS: `{{ asset_url('dist/chat-client.css') }}`
- JS: `{{ asset_url('dist/chat-client.js') }}`
- Als het manifest onverhoopt ontbreekt (bijv. in een edge-case), valt `asset_url` automatisch terug naar het on-gehashte pad onder `/static/` zodat de pagina niet breekt.
## Nginx (DEV/TEST)
In `nginx/nginx.conf` is toegevoegd:
```
location ^~ /static/dist/ {
alias /etc/nginx/static/dist/;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
```
- Hiermee krijgen gehashte assets lange caching. Omdat de URL wijzigt bij iedere inhoudswijziging, halen browsers/CDN de nieuwe versie zonder problemen op.
- HTML wordt niet agressief gecachet door Nginx; wil je volledig no-cache afdwingen voor HTML, dan kun je dat ook applicatiezijde doen (Flask) of via extra Nginx rules voor specifieke HTML routes.
## Ingress (STAGING/PROD) en Bunny.net
- Ingress: Zet (indien nog niet aanwezig) voor het pad dat `/static/dist/` serveert een configuration snippet met dezelfde header:
- `add_header Cache-Control "public, max-age=31536000, immutable" always;`
- HTML/endpoints laat je kort/no-cache of standaard; belangrijk is dat templates met nieuwe gehashte URLs snel door de clients worden opgepakt.
- Bunny.net (pull zone): CDN cachet op basis van volledige URL. Door de content-hash in de bestandsnaam zijn purges normaliter niet nodig.
## Troubleshooting / Tips
- Controleer na build of `manifest.json` bestaat:
- `ls nginx/static/dist/manifest.json`
- Inspecteer headers in DEV:
- `curl -I http://<host>/static/dist/chat-client.<hash>.js`
- Moet `Cache-Control: public, max-age=31536000, immutable` tonen.
- Als een client toch oude CSS/JS toont:
- Check of de geleverde HTML naar de nieuwste gehashte bestandsnaam verwijst (View Source / Network tab).
- Zorg dat de HTML niet op CDN langdurig wordt gecachet.
## Samenvatting workflow per omgeving
- DEV/TEST (Nginx):
1) `docker/rebuild_chat_client.sh` draaien (of handmatig `npm run build` in `nginx/`).
2) Nginx container wordt vernieuwd met nieuwe `static/dist` inclusief manifest en gehashte bundles.
- STAGING/PROD (Ingress + Bunny):
1) Zelfde build, gehashte bundles en manifest worden gedeployed via container image.
2) Ingress dient `/static/dist/` met lange cache headers.
3) Bunny.net cachet de nieuwe URLs automatisch; purgen niet nodig.
Met deze setup combineren we maximale performance (lange caching) met directe updates (nieuwe URL bij wijziging).

View File

@@ -0,0 +1,208 @@
# Migratieplan: Standaardisatie van Startup Processen en Docker Builds
Dit document beschrijft de migratie-afspraken rond het opstarten van onze applicaties, inclusief het gebruik van een generiek startscript, het inzetten van `tini` als entrypoint, en de overstap naar een **shared base build** structuur.
Doel: **standaardisatie**, **betere betrouwbaarheid in Kubernetes**, en **snellere builds**.
---
## 1. Generiek startscript (`scripts/start.sh`)
### Doel
- Eén startscript voor **alle rollen**: web, worker, beat.
- Gedrag wordt bepaald via **environment variables** (`ROLE=web|worker|beat`).
- Geen “magische” verschillen meer tussen Podman en Kubernetes.
### Voorbeeld
```bash
#!/usr/bin/env bash
set -euo pipefail
ROLE="${ROLE:-web}"
case "$ROLE" in
web)
exec gunicorn -w "${WORKERS:-1}" -k "${WORKER_CLASS:-gevent}" -b "0.0.0.0:${PORT:-8080}" --worker-connections "${WORKER_CONN:-100}" --access-logfile - --error-logfile - --log-level "${LOGLEVEL:-info}" scripts.run_eveai_app:app
;;
worker)
exec celery -A scripts.run_eveai_workers worker --loglevel="${CELERY_LOGLEVEL:-INFO}" --concurrency="${CELERY_CONCURRENCY:-2}" --max-tasks-per-child="${CELERY_MAX_TASKS_PER_CHILD:-1000}" --prefetch-multiplier="${CELERY_PREFETCH:-1}" -O fair
;;
beat)
exec celery -A scripts.run_eveai_workers beat --loglevel="${CELERY_LOGLEVEL:-INFO}"
;;
*)
echo "Unknown ROLE=$ROLE" >&2
exit 1
;;
esac
```
### Belangrijk
- **Geen init/migraties** meer in het startscript (die draaien we via Jobs/CronJobs).
- **Geen `cd`, `chown`, of PYTHONPATH-hacks** in startscript → alles naar Dockerfile.
- **Altijd `exec`** gebruiken zodat processen signalen correct ontvangen.
---
## 2. Gebruik van `tini` als ENTRYPOINT
### Waarom?
- In containers draait het eerste proces als **PID 1**.
- Zonder init-proces worden signalen niet correct doorgegeven en ontstaan **zombieprocessen**.
- `tini` is een lichtgewicht init die dit oplost.
### Implementatie
```dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini","-g","--"]
CMD ["bash","-lc","scripts/start.sh"]
```
- `-g` = stuur signalen naar de hele process group (belangrijk voor Gunicorn en Celery).
- Hiermee is een apart `entrypoint.sh` niet meer nodig.
---
## 3. Verplaatsen van logica naar Dockerfile
### Wat naar de Dockerfile moet
- `WORKDIR /app`
- `ENV PYTHONPATH=/app:/app/patched_packages:$PYTHONPATH`
- `ENV FLASK_APP=/app/scripts/run_eveai_app.py` (alleen nodig voor CLI, niet voor gunicorn)
- User-aanmaak en permissies:
```dockerfile
ARG UID=10001
ARG GID=10001
RUN groupadd -g ${GID} appuser && useradd -u ${UID} -g ${GID} -M -d /nonexistent -s /usr/sbin/nologin appuser
RUN chown -R appuser:appuser /app
USER appuser
```
### Wat niet meer in startscript hoort
- `cd /app`
- `export PYTHONPATH=...`
- `chown -R ... /logs`
- DB-migraties of cache-invalidatie → deze verhuizen naar **Kubernetes Jobs/CronJobs**.
---
## 4. Kubernetes Jobs & CronJobs
### Use cases
- **Jobs** → eenmalige taken zoals DB-migraties of cache-invalidatie.
- **CronJobs** → geplande taken (bv. nachtelijke opschoningen).
### Waarom?
- Startup scripts blijven lean.
- Geen race conditions bij meerdere replicas.
- Flexibel los van deploys uit te voeren.
---
## 5. Docker builds: naar een shared base (Optie B)
### Nieuwe structuur
```
repo/
├─ Dockerfile.base
├─ requirements.txt
├─ common/
├─ config/
├─ scripts/
├─ patched_packages/
├─ docker/
│ ├─ eveai_app/Dockerfile
│ ├─ eveai_api/Dockerfile
│ └─ eveai_workers/Dockerfile
└─ compose_dev.yaml
```
### Dockerfile.base
```dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
ARG UID=10001
ARG GID=10001
RUN groupadd -g ${GID} appuser && useradd -u ${UID} -g ${GID} -M -d /nonexistent -s /usr/sbin/nologin appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY common /app/common
COPY config /app/config
COPY scripts /app/scripts
COPY patched_packages /app/patched_packages
RUN chown -R appuser:appuser /app && chmod +x /app/scripts/start.sh
ENV PYTHONPATH=/app:/app/patched_packages:${PYTHONPATH} ROLE=web PORT=8080
USER appuser
EXPOSE 8080
ENTRYPOINT ["/usr/bin/tini","-g","--"]
CMD ["bash","-lc","scripts/start.sh"]
```
### Service Dockerfiles
#### docker/eveai_app/Dockerfile
```dockerfile
FROM yourorg/eveai-base:py312-v1
WORKDIR /app
COPY eveai_app /app/eveai_app
COPY migrations /app/migrations
COPY content /app/content
ENV ROLE=web PORT=8080
```
#### docker/eveai_api/Dockerfile
```dockerfile
FROM yourorg/eveai-base:py312-v1
WORKDIR /app
COPY eveai_api /app/eveai_api
ENV ROLE=web PORT=8080
```
#### docker/eveai_workers/Dockerfile
```dockerfile
FROM yourorg/eveai-base:py312-v1
WORKDIR /app
COPY eveai_workers /app/eveai_workers
ENV ROLE=worker
```
---
## 6. Workflow afspraken
1. **Startscript** is generiek → gedrag via env (`ROLE=...`).
2. **Geen init-taken** meer in startscript → Jobs/CronJobs in k8s.
3. **`tini` als ENTRYPOINT** → correcte signalen en zombie-cleanup.
4. **Dockerfile** regelt PYTHONPATH, FLASK_APP, permissies en user.
5. **Base image** bevat Python, deps en common code.
6. **Service-images** kopiëren enkel hun eigen directories.
7. **Build flow**:
- Base builden/pushen bij gewijzigde deps/common.
- Services snel erbovenop rebuilden bij code-wijzigingen.
---
## 7. Samenvatting
Met deze migratie krijgen we:
- Eenvoudiger startscript (alleen proceskeuze).
- Betere betrouwbaarheid bij shutdowns (via tini).
- Strikte scheiding tussen **lifecycle taken** en **runtime**.
- Snellere builds via shared base image.
- Uniforme aanpak in zowel Podman/Compose als Kubernetes.

108
documentation/mobile-ux.md Normal file
View File

@@ -0,0 +1,108 @@
# Mobile UX Improvements for EveAI Chat Client
This document explains the changes we made to improve the mobile user experience and how to verify or tweak them.
## Summary of Issues Addressed
- Unwanted white margins and page scroll on mobile due to default body margin and mixed vh/dvh usage.
- Layout jitter and incorrect heights when the browser address bar or the on-screen keyboard appears.
- Chat input potentially being obscured by the keyboard.
- Differences between DevTools emulation and real devices.
## Key Changes
1. CSS base reset and layout fixes
- Remove default white margins and prevent double scroll.
- Ensure outer containers dont create conflicting scrollbars.
- Respect device safe-area insets (bottom notch, etc.).
Files:
- eveai_chat_client/templates/base.html
- Added a CSS custom property fallback: `--vvh: 1vh;` on `:root`.
- eveai_chat_client/static/assets/css/chat.css
- `.app-container` now uses `height: calc(var(--vvh, 1vh) * 100);`.
- Added base reset: `body { margin: 0; overflow: hidden; }` and global `box-sizing: border-box`.
- Mobile `.content-area` no longer uses `calc(100vh - header)`; we rely on flex with `min-height: 0`.
- eveai_chat_client/static/assets/css/chat-components.css
- Added `padding-bottom: env(safe-area-inset-bottom)` for the chat input area on mobile.
- When keyboard is open (detected via JS), we slightly increase bottom padding.
2. VisualViewport utility and debug overlay
- We introduced a small utility that:
- Sets a CSS variable `--vvh` equal to 1% of the current visual viewport height.
- Toggles a `keyboard-open` class on `<body>` when the keyboard is likely visible.
- Optionally shows a debug overlay with real-time viewport metrics.
Files:
- frontend_src/js/utils/viewport.js (new)
- Exports `initViewportFix({ enableDebug })`.
- Updates `--vvh` on resize/scroll/orientation changes.
- Optional debug overlay via `enableDebug`.
- frontend_src/js/chat-client.js
- Imports and initializes the util on DOMContentLoaded.
- Enable overlay via query parameter: `?debug=viewport`.
## How It Works
- CSS reads `--vvh` to size the vertical layout. This follows the visual viewport, so when the URL bar or keyboard affects the viewport, the chat layout adapts without extra page scroll.
- The `keyboard-open` body class allows us to slightly increase bottom padding for the input to avoid edge cases where the input is near the keyboard.
- On mobile, `.app-container` spans the full visual viewport height, and `.content-area` flexes to take remaining space under the mobile header without relying on height calculations.
## Testing Checklist
1. Open the chat on real devices (iOS Safari, Android Chrome).
2. Verify there are no white margins around the app; the overall page should not scroll.
3. Focus the message input:
- Keyboard appears; the message list should stay usable and the input should remain visible.
- The page should not jump in height unexpectedly.
4. Rotate device (portrait/landscape) and ensure the layout adapts.
5. Try with/without the browser UI (scroll a bit to collapse the address bar on Android Chrome).
6. Optional: enable overlay with `?debug=viewport` and verify reported values look sane.
## Tuning and Fallbacks
- If you need to support extremely old iOS versions that lack VisualViewport, we still fall back to `1vh` for `--vvh`. You can increase robustness by setting `--vvh` via a simple `window.innerHeight` measurement on load.
- Keyboard detection is heuristic-based (drop > 120px). Adjust the threshold in `viewport.js` if needed for specific device groups.
- Avoid adding fixed `height: 100vh` or `calc(100vh - X)` to new containers; prefer flex layouts with `min-height: 0` for scrollable children.
## Known Limitations
- On some embedded browsers (in-app webviews), viewport behavior can deviate; test with your target apps.
- The safe-area env variables require modern browsers; most iOS/Android are fine, but always test.
## Enabling/Disabling Debug Overlay
- Append `?debug=viewport` to the URL to enable.
- The overlay displays: `innerHeight`, `clientHeight`, `visualViewport.height`, `visualViewport.pageTop`, header height, current `--vvh`, and keyboard state.
## Next Steps (Optional Enhancements)
- PWA manifest with `display: standalone` to reduce address bar effects.
- Add smooth scroll-into-view on input focus for edge cases on particular webviews.
- Add E2E tests using BrowserStack for a small device matrix.
## Focus Zoom Prevention (2025-09-24)
Some mobile browsers (notably iOS Safari) auto-zoom when focusing inputs with small font sizes. To prevent this:
- We enforce font-size: 16px for inputs, selects, textareas, and buttons on mobile in chat-components.css.
- We stabilize text scaling via `-webkit-text-size-adjust: 100%`.
- We extended the meta viewport with `viewport-fit=cover`.
- The debug overlay now also shows `visualViewport.scale` to confirm no zoom is happening (expect 1.00 during focus).
Files:
- eveai_chat_client/static/assets/css/chat-components.css (mobile form control font-size)
- eveai_chat_client/static/assets/css/chat.css (text-size-adjust)
- eveai_chat_client/templates/base.html (viewport meta)
- frontend_src/js/utils/viewport.js (overlay shows vv.scale)
Validation checklist:
- Focus the chat input and language select on iOS Safari and Android Chrome; the UI should not scale up.
- Overlay shows `vv.scale: 1.00` with `?debug=viewport`.
## Mobile Header Overflow Fix (2025-09-24)
We observed that the mobile header could push the layout wider than the viewport on small devices, causing horizontal page scroll. We addressed this by:
- Allowing the header row to wrap: `.mobile-header { flex-wrap: wrap; }`.
- Ensuring children can shrink within the container: `min-width: 0` on `.mobile-logo` and `.mobile-language-selector`.
- Constraining controls: `:deep(.language-select)` uses `max-width: 100%` and on very small screens `max-width: 60vw`.
- Preventing accidental bleed: `.mobile-header { max-width: 100%; overflow: hidden; }`.
- Added `html { overflow-x: hidden; }` as a light global failsafe.
Files:
- eveai_chat_client/static/assets/vue-components/MobileHeader.vue (scoped styles updated)
- eveai_chat_client/static/assets/css/chat.css (added html { overflow-x: hidden; })
Validation checklist:
- On 320px width devices, header may wrap to 2 lines without horizontal scroll.
- Long tenant names and multiple languages do not cause page-wide overflow; content remains within viewport width.

View File

@@ -13,7 +13,7 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
<!-- Gebundelde CSS (bevat nu al je CSS) --> <!-- Gebundelde CSS (bevat nu al je CSS) -->
<link href="{{url_for('static', filename='dist/main.css')}}" rel="stylesheet" /> <link href="{{ asset_url('dist/main.css') }}" rel="stylesheet" />
<base href="/admin/"> <base href="/admin/">
</head> </head>

View File

@@ -1,5 +1,5 @@
{#dist/main.js contains all used javascript libraries#} {#dist/main.js contains all used javascript libraries#}
<script src="{{url_for('static', filename='dist/main.js')}}"></script> <script src="{{ asset_url('dist/main.js') }}"></script>
{% include 'eveai_json_editor.html' %} {% include 'eveai_json_editor.html' %}
{% include 'eveai_ordered_list_editor.html' %} {% include 'eveai_ordered_list_editor.html' %}

View File

@@ -79,6 +79,16 @@
.chat-input-area { .chat-input-area {
max-width: 100%; /* Op mobiel volledige breedte gebruiken */ max-width: 100%; /* Op mobiel volledige breedte gebruiken */
/* Respect safe-area at bottom */
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* Prevent iOS focus zoom by ensuring form controls are >= 16px */
input, textarea, select, button,
.chat-input input, .chat-input textarea, .chat-input select, .chat-input button,
.mobile-language-selector :is(select, input, button) {
font-size: 16px;
line-height: 1.4;
} }
.chat-input { .chat-input {
@@ -97,6 +107,11 @@
} }
} }
/* When keyboard is open, allow a bit more space below */
body.keyboard-open .chat-input-area {
padding-bottom: calc(env(safe-area-inset-bottom, 0) + 8px);
}
/* Extra small screens */ /* Extra small screens */
@media (max-width: 480px) { @media (max-width: 480px) {
.chat-app-container { .chat-app-container {
@@ -251,15 +266,6 @@
margin: 20px 0; margin: 20px 0;
} }
.form-message .message-content {
max-width: 90%;
background: white;
border: 1px solid #e9ecef;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
/* System messages */ /* System messages */
.system-message { .system-message {
text-align: center; text-align: center;
@@ -437,24 +443,6 @@
opacity: 0.8; opacity: 0.8;
} }
/* Mobile responsiveness */
@media (max-width: 768px) {
.message {
padding: 0 15px;
}
/* moved to ChatMessage.vue scoped styles */
.message.user .message-content {
margin-left: 40px;
}
.message.ai .message-content,
.message.bot .message-content {
margin-right: 40px;
}
}
@media (max-width: 480px) { @media (max-width: 480px) {
.message { .message {
padding: 0 10px; padding: 0 10px;

View File

@@ -63,13 +63,6 @@
margin-top: 10px; margin-top: 10px;
} }
/* Dynamic form container transitions */
.dynamic-form-container {
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
transform: translateY(0);
opacity: 1;
}
/* Chat input transitions */ /* Chat input transitions */
.chat-input { .chat-input {
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out; transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;

View File

@@ -146,11 +146,6 @@
font-size: 14px; font-size: 14px;
} }
/* Ensure forms in messages use full available width */
.message .dynamic-form-container {
width: 100%;
max-width: none;
}
.message .dynamic-form { .message .dynamic-form {
width: 100%; width: 100%;

View File

@@ -14,8 +14,9 @@
/* App container layout */ /* App container layout */
.app-container { .app-container {
display: flex; display: flex;
height: 100vh; /* fallback */ /* Use visual viewport variable when available */
height: 100dvh; /* prefer dynamic viewport unit */ min-height: 0;
height: calc(var(--vvh, 1vh) * 100);
width: 100%; width: 100%;
} }
@@ -85,8 +86,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
height: 100vh; /* fallback for desktop */ height: auto; /* prefer dynamic viewport on desktop */
height: 100dvh; /* prefer dynamic viewport on desktop */
} }
.chat-container { .chat-container {
@@ -97,16 +97,19 @@
} }
html, body { html, body {
height: 100%; height: calc(var(--vvh, 1vh) * 100); min-height: 0;
} }
/* Base reset & overflow control */
* { box-sizing: border-box; }
html { overflow-x: hidden; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6; line-height: 1.6;
color: var(--text-color); color: var(--text-color);
background-color: var(--background-color); background-color: var(--background-color);
height: 100vh; overflow: hidden; /* prevent double scroll; inner containers handle scrolling */
overflow: hidden;
} }
.container { .container {
@@ -281,14 +284,6 @@ body {
} }
:root { --mobile-header-height: 60px; } /* default/minimum */ :root { --mobile-header-height: 60px; } /* default/minimum */
/* Content area takes remaining space */
.content-area {
flex: 1;
height: calc(100vh - var(--mobile-header-height)); /* fallback */
height: calc(100dvh - var(--mobile-header-height)); /* prefer dynamic viewport */
min-height: 0;
}
} }
/* Responsieve design regels worden nu gedefinieerd in chat-components.css */ /* Responsieve design regels worden nu gedefinieerd in chat-components.css */

View File

@@ -1,9 +1,5 @@
/* Dynamisch formulier stijlen */ /* Dynamisch formulier stijlen */
.dynamic-form {
padding: 15px;
}
.form-header { .form-header {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -434,13 +434,13 @@ export default {
.chat-input-container { .chat-input-container {
width: 100%; width: 100%;
max-width: 1000px; max-width: 1000px;
padding: 20px; padding: 10px;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--active-background-color); background-color: var(--active-background-color);
color: var(--human-message-text-color); color: var(--human-message-text-color);
border-top: 1px solid #e0e0e0; border-top: 1px solid #e0e0e0;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 14px; font-size: 16px;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@@ -470,7 +470,7 @@ export default {
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 14px; font-size: 16px;
/* Transparante achtergrond in plaats van wit */ /* Transparante achtergrond in plaats van wit */
background-color: var(--human-message-background); background-color: var(--human-message-background);
color: var(--human-message-text-color); color: var(--human-message-text-color);
@@ -534,14 +534,14 @@ export default {
/* Active AI Message Area - positioned at top of ChatInput */ /* Active AI Message Area - positioned at top of ChatInput */
.active-ai-message-area { .active-ai-message-area {
margin-bottom: 15px; margin-bottom: 10px;
padding: 12px;
background-color: var(--ai-message-background); background-color: var(--ai-message-background);
color: var(--ai-message-text-color); color: var(--ai-message-text-color);
border-radius: 8px; border-radius: 8px;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 14px; font-size: 16px;
box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1);
padding: 5px;
} }
/* Ensure the active AI message integrates well with ChatInput styling */ /* Ensure the active AI message integrates well with ChatInput styling */

View File

@@ -432,7 +432,7 @@ export default {
.message-content { .message-content {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 12px; font-size: 12px;
max-width: 90%; max-width: 100%;
padding: 8px; padding: 8px;
border-radius: 10px; border-radius: 10px;
word-wrap: break-word; word-wrap: break-word;
@@ -663,8 +663,7 @@ export default {
.message.bot .message-content { .message.bot .message-content {
background: var(--ai-message-background); background: var(--ai-message-background);
color: var(--ai-message-text-color); color: var(--ai-message-text-color);
border-bottom-left-radius: 4px; margin-right: 20px;
margin-right: 60px;
} }
/* Hover effects for message bubbles */ /* Hover effects for message bubbles */
@@ -692,27 +691,18 @@ export default {
} }
.message.ai .message-content, .message.ai .message-content,
.message.bot .message-content { .message.bot .message-content {
margin-right: 40px; margin-right: 5px;
} }
/* Mobile: place logo inside bubble and prevent overlap with text */ /* Mobile: place logo inside bubble and prevent overlap with text */
.message.ai .ai-message-logo { .message.ai .ai-message-logo {
top: -12px; top: -12px;
left: 8px;
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
.message.ai .message-content { .message.ai .message-content {
/* Reserve space for the in-bubble logo */ /* Reserve space for the in-bubble logo */
padding-top: 1px; /* 24px logo + margins */ padding-top: 1px; /* 24px logo + margins */
padding-left: 1px; padding-left: 10px;
}
}
@media (max-width: 480px) {
.message-content {
max-width: 90%;
margin-left: 20px !important;
margin-right: 20px !important;
} }
} }

View File

@@ -538,14 +538,20 @@ export default {
<style scoped> <style scoped>
/* Dynamisch formulier stijlen */ /* Dynamisch formulier stijlen */
.dynamic-form-container { .dynamic-form-container {
margin-bottom: 15px;
overflow: hidden; overflow: hidden;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
transform: translateY(0);
opacity: 1;
}
.message .dynamic-form-container {
width: 100%;
max-width: none;
} }
.dynamic-form { .dynamic-form {
background: var(--human-message-background); background: var(--human-message-background);
border-radius: 8px; border-radius: 8px;
padding: 15px;
box-shadow: 0 2px 15px rgba(0,0,0,0.1); box-shadow: 0 2px 15px rgba(0,0,0,0.1);
box-sizing: border-box; box-sizing: border-box;
max-width: 100%; max-width: 100%;

View File

@@ -294,7 +294,7 @@ export default {
border-bottom: 2px solid #000; border-bottom: 2px solid #000;
} }
} }
/* migrated from global css: message-content within form-message */
.form-message .message-content { .form-message .message-content {
max-width: 90%; max-width: 90%;
background: white; background: white;

View File

@@ -80,21 +80,24 @@ const handleLanguageChange = (newLanguage) => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; /* allow wrapping to next line on narrow screens */
padding: 10px 15px; padding: 10px 15px;
background: var(--sidebar-background); background: var(--sidebar-background);
color: var(--sidebar-color); color: var(--sidebar-color);
border-bottom: 1px solid rgba(0,0,0,0.1); border-bottom: 1px solid rgba(0,0,0,0.1);
min-height: 60px; min-height: 60px;
max-width: 100%; /* never exceed viewport width */
overflow: hidden; /* clip any accidental overflow */
} }
/* Mobile logo container - meer specifieke styling */ /* Mobile logo container - meer specifieke styling */
.mobile-logo { .mobile-logo {
flex-shrink: 0; flex-shrink: 1; /* allow logo area to shrink */
min-width: 0; /* allow shrinking below intrinsic width */
display: flex !important; display: flex !important;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
height: 50px; /* Vaste hoogte voor consistentie */ height: 50px; /* Vaste hoogte voor consistentie */
min-width: 120px; /* Minimale breedte */
} }
/* Diepere styling voor het logo component */ /* Diepere styling voor het logo component */
@@ -105,6 +108,7 @@ const handleLanguageChange = (newLanguage) => {
align-items: center !important; align-items: center !important;
justify-content: flex-start !important; justify-content: flex-start !important;
height: 100% !important; height: 100% !important;
min-width: 0 !important; /* allow inner content to shrink */
} }
.mobile-logo :deep(.logo-image) { .mobile-logo :deep(.logo-image) {
@@ -128,7 +132,7 @@ const handleLanguageChange = (newLanguage) => {
/* Mobile language selector styling */ /* Mobile language selector styling */
.mobile-language-selector { .mobile-language-selector {
flex-shrink: 1; flex-shrink: 1;
min-width: 140px; min-width: 0; /* allow selector area to shrink */
} }
.mobile-language-selector :deep(.language-selector) { .mobile-language-selector :deep(.language-selector) {
@@ -142,10 +146,18 @@ const handleLanguageChange = (newLanguage) => {
.mobile-language-selector :deep(.language-select) { .mobile-language-selector :deep(.language-select) {
padding: 6px 10px; padding: 6px 10px;
font-size: 0.85rem; font-size: 0.85rem;
min-width: 120px; min-width: 0; /* allow the select to shrink */
max-width: 100%; /* never exceed container width */
margin: 0; margin: 0;
} }
/* Extra constraints on ultra-small screens */
@media (max-width: 360px) {
.mobile-language-selector :deep(.language-select) {
max-width: 60vw; /* avoid pushing beyond viewport */
}
}
/* Media queries voor responsiviteit */ /* Media queries voor responsiviteit */
@media (max-width: 768px) { @media (max-width: 768px) {
.mobile-header { .mobile-header {

View File

@@ -2,14 +2,17 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{% block title %}EveAI Chat{% endblock %}</title> <title>{% block title %}EveAI Chat{% endblock %}</title>
<link href="{{url_for('static', filename='dist/chat-client.css')}}" rel="stylesheet" /> <link href="{{ asset_url('dist/chat-client.css') }}" rel="stylesheet" />
<!-- Custom theme colors from tenant settings --> <!-- Custom theme colors from tenant settings -->
<style> <style>
:root { :root {
/* Visual viewport vh unit fallback (1vh if JS not yet set) */
--vvh: 1vh;
/* Legacy support - keeping for backward compatibility only */ /* Legacy support - keeping for backward compatibility only */
/* These variables are deprecated and should not be used in new code */ /* These variables are deprecated and should not be used in new code */
--primary-color: {{ customisation.active_background_color|default('#ffffff') }}; --primary-color: {{ customisation.active_background_color|default('#ffffff') }};

View File

@@ -26,7 +26,7 @@
</script> </script>
<!-- Chat client JS - bundled met alle componenten en ES modules --> <!-- Chat client JS - bundled met alle componenten en ES modules -->
<script src="{{url_for('static', filename='dist/chat-client.js')}}"></script> <script src="{{ asset_url('dist/chat-client.js') }}"></script>
<script> <script>
// Voeg taalinstellingen toe aan chatConfig indien deze nog niet bestaan // Voeg taalinstellingen toe aan chatConfig indien deze nog niet bestaan
if (window.chatConfig) { if (window.chatConfig) {

View File

@@ -1,97 +0,0 @@
// Chat Client JavaScript modules
// Modern gebundelde versie met ES modules
// CSS imports - zorg dat deze bestanden bestaan en correct worden gebundeld door Parcel
import '../css/chat-client.css';
// CSS imports uit eveai_chat_client
import '../../../eveai_chat_client/static/assets/css/chat.css';
import '../../../eveai_chat_client/static/assets/css/chat-components.css';
import '../../../eveai_chat_client/static/assets/css/chat-input.css';
import '../../../eveai_chat_client/static/assets/css/chat-message.css';
import '../../../eveai_chat_client/static/assets/css/form.css';
import '../../../eveai_chat_client/static/assets/css/form-message.css';
import '../../../eveai_chat_client/static/assets/css/language-selector.css';
// Dependencies
import { createApp } from 'vue';
import { marked } from 'marked';
// Maak fundamentele libraries globaal beschikbaar
window.Vue = { createApp };
window.marked = marked;
// Gebruik barrel export voor componenten
import * as Components from '../../../eveai_chat_client/static/assets/vue-components/index.js';
// Maak Components globaal beschikbaar voor debugging
window.Components = Components;
console.log('Components loaded:', Object.keys(Components));
// Main chat application - moet als laatste worden geladen
import ChatApp from '../../../eveai_chat_client/static/assets/vue-components/ChatApp.vue';
// Wacht tot het document volledig is geladen voordat we Vue initialiseren
document.addEventListener('DOMContentLoaded', () => {
console.log('Initializing Chat Application');
try {
// Mount SideBar component to sidebar-container
const sidebarContainer = document.getElementById('sidebar-container');
if (sidebarContainer && Components.SideBar) {
const sidebarApp = createApp(Components.SideBar, {
tenantMake: window.chatConfig?.tenantMake || { name: 'EveAI', logo_url: '' },
explanationText: window.chatConfig?.explanationText || '',
initialLanguage: window.chatConfig?.language || 'en',
supportedLanguageDetails: window.chatConfig?.supportedLanguageDetails || {},
allowedLanguages: window.chatConfig?.allowedLanguages || ['nl', 'en', 'fr', 'de'],
apiPrefix: window.chatConfig?.apiPrefix || ''
});
// Registreer componenten voor sidebar app
for (const [name, component] of Object.entries(Components)) {
sidebarApp.component(name, component);
}
sidebarApp.mount('#sidebar-container');
console.log('SideBar mounted successfully');
}
// Mount MobileHeader component to mobile-header-container
const mobileHeaderContainer = document.getElementById('mobile-header-container');
if (mobileHeaderContainer && Components.MobileHeader) {
const mobileHeaderApp = createApp(Components.MobileHeader, {
tenantMake: window.chatConfig?.tenantMake || { name: 'EveAI', logo_url: '' },
initialLanguage: window.chatConfig?.language || 'en',
supportedLanguageDetails: window.chatConfig?.supportedLanguageDetails || {},
allowedLanguages: window.chatConfig?.allowedLanguages || ['nl', 'en', 'fr', 'de'],
apiPrefix: window.chatConfig?.apiPrefix || ''
});
// Registreer componenten voor mobile header app
for (const [name, component] of Object.entries(Components)) {
mobileHeaderApp.component(name, component);
}
mobileHeaderApp.mount('#mobile-header-container');
console.log('MobileHeader mounted successfully');
}
// Mount ChatApp to the chat container
const chatContainer = document.querySelector('.chat-container');
if (chatContainer) {
const chatApp = createApp(ChatApp);
// Registreer alle componenten globaal voor chat app
for (const [name, component] of Object.entries(Components)) {
chatApp.component(name, component);
}
chatApp.mount('.chat-container');
console.log('ChatApp mounted successfully');
}
console.log('All Vue apps mounted successfully');
} catch (error) {
console.error('Error initializing Vue applications:', error);
}
});

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat Client Entry</title>
</head>
<body>
<!-- Minimal HTML entry used solely for Parcel to produce hashed JS/CSS bundles. -->
<script type="module" src="../js/chat-client.js"></script>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Main Entry</title>
</head>
<body>
<!-- Minimal HTML entry used solely for Parcel to produce hashed JS/CSS bundles. -->
<script type="module" src="../js/main.js"></script>
</body>
</html>

View File

@@ -62,6 +62,12 @@ http {
alias /etc/nginx/static/; alias /etc/nginx/static/;
} }
# Long-term caching for hashed assets built by Parcel
location ^~ /static/dist/ {
alias /etc/nginx/static/dist/;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
#error_page 404 /404.html; #error_page 404 /404.html;
# redirect server error pages to the static page /50x.html # redirect server error pages to the static page /50x.html

1357
nginx/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -38,14 +38,25 @@
"scripts": { "scripts": {
"prebuild": "mkdir -p static/dist && npm run sync-assets", "prebuild": "mkdir -p static/dist && npm run sync-assets",
"sync-assets": "rsync -av ../eveai_app/static/assets/ static/assets/ && rsync -av ../eveai_chat_client/static/assets/ static/assets/", "sync-assets": "rsync -av ../eveai_app/static/assets/ static/assets/ && rsync -av ../eveai_chat_client/static/assets/ static/assets/",
"build": "npm run prebuild && npm run build:main && npm run build:chat", "build": "npm run prebuild && npm run build:main && npm run build:chat && npm run postbuild",
"build:main": "parcel build frontend_src/js/main.js --dist-dir static/dist --public-url /static/dist/ --no-source-maps", "build:main": "NODE_ENV=production parcel build frontend_src/entries/main.html",
"build:chat": "parcel build frontend_src/js/chat-client.js --dist-dir static/dist --public-url /static/dist/ --no-source-maps", "build:chat": "NODE_ENV=production parcel build frontend_src/entries/chat-client.html",
"predev": "mkdir -p static/dist && npm run sync-assets", "predev": "mkdir -p static/dist && npm run sync-assets",
"dev": "npm run predev && parcel frontend_src/js/main.js --dist-dir static/dist --public-url /static/dist/ & parcel frontend_src/js/chat-client.js --dist-dir static/dist --public-url /static/dist/", "dev": "npm run predev && parcel frontend_src/js/main.js --dist-dir static/dist --public-url /static/dist/ & parcel frontend_src/js/chat-client.js --dist-dir static/dist --public-url /static/dist/",
"prewatch": "mkdir -p static/dist && npm run sync-assets", "prewatch": "mkdir -p static/dist && npm run sync-assets",
"watch": "npm run prewatch && parcel watch frontend_src/js/main.js --dist-dir static/dist --public-url /static/dist/ & parcel watch frontend_src/js/chat-client.js --dist-dir static/dist --public-url /static/dist/", "watch": "npm run prewatch && parcel watch frontend_src/js/main.js --dist-dir static/dist --public-url /static/dist/ & parcel watch frontend_src/js/chat-client.js --dist-dir static/dist --public-url /static/dist/",
"clean": "rm -rf static/dist/* static/assets .parcel-cache" "clean": "rm -rf static/dist/* static/assets .parcel-cache",
"postbuild": "node scripts/generate-manifest.mjs"
},
"targets": {
"default": {
"context": "browser",
"distDir": "static/dist",
"publicUrl": "/static/dist/",
"outputFormat": "esmodule",
"isLibrary": false,
"optimize": true,
"sourceMap": false
}
} }
} }

View File

@@ -0,0 +1,55 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
const DIST_DIR = path.resolve(process.cwd(), 'static', 'dist');
const OUT_FILE = path.join(DIST_DIR, 'manifest.json');
const LOGICAL_ENTRIES = [
{ key: 'dist/chat-client.js', base: 'chat-client', exts: ['.js'] },
{ key: 'dist/chat-client.css', base: 'chat-client', exts: ['.css'] },
// Optionally include main entries if referenced via templates in the future
{ key: 'dist/main.js', base: 'main', exts: ['.js'] },
{ key: 'dist/main.css', base: 'main', exts: ['.css'] },
];
function isHashedFile(file, base, ext) {
return file.startsWith(base + '.') && file.endsWith(ext) && file !== base + ext;
}
async function generate() {
const files = await fs.readdir(DIST_DIR);
const manifest = {};
for (const entry of LOGICAL_ENTRIES) {
const matches = [];
for (const ext of entry.exts) {
for (const f of files) {
if (isHashedFile(f, entry.base, ext)) {
matches.push(`dist/${f}`);
}
}
}
if (matches.length === 1) {
manifest[entry.key] = matches[0];
} else if (matches.length > 1) {
const stats = await Promise.all(
matches.map(async m => ({
file: m,
mtime: (await fs.stat(path.join(DIST_DIR, path.basename(m)))).mtimeMs,
}))
);
stats.sort((a, b) => b.mtime - a.mtime);
manifest[entry.key] = stats[0].file;
} else {
manifest[entry.key] = entry.key; // fallback zodat pagina niet breekt
}
}
await fs.writeFile(OUT_FILE, JSON.stringify(manifest, null, 2), 'utf8');
console.log(`[manifest] written: ${OUT_FILE}`);
}
generate().catch(err => {
console.error('[manifest] generation failed:', err);
process.exit(1);
});