diff --git a/backend/apps/app_builder/App.py b/backend/apps/app_builder/App.py new file mode 100644 index 00000000..0116f3c4 --- /dev/null +++ b/backend/apps/app_builder/App.py @@ -0,0 +1,64 @@ +from pydantic import BaseModel, Field +from typing import Optional, Any +from uuid import uuid4 +from datetime import datetime + + +class App(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex) + name: str + description: str = "" + icon: str = "view_quilt" + input_schema: dict[str, Any] = Field(default_factory=lambda: { + "type": "object", + "properties": {}, + "required": [], + }) + files: dict[str, str] = Field(default_factory=dict) + thumbnail: Optional[str] = None + created_at: str = Field(default_factory=lambda: datetime.now().isoformat()) + updated_at: str = Field(default_factory=lambda: datetime.now().isoformat()) + + +class AppCreate(BaseModel): + name: str + description: str = "" + icon: str = "view_quilt" + input_schema: dict[str, Any] = Field(default_factory=lambda: { + "type": "object", + "properties": {}, + "required": [], + }) + files: dict[str, str] = Field(default_factory=dict) + thumbnail: Optional[str] = None + + +class AppUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + input_schema: Optional[dict[str, Any]] = None + files: Optional[dict[str, str]] = None + thumbnail: Optional[str] = None + + +class AppExecute(BaseModel): + app_id: str + input_data: dict[str, Any] = Field(default_factory=dict) + + +class AppExecuteResult(BaseModel): + app_id: str + app_name: str + frontend_code: str + input_data: dict[str, Any] + backend_result: Optional[dict[str, Any]] = None + stdout: Optional[str] = None + stderr: Optional[str] = None + error: Optional[str] = None + + +class WorkspaceSeedRequest(BaseModel): + workspace_id: str + files: Optional[dict[str, str]] = None + meta: Optional[dict[str, Any]] = None diff --git a/backend/apps/app_builder/app_builder.py b/backend/apps/app_builder/app_builder.py new file mode 100644 index 00000000..0de2e998 --- /dev/null +++ b/backend/apps/app_builder/app_builder.py @@ -0,0 +1,231 @@ +"""App Builder SubApp — CRUD, workspace management, file serving, and execution.""" + +import json +import mimetypes +import os +from datetime import datetime +from contextlib import asynccontextmanager + +from fastapi import HTTPException +from fastapi.responses import Response + +from backend.config.Apps import SubApp +from backend.core.db.PydanticStore import PydanticStore +from backend.apps.app_builder.App import ( + App, AppCreate, AppUpdate, AppExecute, AppExecuteResult, + WorkspaceSeedRequest, +) +from backend.config.paths import DB_ROOT +from backend.apps.app_builder.executor import execute_backend_code +from backend.apps.app_builder.templates import APP_BUILDER_SKILL, APP_BUILDER_TEMPLATE_FILES +from backend.apps.app_builder.helpers import ( + validate_against_schema, inject_data_into_html, decode_data_param, walk_directory, +) + +APP_BUILDER_DIR = os.path.join(DB_ROOT, "app_builder") +APP_BUILDER_WORKSPACE_DIR = os.path.join(APP_BUILDER_DIR, "workspace") + +@asynccontextmanager +async def app_builder_lifespan(): + os.makedirs(APP_BUILDER_DIR, exist_ok=True) + os.makedirs(APP_BUILDER_WORKSPACE_DIR, exist_ok=True) + yield + + +app_builder = SubApp("app_builder", app_builder_lifespan) + +_store = PydanticStore[App](model_cls=App, data_dir=APP_BUILDER_DIR, not_found_detail="App not found") + + +# --------------------------------------------------------------------------- +# File serving +# --------------------------------------------------------------------------- + +@app_builder.router.get("/workspace/{workspace_id}/serve/{filepath:path}") +async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""): + folder = os.path.join(APP_BUILDER_WORKSPACE_DIR, workspace_id) + full_path = os.path.normpath(os.path.join(folder, filepath)) + if not full_path.startswith(os.path.normpath(folder)): + raise HTTPException(status_code=403, detail="Path traversal not allowed") + if not os.path.isfile(full_path): + raise HTTPException(status_code=404, detail="File not found") + with open(full_path) as f: + content = f.read() + if filepath == "index.html": + input_json, result_json = decode_data_param(_d) if _d else ("{}", "null") + content = inject_data_into_html(content, input_json, result_json) + mime, _ = mimetypes.guess_type(filepath) + return Response(content=content, media_type=mime or "text/plain") + + +@app_builder.router.get("/{app_id}/serve/{filepath:path}") +async def serve_app_file(app_id: str, filepath: str, _d: str = ""): + app = _store.load(app_id) + content = app.files.get(filepath) + if content is None: + raise HTTPException(status_code=404, detail="File not found in app") + if filepath == "index.html": + input_json, result_json = decode_data_param(_d) if _d else ("{}", "null") + content = inject_data_into_html(content, input_json, result_json) + mime, _ = mimetypes.guess_type(filepath) + return Response(content=content, media_type=mime or "text/plain") + + +# --------------------------------------------------------------------------- +# Workspace management +# --------------------------------------------------------------------------- + +@app_builder.router.get("/workspace/{workspace_id}") +async def read_workspace(workspace_id: str): + folder = os.path.join(APP_BUILDER_WORKSPACE_DIR, workspace_id) + if not os.path.isdir(folder): + raise HTTPException(status_code=404, detail="Workspace not found") + files = walk_directory(folder) + meta = None + if "meta.json" in files: + try: + meta = json.loads(files["meta.json"]) + except (json.JSONDecodeError, ValueError): + pass + return {"files": files, "meta": meta} + + +@app_builder.router.post("/workspace/seed") +async def seed_workspace(body: WorkspaceSeedRequest): + folder = os.path.join(APP_BUILDER_WORKSPACE_DIR, body.workspace_id) + os.makedirs(folder, exist_ok=True) + if body.files: + for rel_path, content in body.files.items(): + full_path = os.path.normpath(os.path.join(folder, rel_path)) + if not full_path.startswith(os.path.normpath(folder)): + continue + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as f: + f.write(content) + else: + for rel_path, content in APP_BUILDER_TEMPLATE_FILES.items(): + full_path = os.path.join(folder, rel_path) + with open(full_path, "w") as f: + f.write(content) + with open(os.path.join(folder, "SKILL.md"), "w") as f: + f.write(APP_BUILDER_SKILL) + if body.meta: + with open(os.path.join(folder, "meta.json"), "w") as f: + json.dump(body.meta, f, indent=2) + return {"path": os.path.abspath(folder)} + + +@app_builder.router.put("/workspace/{workspace_id}/file/{filepath:path}") +async def write_workspace_file(workspace_id: str, filepath: str, body: dict): + folder = os.path.join(APP_BUILDER_WORKSPACE_DIR, workspace_id) + if not os.path.isdir(folder): + raise HTTPException(status_code=404, detail="Workspace not found") + full_path = os.path.normpath(os.path.join(folder, filepath)) + if not full_path.startswith(os.path.normpath(folder)): + raise HTTPException(status_code=403, detail="Path traversal not allowed") + os.makedirs(os.path.dirname(full_path), exist_ok=True) + with open(full_path, "w") as f: + f.write(body.get("content", "")) + return {"ok": True} + + +@app_builder.router.delete("/workspace/{workspace_id}/file/{filepath:path}") +async def delete_workspace_file(workspace_id: str, filepath: str): + folder = os.path.join(APP_BUILDER_WORKSPACE_DIR, workspace_id) + if not os.path.isdir(folder): + raise HTTPException(status_code=404, detail="Workspace not found") + full_path = os.path.normpath(os.path.join(folder, filepath)) + if not full_path.startswith(os.path.normpath(folder)): + raise HTTPException(status_code=403, detail="Path traversal not allowed") + if os.path.isfile(full_path): + os.remove(full_path) + parent = os.path.dirname(full_path) + while parent != os.path.normpath(folder): + if os.path.isdir(parent) and not os.listdir(parent): + os.rmdir(parent) + parent = os.path.dirname(parent) + else: + break + return {"ok": True} + + +# --------------------------------------------------------------------------- +# App CRUD +# --------------------------------------------------------------------------- + +@app_builder.router.get("/list") +async def list_apps(): + return {"apps": [o.model_dump() for o in _store.load_all()]} + + +@app_builder.router.get("/{app_id}") +async def get_app(app_id: str): + return _store.load(app_id).model_dump() + + +@app_builder.router.post("/create") +async def create_app(body: AppCreate): + now = datetime.now().isoformat() + app = App( + name=body.name, description=body.description, icon=body.icon, + input_schema=body.input_schema, files=body.files, + thumbnail=body.thumbnail, created_at=now, updated_at=now, + ) + _store.save(app) + return {"ok": True, "app": app.model_dump()} + + +@app_builder.router.put("/{app_id}") +async def update_app(app_id: str, body: AppUpdate): + app = _store.load(app_id) + for k, v in body.model_dump(exclude_none=True).items(): + setattr(app, k, v) + app.updated_at = datetime.now().isoformat() + _store.save(app) + return {"ok": True, "app": app.model_dump()} + + +@app_builder.router.delete("/{app_id}") +async def delete_app(app_id: str): + _store.load(app_id) + _store.delete(app_id) + return {"ok": True} + + +# --------------------------------------------------------------------------- +# Execution +# --------------------------------------------------------------------------- + +@app_builder.router.post("/execute") +async def execute_app(body: AppExecute): + app = _store.load(body.app_id) + validation_err = validate_against_schema(body.input_data, app.input_schema) + if validation_err: + return AppExecuteResult( + app_id=app.id, app_name=app.name, + frontend_code=app.files.get("index.html", ""), + input_data=body.input_data, + backend_result=None, error=validation_err, + ).model_dump() + + backend_code = app.files.get("backend.py") + backend_result = None + stdout_text = None + stderr_text = None + error = None + if backend_code: + try: + exec_result = await execute_backend_code(backend_code, body.input_data) + backend_result = exec_result.result + stdout_text = exec_result.stdout + stderr_text = exec_result.stderr + except Exception as e: + error = str(e) + + return AppExecuteResult( + app_id=app.id, app_name=app.name, + frontend_code=app.files.get("index.html", ""), + input_data=body.input_data, + backend_result=backend_result, stdout=stdout_text, + stderr=stderr_text, error=error, + ).model_dump() diff --git a/backend/apps/app_builder/app_builder_skill.md b/backend/apps/app_builder/app_builder_skill.md new file mode 100644 index 00000000..4441f3f4 --- /dev/null +++ b/backend/apps/app_builder/app_builder_skill.md @@ -0,0 +1,223 @@ +# App Builder — Platform Reference + +You are building an **App**: a self-contained web app served in an iframe. +The workspace you're working in is the source of truth — every file you write +here is served directly to the live preview. + +--- + +## File conventions + +| File | Required | Purpose | +|------|----------|---------| +| `index.html` | **Yes** | Entry point. Must be a complete HTML document. This is the ONLY file the preview iframe loads — never rename it. | +| `meta.json` | **Yes** | `{"name":"…","description":"…"}` — displayed in the UI header. Always write this. | +| `schema.json` | Recommended | JSON Schema defining the input form (the "Test Input" tab). | +| `backend.py` | Optional | Server-side Python executed before rendering. | +| Everything else | Optional | JS, CSS, images, subdirectories — referenced from `index.html` via relative paths. | + +### ⚠️ Do NOT + +- Name the main HTML file anything other than `index.html` — the platform + will not find it and the preview will be blank. +- Use `document.write()` — it breaks the injected data globals. +- Assume any external server or API is available unless the user provides one. + +--- + +## Injected globals + +Before `index.html` loads, the platform injects two globals: + +```javascript +window.APP_BUILDER_INPUT // Object — structured input from the schema form +window.APP_BUILDER_BACKEND_RESULT // Object | null — result from backend.py execution +``` + +These are available immediately in any ` +``` + +ES module imports between JS files: + +```javascript +// components/Chart.js +import { formatNumber } from '../utils/helpers.js'; +``` + +--- + +## Using React + +React 18 is available via esm.sh CDN — no build step needed: + +```html + +
+ +``` + +Other CDN libraries work too — use `https://esm.sh/` or `https://cdn.jsdelivr.net/npm/` for any npm package. + +--- + +## Design guidelines + +- **Dark theme by default** — use dark backgrounds (#0f1117, #1a1d27) with + light text (#e2e8f0) unless the user requests otherwise. +- **Modern aesthetics** — rounded corners (8-12px), subtle borders, box shadows, + smooth transitions (0.15-0.3s ease). +- **Responsive** — use flexbox/grid, test at different sizes. +- **Typography** — system font stack for UI, monospace for code/data. +- **Color accents** — use a single accent color with variations for hover/active states. +- **Spacing** — consistent padding (12-20px), adequate whitespace between sections. +- **Interactivity** — hover effects, focus states, loading indicators where appropriate. + +--- + +## Complete minimal example + +```html + + + + + +Describe what you want to build and the agent will update this app.
+