mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
[eric] publish: OUTPUT_COMPUTE/OUTPUT_LLM runtime in preview + /outputs/llm endpoint + skill doc
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user