adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
1585 lines
57 KiB
Python
1585 lines
57 KiB
Python
"""gr.Workflow — high-level API for building and launching AI pipelines."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import secrets
|
|
import sys
|
|
import tempfile
|
|
import types
|
|
import urllib.parse
|
|
import warnings
|
|
import webbrowser
|
|
from collections.abc import Callable
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from typing import TYPE_CHECKING, Optional, TypedDict, Union, get_type_hints
|
|
|
|
import httpx
|
|
from huggingface_hub import HfApi
|
|
from huggingface_hub import get_token as hf_get_token
|
|
|
|
import gradio as gr
|
|
from gradio.blocks import Blocks
|
|
from gradio.components.workflowcanvas import WorkflowCanvas
|
|
from gradio.helpers import special_args as _special_args
|
|
from gradio.oauth import OAuthProfile, OAuthToken
|
|
from gradio.route_utils import Request
|
|
from gradio.utils import get_space
|
|
|
|
if TYPE_CHECKING:
|
|
from gradio.workflow_api import WorkflowEndpointManager
|
|
|
|
_HF_CLIENT = httpx.Client(
|
|
base_url="https://huggingface.co",
|
|
timeout=15,
|
|
headers={"User-Agent": "gradio-workflow"},
|
|
limits=httpx.Limits(max_keepalive_connections=8, max_connections=16),
|
|
)
|
|
_SEARCH_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="hf-search")
|
|
|
|
_CURATED_DATASET_REPO = "gradio/workflow-curated"
|
|
_CURATED_DATASET_FILE = "curated.json"
|
|
_CURATED_TTL_SECONDS = 3600.0
|
|
|
|
_INJECTED_TYPES = frozenset({OAuthToken, OAuthProfile, Request})
|
|
|
|
|
|
def _is_injected_param(hint: object) -> bool:
|
|
if hint in _INJECTED_TYPES:
|
|
return True
|
|
if getattr(hint, "__origin__", None) is Union:
|
|
args = hint.__args__ # type: ignore[union-attr]
|
|
non_none = [a for a in args if a is not type(None)]
|
|
return len(non_none) == 1 and non_none[0] in _INJECTED_TYPES
|
|
if isinstance(hint, types.UnionType):
|
|
non_none = [a for a in hint.__args__ if a is not type(None)]
|
|
return len(non_none) == 1 and non_none[0] in _INJECTED_TYPES
|
|
return False
|
|
|
|
|
|
class _CuratedCache(TypedDict):
|
|
fetched_at: float
|
|
items: Optional[list[dict]]
|
|
|
|
|
|
_CURATED_CACHE: _CuratedCache = {"fetched_at": 0.0, "items": None}
|
|
_CURATED_LOCK = __import__("threading").Lock()
|
|
|
|
|
|
def _bundled_snapshot_path() -> str:
|
|
return os.path.join(os.path.dirname(__file__), "_workflow_curated_snapshot.json")
|
|
|
|
|
|
def _load_bundled_snapshot() -> list[dict]:
|
|
try:
|
|
with open(_bundled_snapshot_path(), encoding="utf-8") as f:
|
|
payload = json.load(f)
|
|
items = payload.get("items") if isinstance(payload, dict) else payload
|
|
return items if isinstance(items, list) else []
|
|
except Exception as e:
|
|
logger.warning("workflow curated snapshot unreadable: %s", e)
|
|
return []
|
|
|
|
|
|
def _fetch_curated_from_hub() -> Optional[list[dict]]:
|
|
try:
|
|
from huggingface_hub import hf_hub_download
|
|
|
|
local = hf_hub_download(
|
|
repo_id=_CURATED_DATASET_REPO,
|
|
filename=_CURATED_DATASET_FILE,
|
|
repo_type="dataset",
|
|
)
|
|
with open(local, encoding="utf-8") as f:
|
|
payload = json.load(f)
|
|
items = payload.get("items") if isinstance(payload, dict) else payload
|
|
if isinstance(items, list):
|
|
return items
|
|
return None
|
|
except Exception as e:
|
|
logger.info("curated dataset fetch failed: %s", e)
|
|
return None
|
|
|
|
|
|
def _load_curated() -> list[dict]:
|
|
import time
|
|
|
|
now = time.monotonic()
|
|
with _CURATED_LOCK:
|
|
cached_at = _CURATED_CACHE["fetched_at"]
|
|
cached_items = _CURATED_CACHE["items"]
|
|
if cached_items is not None and (now - cached_at) < _CURATED_TTL_SECONDS:
|
|
return cached_items
|
|
|
|
live = _fetch_curated_from_hub()
|
|
items: list[dict] = live if live is not None else _load_bundled_snapshot()
|
|
|
|
with _CURATED_LOCK:
|
|
cached_at2 = _CURATED_CACHE["fetched_at"]
|
|
if (now - cached_at2) >= _CURATED_TTL_SECONDS:
|
|
_CURATED_CACHE["fetched_at"] = now
|
|
_CURATED_CACHE["items"] = items
|
|
else:
|
|
cached = _CURATED_CACHE["items"]
|
|
if cached is not None:
|
|
items = cached
|
|
return items
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Scalar-only — everything else (str, list, dict, custom classes) falls through
|
|
# to the default "text" port type, which round-trips as JSON.
|
|
_PY_TO_PORT = {int: "number", float: "number", bool: "boolean"}
|
|
|
|
|
|
def _build_edges(
|
|
edges_spec: list[tuple[str, str]],
|
|
nodes: list[dict],
|
|
) -> list[dict]:
|
|
def resolve(spec: str, ports_key: str) -> tuple[str, str, str]:
|
|
fn_name, _, port_hint = spec.partition(".")
|
|
fn_name = fn_name.strip()
|
|
port_hint = port_hint.strip() or None
|
|
|
|
node = next((n for n in nodes if n.get("fn") == fn_name), None)
|
|
if node is None:
|
|
raise ValueError(
|
|
f"edges: no function '{fn_name}' in bind dict. "
|
|
f"Available: {[n['fn'] for n in nodes]}"
|
|
)
|
|
|
|
ports = node[ports_key]
|
|
if not ports:
|
|
raise ValueError(f"edges: node '{fn_name}' has no {ports_key}")
|
|
|
|
if port_hint is None:
|
|
port = ports[0]
|
|
return node["id"], port["id"], port.get("type", "text")
|
|
|
|
port = next(
|
|
(p for p in ports if p["label"] == port_hint or p["id"] == port_hint),
|
|
None,
|
|
)
|
|
if port is None:
|
|
raise ValueError(
|
|
f"edges: no {ports_key[:-1]} port '{port_hint}' on '{fn_name}'. "
|
|
f"Available: {[p['label'] for p in ports]}"
|
|
)
|
|
return node["id"], port["id"], port.get("type", "text")
|
|
|
|
result = []
|
|
for i, (from_spec, to_spec) in enumerate(edges_spec):
|
|
from_node_id, from_port_id, edge_type = resolve(from_spec, "outputs")
|
|
to_node_id, to_port_id, _ = resolve(to_spec, "inputs")
|
|
result.append(
|
|
{
|
|
"id": f"edge_{i}",
|
|
"from_node_id": from_node_id,
|
|
"from_port_id": from_port_id,
|
|
"to_node_id": to_node_id,
|
|
"to_port_id": to_port_id,
|
|
"type": edge_type,
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def _workflow_from_bind(
|
|
bound: dict[str, Callable],
|
|
edges: list[tuple[str, str]] | None = None,
|
|
name: str = "My Workflow",
|
|
) -> str:
|
|
nodes = []
|
|
for i, (fn_name, fn) in enumerate(bound.items()):
|
|
try:
|
|
sig = inspect.signature(fn)
|
|
except (ValueError, TypeError):
|
|
sig = inspect.Signature()
|
|
|
|
try:
|
|
_hints = get_type_hints(fn)
|
|
except Exception:
|
|
_hints = getattr(fn, "__annotations__", {})
|
|
inputs = [
|
|
{
|
|
"id": f"in_{p}",
|
|
"label": p,
|
|
"type": _PY_TO_PORT.get(param.annotation, "text"),
|
|
}
|
|
for p, param in sig.parameters.items()
|
|
if p != "self" and not _is_injected_param(_hints.get(p))
|
|
]
|
|
outputs = [
|
|
{
|
|
"id": "out_0",
|
|
"label": "output",
|
|
"type": _PY_TO_PORT.get(sig.return_annotation, "text"),
|
|
}
|
|
]
|
|
|
|
if not inputs:
|
|
inputs = [{"id": "in_0", "label": "input", "type": "text"}]
|
|
|
|
nodes.append(
|
|
{
|
|
"id": f"fn_{fn_name}",
|
|
"source": "fn",
|
|
"fn": fn_name,
|
|
"kind": "transform",
|
|
"label": fn_name,
|
|
"x": 80 + i * 280,
|
|
"y": 150,
|
|
"width": 220,
|
|
"height": 80 + max(len(inputs), len(outputs)) * 36,
|
|
"inputs": inputs,
|
|
"outputs": outputs,
|
|
"data": {},
|
|
}
|
|
)
|
|
|
|
edge_dicts = _build_edges(edges or [], nodes)
|
|
return json.dumps(
|
|
{"version": "1", "name": name, "nodes": nodes, "edges": edge_dicts}
|
|
)
|
|
|
|
|
|
def _get_locally_saved_hf_token() -> str | None:
|
|
"""Return the local Hugging Face token when running outside Spaces.
|
|
|
|
Avoid reading a Space's token/secret here: `get_token` is exposed to the
|
|
browser so the workflow canvas can authenticate local apps with the user's
|
|
`huggingface_hub login` token.
|
|
"""
|
|
if get_space() is not None:
|
|
return None
|
|
return hf_get_token()
|
|
|
|
|
|
# Per-process secret granting write access to local Workflow apps, in the same
|
|
# spirit as Jupyter notebook tokens. The full URL (printed at launch) carries it
|
|
# as a query parameter; the frontend then persists it as a cookie. Share-link
|
|
# visitors and tunnelled requests never see it, so they get read-only access
|
|
# and no access to the host's local HF token.
|
|
WRITE_TOKEN = secrets.token_urlsafe(32)
|
|
|
|
_WRITE_TOKEN_COOKIE_PREFIX = "gradio_workflow_write_token"
|
|
_WRITE_TOKEN_HEADER = "x-gradio-workflow-write-token"
|
|
|
|
|
|
def _request_has_write_token(request: Request | None) -> bool:
|
|
"""True when the request carries the per-process write token, checked in
|
|
header → cookie → query-param order (mirrors trackio's scheme). The cookie
|
|
name is prefix-matched because the frontend suffixes it with the port —
|
|
cookies are shared across ports on the same host, so two local apps would
|
|
otherwise clobber each other."""
|
|
if request is None:
|
|
return False
|
|
try:
|
|
headers = {k.lower(): v for k, v in dict(request.headers or {}).items()}
|
|
except Exception:
|
|
return False
|
|
header_value = headers.get(_WRITE_TOKEN_HEADER)
|
|
if header_value:
|
|
return secrets.compare_digest(header_value, WRITE_TOKEN)
|
|
for cookie in headers.get("cookie", "").split(";"):
|
|
name, _, value = cookie.strip().partition("=")
|
|
if (
|
|
name.startswith(_WRITE_TOKEN_COOKIE_PREFIX)
|
|
and value
|
|
and secrets.compare_digest(urllib.parse.unquote(value), WRITE_TOKEN)
|
|
):
|
|
return True
|
|
try:
|
|
query_value = request.query_params.get("write_token")
|
|
except Exception:
|
|
query_value = None
|
|
if query_value:
|
|
return secrets.compare_digest(query_value, WRITE_TOKEN)
|
|
return False
|
|
|
|
|
|
# Shared instance: whoami(cache=True) caches per token on the HfApi instance,
|
|
# which matters because whoami-v2 is heavily rate-limited.
|
|
_hf_api = HfApi()
|
|
|
|
|
|
def _oauth_token_has_space_write_access(oauth_token: str | None) -> bool:
|
|
"""On Spaces, write access belongs to the Space owner: the OAuth user must
|
|
be the owning user, or an admin/write member of the owning org."""
|
|
space_id = get_space()
|
|
if not space_id or not oauth_token:
|
|
return False
|
|
try:
|
|
who = _hf_api.whoami(token=oauth_token, cache=True)
|
|
except Exception:
|
|
return False
|
|
owner = os.getenv("SPACE_AUTHOR_NAME") or space_id.split("/")[0]
|
|
if who.get("name") == owner:
|
|
return True
|
|
return any(
|
|
org.get("name") == owner and org.get("roleInOrg") in ("admin", "write")
|
|
for org in who.get("orgs", [])
|
|
)
|
|
|
|
|
|
def has_write_access(
|
|
request: Request | None = None, token: OAuthToken | None = None
|
|
) -> bool:
|
|
"""Whether this request may modify the workflow (and, locally, use the
|
|
host's saved HF token). Locally: requires the launch-time write token.
|
|
On Spaces: requires the OAuth user to own the Space (or have org write)."""
|
|
if get_space() is not None:
|
|
return _oauth_token_has_space_write_access(token.token if token else None)
|
|
return _request_has_write_token(request)
|
|
|
|
|
|
def _resolve_token(
|
|
data: list, idx: int, token, request: Request | None = None
|
|
) -> str | None:
|
|
manual = data[idx] if len(data) > idx else None
|
|
if manual:
|
|
return manual
|
|
if token:
|
|
return token.token
|
|
if _request_has_write_token(request):
|
|
return _get_locally_saved_hf_token()
|
|
return None
|
|
|
|
|
|
def _hf_request(url: str, hf_token: str | None, timeout: int = 15) -> str:
|
|
headers = {"Authorization": f"Bearer {hf_token}"} if hf_token else {}
|
|
resp = _HF_CLIENT.get(url, headers=headers, timeout=timeout)
|
|
resp.raise_for_status()
|
|
return resp.text
|
|
|
|
|
|
def _save_tmp(result, ext: str) -> dict:
|
|
path = os.path.join(
|
|
tempfile.gettempdir(), f"hf_workflow_{os.urandom(8).hex()}.{ext}"
|
|
)
|
|
if hasattr(result, "save"):
|
|
result.save(path)
|
|
else:
|
|
with open(path, "wb") as f:
|
|
f.write(result)
|
|
return {"path": path, "url": f"/gradio_api/file={path}", "is_file": True}
|
|
|
|
|
|
def _img_url(a) -> str:
|
|
return a.get("url") or a.get("path", "") if isinstance(a, dict) else a
|
|
|
|
|
|
def _classify_error(e: Exception) -> dict:
|
|
http_status: int | None = None
|
|
response = getattr(e, "response", None)
|
|
if response is not None:
|
|
http_status = getattr(response, "status_code", None)
|
|
if http_status is None:
|
|
http_status = getattr(e, "status_code", None)
|
|
|
|
if http_status in (401, 403):
|
|
return {
|
|
"error_type": "auth",
|
|
"suggestion": "Sign in with your HF account to use this model",
|
|
}
|
|
if http_status == 404:
|
|
return {
|
|
"error_type": "not_found",
|
|
"suggestion": "Space not found — it may have been deleted or renamed",
|
|
}
|
|
if http_status == 429:
|
|
return {"error_type": "quota"}
|
|
|
|
type_name = type(e).__name__
|
|
if type_name in (
|
|
"RepositoryNotFoundError",
|
|
"EntryNotFoundError",
|
|
"RevisionNotFoundError",
|
|
):
|
|
return {
|
|
"error_type": "not_found",
|
|
"suggestion": "Space not found — it may have been deleted or renamed",
|
|
}
|
|
if type_name == "GatedRepoError":
|
|
return {
|
|
"error_type": "auth",
|
|
"suggestion": "Sign in with your HF account to use this model",
|
|
}
|
|
|
|
title = getattr(e, "title", None) or ""
|
|
message = getattr(e, "message", None) or str(e)
|
|
full = f"{title} {message}".lower()
|
|
|
|
if "zerogpu" in full or ("gpu" in full and "worker" in full):
|
|
return {"error_type": "gpu"}
|
|
if "quota" in full or "rate limit" in full or "rate_limit" in full:
|
|
return {"error_type": "quota"}
|
|
if "sleeping" in full or "paused" in full:
|
|
return {
|
|
"error_type": "sleeping",
|
|
"suggestion": "Space is sleeping or paused — try again in a minute",
|
|
}
|
|
if (
|
|
"unauthorized" in full
|
|
or "authentication" in full
|
|
or "log in" in full
|
|
or "api_key" in full
|
|
or "api key" in full
|
|
):
|
|
return {
|
|
"error_type": "auth",
|
|
"suggestion": "Sign in with your HF account to use this model",
|
|
}
|
|
if "not found" in full or "repository not found" in full:
|
|
return {
|
|
"error_type": "not_found",
|
|
"suggestion": "Space not found — it may have been deleted or renamed",
|
|
}
|
|
if "build_error" in full or "build error" in full:
|
|
return {
|
|
"error_type": "build_error",
|
|
"suggestion": "Space has a build error — contact the Space owner",
|
|
}
|
|
if "timed out" in full or "timeout" in full or "connection" in full:
|
|
return {
|
|
"error_type": "connection",
|
|
"suggestion": "Could not connect to the Space — it may be down",
|
|
}
|
|
return {"error_type": "unknown", "suggestion": ""}
|
|
|
|
|
|
def _format_error(e: Exception) -> str:
|
|
title = getattr(e, "title", None)
|
|
message = getattr(e, "message", None) or str(e)
|
|
classified = _classify_error(e)
|
|
if classified.get("error_type") == "auth":
|
|
message = "Authentication required"
|
|
title = None
|
|
err = {"error": message, **classified}
|
|
if title:
|
|
err["title"] = title
|
|
return json.dumps(err)
|
|
|
|
|
|
def get_token(
|
|
_data=None,
|
|
request: Optional[Request] = None,
|
|
token: Optional[OAuthToken] = None,
|
|
) -> str:
|
|
"""Return the HF token for this browser session. The host's locally saved
|
|
token is only revealed to sessions holding the write token — share-link
|
|
visitors and other remote clients get "" (logged-out experience)."""
|
|
if token:
|
|
return token.token
|
|
if _request_has_write_token(request):
|
|
return _get_locally_saved_hf_token() or ""
|
|
return ""
|
|
|
|
|
|
def get_write_access(
|
|
_data=None,
|
|
request: Optional[Request] = None,
|
|
token: Optional[OAuthToken] = None,
|
|
) -> str:
|
|
return "true" if has_write_access(request, token) else "false"
|
|
|
|
|
|
def get_oauth_available(_data=None) -> str:
|
|
"""Whether OAuth sign-in is actually wired up. On a Space this requires
|
|
`hf_oauth: true` in the README metadata, which provisions OAUTH_CLIENT_ID
|
|
and causes the `/login/huggingface` route to be mounted (mirrors the gate
|
|
that adds the LoginButton in `__init__`). Without it, sign-in would 404, so
|
|
the frontend hides the login button and explains the fix on the read-only
|
|
badge. OAuth is not used locally (the write-token model is used instead)."""
|
|
return (
|
|
"true"
|
|
if get_space() is not None and bool(os.getenv("OAUTH_CLIENT_ID"))
|
|
else "false"
|
|
)
|
|
|
|
|
|
def call_space(
|
|
data, request: Optional[Request] = None, token: Optional[OAuthToken] = None
|
|
) -> str:
|
|
space_id = data[0] if data else ""
|
|
try:
|
|
from gradio_client import Client, handle_file
|
|
|
|
endpoint = data[1] if len(data) > 1 else None
|
|
args_json = data[2] if len(data) > 2 else "[]"
|
|
hf_token = _resolve_token(data, 3, token, request)
|
|
if not re.fullmatch(r"[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+", space_id or ""):
|
|
return json.dumps(
|
|
{
|
|
"error": "Invalid Space ID",
|
|
"error_type": "not_found",
|
|
"suggestion": "Space ID must be in owner/repo format",
|
|
}
|
|
)
|
|
client = Client(space_id, token=hf_token)
|
|
args = json.loads(args_json)
|
|
if not endpoint or endpoint == "/predict":
|
|
api_info = client.view_api(return_format="dict")
|
|
named = list(
|
|
(
|
|
api_info.get("named_endpoints", {})
|
|
if isinstance(api_info, dict)
|
|
else {}
|
|
).keys()
|
|
)
|
|
endpoint = (
|
|
endpoint if endpoint in named else (named[0] if named else "/predict")
|
|
)
|
|
processed = []
|
|
for arg in args:
|
|
if isinstance(arg, dict) and ("url" in arg or "path" in arg):
|
|
url = arg.get("url") or arg.get("path", "")
|
|
processed.append(handle_file(url) if url else None)
|
|
else:
|
|
processed.append(arg)
|
|
while processed and processed[-1] is None:
|
|
processed.pop()
|
|
result = client.predict(*processed, api_name=endpoint)
|
|
result = list(result) if isinstance(result, (list, tuple)) else [result]
|
|
|
|
_tmpdir = os.path.realpath(tempfile.gettempdir())
|
|
|
|
def process_item(item):
|
|
if isinstance(item, dict):
|
|
path = item.get("path") or item.get("value")
|
|
if (
|
|
isinstance(path, str)
|
|
and os.path.realpath(path).startswith(_tmpdir)
|
|
and os.path.exists(path)
|
|
):
|
|
return {
|
|
"path": path,
|
|
"url": f"/gradio_api/file={path}",
|
|
"is_file": True,
|
|
}
|
|
return item
|
|
if (
|
|
isinstance(item, str)
|
|
and os.path.realpath(item).startswith(_tmpdir)
|
|
and os.path.exists(item)
|
|
):
|
|
return {
|
|
"path": item,
|
|
"url": f"/gradio_api/file={item}",
|
|
"is_file": True,
|
|
}
|
|
if isinstance(item, (list, tuple)):
|
|
return [process_item(s) for s in item]
|
|
return item
|
|
|
|
return json.dumps([process_item(i) for i in result])
|
|
except Exception as e:
|
|
logger.error("call_space failed for %s: %s", space_id, e, exc_info=True)
|
|
return _format_error(e)
|
|
|
|
|
|
def call_model(
|
|
data, request: Optional[Request] = None, token: Optional[OAuthToken] = None
|
|
) -> str:
|
|
model_id = data[0] if data else ""
|
|
task = ""
|
|
try:
|
|
from huggingface_hub import InferenceClient
|
|
|
|
if not re.fullmatch(r"[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+", model_id or ""):
|
|
return json.dumps(
|
|
{
|
|
"error": "Invalid model ID",
|
|
"error_type": "not_found",
|
|
"suggestion": "Model ID must be in owner/repo format",
|
|
}
|
|
)
|
|
pipeline_tag = data[1] if len(data) > 1 else None
|
|
args_json = data[2] if len(data) > 2 else "[]"
|
|
hf_token = _resolve_token(data, 3, token, request)
|
|
# "auto" lets HF route to whichever provider serves the model; pinning
|
|
# "hf-inference" 404s for models not hosted there.
|
|
provider = data[4] if len(data) > 4 and data[4] else "auto"
|
|
client = InferenceClient(model=model_id, token=hf_token, provider=provider)
|
|
args = json.loads(args_json)
|
|
task = pipeline_tag or "text-generation"
|
|
a0 = args[0] if args else ""
|
|
a1 = args[1] if len(args) > 1 else ""
|
|
|
|
if task in (
|
|
"text-generation",
|
|
"text2text-generation",
|
|
"conversational",
|
|
):
|
|
try:
|
|
result = client.text_generation(a0, max_new_tokens=512)
|
|
except Exception as inner:
|
|
msg = str(inner).lower()
|
|
if "not supported" in msg and "conversational" in msg:
|
|
r = client.chat_completion(
|
|
[{"role": "user", "content": a0}], max_tokens=512
|
|
)
|
|
result = r.choices[0].message.content
|
|
else:
|
|
raise
|
|
return json.dumps([result])
|
|
if task == "summarization":
|
|
return json.dumps([client.summarization(a0).summary_text])
|
|
if task == "translation":
|
|
return json.dumps([client.translation(a0).translation_text])
|
|
if task in ("text-classification", "zero-shot-classification"):
|
|
return json.dumps(
|
|
[
|
|
[
|
|
{"label": r.label, "score": r.score}
|
|
for r in client.text_classification(a0)
|
|
]
|
|
]
|
|
)
|
|
if task == "token-classification":
|
|
return json.dumps(
|
|
[
|
|
[
|
|
{
|
|
"entity_group": r.entity_group,
|
|
"word": r.word,
|
|
"score": r.score,
|
|
}
|
|
for r in client.token_classification(a0)
|
|
]
|
|
]
|
|
)
|
|
if task == "fill-mask":
|
|
return json.dumps(
|
|
[
|
|
[
|
|
{
|
|
"token_str": r.token_str,
|
|
"score": r.score,
|
|
"sequence": r.sequence,
|
|
}
|
|
for r in client.fill_mask(a0)
|
|
]
|
|
]
|
|
)
|
|
if task == "question-answering":
|
|
qa_result = client.question_answering(question=a0, context=a1)
|
|
qa_answer = (
|
|
qa_result[0].answer if isinstance(qa_result, list) else qa_result.answer
|
|
) # type: ignore[union-attr]
|
|
return json.dumps([qa_answer])
|
|
if task == "feature-extraction":
|
|
r = client.feature_extraction(a0)
|
|
return json.dumps([r.tolist() if hasattr(r, "tolist") else r])
|
|
if task == "sentence-similarity":
|
|
return json.dumps(
|
|
[client.sentence_similarity(a0, a1.split("\n") if a1 else [])]
|
|
)
|
|
if task == "text-to-image":
|
|
return json.dumps([_save_tmp(client.text_to_image(a0), "png")])
|
|
if task in ("text-to-speech", "text-to-audio"):
|
|
return json.dumps([_save_tmp(client.text_to_speech(a0), "wav")])
|
|
if task == "text-to-video":
|
|
return json.dumps([_save_tmp(client.text_to_video(a0), "mp4")])
|
|
if task == "image-classification":
|
|
return json.dumps(
|
|
[
|
|
[
|
|
{"label": r.label, "score": r.score}
|
|
for r in client.image_classification(_img_url(a0))
|
|
]
|
|
]
|
|
)
|
|
if task == "object-detection":
|
|
return json.dumps(
|
|
[
|
|
[
|
|
{"label": r.label, "score": r.score, "box": r.box}
|
|
for r in client.object_detection(_img_url(a0))
|
|
]
|
|
]
|
|
)
|
|
if task == "image-segmentation":
|
|
return json.dumps(
|
|
[
|
|
[
|
|
{"label": r.label, "score": r.score}
|
|
for r in client.image_segmentation(_img_url(a0))
|
|
]
|
|
]
|
|
)
|
|
if task == "image-to-text":
|
|
r = client.image_to_text(_img_url(a0))
|
|
return json.dumps(
|
|
[r.generated_text if hasattr(r, "generated_text") else str(r)]
|
|
)
|
|
if task == "image-to-image":
|
|
return json.dumps(
|
|
[_save_tmp(client.image_to_image(_img_url(a0), prompt=a1), "png")]
|
|
)
|
|
if task == "automatic-speech-recognition":
|
|
r = client.automatic_speech_recognition(_img_url(a0))
|
|
return json.dumps([r.text if hasattr(r, "text") else str(r)])
|
|
if task == "audio-classification":
|
|
return json.dumps(
|
|
[
|
|
[
|
|
{"label": r.label, "score": r.score}
|
|
for r in client.audio_classification(_img_url(a0))
|
|
]
|
|
]
|
|
)
|
|
if task in (
|
|
"visual-question-answering",
|
|
"document-question-answering",
|
|
"image-text-to-text",
|
|
):
|
|
r = client.visual_question_answering(_img_url(a0), a1)
|
|
return json.dumps([r[0].answer if r else ""])
|
|
if task == "depth-estimation":
|
|
headers = {"Authorization": f"Bearer {hf_token}"} if hf_token else {}
|
|
resp = httpx.post(
|
|
f"https://api-inference.huggingface.co/models/{model_id}",
|
|
headers=headers,
|
|
json={"inputs": _img_url(a0)},
|
|
timeout=60,
|
|
)
|
|
resp.raise_for_status()
|
|
import io as _io
|
|
|
|
from PIL import Image as _Image
|
|
|
|
depth_img = _Image.open(_io.BytesIO(resp.content))
|
|
return json.dumps([_save_tmp(depth_img, "png")])
|
|
|
|
# Fallback for tasks not handled above: chat_completion (works for most
|
|
# text models across providers), then a raw POST as last resort.
|
|
try:
|
|
r = client.chat_completion(
|
|
[{"role": "user", "content": a0}], max_tokens=512
|
|
)
|
|
return json.dumps([r.choices[0].message.content])
|
|
except Exception:
|
|
pass
|
|
headers = {"Authorization": f"Bearer {hf_token}"} if hf_token else {}
|
|
fallback_resp = httpx.post(
|
|
f"https://api-inference.huggingface.co/models/{model_id}",
|
|
headers=headers,
|
|
json={"inputs": a0 if not a1 else [a0, a1]},
|
|
timeout=60,
|
|
)
|
|
fallback_resp.raise_for_status()
|
|
try:
|
|
parsed = fallback_resp.json()
|
|
except Exception:
|
|
parsed = fallback_resp.text
|
|
return json.dumps([parsed])
|
|
except Exception as e:
|
|
logger.error(
|
|
"call_model failed for %s (task=%s): %s",
|
|
model_id,
|
|
task,
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
return _format_error(e)
|
|
|
|
|
|
def fetch_dataset(
|
|
data, request: Optional[Request] = None, token: Optional[OAuthToken] = None
|
|
) -> str:
|
|
try:
|
|
dataset_id = data[0]
|
|
config = data[1] if len(data) > 1 and data[1] else "default"
|
|
split = data[2] if len(data) > 2 and data[2] else "train"
|
|
offset = int(data[3]) if len(data) > 3 and data[3] else 0
|
|
length = int(data[4]) if len(data) > 4 and data[4] else 10
|
|
hf_token = _resolve_token(data, 5, token, request)
|
|
params = urllib.parse.urlencode(
|
|
{
|
|
"dataset": dataset_id,
|
|
"config": config,
|
|
"split": split,
|
|
"offset": offset,
|
|
"length": min(length, 100),
|
|
}
|
|
)
|
|
result = json.loads(
|
|
_hf_request(
|
|
f"https://datasets-server.huggingface.co/rows?{params}",
|
|
hf_token,
|
|
timeout=30,
|
|
)
|
|
)
|
|
return json.dumps(
|
|
{
|
|
"features": result.get("features", []),
|
|
"rows": [r.get("row", {}) for r in result.get("rows", [])],
|
|
"num_rows_total": result.get("num_rows_total", 0),
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
"fetch_dataset failed for %s: %s",
|
|
data[0] if data else "",
|
|
e,
|
|
exc_info=True,
|
|
)
|
|
return json.dumps({"error": str(e), "error_type": "unknown", "suggestion": ""})
|
|
|
|
|
|
def _curated_entry_to_space_result(entry: dict) -> dict:
|
|
return {
|
|
"id": entry.get("id"),
|
|
"likes": 0,
|
|
"trendingScore": (entry.get("validation") or {}).get("latency_ms") or 0,
|
|
"runtime": {"stage": "RUNNING"},
|
|
"pipeline_tag": entry.get("task"),
|
|
"tags": ["zero-gpu"] if entry.get("zero_gpu") else [],
|
|
"cardData": {
|
|
"title": entry.get("title"),
|
|
"short_description": entry.get("description"),
|
|
"tags": ["zero-gpu"] if entry.get("zero_gpu") else [],
|
|
"sdk": "gradio",
|
|
},
|
|
"_curated": True,
|
|
"_featured": bool(entry.get("featured")),
|
|
"_thumbnail": entry.get("thumbnail"),
|
|
}
|
|
|
|
|
|
def _curated_entry_to_model_result(entry: dict) -> dict:
|
|
return {
|
|
"id": entry.get("id"),
|
|
"likes": 0,
|
|
"downloads": 0,
|
|
"pipeline_tag": entry.get("task"),
|
|
"_curated": True,
|
|
"_featured": bool(entry.get("featured")),
|
|
"_thumbnail": entry.get("thumbnail"),
|
|
}
|
|
|
|
|
|
def _filter_curated(
|
|
items: list[dict],
|
|
kind_filter: str,
|
|
task: str,
|
|
query: str,
|
|
modality: str = "",
|
|
) -> list[dict]:
|
|
out: list[dict] = []
|
|
q = query.lower().strip()
|
|
for entry in items:
|
|
if entry.get("kind") != kind_filter:
|
|
continue
|
|
v = entry.get("validation") or {}
|
|
status = v.get("status", "ok")
|
|
if status and status != "ok":
|
|
continue
|
|
if task:
|
|
if entry.get("task") != task and entry.get("space_category") != task:
|
|
continue
|
|
elif modality and entry.get("modality") != modality:
|
|
continue
|
|
if q:
|
|
haystack = " ".join(
|
|
[
|
|
str(entry.get("title") or ""),
|
|
str(entry.get("description") or ""),
|
|
str(entry.get("id") or ""),
|
|
]
|
|
).lower()
|
|
if q not in haystack:
|
|
continue
|
|
out.append(entry)
|
|
return out
|
|
|
|
|
|
def search_spaces(
|
|
data, _request: Optional[Request] = None, _token: Optional[OAuthToken] = None
|
|
) -> str:
|
|
kind = data[0] if data else "trending"
|
|
try:
|
|
query = data[1] if len(data) > 1 and data[1] else ""
|
|
space_tag = data[2] if len(data) > 2 and data[2] else ""
|
|
modality = data[3] if len(data) > 3 and isinstance(data[3], str) else ""
|
|
zero_gpu_only = bool(data[4]) if len(data) > 4 else False
|
|
|
|
items = _filter_curated(_load_curated(), "space", space_tag, query, modality)
|
|
if zero_gpu_only:
|
|
items = [e for e in items if e.get("zero_gpu")]
|
|
|
|
if kind == "new":
|
|
items.sort(key=lambda e: e.get("added_at") or "", reverse=True)
|
|
else:
|
|
|
|
def _rank(e: dict) -> tuple:
|
|
v = e.get("validation") or {}
|
|
return (
|
|
0 if e.get("zero_gpu") else 1,
|
|
0 if e.get("featured") else 1,
|
|
v.get("latency_ms") or 999_999,
|
|
e.get("added_at") or "",
|
|
)
|
|
|
|
items.sort(key=_rank)
|
|
|
|
normalized = [_curated_entry_to_space_result(e) for e in items[:48]]
|
|
return json.dumps(normalized)
|
|
except Exception as e:
|
|
logger.error("search_spaces failed (kind=%s): %s", kind, e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
def search_models(
|
|
data, _request: Optional[Request] = None, _token: Optional[OAuthToken] = None
|
|
) -> str:
|
|
kind = data[0] if data else "trending"
|
|
try:
|
|
query = data[1] if len(data) > 1 and data[1] else ""
|
|
pipeline_tag = data[2] if len(data) > 2 and data[2] else ""
|
|
modality = data[3] if len(data) > 3 and isinstance(data[3], str) else ""
|
|
|
|
items = _filter_curated(_load_curated(), "model", pipeline_tag, query, modality)
|
|
|
|
if kind == "new":
|
|
items.sort(key=lambda e: e.get("added_at") or "", reverse=True)
|
|
else:
|
|
items.sort(
|
|
key=lambda e: (0 if e.get("featured") else 1, e.get("added_at") or "")
|
|
)
|
|
|
|
normalized = [_curated_entry_to_model_result(e) for e in items[:48]]
|
|
return json.dumps(normalized)
|
|
except Exception as e:
|
|
logger.error("search_models failed (kind=%s): %s", kind, e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
def curated_modalities(_data=None, _token: Optional[OAuthToken] = None) -> str:
|
|
try:
|
|
items = _load_curated()
|
|
mods: set[str] = set()
|
|
for entry in items:
|
|
v = entry.get("validation") or {}
|
|
if v.get("status", "ok") != "ok":
|
|
continue
|
|
m = entry.get("modality")
|
|
if m:
|
|
mods.add(m)
|
|
return json.dumps(sorted(mods))
|
|
except Exception as e:
|
|
logger.error("curated_modalities failed: %s", e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
def curated_modality_tasks(data, _token: Optional[OAuthToken] = None) -> str:
|
|
try:
|
|
modality = (data[0] if data else "") or ""
|
|
items = _load_curated()
|
|
tasks: set[str] = set()
|
|
for entry in items:
|
|
if modality and entry.get("modality") != modality:
|
|
continue
|
|
v = entry.get("validation") or {}
|
|
if v.get("status", "ok") != "ok":
|
|
continue
|
|
t = entry.get("task")
|
|
sc = entry.get("space_category")
|
|
if t:
|
|
tasks.add(t)
|
|
if sc:
|
|
tasks.add(sc)
|
|
return json.dumps(sorted(tasks))
|
|
except Exception as e:
|
|
logger.error("curated_modality_tasks failed: %s", e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
def is_curated(data, _token: Optional[OAuthToken] = None) -> str:
|
|
try:
|
|
repo_id = (data[0] if data else "") or ""
|
|
kind = (data[1] if len(data) > 1 else "") or ""
|
|
items = _load_curated()
|
|
for entry in items:
|
|
if entry.get("id") != repo_id:
|
|
continue
|
|
if kind and entry.get("kind") != kind:
|
|
continue
|
|
v = entry.get("validation") or {}
|
|
if v.get("status", "ok") != "ok":
|
|
return json.dumps({"curated": False})
|
|
return json.dumps(
|
|
{
|
|
"curated": True,
|
|
"status": "ok",
|
|
"featured": bool(entry.get("featured")),
|
|
}
|
|
)
|
|
return json.dumps({"curated": False})
|
|
except Exception as e:
|
|
logger.error("is_curated failed: %s", e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
_QUICKSEARCH_CACHE: dict[str, tuple[float, str]] = {}
|
|
_QUICKSEARCH_TTL = 30.0
|
|
|
|
|
|
def search_quick(
|
|
data,
|
|
request: Request | None = None,
|
|
token: Optional[OAuthToken] = None,
|
|
) -> str:
|
|
import time
|
|
|
|
query = data[0] if data and data[0] else ""
|
|
if not query or len(query) < 2:
|
|
return json.dumps({"spaces": [], "models": []})
|
|
try:
|
|
hf_token = _resolve_token(data, 1, token, request)
|
|
|
|
cache_key = query.lower().strip()
|
|
now = time.monotonic()
|
|
cached = _QUICKSEARCH_CACHE.get(cache_key)
|
|
if cached and cached[0] > now:
|
|
return cached[1]
|
|
|
|
def _one(repo_type: str) -> list:
|
|
url = (
|
|
"https://huggingface.co/api/quicksearch?"
|
|
f"q={urllib.parse.quote(query)}&type={repo_type}&limit=8"
|
|
)
|
|
try:
|
|
raw = _hf_request(url, hf_token)
|
|
parsed = json.loads(raw)
|
|
if isinstance(parsed, dict):
|
|
return parsed.get(f"{repo_type}s") or parsed.get(repo_type) or []
|
|
if isinstance(parsed, list):
|
|
return parsed
|
|
return []
|
|
except Exception:
|
|
return []
|
|
|
|
space_future = _SEARCH_POOL.submit(_one, "space")
|
|
model_future = _SEARCH_POOL.submit(_one, "model")
|
|
spaces = space_future.result()
|
|
models = model_future.result()
|
|
|
|
payload = json.dumps({"spaces": spaces, "models": models})
|
|
_QUICKSEARCH_CACHE[cache_key] = (now + _QUICKSEARCH_TTL, payload)
|
|
if len(_QUICKSEARCH_CACHE) > 256:
|
|
for k in list(_QUICKSEARCH_CACHE.keys())[:64]:
|
|
_QUICKSEARCH_CACHE.pop(k, None)
|
|
return payload
|
|
except Exception as e:
|
|
logger.error("search_quick failed (q=%s): %s", query, e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
_REPO_ID_RE = re.compile(r"^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$")
|
|
|
|
|
|
def _parse_repo_input(raw: str) -> tuple[str, Optional[str]]:
|
|
s = (raw or "").strip().rstrip("/")
|
|
if not s:
|
|
return "", None
|
|
if _REPO_ID_RE.match(s):
|
|
return s, None
|
|
s = re.sub(r"^https?://(?:www\.)?", "", s)
|
|
s = re.sub(r"^hf\.co/", "huggingface.co/", s)
|
|
m = re.match(
|
|
r"^huggingface\.co/(spaces|datasets|models)?/?([^/\s?#]+)/([^/\s?#]+)",
|
|
s,
|
|
)
|
|
if m:
|
|
section, org, name = m.group(1), m.group(2), m.group(3)
|
|
if section == "spaces":
|
|
return f"{org}/{name}", "space"
|
|
if section == "datasets":
|
|
return f"{org}/{name}", "dataset"
|
|
return f"{org}/{name}", "model" if section else None
|
|
return "", None
|
|
|
|
|
|
def resolve_repo(
|
|
data,
|
|
request: Request | None = None,
|
|
token: Optional[OAuthToken] = None,
|
|
) -> str:
|
|
raw = data[0] if data else ""
|
|
try:
|
|
hf_token = _resolve_token(data, 1, token, request)
|
|
repo_id, kind_hint = _parse_repo_input(raw)
|
|
|
|
def _try(rid: str, repo_type: str) -> Optional[dict]:
|
|
try:
|
|
url = (
|
|
f"https://huggingface.co/api/{repo_type}s/"
|
|
f"{urllib.parse.quote(rid, safe='/')}"
|
|
)
|
|
return json.loads(_hf_request(url, hf_token))
|
|
except Exception:
|
|
return None
|
|
|
|
if not repo_id:
|
|
sub = re.match(
|
|
r"^(?:https?://)?([^/]+)\.hf\.space", raw.strip(), re.IGNORECASE
|
|
)
|
|
if sub:
|
|
parts = sub.group(1).split("-")
|
|
for i in range(1, len(parts)):
|
|
candidate = f"{'-'.join(parts[:i])}/{'-'.join(parts[i:])}"
|
|
rec = _try(candidate, "space")
|
|
if rec and rec.get("id"):
|
|
return json.dumps(
|
|
{"kind": "space", "id": rec["id"], "record": rec}
|
|
)
|
|
return json.dumps({"error": "not_a_repo"})
|
|
|
|
repo_types: list[str] = (
|
|
[kind_hint]
|
|
if kind_hint in ("space", "model", "dataset")
|
|
else ["space", "model"]
|
|
)
|
|
for repo_type in repo_types:
|
|
rec = _try(repo_id, repo_type)
|
|
if rec and rec.get("id"):
|
|
return json.dumps({"kind": repo_type, "id": rec["id"], "record": rec})
|
|
return json.dumps({"error": "not_found", "id": repo_id})
|
|
except Exception as e:
|
|
logger.error("resolve_repo failed (raw=%s): %s", raw, e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
def search_datasets(
|
|
data, request: Optional[Request] = None, token: Optional[OAuthToken] = None
|
|
) -> str:
|
|
query = data[0] if data else ""
|
|
try:
|
|
hf_token = _resolve_token(data, 1, token, request)
|
|
search_param = f"search={urllib.parse.quote(query)}&" if query else ""
|
|
url = f"https://huggingface.co/api/datasets?{search_param}sort=likes&direction=-1&limit=20"
|
|
return _hf_request(url, hf_token)
|
|
except Exception as e:
|
|
logger.error("search_datasets failed (query=%s): %s", query, e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
def get_dataset_schema(
|
|
data, request: Optional[Request] = None, token: Optional[OAuthToken] = None
|
|
) -> str:
|
|
dataset_id = data[0] if data else ""
|
|
try:
|
|
hf_token = _resolve_token(data, 1, token, request)
|
|
try:
|
|
splits_data = json.loads(
|
|
_hf_request(
|
|
f"https://datasets-server.huggingface.co/splits?dataset={urllib.parse.quote(dataset_id)}",
|
|
hf_token,
|
|
timeout=30,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
raise Exception(
|
|
"Could not load dataset — it may not be viewer-compatible"
|
|
) from exc
|
|
splits = splits_data.get("splits", [])
|
|
if not splits:
|
|
raise Exception("No available splits found for this dataset")
|
|
picked = next((s for s in splits if s["split"] == "train"), splits[0])
|
|
try:
|
|
rows_data = json.loads(
|
|
_hf_request(
|
|
"https://datasets-server.huggingface.co/first-rows?"
|
|
+ urllib.parse.urlencode(
|
|
{
|
|
"dataset": dataset_id,
|
|
"config": picked["config"],
|
|
"split": picked["split"],
|
|
}
|
|
),
|
|
hf_token,
|
|
timeout=30,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
raise Exception(
|
|
"Could not load dataset — it may not be viewer-compatible"
|
|
) from exc
|
|
return json.dumps(
|
|
{
|
|
"config": picked["config"],
|
|
"split": picked["split"],
|
|
"features": rows_data.get("features", []),
|
|
}
|
|
)
|
|
except Exception as e:
|
|
logger.error(
|
|
"get_dataset_schema failed for %s: %s", dataset_id, e, exc_info=True
|
|
)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
|
|
class Workflow(Blocks):
|
|
"""
|
|
Build and launch a visual AI workflow as a Gradio app.
|
|
|
|
Reads a workflow topology from a JSON file and optionally binds Python functions
|
|
that can be used as nodes in the workflow.
|
|
|
|
Example:
|
|
```python
|
|
from gradio import Workflow
|
|
|
|
def summarize(text: str) -> str:
|
|
return text[:200]
|
|
|
|
Workflow(graph="workflow.json", bind={"summarize": summarize}).launch()
|
|
```
|
|
|
|
The graph file defines nodes and edges:
|
|
```json
|
|
{
|
|
"nodes": [
|
|
{"id": "sum", "kind": "transform", "source": "fn", "fn": "summarize", ...},
|
|
{"id": "img", "kind": "transform", "source": "space", "space_id": "black-forest-labs/FLUX.1-schnell", ...}
|
|
],
|
|
"edges": [
|
|
{"id": "e1", "from_node_id": "sum", "from_port_id": "out_0", "to_node_id": "img", "to_port_id": "in_0", "type": "text"}
|
|
]
|
|
}
|
|
```
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
graph: str | None = None,
|
|
*,
|
|
bind: dict[str, Callable] | list[Callable] | None = None,
|
|
edges: list[tuple[str, str]] | None = None,
|
|
):
|
|
"""
|
|
Parameters:
|
|
graph: Path to the workflow JSON file describing the canvas graph
|
|
(nodes + edges). Defaults to `workflow.json` in the same
|
|
directory as the calling script. The file is created on first
|
|
save if it doesn't exist.
|
|
bind: Functions callable from the canvas frontend via the `call_fn` server
|
|
function. Pass a list of callables (keys default to ``fn.__name__``) or
|
|
a dict mapping explicit names to callables.
|
|
edges: List of ``(from_endpoint, to_endpoint)`` tuples that wire nodes
|
|
together when generating a workflow from ``bind`` (ignored when an
|
|
existing ``graph`` file is loaded). Each endpoint is either
|
|
``"fn_name"`` (uses the first available port) or
|
|
``"fn_name.port_label"`` to target a specific port.
|
|
|
|
Example::
|
|
|
|
edges=[
|
|
("shout", "reverse"), # first output → first input
|
|
("clean.output", "tag.text"), # by port label
|
|
]
|
|
"""
|
|
if graph is None:
|
|
caller_filename = sys._getframe(1).f_code.co_filename
|
|
caller_dir = os.path.dirname(os.path.abspath(caller_filename))
|
|
graph = os.path.join(caller_dir, "workflow.json")
|
|
|
|
if isinstance(bind, list):
|
|
bind = {getattr(fn, "__name__", repr(fn)): fn for fn in bind}
|
|
|
|
self._workflow_file = graph
|
|
self._workflow_name = (
|
|
os.path.splitext(os.path.basename(graph))[0]
|
|
.replace("_", " ")
|
|
.replace("-", " ")
|
|
.title()
|
|
)
|
|
self._bound: dict[str, Callable] = bind or {}
|
|
self._edges: list[tuple[str, str]] = edges or []
|
|
|
|
warnings.warn(
|
|
"gr.Workflow is currently in beta. Its API and UX may change in future releases.",
|
|
UserWarning,
|
|
)
|
|
|
|
super().__init__(mode="workflow")
|
|
self._build()
|
|
|
|
def _build(self):
|
|
# Set once the API endpoints are registered (post UI build); save_workflow
|
|
# re-syncs it so /info + /call track edits to the graph.
|
|
self._api_endpoints: WorkflowEndpointManager | None = None
|
|
|
|
if self._edges and os.path.exists(self._workflow_file):
|
|
logger.warning(
|
|
"Workflow: edges= is ignored because '%s' already exists. "
|
|
"Delete the file to regenerate the workflow from bind/edges.",
|
|
self._workflow_file,
|
|
)
|
|
|
|
# Callable so each browser session re-reads `workflow.json`, picking up
|
|
# writes from `save_workflow` instead of the construction-time snapshot.
|
|
def _load_initial() -> str | None:
|
|
try:
|
|
with open(self._workflow_file, encoding="utf-8") as f:
|
|
return f.read()
|
|
except FileNotFoundError:
|
|
if self._bound:
|
|
return _workflow_from_bind(
|
|
self._bound, self._edges, name=self._workflow_name
|
|
)
|
|
return None
|
|
|
|
bound = self._bound
|
|
|
|
def call_fn(
|
|
data,
|
|
_request: Optional[Request] = None,
|
|
_token: Optional[OAuthToken] = None,
|
|
) -> str:
|
|
fn_name = data[0] if data else ""
|
|
try:
|
|
args_json = data[1] if len(data) > 1 else "[]"
|
|
fn = bound.get(fn_name)
|
|
if fn is None:
|
|
return json.dumps(
|
|
{
|
|
"error": f"No function '{fn_name}' bound to this workflow",
|
|
"error_type": "unknown",
|
|
"suggestion": "Check the bind= argument to Workflow()",
|
|
}
|
|
)
|
|
args = json.loads(args_json)
|
|
if not isinstance(args, list):
|
|
args = [args]
|
|
args, *_ = _special_args(fn, args, _request, None, token=_token)
|
|
result = fn(*args)
|
|
result = list(result) if isinstance(result, (list, tuple)) else [result]
|
|
return json.dumps(result)
|
|
except Exception as e:
|
|
logger.error("call_fn failed for %s: %s", fn_name, e, exc_info=True)
|
|
return json.dumps(
|
|
{"error": str(e), "error_type": "unknown", "suggestion": ""}
|
|
)
|
|
|
|
workflow_file = self._workflow_file
|
|
|
|
_max_workflow_bytes = 5 * 1024 * 1024
|
|
|
|
def list_bound_fns(_data=None, _token: Optional[OAuthToken] = None) -> str:
|
|
"""Return the bound functions' signatures so the canvas can offer
|
|
them as add-able nodes via a dedicated bottom-bar button. The
|
|
output port shape mirrors `_workflow_from_bind` so re-adding a
|
|
previously-deleted node produces an identical template.
|
|
"""
|
|
templates = []
|
|
for fn_name, fn in bound.items():
|
|
try:
|
|
sig = inspect.signature(fn)
|
|
except (ValueError, TypeError):
|
|
sig = inspect.Signature()
|
|
try:
|
|
_hints = get_type_hints(fn)
|
|
except Exception:
|
|
_hints = getattr(fn, "__annotations__", {})
|
|
inputs = [
|
|
{
|
|
"id": f"in_{p}",
|
|
"label": p,
|
|
"type": _PY_TO_PORT.get(param.annotation, "text"),
|
|
}
|
|
for p, param in sig.parameters.items()
|
|
if p != "self" and not _is_injected_param(_hints.get(p))
|
|
]
|
|
if not inputs:
|
|
inputs = [{"id": "in_0", "label": "input", "type": "text"}]
|
|
outputs = [
|
|
{
|
|
"id": "out_0",
|
|
"label": "output",
|
|
"type": _PY_TO_PORT.get(sig.return_annotation, "text"),
|
|
}
|
|
]
|
|
templates.append(
|
|
{
|
|
"fn": fn_name,
|
|
"label": fn_name,
|
|
"inputs": inputs,
|
|
"outputs": outputs,
|
|
}
|
|
)
|
|
return json.dumps(templates)
|
|
|
|
def get_workflow_api(_data=None, _request: Optional[Request] = None) -> str:
|
|
"""Describe the workflow's API endpoints (one per subgraph) for the
|
|
frontend "View API" panel. Re-reads the current graph so it tracks
|
|
live edits, same as the registered endpoints."""
|
|
from gradio.workflow_api import WorkflowGraph, describe_workflow_api
|
|
|
|
graph = WorkflowGraph.from_json(_load_initial())
|
|
endpoints = describe_workflow_api(graph) if graph is not None else []
|
|
return json.dumps({"endpoints": endpoints})
|
|
|
|
def save_workflow(
|
|
data,
|
|
request: Optional[Request] = None,
|
|
token: Optional[OAuthToken] = None,
|
|
) -> str:
|
|
if not has_write_access(request, token):
|
|
return json.dumps(
|
|
{
|
|
"error": "Write access required to save this workflow",
|
|
"error_type": "auth",
|
|
"suggestion": "Open the app via the write-access link "
|
|
"printed at launch (or sign in as the Space owner)",
|
|
}
|
|
)
|
|
try:
|
|
payload = data[0] if isinstance(data, list) and data else str(data)
|
|
if len(payload.encode()) > _max_workflow_bytes:
|
|
return json.dumps({"error": "Workflow payload exceeds 5 MB limit"})
|
|
try:
|
|
parsed = json.loads(payload)
|
|
except json.JSONDecodeError as exc:
|
|
return json.dumps({"error": f"Invalid workflow JSON: {exc}"})
|
|
if not isinstance(parsed, dict) or parsed.get("schema_version") != "2":
|
|
return json.dumps(
|
|
{"error": "Workflow payload must use schema_version 2"}
|
|
)
|
|
try:
|
|
from gradio.workflow_api import WorkflowGraph
|
|
|
|
WorkflowGraph(parsed)
|
|
except ValueError as exc:
|
|
return json.dumps({"error": f"Invalid workflow schema: {exc}"})
|
|
with open(workflow_file, "w", encoding="utf-8") as f:
|
|
f.write(payload)
|
|
# Re-derive API endpoints so /info + /call track the saved graph
|
|
# (outputs added / removed / renamed / retyped).
|
|
if self._api_endpoints is not None:
|
|
try:
|
|
self._api_endpoints.sync()
|
|
except Exception:
|
|
logger.error(
|
|
"Workflow: endpoint sync after save failed", exc_info=True
|
|
)
|
|
return "ok"
|
|
except Exception as e:
|
|
logger.error("save_workflow failed: %s", e, exc_info=True)
|
|
return json.dumps({"error": str(e)})
|
|
|
|
server_functions = [
|
|
get_token,
|
|
get_write_access,
|
|
get_oauth_available,
|
|
call_space,
|
|
call_model,
|
|
fetch_dataset,
|
|
search_spaces,
|
|
search_models,
|
|
search_datasets,
|
|
search_quick,
|
|
resolve_repo,
|
|
is_curated,
|
|
curated_modalities,
|
|
curated_modality_tasks,
|
|
get_dataset_schema,
|
|
call_fn,
|
|
list_bound_fns,
|
|
get_workflow_api,
|
|
save_workflow,
|
|
]
|
|
|
|
from gradio.workflow_api import WorkflowGraph, register_workflow_endpoints
|
|
|
|
# Operator-kind → server-function used to execute that node. The same
|
|
# functions back the canvas's client-side run; the API executor reuses
|
|
# them (with request/token threaded for token resolution).
|
|
callers = {
|
|
"space": call_space,
|
|
"model": call_model,
|
|
"fn": call_fn,
|
|
"dataset": fetch_dataset,
|
|
}
|
|
|
|
def _current_graph() -> WorkflowGraph | None:
|
|
return WorkflowGraph.from_json(_load_initial())
|
|
|
|
with self:
|
|
if get_space() is not None and os.getenv("OAUTH_CLIENT_ID"):
|
|
gr.LoginButton(visible=False)
|
|
WorkflowCanvas(
|
|
value=_load_initial,
|
|
server_functions=server_functions,
|
|
)
|
|
|
|
# Expose each subject (output) as a named API endpoint reusing /info +
|
|
# /call. The manager re-syncs on every save_workflow, so adding,
|
|
# removing, renaming, or retyping an output updates the live API.
|
|
self._api_endpoints = register_workflow_endpoints(self, _current_graph, callers)
|
|
|
|
def launch(self, *args, **kwargs): # type: ignore[override]
|
|
"""Launch the workflow as a Gradio app. Accepts the same arguments as `gr.Blocks.launch()`.
|
|
`call_space` / `_save_tmp` write inference outputs to the system tempdir
|
|
and serve them back as `/gradio_api/file=…` URLs; the tempdir is added
|
|
to `allowed_paths` so those URLs resolve.
|
|
|
|
Locally, editing requires the write token: the full edit link is printed
|
|
after the standard launch output (and used for `inbrowser`). Plain
|
|
local/share URLs open the app read-only."""
|
|
if args:
|
|
names = list(inspect.signature(super().launch).parameters)
|
|
kwargs.update(dict(zip(names, args)))
|
|
kwargs["allowed_paths"] = [
|
|
tempfile.gettempdir(),
|
|
*(kwargs.get("allowed_paths") or []),
|
|
]
|
|
# We need the edit link to print (and the browser to open to it) before
|
|
# the main thread is blocked, which means super().launch() must return
|
|
# first. Rather than forcing `debug=False` — which would also strip
|
|
# `debug` from `create_app()` (FastAPI error display) and the Colab
|
|
# error-printing messages — we pass `debug` through unchanged and simply
|
|
# neutralize `block_thread` for the duration of the inner launch, then
|
|
# replicate Blocks.launch()'s blocking behavior ourselves below.
|
|
prevent_thread_lock = bool(kwargs.get("prevent_thread_lock", False))
|
|
debug = bool(kwargs.get("debug", False))
|
|
inbrowser = bool(kwargs.get("inbrowser", False))
|
|
kwargs["inbrowser"] = False
|
|
|
|
real_block_thread = self.block_thread
|
|
self.block_thread = lambda: None # type: ignore[method-assign]
|
|
try:
|
|
launch_result = super().launch(**kwargs)
|
|
finally:
|
|
self.block_thread = real_block_thread # type: ignore[method-assign]
|
|
_, local_url, share_url = launch_result
|
|
|
|
write_url = None
|
|
if get_space() is None and local_url:
|
|
sep = "&" if "?" in local_url else "?"
|
|
write_url = f"{local_url}{sep}write_token={WRITE_TOKEN}"
|
|
if not kwargs.get("quiet", False):
|
|
print(
|
|
f"\n* Workflow write-access link (keep private as it lets you edit the workflow that all users see): {write_url}"
|
|
)
|
|
if inbrowser:
|
|
webbrowser.open(
|
|
write_url or (share_url if self.share and share_url else local_url)
|
|
)
|
|
|
|
is_in_interactive_mode = bool(getattr(sys, "ps1", sys.flags.interactive))
|
|
if (
|
|
debug
|
|
or int(os.getenv("GRADIO_DEBUG", "0")) == 1
|
|
or (not prevent_thread_lock and not is_in_interactive_mode)
|
|
):
|
|
self.block_thread()
|
|
return launch_result
|