tools / app.py
gfathertech's picture
Update app.py
c53cd09 verified
Raw History Blame Contribute Delete
16.9 kB
"""
SOG-X Modular Studio - Dynamic Swagger-Style Interface
Auto-discovers backend routes via OpenAPI and mirrors Swagger UI's own behavior:
- multipart/form-data bodies -> one widget per field (including real file upload)
- application/json bodies -> a single JSON editor, pre-filled with an example
built from each field's default/example value
- query/path parameters -> individual widgets, same as Swagger's parameter list
Compatible with Gradio 6.0+
"""
import json
import os
import gradio as gr
import spaces
from client import BackendClient
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:7860")
HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "")
USER_KEY = os.getenv("USER_KEY", "") # never hardcode a fallback secret here
client = BackendClient(backend_url=BACKEND_URL, token=HF_TOKEN)
FILE_NAME_HINTS = ("file", "document", "upload", "attachment", "audio", "video", "image")
@spaces.GPU
def _warmup():
"""Dummy function so HF's ZeroGPU startup check finds a @spaces.GPU function."""
return "ok"
custom_css = """
.module-radio label {
background-color: #2b2b3b !important; border: 1px solid #3f3f56 !important;
border-radius: 8px !important; margin-bottom: 6px !important; padding: 10px 14px !important;
transition: all 0.2s ease;
}
.module-radio label:hover { border-color: #6c63ff !important; }
"""
# ── Schema Loading ──────────────────────────────────────────────────────
def load_backend_modules(api_key: str):
try:
ok, schema = client.fetch_openapi_schema(user_api_key=api_key)
except Exception as e:
ok, schema = False, {"error": "NETWORK_ERROR", "detail": str(e)}
modules_map, error_detail = {}, None
if not ok:
error_detail = f"{schema.get('error', 'UNKNOWN')}: {schema.get('detail', '')}".strip(": ")
elif isinstance(schema, dict) and "paths" not in schema:
error_detail = "Schema fetched but has no 'paths' key β€” check the response shape."
if ok and isinstance(schema, dict) and "paths" in schema:
for path, methods in schema["paths"].items():
for method, details in methods.items():
if method.lower() not in ["get", "post"]:
continue
tags = details.get("tags", ["General"])
tag_name = tags[0].capitalize() if tags else "General"
summary = details.get("summary") or f"{method.upper()} {path}"
modules_map[f"[{tag_name}] {summary}"] = {
"path": path,
"method": method.upper(),
"summary": summary,
"description": details.get("description", "No description provided."),
"parameters": details.get("parameters", []),
"request_body": details.get("requestBody", {}),
}
if modules_map:
module_names = list(modules_map.keys())
elif error_detail:
module_names = [f"⚠️ Discovery failed: {error_detail}"]
else:
module_names = ["No Modules Discovered"]
full_schema = schema if (ok and isinstance(schema, dict)) else {}
return modules_map, module_names, full_schema
def resolve_ref(full_schema: dict, node):
"""Resolve a single-level '$ref' like '#/components/schemas/ChatRequest'."""
if not isinstance(node, dict):
return {}
if "$ref" in node:
parts = node["$ref"].lstrip("#/").split("/")
resolved = full_schema
for p in parts:
resolved = resolved.get(p, {}) if isinstance(resolved, dict) else {}
return resolved
return node
def _is_file_schema(name: str, schema: dict, is_multipart: bool) -> bool:
if schema.get("format") == "binary" or "contentMediaType" in schema:
return True
if is_multipart and name.lower() in FILE_NAME_HINTS:
return True
return False
def _field_from_schema(name, in_, required, description, schema, full_schema, is_multipart=False):
schema = resolve_ref(full_schema, schema)
items = resolve_ref(full_schema, schema.get("items")) if schema.get("items") else {}
return {
"name": name,
"in": in_,
"required": required,
"type": schema.get("type", "string"),
"is_file": _is_file_schema(name, schema, is_multipart),
"enum": schema.get("enum"),
"items": items,
"pattern": schema.get("pattern"),
"default": schema.get("default"),
"example": schema.get("example"),
"description": description or schema.get("description", ""),
}
def extract_param_fields(mod: dict, full_schema: dict):
"""Query / path parameters β€” Swagger always renders these as individual widgets."""
fields, seen = [], set()
for p in mod.get("parameters", []):
name = p.get("name")
if not name or name in seen:
continue
seen.add(name)
fields.append(_field_from_schema(
name, p.get("in", "query"), bool(p.get("required", False)),
p.get("description", ""), p.get("schema", {}) or {}, full_schema,
))
return fields
def get_request_body(mod: dict, full_schema: dict):
"""Returns (content_type, resolved_body_schema) or (None, None) if there's no body."""
rb = mod.get("request_body") or {}
content = rb.get("content") or {}
for ctype, cinfo in content.items():
return ctype, resolve_ref(full_schema, cinfo.get("schema"))
return None, None
def extract_multipart_fields(body_schema: dict, full_schema: dict):
fields, required_names = [], set(body_schema.get("required") or [])
for name, prop in (body_schema.get("properties") or {}).items():
fields.append(_field_from_schema(
name, "body", name in required_names,
prop.get("description", ""), prop, full_schema, is_multipart=True,
))
return fields
def _placeholder_for_type(t: str):
return {"string": "", "integer": 0, "number": 0, "boolean": False, "array": [], "object": {}}.get(t, "")
def build_json_example(body_schema: dict, full_schema: dict) -> str:
"""Mirrors Swagger's 'Example Value' tab: use an explicit example if given,
else synthesize one from each field's own default/example, falling back to a
type-shaped placeholder."""
if "example" in body_schema:
example = body_schema["example"]
elif body_schema.get("examples"):
ex = body_schema["examples"]
example = list(ex.values())[0] if isinstance(ex, dict) else ex[0]
else:
example = {}
for name, prop in (body_schema.get("properties") or {}).items():
prop = resolve_ref(full_schema, prop)
if "example" in prop:
example[name] = prop["example"]
elif "default" in prop:
example[name] = prop["default"]
else:
example[name] = _placeholder_for_type(prop.get("type", "string"))
return json.dumps(example, indent=2)
def build_field_component(f: dict, key: str):
"""Pick the widget type the way Swagger UI would, based on the field's schema."""
label = f["name"] + (" *required" if f["required"] else "")
default = f["example"] if f["example"] is not None else f["default"]
if f["is_file"]:
return gr.File(label=label, key=key)
if f["enum"]:
return gr.Dropdown(label=label, choices=[str(v) for v in f["enum"]],
value=str(default) if default is not None else None,
info=f["description"], interactive=True, key=key)
if f["type"] == "boolean":
return gr.Checkbox(label=label, value=bool(default) if default is not None else False,
info=f["description"], interactive=True, key=key)
if f["type"] in ("integer", "number"):
return gr.Number(label=label, value=default, info=f["description"], interactive=True, key=key)
if f["type"] == "array":
item_enum = f["items"].get("enum") if isinstance(f["items"], dict) else None
if item_enum:
return gr.Dropdown(label=label, choices=[str(v) for v in item_enum], multiselect=True,
info=f["description"], interactive=True, key=key)
return gr.Textbox(label=label, value=", ".join(default) if isinstance(default, list) else "",
placeholder="Comma-separated values", info=f["description"] or "List β€” separate items with commas",
interactive=True, key=key)
placeholder = f["description"] or f["name"]
if f["pattern"] and default is None:
placeholder = f"Pattern: {f['pattern']}"
return gr.Textbox(label=label, value=str(default) if default is not None else "", placeholder=placeholder,
interactive=True, key=key)
def coerce_field_value(f: dict, val):
if val in (None, ""):
return None
if f["type"] == "array":
return val if isinstance(val, list) else [v.strip() for v in str(val).split(",") if v.strip()]
return val
INITIAL_MAP, INITIAL_CHOICES, INITIAL_SCHEMA = load_backend_modules(USER_KEY)
with gr.Blocks(title="SOG-X Modular Workspace") as demo:
schema_state = gr.State(value=INITIAL_MAP)
full_schema_state = gr.State(value=INITIAL_SCHEMA)
with gr.Sidebar(position="left", open=True, label="SOG-X Studio") as studio_sidebar:
gr.Markdown("## πŸ€– SOG-X Studio")
with gr.Accordion("βš™οΈ Backend & Credentials", open=False):
gr.Markdown(f"**Backend URL:** `{BACKEND_URL}`")
user_key_input = gr.Textbox(label="Active API Key", value="", type="password",
placeholder="Enter your API key...", interactive=True)
sync_btn = gr.Button("πŸ”„ Sync Backend Modules", size="sm")
gr.Markdown("---")
gr.Markdown("### πŸ“‚ Active Modules")
sidebar_menu = gr.Radio(choices=INITIAL_CHOICES,
value=INITIAL_CHOICES[0] if INITIAL_CHOICES else None,
label="Select Module", interactive=True, elem_classes=["module-radio"])
@gr.render(inputs=[sidebar_menu, schema_state, full_schema_state])
def render_workspace(selected_name, current_map, full_schema):
if not selected_name or selected_name not in current_map:
gr.Markdown("## Select a module from the sidebar")
gr.Markdown("Choose any active backend route from the left navigation panel.")
return
mod = current_map[selected_name]
gr.Markdown(f"## `{mod['method']}` {mod['path']}")
gr.Markdown(f"**Summary:** {mod['summary']}\n\n{mod['description']}")
gr.Markdown("---")
gr.Markdown("### πŸ“₯ Input")
param_fields = extract_param_fields(mod, full_schema)
param_components = {f["name"]: build_field_component(f, key=f"param::{f['name']}") for f in param_fields}
content_type, body_schema = get_request_body(mod, full_schema)
body_mode, body_fields, body_components, json_editor = None, [], {}, None
if content_type == "multipart/form-data" and body_schema:
body_mode = "form"
body_fields = extract_multipart_fields(body_schema, full_schema)
body_components = {f["name"]: build_field_component(f, key=f"body::{f['name']}") for f in body_fields}
elif content_type == "application/json" and body_schema:
body_mode = "json"
example_str = build_json_example(body_schema, full_schema)
json_editor = gr.Code(label="Request Body (JSON) β€” edit before running",
language="json", value=example_str, lines=10, interactive=True, key="json_editor")
if not param_fields and not body_mode:
gr.Markdown("_This endpoint takes no parameters β€” just click Run._")
run_btn = gr.Button("πŸš€ Run", variant="primary", size="lg", key="run_btn")
gr.Markdown("---")
gr.Markdown("### πŸ“€ Output")
out_json = gr.JSON(label="Response Payload", visible=True, key="out_json")
out_image = gr.Image(label="Response Image", visible=False, key="out_image")
out_audio = gr.Audio(label="Response Audio", visible=False, key="out_audio")
out_video = gr.Video(label="Response Video", visible=False, key="out_video")
out_file = gr.File(label="Response File", visible=False, key="out_file")
outputs = [out_json, out_image, out_audio, out_video, out_file]
def _hide_all_but(target, value):
result = {o: gr.update(visible=False) for o in outputs if o is not target}
result[target] = gr.update(value=value, visible=True)
return result
run_inputs = [user_key_input] + list(param_components.values())
if body_mode == "form":
run_inputs += list(body_components.values())
elif body_mode == "json":
run_inputs += [json_editor]
def _run(active_key, *values):
n_params = len(param_fields)
param_values = values[:n_params]
rest = values[n_params:]
resolved_path = mod["path"]
params = {}
for f, raw in zip(param_fields, param_values):
val = coerce_field_value(f, raw)
if val is None:
continue
if f["in"] == "path":
resolved_path = resolved_path.replace("{" + f["name"] + "}", str(val))
else:
params[f["name"]] = val
payload, files, file_handles = {}, {}, []
try:
if body_mode == "json":
raw_json = rest[0] if rest else "{}"
try:
payload = json.loads(raw_json) if raw_json.strip() else {}
except Exception as e:
return _hide_all_but(out_json, {"error": f"Invalid JSON in request body: {e}"})
elif body_mode == "form":
for f, raw in zip(body_fields, rest):
val = coerce_field_value(f, raw)
if val is None:
continue
if f["is_file"]:
file_path = val.name if hasattr(val, "name") else str(val)
fh = open(file_path, "rb")
file_handles.append(fh)
files[f["name"]] = fh
else:
payload[f["name"]] = val
success, response = client.execute_dynamic_request(
path=resolved_path, method=mod["method"],
params=params, payload=payload, files=files,
user_api_key=active_key,
)
finally:
for fh in file_handles:
fh.close()
if not success:
return _hide_all_but(out_json, {"error": str(response)})
if isinstance(response, dict) and response.get("__binary__"):
ctype, path = response.get("content_type", ""), response.get("file_path")
if ctype.startswith("image/"):
return _hide_all_but(out_image, path)
if ctype.startswith("audio/"):
return _hide_all_but(out_audio, path)
if ctype.startswith("video/"):
return _hide_all_but(out_video, path)
return _hide_all_but(out_file, path)
result = _hide_all_but(out_json, response)
if isinstance(response, dict) and "file_path" in response:
result[out_file] = gr.update(value=response["file_path"], visible=True)
return result
run_btn.click(fn=_run, inputs=run_inputs, outputs=outputs)
def refresh_schema_handler(active_key):
new_map, new_choices, new_schema = load_backend_modules(active_key)
selected = new_choices[0] if new_choices else None
return new_map, new_schema, gr.Radio(choices=new_choices, value=selected)
sync_btn.click(fn=refresh_schema_handler, inputs=[user_key_input],
outputs=[schema_state, full_schema_state, sidebar_menu])
# Auto-collapse the sidebar the moment a module is picked, so the workspace
# takes over the screen β€” same pattern as a mobile nav drawer closing on tap.
sidebar_menu.change(fn=lambda: gr.Sidebar(open=False), outputs=studio_sidebar)
if __name__ == "__main__":
_warmup()
port = int(os.getenv("PORT", 7860))
demo.launch(server_name="0.0.0.0", server_port=port,
theme=gr.themes.Soft(), css=custom_css, share=False)