diff --git a/config/customisations/globals/CHAT_CLIENT_CUSTOMISATION/1.0.0.yaml b/config/customisations/globals/CHAT_CLIENT_CUSTOMISATION/1.0.0.yaml index 3b4d063..b86a50f 100644 --- a/config/customisations/globals/CHAT_CLIENT_CUSTOMISATION/1.0.0.yaml +++ b/config/customisations/globals/CHAT_CLIENT_CUSTOMISATION/1.0.0.yaml @@ -1,6 +1,18 @@ version: "1.0.0" name: "Chat Client Customisation" configuration: + "sidebar_markdown": + name: "Sidebar Markdown" + description: "Sidebar Markdown-formatted Text" + type: "text" + required: false + "reasoning_visibility": + name: "Reasoning Visibility" + description: "Level of Reasoning showing in Chat Client" + type: "enum" + allowed_values: ["No Visibility", "Detailed Visibility"] + default: "Detailed Visibility" + required: false "primary_color": name: "Primary Color" description: "Primary Color" @@ -51,11 +63,6 @@ configuration: description: "End Color for the gradient in the Chat Area" type: "color" required: false - "sidebar_markdown": - name: "Sidebar Markdown" - description: "Sidebar Markdown-formatted Text" - type: "text" - required: false metadata: author: "Josako" date_added: "2024-06-06" diff --git a/docker/compose_dev.yaml b/docker/compose_dev.yaml index 30f5c05..68a436d 100644 --- a/docker/compose_dev.yaml +++ b/docker/compose_dev.yaml @@ -144,40 +144,6 @@ services: networks: - eveai-network -# eveai_chat: -# image: josakola/eveai_chat:latest -# build: -# context: .. -# dockerfile: ./docker/eveai_chat/Dockerfile -# platforms: -# - linux/amd64 -# - linux/arm64 -# ports: -# - 5002:5002 -# environment: -# <<: *common-variables -# COMPONENT_NAME: eveai_chat -# volumes: -# - ../eveai_chat:/app/eveai_chat -# - ../common:/app/common -# - ../config:/app/config -# - ../scripts:/app/scripts -# - ../patched_packages:/app/patched_packages -# - ./eveai_logs:/app/logs -# depends_on: -# db: -# condition: service_healthy -# redis: -# condition: service_healthy -# healthcheck: -# test: [ "CMD", "curl", "-f", "http://localhost:5002/healthz/ready" ] # Adjust based on your health endpoint -# interval: 30s -# timeout: 1s -# retries: 3 -# start_period: 30s -# networks: -# - eveai-network - eveai_chat_client: image: josakola/eveai_chat_client:latest build: diff --git a/docker/eveai_chat/Dockerfile b/docker/eveai_chat/Dockerfile deleted file mode 100644 index 0bb6324..0000000 --- a/docker/eveai_chat/Dockerfile +++ /dev/null @@ -1,70 +0,0 @@ -ARG PYTHON_VERSION=3.12.7 -FROM python:${PYTHON_VERSION}-slim as base - -# Prevents Python from writing pyc files. -ENV PYTHONDONTWRITEBYTECODE=1 - -# Keeps Python from buffering stdout and stderr to avoid situations where -# the application crashes without emitting any logs due to buffering. -ENV PYTHONUNBUFFERED=1 - -# Create directory for patched packages and set permissions -RUN mkdir -p /app/patched_packages && \ - chmod 777 /app/patched_packages - -# Ensure patches are applied to the application. -ENV PYTHONPATH=/app/patched_packages:$PYTHONPATH - -WORKDIR /app - -# Create a non-privileged user that the app will run under. -# See https://docs.docker.com/go/dockerfile-user-best-practices/ -ARG UID=10001 -RUN adduser \ - --disabled-password \ - --gecos "" \ - --home "/nonexistent" \ - --shell "/bin/bash" \ - --no-create-home \ - --uid "${UID}" \ - appuser - -# Install necessary packages and build tools -RUN apt-get update && apt-get install -y \ - build-essential \ - gcc \ - postgresql-client \ - curl \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -# Create logs directory and set permissions -RUN mkdir -p /app/logs && chown -R appuser:appuser /app/logs - -# Download dependencies as a separate step to take advantage of Docker's caching. -# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. -# Leverage a bind mount to requirements.txt to avoid having to copy them into -# into this layer. - -COPY requirements.txt /app/ -RUN python -m pip install -r requirements.txt - -# Copy the source code into the container. -COPY eveai_chat /app/eveai_chat -COPY common /app/common -COPY config /app/config -COPY scripts /app/scripts -COPY patched_packages /app/patched_packages - -# Set permissions for entrypoint script -RUN chmod 777 /app/scripts/entrypoint.sh - -# Set ownership of the application directory to the non-privileged user -RUN chown -R appuser:appuser /app - -# Expose the port that the application listens on. -EXPOSE 5002 - -# Set entrypoint and command -ENTRYPOINT ["/app/scripts/entrypoint.sh"] -CMD ["/app/scripts/start_eveai_chat.sh"] diff --git a/eveai_chat/__init__.py b/eveai_chat/__init__.py deleted file mode 100644 index 25504f5..0000000 --- a/eveai_chat/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -import logging -import logging.config -from flask import Flask, jsonify, request -import os - -from flask_jwt_extended import verify_jwt_in_request, get_jwt_identity - -from common.extensions import db, socketio, jwt, cors, session, simple_encryption, metrics -from config.logging_config import LOGGING -from eveai_chat.socket_handlers import chat_handler -from common.utils.cors_utils import create_cors_after_request, get_allowed_origins -from common.utils.celery_utils import make_celery, init_celery -from config.config import get_config - - -def create_app(config_file=None): - app = Flask(__name__) - - environment = os.getenv('FLASK_ENV', 'development') - - match environment: - case 'development': - app.config.from_object(get_config('dev')) - case 'production': - app.config.from_object(get_config('prod')) - case _: - app.config.from_object(get_config('dev')) - - app.config['SESSION_KEY_PREFIX'] = 'eveai_chat_' - - logging.config.dictConfig(LOGGING) - register_extensions(app) - - app.celery = make_celery(app.name, app.config) - init_celery(app.celery, app) - - @app.before_request - def check_cors(): - if request.method == 'OPTIONS': - app.logger.debug("Handling OPTIONS request") - return '', 200 # Allow OPTIONS to pass through - - origin = request.headers.get('Origin') - if not origin: - return # Not a CORS request - - # Get tenant ID from request - if verify_jwt_in_request(): - tenant_id = get_jwt_identity() - if not tenant_id: - return - else: - return - - # Check if origin is allowed for this tenant - allowed_origins = get_allowed_origins(tenant_id) - - if origin not in allowed_origins: - app.logger.warning(f'Origin {origin} not allowed for tenant {tenant_id}') - return {'error': 'Origin not allowed'}, 403 - - app.logger.info("EveAI Chat Server Started Successfully") - app.logger.info("-------------------------------------------------------------------------------------------------") - return app - - -def register_extensions(app): - db.init_app(app) - socketio.init_app(app, - message_queue=app.config.get('SOCKETIO_MESSAGE_QUEUE'), - cors_allowed_origins=app.config.get('SOCKETIO_CORS_ALLOWED_ORIGINS'), - async_mode=app.config.get('SOCKETIO_ASYNC_MODE'), - logger=app.config.get('SOCKETIO_LOGGER'), - engineio_logger=app.config.get('SOCKETIO_ENGINEIO_LOGGER'), - path='/socket.io/', - ping_timeout=app.config.get('SOCKETIO_PING_TIMEOUT'), - ping_interval=app.config.get('SOCKETIO_PING_INTERVAL'), - ) - jwt.init_app(app) - simple_encryption.init_app(app) - metrics.init_app(app) - - # Cors setup - cors.init_app(app, resources={ - r"/*": { # Make sure this matches your setup - "origins": "*", - "methods": ["GET", "POST", "PUT", "OPTIONS"], - "allow_headers": ["Content-Type", "Authorization", "X-Requested-With"], - "expose_headers": ["Content-Length", "Content-Range"], - "supports_credentials": True, - "max_age": 1728000, - "allow_credentials": True - } - }) - - session.init_app(app) - - -def register_blueprints(app): - from views.healthz_views import healthz_bp - app.register_blueprint(healthz_bp) diff --git a/eveai_chat/socket_handlers/chat_handler.py b/eveai_chat/socket_handlers/chat_handler.py deleted file mode 100644 index 025c072..0000000 --- a/eveai_chat/socket_handlers/chat_handler.py +++ /dev/null @@ -1,358 +0,0 @@ -import uuid -from functools import wraps - -from flask_jwt_extended import create_access_token, get_jwt_identity, verify_jwt_in_request, decode_token -from flask_socketio import emit, disconnect, join_room, leave_room -from flask import current_app, request, session -from sqlalchemy.exc import SQLAlchemyError -from datetime import datetime, timedelta -from prometheus_client import Counter, Histogram -from time import time -import re - -from common.extensions import socketio, db, simple_encryption -from common.models.user import Tenant -from common.models.interaction import Interaction -from common.utils.celery_utils import current_celery -from common.utils.database import Database -from common.utils.token_validation import TokenValidator -from common.utils.eveai_exceptions import EveAISocketInputException - -# Define custom metrics -socketio_message_counter = Counter('socketio_message_count', 'Count of SocketIO messages', ['event_type']) -socketio_message_latency = Histogram('socketio_message_latency_seconds', 'Latency of SocketIO message processing', ['event_type']) - - -class RoomManager: - def __init__(self): - self.active_rooms = {} # Store active room metadata - - def validate_room_format(self, room_id: str) -> bool: - """Validate room ID format: tenant_id_sessionid_timestamp""" - pattern = r'^\d+_[a-zA-Z0-9]+_\d+$' - return bool(re.match(pattern, room_id)) - - def is_room_active(self, room_id: str) -> bool: - return room_id in self.active_rooms - - def validate_room_ownership(self, room_id: str, tenant_id: int, token: str) -> bool: - if not self.is_room_active(room_id): - return False - - room_data = self.active_rooms[room_id] - return (room_data['tenant_id'] == tenant_id and - room_data['token'] == token) - - def create_room(self, tenant_id: int, token: str) -> str: - """Create new room with metadata""" - timestamp = int(datetime.now().timestamp()) - room_id = f"{tenant_id}_{request.sid}_{timestamp}" - - self.active_rooms[room_id] = { - 'tenant_id': tenant_id, - 'token': token, - 'created_at': datetime.now(), - 'last_activity': datetime.now() - } - - return room_id - - def update_room_activity(self, room_id: str): - """Update room's last activity timestamp""" - if room_id in self.active_rooms: - self.active_rooms[room_id]['last_activity'] = datetime.now() - - def cleanup_inactive_rooms(self, max_age_hours: int = 1): - """Remove inactive rooms""" - now = datetime.now() - cutoff = now - timedelta(hours=max_age_hours) - - inactive_rooms = [ - room_id for room_id, data in self.active_rooms.items() - if data['last_activity'] < cutoff - ] - - for room_id in inactive_rooms: - del self.active_rooms[room_id] - - -room_manager = RoomManager() - - -# Decorator to measure SocketIO events -def track_socketio_event(func): - @wraps(func) - def wrapper(*args, **kwargs): - event_type = func.__name__ - socketio_message_counter.labels(event_type=event_type).inc() - start_time = time() - result = func(*args, **kwargs) - latency = time() - start_time - socketio_message_latency.labels(event_type=event_type).observe(latency) - return result - return wrapper - - -@socketio.on('connect') -@track_socketio_event -def handle_connect(): - """Handle incoming socket connections with enhanced security""" - try: - current_app.logger.debug('Handle Connection') - token = request.args.get('token') - if not token: - raise ValueError("Missing token") - - current_app.logger.debug(f"Token received: {token}") - - if not token: - raise ValueError("Missing token") - - current_app.logger.info(f"Trying to connect with: {token}") - - validator = TokenValidator() - validation_result = validator.validate_token(token) - - if not validation_result.is_valid: - current_app.logger.error(f"Socket connection failed: {validation_result.error_message}") - emit('connect_error', {'error': validation_result.error_message}) - disconnect() - return - - # Create room and setup session - room = room_manager.create_room(validation_result.tenant_id, token) - join_room(room) - - session['session_id'] = str(uuid.uuid4()) - session['last_activity'] = datetime.now() - session['room'] = room - - # Emit success events - emit('connect', { - 'status': 'Connected', - 'tenant_id': validation_result.tenant_id, - 'room': room - }) - emit('authenticated', {'token': token, 'room': room}) - current_app.logger.info(f"Socket connection succeeded: {token} / {room}") - - except Exception as e: - current_app.logger.error(f"Socket connection failed: {str(e)}") - emit('connect_error', {'status': 'Connection Failed'}) - disconnect() - - -@socketio.on('rejoin_room') -def handle_room_rejoin(data): - try: - token = data.get('token') - tenant_id = data.get('tenant_id') - previous_room = data.get('previousRoom') - - validator = TokenValidator() - validation_result = validator.validate_token(token, require_session=True) - if not validation_result.is_valid: - emit('room_rejoin_result', {'success': False, 'error': validation_result.error_message}) - return - - if not all([token, tenant_id, previous_room]): - raise ValueError("Missing required rejoin data") - - # Validate room ownership - if not room_manager.validate_room_ownership(previous_room, tenant_id, token): - raise ValueError("Invalid room ownership") - - # Rejoin room - join_room(previous_room) - session['room'] = previous_room - room_manager.update_room_activity(previous_room) - - emit('room_rejoin_result', { - 'success': True, - 'room': previous_room - }) - except Exception as e: - current_app.logger.error(f'Room rejoin failed: {e}') - emit('room_rejoin_result', { - 'success': False, - 'error': str(e) - }) - - -@socketio.on('disconnect') -@track_socketio_event -def handle_disconnect(): - room = session.get('room') - if room: - leave_room(room) - - -@socketio.on('heartbeat') -def handle_heartbeat(): - last_activity = session.get('last_activity') - if datetime.now() - last_activity > current_app.config.get('SOCKETIO_MAX_IDLE_TIME'): - disconnect() - - -@socketio.on('user_message') -def handle_message(data): - current_app.logger.debug(f"SocketIO: Received message: {data}") - try: - validator = TokenValidator() - validation_result = validator.validate_token(data.get('token')) - - if not validation_result.is_valid: - emit('error', {'message': validation_result.error_message}) - return - - current_app.logger.debug(f"SocketIO: token validated: {validation_result}") - - room = session.get('room') - current_app.logger.debug(f"SocketIO: Room in session: {room}, Room in arguments: {data.get('room')}") - - current_app.logger.debug(f"SocketIO: Room: {room}") - if not room or not room_manager.is_room_active(room): - raise Exception("Invalid or inactive room") - current_app.logger.debug(f"SocketIO: Room active: {room}") - - if not room_manager.validate_room_ownership(room, data['tenant_id'], data['token']): - raise Exception("Room ownership validation failed") - current_app.logger.debug(f"SocketIO: Room ownership validated: {room}") - - room_manager.update_room_activity(room) - current_app.logger.debug(f"SocketIO: Room activity updated: {room}") - session['last_activity'] = datetime.now() - current_tenant_id = validate_incoming_data(data) - - current_app.logger.debug(f"SocketIO: Incoming data validated: {current_tenant_id}") - - # Offload actual processing of question - task = current_celery.send_task('execute_specialist', - queue='llm_interactions', - args=[ - current_tenant_id, - data['specialist_id'], - data['arguments'], - session['session_id'], - data['timezone'], - room - ]) - response = { - 'tenantId': current_tenant_id, - 'message': f'Processing question ... Session ID = {session["session_id"]}', - 'taskId': task.id, - 'room': room, - } - current_app.logger.debug(f"SocketIO: Sent response {response}") - emit('bot_response', response, room=room) - except Exception as e: - current_app.logger.error(f'SocketIO: Message handling failed: {str(e)}') - emit('error', {'message': 'Failed to process message'}, room=room) - - -@socketio.on('check_task_status') -def check_task_status(data): - current_app.logger.debug(f'SocketIO: Checking Task Status ... {data}') - - validator = TokenValidator() - validation_result = validator.validate_token(data.get('token')) - - if not validation_result.is_valid: - emit('feedback_received', {'status': 'error', 'error': validation_result.error_message}) - return - - task_id = data.get('task_id') - room = session.get('room') - if not task_id: - emit('task_status', {'status': 'error', 'message': 'Missing task ID'}, room=room) - return - - task_result = current_celery.AsyncResult(task_id) - if task_result.state == 'PENDING': - emit('task_status', {'status': 'pending', 'taskId': task_id}, room=room) - elif task_result.state == 'SUCCESS': - result = task_result.result - current_app.logger.debug(f'SocketIO: Task {task_id} returned: {result}') - - # Access the result structure correctly - specialist_result = result['result'] # This contains the SpecialistResult model_dump - response = { - 'status': 'success', - 'taskId': task_id, - 'results': { - 'answer': specialist_result.get('answer'), - 'citations': specialist_result.get('citations', []), - 'insufficient_info': specialist_result.get('insufficient_info', False) - }, - 'interaction_id': result['interaction_id'], - 'room': room - } - emit('task_status', response, room=room) - else: - current_app.logger.error(f'SocketIO: Task {task_id} has failed. Error: {task_result.info}') - emit('task_status', {'status': task_result.state, 'message': str(task_result.info)}, room=room) - -@socketio.on('feedback') -def handle_feedback(data): - current_app.logger.debug(f'SocketIO: Received feedback: {data}') - try: - validator = TokenValidator() - validation_result = validator.validate_token(data.get('token')) - - if not validation_result.is_valid: - emit('feedback_received', {'status': 'error', 'error': validation_result.error_message}) - return - - current_tenant_id = validate_incoming_data(data) - - interaction_id = data.get('interactionId') - feedback = data.get('feedback') # 'up' or 'down' - - Database(current_tenant_id).switch_schema() - - interaction = Interaction.query.get_or_404(interaction_id) - interaction.appreciation = 0 if feedback == 'down' else 100 - - room = session.get('room') - if not room: - emit('feedback_received', {'status': 'error', 'message': 'No active room'}) - return - - try: - db.session.commit() - emit('feedback_received', {'status': 'success', 'interaction_id': interaction_id, 'room': room}, room=room) - except SQLAlchemyError as e: - current_app.logger.error(f'SocketIO: Feedback handling failed: {e}') - db.session.rollback() - emit('feedback_received', {'status': 'Could not register feedback', 'interaction_id': interaction_id}) - raise e - except Exception as e: - current_app.logger.error(f'SocketIO: Feedback handling failed: {e}') - disconnect() - - -def validate_api_key(tenant_id, api_key): - tenant = Tenant.query.get_or_404(tenant_id) - decrypted_api_key = simple_encryption.decrypt_api_key(tenant.encrypted_chat_api_key) - - return decrypted_api_key == api_key - - -def validate_incoming_data(data): - current_app.logger.debug(f'SocketIO: Validating incoming data: {data}') - token = data.get('token') - if not token: - raise EveAISocketInputException("SocketIO: Missing token in input") - - decoded_token = decode_token(token) - if not decoded_token: - raise EveAISocketInputException("SocketIO: Invalid token in input") - - current_app.logger.debug(f'SocketIO: Decoded token: {decoded_token}') - - current_tenant_id = decoded_token.get('sub') - - if not current_tenant_id: - raise EveAISocketInputException("SocketIO: Missing tenant_id (sub) in input") - - return current_tenant_id diff --git a/eveai_chat/static/eve_ai_chat.html b/eveai_chat/static/eve_ai_chat.html deleted file mode 100644 index 515941b..0000000 --- a/eveai_chat/static/eve_ai_chat.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - Chat Client - - - -

Chat Client

- -
- - -
-
- - - - diff --git a/eveai_chat/static/eveai_chat.html b/eveai_chat/static/eveai_chat.html deleted file mode 100644 index b464287..0000000 --- a/eveai_chat/static/eveai_chat.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - Chat Client - - - - - -
- - - diff --git a/eveai_chat/views/healthz_views.py b/eveai_chat/views/healthz_views.py deleted file mode 100644 index eabaf6d..0000000 --- a/eveai_chat/views/healthz_views.py +++ /dev/null @@ -1,70 +0,0 @@ -from flask import Blueprint, current_app, request -from flask_healthz import HealthError -from sqlalchemy.exc import SQLAlchemyError -from celery.exceptions import TimeoutError as CeleryTimeoutError -from common.extensions import db, metrics, minio_client -from common.utils.celery_utils import current_celery -from eveai_chat.socket_handlers.chat_handler import socketio_message_counter, socketio_message_latency - -healthz_bp = Blueprint('healthz', __name__, url_prefix='/_healthz') - - -def liveness(): - try: - # Basic check to see if the app is running - return True - except Exception: - raise HealthError("Liveness check failed") - - -def readiness(): - checks = { - "database": check_database(), - "celery": check_celery(), - # Add more checks as needed - } - - if not all(checks.values()): - raise HealthError("Readiness check failed") - - -def check_database(): - try: - # Perform a simple database query - db.session.execute("SELECT 1") - return True - except SQLAlchemyError: - current_app.logger.error("Database check failed", exc_info=True) - return False - - -def check_celery(): - try: - # Send a simple task to Celery - result = current_celery.send_task('ping', queue='llm_interactions') - response = result.get(timeout=10) # Wait for up to 10 seconds for a response - return response == 'pong' - except CeleryTimeoutError: - current_app.logger.error("Celery check timed out", exc_info=True) - return False - except Exception as e: - current_app.logger.error(f"Celery check failed: {str(e)}", exc_info=True) - return False - - -@healthz_bp.route('/metrics') -@metrics.do_not_track() -def prometheus_metrics(): - return metrics.generate_latest() - - -def init_healtz(app): - app.config.update( - HEALTHZ={ - "live": "healthz_views.liveness", - "ready": "healthz_views.readiness", - } - ) - # Register SocketIO metrics with Prometheus - metrics.register(socketio_message_counter) - metrics.register(socketio_message_latency) \ No newline at end of file diff --git a/eveai_chat_client/static/assets/vue-components/ChatMessage.vue b/eveai_chat_client/static/assets/vue-components/ChatMessage.vue index c8b767b..33f19db 100644 --- a/eveai_chat_client/static/assets/vue-components/ChatMessage.vue +++ b/eveai_chat_client/static/assets/vue-components/ChatMessage.vue @@ -14,7 +14,7 @@ > -
+
{ + // Skip if the field doesn't exist in formData + if (!message.formData.fields[key]) return false; + + // Check for meaningful values + if (value === null || value === undefined) return false; + if (typeof value === 'string' && value.trim() === '') return false; + if (typeof value === 'boolean') return true; // Boolean values are always meaningful + if (Array.isArray(value) && value.length === 0) return false; + + return true; + }); + + return hasActualValues; + }, + async handleLanguageChange(event) { // Controleer of dit het eerste bericht is in een gesprek met maar รฉรฉn bericht // Dit wordt al afgehandeld door MessageHistory component, dus we hoeven hier niets te doen diff --git a/eveai_chat_client/static/assets/vue-components/DynamicForm.vue b/eveai_chat_client/static/assets/vue-components/DynamicForm.vue index 19bdbd5..c50ca5e 100644 --- a/eveai_chat_client/static/assets/vue-components/DynamicForm.vue +++ b/eveai_chat_client/static/assets/vue-components/DynamicForm.vue @@ -9,8 +9,8 @@
{{ formData.title || formData.name }}
- -
+ +