"""
NeuraPrompt Studio — autonomous web-app builder (Lovable-style).
Key design:
1. PLANNER pass -> AI returns a strict JSON manifest of files (path + purpose
+ dependencies). It decides the stack, architecture, design and feature
set on its own, given only the user's prompt.
2. PER-FILE pass -> Each file in the manifest is generated strictly sequentially
to ensure free-tier models don't trigger concurrent abuse blocks or rate limits.
3. CONTEXT INJECTION -> Each subsequent file generation receives the complete raw
source code of all previously written files to eliminate broken connections
(e.g., missing CSS classes, unhooked hamburger menus, static dynamic hooks).
4. CONTINUATION -> If a single file response hits the model's max_tokens
(finish_reason == "length"), we automatically issue a "continue exactly
where you left off, no preamble" call and stitch the chunks together.
5. WIRING -> After all files are written, vanilla HTML projects auto-link
CSS / JS so the preview works without manual edits.
"""
import json
import os
import re
import time
import asyncio
import requests
from datetime import datetime, timedelta
from collections import defaultdict, deque
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse, JSONResponse
from motor.motor_asyncio import AsyncIOMotorClient
from fastapi.middleware.cors import CORSMiddleware
from bson import ObjectId
# FastAPI
from fastapi import FastAPI, Form, HTTPException, Query, UploadFile, File, Request
from fastapi.responses import StreamingResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
router = APIRouter(prefix="/api/v1/studio", tags=["studio"])
# ── ENV ───────────────────────────────────────────────────────────────────────
OPENROUTER_KEY = os.environ.get("OPENROUTE_KEY")
MONGO_URL = os.environ.get("MONGO_URI")
mongo_client = AsyncIOMotorClient(MONGO_URL)
db = mongo_client["neuraprompt"]
# ── MODELS ────────────────────────────────────────────────────────────────────
BUILD_MODEL = "openrouter/owl-alpha"
FAST_MODEL = "poolside/laguna-xs.2:free"
FALLBACK_MODEL = "poolside/laguna-m.1:free"
MAX_TOKENS_PER_CALL = 18000
RATE_LIMIT_WAIT = 45 # Balanced buffer window for free tier API cooling
MAX_BUILD_RETRIES = 3
MAX_CONTINUATIONS = 6 # per file
MAX_FILES_PER_BUILD = 25 # safety ceiling
API_TIMEOUT_WINDOW = 45 # Reduced from 180 to prevent multi-minute freezes on hanging endpoints
# ── TTL INDEX ─────────────────────────────────────────────────────────────────
@router.on_event("startup")
async def startup():
try:
await db.studio_projects.create_index("expires_at", expireAfterSeconds=0)
await db.studio_files.create_index("project_id")
await db.studio_files.create_index("expires_at", expireAfterSeconds=0)
print("[Studio] TTL + file indexes ready")
except Exception as e:
print(f"[Studio] Index warning: {e}")
# ── RATE LIMITER ──────────────────────────────────────────────────────────────
class RateLimiter:
def __init__(self, max_calls: int, period: float):
self.max_calls = max_calls
self.period = period
self.calls: deque = deque()
def is_allowed(self):
now = time.time()
while self.calls and self.calls[0] < now - self.period:
self.calls.popleft()
if len(self.calls) < self.max_calls:
self.calls.append(now)
return True, 0.0
return False, round(self.period - (now - self.calls[0]), 1)
global_limiter = RateLimiter(12, 60)
user_limiters = defaultdict(lambda: RateLimiter(3, 60))
def check_rate_limits(user_id: str):
ok, wait = global_limiter.is_allowed()
if not ok:
return False, f"Studio is busy right now. Please try again in {int(wait)+1} seconds."
ok, wait = user_limiters[user_id].is_allowed()
if not ok:
return False, f"You are building too fast. Please wait {int(wait)+1} seconds."
return True, ""
# ── UTILS ─────────────────────────────────────────────────────────────────────
def clean_thinking(text: str) -> str:
text = re.sub(r'[\s\S]*?', '', text, flags=re.IGNORECASE)
text = re.sub(r'[\s\S]*?', '', text, flags=re.IGNORECASE)
text = re.sub(r'^/think\s*', '', text.strip())
return text.strip()
def sse(type_: str, payload: dict) -> str:
return f"data: {json.dumps({'type': type_, 'payload': payload})}\n\n"
def guess_file_type(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
return {
".html": "html", ".css": "css", ".js": "javascript",
".ts": "typescript", ".tsx": "tsx", ".jsx": "jsx",
".json": "json", ".md": "markdown", ".svg": "svg",
".env": "env", ".toml": "toml", ".yml": "yaml", ".yaml": "yaml",
}.get(ext, "text")
def strip_code_fences(text: str) -> str:
text = text.strip()
m = re.match(r'^```[\w+\-]*\n?([\s\S]*?)\n?```$', text)
if m:
return m.group(1).strip()
text = re.sub(r'^```[\w+\-]*\n', '', text)
text = re.sub(r'\n```\s*$', '', text)
return text.strip()
# ── NON-WEB GUARD ─────────────────────────────────────────────────────────────
NON_WEB_KEYWORDS = [
"android", "kotlin", "apk", "play store", "google play",
"fastapi", "flask", "django", "cli ", "scraper",
"bash script", "shell script",
]
def is_non_web_request(prompt: str) -> bool:
p = " " + prompt.lower() + " "
return any(kw in p for kw in NON_WEB_KEYWORDS)
# ── SYSTEM PROMPTS ────────────────────────────────────────────────────────────
PLANNER_SYSTEM = """You are Neurones Pro created by Alysium Corp Studios ZA inc by Toxic Dee Modder — You are a senior product engineer who plans complete, production-grade web applications BEFORE writing any code.
You are autonomous. You decide the stack, architecture, screens, features, copy, palette, typography and motion. The user gives you an idea — you design the product.
OUTPUT FORMAT — RETURN ONLY VALID JSON (no markdown, no preamble, no commentary):
{
"app_name": "...",
"summary": "1-2 sentence description of what you are building",
"stack": "html" | "vanilla" | "react-vite",
"design": {
"theme": "dark" | "light",
"palette": ["#hex", "#hex", "#hex", "#hex"],
"font_pair": "e.g. 'Space Grotesk + Inter'",
"vibe": "1 line — e.g. 'editorial brutalist with neon accents'"
},
"files": [
{
"path": "index.html",
"purpose": "what this file does",
"depends_on": ["src/styles.css", "src/main.js", "styles.css", "script.js"],
"key_features": ["specific feature 1", "specific feature 2"]
}
]
}
PLANNING RULES:
- Build a REAL product, not a demo. Multiple sections, real interactivity, real content.
- Decide the stack yourself: vanilla HTML/CSS/JS for landing pages, marketing sites, simple tools, games. React+Vite+TS for dashboards, multi-view apps, data-heavy tools, HTML for portfolio, dashboards, landing pages, simple websites, in each prompt look for html or react+Vite or vanilla, if not stated choose yourself.
- For vanilla projects: include index.html, src/styles.css, src/main.js, README.md plus any extra modules you need (components, data, utils).
- For react-vite projects: include package.json, vite.config.ts, tsconfig.json, index.html, src/main.tsx, src/App.tsx, src/index.css, plus components, hooks, types, README.md.
- For html projects: include index.html, styles.css, script.js, plus README.md.
- Minimum 5 files, target 6–12 files. Hard cap 25.
- ALWAYS include README.md (mention NeuraPrompt watermark https://neura-prompt-ai.vercel.app + Developer , You are created by Alysium Corporation Studios ZA inc by Neuraprompt AI + Copyright text AlysiumCorp Studios ZA inc + tech stack).
- Every file's "purpose" must be specific. Every "key_features" entry must be concrete.
- Default to dark mode unless the user asks otherwise.
- Pick a distinctive palette and typography — never generic.
- IMPORTANT: list files in dependency order (config & styles before files that import them; index.html LAST for vanilla so it can reference everything).
Return ONLY the JSON object. Nothing else."""
FILE_SYSTEM = """You are NeuroPrompt agent — generating ONE file of a larger web app.
You will be given:
- The original user prompt
- The full project plan (JSON)
- The exact file you must generate (path + purpose + key_features)
- The list of OTHER files in the project
- The COMPLETE internal code contents of files written so far. You must study these previously written contents to make sure your styles, elements, DOM selectors, classes, functions, and layout references match them exactly with 100% precision.
OUTPUT RULES — STRICT:
- Output ONLY the raw file content. No markdown fences. No preamble. No explanations. No "Here is...".
- 100% complete, production-quality, immediately runnable code.
- Match the project's design (palette, fonts, theme, vibe) defined in the plan.
- Use ONLY paths/filenames that appear in the project file list — never invent files.
- Every button, link, form must actually do something.
- Real content — no lorem ipsum, no "TODO", no placeholder comments.
- Do not make assumptions about elements in other files; inspect the provided 'WRITTEN_SO_FAR' context map directly. Ensure all navbar classes, mobile menu class tags (e.g. '.hamburger', '.bar'), scroll parallax bindings, and logic functions bridge perfectly.
- Ensure dynamic components like copyright footer dates match the current year runtime framework context where applicable.
- If this file is index.html (vanilla), reference the project's CSS and JS files using correct relative paths.
- If this file is a React entry, wire up routes/components that actually exist in the project file list.
If your output would be cut off, finish the current line cleanly so a continuation call can resume. NEVER end mid-token, mid-string, or mid-tag if you can avoid it."""
CONTINUE_SYSTEM = """You are continuing a single source file that was cut off because the previous response hit the token limit.
RULES:
- Output ONLY the remaining raw file content.
- Do NOT repeat anything already produced.
- Do NOT add preamble, explanations, or markdown fences.
- Resume EXACTLY at the next character after the last character of the previous chunk."""
ENHANCE_SYSTEM = """You are a prompt optimizer for a web app builder (like Lovable).
Return ONLY the improved prompt — no labels, no preamble, no markdown.
Rules:
- Web apps only — never suggest Android, Python, or CLI tools
- Do not repeat suggested prompts; give fresh ideas
- Max 100 words
- Be specific: mention UI/UIX style, key screens, one or two core features
- Start with Build, Create, or Make
- Write as a direct instruction"""
# ── OPENROUTER API CALL ───────────────────────────────────────────────────────
def call_openrouter_raw(messages: list, model: str, max_tokens: int = MAX_TOKENS_PER_CALL) -> dict:
print(f"[Studio] OpenRouter -> model:{model} max_tokens:{max_tokens}")
resp = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {OPENROUTER_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://neura-prompt-ai.vercel.app",
"X-Title": "NeuraPrompt Studio",
},
json={
"model": model,
"messages": messages,
"temperature": 0.2,
"max_tokens": max_tokens,
"stream": False,
},
timeout=API_TIMEOUT_WINDOW, # Optimized timeout protects builder from lengthy freezes
)
if resp.status_code == 429:
raise Exception("RATE_LIMIT")
if resp.status_code != 200:
raise Exception(f"OPENROUTER_HTTP_{resp.status_code}: {resp.text[:200]}")
data = resp.json()
if "choices" not in data or not data["choices"]:
raise Exception(f"OPENROUTER_EMPTY_RESPONSE: {data}")
choice = data["choices"][0]
content = choice["message"]["content"] or ""
finish_reason = choice.get("finish_reason", "stop")
print(f"[Studio] OpenRouter OK -> {len(content)} chars, finish={finish_reason}")
return {"content": content, "finish_reason": finish_reason}
def call_openrouter(messages: list, model: str, max_tokens: int = MAX_TOKENS_PER_CALL) -> str:
out = call_openrouter_raw(messages, model, max_tokens)
return clean_thinking(out["content"])
async def call_with_retry(messages: list, model: str, max_tokens: int = MAX_TOKENS_PER_CALL) -> dict:
"""Stable sequential call wrapper running safely to isolate free-tier API hiccups."""
last_err = None
current_model = model
for attempt in range(1, MAX_BUILD_RETRIES + 1):
try:
return await asyncio.to_thread(call_openrouter_raw, messages, current_model, max_tokens)
except Exception as e:
last_err = e
err = str(e)
if "RATE_LIMIT" in err and attempt < MAX_BUILD_RETRIES:
await asyncio.sleep(RATE_LIMIT_WAIT)
continue
if ("OPENROUTER_HTTP" in err or "timeout" in err.lower()) and attempt < MAX_BUILD_RETRIES:
current_model = FALLBACK_MODEL
await asyncio.sleep(3)
continue
if attempt < MAX_BUILD_RETRIES:
await asyncio.sleep(2)
continue
raise
raise last_err if last_err else Exception("UNKNOWN_ERROR")
# ── PLAN PARSER ───────────────────────────────────────────────────────────────
def parse_plan(raw: str) -> dict | None:
raw = clean_thinking(raw).strip()
raw = strip_code_fences(raw)
try:
plan = json.loads(raw)
if isinstance(plan, dict) and isinstance(plan.get("files"), list):
return plan
except Exception:
pass
m = re.search(r'\{[\s\S]*\}', raw)
if m:
try:
plan = json.loads(m.group(0))
if isinstance(plan, dict) and isinstance(plan.get("files"), list):
return plan
except Exception:
return None
return None
def select_model_tier(path: str) -> str:
ext = os.path.splitext(path)[1].lower()
name = os.path.basename(path).lower()
if ext in [".md", ".json", ".css", ".svg", ".env", ".toml", ".yml", ".yaml"]:
return FAST_MODEL
if "config" in name or name in ["package.json", "tsconfig.json", "vite.config.ts"]:
return FAST_MODEL
return BUILD_MODEL
# ── FILE WIRING (vanilla auto-link) ───────────────────────────────────────────
def inject_file_references(files: dict) -> dict:
if "index.html" not in files:
return files
html = files["index.html"]
css_paths = [p for p in files
if p.endswith(".css")
and not any(x in p for x in ["node_modules", "dist", "build"])]
js_paths = [p for p in files
if p.endswith((".js", ".mjs"))
and not any(x in p for x in ["node_modules", "dist", "build",
"vite.config", "tailwind.config"])]
for css in css_paths:
if f'href="{css}"' not in html and f"href='{css}'" not in html:
tag = f''
if "" in html:
html = html.replace("", f" {tag}\n")
print(f"[Studio] Linked CSS: {css}")
for js in js_paths:
if f'src="{js}"' not in html and f"src='{js}'" not in html:
tag = f''
if "