diff --git a/.env.template b/.env.template new file mode 100644 index 0000000000000000000000000000000000000000..1b26019631cd678637efc38da589abb8e94c3c07 --- /dev/null +++ b/.env.template @@ -0,0 +1,2 @@ +# Groq API Key for llama3-8b-8192 +GROQ_API_KEY=your_groq_api_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..cddde429d8dc6d50ecb9358b51465285b3514a76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +__pycache__/ +venv/ +env/ +.env +databases/csv/*.db +databases/schema/*.db +databases/schema/*.sql +databases/schema/*.json +*.pyc +.DS_Store +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..11c0a394e28dcc45d1f61cb531c5f5ede0e16679 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Natural Language Data Analyst + +## Overview +A powerful full-stack analytical web application that enables users to query structured databases using plain, conversational English. The system automatically converts natural language questions into highly optimized, dialect-specific SQL, executes it against an embedded SQLite database, renders beautiful responsive charts (pure SVG without third-party chart libraries), and synthesizes qualitative business insights from the results using generative AI. + +## Features +- 🗣️ **Natural language to SQL conversion**: Translates conversational questions into accurate SQLite SELECT queries. +- 📊 **Auto-generated charts**: Renders pure-SVG Bar, Line, and Pie charts dynamically based on query results column metadata (completely zero-dependency). +- 💡 **AI-generated insights**: Summarizes query execution results into concise business insights in real time. +- 📁 **Upload your own CSV datasets**: Seamlessly upload custom CSV files which are automatically formatted, sanitized, and loaded into individual SQLite database files. +- 🗃️ **Built-in sample datasets**: Pre-loaded with Sales, Employees, and E-commerce sample data to start querying immediately. +- 🔄 **Auto-retry on SQL errors**: Automatically catches execution syntax/type failures and prompts the LLM to fix the query (up to 3 retries). +- 📥 **Export results as CSV**: Downloads query data instantly as CSV files named based on the source question. +- 💡 **Query Suggestions**: Generates and caches 6 interesting question suggestions using AI when a dataset is opened. +- 🕒 **Recent Queries Log**: Retains the last 20 queries run during a session for rapid re-execution. +- ⌨️ **Keyboard Shortcuts**: Focuses input with `Ctrl+K`, submits with `Ctrl+Enter`, and pulls the last query using the `Arrow Up` key when empty. + +## Tech Stack + +| Technology | Purpose | +|---|---| +| **Python 3.10+ / FastAPI** | Backend web server and REST API routing | +| **Groq (Llama 3.1 8B)** | Advanced SQL query generation, self-correction, suggestions, and text insights | +| **SQLite + Pandas** | SQL database storage, schema analysis, and query execution | +| **React + Vite** | High-performance, reactive single-page frontend application | +| **Pure CSS / HTML5** | Modern, premium glassmorphism dark-mode styling and UI aesthetics | +| **Pure SVG** | High-fidelity interactive charts and visual graphs (zero-dependency) | + +--- + +## Project Structure + +```text +nl-data-analyst/ +├── backend/ +│ ├── main.py # FastAPI routing, CORS middleware, and cache setup +│ ├── config.py # Global server constants, LLM model selection, and API keys +│ ├── schemas.py # Pydantic models validating REST request and response contracts +│ ├── requirements.txt # Python dependency manifest (FastAPI, Groq, Pandas) +│ └── services/ # Core business logic handlers +│ ├── csv_service.py # CSV loading, parsing, and SQLite DB generation +│ ├── sql_service.py # SQLite execution, query sanitization, and safety audits +│ ├── llm_service.py # SQL generation, self-repair pipelines, and suggestions +│ └── insight_service.py # Text synthesis translating tabular data to business insights +├── databases/ # Storage directory containing generated SQLite .db files +├── sample_data/ # Built-in sample CSV datasets (Sales, Employees, E-commerce) +├── frontend/ +│ ├── src/ +│ │ ├── components/ # Reusable React UI component layers +│ │ │ ├── Sidebar.jsx # Side menu listing datasets, upload buttons, and recent logs +│ │ │ ├── UploadView.jsx # Drag-and-drop CSV upload landing zone +│ │ │ ├── DataPreview.jsx # Overview statistics, column schema, and data grid preview +│ │ │ ├── QueryView.jsx # Context bars, query log panels, and suggestions decks +│ │ │ ├── QueryInput.jsx # Query textarea form with key shortcut binds +│ │ │ ├── QueryResult.jsx # Collapsible SQL, data table, and chart selector display +│ │ │ ├── ChartView.jsx # SVG chart routing, scaling, grid plotting, and tooltips +│ │ │ ├── DataTable.jsx # Interactive data matrix grid +│ │ │ └── LoadingResult.jsx # Dynamic shimmers skeleton loading placeholders +│ │ ├── api.js # JavaScript fetch integration layer mapping REST endpoints +│ │ ├── App.jsx # Top-level state coordinator, stats bar, and toast managers +│ │ ├── index.css # Global custom CSS styles, animations, and responsive queries +│ │ └── main.jsx # React client DOM mounter +│ ├── package.json # Node.js dev server and build tool dependencies +│ ├── vite.config.js # Vite custom proxy and build settings +│ └── index.html # Main HTML skeleton file +├── .env # Environment credentials (API keys, ports) +└── README.md # Project documentation manual +``` + +--- + +## Getting Started + +### Prerequisites +- **Python 3.10+** +- **Node.js 18+** +- **Groq API Key** (obtain free from [console.groq.com](https://console.groq.com)) + +### Installation & Running + +#### 1. Setup the Backend +Navigate to the root directory and create a virtual environment: +```bash +# Create python virtual environment +python -m venv .venv + +# Activate the virtual environment +# On Windows (Command Prompt/PowerShell): +.venv\Scripts\activate +# On macOS/Linux: +source .venv/bin/activate + +# Install backend dependencies +pip install -r backend/requirements.txt +``` + +Create a `.env` configuration file in the project root (using `.env.template` as a model) and fill in your Groq API Key: +```env +GROQ_API_KEY=gsk_your_actual_groq_api_key_here +PORT=8000 +``` + +Start the backend FastAPI server: +```bash +# Run the backend using Uvicorn +python -m uvicorn backend.main:app --host 127.0.0.1 --port 8000 --reload +``` +The API documentation will be available at `http://127.0.0.1:8000/docs`. + +#### 2. Setup the Frontend +Open a new terminal session, navigate to the `frontend/` directory, install packages, and start the development server: +```bash +cd frontend + +# Install client packages +npm install + +# Start Vite dev environment +npm run dev -- --port 5173 --host 127.0.0.1 +``` +The web application is now active at `http://127.0.0.1:5173/`. + +--- + +## How It Works + +1. **Dataset Selection & Conversion**: The user selects a preloaded dataset or drops in a custom CSV file. The backend parses it using Pandas and maps datatypes to create an optimized SQLite database file inside the `databases/` folder. +2. **Dynamic Question Suggestions**: When a dataset is loaded, its schema is analysed and passed to the Llama model on Groq to compile 6 business-relevant query suggestions. Suggestions are cached in-memory. +3. **Natural Language Translation**: The user submits a conversational question. The LLM translates the query based on the table schema and SQL formatting system prompts. +4. **Execution & Self-Correction**: The SQLite query is verified against destructive SQL keywords (only SELECTs allowed) and executed. If SQLite throws a syntax error, the LLM-service receives the failing SQL and stacktrace to attempt self-correction. +5. **Visualization Layout**: The frontend parses the output matrix. If column headers contain labels + numbers, it charts the data: + - **Pie Chart**: Used for $\le 6$ distinct labels (great for composition summaries). + - **Bar Chart**: Generates dynamic scale sizes and plots vertical bars with custom hover tooltips. + - **Line Chart**: Draws line series, highlights points, and adds linear gradients under the curves. +6. **Insight Synthesis**: Groq translates the final matrix rows and original question to output a concise 2-3 sentence business summary. + +--- + +## Future Improvements +- **PostgreSQL / MySQL Connectors**: Run natural language analytics on enterprise production databases. +- **Multi-Table JOIN Support**: Incorporate relational schema graphs to query complex multi-table relationships. +- **Dashboard View**: Pin multiple query results cards and auto-refresh reports. +- **Interactive Visualizations**: Modify chart styles, axes, and colors using conversational commands. +- **PDF Exporter**: Export the full history log along with SVG charts and text insights into a PDF report document. + +## License +MIT diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7f831694f530a6ab8065262da0af99cd4631d15c --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# Backend package diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000000000000000000000000000000000000..1f78db032871af8052afb9da23f017aec6e49471 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,31 @@ +import os +from dotenv import load_dotenv + +# Base directory is the root of the project (parent of 'backend') +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Load .env file from base directory +env_path = os.path.join(BASE_DIR, ".env") +load_dotenv(env_path) + +GROQ_API_KEY = os.getenv("GROQ_API_KEY") +GROQ_MODEL = "llama-3.1-8b-instant" + +DB_DIR = os.path.join(BASE_DIR, "databases", "csv") +SCHEMA_DB_DIR = os.path.join(BASE_DIR, "databases", "schema") +SAMPLE_DATA_DIR = os.path.join(BASE_DIR, "sample_data") + +MAX_ROWS_RETURNED = 500 +MAX_SQL_RETRIES = 3 + +# Auto-create directories if missing +os.makedirs(DB_DIR, exist_ok=True) +os.makedirs(SCHEMA_DB_DIR, exist_ok=True) +os.makedirs(SAMPLE_DATA_DIR, exist_ok=True) + +print(f"[CONFIG] Base Directory: {BASE_DIR}") +print(f"[CONFIG] CSV Database Directory: {DB_DIR}") +print(f"[CONFIG] Schema Database Directory: {SCHEMA_DB_DIR}") +print(f"[CONFIG] Sample Data Directory: {SAMPLE_DATA_DIR}") +print(f"[CONFIG] Groq API Key loaded: {'Yes' if GROQ_API_KEY else 'No'}") + diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000000000000000000000000000000000000..19078d83b4e2e64c23a6202b2f850e339f72da90 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,754 @@ +import os +import re +import time +from datetime import datetime +from collections import defaultdict +from fastapi import FastAPI, HTTPException, UploadFile, File, Response +from fastapi.middleware.cors import CORSMiddleware +from typing import List, Dict, Any + +from backend import config +from backend.services import csv_service, sql_service, llm_service, insight_service +from backend.services.schema_service import schema_service +from backend.schemas import ( + DatasetsResponse, DatasetInfo, DatasetSchemaResponse, ColumnDetail, + UploadResponse, DeleteResponse, QueryRequest, QueryResponse, SQLResults, + HealthResponse, SuggestionsResponse, QueryHistoryResponse, QueryHistoryItem, + SchemaDatasetsResponse, SchemaUploadResponse, SchemaInfoResponse, + SchemaPreviewsResponse, SchemaQueryResponse, SchemaQueryRequest +) + + +# Global in-memory caches +query_histories = defaultdict(list) # db_name -> list of dicts +suggestions_cache = {} # db_name -> list of strings + +app = FastAPI( + title="Natural Language Data Analyst API", + description="Backend API for Natural Language Data Analyst application", + version="0.1.0" +) + +# Enforce CORS allowing all origins +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + expose_headers=["X-Process-Time"] # Make sure the frontend can read this header +) + +def validate_db_name(db_name: str): + """ + Validates that db_name only contains alphanumeric characters and underscores. + Prevents path traversal and injection. + """ + if not re.match(r"^[a-zA-Z0-9_]+$", db_name): + raise HTTPException( + status_code=400, + detail="db_name must only contain alphanumeric characters and underscores." + ) + +# 1. GET /datasets +@app.get("/datasets", response_model=DatasetsResponse) +def get_datasets(): + print("[API] GET /datasets called") + try: + # Load sample datasets first + samples = csv_service.get_sample_datasets() + sample_names = {ds["name"] for ds in samples} + + datasets_list = [] + # Append sample datasets mapped to output schema + for ds in samples: + datasets_list.append( + DatasetInfo( + db_name=ds["name"], + display_name=ds["display_name"], + description=ds["description"], + columns=[col["name"] for col in ds["columns"]], + row_count=ds["row_count"], + is_sample=True + ) + ) + + # Scan databases/ directory for any user uploaded ones + if os.path.exists(config.DB_DIR): + for filename in os.listdir(config.DB_DIR): + if filename.endswith(".db"): + db_name = os.path.splitext(filename)[0] + if db_name not in sample_names: + try: + schema = csv_service.get_table_schema(db_name) + datasets_list.append( + DatasetInfo( + db_name=db_name, + display_name=db_name.replace("_", " ").capitalize(), + description=f"User-uploaded dataset from {db_name}.csv", + columns=[col["name"] for col in schema["columns"]], + row_count=schema["row_count"], + is_sample=False + ) + ) + except Exception as e: + print(f"[API] Error reading user DB '{db_name}': {e}") + + return DatasetsResponse(datasets=datasets_list, total=len(datasets_list)) + except Exception as e: + print(f"[API] Error in GET /datasets: {e}") + raise HTTPException(status_code=500, detail=f"Failed to fetch datasets: {str(e)}") + +# 2. GET /datasets/{db_name}/schema +@app.get("/datasets/{db_name}/schema", response_model=DatasetSchemaResponse) +def get_dataset_schema(db_name: str): + print(f"[API] GET /datasets/{db_name}/schema called") + validate_db_name(db_name) + + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail="Dataset not found.") + + try: + schema = csv_service.get_table_schema(db_name) + return DatasetSchemaResponse( + db_name=db_name, + table_name=schema["table_name"], + columns=[ + ColumnDetail( + name=col["name"], + type=col["type"], + sample_values=col["sample_values"] + ) for col in schema["columns"] + ], + row_count=schema["row_count"] + ) + except Exception as e: + print(f"[API] Error fetching schema for '{db_name}': {e}") + raise HTTPException(status_code=500, detail=str(e)) + +# 3. POST /upload +@app.post("/upload", response_model=UploadResponse) +def upload_csv(file: UploadFile = File(...)): + print(f"[API] POST /upload called with file: {file.filename}") + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="Only .csv files are allowed.") + + # Sanitize file name to produce db_name + base_name = os.path.splitext(file.filename)[0].lower().replace(" ", "_") + db_name = re.sub(r"[^a-zA-Z0-9_]", "", base_name) + if not db_name: + raise HTTPException(status_code=400, detail="Invalid filename characters.") + + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + if os.path.exists(db_path): + raise HTTPException(status_code=409, detail="Dataset already uploaded. Delete it first.") + + # Save to temp location in DB_DIR + temp_path = os.path.join(config.DB_DIR, f"temp_{db_name}.csv") + try: + with open(temp_path, "wb") as buffer: + buffer.write(file.file.read()) + + # Load CSV to SQLite database + schema_info = csv_service.load_csv_to_sqlite(temp_path, db_name) + + display_name = db_name.replace("_", " ").capitalize() + columns = [col["name"] for col in schema_info["columns"]] + + return UploadResponse( + success=True, + db_name=db_name, + display_name=display_name, + columns=columns, + row_count=schema_info["row_count"], + message=f"Dataset '{display_name}' uploaded and processed successfully." + ) + except Exception as e: + print(f"[API] Error uploading CSV: {e}") + # If database was created but failed load, clean it up + if os.path.exists(db_path): + try: + os.remove(db_path) + except Exception: + pass + raise HTTPException(status_code=500, detail=f"Failed to process CSV file: {str(e)}") + finally: + # Cleanup temporary CSV file + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception as e: + print(f"[API] Failed to delete temp file '{temp_path}': {e}") + +# 4. DELETE /datasets/{db_name} +@app.delete("/datasets/{db_name}", response_model=DeleteResponse) +def delete_dataset(db_name: str): + print(f"[API] DELETE /datasets/{db_name} called") + validate_db_name(db_name) + + if db_name in ["sales", "employees", "ecommerce"]: + raise HTTPException(status_code=403, detail="Cannot delete sample datasets.") + + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail="Dataset not found.") + + try: + os.remove(db_path) + return DeleteResponse( + success=True, + message=f"Dataset '{db_name}' deleted successfully." + ) + except Exception as e: + print(f"[API] Error deleting dataset '{db_name}': {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete dataset: {str(e)}") + +# 5. POST /query +@app.post("/query", response_model=QueryResponse) +def run_query(request: QueryRequest, response: Response): + print(f"[API] POST /query called for db: {request.db_name}, question: '{request.question}'") + start_time = time.perf_counter() + + db_path = os.path.join(config.DB_DIR, f"{request.db_name}.db") + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail=f"Database '{request.db_name}' not found.") + + try: + # a. Get schema + schema = csv_service.get_table_schema(request.db_name) + + # b. Generate SQL + generation = llm_service.generate_sql(request.question, schema) + sql = generation["sql"] + + # c. Validate SQL + if not sql_service.validate_sql(sql): + raise HTTPException( + status_code=400, + detail="Dangerous or invalid SQL query generated. Only SELECT operations are allowed." + ) + + # d. Execute SQL with auto-retry + retries = 0 + results = None + current_sql = sql + last_error = "" + + while True: + try: + results = sql_service.execute_query(request.db_name, current_sql) + # Success, break loop + break + except Exception as e: + last_error = str(e) + print(f"[API] Query execution failed: {last_error}") + + if retries < config.MAX_SQL_RETRIES: + retries += 1 + print(f"[API] Retrying query fix (Attempt {retries}/{config.MAX_SQL_RETRIES})...") + try: + fix_result = llm_service.fix_sql(current_sql, last_error, schema) + fixed_sql = fix_result["sql"] + + # Validate fixed SQL + if not sql_service.validate_sql(fixed_sql): + raise ValueError("Fixed SQL failed validation check.") + + current_sql = fixed_sql + except Exception as fix_err: + print(f"[API] Failed to generate/validate fix: {fix_err}") + # Keep the loop going, next attempts might fix it or we exit with retries exceeded + else: + # Retries exceeded + print("[API] Maximum SQL retries exceeded. Aborting.") + raise HTTPException( + status_code=422, + detail=f"SQL execution failed after {config.MAX_SQL_RETRIES} retries. Last error: {last_error}" + ) + + # e. Generate insight + insight = insight_service.generate_insight(request.question, current_sql, results) + + # f. Calculate processing time and inject header + process_time_ms = (time.perf_counter() - start_time) * 1000.0 + response.headers["X-Process-Time"] = f"{process_time_ms:.2f}ms" + print(f"[API] Request processed in {process_time_ms:.2f}ms. Header added.") + + # Save to query history + query_histories[request.db_name].append({ + "question": request.question, + "sql": current_sql, + "insight": insight, + "timestamp": datetime.now().isoformat(), + "row_count": results["row_count"] + }) + if len(query_histories[request.db_name]) > 20: + query_histories[request.db_name] = query_histories[request.db_name][-20:] + + return QueryResponse( + question=request.question, + sql=current_sql, + results=SQLResults( + columns=results["columns"], + rows=results["rows"], + row_count=results["row_count"], + execution_time_ms=results["execution_time_ms"] + ), + insight=insight, + retries=retries, + db_name=request.db_name + ) + + except HTTPException as he: + raise he + except Exception as e: + print(f"[API] Internal error in /query pipeline: {e}") + raise HTTPException(status_code=500, detail=f"Internal pipeline error: {str(e)}") + +# 6. GET /datasets/{db_name}/preview +@app.get("/datasets/{db_name}/preview", response_model=SQLResults) +def preview_dataset(db_name: str): + print(f"[API] GET /datasets/{db_name}/preview called") + validate_db_name(db_name) + + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail="Dataset not found.") + + try: + preview_sql = "SELECT * FROM data LIMIT 10" + results = sql_service.execute_query(db_name, preview_sql) + return SQLResults( + columns=results["columns"], + rows=results["rows"], + row_count=results["row_count"], + execution_time_ms=results["execution_time_ms"] + ) + except Exception as e: + print(f"[API] Error in preview for '{db_name}': {e}") + raise HTTPException(status_code=500, detail=f"Failed to generate preview: {str(e)}") + +# 7. GET /health +@app.get("/health", response_model=HealthResponse) +def health_check(): + print("[API] GET /health called") + groq_configured = bool(config.GROQ_API_KEY) + + db_dir_accessible = False + datasets_loaded = 0 + if os.path.exists(config.DB_DIR): + db_dir_accessible = os.access(config.DB_DIR, os.R_OK | os.W_OK) + try: + datasets_loaded = len([f for f in os.listdir(config.DB_DIR) if f.endswith(".db")]) + except Exception: + pass + + status = "healthy" if (groq_configured and db_dir_accessible) else "unhealthy" + + return HealthResponse( + status=status, + groq_configured=groq_configured, + datasets_loaded=datasets_loaded, + sample_datasets=["sales", "employees", "ecommerce"] + ) + +# 8. GET /datasets/{db_name}/suggestions +@app.get("/datasets/{db_name}/suggestions", response_model=SuggestionsResponse) +def get_dataset_suggestions(db_name: str): + print(f"[API] GET /datasets/{db_name}/suggestions called") + validate_db_name(db_name) + + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail="Dataset not found.") + + # Check cache first + if db_name in suggestions_cache: + print(f"[API] Suggestions cache hit for '{db_name}'") + return SuggestionsResponse(db_name=db_name, suggestions=suggestions_cache[db_name]) + + try: + # Retrieve table schema + schema = csv_service.get_table_schema(db_name) + # Generate question suggestions + suggestions = llm_service.generate_question_suggestions(schema) + # Store in cache + suggestions_cache[db_name] = suggestions + + return SuggestionsResponse(db_name=db_name, suggestions=suggestions) + except Exception as e: + print(f"[API] Error generating suggestions for '{db_name}': {e}") + raise HTTPException(status_code=500, detail=f"Failed to generate suggestions: {str(e)}") + +# 9. GET /query/history +@app.get("/query/history", response_model=QueryHistoryResponse) +def get_query_history(db_name: str): + print(f"[API] GET /query/history called for '{db_name}'") + validate_db_name(db_name) + + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + is_schema = False + if not os.path.exists(db_path): + schema_db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db") + if os.path.exists(schema_db_path): + is_schema = True + db_path = schema_db_path + + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail="Dataset not found.") + + history_key = f"schema_{db_name}" if is_schema else db_name + history_list = query_histories.get(history_key, []) + # Map raw history dict items to QueryHistoryItem + items = [ + QueryHistoryItem( + question=h["question"], + sql=h["sql"], + insight=h["insight"], + timestamp=h["timestamp"], + row_count=h["row_count"] + ) for h in history_list + ] + # Return in reverse chronological order (latest queries first) + items.reverse() + + return QueryHistoryResponse( + db_name=db_name, + history=items, + total=len(items) + ) + +# 10. GET /schema-datasets +@app.get("/schema-datasets", response_model=SchemaDatasetsResponse) +def get_schema_datasets(): + print("[API] GET /schema-datasets called") + try: + datasets_list = schema_service.get_all_schema_datasets() + return SchemaDatasetsResponse(datasets=datasets_list, total=len(datasets_list)) + except Exception as e: + print(f"[API] Error in GET /schema-datasets: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +# 11. POST /upload/schema +@app.post("/upload/schema", response_model=SchemaUploadResponse) +def upload_schema_db( + schema_file: UploadFile = File(None), + db_file: UploadFile = File(None), + erd_image: UploadFile = File(None) +): + schema_fn = schema_file.filename if schema_file else "None" + db_fn = db_file.filename if db_file else "None" + erd_fn = erd_image.filename if erd_image else "None" + print(f"[API] POST /upload/schema called with schema: {schema_fn}, db: {db_fn}, erd_image: {erd_fn}") + + if not schema_file and not erd_image: + raise HTTPException( + status_code=400, + detail="Please provide either a .sql schema file or an ERD diagram image" + ) + + if schema_file and not schema_file.filename.endswith(".sql"): + raise HTTPException(status_code=400, detail="Schema file must have a .sql extension.") + + if erd_image: + ext = os.path.splitext(erd_image.filename)[1].lower() + if ext not in (".png", ".jpg", ".jpeg", ".pdf"): + raise HTTPException( + status_code=400, + detail="ERD image must be a .png, .jpg, .jpeg, or .pdf file." + ) + + if db_file and not db_file.filename.endswith((".db", ".sqlite")): + raise HTTPException(status_code=400, detail="Database file must have a .db or .sqlite extension.") + + try: + # Determine db_name from the uploaded db file or fall back to the schema file name + if db_file: + base_name = os.path.splitext(db_file.filename)[0].lower().replace(" ", "_") + elif schema_file: + base_name = os.path.splitext(schema_file.filename)[0].lower().replace(" ", "_") + # Strip common suffixes like _schema, _ddl + for suffix in ["_schema", "_ddl", "_create", "_tables"]: + if base_name.endswith(suffix): + base_name = base_name[:-len(suffix)] + break + else: + base_name = "uploaded_db" + db_name = re.sub(r"[^a-zA-Z0-9_]", "", base_name) + if not db_name: + raise HTTPException(status_code=400, detail="Invalid filename characters.") + + db_dest_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db") + if os.path.exists(db_dest_path): + raise HTTPException(status_code=409, detail="Database dataset already uploaded. Delete it first.") + + # Save uploaded db to a temporary file (if provided) + temp_db_path = None + if db_file: + temp_db_path = os.path.join(config.SCHEMA_DB_DIR, f"temp_{db_name}.db") + with open(temp_db_path, "wb") as buffer: + buffer.write(db_file.file.read()) + + # Optional sql content + schema_content = None + if schema_file: + schema_content = schema_file.file.read().decode("utf-8") + + # Optional image path + temp_img_path = None + if erd_image: + ext = os.path.splitext(erd_image.filename)[1].lower() + temp_img_path = os.path.join(config.SCHEMA_DB_DIR, f"temp_{db_name}{ext}") + with open(temp_img_path, "wb") as buffer: + buffer.write(erd_image.file.read()) + + # Register schema db (uploaded_db_path may be None if building from SQL) + info = schema_service.register_schema_db( + db_name=db_name, + schema_sql_content=schema_content, + uploaded_db_path=temp_db_path, + erd_image_path=temp_img_path + ) + + # Validation check: at least 2 tables detected after cleaning + if info["total_tables"] < 2: + try: + db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db") + sql_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.sql") + json_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.json") + for p in [db_path, sql_path, json_path]: + if os.path.exists(p): + os.remove(p) + except Exception: + pass + raise HTTPException( + status_code=400, + detail="Validation failed: At least 2 tables must be detected in the schema." + ) + + # Clean up temp db and image + if temp_db_path and os.path.exists(temp_db_path): + try: + os.remove(temp_db_path) + except Exception: + pass + if temp_img_path and os.path.exists(temp_img_path): + try: + os.remove(temp_img_path) + except Exception: + pass + + return SchemaUploadResponse( + success=True, + db_name=db_name, + total_tables=info["total_tables"], + total_rows=info["total_rows"], + tables=[t["name"] for t in info["tables"]], + relationships=info["relationships"], + message="Schema database registered successfully" + ) + except HTTPException as he: + raise he + except Exception as e: + print(f"[API] Error in POST /upload/schema: {e}") + # Clean up temp files on error + if 'temp_db_path' in locals() and temp_db_path and os.path.exists(temp_db_path): + try: + os.remove(temp_db_path) + except Exception: + pass + if 'temp_img_path' in locals() and temp_img_path and os.path.exists(temp_img_path): + try: + os.remove(temp_img_path) + except Exception: + pass + raise HTTPException(status_code=500, detail=str(e)) + +# 12. GET /schema-datasets/{db_name}/schema +@app.get("/schema-datasets/{db_name}/schema", response_model=SchemaInfoResponse) +def get_schema_details(db_name: str): + print(f"[API] GET /schema-datasets/{db_name}/schema called") + validate_db_name(db_name) + + try: + info = schema_service.get_schema_db_info(db_name) + return SchemaInfoResponse( + db_name=db_name, + tables=info["tables"], + relationships=info["relationships"], + total_tables=info["total_tables"], + total_rows=info["total_rows"] + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Schema dataset not found.") + except Exception as e: + print(f"[API] Error in GET schema details for '{db_name}': {e}") + raise HTTPException(status_code=500, detail=str(e)) + +# 13. GET /schema-datasets/{db_name}/preview +@app.get("/schema-datasets/{db_name}/preview", response_model=SchemaPreviewsResponse) +def get_schema_preview(db_name: str): + print(f"[API] GET /schema-datasets/{db_name}/preview called") + validate_db_name(db_name) + + try: + info = schema_service.get_schema_db_info(db_name) + previews = [] + + for tbl in info["tables"]: + tbl_name = tbl["name"] + preview_sql = f"SELECT * FROM {tbl_name} LIMIT 5" + results = sql_service.execute_query(db_name, preview_sql, mode="schema") + previews.append({ + "table_name": tbl_name, + "columns": results["columns"], + "rows": results["rows"], + "total_rows": tbl["row_count"] + }) + + return SchemaPreviewsResponse(db_name=db_name, previews=previews) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Schema dataset not found.") + except Exception as e: + print(f"[API] Error in GET schema preview for '{db_name}': {e}") + raise HTTPException(status_code=500, detail=str(e)) + +# 14. POST /schema-query +@app.post("/schema-query", response_model=SchemaQueryResponse) +def run_schema_query(request: SchemaQueryRequest, response: Response): + print(f"[API] POST /schema-query called for db: {request.db_name}, question: '{request.question}'") + start_time = time.perf_counter() + + db_path = os.path.join(config.SCHEMA_DB_DIR, f"{request.db_name}.db") + if not os.path.exists(db_path): + raise HTTPException(status_code=404, detail=f"Database '{request.db_name}' not found.") + + try: + # a. Get schema info + schema_info = schema_service.get_schema_db_info(request.db_name) + known_tables = [t["name"] for t in schema_info["tables"]] + + # b. Generate SQL + generation = llm_service.generate_sql_schema(request.question, schema_info) + sql = generation["sql"] + + # c. Validate SQL + if not sql_service.validate_sql(sql): + raise HTTPException( + status_code=400, + detail="Dangerous or invalid SQL query generated. Only SELECT operations are allowed." + ) + + # d. Execute query with auto-retry + retries = 0 + results = None + current_sql = sql + last_error = "" + + while True: + try: + results = sql_service.execute_query(request.db_name, current_sql, mode="schema") + break + except Exception as e: + last_error = str(e) + print(f"[API] Schema Query execution failed: {last_error}") + + if retries < config.MAX_SQL_RETRIES: + retries += 1 + print(f"[API] Retrying query fix (Attempt {retries}/{config.MAX_SQL_RETRIES})...") + try: + fix_result = llm_service.fix_sql_schema(current_sql, last_error, schema_info) + fixed_sql = fix_result["sql"] + + if not sql_service.validate_sql(fixed_sql): + raise ValueError("Fixed SQL failed validation check.") + current_sql = fixed_sql + except Exception as fix_err: + print(f"[API] Failed to generate/validate fix: {fix_err}") + else: + print("[API] Maximum SQL retries exceeded in schema mode. Aborting.") + raise HTTPException( + status_code=422, + detail=f"SQL execution failed after {config.MAX_SQL_RETRIES} retries. Last error: {last_error}" + ) + + # e. Generate insight + insight = insight_service.generate_insight(request.question, current_sql, results) + + # f. Process time header + process_time_ms = (time.perf_counter() - start_time) * 1000.0 + response.headers["X-Process-Time"] = f"{process_time_ms:.2f}ms" + + # g. Parse tables_used + matches = re.findall(r"\b(?:FROM|JOIN)\s+([a-zA-Z0-9_]+)", current_sql, re.IGNORECASE) + tables_used = [] + for m in matches: + tbl = m.lower().strip() + if tbl in known_tables and tbl not in tables_used: + tables_used.append(tbl) + + # Save to query history + query_histories[f"schema_{request.db_name}"].append({ + "question": request.question, + "sql": current_sql, + "insight": insight, + "timestamp": datetime.now().isoformat(), + "row_count": results["row_count"] + }) + if len(query_histories[f"schema_{request.db_name}"]) > 20: + query_histories[f"schema_{request.db_name}"] = query_histories[f"schema_{request.db_name}"][-20:] + + return SchemaQueryResponse( + question=request.question, + sql=current_sql, + results=SQLResults( + columns=results["columns"], + rows=results["rows"], + row_count=results["row_count"], + execution_time_ms=results["execution_time_ms"] + ), + insight=insight, + tables_used=tables_used, + retries=retries, + db_name=request.db_name + ) + except HTTPException as he: + raise he + except Exception as e: + print(f"[API] Error in run_schema_query: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +# 15. DELETE /schema-datasets/{db_name} +@app.delete("/schema-datasets/{db_name}", response_model=DeleteResponse) +def delete_schema_dataset(db_name: str): + print(f"[API] DELETE /schema-datasets/{db_name} called") + validate_db_name(db_name) + + if db_name == "ecommerce": + raise HTTPException(status_code=403, detail="Cannot delete sample datasets.") + + db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db") + sql_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.sql") + + if not os.path.exists(db_path) or not os.path.exists(sql_path): + raise HTTPException(status_code=404, detail="Schema dataset not found.") + + try: + os.remove(db_path) + os.remove(sql_path) + json_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.json") + if os.path.exists(json_path): + os.remove(json_path) + return DeleteResponse( + success=True, + message=f"Schema dataset '{db_name}' deleted successfully." + ) + except Exception as e: + print(f"[API] Error deleting schema dataset '{db_name}': {e}") + raise HTTPException(status_code=500, detail=str(e)) + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..9e85d161df13d85a608453bc8c4763386dd21ace --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.137.1 +uvicorn==0.49.0 +python-multipart==0.0.32 +pandas==3.0.3 +groq==1.4.0 +python-dotenv==1.2.2 +pydantic==2.13.4 diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..50af76a37c2b1e376c4c57af5e504295ab181d10 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,184 @@ +import re +from pydantic import BaseModel, Field, field_validator +from typing import List, Any + +# 1. Models for GET /datasets +class DatasetInfo(BaseModel): + db_name: str + display_name: str + description: str + columns: List[str] + row_count: int + is_sample: bool + +class DatasetsResponse(BaseModel): + datasets: List[DatasetInfo] + total: int + +# 2. Models for GET /datasets/{db_name}/schema +class ColumnDetail(BaseModel): + name: str + type: str + sample_values: List[Any] + +class DatasetSchemaResponse(BaseModel): + db_name: str + table_name: str + columns: List[ColumnDetail] + row_count: int + +# 3. Model for POST /upload +class UploadResponse(BaseModel): + success: bool + db_name: str + display_name: str + columns: List[str] + row_count: int + message: str + +# 4. Model for DELETE /datasets/{db_name} +class DeleteResponse(BaseModel): + success: bool + message: str + +# 5. Models for POST /query +class SQLResults(BaseModel): + columns: List[str] + rows: List[List[Any]] + row_count: int + execution_time_ms: float + +class QueryRequest(BaseModel): + question: str = Field(..., min_length=3, max_length=300) + db_name: str + + @field_validator("db_name") + @classmethod + def validate_db_name(cls, v: str) -> str: + # Enforce that db_name must only contain alphanumeric characters and underscores + if not re.match(r"^[a-zA-Z0-9_]+$", v): + raise ValueError("db_name must only contain alphanumeric characters and underscores") + return v + +class QueryResponse(BaseModel): + question: str + sql: str + results: SQLResults + insight: str + retries: int + db_name: str + +# 6. Model for GET /health +class HealthResponse(BaseModel): + status: str + groq_configured: bool + datasets_loaded: int + sample_datasets: List[str] + +# Schemas for new Suggestions and History endpoints +class SuggestionsResponse(BaseModel): + db_name: str + suggestions: List[str] + +class QueryHistoryItem(BaseModel): + question: str + sql: str + insight: str + timestamp: str + row_count: int + +class QueryHistoryResponse(BaseModel): + db_name: str + history: List[QueryHistoryItem] + total: int + +# 7. Models for Schema/Relational databases +class SchemaRelationship(BaseModel): + from_table: str + from_column: str + to_table: str + to_column: str + cardinality: Any = None + from_participation: Any = None + to_participation: Any = None + relationship_name: Any = None + +class SchemaDatasetInfo(BaseModel): + db_name: str + display_name: str + description: str + mode: str = "schema" + is_sample: bool + total_tables: int + total_rows: int + tables: List[str] + relationships: List[SchemaRelationship] + +class SchemaDatasetsResponse(BaseModel): + datasets: List[SchemaDatasetInfo] + total: int + +class SchemaColumnDetail(BaseModel): + name: str + type: str + is_primary_key: bool + is_foreign_key: bool + references_table: Any = None + references_column: Any = None + sample_values: List[Any] = [] + +class SchemaTableDetail(BaseModel): + name: str + columns: List[SchemaColumnDetail] + row_count: int + +class SchemaInfoResponse(BaseModel): + db_name: str + mode: str = "schema" + tables: List[SchemaTableDetail] + relationships: List[SchemaRelationship] + total_tables: int + total_rows: int + +class TablePreviewItem(BaseModel): + table_name: str + columns: List[str] + rows: List[List[Any]] + total_rows: int + +class SchemaPreviewsResponse(BaseModel): + db_name: str + previews: List[TablePreviewItem] + +class SchemaQueryRequest(BaseModel): + question: str = Field(..., min_length=3, max_length=300) + db_name: str + + @field_validator("db_name") + @classmethod + def validate_db_name(cls, v: str) -> str: + if not re.match(r"^[a-zA-Z0-9_]+$", v): + raise ValueError("db_name must only contain alphanumeric characters and underscores") + return v + +class SchemaQueryResponse(BaseModel): + question: str + sql: str + mode: str = "schema" + results: SQLResults + insight: str + tables_used: List[str] + retries: int + db_name: str + +class SchemaUploadResponse(BaseModel): + success: bool + db_name: str + mode: str = "schema" + total_tables: int + total_rows: int + tables: List[str] + relationships: List[SchemaRelationship] + message: str + + diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1c716bcb535a4b00bd297ebfac39de4f7772191d --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,11 @@ +from backend.services.csv_service import csv_service +from backend.services.sql_service import sql_service +from backend.services.llm_service import llm_service +from backend.services.insight_service import insight_service + +__all__ = [ + "csv_service", + "sql_service", + "llm_service", + "insight_service", +] diff --git a/backend/services/csv_service.py b/backend/services/csv_service.py new file mode 100644 index 0000000000000000000000000000000000000000..8d8dd62cfccf638a2e5b71718b823798517f114f --- /dev/null +++ b/backend/services/csv_service.py @@ -0,0 +1,196 @@ +import os +import sqlite3 +import pandas as pd +from typing import List, Dict, Any +from backend import config + +class CSVService: + def __init__(self): + print("[CSVService] Initializing CSVService singleton...") + # Ensure directories exist + os.makedirs(config.DB_DIR, exist_ok=True) + os.makedirs(config.SAMPLE_DATA_DIR, exist_ok=True) + + def _infer_column_type(self, series: pd.Series) -> str: + """ + Helper method to infer SQLite-compatible column types from a pandas Series. + """ + try: + # If dtype is numeric, check if integer or float + if pd.api.types.is_integer_dtype(series): + return "INTEGER" + elif pd.api.types.is_float_dtype(series): + return "REAL" + + # Check if it looks like a datetime + # We try to convert to datetime. If it succeeds without producing more than 10% NaNs (on non-empty series), it's likely a date/time. + non_null_series = series.dropna() + if not non_null_series.empty: + try: + converted = pd.to_datetime(non_null_series, errors='coerce') + null_pct = converted.isna().sum() / len(non_null_series) + if null_pct < 0.1: + return "DATE" + except Exception: + pass + + return "TEXT" + except Exception as e: + print(f"[CSVService] Error inferring type: {e}. Defaulting to TEXT.") + return "TEXT" + + def load_csv_to_sqlite(self, file_path: str, db_name: str) -> dict: + """ + Reads a CSV with pandas, infers column types, creates a SQLite database, + loads the dataframe into a table named 'data', and returns schema metadata. + """ + print(f"[CSVService] Loading CSV from {file_path} into database {db_name}.db") + try: + # Read CSV + df = pd.read_csv(file_path) + + # Remove any leading/trailing whitespaces in string columns and header names + df.columns = [col.strip() for col in df.columns] + for col in df.select_dtypes(include=['object']): + df[col] = df[col].astype(str).str.strip() + + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + conn = sqlite3.connect(db_path) + + # Load into SQLite table 'data' + df.to_sql("data", conn, if_exists="replace", index=False) + + # Determine columns metadata + columns_metadata = [] + for col in df.columns: + inferred_type = self._infer_column_type(df[col]) + # Convert numpy values to native Python types for JSON serialization + sample_vals = df[col].dropna().unique()[:3] + sample_vals_list = [] + for val in sample_vals: + if hasattr(val, "item"): + sample_vals_list.append(val.item()) + else: + sample_vals_list.append(val) + + columns_metadata.append({ + "name": col, + "type": inferred_type, + "sample_values": sample_vals_list + }) + + row_count = len(df) + conn.close() + + result = { + "db_name": db_name, + "table_name": "data", + "columns": columns_metadata, + "row_count": row_count + } + print(f"[CSVService] Successfully loaded {row_count} rows into {db_name}.db table 'data'") + return result + + except Exception as e: + print(f"[CSVService] Failed to load CSV {file_path} to SQLite: {e}") + raise ValueError(f"Failed to load CSV to SQLite: {str(e)}") + + def get_table_schema(self, db_name: str) -> dict: + """ + Connects to db_name.db and retrieves the table 'data' schema details. + """ + print(f"[CSVService] Retrieving table schema for {db_name}.db") + try: + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + if not os.path.exists(db_path): + raise FileNotFoundError(f"Database {db_name}.db does not exist.") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check row count + cursor.execute("SELECT COUNT(*) FROM data") + row_count = cursor.fetchone()[0] + + # Retrieve table schema info + cursor.execute("PRAGMA table_info(data)") + columns_info = cursor.fetchall() + + columns = [] + for col_info in columns_info: + col_name = col_info[1] + col_type = col_info[2] + + # Fetch up to 3 unique non-null sample values from SQLite + cursor.execute(f'SELECT DISTINCT "{col_name}" FROM data WHERE "{col_name}" IS NOT NULL LIMIT 3') + sample_vals = [row[0] for row in cursor.fetchall()] + + columns.append({ + "name": col_name, + "type": col_type, + "sample_values": sample_vals + }) + + conn.close() + + schema = { + "table_name": "data", + "columns": columns, + "row_count": row_count + } + print(f"[CSVService] Retrieved schema for {db_name}.db with {row_count} rows.") + return schema + + except Exception as e: + print(f"[CSVService] Failed to retrieve table schema for {db_name}: {e}") + raise ValueError(f"Failed to retrieve table schema: {str(e)}") + + def get_sample_datasets(self) -> List[dict]: + """ + Reads all CSVs in sample_data/ folder. Load them to SQLite if not already loaded, + and returns details of each. + """ + print("[CSVService] Fetching sample datasets...") + try: + datasets = [] + descriptions = { + "sales": "Monthly sales data across products, regions, and salespeople", + "employees": "Company HR data with salaries, departments, and performance", + "ecommerce": "Online store orders with products, customers, and payments" + } + + if not os.path.exists(config.SAMPLE_DATA_DIR): + print(f"[CSVService] Sample data directory {config.SAMPLE_DATA_DIR} does not exist.") + return [] + + for filename in os.listdir(config.SAMPLE_DATA_DIR): + if filename.endswith(".csv"): + name = os.path.splitext(filename)[0] + file_path = os.path.join(config.SAMPLE_DATA_DIR, filename) + db_path = os.path.join(config.DB_DIR, f"{name}.db") + + display_name = name.capitalize() + description = descriptions.get(name, f"Sample dataset containing {name} records") + + # If not already loaded to SQLite, load it + if not os.path.exists(db_path): + print(f"[CSVService] Pre-loading sample CSV: {filename}") + schema_info = self.load_csv_to_sqlite(file_path, name) + else: + schema_info = self.get_table_schema(name) + + datasets.append({ + "name": name, + "display_name": display_name, + "description": description, + "columns": schema_info["columns"], + "row_count": schema_info["row_count"] + }) + + return datasets + except Exception as e: + print(f"[CSVService] Failed to fetch sample datasets: {e}") + raise ValueError(f"Failed to fetch sample datasets: {str(e)}") + +# Singleton instance +csv_service = CSVService() diff --git a/backend/services/insight_service.py b/backend/services/insight_service.py new file mode 100644 index 0000000000000000000000000000000000000000..2c0c699bddfe937521eeef9c038aa4999ba1853b --- /dev/null +++ b/backend/services/insight_service.py @@ -0,0 +1,77 @@ +import os +import json +from groq import Groq +from typing import Dict, Any +from backend import config + +class InsightService: + def __init__(self): + print("[InsightService] Initializing InsightService singleton...") + self.client = None + + def _get_client(self) -> Groq: + if self.client is None: + if not config.GROQ_API_KEY: + raise ValueError("Groq API Key is not set in the environment or configuration.") + self.client = Groq(api_key=config.GROQ_API_KEY) + return self.client + + def generate_insight(self, question: str, sql: str, results: dict) -> str: + """ + Generates a 2-3 sentence human insight from the user's question, + the SQL query executed, and the query results (limiting to first 10 rows). + """ + print(f"[InsightService] Generating insight for question: '{question}'") + try: + client = self._get_client() + + # 1. Format first 10 rows of results for the prompt + columns = results.get("columns", []) + raw_rows = results.get("rows", []) + first_10_rows = raw_rows[:10] + + # Map columns to values for a friendly JSON representation + formatted_rows = [] + for row in first_10_rows: + formatted_rows.append(dict(zip(columns, row))) + + results_json_str = json.dumps(formatted_rows, indent=2, default=str) + + system_prompt = ( + "You are an expert data analyst who explains database results in clear, natural language.\n" + "Your insights must be factual, directly answering the user's question based ONLY on the provided database results.\n" + "Provide a concise summary (2-3 sentences) pointing out specific numbers, trends, or comparisons from the results.\n" + "Do not start with 'Based on the data' or 'The data shows' - just state the findings directly.\n" + "Do not include any greeting, markdown formatting (other than numbers/currency), or meta-explanation." + ) + + user_prompt = ( + f"Given this question: '{question}'\n" + f"And this SQL query: '{sql}'\n" + f"And these results (first 10 rows):\n" + f"{results_json_str}\n\n" + f"Write 2-3 sentences of insight about what the data shows. " + f"Be specific — mention actual numbers, trends, or comparisons from the results. " + f"Do not say 'the data shows' — just state the findings directly." + ) + + print("[InsightService] Sending insight request to Groq...") + completion = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.3 # slightly higher temperature for smooth natural language generation + ) + + insight = completion.choices[0].message.content.strip() + print(f"[InsightService] Generated insight: {insight}") + return insight + + except Exception as e: + print(f"[InsightService] Error generating insight: {e}") + raise ValueError(f"Failed to generate insight: {str(e)}") + +# Singleton instance +insight_service = InsightService() diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py new file mode 100644 index 0000000000000000000000000000000000000000..45bf7b6f1ea52328e8f80dfcf5ef6e0d993875b2 --- /dev/null +++ b/backend/services/llm_service.py @@ -0,0 +1,446 @@ +import os +from groq import Groq +from typing import Dict, Any +from backend import config + +class LLMService: + def __init__(self): + print("[LLMService] Initializing LLMService singleton...") + # Since Groq client requires an API key, we will instantiate it lazily or during init + # If API key is empty/missing, it will raise an error later when invoked. + self.client = None + + def _get_client(self) -> Groq: + if self.client is None: + if not config.GROQ_API_KEY: + raise ValueError("Groq API Key is not set in the environment or configuration.") + self.client = Groq(api_key=config.GROQ_API_KEY) + return self.client + + def _clean_sql_response(self, response: str) -> str: + """ + Cleans the raw response from the LLM, stripping markdown code blocks, + backticks, and any leading/trailing whitespace. + """ + cleaned = response.strip() + + # Strip code blocks starting with ``` (e.g. ```sql or ```) + if cleaned.startswith("```"): + lines = cleaned.split("\n") + sql_lines = [line for line in lines if not line.strip().startswith("```")] + cleaned = "\n".join(sql_lines).strip() + + # Strip single backticks + cleaned = cleaned.strip("`").strip() + + # Sometimes LLMs add an ending semicolon, which is fine, but let's keep it clean + return cleaned + + def generate_sql(self, question: str, schema: dict) -> Dict[str, Any]: + """ + Generates SQLite SELECT query based on the user question and database schema. + """ + print(f"[LLMService] Generating SQL for question: '{question}'") + try: + client = self._get_client() + + # Format the columns section of the prompt + columns_prompt = [] + for col in schema.get("columns", []): + name = col.get("name") + col_type = col.get("type") + sample_vals = col.get("sample_values", []) + columns_prompt.append(f"- {name} ({col_type}): examples: {sample_vals}") + + columns_str = "\n".join(columns_prompt) + + system_prompt = ( + "You are an expert SQL analyst. Generate SQLite-compatible SELECT queries only.\n" + "Never use INSERT, UPDATE, DELETE, DROP, or any destructive operations.\n" + "Always use the exact table name 'data'.\n" + "For date columns, use SQLite date functions (strftime, date())\n" + "For aggregations, always include ORDER BY to make results meaningful\n" + "Limit results to 20 rows maximum unless the question asks for all data\n" + "Always alias aggregated columns with readable names (e.g. SUM(revenue) AS total_revenue)\n" + "Return ONLY the SQL query, no explanation, no markdown, no backticks." + ) + + user_prompt = ( + f"Table schema:\n" + f"Table name: data\n" + f"Columns:\n" + f"{columns_str}\n\n" + f"Question: {question}\n\n" + f"Write a SQLite SELECT query to answer this question." + ) + + print("[LLMService] Sending generation request to Groq...") + completion = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.0 + ) + + raw_response = completion.choices[0].message.content + sql = self._clean_sql_response(raw_response) + + print(f"[LLMService] Generated SQL:\n{sql}") + return { + "sql": sql, + "raw_response": raw_response + } + + except Exception as e: + print(f"[LLMService] Error generating SQL: {e}") + raise ValueError(f"Failed to generate SQL: {str(e)}") + + def fix_sql(self, original_sql: str, error: str, schema: dict) -> Dict[str, str]: + """ + Takes a failed SQL query, the SQLite execution error, and the database schema, + and requests the LLM to return a corrected query. + """ + print(f"[LLMService] Fixing SQL query: '{original_sql}' due to error: '{error}'") + try: + client = self._get_client() + + # Format the columns section of the prompt + columns_prompt = [] + for col in schema.get("columns", []): + name = col.get("name") + col_type = col.get("type") + sample_vals = col.get("sample_values", []) + columns_prompt.append(f"- {name} ({col_type}): examples: {sample_vals}") + + columns_str = "\n".join(columns_prompt) + + system_prompt = ( + "You are an expert SQL analyst. Correct the failing SQL query.\n" + "Generate SQLite-compatible SELECT queries only.\n" + "Never use INSERT, UPDATE, DELETE, DROP, or any destructive operations.\n" + "Always use the exact table name 'data'.\n" + "Return ONLY the corrected SQL query, no explanation, no markdown, no backticks." + ) + + user_prompt = ( + f"Table schema:\n" + f"Table name: data\n" + f"Columns:\n" + f"{columns_str}\n\n" + f"This SQL query failed:\n" + f"{original_sql}\n\n" + f"Error details:\n" + f"{error}\n\n" + f"Fix the query and write a correct SQLite SELECT query." + ) + + print("[LLMService] Sending fix request to Groq...") + completion = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.0 + ) + + raw_response = completion.choices[0].message.content + fixed_sql = self._clean_sql_response(raw_response) + + print(f"[LLMService] Fixed SQL:\n{fixed_sql}") + return { + "sql": fixed_sql + } + + except Exception as e: + print(f"[LLMService] Error fixing SQL: {e}") + raise ValueError(f"Failed to fix SQL: {str(e)}") + + def generate_question_suggestions(self, schema: dict) -> list: + """ + Generates 6 analytical questions that a business user might ask about the table dataset. + """ + print("[LLMService] Generating question suggestions from schema...") + try: + client = self._get_client() + + # Format the columns section + columns_prompt = [] + for col in schema.get("columns", []): + name = col.get("name") + col_type = col.get("type") + sample_vals = col.get("sample_values", []) + columns_prompt.append(f"- {name} ({col_type}): examples: {sample_vals}") + + columns_str = "\n".join(columns_prompt) + + system_prompt = ( + "You are an expert data analyst assistant. Generate interesting question suggestions for a database schema.\n" + "Return ONLY a JSON array of strings, nothing else. No explanation, no markdown code block, no backticks." + ) + + user_prompt = ( + f"Given a table with these columns:\n" + f"{columns_str}\n\n" + f"generate 6 interesting analytical questions a business user might ask.\n" + f"Return ONLY a JSON array of strings, nothing else." + ) + + completion = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.5 + ) + + raw_response = completion.choices[0].message.content.strip() + print(f"[LLMService] Raw suggestions response: {raw_response}") + + # Strip markdown block wraps if present + cleaned = raw_response + if cleaned.startswith("```"): + lines = cleaned.split("\n") + content_lines = [line for line in lines if not line.strip().startswith("```")] + cleaned = "".join(content_lines).strip() + cleaned = cleaned.strip("`").strip() + + import json + suggestions = json.loads(cleaned) + if isinstance(suggestions, list) and len(suggestions) >= 6: + return suggestions[:6] + else: + raise ValueError("Response is not a valid list of 6 suggestions.") + except Exception as e: + print(f"[LLMService] Error generating suggestions: {e}. Falling back to default list.") + # safe fallback suggestions + return [ + "What is the total number of records?", + "Show the first 10 rows of data.", + "Summary statistics of numeric columns.", + "How are the categories distributed?", + "Which column values have the highest values?", + "Filter and search records." + ] + + def generate_sql_schema(self, question: str, schema_info: dict) -> dict: + """ + Generates SQLite SELECT query using JOINs based on the user question and database relational schema. + """ + print(f"[LLMService] Generating SQL (schema mode) for question: '{question}'") + try: + client = self._get_client() + + # Format database schema tables + tables_prompt = [] + for tbl in schema_info.get("tables", []): + tbl_name = tbl.get("name") + tbl_rows = tbl.get("row_count", 0) + + columns_list = [] + for col in tbl.get("columns", []): + name = col.get("name") + col_type = col.get("type") + is_pk = col.get("is_primary_key", False) + is_fk = col.get("is_foreign_key", False) + ref_tbl = col.get("references_table") + ref_col = col.get("references_column") + + pk_str = " [PRIMARY KEY]" if is_pk else "" + fk_str = f" [FK → {ref_tbl}.{ref_col}]" if is_fk and ref_tbl and ref_col else "" + columns_list.append(f" - {name} ({col_type}){pk_str}{fk_str}") + + columns_str = "\n".join(columns_list) + + # Fetch sample rows and format as clean text + sample_vals = [] + col_samples_len = max([len(c.get("sample_values", [])) for c in tbl.get("columns", [])]) if tbl.get("columns") else 0 + for r_idx in range(min(col_samples_len, 3)): + row_val = {} + for col in tbl.get("columns", []): + samples = col.get("sample_values", []) + if r_idx < len(samples): + row_val[col["name"]] = samples[r_idx] + sample_vals.append(row_val) + + tables_prompt.append( + f"Table: {tbl_name} ({tbl_rows} rows)\n" + f"Columns:\n" + f"{columns_str}\n" + f"Sample data (first 3 rows): {str(sample_vals)}" + ) + + tables_str = "\n\n".join(tables_prompt) + + # Format relationships + rel_prompt = [] + for rel in schema_info.get("relationships", []): + from_table = rel.get("from_table") + from_column = rel.get("from_column") + to_table = rel.get("to_table") + to_column = rel.get("to_column") + cardinality = rel.get("cardinality", "1:N") + from_participation = rel.get("from_participation", "total") + to_participation = rel.get("to_participation", "partial") + rel_prompt.append( + f" {from_table}.{from_column} → {to_table}.{to_column}\n" + f" Type: {cardinality} | {from_table} participation: {from_participation} | {to_table} participation: {to_participation}" + ) + + relationships_str = "\n".join(rel_prompt) + + system_prompt = ( + "You are an expert SQL analyst working with a relational SQLite database.\n" + "Generate SELECT queries only. Never use INSERT, UPDATE, DELETE, DROP, or ALTER.\n" + "Use JOINs when the question requires data from multiple tables.\n" + "Use meaningful table aliases: c for customers, o for orders, " + "p for products, oi for order_items, r for reviews.\n" + "Always alias aggregated columns with readable names.\n" + "Limit results to 20 rows unless the question specifically asks for all.\n" + "Return ONLY the raw SQL query — no explanation, no markdown, no backticks." + ) + + user_prompt = ( + f"Database schema:\n\n" + f"{tables_str}\n\n" + f"Relationships:\n" + f"{relationships_str}\n\n" + f"Question: {question}\n\n" + f"Write a SQLite SELECT query using JOINs as needed." + ) + + print("[LLMService] Sending generation request to Groq...") + completion = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.0 + ) + + raw_response = completion.choices[0].message.content + sql = self._clean_sql_response(raw_response) + + print(f"[LLMService] Generated Schema SQL:\n{sql}") + return { + "sql": sql, + "raw_response": raw_response + } + + except Exception as e: + print(f"[LLMService] Error generating Schema SQL: {e}") + raise ValueError(f"Failed to generate SQL for schema database: {str(e)}") + + def fix_sql_schema(self, original_sql: str, error: str, schema_info: dict) -> dict: + """ + Takes a failing schema-mode SQL query, the execution error, and the schema info, + and requests the LLM to correct the query. + """ + print(f"[LLMService] Fixing SQL (schema mode): '{original_sql}' due to error: '{error}'") + try: + client = self._get_client() + + # Format database schema tables + tables_prompt = [] + for tbl in schema_info.get("tables", []): + tbl_name = tbl.get("name") + tbl_rows = tbl.get("row_count", 0) + + columns_list = [] + for col in tbl.get("columns", []): + name = col.get("name") + col_type = col.get("type") + is_pk = col.get("is_primary_key", False) + is_fk = col.get("is_foreign_key", False) + ref_tbl = col.get("references_table") + ref_col = col.get("references_column") + + pk_str = " [PRIMARY KEY]" if is_pk else "" + fk_str = f" [FK → {ref_tbl}.{ref_col}]" if is_fk and ref_tbl and ref_col else "" + columns_list.append(f" - {name} ({col_type}){pk_str}{fk_str}") + + columns_str = "\n".join(columns_list) + + col_samples_len = max([len(c.get("sample_values", [])) for c in tbl.get("columns", [])]) if tbl.get("columns") else 0 + sample_vals = [] + for r_idx in range(min(col_samples_len, 3)): + row_val = {} + for col in tbl.get("columns", []): + samples = col.get("sample_values", []) + if r_idx < len(samples): + row_val[col["name"]] = samples[r_idx] + sample_vals.append(row_val) + + tables_prompt.append( + f"Table: {tbl_name} ({tbl_rows} rows)\n" + f"Columns:\n" + f"{columns_str}\n" + f"Sample data (first 3 rows): {str(sample_vals)}" + ) + + tables_str = "\n\n".join(tables_prompt) + + # Format relationships + rel_prompt = [] + for rel in schema_info.get("relationships", []): + from_table = rel.get("from_table") + from_column = rel.get("from_column") + to_table = rel.get("to_table") + to_column = rel.get("to_column") + cardinality = rel.get("cardinality", "1:N") + from_participation = rel.get("from_participation", "total") + to_participation = rel.get("to_participation", "partial") + rel_prompt.append( + f" {from_table}.{from_column} → {to_table}.{to_column}\n" + f" Type: {cardinality} | {from_table} participation: {from_participation} | {to_table} participation: {to_participation}" + ) + + relationships_str = "\n".join(rel_prompt) + + system_prompt = ( + "You are an expert SQL analyst. Correct the failing SQLite SQL query.\n" + "Generate SELECT queries only. Never use INSERT, UPDATE, DELETE, DROP, or ALTER.\n" + "Return ONLY the corrected SQL query, no explanation, no markdown, no backticks." + ) + + user_prompt = ( + f"Database schema:\n\n" + f"{tables_str}\n\n" + f"Relationships:\n" + f"{relationships_str}\n\n" + f"This SQL query failed:\n" + f"{original_sql}\n\n" + f"Error details:\n" + f"{error}\n\n" + f"Fix the query and write a correct SQLite SELECT query." + ) + + print("[LLMService] Sending fix request to Groq...") + completion = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.0 + ) + + raw_response = completion.choices[0].message.content + fixed_sql = self._clean_sql_response(raw_response) + + print(f"[LLMService] Fixed Schema SQL:\n{fixed_sql}") + return { + "sql": fixed_sql + } + + except Exception as e: + print(f"[LLMService] Error fixing Schema SQL: {e}") + raise ValueError(f"Failed to fix SQL: {str(e)}") + +# Singleton instance +llm_service = LLMService() + diff --git a/backend/services/schema_service.py b/backend/services/schema_service.py new file mode 100644 index 0000000000000000000000000000000000000000..74e5113c4a80b581c4d1a264140d5228e3e1b702 --- /dev/null +++ b/backend/services/schema_service.py @@ -0,0 +1,923 @@ +import os +import re +import sqlite3 +import shutil +import base64 +import mimetypes +import json +from typing import Dict, Any, List +from groq import Groq +from backend import config + +class SchemaService: + def __init__(self): + print("[SchemaService] Initializing SchemaService singleton...") + os.makedirs(config.SCHEMA_DB_DIR, exist_ok=True) + + def clean_tsql_to_sqlite(self, raw_sql: str) -> tuple: + """ + Takes a raw SQL script (possibly T-SQL / SQL Server syntax) and returns + a clean SQLite-compatible SQL string plus any extra relationships extracted + from ALTER TABLE statements. + + Returns: + tuple: (cleaned_sql: str, extra_relationships: list) + """ + print("[SchemaService] Running T-SQL to SQLite conversion...") + + # Normalize line endings + sql = raw_sql.replace('\r\n', '\n') + + # Remove comments containing T-SQL specific syntax hints + sql = re.sub(r'/\*[^*]*(?:WITH\s*\(\s*NOLOCK\s*\)|NOLOCK)[^*]*\*/', '', sql, flags=re.IGNORECASE) + + # STEP 1 — Remove SQL Server specific statements entirely + # Remove IF NOT EXISTS ... BEGIN ... END blocks (multi-line, non-greedy) + sql = re.sub( + r'(?smi)\bIF\s+NOT\s+EXISTS\s*\(.*?\)\s*BEGIN\s*.*?END\s*;?', + '', sql + ) + + # Remove IF OBJECT_ID(...) IS NOT NULL DROP TABLE ... blocks + sql = re.sub( + r'(?smi)\bIF\s+OBJECT_ID\s*\(.*?\)\s+IS\s+NOT\s+NULL\s+DROP\s+TABLE\s+[^;\n]+;?', + '', sql + ) + + # Remove other IF EXISTS ... BEGIN ... END blocks + sql = re.sub( + r'(?smi)\bIF\s+EXISTS\s*\(.*?\)\s*BEGIN\s*.*?END\s*;?', + '', sql + ) + + # Remove lines containing specific SQL Server statements entirely + lines = sql.split('\n') + cleaned_lines = [] + for line in lines: + stripped = line.strip().upper() + + # Skip GO batch separator + if stripped == 'GO' or stripped == 'GO;': + continue + # Skip PRINT statements + if 'PRINT' in stripped: + continue + # Skip USE statements + if 'USE ' in stripped or re.search(r'\bUSE\b', stripped): + continue + # Skip lines referencing system objects + if any(kw in stripped for kw in ['SYS.TABLES', 'SYS.DATABASES', 'SYS.OBJECTS']): + continue + # Skip OBJECT_ID references + if 'OBJECT_ID(' in stripped: + continue + # Skip SET NOCOUNT, SET ANSI, SET QUOTED + if any(kw in stripped for kw in ['SET NOCOUNT', 'SET ANSI', 'SET QUOTED']): + continue + # Skip EXEC / EXECUTE statements + if 'EXEC' in re.findall(r'\bEXEC\b', stripped) or 'EXECUTE' in re.findall(r'\bEXECUTE\b', stripped): + continue + + # Remove WITH (NOLOCK) hints inline (keep the rest of the line) + line = re.sub(r'\bWITH\s*\(\s*NOLOCK\s*\)', '', line, flags=re.IGNORECASE) + cleaned_lines.append(line) + + sql = '\n'.join(cleaned_lines) + + # Let's split into statements by semicolon, clean each, and rejoin + statements = re.split(r';', sql) + cleaned_statements = [] + extra_relationships = [] + + # ALTER TABLE foreign key constraint pattern + alter_fk_pattern = re.compile( + r'ALTER\s+TABLE\s+(\w+)\s+ADD\s+CONSTRAINT\s+\w+\s+' + r'FOREIGN\s+KEY\s*\(\s*(\w+)\s*\)\s*' + r'REFERENCES\s+(\w+)\s*\(\s*(\w+)\s*\)', + re.IGNORECASE | re.DOTALL + ) + + # Helper to clean CHECK constraints that reference T-SQL functions or subqueries + def clean_check_constraints(sql_chunk: str) -> str: + pos = 0 + while True: + match = re.search(r'\bCHECK\b', sql_chunk[pos:], re.IGNORECASE) + if not match: + break + start_idx = pos + match.start() + open_paren_idx = sql_chunk.find('(', start_idx) + if open_paren_idx == -1: + pos = start_idx + 5 + continue + paren_depth = 0 + close_paren_idx = -1 + for i in range(open_paren_idx, len(sql_chunk)): + if sql_chunk[i] == '(': + paren_depth += 1 + elif sql_chunk[i] == ')': + paren_depth -= 1 + if paren_depth == 0: + close_paren_idx = i + break + if close_paren_idx == -1: + pos = start_idx + 5 + continue + + check_expr = sql_chunk[start_idx:close_paren_idx + 1] + if any(kw in check_expr.upper() for kw in ['GETDATE', 'SYSDATETIME', 'GETUTCDATE', 'NEWID', 'SELECT']): + # Remove the CHECK expression + sql_chunk = sql_chunk[:start_idx] + sql_chunk[close_paren_idx + 1:] + pos = start_idx + else: + pos = close_paren_idx + 1 + return sql_chunk + + for stmt in statements: + stmt = stmt.strip() + if not stmt: + continue + + # If it's just comments, keep it as is + lines_only = re.sub(r'--.*$', '', stmt, flags=re.MULTILINE).strip() + lines_only = re.sub(r'/\*.*?\*/', '', lines_only, flags=re.DOTALL).strip() + if not lines_only: + cleaned_statements.append(stmt + ';') + continue + + # Discard statement if it contains leftovers from line removal or system tables + stmt_upper = stmt.upper() + if any(kw in stmt_upper for kw in ['SYS.TABLES', 'SYS.DATABASES', 'SYS.OBJECTS', 'OBJECT_ID(']): + continue + if stmt_upper.startswith('SELECT') and 'FROM' not in stmt_upper: + continue + + # STEP 6 — Handle schema prefixes and brackets (run early so ALTER TABLE matches clean names) + stmt = re.sub(r'\[dbo\]\.\[([^\]]+)\]', r'\1', stmt, flags=re.IGNORECASE) + stmt = re.sub(r'\bdbo\.\[([^\]]+)\]', r'\1', stmt, flags=re.IGNORECASE) + stmt = re.sub(r'\[dbo\]\.(\w+)', r'\1', stmt, flags=re.IGNORECASE) + stmt = re.sub(r'\bdbo\.(\w+)', r'\1', stmt, flags=re.IGNORECASE) + stmt = re.sub(r'\[([^\]]+)\]', r'\1', stmt) + + # STEP 3 — Handle ALTER TABLE ADD CONSTRAINT FOREIGN KEY statements + m = alter_fk_pattern.search(stmt) + if m: + extra_relationships.append({ + "from_table": m.group(1).lower(), + "from_column": m.group(2).lower(), + "to_table": m.group(3).lower(), + "to_column": m.group(4).lower(), + "source": "alter_table" + }) + # Skip writing the ALTER TABLE statement + continue + + # Also skip other ALTER TABLE constraints SQLite doesn't support + if re.search(r'\bALTER\s+TABLE\s+\w+\s+ADD\s+CONSTRAINT\b', stmt, re.IGNORECASE): + continue + + # STEP 2 — Convert data types + type_map = [ + (r'\bDATETIME2\b', 'TEXT'), + (r'\bDATETIME\b', 'TEXT'), + (r'\bSMALLDATETIME\b', 'TEXT'), + (r'\bNVARCHAR\s*\(\s*MAX\s*\)', 'TEXT'), + (r'\bVARCHAR\s*\(\s*MAX\s*\)', 'TEXT'), + (r'\bNVARCHAR\s*\((\s*\d+\s*)\)', r'VARCHAR(\1)'), + (r'\bNCHAR\s*\((\s*\d+\s*)\)', r'CHAR(\1)'), + (r'\bNTEXT\b', 'TEXT'), + (r'\bUNIQUEIDENTIFIER\b', 'TEXT'), + (r'\bSMALLMONEY\b', 'DECIMAL(6,2)'), + (r'\bMONEY\b', 'DECIMAL(15,2)'), + (r'\bTINYINT\b', 'INTEGER'), + (r'\bSMALLINT\b', 'INTEGER'), + (r'\bBIGINT\b', 'INTEGER'), + (r'\bBIT\b', 'INTEGER'), + (r'\bVARBINARY\s*\([^)]*\)', 'BLOB'), + (r'\bVARBINARY\b', 'BLOB'), + (r'\bIMAGE\b', 'BLOB'), + (r'\bFLOAT\b', 'REAL'), + (r'\bREAL\b', 'REAL'), + ] + for pattern, replacement in type_map: + stmt = re.sub(pattern, replacement, stmt, flags=re.IGNORECASE) + + # STEP 4 — Handle CHECK constraints + stmt = clean_check_constraints(stmt) + + # Clean up commas and spacing inside statement + # Remove multiple commas: e.g. ", ," to "," + stmt = re.sub(r',\s*,', ',', stmt) + # Remove trailing comma before closing parenthesis: e.g. ", )" to ")" + stmt = re.sub(r',\s*\)', ')', stmt) + + stmt = stmt.strip() + if stmt: + cleaned_statements.append(stmt + ';') + + sql = '\n\n'.join(cleaned_statements) + + # Remove multiple consecutive blank lines + sql = re.sub(r'\n{3,}', '\n\n', sql).strip() + + print(f"[SchemaService] T-SQL conversion complete. Extracted {len(extra_relationships)} ALTER TABLE relationships.") + return (sql, extra_relationships) + + + def parse_schema_sql(self, schema_sql_content: str) -> dict: + """ + Parses the raw DDL schema text using regex to extract all tables, + columns, primary keys, and foreign keys. + """ + print("[SchemaService] Parsing SQL schema content...") + # 1. Clean the SQL file comments and whitespace + sql_clean = re.sub(r"--.*?\n", "\n", schema_sql_content) + sql_clean = re.sub(r"/\*.*?\*/", "", sql_clean, flags=re.DOTALL) + + # 2. Match CREATE TABLE statements + table_matches = re.finditer(r"CREATE\s+TABLE\s+(\w+)\s*\((.*?)\);", sql_clean, re.IGNORECASE | re.DOTALL) + + tables = [] + relationships = [] + + for match in table_matches: + table_name = match.group(1).lower().strip() + inner_content = match.group(2).strip() + + # Split definitions by comma, ignoring nested commas inside parentheses (e.g. DECIMAL(10,2)) + defs = [] + current = [] + paren_count = 0 + for char in inner_content: + if char == '(': + paren_count += 1 + current.append(char) + elif char == ')': + paren_count -= 1 + current.append(char) + elif char == ',' and paren_count == 0: + defs.append("".join(current).strip()) + current = [] + else: + current.append(char) + if current: + defs.append("".join(current).strip()) + + columns = [] + table_fk_constraints = [] + + for d in defs: + if not d: + continue + + # Check if this line is a table-level FOREIGN KEY constraint: + # e.g., FOREIGN KEY (customer_id) REFERENCES customers(customer_id) + fk_match = re.search(r"FOREIGN\s+KEY\s*\(\s*(\w+)\s*\)\s*REFERENCES\s*(\w+)\s*\(\s*(\w+)\s*\)", d, re.IGNORECASE) + if fk_match: + from_col = fk_match.group(1).lower().strip() + to_tbl = fk_match.group(2).lower().strip() + to_col = fk_match.group(3).lower().strip() + table_fk_constraints.append({ + "from_column": from_col, + "to_table": to_tbl, + "to_column": to_col + }) + relationships.append({ + "from_table": table_name, + "from_column": from_col, + "to_table": to_tbl, + "to_column": to_col + }) + continue + + # Check if this line is a table-level PRIMARY KEY constraint: + pk_match = re.search(r"PRIMARY\s+KEY\s*\(\s*(\w+)\s*\)", d, re.IGNORECASE) + if pk_match: + pk_col = pk_match.group(1).lower().strip() + for col in columns: + if col["name"] == pk_col: + col["is_primary_key"] = True + continue + + # Otherwise, it's a column definition + parts = d.split() + if not parts: + continue + col_name = parts[0].lower().strip() + col_name = col_name.strip("`\"'") + + col_type = parts[1].upper().strip() if len(parts) > 1 else "TEXT" + col_type = re.sub(r"\(.*?\)", "", col_type) + + is_pk = False + if "PRIMARY" in d.upper() and "KEY" in d.upper() and "FOREIGN" not in d.upper(): + is_pk = True + + inline_ref = re.search(r"REFERENCES\s+(\w+)\s*\(\s*(\w+)\s*\)", d, re.IGNORECASE) + + col_info = { + "name": col_name, + "type": col_type, + "is_primary_key": is_pk, + "is_foreign_key": False, + "references_table": None, + "references_column": None, + "is_not_null": "NOT NULL" in d.upper() or is_pk + } + + if inline_ref: + col_info["is_foreign_key"] = True + col_info["references_table"] = inline_ref.group(1).lower().strip() + col_info["references_column"] = inline_ref.group(2).lower().strip() + relationships.append({ + "from_table": table_name, + "from_column": col_name, + "to_table": col_info["references_table"], + "to_column": col_info["references_column"] + }) + + columns.append(col_info) + + # Enrich columns with table-level FK constraints + for fk in table_fk_constraints: + for col in columns: + if col["name"] == fk["from_column"]: + col["is_foreign_key"] = True + col["references_table"] = fk["to_table"] + col["references_column"] = fk["to_column"] + + tables.append({ + "name": table_name, + "columns": columns + }) + + return { + "tables": tables, + "relationships": relationships + } + + def enrich_schema_db(self, db_name: str, schema_info: dict, db_path: str) -> dict: + """ + Enriches the parsed schema with live table statistics and sample values. + """ + print(f"[SchemaService] Enriches database '{db_name}' metadata...") + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + total_rows = 0 + enriched_tables = [] + + for tbl in schema_info["tables"]: + tbl_name = tbl["name"] + + try: + cursor.execute(f"SELECT COUNT(*) FROM {tbl_name}") + tbl_rows = cursor.fetchone()[0] + except Exception: + tbl_rows = 0 + total_rows += tbl_rows + + sample_rows = [] + try: + col_names_str = ", ".join([f'"{col["name"]}"' for col in tbl["columns"]]) + cursor.execute(f"SELECT {col_names_str} FROM {tbl_name} LIMIT 3") + sample_rows = cursor.fetchall() + except Exception as e: + print(f"[SchemaService] Error getting sample rows for {tbl_name}: {e}") + + enriched_cols = [] + for col_idx, col in enumerate(tbl["columns"]): + col_samples = [] + for row in sample_rows: + if col_idx < len(row): + val = row[col_idx] + if val is not None: + col_samples.append(val) + col["sample_values"] = col_samples + enriched_cols.append(col) + + enriched_tables.append({ + "name": tbl_name, + "columns": enriched_cols, + "row_count": tbl_rows + }) + + conn.close() + + return { + "db_name": db_name, + "mode": "schema", + "tables": enriched_tables, + "relationships": schema_info["relationships"], + "total_tables": len(enriched_tables), + "total_rows": total_rows + } + + def extract_schema_from_erd_image(self, image_path: str) -> dict: + """ + Extracts database schema from the ERD diagram image using Groq vision API. + """ + print(f"[SchemaService] Extracting schema from image: {image_path}") + with open(image_path, "rb") as f: + base64_image = base64.b64encode(f.read()).decode("utf-8") + + mime_type, _ = mimetypes.guess_type(image_path) + if not mime_type: + if image_path.lower().endswith(".png"): + mime_type = "image/png" + elif image_path.lower().endswith((".jpg", ".jpeg")): + mime_type = "image/jpeg" + elif image_path.lower().endswith(".pdf"): + mime_type = "application/pdf" + else: + mime_type = "image/png" + + client = Groq(api_key=config.GROQ_API_KEY) + + completion = client.chat.completions.create( + model="meta-llama/llama-4-scout-17b-16e-instruct", + messages=[ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": f"data:{mime_type};base64,{base64_image}" + } + }, + { + "type": "text", + "text": """Analyze this Entity Relationship Diagram (ERD) carefully. +Extract the complete database schema and return ONLY a JSON object +with this exact structure, nothing else: +{ + "tables": [ + { + "name": "table_name", + "columns": [ + { + "name": "column_name", + "type": "TEXT|INTEGER|REAL|BLOB", + "is_primary_key": true|false, + "is_foreign_key": false, + "references_table": null, + "references_column": null + } + ] + } + ], + "relationships": [ + { + "from_table": "table_a", + "from_column": "col_a", + "to_table": "table_b", + "to_column": "col_b", + "cardinality": "1:1|1:N|N:M", + "from_participation": "total|partial", + "to_participation": "total|partial", + "relationship_name": "places|contains|belongs_to|etc" + } + ] +} + +For cardinality: +- 1:1 means one record in table_a relates to exactly one in table_b +- 1:N means one record in table_a relates to many in table_b +- N:M means many records in table_a relate to many in table_b + +For participation: +- total means every record MUST participate (double line in ERD) +- partial means participation is optional (single line in ERD) + +Look carefully at crow's foot notation, double lines, dashed lines, +min-max notation, or any other ERD notation style used in the image. +Infer participation and cardinality as accurately as possible.""" + } + ] + } + ], + max_tokens=2000 + ) + + raw_response = completion.choices[0].message.content.strip() + cleaned = raw_response + if cleaned.startswith("```"): + lines = cleaned.split("\n") + content_lines = [line for line in lines if not line.strip().startswith("```")] + cleaned = "".join(content_lines).strip() + cleaned = cleaned.strip("`").strip() + + json_start = cleaned.find("{") + json_end = cleaned.rfind("}") + if json_start != -1 and json_end != -1: + cleaned = cleaned[json_start:json_end+1] + + return json.loads(cleaned) + + def enrich_relationships_from_erd_image(self, existing_schema: dict, image_path: str) -> dict: + """ + Enriches relationships in existing schema using the ERD diagram image via Groq vision API. + """ + print(f"[SchemaService] Enriching schema relationships from image: {image_path}") + with open(image_path, "rb") as f: + base64_image = base64.b64encode(f.read()).decode("utf-8") + + mime_type, _ = mimetypes.guess_type(image_path) + if not mime_type: + if image_path.lower().endswith(".png"): + mime_type = "image/png" + elif image_path.lower().endswith((".jpg", ".jpeg")): + mime_type = "image/jpeg" + elif image_path.lower().endswith(".pdf"): + mime_type = "application/pdf" + else: + mime_type = "image/png" + + client = Groq(api_key=config.GROQ_API_KEY) + + schema_text = json.dumps(existing_schema, indent=2) + + completion = client.chat.completions.create( + model="meta-llama/llama-4-scout-17b-16e-instruct", + messages=[ + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": { + "url": f"data:{mime_type};base64,{base64_image}" + } + }, + { + "type": "text", + "text": f"""This ERD diagram corresponds to the following database schema: +{schema_text} + +For each relationship shown in this diagram, extract: +- cardinality (1:1, 1:N, or N:M) +- from_participation (total or partial) +- to_participation (total or partial) +- relationship_name (the verb/label on the relationship line if visible) + +Return ONLY a JSON array: +[ + {{ + 'from_table': '...', 'to_table': '...', + 'cardinality': '1:N', + 'from_participation': 'partial', + 'to_participation': 'total', + 'relationship_name': 'places' + }} +]""" + } + ] + } + ], + max_tokens=2000 + ) + + raw_response = completion.choices[0].message.content.strip() + cleaned = raw_response + if cleaned.startswith("```"): + lines = cleaned.split("\n") + content_lines = [line for line in lines if not line.strip().startswith("```")] + cleaned = "".join(content_lines).strip() + cleaned = cleaned.strip("`").strip() + + json_start = cleaned.find("[") + json_end = cleaned.rfind("]") + if json_start != -1 and json_end != -1: + cleaned = cleaned[json_start:json_end+1] + + try: + enriched_rels = json.loads(cleaned) + except Exception: + try: + import ast + enriched_rels = ast.literal_eval(cleaned) + except Exception: + enriched_rels = [] + + rel_lookup = {} + for r in enriched_rels: + from_t = r.get("from_table", "").lower().strip() + to_t = r.get("to_table", "").lower().strip() + rel_lookup[(from_t, to_t)] = r + + for rel in existing_schema.get("relationships", []): + from_t = rel.get("from_table", "").lower().strip() + to_t = rel.get("to_table", "").lower().strip() + + match = rel_lookup.get((from_t, to_t)) + if not match: + match = rel_lookup.get((to_t, from_t)) + + if match: + rel["cardinality"] = match.get("cardinality", "1:N") + rel["from_participation"] = match.get("from_participation", "total") + rel["to_participation"] = match.get("to_participation", "partial") + rel["relationship_name"] = match.get("relationship_name") + else: + # Default assumptions + rel["cardinality"] = "1:N" + rel["from_participation"] = "total" + rel["to_participation"] = "partial" + rel["relationship_name"] = None + + return existing_schema + + def infer_cardinality_from_sql(self, relationships: list, tables: list = None) -> list: + """ + Infers relationship constraints from SQL database schema definition. + """ + if tables is None: + tables = [] + + junction_tables = set() + for tbl in tables: + fks = [c for c in tbl.get("columns", []) if c.get("is_foreign_key")] + if len(fks) >= 2: + other_cols = [c for c in tbl.get("columns", []) if not c.get("is_foreign_key") and not c.get("is_primary_key") and c.get("name").lower() not in ("id", "created_at", "updated_at", "timestamp")] + if len(other_cols) <= 1: + junction_tables.add(tbl.get("name").lower()) + + for rel in relationships: + from_table = rel.get("from_table", "").lower() + from_column = rel.get("from_column", "").lower() + to_table = rel.get("to_table", "").lower() + to_column = rel.get("to_column", "").lower() + + from_tbl_def = next((t for t in tables if t.get("name", "").lower() == from_table), None) + to_tbl_def = next((t for t in tables if t.get("name", "").lower() == to_table), None) + + from_col_def = None + if from_tbl_def: + from_col_def = next((c for c in from_tbl_def.get("columns", []) if c.get("name", "").lower() == from_column), None) + + to_col_def = None + if to_tbl_def: + to_col_def = next((c for c in to_tbl_def.get("columns", []) if c.get("name", "").lower() == to_column), None) + + if from_table in junction_tables: + cardinality = "N:M" + else: + is_from_pk = from_col_def.get("is_primary_key", False) if from_col_def else False + if is_from_pk: + is_to_pk = to_col_def.get("is_primary_key", False) if to_col_def else False + if is_to_pk: + cardinality = "1:1" + else: + cardinality = "1:N" + else: + cardinality = "1:N" + + to_participation = "partial" + is_not_null = False + if from_col_def: + is_not_null = from_col_def.get("is_primary_key", False) or from_col_def.get("is_not_null", False) + + from_participation = "total" if is_not_null else "partial" + + rel["cardinality"] = cardinality + rel["from_participation"] = from_participation + rel["to_participation"] = to_participation + rel["relationship_name"] = None + + return relationships + + def generate_sql_from_parsed_schema(self, schema_info: dict) -> str: + """ + Helper to construct a .sql schema file if only the ERD image was uploaded. + """ + lines = [] + for tbl in schema_info.get("tables", []): + tbl_name = tbl["name"] + col_defs = [] + for col in tbl.get("columns", []): + col_name = col["name"] + col_type = col.get("type", "TEXT") + pk_str = " PRIMARY KEY" if col.get("is_primary_key") else "" + col_defs.append(f" {col_name} {col_type}{pk_str}") + + for col in tbl.get("columns", []): + if col.get("is_foreign_key") and col.get("references_table") and col.get("references_column"): + ref_tbl = col["references_table"] + ref_col = col["references_column"] + col_defs.append(f" FOREIGN KEY ({col['name']}) REFERENCES {ref_tbl}({ref_col})") + + lines.append(f"CREATE TABLE {tbl_name} (\n" + ",\n".join(col_defs) + "\n);") + return "\n\n".join(lines) + + def verify_tables_in_db(self, schema_info: dict, db_path: str): + """ + Verifies that extracted tables exist in the actual SQLite database. + """ + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") + db_tables = {row[0].lower() for row in cursor.fetchall()} + conn.close() + + valid_tables = [] + for tbl in schema_info.get("tables", []): + tbl_name = tbl["name"].lower() + if tbl_name in db_tables: + valid_tables.append(tbl) + else: + print(f"[SchemaService] Table '{tbl_name}' extracted from ERD but not found in DB.") + + schema_info["tables"] = valid_tables + valid_table_names = {t["name"].lower() for t in valid_tables} + valid_rels = [] + for rel in schema_info.get("relationships", []): + if rel.get("from_table", "").lower() in valid_table_names and rel.get("to_table", "").lower() in valid_table_names: + valid_rels.append(rel) + schema_info["relationships"] = valid_rels + + def build_db_from_sql(self, db_name: str, cleaned_sql: str) -> str: + """ + Creates a new SQLite database from cleaned SQL statements (CREATE TABLE + INSERT INTO). + Returns the path to the created .db file. + """ + db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db") + print(f"[SchemaService] Building database from SQL at: {db_path}") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Split by semicolons and execute each statement individually + statements = cleaned_sql.split(';') + executed = 0 + failed = 0 + + for stmt in statements: + stmt = stmt.strip() + if not stmt: + continue + # Skip pure comments + lines_only = re.sub(r'--.*$', '', stmt, flags=re.MULTILINE).strip() + lines_only = re.sub(r'/\*.*?\*/', '', lines_only, flags=re.DOTALL).strip() + if not lines_only: + continue + try: + cursor.execute(stmt + ';') + executed += 1 + except Exception as e: + failed += 1 + print(f"[SchemaService] Skipped SQL statement (error: {e}): {stmt[:80]}...") + + conn.commit() + conn.close() + + print(f"[SchemaService] Database built: {executed} statements executed, {failed} skipped.") + return db_path + + def register_schema_db(self, db_name: str, schema_sql_content: str = None, uploaded_db_path: str = None, erd_image_path: str = None) -> dict: + """ + Registers a schema database using uploaded files (.sql, .db, and/or erd_image). + If uploaded_db_path is None and schema_sql_content is provided, the .db is auto-built from the SQL. + """ + db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db") + sql_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.sql") + json_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.json") + + # Clean T-SQL to SQLite before anything else + extra_relationships = [] + if schema_sql_content: + schema_sql_content, extra_relationships = self.clean_tsql_to_sqlite(schema_sql_content) + + # If a .db file was uploaded, copy it into place + if uploaded_db_path: + if os.path.abspath(uploaded_db_path) != os.path.abspath(db_path): + shutil.copy2(uploaded_db_path, db_path) + elif schema_sql_content: + # No .db uploaded — build it from the cleaned SQL + db_path = self.build_db_from_sql(db_name, schema_sql_content) + else: + raise ValueError("Either a .db file or a .sql file must be provided.") + + schema_info = None + + if erd_image_path: + if schema_sql_content: + parsed_schema = self.parse_schema_sql(schema_sql_content) + schema_info = self.enrich_relationships_from_erd_image(parsed_schema, erd_image_path) + else: + schema_info = self.extract_schema_from_erd_image(erd_image_path) + self.verify_tables_in_db(schema_info, db_path) + schema_sql_content = self.generate_sql_from_parsed_schema(schema_info) + else: + parsed_schema = self.parse_schema_sql(schema_sql_content) + parsed_schema["relationships"] = self.infer_cardinality_from_sql(parsed_schema["relationships"], parsed_schema["tables"]) + schema_info = parsed_schema + + # Merge extra_relationships from ALTER TABLE FK statements + for rel in extra_relationships: + already_exists = any( + r["from_table"] == rel["from_table"] and + r["from_column"] == rel["from_column"] + for r in schema_info["relationships"] + ) + if not already_exists: + # Infer cardinality for the extra relationship + rel["cardinality"] = "1:N" + rel["from_participation"] = "partial" + rel["to_participation"] = "partial" + rel["relationship_name"] = None + schema_info["relationships"].append(rel) + # Also mark the column as a foreign key in the table definition + for tbl in schema_info.get("tables", []): + if tbl["name"] == rel["from_table"]: + for col in tbl.get("columns", []): + if col["name"] == rel["from_column"]: + col["is_foreign_key"] = True + col["references_table"] = rel["to_table"] + col["references_column"] = rel["to_column"] + + with open(sql_path, "w", encoding="utf-8") as f: + f.write(schema_sql_content) + + enriched_info = self.enrich_schema_db(db_name, schema_info, db_path) + + with open(json_path, "w", encoding="utf-8") as f: + json.dump(enriched_info, f, indent=2) + + return enriched_info + + def get_schema_db_info(self, db_name: str) -> dict: + """ + Retrieves the structured details of the schema. Reads from json cache if available. + """ + sql_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.sql") + db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db") + json_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.json") + + if os.path.exists(json_path): + try: + with open(json_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"[SchemaService] Error reading JSON cache: {e}. Falling back.") + + if not os.path.exists(sql_path) or not os.path.exists(db_path): + raise FileNotFoundError(f"Database schema files for '{db_name}' do not exist.") + + with open(sql_path, "r", encoding="utf-8") as f: + schema_sql_content = f.read() + + schema_info = self.parse_schema_sql(schema_sql_content) + schema_info["relationships"] = self.infer_cardinality_from_sql(schema_info["relationships"], schema_info["tables"]) + + enriched_info = self.enrich_schema_db(db_name, schema_info, db_path) + + try: + with open(json_path, "w", encoding="utf-8") as f: + json.dump(enriched_info, f, indent=2) + except Exception as e: + print(f"[SchemaService] Error caching schema JSON: {e}") + + return enriched_info + + def get_all_schema_datasets(self) -> List[dict]: + """ + Lists all schema datasets, checking and initializing the sample e-commerce DB if needed. + """ + os.makedirs(config.SCHEMA_DB_DIR, exist_ok=True) + + sample_db_source = os.path.join(config.SAMPLE_DATA_DIR, "schema", "ecommerce.db") + sample_sql_source = os.path.join(config.SAMPLE_DATA_DIR, "schema", "ecommerce_schema.sql") + + sample_db_dest = os.path.join(config.SCHEMA_DB_DIR, "ecommerce.db") + sample_sql_dest = os.path.join(config.SCHEMA_DB_DIR, "ecommerce.sql") + + if not os.path.exists(sample_db_dest) and os.path.exists(sample_db_source): + print("[SchemaService] Copying ecommerce sample database to databases/schema/...") + shutil.copy2(sample_db_source, sample_db_dest) + if os.path.exists(sample_sql_source): + shutil.copy2(sample_sql_source, sample_sql_dest) + + datasets = [] + for filename in os.listdir(config.SCHEMA_DB_DIR): + if filename.endswith(".db"): + db_name = os.path.splitext(filename)[0] + try: + info = self.get_schema_db_info(db_name) + datasets.append({ + "db_name": db_name, + "display_name": "E-Commerce Database" if db_name == "ecommerce" else db_name.replace("_", " ").capitalize(), + "description": "Relational e-commerce DB: customers, products, orders" if db_name == "ecommerce" else f"User-uploaded schema database: {db_name}", + "mode": "schema", + "is_sample": (db_name == "ecommerce"), + "total_tables": info["total_tables"], + "total_rows": info["total_rows"], + "tables": [t["name"] for t in info["tables"]], + "relationships": info["relationships"] + }) + except Exception as e: + print(f"[SchemaService] Error loading dataset '{db_name}': {e}") + + return datasets + +# Singleton instance +schema_service = SchemaService() diff --git a/backend/services/sql_service.py b/backend/services/sql_service.py new file mode 100644 index 0000000000000000000000000000000000000000..433661b3a50108baf938ad7787a3186a429ff692 --- /dev/null +++ b/backend/services/sql_service.py @@ -0,0 +1,156 @@ +import os +import sqlite3 +import re +import time +import pandas as pd +from typing import Dict, Any +from backend import config + +class SQLService: + def __init__(self): + print("[SQLService] Initializing SQLService singleton...") + + def validate_sql(self, sql: str) -> bool: + """ + Validates that the SQL query is a SELECT statement and does not contain + forbidden modifying operations like INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, EXEC. + """ + print(f"[SQLService] Validating SQL: {sql}") + try: + if not sql or not isinstance(sql, str): + return False + + sql_upper = sql.upper().strip() + + # The SQL must be a SELECT statement + if "SELECT" not in sql_upper: + print("[SQLService] Validation failed: SQL does not contain SELECT keyword.") + return False + + # Forbidden keywords pattern with word boundaries to avoid false positives (e.g. column name like 'create_date') + forbidden_pattern = r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC)\b" + if re.search(forbidden_pattern, sql_upper): + print("[SQLService] Validation failed: SQL contains forbidden modifying keywords.") + return False + + return True + except Exception as e: + print(f"[SQLService] Error during SQL validation: {e}") + return False + + def _clean_query_tsql_artifacts(self, sql: str) -> str: + """ + Lightweight cleaner that removes accidental T-SQL syntax the LLM might + hallucinate when generating queries for a schema DB that was originally T-SQL. + Only applied to SELECT queries. + """ + cleaned = sql + + # Convert SELECT TOP N ... to SELECT ... LIMIT N + top_match = re.search( + r'\bSELECT\s+TOP\s+(\d+)\b', + cleaned, re.IGNORECASE + ) + if top_match: + limit_n = top_match.group(1) + # Remove TOP N from SELECT + cleaned = re.sub(r'\bSELECT\s+TOP\s+\d+\b', 'SELECT', cleaned, flags=re.IGNORECASE) + + # Check for semicolon at the end + has_semicolon = cleaned.strip().endswith(';') + cleaned_body = cleaned.strip() + if has_semicolon: + cleaned_body = cleaned_body[:-1].strip() + + # Remove any existing LIMIT to avoid duplication + cleaned_body = re.sub(r'\bLIMIT\s+\d+\s*$', '', cleaned_body, flags=re.IGNORECASE).strip() + + # Append LIMIT at the end + cleaned = f"{cleaned_body} LIMIT {limit_n}" + if has_semicolon: + cleaned += ";" + + # Remove WITH (NOLOCK) hints + cleaned = re.sub(r'\bWITH\s*\(\s*NOLOCK\s*\)', '', cleaned, flags=re.IGNORECASE) + + # Remove square brackets around identifiers: [EMPNO] → EMPNO + cleaned = re.sub(r'\[([^\]]+)\]', r'\1', cleaned) + + # Clean up any double spaces left behind + cleaned = re.sub(r' +', ' ', cleaned).strip() + + return cleaned + + def execute_query(self, db_name: str, sql: str, mode: str = "csv") -> Dict[str, Any]: + """ + Executes the provided SQL query against the specified SQLite database. + Validates the SQL first, limits output using pandas, measures execution time, + and translates SQLite/pandas dtypes to JSON-serializable Python objects. + """ + print(f"[SQLService] Executing SQL on {db_name}.db (mode={mode}): {sql}") + try: + # 1. Validate SQL safety + if not self.validate_sql(sql): + raise ValueError("Dangerous or invalid SQL query. Only SELECT queries are allowed.") + + # 1b. Clean any accidental T-SQL artifacts from LLM-generated queries + if mode == "schema": + sql = self._clean_query_tsql_artifacts(sql) + print(f"[SQLService] After T-SQL cleanup: {sql}") + + if mode == "csv": + db_path = os.path.join(config.DB_DIR, f"{db_name}.db") + else: + db_path = os.path.join(config.SCHEMA_DB_DIR, f"{db_name}.db") + + if not os.path.exists(db_path): + raise FileNotFoundError(f"Database {db_name}.db does not exist.") + + + conn = sqlite3.connect(db_path) + + # 2. Execute query and measure time + start_time = time.perf_counter() + try: + df = pd.read_sql_query(sql, conn) + except Exception as sql_err: + conn.close() + print(f"[SQLService] SQLite Error: {sql_err}") + raise ValueError(f"SQLite error: {str(sql_err)}") + + execution_time_ms = (time.perf_counter() - start_time) * 1000.0 + conn.close() + + # 3. Limit rows to MAX_ROWS_RETURNED + df_limited = df.head(config.MAX_ROWS_RETURNED) + + # 4. Extract columns and rows, converting numpy/pandas NaN and types to native types + columns = df_limited.columns.tolist() + rows = [] + for row in df_limited.itertuples(index=False): + clean_row = [] + for val in row: + if pd.isna(val): + clean_row.append(None) + elif hasattr(val, "item") and not isinstance(val, (str, bytes)): + # Convert numpy/pandas numeric scalars to Python scalars + clean_row.append(val.item()) + else: + clean_row.append(val) + rows.append(clean_row) + + result = { + "columns": columns, + "rows": rows, + "row_count": len(rows), + "execution_time_ms": round(execution_time_ms, 2) + } + print(f"[SQLService] Query execution succeeded. Returned {result['row_count']} rows in {result['execution_time_ms']} ms.") + return result + + except Exception as e: + print(f"[SQLService] Error during SQL execution: {e}") + raise ValueError(str(e)) + +# Singleton instance +sql_service = SQLService() diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a36934d874c7fbc51aecd1c66dffc106f60693a9 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ea36dd3dc45ddadb9d25dd5e1c74a706dd61a6a9 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..91a17d8f4a326dba2f9e83b325dba03631955312 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Natural Language Data Analyst + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..a6a72fca08a7b27ae84a35f092576f80bcfc45da --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2438 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.374", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.374.tgz", + "integrity": "sha512-HCF5i7izveksHSGqa7mhDh6tr3Uz9Dar2RAjwuh69bw3QGPVObjQIgLwQWeO/Rxp9/r0KdboKy9RbpQDl97fjg==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "dev": true, + "license": "MIT", + "peer": true, + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..e4d03ce24bb11e820734cd76d9d9ec4dac2f7b51 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "vite": "^8.0.12" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..6893eb13237060adc0c968a690149a49faa2d7d3 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000000000000000000000000000000000000..e9522193d9f796a9748e9ad8c952a5df73c87db9 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..f90339d8f765fa2c69d9a341959a8ddb9fff5720 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5092ddad2dd61e2a6367e9be2919d387f467435a --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,409 @@ +import React, { useState, useEffect } from "react"; +import Sidebar from "./components/Sidebar"; +import UploadView from "./components/UploadView"; +import DataPreview from "./components/DataPreview"; +import QueryView from "./components/QueryView"; +import SchemaPreviewPanel from "./components/SchemaPreviewPanel"; +import SchemaQueryView from "./components/SchemaQueryView"; +import SchemaUploadModal from "./components/SchemaUploadModal"; +import { getDatasets, getSchema, getPreview, getQueryHistory, getSchemaDatasets, getSchemaInfo, getSchemaPreview } from "./api"; + +export default function App() { + const [datasets, setDatasets] = useState([]); + const [schemaDatasets, setSchemaDatasets] = useState([]); + const [selectedDataset, setSelectedDataset] = useState(null); + const [view, setView] = useState("upload"); // upload | preview | query + const [globalLoading, setGlobalLoading] = useState(false); + const [datasetsLoading, setDatasetsLoading] = useState(true); + + // Added polish features + const [history, setHistory] = useState([]); + const [queriesRun, setQueriesRun] = useState(0); + const [activeQuestion, setActiveQuestion] = useState(null); + const [toasts, setToasts] = useState([]); + const [isMobile, setIsMobile] = useState(window.innerWidth < 768); + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + + // 1. Initial Load: Fetch all datasets + useEffect(() => { + fetchDatasetsList(); + }, []); + + // Resize listener for mobile responsiveness + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < 768); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + // Toast Notification manager + const addToast = (message, type = "info") => { + const id = Date.now(); + setToasts((prev) => [...prev, { id, message, type }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 3000); + }; + + const fetchDatasetsList = async () => { + try { + setDatasetsLoading(true); + const [csvRes, schemaRes] = await Promise.all([ + getDatasets(), + getSchemaDatasets().catch(() => ({ datasets: [], total: 0 })) + ]); + setDatasets(csvRes.datasets || []); + setSchemaDatasets(schemaRes.datasets || []); + setDatasetsLoading(false); + } catch (err) { + console.error("Failed to load datasets list:", err); + setDatasetsLoading(false); + } + }; + + const fetchQueryHistory = async (dbName) => { + try { + const res = await getQueryHistory(dbName); + setHistory(res.history || []); + } catch (err) { + console.error("Failed to fetch query history:", err); + } + }; + + // 2. Select Dataset: Fetch details and preview in parallel + const handleSelectDataset = async (datasetInfo) => { + setGlobalLoading(true); + try { + console.log("[App] Selecting dataset:", datasetInfo.db_name, "mode:", datasetInfo.mode); + + if (datasetInfo.mode === "schema") { + const [schemaInfoRes, historyRes] = await Promise.all([ + getSchemaInfo(datasetInfo.db_name), + getQueryHistory(datasetInfo.db_name).catch(() => ({ history: [] })) + ]); + + const mergedDataset = { + db_name: datasetInfo.db_name, + display_name: datasetInfo.display_name, + description: datasetInfo.description, + is_sample: datasetInfo.is_sample, + mode: "schema", + total_tables: schemaInfoRes.total_tables, + total_rows: schemaInfoRes.total_rows, + relationships: schemaInfoRes.relationships, + tables: schemaInfoRes.tables + }; + + setSelectedDataset(mergedDataset); + setHistory(historyRes.history || []); + setView("preview"); + setGlobalLoading(false); + return; + } + + // Existing CSV mode: + const [schemaRes, previewRes, historyRes] = await Promise.all([ + getSchema(datasetInfo.db_name), + getPreview(datasetInfo.db_name), + getQueryHistory(datasetInfo.db_name).catch(() => ({ history: [] })) + ]); + + const mergedDataset = { + db_name: datasetInfo.db_name, + display_name: datasetInfo.display_name, + description: datasetInfo.description, + is_sample: datasetInfo.is_sample, + mode: "csv", + row_count: schemaRes.row_count, + columns: schemaRes.columns, // columns details list {name, type, sample_values} + preview: previewRes // preview data columns and rows + }; + + setSelectedDataset(mergedDataset); + setHistory(historyRes.history || []); + setView("preview"); + setGlobalLoading(false); + } catch (err) { + console.error("[App] Failed to load dataset details:", err); + addToast(`Failed to load dataset details: ${err.message}`, "error"); + setGlobalLoading(false); + } + }; + + // 3. Upload Success Callback: Refresh datasets and select new database + const handleUploadSuccess = async (uploadResult) => { + console.log("[App] CSV Upload success callback triggered:", uploadResult); + addToast("CSV uploaded successfully", "success"); + await fetchDatasetsList(); + handleSelectDataset({ + db_name: uploadResult.db_name, + display_name: uploadResult.display_name, + description: uploadResult.message, + is_sample: false, + mode: "csv" + }); + }; + + const handleSchemaUploadSuccess = async (uploadResult) => { + console.log("[App] Schema Upload success callback:", uploadResult); + await fetchDatasetsList(); + + const displayFriendlyName = uploadResult.db_name.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); + handleSelectDataset({ + db_name: uploadResult.db_name, + display_name: displayFriendlyName, + description: uploadResult.message, + is_sample: false, + mode: "schema" + }); + }; + + // 4. Delete Success Callback: Refresh list and redirect view if active + const handleDeleteDataset = async (deletedDbName, mode = "csv") => { + console.log(`[App] Dataset deleted callback triggered: ${deletedDbName} (mode=${mode})`); + addToast("Dataset deleted", "info"); + if (selectedDataset && selectedDataset.db_name === deletedDbName) { + setSelectedDataset(null); + setView("upload"); + setHistory([]); + } + fetchDatasetsList(); + }; + + const handleStartQuerying = () => { + setView("query"); + }; + + const handleQuerySuccess = () => { + setQueriesRun((prev) => prev + 1); + if (selectedDataset) { + fetchQueryHistory(selectedDataset.db_name); + } + }; + + const handleRunQuestion = (questionText) => { + setView("query"); + setActiveQuestion({ text: questionText, timestamp: Date.now() }); + }; + + return ( +
+ {/* Toast Overlay Container */} +
+ {toasts.map((t) => ( +
+ {t.message} + +
+ ))} +
+ + {/* Relational Database Upload Modal */} + {isUploadModalOpen && ( + setIsUploadModalOpen(false)} + onUploadSuccess={handleSchemaUploadSuccess} + addToast={addToast} + /> + )} + + {/* Top Full-width Stats Bar */} +
+
+ 📊 {datasets.length + schemaDatasets.length} datasets • 🔍 {queriesRun} queries run +
+ {isMobile && ( + + )} +
+ + {/* App Container */} +
+ {/* Sidebar (Left Panel) */} + setIsUploadModalOpen(true)} + onDelete={handleDeleteDataset} + history={history} + onRunQuestion={handleRunQuestion} + addToast={addToast} + /> + + {/* Main content Area (Right Panel) */} +
+ {/* Top Navigation Bar (Only shown if a dataset is selected) */} + {selectedDataset && ( +
+
+ + 📁 {selectedDataset.display_name} + + + {selectedDataset.mode === "schema" ? "Relational" : (selectedDataset.is_sample ? "Sample" : "User")} + +
+ + {/* View Switch tabs */} +
+ + +
+
+ )} + + {/* Global Loading Spinner overlay */} + {globalLoading ? ( +
+
+
+ Loading database metadata and previews... +
+
+ ) : ( + /* Render Active View */ + <> + {view === "upload" && ( + + )} + + {view === "preview" && selectedDataset && selectedDataset.mode === "csv" && ( + + )} + + {view === "preview" && selectedDataset && selectedDataset.mode === "schema" && ( + + )} + + {view === "query" && selectedDataset && selectedDataset.mode === "csv" && ( + + )} + + {view === "query" && selectedDataset && selectedDataset.mode === "schema" && ( + + )} + + )} +
+
+
+ ); +} + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000000000000000000000000000000000000..cfb9a00cf0c01ad6e8a22a58447812da85d3cae5 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,157 @@ +const BASE_URL = "http://localhost:8000"; + +/** + * Helper to process fetch responses, parsing JSON and raising errors with detailed messages if available. + */ +async function handleResponse(response) { + if (!response.ok) { + let errMsg = `Request failed with status ${response.status}`; + try { + const data = await response.json(); + if (data && data.detail) { + if (typeof data.detail === "string") { + errMsg = data.detail; + } else if (Array.isArray(data.detail)) { + // Parse FastAPI validation errors + errMsg = data.detail.map(err => `${err.loc.join(".")}: ${err.msg}`).join(", "); + } + } + } catch (e) { + // Fallback to text if parsing fails + try { + const text = await response.text(); + if (text) errMsg = text; + } catch (innerErr) {} + } + throw new Error(errMsg); + } + + // Return parsed data and preserve headers if needed + const jsonData = await response.json(); + + // If we have custom headers like X-Process-Time, we can attach it to the returned data structure + if (response.headers.has("X-Process-Time")) { + jsonData._processTime = response.headers.get("X-Process-Time"); + } + return jsonData; +} + +export async function getDatasets() { + console.log("[API] Fetching datasets..."); + const response = await fetch(`${BASE_URL}/datasets`); + return handleResponse(response); +} + +export async function getSchema(dbName) { + console.log(`[API] Fetching schema for database: ${dbName}...`); + const response = await fetch(`${BASE_URL}/datasets/${encodeURIComponent(dbName)}/schema`); + return handleResponse(response); +} + +export async function getPreview(dbName) { + console.log(`[API] Fetching preview for database: ${dbName}...`); + const response = await fetch(`${BASE_URL}/datasets/${encodeURIComponent(dbName)}/preview`); + return handleResponse(response); +} + +export async function uploadCSV(file) { + console.log(`[API] Uploading CSV file: ${file.name}...`); + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch(`${BASE_URL}/upload`, { + method: "POST", + body: formData + // Note: Do not set Content-Type header manually. The browser will auto-set it with boundary. + }); + return handleResponse(response); +} + +export async function deleteDataset(dbName) { + console.log(`[API] Deleting database: ${dbName}...`); + const response = await fetch(`${BASE_URL}/datasets/${encodeURIComponent(dbName)}`, { + method: "DELETE" + }); + return handleResponse(response); +} + +export async function queryDataset(question, dbName) { + console.log(`[API] Querying database: ${dbName} with question: '${question}'...`); + const response = await fetch(`${BASE_URL}/query`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ question, db_name: dbName }) + }); + return handleResponse(response); +} + +export async function getSuggestions(dbName) { + console.log(`[API] Fetching suggestions for database: ${dbName}...`); + const response = await fetch(`${BASE_URL}/datasets/${encodeURIComponent(dbName)}/suggestions`); + return handleResponse(response); +} + +export async function getQueryHistory(dbName) { + console.log(`[API] Fetching history for database: ${dbName}...`); + const response = await fetch(`${BASE_URL}/query/history?db_name=${encodeURIComponent(dbName)}`); + return handleResponse(response); +} + +export async function getSchemaDatasets() { + console.log("[API] Fetching schema datasets..."); + const response = await fetch(`${BASE_URL}/schema-datasets`); + return handleResponse(response); +} + +export async function getSchemaInfo(dbName) { + console.log(`[API] Fetching schema details for database: ${dbName}...`); + const response = await fetch(`${BASE_URL}/schema-datasets/${encodeURIComponent(dbName)}/schema`); + return handleResponse(response); +} + +export async function getSchemaPreview(dbName) { + console.log(`[API] Fetching schema preview for database: ${dbName}...`); + const response = await fetch(`${BASE_URL}/schema-datasets/${encodeURIComponent(dbName)}/preview`); + return handleResponse(response); +} + +export async function uploadSchemaDB(sqlFile, erdImage = null) { + console.log(`[API] Uploading schema files...`); + const formData = new FormData(); + if (sqlFile) { + formData.append("schema_file", sqlFile); + } + if (erdImage) { + formData.append("erd_image", erdImage); + } + + const response = await fetch(`${BASE_URL}/upload/schema`, { + method: "POST", + body: formData + }); + return handleResponse(response); +} + +export async function deleteSchemaDataset(dbName) { + console.log(`[API] Deleting schema database: ${dbName}...`); + const response = await fetch(`${BASE_URL}/schema-datasets/${encodeURIComponent(dbName)}`, { + method: "DELETE" + }); + return handleResponse(response); +} + +export async function querySchema(question, dbName) { + console.log(`[API] Querying schema database: ${dbName} with question: '${question}'...`); + const response = await fetch(`${BASE_URL}/schema-query`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ question, db_name: dbName }) + }); + return handleResponse(response); +} + + diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..5101b674df391399da71c767aa5c976426c9dc7a --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/ChartView.jsx b/frontend/src/components/ChartView.jsx new file mode 100644 index 0000000000000000000000000000000000000000..94f4957d777827794b3a7dd97d1763aecf0106a7 --- /dev/null +++ b/frontend/src/components/ChartView.jsx @@ -0,0 +1,550 @@ +import React, { useState, useEffect, useMemo } from "react"; + +// Helper checking if >80% of values are numbers +export function isNumeric(values) { + if (!values || values.length === 0) return false; + const numCount = values.filter(v => typeof v === "number" && !isNaN(v)).length; + return (numCount / values.length) > 0.8; +} + +// Helper checking if >80% of values match YYYY-MM-DD format +export function looksLikeDate(values) { + if (!values || values.length === 0) return false; + const dateRegex = /^\d{4}-\d{2}-\d{2}/; + const dateCount = values.filter(v => typeof v === "string" && dateRegex.test(v)).length; + return (dateCount / values.length) > 0.8; +} + +export default function ChartView({ result }) { + const [mounted, setMounted] = useState(false); + const [hoveredIdx, setHoveredIdx] = useState(null); + + useEffect(() => { + const timer = setTimeout(() => setMounted(true), 50); + return () => clearTimeout(timer); + }, [result]); + + const chartMeta = useMemo(() => { + if (!result || !result.results || !result.results.columns || !result.results.rows || result.results.rows.length === 0) { + return null; + } + + const columns = result.results.columns; + const rows = result.results.rows; + + let chartType = null; + let xIdx = 0; + let yIdx = 1; + + // Detect column roles + if (columns.length >= 2) { + const col1Values = rows.map(r => r[0]); + const col2Values = rows.map(r => r[1]); + + const col1IsNumeric = isNumeric(col1Values); + const col2IsNumeric = isNumeric(col2Values); + const col1IsDate = looksLikeDate(col1Values); + + if (!col1IsNumeric && col2IsNumeric) { + xIdx = 0; + yIdx = 1; + if (col1IsDate) { + chartType = "LINE"; + } else if (rows.length <= 6) { + chartType = "PIE"; + } else { + chartType = "BAR"; + } + } else if (col1IsNumeric && !col2IsNumeric) { + xIdx = 1; + yIdx = 0; + const col2IsDate = looksLikeDate(col2Values); + if (col2IsDate) { + chartType = "LINE"; + } else if (rows.length <= 6) { + chartType = "PIE"; + } else { + chartType = "BAR"; + } + } else if (columns.length >= 3) { + // 3+ columns. Look for first text/date and first numeric + let foundX = -1; + let foundY = -1; + for (let i = 0; i < columns.length; i++) { + const vals = rows.map(r => r[i]); + if (isNumeric(vals)) { + if (foundY === -1) foundY = i; + } else { + if (foundX === -1) foundX = i; + } + } + if (foundX !== -1 && foundY !== -1) { + xIdx = foundX; + yIdx = foundY; + chartType = "BAR"; + } + } + } + + if (!chartType) return null; + + // Format data points (limit to first 20 rows for layout readability) + const points = rows.slice(0, 20).map((row, idx) => { + const rawX = row[xIdx]; + const rawY = row[yIdx]; + return { + xVal: rawX === null || rawX === undefined ? "NULL" : String(rawX), + yVal: Number(rawY || 0), + originalRowIdx: idx + }; + }); + + return { + chartType, + points, + xLabel: columns[xIdx], + yLabel: columns[yIdx], + totalCount: rows.length + }; + }, [result]); + + if (!chartMeta) { + return ( +
+
+ + + + + +
+ Chart not available for this query type. + View data in the Table tab. +
+ ); + } + + const { chartType, points, xLabel, yLabel, totalCount } = chartMeta; + + // Format large numbers cleanly + const formatNumber = (num) => { + if (Math.abs(num) >= 1_000_000) return (num / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M"; + if (Math.abs(num) >= 1_000) return (num / 1_000).toFixed(1).replace(/\.0$/, "") + "K"; + if (num % 1 !== 0) return num.toFixed(2); + return num.toString(); + }; + + // --- 1. BAR CHART RENDERER --- + if (chartType === "BAR") { + const svgWidth = 600; + const svgHeight = 350; + const padding = { top: 40, right: 30, bottom: 60, left: 65 }; + const graphWidth = svgWidth - padding.left - padding.right; + const graphHeight = svgHeight - padding.top - padding.bottom; + + const yValues = points.map(p => p.yVal); + const maxY = Math.max(...yValues, 0); + const minY = Math.min(...yValues, 0); + const upperLimit = maxY === 0 ? 10 : maxY * 1.15; // 15% top padding + + // Grid ticks (5 marks) + const tickCount = 4; + const ticks = Array.from({ length: tickCount + 1 }).map((_, i) => { + const val = (upperLimit / tickCount) * i; + const y = padding.top + graphHeight - (val / upperLimit) * graphHeight; + return { val, y }; + }); + + const colWidth = graphWidth / points.length; + const barPadding = 0.35; + const barWidth = colWidth * (1 - barPadding); + const barOffset = colWidth * (barPadding / 2); + + return ( +
+ + {/* Grid lines */} + {ticks.map((t, idx) => ( + + + + {formatNumber(t.val)} + + + ))} + + {/* Bars */} + {points.map((pt, idx) => { + const x = padding.left + idx * colWidth + barOffset; + const barHeightVal = (pt.yVal / upperLimit) * graphHeight; + + // Mount animation height + const animatedHeight = mounted ? barHeightVal : 0; + const y = padding.top + graphHeight - animatedHeight; + + // Shorten label + const displayLabel = pt.xVal.length > 10 ? pt.xVal.substring(0, 10) + "..." : pt.xVal; + const isHovered = hoveredIdx === idx; + + return ( + setHoveredIdx(idx)} + onMouseLeave={() => setHoveredIdx(null)} + > + {/* Bar */} + + + {/* Label on top of bar */} + {mounted && isHovered && ( + + {pt.yVal.toLocaleString()} + + )} + + {/* X axis labels (slanted for readability) */} + + {displayLabel} + + + ); + })} + + {/* Axis lines */} + + + + {/* Title labels */} + + {yLabel} by {xLabel} {totalCount > 20 && (first 20 rows)} + + +
+ ); + } + + // --- 2. LINE CHART RENDERER --- + if (chartType === "LINE") { + const svgWidth = 600; + const svgHeight = 350; + const padding = { top: 40, right: 40, bottom: 50, left: 65 }; + const graphWidth = svgWidth - padding.left - padding.right; + const graphHeight = svgHeight - padding.top - padding.bottom; + + const yValues = points.map(p => p.yVal); + const maxY = Math.max(...yValues, 0); + const upperLimit = maxY === 0 ? 10 : maxY * 1.15; + + // Grid ticks (5 marks) + const tickCount = 4; + const ticks = Array.from({ length: tickCount + 1 }).map((_, i) => { + const val = (upperLimit / tickCount) * i; + const y = padding.top + graphHeight - (val / upperLimit) * graphHeight; + return { val, y }; + }); + + // Compute coordinates + const segmentWidth = graphWidth / Math.max(points.length - 1, 1); + const coords = points.map((pt, idx) => { + const x = padding.left + idx * segmentWidth; + const y = padding.top + graphHeight - (pt.yVal / upperLimit) * graphHeight; + return { x, y, pt, idx }; + }); + + // Generate polyline string points + const polylinePoints = coords.map(c => `${c.x},${c.y}`).join(" "); + + // Generate gradient fill area path points + const areaPath = coords.length > 0 + ? `M ${coords[0].x},${padding.top + graphHeight} ` + + coords.map(c => `L ${c.x},${c.y}`).join(" ") + + ` L ${coords[coords.length - 1].x},${padding.top + graphHeight} Z` + : ""; + + return ( +
+ + + + + + + + + {/* Grid lines */} + {ticks.map((t, idx) => ( + + + + {formatNumber(t.val)} + + + ))} + + {/* Area under the line */} + {mounted && areaPath && ( + + )} + + {/* Line string */} + {mounted && polylinePoints && ( + + )} + + {/* Vertical crosshair on hover */} + {hoveredIdx !== null && coords[hoveredIdx] && ( + + + {/* Tooltip */} + + + {coords[hoveredIdx].pt.xVal} + + + {coords[hoveredIdx].pt.yVal.toLocaleString()} + + + )} + + {/* Data point dots and hover trigger zones */} + {coords.map((c, idx) => ( + + + {/* Invisible large target for easier hover */} + setHoveredIdx(idx)} + onMouseLeave={() => setHoveredIdx(null)} + /> + + {/* X axis labels (limited spacing) */} + {(points.length < 8 || idx % 2 === 0) && ( + + {c.pt.xVal} + + )} + + ))} + + {/* Axis lines */} + + + + {/* Title labels */} + + {yLabel} trend over {xLabel} {totalCount > 20 && (first 20 rows)} + + +
+ ); + } + + // --- 3. PIE CHART RENDERER --- + if (chartType === "PIE") { + const svgWidth = 400; + const svgHeight = 300; + const cx = 130; + const cy = 150; + const r = 95; + const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899"]; + + const yValues = points.map(p => p.yVal); + const sum = yValues.reduce((a, b) => a + b, 0); + + let currentAngle = -Math.PI / 2; // Start at 12 o'clock + + const slices = points.map((pt, idx) => { + const val = pt.yVal; + const pct = sum === 0 ? 0 : val / sum; + const angle = pct * 2 * Math.PI; + const startAngle = currentAngle; + const endAngle = currentAngle + angle; + currentAngle = endAngle; + + const midAngle = (startAngle + endAngle) / 2; + return { + pt, + pct, + startAngle, + endAngle, + midAngle, + color: colors[idx % colors.length], + idx + }; + }); + + return ( +
+ + {/* Slices */} + {sum > 0 ? ( + slices.map((slice, idx) => { + const x1 = cx + r * Math.cos(slice.startAngle); + const y1 = cy + r * Math.sin(slice.startAngle); + const x2 = cx + r * Math.cos(slice.endAngle); + const y2 = cy + r * Math.sin(slice.endAngle); + + const largeArcFlag = slice.pct > 0.5 ? 1 : 0; + const isHovered = hoveredIdx === idx; + + // Arc path: Move to center, Line to start boundary, Arc to end boundary, close path + let d = `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`; + + if (slice.pct >= 0.999) { + // Draw a circle if single slice dominates + d = `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx - 0.01} ${cy - r} Z`; + } + + // Displace slice on hover + const dx = isHovered ? 8 * Math.cos(slice.midAngle) : 0; + const dy = isHovered ? 8 * Math.sin(slice.midAngle) : 0; + + return ( + setHoveredIdx(idx)} + onMouseLeave={() => setHoveredIdx(null)} + style={{ + transform: `translate(${dx}px, ${dy}px)`, + transition: "transform 0.2s ease-out", + cursor: "pointer" + }} + /> + ); + }) + ) : ( + + )} + + {/* Legend keys on the right */} + + {slices.map((slice, idx) => { + const displayLabel = slice.pt.xVal.length > 12 + ? slice.pt.xVal.substring(0, 12) + "..." + : slice.pt.xVal; + const pctText = (slice.pct * 100).toFixed(1) + "%"; + const isHovered = hoveredIdx === idx; + + return ( + setHoveredIdx(idx)} + onMouseLeave={() => setHoveredIdx(null)} + > + + + {displayLabel} ({pctText}) + + + ); + })} + + + {/* Title labels */} + + {yLabel} share by {xLabel} + + +
+ ); + } + + return null; +} diff --git a/frontend/src/components/DataPreview.jsx b/frontend/src/components/DataPreview.jsx new file mode 100644 index 0000000000000000000000000000000000000000..33bc6e15f3c3bdb8c546baa844abd1f468e557fc --- /dev/null +++ b/frontend/src/components/DataPreview.jsx @@ -0,0 +1,99 @@ +import React from "react"; +import DataTable from "./DataTable"; + +export default function DataPreview({ dataset, onStartQuerying }) { + if (!dataset) return null; + + // Extract columns (schema) and rows (preview data) + const columnsSchema = dataset.columns || []; + const previewData = dataset.preview || { columns: [], rows: [] }; + const displayDescription = dataset.description || `Database containing ${dataset.display_name} tables.`; + + const getTypeBadgeClass = (type) => { + const t = type ? type.toUpperCase() : "TEXT"; + if (t === "INTEGER" || t === "REAL") return "badge-type integer"; + if (t === "DATE" || t === "TIMESTAMP" || t === "DATETIME") return "badge-type date"; + return "badge-type text"; + }; + + return ( +
+ {/* 1. Dataset Info Card */} +
+
+

{dataset.display_name}

+

{displayDescription}

+
+
+ Total Rows: + {dataset.row_count} +
+
+ Columns: + {columnsSchema.length} +
+
+ Format: + SQLite +
+
+
+ + {/* Start Querying button */} + +
+ + {/* 2. Schema Table Section */} +
+

Database Schema

+
+ + + + + + + + + + {columnsSchema.map((col, idx) => ( + + + + + + ))} + +
Column NameData TypeSample Values
{col.name} + + {col.type} + + + {col.sample_values && col.sample_values.length > 0 + ? col.sample_values.map(val => (val === null ? "null" : String(val))).join(", ") + : "No examples available"} +
+
+
+ + {/* 3. Data Preview Section (first 10 rows) */} +
+

Data Preview (first 10 rows)

+ +
+ + {/* Bottom CTA Button */} +
+ +
+
+ ); +} diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fc7c7c8cdffbb1eddfcb33763849c477161b0bd3 --- /dev/null +++ b/frontend/src/components/DataTable.jsx @@ -0,0 +1,64 @@ +import React from "react"; + +export default function DataTable({ columns, rows, maxHeight }) { + if (!columns || columns.length === 0) { + return
No data columns.
; + } + + const isNumeric = (val) => { + return typeof val === "number" && !isNaN(val); + }; + + return ( +
+ + + + {columns.map((col, idx) => ( + + ))} + + + + {rows && rows.length > 0 ? ( + rows.map((row, rowIdx) => ( + + {row.map((cell, cellIdx) => { + const numeric = isNumeric(cell); + return ( + + ); + })} + + )) + ) : ( + + + + )} + +
{col}
+ {cell === null || cell === undefined ? ( + + NULL + + ) : typeof cell === "boolean" ? ( + cell.toString().toUpperCase() + ) : ( + cell + )} +
+ No records returned. +
+
+ ); +} diff --git a/frontend/src/components/LoadingResult.jsx b/frontend/src/components/LoadingResult.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5874e00c8b5bcafa8b42016b7382b87361cf8204 --- /dev/null +++ b/frontend/src/components/LoadingResult.jsx @@ -0,0 +1,44 @@ +import React from "react"; + +export default function LoadingResult({ question, themeColor = "var(--primary-color)" }) { + return ( +
+ {/* Question Header */} +
+ Q: +
+ {question} + Running query pipeline... +
+
+ + {/* SQL block skeleton */} +
+
+
+
+ + {/* Insight box skeleton */} +
+
+
+
+ + {/* Chart tab content area skeleton */} +
+
+
+
+
+
+
+ + {/* Status Footer */} +
+ 🤔 Generating SQL and running query... +
+
+
+ ); +} + diff --git a/frontend/src/components/QueryInput.jsx b/frontend/src/components/QueryInput.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e180cddf70ba0082df799c141fbce980a7a5443d --- /dev/null +++ b/frontend/src/components/QueryInput.jsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from "react"; + +export default function QueryInput({ onSubmit, loading, datasetName, schema, onArrowUp }) { + const [question, setQuestion] = useState(""); + + // Clear input when dataset changes + useEffect(() => { + setQuestion(""); + }, [datasetName]); + + const handleFormSubmit = (e) => { + if (e) e.preventDefault(); + if (!question || question.trim().length < 3 || loading) return; + + onSubmit(question.trim()); + setQuestion(""); + }; + + const handleKeyDown = (e) => { + // Enter key or Ctrl+Enter submits the form, but Shift+Enter adds a newline + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleFormSubmit(); + } else if (e.key === "Enter" && e.ctrlKey) { + e.preventDefault(); + handleFormSubmit(); + } + + // Arrow Up when input is empty fills with last question + if (e.key === "ArrowUp" && !question.trim()) { + if (onArrowUp) { + const lastQ = onArrowUp(); + if (lastQ) { + e.preventDefault(); + setQuestion(lastQ); + } + } + } + }; + + return ( +
+
+
+