From 783ce1ddc6384957b52ea7afd4013c31bfbb23f4 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Fri, 1 Aug 2025 20:51:28 +0300 Subject: [PATCH 01/16] refactor routes --- api/app_factory.py | 73 +++ api/auth/__init__.py | 17 + api/auth/oauth_handlers.py | 112 +++++ api/auth/user_management.py | 220 +++++++++ api/index.py | 934 +---------------------------------- api/index_original.py | 936 ++++++++++++++++++++++++++++++++++++ api/routes/__init__.py | 7 + api/routes/auth.py | 82 ++++ api/routes/database.py | 37 ++ api/routes/graphs.py | 474 ++++++++++++++++++ 10 files changed, 1961 insertions(+), 931 deletions(-) create mode 100644 api/app_factory.py create mode 100644 api/auth/__init__.py create mode 100644 api/auth/oauth_handlers.py create mode 100644 api/auth/user_management.py create mode 100644 api/index_original.py create mode 100644 api/routes/__init__.py create mode 100644 api/routes/auth.py create mode 100644 api/routes/database.py create mode 100644 api/routes/graphs.py diff --git a/api/app_factory.py b/api/app_factory.py new file mode 100644 index 00000000..b68421de --- /dev/null +++ b/api/app_factory.py @@ -0,0 +1,73 @@ +"""Application factory for the text2sql Flask app.""" + +import logging +import os + +from dotenv import load_dotenv +from flask import Flask, redirect, url_for +from flask_dance.contrib.google import make_google_blueprint +from flask_dance.contrib.github import make_github_blueprint +from flask_dance.consumer.storage.session import SessionStorage + +from api.auth.oauth_handlers import setup_oauth_handlers +from api.routes.auth import auth_bp +from api.routes.graphs import graphs_bp +from api.routes.database import database_bp + +# Load environment variables from .env file +load_dotenv() +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + + +def create_app(): + """Create and configure the Flask application.""" + app = Flask(__name__) + app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersekrit") + + # Google OAuth setup + GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") + GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") + google_bp = make_google_blueprint( + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + scope=[ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "openid" + ] + ) + app.register_blueprint(google_bp, url_prefix="/login") + + # GitHub OAuth setup + GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") + GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") + github_bp = make_github_blueprint( + client_id=GITHUB_CLIENT_ID, + client_secret=GITHUB_CLIENT_SECRET, + scope="user:email", + storage=SessionStorage() + ) + app.register_blueprint(github_bp, url_prefix="/login") + + # Set up OAuth signal handlers + setup_oauth_handlers(google_bp, github_bp) + + # Register blueprints + app.register_blueprint(auth_bp) + app.register_blueprint(graphs_bp) + app.register_blueprint(database_bp) + + @app.errorhandler(Exception) + def handle_oauth_error(error): + """Handle OAuth-related errors gracefully""" + # Check if it's an OAuth-related error + if "token" in str(error).lower() or "oauth" in str(error).lower(): + logging.warning("OAuth error occurred: %s", error) + from flask import session + session.clear() + return redirect(url_for("auth.home")) + + # For other errors, let them bubble up + raise error + + return app diff --git a/api/auth/__init__.py b/api/auth/__init__.py new file mode 100644 index 00000000..cdba858b --- /dev/null +++ b/api/auth/__init__.py @@ -0,0 +1,17 @@ +# Authentication module for text2sql API + +from .user_management import ( + ensure_user_in_organizations, + update_identity_last_login, + validate_and_cache_user, + token_required +) +from .oauth_handlers import setup_oauth_handlers + +__all__ = [ + "ensure_user_in_organizations", + "update_identity_last_login", + "validate_and_cache_user", + "token_required", + "setup_oauth_handlers" +] diff --git a/api/auth/oauth_handlers.py b/api/auth/oauth_handlers.py new file mode 100644 index 00000000..6e7e98bc --- /dev/null +++ b/api/auth/oauth_handlers.py @@ -0,0 +1,112 @@ +"""OAuth signal handlers for Google and GitHub authentication.""" + +import logging +import time + +import requests +from flask import session +from flask_dance.consumer import oauth_authorized +from flask_dance.contrib.google import google +from flask_dance.contrib.github import github + +from .user_management import ensure_user_in_organizations + + +def setup_oauth_handlers(google_bp, github_bp): + """Set up OAuth signal handlers for both Google and GitHub blueprints.""" + + @oauth_authorized.connect_via(google_bp) + def google_logged_in(blueprint, token): # pylint: disable=unused-argument + """Handle Google OAuth authorization callback.""" + if not token: + return False + + try: + # Get user profile + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + google_user = resp.json() + user_id = google_user.get("id") + email = google_user.get("email") + name = google_user.get("name") + + if user_id and email: + # Check if identity exists in Organizations graph, create if new + is_new_user, _ = ensure_user_in_organizations( + user_id, email, name, "google", google_user.get("picture") + ) + + # If existing identity, just update last login + # (already done in ensure_user_in_organizations) + + # Normalize user info structure for session + user_info_session = { + "id": user_id, + "name": name, + "email": email, + "picture": google_user.get("picture"), + "provider": "google" + } + session["user_info"] = user_info_session + session["token_validated_at"] = time.time() + return False # Don't create default flask-dance entry in session + + except (requests.RequestException, KeyError, ValueError, AttributeError) as e: + logging.error("Google OAuth signal error: %s", e) + + return False + + @oauth_authorized.connect_via(github_bp) + def github_logged_in(blueprint, token): # pylint: disable=unused-argument + """Handle GitHub OAuth authorization callback.""" + if not token: + return False + + try: + # Get user profile + resp = github.get("/user") + if resp.ok: + github_user = resp.json() + + # Get user email (GitHub may require separate call for email) + email_resp = github.get("/user/emails") + email = None + if email_resp.ok: + emails = email_resp.json() + # Find primary email + for email_obj in emails: + if email_obj.get("primary", False): + email = email_obj.get("email") + break + # If no primary email found, use the first one + if not email and emails: + email = emails[0].get("email") + + user_id = str(github_user.get("id")) + name = github_user.get("name") or github_user.get("login") + + if user_id and email: + # Check if identity exists in Organizations graph, create if new + is_new_user, _ = ensure_user_in_organizations( + user_id, email, name, "github", github_user.get("avatar_url") + ) + + # If existing identity, just update last login + # (already done in ensure_user_in_organizations) + + # Normalize user info structure for session + user_info_session = { + "id": user_id, + "name": name, + "email": email, + "picture": github_user.get("avatar_url"), + "provider": "github" + } + session["user_info"] = user_info_session + session["token_validated_at"] = time.time() + return False # Don't create default flask-dance entry in session + + except (requests.RequestException, KeyError, ValueError, AttributeError) as e: + logging.error("GitHub OAuth signal error: %s", e) + + return False diff --git a/api/auth/user_management.py b/api/auth/user_management.py new file mode 100644 index 00000000..d21aadca --- /dev/null +++ b/api/auth/user_management.py @@ -0,0 +1,220 @@ +"""User management and authentication functions for text2sql API.""" + +import logging +import time +from functools import wraps + +import requests +from flask import g, session, jsonify +from flask_dance.contrib.google import google +from flask_dance.contrib.github import github + +from api.extensions import db + + +def ensure_user_in_organizations(provider_user_id, email, name, provider, picture=None): + """ + Check if identity exists in Organizations graph, create if not. + Creates separate Identity and User nodes with proper relationships. + Uses MERGE for atomic operations and better performance. + Returns (is_new_user, user_info) + """ + try: + # Select the Organizations graph + organizations_graph = db.select_graph("Organizations") + + # Extract first and last name + name_parts = (name or "").split(" ", 1) if name else ["", ""] + first_name = name_parts[0] if len(name_parts) > 0 else "" + last_name = name_parts[1] if len(name_parts) > 1 else "" + + # Use MERGE to handle all scenarios in a single atomic operation + merge_query = """ + // First, ensure user exists (merge by email) + MERGE (user:User {email: $email}) + ON CREATE SET + user.first_name = $first_name, + user.last_name = $last_name, + user.created_at = timestamp() + + // Then, merge identity and link to user + MERGE (identity:Identity {provider: $provider, provider_user_id: $provider_user_id}) + ON CREATE SET + identity.email = $email, + identity.name = $name, + identity.picture = $picture, + identity.created_at = timestamp(), + identity.last_login = timestamp() + ON MATCH SET + identity.email = $email, + identity.name = $name, + identity.picture = $picture, + identity.last_login = timestamp() + + // Ensure relationship exists + MERGE (identity)-[:AUTHENTICATES]->(user) + + // Return results with flags to determine if this was a new user/identity + RETURN + identity, + user, + identity.created_at = identity.last_login AS is_new_identity, + EXISTS((user)<-[:AUTHENTICATES]-(:Identity)) AS had_other_identities + """ + + result = organizations_graph.query(merge_query, { + "provider": provider, + "provider_user_id": provider_user_id, + "email": email, + "name": name, + "picture": picture, + "first_name": first_name, + "last_name": last_name + }) + + if result.result_set: + identity = result.result_set[0][0] + user = result.result_set[0][1] + is_new_identity = result.result_set[0][2] + had_other_identities = result.result_set[0][3] + + # Determine the type of operation for logging + if is_new_identity and not had_other_identities: + # Brand new user (first identity) + logging.info("NEW USER CREATED: provider=%s, provider_user_id=%s, " + "email=%s, name=%s", provider, provider_user_id, email, name) + return True, {"identity": identity, "user": user} + elif is_new_identity and had_other_identities: + # New identity for existing user (cross-provider linking) + logging.info("NEW IDENTITY LINKED TO EXISTING USER: provider=%s, " + "provider_user_id=%s, email=%s, name=%s", + provider, provider_user_id, email, name) + return True, {"identity": identity, "user": user} + else: + # Existing identity login + logging.info("Existing identity found: provider=%s, email=%s", provider, email) + return False, {"identity": identity, "user": user} + else: + logging.error("Failed to create/update identity and user: email=%s", email) + return False, None + + except Exception as e: + logging.error("Error managing user in Organizations graph: %s", e) + return False, None + + +def update_identity_last_login(provider, provider_user_id): + """Update the last login timestamp for an existing identity""" + try: + organizations_graph = db.select_graph("Organizations") + update_query = """ + MATCH (identity:Identity {provider: $provider, provider_user_id: $provider_user_id}) + SET identity.last_login = timestamp() + RETURN identity + """ + organizations_graph.query(update_query, { + "provider": provider, + "provider_user_id": provider_user_id + }) + logging.info("Updated last login for identity: provider=%s, provider_user_id=%s", + provider, provider_user_id) + except Exception as e: + logging.error("Error updating last login for identity %s/%s: %s", + provider, provider_user_id, e) + + +def validate_and_cache_user(): + """ + Helper function to validate OAuth token and cache user info. + Returns (user_info, is_authenticated) tuple. + Supports both Google and GitHub OAuth. + """ + # Check for cached user info from either provider + user_info = session.get("user_info") + token_validated_at = session.get("token_validated_at", 0) + current_time = time.time() + + # Use cached user info if it's less than 15 minutes old + if user_info and (current_time - token_validated_at) < 900: # 15 minutes + return user_info, True + + # Check Google OAuth first + if google.authorized: + try: + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + google_user = resp.json() + # Normalize user info structure + user_info = { + "id": google_user.get("id"), + "name": google_user.get("name"), + "email": google_user.get("email"), + "picture": google_user.get("picture"), + "provider": "google" + } + session["user_info"] = user_info + session["token_validated_at"] = current_time + return user_info, True + except (requests.RequestException, KeyError, ValueError) as e: + logging.warning("Google OAuth validation error: %s", e) + + # Check GitHub OAuth + if github.authorized: + try: + # Get user profile + resp = github.get("/user") + if resp.ok: + github_user = resp.json() + + # Get user email (GitHub may require separate call for email) + email_resp = github.get("/user/emails") + email = None + if email_resp.ok: + emails = email_resp.json() + # Find primary email + for email_obj in emails: + if email_obj.get("primary", False): + email = email_obj.get("email") + break + + # If no primary email found, use the first one + if not email and emails: + email = emails[0].get("email") + + # Normalize user info structure + user_info = { + "id": str(github_user.get("id")), # Convert to string for consistency + "name": github_user.get("name") or github_user.get("login"), + "email": email, + "picture": github_user.get("avatar_url"), + "provider": "github" + } + session["user_info"] = user_info + session["token_validated_at"] = current_time + return user_info, True + except (requests.RequestException, KeyError, ValueError) as e: + logging.warning("GitHub OAuth validation error: %s", e) + + # If no valid authentication found, clear session + session.clear() + return None, False + + +def token_required(f): + """Decorator to protect routes with token authentication""" + + @wraps(f) + def decorated_function(*args, **kwargs): + user_info, is_authenticated = validate_and_cache_user() + + if not is_authenticated: + return jsonify(message="Unauthorized - Please log in"), 401 + + g.user_id = user_info.get("id") + if not g.user_id: + session.clear() + return jsonify(message="Unauthorized - Invalid user"), 401 + + return f(*args, **kwargs) + + return decorated_function diff --git a/api/index.py b/api/index.py index e7e8f1d5..18aff240 100644 --- a/api/index.py +++ b/api/index.py @@ -1,936 +1,8 @@ -"""This module contains the routes for the text2sql API.""" +"""Main entry point for the text2sql API.""" -import json -import logging -import os -import time -from concurrent.futures import ThreadPoolExecutor -from concurrent.futures import TimeoutError as FuturesTimeoutError -from functools import wraps - -import requests -from dotenv import load_dotenv -from flask import (Blueprint, Flask, Response, jsonify, render_template, - request, stream_with_context, g, session, redirect, url_for) -from flask_dance.contrib.google import make_google_blueprint, google -from flask_dance.contrib.github import make_github_blueprint, github -from flask_dance.consumer.storage.session import SessionStorage -from flask_dance.consumer import oauth_authorized - -from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent -from api.extensions import db -from api.graph import find, get_db_description -from api.loaders.csv_loader import CSVLoader -from api.loaders.json_loader import JSONLoader -from api.loaders.postgres_loader import PostgresLoader -from api.loaders.odata_loader import ODataLoader - -# User management functions -def ensure_user_in_organizations(provider_user_id, email, name, provider, picture=None): - """ - Check if identity exists in Organizations graph, create if not. - Creates separate Identity and User nodes with proper relationships. - Uses MERGE for atomic operations and better performance. - Returns (is_new_user, user_info) - """ - try: - # Select the Organizations graph - organizations_graph = db.select_graph("Organizations") - - # Extract first and last name - name_parts = (name or "").split(" ", 1) if name else ["", ""] - first_name = name_parts[0] if len(name_parts) > 0 else "" - last_name = name_parts[1] if len(name_parts) > 1 else "" - - # Use MERGE to handle all scenarios in a single atomic operation - merge_query = """ - // First, ensure user exists (merge by email) - MERGE (user:User {email: $email}) - ON CREATE SET - user.first_name = $first_name, - user.last_name = $last_name, - user.created_at = timestamp() - - // Then, merge identity and link to user - MERGE (identity:Identity {provider: $provider, provider_user_id: $provider_user_id}) - ON CREATE SET - identity.email = $email, - identity.name = $name, - identity.picture = $picture, - identity.created_at = timestamp(), - identity.last_login = timestamp() - ON MATCH SET - identity.email = $email, - identity.name = $name, - identity.picture = $picture, - identity.last_login = timestamp() - - // Ensure relationship exists - MERGE (identity)-[:AUTHENTICATES]->(user) - - // Return results with flags to determine if this was a new user/identity - RETURN - identity, - user, - identity.created_at = identity.last_login AS is_new_identity, - EXISTS((user)<-[:AUTHENTICATES]-(:Identity)) AS had_other_identities - """ - - result = organizations_graph.query(merge_query, { - "provider": provider, - "provider_user_id": provider_user_id, - "email": email, - "name": name, - "picture": picture, - "first_name": first_name, - "last_name": last_name - }) - - if result.result_set: - identity = result.result_set[0][0] - user = result.result_set[0][1] - is_new_identity = result.result_set[0][2] - had_other_identities = result.result_set[0][3] - - # Determine the type of operation for logging - if is_new_identity and not had_other_identities: - # Brand new user (first identity) - logging.info("NEW USER CREATED: provider=%s, provider_user_id=%s, " - "email=%s, name=%s", provider, provider_user_id, email, name) - return True, {"identity": identity, "user": user} - elif is_new_identity and had_other_identities: - # New identity for existing user (cross-provider linking) - logging.info("NEW IDENTITY LINKED TO EXISTING USER: provider=%s, " - "provider_user_id=%s, email=%s, name=%s", - provider, provider_user_id, email, name) - return True, {"identity": identity, "user": user} - else: - # Existing identity login - logging.info("Existing identity found: provider=%s, email=%s", provider, email) - return False, {"identity": identity, "user": user} - else: - logging.error("Failed to create/update identity and user: email=%s", email) - return False, None - - except Exception as e: - logging.error("Error managing user in Organizations graph: %s", e) - return False, None - -def update_identity_last_login(provider, provider_user_id): - """Update the last login timestamp for an existing identity""" - try: - organizations_graph = db.select_graph("Organizations") - update_query = """ - MATCH (identity:Identity {provider: $provider, provider_user_id: $provider_user_id}) - SET identity.last_login = timestamp() - RETURN identity - """ - organizations_graph.query(update_query, { - "provider": provider, - "provider_user_id": provider_user_id - }) - logging.info("Updated last login for identity: provider=%s, provider_user_id=%s", - provider, provider_user_id) - except Exception as e: - logging.error("Error updating last login for identity %s/%s: %s", - provider, provider_user_id, e) - -# Load environment variables from .env file -load_dotenv() -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") - -# Use the same delimiter as in the JavaScript -MESSAGE_DELIMITER = "|||FALKORDB_MESSAGE_BOUNDARY|||" - -main = Blueprint("main", __name__) - -def validate_and_cache_user(): - """ - Helper function to validate OAuth token and cache user info. - Returns (user_info, is_authenticated) tuple. - Supports both Google and GitHub OAuth. - """ - # Check for cached user info from either provider - user_info = session.get("user_info") - token_validated_at = session.get("token_validated_at", 0) - current_time = time.time() - - # Use cached user info if it's less than 15 minutes old - if user_info and (current_time - token_validated_at) < 900: # 15 minutes - return user_info, True - - # Check Google OAuth first - if google.authorized: - try: - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - google_user = resp.json() - # Normalize user info structure - user_info = { - "id": google_user.get("id"), - "name": google_user.get("name"), - "email": google_user.get("email"), - "picture": google_user.get("picture"), - "provider": "google" - } - session["user_info"] = user_info - session["token_validated_at"] = current_time - return user_info, True - except (requests.RequestException, KeyError, ValueError) as e: - logging.warning("Google OAuth validation error: %s", e) - - # Check GitHub OAuth - if github.authorized: - try: - # Get user profile - resp = github.get("/user") - if resp.ok: - github_user = resp.json() - - # Get user email (GitHub may require separate call for email) - email_resp = github.get("/user/emails") - email = None - if email_resp.ok: - emails = email_resp.json() - # Find primary email - for email_obj in emails: - if email_obj.get("primary", False): - email = email_obj.get("email") - break - - # If no primary email found, use the first one - if not email and emails: - email = emails[0].get("email") - - # Normalize user info structure - user_info = { - "id": str(github_user.get("id")), # Convert to string for consistency - "name": github_user.get("name") or github_user.get("login"), - "email": email, - "picture": github_user.get("avatar_url"), - "provider": "github" - } - session["user_info"] = user_info - session["token_validated_at"] = current_time - return user_info, True - except (requests.RequestException, KeyError, ValueError) as e: - logging.warning("GitHub OAuth validation error: %s", e) - - # If no valid authentication found, clear session - session.clear() - return None, False - - -def token_required(f): - """Decorator to protect routes with token authentication""" - - @wraps(f) - def decorated_function(*args, **kwargs): - user_info, is_authenticated = validate_and_cache_user() - - if not is_authenticated: - return jsonify(message="Unauthorized - Please log in"), 401 - - g.user_id = user_info.get("id") - if not g.user_id: - session.clear() - return jsonify(message="Unauthorized - Invalid user"), 401 - - return f(*args, **kwargs) - - return decorated_function - - -app = Flask(__name__) -app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersekrit") - -# Google OAuth setup -GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") -GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") -google_bp = make_google_blueprint( - client_id=GOOGLE_CLIENT_ID, - client_secret=GOOGLE_CLIENT_SECRET, - scope=[ - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "openid" - ] -) -app.register_blueprint(google_bp, url_prefix="/login") - -# GitHub OAuth setup -GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") -GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") -github_bp = make_github_blueprint( - client_id=GITHUB_CLIENT_ID, - client_secret=GITHUB_CLIENT_SECRET, - scope="user:email", - storage=SessionStorage() -) -app.register_blueprint(github_bp, url_prefix="/login") - -# GitHub OAuth signal handler - -@oauth_authorized.connect_via(google_bp) -def google_logged_in(blueprint, token): # pylint: disable=unused-argument - """Handle Google OAuth authorization callback.""" - if not token: - return False - - try: - # Get user profile - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - google_user = resp.json() - user_id = google_user.get("id") - email = google_user.get("email") - name = google_user.get("name") - - if user_id and email: - # Check if identity exists in Organizations graph, create if new - is_new_user, _ = ensure_user_in_organizations( - user_id, email, name, "google", google_user.get("picture") - ) - - # If existing identity, just update last login - # (already done in ensure_user_in_organizations) - - # Normalize user info structure for session - user_info_session = { - "id": user_id, - "name": name, - "email": email, - "picture": google_user.get("picture"), - "provider": "google" - } - session["user_info"] = user_info_session - session["token_validated_at"] = time.time() - return False # Don't create default flask-dance entry in session - - except (requests.RequestException, KeyError, ValueError, AttributeError) as e: - logging.error("Google OAuth signal error: %s", e) - - return False - -@oauth_authorized.connect_via(github_bp) -def github_logged_in(blueprint, token): # pylint: disable=unused-argument - """Handle GitHub OAuth authorization callback.""" - if not token: - return False - - try: - # Get user profile - resp = github.get("/user") - if resp.ok: - github_user = resp.json() - - # Get user email (GitHub may require separate call for email) - email_resp = github.get("/user/emails") - email = None - if email_resp.ok: - emails = email_resp.json() - # Find primary email - for email_obj in emails: - if email_obj.get("primary", False): - email = email_obj.get("email") - break - # If no primary email found, use the first one - if not email and emails: - email = emails[0].get("email") - - user_id = str(github_user.get("id")) - name = github_user.get("name") or github_user.get("login") - - if user_id and email: - # Check if identity exists in Organizations graph, create if new - is_new_user, _ = ensure_user_in_organizations( - user_id, email, name, "github", github_user.get("avatar_url") - ) - - # If existing identity, just update last login - # (already done in ensure_user_in_organizations) - - # Normalize user info structure for session - user_info_session = { - "id": user_id, - "name": name, - "email": email, - "picture": github_user.get("avatar_url"), - "provider": "github" - } - session["user_info"] = user_info_session - session["token_validated_at"] = time.time() - return False # Don't create default flask-dance entry in session - - except (requests.RequestException, KeyError, ValueError, AttributeError) as e: - logging.error("GitHub OAuth signal error: %s", e) - - return False - - -@app.errorhandler(Exception) -def handle_oauth_error(error): - """Handle OAuth-related errors gracefully""" - # Check if it's an OAuth-related error - if "token" in str(error).lower() or "oauth" in str(error).lower(): - logging.warning("OAuth error occurred: %s", error) - session.clear() - return redirect(url_for("home")) - - # For other errors, let them bubble up - raise error - - -@app.route("/") -def home(): - """Home route""" - user_info, is_authenticated = validate_and_cache_user() - - # If not authenticated through OAuth, check for any stale session data - if not is_authenticated and not google.authorized and not github.authorized: - session.pop("user_info", None) - - return render_template("chat.j2", is_authenticated=is_authenticated, user_info=user_info) - - -@app.route("/graphs") -@token_required -def graphs(): - """ - This route is used to list all the graphs that are available in the database. - """ - user_id = g.user_id - user_graphs = db.list_graphs() - # Only include graphs that start with user_id + '_', and strip the prefix - filtered_graphs = [graph[len(f"{user_id}_"):] - for graph in user_graphs if graph.startswith(f"{user_id}_")] - return jsonify(filtered_graphs) - - -@app.route("/graphs", methods=["POST"]) -@token_required -def load(): - """ - This route is used to load the graph data into the database. - It expects either: - - A JSON payload (application/json) - - A File upload (multipart/form-data) - - An XML payload (application/xml or text/xml) - """ - content_type = request.content_type - success, result = False, "Invalid content type" - graph_id = "" - - # ✅ Handle JSON Payload - if content_type.startswith("application/json"): - data = request.get_json() - if not data or "database" not in data: - return jsonify({"error": "Invalid JSON data"}), 400 - - graph_id = g.user_id + "_" + data["database"] - success, result = JSONLoader.load(graph_id, data) - - # # ✅ Handle XML Payload - # elif content_type.startswith("application/xml") or content_type.startswith("text/xml"): - # xml_data = request.data - # graph_id = "" - # success, result = ODataLoader.load(graph_id, xml_data) - - # # ✅ Handle CSV Payload - # elif content_type.startswith("text/csv"): - # csv_data = request.data - # graph_id = "" - # success, result = CSVLoader.load(graph_id, csv_data) - - # ✅ Handle File Upload (FormData with JSON/XML) - elif content_type.startswith("multipart/form-data"): - if "file" not in request.files: - return jsonify({"error": "No file uploaded"}), 400 - - file = request.files["file"] - if file.filename == "": - return jsonify({"error": "Empty file"}), 400 - - # ✅ Check if file is JSON - if file.filename.endswith(".json"): - try: - data = json.load(file) - graph_id = g.user_id + "_" + data.get("database", "") - success, result = JSONLoader.load(graph_id, data) - except json.JSONDecodeError: - return jsonify({"error": "Invalid JSON file"}), 400 - - # ✅ Check if file is XML - elif file.filename.endswith(".xml"): - xml_data = file.read().decode("utf-8") # Convert bytes to string - graph_id = g.user_id + "_" + file.filename.replace(".xml", "") - success, result = ODataLoader.load(graph_id, xml_data) - - # ✅ Check if file is csv - elif file.filename.endswith(".csv"): - csv_data = file.read().decode("utf-8") # Convert bytes to string - graph_id = g.user_id + "_" + file.filename.replace(".csv", "") - success, result = CSVLoader.load(graph_id, csv_data) - - else: - return jsonify({"error": "Unsupported file type"}), 415 - else: - return jsonify({"error": "Unsupported Content-Type"}), 415 - - # ✅ Return the final response - if success: - return jsonify({"message": result, "graph_id": graph_id}) - - return jsonify({"error": result}), 400 - - -@app.route("/graphs/", methods=["POST"]) -@token_required -def query(graph_id: str): - """ - text2sql - """ - graph_id = g.user_id + "_" + graph_id.strip() - request_data = request.get_json() - queries_history = request_data.get("chat") - result_history = request_data.get("result") - instructions = request_data.get("instructions") - if not queries_history: - return jsonify({"error": "Invalid or missing JSON data"}), 400 - - logging.info("User Query: %s", queries_history[-1]) - - # Create a generator function for streaming - def generate(): - agent_rel = RelevancyAgent(queries_history, result_history) - agent_an = AnalysisAgent(queries_history, result_history) - - step = {"type": "reasoning_step", - "message": "Step 1: Analyzing user query and generating SQL..."} - yield json.dumps(step) + MESSAGE_DELIMITER - # Ensure the database description is loaded - db_description, db_url = get_db_description(graph_id) - - logging.info("Calling to relevancy agent with query: %s", - queries_history[-1]) - answer_rel = agent_rel.get_answer(queries_history[-1], db_description) - if answer_rel["status"] != "On-topic": - step = { - "type": "followup_questions", - "message": "Off topic question: " + answer_rel["reason"], - } - logging.info("SQL Fail reason: %s", answer_rel["reason"]) - yield json.dumps(step) + MESSAGE_DELIMITER - else: - # Use a thread pool to enforce timeout - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(find, graph_id, queries_history, db_description) - try: - _, result, _ = future.result(timeout=120) - except FuturesTimeoutError: - yield json.dumps( - { - "type": "error", - "message": ("Timeout error while finding tables relevant to " - "your request."), - } - ) + MESSAGE_DELIMITER - return - except Exception as e: - logging.info("Error in find function: %s", e) - yield json.dumps( - {"type": "error", "message": "Error in find function"} - ) + MESSAGE_DELIMITER - return - - logging.info("Calling to analysis agent with query: %s", queries_history[-1]) - answer_an = agent_an.get_analysis( - queries_history[-1], result, db_description, instructions - ) - - logging.info("SQL Result: %s", answer_an['sql_query']) - yield json.dumps( - { - "type": "final_result", - "data": answer_an["sql_query"], - "conf": answer_an["confidence"], - "miss": answer_an["missing_information"], - "amb": answer_an["ambiguities"], - "exp": answer_an["explanation"], - "is_valid": answer_an["is_sql_translatable"], - } - ) + MESSAGE_DELIMITER - - # If the SQL query is valid, execute it using the postgress database db_url - if answer_an["is_sql_translatable"]: - # Check if this is a destructive operation that requires confirmation - sql_query = answer_an["sql_query"] - sql_type = sql_query.strip().split()[0].upper() if sql_query else "" - - destructive_ops = ['INSERT', 'UPDATE', 'DELETE', 'DROP', - 'CREATE', 'ALTER', 'TRUNCATE'] - if sql_type in destructive_ops: - # This is a destructive operation - ask for user confirmation - confirmation_message = f"""⚠️ DESTRUCTIVE OPERATION DETECTED ⚠️ - -The generated SQL query will perform a **{sql_type}** operation: - -SQL: -{sql_query} - -What this will do: -""" - if sql_type == 'INSERT': - confirmation_message += "• Add new data to the database" - elif sql_type == 'UPDATE': - confirmation_message += ("• Modify existing data in the " - "database") - elif sql_type == 'DELETE': - confirmation_message += ("• **PERMANENTLY DELETE** data " - "from the database") - elif sql_type == 'DROP': - confirmation_message += ("• **PERMANENTLY DELETE** entire " - "tables or database objects") - elif sql_type == 'CREATE': - confirmation_message += ("• Create new tables or database " - "objects") - elif sql_type == 'ALTER': - confirmation_message += ("• Modify the structure of existing " - "tables") - elif sql_type == 'TRUNCATE': - confirmation_message += ("• **PERMANENTLY DELETE ALL DATA** " - "from specified tables") - confirmation_message += """ - -⚠️ WARNING: This operation will make changes to your database and may be irreversible. -""" - - yield json.dumps( - { - "type": "destructive_confirmation", - "message": confirmation_message, - "sql_query": sql_query, - "operation_type": sql_type - } - ) + MESSAGE_DELIMITER - return # Stop here and wait for user confirmation - - try: - step = {"type": "reasoning_step", "message": "Step 2: Executing SQL query"} - yield json.dumps(step) + MESSAGE_DELIMITER - - # Check if this query modifies the database schema - is_schema_modifying, operation_type = ( - PostgresLoader.is_schema_modifying_query(sql_query) - ) - - query_results = PostgresLoader.execute_sql_query(answer_an["sql_query"], db_url) - yield json.dumps( - { - "type": "query_result", - "data": query_results, - } - ) + MESSAGE_DELIMITER - - # If schema was modified, refresh the graph - if is_schema_modifying: - step = {"type": "reasoning_step", - "message": ("Step 3: Schema change detected - " - "refreshing graph...")} - yield json.dumps(step) + MESSAGE_DELIMITER - - refresh_result = PostgresLoader.refresh_graph_schema( - graph_id, db_url) - refresh_success, refresh_message = refresh_result - - if refresh_success: - refresh_msg = (f"✅ Schema change detected " - f"({operation_type} operation)\n\n" - f"🔄 Graph schema has been automatically " - f"refreshed with the latest database " - f"structure.") - yield json.dumps( - { - "type": "schema_refresh", - "message": refresh_msg, - "refresh_status": "success" - } - ) + MESSAGE_DELIMITER - else: - failure_msg = (f"⚠️ Schema was modified but graph " - f"refresh failed: {refresh_message}") - yield json.dumps( - { - "type": "schema_refresh", - "message": failure_msg, - "refresh_status": "failed" - } - ) + MESSAGE_DELIMITER - - # Generate user-readable response using AI - step_num = "4" if is_schema_modifying else "3" - step = {"type": "reasoning_step", - "message": f"Step {step_num}: Generating user-friendly response"} - yield json.dumps(step) + MESSAGE_DELIMITER - - response_agent = ResponseFormatterAgent() - user_readable_response = response_agent.format_response( - user_query=queries_history[-1], - sql_query=answer_an["sql_query"], - query_results=query_results, - db_description=db_description - ) - - yield json.dumps( - { - "type": "ai_response", - "message": user_readable_response, - } - ) + MESSAGE_DELIMITER - - except Exception as e: - logging.error("Error executing SQL query: %s", e) - yield json.dumps( - {"type": "error", "message": str(e)} - ) + MESSAGE_DELIMITER - - return Response(stream_with_context(generate()), content_type="application/json") - - -@app.route("/graphs//confirm", methods=["POST"]) -@token_required -def confirm_destructive_operation(graph_id: str): - """ - Handle user confirmation for destructive SQL operations - """ - graph_id = g.user_id + "_" + graph_id.strip() - request_data = request.get_json() - confirmation = request_data.get("confirmation", "").strip().upper() - sql_query = request_data.get("sql_query", "") - queries_history = request_data.get("chat", []) - - if not sql_query: - return jsonify({"error": "No SQL query provided"}), 400 - - # Create a generator function for streaming the confirmation response - def generate_confirmation(): - if confirmation == "CONFIRM": - try: - db_description, db_url = get_db_description(graph_id) - - step = {"type": "reasoning_step", - "message": "Step 2: Executing confirmed SQL query"} - yield json.dumps(step) + MESSAGE_DELIMITER - - # Check if this query modifies the database schema - is_schema_modifying, operation_type = ( - PostgresLoader.is_schema_modifying_query(sql_query) - ) - - query_results = PostgresLoader.execute_sql_query(sql_query, db_url) - yield json.dumps( - { - "type": "query_result", - "data": query_results, - } - ) + MESSAGE_DELIMITER - - # If schema was modified, refresh the graph - if is_schema_modifying: - step = {"type": "reasoning_step", - "message": "Step 3: Schema change detected - refreshing graph..."} - yield json.dumps(step) + MESSAGE_DELIMITER - - refresh_success, refresh_message = ( - PostgresLoader.refresh_graph_schema(graph_id, db_url) - ) - - if refresh_success: - yield json.dumps( - { - "type": "schema_refresh", - "message": (f"✅ Schema change detected ({operation_type} " - "operation)\n\n🔄 Graph schema has been automatically " - "refreshed with the latest database structure."), - "refresh_status": "success" - } - ) + MESSAGE_DELIMITER - else: - yield json.dumps( - { - "type": "schema_refresh", - "message": (f"⚠️ Schema was modified but graph refresh failed: " - f"{refresh_message}"), - "refresh_status": "failed" - } - ) + MESSAGE_DELIMITER - - # Generate user-readable response using AI - step_num = "4" if is_schema_modifying else "3" - step = {"type": "reasoning_step", - "message": f"Step {step_num}: Generating user-friendly response"} - yield json.dumps(step) + MESSAGE_DELIMITER - - response_agent = ResponseFormatterAgent() - user_readable_response = response_agent.format_response( - user_query=queries_history[-1] if queries_history else "Destructive operation", - sql_query=sql_query, - query_results=query_results, - db_description=db_description - ) - - yield json.dumps( - { - "type": "ai_response", - "message": user_readable_response, - } - ) + MESSAGE_DELIMITER - - except Exception as e: - logging.error("Error executing confirmed SQL query: %s", e) - yield json.dumps( - {"type": "error", "message": f"Error executing query: {str(e)}"} - ) + MESSAGE_DELIMITER - else: - # User cancelled or provided invalid confirmation - yield json.dumps( - { - "type": "operation_cancelled", - "message": "Operation cancelled. The destructive SQL query was not executed." - } - ) + MESSAGE_DELIMITER - - return Response(stream_with_context(generate_confirmation()), content_type="application/json") - -@app.route("/login") -def login_google(): - """Handle Google OAuth login route.""" - if not google.authorized: - return redirect(url_for("google.login")) - - try: - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - google_user = resp.json() - # Normalize user info structure - user_info = { - "id": google_user.get("id"), - "name": google_user.get("name"), - "email": google_user.get("email"), - "picture": google_user.get("picture"), - "provider": "google" - } - session["user_info"] = user_info - session["token_validated_at"] = time.time() - return redirect(url_for("home")) - - # OAuth token might be expired, redirect to login - session.clear() - return redirect(url_for("google.login")) - except Exception as e: - logging.error("Google login error: %s", e) - session.clear() - return redirect(url_for("google.login")) - - - - -@app.route("/logout") -def logout(): - """Handle user logout and token revocation.""" - session.clear() - - # Revoke Google OAuth token if authorized - if google.authorized: - try: - google.get( - "https://accounts.google.com/o/oauth2/revoke", - params={"token": google.access_token} - ) - except Exception as e: - logging.warning("Error revoking Google token: %s", e) - - # Revoke GitHub OAuth token if authorized - if github.authorized: - try: - # GitHub doesn't have a simple revoke endpoint like Google - # The token will expire naturally or can be revoked from GitHub settings - pass - except Exception as e: - logging.warning("Error with GitHub token cleanup: %s", e) - - return redirect(url_for("home")) - -@app.route("/graphs//refresh", methods=["POST"]) -@token_required -def refresh_graph_schema(graph_id: str): - """ - Manually refresh the graph schema from the database. - This endpoint allows users to manually trigger a schema refresh - if they suspect the graph is out of sync with the database. - """ - graph_id = g.user_id + "_" + graph_id.strip() - - try: - # Get database connection details - _, db_url = get_db_description(graph_id) - - if not db_url or db_url == "No URL available for this database.": - return jsonify({ - "success": False, - "error": "No database URL found for this graph" - }), 400 - - # Perform schema refresh - success, message = PostgresLoader.refresh_graph_schema(graph_id, db_url) - - if success: - return jsonify({ - "success": True, - "message": f"Graph schema refreshed successfully. {message}" - }), 200 - else: - return jsonify({ - "success": False, - "error": f"Failed to refresh schema: {message}" - }), 500 - - except Exception as e: - logging.error("Error in manual schema refresh: %s", e) - return jsonify({ - "success": False, - "error": f"Error refreshing schema: {str(e)}" - }), 500 - -@app.route("/database", methods=["POST"]) -@token_required -def connect_database(): - """ - Accepts a JSON payload with a Postgres URL and attempts to connect. - Returns success or error message. - """ - data = request.get_json() - url = data.get("url") if data else None - if not url: - return jsonify({"success": False, "error": "No URL provided"}), 400 - try: - # Check for Postgres URL - if url.startswith("postgres://") or url.startswith("postgresql://"): - try: - # Attempt to connect/load using the loader - success, result = PostgresLoader.load(g.user_id, url) - if success: - return jsonify({"success": True, "message": result}), 200 - - return jsonify({"success": False, "error": result}), 400 - except Exception as e: - return jsonify({"success": False, "error": str(e)}), 500 - - return jsonify({"success": False, "error": "Invalid Postgres URL"}), 400 - except Exception as e: - return jsonify({"success": False, "error": str(e)}), 500 +from api.app_factory import create_app +app = create_app() if __name__ == "__main__": - app.register_blueprint(main) app.run(debug=True) diff --git a/api/index_original.py b/api/index_original.py new file mode 100644 index 00000000..e7e8f1d5 --- /dev/null +++ b/api/index_original.py @@ -0,0 +1,936 @@ +"""This module contains the routes for the text2sql API.""" + +import json +import logging +import os +import time +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FuturesTimeoutError +from functools import wraps + +import requests +from dotenv import load_dotenv +from flask import (Blueprint, Flask, Response, jsonify, render_template, + request, stream_with_context, g, session, redirect, url_for) +from flask_dance.contrib.google import make_google_blueprint, google +from flask_dance.contrib.github import make_github_blueprint, github +from flask_dance.consumer.storage.session import SessionStorage +from flask_dance.consumer import oauth_authorized + +from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent +from api.extensions import db +from api.graph import find, get_db_description +from api.loaders.csv_loader import CSVLoader +from api.loaders.json_loader import JSONLoader +from api.loaders.postgres_loader import PostgresLoader +from api.loaders.odata_loader import ODataLoader + +# User management functions +def ensure_user_in_organizations(provider_user_id, email, name, provider, picture=None): + """ + Check if identity exists in Organizations graph, create if not. + Creates separate Identity and User nodes with proper relationships. + Uses MERGE for atomic operations and better performance. + Returns (is_new_user, user_info) + """ + try: + # Select the Organizations graph + organizations_graph = db.select_graph("Organizations") + + # Extract first and last name + name_parts = (name or "").split(" ", 1) if name else ["", ""] + first_name = name_parts[0] if len(name_parts) > 0 else "" + last_name = name_parts[1] if len(name_parts) > 1 else "" + + # Use MERGE to handle all scenarios in a single atomic operation + merge_query = """ + // First, ensure user exists (merge by email) + MERGE (user:User {email: $email}) + ON CREATE SET + user.first_name = $first_name, + user.last_name = $last_name, + user.created_at = timestamp() + + // Then, merge identity and link to user + MERGE (identity:Identity {provider: $provider, provider_user_id: $provider_user_id}) + ON CREATE SET + identity.email = $email, + identity.name = $name, + identity.picture = $picture, + identity.created_at = timestamp(), + identity.last_login = timestamp() + ON MATCH SET + identity.email = $email, + identity.name = $name, + identity.picture = $picture, + identity.last_login = timestamp() + + // Ensure relationship exists + MERGE (identity)-[:AUTHENTICATES]->(user) + + // Return results with flags to determine if this was a new user/identity + RETURN + identity, + user, + identity.created_at = identity.last_login AS is_new_identity, + EXISTS((user)<-[:AUTHENTICATES]-(:Identity)) AS had_other_identities + """ + + result = organizations_graph.query(merge_query, { + "provider": provider, + "provider_user_id": provider_user_id, + "email": email, + "name": name, + "picture": picture, + "first_name": first_name, + "last_name": last_name + }) + + if result.result_set: + identity = result.result_set[0][0] + user = result.result_set[0][1] + is_new_identity = result.result_set[0][2] + had_other_identities = result.result_set[0][3] + + # Determine the type of operation for logging + if is_new_identity and not had_other_identities: + # Brand new user (first identity) + logging.info("NEW USER CREATED: provider=%s, provider_user_id=%s, " + "email=%s, name=%s", provider, provider_user_id, email, name) + return True, {"identity": identity, "user": user} + elif is_new_identity and had_other_identities: + # New identity for existing user (cross-provider linking) + logging.info("NEW IDENTITY LINKED TO EXISTING USER: provider=%s, " + "provider_user_id=%s, email=%s, name=%s", + provider, provider_user_id, email, name) + return True, {"identity": identity, "user": user} + else: + # Existing identity login + logging.info("Existing identity found: provider=%s, email=%s", provider, email) + return False, {"identity": identity, "user": user} + else: + logging.error("Failed to create/update identity and user: email=%s", email) + return False, None + + except Exception as e: + logging.error("Error managing user in Organizations graph: %s", e) + return False, None + +def update_identity_last_login(provider, provider_user_id): + """Update the last login timestamp for an existing identity""" + try: + organizations_graph = db.select_graph("Organizations") + update_query = """ + MATCH (identity:Identity {provider: $provider, provider_user_id: $provider_user_id}) + SET identity.last_login = timestamp() + RETURN identity + """ + organizations_graph.query(update_query, { + "provider": provider, + "provider_user_id": provider_user_id + }) + logging.info("Updated last login for identity: provider=%s, provider_user_id=%s", + provider, provider_user_id) + except Exception as e: + logging.error("Error updating last login for identity %s/%s: %s", + provider, provider_user_id, e) + +# Load environment variables from .env file +load_dotenv() +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + +# Use the same delimiter as in the JavaScript +MESSAGE_DELIMITER = "|||FALKORDB_MESSAGE_BOUNDARY|||" + +main = Blueprint("main", __name__) + +def validate_and_cache_user(): + """ + Helper function to validate OAuth token and cache user info. + Returns (user_info, is_authenticated) tuple. + Supports both Google and GitHub OAuth. + """ + # Check for cached user info from either provider + user_info = session.get("user_info") + token_validated_at = session.get("token_validated_at", 0) + current_time = time.time() + + # Use cached user info if it's less than 15 minutes old + if user_info and (current_time - token_validated_at) < 900: # 15 minutes + return user_info, True + + # Check Google OAuth first + if google.authorized: + try: + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + google_user = resp.json() + # Normalize user info structure + user_info = { + "id": google_user.get("id"), + "name": google_user.get("name"), + "email": google_user.get("email"), + "picture": google_user.get("picture"), + "provider": "google" + } + session["user_info"] = user_info + session["token_validated_at"] = current_time + return user_info, True + except (requests.RequestException, KeyError, ValueError) as e: + logging.warning("Google OAuth validation error: %s", e) + + # Check GitHub OAuth + if github.authorized: + try: + # Get user profile + resp = github.get("/user") + if resp.ok: + github_user = resp.json() + + # Get user email (GitHub may require separate call for email) + email_resp = github.get("/user/emails") + email = None + if email_resp.ok: + emails = email_resp.json() + # Find primary email + for email_obj in emails: + if email_obj.get("primary", False): + email = email_obj.get("email") + break + + # If no primary email found, use the first one + if not email and emails: + email = emails[0].get("email") + + # Normalize user info structure + user_info = { + "id": str(github_user.get("id")), # Convert to string for consistency + "name": github_user.get("name") or github_user.get("login"), + "email": email, + "picture": github_user.get("avatar_url"), + "provider": "github" + } + session["user_info"] = user_info + session["token_validated_at"] = current_time + return user_info, True + except (requests.RequestException, KeyError, ValueError) as e: + logging.warning("GitHub OAuth validation error: %s", e) + + # If no valid authentication found, clear session + session.clear() + return None, False + + +def token_required(f): + """Decorator to protect routes with token authentication""" + + @wraps(f) + def decorated_function(*args, **kwargs): + user_info, is_authenticated = validate_and_cache_user() + + if not is_authenticated: + return jsonify(message="Unauthorized - Please log in"), 401 + + g.user_id = user_info.get("id") + if not g.user_id: + session.clear() + return jsonify(message="Unauthorized - Invalid user"), 401 + + return f(*args, **kwargs) + + return decorated_function + + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersekrit") + +# Google OAuth setup +GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") +GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") +google_bp = make_google_blueprint( + client_id=GOOGLE_CLIENT_ID, + client_secret=GOOGLE_CLIENT_SECRET, + scope=[ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "openid" + ] +) +app.register_blueprint(google_bp, url_prefix="/login") + +# GitHub OAuth setup +GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") +GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") +github_bp = make_github_blueprint( + client_id=GITHUB_CLIENT_ID, + client_secret=GITHUB_CLIENT_SECRET, + scope="user:email", + storage=SessionStorage() +) +app.register_blueprint(github_bp, url_prefix="/login") + +# GitHub OAuth signal handler + +@oauth_authorized.connect_via(google_bp) +def google_logged_in(blueprint, token): # pylint: disable=unused-argument + """Handle Google OAuth authorization callback.""" + if not token: + return False + + try: + # Get user profile + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + google_user = resp.json() + user_id = google_user.get("id") + email = google_user.get("email") + name = google_user.get("name") + + if user_id and email: + # Check if identity exists in Organizations graph, create if new + is_new_user, _ = ensure_user_in_organizations( + user_id, email, name, "google", google_user.get("picture") + ) + + # If existing identity, just update last login + # (already done in ensure_user_in_organizations) + + # Normalize user info structure for session + user_info_session = { + "id": user_id, + "name": name, + "email": email, + "picture": google_user.get("picture"), + "provider": "google" + } + session["user_info"] = user_info_session + session["token_validated_at"] = time.time() + return False # Don't create default flask-dance entry in session + + except (requests.RequestException, KeyError, ValueError, AttributeError) as e: + logging.error("Google OAuth signal error: %s", e) + + return False + +@oauth_authorized.connect_via(github_bp) +def github_logged_in(blueprint, token): # pylint: disable=unused-argument + """Handle GitHub OAuth authorization callback.""" + if not token: + return False + + try: + # Get user profile + resp = github.get("/user") + if resp.ok: + github_user = resp.json() + + # Get user email (GitHub may require separate call for email) + email_resp = github.get("/user/emails") + email = None + if email_resp.ok: + emails = email_resp.json() + # Find primary email + for email_obj in emails: + if email_obj.get("primary", False): + email = email_obj.get("email") + break + # If no primary email found, use the first one + if not email and emails: + email = emails[0].get("email") + + user_id = str(github_user.get("id")) + name = github_user.get("name") or github_user.get("login") + + if user_id and email: + # Check if identity exists in Organizations graph, create if new + is_new_user, _ = ensure_user_in_organizations( + user_id, email, name, "github", github_user.get("avatar_url") + ) + + # If existing identity, just update last login + # (already done in ensure_user_in_organizations) + + # Normalize user info structure for session + user_info_session = { + "id": user_id, + "name": name, + "email": email, + "picture": github_user.get("avatar_url"), + "provider": "github" + } + session["user_info"] = user_info_session + session["token_validated_at"] = time.time() + return False # Don't create default flask-dance entry in session + + except (requests.RequestException, KeyError, ValueError, AttributeError) as e: + logging.error("GitHub OAuth signal error: %s", e) + + return False + + +@app.errorhandler(Exception) +def handle_oauth_error(error): + """Handle OAuth-related errors gracefully""" + # Check if it's an OAuth-related error + if "token" in str(error).lower() or "oauth" in str(error).lower(): + logging.warning("OAuth error occurred: %s", error) + session.clear() + return redirect(url_for("home")) + + # For other errors, let them bubble up + raise error + + +@app.route("/") +def home(): + """Home route""" + user_info, is_authenticated = validate_and_cache_user() + + # If not authenticated through OAuth, check for any stale session data + if not is_authenticated and not google.authorized and not github.authorized: + session.pop("user_info", None) + + return render_template("chat.j2", is_authenticated=is_authenticated, user_info=user_info) + + +@app.route("/graphs") +@token_required +def graphs(): + """ + This route is used to list all the graphs that are available in the database. + """ + user_id = g.user_id + user_graphs = db.list_graphs() + # Only include graphs that start with user_id + '_', and strip the prefix + filtered_graphs = [graph[len(f"{user_id}_"):] + for graph in user_graphs if graph.startswith(f"{user_id}_")] + return jsonify(filtered_graphs) + + +@app.route("/graphs", methods=["POST"]) +@token_required +def load(): + """ + This route is used to load the graph data into the database. + It expects either: + - A JSON payload (application/json) + - A File upload (multipart/form-data) + - An XML payload (application/xml or text/xml) + """ + content_type = request.content_type + success, result = False, "Invalid content type" + graph_id = "" + + # ✅ Handle JSON Payload + if content_type.startswith("application/json"): + data = request.get_json() + if not data or "database" not in data: + return jsonify({"error": "Invalid JSON data"}), 400 + + graph_id = g.user_id + "_" + data["database"] + success, result = JSONLoader.load(graph_id, data) + + # # ✅ Handle XML Payload + # elif content_type.startswith("application/xml") or content_type.startswith("text/xml"): + # xml_data = request.data + # graph_id = "" + # success, result = ODataLoader.load(graph_id, xml_data) + + # # ✅ Handle CSV Payload + # elif content_type.startswith("text/csv"): + # csv_data = request.data + # graph_id = "" + # success, result = CSVLoader.load(graph_id, csv_data) + + # ✅ Handle File Upload (FormData with JSON/XML) + elif content_type.startswith("multipart/form-data"): + if "file" not in request.files: + return jsonify({"error": "No file uploaded"}), 400 + + file = request.files["file"] + if file.filename == "": + return jsonify({"error": "Empty file"}), 400 + + # ✅ Check if file is JSON + if file.filename.endswith(".json"): + try: + data = json.load(file) + graph_id = g.user_id + "_" + data.get("database", "") + success, result = JSONLoader.load(graph_id, data) + except json.JSONDecodeError: + return jsonify({"error": "Invalid JSON file"}), 400 + + # ✅ Check if file is XML + elif file.filename.endswith(".xml"): + xml_data = file.read().decode("utf-8") # Convert bytes to string + graph_id = g.user_id + "_" + file.filename.replace(".xml", "") + success, result = ODataLoader.load(graph_id, xml_data) + + # ✅ Check if file is csv + elif file.filename.endswith(".csv"): + csv_data = file.read().decode("utf-8") # Convert bytes to string + graph_id = g.user_id + "_" + file.filename.replace(".csv", "") + success, result = CSVLoader.load(graph_id, csv_data) + + else: + return jsonify({"error": "Unsupported file type"}), 415 + else: + return jsonify({"error": "Unsupported Content-Type"}), 415 + + # ✅ Return the final response + if success: + return jsonify({"message": result, "graph_id": graph_id}) + + return jsonify({"error": result}), 400 + + +@app.route("/graphs/", methods=["POST"]) +@token_required +def query(graph_id: str): + """ + text2sql + """ + graph_id = g.user_id + "_" + graph_id.strip() + request_data = request.get_json() + queries_history = request_data.get("chat") + result_history = request_data.get("result") + instructions = request_data.get("instructions") + if not queries_history: + return jsonify({"error": "Invalid or missing JSON data"}), 400 + + logging.info("User Query: %s", queries_history[-1]) + + # Create a generator function for streaming + def generate(): + agent_rel = RelevancyAgent(queries_history, result_history) + agent_an = AnalysisAgent(queries_history, result_history) + + step = {"type": "reasoning_step", + "message": "Step 1: Analyzing user query and generating SQL..."} + yield json.dumps(step) + MESSAGE_DELIMITER + # Ensure the database description is loaded + db_description, db_url = get_db_description(graph_id) + + logging.info("Calling to relevancy agent with query: %s", + queries_history[-1]) + answer_rel = agent_rel.get_answer(queries_history[-1], db_description) + if answer_rel["status"] != "On-topic": + step = { + "type": "followup_questions", + "message": "Off topic question: " + answer_rel["reason"], + } + logging.info("SQL Fail reason: %s", answer_rel["reason"]) + yield json.dumps(step) + MESSAGE_DELIMITER + else: + # Use a thread pool to enforce timeout + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(find, graph_id, queries_history, db_description) + try: + _, result, _ = future.result(timeout=120) + except FuturesTimeoutError: + yield json.dumps( + { + "type": "error", + "message": ("Timeout error while finding tables relevant to " + "your request."), + } + ) + MESSAGE_DELIMITER + return + except Exception as e: + logging.info("Error in find function: %s", e) + yield json.dumps( + {"type": "error", "message": "Error in find function"} + ) + MESSAGE_DELIMITER + return + + logging.info("Calling to analysis agent with query: %s", queries_history[-1]) + answer_an = agent_an.get_analysis( + queries_history[-1], result, db_description, instructions + ) + + logging.info("SQL Result: %s", answer_an['sql_query']) + yield json.dumps( + { + "type": "final_result", + "data": answer_an["sql_query"], + "conf": answer_an["confidence"], + "miss": answer_an["missing_information"], + "amb": answer_an["ambiguities"], + "exp": answer_an["explanation"], + "is_valid": answer_an["is_sql_translatable"], + } + ) + MESSAGE_DELIMITER + + # If the SQL query is valid, execute it using the postgress database db_url + if answer_an["is_sql_translatable"]: + # Check if this is a destructive operation that requires confirmation + sql_query = answer_an["sql_query"] + sql_type = sql_query.strip().split()[0].upper() if sql_query else "" + + destructive_ops = ['INSERT', 'UPDATE', 'DELETE', 'DROP', + 'CREATE', 'ALTER', 'TRUNCATE'] + if sql_type in destructive_ops: + # This is a destructive operation - ask for user confirmation + confirmation_message = f"""⚠️ DESTRUCTIVE OPERATION DETECTED ⚠️ + +The generated SQL query will perform a **{sql_type}** operation: + +SQL: +{sql_query} + +What this will do: +""" + if sql_type == 'INSERT': + confirmation_message += "• Add new data to the database" + elif sql_type == 'UPDATE': + confirmation_message += ("• Modify existing data in the " + "database") + elif sql_type == 'DELETE': + confirmation_message += ("• **PERMANENTLY DELETE** data " + "from the database") + elif sql_type == 'DROP': + confirmation_message += ("• **PERMANENTLY DELETE** entire " + "tables or database objects") + elif sql_type == 'CREATE': + confirmation_message += ("• Create new tables or database " + "objects") + elif sql_type == 'ALTER': + confirmation_message += ("• Modify the structure of existing " + "tables") + elif sql_type == 'TRUNCATE': + confirmation_message += ("• **PERMANENTLY DELETE ALL DATA** " + "from specified tables") + confirmation_message += """ + +⚠️ WARNING: This operation will make changes to your database and may be irreversible. +""" + + yield json.dumps( + { + "type": "destructive_confirmation", + "message": confirmation_message, + "sql_query": sql_query, + "operation_type": sql_type + } + ) + MESSAGE_DELIMITER + return # Stop here and wait for user confirmation + + try: + step = {"type": "reasoning_step", "message": "Step 2: Executing SQL query"} + yield json.dumps(step) + MESSAGE_DELIMITER + + # Check if this query modifies the database schema + is_schema_modifying, operation_type = ( + PostgresLoader.is_schema_modifying_query(sql_query) + ) + + query_results = PostgresLoader.execute_sql_query(answer_an["sql_query"], db_url) + yield json.dumps( + { + "type": "query_result", + "data": query_results, + } + ) + MESSAGE_DELIMITER + + # If schema was modified, refresh the graph + if is_schema_modifying: + step = {"type": "reasoning_step", + "message": ("Step 3: Schema change detected - " + "refreshing graph...")} + yield json.dumps(step) + MESSAGE_DELIMITER + + refresh_result = PostgresLoader.refresh_graph_schema( + graph_id, db_url) + refresh_success, refresh_message = refresh_result + + if refresh_success: + refresh_msg = (f"✅ Schema change detected " + f"({operation_type} operation)\n\n" + f"🔄 Graph schema has been automatically " + f"refreshed with the latest database " + f"structure.") + yield json.dumps( + { + "type": "schema_refresh", + "message": refresh_msg, + "refresh_status": "success" + } + ) + MESSAGE_DELIMITER + else: + failure_msg = (f"⚠️ Schema was modified but graph " + f"refresh failed: {refresh_message}") + yield json.dumps( + { + "type": "schema_refresh", + "message": failure_msg, + "refresh_status": "failed" + } + ) + MESSAGE_DELIMITER + + # Generate user-readable response using AI + step_num = "4" if is_schema_modifying else "3" + step = {"type": "reasoning_step", + "message": f"Step {step_num}: Generating user-friendly response"} + yield json.dumps(step) + MESSAGE_DELIMITER + + response_agent = ResponseFormatterAgent() + user_readable_response = response_agent.format_response( + user_query=queries_history[-1], + sql_query=answer_an["sql_query"], + query_results=query_results, + db_description=db_description + ) + + yield json.dumps( + { + "type": "ai_response", + "message": user_readable_response, + } + ) + MESSAGE_DELIMITER + + except Exception as e: + logging.error("Error executing SQL query: %s", e) + yield json.dumps( + {"type": "error", "message": str(e)} + ) + MESSAGE_DELIMITER + + return Response(stream_with_context(generate()), content_type="application/json") + + +@app.route("/graphs//confirm", methods=["POST"]) +@token_required +def confirm_destructive_operation(graph_id: str): + """ + Handle user confirmation for destructive SQL operations + """ + graph_id = g.user_id + "_" + graph_id.strip() + request_data = request.get_json() + confirmation = request_data.get("confirmation", "").strip().upper() + sql_query = request_data.get("sql_query", "") + queries_history = request_data.get("chat", []) + + if not sql_query: + return jsonify({"error": "No SQL query provided"}), 400 + + # Create a generator function for streaming the confirmation response + def generate_confirmation(): + if confirmation == "CONFIRM": + try: + db_description, db_url = get_db_description(graph_id) + + step = {"type": "reasoning_step", + "message": "Step 2: Executing confirmed SQL query"} + yield json.dumps(step) + MESSAGE_DELIMITER + + # Check if this query modifies the database schema + is_schema_modifying, operation_type = ( + PostgresLoader.is_schema_modifying_query(sql_query) + ) + + query_results = PostgresLoader.execute_sql_query(sql_query, db_url) + yield json.dumps( + { + "type": "query_result", + "data": query_results, + } + ) + MESSAGE_DELIMITER + + # If schema was modified, refresh the graph + if is_schema_modifying: + step = {"type": "reasoning_step", + "message": "Step 3: Schema change detected - refreshing graph..."} + yield json.dumps(step) + MESSAGE_DELIMITER + + refresh_success, refresh_message = ( + PostgresLoader.refresh_graph_schema(graph_id, db_url) + ) + + if refresh_success: + yield json.dumps( + { + "type": "schema_refresh", + "message": (f"✅ Schema change detected ({operation_type} " + "operation)\n\n🔄 Graph schema has been automatically " + "refreshed with the latest database structure."), + "refresh_status": "success" + } + ) + MESSAGE_DELIMITER + else: + yield json.dumps( + { + "type": "schema_refresh", + "message": (f"⚠️ Schema was modified but graph refresh failed: " + f"{refresh_message}"), + "refresh_status": "failed" + } + ) + MESSAGE_DELIMITER + + # Generate user-readable response using AI + step_num = "4" if is_schema_modifying else "3" + step = {"type": "reasoning_step", + "message": f"Step {step_num}: Generating user-friendly response"} + yield json.dumps(step) + MESSAGE_DELIMITER + + response_agent = ResponseFormatterAgent() + user_readable_response = response_agent.format_response( + user_query=queries_history[-1] if queries_history else "Destructive operation", + sql_query=sql_query, + query_results=query_results, + db_description=db_description + ) + + yield json.dumps( + { + "type": "ai_response", + "message": user_readable_response, + } + ) + MESSAGE_DELIMITER + + except Exception as e: + logging.error("Error executing confirmed SQL query: %s", e) + yield json.dumps( + {"type": "error", "message": f"Error executing query: {str(e)}"} + ) + MESSAGE_DELIMITER + else: + # User cancelled or provided invalid confirmation + yield json.dumps( + { + "type": "operation_cancelled", + "message": "Operation cancelled. The destructive SQL query was not executed." + } + ) + MESSAGE_DELIMITER + + return Response(stream_with_context(generate_confirmation()), content_type="application/json") + +@app.route("/login") +def login_google(): + """Handle Google OAuth login route.""" + if not google.authorized: + return redirect(url_for("google.login")) + + try: + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + google_user = resp.json() + # Normalize user info structure + user_info = { + "id": google_user.get("id"), + "name": google_user.get("name"), + "email": google_user.get("email"), + "picture": google_user.get("picture"), + "provider": "google" + } + session["user_info"] = user_info + session["token_validated_at"] = time.time() + return redirect(url_for("home")) + + # OAuth token might be expired, redirect to login + session.clear() + return redirect(url_for("google.login")) + except Exception as e: + logging.error("Google login error: %s", e) + session.clear() + return redirect(url_for("google.login")) + + + + +@app.route("/logout") +def logout(): + """Handle user logout and token revocation.""" + session.clear() + + # Revoke Google OAuth token if authorized + if google.authorized: + try: + google.get( + "https://accounts.google.com/o/oauth2/revoke", + params={"token": google.access_token} + ) + except Exception as e: + logging.warning("Error revoking Google token: %s", e) + + # Revoke GitHub OAuth token if authorized + if github.authorized: + try: + # GitHub doesn't have a simple revoke endpoint like Google + # The token will expire naturally or can be revoked from GitHub settings + pass + except Exception as e: + logging.warning("Error with GitHub token cleanup: %s", e) + + return redirect(url_for("home")) + +@app.route("/graphs//refresh", methods=["POST"]) +@token_required +def refresh_graph_schema(graph_id: str): + """ + Manually refresh the graph schema from the database. + This endpoint allows users to manually trigger a schema refresh + if they suspect the graph is out of sync with the database. + """ + graph_id = g.user_id + "_" + graph_id.strip() + + try: + # Get database connection details + _, db_url = get_db_description(graph_id) + + if not db_url or db_url == "No URL available for this database.": + return jsonify({ + "success": False, + "error": "No database URL found for this graph" + }), 400 + + # Perform schema refresh + success, message = PostgresLoader.refresh_graph_schema(graph_id, db_url) + + if success: + return jsonify({ + "success": True, + "message": f"Graph schema refreshed successfully. {message}" + }), 200 + else: + return jsonify({ + "success": False, + "error": f"Failed to refresh schema: {message}" + }), 500 + + except Exception as e: + logging.error("Error in manual schema refresh: %s", e) + return jsonify({ + "success": False, + "error": f"Error refreshing schema: {str(e)}" + }), 500 + +@app.route("/database", methods=["POST"]) +@token_required +def connect_database(): + """ + Accepts a JSON payload with a Postgres URL and attempts to connect. + Returns success or error message. + """ + data = request.get_json() + url = data.get("url") if data else None + if not url: + return jsonify({"success": False, "error": "No URL provided"}), 400 + try: + # Check for Postgres URL + if url.startswith("postgres://") or url.startswith("postgresql://"): + try: + # Attempt to connect/load using the loader + success, result = PostgresLoader.load(g.user_id, url) + if success: + return jsonify({"success": True, "message": result}), 200 + + return jsonify({"success": False, "error": result}), 400 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + return jsonify({"success": False, "error": "Invalid Postgres URL"}), 400 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +if __name__ == "__main__": + app.register_blueprint(main) + app.run(debug=True) diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 00000000..375b53c1 --- /dev/null +++ b/api/routes/__init__.py @@ -0,0 +1,7 @@ +# Routes module for text2sql API + +from .auth import auth_bp +from .graphs import graphs_bp +from .database import database_bp + +__all__ = ["auth_bp", "graphs_bp", "database_bp"] diff --git a/api/routes/auth.py b/api/routes/auth.py new file mode 100644 index 00000000..2899afc4 --- /dev/null +++ b/api/routes/auth.py @@ -0,0 +1,82 @@ +"""Authentication routes for the text2sql API.""" + +import logging +import time + +from flask import Blueprint, render_template, redirect, url_for, session +from flask_dance.contrib.google import google +from flask_dance.contrib.github import github + +from api.auth.user_management import validate_and_cache_user + +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/") +def home(): + """Home route""" + user_info, is_authenticated = validate_and_cache_user() + + # If not authenticated through OAuth, check for any stale session data + if not is_authenticated and not google.authorized and not github.authorized: + session.pop("user_info", None) + + return render_template("chat.j2", is_authenticated=is_authenticated, user_info=user_info) + + +@auth_bp.route("/login") +def login_google(): + """Handle Google OAuth login route.""" + if not google.authorized: + return redirect(url_for("google.login")) + + try: + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + google_user = resp.json() + # Normalize user info structure + user_info = { + "id": google_user.get("id"), + "name": google_user.get("name"), + "email": google_user.get("email"), + "picture": google_user.get("picture"), + "provider": "google" + } + session["user_info"] = user_info + session["token_validated_at"] = time.time() + return redirect(url_for("auth.home")) + + # OAuth token might be expired, redirect to login + session.clear() + return redirect(url_for("google.login")) + except Exception as e: + logging.error("Google login error: %s", e) + session.clear() + return redirect(url_for("google.login")) + + +@auth_bp.route("/logout") +def logout(): + """Handle user logout and token revocation.""" + session.clear() + + # Revoke Google OAuth token if authorized + if google.authorized: + try: + google.get( + "https://accounts.google.com/o/oauth2/revoke", + params={"token": google.access_token} + ) + except Exception as e: + logging.warning("Error revoking Google token: %s", e) + + # Revoke GitHub OAuth token if authorized + if github.authorized: + try: + # GitHub doesn't have a simple revoke endpoint like Google + # The token will expire naturally or can be revoked from GitHub settings + pass + except Exception as e: + logging.warning("Error with GitHub token cleanup: %s", e) + + return redirect(url_for("auth.home")) diff --git a/api/routes/database.py b/api/routes/database.py new file mode 100644 index 00000000..51a7853e --- /dev/null +++ b/api/routes/database.py @@ -0,0 +1,37 @@ +"""Database connection routes for the text2sql API.""" + +from flask import Blueprint, jsonify, request, g + +from api.auth.user_management import token_required +from api.loaders.postgres_loader import PostgresLoader + +database_bp = Blueprint("database", __name__) + + +@database_bp.route("/database", methods=["POST"]) +@token_required +def connect_database(): + """ + Accepts a JSON payload with a Postgres URL and attempts to connect. + Returns success or error message. + """ + data = request.get_json() + url = data.get("url") if data else None + if not url: + return jsonify({"success": False, "error": "No URL provided"}), 400 + try: + # Check for Postgres URL + if url.startswith("postgres://") or url.startswith("postgresql://"): + try: + # Attempt to connect/load using the loader + success, result = PostgresLoader.load(g.user_id, url) + if success: + return jsonify({"success": True, "message": result}), 200 + + return jsonify({"success": False, "error": result}), 400 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + return jsonify({"success": False, "error": "Invalid Postgres URL"}), 400 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/api/routes/graphs.py b/api/routes/graphs.py new file mode 100644 index 00000000..4635147b --- /dev/null +++ b/api/routes/graphs.py @@ -0,0 +1,474 @@ +"""Graph-related routes for the text2sql API.""" + +import json +import logging +from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import TimeoutError as FuturesTimeoutError + +from flask import Blueprint, jsonify, request, Response, stream_with_context, g + +from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent +from api.auth.user_management import token_required +from api.extensions import db +from api.graph import find, get_db_description +from api.loaders.csv_loader import CSVLoader +from api.loaders.json_loader import JSONLoader +from api.loaders.postgres_loader import PostgresLoader +from api.loaders.odata_loader import ODataLoader + +# Use the same delimiter as in the JavaScript +MESSAGE_DELIMITER = "|||FALKORDB_MESSAGE_BOUNDARY|||" + +graphs_bp = Blueprint("graphs", __name__, url_prefix="/graphs") + + +@graphs_bp.route("") +@token_required +def list_graphs(): + """ + This route is used to list all the graphs that are available in the database. + """ + user_id = g.user_id + user_graphs = db.list_graphs() + # Only include graphs that start with user_id + '_', and strip the prefix + filtered_graphs = [graph[len(f"{user_id}_"):] + for graph in user_graphs if graph.startswith(f"{user_id}_")] + return jsonify(filtered_graphs) + + +@graphs_bp.route("", methods=["POST"]) +@token_required +def load_graph(): + """ + This route is used to load the graph data into the database. + It expects either: + - A JSON payload (application/json) + - A File upload (multipart/form-data) + - An XML payload (application/xml or text/xml) + """ + content_type = request.content_type + success, result = False, "Invalid content type" + graph_id = "" + + # ✅ Handle JSON Payload + if content_type.startswith("application/json"): + data = request.get_json() + if not data or "database" not in data: + return jsonify({"error": "Invalid JSON data"}), 400 + + graph_id = g.user_id + "_" + data["database"] + success, result = JSONLoader.load(graph_id, data) + + # # ✅ Handle XML Payload + # elif content_type.startswith("application/xml") or content_type.startswith("text/xml"): + # xml_data = request.data + # graph_id = "" + # success, result = ODataLoader.load(graph_id, xml_data) + + # # ✅ Handle CSV Payload + # elif content_type.startswith("text/csv"): + # csv_data = request.data + # graph_id = "" + # success, result = CSVLoader.load(graph_id, csv_data) + + # ✅ Handle File Upload (FormData with JSON/XML) + elif content_type.startswith("multipart/form-data"): + if "file" not in request.files: + return jsonify({"error": "No file uploaded"}), 400 + + file = request.files["file"] + if file.filename == "": + return jsonify({"error": "Empty file"}), 400 + + # ✅ Check if file is JSON + if file.filename.endswith(".json"): + try: + data = json.load(file) + graph_id = g.user_id + "_" + data.get("database", "") + success, result = JSONLoader.load(graph_id, data) + except json.JSONDecodeError: + return jsonify({"error": "Invalid JSON file"}), 400 + + # ✅ Check if file is XML + elif file.filename.endswith(".xml"): + xml_data = file.read().decode("utf-8") # Convert bytes to string + graph_id = g.user_id + "_" + file.filename.replace(".xml", "") + success, result = ODataLoader.load(graph_id, xml_data) + + # ✅ Check if file is csv + elif file.filename.endswith(".csv"): + csv_data = file.read().decode("utf-8") # Convert bytes to string + graph_id = g.user_id + "_" + file.filename.replace(".csv", "") + success, result = CSVLoader.load(graph_id, csv_data) + + else: + return jsonify({"error": "Unsupported file type"}), 415 + else: + return jsonify({"error": "Unsupported Content-Type"}), 415 + + # ✅ Return the final response + if success: + return jsonify({"message": result, "graph_id": graph_id}) + + return jsonify({"error": result}), 400 + + +@graphs_bp.route("/", methods=["POST"]) +@token_required +def query_graph(graph_id: str): + """ + text2sql + """ + graph_id = g.user_id + "_" + graph_id.strip() + request_data = request.get_json() + queries_history = request_data.get("chat") + result_history = request_data.get("result") + instructions = request_data.get("instructions") + if not queries_history: + return jsonify({"error": "Invalid or missing JSON data"}), 400 + + logging.info("User Query: %s", queries_history[-1]) + + # Create a generator function for streaming + def generate(): + agent_rel = RelevancyAgent(queries_history, result_history) + agent_an = AnalysisAgent(queries_history, result_history) + + step = {"type": "reasoning_step", + "message": "Step 1: Analyzing user query and generating SQL..."} + yield json.dumps(step) + MESSAGE_DELIMITER + # Ensure the database description is loaded + db_description, db_url = get_db_description(graph_id) + + logging.info("Calling to relevancy agent with query: %s", + queries_history[-1]) + answer_rel = agent_rel.get_answer(queries_history[-1], db_description) + if answer_rel["status"] != "On-topic": + step = { + "type": "followup_questions", + "message": "Off topic question: " + answer_rel["reason"], + } + logging.info("SQL Fail reason: %s", answer_rel["reason"]) + yield json.dumps(step) + MESSAGE_DELIMITER + else: + # Use a thread pool to enforce timeout + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(find, graph_id, queries_history, db_description) + try: + _, result, _ = future.result(timeout=120) + except FuturesTimeoutError: + yield json.dumps( + { + "type": "error", + "message": ("Timeout error while finding tables relevant to " + "your request."), + } + ) + MESSAGE_DELIMITER + return + except Exception as e: + logging.info("Error in find function: %s", e) + yield json.dumps( + {"type": "error", "message": "Error in find function"} + ) + MESSAGE_DELIMITER + return + + logging.info("Calling to analysis agent with query: %s", queries_history[-1]) + answer_an = agent_an.get_analysis( + queries_history[-1], result, db_description, instructions + ) + + logging.info("SQL Result: %s", answer_an['sql_query']) + yield json.dumps( + { + "type": "final_result", + "data": answer_an["sql_query"], + "conf": answer_an["confidence"], + "miss": answer_an["missing_information"], + "amb": answer_an["ambiguities"], + "exp": answer_an["explanation"], + "is_valid": answer_an["is_sql_translatable"], + } + ) + MESSAGE_DELIMITER + + # If the SQL query is valid, execute it using the postgress database db_url + if answer_an["is_sql_translatable"]: + # Check if this is a destructive operation that requires confirmation + sql_query = answer_an["sql_query"] + sql_type = sql_query.strip().split()[0].upper() if sql_query else "" + + destructive_ops = ['INSERT', 'UPDATE', 'DELETE', 'DROP', + 'CREATE', 'ALTER', 'TRUNCATE'] + if sql_type in destructive_ops: + # This is a destructive operation - ask for user confirmation + confirmation_message = f"""⚠️ DESTRUCTIVE OPERATION DETECTED ⚠️ + +The generated SQL query will perform a **{sql_type}** operation: + +SQL: +{sql_query} + +What this will do: +""" + if sql_type == 'INSERT': + confirmation_message += "• Add new data to the database" + elif sql_type == 'UPDATE': + confirmation_message += ("• Modify existing data in the " + "database") + elif sql_type == 'DELETE': + confirmation_message += ("• **PERMANENTLY DELETE** data " + "from the database") + elif sql_type == 'DROP': + confirmation_message += ("• **PERMANENTLY DELETE** entire " + "tables or database objects") + elif sql_type == 'CREATE': + confirmation_message += ("• Create new tables or database " + "objects") + elif sql_type == 'ALTER': + confirmation_message += ("• Modify the structure of existing " + "tables") + elif sql_type == 'TRUNCATE': + confirmation_message += ("• **PERMANENTLY DELETE ALL DATA** " + "from specified tables") + confirmation_message += """ + +⚠️ WARNING: This operation will make changes to your database and may be irreversible. +""" + + yield json.dumps( + { + "type": "destructive_confirmation", + "message": confirmation_message, + "sql_query": sql_query, + "operation_type": sql_type + } + ) + MESSAGE_DELIMITER + return # Stop here and wait for user confirmation + + try: + step = {"type": "reasoning_step", "message": "Step 2: Executing SQL query"} + yield json.dumps(step) + MESSAGE_DELIMITER + + # Check if this query modifies the database schema + is_schema_modifying, operation_type = ( + PostgresLoader.is_schema_modifying_query(sql_query) + ) + + query_results = PostgresLoader.execute_sql_query(answer_an["sql_query"], db_url) + yield json.dumps( + { + "type": "query_result", + "data": query_results, + } + ) + MESSAGE_DELIMITER + + # If schema was modified, refresh the graph + if is_schema_modifying: + step = {"type": "reasoning_step", + "message": ("Step 3: Schema change detected - " + "refreshing graph...")} + yield json.dumps(step) + MESSAGE_DELIMITER + + refresh_result = PostgresLoader.refresh_graph_schema( + graph_id, db_url) + refresh_success, refresh_message = refresh_result + + if refresh_success: + refresh_msg = (f"✅ Schema change detected " + f"({operation_type} operation)\n\n" + f"🔄 Graph schema has been automatically " + f"refreshed with the latest database " + f"structure.") + yield json.dumps( + { + "type": "schema_refresh", + "message": refresh_msg, + "refresh_status": "success" + } + ) + MESSAGE_DELIMITER + else: + failure_msg = (f"⚠️ Schema was modified but graph " + f"refresh failed: {refresh_message}") + yield json.dumps( + { + "type": "schema_refresh", + "message": failure_msg, + "refresh_status": "failed" + } + ) + MESSAGE_DELIMITER + + # Generate user-readable response using AI + step_num = "4" if is_schema_modifying else "3" + step = {"type": "reasoning_step", + "message": f"Step {step_num}: Generating user-friendly response"} + yield json.dumps(step) + MESSAGE_DELIMITER + + response_agent = ResponseFormatterAgent() + user_readable_response = response_agent.format_response( + user_query=queries_history[-1], + sql_query=answer_an["sql_query"], + query_results=query_results, + db_description=db_description + ) + + yield json.dumps( + { + "type": "ai_response", + "message": user_readable_response, + } + ) + MESSAGE_DELIMITER + + except Exception as e: + logging.error("Error executing SQL query: %s", e) + yield json.dumps( + {"type": "error", "message": str(e)} + ) + MESSAGE_DELIMITER + + return Response(stream_with_context(generate()), content_type="application/json") + + +@graphs_bp.route("//confirm", methods=["POST"]) +@token_required +def confirm_destructive_operation(graph_id: str): + """ + Handle user confirmation for destructive SQL operations + """ + graph_id = g.user_id + "_" + graph_id.strip() + request_data = request.get_json() + confirmation = request_data.get("confirmation", "").strip().upper() + sql_query = request_data.get("sql_query", "") + queries_history = request_data.get("chat", []) + + if not sql_query: + return jsonify({"error": "No SQL query provided"}), 400 + + # Create a generator function for streaming the confirmation response + def generate_confirmation(): + if confirmation == "CONFIRM": + try: + db_description, db_url = get_db_description(graph_id) + + step = {"type": "reasoning_step", + "message": "Step 2: Executing confirmed SQL query"} + yield json.dumps(step) + MESSAGE_DELIMITER + + # Check if this query modifies the database schema + is_schema_modifying, operation_type = ( + PostgresLoader.is_schema_modifying_query(sql_query) + ) + + query_results = PostgresLoader.execute_sql_query(sql_query, db_url) + yield json.dumps( + { + "type": "query_result", + "data": query_results, + } + ) + MESSAGE_DELIMITER + + # If schema was modified, refresh the graph + if is_schema_modifying: + step = {"type": "reasoning_step", + "message": "Step 3: Schema change detected - refreshing graph..."} + yield json.dumps(step) + MESSAGE_DELIMITER + + refresh_success, refresh_message = ( + PostgresLoader.refresh_graph_schema(graph_id, db_url) + ) + + if refresh_success: + yield json.dumps( + { + "type": "schema_refresh", + "message": (f"✅ Schema change detected ({operation_type} " + "operation)\n\n🔄 Graph schema has been automatically " + "refreshed with the latest database structure."), + "refresh_status": "success" + } + ) + MESSAGE_DELIMITER + else: + yield json.dumps( + { + "type": "schema_refresh", + "message": (f"⚠️ Schema was modified but graph refresh failed: " + f"{refresh_message}"), + "refresh_status": "failed" + } + ) + MESSAGE_DELIMITER + + # Generate user-readable response using AI + step_num = "4" if is_schema_modifying else "3" + step = {"type": "reasoning_step", + "message": f"Step {step_num}: Generating user-friendly response"} + yield json.dumps(step) + MESSAGE_DELIMITER + + response_agent = ResponseFormatterAgent() + user_readable_response = response_agent.format_response( + user_query=queries_history[-1] if queries_history else "Destructive operation", + sql_query=sql_query, + query_results=query_results, + db_description=db_description + ) + + yield json.dumps( + { + "type": "ai_response", + "message": user_readable_response, + } + ) + MESSAGE_DELIMITER + + except Exception as e: + logging.error("Error executing confirmed SQL query: %s", e) + yield json.dumps( + {"type": "error", "message": f"Error executing query: {str(e)}"} + ) + MESSAGE_DELIMITER + else: + # User cancelled or provided invalid confirmation + yield json.dumps( + { + "type": "operation_cancelled", + "message": "Operation cancelled. The destructive SQL query was not executed." + } + ) + MESSAGE_DELIMITER + + return Response(stream_with_context(generate_confirmation()), content_type="application/json") + + +@graphs_bp.route("//refresh", methods=["POST"]) +@token_required +def refresh_graph_schema(graph_id: str): + """ + Manually refresh the graph schema from the database. + This endpoint allows users to manually trigger a schema refresh + if they suspect the graph is out of sync with the database. + """ + graph_id = g.user_id + "_" + graph_id.strip() + + try: + # Get database connection details + _, db_url = get_db_description(graph_id) + + if not db_url or db_url == "No URL available for this database.": + return jsonify({ + "success": False, + "error": "No database URL found for this graph" + }), 400 + + # Perform schema refresh + success, message = PostgresLoader.refresh_graph_schema(graph_id, db_url) + + if success: + return jsonify({ + "success": True, + "message": f"Graph schema refreshed successfully. {message}" + }), 200 + else: + return jsonify({ + "success": False, + "error": f"Failed to refresh schema: {message}" + }), 500 + + except Exception as e: + logging.error("Error in manual schema refresh: %s", e) + return jsonify({ + "success": False, + "error": f"Error refreshing schema: {str(e)}" + }), 500 From e243ade3298ee9b7fd4d54a29d0e5d310035cd3e Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Fri, 1 Aug 2025 22:58:46 +0300 Subject: [PATCH 02/16] refactor code --- api/auth/oauth_handlers.py | 24 +- api/auth/user_management.py | 223 ++++++--- api/index_original.py | 936 ------------------------------------ api/routes/auth.py | 20 +- api/routes/database.py | 9 +- api/routes/graphs.py | 23 +- 6 files changed, 202 insertions(+), 1033 deletions(-) delete mode 100644 api/index_original.py diff --git a/api/auth/oauth_handlers.py b/api/auth/oauth_handlers.py index 6e7e98bc..d42d1188 100644 --- a/api/auth/oauth_handlers.py +++ b/api/auth/oauth_handlers.py @@ -30,9 +30,14 @@ def google_logged_in(blueprint, token): # pylint: disable=unused-argument email = google_user.get("email") name = google_user.get("name") + # Validate required fields + if not user_id or not email: + logging.error("Missing required fields from Google OAuth response") + return False + if user_id and email: # Check if identity exists in Organizations graph, create if new - is_new_user, _ = ensure_user_in_organizations( + _, _ = ensure_user_in_organizations( user_id, email, name, "google", google_user.get("picture") ) @@ -41,10 +46,10 @@ def google_logged_in(blueprint, token): # pylint: disable=unused-argument # Normalize user info structure for session user_info_session = { - "id": user_id, - "name": name, + "id": str(user_id), # Ensure string type + "name": name or "", "email": email, - "picture": google_user.get("picture"), + "picture": google_user.get("picture", ""), "provider": "google" } session["user_info"] = user_info_session @@ -85,9 +90,14 @@ def github_logged_in(blueprint, token): # pylint: disable=unused-argument user_id = str(github_user.get("id")) name = github_user.get("name") or github_user.get("login") + # Validate required fields + if not user_id or not email: + logging.error("Missing required fields from GitHub OAuth response") + return False + if user_id and email: # Check if identity exists in Organizations graph, create if new - is_new_user, _ = ensure_user_in_organizations( + _, _ = ensure_user_in_organizations( user_id, email, name, "github", github_user.get("avatar_url") ) @@ -97,9 +107,9 @@ def github_logged_in(blueprint, token): # pylint: disable=unused-argument # Normalize user info structure for session user_info_session = { "id": user_id, - "name": name, + "name": name or "", "email": email, - "picture": github_user.get("avatar_url"), + "picture": github_user.get("avatar_url", ""), "provider": "github" } session["user_info"] = user_info_session diff --git a/api/auth/user_management.py b/api/auth/user_management.py index d21aadca..61925246 100644 --- a/api/auth/user_management.py +++ b/api/auth/user_management.py @@ -19,6 +19,23 @@ def ensure_user_in_organizations(provider_user_id, email, name, provider, pictur Uses MERGE for atomic operations and better performance. Returns (is_new_user, user_info) """ + # Input validation + if not provider_user_id or not email or not provider: + logging.error("Missing required parameters: provider_user_id=%s, email=%s, provider=%s", + provider_user_id, email, provider) + return False, None + + # Validate email format (basic check) + if "@" not in email or "." not in email: + logging.error("Invalid email format: %s", email) + return False, None + + # Validate provider is in allowed list + allowed_providers = ["google", "github"] + if provider not in allowed_providers: + logging.error("Invalid provider: %s", provider) + return False, None + try: # Select the Organizations graph organizations_graph = db.select_graph("Organizations") @@ -98,13 +115,28 @@ def ensure_user_in_organizations(provider_user_id, email, name, provider, pictur logging.error("Failed to create/update identity and user: email=%s", email) return False, None - except Exception as e: + except (AttributeError, ValueError, KeyError) as e: logging.error("Error managing user in Organizations graph: %s", e) return False, None + except Exception as e: + logging.error("Unexpected error managing user in Organizations graph: %s", e) + return False, None def update_identity_last_login(provider, provider_user_id): """Update the last login timestamp for an existing identity""" + # Input validation + if not provider or not provider_user_id: + logging.error("Missing required parameters: provider=%s, provider_user_id=%s", + provider, provider_user_id) + return + + # Validate provider is in allowed list + allowed_providers = ["google", "github"] + if provider not in allowed_providers: + logging.error("Invalid provider: %s", provider) + return + try: organizations_graph = db.select_graph("Organizations") update_query = """ @@ -118,9 +150,12 @@ def update_identity_last_login(provider, provider_user_id): }) logging.info("Updated last login for identity: provider=%s, provider_user_id=%s", provider, provider_user_id) - except Exception as e: + except (AttributeError, ValueError, KeyError) as e: logging.error("Error updating last login for identity %s/%s: %s", provider, provider_user_id, e) + except Exception as e: + logging.error("Unexpected error updating last login for identity %s/%s: %s", + provider, provider_user_id, e) def validate_and_cache_user(): @@ -129,75 +164,100 @@ def validate_and_cache_user(): Returns (user_info, is_authenticated) tuple. Supports both Google and GitHub OAuth. """ - # Check for cached user info from either provider - user_info = session.get("user_info") - token_validated_at = session.get("token_validated_at", 0) - current_time = time.time() - - # Use cached user info if it's less than 15 minutes old - if user_info and (current_time - token_validated_at) < 900: # 15 minutes - return user_info, True - - # Check Google OAuth first - if google.authorized: - try: - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - google_user = resp.json() - # Normalize user info structure - user_info = { - "id": google_user.get("id"), - "name": google_user.get("name"), - "email": google_user.get("email"), - "picture": google_user.get("picture"), - "provider": "google" - } - session["user_info"] = user_info - session["token_validated_at"] = current_time - return user_info, True - except (requests.RequestException, KeyError, ValueError) as e: - logging.warning("Google OAuth validation error: %s", e) - - # Check GitHub OAuth - if github.authorized: - try: - # Get user profile - resp = github.get("/user") - if resp.ok: - github_user = resp.json() - - # Get user email (GitHub may require separate call for email) - email_resp = github.get("/user/emails") - email = None - if email_resp.ok: - emails = email_resp.json() - # Find primary email - for email_obj in emails: - if email_obj.get("primary", False): - email = email_obj.get("email") - break - - # If no primary email found, use the first one - if not email and emails: - email = emails[0].get("email") - - # Normalize user info structure - user_info = { - "id": str(github_user.get("id")), # Convert to string for consistency - "name": github_user.get("name") or github_user.get("login"), - "email": email, - "picture": github_user.get("avatar_url"), - "provider": "github" - } - session["user_info"] = user_info - session["token_validated_at"] = current_time - return user_info, True - except (requests.RequestException, KeyError, ValueError) as e: - logging.warning("GitHub OAuth validation error: %s", e) - - # If no valid authentication found, clear session - session.clear() - return None, False + try: + # Check for cached user info from either provider + user_info = session.get("user_info") + token_validated_at = session.get("token_validated_at", 0) + current_time = time.time() + + # Use cached user info if it's less than 15 minutes old + if user_info and (current_time - token_validated_at) < 900: # 15 minutes + return user_info, True + + # Check Google OAuth first + if google.authorized: + try: + resp = google.get("/oauth2/v2/userinfo") + if resp.ok: + google_user = resp.json() + # Validate required fields + if not google_user.get("id") or not google_user.get("email"): + logging.warning("Invalid Google user data received") + session.clear() + return None, False + + # Normalize user info structure + user_info = { + "id": str(google_user.get("id")), # Ensure string type + "name": google_user.get("name", ""), + "email": google_user.get("email"), + "picture": google_user.get("picture", ""), + "provider": "google" + } + session["user_info"] = user_info + session["token_validated_at"] = current_time + return user_info, True + except (requests.RequestException, KeyError, ValueError) as e: + logging.warning("Google OAuth validation error: %s", e) + session.clear() + + # Check GitHub OAuth + if github.authorized: + try: + # Get user profile + resp = github.get("/user") + if resp.ok: + github_user = resp.json() + + # Validate required fields + if not github_user.get("id"): + logging.warning("Invalid GitHub user data received") + session.clear() + return None, False + + # Get user email (GitHub may require separate call for email) + email_resp = github.get("/user/emails") + email = None + if email_resp.ok: + emails = email_resp.json() + # Find primary email + for email_obj in emails: + if email_obj.get("primary", False): + email = email_obj.get("email") + break + + # If no primary email found, use the first one + if not email and emails: + email = emails[0].get("email") + + if not email: + logging.warning("No email found for GitHub user") + session.clear() + return None, False + + # Normalize user info structure + user_info = { + "id": str(github_user.get("id")), # Convert to string for consistency + "name": github_user.get("name") or github_user.get("login", ""), + "email": email, + "picture": github_user.get("avatar_url", ""), + "provider": "github" + } + session["user_info"] = user_info + session["token_validated_at"] = current_time + return user_info, True + except (requests.RequestException, KeyError, ValueError) as e: + logging.warning("GitHub OAuth validation error: %s", e) + session.clear() + + # If no valid authentication found, clear session + session.clear() + return None, False + + except Exception as e: + logging.error("Unexpected error in validate_and_cache_user: %s", e) + session.clear() + return None, False def token_required(f): @@ -205,16 +265,21 @@ def token_required(f): @wraps(f) def decorated_function(*args, **kwargs): - user_info, is_authenticated = validate_and_cache_user() + try: + user_info, is_authenticated = validate_and_cache_user() - if not is_authenticated: - return jsonify(message="Unauthorized - Please log in"), 401 + if not is_authenticated: + return jsonify(message="Unauthorized - Please log in"), 401 - g.user_id = user_info.get("id") - if not g.user_id: - session.clear() - return jsonify(message="Unauthorized - Invalid user"), 401 + g.user_id = user_info.get("id") + if not g.user_id: + session.clear() + return jsonify(message="Unauthorized - Invalid user"), 401 - return f(*args, **kwargs) + return f(*args, **kwargs) + except Exception as e: + logging.error("Unexpected error in token_required: %s", e) + session.clear() + return jsonify(message="Unauthorized - Authentication error"), 401 return decorated_function diff --git a/api/index_original.py b/api/index_original.py deleted file mode 100644 index e7e8f1d5..00000000 --- a/api/index_original.py +++ /dev/null @@ -1,936 +0,0 @@ -"""This module contains the routes for the text2sql API.""" - -import json -import logging -import os -import time -from concurrent.futures import ThreadPoolExecutor -from concurrent.futures import TimeoutError as FuturesTimeoutError -from functools import wraps - -import requests -from dotenv import load_dotenv -from flask import (Blueprint, Flask, Response, jsonify, render_template, - request, stream_with_context, g, session, redirect, url_for) -from flask_dance.contrib.google import make_google_blueprint, google -from flask_dance.contrib.github import make_github_blueprint, github -from flask_dance.consumer.storage.session import SessionStorage -from flask_dance.consumer import oauth_authorized - -from api.agents import AnalysisAgent, RelevancyAgent, ResponseFormatterAgent -from api.extensions import db -from api.graph import find, get_db_description -from api.loaders.csv_loader import CSVLoader -from api.loaders.json_loader import JSONLoader -from api.loaders.postgres_loader import PostgresLoader -from api.loaders.odata_loader import ODataLoader - -# User management functions -def ensure_user_in_organizations(provider_user_id, email, name, provider, picture=None): - """ - Check if identity exists in Organizations graph, create if not. - Creates separate Identity and User nodes with proper relationships. - Uses MERGE for atomic operations and better performance. - Returns (is_new_user, user_info) - """ - try: - # Select the Organizations graph - organizations_graph = db.select_graph("Organizations") - - # Extract first and last name - name_parts = (name or "").split(" ", 1) if name else ["", ""] - first_name = name_parts[0] if len(name_parts) > 0 else "" - last_name = name_parts[1] if len(name_parts) > 1 else "" - - # Use MERGE to handle all scenarios in a single atomic operation - merge_query = """ - // First, ensure user exists (merge by email) - MERGE (user:User {email: $email}) - ON CREATE SET - user.first_name = $first_name, - user.last_name = $last_name, - user.created_at = timestamp() - - // Then, merge identity and link to user - MERGE (identity:Identity {provider: $provider, provider_user_id: $provider_user_id}) - ON CREATE SET - identity.email = $email, - identity.name = $name, - identity.picture = $picture, - identity.created_at = timestamp(), - identity.last_login = timestamp() - ON MATCH SET - identity.email = $email, - identity.name = $name, - identity.picture = $picture, - identity.last_login = timestamp() - - // Ensure relationship exists - MERGE (identity)-[:AUTHENTICATES]->(user) - - // Return results with flags to determine if this was a new user/identity - RETURN - identity, - user, - identity.created_at = identity.last_login AS is_new_identity, - EXISTS((user)<-[:AUTHENTICATES]-(:Identity)) AS had_other_identities - """ - - result = organizations_graph.query(merge_query, { - "provider": provider, - "provider_user_id": provider_user_id, - "email": email, - "name": name, - "picture": picture, - "first_name": first_name, - "last_name": last_name - }) - - if result.result_set: - identity = result.result_set[0][0] - user = result.result_set[0][1] - is_new_identity = result.result_set[0][2] - had_other_identities = result.result_set[0][3] - - # Determine the type of operation for logging - if is_new_identity and not had_other_identities: - # Brand new user (first identity) - logging.info("NEW USER CREATED: provider=%s, provider_user_id=%s, " - "email=%s, name=%s", provider, provider_user_id, email, name) - return True, {"identity": identity, "user": user} - elif is_new_identity and had_other_identities: - # New identity for existing user (cross-provider linking) - logging.info("NEW IDENTITY LINKED TO EXISTING USER: provider=%s, " - "provider_user_id=%s, email=%s, name=%s", - provider, provider_user_id, email, name) - return True, {"identity": identity, "user": user} - else: - # Existing identity login - logging.info("Existing identity found: provider=%s, email=%s", provider, email) - return False, {"identity": identity, "user": user} - else: - logging.error("Failed to create/update identity and user: email=%s", email) - return False, None - - except Exception as e: - logging.error("Error managing user in Organizations graph: %s", e) - return False, None - -def update_identity_last_login(provider, provider_user_id): - """Update the last login timestamp for an existing identity""" - try: - organizations_graph = db.select_graph("Organizations") - update_query = """ - MATCH (identity:Identity {provider: $provider, provider_user_id: $provider_user_id}) - SET identity.last_login = timestamp() - RETURN identity - """ - organizations_graph.query(update_query, { - "provider": provider, - "provider_user_id": provider_user_id - }) - logging.info("Updated last login for identity: provider=%s, provider_user_id=%s", - provider, provider_user_id) - except Exception as e: - logging.error("Error updating last login for identity %s/%s: %s", - provider, provider_user_id, e) - -# Load environment variables from .env file -load_dotenv() -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") - -# Use the same delimiter as in the JavaScript -MESSAGE_DELIMITER = "|||FALKORDB_MESSAGE_BOUNDARY|||" - -main = Blueprint("main", __name__) - -def validate_and_cache_user(): - """ - Helper function to validate OAuth token and cache user info. - Returns (user_info, is_authenticated) tuple. - Supports both Google and GitHub OAuth. - """ - # Check for cached user info from either provider - user_info = session.get("user_info") - token_validated_at = session.get("token_validated_at", 0) - current_time = time.time() - - # Use cached user info if it's less than 15 minutes old - if user_info and (current_time - token_validated_at) < 900: # 15 minutes - return user_info, True - - # Check Google OAuth first - if google.authorized: - try: - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - google_user = resp.json() - # Normalize user info structure - user_info = { - "id": google_user.get("id"), - "name": google_user.get("name"), - "email": google_user.get("email"), - "picture": google_user.get("picture"), - "provider": "google" - } - session["user_info"] = user_info - session["token_validated_at"] = current_time - return user_info, True - except (requests.RequestException, KeyError, ValueError) as e: - logging.warning("Google OAuth validation error: %s", e) - - # Check GitHub OAuth - if github.authorized: - try: - # Get user profile - resp = github.get("/user") - if resp.ok: - github_user = resp.json() - - # Get user email (GitHub may require separate call for email) - email_resp = github.get("/user/emails") - email = None - if email_resp.ok: - emails = email_resp.json() - # Find primary email - for email_obj in emails: - if email_obj.get("primary", False): - email = email_obj.get("email") - break - - # If no primary email found, use the first one - if not email and emails: - email = emails[0].get("email") - - # Normalize user info structure - user_info = { - "id": str(github_user.get("id")), # Convert to string for consistency - "name": github_user.get("name") or github_user.get("login"), - "email": email, - "picture": github_user.get("avatar_url"), - "provider": "github" - } - session["user_info"] = user_info - session["token_validated_at"] = current_time - return user_info, True - except (requests.RequestException, KeyError, ValueError) as e: - logging.warning("GitHub OAuth validation error: %s", e) - - # If no valid authentication found, clear session - session.clear() - return None, False - - -def token_required(f): - """Decorator to protect routes with token authentication""" - - @wraps(f) - def decorated_function(*args, **kwargs): - user_info, is_authenticated = validate_and_cache_user() - - if not is_authenticated: - return jsonify(message="Unauthorized - Please log in"), 401 - - g.user_id = user_info.get("id") - if not g.user_id: - session.clear() - return jsonify(message="Unauthorized - Invalid user"), 401 - - return f(*args, **kwargs) - - return decorated_function - - -app = Flask(__name__) -app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersekrit") - -# Google OAuth setup -GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") -GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") -google_bp = make_google_blueprint( - client_id=GOOGLE_CLIENT_ID, - client_secret=GOOGLE_CLIENT_SECRET, - scope=[ - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - "openid" - ] -) -app.register_blueprint(google_bp, url_prefix="/login") - -# GitHub OAuth setup -GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID") -GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET") -github_bp = make_github_blueprint( - client_id=GITHUB_CLIENT_ID, - client_secret=GITHUB_CLIENT_SECRET, - scope="user:email", - storage=SessionStorage() -) -app.register_blueprint(github_bp, url_prefix="/login") - -# GitHub OAuth signal handler - -@oauth_authorized.connect_via(google_bp) -def google_logged_in(blueprint, token): # pylint: disable=unused-argument - """Handle Google OAuth authorization callback.""" - if not token: - return False - - try: - # Get user profile - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - google_user = resp.json() - user_id = google_user.get("id") - email = google_user.get("email") - name = google_user.get("name") - - if user_id and email: - # Check if identity exists in Organizations graph, create if new - is_new_user, _ = ensure_user_in_organizations( - user_id, email, name, "google", google_user.get("picture") - ) - - # If existing identity, just update last login - # (already done in ensure_user_in_organizations) - - # Normalize user info structure for session - user_info_session = { - "id": user_id, - "name": name, - "email": email, - "picture": google_user.get("picture"), - "provider": "google" - } - session["user_info"] = user_info_session - session["token_validated_at"] = time.time() - return False # Don't create default flask-dance entry in session - - except (requests.RequestException, KeyError, ValueError, AttributeError) as e: - logging.error("Google OAuth signal error: %s", e) - - return False - -@oauth_authorized.connect_via(github_bp) -def github_logged_in(blueprint, token): # pylint: disable=unused-argument - """Handle GitHub OAuth authorization callback.""" - if not token: - return False - - try: - # Get user profile - resp = github.get("/user") - if resp.ok: - github_user = resp.json() - - # Get user email (GitHub may require separate call for email) - email_resp = github.get("/user/emails") - email = None - if email_resp.ok: - emails = email_resp.json() - # Find primary email - for email_obj in emails: - if email_obj.get("primary", False): - email = email_obj.get("email") - break - # If no primary email found, use the first one - if not email and emails: - email = emails[0].get("email") - - user_id = str(github_user.get("id")) - name = github_user.get("name") or github_user.get("login") - - if user_id and email: - # Check if identity exists in Organizations graph, create if new - is_new_user, _ = ensure_user_in_organizations( - user_id, email, name, "github", github_user.get("avatar_url") - ) - - # If existing identity, just update last login - # (already done in ensure_user_in_organizations) - - # Normalize user info structure for session - user_info_session = { - "id": user_id, - "name": name, - "email": email, - "picture": github_user.get("avatar_url"), - "provider": "github" - } - session["user_info"] = user_info_session - session["token_validated_at"] = time.time() - return False # Don't create default flask-dance entry in session - - except (requests.RequestException, KeyError, ValueError, AttributeError) as e: - logging.error("GitHub OAuth signal error: %s", e) - - return False - - -@app.errorhandler(Exception) -def handle_oauth_error(error): - """Handle OAuth-related errors gracefully""" - # Check if it's an OAuth-related error - if "token" in str(error).lower() or "oauth" in str(error).lower(): - logging.warning("OAuth error occurred: %s", error) - session.clear() - return redirect(url_for("home")) - - # For other errors, let them bubble up - raise error - - -@app.route("/") -def home(): - """Home route""" - user_info, is_authenticated = validate_and_cache_user() - - # If not authenticated through OAuth, check for any stale session data - if not is_authenticated and not google.authorized and not github.authorized: - session.pop("user_info", None) - - return render_template("chat.j2", is_authenticated=is_authenticated, user_info=user_info) - - -@app.route("/graphs") -@token_required -def graphs(): - """ - This route is used to list all the graphs that are available in the database. - """ - user_id = g.user_id - user_graphs = db.list_graphs() - # Only include graphs that start with user_id + '_', and strip the prefix - filtered_graphs = [graph[len(f"{user_id}_"):] - for graph in user_graphs if graph.startswith(f"{user_id}_")] - return jsonify(filtered_graphs) - - -@app.route("/graphs", methods=["POST"]) -@token_required -def load(): - """ - This route is used to load the graph data into the database. - It expects either: - - A JSON payload (application/json) - - A File upload (multipart/form-data) - - An XML payload (application/xml or text/xml) - """ - content_type = request.content_type - success, result = False, "Invalid content type" - graph_id = "" - - # ✅ Handle JSON Payload - if content_type.startswith("application/json"): - data = request.get_json() - if not data or "database" not in data: - return jsonify({"error": "Invalid JSON data"}), 400 - - graph_id = g.user_id + "_" + data["database"] - success, result = JSONLoader.load(graph_id, data) - - # # ✅ Handle XML Payload - # elif content_type.startswith("application/xml") or content_type.startswith("text/xml"): - # xml_data = request.data - # graph_id = "" - # success, result = ODataLoader.load(graph_id, xml_data) - - # # ✅ Handle CSV Payload - # elif content_type.startswith("text/csv"): - # csv_data = request.data - # graph_id = "" - # success, result = CSVLoader.load(graph_id, csv_data) - - # ✅ Handle File Upload (FormData with JSON/XML) - elif content_type.startswith("multipart/form-data"): - if "file" not in request.files: - return jsonify({"error": "No file uploaded"}), 400 - - file = request.files["file"] - if file.filename == "": - return jsonify({"error": "Empty file"}), 400 - - # ✅ Check if file is JSON - if file.filename.endswith(".json"): - try: - data = json.load(file) - graph_id = g.user_id + "_" + data.get("database", "") - success, result = JSONLoader.load(graph_id, data) - except json.JSONDecodeError: - return jsonify({"error": "Invalid JSON file"}), 400 - - # ✅ Check if file is XML - elif file.filename.endswith(".xml"): - xml_data = file.read().decode("utf-8") # Convert bytes to string - graph_id = g.user_id + "_" + file.filename.replace(".xml", "") - success, result = ODataLoader.load(graph_id, xml_data) - - # ✅ Check if file is csv - elif file.filename.endswith(".csv"): - csv_data = file.read().decode("utf-8") # Convert bytes to string - graph_id = g.user_id + "_" + file.filename.replace(".csv", "") - success, result = CSVLoader.load(graph_id, csv_data) - - else: - return jsonify({"error": "Unsupported file type"}), 415 - else: - return jsonify({"error": "Unsupported Content-Type"}), 415 - - # ✅ Return the final response - if success: - return jsonify({"message": result, "graph_id": graph_id}) - - return jsonify({"error": result}), 400 - - -@app.route("/graphs/", methods=["POST"]) -@token_required -def query(graph_id: str): - """ - text2sql - """ - graph_id = g.user_id + "_" + graph_id.strip() - request_data = request.get_json() - queries_history = request_data.get("chat") - result_history = request_data.get("result") - instructions = request_data.get("instructions") - if not queries_history: - return jsonify({"error": "Invalid or missing JSON data"}), 400 - - logging.info("User Query: %s", queries_history[-1]) - - # Create a generator function for streaming - def generate(): - agent_rel = RelevancyAgent(queries_history, result_history) - agent_an = AnalysisAgent(queries_history, result_history) - - step = {"type": "reasoning_step", - "message": "Step 1: Analyzing user query and generating SQL..."} - yield json.dumps(step) + MESSAGE_DELIMITER - # Ensure the database description is loaded - db_description, db_url = get_db_description(graph_id) - - logging.info("Calling to relevancy agent with query: %s", - queries_history[-1]) - answer_rel = agent_rel.get_answer(queries_history[-1], db_description) - if answer_rel["status"] != "On-topic": - step = { - "type": "followup_questions", - "message": "Off topic question: " + answer_rel["reason"], - } - logging.info("SQL Fail reason: %s", answer_rel["reason"]) - yield json.dumps(step) + MESSAGE_DELIMITER - else: - # Use a thread pool to enforce timeout - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(find, graph_id, queries_history, db_description) - try: - _, result, _ = future.result(timeout=120) - except FuturesTimeoutError: - yield json.dumps( - { - "type": "error", - "message": ("Timeout error while finding tables relevant to " - "your request."), - } - ) + MESSAGE_DELIMITER - return - except Exception as e: - logging.info("Error in find function: %s", e) - yield json.dumps( - {"type": "error", "message": "Error in find function"} - ) + MESSAGE_DELIMITER - return - - logging.info("Calling to analysis agent with query: %s", queries_history[-1]) - answer_an = agent_an.get_analysis( - queries_history[-1], result, db_description, instructions - ) - - logging.info("SQL Result: %s", answer_an['sql_query']) - yield json.dumps( - { - "type": "final_result", - "data": answer_an["sql_query"], - "conf": answer_an["confidence"], - "miss": answer_an["missing_information"], - "amb": answer_an["ambiguities"], - "exp": answer_an["explanation"], - "is_valid": answer_an["is_sql_translatable"], - } - ) + MESSAGE_DELIMITER - - # If the SQL query is valid, execute it using the postgress database db_url - if answer_an["is_sql_translatable"]: - # Check if this is a destructive operation that requires confirmation - sql_query = answer_an["sql_query"] - sql_type = sql_query.strip().split()[0].upper() if sql_query else "" - - destructive_ops = ['INSERT', 'UPDATE', 'DELETE', 'DROP', - 'CREATE', 'ALTER', 'TRUNCATE'] - if sql_type in destructive_ops: - # This is a destructive operation - ask for user confirmation - confirmation_message = f"""⚠️ DESTRUCTIVE OPERATION DETECTED ⚠️ - -The generated SQL query will perform a **{sql_type}** operation: - -SQL: -{sql_query} - -What this will do: -""" - if sql_type == 'INSERT': - confirmation_message += "• Add new data to the database" - elif sql_type == 'UPDATE': - confirmation_message += ("• Modify existing data in the " - "database") - elif sql_type == 'DELETE': - confirmation_message += ("• **PERMANENTLY DELETE** data " - "from the database") - elif sql_type == 'DROP': - confirmation_message += ("• **PERMANENTLY DELETE** entire " - "tables or database objects") - elif sql_type == 'CREATE': - confirmation_message += ("• Create new tables or database " - "objects") - elif sql_type == 'ALTER': - confirmation_message += ("• Modify the structure of existing " - "tables") - elif sql_type == 'TRUNCATE': - confirmation_message += ("• **PERMANENTLY DELETE ALL DATA** " - "from specified tables") - confirmation_message += """ - -⚠️ WARNING: This operation will make changes to your database and may be irreversible. -""" - - yield json.dumps( - { - "type": "destructive_confirmation", - "message": confirmation_message, - "sql_query": sql_query, - "operation_type": sql_type - } - ) + MESSAGE_DELIMITER - return # Stop here and wait for user confirmation - - try: - step = {"type": "reasoning_step", "message": "Step 2: Executing SQL query"} - yield json.dumps(step) + MESSAGE_DELIMITER - - # Check if this query modifies the database schema - is_schema_modifying, operation_type = ( - PostgresLoader.is_schema_modifying_query(sql_query) - ) - - query_results = PostgresLoader.execute_sql_query(answer_an["sql_query"], db_url) - yield json.dumps( - { - "type": "query_result", - "data": query_results, - } - ) + MESSAGE_DELIMITER - - # If schema was modified, refresh the graph - if is_schema_modifying: - step = {"type": "reasoning_step", - "message": ("Step 3: Schema change detected - " - "refreshing graph...")} - yield json.dumps(step) + MESSAGE_DELIMITER - - refresh_result = PostgresLoader.refresh_graph_schema( - graph_id, db_url) - refresh_success, refresh_message = refresh_result - - if refresh_success: - refresh_msg = (f"✅ Schema change detected " - f"({operation_type} operation)\n\n" - f"🔄 Graph schema has been automatically " - f"refreshed with the latest database " - f"structure.") - yield json.dumps( - { - "type": "schema_refresh", - "message": refresh_msg, - "refresh_status": "success" - } - ) + MESSAGE_DELIMITER - else: - failure_msg = (f"⚠️ Schema was modified but graph " - f"refresh failed: {refresh_message}") - yield json.dumps( - { - "type": "schema_refresh", - "message": failure_msg, - "refresh_status": "failed" - } - ) + MESSAGE_DELIMITER - - # Generate user-readable response using AI - step_num = "4" if is_schema_modifying else "3" - step = {"type": "reasoning_step", - "message": f"Step {step_num}: Generating user-friendly response"} - yield json.dumps(step) + MESSAGE_DELIMITER - - response_agent = ResponseFormatterAgent() - user_readable_response = response_agent.format_response( - user_query=queries_history[-1], - sql_query=answer_an["sql_query"], - query_results=query_results, - db_description=db_description - ) - - yield json.dumps( - { - "type": "ai_response", - "message": user_readable_response, - } - ) + MESSAGE_DELIMITER - - except Exception as e: - logging.error("Error executing SQL query: %s", e) - yield json.dumps( - {"type": "error", "message": str(e)} - ) + MESSAGE_DELIMITER - - return Response(stream_with_context(generate()), content_type="application/json") - - -@app.route("/graphs//confirm", methods=["POST"]) -@token_required -def confirm_destructive_operation(graph_id: str): - """ - Handle user confirmation for destructive SQL operations - """ - graph_id = g.user_id + "_" + graph_id.strip() - request_data = request.get_json() - confirmation = request_data.get("confirmation", "").strip().upper() - sql_query = request_data.get("sql_query", "") - queries_history = request_data.get("chat", []) - - if not sql_query: - return jsonify({"error": "No SQL query provided"}), 400 - - # Create a generator function for streaming the confirmation response - def generate_confirmation(): - if confirmation == "CONFIRM": - try: - db_description, db_url = get_db_description(graph_id) - - step = {"type": "reasoning_step", - "message": "Step 2: Executing confirmed SQL query"} - yield json.dumps(step) + MESSAGE_DELIMITER - - # Check if this query modifies the database schema - is_schema_modifying, operation_type = ( - PostgresLoader.is_schema_modifying_query(sql_query) - ) - - query_results = PostgresLoader.execute_sql_query(sql_query, db_url) - yield json.dumps( - { - "type": "query_result", - "data": query_results, - } - ) + MESSAGE_DELIMITER - - # If schema was modified, refresh the graph - if is_schema_modifying: - step = {"type": "reasoning_step", - "message": "Step 3: Schema change detected - refreshing graph..."} - yield json.dumps(step) + MESSAGE_DELIMITER - - refresh_success, refresh_message = ( - PostgresLoader.refresh_graph_schema(graph_id, db_url) - ) - - if refresh_success: - yield json.dumps( - { - "type": "schema_refresh", - "message": (f"✅ Schema change detected ({operation_type} " - "operation)\n\n🔄 Graph schema has been automatically " - "refreshed with the latest database structure."), - "refresh_status": "success" - } - ) + MESSAGE_DELIMITER - else: - yield json.dumps( - { - "type": "schema_refresh", - "message": (f"⚠️ Schema was modified but graph refresh failed: " - f"{refresh_message}"), - "refresh_status": "failed" - } - ) + MESSAGE_DELIMITER - - # Generate user-readable response using AI - step_num = "4" if is_schema_modifying else "3" - step = {"type": "reasoning_step", - "message": f"Step {step_num}: Generating user-friendly response"} - yield json.dumps(step) + MESSAGE_DELIMITER - - response_agent = ResponseFormatterAgent() - user_readable_response = response_agent.format_response( - user_query=queries_history[-1] if queries_history else "Destructive operation", - sql_query=sql_query, - query_results=query_results, - db_description=db_description - ) - - yield json.dumps( - { - "type": "ai_response", - "message": user_readable_response, - } - ) + MESSAGE_DELIMITER - - except Exception as e: - logging.error("Error executing confirmed SQL query: %s", e) - yield json.dumps( - {"type": "error", "message": f"Error executing query: {str(e)}"} - ) + MESSAGE_DELIMITER - else: - # User cancelled or provided invalid confirmation - yield json.dumps( - { - "type": "operation_cancelled", - "message": "Operation cancelled. The destructive SQL query was not executed." - } - ) + MESSAGE_DELIMITER - - return Response(stream_with_context(generate_confirmation()), content_type="application/json") - -@app.route("/login") -def login_google(): - """Handle Google OAuth login route.""" - if not google.authorized: - return redirect(url_for("google.login")) - - try: - resp = google.get("/oauth2/v2/userinfo") - if resp.ok: - google_user = resp.json() - # Normalize user info structure - user_info = { - "id": google_user.get("id"), - "name": google_user.get("name"), - "email": google_user.get("email"), - "picture": google_user.get("picture"), - "provider": "google" - } - session["user_info"] = user_info - session["token_validated_at"] = time.time() - return redirect(url_for("home")) - - # OAuth token might be expired, redirect to login - session.clear() - return redirect(url_for("google.login")) - except Exception as e: - logging.error("Google login error: %s", e) - session.clear() - return redirect(url_for("google.login")) - - - - -@app.route("/logout") -def logout(): - """Handle user logout and token revocation.""" - session.clear() - - # Revoke Google OAuth token if authorized - if google.authorized: - try: - google.get( - "https://accounts.google.com/o/oauth2/revoke", - params={"token": google.access_token} - ) - except Exception as e: - logging.warning("Error revoking Google token: %s", e) - - # Revoke GitHub OAuth token if authorized - if github.authorized: - try: - # GitHub doesn't have a simple revoke endpoint like Google - # The token will expire naturally or can be revoked from GitHub settings - pass - except Exception as e: - logging.warning("Error with GitHub token cleanup: %s", e) - - return redirect(url_for("home")) - -@app.route("/graphs//refresh", methods=["POST"]) -@token_required -def refresh_graph_schema(graph_id: str): - """ - Manually refresh the graph schema from the database. - This endpoint allows users to manually trigger a schema refresh - if they suspect the graph is out of sync with the database. - """ - graph_id = g.user_id + "_" + graph_id.strip() - - try: - # Get database connection details - _, db_url = get_db_description(graph_id) - - if not db_url or db_url == "No URL available for this database.": - return jsonify({ - "success": False, - "error": "No database URL found for this graph" - }), 400 - - # Perform schema refresh - success, message = PostgresLoader.refresh_graph_schema(graph_id, db_url) - - if success: - return jsonify({ - "success": True, - "message": f"Graph schema refreshed successfully. {message}" - }), 200 - else: - return jsonify({ - "success": False, - "error": f"Failed to refresh schema: {message}" - }), 500 - - except Exception as e: - logging.error("Error in manual schema refresh: %s", e) - return jsonify({ - "success": False, - "error": f"Error refreshing schema: {str(e)}" - }), 500 - -@app.route("/database", methods=["POST"]) -@token_required -def connect_database(): - """ - Accepts a JSON payload with a Postgres URL and attempts to connect. - Returns success or error message. - """ - data = request.get_json() - url = data.get("url") if data else None - if not url: - return jsonify({"success": False, "error": "No URL provided"}), 400 - try: - # Check for Postgres URL - if url.startswith("postgres://") or url.startswith("postgresql://"): - try: - # Attempt to connect/load using the loader - success, result = PostgresLoader.load(g.user_id, url) - if success: - return jsonify({"success": True, "message": result}), 200 - - return jsonify({"success": False, "error": result}), 400 - except Exception as e: - return jsonify({"success": False, "error": str(e)}), 500 - - return jsonify({"success": False, "error": "Invalid Postgres URL"}), 400 - except Exception as e: - return jsonify({"success": False, "error": str(e)}), 500 - - -if __name__ == "__main__": - app.register_blueprint(main) - app.run(debug=True) diff --git a/api/routes/auth.py b/api/routes/auth.py index 2899afc4..14c1ca81 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -3,6 +3,7 @@ import logging import time +import requests from flask import Blueprint, render_template, redirect, url_for, session from flask_dance.contrib.google import google from flask_dance.contrib.github import github @@ -34,12 +35,19 @@ def login_google(): resp = google.get("/oauth2/v2/userinfo") if resp.ok: google_user = resp.json() + + # Validate required fields + if not google_user.get("id") or not google_user.get("email"): + logging.error("Invalid Google user data received during login") + session.clear() + return redirect(url_for("google.login")) + # Normalize user info structure user_info = { - "id": google_user.get("id"), - "name": google_user.get("name"), + "id": str(google_user.get("id")), # Ensure string type + "name": google_user.get("name", ""), "email": google_user.get("email"), - "picture": google_user.get("picture"), + "picture": google_user.get("picture", ""), "provider": "google" } session["user_info"] = user_info @@ -49,7 +57,7 @@ def login_google(): # OAuth token might be expired, redirect to login session.clear() return redirect(url_for("google.login")) - except Exception as e: + except (requests.RequestException, KeyError, ValueError) as e: logging.error("Google login error: %s", e) session.clear() return redirect(url_for("google.login")) @@ -67,7 +75,7 @@ def logout(): "https://accounts.google.com/o/oauth2/revoke", params={"token": google.access_token} ) - except Exception as e: + except (requests.RequestException, AttributeError) as e: logging.warning("Error revoking Google token: %s", e) # Revoke GitHub OAuth token if authorized @@ -76,7 +84,7 @@ def logout(): # GitHub doesn't have a simple revoke endpoint like Google # The token will expire naturally or can be revoked from GitHub settings pass - except Exception as e: + except AttributeError as e: logging.warning("Error with GitHub token cleanup: %s", e) return redirect(url_for("auth.home")) diff --git a/api/routes/database.py b/api/routes/database.py index 51a7853e..6e550059 100644 --- a/api/routes/database.py +++ b/api/routes/database.py @@ -19,6 +19,11 @@ def connect_database(): url = data.get("url") if data else None if not url: return jsonify({"success": False, "error": "No URL provided"}), 400 + + # Validate URL format + if not isinstance(url, str) or len(url.strip()) == 0: + return jsonify({"success": False, "error": "Invalid URL format"}), 400 + try: # Check for Postgres URL if url.startswith("postgres://") or url.startswith("postgresql://"): @@ -29,9 +34,9 @@ def connect_database(): return jsonify({"success": True, "message": result}), 200 return jsonify({"success": False, "error": result}), 400 - except Exception as e: + except (ValueError, ConnectionError) as e: return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": "Invalid Postgres URL"}), 400 - except Exception as e: + except (ValueError, TypeError) as e: return jsonify({"success": False, "error": str(e)}), 500 diff --git a/api/routes/graphs.py b/api/routes/graphs.py index 4635147b..43ca8701 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -119,13 +119,30 @@ def query_graph(graph_id: str): """ text2sql """ - graph_id = g.user_id + "_" + graph_id.strip() + # Input validation + if not graph_id or not isinstance(graph_id, str): + return jsonify({"error": "Invalid graph_id"}), 400 + + # Sanitize graph_id to prevent injection + graph_id = graph_id.strip()[:100] # Limit length and strip whitespace + if not graph_id: + return jsonify({"error": "Invalid graph_id"}), 400 + + graph_id = g.user_id + "_" + graph_id request_data = request.get_json() + + if not request_data: + return jsonify({"error": "No JSON data provided"}), 400 + queries_history = request_data.get("chat") result_history = request_data.get("result") instructions = request_data.get("instructions") - if not queries_history: - return jsonify({"error": "Invalid or missing JSON data"}), 400 + + if not queries_history or not isinstance(queries_history, list): + return jsonify({"error": "Invalid or missing chat history"}), 400 + + if len(queries_history) == 0: + return jsonify({"error": "Empty chat history"}), 400 logging.info("User Query: %s", queries_history[-1]) From 18c79cd106fd5152c82c9f3f2c820829b17fd098 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Fri, 1 Aug 2025 23:11:39 +0300 Subject: [PATCH 03/16] refactor j2 file --- api/__init__.py | 1 + api/templates/base.j2 | 34 +++ api/templates/chat.j2 | 240 +++--------------- api/templates/chat_new.j2 | 40 +++ api/templates/components/chat_header.j2 | 19 ++ api/templates/components/chat_input.j2 | 17 ++ api/templates/components/login_modal.j2 | 13 + api/templates/components/menu_analytics.j2 | 17 ++ api/templates/components/menu_instructions.j2 | 6 + api/templates/components/postgres_modal.j2 | 17 ++ api/templates/components/reset_modal.j2 | 11 + api/templates/components/scripts.j2 | 17 ++ api/templates/components/sidebar_menu.j2 | 21 ++ api/templates/components/toolbar.j2 | 41 +++ api/templates/components/user_profile.j2 | 13 + 15 files changed, 300 insertions(+), 207 deletions(-) create mode 100644 api/__init__.py create mode 100644 api/templates/base.j2 create mode 100644 api/templates/chat_new.j2 create mode 100644 api/templates/components/chat_header.j2 create mode 100644 api/templates/components/chat_input.j2 create mode 100644 api/templates/components/login_modal.j2 create mode 100644 api/templates/components/menu_analytics.j2 create mode 100644 api/templates/components/menu_instructions.j2 create mode 100644 api/templates/components/postgres_modal.j2 create mode 100644 api/templates/components/reset_modal.j2 create mode 100644 api/templates/components/scripts.j2 create mode 100644 api/templates/components/sidebar_menu.j2 create mode 100644 api/templates/components/toolbar.j2 create mode 100644 api/templates/components/user_profile.j2 diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 00000000..0b1898f0 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +"""Text2SQL API package.""" diff --git a/api/templates/base.j2 b/api/templates/base.j2 new file mode 100644 index 00000000..2b6575b6 --- /dev/null +++ b/api/templates/base.j2 @@ -0,0 +1,34 @@ + + + + + + {% block title %}Chatbot Interface{% endblock %} + + + {% block extra_css %}{% endblock %} + + + {% block toolbar %}{% endblock %} + + {% block content %}{% endblock %} + + {% block modals %}{% endblock %} + + {% block scripts %} + {# Set authentication state for JS #} + + {% endblock %} + + diff --git a/api/templates/chat.j2 b/api/templates/chat.j2 index 7d3c9e31..7db6ab93 100644 --- a/api/templates/chat.j2 +++ b/api/templates/chat.j2 @@ -1,214 +1,40 @@ - - +{% extends "base.j2" %} - - - - Chatbot Interface - - - +{% block title %}Text-to-SQL Chat Interface{% endblock %} - - - - - - - - - - - - - - - {% if is_authenticated and user_info %} - - - {% endif %} -
- -
- -
- -

Text-to-SQL(Natural Language to SQL Generator)

-
- -
- - -
- -
-
-
-
-
-
- - - - -
-
-
-
-
- -
-
-

Connect to Postgres

- -
- - -
-
-
-
-
-

Reset Session

-

Are you sure you want to reset the current session? This will clear all chat history and start a new conversation.

-
- - -
+ {% include 'components/chat_header.j2' %} + +
+ + {% include 'components/chat_input.j2' %}
- {# Set authentication state for JS before loading chat.js #} - - - +
+
+{% endblock %} + +{% block modals %} + {% include 'components/login_modal.j2' %} + {% include 'components/postgres_modal.j2' %} + {% include 'components/reset_modal.j2' %} +{% endblock %} - \ No newline at end of file +{% block scripts %} + {{ super() }} + {% include 'components/scripts.j2' %} +{% endblock %} diff --git a/api/templates/chat_new.j2 b/api/templates/chat_new.j2 new file mode 100644 index 00000000..7db6ab93 --- /dev/null +++ b/api/templates/chat_new.j2 @@ -0,0 +1,40 @@ +{% extends "base.j2" %} + +{% block title %}Text-to-SQL Chat Interface{% endblock %} + +{% block toolbar %} + {% include 'components/toolbar.j2' %} +{% endblock %} + +{% block content %} +
+ {% include 'components/sidebar_menu.j2' %} + +
+ + + {% include 'components/chat_header.j2' %} + +
+
+ + {% include 'components/chat_input.j2' %} +
+
+
+{% endblock %} + +{% block modals %} + {% include 'components/login_modal.j2' %} + {% include 'components/postgres_modal.j2' %} + {% include 'components/reset_modal.j2' %} +{% endblock %} + +{% block scripts %} + {{ super() }} + {% include 'components/scripts.j2' %} +{% endblock %} diff --git a/api/templates/components/chat_header.j2 b/api/templates/components/chat_header.j2 new file mode 100644 index 00000000..5f99a603 --- /dev/null +++ b/api/templates/components/chat_header.j2 @@ -0,0 +1,19 @@ +{# Chat header with logo, title, and action buttons #} +
+ +

Text-to-SQL(Natural Language to SQL Generator)

+
+ +
+ + +
+ +
+
diff --git a/api/templates/components/chat_input.j2 b/api/templates/components/chat_input.j2 new file mode 100644 index 00000000..e2d64134 --- /dev/null +++ b/api/templates/components/chat_input.j2 @@ -0,0 +1,17 @@ +{# Chat input area with text input and action buttons #} +
+
+ + + + +
+
diff --git a/api/templates/components/login_modal.j2 b/api/templates/components/login_modal.j2 new file mode 100644 index 00000000..dc0c6926 --- /dev/null +++ b/api/templates/components/login_modal.j2 @@ -0,0 +1,13 @@ +{# Login modal for authentication #} + diff --git a/api/templates/components/menu_analytics.j2 b/api/templates/components/menu_analytics.j2 new file mode 100644 index 00000000..32202cec --- /dev/null +++ b/api/templates/components/menu_analytics.j2 @@ -0,0 +1,17 @@ +{# Analytics section in sidebar menu #} + + + + diff --git a/api/templates/components/menu_instructions.j2 b/api/templates/components/menu_instructions.j2 new file mode 100644 index 00000000..7a66c5eb --- /dev/null +++ b/api/templates/components/menu_instructions.j2 @@ -0,0 +1,6 @@ +{# Instructions section in sidebar menu #} + diff --git a/api/templates/components/postgres_modal.j2 b/api/templates/components/postgres_modal.j2 new file mode 100644 index 00000000..8fcc222b --- /dev/null +++ b/api/templates/components/postgres_modal.j2 @@ -0,0 +1,17 @@ +{# Postgres connection modal #} +
+
+

Connect to Postgres

+ +
+ + +
+
+
diff --git a/api/templates/components/reset_modal.j2 b/api/templates/components/reset_modal.j2 new file mode 100644 index 00000000..1880540e --- /dev/null +++ b/api/templates/components/reset_modal.j2 @@ -0,0 +1,11 @@ +{# Reset session confirmation modal #} +
+
+

Reset Session

+

Are you sure you want to reset the current session? This will clear all chat history and start a new conversation.

+
+ + +
+
+
diff --git a/api/templates/components/scripts.j2 b/api/templates/components/scripts.j2 new file mode 100644 index 00000000..2aad2d99 --- /dev/null +++ b/api/templates/components/scripts.j2 @@ -0,0 +1,17 @@ +{# JavaScript initialization and imports #} + + diff --git a/api/templates/components/sidebar_menu.j2 b/api/templates/components/sidebar_menu.j2 new file mode 100644 index 00000000..e7e800ce --- /dev/null +++ b/api/templates/components/sidebar_menu.j2 @@ -0,0 +1,21 @@ +{# Side menu with instructions and analytics #} + diff --git a/api/templates/components/toolbar.j2 b/api/templates/components/toolbar.j2 new file mode 100644 index 00000000..b3fea500 --- /dev/null +++ b/api/templates/components/toolbar.j2 @@ -0,0 +1,41 @@ +{# Top toolbar with theme toggle, GitHub link, and user profile #} + + + + + + + + + + + + - + + +{% if is_authenticated and user_info %} + {% include 'components/user_profile.j2' %} +{% endif %} diff --git a/api/templates/components/user_profile.j2 b/api/templates/components/user_profile.j2 new file mode 100644 index 00000000..00db33e5 --- /dev/null +++ b/api/templates/components/user_profile.j2 @@ -0,0 +1,13 @@ +{# User profile button and dropdown #} + + From 77c00ffd8d2118b30d07d1d1baabe9a5bc84d334 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Fri, 1 Aug 2025 23:16:27 +0300 Subject: [PATCH 04/16] fix light mode in warning message --- api/static/css/chat.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/static/css/chat.css b/api/static/css/chat.css index 04c2045b..abc02f39 100644 --- a/api/static/css/chat.css +++ b/api/static/css/chat.css @@ -1243,12 +1243,13 @@ button, input, select, textarea { .confirmation-text { margin-bottom: 20px; line-height: 1.6; - color: #ffffff; + color: var(--text-primary); font-size: 14px; } .confirmation-text strong { - color: #FFCDD2; + color: var(--text-primary); + font-weight: bold; } .confirmation-buttons { From c46be44544fb6c7de77b6cc6da96732bbe9c878d Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 11:36:28 +0300 Subject: [PATCH 05/16] handle security risks --- api/app_factory.py | 6 +++++- api/index.py | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/api/app_factory.py b/api/app_factory.py index b68421de..78106a08 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -22,7 +22,11 @@ def create_app(): """Create and configure the Flask application.""" app = Flask(__name__) - app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersekrit") + app.secret_key = os.getenv("FLASK_SECRET_KEY") + if not app.secret_key: + import secrets + app.secret_key = secrets.token_hex(32) + logging.warning("FLASK_SECRET_KEY not set, using generated key. Set this in production!") # Google OAuth setup GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") diff --git a/api/index.py b/api/index.py index 18aff240..4d7505ca 100644 --- a/api/index.py +++ b/api/index.py @@ -5,4 +5,9 @@ app = create_app() if __name__ == "__main__": - app.run(debug=True) + import os + debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' + app.run(debug=debug_mode) +# This allows running the app with `flask run` or directly with `python api/index.py` +# Ensure the environment variable FLASK_DEBUG is set to 'True' for debug mode +# or 'False' for production mode. From 09ba7d85b7ad8d854e0ce14ddff285c71ca46608 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 11:41:28 +0300 Subject: [PATCH 06/16] don't return internal info on error --- api/loaders/postgres_loader.py | 9 ++++++--- api/routes/graphs.py | 13 +++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/api/loaders/postgres_loader.py b/api/loaders/postgres_loader.py index 4166b446..cb9c2d96 100644 --- a/api/loaders/postgres_loader.py +++ b/api/loaders/postgres_loader.py @@ -402,11 +402,14 @@ def refresh_graph_schema(graph_id: str, db_url: str) -> Tuple[bool, str]: if success: logging.info("Graph schema refreshed successfully.") return True, message - else: - return False, f"Failed to reload schema: {message}" + + logging.error("Schema refresh failed for graph %s: %s", graph_id, message) + return False, "Failed to reload schema" except Exception as e: - error_msg = f"Error refreshing graph schema: {str(e)}" + # Log the error and return failure + logging.error("Error refreshing graph schema: %s", str(e)) + error_msg = "Error refreshing graph schema" logging.error(error_msg) return False, error_msg diff --git a/api/routes/graphs.py b/api/routes/graphs.py index 43ca8701..7c15b9e7 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -477,15 +477,16 @@ def refresh_graph_schema(graph_id: str): "success": True, "message": f"Graph schema refreshed successfully. {message}" }), 200 - else: - return jsonify({ - "success": False, - "error": f"Failed to refresh schema: {message}" - }), 500 + + logging.error("Schema refresh failed for graph %s: %s", graph_id, message) + return jsonify({ + "success": False, + "error": "Failed to refresh schema" + }), 500 except Exception as e: logging.error("Error in manual schema refresh: %s", e) return jsonify({ "success": False, - "error": f"Error refreshing schema: {str(e)}" + "error": "Error refreshing schema" }), 500 From b21f8786563b51c864314c243e7c361119bfa328 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 11:47:39 +0300 Subject: [PATCH 07/16] don't return internal info on error --- api/routes/database.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/api/routes/database.py b/api/routes/database.py index 6e550059..06869085 100644 --- a/api/routes/database.py +++ b/api/routes/database.py @@ -1,5 +1,5 @@ """Database connection routes for the text2sql API.""" - +import logging from flask import Blueprint, jsonify, request, g from api.auth.user_management import token_required @@ -19,11 +19,11 @@ def connect_database(): url = data.get("url") if data else None if not url: return jsonify({"success": False, "error": "No URL provided"}), 400 - + # Validate URL format if not isinstance(url, str) or len(url.strip()) == 0: return jsonify({"success": False, "error": "Invalid URL format"}), 400 - + try: # Check for Postgres URL if url.startswith("postgres://") or url.startswith("postgresql://"): @@ -33,10 +33,13 @@ def connect_database(): if success: return jsonify({"success": True, "message": result}), 200 + # result from PostgresLoader should be safe to return return jsonify({"success": False, "error": result}), 400 except (ValueError, ConnectionError) as e: - return jsonify({"success": False, "error": str(e)}), 500 + logging.error("Database connection error: %s", str(e)) + return jsonify({"success": False, "error": "Failed to connect to database"}), 500 return jsonify({"success": False, "error": "Invalid Postgres URL"}), 400 except (ValueError, TypeError) as e: - return jsonify({"success": False, "error": str(e)}), 500 + logging.error("Unexpected error in database connection: %s", str(e)) + return jsonify({"success": False, "error": "Internal server error"}), 500 From 9e9cc863bdbef7c0d66cb004e8d94826c21eb498 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 11:55:28 +0300 Subject: [PATCH 08/16] sanitized_query before logging --- api/routes/graphs.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/api/routes/graphs.py b/api/routes/graphs.py index 7c15b9e7..af327f05 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -21,6 +21,9 @@ graphs_bp = Blueprint("graphs", __name__, url_prefix="/graphs") +def sanitize_query(query: str) -> str: + """Sanitize the query to prevent injection attacks.""" + return query.replace('\n', ' ').replace('\r', ' ')[:500] @graphs_bp.route("") @token_required @@ -122,29 +125,29 @@ def query_graph(graph_id: str): # Input validation if not graph_id or not isinstance(graph_id, str): return jsonify({"error": "Invalid graph_id"}), 400 - + # Sanitize graph_id to prevent injection graph_id = graph_id.strip()[:100] # Limit length and strip whitespace if not graph_id: return jsonify({"error": "Invalid graph_id"}), 400 - + graph_id = g.user_id + "_" + graph_id request_data = request.get_json() - + if not request_data: return jsonify({"error": "No JSON data provided"}), 400 - + queries_history = request_data.get("chat") result_history = request_data.get("result") instructions = request_data.get("instructions") - + if not queries_history or not isinstance(queries_history, list): return jsonify({"error": "Invalid or missing chat history"}), 400 - + if len(queries_history) == 0: return jsonify({"error": "Empty chat history"}), 400 - logging.info("User Query: %s", queries_history[-1]) + logging.info("User Query: %s", sanitize_query(queries_history[-1])) # Create a generator function for streaming def generate(): @@ -158,7 +161,8 @@ def generate(): db_description, db_url = get_db_description(graph_id) logging.info("Calling to relevancy agent with query: %s", - queries_history[-1]) + sanitize_query(queries_history[-1])) + answer_rel = agent_rel.get_answer(queries_history[-1], db_description) if answer_rel["status"] != "On-topic": step = { @@ -189,7 +193,9 @@ def generate(): ) + MESSAGE_DELIMITER return - logging.info("Calling to analysis agent with query: %s", queries_history[-1]) + logging.info("Calling to analysis agent with query: %s", + sanitize_query(queries_history[-1])) + answer_an = agent_an.get_analysis( queries_history[-1], result, db_description, instructions ) From 7591ce157e606bd63121da82ad4612e79cdcde45 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 12:13:38 +0300 Subject: [PATCH 09/16] fix CodeQL errors --- api/routes/database.py | 8 +++++--- api/routes/graphs.py | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/api/routes/database.py b/api/routes/database.py index 06869085..38bc71e6 100644 --- a/api/routes/database.py +++ b/api/routes/database.py @@ -31,10 +31,12 @@ def connect_database(): # Attempt to connect/load using the loader success, result = PostgresLoader.load(g.user_id, url) if success: - return jsonify({"success": True, "message": result}), 200 + return jsonify({"success": True, + "message": "Database connected successfully"}), 200 - # result from PostgresLoader should be safe to return - return jsonify({"success": False, "error": result}), 400 + # Don't return detailed error messages to prevent information exposure + logging.error("PostgresLoader failed: %s", result) + return jsonify({"success": False, "error": "Failed to load database schema"}), 400 except (ValueError, ConnectionError) as e: logging.error("Database connection error: %s", str(e)) return jsonify({"success": False, "error": "Failed to connect to database"}), 500 diff --git a/api/routes/graphs.py b/api/routes/graphs.py index af327f05..b10ed269 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -111,9 +111,11 @@ def load_graph(): # ✅ Return the final response if success: - return jsonify({"message": result, "graph_id": graph_id}) + return jsonify({"message": "Graph loaded successfully", "graph_id": graph_id}) - return jsonify({"error": result}), 400 + # Log detailed error but return generic message to user + logging.error("Graph loading failed: %s", str(result)[:100]) + return jsonify({"error": "Failed to load graph data"}), 400 @graphs_bp.route("/", methods=["POST"]) @@ -481,7 +483,7 @@ def refresh_graph_schema(graph_id: str): if success: return jsonify({ "success": True, - "message": f"Graph schema refreshed successfully. {message}" + "message": "Graph schema refreshed successfully" }), 200 logging.error("Schema refresh failed for graph %s: %s", graph_id, message) From 7f081775a8897f656fdd436c33f8e5ab8fe3292d Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 12:16:27 +0300 Subject: [PATCH 10/16] set noopener --- api/templates/components/sidebar_menu.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/templates/components/sidebar_menu.j2 b/api/templates/components/sidebar_menu.j2 index e7e800ce..b07d221a 100644 --- a/api/templates/components/sidebar_menu.j2 +++ b/api/templates/components/sidebar_menu.j2 @@ -16,6 +16,6 @@ From cfc80be38daa70ad28e9fb75b6de0c7873a31f7a Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 12:36:31 +0300 Subject: [PATCH 11/16] Don't fetch graphs if not authenticated --- api/static/js/chat.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/static/js/chat.js b/api/static/js/chat.js index cda533e0..089e68f3 100644 --- a/api/static/js/chat.js +++ b/api/static/js/chat.js @@ -608,7 +608,13 @@ document.addEventListener("DOMContentLoaded", function () { const chatMessages = document.getElementById("chat-messages"); const graphSelect = document.getElementById("graph-select"); - // Fetch available graphs + // Only fetch available graphs if user is authenticated + const isAuthenticated = window.isAuthenticated !== undefined ? window.isAuthenticated : false; + if (!isAuthenticated) { + return; // Don't fetch graphs if not authenticated + } + + // Fetch available graphs (only if authenticated) fetch("/graphs") .then(response => { if (!response.ok) { From b1c9845858c29854b13df5aa666deb7e905f7b13 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 12:45:52 +0300 Subject: [PATCH 12/16] break js files to modules --- api/static/js/app.js | 88 ++++++++ api/static/js/modules/chat.js | 302 ++++++++++++++++++++++++++++ api/static/js/modules/config.js | 66 ++++++ api/static/js/modules/graphs.js | 117 +++++++++++ api/static/js/modules/messages.js | 143 +++++++++++++ api/static/js/modules/modals.js | 116 +++++++++++ api/static/js/modules/ui.js | 150 ++++++++++++++ api/templates/components/scripts.j2 | 2 +- 8 files changed, 983 insertions(+), 1 deletion(-) create mode 100644 api/static/js/app.js create mode 100644 api/static/js/modules/chat.js create mode 100644 api/static/js/modules/config.js create mode 100644 api/static/js/modules/graphs.js create mode 100644 api/static/js/modules/messages.js create mode 100644 api/static/js/modules/modals.js create mode 100644 api/static/js/modules/ui.js diff --git a/api/static/js/app.js b/api/static/js/app.js new file mode 100644 index 00000000..c4ca7df0 --- /dev/null +++ b/api/static/js/app.js @@ -0,0 +1,88 @@ +/** + * Main application entry point - coordinates all modules + */ + +import { DOM } from './modules/config.js'; +import { initChat } from './modules/messages.js'; +import { sendMessage, pauseRequest } from './modules/chat.js'; +import { loadGraphs, handleFileUpload, onGraphChange } from './modules/graphs.js'; +import { + toggleMenu, + showResetConfirmation, + hideResetConfirmation, + handleResetConfirmation, + setupUserProfileDropdown, + setupThemeToggle, + handleWindowResize +} from './modules/ui.js'; +import { setupAuthenticationModal, setupPostgresModal } from './modules/modals.js'; + +// Initialize the application +function initializeApp() { + // Initialize chat + initChat(); + + // Set up event listeners + setupEventListeners(); + + // Set up UI components + setupUIComponents(); + + // Load initial data + loadInitialData(); +} + +function setupEventListeners() { + // Chat functionality + DOM.submitButton.addEventListener('click', sendMessage); + DOM.pauseButton.addEventListener('click', pauseRequest); + DOM.messageInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + sendMessage(); + } + }); + + // Menu functionality + DOM.menuButton.addEventListener('click', toggleMenu); + DOM.sideMenuButton.addEventListener('click', toggleMenu); + + // Reset functionality + DOM.newChatButton.addEventListener('click', showResetConfirmation); + DOM.resetConfirmBtn.addEventListener('click', handleResetConfirmation); + DOM.resetCancelBtn.addEventListener('click', hideResetConfirmation); + + // Modal interactions + DOM.resetConfirmationModal.addEventListener('click', (e) => { + if (e.target === DOM.resetConfirmationModal) { + hideResetConfirmation(); + } + }); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && DOM.resetConfirmationModal.style.display === 'flex') { + hideResetConfirmation(); + } + }); + + // Graph management + DOM.graphSelect.addEventListener("change", onGraphChange); + DOM.fileUpload.addEventListener('change', handleFileUpload); + + // Window resize handling + window.addEventListener('resize', handleWindowResize); +} + +function setupUIComponents() { + setupUserProfileDropdown(); + setupThemeToggle(); + setupAuthenticationModal(); + setupPostgresModal(); +} + +function loadInitialData() { + loadGraphs(); +} + +// Initialize when DOM is ready +document.addEventListener("DOMContentLoaded", initializeApp); diff --git a/api/static/js/modules/chat.js b/api/static/js/modules/chat.js new file mode 100644 index 00000000..02e2a374 --- /dev/null +++ b/api/static/js/modules/chat.js @@ -0,0 +1,302 @@ +/** + * Chat API and messaging functionality + */ + +import { DOM, state, MESSAGE_DELIMITER } from './config.js'; +import { addMessage, removeLoadingMessage, moveLoadingMessageToBottom } from './messages.js'; + +export async function sendMessage() { + const message = DOM.messageInput.value.trim(); + if (!message) return; + + // Check if a graph is selected + const selectedValue = DOM.graphSelect.value; + if (!selectedValue) { + addMessage("Please select a graph from the dropdown before sending a message.", false, true); + return; + } + + // Cancel any ongoing request + if (state.currentRequestController) { + state.currentRequestController.abort(); + } + + // Add user message to chat + addMessage(message, true, false, false, false, window.currentUser); + DOM.messageInput.value = ''; + + // Show typing indicator + DOM.inputContainer.classList.add('loading'); + DOM.submitButton.style.display = 'none'; + DOM.pauseButton.style.display = 'block'; + DOM.newChatButton.disabled = true; + addMessage("", false, false, false, true); + + [DOM.confValue, DOM.expValue, DOM.missValue, DOM.ambValue].forEach((element) => { + element.innerHTML = ''; + }); + + try { + // Create an AbortController for this request + state.currentRequestController = new AbortController(); + + // Use fetch with streaming response (POST method) + const response = await fetch('/graphs/' + selectedValue + '?q=' + encodeURIComponent(message), { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + chat: state.questions_history, + result: state.result_history, + instructions: DOM.expInstructions.value, + }), + signal: state.currentRequestController.signal + }); + + // Check if the response is ok + if (!response.ok) { + throw new Error(`Server responded with ${response.status}`); + } + + // Process the streaming response + await processStreamingResponse(response); + state.currentRequestController = null; + + } catch (error) { + if (error.name === 'AbortError') { + console.log('Request was aborted'); + } else { + console.error('Error:', error); + resetUIState(); + addMessage('Sorry, there was an error processing your message: ' + error.message, false); + } + state.currentRequestController = null; + } +} + +async function processStreamingResponse(response) { + const reader = response.body.getReader(); + let decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + // Process any remaining data in the buffer + if (buffer.trim()) { + try { + const step = JSON.parse(buffer); + addMessage(step.message || JSON.stringify(step), false); + } catch (e) { + addMessage(buffer, false); + } + } + break; + } + + // Decode the chunk and add it to our buffer + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + + // Process complete message objects from the buffer using custom delimiter + let delimiterIndex; + while ((delimiterIndex = buffer.indexOf(MESSAGE_DELIMITER)) !== -1) { + const message = buffer.slice(0, delimiterIndex).trim(); + buffer = buffer.slice(delimiterIndex + MESSAGE_DELIMITER.length); + + if (!message) continue; + + try { + const step = JSON.parse(message); + handleStreamMessage(step); + } catch (e) { + addMessage("Failed: " + message, false); + } + } + } +} + +function handleStreamMessage(step) { + if (step.type === 'reasoning_step') { + addMessage(step.message, false); + moveLoadingMessageToBottom(); + } else if (step.type === 'final_result') { + handleFinalResult(step); + } else if (step.type === 'followup_questions') { + handleFollowupQuestions(step); + } else if (step.type === 'query_result') { + handleQueryResult(step); + } else if (step.type === 'ai_response') { + addMessage(step.message, false, false, true); + } else if (step.type === 'destructive_confirmation') { + addDestructiveConfirmationMessage(step); + } else if (step.type === 'operation_cancelled') { + addMessage(step.message, false, true); + } else { + addMessage(step.message || JSON.stringify(step), false); + } + + if (step.type !== 'reasoning_step') { + resetUIState(); + } +} + +function handleFinalResult(step) { + DOM.confValue.textContent = `${step.conf}%`; + + [[step.exp, DOM.expValue], [step.miss, DOM.missValue], [step.amb, DOM.ambValue]].forEach(([value, element]) => { + element.innerHTML = ''; + let ul = document.getElementById(`${element.id}-list`); + + ul = document.createElement("ul"); + ul.className = `final-result-list`; + ul.id = `${element.id}-list`; + element.appendChild(ul); + + value.split('-').forEach((item, i) => { + if (item === '') return; + + let li = document.getElementById(`${element.id}-${i}-li`); + + li = document.createElement("li"); + li.id = `${element.id}-${i}-li`; + ul.appendChild(li); + + li.textContent = i === 0 ? `${item}` : `- ${item}`; + }); + }); + + let message = step.message || JSON.stringify(step.data, null, 2); + if (step.is_valid) { + addMessage(message, false, false, true); + } else { + addMessage("Sorry, we couldn't generate a valid SQL query. Please try rephrasing your question or add more details. For help, check the explanation window.", false, true); + } +} + +function handleFollowupQuestions(step) { + DOM.expValue.textContent = "N/A"; + DOM.confValue.textContent = "N/A"; + DOM.missValue.textContent = "N/A"; + DOM.ambValue.textContent = "N/A"; + addMessage(step.message, false, true); +} + +function handleQueryResult(step) { + if (step.data) { + addMessage(`Query Result: ${JSON.stringify(step.data)}`, false, false, true); + } else { + addMessage("No results found for the query.", false); + } +} + +function resetUIState() { + DOM.inputContainer.classList.remove('loading'); + DOM.submitButton.style.display = 'block'; + DOM.pauseButton.style.display = 'none'; + DOM.newChatButton.disabled = false; + removeLoadingMessage(); +} + +export function pauseRequest() { + if (state.currentRequestController) { + state.currentRequestController.abort(); + state.currentRequestController = null; + + resetUIState(); + addMessage("Request was paused by user.", false, true); + } +} + +export function addDestructiveConfirmationMessage(step) { + const messageDiv = document.createElement('div'); + const messageDivContainer = document.createElement('div'); + + messageDivContainer.className = "message-container bot-message-container destructive-confirmation-container"; + messageDiv.className = "message bot-message destructive-confirmation-message"; + + // Generate a unique ID for this confirmation dialog + const confirmationId = 'confirmation-' + Date.now(); + + // Create the confirmation UI + const confirmationHTML = ` +
+
${step.message.replace(/\n/g, '
')}
+
+ + +
+
+ `; + + messageDiv.innerHTML = confirmationHTML; + + messageDivContainer.appendChild(messageDiv); + DOM.chatMessages.appendChild(messageDivContainer); + DOM.chatMessages.scrollTop = DOM.chatMessages.scrollHeight; + + // Disable the main input while waiting for confirmation + DOM.messageInput.disabled = true; + DOM.submitButton.disabled = true; +} + +export async function handleDestructiveConfirmation(confirmation, sqlQuery, confirmationId) { + // Find the specific confirmation dialog using the unique ID + const confirmationDialog = document.querySelector(`[data-confirmation-id="${confirmationId}"]`); + if (confirmationDialog) { + // Disable both confirmation buttons within this specific dialog + const confirmBtn = confirmationDialog.querySelector('.confirm-btn'); + const cancelBtn = confirmationDialog.querySelector('.cancel-btn'); + if (confirmBtn) confirmBtn.disabled = true; + if (cancelBtn) cancelBtn.disabled = true; + } + + // Re-enable the input + DOM.messageInput.disabled = false; + DOM.submitButton.disabled = false; + + // Add user's choice as a message + addMessage(`User choice: ${confirmation}`, true, false, false, false, window.currentUser); + + if (confirmation === 'CANCEL') { + addMessage("Operation cancelled. The destructive SQL query was not executed.", false, true); + return; + } + + // If confirmed, send confirmation to server + try { + const selectedValue = DOM.graphSelect.value; + + const response = await fetch('/graphs/' + selectedValue + '/confirm', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + confirmation: confirmation, + sql_query: sqlQuery, + chat: state.questions_history + }) + }); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}`); + } + + // Process the streaming response + await processStreamingResponse(response); + + } catch (error) { + console.error('Error:', error); + addMessage('Sorry, there was an error processing the confirmation: ' + error.message, false); + } +} + +// Make functions globally available for onclick handlers +window.handleDestructiveConfirmation = handleDestructiveConfirmation; diff --git a/api/static/js/modules/config.js b/api/static/js/modules/config.js new file mode 100644 index 00000000..ab53f7e7 --- /dev/null +++ b/api/static/js/modules/config.js @@ -0,0 +1,66 @@ +/** + * Constants and configuration for the chat application + */ + +// Custom delimiter that's unlikely to appear in your data +export const MESSAGE_DELIMITER = '|||FALKORDB_MESSAGE_BOUNDARY|||'; + +// DOM element selectors +export const SELECTORS = { + messageInput: '#message-input', + submitButton: '#submit-button', + pauseButton: '#pause-button', + newChatButton: '#reset-button', + chatMessages: '#chat-messages', + expValue: '#exp-value', + confValue: '#conf-value', + missValue: '#info-value', + ambValue: '#amb-value', + fileUpload: '#schema-upload', + fileLabel: '#custom-file-upload', + sideMenuButton: '#side-menu-button', + menuButton: '#menu-button', + menuContainer: '#menu-container', + chatContainer: '#chat-container', + expInstructions: '#instructions-textarea', + inputContainer: '#input-container', + graphSelect: '#graph-select', + resetConfirmationModal: '#reset-confirmation-modal', + resetConfirmBtn: '#reset-confirm-btn', + resetCancelBtn: '#reset-cancel-btn' +}; + +// Get DOM elements +export const DOM = { + messageInput: document.getElementById('message-input'), + submitButton: document.getElementById('submit-button'), + pauseButton: document.getElementById('pause-button'), + newChatButton: document.getElementById('reset-button'), + chatMessages: document.getElementById('chat-messages'), + expValue: document.getElementById('exp-value'), + confValue: document.getElementById('conf-value'), + missValue: document.getElementById('info-value'), + ambValue: document.getElementById('amb-value'), + fileUpload: document.getElementById('schema-upload'), + fileLabel: document.getElementById('custom-file-upload'), + sideMenuButton: document.getElementById('side-menu-button'), + menuButton: document.getElementById('menu-button'), + menuContainer: document.getElementById('menu-container'), + chatContainer: document.getElementById('chat-container'), + expInstructions: document.getElementById('instructions-textarea'), + inputContainer: document.getElementById('input-container'), + graphSelect: document.getElementById('graph-select'), + resetConfirmationModal: document.getElementById('reset-confirmation-modal'), + resetConfirmBtn: document.getElementById('reset-confirm-btn'), + resetCancelBtn: document.getElementById('reset-cancel-btn') +}; + +// Application state +export const state = { + questions_history: [], + result_history: [], + currentRequestController: null +}; + +// URL parameters +export const urlParams = new URLSearchParams(window.location.search); diff --git a/api/static/js/modules/graphs.js b/api/static/js/modules/graphs.js new file mode 100644 index 00000000..d26ee305 --- /dev/null +++ b/api/static/js/modules/graphs.js @@ -0,0 +1,117 @@ +/** + * Graph loading and management functionality + */ + +import { DOM } from './config.js'; +import { addMessage, initChat } from './messages.js'; + +export function loadGraphs() { + // Only fetch available graphs if user is authenticated + const isAuthenticated = window.isAuthenticated !== undefined ? window.isAuthenticated : false; + + if (!isAuthenticated) { + // User not authenticated - set appropriate placeholder + DOM.graphSelect.innerHTML = ""; + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Please log in to access graphs"; + option.disabled = true; + DOM.graphSelect.appendChild(option); + + DOM.messageInput.disabled = true; + DOM.submitButton.disabled = true; + DOM.messageInput.placeholder = "Please log in to start chatting"; + return; + } + + // Fetch available graphs (only if authenticated) + fetch("/graphs") + .then(response => { + if (!response.ok) { + if (response.status === 401) { + throw new Error("Authentication required. Please log in to access graphs."); + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then(data => { + DOM.graphSelect.innerHTML = ""; + + if (!data || data.length === 0) { + // No graphs available + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No graphs available"; + option.disabled = true; + DOM.graphSelect.appendChild(option); + + // Disable chat input when no graphs are available + DOM.messageInput.disabled = true; + DOM.submitButton.disabled = true; + DOM.messageInput.placeholder = "Please upload a schema or connect a database to start chatting"; + + addMessage("No graphs are available. Please upload a schema file or connect to a database to get started.", false); + return; + } + + data.forEach(graph => { + const option = document.createElement("option"); + option.value = graph; + option.textContent = graph; + option.title = graph; + DOM.graphSelect.appendChild(option); + }); + + // Re-enable chat input when graphs are available + DOM.messageInput.disabled = false; + DOM.submitButton.disabled = false; + DOM.messageInput.placeholder = "Describe the SQL query you want..."; + }) + .catch(error => { + console.error("Error fetching graphs:", error); + + // Show appropriate error message and disable input + if (error.message.includes("Authentication required")) { + addMessage("Authentication required. Please log in to access your graphs.", false); + // Don't disable input for auth errors as user needs to log in + } else { + addMessage("Sorry, there was an error fetching the available graphs: " + error.message, false); + DOM.messageInput.disabled = true; + DOM.submitButton.disabled = true; + DOM.messageInput.placeholder = "Cannot connect to server"; + } + + // Add a placeholder option to show the error state + DOM.graphSelect.innerHTML = ""; + const option = document.createElement("option"); + option.value = ""; + option.textContent = error.message.includes("Authentication") ? "Please log in" : "Error loading graphs"; + option.disabled = true; + DOM.graphSelect.appendChild(option); + }); +} + +export function handleFileUpload(event) { + const file = event.target.files[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + fetch("/graphs", { + method: 'POST', + body: formData, + }).then(response => { + response.json() + }).then(data => { + console.log('File uploaded successfully', data); + }).catch(error => { + console.error('Error uploading file:', error); + addMessage('Sorry, there was an error uploading your file: ' + error.message, false); + }); +} + +export function onGraphChange() { + initChat(); +} diff --git a/api/static/js/modules/messages.js b/api/static/js/modules/messages.js new file mode 100644 index 00000000..b947c303 --- /dev/null +++ b/api/static/js/modules/messages.js @@ -0,0 +1,143 @@ +/** + * Message handling and UI functions + */ + +import { DOM, state } from './config.js'; + +export function addMessage(message, isUser = false, isFollowup = false, isFinalResult = false, isLoading = false, userInfo = null) { + const messageDiv = document.createElement('div'); + const messageDivContainer = document.createElement('div'); + + messageDiv.className = "message"; + messageDivContainer.className = "message-container"; + + let userAvatar = null; + + if (isFollowup) { + messageDivContainer.className += " followup-message-container"; + messageDiv.className += " followup-message"; + messageDiv.textContent = message; + } else if (isUser) { + messageDivContainer.className += " user-message-container"; + messageDiv.className += " user-message"; + + // Prepare user avatar if userInfo is provided + if (userInfo && userInfo.picture) { + userAvatar = document.createElement('img'); + userAvatar.src = userInfo.picture; + userAvatar.alt = userInfo.name?.charAt(0).toUpperCase() || 'User'; + userAvatar.className = 'user-message-avatar'; + messageDivContainer.classList.add('has-avatar'); + } + + state.questions_history.push(message); + } else if (isFinalResult) { + state.result_history.push(message); + messageDivContainer.className += " final-result-message-container"; + messageDiv.className += " final-result-message"; + } else { + messageDivContainer.className += " bot-message-container"; + messageDiv.className += " bot-message"; + if (isLoading) { + messageDivContainer.id = "loading-message-container"; + messageDivContainer.className += " loading-message-container"; + } + } + + const block = formatBlock(message); + + if (block) { + block.forEach(lineDiv => { + messageDiv.appendChild(lineDiv); + }); + } else if (!isLoading) { + messageDiv.textContent = message; + } + + if (!isLoading) { + messageDivContainer.appendChild(messageDiv); + if (userAvatar) { + messageDivContainer.appendChild(userAvatar); + } + } + + DOM.chatMessages.appendChild(messageDivContainer); + DOM.chatMessages.scrollTop = DOM.chatMessages.scrollHeight; + + return messageDiv; +} + +export function removeLoadingMessage() { + const loadingMessageContainer = document.getElementById("loading-message-container"); + if (loadingMessageContainer) { + loadingMessageContainer.remove(); + } +} + +export function moveLoadingMessageToBottom() { + const loadingMessageContainer = document.getElementById("loading-message-container"); + if (loadingMessageContainer) { + // Remove from current position and append to bottom + loadingMessageContainer.remove(); + DOM.chatMessages.appendChild(loadingMessageContainer); + DOM.chatMessages.scrollTop = DOM.chatMessages.scrollHeight; + } +} + +export function formatBlock(text) { + // Remove surrounding quotes if present + text = text.replace(/^"(.*)"$/, '$1').trim(); + + // SQL block + if (text.startsWith('```sql') && text.endsWith('```')) { + const sql = text.slice(6, -3).trim(); + return sql.split('\n').map((line, i) => { + const lineDiv = document.createElement('div'); + lineDiv.className = 'sql-line'; + lineDiv.textContent = line; + return lineDiv; + }); + } + + // Array block + if (text.includes('[') && text.includes(']')) { + const parts = text.split('['); + const formattedParts = parts.map((part, i) => { + const lineDiv = document.createElement('div'); + lineDiv.className = 'array-line'; + part = part.replaceAll(']', ''); + lineDiv.textContent = part; + return lineDiv; + }); + return formattedParts; + } + + // Generic multi-line block (replace \n with real newlines first) + text = text.replace(/\\n/g, '\n'); + if (text.includes('\n')) { + return text.split('\n').map((line, i) => { + const lineDiv = document.createElement('div'); + lineDiv.className = 'plain-line'; + lineDiv.textContent = line; + return lineDiv; + }); + } +} + +export function initChat() { + DOM.messageInput.value = ''; + DOM.chatMessages.innerHTML = ''; + [DOM.confValue, DOM.expValue, DOM.missValue].forEach((element) => { + element.innerHTML = ''; + }); + + // Check if we have graphs available + if (DOM.graphSelect && DOM.graphSelect.options.length > 0 && DOM.graphSelect.options[0].value) { + addMessage('Hello! How can I help you today?', false); + } else { + addMessage('Hello! Please select a graph from the dropdown above or upload a schema to get started.', false); + } + + state.questions_history = []; + state.result_history = []; +} diff --git a/api/static/js/modules/modals.js b/api/static/js/modules/modals.js new file mode 100644 index 00000000..d3257689 --- /dev/null +++ b/api/static/js/modules/modals.js @@ -0,0 +1,116 @@ +/** + * Modal dialogs and authentication + */ + +export function setupAuthenticationModal() { + var isAuthenticated = window.isAuthenticated !== undefined ? window.isAuthenticated : false; + var googleLoginModal = document.getElementById('google-login-modal'); + var container = document.getElementById('container'); + + if (googleLoginModal && container) { + if (!isAuthenticated) { + googleLoginModal.style.display = 'flex'; + container.style.filter = 'blur(2px)'; + } else { + googleLoginModal.style.display = 'none'; + container.style.filter = ''; + } + } +} + +export function setupPostgresModal() { + var pgModal = document.getElementById('pg-modal'); + var openPgModalBtn = document.getElementById('open-pg-modal'); + var cancelPgModalBtn = document.getElementById('pg-modal-cancel'); + var connectPgModalBtn = document.getElementById('pg-modal-connect'); + var pgUrlInput = document.getElementById('pg-url-input'); + + if (openPgModalBtn && pgModal) { + openPgModalBtn.addEventListener('click', function() { + pgModal.style.display = 'flex'; + // Focus the input field when modal opens + if (pgUrlInput) { + setTimeout(() => { + pgUrlInput.focus(); + }, 100); + } + }); + } + + if (cancelPgModalBtn && pgModal) { + cancelPgModalBtn.addEventListener('click', function() { + pgModal.style.display = 'none'; + }); + } + + // Allow closing Postgres modal with Escape key + document.addEventListener('keydown', function(e) { + if (pgModal && pgModal.style.display === 'flex' && e.key === 'Escape') { + pgModal.style.display = 'none'; + } + }); + + // Handle Connect button for Postgres modal + if (connectPgModalBtn && pgUrlInput && pgModal) { + // Add Enter key support for the input field + pgUrlInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + connectPgModalBtn.click(); + } + }); + + connectPgModalBtn.addEventListener('click', function() { + const pgUrl = pgUrlInput.value.trim(); + if (!pgUrl) { + alert('Please enter a Postgres URL.'); + return; + } + + // Show loading state + const connectText = connectPgModalBtn.querySelector('.pg-modal-connect-text'); + const loadingSpinner = connectPgModalBtn.querySelector('.pg-modal-loading-spinner'); + const cancelBtn = document.getElementById('pg-modal-cancel'); + + connectText.style.display = 'none'; + loadingSpinner.style.display = 'flex'; + connectPgModalBtn.disabled = true; + cancelBtn.disabled = true; + pgUrlInput.disabled = true; + + fetch('/database', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url: pgUrl }) + }) + .then(response => response.json()) + .then(data => { + // Reset loading state + connectText.style.display = 'inline'; + loadingSpinner.style.display = 'none'; + connectPgModalBtn.disabled = false; + cancelBtn.disabled = false; + pgUrlInput.disabled = false; + + if (data.success) { + pgModal.style.display = 'none'; // Close modal on success + // Refresh the graph list to show the new database + location.reload(); + } else { + alert('Failed to connect: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + // Reset loading state on error + connectText.style.display = 'inline'; + loadingSpinner.style.display = 'none'; + connectPgModalBtn.disabled = false; + cancelBtn.disabled = false; + pgUrlInput.disabled = false; + + alert('Error connecting to database: ' + error.message); + }); + }); + } +} diff --git a/api/static/js/modules/ui.js b/api/static/js/modules/ui.js new file mode 100644 index 00000000..ae9b7388 --- /dev/null +++ b/api/static/js/modules/ui.js @@ -0,0 +1,150 @@ +/** + * UI components and interactions + */ + +import { DOM } from './config.js'; + +export function toggleMenu() { + // Check if we're on mobile (768px breakpoint to match CSS) + const isMobile = window.innerWidth <= 768; + + if (!DOM.menuContainer.classList.contains('open')) { + DOM.menuContainer.classList.add('open'); + DOM.sideMenuButton.style.display = 'none'; + + // Only adjust padding on desktop, not mobile (mobile uses overlay) + if (!isMobile) { + DOM.chatContainer.style.paddingRight = '10%'; + DOM.chatContainer.style.paddingLeft = '10%'; + } + } else { + DOM.menuContainer.classList.remove('open'); + DOM.sideMenuButton.style.display = 'block'; + + // Only adjust padding on desktop, not mobile (mobile uses overlay) + if (!isMobile) { + DOM.chatContainer.style.paddingRight = '20%'; + DOM.chatContainer.style.paddingLeft = '20%'; + } + } +} + +export function showResetConfirmation() { + DOM.resetConfirmationModal.style.display = 'flex'; + // Focus the Reset Session button when modal opens + setTimeout(() => { + DOM.resetConfirmBtn.focus(); + }, 100); +} + +export function hideResetConfirmation() { + DOM.resetConfirmationModal.style.display = 'none'; +} + +export function handleResetConfirmation() { + hideResetConfirmation(); + // Import initChat dynamically to avoid circular dependency + import('./messages.js').then(({ initChat }) => { + initChat(); + }); +} + +export function setupUserProfileDropdown() { + const userProfileBtn = document.getElementById('user-profile-btn'); + const userProfileDropdown = document.getElementById('user-profile-dropdown'); + + if (userProfileBtn && userProfileDropdown) { + // Toggle dropdown when profile button is clicked + userProfileBtn.addEventListener('click', function(e) { + e.stopPropagation(); + userProfileDropdown.classList.toggle('show'); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', function(e) { + if (!userProfileBtn.contains(e.target) && !userProfileDropdown.contains(e.target)) { + userProfileDropdown.classList.remove('show'); + } + }); + + // Close dropdown with Escape key + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && userProfileDropdown.classList.contains('show')) { + userProfileDropdown.classList.remove('show'); + } + }); + + // Prevent dropdown from closing when clicking inside it + userProfileDropdown.addEventListener('click', function(e) { + e.stopPropagation(); + }); + } +} + +export function setupThemeToggle() { + const themeToggleBtn = document.getElementById('theme-toggle-btn'); + + // Get theme from localStorage or default to 'system' + const currentTheme = localStorage.getItem('theme') || 'system'; + document.documentElement.setAttribute('data-theme', currentTheme); + + if (themeToggleBtn) { + themeToggleBtn.addEventListener('click', function() { + const currentTheme = document.documentElement.getAttribute('data-theme'); + let newTheme; + + // Cycle through themes: dark -> light -> system -> dark + switch (currentTheme) { + case 'dark': + newTheme = 'light'; + break; + case 'light': + newTheme = 'system'; + break; + case 'system': + default: + newTheme = 'dark'; + break; + } + + document.documentElement.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + + // Update button title + const titles = { + 'dark': 'Switch to Light Mode', + 'light': 'Switch to System Mode', + 'system': 'Switch to Dark Mode' + }; + themeToggleBtn.title = titles[newTheme]; + }); + + // Set initial title + const titles = { + 'dark': 'Switch to Light Mode', + 'light': 'Switch to System Mode', + 'system': 'Switch to Dark Mode' + }; + themeToggleBtn.title = titles[currentTheme]; + } +} + +export function handleWindowResize() { + const isMobile = window.innerWidth <= 768; + + // If menu is open and we switch to mobile, remove any desktop padding + if (isMobile && DOM.menuContainer.classList.contains('open')) { + DOM.chatContainer.style.paddingRight = ''; + DOM.chatContainer.style.paddingLeft = ''; + } + // If menu is open and we switch to desktop, apply desktop padding + else if (!isMobile && DOM.menuContainer.classList.contains('open')) { + DOM.chatContainer.style.paddingRight = '10%'; + DOM.chatContainer.style.paddingLeft = '10%'; + } + // If menu is closed and we're on desktop, ensure default desktop padding + else if (!isMobile && !DOM.menuContainer.classList.contains('open')) { + DOM.chatContainer.style.paddingRight = '20%'; + DOM.chatContainer.style.paddingLeft = '20%'; + } +} diff --git a/api/templates/components/scripts.j2 b/api/templates/components/scripts.j2 index 2aad2d99..a9c7e273 100644 --- a/api/templates/components/scripts.j2 +++ b/api/templates/components/scripts.j2 @@ -14,4 +14,4 @@ // Keep the default '-' if fetch fails }); - + From 5ccb46cbc16953cb02c9a2b17ba7d06ac7f2a975 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 14:30:21 +0300 Subject: [PATCH 13/16] break css to modules --- api/static/css/{chat.css => main.css} | 0 api/static/css/modules/base.css | 0 api/static/css/modules/confirmations.css | 145 ++++++++++++++ api/static/css/modules/forms.css | 0 api/static/css/modules/layout.css | 0 api/static/css/modules/messages.css | 151 ++++++++++++++ api/static/css/modules/modals.css | 215 ++++++++++++++++++++ api/static/css/modules/navigation.css | 0 api/static/css/modules/toolbar.css | 238 +++++++++++++++++++++++ api/static/css/modules/utilities.css | 70 +++++++ api/static/css/modules/variables.css | 101 ++++++++++ api/templates/base.j2 | 2 +- 12 files changed, 921 insertions(+), 1 deletion(-) rename api/static/css/{chat.css => main.css} (100%) create mode 100644 api/static/css/modules/base.css create mode 100644 api/static/css/modules/confirmations.css create mode 100644 api/static/css/modules/forms.css create mode 100644 api/static/css/modules/layout.css create mode 100644 api/static/css/modules/messages.css create mode 100644 api/static/css/modules/modals.css create mode 100644 api/static/css/modules/navigation.css create mode 100644 api/static/css/modules/toolbar.css create mode 100644 api/static/css/modules/utilities.css create mode 100644 api/static/css/modules/variables.css diff --git a/api/static/css/chat.css b/api/static/css/main.css similarity index 100% rename from api/static/css/chat.css rename to api/static/css/main.css diff --git a/api/static/css/modules/base.css b/api/static/css/modules/base.css new file mode 100644 index 00000000..e69de29b diff --git a/api/static/css/modules/confirmations.css b/api/static/css/modules/confirmations.css new file mode 100644 index 00000000..a38c8291 --- /dev/null +++ b/api/static/css/modules/confirmations.css @@ -0,0 +1,145 @@ +/** + * Confirmation Dialogs and Destructive Actions + * Reset confirmation modal and destructive action styling + */ + +/* Destructive Confirmation Styles */ +.destructive-confirmation-container { + transition: all 0.3s ease; +} + +.destructive-confirmation-message { + border: 1px solid #D32F2F; + border-radius: 6px; + background: linear-gradient(135deg, var(--bg-tertiary), var(--falkor-quaternary)); +} + +.destructive-confirmation { + padding: 20px; + transition: all 0.3s ease; +} + +.confirmation-text { + margin-bottom: 20px; + line-height: 1.6; + color: var(--text-primary); + font-size: 14px; +} + +.confirmation-text strong { + color: var(--text-primary); + font-weight: bold; +} + +.confirmation-buttons { + display: flex; + gap: 15px; + justify-content: center; + margin-top: 20px; +} + +.confirm-btn, .cancel-btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + font-family: 'Fira Code', monospace; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + min-width: 140px; +} + +.confirm-btn { + background: #D32F2F; + color: white; + border: 1px solid #D32F2F; +} + +.confirm-btn:hover { + background: #B71C1C; + border-color: #B71C1C; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(211, 47, 47, 0.3); +} + +.cancel-btn { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.cancel-btn:hover { + background: var(--bg-tertiary); + border-color: var(--text-secondary); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.confirm-btn:active, .cancel-btn:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +.confirm-btn:disabled, .cancel-btn:disabled { + background: #cccccc; + color: #888888; + border-color: #cccccc; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.confirm-btn:disabled:hover, .cancel-btn:disabled:hover { + background: #cccccc; + color: #888888; + border-color: #cccccc; + transform: none; + box-shadow: none; +} + +/* Reset Confirmation Modal */ +.reset-confirmation-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0,0,0,0.6); + z-index: 2000; + align-items: center; + justify-content: center; +} + +.reset-confirmation-modal-content { + background: var(--falkor-secondary); + padding: 2em 3em; + border-radius: 10px; + box-shadow: 0 2px 16px rgba(0,0,0,0.2); + text-align: center; + color: var(--text-primary); + min-width: 400px; +} + +/* Mobile responsive reset modal */ +@media (max-width: 768px) { + .reset-confirmation-modal-content { + padding: 1.5em 2em; + min-width: 90vw; + margin: 0 5vw; + } +} + +.reset-confirmation-modal-content h3 { + color: var(--text-primary); + margin-bottom: 0.5em; + font-size: 1.3em; +} + +.reset-confirmation-modal-content p { + color: var(--text-secondary); + margin-bottom: 1.5em; + font-size: 1em; + line-height: 1.5; +} diff --git a/api/static/css/modules/forms.css b/api/static/css/modules/forms.css new file mode 100644 index 00000000..e69de29b diff --git a/api/static/css/modules/layout.css b/api/static/css/modules/layout.css new file mode 100644 index 00000000..e69de29b diff --git a/api/static/css/modules/messages.css b/api/static/css/modules/messages.css new file mode 100644 index 00000000..46fe8431 --- /dev/null +++ b/api/static/css/modules/messages.css @@ -0,0 +1,151 @@ +/** + * Chat Messages and Message Containers + * All message styling including user, bot, and special message types + */ + +.message-container { + display: flex; + flex-direction: row; + align-items: center; +} + +.user-message-container { + justify-content: flex-end; + position: relative; +} + +/* Hide the default "User" text when avatar is present */ +.user-message-container.has-avatar::after { + display: none; +} + +/* User message avatar styling */ +.user-message-avatar { + height: 40px; + width: 40px; + border-radius: 50%; + object-fit: cover; + margin-left: 10px; + border: 2px solid var(--falkor-quaternary); + font-weight: 500; + font-size: 16px; + justify-content: center; + align-items: center; + display: flex; +} + +.bot-message-container, +.followup-message-container, +.final-result-message-container { + justify-content: flex-start; +} + +.user-message-container::after, +.bot-message-container::before, +.followup-message-container::before, +.final-result-message-container::before { + height: 32px; + width: 32px; + content: 'Bot'; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 500; + font-size: 16px; + margin-right: 10px; + border-radius: 100%; + padding: 4px; +} + +.user-message-container::after { + margin-left: 10px; + background: var(--falkor-quaternary); + content: 'User'; +} + +.bot-message-container::before { + background: color-mix(in srgb, var(--falkor-tertiary) 33%, transparent); +} + +.followup-message-container::before { + background: color-mix(in srgb, var(--falkor-tertiary) 40%, transparent); + border: 1px solid var(--falkor-tertiary); +} + +.final-result-message-container::before { + background: color-mix(in srgb, var(--accent-green) 40%, transparent); + border: 1px solid var(--accent-green); +} + +.loading-message-container::before { + content: "..."; + font-size: 32px; + line-height: 1.2; + text-align: center; + animation: typing 1.5s steps(3) infinite; + padding-bottom: 16px; + border-radius: 6px; + color: var(--text-primary); + background: color-mix(in srgb, var(--falkor-tertiary) 33%, transparent); +} + +.message { + max-width: 70%; + padding: 12px 16px; + border-radius: 6px; + margin: 5px 0; + line-height: 1.4; + color: var(--text-primary); + word-wrap: break-word; + overflow-wrap: break-word; + overflow-x: auto; +} + +/* Mobile responsive messages */ +@media (max-width: 768px) { + .message { + max-width: 85%; + padding: 10px 12px; + font-size: 14px; + } + + .chat-header h1 { + font-size: 18px; + } + + #message-input { + font-size: 16px !important; + } + + #message-input::placeholder { + font-size: 16px !important; + } +} + +/* Styles for formatted text blocks */ +.sql-line, .array-line, .plain-line { + word-wrap: break-word; + overflow-wrap: break-word; + white-space: pre-wrap; + margin: 2px 0; +} + +.bot-message { + background: color-mix(in srgb, var(--falkor-tertiary) 33%, transparent); +} + +.user-message { + background: var(--falkor-quaternary); + font-weight: 500; +} + +.followup-message { + background: color-mix(in srgb, var(--falkor-tertiary) 20%, transparent); + border-left: 3px solid var(--falkor-tertiary); +} + +.final-result-message { + background: color-mix(in srgb, var(--accent-green) 15%, transparent); + border-left: 3px solid var(--accent-green); +} diff --git a/api/static/css/modules/modals.css b/api/static/css/modules/modals.css new file mode 100644 index 00000000..b98fe7e8 --- /dev/null +++ b/api/static/css/modules/modals.css @@ -0,0 +1,215 @@ +/** + * Modal Dialogs and Popups + * Login modals, postgres modal, and other popup dialogs + */ + +.pg-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0,0,0,0.5); + z-index: 3000; + align-items: center; + justify-content: center; +} + +.pg-modal-content { + background: var(--falkor-secondary); + padding: 2em 5em; + border-radius: 10px; + box-shadow: 0 2px 16px rgba(0,0,0,0.2); + text-align: center; + min-width: 680px; +} + +/* Mobile responsive modal */ +@media (max-width: 768px) { + .pg-modal-content { + padding: 1.5em 2em; + min-width: 90vw; + margin: 0 5vw; + } +} + +.pg-modal-title { + margin-bottom: 1em; + color: var(--text-primary); +} + +.pg-modal-input { + width: 100%; + padding: 0.6em; + font-size: 1em; + border: 1px solid var(--border-color); + border-radius: 6px; + margin-bottom: 1.5em; + color: var(--text-primary); + background: var(--falkor-quaternary); +} + +.pg-modal-actions { + display: flex; + justify-content: space-between; + gap: 1em; +} + +.pg-modal-btn { + flex: 1; + padding: 0.5em 0; + border: none; + border-radius: 6px; + font-size: 1em; + font-weight: bold; + cursor: pointer; + transition: background 0.2s; +} + +.pg-modal-connect { + background: #4285F4; + color: #fff; +} + +.pg-modal-connect:hover { + background: #3367d6; +} + +.pg-modal-connect:disabled { + background: #6c8db8; + cursor: not-allowed; +} + +.pg-modal-loading-spinner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid #ffffff40; + border-top: 2px solid #ffffff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.pg-modal-cancel { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.pg-modal-cancel:hover { + background: var(--border-color); +} + +.pg-modal-cancel:disabled { + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: not-allowed; + border: 1px solid var(--text-secondary); +} + +.pg-modal-input:disabled { + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: not-allowed; +} + +.google-login-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0,0,0,0.6); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.google-login-modal-content { + background: var(--falkor-secondary); + padding: 2em 3em; + border-radius: 10px; + box-shadow: 0 2px 16px rgba(0,0,0,0.2); + text-align: center; + color: var(--text-primary); +} + +.google-login-modal-content h2 { + color: var(--text-primary); + margin-bottom: 0.5em; + font-size: 1.5em; +} + +.google-login-modal-content p { + color: var(--text-secondary); + margin-bottom: 1em; + font-size: 1em; +} + +.google-login-btn { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + margin-top: 1em; + padding: 0.7em 2em; + background: #4285F4; + color: #fff; + border-radius: 6px; + font-size: 1.1em; + text-decoration: none; + font-weight: 500; + transition: background 0.2s; +} + +.google-login-btn:hover { + background: #3367d6; +} + +.google-login-logo { + height: 20px; + margin-right: 10px; + margin-left: 0; + vertical-align: middle; + display: inline-block; +} + +.github-login-btn { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 0.7em 2em; + background: #24292f; + color: #fff; + border-radius: 6px; + font-size: 1.1em; + text-decoration: none; + font-weight: 500; + transition: background 0.2s; +} + +.github-login-btn:hover { + background: #171a1d; +} + +.github-login-logo { + height: 20px; + margin-right: 10px; + margin-left: 0; + vertical-align: middle; + display: inline-block; +} diff --git a/api/static/css/modules/navigation.css b/api/static/css/modules/navigation.css new file mode 100644 index 00000000..e69de29b diff --git a/api/static/css/modules/toolbar.css b/api/static/css/modules/toolbar.css new file mode 100644 index 00000000..6723cea7 --- /dev/null +++ b/api/static/css/modules/toolbar.css @@ -0,0 +1,238 @@ +/** + * Toolbar and Header Controls + * User profile, theme toggle, GitHub link, and other header controls + */ + +.pg-connect-btn { + margin-left: 12px; + padding: 0.4em 0.8em; + background: #f5f5f5; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 1em; + color: #333; + cursor: pointer; + transition: background 0.2s, border 0.2s; +} + +.pg-connect-btn:hover { + background: #eaeaea; + border-color: #888; +} + +.logout-btn { + position: fixed; + top: 20px; + right: 30px; + z-index: 2000; + padding: 0.5em 1.2em; + background: #e74c3c; + color: #fff; + border-radius: 6px; + text-decoration: none; + font-weight: bold; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + transition: background 0.2s; +} + +.logout-btn:hover { + background: #c0392b; +} + +/* User Profile Button Styles */ +.user-profile-btn { + position: fixed; + top: 20px; + right: 30px; + z-index: 2000; + width: 48px; + height: 48px; + border: none; + border-radius: 6px; + background: #fff; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + cursor: pointer; + transition: all 0.2s ease; + padding: 2px; +} + +.user-profile-btn:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + transform: translateY(-1px); +} + +.user-profile-img { + width: 100%; + height: 100%; + border-radius: 6px; + object-fit: cover; + font-weight: 500; + font-size: 22px; +} + +/* User Profile Dropdown */ +.user-profile-dropdown { + position: fixed; + top: 80px; + right: 30px; + z-index: 1999; + background: var(--falkor-secondary); + border: 1px solid var(--falkor-border-primary); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + min-width: 200px; + display: none; +} + +/* Mobile responsive dropdown */ +@media (max-width: 768px) { + .user-profile-dropdown { + top: 65px; + right: 15px; + min-width: 180px; + } +} + +.user-profile-dropdown.show { + display: block; +} + +.user-profile-info { + padding: 15px; + border-bottom: 1px solid var(--falkor-quaternary); +} + +.user-profile-name { + color: var(--text-primary); + font-weight: bold; + margin-bottom: 5px; +} + +.user-profile-email { + color: var(--text-secondary); + font-size: 0.9em; +} + +.user-profile-actions { + padding: 10px; +} + +.user-profile-logout { + width: 100%; + padding: 10px; + background: #D32F2F; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + transition: background 0.2s; +} + +.user-profile-logout:hover { + background: #B71C1C; +} + +/* Theme Toggle Button Styles */ +#theme-toggle-btn { + position: fixed; + top: 20px; + right: 90px; +} + +/* GitHub Link Button Styles */ +#github-link-btn { + position: fixed; + top: 20px; + right: 150px; + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + border-radius: 6px; + background: var(--falkor-quaternary); + color: var(--text-primary); + text-decoration: none; + font-size: 14px; + font-weight: 500; + border: 1px solid var(--border-color); + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + transition: all 0.2s ease; + z-index: 100; + height: 48px; + box-sizing: border-box; +} + +#github-link-btn:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + background: var(--falkor-accent); + transform: translateY(-1px); + color: var(--text-primary); +} + +.theme-icon { + width: 20px; + height: 20px; + color: var(--text-primary); + transition: all 0.3s ease; +} + +/* Mobile responsive user profile and theme toggle */ +@media (max-width: 768px) { + .user-profile-btn { + top: 15px; + right: 15px; + width: 40px; + height: 40px; + } + + #theme-toggle-btn { + top: 15px; + right: 80px; + } + + #github-link-btn { + top: 15px; + right: 135px; + padding: 6px 8px; + font-size: 12px; + height: 40px; + } + + .theme-icon { + width: 18px; + height: 18px; + } +} + +/* Theme icon states */ +[data-theme="dark"] .theme-icon .sun, +[data-theme="system"] .theme-icon .sun { + display: none; +} + +[data-theme="dark"] .theme-icon .moon { + display: block; +} + +[data-theme="dark"] .theme-icon .system, +[data-theme="light"] .theme-icon .system { + display: none; +} + +[data-theme="light"] .theme-icon .sun { + display: block; +} + +[data-theme="light"] .theme-icon .moon, +[data-theme="system"] .theme-icon .moon { + display: none; +} + +[data-theme="system"] .theme-icon .system { + display: block; +} + +[data-theme="system"] .theme-icon .sun { + display: none; +} diff --git a/api/static/css/modules/utilities.css b/api/static/css/modules/utilities.css new file mode 100644 index 00000000..94363cc2 --- /dev/null +++ b/api/static/css/modules/utilities.css @@ -0,0 +1,70 @@ +/** + * Utility Classes and Animations + * Keyframe animations, scrollbar styles, and utility classes + */ + +/* Keyframe Animations */ +@keyframes shadow-fade { + 0% { + box-shadow: 0 0 8px 1px var(--falkor-primary) + } + + 50% { + box-shadow: 0 0 12px 2px var(--falkor-primary) + } + + 100% { + box-shadow: 0 0 8px 1px var(--falkor-primary) + } +} + +@keyframes typing { + 0% { + content: "."; + } + + 50% { + content: ".."; + } + + 100% { + content: "..."; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + + 50% { + opacity: 0.8; + } +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Custom Scrollbar Styles */ +::-webkit-scrollbar { + width: 20px; + height: 20px; +} + +::-webkit-scrollbar-corner { + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: #d6dee1; + border-radius: 20px; + border: 6px solid transparent; + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #a8bbbf; +} \ No newline at end of file diff --git a/api/static/css/modules/variables.css b/api/static/css/modules/variables.css new file mode 100644 index 00000000..43c30d7b --- /dev/null +++ b/api/static/css/modules/variables.css @@ -0,0 +1,101 @@ +/** + * CSS Variables and Theme System + * Contains all CSS custom properties for theming + */ + +/* CSS Reset */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Professional color palette - muted and business-appropriate */ + --falkor-primary: #5B6BC0; + /* Muted indigo - professional primary */ + --falkor-secondary: #1A1A1A; + /* Dark charcoal - main background */ + --falkor-tertiary: #B39DDB; + /* Muted lavender - subtle tertiary */ + --falkor-quaternary: #424242; + /* Medium gray - quaternary */ + --dark-bg: #0F0F0F; + /* Deep dark for surfaces */ + --falkor-accent: #26A69A; + /* Professional teal - subdued accent */ + --falkor-border-primary: #5B6BC0; + /* Primary border color*/ + --falkor-border-secondary: #90A4AE; + /* Muted blue-gray - professional secondary border */ + --falkor-border-tertiary: #E0E0E0; + /* Light gray border */ + --text-primary: #F5F5F5; + /* Soft white text */ + --text-secondary: #B0BEC5; + /* Muted blue-gray text */ + --text-tertiary: #616161; + /* Medium gray text */ + --falkor-highlight: #4DB6AC; + /* Subtle teal highlight */ + --accent-green: #66BB6A; + /* Professional green */ + --icon-filter: invert(1); + /* Dark theme - invert icons for white appearance */ + --bg-tertiary: #2E2E2E; + /* Professional dark gray */ + --border-color: #616161; + /* Subtle border color */ +} + +/* Light theme variables */ +[data-theme="light"] { + --falkor-primary: #5B6BC0; + --falkor-secondary: #FAFAFA; + --falkor-tertiary: #B39DDB; + --falkor-quaternary: #F5F5F5; + --dark-bg: #FFFFFF; + --falkor-accent: #26A69A; + --falkor-border-primary: #5B6BC0; + --falkor-border-secondary: #90A4AE; + --falkor-border-tertiary: #424242; + --text-primary: #212121; + --text-secondary: #616161; + --text-tertiary: #9E9E9E; + --falkor-highlight: #4DB6AC; + --accent-green: #388E3C; + /* Professional dark green for light theme */ + --icon-filter: invert(0); + /* Light theme - no inversion for dark icons */ + --bg-tertiary: #F8F8F8; + /* Very light gray background */ + --border-color: #E0E0E0; + /* Light border color */ +} + +/* System theme detection */ +@media (prefers-color-scheme: light) { + [data-theme="system"] { + --falkor-primary: #5B6BC0; + --falkor-secondary: #FAFAFA; + --falkor-tertiary: #B39DDB; + --falkor-quaternary: #F5F5F5; + --dark-bg: #FFFFFF; + --falkor-accent: #26A69A; + --falkor-border-primary: #5B6BC0; + --falkor-border-secondary: #90A4AE; + --falkor-border-tertiary: #424242; + --text-primary: #212121; + --text-secondary: #616161; + --text-tertiary: #9E9E9E; + --falkor-highlight: #4DB6AC; + --accent-green: #388E3C; + /* Professional dark green for light theme */ + --icon-filter: invert(0); + /* Light theme - no inversion for dark icons */ + --bg-tertiary: #F8F8F8; + /* Very light gray background */ + --border-color: #E0E0E0; + /* Light border color */ + } +} \ No newline at end of file diff --git a/api/templates/base.j2 b/api/templates/base.j2 index 2b6575b6..decd4ffc 100644 --- a/api/templates/base.j2 +++ b/api/templates/base.j2 @@ -4,7 +4,7 @@ {% block title %}Chatbot Interface{% endblock %} - + {% block extra_css %}{% endblock %} From 890228acdddb5c9ff0ee23b9d0bc494c571c8983 Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 15:00:40 +0300 Subject: [PATCH 14/16] break CSS to modules --- api/static/css/animations.css | 49 + api/static/css/base.css | 49 + api/static/css/buttons.css | 297 ++++ .../messages.css => chat-components.css} | 119 +- api/static/css/layout.css | 46 + api/static/css/main.css | 1377 +---------------- api/static/css/menu.css | 189 +++ api/static/css/{modules => }/modals.css | 166 +- api/static/css/modules/base.css | 0 api/static/css/modules/confirmations.css | 145 -- api/static/css/modules/forms.css | 0 api/static/css/modules/layout.css | 0 api/static/css/modules/navigation.css | 0 api/static/css/modules/toolbar.css | 238 --- api/static/css/modules/utilities.css | 70 - api/static/css/responsive.css | 176 +++ api/static/css/themes.css | 37 + api/static/css/{modules => }/variables.css | 14 +- 18 files changed, 1062 insertions(+), 1910 deletions(-) create mode 100644 api/static/css/animations.css create mode 100644 api/static/css/base.css create mode 100644 api/static/css/buttons.css rename api/static/css/{modules/messages.css => chat-components.css} (64%) create mode 100644 api/static/css/layout.css create mode 100644 api/static/css/menu.css rename api/static/css/{modules => }/modals.css (56%) delete mode 100644 api/static/css/modules/base.css delete mode 100644 api/static/css/modules/confirmations.css delete mode 100644 api/static/css/modules/forms.css delete mode 100644 api/static/css/modules/layout.css delete mode 100644 api/static/css/modules/navigation.css delete mode 100644 api/static/css/modules/toolbar.css delete mode 100644 api/static/css/modules/utilities.css create mode 100644 api/static/css/responsive.css create mode 100644 api/static/css/themes.css rename api/static/css/{modules => }/variables.css (94%) diff --git a/api/static/css/animations.css b/api/static/css/animations.css new file mode 100644 index 00000000..da6771a6 --- /dev/null +++ b/api/static/css/animations.css @@ -0,0 +1,49 @@ +/* Animations and Keyframes */ + +@keyframes shadow-fade { + 0% { + box-shadow: 0 0 8px 1px var(--falkor-primary) + } + + 50% { + box-shadow: 0 0 12px 2px var(--falkor-primary) + } + + 100% { + box-shadow: 0 0 8px 1px var(--falkor-primary) + } +} + +@keyframes typing { + 0% { + content: "."; + } + + 50% { + content: ".."; + } + + 100% { + content: "..."; + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + + 50% { + opacity: 0.8; + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/api/static/css/base.css b/api/static/css/base.css new file mode 100644 index 00000000..419d9f01 --- /dev/null +++ b/api/static/css/base.css @@ -0,0 +1,49 @@ +/* CSS Reset and Base Styles */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +@font-face { + font-family: 'Fira Code'; + src: url('../public/fonts/fira_code.ttf'); +} + +body { + font-family: 'Fira Code', monospace; + background-color: var(--falkor-secondary); + height: 100dvh; + width: 100dvw; + display: flex; + flex-direction: column; + color: var(--text-primary); + overflow: hidden; +} + +/* Ensure all form elements inherit the consistent font */ +button, input, select, textarea { + font-family: inherit; +} + +/* Scrollbar Styles */ +::-webkit-scrollbar { + width: 20px; + height: 20px; +} + +::-webkit-scrollbar-corner { + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: #d6dee1; + border-radius: 20px; + border: 6px solid transparent; + background-clip: content-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #a8bbbf; +} diff --git a/api/static/css/buttons.css b/api/static/css/buttons.css new file mode 100644 index 00000000..0199376e --- /dev/null +++ b/api/static/css/buttons.css @@ -0,0 +1,297 @@ +/* Button Styles and Interactive Elements */ + +.input-button { + background: var(--falkor-quaternary); + cursor: pointer; + transition: all 0.2s ease; + border: none; + width: 48px; + height: 48px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} + +.input-button img { + filter: var(--icon-filter) brightness(0.8) saturate(0.3); + width: 100%; + height: 100%; + object-fit: contain; +} + +.input-button:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + background: var(--falkor-accent); + transform: translateY(-1px); +} + +.action-button { + z-index: 100; + width: 48px; + height: 48px; + border: none; + border-radius: 6px; + background: var(--falkor-quaternary); + color: var(--text-primary); + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + position: relative; +} + +.action-button:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + background: var(--falkor-accent); + transform: translateY(-1px); +} + +.pg-connect-btn { + margin-left: 12px; + padding: 0.4em 0.8em; + background: #f5f5f5; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 1em; + color: #333; + cursor: pointer; + transition: background 0.2s, border 0.2s; +} + +.pg-connect-btn:hover { + background: #eaeaea; + border-color: #888; +} + +.logout-btn { + position: fixed; + top: 20px; + right: 30px; + z-index: 2000; + padding: 0.5em 1.2em; + background: #e74c3c; + color: #fff; + border-radius: 6px; + text-decoration: none; + font-weight: bold; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + transition: background 0.2s; +} + +.logout-btn:hover { + background: #c0392b; +} + +/* User Profile Button Styles */ +.user-profile-btn { + position: fixed; + top: 20px; + right: 30px; + z-index: 2000; + width: 48px; + height: 48px; + border: none; + border-radius: 6px; + background: #fff; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + cursor: pointer; + transition: all 0.2s ease; + padding: 2px; +} + +.user-profile-btn:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + transform: translateY(-1px); +} + +.user-profile-img { + width: 100%; + height: 100%; + border-radius: 6px; + object-fit: cover; + font-weight: 500; + font-size: 22px; +} + +.user-profile-logout { + width: 100%; + padding: 10px; + background: #D32F2F; + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: bold; + transition: background 0.2s; +} + +.user-profile-logout:hover { + background: #B71C1C; +} + +/* Theme Toggle Button Styles */ +#theme-toggle-btn { + position: fixed; + top: 20px; + right: 90px; +} + +/* GitHub Link Button Styles */ +#github-link-btn { + position: fixed; + top: 20px; + right: 150px; + display: flex; + align-items: center; + gap: 4px; + padding: 8px 12px; + border-radius: 6px; + background: var(--falkor-quaternary); + color: var(--text-primary); + text-decoration: none; + font-size: 14px; + font-weight: 500; + border: 1px solid var(--border-color); + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + transition: all 0.2s ease; + z-index: 100; + height: 48px; + box-sizing: border-box; +} + +#github-link-btn:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + background: var(--falkor-accent); + transform: translateY(-1px); + color: var(--text-primary); +} + +.theme-icon { + width: 20px; + height: 20px; + color: var(--text-primary); + transition: all 0.3s ease; +} + +/* Authentication Buttons */ +.google-login-btn { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + margin-top: 1em; + padding: 0.7em 2em; + background: #4285F4; + color: #fff; + border-radius: 6px; + font-size: 1.1em; + text-decoration: none; + font-weight: 500; + transition: background 0.2s; +} + +.google-login-btn:hover { + background: #3367d6; +} + +.google-login-logo { + height: 20px; + margin-right: 10px; + margin-left: 0; + vertical-align: middle; + display: inline-block; +} + +.github-login-btn { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + padding: 0.7em 2em; + background: #24292f; + color: #fff; + border-radius: 6px; + font-size: 1.1em; + text-decoration: none; + font-weight: 500; + transition: background 0.2s; +} + +.github-login-btn:hover { + background: #171a1d; +} + +.github-login-logo { + height: 20px; + margin-right: 10px; + margin-left: 0; + vertical-align: middle; + display: inline-block; +} + +/* Confirmation Buttons */ +.confirm-btn, .cancel-btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + font-family: 'Fira Code', monospace; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + min-width: 140px; +} + +.confirm-btn { + background: #D32F2F; + color: white; + border: 1px solid #D32F2F; +} + +.confirm-btn:hover { + background: #B71C1C; + border-color: #B71C1C; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(211, 47, 47, 0.3); +} + +.cancel-btn { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.cancel-btn:hover { + background: var(--bg-tertiary); + border-color: var(--text-secondary); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.confirm-btn:active, .cancel-btn:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +.confirm-btn:disabled, .cancel-btn:disabled { + background: #cccccc; + color: #888888; + border-color: #cccccc; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.confirm-btn:disabled:hover, .cancel-btn:disabled:hover { + background: #cccccc; + color: #888888; + border-color: #cccccc; + transform: none; + box-shadow: none; +} diff --git a/api/static/css/modules/messages.css b/api/static/css/chat-components.css similarity index 64% rename from api/static/css/modules/messages.css rename to api/static/css/chat-components.css index 46fe8431..78c05656 100644 --- a/api/static/css/modules/messages.css +++ b/api/static/css/chat-components.css @@ -1,7 +1,32 @@ -/** - * Chat Messages and Message Containers - * All message styling including user, bot, and special message types - */ +/* Chat Components Styles */ + +.chat-header { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + background: var(--falkor-secondary); + color: var(--text-primary); + padding: 16px 20px; + text-align: center; + position: relative; +} + +.chat-header h1 { + font-weight: 600; + font-size: 22px; +} + +.chat-messages { + flex-grow: 1; + padding: 20px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 15px; + background-color: var(--falkor-secondary); +} .message-container { display: flex; @@ -102,27 +127,6 @@ overflow-x: auto; } -/* Mobile responsive messages */ -@media (max-width: 768px) { - .message { - max-width: 85%; - padding: 10px 12px; - font-size: 14px; - } - - .chat-header h1 { - font-size: 18px; - } - - #message-input { - font-size: 16px !important; - } - - #message-input::placeholder { - font-size: 16px !important; - } -} - /* Styles for formatted text blocks */ .sql-line, .array-line, .plain-line { word-wrap: break-word; @@ -149,3 +153,68 @@ background: color-mix(in srgb, var(--accent-green) 15%, transparent); border-left: 3px solid var(--accent-green); } + +.chat-input { + display: flex; + padding: 16px 20px; + background: var(--falkor-secondary); + gap: 20px; +} + +.input-container { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + flex-grow: 1; + padding: 10px; + border-radius: 6px; + box-shadow: 0 0 8px 1px var(--falkor-primary); + border: 1px solid var(--border-color); +} + +.input-container.loading { + animation: shadow-fade 1.2s infinite ease-in-out; +} + +#message-input { + color: var(--text-primary); + flex-grow: 1; + background-color: transparent; + border: none; + font-size: 18px !important; + font-weight: 500 !important; +} + +#message-input:focus { + outline: none; +} + +#message-input::placeholder { + font-size: 18px !important; + font-weight: 500 !important; + color: var(--text-primary); + opacity: 0.7; +} + +#pause-button { + display: none; +} + +#reset-button { + position: absolute; + right: -60px; + top: 50%; + transform: translateY(-50%); +} + +#reset-button svg { + width: 20px; + height: 20px; +} + +#reset-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/api/static/css/layout.css b/api/static/css/layout.css new file mode 100644 index 00000000..34db21d1 --- /dev/null +++ b/api/static/css/layout.css @@ -0,0 +1,46 @@ +/* Main Layout Components */ + +#container { + height: 98%; + width: 100%; + display: flex; + flex-direction: row; + position: relative; +} + +#gradient { + width: 100%; + height: 2%; + background: linear-gradient(to right, var(--falkor-primary), var(--falkor-accent)); +} + +.logo { + height: 40px; + width: auto; +} + +.chat-container { + flex: 1 1 0; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + z-index: 1; + padding-right: 20%; + padding-left: 20%; + padding-top: 20px; + padding-bottom: 20px; + transition: margin-left 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.vertical-separator { + width: 2px; + height: 80%; + background-color: var(--text-tertiary); +} + +.horizontal-separator { + width: 80%; + height: 2px; + background-color: var(--text-tertiary); +} diff --git a/api/static/css/main.css b/api/static/css/main.css index abc02f39..8c34c5d9 100644 --- a/api/static/css/main.css +++ b/api/static/css/main.css @@ -1,1366 +1,31 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +/* Main CSS Entry Point - Imports all modular CSS files */ -:root { - /* Professional color palette - muted and business-appropriate */ - --falkor-primary: #5B6BC0; - /* Muted indigo - professional primary */ - --falkor-secondary: #1A1A1A; - /* Dark charcoal - main background */ - --falkor-tertiary: #B39DDB; - /* Muted lavender - subtle tertiary */ - --falkor-quaternary: #424242; - /* Medium gray - quaternary */ - --dark-bg: #0F0F0F; - /* Deep dark for surfaces */ - --falkor-accent: #26A69A; - /* Professional teal - subdued accent */ - --falkor-border-primary: #5B6BC0; - /* Primary border color*/ - --falkor-border-secondary: #90A4AE; - /* Muted blue-gray - professional secondary border */ - --falkor-border-tertiary: #E0E0E0; - /* Light gray border */ - --text-primary: #F5F5F5; - /* Soft white text */ - --text-secondary: #B0BEC5; - /* Muted blue-gray text */ - --text-tertiary: #616161; - /* Medium gray text */ - --falkor-highlight: #4DB6AC; - /* Subtle teal highlight */ - --accent-green: #66BB6A; - /* Professional green */ - --icon-filter: invert(1); - /* Dark theme - invert icons for white appearance */ - --bg-tertiary: #2E2E2E; - /* Professional dark gray */ - --border-color: #616161; - /* Subtle border color */ -} +/* CSS Variables and Theme System */ +@import url('variables.css'); -/* Light theme variables */ -[data-theme="light"] { - --falkor-primary: #5B6BC0; - --falkor-secondary: #FAFAFA; - --falkor-tertiary: #B39DDB; - --falkor-quaternary: #F5F5F5; - --dark-bg: #FFFFFF; - --falkor-accent: #26A69A; - --falkor-border-primary: #5B6BC0; - --falkor-border-secondary: #90A4AE; - --falkor-border-tertiary: #424242; - --text-primary: #212121; - --text-secondary: #616161; - --text-tertiary: #9E9E9E; - --falkor-highlight: #4DB6AC; - --accent-green: #388E3C; - /* Professional dark green for light theme */ - --icon-filter: invert(0); - /* Light theme - no inversion for dark icons */ - --bg-tertiary: #F8F8F8; - /* Very light gray background */ - --border-color: #E0E0E0; - /* Light border color */ -} +/* Base styles and reset */ +@import url('base.css'); -/* System theme detection */ -@media (prefers-color-scheme: light) { - [data-theme="system"] { - --falkor-primary: #5B6BC0; - --falkor-secondary: #FAFAFA; - --falkor-tertiary: #B39DDB; - --falkor-quaternary: #F5F5F5; - --dark-bg: #FFFFFF; - --falkor-accent: #26A69A; - --falkor-border-primary: #5B6BC0; - --falkor-border-secondary: #90A4AE; - --falkor-border-tertiary: #424242; - --text-primary: #212121; - --text-secondary: #616161; - --text-tertiary: #9E9E9E; - --falkor-highlight: #4DB6AC; - --accent-green: #388E3C; - /* Professional dark green for light theme */ - --icon-filter: invert(0); - /* Light theme - no inversion for dark icons */ - --bg-tertiary: #F8F8F8; - /* Very light gray background */ - --border-color: #E0E0E0; - /* Light border color */ - } -} +/* Layout components */ +@import url('layout.css'); -@font-face { - font-family: 'Fira Code'; - src: url('../public/fonts/fira_code.ttf'); -} +/* Chat-specific components */ +@import url('chat-components.css'); -body { - font-family: 'Fira Code', monospace; - background-color: var(--falkor-secondary); - height: 100dvh; - width: 100dvw; - display: flex; - flex-direction: column; - color: var(--text-primary); - overflow: hidden; -} +/* Menu and navigation */ +@import url('menu.css'); -/* Ensure all form elements inherit the consistent font */ -button, input, select, textarea { - font-family: inherit; -} +/* Buttons and interactive elements */ +@import url('buttons.css'); -#container { - height: 98%; - width: 100%; - display: flex; - flex-direction: row; - position: relative; -} +/* Modals and popups */ +@import url('modals.css'); -#gradient { - width: 100%; - height: 2%; - background: linear-gradient(to right, var(--falkor-primary), var(--falkor-accent)); -} +/* Responsive styles */ +@import url('responsive.css'); -.logo { - height: 40px; - width: auto; -} +/* Animations and keyframes */ +@import url('animations.css'); -[data-theme="light"] .logo { - filter: invert(1); -} - - -.chat-container { - flex: 1 1 0; - display: flex; - flex-direction: column; - overflow: hidden; - position: relative; - z-index: 1; - padding-right: 20%; - padding-left: 20%; - padding-top: 20px; - padding-bottom: 20px; - transition: margin-left 0.4s cubic-bezier(0.4, 0, 0.2, 1); -} - -/* Mobile responsive adjustments */ -@media (max-width: 768px) { - .chat-container { - padding-right: 10px; - padding-left: 10px; - padding-top: 10px; - padding-bottom: 10px; - } -} - -/* Mobile: Menu overlays content (no pushing) */ -@media (max-width: 768px) { - #menu-container.open~#chat-container { - margin-left: 0; - } -} - -.chat-header { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 20px; - background: var(--falkor-secondary); - color: var(--text-primary); - padding: 16px 20px; - text-align: center; - position: relative; -} - -.chat-header h1 { - font-weight: 600; - font-size: 22px; -} - -.chat-messages { - flex-grow: 1; - padding: 20px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 15px; - background-color: var(--falkor-secondary); -} - -.message-container { - display: flex; - flex-direction: row; - align-items: center; -} - -.user-message-container { - justify-content: flex-end; - position: relative; -} - -/* Hide the default "User" text when avatar is present */ -.user-message-container.has-avatar::after { - display: none; -} - -/* User message avatar styling */ -.user-message-avatar { - height: 40px; - width: 40px; - border-radius: 50%; - object-fit: cover; - margin-left: 10px; - border: 2px solid var(--falkor-quaternary); - font-weight: 500; - font-size: 16px; - justify-content: center; - align-items: center; - display: flex; -} - -.bot-message-container, -.followup-message-container, -.final-result-message-container { - justify-content: flex-start; -} - -.user-message-container::after, -.bot-message-container::before, -.followup-message-container::before, -.final-result-message-container::before { - height: 32px; - width: 32px; - content: 'Bot'; - color: var(--text-secondary); - display: flex; - align-items: center; - justify-content: center; - font-weight: 500; - font-size: 16px; - margin-right: 10px; - border-radius: 100%; - padding: 4px; -} - -.user-message-container::after { - margin-left: 10px; - background: var(--falkor-quaternary); - content: 'User'; -} - -.bot-message-container::before { - background: color-mix(in srgb, var(--falkor-tertiary) 33%, transparent); -} - -.followup-message-container::before { - background: color-mix(in srgb, var(--falkor-tertiary) 40%, transparent); - border: 1px solid var(--falkor-tertiary); -} - -.final-result-message-container::before { - background: color-mix(in srgb, var(--accent-green) 40%, transparent); - border: 1px solid var(--accent-green); -} - -.loading-message-container::before { - content: "..."; - font-size: 32px; - line-height: 1.2; - text-align: center; - animation: typing 1.5s steps(3) infinite; - padding-bottom: 16px; - border-radius: 6px; - color: var(--text-primary); - background: color-mix(in srgb, var(--falkor-tertiary) 33%, transparent); -} - -.message { - max-width: 70%; - padding: 12px 16px; - border-radius: 6px; - margin: 5px 0; - line-height: 1.4; - color: var(--text-primary); - word-wrap: break-word; - overflow-wrap: break-word; - overflow-x: auto; -} - -/* Mobile responsive messages */ -@media (max-width: 768px) { - .message { - max-width: 85%; - padding: 10px 12px; - font-size: 14px; - } - - .chat-header h1 { - font-size: 18px; - } - - #message-input { - font-size: 16px !important; - } - - #message-input::placeholder { - font-size: 16px !important; - } -} - -/* Styles for formatted text blocks */ -.sql-line, .array-line, .plain-line { - word-wrap: break-word; - overflow-wrap: break-word; - white-space: pre-wrap; - margin: 2px 0; -} - -.bot-message { - background: color-mix(in srgb, var(--falkor-tertiary) 33%, transparent); -} - -.user-message { - background: var(--falkor-quaternary); - font-weight: 500; -} - -.followup-message { - background: color-mix(in srgb, var(--falkor-tertiary) 20%, transparent); - border-left: 3px solid var(--falkor-tertiary); -} - -.final-result-message { - background: color-mix(in srgb, var(--accent-green) 15%, transparent); - border-left: 3px solid var(--accent-green); -} - -.chat-input { - display: flex; - padding: 16px 20px; - background: var(--falkor-secondary); - gap: 20px; -} - -.input-container { - position: relative; - display: flex; - flex-direction: row; - align-items: center; - gap: 5px; - flex-grow: 1; - padding: 10px; - border-radius: 6px; - box-shadow: 0 0 8px 1px var(--falkor-primary); - border: 1px solid var(--border-color); -} - -.input-container.loading { - animation: shadow-fade 1.2s infinite ease-in-out; -} - -#message-input { - color: var(--text-primary); - flex-grow: 1; - background-color: transparent; - border: none; - font-size: 18px !important; - font-weight: 500 !important; -} - -#message-input:focus { - outline: none; -} - -#message-input::placeholder { - font-size: 18px !important; - font-weight: 500 !important; - color: var(--text-primary); - opacity: 0.7; -} - -.input-button { - background: var(--falkor-quaternary); - cursor: pointer; - transition: all 0.2s ease; - border: none; - width: 48px; - height: 48px; - overflow: hidden; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); -} - -.input-button img { - filter: var(--icon-filter) brightness(0.8) saturate(0.3); - width: 100%; - height: 100%; - object-fit: contain; -} - -.input-button:hover { - box-shadow: 0 4px 12px rgba(0,0,0,0.25); - background: var(--falkor-accent); - transform: translateY(-1px); -} - -#pause-button { - display: none; -} - -/* Mobile responsive reset button */ -@media (max-width: 768px) { - .input-button { - width: 40px; - height: 40px; - } -} - - -#menu-container { - width: 0; - min-width: 0; - overflow: hidden; - padding: 20px 0; - display: flex; - flex-direction: column; - gap: 20px; - background: var(--falkor-quaternary); - transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1), padding 0.4s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 1; - position: relative; - visibility: hidden; - pointer-events: none; -} - -#menu-container.open { - width: 30dvw; - padding: 20px; - visibility: visible; - pointer-events: auto; -} - -/* Mobile responsive menu */ -@media (max-width: 768px) { - #menu-container { - position: fixed; - top: 0; - left: 0; - height: 100vh; - z-index: 999; - } - - #menu-container.open { - width: 80vw; - padding: 15px; - } -} - -#menu-header { - display: flex; - flex-direction: row; -} - -#menu-content { - flex-grow: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 20px; -} - -#side-menu-button { - position: absolute; - top: 20px; - left: 20px; -} - -/* Mobile responsive side menu button */ -@media (max-width: 768px) { - #side-menu-button { - top: 15px; - left: 15px; - } -} - -#menu-button img { - rotate: 180deg; - filter: brightness(0) invert(1); -} - -/* Menu user image styling */ -.menu-user-img { - width: 32px; - height: 32px; - object-fit: cover; -} - -.menu-item { - overflow: hidden; - flex: 1 1 auto; - width: 100%; - display: flex; - flex-direction: column; - gap: 8px; -} - -#conf-container { - flex-grow: 0; - height: fit-content; - flex-direction: row; - justify-content: space-between; - width: 100%; - gap: 16px; -} - -.menu-value { - flex: 1 1 0; - border: 1px solid var(--falkor-border-primary); - width: 100%; - background: var(--falkor-secondary); - padding: 4px; - border-radius: 6px; - color: var(--text-primary); - overflow-y: auto; -} - -#conf-value { - flex: 0 0 auto; - overflow: hidden; - min-height: 0; - width: fit-content; - min-width: 50px; - padding: 8px; -} - -#info-value, -#amb-value { - border-color: var(--falkor-border-secondary); -} - -#instructions-textarea { - resize: none; -} - -#instructions-textarea:focus { - outline: none; - border: 1px solid var(--falkor-border-tertiary); -} - -.vertical-separator { - width: 2px; - height: 80%; - background-color: var(--text-tertiary); -} - -.horizontal-separator { - width: 80%; - height: 2px; - background-color: var(--text-tertiary); -} - - -.button-container { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - gap: 10px; -} - -/* Mobile responsive button container */ -@media (max-width: 768px) { - .button-container { - flex-direction: row; - gap: 8px; - } -} - -#graph-select { - height: 100%; - padding: 8px 12px; - border-radius: 6px; - border: 1px solid var(--text-primary); - font-size: 14px; - background-color: var(--falkor-secondary); - color: var(--text-primary); - transition: border-color 0.2s; - min-width: 180px; - appearance: none; - background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%), - linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%); - background-position: calc(100% - 20px) center, calc(100% - 15px) center; - background-size: 5px 5px, 5px 5px; - background-repeat: no-repeat; - cursor:pointer; -} - -/* Mobile responsive select elements */ -@media (max-width: 768px) { - #graph-select, - #custom-file-upload, - #open-pg-modal { - min-width: 120px; - width: auto; - padding: 8px 10px; - font-size: 14px; - flex: 1; - } -} - -#graph-select:focus { - outline: none; - border-color: var(--falkor-border-primary); -} - -#custom-file-upload { - height: 100%; - padding: 8px 12px; - border-radius: 6px; - border: 1px solid var(--text-primary); - font-size: 14px; - background-color: var(--falkor-secondary); - color: var(--text-primary); - transition: border-color 0.2s; - min-width: 180px; - appearance: none; - background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%), - linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%); - background-position: calc(100% - 20px) center, calc(100% - 15px) center; - background-size: 5px 5px, 5px 5px; - background-repeat: no-repeat; - cursor:pointer; -} - -#open-pg-modal { - height: 100%; - padding: 8px 12px; - border-radius: 6px; - border: 1px solid var(--text-primary); - font-size: 14px; - background-color: var(--falkor-secondary); - color: var(--text-primary); - transition: border-color 0.2s; - min-width: 180px; - appearance: none; - background-position: calc(100% - 20px) center, calc(100% - 15px) center; - background-size: 5px 5px, 5px 5px; - background-repeat: no-repeat; - cursor: pointer; - display: inline-block; - margin-bottom: 0; - margin-right: 0; - margin-left: 0; -} - -#schema-upload:disabled+label { - opacity: 0.5; - cursor: not-allowed; -} - -#custom-file-upload:focus { - outline: none; - border-color: var(--falkor-border-primary); -} - -#reset-button { - position: absolute; - right: -60px; - top: 50%; - transform: translateY(-50%); -} - -#reset-button svg { - width: 20px; - height: 20px; -} - -/* Mobile responsive reset button */ -@media (max-width: 768px) { - #reset-button { - position: relative; - right: auto; - top: auto; - transform: none; - margin-left: 10px; - } - - #reset-button svg { - width: 18px; - height: 18px; - } - - .chat-input { - padding: 12px 16px; - gap: 12px; - } -} - -.action-button:hover { - box-shadow: 0 4px 12px rgba(0,0,0,0.25); - background: var(--falkor-accent); - transform: translateY(-1px); -} - -.action-button{ - z-index: 100; - width: 48px; - height: 48px; - border: none; - border-radius: 6px; - background: var(--falkor-quaternary); - color: var(--text-primary); - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: center; - padding: 0; - position: relative; -} - -#reset-button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.pg-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: rgba(0,0,0,0.5); - z-index: 3000; - align-items: center; - justify-content: center; -} -.pg-modal-content { - background: var(--falkor-secondary); - padding: 2em 5em; - border-radius: 10px; - box-shadow: 0 2px 16px rgba(0,0,0,0.2); - text-align: center; - min-width: 680px; -} - -/* Mobile responsive modal */ -@media (max-width: 768px) { - .pg-modal-content { - padding: 1.5em 2em; - min-width: 90vw; - margin: 0 5vw; - } -} -.pg-modal-title { - margin-bottom: 1em; - color: var(--text-primary); -} -.pg-modal-input { - width: 100%; - padding: 0.6em; - font-size: 1em; - border: 1px solid var(--border-color); - border-radius: 6px; - margin-bottom: 1.5em; - color: var(--text-primary); - background: var(--falkor-quaternary); -} -.pg-modal-actions { - display: flex; - justify-content: space-between; - gap: 1em; -} -.pg-modal-btn { - flex: 1; - padding: 0.5em 0; - border: none; - border-radius: 6px; - font-size: 1em; - font-weight: bold; - cursor: pointer; - transition: background 0.2s; -} -.pg-modal-connect { - background: #4285F4; - color: #fff; -} -.pg-modal-connect:hover { - background: #3367d6; -} -.pg-modal-connect:disabled { - background: #6c8db8; - cursor: not-allowed; -} -.pg-modal-loading-spinner { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} -.spinner { - width: 16px; - height: 16px; - border: 2px solid #ffffff40; - border-top: 2px solid #ffffff; - border-radius: 50%; - animation: spin 1s linear infinite; -} -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} -.pg-modal-cancel { - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-color); -} -.pg-modal-cancel:hover { - background: var(--border-color); -} -.pg-modal-cancel:disabled { - background: var(--bg-tertiary); - color: var(--text-secondary); - cursor: not-allowed; - border: 1px solid var(--text-secondary); -} -.pg-modal-input:disabled { - background: var(--bg-tertiary); - color: var(--text-secondary); - cursor: not-allowed; -} -.google-login-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: rgba(0,0,0,0.6); - z-index: 1000; - align-items: center; - justify-content: center; -} -.google-login-modal-content { - background: var(--falkor-secondary); - padding: 2em 3em; - border-radius: 10px; - box-shadow: 0 2px 16px rgba(0,0,0,0.2); - text-align: center; - color: var(--text-primary); -} - -.google-login-modal-content h2 { - color: var(--text-primary); - margin-bottom: 0.5em; - font-size: 1.5em; -} - -.google-login-modal-content p { - color: var(--text-secondary); - margin-bottom: 1em; - font-size: 1em; -} -.google-login-btn { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - margin-top: 1em; - padding: 0.7em 2em; - background: #4285F4; - color: #fff; - border-radius: 6px; - font-size: 1.1em; - text-decoration: none; - font-weight: 500; - transition: background 0.2s; -} -.google-login-btn:hover { - background: #3367d6; -} -.google-login-logo { - height: 20px; - margin-right: 10px; - margin-left: 0; - vertical-align: middle; - display: inline-block; -} - -.github-login-btn { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - padding: 0.7em 2em; - background: #24292f; - color: #fff; - border-radius: 6px; - font-size: 1.1em; - text-decoration: none; - font-weight: 500; - transition: background 0.2s; -} -.github-login-btn:hover { - background: #171a1d; -} -.github-login-logo { - height: 20px; - margin-right: 10px; - margin-left: 0; - vertical-align: middle; - display: inline-block; -} -@keyframes shadow-fade { - 0% { - box-shadow: 0 0 8px 1px var(--falkor-primary) - } - - 50% { - box-shadow: 0 0 12px 2px var(--falkor-primary) - } - - 100% { - box-shadow: 0 0 8px 1px var(--falkor-primary) - } -} - -@keyframes typing { - 0% { - content: "."; - } - - 50% { - content: ".."; - } - - 100% { - content: "..."; - } -} - -@keyframes pulse { - - 0%, - 100% { - opacity: 0.4; - } - - 50% { - opacity: 0.8; - } -} - -::-webkit-scrollbar { - width: 20px; - height: 20px; -} - -::-webkit-scrollbar-corner { - background-color: transparent; -} - -::-webkit-scrollbar-thumb { - background-color: #d6dee1; - border-radius: 20px; - border: 6px solid transparent; - background-clip: content-box; -} - -::-webkit-scrollbar-thumb:hover { - background-color: #a8bbbf; -} - -.pg-connect-btn { - margin-left: 12px; - padding: 0.4em 0.8em; - background: #f5f5f5; - border: 1px solid #ccc; - border-radius: 6px; - font-size: 1em; - color: #333; - cursor: pointer; - transition: background 0.2s, border 0.2s; -} - -.pg-connect-btn:hover { - background: #eaeaea; - border-color: #888; -} - -.logout-btn { - position: fixed; - top: 20px; - right: 30px; - z-index: 2000; - padding: 0.5em 1.2em; - background: #e74c3c; - color: #fff; - border-radius: 6px; - text-decoration: none; - font-weight: bold; - box-shadow: 0 2px 8px rgba(0,0,0,0.08); - transition: background 0.2s; -} - -.logout-btn:hover { - background: #c0392b; -} - -/* User Profile Button Styles */ -.user-profile-btn { - position: fixed; - top: 20px; - right: 30px; - z-index: 2000; - width: 48px; - height: 48px; - border: none; - border-radius: 6px; - background: #fff; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - cursor: pointer; - transition: all 0.2s ease; - padding: 2px; -} - -.user-profile-btn:hover { - box-shadow: 0 4px 12px rgba(0,0,0,0.25); - transform: translateY(-1px); -} - -.user-profile-img { - width: 100%; - height: 100%; - border-radius: 6px; - object-fit: cover; - font-weight: 500; - font-size: 22px; -} - -/* User Profile Dropdown */ -.user-profile-dropdown { - position: fixed; - top: 80px; - right: 30px; - z-index: 1999; - background: var(--falkor-secondary); - border: 1px solid var(--falkor-border-primary); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0,0,0,0.25); - min-width: 200px; - display: none; -} - -/* Mobile responsive dropdown */ -@media (max-width: 768px) { - .user-profile-dropdown { - top: 65px; - right: 15px; - min-width: 180px; - } -} - -.user-profile-dropdown.show { - display: block; -} - -.user-profile-info { - padding: 15px; - border-bottom: 1px solid var(--falkor-quaternary); -} - -.user-profile-name { - color: var(--text-primary); - font-weight: bold; - margin-bottom: 5px; -} - -.user-profile-email { - color: var(--text-secondary); - font-size: 0.9em; -} - -.user-profile-actions { - padding: 10px; -} - -.user-profile-logout { - width: 100%; - padding: 10px; - background: #D32F2F; - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-weight: bold; - transition: background 0.2s; -} - -.user-profile-logout:hover { - background: #B71C1C; -} - -/* Theme Toggle Button Styles */ -#theme-toggle-btn { - position: fixed; - top: 20px; - right: 90px; -} - -/* GitHub Link Button Styles */ -#github-link-btn { - position: fixed; - top: 20px; - right: 150px; - display: flex; - align-items: center; - gap: 4px; - padding: 8px 12px; - border-radius: 6px; - background: var(--falkor-quaternary); - color: var(--text-primary); - text-decoration: none; - font-size: 14px; - font-weight: 500; - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - transition: all 0.2s ease; - z-index: 100; - height: 48px; - box-sizing: border-box; -} - -#github-link-btn:hover { - box-shadow: 0 4px 12px rgba(0,0,0,0.25); - background: var(--falkor-accent); - transform: translateY(-1px); - color: var(--text-primary); -} - -.theme-icon { - width: 20px; - height: 20px; - color: var(--text-primary); - transition: all 0.3s ease; -} - -/* Mobile responsive user profile and theme toggle */ -@media (max-width: 768px) { - .action-button { - width: 40px; - height: 40px; - } - .user-profile-btn { - top: 15px; - right: 15px; - width: 40px; - height: 40px; - } - - #theme-toggle-btn { - top: 15px; - right: 80px; - } - - #github-link-btn { - top: 15px; - right: 135px; - padding: 6px 8px; - font-size: 12px; - height: 40px; - } - - .theme-icon { - width: 18px; - height: 18px; - } -} - -/* Theme icon states */ -[data-theme="dark"] .theme-icon .sun, -[data-theme="system"] .theme-icon .sun { - display: none; -} - -[data-theme="dark"] .theme-icon .moon { - display: block; -} - -[data-theme="dark"] .theme-icon .system, -[data-theme="light"] .theme-icon .system { - display: none; -} - -[data-theme="light"] .theme-icon .sun { - display: block; -} - -[data-theme="light"] .theme-icon .moon, -[data-theme="system"] .theme-icon .moon { - display: none; -} - -[data-theme="system"] .theme-icon .system { - display: block; -} - -[data-theme="system"] .theme-icon .sun { - display: none; -} - -/* Destructive Confirmation Styles */ -.destructive-confirmation-container { - transition: all 0.3s ease; -} - -.destructive-confirmation-message { - border: 1px solid #D32F2F; - border-radius: 6px; - background: linear-gradient(135deg, var(--bg-tertiary), var(--falkor-quaternary)); -} - -.destructive-confirmation { - padding: 20px; - transition: all 0.3s ease; -} - -.confirmation-text { - margin-bottom: 20px; - line-height: 1.6; - color: var(--text-primary); - font-size: 14px; -} - -.confirmation-text strong { - color: var(--text-primary); - font-weight: bold; -} - -.confirmation-buttons { - display: flex; - gap: 15px; - justify-content: center; - margin-top: 20px; -} - -.confirm-btn, .cancel-btn { - padding: 12px 24px; - border: none; - border-radius: 6px; - font-family: 'Fira Code', monospace; - font-size: 14px; - font-weight: bold; - cursor: pointer; - transition: all 0.3s ease; - min-width: 140px; -} - -.confirm-btn { - background: #D32F2F; - color: white; - border: 1px solid #D32F2F; -} - -.confirm-btn:hover { - background: #B71C1C; - border-color: #B71C1C; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(211, 47, 47, 0.3); -} - -.cancel-btn { - background: transparent; - color: var(--text-primary); - border: 1px solid var(--border-color); -} - -.cancel-btn:hover { - background: var(--bg-tertiary); - border-color: var(--text-secondary); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); -} - -.confirm-btn:active, .cancel-btn:active { - transform: translateY(0); - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); -} - -.confirm-btn:disabled, .cancel-btn:disabled { - background: #cccccc; - color: #888888; - border-color: #cccccc; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.confirm-btn:disabled:hover, .cancel-btn:disabled:hover { - background: #cccccc; - color: #888888; - border-color: #cccccc; - transform: none; - box-shadow: none; -} - -/* Reset Confirmation Modal */ -.reset-confirmation-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: rgba(0,0,0,0.6); - z-index: 2000; - align-items: center; - justify-content: center; -} - -.reset-confirmation-modal-content { - background: var(--falkor-secondary); - padding: 2em 3em; - border-radius: 10px; - box-shadow: 0 2px 16px rgba(0,0,0,0.2); - text-align: center; - color: var(--text-primary); - min-width: 400px; -} - -/* Mobile responsive reset modal */ -@media (max-width: 768px) { - .reset-confirmation-modal-content { - padding: 1.5em 2em; - min-width: 90vw; - margin: 0 5vw; - } -} - -.reset-confirmation-modal-content h3 { - color: var(--text-primary); - margin-bottom: 0.5em; - font-size: 1.3em; -} - -.reset-confirmation-modal-content p { - color: var(--text-secondary); - margin-bottom: 1.5em; - font-size: 1em; - line-height: 1.5; -} \ No newline at end of file +/* Theme-specific overrides */ +@import url('themes.css'); diff --git a/api/static/css/menu.css b/api/static/css/menu.css new file mode 100644 index 00000000..b9d87aa3 --- /dev/null +++ b/api/static/css/menu.css @@ -0,0 +1,189 @@ +/* Menu and Navigation Components */ + +#menu-container { + width: 0; + min-width: 0; + overflow: hidden; + padding: 20px 0; + display: flex; + flex-direction: column; + gap: 20px; + background: var(--falkor-quaternary); + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1), padding 0.4s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1; + position: relative; + visibility: hidden; + pointer-events: none; +} + +#menu-container.open { + width: 30dvw; + padding: 20px; + visibility: visible; + pointer-events: auto; +} + +#menu-header { + display: flex; + flex-direction: row; +} + +#menu-content { + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +#side-menu-button { + position: absolute; + top: 20px; + left: 20px; +} + +#menu-button img { + rotate: 180deg; + filter: brightness(0) invert(1); +} + +/* Menu user image styling */ +.menu-user-img { + width: 32px; + height: 32px; + object-fit: cover; +} + +.menu-item { + overflow: hidden; + flex: 1 1 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} + +#conf-container { + flex-grow: 0; + height: fit-content; + flex-direction: row; + justify-content: space-between; + width: 100%; + gap: 16px; +} + +.menu-value { + flex: 1 1 0; + border: 1px solid var(--falkor-border-primary); + width: 100%; + background: var(--falkor-secondary); + padding: 4px; + border-radius: 6px; + color: var(--text-primary); + overflow-y: auto; +} + +#conf-value { + flex: 0 0 auto; + overflow: hidden; + min-height: 0; + width: fit-content; + min-width: 50px; + padding: 8px; +} + +#info-value, +#amb-value { + border-color: var(--falkor-border-secondary); +} + +#instructions-textarea { + resize: none; +} + +#instructions-textarea:focus { + outline: none; + border: 1px solid var(--falkor-border-tertiary); +} + +.button-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 10px; +} + +#graph-select { + height: 100%; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--text-primary); + font-size: 14px; + background-color: var(--falkor-secondary); + color: var(--text-primary); + transition: border-color 0.2s; + min-width: 180px; + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%), + linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%); + background-position: calc(100% - 20px) center, calc(100% - 15px) center; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + cursor: pointer; +} + +#graph-select:focus { + outline: none; + border-color: var(--falkor-border-primary); +} + +#custom-file-upload { + height: 100%; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--text-primary); + font-size: 14px; + background-color: var(--falkor-secondary); + color: var(--text-primary); + transition: border-color 0.2s; + min-width: 180px; + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, var(--text-secondary) 50%), + linear-gradient(135deg, var(--text-secondary) 50%, transparent 50%); + background-position: calc(100% - 20px) center, calc(100% - 15px) center; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + cursor: pointer; +} + +#open-pg-modal { + height: 100%; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--text-primary); + font-size: 14px; + background-color: var(--falkor-secondary); + color: var(--text-primary); + transition: border-color 0.2s; + min-width: 180px; + appearance: none; + background-position: calc(100% - 20px) center, calc(100% - 15px) center; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + cursor: pointer; + display: inline-block; + margin-bottom: 0; + margin-right: 0; + margin-left: 0; +} + +#schema-upload:disabled+label { + opacity: 0.5; + cursor: not-allowed; +} + +#custom-file-upload:focus { + outline: none; + border-color: var(--falkor-border-primary); +} diff --git a/api/static/css/modules/modals.css b/api/static/css/modals.css similarity index 56% rename from api/static/css/modules/modals.css rename to api/static/css/modals.css index b98fe7e8..c3de6ac9 100644 --- a/api/static/css/modules/modals.css +++ b/api/static/css/modals.css @@ -1,7 +1,4 @@ -/** - * Modal Dialogs and Popups - * Login modals, postgres modal, and other popup dialogs - */ +/* Modal and Popup Components */ .pg-modal { display: none; @@ -25,15 +22,6 @@ min-width: 680px; } -/* Mobile responsive modal */ -@media (max-width: 768px) { - .pg-modal-content { - padding: 1.5em 2em; - min-width: 90vw; - margin: 0 5vw; - } -} - .pg-modal-title { margin-bottom: 1em; color: var(--text-primary); @@ -50,6 +38,12 @@ background: var(--falkor-quaternary); } +.pg-modal-input:disabled { + background: var(--bg-tertiary); + color: var(--text-secondary); + cursor: not-allowed; +} + .pg-modal-actions { display: flex; justify-content: space-between; @@ -97,11 +91,6 @@ animation: spin 1s linear infinite; } -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - .pg-modal-cancel { background: var(--bg-tertiary); color: var(--text-primary); @@ -119,12 +108,6 @@ border: 1px solid var(--text-secondary); } -.pg-modal-input:disabled { - background: var(--bg-tertiary); - color: var(--text-secondary); - cursor: not-allowed; -} - .google-login-modal { display: none; position: fixed; @@ -159,57 +142,112 @@ font-size: 1em; } -.google-login-btn { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - margin-top: 1em; - padding: 0.7em 2em; - background: #4285F4; - color: #fff; +/* User Profile Dropdown */ +.user-profile-dropdown { + position: fixed; + top: 80px; + right: 30px; + z-index: 1999; + background: var(--falkor-secondary); + border: 1px solid var(--falkor-border-primary); border-radius: 6px; - font-size: 1.1em; - text-decoration: none; - font-weight: 500; - transition: background 0.2s; + box-shadow: 0 4px 12px rgba(0,0,0,0.25); + min-width: 200px; + display: none; } -.google-login-btn:hover { - background: #3367d6; +.user-profile-dropdown.show { + display: block; +} + +.user-profile-info { + padding: 15px; + border-bottom: 1px solid var(--falkor-quaternary); } -.google-login-logo { - height: 20px; - margin-right: 10px; - margin-left: 0; - vertical-align: middle; - display: inline-block; +.user-profile-name { + color: var(--text-primary); + font-weight: bold; + margin-bottom: 5px; } -.github-login-btn { +.user-profile-email { + color: var(--text-secondary); + font-size: 0.9em; +} + +.user-profile-actions { + padding: 10px; +} + +/* Destructive Confirmation Styles */ +.destructive-confirmation-container { + transition: all 0.3s ease; +} + +.destructive-confirmation-message { + border: 1px solid #D32F2F; + border-radius: 6px; + background: linear-gradient(135deg, var(--bg-tertiary), var(--falkor-quaternary)); +} + +.destructive-confirmation { + padding: 20px; + transition: all 0.3s ease; +} + +.confirmation-text { + margin-bottom: 20px; + line-height: 1.6; + color: var(--text-primary); + font-size: 14px; +} + +.confirmation-text strong { + color: var(--text-primary); + font-weight: bold; +} + +.confirmation-buttons { display: flex; + gap: 15px; + justify-content: center; + margin-top: 20px; +} + +/* Reset Confirmation Modal */ +.reset-confirmation-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0,0,0,0.6); + z-index: 2000; align-items: center; - justify-content: flex-start; - gap: 10px; - padding: 0.7em 2em; - background: #24292f; - color: #fff; - border-radius: 6px; - font-size: 1.1em; - text-decoration: none; - font-weight: 500; - transition: background 0.2s; + justify-content: center; +} + +.reset-confirmation-modal-content { + background: var(--falkor-secondary); + padding: 2em 3em; + border-radius: 10px; + box-shadow: 0 2px 16px rgba(0,0,0,0.2); + text-align: center; + color: var(--text-primary); + min-width: 400px; } -.github-login-btn:hover { - background: #171a1d; +.reset-confirmation-modal-content h3 { + color: var(--text-primary); + margin-bottom: 0.5em; + font-size: 1.3em; } -.github-login-logo { - height: 20px; - margin-right: 10px; - margin-left: 0; - vertical-align: middle; - display: inline-block; +.reset-confirmation-modal-content p { + color: var(--text-secondary); + margin-bottom: 1.5em; + font-size: 1em; + line-height: 1.5; } diff --git a/api/static/css/modules/base.css b/api/static/css/modules/base.css deleted file mode 100644 index e69de29b..00000000 diff --git a/api/static/css/modules/confirmations.css b/api/static/css/modules/confirmations.css deleted file mode 100644 index a38c8291..00000000 --- a/api/static/css/modules/confirmations.css +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Confirmation Dialogs and Destructive Actions - * Reset confirmation modal and destructive action styling - */ - -/* Destructive Confirmation Styles */ -.destructive-confirmation-container { - transition: all 0.3s ease; -} - -.destructive-confirmation-message { - border: 1px solid #D32F2F; - border-radius: 6px; - background: linear-gradient(135deg, var(--bg-tertiary), var(--falkor-quaternary)); -} - -.destructive-confirmation { - padding: 20px; - transition: all 0.3s ease; -} - -.confirmation-text { - margin-bottom: 20px; - line-height: 1.6; - color: var(--text-primary); - font-size: 14px; -} - -.confirmation-text strong { - color: var(--text-primary); - font-weight: bold; -} - -.confirmation-buttons { - display: flex; - gap: 15px; - justify-content: center; - margin-top: 20px; -} - -.confirm-btn, .cancel-btn { - padding: 12px 24px; - border: none; - border-radius: 6px; - font-family: 'Fira Code', monospace; - font-size: 14px; - font-weight: bold; - cursor: pointer; - transition: all 0.3s ease; - min-width: 140px; -} - -.confirm-btn { - background: #D32F2F; - color: white; - border: 1px solid #D32F2F; -} - -.confirm-btn:hover { - background: #B71C1C; - border-color: #B71C1C; - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(211, 47, 47, 0.3); -} - -.cancel-btn { - background: transparent; - color: var(--text-primary); - border: 1px solid var(--border-color); -} - -.cancel-btn:hover { - background: var(--bg-tertiary); - border-color: var(--text-secondary); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); -} - -.confirm-btn:active, .cancel-btn:active { - transform: translateY(0); - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); -} - -.confirm-btn:disabled, .cancel-btn:disabled { - background: #cccccc; - color: #888888; - border-color: #cccccc; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.confirm-btn:disabled:hover, .cancel-btn:disabled:hover { - background: #cccccc; - color: #888888; - border-color: #cccccc; - transform: none; - box-shadow: none; -} - -/* Reset Confirmation Modal */ -.reset-confirmation-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: rgba(0,0,0,0.6); - z-index: 2000; - align-items: center; - justify-content: center; -} - -.reset-confirmation-modal-content { - background: var(--falkor-secondary); - padding: 2em 3em; - border-radius: 10px; - box-shadow: 0 2px 16px rgba(0,0,0,0.2); - text-align: center; - color: var(--text-primary); - min-width: 400px; -} - -/* Mobile responsive reset modal */ -@media (max-width: 768px) { - .reset-confirmation-modal-content { - padding: 1.5em 2em; - min-width: 90vw; - margin: 0 5vw; - } -} - -.reset-confirmation-modal-content h3 { - color: var(--text-primary); - margin-bottom: 0.5em; - font-size: 1.3em; -} - -.reset-confirmation-modal-content p { - color: var(--text-secondary); - margin-bottom: 1.5em; - font-size: 1em; - line-height: 1.5; -} diff --git a/api/static/css/modules/forms.css b/api/static/css/modules/forms.css deleted file mode 100644 index e69de29b..00000000 diff --git a/api/static/css/modules/layout.css b/api/static/css/modules/layout.css deleted file mode 100644 index e69de29b..00000000 diff --git a/api/static/css/modules/navigation.css b/api/static/css/modules/navigation.css deleted file mode 100644 index e69de29b..00000000 diff --git a/api/static/css/modules/toolbar.css b/api/static/css/modules/toolbar.css deleted file mode 100644 index 6723cea7..00000000 --- a/api/static/css/modules/toolbar.css +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Toolbar and Header Controls - * User profile, theme toggle, GitHub link, and other header controls - */ - -.pg-connect-btn { - margin-left: 12px; - padding: 0.4em 0.8em; - background: #f5f5f5; - border: 1px solid #ccc; - border-radius: 6px; - font-size: 1em; - color: #333; - cursor: pointer; - transition: background 0.2s, border 0.2s; -} - -.pg-connect-btn:hover { - background: #eaeaea; - border-color: #888; -} - -.logout-btn { - position: fixed; - top: 20px; - right: 30px; - z-index: 2000; - padding: 0.5em 1.2em; - background: #e74c3c; - color: #fff; - border-radius: 6px; - text-decoration: none; - font-weight: bold; - box-shadow: 0 2px 8px rgba(0,0,0,0.08); - transition: background 0.2s; -} - -.logout-btn:hover { - background: #c0392b; -} - -/* User Profile Button Styles */ -.user-profile-btn { - position: fixed; - top: 20px; - right: 30px; - z-index: 2000; - width: 48px; - height: 48px; - border: none; - border-radius: 6px; - background: #fff; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - cursor: pointer; - transition: all 0.2s ease; - padding: 2px; -} - -.user-profile-btn:hover { - box-shadow: 0 4px 12px rgba(0,0,0,0.25); - transform: translateY(-1px); -} - -.user-profile-img { - width: 100%; - height: 100%; - border-radius: 6px; - object-fit: cover; - font-weight: 500; - font-size: 22px; -} - -/* User Profile Dropdown */ -.user-profile-dropdown { - position: fixed; - top: 80px; - right: 30px; - z-index: 1999; - background: var(--falkor-secondary); - border: 1px solid var(--falkor-border-primary); - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0,0,0,0.25); - min-width: 200px; - display: none; -} - -/* Mobile responsive dropdown */ -@media (max-width: 768px) { - .user-profile-dropdown { - top: 65px; - right: 15px; - min-width: 180px; - } -} - -.user-profile-dropdown.show { - display: block; -} - -.user-profile-info { - padding: 15px; - border-bottom: 1px solid var(--falkor-quaternary); -} - -.user-profile-name { - color: var(--text-primary); - font-weight: bold; - margin-bottom: 5px; -} - -.user-profile-email { - color: var(--text-secondary); - font-size: 0.9em; -} - -.user-profile-actions { - padding: 10px; -} - -.user-profile-logout { - width: 100%; - padding: 10px; - background: #D32F2F; - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-weight: bold; - transition: background 0.2s; -} - -.user-profile-logout:hover { - background: #B71C1C; -} - -/* Theme Toggle Button Styles */ -#theme-toggle-btn { - position: fixed; - top: 20px; - right: 90px; -} - -/* GitHub Link Button Styles */ -#github-link-btn { - position: fixed; - top: 20px; - right: 150px; - display: flex; - align-items: center; - gap: 4px; - padding: 8px 12px; - border-radius: 6px; - background: var(--falkor-quaternary); - color: var(--text-primary); - text-decoration: none; - font-size: 14px; - font-weight: 500; - border: 1px solid var(--border-color); - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - transition: all 0.2s ease; - z-index: 100; - height: 48px; - box-sizing: border-box; -} - -#github-link-btn:hover { - box-shadow: 0 4px 12px rgba(0,0,0,0.25); - background: var(--falkor-accent); - transform: translateY(-1px); - color: var(--text-primary); -} - -.theme-icon { - width: 20px; - height: 20px; - color: var(--text-primary); - transition: all 0.3s ease; -} - -/* Mobile responsive user profile and theme toggle */ -@media (max-width: 768px) { - .user-profile-btn { - top: 15px; - right: 15px; - width: 40px; - height: 40px; - } - - #theme-toggle-btn { - top: 15px; - right: 80px; - } - - #github-link-btn { - top: 15px; - right: 135px; - padding: 6px 8px; - font-size: 12px; - height: 40px; - } - - .theme-icon { - width: 18px; - height: 18px; - } -} - -/* Theme icon states */ -[data-theme="dark"] .theme-icon .sun, -[data-theme="system"] .theme-icon .sun { - display: none; -} - -[data-theme="dark"] .theme-icon .moon { - display: block; -} - -[data-theme="dark"] .theme-icon .system, -[data-theme="light"] .theme-icon .system { - display: none; -} - -[data-theme="light"] .theme-icon .sun { - display: block; -} - -[data-theme="light"] .theme-icon .moon, -[data-theme="system"] .theme-icon .moon { - display: none; -} - -[data-theme="system"] .theme-icon .system { - display: block; -} - -[data-theme="system"] .theme-icon .sun { - display: none; -} diff --git a/api/static/css/modules/utilities.css b/api/static/css/modules/utilities.css deleted file mode 100644 index 94363cc2..00000000 --- a/api/static/css/modules/utilities.css +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Utility Classes and Animations - * Keyframe animations, scrollbar styles, and utility classes - */ - -/* Keyframe Animations */ -@keyframes shadow-fade { - 0% { - box-shadow: 0 0 8px 1px var(--falkor-primary) - } - - 50% { - box-shadow: 0 0 12px 2px var(--falkor-primary) - } - - 100% { - box-shadow: 0 0 8px 1px var(--falkor-primary) - } -} - -@keyframes typing { - 0% { - content: "."; - } - - 50% { - content: ".."; - } - - 100% { - content: "..."; - } -} - -@keyframes pulse { - 0%, - 100% { - opacity: 0.4; - } - - 50% { - opacity: 0.8; - } -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* Custom Scrollbar Styles */ -::-webkit-scrollbar { - width: 20px; - height: 20px; -} - -::-webkit-scrollbar-corner { - background-color: transparent; -} - -::-webkit-scrollbar-thumb { - background-color: #d6dee1; - border-radius: 20px; - border: 6px solid transparent; - background-clip: content-box; -} - -::-webkit-scrollbar-thumb:hover { - background-color: #a8bbbf; -} \ No newline at end of file diff --git a/api/static/css/responsive.css b/api/static/css/responsive.css new file mode 100644 index 00000000..25447ef6 --- /dev/null +++ b/api/static/css/responsive.css @@ -0,0 +1,176 @@ +/* Mobile Responsive Styles */ + +/* Layout Responsive */ +@media (max-width: 768px) { + .chat-container { + padding-right: 10px; + padding-left: 10px; + padding-top: 10px; + padding-bottom: 10px; + } +} + +/* Mobile: Menu overlays content (no pushing) */ +@media (max-width: 768px) { + #menu-container.open~#chat-container { + margin-left: 0; + } +} + +/* Menu Responsive */ +@media (max-width: 768px) { + #menu-container { + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: 999; + } + + #menu-container.open { + width: 80vw; + padding: 15px; + } +} + +/* Side Menu Button Responsive */ +@media (max-width: 768px) { + #side-menu-button { + top: 15px; + left: 15px; + } +} + +/* Chat Messages Responsive */ +@media (max-width: 768px) { + .message { + max-width: 85%; + padding: 10px 12px; + font-size: 14px; + } + + .chat-header h1 { + font-size: 18px; + } + + #message-input { + font-size: 16px !important; + } + + #message-input::placeholder { + font-size: 16px !important; + } +} + +/* Button Container Responsive */ +@media (max-width: 768px) { + .button-container { + flex-direction: row; + gap: 8px; + } +} + +/* Select Elements Responsive */ +@media (max-width: 768px) { + #graph-select, + #custom-file-upload, + #open-pg-modal { + min-width: 120px; + width: auto; + padding: 8px 10px; + font-size: 14px; + flex: 1; + } +} + +/* Input Button Responsive */ +@media (max-width: 768px) { + .input-button { + width: 40px; + height: 40px; + } +} + +/* Reset Button Responsive */ +@media (max-width: 768px) { + #reset-button { + position: relative; + right: auto; + top: auto; + transform: none; + margin-left: 10px; + } + + #reset-button svg { + width: 18px; + height: 18px; + } + + .chat-input { + padding: 12px 16px; + gap: 12px; + } +} + +/* Action Buttons Responsive */ +@media (max-width: 768px) { + .action-button { + width: 40px; + height: 40px; + } +} + +/* User Profile and Navigation Responsive */ +@media (max-width: 768px) { + .user-profile-btn { + top: 15px; + right: 15px; + width: 40px; + height: 40px; + } + + #theme-toggle-btn { + top: 15px; + right: 80px; + } + + #github-link-btn { + top: 15px; + right: 135px; + padding: 6px 8px; + font-size: 12px; + height: 40px; + } + + .theme-icon { + width: 18px; + height: 18px; + } +} + +/* User Profile Dropdown Responsive */ +@media (max-width: 768px) { + .user-profile-dropdown { + top: 65px; + right: 15px; + min-width: 180px; + } +} + +/* Modal Responsive */ +@media (max-width: 768px) { + .pg-modal-content { + padding: 1.5em 2em; + min-width: 90vw; + margin: 0 5vw; + } +} + +/* Reset Modal Responsive */ +@media (max-width: 768px) { + .reset-confirmation-modal-content { + padding: 1.5em 2em; + min-width: 90vw; + margin: 0 5vw; + } +} diff --git a/api/static/css/themes.css b/api/static/css/themes.css new file mode 100644 index 00000000..51b79b4c --- /dev/null +++ b/api/static/css/themes.css @@ -0,0 +1,37 @@ +/* Theme-specific Styles and Overrides */ + +[data-theme="light"] .logo { + filter: invert(1); +} + +/* Theme icon states */ +[data-theme="dark"] .theme-icon .sun, +[data-theme="system"] .theme-icon .sun { + display: none; +} + +[data-theme="dark"] .theme-icon .moon { + display: block; +} + +[data-theme="dark"] .theme-icon .system, +[data-theme="light"] .theme-icon .system { + display: none; +} + +[data-theme="light"] .theme-icon .sun { + display: block; +} + +[data-theme="light"] .theme-icon .moon, +[data-theme="system"] .theme-icon .moon { + display: none; +} + +[data-theme="system"] .theme-icon .system { + display: block; +} + +[data-theme="system"] .theme-icon .sun { + display: none; +} diff --git a/api/static/css/modules/variables.css b/api/static/css/variables.css similarity index 94% rename from api/static/css/modules/variables.css rename to api/static/css/variables.css index 43c30d7b..693f1bb5 100644 --- a/api/static/css/modules/variables.css +++ b/api/static/css/variables.css @@ -1,14 +1,4 @@ -/** - * CSS Variables and Theme System - * Contains all CSS custom properties for theming - */ - -/* CSS Reset */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} +/* CSS Custom Properties and Theme Variables */ :root { /* Professional color palette - muted and business-appropriate */ @@ -98,4 +88,4 @@ --border-color: #E0E0E0; /* Light border color */ } -} \ No newline at end of file +} From 1002d363d142bb99728d1656bd97d064c3766afd Mon Sep 17 00:00:00 2001 From: Guy Korland Date: Sat, 2 Aug 2025 15:13:15 +0300 Subject: [PATCH 15/16] security warnings --- api/routes/graphs.py | 8 ++++---- api/static/js/chat.js | 9 +++++++-- api/templates/components/toolbar.j2 | 2 +- api/templates/components/user_profile.j2 | 5 +++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/api/routes/graphs.py b/api/routes/graphs.py index b10ed269..ad4fdfd8 100644 --- a/api/routes/graphs.py +++ b/api/routes/graphs.py @@ -343,9 +343,9 @@ def generate(): ) + MESSAGE_DELIMITER except Exception as e: - logging.error("Error executing SQL query: %s", e) + logging.error("Error executing SQL query: %s", str(e)) yield json.dumps( - {"type": "error", "message": str(e)} + {"type": "error", "message": "Error executing SQL query"} ) + MESSAGE_DELIMITER return Response(stream_with_context(generate()), content_type="application/json") @@ -441,9 +441,9 @@ def generate_confirmation(): ) + MESSAGE_DELIMITER except Exception as e: - logging.error("Error executing confirmed SQL query: %s", e) + logging.error("Error executing confirmed SQL query: %s", str(e)) yield json.dumps( - {"type": "error", "message": f"Error executing query: {str(e)}"} + {"type": "error", "message": "Error executing query"} ) + MESSAGE_DELIMITER else: # User cancelled or provided invalid confirmation diff --git a/api/static/js/chat.js b/api/static/js/chat.js index 089e68f3..2fefc61c 100644 --- a/api/static/js/chat.js +++ b/api/static/js/chat.js @@ -25,6 +25,11 @@ const MESSAGE_DELIMITER = '|||FALKORDB_MESSAGE_BOUNDARY|||'; const urlParams = new URLSearchParams(window.location.search); +// Helper function to escape backslashes and single quotes for JS string literals +function escapeForJsString(str) { + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +} + function addMessage(message, isUser = false, isFollowup = false, isFinalResult = false, isLoading = false, userInfo = null) { const messageDiv = document.createElement('div'); const messageDivContainer = document.createElement('div'); @@ -428,10 +433,10 @@ function addDestructiveConfirmationMessage(step) {
${step.message.replace(/\n/g, '
')}
- -
diff --git a/api/templates/components/toolbar.j2 b/api/templates/components/toolbar.j2 index b3fea500..117b4434 100644 --- a/api/templates/components/toolbar.j2 +++ b/api/templates/components/toolbar.j2 @@ -26,7 +26,7 @@ - + diff --git a/api/templates/components/user_profile.j2 b/api/templates/components/user_profile.j2 index 00db33e5..fd4b4846 100644 --- a/api/templates/components/user_profile.j2 +++ b/api/templates/components/user_profile.j2 @@ -1,8 +1,9 @@ {# User profile button and dropdown #} - -