diff --git a/.changeset/late-poets-see.md b/.changeset/late-poets-see.md new file mode 100644 index 00000000000..0a65e7544d1 --- /dev/null +++ b/.changeset/late-poets-see.md @@ -0,0 +1,6 @@ +--- +"@gradio/workflowcanvas": minor +"gradio": minor +--- + +feat:add workflow generation history persistence diff --git a/gradio/routes.py b/gradio/routes.py index 8af6be360f7..74477047b96 100644 --- a/gradio/routes.py +++ b/gradio/routes.py @@ -1405,7 +1405,9 @@ async def queue_join_helper( ) error_map = { "queue_full": status.HTTP_503_SERVICE_UNAVAILABLE, - "validator_error": status.HTTP_422_UNPROCESSABLE_CONTENT, + "validator_error": getattr( + status, "HTTP_422_UNPROCESSABLE_CONTENT", 422 + ), "error": status.HTTP_400_BAD_REQUEST, "success": status.HTTP_200_OK, } diff --git a/gradio/workflow.py b/gradio/workflow.py index 9df0af98272..dce7f2f4985 100644 --- a/gradio/workflow.py +++ b/gradio/workflow.py @@ -72,6 +72,9 @@ class _CuratedCache(TypedDict): _CURATED_CACHE: _CuratedCache = {"fetched_at": 0.0, "items": None} _CURATED_LOCK = threading.Lock() +_MODEL_TAG_CACHE: dict[str, str] = {} +_MODEL_TAG_LOCK = threading.Lock() + def _bundled_snapshot_path() -> str: return os.path.join(os.path.dirname(__file__), "_workflow_curated_snapshot.json") @@ -375,7 +378,18 @@ def _save_tmp(result, ext: str) -> dict: def _img_url(a) -> str: - return a.get("url") or a.get("path", "") if isinstance(a, dict) else a + if not isinstance(a, dict): + return a + path = a.get("path", "") + url = a.get("url", "") + # Prefer the local file path: InferenceClient reads it directly. + # Only fall back to url if it's a public HTTPS address; never send + # Gradio-internal relative URLs (e.g. /gradio_api/file=...) to the Hub. + if path and os.path.isfile(path): + return path + if url.startswith("https://"): + return url + return path or url def _classify_error(e: Exception) -> dict: @@ -773,10 +787,6 @@ def process_item(item): } -# Legacy pipeline tags (as sent by older saved workflows and the browser -# executor) → InferenceClient endpoint names. depth-estimation is absent on -# purpose: InferenceClient has no such method, so it keeps its raw-POST branch -# in call_model. Unmapped tags fall through to the chat/raw-POST fallback. _PIPELINE_TAG_TO_ENDPOINT: dict[str, str] = { "text-generation": "text_generation", "text2text-generation": "text_generation", @@ -808,8 +818,6 @@ def process_item(item): } -# Client params that expect a list of strings; port values arrive as a single -# string, split on the given pattern. _ENDPOINT_LIST_KWARGS: dict[str, dict[str, str]] = { "zero_shot_classification": {"candidate_labels": r"[\n,]"}, "sentence_similarity": {"other_sentences": r"\n"}, @@ -821,8 +829,6 @@ def get_model_endpoints( ) -> str: from huggingface_hub import InferenceClient - # Only advertise endpoints the installed huggingface_hub can actually run, - # so the UI never shapes a node around a method that would fail server-side. endpoints = [ {"name": name, **schema} for name, schema in _INFERENCE_ENDPOINT_SCHEMAS.items() @@ -851,7 +857,6 @@ def _dispatch_model_endpoint(client, endpoint: str, kwargs: dict) -> str: continue legacy = re.fullmatch(r"in_(\d+)", k) if legacy and k not in schema_ids and int(legacy.group(1)) < len(schema_ids): - # positional port IDs from workflows saved before endpoint schemas k = schema_ids[int(legacy.group(1))] clean[k] = ( _img_url(v) if isinstance(v, dict) and ("url" in v or "path" in v) else v @@ -860,7 +865,6 @@ def _dispatch_model_endpoint(client, endpoint: str, kwargs: dict) -> str: if isinstance(clean.get(key), str): clean[key] = [s.strip() for s in re.split(sep, clean[key]) if s.strip()] if endpoint == "zero_shot_classification" and not clean.get("candidate_labels"): - # without labels, fall back to scoring with the model's own label set return _dispatch_model_endpoint( client, "text_classification", {"text": clean.get("text", "")} ) @@ -884,7 +888,6 @@ def _dispatch_model_endpoint(client, endpoint: str, kwargs: dict) -> str: if ext: return json.dumps([_save_tmp(result, ext)]) if isinstance(result, list) and result and hasattr(result[0], "answer"): - # question-answering-style outputs: surface the top answer result = result[0] for attr in ( "summary_text", @@ -937,6 +940,20 @@ def call_model( 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) + + if not pipeline_tag: + with _MODEL_TAG_LOCK: + pipeline_tag = _MODEL_TAG_CACHE.get(model_id) + if not pipeline_tag: + try: + info = HfApi(token=hf_token).model_info(model_id) + pipeline_tag = info.pipeline_tag or "" + if pipeline_tag: + with _MODEL_TAG_LOCK: + _MODEL_TAG_CACHE[model_id] = pipeline_tag + except Exception: + pass + if isinstance(args, dict): endpoint = pipeline_tag or "" return _dispatch_model_endpoint(client, endpoint, args) @@ -963,8 +980,6 @@ def call_model( endpoint = _PIPELINE_TAG_TO_ENDPOINT.get(task) if endpoint: - # Positional args from legacy saved workflows and the browser - # executor map onto the endpoint schema's input order. schema_inputs = _INFERENCE_ENDPOINT_SCHEMAS[endpoint]["inputs"] kwargs = { schema_inputs[i]["id"]: val @@ -1505,6 +1520,7 @@ def __init__( *, bind: dict[str, Callable] | list[Callable] | None = None, edges: list[tuple[str, str]] | None = None, + history: str | bool | None = None, ): """ Parameters: @@ -1527,6 +1543,11 @@ def __init__( ("shout", "reverse"), # first output → first input ("clean.output", "tag.text"), # by port label ] + history: HF Hub bucket ID used to persist generation history. + Pass a string like ``"username/my-workflow-history"`` to use an + existing or auto-created bucket. Pass ``True`` to auto-name the + bucket as ``"{hf_user}/{workflow-slug}-history"``. + Defaults to ``None`` (no persistence). """ if graph is None: caller_filename = sys._getframe(1).f_code.co_filename @@ -1545,6 +1566,7 @@ def __init__( ) self._bound: dict[str, Callable] = bind or {} self._edges: list[tuple[str, str]] = edges or [] + self._history_param: str | bool | None = history if Context.root_block is not None: raise ValueError( @@ -1571,6 +1593,8 @@ def _build(self): self._workflow_file, ) + self._wh = self._resolve_history() + # 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: @@ -1721,11 +1745,166 @@ def save_workflow( "Workflow: endpoint sync after save failed", exc_info=True, ) + if self._wh is not None: + import threading as _threading + + _threading.Thread( + target=self._wh.push_graph_file, args=(payload,), daemon=True + ).start() return "ok" except Exception as e: logger.error("save_workflow failed: %s", e, exc_info=True) return json.dumps({"error": str(e)}) + def list_history( + data=None, + request: Optional[Request] = None, + token: Optional[OAuthToken] = None, + ) -> str: + """Return recent generation records for the History panel.""" + if not has_write_access(request, token): + return json.dumps({"records": [], "repo_id": None}) + if self._wh is None: + return json.dumps({"records": [], "repo_id": None}) + subgraph = data[0] if data else None + limit = ( + int(data[1]) if data and len(data) > 1 and data[1] is not None else 50 + ) + if limit == 0: + return json.dumps({"records": [], "repo_id": self._wh.repo_id}) + records = self._wh.list(limit=limit, subgraph=subgraph or None) + return json.dumps({"records": records, "repo_id": self._wh.repo_id}) + + def connect_history( + data=None, + request: Optional[Request] = None, + token: Optional[OAuthToken] = None, + ) -> str: + """Connect (or switch) the workflow to a HF Hub bucket for history. + + data[0]: bucket_id string, e.g. ``"user/my-history"``. + data[1]: optional bool — if truthy, auto-derive bucket_id from the + HF username and workflow name (same as ``history=True``). + """ + if not has_write_access(request, token): + return json.dumps( + {"error": "Write access required", "error_type": "auth"} + ) + try: + from gradio.workflow_history import WorkflowHistory + + auto = data[1] if data and len(data) > 1 else False + hf_token = ( + token.token if token is not None else None + ) or hf_get_token() + if auto: + user = HfApi(token=hf_token).whoami()["name"] + try: + with open(workflow_file, encoding="utf-8") as _f: + _wf_name = json.load(_f).get("name") or self._workflow_name + except Exception: + _wf_name = self._workflow_name + slug = re.sub(r"[^a-z0-9-]", "-", _wf_name.lower()).strip("-") + repo_id = f"{user}/{slug}-history" + else: + repo_id = (data[0] if data else "").strip() + if not re.fullmatch( + r"[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-][a-zA-Z0-9_./-]*", repo_id + ): + return json.dumps({"error": f"Invalid bucket ID: {repo_id}"}) + + wh = WorkflowHistory(repo_id=repo_id, token=hf_token) + wh.ensure_repo() + self._wh = wh + + try: + with _save_lock: + raw = _load_initial() + if raw: + parsed = json.loads(raw) + parsed["history_repo"] = repo_id + with open(workflow_file, "w", encoding="utf-8") as f: + json.dump(parsed, f, ensure_ascii=False) + except Exception: + logger.debug( + "connect_history: could not persist to workflow.json", + exc_info=True, + ) + + return json.dumps({"ok": True, "repo_id": repo_id}) + except Exception as e: + logger.error("connect_history failed: %s", e, exc_info=True) + return json.dumps({"error": str(e)}) + + def push_history( + data=None, + request: Optional[Request] = None, + token: Optional[OAuthToken] = None, + ) -> str: + """Accept a pre-built record from a client-side run and push it to Hub. + + data[0]: JSON string of the history record dict. + """ + if not has_write_access(request, token): + return json.dumps({"ok": False, "reason": "auth"}) + if self._wh is None: + return json.dumps({"ok": False, "reason": "no_history"}) + try: + record = json.loads(data[0]) if data else {} + if not record.get("id"): + return json.dumps({"ok": False, "reason": "invalid_record"}) + self._wh.push(record) + return json.dumps({"ok": True}) + except Exception as e: + logger.debug("push_history failed: %s", e, exc_info=True) + return json.dumps({"ok": False, "reason": str(e)}) + + def list_user_buckets( + _data=None, + request: Optional[Request] = None, + token: Optional[OAuthToken] = None, + ) -> str: + """Return the authenticated user's Hub buckets for the connect modal.""" + if not has_write_access(request, token): + return json.dumps({"buckets": []}) + try: + hf_token = ( + token.token if token is not None else None + ) or hf_get_token() + buckets = [ + {"id": b.id, "private": b.private} + for b in HfApi(token=hf_token).list_buckets(token=hf_token) + ] + return json.dumps({"buckets": buckets}) + except Exception as e: + logger.debug("list_user_buckets failed: %s", e, exc_info=True) + return json.dumps({"buckets": []}) + + def delete_history( + data=None, + request: Optional[Request] = None, + token: Optional[OAuthToken] = None, + ) -> str: + """Delete a generation record from the bucket. + + data[0]: record id string. + data[1]: record timestamp string. + """ + if not has_write_access(request, token): + return json.dumps({"error": "Write access required"}) + if self._wh is None: + return json.dumps({"ok": False, "reason": "no_history"}) + try: + record_id = data[0] if data else "" + timestamp = data[1] if data and len(data) > 1 else "" + if not record_id or not timestamp: + return json.dumps({"ok": False, "reason": "missing_fields"}) + ok = self._wh.delete(record_id, timestamp) + return json.dumps({"ok": ok}) + except Exception as e: + logger.debug("delete_history failed: %s", e, exc_info=True) + return json.dumps({"ok": False, "reason": str(e)}) + server_functions = [ get_token, get_write_access, @@ -1746,6 +1925,11 @@ def save_workflow( get_workflow_api, get_model_endpoints, save_workflow, + list_history, + connect_history, + push_history, + list_user_buckets, + delete_history, ] from gradio.workflow_api import WorkflowGraph, register_workflow_endpoints @@ -1819,7 +2003,47 @@ async def wrapper( # 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) + self._api_endpoints = register_workflow_endpoints( + self, _current_graph, callers, get_history=lambda: self._wh + ) + + def _resolve_history(self): + """Instantiate WorkflowHistory from ``self._history_param``, the saved + ``history_repo`` field in workflow.json, or return None.""" + from gradio.workflow_history import WorkflowHistory + + param = self._history_param + + if param is None: + try: + with open(self._workflow_file, encoding="utf-8") as f: + saved = json.load(f) + repo_id = saved.get("history_repo") + if repo_id: + return WorkflowHistory(repo_id=repo_id, token=hf_get_token()) + except Exception: + pass + return None + + if param is True: + try: + token = hf_get_token() + user = HfApi(token=token).whoami()["name"] + slug = re.sub(r"[^a-z0-9-]", "-", self._workflow_name.lower()).strip( + "-" + ) + repo_id = f"{user}/{slug}-history" + except Exception: + logger.warning( + "Workflow: history=True requires a logged-in HF account " + "(run `huggingface-cli login`). History disabled.", + exc_info=True, + ) + return None + else: + repo_id = str(param) + token = hf_get_token() + return WorkflowHistory(repo_id=repo_id, token=token) def launch(self, *args, **kwargs): # type: ignore[override] """Launch the workflow as a Gradio app. Accepts the same arguments as `gr.Blocks.launch()`. @@ -1835,6 +2059,7 @@ def launch(self, *args, **kwargs): # type: ignore[override] kwargs.update(dict(zip(names, args))) kwargs["allowed_paths"] = [ tempfile.gettempdir(), + os.path.realpath(tempfile.gettempdir()), *(kwargs.get("allowed_paths") or []), ] # We need the edit link to print (and the browser to open to it) before diff --git a/gradio/workflow_api.py b/gradio/workflow_api.py index 15ac52499bb..977e1191c57 100644 --- a/gradio/workflow_api.py +++ b/gradio/workflow_api.py @@ -20,9 +20,13 @@ import json import logging import re +import secrets from collections import deque from collections.abc import Callable -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from gradio.workflow_history import WorkflowHistory logger = logging.getLogger(__name__) @@ -550,7 +554,7 @@ def _run_model(self, node: dict, data_map: dict[str, dict[str, Any]]) -> None: ] else: args = [resolved[p["id"]] for p in node.get("inputs") or []] - tag = node.get("pipeline_tag") or "text-generation" + tag = node.get("pipeline_tag") or node.get("task") or "text-generation" call_data = [node.get("model_id"), tag, json.dumps(args), None, provider] output_data = self._call("model", call_data) self._map_outputs(node, output_data, data_map) @@ -707,6 +711,9 @@ def _build_endpoint_fn( subject_ids: list[str], free_ids: list[str], callers: dict[str, Callable], + api_name: str = "", + get_history: Optional[Callable[[], Optional[WorkflowHistory]]] = None, + free_items: Optional[list[dict]] = None, ): """Build the callable backing one subgraph endpoint. `subject_ids` are all the outputs of the subgraph; with more than one the endpoint returns a tuple @@ -730,6 +737,21 @@ def endpoint(*args): results = WorkflowExecutor(graph, callers).run_many( subject_ids, inputs, request, token ) + history = get_history() if get_history else None + if history is not None: + try: + _record_generation( + history, + api_name, + graph, + free_items or [], + input_values, + subject_ids, + results, + request, + ) + except Exception: + logger.debug("_record_generation failed", exc_info=True) return results[0] if len(results) == 1 else tuple(results) params = [ @@ -760,6 +782,40 @@ def endpoint(*args): return endpoint +def _record_generation( + history: WorkflowHistory, + api_name: str, + graph: WorkflowGraph, + free_items: list[dict], + input_values: list[Any], + subject_ids: list[str], + results: list[Any], + request: Any, +) -> None: + """Fire-and-forget: push a generation record to WorkflowHistory.""" + from gradio.workflow_history import build_history_record + + user: str | None = None + try: + if hasattr(request, "username"): + user = request.username + except Exception: + pass + + gen_id = secrets.token_hex(12) + record = build_history_record( + gen_id=gen_id, + subgraph=api_name, + graph=graph, + free_items=free_items, + input_values=list(input_values), + subject_ids=subject_ids, + results=list(results), + user=user, + ) + history.push(record) + + class WorkflowEndpointManager: """Owns the lifecycle of the per-subject API endpoints and keeps them in sync with the workflow graph. @@ -777,10 +833,12 @@ def __init__( blocks, get_graph: Callable[[], Optional[WorkflowGraph]], callers: dict[str, Callable], + get_history: Optional[Callable[[], Optional[WorkflowHistory]]] = None, ): self.blocks = blocks self.get_graph = get_graph self.callers = callers + self.get_history = get_history self._blocks_created: list = [] self._fn_ids: list[int] = [] self.api_names: list[str] = [] @@ -838,6 +896,9 @@ def _register(self, graph: WorkflowGraph) -> None: [s["id"] for s in group], [f["node"]["id"] for f in frees], self.callers, + api_name=api_name, + get_history=self.get_history, + free_items=frees, ) trigger = gr.Button(visible=False) self._blocks_created.append(trigger) @@ -876,10 +937,13 @@ def register_workflow_endpoints( blocks, get_graph: Callable[[], Optional[WorkflowGraph]], callers: dict[str, Callable], + get_history: Optional[Callable[[], Optional[WorkflowHistory]]] = None, ) -> WorkflowEndpointManager: """Create a `WorkflowEndpointManager` and register the initial endpoint set from the current graph. Returns the manager so the caller can `.sync()` it again whenever the graph is saved.""" - manager = WorkflowEndpointManager(blocks, get_graph, callers) + manager = WorkflowEndpointManager( + blocks, get_graph, callers, get_history=get_history + ) manager.sync() return manager diff --git a/gradio/workflow_history.py b/gradio/workflow_history.py new file mode 100644 index 00000000000..8964002f71c --- /dev/null +++ b/gradio/workflow_history.py @@ -0,0 +1,300 @@ +"""WorkflowHistory — persists gr.Workflow generation records to a HF Hub bucket.""" + +from __future__ import annotations + +import json +import logging +import os +import pathlib +import secrets +import tempfile +import threading +import time +from datetime import datetime, timezone +from typing import Any + +from huggingface_hub import HfApi +from huggingface_hub import get_token as hf_get_token + +logger = logging.getLogger(__name__) + +MEDIA_PORT_TYPES = {"image", "audio", "video"} + +_LIST_CACHE_TTL = 10.0 + +_MAX_FILES_SCAN = 200 + + +class WorkflowHistory: + """Persists workflow generation records to a HF Hub bucket. + + Each generation is stored as an individual JSON file + (``data/_.json``). Media outputs (images, audio, + video) are uploaded to ``media/`` and their local paths are replaced + with stable Hub URLs before the record is stored. + + Args: + repo_id: HF Hub bucket identifier, e.g. ``"user/my-history"``. + token: HF access token. Falls back to the cached CLI token. + """ + + def __init__(self, repo_id: str, token: str | None = None) -> None: + self.repo_id = repo_id + self._token = token or hf_get_token() + self._api = HfApi(token=self._token) + self._repo_ready = False + self._repo_lock = threading.Lock() + self._cache_lock = threading.Lock() + self._cache: list[dict] | None = None + self._cache_at: float = 0.0 + + def push(self, record: dict) -> None: + """Persist *record* to Hub in a background thread (non-blocking).""" + threading.Thread(target=self._push_sync, args=(record,), daemon=True).start() + + def list(self, limit: int = 50, subgraph: str | None = None) -> list[dict]: + """Return recent generation records, newest first. + + Results are cached for ``_LIST_CACHE_TTL`` seconds to avoid + hammering the Hub on rapid panel refreshes. + """ + now = time.monotonic() + with self._cache_lock: + if self._cache is None or (now - self._cache_at) >= _LIST_CACHE_TTL: + self._cache = self._fetch_records() + self._cache_at = time.monotonic() + records = self._cache + + if subgraph: + records = [r for r in records if r.get("subgraph") == subgraph] + return records[:limit] + + def delete(self, record_id: str, timestamp: str) -> bool: + """Delete a single generation record from the bucket. + + Returns True on success, False if the record could not be deleted. + """ + try: + safe_ts = timestamp.replace(":", "-") + path_in_repo = f"data/{safe_ts}_{record_id}.json" + self._api.batch_bucket_files( + bucket_id=self.repo_id, + delete=[path_in_repo], + ) + with self._cache_lock: + self._cache = None + return True + except Exception: + logger.debug("WorkflowHistory: delete failed", exc_info=True) + return False + + def push_graph_file(self, workflow_json: str) -> None: + """Upload the current ``workflow.json`` to the bucket for versioning.""" + try: + self.ensure_repo() + if not self._repo_ready: + return + self._api.batch_bucket_files( + bucket_id=self.repo_id, + add=[(workflow_json.encode("utf-8"), "workflow.json")], + ) + except Exception: + logger.debug("WorkflowHistory: graph file upload failed", exc_info=True) + + def ensure_repo(self) -> None: + with self._repo_lock: + if self._repo_ready: + return + try: + self._api.create_bucket(self.repo_id, private=True, exist_ok=True) + self._repo_ready = True + except Exception: + logger.warning( + "WorkflowHistory: could not create bucket %s", + self.repo_id, + exc_info=True, + ) + + def _push_sync(self, record: dict) -> None: + """Called in a daemon thread — uploads media then writes the record.""" + try: + self.ensure_repo() + if not self._repo_ready: + return + + for sid, output in list(record.get("outputs", {}).items()): + value = output.get("value") + port_type = output.get("type", "text") + if port_type in MEDIA_PORT_TYPES and isinstance(value, str): + fs_path = ( + value[len("/gradio_api/file=") :] + if value.startswith("/gradio_api/file=") + else value + ) + hub_url = self._upload_media(record["id"], sid, fs_path, port_type) + if hub_url: + record["outputs"][sid]["bucket_url"] = hub_url + # Keep value as the Gradio-served URL so the image renders + # in the current session. bucket_url holds the durable copy. + + ts = record.get("timestamp") or datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + safe_ts = ts.replace(":", "-") + path_in_repo = ( + f"data/{safe_ts}_{record.get('id', secrets.token_hex(8))}.json" + ) + data = json.dumps(record, ensure_ascii=False).encode("utf-8") + self._api.batch_bucket_files( + bucket_id=self.repo_id, + add=[(data, path_in_repo)], + ) + with self._cache_lock: + self._cache = None + + except Exception: + logger.debug("WorkflowHistory: push failed", exc_info=True) + + def _upload_media( + self, gen_id: str, sid: str, value: str, port_type: str + ) -> str | None: + """Upload a local media file to the bucket's ``media/`` dir.""" + if not os.path.isfile(value): + return None + if not os.path.realpath(value).startswith(os.path.realpath(tempfile.gettempdir())): + logger.debug("WorkflowHistory: refusing to upload non-temp path: %s", value) + return None + ext = pathlib.Path(value).suffix or { + "image": ".png", + "audio": ".mp3", + "video": ".mp4", + }.get(port_type, ".bin") + path_in_repo = f"media/{gen_id}_{sid}{ext}" + try: + with open(value, "rb") as fh: + self._api.batch_bucket_files( + bucket_id=self.repo_id, + add=[(fh.read(), path_in_repo)], + ) + return f"https://huggingface.co/buckets/{self.repo_id}/{path_in_repo}" + except Exception: + logger.debug("WorkflowHistory: media upload failed", exc_info=True) + return None + + def _fetch_records(self) -> list[dict]: + """Download record files from the bucket and sort newest-first.""" + try: + all_items = sorted( + ( + item + for item in self._api.list_bucket_tree(self.repo_id, prefix="data/") + if getattr(item, "path", "").endswith(".json") + and not hasattr(item, "count") + ), + key=lambda f: f.path, + reverse=True, + )[:_MAX_FILES_SCAN] + + if not all_items: + return [] + + records: list[dict] = [] + with tempfile.TemporaryDirectory() as tmpdir: + downloads = [ + (item, os.path.join(tmpdir, f"{i}.json")) + for i, item in enumerate(all_items) + ] + try: + self._api.download_bucket_files( + bucket_id=self.repo_id, + files=[(item, local) for item, local in downloads], + token=self._token, + ) + except Exception: + logger.debug( + "WorkflowHistory: bucket download failed", exc_info=True + ) + return [] + + for _, local in downloads: + try: + with open(local, encoding="utf-8") as fh: + records.append(json.load(fh)) + except Exception: + continue + + records.sort(key=lambda r: r.get("timestamp", ""), reverse=True) + return records + except Exception: + logger.debug("WorkflowHistory: fetch failed", exc_info=True) + return [] + + +def build_history_record( + gen_id: str, + subgraph: str, + graph: Any, + free_items: list[dict], + input_values: list[Any], + subject_ids: list[str], + results: list[Any], + user: str | None, +) -> dict: + """Build a history record dict from a completed subgraph execution. + + Args: + gen_id: UUID string for this generation. + subgraph: API name of the subgraph endpoint. + graph: ``WorkflowGraph`` instance (for node metadata). + free_items: List of free-input dicts from ``group_free_inputs()``. + input_values: Positional input values passed to the executor. + subject_ids: Subject node IDs in the same order as *results*. + results: Output values from ``WorkflowExecutor.run_many()``. + user: HF username (or None for anonymous). + """ + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + inputs: dict[str, dict] = {} + for item, value in zip(free_items, input_values): + node = item["node"] + node_id = node["id"] + port_type = item.get("type", "text") + label = item.get("label") or node.get("label", node_id) + port = item.get("port") or {} + port_id = port.get("id", "out_0") + safe_value: Any + if isinstance(value, (str, int, float, bool)) or value is None: + safe_value = value + else: + safe_value = str(value) + inputs[node_id] = { + "value": safe_value, + "type": port_type, + "label": label, + "port_id": port_id, + } + + outputs: dict[str, dict] = {} + for sid, result in zip(subject_ids, results): + node = graph.node_by_id.get(sid, {}) + in_ports = node.get("inputs") or [] + port_type = (in_ports[0].get("type") if in_ports else None) or node.get( + "asset_type", "text" + ) + label = node.get("label", sid) + safe_result: Any + if isinstance(result, (str, int, float, bool)) or result is None: + safe_result = result + else: + safe_result = str(result) + outputs[sid] = {"value": safe_result, "type": port_type, "label": label} + + return { + "id": gen_id, + "timestamp": ts, + "subgraph": subgraph, + "subject_ids": subject_ids, + "inputs": inputs, + "outputs": outputs, + "user": user, + } diff --git a/js/workflowcanvas/workflow/WorkflowCanvas.svelte b/js/workflowcanvas/workflow/WorkflowCanvas.svelte index e8c8a0c2369..9155c0b67ed 100644 --- a/js/workflowcanvas/workflow/WorkflowCanvas.svelte +++ b/js/workflowcanvas/workflow/WorkflowCanvas.svelte @@ -8,6 +8,8 @@ import NodeModelPicker from "./NodeModelPicker.svelte"; import WorkflowEmptyState from "./WorkflowEmptyState.svelte"; import WorkflowApiPanel from "./WorkflowApiPanel.svelte"; + import WorkflowHistoryPanel from "./WorkflowHistoryPanel.svelte"; + import WorkflowHistoryConnect from "./WorkflowHistoryConnect.svelte"; import CheckIcon from "./icons/CheckIcon.svelte"; import LayoutIcon from "./icons/LayoutIcon.svelte"; import InfoIcon from "./icons/InfoIcon.svelte"; @@ -197,6 +199,21 @@ .catch(() => {}); }); + $effect(() => { + if (!server?.list_history) return; + void server + .list_history([null, 0]) + .then((raw: string) => { + try { + const parsed = JSON.parse(raw); + historyAvailable = parsed?.repo_id != null; + } catch { + /* ignore */ + } + }) + .catch(() => {}); + }); + $effect(() => { if (!server?.get_model_endpoints) return; void fetchModelEndpoints(server).then((schemas) => { @@ -312,6 +329,10 @@ let showShortcuts = $state(false); let showUserMenu = $state(false); let showApiPanel = $state(false); + let showHistoryPanel = $state(false); + let showHistoryConnect = $state(false); + let historyAvailable = $state(false); + let historyRefreshCount = $state(0); // Popover shown when the "Run only" badge is clicked, explaining why editing // is disabled and how to enable it. let showAccessInfo = $state(false); @@ -1600,6 +1621,9 @@ } : undefined; + const capturedOutputs: Record = + {}; + await executeWorkflow( wfToRun, (nodeId, status, error, errorType) => { @@ -1654,6 +1678,8 @@ }, (nodeId, portId, value) => { updateNodeData(nodeId, portId, value); + if (!capturedOutputs[nodeId]) capturedOutputs[nodeId] = []; + capturedOutputs[nodeId].push({ portId, value }); }, abortController.signal, callSpaceWithToken, @@ -1674,9 +1700,88 @@ ); running = false; + const wasAborted = abortController?.signal.aborted ?? false; abortController = null; - const hasErrors = Object.values(nodeStatus).some((s) => s === "error"); + + if (!wasAborted && !hasErrors && server?.push_history) { + try { + const MEDIA_PORT_TYPES = new Set(["image", "audio", "video"]); + const extractValue = (raw: any, type: string): any => { + if (raw && typeof raw === "object" && MEDIA_PORT_TYPES.has(type)) { + // Prefer the Gradio-served URL so the browser can render the image + // in the current session. Python's _push_sync strips the + // /gradio_api/file= prefix before uploading to the bucket. + const url: string = raw.url ?? ""; + if (url) return url; + if (raw.path) return "/gradio_api/file=" + raw.path; + } + return raw; + }; + + const genInputs: Record< + string, + { value: any; type: string; label: string; port_id?: string } + > = {}; + for (const ref of wfToRun.references) { + const node = legacyView.nodes.find((n) => n.id === ref.id); + if (!node) continue; + const outPort = node.outputs[0]; + if (!outPort) continue; + const captured = capturedOutputs[ref.id]; + const raw = captured + ? ((captured.find((o) => o.portId === outPort.id) ?? captured[0]) + ?.value ?? null) + : (node.data?.[outPort.id] ?? null); + genInputs[ref.id] = { + value: extractValue(raw, outPort.type), + type: outPort.type, + label: node.label, + port_id: outPort.id + }; + } + const genOutputs: Record< + string, + { value: any; type: string; label: string } + > = {}; + for (const subj of wfToRun.subjects) { + const node = legacyView.nodes.find((n) => n.id === subj.id); + if (!node) continue; + const inPort = node.inputs[0]; + if (!inPort) continue; + const captured = capturedOutputs[subj.id]; + const raw = captured + ? ((captured.find((o) => o.portId === inPort.id) ?? captured[0]) + ?.value ?? null) + : (node.data?.[inPort.id] ?? null); + genOutputs[subj.id] = { + value: extractValue(raw, inPort.type), + type: inPort.type, + label: node.label + }; + } + const record = { + id: crypto.randomUUID().replace(/-/g, "").slice(0, 24), + timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + subgraph: wfToRun.name ?? "default", + subject_ids: wfToRun.subjects.map((s) => s.id), + inputs: genInputs, + outputs: genOutputs, + user: null + }; + server + .push_history([JSON.stringify(record)]) + .then(() => { + if (showHistoryPanel) { + setTimeout(() => { + historyRefreshCount++; + }, 1500); + } + }) + .catch(() => {}); + } catch {} + } + showToast( hasErrors ? "Workflow finished with errors" : "Workflow complete", hasErrors ? 5000 : 3000, @@ -2255,6 +2360,23 @@ View API + {#if historyAvailable} + + {:else if server?.connect_history} + + {/if} {#if saveIndicator} (showApiPanel = false)} /> {/if} + + {#if showHistoryConnect} + { + historyAvailable = true; + showHistoryConnect = false; + showHistoryPanel = true; + }} + onclose={() => (showHistoryConnect = false)} + /> + {/if} + + {#if showHistoryPanel} + (showHistoryPanel = false)} + onchange={() => { + showHistoryPanel = false; + showHistoryConnect = true; + }} + onload={(inputs) => { + for (const [nodeId, input] of Object.entries( + inputs as Record + )) { + const portId: string = (input as any).port_id ?? "out_0"; + const value = (input as any).value; + updateNodeData(nodeId, portId, value); + } + showHistoryPanel = false; + }} + /> + {/if} diff --git a/js/workflowcanvas/workflow/WorkflowHistoryPanel.svelte b/js/workflowcanvas/workflow/WorkflowHistoryPanel.svelte new file mode 100644 index 00000000000..1f088feba2f --- /dev/null +++ b/js/workflowcanvas/workflow/WorkflowHistoryPanel.svelte @@ -0,0 +1,634 @@ + + + + + diff --git a/js/workflowcanvas/workflow/workflow-executor.ts b/js/workflowcanvas/workflow/workflow-executor.ts index 84f99332504..a8c8ec9da29 100644 --- a/js/workflowcanvas/workflow/workflow-executor.ts +++ b/js/workflowcanvas/workflow/workflow-executor.ts @@ -419,7 +419,8 @@ export async function executeWorkflow( // Prefer browser-side streaming for chat-completion-compatible // text tasks so the UI receives tokens as they arrive. The // Python path stays for every other task. - const tag = node.pipeline_tag ?? "text-generation"; + const tag = + node.pipeline_tag ?? (node as any).task ?? "text-generation"; const streamable = (tag === "text-generation" || tag === "text2text-generation" || diff --git a/js/workflowcanvas/workflow/workflow-migration.ts b/js/workflowcanvas/workflow/workflow-migration.ts index 8a52b1b121f..ed84c7f2fc3 100644 --- a/js/workflowcanvas/workflow/workflow-migration.ts +++ b/js/workflowcanvas/workflow/workflow-migration.ts @@ -211,8 +211,8 @@ export function toLegacyShape(wf: Workflow): { kind: "component", label: n.label, source: "local", - inputs: n.inputs, - outputs: n.outputs, + inputs: n.inputs ?? [], + outputs: n.outputs ?? [], x: n.x, y: n.y, width: n.width, @@ -236,8 +236,8 @@ export function toLegacyShape(wf: Workflow): { pipeline_tag: n.pipeline_tag, provider: n.provider, fn: n.fn, - inputs: n.inputs, - outputs: n.outputs, + inputs: n.inputs ?? [], + outputs: n.outputs ?? [], x: n.x, y: n.y, width: n.width, @@ -251,8 +251,8 @@ export function toLegacyShape(wf: Workflow): { kind: "component", label: n.label, source: "local", - inputs: n.inputs, - outputs: n.outputs, + inputs: n.inputs ?? [], + outputs: n.outputs ?? [], x: n.x, y: n.y, width: n.width, diff --git a/test/test_workflow_history.py b/test/test_workflow_history.py new file mode 100644 index 00000000000..d03e7340f2c --- /dev/null +++ b/test/test_workflow_history.py @@ -0,0 +1,327 @@ +"""Unit tests for gradio.workflow_history.WorkflowHistory and build_history_record.""" + +from __future__ import annotations + +import json +import threading +from unittest.mock import MagicMock, patch + +from gradio.workflow_history import WorkflowHistory, build_history_record + +# ─── build_history_record ───────────────────────────────────────────────────── + + +class FakeGraph: + def __init__(self, nodes: dict): + self.node_by_id = nodes + + +def test_build_history_record_text_inputs(): + graph = FakeGraph( + { + "subj_0": { + "id": "subj_0", + "label": "Result", + "inputs": [{"id": "in_0", "type": "text"}], + } + } + ) + free_items = [ + { + "node": {"id": "ref_0", "label": "Prompt"}, + "port": {"id": "out_0", "type": "text"}, + "type": "text", + "label": "Prompt", + } + ] + record = build_history_record( + gen_id="abc123", + subgraph="generate", + graph=graph, + free_items=free_items, + input_values=["hello world"], + subject_ids=["subj_0"], + results=["world hello"], + user="alice", + ) + assert record["id"] == "abc123" + assert record["subgraph"] == "generate" + assert record["user"] == "alice" + assert record["inputs"]["ref_0"]["value"] == "hello world" + assert record["inputs"]["ref_0"]["port_id"] == "out_0" + assert record["inputs"]["ref_0"]["type"] == "text" + assert record["outputs"]["subj_0"]["value"] == "world hello" + assert record["outputs"]["subj_0"]["type"] == "text" + assert "timestamp" in record + + +def test_build_history_record_non_serializable_value(): + graph = FakeGraph({"subj_0": {"id": "subj_0", "label": "Out", "inputs": []}}) + free_items = [ + { + "node": {"id": "ref_0", "label": "Input"}, + "port": {"id": "out_0", "type": "text"}, + "type": "text", + "label": "Input", + } + ] + record = build_history_record( + gen_id="x", + subgraph="sg", + graph=graph, + free_items=free_items, + input_values=[object()], + subject_ids=["subj_0"], + results=[None], + user=None, + ) + assert isinstance(record["inputs"]["ref_0"]["value"], str) + assert record["outputs"]["subj_0"]["value"] is None + assert record["user"] is None + + +def test_build_history_record_missing_port_uses_default(): + graph = FakeGraph({"subj_0": {"id": "subj_0", "label": "Out", "inputs": []}}) + free_items = [ + { + "node": {"id": "ref_0", "label": "Input"}, + "port": None, + "type": "text", + "label": "Input", + } + ] + record = build_history_record( + gen_id="x", + subgraph="sg", + graph=graph, + free_items=free_items, + input_values=["val"], + subject_ids=["subj_0"], + results=["out"], + user=None, + ) + assert record["inputs"]["ref_0"]["port_id"] == "out_0" + + +# ─── WorkflowHistory ────────────────────────────────────────────────────────── + + +def _make_history(repo_id="user/test-history"): + mock_api = MagicMock() + wh = WorkflowHistory.__new__(WorkflowHistory) + wh.repo_id = repo_id + wh._token = "tok" + wh._api = mock_api + wh._repo_ready = True + wh._repo_lock = threading.Lock() + wh._cache_lock = threading.Lock() + wh._cache = None + wh._cache_at = 0.0 + return wh, mock_api + + +def test_push_uploads_record_to_bucket(): + wh, mock_api = _make_history() + record = { + "id": "gen1", + "timestamp": "2026-06-30T12:00:00Z", + "subgraph": "generate", + "subject_ids": ["subj_0"], + "inputs": { + "ref_0": { + "value": "cat", + "type": "text", + "label": "Prompt", + "port_id": "out_0", + } + }, + "outputs": { + "subj_0": {"value": "a cute cat", "type": "text", "label": "Result"} + }, + "user": None, + } + wh._push_sync(record) + + mock_api.batch_bucket_files.assert_called_once() + call_kwargs = mock_api.batch_bucket_files.call_args.kwargs + assert call_kwargs["bucket_id"] == "user/test-history" + data_bytes, path = call_kwargs["add"][0] + assert path.startswith("data/") + assert path.endswith(".json") + saved = json.loads(data_bytes.decode()) + assert saved["id"] == "gen1" + + +def test_push_uploads_image_to_media(tmp_path): + wh, mock_api = _make_history() + img_path = str(tmp_path / "out.png") + with open(img_path, "wb") as f: + f.write(b"\x89PNG\r\n") + + record = { + "id": "gen2", + "timestamp": "2026-06-30T13:00:00Z", + "subgraph": "img", + "subject_ids": ["subj_img"], + "inputs": {}, + "outputs": {"subj_img": {"value": img_path, "type": "image", "label": "Image"}}, + "user": None, + } + wh._push_sync(record) + + # Two batch_bucket_files calls: media upload + record upload + assert mock_api.batch_bucket_files.call_count == 2 + media_call = mock_api.batch_bucket_files.call_args_list[0] + media_path = media_call.kwargs["add"][0][1] + assert media_path.startswith("media/") + + # Record stored in bucket should have the Hub URL, not the local path + record_call = mock_api.batch_bucket_files.call_args_list[1] + record_bytes = record_call.kwargs["add"][0][0] + saved = json.loads(record_bytes.decode()) + assert "huggingface.co" in saved["outputs"]["subj_img"]["value"] + + +def test_push_skips_media_upload_for_urls(): + wh, mock_api = _make_history() + record = { + "id": "gen3", + "timestamp": "2026-06-30T14:00:00Z", + "subgraph": "img", + "subject_ids": ["subj_img"], + "inputs": {}, + "outputs": { + "subj_img": { + "value": "https://example.com/img.png", + "type": "image", + "label": "Image", + } + }, + "user": None, + } + wh._push_sync(record) + + # Only one batch_bucket_files call (record only — no media upload for URLs) + assert mock_api.batch_bucket_files.call_count == 1 + record_bytes = mock_api.batch_bucket_files.call_args.kwargs["add"][0][0] + saved = json.loads(record_bytes.decode()) + assert saved["outputs"]["subj_img"]["value"] == "https://example.com/img.png" + + +def test_list_returns_cached_results(): + wh, mock_api = _make_history() + wh._cache = [{"id": "cached", "timestamp": "2026-06-30T00:00:00Z"}] + wh._cache_at = float("inf") + + results = wh.list(limit=10) + mock_api.list_bucket_tree.assert_not_called() + assert results[0]["id"] == "cached" + + +def test_list_filters_by_subgraph(): + wh, _ = _make_history() + wh._cache = [ + {"id": "a", "timestamp": "2026-06-30T01:00:00Z", "subgraph": "foo"}, + {"id": "b", "timestamp": "2026-06-30T02:00:00Z", "subgraph": "bar"}, + {"id": "c", "timestamp": "2026-06-30T03:00:00Z", "subgraph": "foo"}, + ] + wh._cache_at = float("inf") + + filtered = wh.list(subgraph="foo") + assert len(filtered) == 2 + assert all(r["subgraph"] == "foo" for r in filtered) + + +def test_list_respects_limit(): + wh, _ = _make_history() + wh._cache = [ + {"id": str(i), "timestamp": f"2026-06-30T{i:02d}:00:00Z", "subgraph": "sg"} + for i in range(20) + ] + wh._cache_at = float("inf") + assert len(wh.list(limit=5)) == 5 + + +def test_push_graph_file(): + wh, mock_api = _make_history() + wh.push_graph_file('{"schema_version": "2"}') + mock_api.batch_bucket_files.assert_called_once() + call_kwargs = mock_api.batch_bucket_files.call_args.kwargs + assert call_kwargs["bucket_id"] == "user/test-history" + data_bytes, path = call_kwargs["add"][0] + assert path == "workflow.json" + assert b"schema_version" in data_bytes + + +def test_ensure_repo_creates_bucket(): + with ( + patch("gradio.workflow_history.HfApi") as mock_api_cls, + patch("gradio.workflow_history.hf_get_token", return_value="tok"), + ): + mock_api = mock_api_cls.return_value + wh = WorkflowHistory("user/new-bucket", token="tok") + wh._api = mock_api + wh.ensure_repo() + mock_api.create_bucket.assert_called_once_with( + "user/new-bucket", + private=True, + exist_ok=True, + ) + assert wh._repo_ready is True + + +def test_history_true_auto_names_repo(): + """history=True should derive the bucket ID from the HF username and workflow name.""" + import warnings + + with ( + patch("gradio.workflow.HfApi") as mock_hf_api_cls, + patch("gradio.workflow.hf_get_token", return_value="tok"), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore") + mock_api_inst = mock_hf_api_cls.return_value + mock_api_inst.whoami.return_value = {"name": "testuser"} + + from gradio.workflow import Workflow + + wf = Workflow.__new__(Workflow) + wf._workflow_name = "My Workflow" + wf._history_param = True + result = wf._resolve_history() + + assert result is not None + assert result.repo_id == "testuser/my-workflow-history" + + +def test_history_string_uses_explicit_repo(): + import warnings + + with ( + patch("gradio.workflow.hf_get_token", return_value="tok"), + warnings.catch_warnings(), + ): + warnings.simplefilter("ignore") + from gradio.workflow import Workflow + + wf = Workflow.__new__(Workflow) + wf._workflow_name = "Workflow" + wf._history_param = "acme/custom-history" + result = wf._resolve_history() + + assert result is not None + assert result.repo_id == "acme/custom-history" + + +def test_history_none_returns_none(): + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from gradio.workflow import Workflow + + wf = Workflow.__new__(Workflow) + wf._history_param = None + result = wf._resolve_history() + + assert result is None