[eric] publish: OUTPUT_COMPUTE/OUTPUT_LLM runtime in preview + /outputs/llm endpoint + skill doc

This commit is contained in:
ciregenz
2026-06-19 04:57:06 -07:00
parent def971f5f2
commit abcd334b34
5 changed files with 125 additions and 7 deletions
+37
View File
@@ -325,6 +325,43 @@ export const JOBS_LIST = '/api/jobs/list';
---
## Publishable AI + compute — `window.OUTPUT_LLM` / `window.OUTPUT_COMPUTE`
The FastAPI backend above runs in preview but is **not hosted when an app is
published** to the web. For features that should keep working on a published
`{slug}.openswarm.dev` link, use these two runtime calls instead of a backend.
They behave the same in preview and when published.
**AI (Claude):** call `window.OUTPUT_LLM` with an Anthropic-style messages body.
The model is chosen for you (a cheap default), so don't pass one.
```ts
const res = await window.OUTPUT_LLM({
messages: [{ role: 'user', content: prompt }],
max_tokens: 512,
});
const data = await res.json();
const text = data.content[0].text;
```
**Data-shaping compute:** put pure Python (json/math/csv/datetime only — no
network, no files) in a top-level `backend.py` that reads `input_data` and assigns
`result`, then call `window.OUTPUT_COMPUTE(input)`:
```python
# backend.py
result = {"total": sum(input_data["nums"])}
```
```ts
const out = await window.OUTPUT_COMPUTE({ nums: [1, 2, 3] }); // -> { total: 6 }
```
Rule of thumb: if the app should be publishable, reach for `OUTPUT_LLM` /
`OUTPUT_COMPUTE` first; only use the FastAPI backend for preview-only tools or
things those two can't do (it won't be there once published).
---
## Debugging — use `swarm_debug`, not `print()`
The backend has `swarm_debug` pre-installed. It's a colored frame-aware
+34 -4
View File
@@ -54,20 +54,50 @@ def _validate_against_schema(data: dict, schema: dict) -> str | None:
return f"Schema validation failed at {path}: {exc.message}"
def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null") -> str:
def _runtime_helpers_js(token: str, output_id: str | None) -> str:
"""OUTPUT_COMPUTE / OUTPUT_LLM: the same runtime API the published edge injects,
pointed at this install's backend so an app works in preview AND when published.
The install token rides in the header (the iframe already exposes it via the
relative-URL rewrite; this is the same local-only credential, not a cloud secret)."""
auth = json.dumps(f"Bearer {token}")
js = ""
if output_id:
oid = json.dumps(output_id)
js += (
" window.OUTPUT_COMPUTE = async function (input) {\n"
" var r = await fetch('/api/outputs/execute', {method:'POST', headers:{'Content-Type':'application/json','Authorization': " + auth + "}, body: JSON.stringify({output_id: " + oid + ", input_data: input || {}, force: true})});\n"
" var d = await r.json();\n"
" if (d.error) throw new Error(d.error);\n"
" return d.backend_result;\n"
" };\n"
)
js += (
" window.OUTPUT_LLM = async function (body) {\n"
" return fetch('/api/outputs/llm', {method:'POST', headers:{'Content-Type':'application/json','Authorization': " + auth + "}, body: JSON.stringify(body || {})});\n"
" };\n"
)
return js
def _build_data_injection(input_json: str, result_json: str, backend_url_json: str = "null", runtime: dict | None = None) -> str:
"""Build a <script> tag that sets OUTPUT_INPUT / OUTPUT_BACKEND_RESULT /
OUTPUT_BACKEND_URL and listens for postMessage updates.
OUTPUT_BACKEND_URL, optionally wires OUTPUT_COMPUTE / OUTPUT_LLM, and listens
for postMessage updates.
OUTPUT_BACKEND_URL is `null` when the app has no live `backend.py`
process; otherwise it's `http://localhost:<port>` and app code can
`fetch(window.OUTPUT_BACKEND_URL + '/route')` to hit the persistent
backend's endpoints."""
helpers = ""
if runtime and runtime.get("token"):
helpers = _runtime_helpers_js(runtime["token"], runtime.get("output_id"))
return (
"<script>\n"
"(function() {\n"
" window.OUTPUT_INPUT = " + input_json + ";\n"
" window.OUTPUT_BACKEND_RESULT = " + result_json + ";\n"
" window.OUTPUT_BACKEND_URL = " + backend_url_json + ";\n"
+ helpers +
" window.addEventListener('message', function(e) {\n"
" if (e.data && e.data.type === 'OUTPUT_DATA') {\n"
" window.OUTPUT_INPUT = e.data.input || {};\n"
@@ -81,8 +111,8 @@ def _build_data_injection(input_json: str, result_json: str, backend_url_json: s
)
def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null") -> str:
injection = _build_data_injection(input_json, result_json, backend_url_json)
def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str = "null", backend_url_json: str = "null", runtime: dict | None = None) -> str:
injection = _build_data_injection(input_json, result_json, backend_url_json, runtime)
if "</head>" in html:
return html.replace("</head>", f"{injection}\n</head>", 1)
if "<body" in html:
+8
View File
@@ -235,3 +235,11 @@ class PublishResult(BaseModel):
blocked: bool = False
review: Optional[PublishReview] = None
error: Optional[str] = None
class AppLLMRequest(BaseModel):
# The runtime LLM call an app makes via window.OUTPUT_LLM. Anthropic-ish shape;
# the model is chosen server-side (the user's cheap tier), not by the app.
messages: list[dict[str, Any]] = Field(default_factory=list)
system: Optional[str] = None
max_tokens: int = 1024
+30 -3
View File
@@ -13,7 +13,7 @@ from backend.apps.outputs.models import (
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
VibeCodeRequest, WorkspaceSeedRequest,
PublishPreflightRequest, PublishRequest, PublishPreflightResponse,
PublishResult, PublishReview,
PublishResult, PublishReview, AppLLMRequest,
)
from backend.apps.outputs.executor import execute_backend_code, get_code_warnings
from backend.apps.outputs.publish import (
@@ -93,7 +93,7 @@ async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""):
if filepath == "index.html":
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
backend_url_json = _backend_url_for_workspace(workspace_id)
content = _inject_data_into_html(content, input_json, result_json, backend_url_json)
content = _inject_data_into_html(content, input_json, result_json, backend_url_json, runtime={"token": get_auth_token()})
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the
# parent's ?token= query string, so rewrite the HTML to put the token
# back on every relative URL; otherwise sub-resources 401.
@@ -114,7 +114,7 @@ async def serve_output_file(output_id: str, filepath: str, _d: str = ""):
if filepath == "index.html":
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
backend_url_json = _backend_url_for_workspace(output.workspace_id) if output.workspace_id else "null"
content = _inject_data_into_html(content, input_json, result_json, backend_url_json)
content = _inject_data_into_html(content, input_json, result_json, backend_url_json, runtime={"token": get_auth_token(), "output_id": output.id})
content = _inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
@@ -807,6 +807,33 @@ async def publish_output(body: PublishRequest):
).model_dump()
@outputs.router.post("/llm")
async def app_llm(body: AppLLMRequest):
"""Runtime LLM for an app's window.OUTPUT_LLM call. Uses the user's configured
cheap tier (provider-agnostic), so an app's AI features work in the App Builder
preview the same way the published edge serves them via /__llm. Non-streaming:
the app reads `(await res.json()).content[0].text`."""
if not body.messages:
raise HTTPException(status_code=400, detail="messages is required")
settings = load_settings()
from backend.apps.agents.providers.registry import resolve_aux_model
from backend.apps.agents.core.aux_llm import _safe_resp_text
try:
model, _base = await resolve_aux_model(settings, preferred_tier="haiku")
except Exception:
raise HTTPException(status_code=503, detail="No model is configured. Add a key or connect a plan in Settings.")
client = _get_anthropic_client(model)
kwargs: dict = {"model": model, "max_tokens": max(1, min(body.max_tokens, 4096)), "messages": body.messages}
if body.system:
kwargs["system"] = body.system
try:
resp = await client.messages.create(**kwargs)
except Exception:
logger.exception("app llm call failed")
raise HTTPException(status_code=502, detail="The model call failed.")
return {"content": [{"type": "text", "text": _safe_resp_text(resp)}], "model": model}
@outputs.router.post("/unpublish")
async def unpublish_output(body: PublishPreflightRequest):
"""Take the app offline and clear its publish state."""
+16
View File
@@ -110,6 +110,22 @@ def test_scan_for_publish_merges_ast():
publish._llm_findings = orig
def test_runtime_injection():
from backend.apps.outputs.html_inject import _build_data_injection, _inject_data_into_html
base = _build_data_injection("{}", "null")
assert "OUTPUT_COMPUTE" not in base and "OUTPUT_LLM" not in base # no runtime -> no helpers
full = _build_data_injection("{}", "null", "null", {"token": "tok123", "output_id": "abc"})
assert "OUTPUT_COMPUTE" in full and "OUTPUT_LLM" in full and "Bearer tok123" in full
llm_only = _build_data_injection("{}", "null", "null", {"token": "tok123"})
assert "OUTPUT_LLM" in llm_only and "OUTPUT_COMPUTE" not in llm_only # webapp: no output_id -> LLM only
html = _inject_data_into_html("<html><head></head><body>x</body></html>", "{}", "null", "null", {"token": "t"})
assert "OUTPUT_LLM" in html and "</head>" in html
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns: