mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[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:
@@ -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
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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]}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user