import os import zipfile import json import tempfile import rarfile import datetime from github import Github import requests import random import string import time import shutil from fastapi.responses import JSONResponse from fastapi import FastAPI, File, UploadFile, Form, Request from fastapi.middleware.cors import CORSMiddleware from dotenv import load_dotenv from github_utils import create_github_repo, upload_files_to_repo, enable_github_pages, resync_webcraft_file, get_repository from title_generator import get_repo_title, sanitize_for_repo_name from code_commenter import add_comments_to_webcraft_file # تحميل الإعدادات من ملف .env load_dotenv() GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") GITHUB_USERNAME = os.getenv("GITHUB_USERNAME") app = FastAPI() # Add CORS middleware to allow browser requests app.add_middleware( CORSMiddleware, allow_origins=["*"], # Allows all origins allow_credentials=True, allow_methods=["*"], # Allows all methods allow_headers=["*"], # Allows all headers ) @app.get("/") async def root(): return {"status": "success", "message": "WebCraft AI Backend is running!"} @app.post("/upload_website") async def upload_website( request: Request, file: UploadFile = File(...), github_token: str = Form(None), github_username: str = Form(None), repo_name: str = Form(None), add_comments: bool = Form(True), comment_level: str = Form("concise"), is_update: bool = Form(False) ): try: # ---------------------------------------- # 0. Handle Repo Name # ---------------------------------------- provided_repo_name = repo_name # ---------------------------------------- # 1. read uploaded file # ---------------------------------------- is_zip = file.filename.endswith(".zip") is_html = file.filename.endswith(".html") if not (is_zip or is_html): return {"status": "error", "message": "Only .html or .zip files are accepted."} # Create temporary directory temp_dir = tempfile.mkdtemp() file_path = os.path.join(temp_dir, file.filename) # Save file with open(file_path, "wb") as f: f.write(await file.read()) # If it's a zip, extract it if is_zip: print(f"📦 Extracting {file.filename}...") # Use zipfile to extract with zipfile.ZipFile(file_path, 'r') as zip_ref: zip_ref.extractall(temp_dir) # Remove the original zip file so it doesn't get uploaded os.remove(file_path) # --- FLATTEN FRONTEND FOLDER FOR GITHUB PAGES --- # If there is a 'frontend' folder, move its contents to the root frontend_dir = os.path.join(temp_dir, "frontend") if os.path.exists(frontend_dir) and os.path.isdir(frontend_dir): print("📂 Multi-file project detected. Flattening 'frontend/' for GitHub Pages...") for item in os.listdir(frontend_dir): s = os.path.join(frontend_dir, item) d = os.path.join(temp_dir, item) # if destination exists (unlikely), remove it first if os.path.exists(d): if os.path.isdir(d): shutil.rmtree(d) else: os.remove(d) shutil.move(s, d) try: os.rmdir(frontend_dir) except: pass # Update file_path to point to the index.html (now in root after flattening) # Find the main HTML file for comment adding if enabled # We'll look for index.html or any html file in the tree found_html = None for root, dirs, files in os.walk(temp_dir): if "index.html" in files: found_html = os.path.join(root, "index.html") break if not found_html: # Fallback to any html file for root, dirs, files in os.walk(temp_dir): html_files = [f for f in files if f.endswith(".html")] if html_files: found_html = os.path.join(root, html_files[0]) break if found_html: file_path = found_html print(f"Target HTML for AI comments: {file_path}") else: print("No HTML file found for AI commenting in the extracted package.") add_comments = False # ---------------------------------------- # 2. Generate repo name # ---------------------------------------- if provided_repo_name: repo_name = sanitize_for_repo_name(provided_repo_name) print(f"Using provided repo name: {repo_name}") else: # Using the free local model (T5-small) title_results = get_repo_title(file_path) # Prioritize: AI Title > Title Tag (if specific) > Fallback # Check if title_tag is generic t_tag = title_results.get("title_tag") ai_title = title_results.get("ai_title") fallback = title_results.get("fallback") generic_terms = ["document", "untitled", "index", "home", "test", "my-site", "new-project"] is_generic = not t_tag or any(term in t_tag.lower() for term in generic_terms) if ai_title and (is_generic or len(t_tag) < 3): repo_name = ai_title print(f"Preferring AI Title over generic/short tag: {repo_name}") else: repo_name = t_tag or ai_title or fallback print(f"Selected best repo name (Final): {repo_name}") # ---------------------------------------- # 3. Add AI comments to WebCraft code (if enabled) # ---------------------------------------- if add_comments and comment_level in ["detailed", "concise", "minimal"]: try: # Using the free rule-based + CodeT5 way # Pass repo_name to force update the tag is_benchmark = request.headers.get("x-benchmark-mode") == "true" file_path = add_comments_to_webcraft_file( file_path, comment_level, force_title=repo_name, benchmark_mode=is_benchmark ) print(f"Added {comment_level} comments to code and updated title to '{repo_name}'") except Exception as e: if str(e) == "AI_EXHAUSTED": raise e print(f"Could not add comments: {e}") # Continue without comments # ---------------------------------------- # 4. Determine user credentials # ---------------------------------------- user_token = github_token if github_token else GITHUB_TOKEN if not user_token: print("Error: GITHUB_TOKEN is missing!") return {"status": "error", "message": "GitHub Token is missing. Please provide it in the form or as a Secret."} # Get the actual username from GitHub token to ensure correctness try: g = Github(user_token) authed_user = g.get_user() authed_username = authed_user.login print(f"🔑 Token verified for GitHub user: {authed_username}") except Exception as e: print(f"GitHub Token Verification Failed: {e}") return {"status": "error", "message": f"Invalid GitHub Token: {str(e)}"} # ---------------------------------------- # 5. Create or Get repo # ---------------------------------------- if is_update: print(f"Attempting to update existing repository: {repo_name}...") repo = get_repository(authed_username, repo_name, user_token) if not repo: print(f"Repository {repo_name} not found. Creating new instead...") repo = create_github_repo(user_token, repo_name) else: print(f"Attempting to create new repository: {repo_name}...") repo = create_github_repo(user_token, repo_name) actual_repo_name = repo.name print(f"Repository ready: {repo.html_url}") # ---------------------------------------- # 6. Upload the HTML file ONLY # ---------------------------------------- print(f"📤 Uploading files to {actual_repo_name}...") upload_files_to_repo(repo, temp_dir) print("Upload complete.") # ---------------------------------------- # 7. Enable GitHub Pages # ---------------------------------------- print(f"🌐 Enabling GitHub Pages for {actual_repo_name}...") if enable_github_pages(authed_username, actual_repo_name, user_token): site_url = f"https://{authed_username}.github.io/{actual_repo_name}/" print(f"🚀 Success! Website live at: {site_url}") return { "status": "success", "repo_name": actual_repo_name, "repo_url": repo.html_url, "website_url": site_url } else: print("GitHub Pages activation returned non-200 status. It may still be activating...") site_url = f"https://{authed_username}.github.io/{actual_repo_name}/" return { "status": "partial_success", "message": "Files uploaded, but GitHub Pages is still processing. Check back in 1 minute.", "repo_name": actual_repo_name, "repo_url": repo.html_url, "website_url": site_url } except Exception as e: if str(e) == "AI_EXHAUSTED": return JSONResponse( status_code=503, content={"error": "AI service temporarily unavailable. Please retry."} ) import traceback error_details = traceback.format_exc() print(f"🔥 UNCAUGHT EXCEPTION:\n{error_details}") return JSONResponse( status_code=400, content={"status": "error", "message": str(e)} ) @app.post("/resync_repository") async def resync_repository( request: Request, file: UploadFile = File(...), repo_name: str = Form(...), github_token: str = Form(None), github_username: str = Form(None), add_comments: bool = Form(False), comment_level: str = Form("concise") ): """ Resync WebCraft HTML file with existing GitHub repository. Args: file: Updated WebCraft HTML file repo_name: Existing repository name github_token: GitHub token (optional, uses .env) github_username: GitHub username (optional, uses .env) add_comments: Whether to add/update comments comment_level: Comment detail level (detailed/concise/minimal) """ try: # Validate file type if not file.filename.endswith(".html"): return {"status": "error", "message": "Only .html files are accepted."} # Create temporary directory and save file temp_dir = tempfile.mkdtemp() file_path = os.path.join(temp_dir, file.filename) with open(file_path, "wb") as f: f.write(await file.read()) # Add comments if requested if add_comments and comment_level in ["detailed", "concise", "minimal"]: try: is_benchmark = request.headers.get("x-benchmark-mode") == "true" file_path = add_comments_to_webcraft_file( file_path, comment_level, benchmark_mode=is_benchmark ) print(f"Added {comment_level} comments") except Exception as e: if str(e) == "AI_EXHAUSTED": raise e print(f"Could not add comments: {e}") # Use provided credentials or fallback to .env user_token = github_token if github_token else GITHUB_TOKEN user_name = github_username if github_username else GITHUB_USERNAME # Resync with GitHub repository result = resync_webcraft_file( username=user_name, repo_name=repo_name, token=user_token, html_file_path=file_path, commit_message="Resync from WebCraft" ) return result except Exception as e: if str(e) == "AI_EXHAUSTED": return JSONResponse( status_code=503, content={"error": "AI service temporarily unavailable. Please retry."} ) return JSONResponse( status_code=400, content={"status": "error", "message": str(e)} ) @app.post("/preview_comments") async def preview_comments( request: Request, file: UploadFile = File(...), comment_level: str = Form("concise") ): """ Preview WebCraft HTML file with AI-generated comments. Does not upload to GitHub - just returns the commented code. Args: file: WebCraft HTML file comment_level: Comment detail level (detailed/concise/minimal) """ try: # Validate file type if not file.filename.endswith(".html"): return {"status": "error", "message": "Only .html files are accepted."} # Create temporary directory and save file temp_dir = tempfile.mkdtemp() file_path = os.path.join(temp_dir, file.filename) output_path = os.path.join(temp_dir, f"commented_{file.filename}") with open(file_path, "wb") as f: f.write(await file.read()) # Add comments if comment_level not in ["detailed", "concise", "minimal", "educational"]: comment_level = "concise" is_benchmark = request.headers.get("x-benchmark-mode") == "true" commented_file = add_comments_to_webcraft_file( file_path, comment_level, output_path, benchmark_mode=is_benchmark ) # Read commented file with open(commented_file, "r", encoding="utf-8") as f: commented_content = f.read() return { "status": "success", "commented_code": commented_content, "comment_level": comment_level } except Exception as e: if str(e) == "AI_EXHAUSTED": return JSONResponse( status_code=503, content={"error": "AI service temporarily unavailable. Please retry."} ) return JSONResponse( status_code=400, content={"status": "error", "message": str(e)} ) @app.post("/suggest_repo_name") async def suggest_repo_name( file: UploadFile = File(...) ): """ Suggest a repository name based on HTML content. """ try: # Validate file type if not file.filename.endswith(".html"): return {"status": "error", "message": "Only .html files are accepted."} # Create temporary directory and save file temp_dir = tempfile.mkdtemp() file_path = os.path.join(temp_dir, file.filename) with open(file_path, "wb") as f: f.write(await file.read()) # Generate repo name using local T5 model title_results = get_repo_title(file_path) # Build list of options options = [] if title_results.get("title_tag"): options.append({"type": "title_tag", "name": title_results["title_tag"]}) if title_results.get("ai_title"): options.append({"type": "ai_title", "name": title_results["ai_title"]}) if title_results.get("fallback"): # If not already present if not any(opt["name"] == title_results["fallback"] for opt in options): options.append({"type": "fallback", "name": title_results["fallback"]}) return { "status": "success", "suggestions": options, "best_suggestion": options[0]["name"] if options else "my-webcraft-project" } except Exception as e: return JSONResponse( status_code=400, content={"status": "error", "message": str(e)} ) @app.post("/ai_builder") async def ai_builder( prompt: str = Form(...), current_workspace: str = Form(None), # New optional parameter max_blocks: int = Form(50), max_retries: int = Form(3) ): """ Generate Blockly workspace from natural language prompt using AI. This endpoint uses Qwen2.5-Coder-7B-Instruct to convert natural language descriptions into Blockly block configurations. Args: prompt: Natural language description (e.g., "Create a landing page with hero") current_workspace: JSON string of existing workspace (optional, for context) max_blocks: Maximum blocks to generate (safety limit, default: 50) max_retries: Maximum retry attempts for validation (default: 3) Returns: { "status": "success" | "error", "workspace": {"blocks": [...], "connections": [...]}, "block_count": N, "attempts": N, "message": "..." } """ try: from ai_planner import get_planner from block_validator import validate_workspace, format_validation_errors # Use plain ASCII logging to avoid Windows console encoding issues print(f"\nAI Builder Request: '{prompt[:100]}...'") if current_workspace: print(f"Context provided: {len(current_workspace)} chars") # Get AI planner (singleton, loaded once) planner = get_planner() # Generate with retry logic retry_context = None for attempt in range(1, max_retries + 1): print(f"Attempt {attempt}/{max_retries}...") # Generate workspace try: # Pass current_workspace to the planner workspace = planner.generate_blocks(prompt, retry_context, current_workspace) print(f"Generated {len(workspace.get('blocks', []))} blocks") except Exception as e: print(f"Generation failed: {e}") if attempt == max_retries: return JSONResponse( status_code=500, content={ "status": "error", "message": f"AI generation failed: {str(e)}", "attempts": attempt } ) continue # Validate validation = validate_workspace(workspace, max_blocks) if validation["valid"]: print("Valid workspace generated!") return { "status": "success", "workspace": workspace, "block_count": len(workspace["blocks"]), "attempts": attempt, "message": f"Successfully generated {len(workspace['blocks'])} blocks" } # Validation failed print("Validation failed:") for error in validation.get("errors", [])[:3]: # Show first 3 errors print(f" - {error}") # Retry with error context if attempt < max_retries: retry_context = format_validation_errors(validation) print("Retrying with error context...") # All retries failed return JSONResponse( status_code=400, content={ "status": "error", "message": "Failed to generate valid workspace after all retries", "last_errors": validation.get("errors", []), "last_warnings": validation.get("warnings"), "attempts": max_retries } ) except ImportError as e: return JSONResponse( status_code=500, content={ "status": "error", "message": f"AI Builder not available: {str(e)}. Please install required dependencies: transformers, torch, pydantic" } ) except Exception as e: # Keep logging minimal and ASCII-only to avoid Windows console encoding issues. print(f"AI Builder Error (internal): {type(e).__name__}") return JSONResponse( status_code=500, content={ "status": "error", "message": f"Internal server error: {str(e)}" } ) @app.get("/templates") async def list_templates(): """List all saved templates.""" try: templates_dir = "templates" if not os.path.exists(templates_dir): os.makedirs(templates_dir) templates = [] for filename in os.listdir(templates_dir): if filename.endswith(".json"): with open(os.path.join(templates_dir, filename), "r", encoding="utf-8") as f: try: data = json.load(f) templates.append(data) except: pass return {"status": "success", "templates": templates} except Exception as e: return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) @app.post("/templates") async def save_template( name: str = Form(...), description: str = Form(""), blocks: str = Form(...) ): """Save a new block template.""" try: templates_dir = "templates" if not os.path.exists(templates_dir): os.makedirs(templates_dir) # Create a safe filename safe_name = "".join([c for c in name if c.isalnum() or c in (' ', '-', '_')]).rstrip() safe_name = safe_name.replace(' ', '_').lower() filename = f"{safe_name}.json" template_data = { "id": safe_name, "name": name, "description": description, "blocks": blocks, "created_at": str(datetime.datetime.now()) } with open(os.path.join(templates_dir, filename), "w", encoding="utf-8") as f: json.dump(template_data, f, indent=2) return {"status": "success", "message": "Template saved", "template": template_data} except Exception as e: return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) @app.delete("/templates/{template_id}") async def delete_template(template_id: str): """Delete a saved template.""" try: templates_dir = "templates" filename = f"{template_id}.json" file_path = os.path.join(templates_dir, filename) if os.path.exists(file_path): os.remove(file_path) return {"status": "success", "message": f"Template {template_id} deleted"} else: return JSONResponse(status_code=404, content={"status": "error", "message": "Template not found"}) except Exception as e: return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) @app.get("/health_check") async def health_check(): """Diagnostic endpoint: tests all AI models and returns 'UP'/'DOWN' per model. Intended for benchmark scripts only — each call fires up to 5 LLM test prompts. """ from code_commenter import _FREE_MODEL_CHAIN, _call_openrouter, _call_huggingface status = {} test_msg = [ {"role": "system", "content": "You are a tester."}, {"role": "user", "content": "Reply exactly with the word OK and nothing else."} ] for model in _FREE_MODEL_CHAIN: try: resp = _call_openrouter(model, test_msg, label="health-check") if resp and resp.status_code == 200: status[model] = "UP" else: status[model] = "DOWN" except Exception: status[model] = "DOWN" try: hf_resp = _call_huggingface(test_msg) status["microsoft/Phi-3.5-mini-instruct (HF)"] = "UP" if hf_resp else "DOWN" except Exception: status["microsoft/Phi-3.5-mini-instruct (HF)"] = "DOWN" return status if __name__ == "__main__": import uvicorn # Get port from environment variable for Heroku port = int(os.environ.get("PORT", 8000)) uvicorn.run(app, host="0.0.0.0", port=port)