diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..2f31257 --- /dev/null +++ b/.aiignore @@ -0,0 +1,19 @@ +# An .aiignore file follows the same syntax as a .gitignore file. +# .gitignore documentation: https://git-scm.com/docs/gitignore + +# you can ignore files +.DS_Store +*.log +*.tmp + +# or folders +dist/ +build/ +out/ +nginx/node_modules/ +nginx/static/ +db_backups/ +docker/eveai_logs/ +docker/logs/ +docker/minio/ + diff --git a/.gitignore b/.gitignore index ddb859f..ebd0c98 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,7 @@ scripts/__pycache__/run_eveai_app.cpython-312.pyc /docker/grafana/data/ /temp_requirements/ /nginx/node_modules/ +/nginx/static/assets/css/chat.css +/nginx/static/assets/css/chat-components.css +/nginx/static/assets/js/components/ +/nginx/static/assets/js/chat-app.js diff --git a/app/eveai_chat_client/templates/chat.html b/app/eveai_chat_client/templates/chat.html new file mode 100644 index 0000000..07f3f52 --- /dev/null +++ b/app/eveai_chat_client/templates/chat.html @@ -0,0 +1,6 @@ + +{% extends "base.html" %} + +{% block title %}{{ tenant_make.name|default('EveAI') }} - AI Chat{% endblock %} + +{% block head %} diff --git a/common/utils/specialist_utils.py b/common/utils/specialist_utils.py deleted file mode 100644 index f0b3200..0000000 --- a/common/utils/specialist_utils.py +++ /dev/null @@ -1,196 +0,0 @@ -from datetime import datetime as dt, timezone as tz -from typing import Optional, Dict, Any -from flask import current_app -from sqlalchemy.exc import SQLAlchemyError - -from common.extensions import db, cache_manager -from common.models.interaction import ( - Specialist, EveAIAgent, EveAITask, EveAITool -) -from common.utils.model_logging_utils import set_logging_information, update_logging_information - - -def initialize_specialist(specialist_id: int, specialist_type: str, specialist_version: str): - """ - Initialize an agentic specialist by creating all its components based on configuration. - - Args: - specialist_id: ID of the specialist to initialize - specialist_type: Type of the specialist - specialist_version: Version of the specialist type to use - - Raises: - ValueError: If specialist not found or invalid configuration - SQLAlchemyError: If database operations fail - """ - config = cache_manager.specialists_config_cache.get_config(specialist_type, specialist_version) - if not config: - raise ValueError(f"No configuration found for {specialist_type} version {specialist_version}") - if config['framework'] == 'langchain': - pass # Langchain does not require additional items to be initialized. All configuration is in the specialist. - - specialist = Specialist.query.get(specialist_id) - if not specialist: - raise ValueError(f"Specialist with ID {specialist_id} not found") - - if config['framework'] == 'crewai': - initialize_crewai_specialist(specialist, config) - - -def initialize_crewai_specialist(specialist: Specialist, config: Dict[str, Any]): - timestamp = dt.now(tz=tz.utc) - - try: - # Initialize agents - if 'agents' in config: - for agent_config in config['agents']: - _create_agent( - specialist_id=specialist.id, - agent_type=agent_config['type'], - agent_version=agent_config['version'], - name=agent_config.get('name'), - description=agent_config.get('description'), - timestamp=timestamp - ) - - # Initialize tasks - if 'tasks' in config: - for task_config in config['tasks']: - _create_task( - specialist_id=specialist.id, - task_type=task_config['type'], - task_version=task_config['version'], - name=task_config.get('name'), - description=task_config.get('description'), - timestamp=timestamp - ) - - # Initialize tools - if 'tools' in config: - for tool_config in config['tools']: - _create_tool( - specialist_id=specialist.id, - tool_type=tool_config['type'], - tool_version=tool_config['version'], - name=tool_config.get('name'), - description=tool_config.get('description'), - timestamp=timestamp - ) - - db.session.commit() - current_app.logger.info(f"Successfully initialized crewai specialist {specialist.id}") - - except SQLAlchemyError as e: - db.session.rollback() - current_app.logger.error(f"Database error initializing crewai specialist {specialist.id}: {str(e)}") - raise - except Exception as e: - db.session.rollback() - current_app.logger.error(f"Error initializing crewai specialist {specialist.id}: {str(e)}") - raise - - -def _create_agent( - specialist_id: int, - agent_type: str, - agent_version: str, - name: Optional[str] = None, - description: Optional[str] = None, - timestamp: Optional[dt] = None -) -> EveAIAgent: - """Create an agent with the given configuration.""" - if timestamp is None: - timestamp = dt.now(tz=tz.utc) - - # Get agent configuration from cache - agent_config = cache_manager.agents_config_cache.get_config(agent_type, agent_version) - - agent = EveAIAgent( - specialist_id=specialist_id, - name=name or agent_config.get('name', agent_type), - description=description or agent_config.get('metadata').get('description', ''), - type=agent_type, - type_version=agent_version, - role=None, - goal=None, - backstory=None, - tuning=False, - configuration=None, - arguments=None - ) - - set_logging_information(agent, timestamp) - - db.session.add(agent) - current_app.logger.info(f"Created agent {agent.id} of type {agent_type}") - return agent - - -def _create_task( - specialist_id: int, - task_type: str, - task_version: str, - name: Optional[str] = None, - description: Optional[str] = None, - timestamp: Optional[dt] = None -) -> EveAITask: - """Create a task with the given configuration.""" - if timestamp is None: - timestamp = dt.now(tz=tz.utc) - - # Get task configuration from cache - task_config = cache_manager.tasks_config_cache.get_config(task_type, task_version) - - task = EveAITask( - specialist_id=specialist_id, - name=name or task_config.get('name', task_type), - description=description or task_config.get('metadata').get('description', ''), - type=task_type, - type_version=task_version, - task_description=None, - expected_output=None, - tuning=False, - configuration=None, - arguments=None, - context=None, - asynchronous=False, - ) - - set_logging_information(task, timestamp) - - db.session.add(task) - current_app.logger.info(f"Created task {task.id} of type {task_type}") - return task - - -def _create_tool( - specialist_id: int, - tool_type: str, - tool_version: str, - name: Optional[str] = None, - description: Optional[str] = None, - timestamp: Optional[dt] = None -) -> EveAITool: - """Create a tool with the given configuration.""" - if timestamp is None: - timestamp = dt.now(tz=tz.utc) - - # Get tool configuration from cache - tool_config = cache_manager.tools_config_cache.get_config(tool_type, tool_version) - - tool = EveAITool( - specialist_id=specialist_id, - name=name or tool_config.get('name', tool_type), - description=description or tool_config.get('metadata').get('description', ''), - type=tool_type, - type_version=tool_version, - tuning=False, - configuration=None, - arguments=None, - ) - - set_logging_information(tool, timestamp) - - db.session.add(tool) - current_app.logger.info(f"Created tool {tool.id} of type {tool_type}") - return tool diff --git a/docker/docker_env_switch.sh b/docker/docker_env_switch.sh index 00bc8e9..1cfa407 100755 --- a/docker/docker_env_switch.sh +++ b/docker/docker_env_switch.sh @@ -115,15 +115,41 @@ echo "Set COMPOSE_FILE to $COMPOSE_FILE" echo "Set EVEAI_VERSION to $VERSION" echo "Set DOCKER_ACCOUNT to $DOCKER_ACCOUNT" -# Define aliases for common Docker commands -alias docker-compose="docker compose -f $COMPOSE_FILE" -alias dc="docker compose -f $COMPOSE_FILE" -alias dcup="docker compose -f $COMPOSE_FILE up -d --remove-orphans" -alias dcdown="docker compose -f $COMPOSE_FILE down" -alias dcps="docker compose -f $COMPOSE_FILE ps" -alias dclogs="docker compose -f $COMPOSE_FILE logs" -alias dcpull="docker compose -f $COMPOSE_FILE pull" -alias dcrefresh="docker compose -f $COMPOSE_FILE pull && docker compose -f $COMPOSE_FILE up -d --remove-orphans" +docker-compose() { + docker compose -f $COMPOSE_FILE "$@" +} + +dc() { + docker compose -f $COMPOSE_FILE "$@" +} + +dcup() { + docker compose -f $COMPOSE_FILE up -d --remove-orphans "$@" +} + +dcdown() { + docker compose -f $COMPOSE_FILE down "$@" +} + +dcps() { + docker compose -f $COMPOSE_FILE ps "$@" +} + +dclogs() { + docker compose -f $COMPOSE_FILE logs "$@" +} + +dcpull() { + docker compose -f $COMPOSE_FILE pull "$@" +} + +dcrefresh() { + docker compose -f $COMPOSE_FILE pull && docker compose -f $COMPOSE_FILE up -d --remove-orphans "$@" +} + +# Exporteer de functies zodat ze beschikbaar zijn in andere scripts +export -f docker-compose dc dcup dcdown dcps dclogs dcpull dcrefresh + echo "Docker environment switched to $ENVIRONMENT with version $VERSION" echo "You can now use 'docker-compose', 'dc', 'dcup', 'dcdown', 'dcps', 'dclogs', 'dcpull' or 'dcrefresh' commands" \ No newline at end of file diff --git a/docker/rebuild_chat_client.sh b/docker/rebuild_chat_client.sh new file mode 100755 index 0000000..3c05bac --- /dev/null +++ b/docker/rebuild_chat_client.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +source ./docker_env_switch.sh dev +source .env + +dcdown eveai_chat_client nginx +./update_chat_client_statics.sh +./build_and_push_eveai.sh -b nginx +dcup eveai_chat_client nginx diff --git a/docker/update_chat_client_statics.sh b/docker/update_chat_client_statics.sh new file mode 100755 index 0000000..ffa0c9f --- /dev/null +++ b/docker/update_chat_client_statics.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Script to copy eveai_chat_client/static files to nginx/static +# without overwriting existing files + +SRC_DIR="../eveai_chat_client/static" +DEST_DIR="../nginx/static/assets" + +# Check if source directory exists +if [ ! -d "$SRC_DIR" ]; then + echo "Error: Source directory $SRC_DIR does not exist!" + exit 1 +fi + +# Create destination directory if it doesn't exist +if [ ! -d "$DEST_DIR" ]; then + echo "Destination directory $DEST_DIR does not exist. Creating it..." + mkdir -p "$DEST_DIR" +fi + +# Function to recursively copy files without overwriting +copy_without_overwrite() { + local src=$1 + local dest=$2 + + # Loop through all items in source directory + for item in "$src"/*; do + # Get the filename from the path + base_name=$(basename "$item") + + # If it's a directory, create it in the destination and recurse + if [ -d "$item" ]; then + if [ ! -d "$dest/$base_name" ]; then + echo "Creating directory: $dest/$base_name" + mkdir -p "$dest/$base_name" + fi + copy_without_overwrite "$item" "$dest/$base_name" + else + # If it's a file and doesn't exist in the destination, copy it + cp "$item" "$dest/$base_name" + fi + done +} + +# Start the copy process +echo "Starting to copy files from $SRC_DIR to $DEST_DIR..." +copy_without_overwrite "$SRC_DIR" "$DEST_DIR" + +echo "Copy completed!" diff --git a/eveai_chat_client/static/css/chat-components.css b/eveai_chat_client/static/css/chat-components.css new file mode 100644 index 0000000..cbe8c82 --- /dev/null +++ b/eveai_chat_client/static/css/chat-components.css @@ -0,0 +1,806 @@ + +/* Chat App Container Layout */ +.chat-app-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + min-height: 0; /* Belangrijk voor flexbox overflow */ + padding: 20px; /* Algemene padding voor alle kanten */ + box-sizing: border-box; +} + +/* Message Area - neemt alle beschikbare ruimte */ +.chat-messages-area { + flex: 1; /* Neemt alle beschikbare ruimte */ + overflow: hidden; /* Voorkomt dat het groter wordt dan container */ + display: flex; + flex-direction: column; + min-height: 0; /* Belangrijk voor nested flexbox */ + margin-bottom: 20px; /* Ruimte tussen messages en input */ + border-radius: 15px; + background: rgba(255,255,255,0.1); + backdrop-filter: blur(10px); + border: 1px solid rgba(255,255,255,0.2); + box-shadow: 0 4px 20px rgba(0,0,0,0.1); +} + +/* Chat Input - altijd onderaan */ +.chat-input-area { + flex: none; /* Neemt alleen benodigde ruimte */ + border-radius: 15px; + background: rgba(255,255,255,0.15); + backdrop-filter: blur(10px); + border: 1px solid rgba(255,255,255,0.2); + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + z-index: 10; +} + +/* Zorg dat de MessageHistory container ook flexbox gebruikt */ +.message-history-container { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 20px; /* Interne padding voor MessageHistory */ + box-sizing: border-box; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding-right: 10px; /* Ruimte voor scrollbar */ + margin-right: -10px; /* Compenseer voor scrollbar */ + scroll-behavior: smooth; +} + +/* Chat Input styling */ +.chat-input-container { + width: 100%; + position: relative; + padding: 20px; /* Interne padding voor ChatInput */ + box-sizing: border-box; +} + +.chat-input { + display: flex; + align-items: flex-end; + gap: 12px; + padding: 20px; + background: white; + border-radius: 15px; + box-shadow: 0 2px 15px rgba(0,0,0,0.1); + border: 1px solid rgba(0,0,0,0.05); +} + +.input-main { + flex: 1; + position: relative; +} + +.message-input { + width: 100%; + min-height: 45px; + max-height: 120px; + padding: 12px 18px; + border: 1px solid #ddd; + border-radius: 25px; + resize: none; + font-family: inherit; + font-size: 14px; + line-height: 1.4; + outline: none; + transition: all 0.2s ease; + box-sizing: border-box; +} + +.message-input:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); +} + +.message-input.over-limit { + border-color: #dc3545; + background-color: rgba(220, 53, 69, 0.05); +} + + +.input-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.send-btn { + width: 45px; + height: 45px; + border: none; + border-radius: 50%; + background: var(--primary-color); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s ease; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.send-btn:hover:not(:disabled) { + background: var(--secondary-color); + transform: scale(1.05); + box-shadow: 0 4px 15px rgba(0,0,0,0.2); +} + +.send-btn:disabled { + background: #ccc; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + + +/* Character counter */ +.character-counter { + position: absolute; + bottom: -25px; + right: 15px; + font-size: 12px; + color: #666; + padding: 2px 6px; + background: rgba(255,255,255,0.9); + border-radius: 10px; + backdrop-filter: blur(5px); +} + +.character-counter.over-limit { + color: #dc3545; + font-weight: bold; + background: rgba(220, 53, 69, 0.1); +} + +/* Loading spinner */ +.loading-spinner { + font-size: 16px; + animation: spin 1s linear infinite; +} + + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .chat-app-container { + padding: 10px; /* Kleinere padding op mobiel */ + } + + .chat-messages-area { + margin-bottom: 15px; + } + + .message-history-container { + padding: 15px; + } + + .chat-input-container { + padding: 15px; + } + + .chat-input { + padding: 15px; + gap: 10px; + } + + .action-btn { + width: 40px; + height: 40px; + font-size: 16px; + } + + .message-input { + font-size: 16px; /* Voorkomt zoom op iOS */ + padding: 10px 15px; + min-height: 40px; + } +} + +/* Extra small screens */ +@media (max-width: 480px) { + .chat-app-container { + padding: 8px; + } + + .chat-messages-area { + margin-bottom: 12px; + } + + .message-history-container { + padding: 12px; + } + + .chat-input-container { + padding: 12px; + } +} + +/* Loading states */ +.chat-input.loading .message-input { + opacity: 0.7; +} + +.chat-input.loading .action-btn { + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } +} + +/* Scrollbar styling voor webkit browsers */ +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.chat-messages::-webkit-scrollbar-track { + background: rgba(0,0,0,0.1); + border-radius: 3px; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: rgba(0,0,0,0.3); + border-radius: 3px; +} + +.chat-messages::-webkit-scrollbar-thumb:hover { + background: rgba(0,0,0,0.5); +} + +/* Verberg lege message bubbles tot er inhoud is */ +.message-text:empty { + display: none; +} + +.progress-tracker .status-icon.error { + color: #f44336; +} + +.progress-tracker.error .progress-header { + background-color: rgba(244, 67, 54, 0.1); + border-color: #f44336; +} + +/* Zorg dat de progress tracker goed wordt weergegeven in een lege message bubble */ +.message-content:has(.message-text:empty) .message-progress { + margin-bottom: 0; +} + +/* Verberg de message content container als er geen inhoud is en de verwerking bezig is */ +.message-content:has(.message-text:empty):not(:has(.message-progress.completed)):not(:has(.message-progress.error)) { + background: transparent; + box-shadow: none; + border: none; + padding: 0; + margin: 0; +} + +/* Focus binnen ChatInput voor toegankelijkheid */ +.chat-input:focus-within { + box-shadow: 0 2px 20px rgba(0, 123, 255, 0.2); + border-color: rgba(0, 123, 255, 0.3); +} + +/* Smooth transitions */ +.chat-messages-area, +.chat-input-area { + transition: all 0.3s ease; +} + +.chat-messages-area:hover, +.chat-input-area:hover { + box-shadow: 0 6px 25px rgba(0,0,0,0.15); +} + + +/* Message Bubbles Styling - Aangepast voor werkelijke template structuur */ + +/* Basis message container */ +.message { + display: flex; + margin-bottom: 16px; + padding: 0 20px; + animation: messageSlideIn 0.3s ease-out; + clear: both; +} + +/* User message alignment - rechts uitgelijnd */ +.message.user { + justify-content: flex-end; +} + +/* AI/Bot message alignment - links uitgelijnd */ +.message.ai, +.message.bot { + justify-content: flex-start; +} + +/* Message content wrapper - dit wordt de bubble */ +.message-content { + max-width: 70%; + padding: 12px 16px; + border-radius: 18px; + word-wrap: break-word; + position: relative; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + transition: all 0.2s ease; + display: inline-block; +} + +/* User message bubble styling */ +.message.user .message-content { + background: linear-gradient(135deg, #007bff, #0056b3); + color: white; + border-bottom-right-radius: 4px; +} + +/* AI/Bot message bubble styling */ +.message.ai .message-content, +.message.bot .message-content { + background: #f8f9fa; + color: #212529; + border: 1px solid #e9ecef; + border-bottom-left-radius: 4px; + margin-right: 60px; +} + +/* Message text content */ +.message-text { + line-height: 1.4; + font-size: 14px; + margin-bottom: 6px; +} + +.message-text p { + margin: 0; +} + +.message-text p + p { + margin-top: 8px; +} + + +/* Edit mode styling */ +.edit-mode .edit-textarea { + width: 100%; + padding: 8px; + border: 1px solid #ddd; + border-radius: 4px; + resize: vertical; + min-height: 60px; + font-family: inherit; + margin-bottom: 8px; +} + +.edit-actions { + display: flex; + gap: 8px; +} + +.btn-small { + padding: 4px 12px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; +} + +.btn-primary { + background: #007bff; + color: white; +} + +.btn-primary:hover { + background: #0056b3; +} + +.btn-secondary { + background: #6c757d; + color: white; +} + +.btn-secondary:hover { + background: #545b62; +} + +/* Special message types */ + +/* Form messages */ +.form-message { + justify-content: center; + 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-message { + text-align: center; + background: rgba(108, 117, 125, 0.1); + color: #6c757d; + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + margin: 10px auto; + max-width: 80%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.system-icon { + font-size: 14px; +} + +/* Error messages */ +.error-message { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + border-radius: 8px; + padding: 12px 16px; + margin: 10px auto; + max-width: 80%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.error-icon { + font-size: 16px; + color: #dc3545; +} + +.retry-btn { + background: #dc3545; + color: white; + border: none; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: background-color 0.2s ease; +} + +.retry-btn:hover { + background: #c82333; +} + +/* Message reactions */ +.message-reactions { + display: flex; + gap: 4px; + margin-top: 8px; + flex-wrap: wrap; +} + +.reaction { + background: rgba(0,0,0,0.05); + border: 1px solid rgba(0,0,0,0.1); + border-radius: 12px; + padding: 2px 8px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.reaction:hover { + background: rgba(0,0,0,0.1); + transform: scale(1.05); +} + +/* Image and file messages */ +.message-image { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + margin-bottom: 8px; + cursor: pointer; + transition: transform 0.2s ease; +} + +.message-image:hover { + transform: scale(1.02); +} + +.image-caption { + font-size: 13px; + margin-bottom: 6px; + opacity: 0.9; +} + +.file-attachment { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: rgba(0,0,0,0.03); + border-radius: 8px; + margin-bottom: 8px; +} + +.file-icon { + font-size: 24px; +} + +.file-info { + flex: 1; +} + +.file-name { + font-weight: 500; + margin-bottom: 2px; +} + +.file-size { + font-size: 12px; + opacity: 0.7; +} + +.file-download { + font-size: 20px; + text-decoration: none; + cursor: pointer; + transition: transform 0.2s ease; +} + +.file-download:hover { + transform: scale(1.1); +} + +/* Hover effects voor message bubbles */ +.message-content:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0,0,0,0.15); +} + +/* Bestaande animation en date-separator blijven hetzelfde */ +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + + +/* Empty state styling - blijft hetzelfde */ +.empty-state { + text-align: center; + padding: 40px 20px; + color: #6c757d; +} + +.empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-text { + font-size: 18px; + font-weight: 500; + margin-bottom: 8px; +} + +.empty-subtext { + font-size: 14px; + opacity: 0.8; +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .message { + padding: 0 15px; + } + + .message-content { + max-width: 85%; + padding: 10px 14px; + font-size: 14px; + } + + .message.user .message-content { + margin-left: 40px; + } + + .message.ai .message-content, + .message.bot .message-content { + margin-right: 40px; + } +} + +@media (max-width: 480px) { + .message { + padding: 0 10px; + } + + .message-content { + max-width: 90%; + margin-left: 20px !important; + margin-right: 20px !important; + } +} + +/* Progress Tracker Styling */ +.progress-tracker { + margin: 8px 0; + border: 1px solid #e9ecef; + border-radius: 8px; + background: #f8f9fa; + overflow: hidden; + transition: all 0.3s ease; + font-size: 13px; +} + +.progress-tracker.expanded { + max-height: 200px; +} + +.progress-tracker.completed { + border-color: #28a745; + background: #d4edda; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + cursor: pointer; + background: rgba(0,0,0,0.02); + border-bottom: 1px solid transparent; + transition: all 0.2s ease; +} + +.progress-header:hover { + background: rgba(0,0,0,0.05); +} + +.progress-tracker.expanded .progress-header { + border-bottom-color: #e9ecef; +} + +.progress-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + color: #495057; +} + +.status-icon { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + position: relative; +} + +.status-icon.completed { + background: #28a745; + color: white; + font-size: 8px; + line-height: 12px; + text-align: center; +} + +.status-icon.in-progress { + background: #007bff; + animation: pulse 1.5s infinite; +} + +.spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid #f3f3f3; + border-top: 2px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.progress-toggle { + color: #6c757d; + font-size: 14px; + transition: transform 0.2s ease; +} + +.progress-tracker.expanded .progress-toggle { + transform: rotate(180deg); +} + +.progress-error { + padding: 8px 12px; + color: #721c24; + background: #f8d7da; + border-top: 1px solid #f5c6cb; + font-size: 12px; +} + +.progress-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; +} + +.progress-tracker.expanded .progress-content { + max-height: 150px; + overflow-y: auto; +} + +.progress-content.single-line { + max-height: 30px; + overflow: hidden; + padding: 8px 12px; +} + +.progress-line { + padding: 4px 12px; + border-bottom: 1px solid rgba(0,0,0,0.05); + color: #6c757d; + line-height: 1.3; +} + +.progress-line:last-child { + border-bottom: none; +} + +/* Animaties */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } +} + +/* Integratie met message bubbles */ +.message.ai .progress-tracker, +.message.bot .progress-tracker { + margin-bottom: 8px; +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .progress-tracker { + font-size: 12px; + } + + .progress-header { + padding: 6px 10px; + } + + .progress-line { + padding: 3px 10px; + } + + .progress-content.single-line { + padding: 6px 10px; + } +} \ No newline at end of file diff --git a/eveai_chat_client/static/css/chat.css b/eveai_chat_client/static/css/chat.css index 45ba6ff..b97d2fc 100644 --- a/eveai_chat_client/static/css/chat.css +++ b/eveai_chat_client/static/css/chat.css @@ -98,17 +98,9 @@ body { border-bottom: 1px solid rgba(0,0,0,0.1); } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: var(--spacing); -} +/* .chat-messages wordt nu gedefinieerd in chat-components.css */ -.message { - margin-bottom: var(--spacing); - max-width: 80%; - clear: both; -} +/* .message wordt nu gedefinieerd in chat-components.css */ .user-message { float: right; @@ -118,11 +110,7 @@ body { float: left; } -.message-content { - padding: 12px 16px; - border-radius: var(--border-radius); - display: inline-block; -} +/* .message-content wordt nu gedefinieerd in chat-components.css */ .user-message .message-content { background-color: var(--message-user-bg); @@ -134,11 +122,7 @@ body { color: var(--text-color); } -.chat-input-container { - padding: var(--spacing); - border-top: 1px solid rgba(0,0,0,0.1); - display: flex; -} +/* .chat-input-container wordt nu gedefinieerd in chat-components.css */ #chat-input { flex: 1; @@ -150,44 +134,7 @@ body { margin-right: 8px; } -#send-button { - padding: 0 24px; - background-color: var(--primary-color); - color: white; - border: none; - border-radius: var(--border-radius); - cursor: pointer; -} - -/* Loading indicator */ -.typing-indicator { - display: flex; - align-items: center; -} - -.typing-indicator span { - height: 8px; - width: 8px; - background-color: rgba(0,0,0,0.3); - border-radius: 50%; - display: inline-block; - margin-right: 4px; - animation: typing 1.5s infinite ease-in-out; -} - -.typing-indicator span:nth-child(2) { - animation-delay: 0.2s; -} - -.typing-indicator span:nth-child(3) { - animation-delay: 0.4s; -} - -@keyframes typing { - 0% { transform: scale(1); } - 50% { transform: scale(1.5); } - 100% { transform: scale(1); } -} +/* .typing-indicator en bijbehorende animaties worden nu gedefinieerd in chat-components.css */ /* Error page styles */ .error-container { @@ -215,30 +162,6 @@ body { margin-top: 1.5rem; } -.btn-primary { - display: inline-block; - background-color: var(--primary-color); - color: white; - padding: 0.5rem 1rem; - border-radius: var(--border-radius); - text-decoration: none; -} +/* .btn-primary wordt nu gedefinieerd in chat-components.css */ -/* Responsive design */ -@media (max-width: 768px) { - .chat-container { - flex-direction: column; - } - - .sidebar { - width: 100%; - height: auto; - max-height: 30%; - border-right: none; - border-bottom: 1px solid rgba(0,0,0,0.1); - } - - .message { - max-width: 90%; - } -} \ No newline at end of file +/* Responsieve design regels worden nu gedefinieerd in chat-components.css */ \ No newline at end of file diff --git a/eveai_chat_client/static/js/chat-app.js b/eveai_chat_client/static/js/chat-app.js new file mode 100644 index 0000000..cbbf87d --- /dev/null +++ b/eveai_chat_client/static/js/chat-app.js @@ -0,0 +1,523 @@ +// Import all components +import { TypingIndicator } from '/static/assets/js/components/TypingIndicator.js'; +import { FormField } from '/static/assets/js/components/FormField.js'; +import { DynamicForm } from '/static/assets/js/components/DynamicForm.js'; +import { ChatMessage } from '/static/assets/js/components/ChatMessage.js'; +import { MessageHistory } from '/static/assets/js/components/MessageHistory.js'; +import { ChatInput } from '/static/assets/js/components/ChatInput.js'; +import { ProgressTracker } from '/static/assets/js/components/ProgressTracker.js'; + +// Main Chat Application +export const ChatApp = { + name: 'ChatApp', + components: { + TypingIndicator, + FormField, + DynamicForm, + ChatMessage, + MessageHistory, + ChatInput + }, + + data() { + // Maak een lokale kopie van de chatConfig om undefined errors te voorkomen + const chatConfig = window.chatConfig || {}; + const settings = chatConfig.settings || {}; + + return { + // Base template data (keeping existing functionality) + explanation: chatConfig.explanation || '', + + // Chat-specific data + currentMessage: '', + allMessages: [], + isTyping: false, + isLoading: false, + isSubmittingForm: false, + messageIdCounter: 1, + formValues: {}, + + // API prefix voor endpoints + apiPrefix: chatConfig.apiPrefix || '', + + // Configuration from Flask/server + conversationId: chatConfig.conversationId || 'default', + userId: chatConfig.userId || null, + userName: chatConfig.userName || '', + + // Settings met standaard waarden en overschreven door server config + settings: { + maxMessageLength: settings.maxMessageLength || 2000, + allowFileUpload: settings.allowFileUpload === true, + allowVoiceMessage: settings.allowVoiceMessage === true, + autoScroll: settings.autoScroll === true + }, + + // UI state + isMobile: window.innerWidth <= 768, + showSidebar: window.innerWidth > 768, + + // Advanced features + messageSearch: '', + filteredMessages: [], + isSearching: false + }; + }, + + computed: { + // Keep existing computed from base.html + compiledExplanation() { + if (typeof marked === 'function') { + return marked(this.explanation); + } else if (marked && typeof marked.parse === 'function') { + return marked.parse(this.explanation); + } else { + console.error('Marked library not properly loaded'); + return this.explanation; + } + }, + + displayMessages() { + return this.isSearching ? this.filteredMessages : this.allMessages; + }, + + hasMessages() { + return this.allMessages.length > 0; + } + }, + + mounted() { + this.initializeChat(); + this.setupEventListeners(); + }, + + beforeUnmount() { + this.cleanup(); + }, + + methods: { + // Initialization + initializeChat() { + console.log('Initializing chat application...'); + + // Load historical messages from server + this.loadHistoricalMessages(); + + // Add welcome message if no history + if (this.allMessages.length === 0) { + this.addWelcomeMessage(); + } + + // Focus input after initialization + this.$nextTick(() => { + this.focusChatInput(); + }); + }, + + loadHistoricalMessages() { + // Veilige toegang tot messages met fallback + const chatConfig = window.chatConfig || {}; + const historicalMessages = chatConfig.messages || []; + + if (historicalMessages.length > 0) { + this.allMessages = historicalMessages.map(msg => { + // Zorg voor een correct geformatteerde bericht-object + return { + 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' + }; + }); + + console.log(`Loaded ${this.allMessages.length} historical messages`); + } + }, + + addWelcomeMessage() { + this.addMessage( + 'Hallo! Ik ben je AI assistant. Vraag gerust om een formulier zoals "contactformulier" of "bestelformulier"!', + 'ai', + 'text' + ); + }, + + setupEventListeners() { + // Window resize listener + window.addEventListener('resize', this.handleResize); + + // Keyboard shortcuts + document.addEventListener('keydown', this.handleGlobalKeydown); + }, + + cleanup() { + window.removeEventListener('resize', this.handleResize); + document.removeEventListener('keydown', this.handleGlobalKeydown); + }, + + // Message management + addMessage(content, sender, type = 'text', formData = null) { + const message = { + id: this.messageIdCounter++, + content, + sender, + type, + formData, + timestamp: new Date().toISOString(), + status: sender === 'user' ? 'sent' : 'delivered' + }; + + this.allMessages.push(message); + + // Initialize form values if it's a form + if (type === 'form' && formData) { + this.$set(this.formValues, message.id, {}); + formData.fields.forEach(field => { + this.$set(this.formValues[message.id], field.name, field.defaultValue || ''); + }); + } + + // Update search results if searching + if (this.isSearching) { + this.performSearch(); + } + + return message; + }, + + updateCurrentMessage(value) { + this.currentMessage = value; + }, + + // Message sending + async sendMessage() { + const text = this.currentMessage.trim(); + if (!text || this.isLoading) return; + + console.log('Sending message:', text); + + // Add user message + const userMessage = this.addMessage(text, 'user', 'text'); + this.currentMessage = ''; + + // Show typing and loading state + this.isTyping = true; + this.isLoading = true; + + try { + const response = await this.callAPI('/api/send_message', { + message: text, + conversation_id: this.conversationId, + user_id: this.userId + }); + + // Hide typing indicator + this.isTyping = false; + + // Mark user message as delivered + userMessage.status = 'delivered'; + + // Add AI response + if (response.type === 'form') { + this.addMessage('', 'ai', 'form', response.formData); + } else { + // Voeg het bericht toe met task_id voor tracking - initieel leeg + const aiMessage = this.addMessage( + '', + 'ai', + 'text' + ); + + // Voeg task_id toe als beschikbaar + if (response.task_id) { + console.log('Monitoring Task ID: ', response.task_id); + aiMessage.taskId = response.task_id; + } + } + + } catch (error) { + console.error('Error sending message:', error); + this.isTyping = false; + + // Mark user message as failed + userMessage.status = 'failed'; + + this.addMessage( + 'Sorry, er ging iets mis bij het verzenden van je bericht. Probeer het opnieuw.', + 'ai', + 'error' + ); + } finally { + this.isLoading = false; + } + }, + + // Form handling + async submitForm(formData, messageId) { + this.isSubmittingForm = true; + + console.log('Submitting form:', formData.title, this.formValues[messageId]); + + try { + const response = await this.callAPI('/api/submit_form', { + formData: this.formValues[messageId], + formType: formData.title, + conversation_id: this.conversationId, + user_id: this.userId + }); + + if (response.success) { + this.addMessage( + `✅ ${response.message || 'Formulier succesvol verzonden!'}`, + 'ai', + 'text' + ); + + // Remove the form message + this.removeMessage(messageId); + } else { + this.addMessage( + `❌ Er ging iets mis: ${response.error || 'Onbekende fout'}`, + 'ai', + 'text' + ); + } + + } catch (error) { + console.error('Error submitting form:', error); + this.addMessage( + 'Sorry, er ging iets mis bij het verzenden van het formulier. Probeer het opnieuw.', + 'ai', + 'text' + ); + } finally { + this.isSubmittingForm = false; + } + }, + + // Message actions + + retryMessage(messageId) { + const message = this.allMessages.find(m => m.id === messageId); + if (message && message.status === 'failed') { + // Retry sending the message + this.currentMessage = message.content; + this.removeMessage(messageId); + this.sendMessage(); + } + }, + + removeMessage(messageId) { + const index = this.allMessages.findIndex(m => m.id === messageId); + if (index !== -1) { + this.allMessages.splice(index, 1); + // Verwijder ook eventuele formuliergegevens + if (this.formValues[messageId]) { + delete this.formValues[messageId]; + } + } + }, + + // File handling + async handleFileUpload(file) { + console.log('Uploading file:', file.name); + + // Add file message + const fileMessage = this.addMessage('', 'user', 'file', { + fileName: file.name, + fileSize: this.formatFileSize(file.size), + fileType: file.type + }); + + try { + // TODO: Implement actual file upload + // const response = await this.uploadFile(file); + // fileMessage.fileUrl = response.url; + + // Simulate file upload + setTimeout(() => { + fileMessage.fileUrl = URL.createObjectURL(file); + fileMessage.status = 'delivered'; + }, 1000); + + } catch (error) { + console.error('Error uploading file:', error); + fileMessage.status = 'failed'; + } + }, + + async handleVoiceRecord(audioBlob) { + console.log('Processing voice recording'); + + // Add voice message + const voiceMessage = this.addMessage('', 'user', 'voice', { + audioBlob, + duration: '00:05' // TODO: Calculate actual duration + }); + + // TODO: Send to speech-to-text service + // const transcription = await this.transcribeAudio(audioBlob); + // this.currentMessage = transcription; + // this.sendMessage(); + }, + + // Search functionality + performSearch() { + if (!this.messageSearch.trim()) { + this.isSearching = false; + this.filteredMessages = []; + return; + } + + this.isSearching = true; + const query = this.messageSearch.toLowerCase(); + + this.filteredMessages = this.allMessages.filter(message => + message.content && + message.content.toLowerCase().includes(query) + ); + }, + + clearSearch() { + this.messageSearch = ''; + this.isSearching = false; + this.filteredMessages = []; + }, + + // Event handlers + handleResize() { + this.isMobile = window.innerWidth <= 768; + this.showSidebar = window.innerWidth > 768; + }, + + handleGlobalKeydown(event) { + // Ctrl/Cmd + K for search + if ((event.ctrlKey || event.metaKey) && event.key === 'k') { + event.preventDefault(); + this.focusSearch(); + } + + // Escape to clear search + if (event.key === 'Escape' && this.isSearching) { + this.clearSearch(); + } + }, + + + // Utility methods + async callAPI(endpoint, data) { + // Gebruik de API prefix uit de lokale variabele + const fullEndpoint = this.apiPrefix + '/chat' + endpoint; + + console.log('Calling API with prefix:', { + prefix: this.apiPrefix, + endpoint: endpoint, + fullEndpoint: fullEndpoint + }); + + const response = await fetch(fullEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); + }, + + + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }, + + focusChatInput() { + this.$refs.chatInput?.focusInput(); + }, + + focusSearch() { + this.$refs.searchInput?.focus(); + }, + + handleSpecialistError(errorData) { + console.error('Specialist error:', errorData); + // Als we willen kunnen we hier nog extra logica toevoegen, zoals statistieken bijhouden of centraal loggen + }, + + }, + + template: ` +
+ + + + + + +
+ ` + +}; + +// Initialize app when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + console.log('Initializing Chat Application'); + + // Get access to the existing Vue app instance + if (window.__vueApp) { + // Register ALL components globally + window.__vueApp.component('TypingIndicator', TypingIndicator); + window.__vueApp.component('FormField', FormField); + window.__vueApp.component('DynamicForm', DynamicForm); + window.__vueApp.component('ChatMessage', ChatMessage); + window.__vueApp.component('MessageHistory', MessageHistory); + window.__vueApp.component('ChatInput', ChatInput); + window.__vueApp.component('ProgressTracker', ProgressTracker); + console.log('All chat components registered with existing Vue instance'); + + // Register the ChatApp component + window.__vueApp.component('ChatApp', ChatApp); + console.log('ChatApp component registered with existing Vue instance'); + + // Mount the Vue app + window.__vueApp.mount('#app'); + console.log('Vue app mounted with chat components'); + + } else { + console.error('No existing Vue instance found on window.__vueApp'); + } +}); \ No newline at end of file diff --git a/eveai_chat_client/static/js/components/ChatInput.js b/eveai_chat_client/static/js/components/ChatInput.js new file mode 100644 index 0000000..139baaa --- /dev/null +++ b/eveai_chat_client/static/js/components/ChatInput.js @@ -0,0 +1,132 @@ +// static/js/components/ChatInput.js + +export const ChatInput = { + name: 'ChatInput', + props: { + currentMessage: { + type: String, + default: '' + }, + isLoading: { + type: Boolean, + default: false + }, + placeholder: { + type: String, + default: 'Typ je bericht hier... (Enter om te verzenden, Shift+Enter voor nieuwe regel)' + }, + maxLength: { + type: Number, + default: 2000 + }, + }, + emits: ['send-message', 'update-message'], + data() { + return { + localMessage: this.currentMessage, + }; + }, + computed: { + characterCount() { + return this.localMessage.length; + }, + + isOverLimit() { + return this.characterCount > this.maxLength; + }, + + canSend() { + return this.localMessage.trim() && + !this.isLoading && + !this.isOverLimit; + } + }, + watch: { + currentMessage(newVal) { + this.localMessage = newVal; + }, + localMessage(newVal) { + this.$emit('update-message', newVal); + this.autoResize(); + } + }, + mounted() { + this.autoResize(); + }, + methods: { + handleKeydown(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(); + } else if (event.key === 'Escape') { + this.localMessage = ''; + } + }, + + sendMessage() { + if (this.canSend) { + this.$emit('send-message'); + } + }, + + autoResize() { + this.$nextTick(() => { + const textarea = this.$refs.messageInput; + if (textarea) { + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; + } + }); + }, + + focusInput() { + this.$refs.messageInput?.focus(); + }, + + clearInput() { + this.localMessage = ''; + this.focusInput(); + } + }, + template: ` +
+
+ +
+ + + +
+ {{ characterCount }}/{{ maxLength }} +
+
+ + +
+ + +
+
+
+ ` +}; \ No newline at end of file diff --git a/eveai_chat_client/static/js/components/ChatMessage.js b/eveai_chat_client/static/js/components/ChatMessage.js new file mode 100644 index 0000000..d13935b --- /dev/null +++ b/eveai_chat_client/static/js/components/ChatMessage.js @@ -0,0 +1,240 @@ +export const ChatMessage = { + name: 'ChatMessage', + props: { + message: { + type: Object, + required: true, + validator: (message) => { + return message.id && message.content !== undefined && message.sender && message.type; + } + }, + formValues: { + type: Object, + default: () => ({}) + }, + isSubmittingForm: { + type: Boolean, + default: false + }, + apiPrefix: { + type: String, + default: '' + } + }, + emits: ['submit-form', 'image-loaded', 'retry-message', 'specialist-complete', 'specialist-error'], + data() { + return { + isEditing: false, + editedContent: '' + }; + }, + methods: { + handleSpecialistError(eventData) { + console.log('ChatMessage received specialist-error event:', eventData); + + // Creëer een error message met correcte styling + this.message.type = 'error'; + this.message.content = eventData.message || 'Er is een fout opgetreden bij het verwerken van uw verzoek.'; + this.message.retryable = true; + this.message.error = true; // Voeg error flag toe voor styling + + // Bubble up naar parent component voor verdere afhandeling + this.$emit('specialist-error', { + messageId: this.message.id, + ...eventData + }); + }, + + handleSpecialistComplete(eventData) { + console.log('ChatMessage received specialist-complete event:', eventData); + + // Update de inhoud van het bericht met het antwoord + if (eventData.answer) { + console.log('Updating message content with answer:', eventData.answer); + this.message.content = eventData.answer; + } else { + console.error('No answer in specialist-complete event data'); + } + + // Bubble up naar parent component voor eventuele verdere afhandeling + this.$emit('specialist-complete', { + messageId: this.message.id, + ...eventData + }); + }, + + formatMessage(content) { + if (!content) return ''; + + // Enhanced markdown-like formatting + return content + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/`(.*?)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/\n/g, '
'); + }, + + startEdit() { + this.editedContent = this.message.content; + this.isEditing = true; + }, + + saveEdit() { + // Implementatie van bewerkingen zou hier komen + this.message.content = this.editedContent; + this.isEditing = false; + }, + + cancelEdit() { + this.isEditing = false; + this.editedContent = ''; + }, + + submitForm() { + this.$emit('submit-form', this.message.formData, this.message.id); + }, + + removeMessage() { + // Dit zou een event moeten triggeren naar de parent component + }, + + reactToMessage(emoji) { + // Implementatie van reacties zou hier komen + }, + + getMessageClass() { + if (this.message.type === 'form') { + return 'form-message'; + } + return `message ${this.message.sender}`; + } + }, + template: ` +
+ + + + + + + + + + + + + + + + + + + +
+ + {{ reaction.emoji }} {{ reaction.count }} + +
+
+ ` +}; \ No newline at end of file diff --git a/eveai_chat_client/static/js/components/DynamicForm.js b/eveai_chat_client/static/js/components/DynamicForm.js new file mode 100644 index 0000000..cec2ace --- /dev/null +++ b/eveai_chat_client/static/js/components/DynamicForm.js @@ -0,0 +1,110 @@ +export const DynamicForm = { + name: 'DynamicForm', + props: { + formData: { + type: Object, + required: true, + validator: (formData) => { + return formData.title && formData.fields && Array.isArray(formData.fields); + } + }, + formValues: { + type: Object, + required: true + }, + isSubmitting: { + type: Boolean, + default: false + } + }, + emits: ['submit', 'cancel'], + methods: { + handleSubmit() { + // Basic validation + const requiredFields = this.formData.fields.filter(field => field.required); + const missingFields = requiredFields.filter(field => { + const value = this.formValues[field.name]; + return !value || (typeof value === 'string' && !value.trim()); + }); + + if (missingFields.length > 0) { + const fieldNames = missingFields.map(f => f.label).join(', '); + alert(`De volgende velden zijn verplicht: ${fieldNames}`); + return; + } + + this.$emit('submit'); + }, + + handleCancel() { + this.$emit('cancel'); + }, + + updateFieldValue(fieldName, value) { + // Emit an update for reactive binding + this.$emit('update-field', fieldName, value); + } + }, + template: ` +
+
{{ formData.title }}
+ +
+ {{ formData.description }} +
+ +
+ + +
+ + + + + + +
+ + +
+
+
+
+ Stap {{ formData.currentStep }} van {{ formData.steps }} +
+
+
+ ` +}; \ No newline at end of file diff --git a/eveai_chat_client/static/js/components/FormField.js b/eveai_chat_client/static/js/components/FormField.js new file mode 100644 index 0000000..a1a06dd --- /dev/null +++ b/eveai_chat_client/static/js/components/FormField.js @@ -0,0 +1,179 @@ +export const FormField = { + name: 'FormField', + props: { + field: { + type: Object, + required: true, + validator: (field) => { + return field.name && field.type && field.label; + } + }, + modelValue: { + default: '' + } + }, + emits: ['update:modelValue'], + computed: { + value: { + get() { + return this.modelValue; + }, + set(value) { + this.$emit('update:modelValue', value); + } + } + }, + methods: { + handleFileUpload(event) { + const file = event.target.files[0]; + if (file) { + this.value = file; + } + } + }, + template: ` +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + +
+ +
+ + + + + + + + +
+ + {{ value }} +
+ + + + {{ field.helpText }} + +
+ ` +}; \ No newline at end of file diff --git a/eveai_chat_client/static/js/components/MessageHistory.js b/eveai_chat_client/static/js/components/MessageHistory.js new file mode 100644 index 0000000..b6db9e6 --- /dev/null +++ b/eveai_chat_client/static/js/components/MessageHistory.js @@ -0,0 +1,147 @@ +export const MessageHistory = { + name: 'MessageHistory', + props: { + messages: { + type: Array, + required: true, + default: () => [] + }, + isTyping: { + type: Boolean, + default: false + }, + formValues: { + type: Object, + default: () => ({}) + }, + isSubmittingForm: { + type: Boolean, + default: false + }, + apiPrefix: { + type: String, + default: '' + }, + autoScroll: { + type: Boolean, + default: true + } + }, + emits: ['submit-form', 'load-more', 'specialist-complete', 'specialist-error'], + data() { + return { + isAtBottom: true, + unreadCount: 0 + }; + }, + mounted() { + this.scrollToBottom(); + this.setupScrollListener(); + }, + updated() { + if (this.autoScroll && this.isAtBottom) { + this.$nextTick(() => this.scrollToBottom()); + } + }, + methods: { + scrollToBottom() { + const container = this.$refs.messagesContainer; + if (container) { + container.scrollTop = container.scrollHeight; + this.isAtBottom = true; + this.showScrollButton = false; + this.unreadCount = 0; + } + }, + + setupScrollListener() { + const container = this.$refs.messagesContainer; + if (!container) return; + + container.addEventListener('scroll', this.handleScroll); + }, + + handleScroll() { + const container = this.$refs.messagesContainer; + if (!container) return; + + const threshold = 100; // pixels from bottom + const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold; + + this.isAtBottom = isNearBottom; + + // Load more messages when scrolled to top + if (container.scrollTop === 0) { + this.$emit('load-more'); + } + }, + + handleSubmitForm(formData, messageId) { + this.$emit('submit-form', formData, messageId); + }, + + handleImageLoaded() { + // Auto-scroll when images load to maintain position + if (this.isAtBottom) { + this.$nextTick(() => this.scrollToBottom()); + } + }, + + searchMessages(query) { + // Simple message search + if (!query.trim()) return this.messages; + + const searchTerm = query.toLowerCase(); + return this.messages.filter(message => + message.content && + message.content.toLowerCase().includes(searchTerm) + ); + }, + + }, + beforeUnmount() { + // Cleanup scroll listener + const container = this.$refs.messagesContainer; + if (container) { + container.removeEventListener('scroll', this.handleScroll); + } + }, + template: ` +
+ +
+ +
+ +
+ + +
+
💬
+
Nog geen berichten
+
Start een gesprek door een bericht te typen!
+
+ + + + + + +
+ +
+ `, +}; \ No newline at end of file diff --git a/eveai_chat_client/static/js/components/ProgressTracker.js b/eveai_chat_client/static/js/components/ProgressTracker.js new file mode 100644 index 0000000..e3c01b7 --- /dev/null +++ b/eveai_chat_client/static/js/components/ProgressTracker.js @@ -0,0 +1,309 @@ +export const ProgressTracker = { + name: 'ProgressTracker', + props: { + taskId: { + type: String, + required: true + }, + apiPrefix: { + type: String, + default: '' + } + }, + emits: ['specialist-complete', 'progress-update', 'specialist-error'], + data() { + return { + isExpanded: false, + progressLines: [], + eventSource: null, + isCompleted: false, + lastLine: '', + error: null, + connecting: true, + finalAnswer: null, + hasError: false + }; + }, + computed: { + progressEndpoint() { + return `${this.apiPrefix}/chat/api/task_progress/${this.taskId}`; + }, + displayLines() { + return this.isExpanded ? this.progressLines : [ + this.lastLine || 'Verbinden met taak...' + ]; + } + }, + mounted() { + this.connectToEventSource(); + }, + beforeUnmount() { + this.disconnectEventSource(); + }, + methods: { + connectToEventSource() { + try { + this.connecting = true; + this.error = null; + + // Sluit eventuele bestaande verbinding + this.disconnectEventSource(); + + // Maak nieuwe SSE verbinding + this.eventSource = new EventSource(this.progressEndpoint); + + // Algemene event handler + this.eventSource.onmessage = (event) => { + this.handleProgressUpdate(event); + }; + + // Specifieke event handlers per type + this.eventSource.addEventListener('progress', (event) => { + this.handleProgressUpdate(event, 'progress'); + }); + + this.eventSource.addEventListener('EveAI Specialist Complete', (event) => { + console.log('Received EveAI Specialist Complete event'); + this.handleProgressUpdate(event, 'EveAI Specialist Complete'); + }); + + this.eventSource.addEventListener('error', (event) => { + this.handleError(event); + }); + + // Status handlers + this.eventSource.onopen = () => { + this.connecting = false; + }; + + this.eventSource.onerror = (error) => { + console.error('SSE Connection error:', error); + this.error = 'Verbindingsfout. Probeer het later opnieuw.'; + this.connecting = false; + + // Probeer opnieuw te verbinden na 3 seconden + setTimeout(() => { + if (!this.isCompleted && this.progressLines.length === 0) { + this.connectToEventSource(); + } + }, 3000); + }; + } catch (err) { + console.error('Error setting up event source:', err); + this.error = 'Kan geen verbinding maken met de voortgangsupdates.'; + this.connecting = false; + } + }, + + disconnectEventSource() { + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + }, + + handleProgressUpdate(event, eventType = null) { + try { + const update = JSON.parse(event.data); + + // Controleer op verschillende typen updates + const processingType = update.processing_type; + const data = update.data || {}; + + // Process based on processing type + let message = this.formatProgressMessage(processingType, data); + + // Alleen bericht toevoegen als er daadwerkelijk een bericht is + if (message) { + this.progressLines.push(message); + this.lastLine = message; + } + + // Emit progress update voor parent component + this.$emit('progress-update', { + processingType, + data, + message + }); + + // Handle completion and errors + if (processingType === 'EveAI Specialist Complete') { + console.log('Processing EveAI Specialist Complete:', data); + this.handleSpecialistComplete(data); + } else if (processingType === 'EveAI Specialist Error') { + this.handleSpecialistError(data); + } else if (processingType === 'Task Complete' || processingType === 'Task Error') { + this.isCompleted = true; + this.disconnectEventSource(); + } + + // Scroll automatisch naar beneden als uitgevouwen + if (this.isExpanded) { + this.$nextTick(() => { + const container = this.$refs.progressContainer; + if (container) { + container.scrollTop = container.scrollHeight; + } + }); + } + } catch (err) { + console.error('Error parsing progress update:', err, event.data); + } + }, + + formatProgressMessage(processingType, data) { + // Lege data dictionary - toon enkel processing type + if (!data || Object.keys(data).length === 0) { + return processingType; + } + + // Specifiek bericht als er een message field is + if (data.message) { + return data.message; + } + + // Processing type met name veld als dat bestaat + if (data.name) { + return `${processingType}: ${data.name}`; + } + + // Stap informatie + if (data.step) { + return `Stap ${data.step}: ${data.description || ''}`; + } + + // Voor EveAI Specialist Complete - geen progress message + if (processingType === 'EveAI Specialist Complete') { + return null; + } + + // Default: processing type + eventueel data als string + return processingType; + }, + + handleSpecialistComplete(data) { + this.isCompleted = true; + this.disconnectEventSource(); + + // Debug logging + console.log('Specialist Complete Data:', data); + + // Extract answer from data.result.answer + if (data.result && data.result.answer) { + this.finalAnswer = data.result.answer; + + console.log('Final Answer:', this.finalAnswer); + + // Direct update van de parent message als noodoplossing + try { + if (this.$parent && this.$parent.message) { + console.log('Direct update parent message'); + this.$parent.message.content = data.result.answer; + } + } catch(err) { + console.error('Error updating parent message:', err); + } + + // Emit event to parent met alle relevante data + this.$emit('specialist-complete', { + answer: data.result.answer, + result: data.result, + interactionId: data.interaction_id, + taskId: this.taskId + }); + } else { + console.error('Missing result.answer in specialist complete data:', data); + } + }, + + handleSpecialistError(data) { + this.isCompleted = true; + this.hasError = true; + this.disconnectEventSource(); + + // Zet gebruiksvriendelijke foutmelding + const errorMessage = "We could not process your request. Please try again later."; + this.error = errorMessage; + + // Log de werkelijke fout voor debug doeleinden + if (data.Error) { + console.error('Specialist Error:', data.Error); + } + + // Emit error event naar parent + this.$emit('specialist-error', { + message: errorMessage, + originalError: data.Error, + taskId: this.taskId + }); + }, + + handleError(event) { + console.error('SSE Error event:', event); + this.error = 'Er is een fout opgetreden bij het verwerken van updates.'; + + // Probeer parse van foutgegevens + try { + const errorData = JSON.parse(event.data); + if (errorData && errorData.message) { + this.error = errorData.message; + } + } catch (err) { + // Blijf bij algemene foutmelding als parsing mislukt + } + }, + + toggleExpand() { + this.isExpanded = !this.isExpanded; + + if (this.isExpanded) { + this.$nextTick(() => { + const container = this.$refs.progressContainer; + if (container) { + container.scrollTop = container.scrollHeight; + } + }); + } + } + }, + template: ` +
+
+
+ + + + + Fout bij verwerking + Verwerking voltooid + Bezig met redeneren... +
+
+ {{ isExpanded ? '▲' : '▼' }} +
+
+ +
+ {{ error }} +
+ +
+
+ {{ line }} +
+
+
+ ` +}; \ No newline at end of file diff --git a/eveai_chat_client/static/js/components/TypingIndicator.js b/eveai_chat_client/static/js/components/TypingIndicator.js new file mode 100644 index 0000000..be20bc7 --- /dev/null +++ b/eveai_chat_client/static/js/components/TypingIndicator.js @@ -0,0 +1,10 @@ +export const TypingIndicator = { + name: 'TypingIndicator', + template: ` +
+
+
+
+
+ ` +}; \ No newline at end of file diff --git a/eveai_chat_client/templates/base.html b/eveai_chat_client/templates/base.html index 1a1441e..003cbbd 100644 --- a/eveai_chat_client/templates/base.html +++ b/eveai_chat_client/templates/base.html @@ -6,7 +6,7 @@ {% block title %}EveAI Chat{% endblock %} - + @@ -99,9 +99,9 @@ .chat-container { flex: 1; - padding: 20px; display: flex; flex-direction: column; + min-height: 0; } @@ -129,7 +129,8 @@ {% block scripts %}{% endblock %} diff --git a/eveai_chat_client/templates/chat.html b/eveai_chat_client/templates/chat.html index 848d101..8125f93 100644 --- a/eveai_chat_client/templates/chat.html +++ b/eveai_chat_client/templates/chat.html @@ -1,214 +1,43 @@ + {% extends "base.html" %} -{% block title %}Chat{% endblock %} +{% block title %}{{ tenant_make.name|default('EveAI') }} - AI Chat{% endblock %} + +{% block head %} + + + + + +{% endblock %} {% block content %} -
- - - - -
-
-

{{ specialist.name }}

-
- -
- - {% if customisation.welcome_message %} -
-
{{ customisation.welcome_message|safe }}
-
- {% else %} -
-
Hello! How can I help you today?
-
- {% endif %} -
- -
- - -
-
-
+ + + + {% endblock %} {% block scripts %} - + + + {% endblock %} \ No newline at end of file diff --git a/eveai_chat_client/views/chat_views.py b/eveai_chat_client/views/chat_views.py index a0610ed..0d55c59 100644 --- a/eveai_chat_client/views/chat_views.py +++ b/eveai_chat_client/views/chat_views.py @@ -1,5 +1,5 @@ import uuid -from flask import Blueprint, render_template, request, session, current_app, jsonify, abort +from flask import Blueprint, render_template, request, session, current_app, jsonify, Response, stream_with_context from sqlalchemy.exc import SQLAlchemyError from common.extensions import db @@ -8,6 +8,7 @@ from common.models.interaction import SpecialistMagicLink, Specialist, ChatSessi from common.services.interaction.specialist_services import SpecialistServices from common.utils.database import Database from common.utils.chat_utils import get_default_chat_customisation +from common.utils.execution_progress import ExecutionProgressTracker chat_bp = Blueprint('chat_bp', __name__, url_prefix='/chat') @@ -82,6 +83,7 @@ def chat(magic_link_code): session['specialist'] = specialist.to_dict() session['magic_link'] = specialist_ml.to_dict() session['tenant_make'] = tenant_make.to_dict() + session['chat_session_id'] = SpecialistServices.start_session() # Get customisation options with defaults customisation = get_default_chat_customisation(tenant_make.chat_customisation_options) @@ -89,11 +91,20 @@ def chat(magic_link_code): # Start a new chat session session['chat_session_id'] = SpecialistServices.start_session() + # Define settings for the client + settings = { + "max_message_length": 2000, + "auto_scroll": True + } + return render_template('chat.html', tenant=tenant, tenant_make=tenant_make, specialist=specialist, - customisation=customisation) + customisation=customisation, + messages=[customisation['welcome_message']], + settings=settings + ) except Exception as e: current_app.logger.error(f"Error in chat view: {str(e)}", exc_info=True) @@ -111,10 +122,10 @@ def send_message(): if not message: return jsonify({'error': 'No message provided'}), 400 - tenant_id = session.get('tenant_id') - specialist_id = session.get('specialist_id') + tenant_id = session['tenant']['id'] + specialist_id = session['specialist']['id'] chat_session_id = session.get('chat_session_id') - specialist_args = session.get('specialist_args', {}) + specialist_args = session['magic_link'].get('specialist_args', {}) if not all([tenant_id, specialist_id, chat_session_id]): return jsonify({'error': 'Session expired or invalid'}), 400 @@ -123,7 +134,11 @@ def send_message(): Database(tenant_id).switch_schema() # Add user message to specialist arguments - specialist_args['user_message'] = message + specialist_args['question'] = message + + current_app.logger.debug(f"Sending message to specialist: {specialist_id} for tenant {tenant_id}\n" + f" with args: {specialist_args}\n" + f"with session ID: {chat_session_id}") # Execute specialist result = SpecialistServices.execute_specialist( @@ -134,12 +149,16 @@ def send_message(): user_timezone=data.get('timezone', 'UTC') ) + current_app.logger.debug(f"Specialist execution result: {result}") + # Store the task ID for polling session['current_task_id'] = result['task_id'] return jsonify({ 'status': 'processing', - 'task_id': result['task_id'] + 'task_id': result['task_id'], + 'content': 'Verwerking gestart...', + 'type': 'text' }) except Exception as e: @@ -192,3 +211,34 @@ def check_status(): except Exception as e: current_app.logger.error(f"Error checking status: {str(e)}", exc_info=True) return jsonify({'error': str(e)}), 500 + +@chat_bp.route('/api/task_progress/') +def task_progress_stream(task_id): + """ + Server-Sent Events endpoint voor realtime voortgangsupdates + """ + current_app.logger.debug(f"Streaming updates for task ID: {task_id}") + try: + tracker = ExecutionProgressTracker() + + def generate(): + try: + for update in tracker.get_updates(task_id): + current_app.logger.debug(f"Progress update: {update}") + yield update + except Exception as e: + current_app.logger.error(f"Progress stream error: {str(e)}") + yield f"data: {{'error': '{str(e)}'}}\n\n" + + return Response( + stream_with_context(generate()), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no' + } + ) + except Exception as e: + current_app.logger.error(f"Failed to start progress stream: {str(e)}") + return jsonify({'error': str(e)}), 500 + diff --git a/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_1.py b/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_1.py index b7b8076..f7fd833 100644 --- a/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_1.py +++ b/eveai_chat_workers/specialists/traicie/TRAICIE_SELECTION_SPECIALIST/1_1.py @@ -2,7 +2,7 @@ import asyncio import json from os import wait from typing import Optional, List - +from time import sleep from crewai.flow.flow import start, listen, and_ from flask import current_app from pydantic import BaseModel, Field @@ -22,7 +22,7 @@ from common.services.interaction.specialist_services import SpecialistServices class SpecialistExecutor(CrewAIBaseSpecialistExecutor): """ type: TRAICIE_SELECTION_SPECIALIST - type_version: 1.0 + type_version: 1.1 Traicie Selection Specialist Executor class """ @@ -40,7 +40,7 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor): @property def type_version(self) -> str: - return "1.0" + return "1.1" def _config_task_agents(self): self._add_task_agent("traicie_get_competencies_task", "traicie_hr_bp_agent") @@ -67,25 +67,34 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor): ) def execute(self, arguments: SpecialistArguments, formatted_context, citations) -> SpecialistResult: - self.log_tuning("Traicie Role Definition Specialist execution started", {}) + self.log_tuning("Traicie Selection Specialist execution started", {}) - flow_inputs = { - "vacancy_text": arguments.vacancy_text, - "role_name": arguments.role_name, - 'role_reference': arguments.role_reference, - } + # flow_inputs = { + # "vacancy_text": arguments.vacancy_text, + # "role_name": arguments.role_name, + # 'role_reference': arguments.role_reference, + # } + # + # flow_results = self.flow.kickoff(inputs=flow_inputs) + # + # flow_state = self.flow.state + # + # results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version) + # if flow_state.competencies: + # results.competencies = flow_state.competencies - flow_results = self.flow.kickoff(inputs=flow_inputs) + # self.create_selection_specialist(arguments, flow_state.competencies) + for i in range(5): + sleep(3) + self.ept.send_update(self.task_id, "Traicie Selection Specialist Processing", {"name": f"Processing Iteration {i}"}) - flow_state = self.flow.state + # flow_results = asyncio.run(self.flow.kickoff_async(inputs=arguments.model_dump())) + # flow_state = self.flow.state + # results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version) + results = SpecialistResult.create_for_type(self.type, self.type_version, + answer=f"Antwoord op uw vraag: {arguments.question}") - results = RoleDefinitionSpecialistResult.create_for_type(self.type, self.type_version) - if flow_state.competencies: - results.competencies = flow_state.competencies - - self.create_selection_specialist(arguments, flow_state.competencies) - - self.log_tuning(f"Traicie Role Definition Specialist execution ended", {"Results": results.model_dump()}) + self.log_tuning(f"Traicie Selection Specialist execution ended", {"Results": results.model_dump()}) return results @@ -129,9 +138,7 @@ class SpecialistExecutor(CrewAIBaseSpecialistExecutor): current_app.logger.error(f"Error creating selection specialist: {str(e)}") raise e - SpecialistServices.initialize_specialist(new_specialist.id, "TRAICIE_SELECTION_SPECIALIST", "1.0") - - + SpecialistServices.initialize_specialist(new_specialist.id, self.type, self.type_version) class RoleDefinitionSpecialistInput(BaseModel): diff --git a/eveai_chat_workers/tasks.py b/eveai_chat_workers/tasks.py index ed09a34..12c0b39 100644 --- a/eveai_chat_workers/tasks.py +++ b/eveai_chat_workers/tasks.py @@ -1,5 +1,6 @@ from datetime import datetime as dt, timezone as tz from typing import Dict, Any, Optional +import traceback from flask import current_app from sqlalchemy.exc import SQLAlchemyError @@ -240,8 +241,9 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict # Get specialist from database specialist = Specialist.query.get_or_404(specialist_id) except Exception as e: + stacktrace = traceback.format_exc() ept.send_update(task_id, "EveAI Specialist Error", {'Error': str(e)}) - current_app.logger.error(f'execute_specialist: Error executing specialist: {e}') + current_app.logger.error(f'execute_specialist: Error executing specialist: {e}\n{stacktrace}') raise with BusinessEvent("Execute Specialist", @@ -272,7 +274,8 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict retriever_args=raw_arguments.get('retriever_arguments', {}) ) except ValueError as e: - current_app.logger.error(f'execute_specialist: Error preparing arguments: {e}') + stacktrace = traceback.format_exc() + current_app.logger.error(f'execute_specialist: Error preparing arguments: {e}\n{stacktrace}') raise # Create new interaction record @@ -289,7 +292,8 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict event.update_attribute('interaction_id', new_interaction.id) except SQLAlchemyError as e: - current_app.logger.error(f'execute_specialist: Error creating interaction: {e}') + stacktrace = traceback.format_exc() + current_app.logger.error(f'execute_specialist: Error creating interaction: {e}\n{stacktrace}') raise with current_event.create_span("Specialist invocation"): @@ -314,7 +318,8 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict db.session.add(new_interaction) db.session.commit() except SQLAlchemyError as e: - current_app.logger.error(f'execute_specialist: Error updating interaction: {e}') + stacktrace = traceback.format_exc() + current_app.logger.error(f'execute_specialist: Error updating interaction: {e}\n{stacktrace}') raise # Now that we have a complete interaction with an answer, add it to the cache @@ -330,14 +335,16 @@ def execute_specialist(self, tenant_id: int, specialist_id: int, arguments: Dict return response except Exception as e: + stacktrace = traceback.format_exc() ept.send_update(task_id, "EveAI Specialist Error", {'Error': str(e)}) - current_app.logger.error(f'execute_specialist: Error executing specialist: {e}') + current_app.logger.error(f'execute_specialist: Error executing specialist: {e}\n{stacktrace}') new_interaction.processing_error = str(e)[:255] try: db.session.add(new_interaction) db.session.commit() except SQLAlchemyError as e: - current_app.logger.error(f'execute_specialist: Error updating interaction: {e}') + stacktrace = traceback.format_exc() + current_app.logger.error(f'execute_specialist: Error updating interaction: {e}\n{stacktrace}') raise