From 6fbd766bbe43fc41c8363fce3066537392a24a1e Mon Sep 17 00:00:00 2001 From: haikdc Date: Sun, 5 Apr 2026 16:32:50 -0700 Subject: [PATCH] [Haik]: ckpt, app builder skeleton copied over from legacy with slight mods and fluff trimmage. Now gonna trim out some more stuff, then refactor / organize --- backend/apps/app_builder/App.py | 64 +++++ backend/apps/app_builder/app_builder.py | 231 ++++++++++++++++++ backend/apps/app_builder/app_builder_skill.md | 223 +++++++++++++++++ backend/apps/app_builder/executor.py | 74 ++++++ backend/apps/app_builder/helpers.py | 72 ++++++ backend/apps/app_builder/templates.py | 74 ++++++ backend/apps/modes/BUILTIN_MODES.py | 5 +- backend/main.py | 3 +- 8 files changed, 742 insertions(+), 4 deletions(-) create mode 100644 backend/apps/app_builder/App.py create mode 100644 backend/apps/app_builder/app_builder.py create mode 100644 backend/apps/app_builder/app_builder_skill.md create mode 100644 backend/apps/app_builder/executor.py create mode 100644 backend/apps/app_builder/helpers.py create mode 100644 backend/apps/app_builder/templates.py 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 + + + + + + My App + + + +
+

Loading…

+

+
+ + + +``` diff --git a/backend/apps/app_builder/executor.py b/backend/apps/app_builder/executor.py new file mode 100644 index 00000000..2ce9bd87 --- /dev/null +++ b/backend/apps/app_builder/executor.py @@ -0,0 +1,74 @@ +import asyncio +import json +import logging +import sys +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +TIMEOUT_SECONDS = 30 + + +@dataclass +class BackendExecResult: + result: dict + stdout: str + stderr: str + + +async def execute_backend_code(code: str, input_data: dict) -> BackendExecResult: + """Execute user-provided Python code in a subprocess. + + The code receives ``input_data`` as a global dict and must assign its + result to a global ``result`` dict. User print() calls are captured + separately from the result via an in-process StringIO redirect. + """ + + preamble = ( + "import json, sys, io\n" + "_orig_stdout = sys.stdout\n" + "_capture = io.StringIO()\n" + "sys.stdout = _capture\n" + "input_data = json.loads(sys.stdin.read())\n" + "result = {}\n" + ) + postamble = ( + "\nsys.stdout = _orig_stdout\n" + 'json.dump({"__stdout__": _capture.getvalue(), "__result__": result}, sys.stdout)\n' + ) + wrapper = preamble + code + postamble + + proc = await asyncio.create_subprocess_exec( + sys.executable, "-c", wrapper, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(input=json.dumps(input_data).encode()), + timeout=TIMEOUT_SECONDS, + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise RuntimeError(f"Backend code execution timed out after {TIMEOUT_SECONDS}s") + + stderr_text = stderr.decode(errors="replace").strip() + + if proc.returncode != 0: + raise RuntimeError(f"Backend code error (exit {proc.returncode}): {stderr_text}") + + try: + parsed = json.loads(stdout.decode()) + return BackendExecResult( + result=parsed.get("__result__", {}), + stdout=parsed.get("__stdout__", ""), + stderr=stderr_text, + ) + except json.JSONDecodeError: + raw = stdout.decode(errors="replace").strip() + raise RuntimeError( + f"Backend code did not produce valid JSON. Raw output: {raw[:500]}" + ) diff --git a/backend/apps/app_builder/helpers.py b/backend/apps/app_builder/helpers.py new file mode 100644 index 00000000..24b07e31 --- /dev/null +++ b/backend/apps/app_builder/helpers.py @@ -0,0 +1,72 @@ +"""Pure helpers for data injection, validation, and directory walking.""" + +from __future__ import annotations + +import base64 +import json +import os + +from jsonschema import validate as schema_validate, ValidationError as SchemaValidationError + + +def validate_against_schema(data: dict, schema: dict) -> str | None: + """Validate *data* against *schema*. Return an error string or None.""" + try: + schema_validate(instance=data, schema=schema) + return None + except SchemaValidationError as exc: + path = " -> ".join(str(p) for p in exc.absolute_path) if exc.absolute_path else "(root)" + return f"Schema validation failed at {path}: {exc.message}" + + +def build_data_injection(input_json: str, result_json: str) -> str: + return ( + "" + ) + + +def inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null") -> str: + injection = build_data_injection(input_json, result_json) + if "" in html: + return html.replace("", f"{injection}\n", 1) + if " tuple[str, str]: + try: + decoded = json.loads(base64.b64decode(d)) + input_json = json.dumps(decoded.get("i", {})) + result_json = json.dumps(decoded.get("r", None)) + return input_json, result_json + except Exception: + return "{}", "null" + + +def walk_directory(folder: str) -> dict[str, str]: + files: dict[str, str] = {} + if not os.path.isdir(folder): + return files + for root, _dirs, filenames in os.walk(folder): + for fname in filenames: + full_path = os.path.join(root, fname) + rel_path = os.path.relpath(full_path, folder) + try: + with open(full_path) as f: + files[rel_path] = f.read() + except Exception: + pass + return files diff --git a/backend/apps/app_builder/templates.py b/backend/apps/app_builder/templates.py new file mode 100644 index 00000000..c228b0bb --- /dev/null +++ b/backend/apps/app_builder/templates.py @@ -0,0 +1,74 @@ +"""Default template files seeded into new App Builder workspaces.""" + +import os + +_SKILL_PATH = os.path.join(os.path.dirname(__file__), "app_builder_skill.md") + +with open(_SKILL_PATH) as _f: + APP_BUILDER_SKILL = _f.read() + +APP_BUILDER_TEMPLATE_INDEX = """\ + + + + + + App + + + +
+

Ready

+

Describe what you want to build and the agent will update this app.

+
+ + + +""" + +APP_BUILDER_TEMPLATE_SCHEMA = """\ +{ + "type": "object", + "properties": {}, + "required": [] +} +""" + +APP_BUILDER_TEMPLATE_META = """\ +{ + "name": "", + "description": "" +} +""" + +APP_BUILDER_TEMPLATE_FILES = { + "index.html": APP_BUILDER_TEMPLATE_INDEX, + "schema.json": APP_BUILDER_TEMPLATE_SCHEMA, + "meta.json": APP_BUILDER_TEMPLATE_META, +} diff --git a/backend/apps/modes/BUILTIN_MODES.py b/backend/apps/modes/BUILTIN_MODES.py index 84b4a38e..0bfc6689 100644 --- a/backend/apps/modes/BUILTIN_MODES.py +++ b/backend/apps/modes/BUILTIN_MODES.py @@ -6,11 +6,10 @@ Separated from models.py to keep schema classes small and data separate. from backend.apps.modes.Mode import Mode from typing import List from backend.config.paths import DB_ROOT +from backend.apps.app_builder.app_builder import APP_BUILDER_WORKSPACE_DIR import os -# NOTE: When the skills and outputs subapps are implemented, we will need to update these paths to import from the subapps. SKILLS_WORKSPACE: str = os.path.join(DB_ROOT, "skills") -OUTPUTS_WORKSPACE: str = os.path.join(DB_ROOT, "outputs") BUILTIN_MODES: List[Mode] = [ Mode( @@ -76,7 +75,7 @@ BUILTIN_MODES: List[Mode] = [ is_builtin=True, icon="view_quilt", color="#f472b6", - default_folder=OUTPUTS_WORKSPACE, + default_folder=APP_BUILDER_WORKSPACE_DIR, ), Mode( id="skill-builder", diff --git a/backend/main.py b/backend/main.py index 04e2380f..75c136ad 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,10 +13,11 @@ from backend.apps.health.health import health from backend.apps.settings.settings import settings from backend.apps.modes.modes import modes from backend.apps.tools.tools import tools +from backend.apps.app_builder.app_builder import app_builder from fastapi.middleware.cors import CORSMiddleware main_app = MainApp([ - health, tools, agents, settings, dashboards, modes + health, tools, agents, settings, dashboards, modes, app_builder ]) app = main_app.app