Spaces:
Sleeping
Sleeping
3ssem0
fix: configuration error - aligned entry point to app.py and enforced LF/BOM-less encoding
ec47953 | 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 | |
| ) | |
| async def root(): | |
| return {"status": "success", "message": "WebCraft AI Backend is running!"} | |
| 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 <title> 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)} | |
| ) | |
| 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)} | |
| ) | |
| 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)} | |
| ) | |
| 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)} | |
| ) | |
| 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)}" | |
| } | |
| ) | |
| 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)}) | |
| 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)}) | |
| 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)}) | |
| 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) | |