diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..096503629e00c3b6282f8b299ee66d64adbf05c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +.env +.venv/ +.venv-production/ +.venv311/ +venv/ +frontend/node_modules/ +frontend/.next/ +builds/ +releases/ +backups/ +__pycache__/ +*.pyc +.pytest_cache/ +storage/cms/ + +# Exclude local logs/reports/backups from HF push +reports/ +logs/ +backups/ +frontend/.backup/ +*.bak.* +*.bak +frontend/interrogation/ +frontend/mcp/ +frontend/production.log +scripts/*.sh +!scripts/197_add_missing_backend_contracts.sh +!scripts/198_clean_push_to_hf.sh + +# Exclude local logs/reports/backups from HF push +reports/ +logs/ +backups/ +frontend/.backup/ +*.bak.* +*.bak +frontend/interrogation/ +frontend/mcp/ +frontend/production.log +scripts/*.sh +!scripts/197_add_missing_backend_contracts.sh +!scripts/198_clean_push_to_hf.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..6916ac14dfc7a6396ec3499bdcca841f61b45cad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,126 @@ +FROM python:3.11-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + ANDROID_HOME=/opt/android-sdk \ + ANDROID_SDK_ROOT=/opt/android-sdk \ + ANDROID_BUILD_TOOLS_VERSION=36.0.0 \ + ANDROID_PLATFORM_VERSION=36 \ + PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/opt/android-sdk/build-tools/36.0.0:$PATH + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + wget \ + git \ + unzip \ + zip \ + openjdk-17-jdk-headless \ + build-essential \ + libstdc++6 \ + libc6 \ + zlib1g && \ + rm -rf /var/lib/apt/lists/* + +############################################################################### +# Android command-line tools +# +# Official Linux command-line tools release. +# SHA-256 is verified before extraction. +############################################################################### + +ARG ANDROID_CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-15859902_latest.zip" +ARG ANDROID_CMDLINE_TOOLS_SHA256="4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583" + +RUN set -eux; \ + mkdir -p "${ANDROID_SDK_ROOT}/cmdline-tools"; \ + cd /tmp; \ + curl -fsSL --retry 5 --retry-delay 3 \ + "${ANDROID_CMDLINE_TOOLS_URL}" \ + -o commandlinetools.zip; \ + echo "${ANDROID_CMDLINE_TOOLS_SHA256} commandlinetools.zip" | sha256sum -c -; \ + unzip -q commandlinetools.zip -d "${ANDROID_SDK_ROOT}/cmdline-tools"; \ + mv "${ANDROID_SDK_ROOT}/cmdline-tools/cmdline-tools" \ + "${ANDROID_SDK_ROOT}/cmdline-tools/latest"; \ + rm -f commandlinetools.zip + +############################################################################### +# Android SDK packages required by native_apk_builder +############################################################################### + +RUN yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null || true + +RUN sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" \ + "platform-tools" \ + "platforms;android-${ANDROID_PLATFORM_VERSION}" \ + "build-tools;${ANDROID_BUILD_TOOLS_VERSION}" + +############################################################################### +# Verify the complete native Android toolchain during image construction. +############################################################################### + +RUN set -eux; \ + test -x "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/aapt2"; \ + test -x "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/d8"; \ + test -x "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/zipalign"; \ + test -x "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/apksigner"; \ + test -f "${ANDROID_SDK_ROOT}/platforms/android-${ANDROID_PLATFORM_VERSION}/android.jar"; \ + java -version; \ + "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/aapt2" version; \ + "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/d8" --version; \ + "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/zipalign" -h >/dev/null; \ + "${ANDROID_SDK_ROOT}/build-tools/${ANDROID_BUILD_TOOLS_VERSION}/apksigner" --version + +############################################################################### +# Python dependencies +############################################################################### + +COPY requirements.txt . + +RUN python -m pip install --upgrade pip && \ + python -m pip install --no-cache-dir -r requirements.txt + +############################################################################### +# Application +############################################################################### + +COPY . . + +RUN python - <<'PY' +import importlib + +for module in ( + "fastapi", + "uvicorn", + "asyncpg", + "edge_tts", +): + importlib.import_module(module) + +import app + +assert app.app.__class__.__name__ == "FastAPI" + +print("[OK] Production application import passed") +PY + +############################################################################### +# Runtime verification +############################################################################### + +RUN set -eux; \ + command -v java; \ + command -v aapt2; \ + command -v d8; \ + command -v zipalign; \ + command -v apksigner; \ + test -f "${ANDROID_SDK_ROOT}/platforms/android-${ANDROID_PLATFORM_VERSION}/android.jar" + +EXPOSE 7860 + +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..9be5dfc694cff55f51184945597d743751275134 --- /dev/null +++ b/app.py @@ -0,0 +1,7 @@ +from app_part1 import app +import app_auth # noqa: F401 registers /v1/auth/register and /v1/auth/login +import app_part3 # noqa: F401 +import mcp_routes # noqa: F401 registers /api/mcp (JSON-RPC 2.0) + +from app_routes_extension import register as _register_ext +_register_ext(app) diff --git a/app_auth.py b/app_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..e783dc956b81f536026508ac63f322c2b7687195 --- /dev/null +++ b/app_auth.py @@ -0,0 +1,59 @@ +import os +import hashlib +import asyncpg +from fastapi import HTTPException +from pydantic import BaseModel + +import app_part1 +from app_part1 import app + +SECRET_KEY = os.environ.get("DOLOR3V_SECRET_KEY", "dev-secret-change-me") +IN_MEMORY_USERS = {} + +def hash_password(password: str) -> str: + return hashlib.sha256((password + SECRET_KEY).encode()).hexdigest() + +class UserCreateRequest(BaseModel): + email: str + password: str + name: str + +class UserLoginRequest(BaseModel): + email: str + password: str + +USERS_TABLE_SQL = "CREATE TABLE IF NOT EXISTS dolor3v_users (id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, name TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())" + +@app.post("/v1/auth/register") +async def register_user(req: UserCreateRequest): + password_hash = hash_password(req.password) + if app_part1.db_pool is not None: + try: + async with app_part1.db_pool.acquire() as conn: + await conn.execute(USERS_TABLE_SQL) + row = await conn.fetchrow( + "INSERT INTO dolor3v_users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name", + req.email, password_hash, req.name + ) + return {"user": dict(row), "token": f"user_{row['id']}"} + except asyncpg.UniqueViolationError: + raise HTTPException(status_code=400, detail="Email already exists") + if req.email in IN_MEMORY_USERS: + raise HTTPException(status_code=400, detail="Email already exists") + user_id = len(IN_MEMORY_USERS) + 1 + IN_MEMORY_USERS[req.email] = {"id": user_id, "email": req.email, "password_hash": password_hash, "name": req.name} + return {"user": {"id": user_id, "email": req.email, "name": req.name}, "token": f"user_{user_id}"} + +@app.post("/v1/auth/login") +async def login_user(req: UserLoginRequest): + password_hash = hash_password(req.password) + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + row = await conn.fetchrow("SELECT id, email, name FROM dolor3v_users WHERE email = $1 AND password_hash = $2", req.email, password_hash) + if not row: + raise HTTPException(status_code=401, detail="Invalid credentials") + return {"user": dict(row), "token": f"user_{row['id']}"} + user = IN_MEMORY_USERS.get(req.email) + if not user or user["password_hash"] != password_hash: + raise HTTPException(status_code=401, detail="Invalid credentials") + return {"user": {"id": user["id"], "email": user["email"], "name": user["name"]}, "token": f"user_{user['id']}"} diff --git a/app_part1.py b/app_part1.py new file mode 100644 index 0000000000000000000000000000000000000000..ce4ecca2030a27251da5377d3e9e13990ef039d6 --- /dev/null +++ b/app_part1.py @@ -0,0 +1,187 @@ +import io +import json +import os +import logging +import uuid +import asyncio +import zipfile +from datetime import datetime +from contextlib import asynccontextmanager +from typing import Any, Optional, List + +import asyncpg +import httpx +from fastapi import FastAPI, File, HTTPException, UploadFile, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("dolor3v-unified-backend") + +# --------------------------------------------------------------------------- +# Config: LLM Providers +# --------------------------------------------------------------------------- +GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") +CEREBRAS_API_KEY = os.environ.get("CEREBRAS_API_KEY", "") +OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "") + +GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" +CEREBRAS_URL = "https://api.cerebras.ai/v1/chat/completions" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" + +VIRTUAL_MODELS = { + "auto": None, + "groq-llama-3.3-70b": ("groq", "llama-3.3-70b-versatile"), + "cerebras-glm-4.7": ("cerebras", "zai-glm-4.7"), + "openrouter-gpt-oss-120b-free": ("openrouter", "openai/gpt-oss-120b:free"), +} + +AUTO_CHAIN = [ + ("groq", "llama-3.3-70b-versatile"), + ("cerebras", "zai-glm-4.7"), + ("openrouter", "openai/gpt-oss-120b:free"), +] + +# --------------------------------------------------------------------------- +# Config: Postgres with Dual-Mode In-Memory Fallbacks +# --------------------------------------------------------------------------- +POSTGRES_SERVER = os.environ.get("POSTGRES_SERVER", "") +POSTGRES_PORT = os.environ.get("POSTGRES_PORT", "5432") +POSTGRES_USER = os.environ.get("POSTGRES_USER", "") +POSTGRES_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "") +POSTGRES_DB = os.environ.get("POSTGRES_DB", "") +POSTGRES_SSL = os.environ.get("POSTGRES_SSL", "false").strip().lower() in ("1", "true", "yes") + +db_pool: Optional[asyncpg.Pool] = None + +IN_MEMORY_PROJECTS = {} +IN_MEMORY_VERSIONS = {} +IN_MEMORY_BUILDS = {} +IN_MEMORY_ASSETS = [] + +async def init_db_pool() -> asyncpg.Pool: + pool = await asyncpg.create_pool( + host=POSTGRES_SERVER, + port=int(POSTGRES_PORT), + user=POSTGRES_USER, + password=POSTGRES_PASSWORD, + database=POSTGRES_DB, + min_size=1, + max_size=5, + timeout=10, + ssl=True if POSTGRES_SSL else None, + ) + async with pool.acquire() as conn: + await conn.execute("""CREATE TABLE IF NOT EXISTS dolor3v_projects (page_id TEXT PRIMARY KEY, data JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now())""") + await conn.execute("""CREATE TABLE IF NOT EXISTS dolor3v_assets (id SERIAL PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL, type TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())""") + await conn.execute("""CREATE TABLE IF NOT EXISTS dolor3v_versions (id SERIAL PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, data JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now())""") + await conn.execute("""CREATE TABLE IF NOT EXISTS dolor3v_builds (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, platform TEXT NOT NULL, status TEXT NOT NULL, logs TEXT NOT NULL, download_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())""") + return pool + +@asynccontextmanager +async def lifespan(app: FastAPI): + global db_pool + try: + db_pool = await init_db_pool() + logger.info("Database pool initialized.") + except Exception as exc: + logger.error("Failed to initialize Postgres pool. Falling back to memory: %s", exc) + db_pool = None + yield + if db_pool is not None: + await db_pool.close() + +app = FastAPI(title="Dolor3v AI Workspace Core Engine", lifespan=lifespan) + +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +# --------------------------------------------------------------------------- +# Config: Google Drive +# --------------------------------------------------------------------------- +GDRIVE_SERVICE_ACCOUNT_JSON = os.environ.get("GDRIVE_SERVICE_ACCOUNT_JSON", "") +GDRIVE_FOLDER_ID = os.environ.get("GDRIVE_FOLDER_ID", "") +_drive_service_cache: Optional[Any] = None + +def _get_drive_service_sync() -> Any: + global _drive_service_cache + if _drive_service_cache is not None: + return _drive_service_cache + if not GDRIVE_SERVICE_ACCOUNT_JSON: + raise RuntimeError("GDRIVE_SERVICE_ACCOUNT_JSON is not configured") + from google.oauth2 import service_account + from googleapiclient.discovery import build + info = json.loads(GDRIVE_SERVICE_ACCOUNT_JSON) + credentials = service_account.Credentials.from_service_account_info(info, scopes=["https://www.googleapis.com/auth/drive"]) + _drive_service_cache = build("drive", "v3", credentials=credentials, cache_discovery=False) + return _drive_service_cache + +def _upload_one_sync(service: Any, filename: str, mimetype: str, content: bytes) -> dict: + from googleapiclient.http import MediaIoBaseUpload + media = MediaIoBaseUpload(io.BytesIO(content), mimetype=mimetype or "application/octet-stream", resumable=False) + file_metadata = {"name": filename, "parents": [GDRIVE_FOLDER_ID]} + created = service.files().create(body=file_metadata, media_body=media, fields="id, name, webContentLink, webViewLink").execute() + service.permissions().create(fileId=created["id"], body={"role": "reader", "type": "anyone"}).execute() + src = created.get("webContentLink") or f"https://drive.google.com/uc?export=view&id={created['id']}" + return {"src": src, "name": created.get("name", filename), "type": "image"} + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- +class ChatMessage(BaseModel): role: str; content: str +class ChatRequest(BaseModel): model: str = "auto"; messages: list[ChatMessage]; temperature: Optional[float] = None; max_tokens: Optional[int] = None +class ProjectSaveRequest(BaseModel): pageId: str; data: dict[str, Any] +class PublishRequest(BaseModel): project_id: str; platform: str = "web" +class NativeGenerateRequest(BaseModel): prompt: str; platform: str = "flutter"; project_id: str +class NativeBuildRequest(BaseModel): project_id: str; platform: str; config: Optional[dict[str, Any]] = None +class CodeGenerateRequest(BaseModel): prompt: str; language: str; context: Optional[str] = None +class CodeRefactorRequest(BaseModel): code: str; instructions: str; language: str +class CodeFixRequest(BaseModel): code: str; error: str; language: str +class VersionSaveRequest(BaseModel): project_id: str; name: str; data: dict[str, Any] +class VersionRestoreRequest(BaseModel): project_id: str; version_id: int + +# --------------------------------------------------------------------------- +# AI Engine Fallback & Completions +# --------------------------------------------------------------------------- +async def call_groq(model: str, messages: list[dict], **kwargs) -> dict[str, Any]: + if not GROQ_API_KEY: raise RuntimeError("GROQ_API_KEY not configured") + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(GROQ_URL, headers={"Authorization": f"Bearer {GROQ_API_KEY}"}, json={"model": model, "messages": messages, **kwargs}) + if resp.status_code!= 200: raise RuntimeError(f"Groq {resp.status_code}: {resp.text[:500]}") + return resp.json() + +async def call_cerebras(model: str, messages: list[dict], **kwargs) -> dict[str, Any]: + if not CEREBRAS_API_KEY: raise RuntimeError("CEREBRAS_API_KEY not configured") + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(CEREBRAS_URL, headers={"Authorization": f"Bearer {CEREBRAS_API_KEY}"}, json={"model": model, "messages": messages, **kwargs}) + if resp.status_code!= 200: raise RuntimeError(f"Cerebras {resp.status_code}: {resp.text[:500]}") + return resp.json() + +async def call_openrouter(model: str, messages: list[dict], **kwargs) -> dict[str, Any]: + if not OPENROUTER_API_KEY: raise RuntimeError("OPENROUTER_API_KEY not configured") + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(OPENROUTER_URL, headers={"Authorization": f"Bearer {OPENROUTER_API_KEY}"}, json={"model": model, "messages": messages, **kwargs}) + if resp.status_code!= 200: raise RuntimeError(f"OpenRouter {resp.status_code}: {resp.text[:500]}") + return resp.json() + +PROVIDER_FUNCS = {"groq": call_groq, "cerebras": call_cerebras, "openrouter": call_openrouter} + +async def get_ai_completion(system_prompt: str, user_prompt: str, temperature: float = 0.3) -> str: + messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}] + errors = [] + for provider, real_model in AUTO_CHAIN: + try: + result = await PROVIDER_FUNCS[provider](real_model, messages, temperature=temperature) + return result.get("choices", [])[0].get("message", {}).get("content", "") + except Exception as exc: + logger.warning("Provider %s failed: %s", provider, exc); errors.append(str(exc)) + raise HTTPException(status_code=502, detail={"error": "All AI providers failed", "details": errors}) + +BUILD_JOBS = {} + + +from mcp_routes import router as mcp_router +app.include_router(mcp_router) + +from builder_routes import router as builder_router +app.include_router(builder_router) diff --git a/app_part2.py b/app_part2.py new file mode 100644 index 0000000000000000000000000000000000000000..4852c6d7ec4e993938c3afc554c33a46cad91a29 --- /dev/null +++ b/app_part2.py @@ -0,0 +1,442 @@ +from app_part1 import * +from pathlib import Path +from datetime import datetime +from typing import Optional +import uuid + +from fastapi import BackgroundTasks, HTTPException +from starlette.concurrency import run_in_threadpool + + +from pathlib import Path +from datetime import datetime +from typing import Optional +import uuid + +from fastapi import BackgroundTasks, HTTPException +from starlette.concurrency import run_in_threadpool + +import app_part1 +import native_apk_builder +from bundle_parser import parse_bundle, BundleParseError +import traceback +from fastapi.responses import JSONResponse + +NATIVE_PROJECT_FILES: dict = {} + + +# --------------------------------------------------------------------------- +# Base & Diagnostics Endpoints +# --------------------------------------------------------------------------- +@app.get("/health") +async def health(): + return {"status": "ok", "providers_configured": {"groq": bool(GROQ_API_KEY), "cerebras": bool(CEREBRAS_API_KEY), "openrouter": bool(OPENROUTER_API_KEY)}, "postgres_connected": db_pool is not None, "google_drive_configured": bool(GDRIVE_SERVICE_ACCOUNT_JSON and GDRIVE_FOLDER_ID), "active_storage_mode": "Postgres" if db_pool is not None else "In-Memory"} + +@app.post("/v1/chat/completions") +async def chat_completions(req: ChatRequest): + messages = [m.model_dump() for m in req.messages]; kwargs = {"temperature": req.temperature, "max_tokens": req.max_tokens} + if req.model in VIRTUAL_MODELS and VIRTUAL_MODELS[req.model] is not None: + provider, real_model = VIRTUAL_MODELS[req.model] + result = await PROVIDER_FUNCS[provider](real_model, messages, **kwargs) + result["_dolor3v_provider"] = provider; return result + for provider, real_model in AUTO_CHAIN: + try: + result = await PROVIDER_FUNCS[provider](real_model, messages, **kwargs) + result["_dolor3v_provider"] = provider; result["_dolor3v_model"] = real_model; return result + except Exception as exc: logger.warning("Provider %s failed: %s", provider, exc) + raise HTTPException(status_code=502, detail={"error": "All providers failed"}) + +# --------------------------------------------------------------------------- +# Project Storage Services +# --------------------------------------------------------------------------- +@app.get("/v1/projects") +async def list_projects(): + if db_pool is not None: + async with db_pool.acquire() as conn: rows = await conn.fetch("SELECT page_id, updated_at FROM dolor3v_projects ORDER BY updated_at DESC") + return [{"projectId": row["page_id"], "updated_at": row["updated_at"].isoformat()} for row in rows] + return [{"projectId": k, "updated_at": v["updated_at"]} for k, v in IN_MEMORY_PROJECTS.items()] + +@app.get("/v1/projects/{page_id}") +async def get_project(page_id: str): + if db_pool is not None: + async with db_pool.acquire() as conn: row = await conn.fetchrow("SELECT data, updated_at FROM dolor3v_projects WHERE page_id = $1", page_id) + if row: return {"pageId": page_id, "data": json.loads(row["data"]), "updated_at": row["updated_at"].isoformat()} + elif page_id in IN_MEMORY_PROJECTS: return {"pageId": page_id, "data": IN_MEMORY_PROJECTS[page_id]["data"], "updated_at": IN_MEMORY_PROJECTS[page_id]["updated_at"]} + raise HTTPException(status_code=404, detail={"error": f"No project found for id '{page_id}'"}) + +@app.post("/v1/projects/{page_id}") +@app.put("/v1/projects/{page_id}") +async def save_project(page_id: str, req: ProjectSaveRequest): + payload = json.dumps(req.data) + if db_pool is not None: + async with db_pool.acquire() as conn: row = await conn.fetchrow("INSERT INTO dolor3v_projects (page_id, data, updated_at) VALUES ($1, $2::jsonb, now()) ON CONFLICT (page_id) DO UPDATE SET data = EXCLUDED.data, updated_at = now() RETURNING updated_at", page_id, payload) + return {"pageId": page_id, "saved": True, "updated_at": row["updated_at"].isoformat()} + IN_MEMORY_PROJECTS[page_id] = {"data": req.data, "updated_at": datetime.utcnow().isoformat() + "Z"}; return {"pageId": page_id, "saved": True, "updated_at": IN_MEMORY_PROJECTS[page_id]["updated_at"]} + +# --------------------------------------------------------------------------- +# Asset Pipeline Services +# --------------------------------------------------------------------------- +@app.get("/v1/assets") +async def list_assets(): + if db_pool is not None: + async with db_pool.acquire() as conn: rows = await conn.fetch("SELECT id, name, url, type, created_at FROM dolor3v_assets ORDER BY created_at DESC") + return [{"id": row["id"], "name": row["name"], "url": row["url"], "type": row["type"], "created_at": row["created_at"].isoformat()} for row in rows] + return IN_MEMORY_ASSETS + +@app.post("/v1/assets/upload") +async def upload_assets(files: list[UploadFile] = File(...)): + if not GDRIVE_FOLDER_ID: raise HTTPException(status_code=500, detail={"error": "GDRIVE_FOLDER_ID not configured"}) + service = await run_in_threadpool(_get_drive_service_sync) + uploaded = [] + for f in files: + content = await f.read() + asset = await run_in_threadpool(_upload_one_sync, service, f.filename, f.content_type, content) + if db_pool is not None: + async with db_pool.acquire() as conn: await conn.execute("INSERT INTO dolor3v_assets (name, url, type) VALUES ($1, $2, $3)", asset["name"], asset["src"], asset["type"]) + else: IN_MEMORY_ASSETS.append({"id": len(IN_MEMORY_ASSETS)+1, "name": asset["name"], "url": asset["src"], "type": asset["type"], "created_at": datetime.utcnow().isoformat()+"Z"}) + uploaded.append(asset) + return {"data": uploaded} + +# --------------------------------------------------------------------------- +# AI Code Suite +# --------------------------------------------------------------------------- +@app.post("/v1/code/generate") +async def generate_code(req: CodeGenerateRequest): + system_prompt = "You are an elite software architect. Generate production-grade code. Return ONLY pure formatted code inside Markdown codeblocks." + user_prompt = f"Language: {req.language}\nContext:\n{req.context}\nPrompt: {req.prompt}" if req.context else f"Language: {req.language}\nPrompt: {req.prompt}" + raw_code = await get_ai_completion(system_prompt, user_prompt, temperature=0.2); return {"code": raw_code, "language": req.language} + +@app.post("/v1/code/refactor") +async def refactor_code(req: CodeRefactorRequest): + system_prompt = "You are an automated code refactoring engine. Optimize performance and readability. Return ONLY the refactored code block." + user_prompt = f"Language: {req.language}\nRefactor Targets: {req.instructions}\nCodebase:\n{req.code}" + raw_code = await get_ai_completion(system_prompt, user_prompt, temperature=0.1); return {"code": raw_code, "language": req.language} + +@app.post("/v1/code/fix") +async def fix_code(req: CodeFixRequest): + system_prompt = "You are an automated debugging system. Fix the code and return ONLY the fully corrected code block, then a 2-sentence summary." + user_prompt = f"Language: {req.language}\nError:\n{req.error}\nCode:\n{req.code}" + raw_code = await get_ai_completion(system_prompt, user_prompt, temperature=0.1); return {"code": raw_code, "language": req.language} + +# --------------------------------------------------------------------------- +# Version History Systems +# --------------------------------------------------------------------------- +@app.post("/v1/version/save") +async def save_version(req: VersionSaveRequest): + payload = json.dumps(req.data) + if db_pool is not None: + async with db_pool.acquire() as conn: row = await conn.fetchrow("INSERT INTO dolor3v_versions (project_id, name, data) VALUES ($1, $2, $3::jsonb) RETURNING id, created_at", req.project_id, req.name, payload) + return {"version_id": row["id"], "project_id": req.project_id, "name": req.name, "created_at": row["created_at"].isoformat()} + if req.project_id not in IN_MEMORY_VERSIONS: IN_MEMORY_VERSIONS[req.project_id] = [] + v_id = len(IN_MEMORY_VERSIONS[req.project_id]) + 1; record = {"version_id": v_id, "project_id": req.project_id, "name": req.name, "data": req.data, "created_at": datetime.utcnow().isoformat() + "Z"} + IN_MEMORY_VERSIONS[req.project_id].append(record); return record + +@app.get("/v1/version/history/{project_id}") +async def get_version_history(project_id: str): + if db_pool is not None: + async with db_pool.acquire() as conn: rows = await conn.fetch("SELECT id, name, created_at FROM dolor3v_versions WHERE project_id = $1 ORDER BY id DESC", project_id) + return [{"version_id": row["id"], "name": row["name"], "created_at": row["created_at"].isoformat()} for row in rows] + return [{"version_id": v["version_id"], "name": v["name"], "created_at": v["created_at"]} for v in IN_MEMORY_VERSIONS.get(project_id, [])[::-1]] + +@app.post("/v1/version/restore") +async def restore_version(req: VersionRestoreRequest): + restored_data = None + if db_pool is not None: + async with db_pool.acquire() as conn: + row = await conn.fetchrow("SELECT data FROM dolor3v_versions WHERE project_id = $1 AND id = $2", req.project_id, req.version_id) + if row: restored_data = json.loads(row["data"]); await conn.execute("INSERT INTO dolor3v_projects (page_id, data, updated_at) VALUES ($1, $2::jsonb, now()) ON CONFLICT (page_id) DO UPDATE SET data = EXCLUDED.data, updated_at = now()", req.project_id, row["data"]) + else: + for v in IN_MEMORY_VERSIONS.get(req.project_id, []): + if v["version_id"] == req.version_id: restored_data = v["data"]; IN_MEMORY_PROJECTS[req.project_id] = {"data": restored_data, "updated_at": datetime.utcnow().isoformat() + "Z"}; break + if restored_data is None: raise HTTPException(status_code=404, detail={"error": "Version not found"}) + return {"project_id": req.project_id, "restored": True, "version_id": req.version_id} + +# --------------------------------------------------------------------------- +# Native Application Builder & Compilation Pipeline +# --------------------------------------------------------------------------- +# Real toolchain only: aapt2 + javac + d8 + zipalign + apksigner +# (native_apk_builder.py). No mock steps, no placeholder output. + +NATIVE_TEMPLATES = [ + {"id": "android-java-view", "name": "Android (Java, View-based)", "framework": "Android", "buildable": True}, + {"id": "flutter-dashboard", "name": "Responsive Cloud Dashboard", "framework": "Flutter", "buildable": False}, + {"id": "react-native-store", "name": "Headless E-Commerce", "framework": "React Native", "buildable": False}, + {"id": "capacitor-pwa-hybrid", "name": "WebView Core Shell", "framework": "CapacitorJS", "buildable": False}, + {"id": "swiftui-base", "name": "SwiftUI Standard", "framework": "SwiftUI", "buildable": False}, +] + +@app.get("/v1/native/templates") +async def list_native_templates(): + return {"templates": NATIVE_TEMPLATES} + + +@app.post("/v1/native/generate") +async def generate_native_layout(req: NativeGenerateRequest): + system_prompt = ( + f"You are a senior {req.platform} software architect. " + "Generate a complete production-ready application source tree. " + "Return every file using the format: // File: . " + "Do not invent placeholders or omit files." + ) + user_prompt = f"Project ID: {req.project_id}\nPlatform: {req.platform}\nUser Request: {req.prompt}" + + generated_text = None + try: + generated_text = await get_ai_completion(system_prompt, user_prompt, temperature=0.2) + if not isinstance(generated_text, str): + generated_text = str(generated_text) + parsed = parse_bundle(generated_text) + except BundleParseError as e: + return {"project_id": req.project_id, "platform": req.platform, "generated_files": [], + "raw_response": generated_text, "error": f"Model output did not parse as a file bundle: {e}"} + except Exception as e: + return JSONResponse(status_code=500, content={ + "project_id": req.project_id, "platform": req.platform, + "error": f"{type(e).__name__}: {e}", + "raw_response": generated_text, + "traceback": traceback.format_exc(), + }) + + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + for f in parsed.files: + await conn.execute( + "INSERT INTO dolor3v_files (project_id, path, content, updated_at) VALUES ($1, $2, $3, now()) " + "ON CONFLICT (project_id, path) DO UPDATE SET content = EXCLUDED.content, updated_at = now()", + req.project_id, f.path, f.content, + ) + else: + NATIVE_PROJECT_FILES.setdefault(req.project_id, {}) + for f in parsed.files: + NATIVE_PROJECT_FILES[req.project_id][f.path] = f.content + + return {"project_id": req.project_id, "platform": req.platform, + "files_written": [f.path for f in parsed.files], "file_count": len(parsed.files)} + + +async def _load_project_files(project_id: str) -> dict: + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + rows = await conn.fetch("SELECT path, content FROM dolor3v_files WHERE project_id = $1", project_id) + if rows: + return {row["path"]: row["content"] for row in rows} + files = NATIVE_PROJECT_FILES.get(project_id) + if not files: + raise HTTPException(status_code=404, detail=f"No files for project '{project_id}'. Run /v1/native/generate first.") + return files + + +async def run_native_build_pipeline(build_id: str, project_id: str, platform: str, config: Optional[dict]): + logs = [] + + async def update_build_state(status: str, current_logs: str, download_url: Optional[str] = None): + if app_part1.db_pool is not None: + try: + async with app_part1.db_pool.acquire() as conn: + await conn.execute( + "INSERT INTO dolor3v_builds (id, project_id, platform, status, logs, download_url, created_at) " + "VALUES ($1, $2, $3, $4, $5, $6, now()) " + "ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, logs = EXCLUDED.logs, download_url = EXCLUDED.download_url", + build_id, project_id, platform, status, current_logs, download_url, + ) + except Exception as e: + logger.error("Failed to write build log: %s", e) + BUILD_JOBS[build_id] = {"id": build_id, "project_id": project_id, "platform": platform, + "status": status, "logs": current_logs, "download_url": download_url, + "created_at": datetime.utcnow().isoformat() + "Z"} + + def log(msg: str): + logs.append(f"[{datetime.utcnow().strftime('%H:%M:%S')}] {msg}") + + log("Loading generated project files...") + await update_build_state("BUILDING", "\n".join(logs)) + + try: + files = await _load_project_files(project_id) + except HTTPException as e: + log(f"FAILED: {e.detail}") + await update_build_state("FAILED", "\n".join(logs)) + return + + log(f"Loaded {len(files)} files. Compiling with aapt2/javac/d8...") + await update_build_state("BUILDING", "\n".join(logs)) + + try: + apk_bytes = await run_in_threadpool(native_apk_builder.build_apk_from_project, files) + except native_apk_builder.NativeBuildError as e: + log(f"BUILD FAILED at step [{e.step}]:") + log(e.stderr.strip() or e.stdout.strip() or "no output") + await update_build_state("FAILED", "\n".join(logs)) + return + except Exception as e: + log(f"BUILD FAILED (unexpected): {e}") + await update_build_state("FAILED", "\n".join(logs)) + return + + filename = f"dolor3v-app-{project_id}-{build_id[:8]}.apk" + (BUILD_OUTPUT_DIR / filename).write_bytes(apk_bytes) + download_url = f"/downloads/{filename}" + + if GDRIVE_FOLDER_ID and GDRIVE_SERVICE_ACCOUNT_JSON: + try: + service = await run_in_threadpool(_get_drive_service_sync) + asset = await run_in_threadpool(_upload_one_sync, service, filename, + "application/vnd.android.package-archive", apk_bytes) + log(f"Backed up to Drive: {asset['src']}") + except Exception as drive_exc: + log(f"Warning: Drive backup failed (APK still saved locally): {drive_exc}") + + log(f"BUILD SUCCESS — {filename} ({len(apk_bytes)} bytes)") + await update_build_state("SUCCESS", "\n".join(logs), download_url) + + +@app.post("/v1/native/build") +async def start_native_build(req: NativeBuildRequest, tasks: BackgroundTasks): + build_id = str(uuid.uuid4()) + tasks.add_task(run_native_build_pipeline, build_id, req.project_id, req.platform, req.config) + return {"build_id": build_id, "project_id": req.project_id, "platform": req.platform, "status": "PENDING"} + + +@app.get("/v1/native/build/{build_id}") +async def get_build_status(build_id: str): + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, project_id, platform, status, logs, download_url FROM dolor3v_builds WHERE id = $1", + build_id, + ) + if row: + return {"build_id": row["id"], "project_id": row["project_id"], "platform": row["platform"], + "status": row["status"], "logs": row["logs"], "download_url": row["download_url"]} + if build_id in BUILD_JOBS: + return BUILD_JOBS[build_id] + raise HTTPException(status_code=404, detail={"error": f"Build '{build_id}' not found"}) + + +@app.post("/v1/native/preview") +async def preview_native_app(req: NativeBuildRequest): + return {"project_id": req.project_id, "platform": req.platform, "preview_available": False, + "message": "No device/emulator is attached to this backend, so a live in-app preview isn't possible here. Run /v1/native/build, then install the returned .apk on a device or emulator."} + + +@app.post("/v1/native/export") +async def export_native_project(req: NativeBuildRequest): + if app_part1.db_pool is None: + raise HTTPException(status_code=503, detail="Postgres not configured — cannot look up builds") + async with app_part1.db_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT download_url FROM dolor3v_builds WHERE project_id = $1 AND status = 'SUCCESS' ORDER BY created_at DESC LIMIT 1", + req.project_id, + ) + if not row or not row["download_url"]: + raise HTTPException(status_code=404, detail=f"No successful build for '{req.project_id}'. Run /v1/native/build first.") + return {"project_id": req.project_id, "platform": req.platform, "export_package_url": row["download_url"], + "instructions": "Download the .apk and install it directly, or push it to your own CI for release signing before publishing to a store."} + +# --------------------------------------------------------------------------- +# Global Publishing Router +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Artifact Download Endpoint +# --------------------------------------------------------------------------- + +from fastapi.responses import FileResponse + +BUILD_OUTPUT_DIR = Path("build_output") +BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +@app.get("/downloads/{filename}") +async def download_build_artifact(filename: str): + artifact = BUILD_OUTPUT_DIR / filename + + if not artifact.exists(): + raise HTTPException( + status_code=404, + detail={ + "error": "Build artifact not found" + } + ) + + media_type = ( + "application/vnd.android.package-archive" + if artifact.suffix == ".apk" + else "application/zip" + ) + + return FileResponse( + path=str(artifact), + filename=artifact.name, + media_type=media_type, + ) + + +# NOTE: stub publish_deployment() removed here — it was shadowing the real +# Cloudflare Pages deploy implementation in app_part3.py, since this module +# is imported first and Starlette matches the first-registered route. + +# --------------------------------------------------------------------------- +# Base Default Routing Definition +# --------------------------------------------------------------------------- +@app.get("/") +async def root(): + return {"service": "dolor3v-unified-ai-workspace-core", "endpoints": ["/health", "/v1/chat/completions", "/v1/projects", "/v1/assets", "/v1/code/generate", "/v1/native/build", "/v1/publish"], "virtual_models": list(VIRTUAL_MODELS.keys())} + +# --------------------------------------------------------------------------- +# Project Creation +# --------------------------------------------------------------------------- + +class ProjectCreateRequest(BaseModel): + name: str + type: str + data: dict = {} + +@app.post("/v1/projects") +async def create_project(req: ProjectCreateRequest): + page_id = str(uuid.uuid4()) + timestamp = datetime.utcnow().isoformat() + "Z" + + project = { + "id": page_id, + "name": req.name, + "type": req.type, + "data": req.data, + "created_at": timestamp, + "updated_at": timestamp, + } + + if db_pool is not None: + payload = json.dumps(project) + + async with db_pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO dolor3v_projects ( + page_id, + data, + updated_at + ) + VALUES ( + $1, + $2::jsonb, + now() + ) + """, + page_id, + payload, + ) + else: + IN_MEMORY_PROJECTS[page_id] = { + "data": project, + "updated_at": timestamp, + } + + return { + "projectId": page_id, + "name": req.name, + "type": req.type, + "created": True, + "updated_at": timestamp, + } + diff --git a/app_part3.py b/app_part3.py new file mode 100644 index 0000000000000000000000000000000000000000..5d8edf260d76a6045f1f463fe775f9347b1272d1 --- /dev/null +++ b/app_part3.py @@ -0,0 +1,578 @@ +""" +app_part3.py + +Extends the workspace: voice generation (edge-tts, free/no-key), image +generation (Pollinations Flux, free/no-key), GrapesJS visual design storage, +project file tree + AI bundle_parser wiring, missing deletes, /auth/me, +real website publishing to Cloudflare Pages, and a JSON-action AI +assistant/agent loop that calls the other tools itself. + +Imported by app.py after app_part2 and app_auth, so `app`, `db_pool`, +provider functions and helpers are already live. +""" + +import os +import io +import json +import re +import uuid +import logging +import subprocess +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any, Optional +from urllib.parse import quote + +import httpx +import edge_tts +from fastapi import HTTPException, Header, BackgroundTasks +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from starlette.concurrency import run_in_threadpool + +import app_part1 +from app_part1 import ( + get_ai_completion, + GDRIVE_FOLDER_ID, + _get_drive_service_sync, + _upload_one_sync, + CodeGenerateRequest, + NativeBuildRequest, + PublishRequest, +) +from app_part1 import app +from app_part2 import ( + generate_code as _gc, + start_native_build as _snb, + get_build_status as _gbs, +) +import app_auth +from bundle_parser import parse_bundle, BundleParseError + +logger = logging.getLogger("dolor3v-part3") + +POLLINATIONS_API_KEY = os.environ.get("POLLINATIONS_API_KEY", "") +CLOUDFLARE_ACCOUNT_ID = os.environ.get("CLOUDFLARE_ACCOUNT_ID", "") +CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "") + +IN_MEMORY_FILES: dict = {} +IN_MEMORY_DESIGN: dict = {} + +# --------------------------------------------------------------------------- +# New tables (created alongside the existing ones on startup) +# --------------------------------------------------------------------------- +async def _ensure_part3_tables(): + if app_part1.db_pool is None: + return + async with app_part1.db_pool.acquire() as conn: + await conn.execute("""CREATE TABLE IF NOT EXISTS dolor3v_files ( + project_id TEXT NOT NULL, + path TEXT NOT NULL, + content TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (project_id, path) + )""") + await conn.execute("""CREATE TABLE IF NOT EXISTS dolor3v_design ( + project_id TEXT PRIMARY KEY, + data JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )""") + # Rendered site output, captured alongside the GrapesJS project JSON + # so /v1/publish can deploy a real site without re-rendering GrapesJS + # component trees on the server. + await conn.execute("ALTER TABLE dolor3v_design ADD COLUMN IF NOT EXISTS html TEXT") + await conn.execute("ALTER TABLE dolor3v_design ADD COLUMN IF NOT EXISTS css TEXT") + +@app.on_event("startup") +async def _part3_startup(): + try: + await _ensure_part3_tables() + logger.info("Part3 tables ensured (dolor3v_files, dolor3v_design).") + except Exception as exc: + logger.error("Failed to ensure part3 tables: %s", exc) + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- +class ImageGenerateRequest(BaseModel): + prompt: str + width: int = 1024 + height: int = 1024 + model: str = "flux" + save_to_drive: bool = False + +class VoiceGenerateRequest(BaseModel): + text: str + voice: str = "en-US-AriaNeural" + rate: str = "+0%" + pitch: str = "+0Hz" + save_to_drive: bool = False + +class FileWriteRequest(BaseModel): + content: str + +class ApplyBundleRequest(BaseModel): + bundle_text: str + +class AssistantMessage(BaseModel): + role: str + content: str + +class AssistantChatRequest(BaseModel): + project_id: Optional[str] = None + message: str + history: list[AssistantMessage] = [] + +# --------------------------------------------------------------------------- +# Deletes missing from app_part2 +# --------------------------------------------------------------------------- +@app.delete("/v1/projects/{page_id}") +async def delete_project(page_id: str): + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + result = await conn.execute("DELETE FROM dolor3v_projects WHERE page_id = $1", page_id) + deleted = int(result.split()[-1]) > 0 + else: + deleted = app_part1.IN_MEMORY_PROJECTS.pop(page_id, None) is not None + if not deleted: + raise HTTPException(status_code=404, detail={"error": f"No project found for id '{page_id}'"}) + return {"pageId": page_id, "deleted": True} + +@app.delete("/v1/assets/{asset_id}") +async def delete_asset(asset_id: int): + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + result = await conn.execute("DELETE FROM dolor3v_assets WHERE id = $1", asset_id) + deleted = int(result.split()[-1]) > 0 + else: + before = len(app_part1.IN_MEMORY_ASSETS) + app_part1.IN_MEMORY_ASSETS[:] = [a for a in app_part1.IN_MEMORY_ASSETS if a.get("id") != asset_id] + deleted = len(app_part1.IN_MEMORY_ASSETS) != before + if not deleted: + raise HTTPException(status_code=404, detail={"error": f"No asset found for id '{asset_id}'"}) + return {"id": asset_id, "deleted": True} + +# --------------------------------------------------------------------------- +# Auth: /me +# --------------------------------------------------------------------------- +@app.get("/v1/auth/me") +async def get_current_user(authorization: str = Header(default="")): + token = authorization.replace("Bearer ", "").strip() + if not token.startswith("user_"): + raise HTTPException(status_code=401, detail={"error": "Invalid or missing token"}) + try: + user_id = int(token.split("_", 1)[1]) + except (IndexError, ValueError): + raise HTTPException(status_code=401, detail={"error": "Invalid token format"}) + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + row = await conn.fetchrow("SELECT id, email, name FROM dolor3v_users WHERE id = $1", user_id) + if row: + return dict(row) + else: + for u in app_auth.IN_MEMORY_USERS.values(): + if u["id"] == user_id: + return {"id": u["id"], "email": u["email"], "name": u["name"]} + raise HTTPException(status_code=404, detail={"error": "User not found"}) + +# --------------------------------------------------------------------------- +# Image generation (Pollinations Flux - free, no key required) +# --------------------------------------------------------------------------- +@app.post("/v1/image/generate") +async def generate_image(req: ImageGenerateRequest): + url = f"https://image.pollinations.ai/prompt/{quote(req.prompt)}" + params = {"width": req.width, "height": req.height, "model": req.model, "nologo": "true"} + if POLLINATIONS_API_KEY: + params["key"] = POLLINATIONS_API_KEY + async with httpx.AsyncClient(timeout=90) as client: + resp = await client.get(url, params=params) + if resp.status_code != 200: + raise HTTPException(status_code=502, detail={"error": f"Image generation failed: {resp.status_code}"}) + image_bytes = resp.content + + if req.save_to_drive: + if not GDRIVE_FOLDER_ID: + raise HTTPException(status_code=500, detail={"error": "GDRIVE_FOLDER_ID not configured"}) + service = await run_in_threadpool(_get_drive_service_sync) + filename = f"generated-{uuid.uuid4().hex[:12]}.jpg" + asset = await run_in_threadpool(_upload_one_sync, service, filename, "image/jpeg", image_bytes) + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + await conn.execute("INSERT INTO dolor3v_assets (name, url, type) VALUES ($1, $2, $3)", asset["name"], asset["src"], "image") + else: + app_part1.IN_MEMORY_ASSETS.append({"id": len(app_part1.IN_MEMORY_ASSETS) + 1, "name": asset["name"], "url": asset["src"], "type": "image", "created_at": datetime.utcnow().isoformat() + "Z"}) + return {"prompt": req.prompt, "url": asset["src"], "saved": True} + + return StreamingResponse(io.BytesIO(image_bytes), media_type="image/jpeg") + +# --------------------------------------------------------------------------- +# Voice generation (edge-tts - free, no key, human-sounding neural voices) +# --------------------------------------------------------------------------- +@app.post("/v1/voice/generate") +async def generate_voice(req: VoiceGenerateRequest): + communicate = edge_tts.Communicate(req.text, req.voice, rate=req.rate, pitch=req.pitch) + chunks = [] + async for chunk in communicate.stream(): + if chunk["type"] == "audio": + chunks.append(chunk["data"]) + if not chunks: + raise HTTPException(status_code=502, detail={"error": "Voice generation produced no audio"}) + audio_bytes = b"".join(chunks) + + if req.save_to_drive: + if not GDRIVE_FOLDER_ID: + raise HTTPException(status_code=500, detail={"error": "GDRIVE_FOLDER_ID not configured"}) + service = await run_in_threadpool(_get_drive_service_sync) + filename = f"voice-{uuid.uuid4().hex[:12]}.mp3" + asset = await run_in_threadpool(_upload_one_sync, service, filename, "audio/mpeg", audio_bytes) + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + await conn.execute("INSERT INTO dolor3v_assets (name, url, type) VALUES ($1, $2, $3)", asset["name"], asset["src"], "audio") + else: + app_part1.IN_MEMORY_ASSETS.append({"id": len(app_part1.IN_MEMORY_ASSETS) + 1, "name": asset["name"], "url": asset["src"], "type": "audio", "created_at": datetime.utcnow().isoformat() + "Z"}) + return {"text": req.text, "voice": req.voice, "url": asset["src"], "saved": True} + + return StreamingResponse(io.BytesIO(audio_bytes), media_type="audio/mpeg") + +@app.get("/v1/voice/list") +async def list_voices(): + voices = await edge_tts.list_voices() + return {"voices": [{"name": v["ShortName"], "gender": v["Gender"], "locale": v["Locale"]} for v in voices]} + +# --------------------------------------------------------------------------- +# GrapesJS visual design storage (raw passthrough - matches GrapesJS's own +# remote-storage contract, which posts/loads a single JSON blob as-is). +# +# Two extra top-level keys, __dolor3v_html and __dolor3v_css, are treated +# specially: if the frontend includes them in the save payload (the custom +# GrapesJS Storage Manager sends them alongside the normal project data via +# editor.getHtml()/getCss()), they're stripped out and stored in their own +# columns instead of inside `data`. GrapesJS itself never asked for these +# keys, so load_design does NOT hand them back — it returns pure GrapesJS +# project data, exactly as before. +# --------------------------------------------------------------------------- +@app.get("/v1/design/{project_id}") +async def load_design(project_id: str): + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + row = await conn.fetchrow("SELECT data FROM dolor3v_design WHERE project_id = $1", project_id) + return json.loads(row["data"]) if row else {} + return IN_MEMORY_DESIGN.get(project_id, {}).get("data", {}) + +@app.post("/v1/design/{project_id}") +async def save_design(project_id: str, payload: dict[str, Any]): + html = payload.pop("__dolor3v_html", None) + css = payload.pop("__dolor3v_css", None) + + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + await conn.execute( + "INSERT INTO dolor3v_design (project_id, data, html, css, updated_at) " + "VALUES ($1, $2::jsonb, $3, $4, now()) " + "ON CONFLICT (project_id) DO UPDATE SET " + "data = EXCLUDED.data, " + "html = COALESCE(EXCLUDED.html, dolor3v_design.html), " + "css = COALESCE(EXCLUDED.css, dolor3v_design.css), " + "updated_at = now()", + project_id, json.dumps(payload), html, css, + ) + else: + existing = IN_MEMORY_DESIGN.get(project_id, {}) + IN_MEMORY_DESIGN[project_id] = { + "data": payload, + "html": html if html is not None else existing.get("html"), + "css": css if css is not None else existing.get("css"), + } + return {"project_id": project_id, "saved": True} + +async def _get_site_output(project_id: str) -> tuple[Optional[str], Optional[str]]: + """Returns (html, css) for the last saved design, or (None, None).""" + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + row = await conn.fetchrow("SELECT html, css FROM dolor3v_design WHERE project_id = $1", project_id) + if row: + return row["html"], row["css"] + return None, None + entry = IN_MEMORY_DESIGN.get(project_id, {}) + return entry.get("html"), entry.get("css") + +async def _agent_update_site(project_id: str, html: str, css: str = "") -> None: + """ + Writes agent-authored html/css WITHOUT touching the `data` column — + unlike save_design, which always overwrites `data` because that call + always comes with a fresh GrapesJS state attached. The agent has no + GrapesJS state at all, so touching `data` here would either wipe out + or desync whatever the human last built visually. The tradeoff: if a + human opens the visual editor after an agent build, they'll see their + own last-saved canvas, not the agent's HTML — the two don't merge. + """ + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + await conn.execute( + "INSERT INTO dolor3v_design (project_id, data, html, css, updated_at) " + "VALUES ($1, '{}'::jsonb, $2, $3, now()) " + "ON CONFLICT (project_id) DO UPDATE SET " + "html = EXCLUDED.html, css = EXCLUDED.css, updated_at = now()", + project_id, html, css, + ) + else: + existing = IN_MEMORY_DESIGN.get(project_id, {}) + IN_MEMORY_DESIGN[project_id] = {"data": existing.get("data", {}), "html": html, "css": css} + +# --------------------------------------------------------------------------- +# Real website publishing -> Cloudflare Pages (Direct Upload, via Wrangler) +# --------------------------------------------------------------------------- +def _pages_project_name(project_id: str) -> str: + slug = "".join(c if c.isalnum() else "-" for c in project_id.lower()).strip("-") + name = f"dolor3v-{slug}" or "dolor3v-site" + return name[:58] + +def _run_wrangler(args: list[str]) -> subprocess.CompletedProcess: + env = { + **os.environ, + "CLOUDFLARE_ACCOUNT_ID": CLOUDFLARE_ACCOUNT_ID, + "CLOUDFLARE_API_TOKEN": CLOUDFLARE_API_TOKEN, + } + return subprocess.run( + ["npx", "wrangler", *args], + capture_output=True, + text=True, + timeout=180, + env=env, + ) + +def _deploy_to_pages_sync(project_id: str, html: str, css: str) -> dict: + project_name = _pages_project_name(project_id) + document = ( + "\n" + f"{html}" + ) + with tempfile.TemporaryDirectory(prefix="dolor3v-publish-") as tmp: + (Path(tmp) / "index.html").write_text(document, encoding="utf-8") + result = _run_wrangler([ + "pages", "deploy", tmp, + f"--project-name={project_name}", + "--branch=main", + "--commit-dirty=true", + ]) + + if result.returncode != 0: + raise RuntimeError(f"wrangler exit {result.returncode}: {(result.stderr or result.stdout)[-1500:]}") + + match = re.search(r"https://\S+\.pages\.dev\S*", result.stdout) + if not match: + raise RuntimeError(f"wrangler succeeded but no deployment URL found in output: {result.stdout[-1500:]}") + + return {"live_endpoint": match.group(0), "project_name": project_name, "log_tail": result.stdout[-1500:]} + +@app.post("/v1/publish") +async def publish_deployment(req: PublishRequest): + if not CLOUDFLARE_ACCOUNT_ID or not CLOUDFLARE_API_TOKEN: + raise HTTPException( + status_code=500, + detail={"error": "CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN not configured"}, + ) + + html, css = await _get_site_output(req.project_id) + if not html: + raise HTTPException( + status_code=400, + detail={"error": "No saved site output for this project yet — save the design from the builder first"}, + ) + + try: + result = await run_in_threadpool(_deploy_to_pages_sync, req.project_id, html, css or "") + except Exception as exc: + logger.error("Cloudflare Pages deploy failed for %s: %s", req.project_id, exc) + raise HTTPException(status_code=502, detail={"error": f"Deployment failed: {exc}"}) + + return { + "project_id": req.project_id, + "status": "LIVE", + "live_endpoint": result["live_endpoint"], + "deployment_timestamp": datetime.utcnow().isoformat() + "Z", + } + +# --------------------------------------------------------------------------- +# Project file tree (backs Monaco editor + the AI agent's file writes) +# --------------------------------------------------------------------------- +@app.get("/v1/files/{project_id}") +async def list_files(project_id: str): + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + rows = await conn.fetch("SELECT path, updated_at FROM dolor3v_files WHERE project_id = $1 ORDER BY path", project_id) + return [{"path": r["path"], "updated_at": r["updated_at"].isoformat()} for r in rows] + return [{"path": p, "updated_at": v["updated_at"]} for p, v in IN_MEMORY_FILES.get(project_id, {}).items()] + +@app.get("/v1/files/{project_id}/{file_path:path}") +async def read_file(project_id: str, file_path: str): + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + row = await conn.fetchrow("SELECT content, updated_at FROM dolor3v_files WHERE project_id = $1 AND path = $2", project_id, file_path) + if row: + return {"path": file_path, "content": row["content"], "updated_at": row["updated_at"].isoformat()} + elif file_path in IN_MEMORY_FILES.get(project_id, {}): + f = IN_MEMORY_FILES[project_id][file_path] + return {"path": file_path, "content": f["content"], "updated_at": f["updated_at"]} + raise HTTPException(status_code=404, detail={"error": f"File '{file_path}' not found"}) + +@app.put("/v1/files/{project_id}/{file_path:path}") +async def write_file(project_id: str, file_path: str, req: FileWriteRequest): + ts = datetime.utcnow().isoformat() + "Z" + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + await conn.execute( + "INSERT INTO dolor3v_files (project_id, path, content, updated_at) VALUES ($1, $2, $3, now()) " + "ON CONFLICT (project_id, path) DO UPDATE SET content = EXCLUDED.content, updated_at = now()", + project_id, file_path, req.content, + ) + else: + IN_MEMORY_FILES.setdefault(project_id, {})[file_path] = {"content": req.content, "updated_at": ts} + return {"path": file_path, "saved": True, "updated_at": ts} + +@app.delete("/v1/files/{project_id}/{file_path:path}") +async def delete_file(project_id: str, file_path: str): + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + result = await conn.execute("DELETE FROM dolor3v_files WHERE project_id = $1 AND path = $2", project_id, file_path) + deleted = int(result.split()[-1]) > 0 + else: + deleted = IN_MEMORY_FILES.get(project_id, {}).pop(file_path, None) is not None + if not deleted: + raise HTTPException(status_code=404, detail={"error": f"File '{file_path}' not found"}) + return {"path": file_path, "deleted": True} + +@app.post("/v1/files/{project_id}/apply-bundle") +async def apply_bundle(project_id: str, req: ApplyBundleRequest): + try: + result = parse_bundle(req.bundle_text) + except BundleParseError as exc: + raise HTTPException(status_code=422, detail={"error": str(exc)}) + ts = datetime.utcnow().isoformat() + "Z" + written = [] + for f in result.files: + if app_part1.db_pool is not None: + async with app_part1.db_pool.acquire() as conn: + await conn.execute( + "INSERT INTO dolor3v_files (project_id, path, content, updated_at) VALUES ($1, $2, $3, now()) " + "ON CONFLICT (project_id, path) DO UPDATE SET content = EXCLUDED.content, updated_at = now()", + project_id, f.path, f.content, + ) + else: + IN_MEMORY_FILES.setdefault(project_id, {})[f.path] = {"content": f.content, "updated_at": ts} + written.append(f.path) + return {"project_id": project_id, "files_written": written, "skipped": result.skipped, "marker_count": result.raw_marker_count} + +# --------------------------------------------------------------------------- +# General-purpose AI Assistant: JSON-action agent loop. +# The model picks ONE tool per turn; we execute it server-side and feed the +# result back, up to max_steps. Uses the same extract-JSON + retry pattern +# already proven in your Instatic interrogation agent. +# --------------------------------------------------------------------------- +ASSISTANT_TOOLS_DESC = """ +Available tools. Respond with ONE JSON object per turn, nothing else: +- {"action": "reply", "message": ""} +- {"action": "list_files", "project_id": ""} +- {"action": "read_file", "project_id": "", "path": ""} +- {"action": "write_file", "project_id": "", "path": "", "content": ""} +- {"action": "generate_code", "prompt": "", "language": ""} +- {"action": "generate_image", "prompt": ""} +- {"action": "generate_voice", "text": ""} +- {"action": "build_website", "project_id": "", "prompt": ""} +- {"action": "publish_website", "project_id": ""} +- {"action": "start_native_build", "project_id": "", "platform": ""} +- {"action": "get_build_status", "build_id": ""} +Return exactly one JSON object. No markdown, no prose outside the JSON. +""" + +def _extract_json_object(text: str) -> dict: + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end < start: + raise ValueError("No JSON object found in model response") + return json.loads(text[start:end + 1]) + +async def _run_assistant_action(action: dict, project_id: Optional[str]) -> dict: + kind = action.get("action") + pid = action.get("project_id") or project_id + if kind == "list_files": + return {"result": await list_files(pid)} + if kind == "read_file": + return {"result": await read_file(pid, action["path"])} + if kind == "write_file": + return {"result": await write_file(pid, action["path"], FileWriteRequest(content=action["content"]))} + if kind == "generate_code": + req = CodeGenerateRequest(prompt=action["prompt"], language=action.get("language", "text")) + return {"result": await _gc(req)} + if kind == "generate_image": + # save_to_drive forced True here so the agent gets a URL back, not raw bytes + return {"result": await generate_image(ImageGenerateRequest(prompt=action["prompt"], save_to_drive=True))} + if kind == "generate_voice": + return {"result": await generate_voice(VoiceGenerateRequest(text=action["text"], save_to_drive=True))} + if kind == "build_website": + system_prompt = ( + "You are a senior web developer. Generate a single, complete, " + "production-ready HTML page for the request below. Embed all " + "CSS in a