[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

This commit is contained in:
haikdc
2026-04-05 16:32:50 -07:00
parent fa3376f9d8
commit 6fbd766bbe
8 changed files with 742 additions and 4 deletions
+64
View File
@@ -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
+231
View File
@@ -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()
@@ -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 `<script>` tag. You can also listen for
live updates when the user changes input:
```javascript
window.addEventListener('app-builder-data-ready', () => {
const input = window.APP_BUILDER_INPUT;
const result = window.APP_BUILDER_BACKEND_RESULT;
// re-render with new data
});
```
---
## schema.json format
Standard JSON Schema. The platform renders a form from this automatically.
```json
{
"type": "object",
"properties": {
"title": { "type": "string", "default": "My Dashboard" },
"count": { "type": "number", "default": 10 },
"enabled": { "type": "boolean", "default": true },
"items": {
"type": "array",
"items": { "type": "string" },
"default": ["alpha", "beta"]
}
},
"required": ["title"]
}
```
Supported types: `string`, `number`, `integer`, `boolean`, `array`, `object`.
Use `"default"` values so the preview works without manual input.
---
## backend.py
Optional server-side Python that runs before the frontend renders.
It receives a global `input_data` dict (the schema form values) and must
assign its result to a global `result` dict.
```python
# input_data is pre-populated from the schema form
import json
result = {
"processed_items": [item.upper() for item in input_data.get("items", [])],
"timestamp": "2024-01-01T00:00:00Z",
}
```
The `result` dict becomes `window.APP_BUILDER_BACKEND_RESULT` in the frontend.
---
## Multi-file projects
Split code across files for organization. All files are served from the
workspace root, so relative imports work naturally:
```
workspace/
├── index.html
├── meta.json
├── schema.json
├── styles/
│ └── main.css
├── components/
│ └── Chart.js
└── utils/
└── helpers.js
```
Reference from `index.html`:
```html
<link rel="stylesheet" href="./styles/main.css">
<script type="module" src="./components/Chart.js"></script>
```
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
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18",
"react-dom/client": "https://esm.sh/react-dom@18/client"
}
}
</script>
<div id="root"></div>
<script type="module">
import React from 'react';
import { createRoot } from 'react-dom/client';
function App() {
const input = window.APP_BUILDER_INPUT || {};
return React.createElement('div', null,
React.createElement('h1', null, input.title || 'Hello')
);
}
createRoot(document.getElementById('root')).render(
React.createElement(App)
);
</script>
```
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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #1a1d27;
border: 1px solid #2e3248;
border-radius: 12px;
padding: 32px;
max-width: 480px;
width: 100%;
}
h1 { font-size: 1.5rem; margin-bottom: 8px; }
p { color: #8892a4; line-height: 1.6; }
</style>
</head>
<body>
<div class="card">
<h1 id="title">Loading…</h1>
<p id="desc"></p>
</div>
<script>
const input = window.APP_BUILDER_INPUT || {};
document.getElementById('title').textContent = input.title || 'Untitled';
document.getElementById('desc').textContent = input.description || 'No description provided.';
</script>
</body>
</html>
```
+74
View File
@@ -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]}"
)
+72
View File
@@ -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 (
"<script>\n"
"(function() {\n"
" window.APP_BUILDER_INPUT = " + input_json + ";\n"
" window.APP_BUILDER_BACKEND_RESULT = " + result_json + ";\n"
" window.addEventListener('message', function(e) {\n"
" if (e.data && e.data.type === 'APP_BUILDER_DATA') {\n"
" window.APP_BUILDER_INPUT = e.data.input || {};\n"
" window.APP_BUILDER_BACKEND_RESULT = e.data.backendResult || null;\n"
" window.dispatchEvent(new CustomEvent('app-builder-data-ready'));\n"
" }\n"
" });\n"
"})();\n"
"</script>"
)
def inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null") -> str:
injection = build_data_injection(input_json, result_json)
if "</head>" in html:
return html.replace("</head>", f"{injection}\n</head>", 1)
if "<body" in html:
return html.replace("<body", f"{injection}\n<body", 1)
return f"{injection}\n{html}"
def decode_data_param(d: str) -> 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
+74
View File
@@ -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 = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1117;
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.container {
background: #1a1d27;
border: 1px solid #2e3248;
border-radius: 12px;
padding: 32px;
max-width: 600px;
width: 100%;
text-align: center;
}
h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
p { color: #8892a4; font-size: 0.95rem; line-height: 1.6; }
</style>
</head>
<body>
<div class="container">
<h1 id="title">Ready</h1>
<p id="desc">Describe what you want to build and the agent will update this app.</p>
</div>
<script>
const input = window.APP_BUILDER_INPUT || {};
const result = window.APP_BUILDER_BACKEND_RESULT || null;
</script>
</body>
</html>
"""
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,
}
+2 -3
View File
@@ -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",
+2 -1
View File
@@ -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