mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 12:17:45 +02:00
235 lines
8.9 KiB
Python
235 lines
8.9 KiB
Python
"""Outputs SubApp — CRUD, workspace management, and file serving.
|
|
|
|
AI-generation endpoints live in ``ai_generation.py``; pure helpers in ``helpers.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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.apps.common.json_store import JsonStore
|
|
from backend.apps.outputs.models import (
|
|
Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult,
|
|
AutoRunConfig, WorkspaceSeedRequest,
|
|
)
|
|
from backend.apps.outputs.executor import execute_backend_code
|
|
from backend.apps.outputs.view_builder_templates import VIEW_BUILDER_SKILL, VIEW_TEMPLATE_FILES
|
|
from backend.apps.outputs.helpers import (
|
|
_validate_against_schema, _inject_data_into_html, _decode_data_param, _walk_directory,
|
|
)
|
|
from backend.apps.outputs import ai_generation
|
|
from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR
|
|
from backend.apps.analytics.collector import record as _analytics
|
|
|
|
|
|
@asynccontextmanager
|
|
async def outputs_lifespan():
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
os.makedirs(WORKSPACE_DIR, exist_ok=True)
|
|
yield
|
|
|
|
|
|
outputs = SubApp("outputs", outputs_lifespan)
|
|
|
|
_store = JsonStore(Output, DATA_DIR, not_found_detail="Output not found")
|
|
_load_all = _store.load_all
|
|
_save = _store.save
|
|
_load = _store.load
|
|
|
|
|
|
# -- File serving --
|
|
|
|
@outputs.router.get("/workspace/{workspace_id}/serve/{filepath:path}")
|
|
async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""):
|
|
folder = os.path.join(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")
|
|
|
|
|
|
@outputs.router.get("/{output_id}/serve/{filepath:path}")
|
|
async def serve_output_file(output_id: str, filepath: str, _d: str = ""):
|
|
output = _load(output_id)
|
|
content = output.files.get(filepath)
|
|
if content is None:
|
|
raise HTTPException(status_code=404, detail="File not found in output")
|
|
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")
|
|
|
|
|
|
# -- CRUD --
|
|
|
|
@outputs.router.get("/list")
|
|
async def list_outputs():
|
|
return {"outputs": [o.model_dump() for o in _load_all()]}
|
|
|
|
|
|
@outputs.router.get("/workspace/{workspace_id}")
|
|
async def read_workspace(workspace_id: str):
|
|
folder = os.path.join(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}
|
|
|
|
|
|
@outputs.router.post("/workspace/seed")
|
|
async def seed_workspace(body: WorkspaceSeedRequest):
|
|
folder = os.path.join(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 VIEW_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(VIEW_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)}
|
|
|
|
|
|
@outputs.router.put("/workspace/{workspace_id}/file/{filepath:path}")
|
|
async def write_workspace_file(workspace_id: str, filepath: str, body: dict):
|
|
folder = os.path.join(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}
|
|
|
|
|
|
@outputs.router.delete("/workspace/{workspace_id}/file/{filepath:path}")
|
|
async def delete_workspace_file(workspace_id: str, filepath: str):
|
|
folder = os.path.join(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}
|
|
|
|
|
|
@outputs.router.get("/{output_id}")
|
|
async def get_output(output_id: str):
|
|
return _load(output_id).model_dump()
|
|
|
|
|
|
@outputs.router.post("/create")
|
|
async def create_output(body: OutputCreate):
|
|
now = datetime.now().isoformat()
|
|
output = Output(
|
|
name=body.name, description=body.description, icon=body.icon,
|
|
input_schema=body.input_schema, files=body.files,
|
|
auto_run_config=body.auto_run_config, thumbnail=body.thumbnail,
|
|
created_at=now, updated_at=now,
|
|
)
|
|
_save(output)
|
|
_analytics("feature.used", {"feature": "view.created"})
|
|
return {"ok": True, "output": output.model_dump()}
|
|
|
|
|
|
@outputs.router.put("/{output_id}")
|
|
async def update_output(output_id: str, body: OutputUpdate):
|
|
output = _load(output_id)
|
|
for k, v in body.model_dump(exclude_none=True).items():
|
|
if k == "auto_run_config" and isinstance(v, dict):
|
|
v = AutoRunConfig(**v)
|
|
setattr(output, k, v)
|
|
output.updated_at = datetime.now().isoformat()
|
|
_save(output)
|
|
return {"ok": True, "output": output.model_dump()}
|
|
|
|
|
|
@outputs.router.delete("/{output_id}")
|
|
async def delete_output(output_id: str):
|
|
_load(output_id)
|
|
path = os.path.join(DATA_DIR, f"{output_id}.json")
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
return {"ok": True}
|
|
|
|
|
|
@outputs.router.post("/execute")
|
|
async def execute_output(body: OutputExecute):
|
|
output = _load(body.output_id)
|
|
validation_err = _validate_against_schema(body.input_data, output.input_schema)
|
|
if validation_err:
|
|
return OutputExecuteResult(
|
|
output_id=output.id, output_name=output.name,
|
|
frontend_code=output.frontend_code, input_data=body.input_data,
|
|
backend_result=None, error=validation_err,
|
|
).model_dump()
|
|
backend_result = None
|
|
stdout_text = None
|
|
stderr_text = None
|
|
error = None
|
|
if output.backend_code:
|
|
try:
|
|
exec_result = await execute_backend_code(output.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 OutputExecuteResult(
|
|
output_id=output.id, output_name=output.name,
|
|
frontend_code=output.frontend_code, input_data=body.input_data,
|
|
backend_result=backend_result, stdout=stdout_text,
|
|
stderr=stderr_text, error=error,
|
|
).model_dump()
|
|
|
|
|
|
# -- AI generation routes --
|
|
outputs.router.add_api_route("/auto-run", ai_generation.auto_run_output, methods=["POST"])
|
|
outputs.router.add_api_route("/auto-run-agent", ai_generation.auto_run_agent, methods=["POST"])
|
|
outputs.router.add_api_route("/auto-run-agent/{session_id}", ai_generation.cleanup_auto_run_agent, methods=["DELETE"])
|