diff --git a/studio/backend/core/data_recipe/jobs/constants.py b/studio/backend/core/data_recipe/jobs/constants.py
new file mode 100644
index 0000000000..5d4081abbc
--- /dev/null
+++ b/studio/backend/core/data_recipe/jobs/constants.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+# stages parsed from data-designer logs
+STAGE_CREATE = "create"
+STAGE_PREVIEW = "preview"
+STAGE_DAG = "dag"
+STAGE_HEALTHCHECK = "healthcheck"
+STAGE_SAMPLING = "sampling"
+STAGE_COLUMN_CONFIG = "column_config"
+STAGE_GENERATING = "generating"
+STAGE_BATCH = "batch"
+STAGE_PROFILING = "profiling"
+
+USAGE_RESET_STAGES = {
+ STAGE_CREATE,
+ STAGE_PREVIEW,
+ STAGE_DAG,
+ STAGE_HEALTHCHECK,
+ STAGE_SAMPLING,
+ STAGE_GENERATING,
+ STAGE_PROFILING,
+}
+
+# job event types emitted by worker/manager
+EVENT_JOB_ENQUEUED = "job.enqueued"
+EVENT_JOB_STARTED = "job.started"
+EVENT_JOB_CANCELLING = "job.cancelling"
+EVENT_JOB_CANCELLED = "job.cancelled"
+EVENT_JOB_COMPLETED = "job.completed"
+EVENT_JOB_ERROR = "job.error"
diff --git a/studio/backend/core/data_recipe/jobs/manager.py b/studio/backend/core/data_recipe/jobs/manager.py
index eb8c10bb81..dbaa620004 100644
--- a/studio/backend/core/data_recipe/jobs/manager.py
+++ b/studio/backend/core/data_recipe/jobs/manager.py
@@ -13,6 +13,15 @@ from typing import Any
import multiprocessing as mp
+from ..jsonable import to_jsonable
+from .constants import (
+ EVENT_JOB_CANCELLING,
+ EVENT_JOB_CANCELLED,
+ EVENT_JOB_COMPLETED,
+ EVENT_JOB_ENQUEUED,
+ EVENT_JOB_ERROR,
+ EVENT_JOB_STARTED,
+)
from .parse import apply_update, coerce_event, parse_log_message
from .types import Job
from .worker import run_job_process
@@ -20,29 +29,6 @@ from .worker import run_job_process
_CTX = mp.get_context("spawn")
-def _to_jsonable(value: Any) -> Any:
- try:
- import numpy as np # type: ignore
- except Exception: # pragma: no cover
- np = None # type: ignore
-
- if np is not None:
- if isinstance(value, np.ndarray):
- return value.tolist()
- if isinstance(value, np.generic):
- return value.item()
-
- if isinstance(value, dict):
- return {str(k): _to_jsonable(v) for k, v in value.items()}
- if isinstance(value, (list, tuple, set)):
- return [_to_jsonable(v) for v in value]
- if hasattr(value, "isoformat") and callable(value.isoformat):
- try:
- return value.isoformat()
- except Exception:
- pass
- return value
-
@dataclass
class Subscription:
@@ -123,7 +109,7 @@ class JobManager:
self._pump_thread = threading.Thread(target=self._pump_loop, daemon=True)
self._pump_thread.start()
- self._emit({"type": "job.enqueued", "ts": time.time(), "job_id": job_id})
+ self._emit({"type": EVENT_JOB_ENQUEUED, "ts": time.time(), "job_id": job_id})
return job_id
def cancel(self, job_id: str) -> bool:
@@ -134,15 +120,15 @@ class JobManager:
if self._proc is None or not self._proc.is_alive():
return True
self._job.status = "cancelling"
- self._emit({"type": "job.cancelling", "ts": time.time(), "job_id": job_id})
+ self._emit({"type": EVENT_JOB_CANCELLING, "ts": time.time(), "job_id": job_id})
try:
self._proc.terminate()
- except Exception:
+ except (AttributeError, OSError):
pass
return True
def get_status(self, job_id: str) -> dict | None:
- """UI-friendly snapshot. Poll this if you don't want SSE."""
+ """UI friendly snapshot that we need. Alternative to sse kinda of and structured"""
with self._lock:
if self._job is None or self._job.job_id != job_id:
return None
@@ -304,7 +290,7 @@ class JobManager:
).fetchdf()
finally:
conn.close()
- except Exception:
+ except (RuntimeError, ValueError, duckdb.Error):
return None
for helper_col in ("filename", "__row_num__"):
@@ -312,7 +298,7 @@ class JobManager:
dataframe = dataframe.drop(columns=[helper_col])
rows = dataframe.to_dict(orient="records")
- return {"dataset": _to_jsonable(rows), "total": total}
+ return {"dataset": to_jsonable(rows), "total": total}
@staticmethod
def _load_dataset_page_with_data_designer(
@@ -326,7 +312,7 @@ class JobManager:
dataframe = read_parquet_dataset(parquet_dir)
total = int(len(dataframe.index))
rows = dataframe.iloc[offset:offset + limit].to_dict(orient="records")
- return {"dataset": _to_jsonable(rows), "total": total}
+ return {"dataset": to_jsonable(rows), "total": total}
def subscribe(self, job_id: str, *, after_seq: int | None = None) -> Subscription | None:
"""SSE subscribe: get replay buffer + live events stream."""
@@ -355,7 +341,7 @@ class JobManager:
for q in self._subs:
try:
q.put_nowait(event)
- except Exception:
+ except queue.Full:
stale.append(q)
if stale:
self._subs = [q for q in self._subs if q not in stale]
@@ -374,7 +360,7 @@ class JobManager:
return coerce_event(q.get(timeout=timeout_sec))
except queue.Empty:
return None
- except Exception:
+ except (EOFError, OSError, ValueError):
return None
@staticmethod
@@ -386,7 +372,7 @@ class JobManager:
events.append(coerce_event(q.get_nowait()))
except queue.Empty:
return events
- except Exception:
+ except (EOFError, OSError, ValueError):
return events
def _pump_loop(self) -> None:
@@ -416,13 +402,10 @@ class JobManager:
self._job.status = "error"
self._job.error = self._job.error or "process exited"
self._job.finished_at = time.time()
- self._emit(
- {
- "type": f"job.{self._job.status}",
- "ts": time.time(),
- "job_id": self._job.job_id,
- }
+ event_type = (
+ EVENT_JOB_CANCELLED if self._job.status == "cancelled" else EVENT_JOB_ERROR
)
+ self._emit({"type": event_type, "ts": time.time(), "job_id": self._job.job_id})
return
def _handle_event(self, job: Job, event: dict) -> None:
@@ -433,9 +416,9 @@ class JobManager:
with self._lock:
if self._job is None or self._job.job_id != job.job_id:
return
- if et == "job.started":
+ if et == EVENT_JOB_STARTED:
self._job.status = "active"
- if et == "job.completed":
+ if et == EVENT_JOB_COMPLETED:
self._job.status = "completed"
self._job.finished_at = time.time()
self._job.analysis = event.get("analysis")
@@ -445,7 +428,7 @@ class JobManager:
if self._job.progress.total and self._job.progress.total > 0:
self._job.progress.done = self._job.progress.total
self._job.progress.percent = 100.0
- if et == "job.error":
+ if et == EVENT_JOB_ERROR:
self._job.status = "error"
self._job.finished_at = time.time()
self._job.error = event.get("error") or "error"
diff --git a/studio/backend/core/data_recipe/jobs/parse.py b/studio/backend/core/data_recipe/jobs/parse.py
index 99d1a85a79..6e2142adf2 100644
--- a/studio/backend/core/data_recipe/jobs/parse.py
+++ b/studio/backend/core/data_recipe/jobs/parse.py
@@ -4,6 +4,18 @@ import re
from dataclasses import dataclass
from typing import Any
+from .constants import (
+ STAGE_BATCH,
+ STAGE_COLUMN_CONFIG,
+ STAGE_CREATE,
+ STAGE_DAG,
+ STAGE_GENERATING,
+ STAGE_HEALTHCHECK,
+ STAGE_PREVIEW,
+ STAGE_PROFILING,
+ STAGE_SAMPLING,
+ USAGE_RESET_STAGES,
+)
from .types import Job, ModelUsage, Progress
@@ -27,8 +39,7 @@ class ParsedUpdate:
usage_rpm: float | None = None
usage_section_start: bool | None = None
-# welp, best effort to parse the logs and convert them to structured information so we can access it and read it properly on the client
-# i couldnt find what datadesigner do for progress tracking besides the logs so il probably raise a pr in their repo to add some sort of progress monitoring
+# kinda of a bummber but currently only option, Best effort parser from data-designer logs -> structured status for UI.
_RE_SAMPLERS = re.compile(
r"Preparing samplers to generate (?P\d+) records across (?P\d+) columns"
)
@@ -52,31 +63,31 @@ def parse_log_message(msg: str) -> ParsedUpdate | None:
m = _RE_SAMPLERS.search(msg)
if m:
return ParsedUpdate(
- stage="sampling",
+ stage=STAGE_SAMPLING,
rows=int(m.group("rows")),
cols=int(m.group("cols")),
)
if "Sorting column configs into a Directed Acyclic Graph" in msg:
- return ParsedUpdate(stage="dag")
+ return ParsedUpdate(stage=STAGE_DAG)
if "Running health checks for models" in msg:
- return ParsedUpdate(stage="healthcheck")
+ return ParsedUpdate(stage=STAGE_HEALTHCHECK)
if "Preview generation in progress" in msg:
- return ParsedUpdate(stage="preview")
+ return ParsedUpdate(stage=STAGE_PREVIEW)
if "Creating Data Designer dataset" in msg:
- return ParsedUpdate(stage="create")
+ return ParsedUpdate(stage=STAGE_CREATE)
if "Measuring dataset column statistics" in msg:
- return ParsedUpdate(stage="profiling")
+ return ParsedUpdate(stage=STAGE_PROFILING)
m = _RE_COLCFG.search(msg)
if m:
col = m.group("col")
- return ParsedUpdate(stage="column_config", current_column=col)
+ return ParsedUpdate(stage=STAGE_COLUMN_CONFIG, current_column=col)
m = _RE_PROCESSING_COL.search(msg)
if m:
col = m.group("col")
- return ParsedUpdate(stage="generating", current_column=col)
+ return ParsedUpdate(stage=STAGE_GENERATING, current_column=col)
m = _RE_PROGRESS.search(msg)
if m:
@@ -89,12 +100,12 @@ def parse_log_message(msg: str) -> ParsedUpdate | None:
rate=float(m.group("rate")),
eta_sec=float(m.group("eta")),
)
- return ParsedUpdate(stage="generating", progress=p)
+ return ParsedUpdate(stage=STAGE_GENERATING, progress=p)
m = _RE_BATCH.search(msg)
if m:
return ParsedUpdate(
- stage="batch",
+ stage=STAGE_BATCH,
batch_idx=int(m.group("idx")),
batch_total=int(m.group("total")),
)
@@ -132,7 +143,7 @@ def apply_update(job: Job, update: ParsedUpdate) -> None:
job.stage = update.stage
if update.current_column is not None:
job.current_column = update.current_column
- if update.stage == "generating" and update.current_column not in job._seen_generation_columns:
+ if update.stage == STAGE_GENERATING and update.current_column not in job._seen_generation_columns:
job._seen_generation_columns.append(update.current_column)
if update.rows is not None:
job.rows = update.rows
@@ -146,16 +157,8 @@ def apply_update(job: Job, update: ParsedUpdate) -> None:
if update.batch_total is not None:
job.batch.total = update.batch_total
- if update.stage in {
- "profiling",
- "generating",
- "sampling",
- "healthcheck",
- "dag",
- "create",
- "preview",
- }:
- # usage summary is a short block; reset once we move into the next stage.
+ if update.stage in USAGE_RESET_STAGES:
+ # usage summary is a short block so we reset once we move into the next stage.
job._in_usage_summary = False
if update.usage_section_start is not None:
diff --git a/studio/backend/core/data_recipe/jobs/worker.py b/studio/backend/core/data_recipe/jobs/worker.py
index ac27cd0d0b..8c0996b140 100644
--- a/studio/backend/core/data_recipe/jobs/worker.py
+++ b/studio/backend/core/data_recipe/jobs/worker.py
@@ -7,6 +7,8 @@ import traceback
from pathlib import Path
from typing import Any
+from ..jsonable import to_jsonable
+from .constants import EVENT_JOB_COMPLETED, EVENT_JOB_ERROR, EVENT_JOB_STARTED
from ..service import build_config_builder, create_data_designer
_PROJECT_ROOT = Path(__file__).resolve().parents[5]
@@ -28,36 +30,10 @@ class _QueueLogHandler(logging.Handler):
"message": record.getMessage(),
}
self._q.put(event)
- except Exception:
+ except (OSError, RuntimeError, ValueError):
pass
-def _to_jsonable(value: Any) -> Any:
- try:
- import numpy as np # type: ignore
- except Exception: # pragma: no cover
- np = None # type: ignore
-
- if np is not None:
- if isinstance(value, np.ndarray):
- return value.tolist()
- if isinstance(value, np.generic):
- return value.item()
-
- if isinstance(value, dict):
- return {str(k): _to_jsonable(v) for k, v in value.items()}
- if isinstance(value, (list, tuple, set)):
- return [_to_jsonable(v) for v in value]
-
- if hasattr(value, "isoformat") and callable(value.isoformat):
- try:
- return value.isoformat()
- except Exception:
- pass
-
- return value
-
-
def run_job_process(
*,
event_queue,
@@ -68,7 +44,7 @@ def run_job_process(
Subprocess entrypoint.
Sends events to `event_queue`.
"""
- event_queue.put({"type": "job.started", "ts": time.time()})
+ event_queue.put({"type": EVENT_JOB_STARTED, "ts": time.time()})
try:
from data_designer.config.run_config import RunConfig
@@ -103,21 +79,21 @@ def run_job_process(
analysis = (
None
if results.analysis is None
- else _to_jsonable(results.analysis.model_dump(mode="json"))
+ else to_jsonable(results.analysis.model_dump(mode="json"))
)
dataset = (
[]
if results.dataset is None
- else _to_jsonable(results.dataset.to_dict(orient="records"))
+ else to_jsonable(results.dataset.to_dict(orient="records"))
)
processor_artifacts = (
None
if results.processor_artifacts is None
- else _to_jsonable(results.processor_artifacts)
+ else to_jsonable(results.processor_artifacts)
)
event_queue.put(
{
- "type": "job.completed",
+ "type": EVENT_JOB_COMPLETED,
"ts": time.time(),
"analysis": analysis,
"dataset": dataset,
@@ -128,13 +104,13 @@ def run_job_process(
)
else:
results = designer.create(builder, num_records=rows, dataset_name=dataset_name)
- analysis = _to_jsonable(results.load_analysis().model_dump(mode="json"))
+ analysis = to_jsonable(results.load_analysis().model_dump(mode="json"))
if merge_batches:
_merge_batches_to_single_parquet(results.artifact_storage.base_dataset_path)
artifact_path = str(results.artifact_storage.base_dataset_path)
event_queue.put(
{
- "type": "job.completed",
+ "type": EVENT_JOB_COMPLETED,
"ts": time.time(),
"analysis": analysis,
"artifact_path": artifact_path,
@@ -144,7 +120,7 @@ def run_job_process(
except Exception as exc:
event_queue.put(
{
- "type": "job.error",
+ "type": EVENT_JOB_ERROR,
"ts": time.time(),
"error": str(exc),
"stack": traceback.format_exc(limit=20),
@@ -160,7 +136,7 @@ def _merge_batches_to_single_parquet(base_dataset_path: Path) -> None:
try:
from data_designer.config.utils.io_helpers import read_parquet_dataset
- except Exception:
+ except ImportError:
return
dataframe = read_parquet_dataset(parquet_dir)
diff --git a/studio/backend/core/data_recipe/jsonable.py b/studio/backend/core/data_recipe/jsonable.py
new file mode 100644
index 0000000000..aa6e1d6b2e
--- /dev/null
+++ b/studio/backend/core/data_recipe/jsonable.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import Any
+
+
+def to_jsonable(value: Any) -> Any:
+ """Convert numpy/pandas-ish values into plain JSON-safe values."""
+ try:
+ import numpy as np # type: ignore
+ except ImportError: # pragma: no cover
+ np = None # type: ignore
+
+ if np is not None:
+ if isinstance(value, np.ndarray):
+ return value.tolist()
+ if isinstance(value, np.generic):
+ return value.item()
+
+ if isinstance(value, dict):
+ return {str(k): to_jsonable(v) for k, v in value.items()}
+ if isinstance(value, (list, tuple, set)):
+ return [to_jsonable(v) for v in value]
+
+ if hasattr(value, "isoformat") and callable(value.isoformat):
+ try:
+ return value.isoformat()
+ except (TypeError, ValueError):
+ return value
+
+ return value
diff --git a/studio/backend/core/data_recipe/service.py b/studio/backend/core/data_recipe/service.py
index b04c6bbf00..2d11cb2845 100644
--- a/studio/backend/core/data_recipe/service.py
+++ b/studio/backend/core/data_recipe/service.py
@@ -3,32 +3,7 @@ from __future__ import annotations
import os
from typing import Any
-def _to_jsonable(value: Any) -> Any:
- # pydantic/fastapi can't serialize numpy arrays/scalars.
- try:
- import numpy as np # type: ignore
- except Exception: # pragma: no cover
- np = None # type: ignore
-
- if np is not None:
- if isinstance(value, np.ndarray):
- return value.tolist()
- if isinstance(value, np.generic):
- return value.item()
-
- if isinstance(value, dict):
- return {str(k): _to_jsonable(v) for k, v in value.items()}
- if isinstance(value, (list, tuple, set)):
- return [_to_jsonable(v) for v in value]
-
- # pandas Timestamp/date-like
- if hasattr(value, "isoformat") and callable(value.isoformat):
- try:
- return value.isoformat()
- except Exception:
- pass
-
- return value
+from .jsonable import to_jsonable
def build_model_providers(recipe: dict[str, Any]):
@@ -158,17 +133,17 @@ def preview_recipe(
dataset: list[dict[str, Any]] = []
if results.dataset is not None:
raw_rows = results.dataset.to_dict(orient="records")
- dataset = [_to_jsonable(row) for row in raw_rows]
+ dataset = [to_jsonable(row) for row in raw_rows]
artifacts = (
None
if results.processor_artifacts is None
- else _to_jsonable(results.processor_artifacts)
+ else to_jsonable(results.processor_artifacts)
)
analysis = (
None
if results.analysis is None
- else _to_jsonable(results.analysis.model_dump(mode="json"))
+ else to_jsonable(results.analysis.model_dump(mode="json"))
)
return dataset, artifacts, analysis
diff --git a/studio/backend/routes/data_recipe/__init__.py b/studio/backend/routes/data_recipe/__init__.py
new file mode 100644
index 0000000000..dc0301d1c9
--- /dev/null
+++ b/studio/backend/routes/data_recipe/__init__.py
@@ -0,0 +1,23 @@
+"""Data Recipe route package."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+from fastapi import APIRouter
+
+backend_path = Path(__file__).parent.parent.parent
+if str(backend_path) not in sys.path:
+ sys.path.insert(0, str(backend_path))
+
+from .jobs import router as jobs_router
+from .seed import router as seed_router
+from .validate import router as validate_router
+
+router = APIRouter()
+router.include_router(seed_router)
+router.include_router(validate_router)
+router.include_router(jobs_router)
+
+__all__ = ["router"]
diff --git a/studio/backend/routes/data_recipe/jobs.py b/studio/backend/routes/data_recipe/jobs.py
new file mode 100644
index 0000000000..ffbded9474
--- /dev/null
+++ b/studio/backend/routes/data_recipe/jobs.py
@@ -0,0 +1,143 @@
+"""Job lifecycle endpoints for data recipe."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Query, Request
+from fastapi.responses import JSONResponse, StreamingResponse
+from pydantic import ValidationError
+
+from core.data_recipe.jobs import get_job_manager
+from models.data_recipe import JobCreateResponse, RecipePayload
+
+router = APIRouter()
+
+
+@router.post("/jobs", response_class=JSONResponse, response_model=JobCreateResponse)
+def create_job(payload: RecipePayload):
+ recipe = payload.recipe
+ if not recipe.get("columns"):
+ raise HTTPException(status_code=400, detail="Recipe must include columns.")
+
+ run: dict[str, Any] = payload.run or {}
+ run.pop("artifact_path", None)
+ run.pop("dataset_name", None)
+ execution_type = str(run.get("execution_type") or "full").strip().lower()
+ if execution_type not in {"preview", "full"}:
+ raise HTTPException(status_code=400, detail="invalid execution_type: must be 'preview' or 'full'")
+ run["execution_type"] = execution_type
+ run_config_raw = run.get("run_config")
+ if run_config_raw is not None:
+ try:
+ from data_designer.config.run_config import RunConfig
+
+ RunConfig.model_validate(run_config_raw)
+ except (ImportError, ValidationError, TypeError, ValueError) as exc:
+ raise HTTPException(status_code=400, detail=f"invalid run_config: {exc}") from exc
+
+ mgr = get_job_manager()
+ try:
+ job_id = mgr.start(recipe=recipe, run=run)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+ return {"job_id": job_id}
+
+
+@router.get("/jobs/{job_id}/status")
+def job_status(job_id: str):
+ mgr = get_job_manager()
+ state = mgr.get_status(job_id)
+ if state is None:
+ raise HTTPException(status_code=404, detail="job not found")
+ return state
+
+
+@router.get("/jobs/current")
+def current_job():
+ mgr = get_job_manager()
+ state = mgr.get_current_status()
+ if state is None:
+ raise HTTPException(status_code=404, detail="no job")
+ return state
+
+
+@router.post("/jobs/{job_id}/cancel")
+def cancel_job(job_id: str):
+ mgr = get_job_manager()
+ ok = mgr.cancel(job_id)
+ if not ok:
+ raise HTTPException(status_code=404, detail="job not found")
+ return mgr.get_status(job_id)
+
+
+@router.get("/jobs/{job_id}/analysis")
+def job_analysis(job_id: str):
+ mgr = get_job_manager()
+ analysis = mgr.get_analysis(job_id)
+ if analysis is None:
+ raise HTTPException(status_code=404, detail="analysis not ready")
+ return analysis
+
+
+@router.get("/jobs/{job_id}/dataset")
+def job_dataset(
+ job_id: str,
+ limit: int = Query(default=20, ge=1, le=500),
+ offset: int = Query(default=0, ge=0),
+):
+ mgr = get_job_manager()
+ result = mgr.get_dataset(job_id, limit=limit, offset=offset)
+ if result is None:
+ raise HTTPException(status_code=404, detail="dataset not ready")
+ if "error" in result:
+ raise HTTPException(status_code=422, detail=result["error"])
+ return {
+ "dataset": result["dataset"],
+ "total": result["total"],
+ "limit": limit,
+ "offset": offset,
+ }
+
+
+@router.get("/jobs/{job_id}/events")
+async def job_events(request: Request, job_id: str):
+ mgr = get_job_manager()
+ last_id = request.headers.get("last-event-id")
+ after_seq: int | None = None
+ if last_id:
+ try:
+ after_seq = int(str(last_id).strip())
+ except (TypeError, ValueError):
+ after_seq = None
+
+ after_q = request.query_params.get("after")
+ if after_q:
+ try:
+ after_seq = int(str(after_q).strip())
+ except (TypeError, ValueError):
+ pass
+
+ sub = mgr.subscribe(job_id, after_seq=after_seq)
+ if sub is None:
+ raise HTTPException(status_code=404, detail="job not found")
+
+ async def gen():
+ try:
+ for event in sub.replay:
+ yield sub.format_sse(event)
+
+ while True:
+ if await request.is_disconnected():
+ break
+ event = await sub.next_event(timeout_sec=1.0)
+ if event is None:
+ continue
+ yield sub.format_sse(event)
+ finally:
+ mgr.unsubscribe(sub)
+
+ return StreamingResponse(gen(), media_type="text/event-stream")
diff --git a/studio/backend/routes/data_recipe.py b/studio/backend/routes/data_recipe/seed.py
similarity index 53%
rename from studio/backend/routes/data_recipe.py
rename to studio/backend/routes/data_recipe/seed.py
index 8a713f2cec..eb02ab2bbf 100644
--- a/studio/backend/routes/data_recipe.py
+++ b/studio/backend/routes/data_recipe/seed.py
@@ -1,42 +1,24 @@
-"""
-Data Recipe routes (DataDesigner runner).
-"""
+"""Seed inspect endpoints for data recipe."""
from __future__ import annotations
import base64
import binascii
-import sys
from itertools import islice
from pathlib import Path
from typing import Any
from uuid import uuid4
-from fastapi import APIRouter, HTTPException, Query, Request
-from fastapi.responses import JSONResponse, StreamingResponse
+from fastapi import APIRouter, HTTPException
-# same thing as other files do
-backend_path = Path(__file__).parent.parent.parent
-if str(backend_path) not in sys.path:
- sys.path.insert(0, str(backend_path))
-
-from core.data_recipe.jobs import get_job_manager
-from core.data_recipe.service import (
- build_config_builder,
- create_data_designer,
- validate_recipe,
-)
from models.data_recipe import (
- JobCreateResponse,
- RecipePayload,
SeedInspectRequest,
- SeedInspectUploadRequest,
SeedInspectResponse,
- ValidateError,
- ValidateResponse,
+ SeedInspectUploadRequest,
)
router = APIRouter()
+
DATA_EXTS = (".parquet", ".jsonl", ".json", ".csv")
DEFAULT_SPLIT = "train"
LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl"}
@@ -70,20 +52,21 @@ def _normalize_optional_text(value: str | None) -> str | None:
def _list_hf_data_files(*, dataset_name: str, token: str | None) -> list[str]:
try:
from huggingface_hub import HfApi
-
+ from huggingface_hub.utils import HfHubHTTPError
+ except ImportError:
+ return []
+ try:
api = HfApi()
repo_files = api.list_repo_files(dataset_name, repo_type="dataset", token=token)
return [file for file in repo_files if file.lower().endswith(DATA_EXTS)]
- except Exception:
+ except (HfHubHTTPError, OSError, ValueError):
return []
-def _select_best_file(data_files: list[str], split: str | None) -> str | None:
+def _select_best_file(data_files: list[str]) -> str | None:
if not data_files:
return None
- if not split:
- return data_files[0]
- split_lower = split.lower()
+ split_lower = DEFAULT_SPLIT
def score(path: str) -> tuple[int, int]:
name = path.lower()
@@ -102,8 +85,8 @@ def _select_best_file(data_files: list[str], split: str | None) -> str | None:
return sorted(data_files, key=score)[0]
-def _resolve_seed_hf_path(dataset_name: str, data_files: list[str], split: str | None) -> str | None:
- selected = _select_best_file(data_files, split)
+def _resolve_seed_hf_path(dataset_name: str, data_files: list[str]) -> str | None:
+ selected = _select_best_file(data_files)
if not selected:
return None
@@ -177,7 +160,7 @@ def _decode_base64_payload(content_base64: str) -> bytes:
def _read_preview_rows_from_local_file(path: Path, preview_size: int) -> list[dict[str, Any]]:
try:
import pandas as pd
- except Exception as exc:
+ except ImportError as exc:
raise HTTPException(status_code=500, detail=f"seed inspect dependencies unavailable: {exc}") from exc
ext = path.suffix.lower()
@@ -189,68 +172,19 @@ def _read_preview_rows_from_local_file(path: Path, preview_size: int) -> list[di
elif ext == ".json":
try:
df = pd.read_json(path, lines=True).head(preview_size)
- except Exception:
+ except ValueError:
df = pd.read_json(path).head(preview_size)
else:
raise HTTPException(status_code=422, detail=f"unsupported file type: {ext}")
except HTTPException:
raise
- except Exception as exc:
+ except (ValueError, OSError) as exc:
raise HTTPException(status_code=422, detail=f"seed inspect failed: {exc}") from exc
rows = df.to_dict(orient="records")
return _serialize_preview_rows(rows)
-def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
- try:
- from data_designer.engine.compiler import (
- _add_internal_row_id_column_if_needed,
- _get_allowed_references,
- _resolve_and_add_seed_columns,
- )
- from data_designer.engine.validation import (
- ViolationLevel,
- validate_data_designer_config,
- )
- except Exception:
- return []
-
- try:
- builder = build_config_builder(recipe)
- designer = create_data_designer(recipe)
- resource_provider = designer._create_resource_provider( # type: ignore[attr-defined]
- "validate-configuration",
- builder,
- )
- config = builder.build()
- _resolve_and_add_seed_columns(config, resource_provider.seed_reader)
- _add_internal_row_id_column_if_needed(config)
- violations = validate_data_designer_config(
- columns=config.columns,
- processor_configs=config.processors or [],
- allowed_references=_get_allowed_references(config),
- )
- except Exception:
- return []
-
- errors: list[ValidateError] = []
- for violation in violations:
- if violation.level != ViolationLevel.ERROR:
- continue
- code = getattr(violation.type, "value", None)
- path = violation.column if violation.column else None
- message = str(violation.message).strip() or "Validation failed."
- errors.append(
- ValidateError(
- message=message,
- path=path,
- code=code,
- )
- )
- return errors
-
-
@router.post("/seed/inspect", response_model=SeedInspectResponse)
def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
dataset_name = payload.dataset_name.strip()
@@ -259,10 +193,10 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
try:
from datasets import load_dataset
- except Exception as exc:
+ except ImportError as exc:
raise HTTPException(status_code=500, detail=f"seed inspect dependencies unavailable: {exc}") from exc
- split = (payload.split or DEFAULT_SPLIT).strip() or DEFAULT_SPLIT
+ split = DEFAULT_SPLIT
subset = _normalize_optional_text(payload.subset)
token = _normalize_optional_text(payload.hf_token)
preview_size = int(payload.preview_size)
@@ -270,7 +204,7 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
preview_rows: list[dict[str, Any]] = []
data_files = _list_hf_data_files(dataset_name=dataset_name, token=token)
- selected_file = _select_best_file(data_files, split)
+ selected_file = _select_best_file(data_files)
if selected_file:
try:
single_file_kwargs = _build_stream_load_kwargs(
@@ -285,7 +219,7 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
load_kwargs=single_file_kwargs,
preview_size=preview_size,
)
- except Exception:
+ except (ValueError, OSError, RuntimeError):
preview_rows = []
if not preview_rows:
@@ -301,7 +235,7 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
load_kwargs=split_kwargs,
preview_size=preview_size,
)
- except Exception as exc:
+ except (ValueError, OSError, RuntimeError) as exc:
raise HTTPException(status_code=422, detail=f"seed inspect failed: {exc}") from exc
if not preview_rows:
@@ -310,10 +244,9 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
columns = _extract_columns(preview_rows)
if not data_files:
- # Best effort path fallback when file list is unavailable.
resolved_path = f"datasets/{dataset_name}/**/*.parquet"
else:
- resolved_path = _resolve_seed_hf_path(dataset_name, data_files, split)
+ resolved_path = _resolve_seed_hf_path(dataset_name, data_files)
if not resolved_path:
raise HTTPException(status_code=422, detail="unable to resolve seed dataset path")
@@ -322,7 +255,7 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
resolved_path=resolved_path,
columns=columns,
preview_rows=preview_rows,
- split=split,
+ split=None,
subset=subset,
)
@@ -363,159 +296,3 @@ def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectRespons
split=None,
subset=None,
)
-
-
-@router.post("/validate", response_model=ValidateResponse)
-def validate(payload: RecipePayload) -> ValidateResponse:
- recipe = payload.recipe
- if not recipe.get("columns"):
- return ValidateResponse(
- valid=False,
- errors=[ValidateError(message="Recipe must include columns.")],
- )
-
- try:
- validate_recipe(recipe)
- except RuntimeError as exc:
- raise HTTPException(status_code=503, detail=str(exc)) from exc
- except Exception as exc:
- detail = str(exc).strip() or "Validation failed."
- parsed_errors = _collect_validation_errors(recipe)
- return ValidateResponse(
- valid=False,
- errors=parsed_errors or [ValidateError(message=detail)],
- raw_detail=detail,
- )
-
- return ValidateResponse(valid=True)
-
-
-@router.post("/jobs", response_class=JSONResponse, response_model=JobCreateResponse)
-def create_job(payload: RecipePayload):
- recipe = payload.recipe
- if not recipe.get("columns"):
- raise HTTPException(status_code=400, detail="Recipe must include columns.")
-
- run: dict[str, Any] = payload.run or {}
- run.pop("artifact_path", None)
- run.pop("dataset_name", None)
- execution_type = str(run.get("execution_type") or "full").strip().lower()
- if execution_type not in {"preview", "full"}:
- raise HTTPException(status_code=400, detail="invalid execution_type: must be 'preview' or 'full'")
- run["execution_type"] = execution_type
- run_config_raw = run.get("run_config")
- if run_config_raw is not None:
- try:
- from data_designer.config.run_config import RunConfig
-
- RunConfig.model_validate(run_config_raw)
- except Exception as exc:
- raise HTTPException(status_code=400, detail=f"invalid run_config: {exc}") from exc
-
- mgr = get_job_manager()
- try:
- job_id = mgr.start(recipe=recipe, run=run)
- except RuntimeError as exc:
- raise HTTPException(status_code=409, detail=str(exc)) from exc
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
-
- return {"job_id": job_id}
-
-
-@router.get("/jobs/{job_id}/status")
-def job_status(job_id: str):
- mgr = get_job_manager()
- state = mgr.get_status(job_id)
- if state is None:
- raise HTTPException(status_code=404, detail="job not found")
- return state
-
-
-@router.get("/jobs/current")
-def current_job():
- mgr = get_job_manager()
- state = mgr.get_current_status()
- if state is None:
- raise HTTPException(status_code=404, detail="no job")
- return state
-
-
-@router.post("/jobs/{job_id}/cancel")
-def cancel_job(job_id: str):
- mgr = get_job_manager()
- ok = mgr.cancel(job_id)
- if not ok:
- raise HTTPException(status_code=404, detail="job not found")
- return mgr.get_status(job_id)
-
-
-@router.get("/jobs/{job_id}/analysis")
-def job_analysis(job_id: str):
- mgr = get_job_manager()
- analysis = mgr.get_analysis(job_id)
- if analysis is None:
- raise HTTPException(status_code=404, detail="analysis not ready")
- return analysis
-
-
-@router.get("/jobs/{job_id}/dataset")
-def job_dataset(
- job_id: str,
- limit: int = Query(default=20, ge=1, le=500),
- offset: int = Query(default=0, ge=0),
-):
- mgr = get_job_manager()
- result = mgr.get_dataset(job_id, limit=limit, offset=offset)
- if result is None:
- raise HTTPException(status_code=404, detail="dataset not ready")
- if "error" in result:
- raise HTTPException(status_code=422, detail=result["error"])
- return {
- "dataset": result["dataset"],
- "total": result["total"],
- "limit": limit,
- "offset": offset,
- }
-
-
-@router.get("/jobs/{job_id}/events")
-async def job_events(request: Request, job_id: str):
- mgr = get_job_manager()
- last_id = request.headers.get("last-event-id")
- after_seq: int | None = None
- if last_id:
- try:
- after_seq = int(str(last_id).strip())
- except Exception:
- after_seq = None
-
- # EventSource can't set custom headers on first connect after a full page refresh,
- # so allow resume via query param too: /events?after=
- after_q = request.query_params.get("after")
- if after_q:
- try:
- after_seq = int(str(after_q).strip())
- except Exception:
- pass
-
- sub = mgr.subscribe(job_id, after_seq=after_seq)
- if sub is None:
- raise HTTPException(status_code=404, detail="job not found")
-
- async def gen():
- try:
- for event in sub.replay:
- yield sub.format_sse(event)
-
- while True:
- if await request.is_disconnected():
- break
- event = await sub.next_event(timeout_sec=1.0)
- if event is None:
- continue
- yield sub.format_sse(event)
- finally:
- mgr.unsubscribe(sub)
-
- return StreamingResponse(gen(), media_type="text/event-stream")
diff --git a/studio/backend/routes/data_recipe/validate.py b/studio/backend/routes/data_recipe/validate.py
new file mode 100644
index 0000000000..a8755f9410
--- /dev/null
+++ b/studio/backend/routes/data_recipe/validate.py
@@ -0,0 +1,90 @@
+"""Validation endpoints for data recipe."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+
+from core.data_recipe.service import (
+ build_config_builder,
+ create_data_designer,
+ validate_recipe,
+)
+from models.data_recipe import RecipePayload, ValidateError, ValidateResponse
+
+router = APIRouter()
+
+
+def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
+ try:
+ from data_designer.engine.compiler import (
+ _add_internal_row_id_column_if_needed,
+ _get_allowed_references,
+ _resolve_and_add_seed_columns,
+ )
+ from data_designer.engine.validation import (
+ ViolationLevel,
+ validate_data_designer_config,
+ )
+ except ImportError:
+ return []
+
+ try:
+ builder = build_config_builder(recipe)
+ designer = create_data_designer(recipe)
+ resource_provider = designer._create_resource_provider( # type: ignore[attr-defined]
+ "validate-configuration",
+ builder,
+ )
+ config = builder.build()
+ _resolve_and_add_seed_columns(config, resource_provider.seed_reader)
+ _add_internal_row_id_column_if_needed(config)
+ violations = validate_data_designer_config(
+ columns=config.columns,
+ processor_configs=config.processors or [],
+ allowed_references=_get_allowed_references(config),
+ )
+ except (TypeError, ValueError, AttributeError):
+ return []
+
+ errors: list[ValidateError] = []
+ for violation in violations:
+ if violation.level != ViolationLevel.ERROR:
+ continue
+ code = getattr(violation.type, "value", None)
+ path = violation.column if violation.column else None
+ message = str(violation.message).strip() or "Validation failed."
+ errors.append(
+ ValidateError(
+ message=message,
+ path=path,
+ code=code,
+ )
+ )
+ return errors
+
+
+@router.post("/validate", response_model=ValidateResponse)
+def validate(payload: RecipePayload) -> ValidateResponse:
+ recipe = payload.recipe
+ if not recipe.get("columns"):
+ return ValidateResponse(
+ valid=False,
+ errors=[ValidateError(message="Recipe must include columns.")],
+ )
+
+ try:
+ validate_recipe(recipe)
+ except RuntimeError as exc:
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
+ except Exception as exc:
+ detail = str(exc).strip() or "Validation failed."
+ parsed_errors = _collect_validation_errors(recipe)
+ return ValidateResponse(
+ valid=False,
+ errors=parsed_errors or [ValidateError(message=detail)],
+ raw_detail=detail,
+ )
+
+ return ValidateResponse(valid=True)
diff --git a/studio/frontend/src/components/ui/combobox.tsx b/studio/frontend/src/components/ui/combobox.tsx
index 9c1e970c57..8ccc40c95f 100644
--- a/studio/frontend/src/components/ui/combobox.tsx
+++ b/studio/frontend/src/components/ui/combobox.tsx
@@ -162,20 +162,20 @@ function ComboboxContent({
-
+ align={align}
+ alignOffset={alignOffset}
+ anchor={anchor}
+ className="isolate z-[120] pointer-events-auto"
+ >
+
);
diff --git a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
index 75d860290e..073cce928e 100644
--- a/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
+++ b/studio/frontend/src/features/recipe-studio/blocks/definitions.ts
@@ -125,7 +125,7 @@ const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "seed",
type: "seed_local",
- title: "Local file",
+ title: "Structured file",
description: "Upload CSV/JSON/JSONL and use rows as seed context.",
icon: DocumentCodeIcon,
dialogKey: "seed",
diff --git a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
index d9e278c05d..be301a36fd 100644
--- a/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/block-sheet.tsx
@@ -1,4 +1,5 @@
import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import {
Sheet,
SheetContent,
@@ -12,17 +13,24 @@ import {
CodeIcon,
Copy02Icon,
type Database02Icon,
+ DragDropVerticalIcon,
PlusSignIcon,
Tick02Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { type ReactElement, useMemo, useState } from "react";
+import {
+ type DragEvent as ReactDragEvent,
+ type ReactElement,
+ useMemo,
+ useState,
+} from "react";
import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "./recipe-floating-icon-button-class";
import type { LlmType, SamplerType } from "../types";
import {
BLOCK_GROUPS,
getBlocksForKind,
+ type BlockType,
type SeedBlockType,
} from "../blocks/registry";
@@ -62,6 +70,12 @@ type BlockSheetProps = {
onImport: () => void;
};
+export const RECIPE_BLOCK_DND_MIME = "application/x-recipe-studio-block";
+export type RecipeBlockDragPayload = {
+ kind: SheetKind;
+ type: BlockType;
+};
+
function getSheetTitle(sheetView: SheetView): string {
if (sheetView === "root") {
return "Add a block";
@@ -103,6 +117,15 @@ const ROOT_GROUPS: RootGroup[] = [
icon: CodeIcon,
},
];
+const SEARCHABLE_KINDS: SheetKind[] = [
+ "sampler",
+ "seed",
+ "llm",
+ "expression",
+ "note",
+];
+const PROCESSOR_TITLE = "Schema Transform";
+const PROCESSOR_DESCRIPTION = "Transform final dataset schema.";
function BlockSheetButton({
icon,
@@ -110,22 +133,30 @@ function BlockSheetButton({
description,
onClick,
isActive = false,
+ draggable = false,
+ onDragStart,
+ trailing = "chevron",
}: {
icon: typeof Database02Icon;
title: string;
description: string;
onClick: () => void;
isActive?: boolean;
+ draggable?: boolean;
+ onDragStart?: (event: ReactDragEvent) => void;
+ trailing?: "chevron" | "drag" | "none";
}): ReactElement {
return (
);
}
@@ -162,11 +201,17 @@ export function BlockSheet({
}: BlockSheetProps): ReactElement {
const sheetTitle = getSheetTitle(sheetView);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
+ const [search, setSearch] = useState("");
const expressionBlocks = useMemo(() => getBlocksForKind("expression"), []);
const noteBlocks = useMemo(() => getBlocksForKind("note"), []);
const seedBlocks = useMemo(() => getBlocksForKind("seed"), []);
const isControlled = typeof open === "boolean";
const sheetOpen = isControlled ? (open as boolean) : uncontrolledOpen;
+ const normalizedSearch = search.trim().toLowerCase();
+ const hasSearch = normalizedSearch.length > 0;
+ const isProcessorView = sheetView === "processor";
+ const isRootView = sheetView === "root";
+ const isScopedBlockView = !isRootView && !isProcessorView;
const setSheetOpen = (nextOpen: boolean) => {
if (!isControlled) {
@@ -174,6 +219,95 @@ export function BlockSheet({
}
onOpenChange?.(nextOpen);
};
+ const matchesSearch = (title: string, description: string) =>
+ title.toLowerCase().includes(normalizedSearch) ||
+ description.toLowerCase().includes(normalizedSearch);
+
+ const searchableBlocks = useMemo(
+ () => SEARCHABLE_KINDS.flatMap((kind) => getBlocksForKind(kind)),
+ [],
+ );
+ const rootSearchBlocks = useMemo(() => {
+ if (!hasSearch) {
+ return [];
+ }
+ return searchableBlocks.filter((item) =>
+ matchesSearch(item.title, item.description),
+ );
+ }, [hasSearch, searchableBlocks, normalizedSearch]);
+
+ const scopedBlocks = useMemo(() => {
+ if (!isScopedBlockView) {
+ return [];
+ }
+ const blocks = getBlocksForKind(VIEW_KIND[sheetView] ?? "sampler");
+ if (!hasSearch) {
+ return blocks;
+ }
+ return blocks.filter((item) => matchesSearch(item.title, item.description));
+ }, [hasSearch, isScopedBlockView, normalizedSearch, sheetView]);
+
+ const rootGroups = useMemo(() => {
+ if (!hasSearch) {
+ return ROOT_GROUPS;
+ }
+ return ROOT_GROUPS.filter((group) => {
+ if (matchesSearch(group.title, group.description)) {
+ return true;
+ }
+ if (group.kind === "processor") {
+ return matchesSearch(PROCESSOR_TITLE, PROCESSOR_DESCRIPTION);
+ }
+ return getBlocksForKind(group.kind).some((item) =>
+ matchesSearch(item.title, item.description),
+ );
+ });
+ }, [hasSearch, normalizedSearch]);
+ const showNoMatches =
+ (isRootView && hasSearch && rootSearchBlocks.length === 0) ||
+ (isScopedBlockView && scopedBlocks.length === 0) ||
+ (isProcessorView &&
+ hasSearch &&
+ !matchesSearch(PROCESSOR_TITLE, PROCESSOR_DESCRIPTION));
+
+ const buildDragStart =
+ (kind: SheetKind, type: BlockType) =>
+ (event: ReactDragEvent) => {
+ const payload: RecipeBlockDragPayload = { kind, type };
+ const serialized = JSON.stringify(payload);
+ event.dataTransfer.setData(RECIPE_BLOCK_DND_MIME, serialized);
+ event.dataTransfer.setData("text/plain", serialized);
+ event.dataTransfer.effectAllowed = "copy";
+ };
+ const getTrailing = (_kind: SheetKind): "drag" => "drag";
+ const onBlockClick = (kind: SheetKind, type: BlockType) => {
+ setSheetOpen(false);
+ if (kind === "sampler") {
+ onAddSampler(type as SamplerType);
+ return;
+ }
+ if (kind === "seed") {
+ onAddSeed(type as SeedBlockType);
+ return;
+ }
+ if (kind === "llm") {
+ if (type === "model_provider") {
+ onAddModelProvider();
+ return;
+ }
+ if (type === "model_config") {
+ onAddModelConfig();
+ return;
+ }
+ onAddLlm(type as LlmType);
+ return;
+ }
+ if (kind === "expression") {
+ onAddExpression();
+ return;
+ }
+ onAddMarkdownNote();
+ };
return (
@@ -183,6 +317,7 @@ export function BlockSheet({
setSheetOpen(nextOpen);
if (nextOpen) {
onViewChange("root");
+ setSearch("");
}
}}
>
@@ -206,7 +341,7 @@ export function BlockSheet({
className="absolute gap-0 p-0 shadow-none"
overlayClassName="bg-transparent pointer-events-none backdrop-blur-none supports-backdrop-filter:backdrop-blur-none"
>
-
+
{sheetView !== "root" && (
+ setSearch(event.target.value)}
+ placeholder="Search blocks..."
+ className="corner-squircle mt-3 h-9"
+ />
- {sheetView === "root" &&
- ROOT_GROUPS.map((item, index) => (
+ {isRootView &&
+ hasSearch &&
+ rootSearchBlocks.map((item, index) => (
+
onBlockClick(item.kind, item.type)}
+ />
+ ))}
+ {isRootView &&
+ !hasSearch &&
+ rootGroups.map((item, index) => (
{
if (item.kind === "processor") {
setSheetOpen(false);
@@ -256,18 +426,20 @@ export function BlockSheet({
}}
/>
))}
- {sheetView === "processor" && (
-
+ {isProcessorView && (
+ (!hasSearch ||
+ matchesSearch(PROCESSOR_TITLE, PROCESSOR_DESCRIPTION)) && (
+
+ )
)}
- {sheetView !== "root" &&
- sheetView !== "processor" &&
- getBlocksForKind(VIEW_KIND[sheetView] ?? "sampler").map(
+ {isScopedBlockView &&
+ scopedBlocks.map(
(item, index) => (
{
- setSheetOpen(false);
- if (item.kind === "sampler") {
- onAddSampler(item.type as SamplerType);
- } else if (item.kind === "seed") {
- onAddSeed(item.type as SeedBlockType);
- } else if (item.kind === "llm") {
- if (item.type === "model_provider") {
- onAddModelProvider();
- } else if (item.type === "model_config") {
- onAddModelConfig();
- } else {
- onAddLlm(item.type as LlmType);
- }
- } else if (item.kind === "expression") {
- onAddExpression();
- } else {
- onAddMarkdownNote();
- }
- }}
+ draggable={true}
+ onDragStart={buildDragStart(item.kind, item.type)}
+ trailing={getTrailing(item.kind)}
+ onClick={() => onBlockClick(item.kind, item.type)}
/>
),
)}
+ {showNoMatches && (
+
+ No blocks match.
+
+ )}
diff --git a/studio/frontend/src/features/recipe-studio/components/chip-input.tsx b/studio/frontend/src/features/recipe-studio/components/chip-input.tsx
index 5162b73e03..a7dd5ccc16 100644
--- a/studio/frontend/src/features/recipe-studio/components/chip-input.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/chip-input.tsx
@@ -1,7 +1,15 @@
import { Button } from "@/components/ui/button";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
-import { type KeyboardEvent, type ReactElement, useId, useMemo, useState } from "react";
+import {
+ type KeyboardEvent,
+ type ReactElement,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
type ChipInputProps = {
values: string[];
@@ -19,12 +27,28 @@ export function ChipInput({
suggestions,
}: ChipInputProps): ReactElement {
const [draft, setDraft] = useState("");
+ const [isWrapped, setIsWrapped] = useState(false);
+ const containerRef = useRef(null);
const listId = useId();
const suggestionSet = useMemo(
() => new Set((suggestions ?? []).map((value) => value.trim())),
[suggestions],
);
+ useEffect(() => {
+ const element = containerRef.current;
+ if (!element) {
+ return;
+ }
+ const syncWrapped = () => {
+ setIsWrapped(element.clientHeight > 44);
+ };
+ syncWrapped();
+ const observer = new ResizeObserver(syncWrapped);
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [values.length, draft]);
+
function addValue(rawValue: string, allowAny: boolean): void {
const trimmed = rawValue.trim();
if (!trimmed) {
@@ -55,7 +79,10 @@ export function ChipInput({
}
return (
-
+
{values.map((value, index) => (
{
refreshNodeInternals();
requestAnimationFrame(() => {
- fitView({ duration: 250 });
+ fitView({
+ duration: 250,
+ nodes: getFitNodeIdsIgnoringNotes(getNodes()),
+ });
});
});
- }, [fitView, onLayout, refreshNodeInternals]);
+ }, [fitView, getNodes, onLayout, refreshNodeInternals]);
const handleToggleDirection = useCallback(() => {
onToggleDirection();
requestAnimationFrame(() => {
- refreshNodeInternals();
+ onLayout();
requestAnimationFrame(() => {
refreshNodeInternals();
+ requestAnimationFrame(() => {
+ fitView({
+ duration: 250,
+ nodes: getFitNodeIdsIgnoringNotes(getNodes()),
+ });
+ });
});
});
- }, [onToggleDirection, refreshNodeInternals]);
+ }, [fitView, getNodes, onLayout, onToggleDirection, refreshNodeInternals]);
return (
diff --git a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx
index 27e225f39e..e104841100 100644
--- a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx
@@ -2,6 +2,7 @@ import { type ReactElement, useCallback } from "react";
import { Lock, LockOpen, Maximize2, Minus, Plus } from "lucide-react";
import { Panel, useReactFlow } from "@xyflow/react";
import { Button } from "@/components/ui/button";
+import { getFitNodeIdsIgnoringNotes } from "../../utils/graph/fit-view";
import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-button-class";
type ViewportControlsProps = {
@@ -13,7 +14,7 @@ export function ViewportControls({
interactive,
onToggleInteractive,
}: ViewportControlsProps): ReactElement {
- const { zoomIn, zoomOut, fitView } = useReactFlow();
+ const { zoomIn, zoomOut, fitView, getNodes } = useReactFlow();
const handleZoomIn = useCallback(() => {
zoomIn({ duration: 150 });
@@ -24,8 +25,11 @@ export function ViewportControls({
}, [zoomOut]);
const handleFitView = useCallback(() => {
- fitView({ duration: 250 });
- }, [fitView]);
+ fitView({
+ duration: 250,
+ nodes: getFitNodeIdsIgnoringNotes(getNodes()),
+ });
+ }, [fitView, getNodes]);
return (
diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-expression.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-expression.tsx
index 94c5470f49..206a068a78 100644
--- a/studio/frontend/src/features/recipe-studio/components/inline/inline-expression.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-expression.tsx
@@ -1,4 +1,3 @@
-import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -10,7 +9,9 @@ import {
import type { ReactElement } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
+import { findInvalidJinjaReferences } from "../../utils/refs";
import { getAvailableVariableEntries } from "../../utils/variables";
+import { AvailableReferencesInline } from "../shared/available-references-inline";
import { InlineField } from "./inline-field";
type InlineExpressionProps = {
@@ -26,6 +27,10 @@ export function InlineExpression({
}: InlineExpressionProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const vars = getAvailableVariableEntries(configs, config.id);
+ const invalidRefs = findInvalidJinjaReferences(
+ config.expr,
+ vars.map((entry) => entry.name),
+ );
return (
@@ -52,32 +57,14 @@ export function InlineExpression({
0}
placeholder="{{ column_name }}"
value={config.expr}
onChange={(event) => onUpdate({ expr: event.target.value })}
/>
- {vars.length > 0 && (
-
-
Available references
-
- {vars.map((v) => (
-
- {v.name}
-
- ))}
-
-
- )}
+
);
}
diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx
index 5316b0f297..2c2c2ffd41 100644
--- a/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-model.tsx
@@ -14,19 +14,6 @@ export function InlineModel(props: InlineModelProps): ReactElement {
if (props.config.kind === "model_provider") {
return (
-
-
- props.onUpdate({
- // biome-ignore lint/style/useNamingConvention: api schema
- provider_type: event.target.value,
- })
- }
- />
-
props.onUpdate({ endpoint: event.target.value })}
/>
+
+
+ props.onUpdate({
+ // biome-ignore lint/style/useNamingConvention: api schema
+ api_key: event.target.value,
+ })
+ }
+ />
+
);
}
diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-policy.ts b/studio/frontend/src/features/recipe-studio/components/inline/inline-policy.ts
index 97f441edce..17f22b6557 100644
--- a/studio/frontend/src/features/recipe-studio/components/inline/inline-policy.ts
+++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-policy.ts
@@ -27,6 +27,9 @@ export function getConfigUiMode(
}
return "dialog";
}
+ if (config.kind === "seed") {
+ return "inline";
+ }
if (config.kind === "expression") {
return "inline";
}
diff --git a/studio/frontend/src/features/recipe-studio/components/inline/inline-seed.tsx b/studio/frontend/src/features/recipe-studio/components/inline/inline-seed.tsx
new file mode 100644
index 0000000000..619faa7704
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/components/inline/inline-seed.tsx
@@ -0,0 +1,66 @@
+import { DocumentAttachmentIcon, DocumentCodeIcon, Plant01Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { ReactElement } from "react";
+import type { SeedConfig } from "../../types";
+import { HfDatasetCombobox } from "../shared/hf-dataset-combobox";
+import { InlineField } from "./inline-field";
+
+type InlineSeedProps = {
+ config: SeedConfig;
+ onUpdate: (patch: Partial
) => void;
+};
+
+export function InlineSeed({ config, onUpdate }: InlineSeedProps): ReactElement {
+ const mode = config.seed_source_type ?? "hf";
+
+ if (mode === "hf") {
+ return (
+
+
+
+ onUpdate({
+ hf_repo_id: next,
+ hf_path: "",
+ seed_columns: [],
+ seed_drop_columns: [],
+ seed_preview_rows: [],
+ })
+ }
+ placeholder="org/repo"
+ />
+
+
+ Load columns in dialog.
+
+
+ );
+ }
+
+ const isLocal = mode === "local";
+ const fileName = isLocal
+ ? config.local_file_name?.trim()
+ : config.unstructured_file_name?.trim();
+
+ return (
+
+
+
+
+
+
+ {fileName || "No file selected"}
+
+
+ {isLocal ? "Structured file" : "Unstructured document"} · configure in dialog
+
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx
index c7f14ebbc4..bbb5dead41 100644
--- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-aux-node.tsx
@@ -1,24 +1,21 @@
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Handle,
- NodeResizer,
+ Position,
type Node,
type NodeProps,
useUpdateNodeInternals,
} from "@xyflow/react";
import { memo, type ReactElement, useEffect } from "react";
-import { MAX_NODE_WIDTH, MIN_NODE_WIDTH } from "../constants";
import { useRecipeStudioStore } from "../stores/recipe-studio";
-import type { LayoutDirection, LlmConfig, Score, ScoreOption } from "../types";
-import {
- AUX_HANDLE_CLASS,
- getAuxSourceHandlePosition,
-} from "../utils/handle-layout";
+import type { LlmConfig, Score, ScoreOption } from "../types";
+import { AUX_HANDLE_CLASS } from "../utils/handle-layout";
import { HANDLE_IDS } from "../utils/handles";
+import { findInvalidJinjaReferences } from "../utils/refs";
import { getAvailableVariableEntries } from "../utils/variables";
+import { AvailableReferencesInline } from "./shared/available-references-inline";
import { BaseNode, BaseNodeContent, BaseNodeHeader, BaseNodeHeaderTitle } from "./rf-ui/base-node";
type PromptField = "prompt" | "system_prompt";
@@ -28,14 +25,12 @@ type PromptInputNodeData = {
llmId: string;
field: PromptField;
title: string;
- layoutDirection: LayoutDirection;
};
type JudgeScoreNodeData = {
kind: "llm-judge-score";
llmId: string;
scoreIndex: number;
- layoutDirection: LayoutDirection;
};
export type RecipeGraphAuxNodeData = PromptInputNodeData | JudgeScoreNodeData;
@@ -62,30 +57,12 @@ function updateOptionAt(
);
}
-function AuxVariableBadges({ llmId }: { llmId: string }): ReactElement | null {
- const configs = useRecipeStudioStore((state) => state.configs);
- const vars = getAvailableVariableEntries(configs, llmId);
- if (vars.length === 0) return null;
- return (
-
-
Available references
-
- {vars.map((v) => (
-
- {v.name}
-
- ))}
-
-
- );
+function AuxVariableBadges({
+ entries,
+}: {
+ entries: ReturnType;
+}): ReactElement | null {
+ return ;
}
function AuxNodeBase({
@@ -93,6 +70,7 @@ function AuxNodeBase({
data,
}: NodeProps): ReactElement | null {
const config = useRecipeStudioStore((state) => state.configs[data.llmId]);
+ const configs = useRecipeStudioStore((state) => state.configs);
const updateConfig = useRecipeStudioStore((state) => state.updateConfig);
const updateNodeInternals = useUpdateNodeInternals();
@@ -104,30 +82,58 @@ function AuxNodeBase({
return null;
}
- const sourcePosition = getAuxSourceHandlePosition(data.layoutDirection);
+ const sourceHandles = (
+ <>
+
+
+
+
+ >
+ );
if (data.kind === "llm-prompt-input") {
const value = data.field === "prompt" ? config.prompt : config.system_prompt;
+ const variableEntries = getAvailableVariableEntries(configs, data.llmId);
+ const availableRefs = variableEntries.map((entry) => entry.name);
+ const hasInvalidRefs =
+ findInvalidJinjaReferences(value, availableRefs).length > 0;
return (
-
{data.title}
-
+ {sourceHandles}
);
}
@@ -190,18 +189,6 @@ function AuxNodeBase({
return (
-
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}
@@ -218,7 +205,7 @@ function AuxNodeBase({
onChange={(event) => updateScore({ name: event.target.value })}
/>
-
+ {sourceHandles}
);
}
diff --git a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
index 54bef62fc6..4d1bbeedc5 100644
--- a/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
+++ b/studio/frontend/src/features/recipe-studio/components/recipe-graph-node.tsx
@@ -20,7 +20,6 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
- Handle,
NodeResizer,
Position,
useUpdateNodeInternals,
@@ -32,18 +31,18 @@ import { useRecipeStudioStore } from "../stores/recipe-studio";
import type {
RecipeNode as RecipeGraphNodeType,
LlmType,
- LayoutDirection,
NodeConfig,
SamplerType,
} from "../types";
import { NODE_HANDLE_CLASS } from "../utils/handle-layout";
-import { getLlmJudgeScoreHandleId, HANDLE_IDS } from "../utils/handles";
+import { HANDLE_IDS } from "../utils/handles";
import { InlineCategoryBadges } from "./inline/inline-category-badges";
import { InlineExpression } from "./inline/inline-expression";
import { InlineLlm } from "./inline/inline-llm";
import { InlineModel } from "./inline/inline-model";
import { isInlineConfig } from "./inline/inline-policy";
import { InlineSampler } from "./inline/inline-sampler";
+import { InlineSeed } from "./inline/inline-seed";
import {
BaseNode,
BaseNodeContent,
@@ -220,7 +219,7 @@ function getConfigSummary(config: NodeConfig | undefined): string {
return "Set HF dataset repo";
}
if (seedSourceType === "local") {
- return "Upload CSV/JSON file";
+ return "Upload structured file";
}
return "Upload PDF/DOCX/TXT file";
}
@@ -259,6 +258,9 @@ function renderNodeBody(
if (config.kind === "expression") {
return ;
}
+ if (config.kind === "seed") {
+ return ;
+ }
}
if (config?.kind === "sampler" && config.sampler_type === "category") {
@@ -268,89 +270,6 @@ function renderNodeBody(
return {summary}
;
}
-type LlmInputHandleItem = {
- id: string;
- label: string;
-};
-
-function getLlmInputHandleItems(config: NodeConfig | undefined): LlmInputHandleItem[] {
- if (!(config && config.kind === "llm")) {
- return [];
- }
- const items: LlmInputHandleItem[] = [];
- if (config.system_prompt.trim()) {
- items.push({ id: HANDLE_IDS.llmSystemIn, label: "System" });
- }
- if (config.prompt.trim()) {
- items.push({ id: HANDLE_IDS.llmPromptIn, label: "Prompt" });
- }
- if (config.llm_type === "judge") {
- (config.scores ?? []).forEach((score, index) => {
- items.push({
- id: getLlmJudgeScoreHandleId(index),
- label: score.name.trim() || `Score ${index + 1}`,
- });
- });
- }
- return items;
-}
-
-type LlmInputHandlesProps = {
- items: LlmInputHandleItem[];
- layoutDirection: LayoutDirection;
-};
-
-function LlmInputHandles({
- items,
- layoutDirection,
-}: LlmInputHandlesProps): ReactElement | null {
- if (items.length === 0) {
- return null;
- }
- const isTopBottom = layoutDirection === "TB";
-
- if (isTopBottom) {
- return (
-
- {items.map((item) => (
-
-
- {item.label}
-
- ))}
-
- );
- }
-
- return (
-
- {items.map((item) => (
-
-
-
- {item.label}
-
-
- ))}
-
- );
-}
-
function RecipeGraphNodeBase({
id,
data,
@@ -418,7 +337,6 @@ function RecipeGraphNodeBase({
data.kind === "model_config" || data.kind === "model_provider";
const summary = getConfigSummary(config);
const nodeBody = renderNodeBody(config, summary, updateConfig);
- const llmInputHandles = llmAuxVisible ? getLlmInputHandleItems(config) : [];
const canShowLlmAux =
config?.kind === "llm" &&
(Boolean(config.prompt.trim()) ||
@@ -491,7 +409,6 @@ function RecipeGraphNodeBase({
-
{nodeBody}
@@ -506,6 +423,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
+
+
+
+
>
)}
diff --git a/studio/frontend/src/features/recipe-studio/components/shared/available-references-inline.tsx b/studio/frontend/src/features/recipe-studio/components/shared/available-references-inline.tsx
new file mode 100644
index 0000000000..938e5405ab
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/components/shared/available-references-inline.tsx
@@ -0,0 +1,129 @@
+import { Badge } from "@/components/ui/badge";
+import { type ReactElement, useLayoutEffect, useRef, useState } from "react";
+import type { AvailableVariableEntry } from "../../utils/variables";
+
+type AvailableReferencesInlineProps = {
+ entries: AvailableVariableEntry[];
+};
+
+const MAX_ROWS = 2;
+
+export function AvailableReferencesInline({
+ entries,
+}: AvailableReferencesInlineProps): ReactElement | null {
+ const [expanded, setExpanded] = useState(false);
+ const [collapsedCount, setCollapsedCount] = useState(entries.length);
+ const wrapperRef = useRef(null);
+ const measureRefs = useRef>([]);
+
+ useLayoutEffect(() => {
+ if (expanded) {
+ return;
+ }
+ const wrapper = wrapperRef.current;
+ const items = measureRefs.current.filter(
+ (node): node is HTMLSpanElement => Boolean(node),
+ );
+ if (!(wrapper && items.length > 0)) {
+ setCollapsedCount(entries.length);
+ return;
+ }
+
+ const compute = () => {
+ const rowTops: number[] = [];
+ let cutoff = items.length;
+ for (let i = 0; i < items.length; i += 1) {
+ const top = items[i].offsetTop;
+ if (!rowTops.some((value) => Math.abs(value - top) <= 1)) {
+ rowTops.push(top);
+ }
+ if (rowTops.length > MAX_ROWS) {
+ cutoff = i;
+ break;
+ }
+ }
+ if (cutoff < items.length) {
+ cutoff = Math.max(0, cutoff - 1);
+ }
+ setCollapsedCount(cutoff);
+ };
+
+ compute();
+ const observer = new ResizeObserver(compute);
+ observer.observe(wrapper);
+ return () => observer.disconnect();
+ }, [entries.length, expanded]);
+
+ if (entries.length === 0) {
+ return null;
+ }
+
+ const shown = expanded ? entries : entries.slice(0, collapsedCount);
+ const hiddenCount = Math.max(0, entries.length - shown.length);
+
+ return (
+
+
+ Available references
+
+
+ {!expanded && (
+
+
+ {entries.map((entry, index) => (
+ {
+ measureRefs.current[index] = node;
+ }}
+ variant="secondary"
+ className={
+ entry.source === "seed"
+ ? "corner-squircle h-4 border-blue-500/25 bg-blue-500/10 px-1.5 font-mono text-[10px] text-blue-700 dark:text-blue-300"
+ : "corner-squircle h-4 px-1.5 font-mono text-[10px]"
+ }
+ >
+ {entry.name}
+
+ ))}
+
+
+ )}
+
+ {shown.map((entry) => (
+
+ {entry.name}
+
+ ))}
+ {!expanded && hiddenCount > 0 && (
+
+ )}
+ {expanded && collapsedCount < entries.length && (
+
+ )}
+
+
+
+ );
+}
diff --git a/studio/frontend/src/features/recipe-studio/components/shared/hf-dataset-combobox.tsx b/studio/frontend/src/features/recipe-studio/components/shared/hf-dataset-combobox.tsx
new file mode 100644
index 0000000000..7377bcac69
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/components/shared/hf-dataset-combobox.tsx
@@ -0,0 +1,122 @@
+import {
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+} from "@/components/ui/combobox";
+import { Spinner } from "@/components/ui/spinner";
+import { useDebouncedValue, useHfDatasetSearch } from "@/hooks";
+import { type ReactElement, useEffect, useMemo, useRef, useState } from "react";
+
+type HfDatasetComboboxProps = {
+ value: string;
+ onValueChange: (value: string) => void;
+ accessToken?: string;
+ inputId?: string;
+ placeholder?: string;
+ className?: string;
+};
+
+export function HfDatasetCombobox({
+ value,
+ onValueChange,
+ accessToken,
+ inputId,
+ placeholder = "Search datasets...",
+ className,
+}: HfDatasetComboboxProps): ReactElement {
+ const [inputValue, setInputValue] = useState(value);
+ const selectingRef = useRef(false);
+ const anchorRef = useRef(null);
+ const debouncedQuery = useDebouncedValue(inputValue);
+
+ useEffect(() => {
+ setInputValue(value);
+ }, [value]);
+
+ const { results, isLoading, error } = useHfDatasetSearch(debouncedQuery, {
+ accessToken,
+ });
+
+ const items = useMemo(() => {
+ const ids = results.map((item) => item.id);
+ const selected = value.trim();
+ if (selected && !ids.includes(selected)) {
+ ids.push(selected);
+ }
+ return ids;
+ }, [results, value]);
+
+ return (
+ {
+ if (event.key !== "Enter") return;
+ if (!(event.target instanceof HTMLInputElement)) return;
+ event.preventDefault();
+ if (items.length > 0) {
+ onValueChange(items[0]);
+ return;
+ }
+ const typed = event.target.value.trim();
+ if (typed) {
+ onValueChange(typed);
+ }
+ }}
+ >
+
onValueChange(next ?? "")}
+ onInputValueChange={(next) => {
+ if (selectingRef.current) {
+ selectingRef.current = false;
+ return;
+ }
+ setInputValue(next);
+ }}
+ itemToStringValue={(item) => item}
+ autoHighlight={true}
+ >
+
+
+ {isLoading ? (
+
+
+ Searching...
+
+ ) : (
+ No datasets found
+ )}
+
+ {(id: string) => (
+ {
+ selectingRef.current = true;
+ }}
+ >
+ {id}
+
+ )}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/expression/expression-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/expression/expression-dialog.tsx
index 76579eff38..c734f67fae 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/expression/expression-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/expression/expression-dialog.tsx
@@ -7,7 +7,11 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { ReactElement } from "react";
+import { useMemo } from "react";
+import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
+import { findInvalidJinjaReferences } from "../../utils/refs";
+import { getAvailableVariables } from "../../utils/variables";
import { AvailableVariables } from "../shared/available-variables";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@@ -23,8 +27,21 @@ export function ExpressionDialog({
config,
onUpdate,
}: ExpressionDialogProps): ReactElement {
+ const configs = useRecipeStudioStore((state) => state.configs);
const dtypeId = `${config.id}-dtype`;
const exprId = `${config.id}-expr`;
+ const validReferences = useMemo(
+ () => getAvailableVariables(configs, config.id),
+ [configs, config.id],
+ );
+ const invalidExprRefs = useMemo(
+ () => findInvalidJinjaReferences(config.expr, validReferences),
+ [config.expr, validReferences],
+ );
+ const invalidExprText = invalidExprRefs
+ .slice(0, 3)
+ .map((ref) => `{{ ${ref} }}`)
+ .join(", ");
const updateField = (
key: K,
value: ExpressionConfig[K],
@@ -71,10 +88,19 @@ export function ExpressionDialog({
);
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx
index f33e534195..589c33b6f1 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/models/model-provider-dialog.tsx
@@ -1,6 +1,11 @@
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
-import type { ReactElement } from "react";
+import { type ReactElement, useState } from "react";
import type { ModelProviderConfig } from "../../types";
import { FieldLabel } from "../shared/field-label";
import { NameField } from "../shared/name-field";
@@ -14,8 +19,8 @@ export function ModelProviderDialog({
config,
onUpdate,
}: ModelProviderDialogProps): ReactElement {
+ const [optionalOpen, setOptionalOpen] = useState(false);
const endpointId = `${config.id}-endpoint`;
- const providerTypeId = `${config.id}-provider-type`;
const apiKeyEnvId = `${config.id}-api-key-env`;
const apiKeyId = `${config.id}-api-key`;
const extraHeadersId = `${config.id}-extra-headers`;
@@ -33,22 +38,6 @@ export function ModelProviderDialog({
value={config.name}
onChange={(value) => onUpdate({ name: value })}
/>
-
-
-
- updateField("provider_type", event.target.value)
- }
- />
-
updateField("endpoint", event.target.value)}
/>
-
-
- updateField("api_key_env", event.target.value)}
- />
-
updateField("api_key", event.target.value)}
/>
-
-
-
-
-
-
+
+
+
+
+
+
+
+ updateField("api_key_env", event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
);
}
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/preview-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/preview-dialog.tsx
index 296172777f..5ab40d80a5 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/preview-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/preview-dialog.tsx
@@ -285,7 +285,7 @@ export function RunDialog({
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
- className="corner-squircle sm:max-w-2xl"
+ className="corner-squircle sm:max-w-2xl shadow-border"
>
{kindLabel} settings
@@ -294,7 +294,7 @@ export function RunDialog({
-
+
Preview mode
Processors
diff --git a/studio/frontend/src/features/recipe-studio/dialogs/samplers/category-dialog.tsx b/studio/frontend/src/features/recipe-studio/dialogs/samplers/category-dialog.tsx
index f209078799..49fb6c62a1 100644
--- a/studio/frontend/src/features/recipe-studio/dialogs/samplers/category-dialog.tsx
+++ b/studio/frontend/src/features/recipe-studio/dialogs/samplers/category-dialog.tsx
@@ -133,12 +133,15 @@ export function CategoryDialog({
Add values first, then set optional weights.
) : (
-
+
{(config.values ?? []).map((value, index) => (
-
-
+
+
{value}
-
+
Rule weights (optional)
-
+
{(params.values ?? []).map((value, index) => (
-
+
{value}
-
+
= [
@@ -209,8 +210,6 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
const samplingId = `${config.id}-sampling`;
const selectionId = `${config.id}-selection`;
const tokenId = `${config.id}-hf-token`;
- const subsetId = `${config.id}-hf-subset`;
- const splitId = `${config.id}-hf-split`;
const datasetId = `${config.id}-hf-dataset`;
const chunkSizeId = `${config.id}-chunk-size`;
const chunkOverlapId = `${config.id}-chunk-overlap`;
@@ -221,10 +220,8 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
if (mode === "hf") {
const dataset = config.hf_repo_id.trim();
if (!dataset) return null;
- const subset = config.hf_subset?.trim() ?? "";
- const split = config.hf_split?.trim() || "train";
const token = config.hf_token?.trim() ?? "";
- return `hf:${dataset}|${subset}|${split}|${token}`;
+ return `hf:${dataset}|${token}`;
}
if (mode === "local") {
if (!localFile) return null;
@@ -255,8 +252,7 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
const response = await inspectSeedDataset({
dataset_name: datasetName,
hf_token: config.hf_token?.trim() || undefined,
- subset: config.hf_subset || undefined,
- split: config.hf_split || "train",
+ subset: undefined,
preview_size: 10,
});
onUpdate({
@@ -266,8 +262,8 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
response.columns.includes(name),
),
seed_preview_rows: response.preview_rows ?? [],
- hf_split: response.split ?? config.hf_split ?? "",
- hf_subset: response.subset ?? config.hf_subset ?? "",
+ hf_split: "",
+ hf_subset: "",
local_file_name: "",
unstructured_file_name: "",
});
@@ -416,14 +412,17 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
hint="Hugging Face dataset repo id (org/repo)."
/>
-
+ accessToken={config.hf_token?.trim() || undefined}
+ placeholder="org/repo"
+ onValueChange={(nextValue) =>
onUpdate({
- hf_repo_id: event.target.value,
+ hf_repo_id: nextValue,
+ hf_subset: "",
+ hf_split: "",
hf_path: "",
seed_columns: [],
seed_drop_columns: [],
@@ -458,43 +457,13 @@ export function SeedDialog({ config, onUpdate, open }: SeedDialogProps): ReactEl
/>
-
>
)}
{mode === "local" && (
diff --git a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts
index 9826a7396f..740ab54c74 100644
--- a/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts
+++ b/studio/frontend/src/features/recipe-studio/hooks/use-recipe-persistence.ts
@@ -64,6 +64,58 @@ function stripApiKeys(value: unknown): unknown {
return output;
}
+function sanitizeSeedForShare(payload: unknown): unknown {
+ if (!payload || typeof payload !== "object") {
+ return payload;
+ }
+ const root = payload as Record
;
+ const recipe =
+ root.recipe && typeof root.recipe === "object"
+ ? (root.recipe as Record)
+ : null;
+ const ui =
+ root.ui && typeof root.ui === "object"
+ ? (root.ui as Record)
+ : null;
+
+ const seedConfig =
+ recipe?.seed_config && typeof recipe.seed_config === "object"
+ ? (recipe.seed_config as Record)
+ : null;
+ const source =
+ seedConfig?.source && typeof seedConfig.source === "object"
+ ? (seedConfig.source as Record)
+ : null;
+
+ if (source && "token" in source) {
+ delete source.token;
+ }
+
+ const uiSourceType =
+ typeof ui?.seed_source_type === "string" ? ui.seed_source_type : null;
+ const sourceType =
+ typeof source?.seed_type === "string" ? source.seed_type : null;
+ const shouldResetLocalState =
+ sourceType === "local" ||
+ uiSourceType === "local" ||
+ uiSourceType === "unstructured";
+
+ if (shouldResetLocalState) {
+ if (source && "path" in source) {
+ source.path = "";
+ }
+ if (ui) {
+ ui.seed_columns = [];
+ ui.seed_drop_columns = [];
+ ui.seed_preview_rows = [];
+ ui.local_file_name = "";
+ ui.unstructured_file_name = "";
+ }
+ }
+
+ return root;
+}
+
export function useRecipePersistence({
recipeId,
initialRecipeName,
@@ -160,14 +212,14 @@ export function useRecipePersistence({
const copyRecipe = useCallback(async (): Promise => {
setCopied(false);
try {
- const safePayload = stripApiKeys(payloadResult.payload);
+ const safePayload = sanitizeSeedForShare(stripApiKeys(payloadResult.payload));
const ok = await copyTextToClipboard(JSON.stringify(safePayload, null, 2));
if (!ok) {
throw new Error("Clipboard not available.");
}
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
- toastSuccess("Payload copied");
+ toastSuccess("👨🍳 Recipe copied");
} catch (error) {
console.error("Copy failed:", error);
toastError("Copy failed", "Could not copy payload.");
diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
index ca4af3a23c..d4528836c4 100644
--- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
+++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx
@@ -18,16 +18,22 @@ import {
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
+ type DragEvent as ReactDragEvent,
type ReactElement,
useCallback,
useEffect,
useMemo,
+ useRef,
useState,
} from "react";
import { useShallow } from "zustand/react/shallow";
import "@xyflow/react/dist/style.css";
import { RecipeGraphAuxNode, type RecipeGraphAuxNodeData } from "./components/recipe-graph-aux-node";
-import { BlockSheet } from "./components/block-sheet";
+import {
+ BlockSheet,
+ RECIPE_BLOCK_DND_MIME,
+ type RecipeBlockDragPayload,
+} from "./components/block-sheet";
import { LayoutControls } from "./components/controls/layout-controls";
import { ViewportControls } from "./components/controls/viewport-controls";
import { ExecutionsView } from "./components/executions/executions-view";
@@ -44,10 +50,14 @@ import { ProcessorsDialog } from "./dialogs/processors-dialog";
import { useRecipeStudioActions } from "./hooks/use-recipe-studio-actions";
import { useRecipeStudioStore } from "./stores/recipe-studio";
import type {
+ LlmType,
RecipeNode as RecipeBuilderNode,
RecipeNodeData,
+ SamplerType,
} from "./types";
+import type { SeedBlockType } from "./blocks/registry";
import { deriveDisplayGraph } from "./utils/graph/derive-display-graph";
+import { getFitNodeIdsIgnoringNotes } from "./utils/graph/fit-view";
import { buildRecipePayload } from "./utils/payload";
import type { RecipePayload } from "./utils/payload/types";
import { buildDefaultSchemaTransform } from "./utils/processors";
@@ -63,6 +73,35 @@ import type { RecipeStudioView } from "./execution-types";
const NODE_TYPES: NodeTypes = { builder: RecipeNode, aux: RecipeGraphAuxNode };
const EDGE_TYPES: EdgeTypes = { canvas: DataEdge, semantic: RecipeGraphSemanticEdge };
+const SUPPORTED_DRAG_KINDS: RecipeBlockDragPayload["kind"][] = [
+ "sampler",
+ "seed",
+ "llm",
+ "expression",
+ "note",
+];
+
+function parseRecipeBlockDragPayload(raw: string): RecipeBlockDragPayload | null {
+ try {
+ const parsed = JSON.parse(raw) as {
+ kind?: RecipeBlockDragPayload["kind"];
+ type?: RecipeBlockDragPayload["type"];
+ };
+ if (
+ !parsed.kind ||
+ !parsed.type ||
+ !SUPPORTED_DRAG_KINDS.includes(parsed.kind)
+ ) {
+ return null;
+ }
+ return {
+ kind: parsed.kind,
+ type: parsed.type,
+ };
+ } catch {
+ return null;
+ }
+}
export type PersistRecipeInput = {
id: string | null;
@@ -94,7 +133,6 @@ export function RecipeStudioPage({
nodes,
edges,
auxNodePositions,
- auxNodeSizes,
llmAuxVisibility,
configs,
processors,
@@ -125,15 +163,11 @@ export function RecipeStudioPage({
setLayoutDirection,
applyLayout,
setAuxNodePosition,
- setAuxNodeSize,
- syncAuxNodePositions,
- syncAuxNodeSizes,
} = useRecipeStudioStore(
useShallow((state) => ({
nodes: state.nodes,
edges: state.edges,
auxNodePositions: state.auxNodePositions,
- auxNodeSizes: state.auxNodeSizes,
llmAuxVisibility: state.llmAuxVisibility,
configs: state.configs,
processors: state.processors,
@@ -164,14 +198,12 @@ export function RecipeStudioPage({
setLayoutDirection: state.setLayoutDirection,
applyLayout: state.applyLayout,
setAuxNodePosition: state.setAuxNodePosition,
- setAuxNodeSize: state.setAuxNodeSize,
- syncAuxNodePositions: state.syncAuxNodePositions,
- syncAuxNodeSizes: state.syncAuxNodeSizes,
})),
);
const [sheetContainer, setSheetContainer] = useState(
null,
);
+ const flowContainerRef = useRef(null);
const [blockSheetOpen, setBlockSheetOpen] = useState(false);
const [activeView, setActiveView] = useState("editor");
const [processorsOpen, setProcessorsOpen] = useState(false);
@@ -179,6 +211,7 @@ export function RecipeStudioPage({
const [reactFlowInstance, setReactFlowInstance] = useState<
ReactFlowInstance, Edge> | null
>(null);
+ const lastProcessedFitTickRef = useRef(0);
const handleExecutionStart = useCallback(() => {
setActiveView("executions");
}, []);
@@ -202,12 +235,10 @@ export function RecipeStudioPage({
configs,
layoutDirection,
auxNodePositions,
- auxNodeSizes,
llmAuxVisibility,
});
}, [
auxNodePositions,
- auxNodeSizes,
configs,
edges,
layoutDirection,
@@ -218,12 +249,6 @@ export function RecipeStudioPage({
() => displayGraph.nodes.map((node) => node.id),
[displayGraph.nodes],
);
- useEffect(() => {
- syncAuxNodePositions(displayGraph.auxNodeIds, displayGraph.auxDefaults);
- }, [displayGraph.auxDefaults, displayGraph.auxNodeIds, syncAuxNodePositions]);
- useEffect(() => {
- syncAuxNodeSizes(displayGraph.auxNodeIds);
- }, [displayGraph.auxNodeIds, syncAuxNodeSizes]);
const handleNodeClick = useCallback(
(_: unknown, node: Node) => {
@@ -250,7 +275,7 @@ export function RecipeStudioPage({
const handleNodesChange = useCallback(
(changes: NodeChange>[]) => {
- applyAuxNodeChanges(changes, { setAuxNodePosition, setAuxNodeSize });
+ applyAuxNodeChanges(changes, { setAuxNodePosition });
const next = filterNodeChangesByIds(
changes as NodeChange[],
baseNodeIds,
@@ -259,7 +284,7 @@ export function RecipeStudioPage({
onNodesChange(next);
}
},
- [baseNodeIds, onNodesChange, setAuxNodePosition, setAuxNodeSize],
+ [baseNodeIds, onNodesChange, setAuxNodePosition],
);
const handleEdgesChange = useCallback(
@@ -272,6 +297,116 @@ export function RecipeStudioPage({
[baseEdgeIds, onEdgesChange],
);
+ const handleDragOver = useCallback((event: ReactDragEvent) => {
+ if (
+ !event.dataTransfer.types.includes(RECIPE_BLOCK_DND_MIME) &&
+ !event.dataTransfer.types.includes("text/plain")
+ ) {
+ return;
+ }
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "copy";
+ }, []);
+
+ const handleDrop = useCallback(
+ (event: ReactDragEvent) => {
+ if (!reactFlowInstance) {
+ return;
+ }
+ const raw =
+ event.dataTransfer.getData(RECIPE_BLOCK_DND_MIME) ||
+ event.dataTransfer.getData("text/plain");
+ if (!raw) {
+ return;
+ }
+ const payload = parseRecipeBlockDragPayload(raw);
+ if (!payload) {
+ return;
+ }
+ event.preventDefault();
+ const position = reactFlowInstance.screenToFlowPosition({
+ x: event.clientX,
+ y: event.clientY,
+ });
+
+ if (payload.kind === "sampler") {
+ addSamplerNode(payload.type as SamplerType, position, false);
+ return;
+ }
+ if (payload.kind === "seed") {
+ addSeedNode(payload.type as SeedBlockType, position, false);
+ return;
+ }
+ if (payload.kind === "expression") {
+ addExpressionNode(position, false);
+ return;
+ }
+ if (payload.kind === "note") {
+ addMarkdownNoteNode(position, false);
+ return;
+ }
+ if (payload.type === "model_provider") {
+ addModelProviderNode(position, false);
+ return;
+ }
+ if (payload.type === "model_config") {
+ addModelConfigNode(position, false);
+ return;
+ }
+ addLlmNode(payload.type as LlmType, position, false);
+ },
+ [
+ addExpressionNode,
+ addLlmNode,
+ addMarkdownNoteNode,
+ addModelConfigNode,
+ addModelProviderNode,
+ addSamplerNode,
+ addSeedNode,
+ reactFlowInstance,
+ ],
+ );
+ const getViewportCenterPosition = useCallback(() => {
+ if (!reactFlowInstance || !flowContainerRef.current) {
+ return undefined;
+ }
+ const rect = flowContainerRef.current.getBoundingClientRect();
+ return reactFlowInstance.screenToFlowPosition({
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2,
+ });
+ }, [reactFlowInstance]);
+ const handleAddSamplerFromSheet = useCallback(
+ (type: SamplerType) => {
+ addSamplerNode(type, getViewportCenterPosition());
+ },
+ [addSamplerNode, getViewportCenterPosition],
+ );
+ const handleAddSeedFromSheet = useCallback(
+ (type: SeedBlockType) => {
+ addSeedNode(type, getViewportCenterPosition());
+ },
+ [addSeedNode, getViewportCenterPosition],
+ );
+ const handleAddLlmFromSheet = useCallback(
+ (type: LlmType) => {
+ addLlmNode(type, getViewportCenterPosition());
+ },
+ [addLlmNode, getViewportCenterPosition],
+ );
+ const handleAddModelProviderFromSheet = useCallback(() => {
+ addModelProviderNode(getViewportCenterPosition());
+ }, [addModelProviderNode, getViewportCenterPosition]);
+ const handleAddModelConfigFromSheet = useCallback(() => {
+ addModelConfigNode(getViewportCenterPosition());
+ }, [addModelConfigNode, getViewportCenterPosition]);
+ const handleAddExpressionFromSheet = useCallback(() => {
+ addExpressionNode(getViewportCenterPosition());
+ }, [addExpressionNode, getViewportCenterPosition]);
+ const handleAddMarkdownNoteFromSheet = useCallback(() => {
+ addMarkdownNoteNode(getViewportCenterPosition());
+ }, [addMarkdownNoteNode, getViewportCenterPosition]);
+
const configList = useMemo(() => Object.values(configs), [configs]);
const config = activeConfigId ? configs[activeConfigId] : null;
const dialogOptions = useMemo(
@@ -288,8 +423,16 @@ export function RecipeStudioPage({
}, []);
const payloadResult = useMemo(
- () => buildRecipePayload(configs, nodes, edges, processors, layoutDirection),
- [configs, edges, layoutDirection, nodes, processors],
+ () =>
+ buildRecipePayload(
+ configs,
+ nodes,
+ edges,
+ processors,
+ layoutDirection,
+ auxNodePositions,
+ ),
+ [auxNodePositions, configs, edges, layoutDirection, nodes, processors],
);
const getCurrentPayloadFromStore = useCallback((): RecipePayload => {
const state = useRecipeStudioStore.getState();
@@ -299,6 +442,7 @@ export function RecipeStudioPage({
state.edges,
state.processors,
state.layoutDirection,
+ state.auxNodePositions,
).payload;
}, []);
const {
@@ -371,13 +515,23 @@ export function RecipeStudioPage({
runDialogKind === "preview" ? previewLoading : fullLoading;
useEffect(() => {
- if (!reactFlowInstance || activeView !== "editor" || fitViewTick === 0) {
+ if (!reactFlowInstance || fitViewTick === 0 || activeView !== "editor") {
return;
}
+ if (lastProcessedFitTickRef.current === fitViewTick) {
+ return;
+ }
+ lastProcessedFitTickRef.current = fitViewTick;
let frame2 = 0;
+ let frame3 = 0;
const frame1 = window.requestAnimationFrame(() => {
frame2 = window.requestAnimationFrame(() => {
- reactFlowInstance.fitView({ duration: 250 });
+ frame3 = window.requestAnimationFrame(() => {
+ reactFlowInstance.fitView({
+ duration: 320,
+ nodes: getFitNodeIdsIgnoringNotes(reactFlowInstance.getNodes()),
+ });
+ });
});
});
return () => {
@@ -385,6 +539,9 @@ export function RecipeStudioPage({
if (frame2) {
window.cancelAnimationFrame(frame2);
}
+ if (frame3) {
+ window.cancelAnimationFrame(frame3);
+ }
};
}, [activeView, fitViewTick, reactFlowInstance]);
@@ -407,10 +564,12 @@ export function RecipeStudioPage({
void persistRecipe();
}}
/>
-
+
{activeView === "editor" ? (
, Edge>
onInit={setReactFlowInstance}
+ onDragOver={handleDragOver}
+ onDrop={handleDrop}
nodes={displayGraph.nodes}
edges={displayGraph.edges}
nodeTypes={NODE_TYPES}
@@ -428,7 +587,7 @@ export function RecipeStudioPage({
nodesDraggable={interactive}
nodesConnectable={interactive}
elementsSelectable={interactive}
- fitView={true}
+ fitView={false}
className="h-full w-full rounded-t-none"
>
,
- activeIds: string[],
- defaults: Record,
-): Record {
- const next: Record = {};
- for (const id of activeIds) {
- const existing = prev[id];
- if (existing) {
- next[id] = existing;
- continue;
- }
- const fallback = defaults[id];
- if (fallback) {
- next[id] = fallback;
- }
- }
-
- const prevIds = Object.keys(prev);
- const nextIds = Object.keys(next);
- if (prevIds.length !== nextIds.length) {
- return next;
- }
- for (const id of nextIds) {
- const a = prev[id];
- const b = next[id];
- if (!(a && b && a.x === b.x && a.y === b.y)) {
- return next;
- }
- }
- return prev;
-}
-
-export function syncSizesRecord(
- prev: Record,
- activeIds: string[],
-): Record {
- const active = new Set(activeIds);
- const next: Record = {};
- for (const [id, size] of Object.entries(prev)) {
- if (active.has(id)) {
- next[id] = size;
- }
- }
-
- const prevIds = Object.keys(prev);
- const nextIds = Object.keys(next);
- if (prevIds.length !== nextIds.length) {
- return next;
- }
- for (const id of nextIds) {
- const a = prev[id];
- const b = next[id];
- if (!(a && b && a.width === b.width && a.height === b.height)) {
- return next;
- }
- }
- return prev;
-}
diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts
new file mode 100644
index 0000000000..1def42bfbf
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/stores/helpers/model-infra-layout.ts
@@ -0,0 +1,397 @@
+import type { Edge, XYPosition } from "@xyflow/react";
+import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../../constants";
+import type { LayoutDirection, NodeConfig, RecipeNode } from "../../types";
+import { HANDLE_IDS, normalizeRecipeHandleId } from "../../utils/handles";
+import { readNodeHeight, readNodeWidth } from "../../utils/rf-node-dimensions";
+
+type Rect = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+type Bounds = {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+};
+
+function toRect(node: RecipeNode): Rect {
+ return {
+ x: node.position.x,
+ y: node.position.y,
+ width: readNodeWidth(node) ?? DEFAULT_NODE_WIDTH,
+ height: readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT,
+ };
+}
+
+function intersects(a: Rect, b: Rect, pad = 18): boolean {
+ return !(
+ a.x + a.width + pad <= b.x ||
+ b.x + b.width + pad <= a.x ||
+ a.y + a.height + pad <= b.y ||
+ b.y + b.height + pad <= a.y
+ );
+}
+
+function findNonOverlappingPosition(
+ preferred: XYPosition,
+ width: number,
+ height: number,
+ occupied: Rect[],
+): XYPosition {
+ const step = 24;
+ for (let ring = 0; ring <= 16; ring += 1) {
+ for (let dx = -ring; dx <= ring; dx += 1) {
+ for (let dy = -ring; dy <= ring; dy += 1) {
+ if (ring > 0 && Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
+ continue;
+ }
+ const candidate = {
+ x: preferred.x + dx * step,
+ y: preferred.y + dy * step,
+ };
+ const rect = {
+ x: candidate.x,
+ y: candidate.y,
+ width,
+ height,
+ };
+ if (!occupied.some((item) => intersects(rect, item))) {
+ return candidate;
+ }
+ }
+ }
+ }
+ return preferred;
+}
+
+function isProviderToConfigEdge(edge: Edge, configs: Record): boolean {
+ const source = configs[edge.source];
+ const target = configs[edge.target];
+ return source?.kind === "model_provider" && target?.kind === "model_config";
+}
+
+function isConfigToLlmEdge(edge: Edge, configs: Record): boolean {
+ const source = configs[edge.source];
+ const target = configs[edge.target];
+ return source?.kind === "model_config" && target?.kind === "llm";
+}
+
+function usageKey(nodeId: string, handleId: string): string {
+ return `${nodeId}::${handleId}`;
+}
+
+function incrementUsage(map: Map, nodeId: string, handleId: string): void {
+ const key = usageKey(nodeId, handleId);
+ map.set(key, (map.get(key) ?? 0) + 1);
+}
+
+function decrementUsage(map: Map, nodeId: string, handleId: string): void {
+ const key = usageKey(nodeId, handleId);
+ map.set(key, Math.max(0, (map.get(key) ?? 0) - 1));
+}
+
+function getUsage(map: Map, nodeId: string, handleId: string): number {
+ return map.get(usageKey(nodeId, handleId)) ?? 0;
+}
+
+function pickHandleByUsage(
+ candidates: string[],
+ nodeId: string,
+ usageMap: Map,
+): string {
+ const free = candidates.filter((handleId) => getUsage(usageMap, nodeId, handleId) === 0);
+ if (free.length > 0) {
+ return free[0];
+ }
+ let bestHandle = candidates[0];
+ let bestCount = Number.POSITIVE_INFINITY;
+ for (const handleId of candidates) {
+ const count = getUsage(usageMap, nodeId, handleId);
+ if (count < bestCount) {
+ bestHandle = handleId;
+ bestCount = count;
+ }
+ }
+ return bestHandle;
+}
+
+function applyEdgeWithHandles(
+ edge: Edge,
+ sourceHandle: string,
+ targetHandle: string,
+ sourceUsage: Map,
+ targetUsage: Map,
+): Edge {
+ incrementUsage(sourceUsage, edge.source, sourceHandle);
+ incrementUsage(targetUsage, edge.target, targetHandle);
+ return { ...edge, sourceHandle, targetHandle, type: "semantic" };
+}
+
+function getNodeCenter(node: RecipeNode): { x: number; y: number } {
+ const width = readNodeWidth(node) ?? DEFAULT_NODE_WIDTH;
+ const height = readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT;
+ return {
+ x: node.position.x + width / 2,
+ y: node.position.y + height / 2,
+ };
+}
+
+function collectBounds(ids: string[], nodesById: Map): Bounds | null {
+ const rects = ids
+ .map((id) => nodesById.get(id))
+ .flatMap((node) => (node ? [toRect(node)] : []));
+ if (rects.length === 0) {
+ return null;
+ }
+ return rects.reduce(
+ (acc, rect) => ({
+ minX: Math.min(acc.minX, rect.x),
+ maxX: Math.max(acc.maxX, rect.x + rect.width),
+ minY: Math.min(acc.minY, rect.y),
+ maxY: Math.max(acc.maxY, rect.y + rect.height),
+ }),
+ {
+ minX: rects[0].x,
+ maxX: rects[0].x + rects[0].width,
+ minY: rects[0].y,
+ maxY: rects[0].y + rects[0].height,
+ },
+ );
+}
+
+function sortPreferredLlmTargetHandles(
+ direction: LayoutDirection,
+ sourceNode: RecipeNode | undefined,
+ targetNode: RecipeNode | undefined,
+): string[] {
+ const sourceCenter = sourceNode ? getNodeCenter(sourceNode) : { x: 0, y: 0 };
+ const targetCenter = targetNode ? getNodeCenter(targetNode) : { x: 0, y: 0 };
+
+ if (direction === "TB") {
+ const horizontalFirst =
+ sourceCenter.x <= targetCenter.x
+ ? [HANDLE_IDS.dataIn, HANDLE_IDS.dataInRight]
+ : [HANDLE_IDS.dataInRight, HANDLE_IDS.dataIn];
+ return [...horizontalFirst, HANDLE_IDS.dataInTop, HANDLE_IDS.dataInBottom];
+ }
+
+ const verticalFirst =
+ sourceCenter.y <= targetCenter.y
+ ? [HANDLE_IDS.dataInTop, HANDLE_IDS.dataInBottom]
+ : [HANDLE_IDS.dataInBottom, HANDLE_IDS.dataInTop];
+ return [...verticalFirst, HANDLE_IDS.dataIn, HANDLE_IDS.dataInRight];
+}
+
+function getProviderSourceHandleCandidates(direction: LayoutDirection): string[] {
+ return direction === "TB"
+ ? [HANDLE_IDS.semanticOut, HANDLE_IDS.semanticOutBottom]
+ : [HANDLE_IDS.semanticOutBottom, HANDLE_IDS.semanticOut];
+}
+
+function getProviderTargetHandleCandidates(direction: LayoutDirection): string[] {
+ return direction === "TB"
+ ? [HANDLE_IDS.semanticIn, HANDLE_IDS.semanticInTop]
+ : [HANDLE_IDS.semanticInTop, HANDLE_IDS.semanticIn];
+}
+
+function getConfigSourceHandleCandidates(direction: LayoutDirection): string[] {
+ return direction === "TB" ? [HANDLE_IDS.semanticOut] : [HANDLE_IDS.semanticOutBottom];
+}
+
+export function optimizeModelInfraEdgeHandles(
+ edges: Edge[],
+ nodes: RecipeNode[],
+ configs: Record,
+ direction: LayoutDirection,
+): Edge[] {
+ const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
+ const sourceUsage = new Map();
+ const targetUsage = new Map();
+
+ for (const edge of edges) {
+ const sourceHandle = normalizeRecipeHandleId(edge.sourceHandle);
+ const targetHandle = normalizeRecipeHandleId(edge.targetHandle);
+ if (sourceHandle) {
+ incrementUsage(sourceUsage, edge.source, sourceHandle);
+ }
+ if (targetHandle) {
+ incrementUsage(targetUsage, edge.target, targetHandle);
+ }
+ }
+
+ const nextEdges: Edge[] = [];
+ for (const edge of edges) {
+ const source = configs[edge.source];
+ const target = configs[edge.target];
+ if (!(source && target)) {
+ nextEdges.push(edge);
+ continue;
+ }
+
+ const sourceHandleBefore = normalizeRecipeHandleId(edge.sourceHandle);
+ const targetHandleBefore = normalizeRecipeHandleId(edge.targetHandle);
+ const isModelSemantic =
+ isProviderToConfigEdge(edge, configs) || isConfigToLlmEdge(edge, configs);
+ if (!isModelSemantic) {
+ nextEdges.push(edge);
+ continue;
+ }
+
+ if (sourceHandleBefore) {
+ decrementUsage(sourceUsage, edge.source, sourceHandleBefore);
+ }
+ if (targetHandleBefore) {
+ decrementUsage(targetUsage, edge.target, targetHandleBefore);
+ }
+
+ if (isProviderToConfigEdge(edge, configs)) {
+ const sourceCandidates = getProviderSourceHandleCandidates(direction);
+ const targetCandidates = getProviderTargetHandleCandidates(direction);
+ const sourceHandle = pickHandleByUsage(sourceCandidates, edge.source, sourceUsage);
+ const targetHandle = pickHandleByUsage(targetCandidates, edge.target, targetUsage);
+ nextEdges.push(
+ applyEdgeWithHandles(
+ edge,
+ sourceHandle,
+ targetHandle,
+ sourceUsage,
+ targetUsage,
+ ),
+ );
+ continue;
+ }
+
+ const sourceCandidates = getConfigSourceHandleCandidates(direction);
+ const targetCandidates = sortPreferredLlmTargetHandles(
+ direction,
+ nodesById.get(edge.source),
+ nodesById.get(edge.target),
+ );
+ const sourceHandle = pickHandleByUsage(sourceCandidates, edge.source, sourceUsage);
+ const targetHandle = pickHandleByUsage(targetCandidates, edge.target, targetUsage);
+ nextEdges.push(
+ applyEdgeWithHandles(
+ edge,
+ sourceHandle,
+ targetHandle,
+ sourceUsage,
+ targetUsage,
+ ),
+ );
+ }
+
+ return nextEdges;
+}
+
+export function centerModelInfraNodes(
+ nodes: RecipeNode[],
+ edges: Edge[],
+ configs: Record,
+ direction: LayoutDirection,
+): RecipeNode[] {
+ const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
+ const configToLlmIds = new Map();
+ const providerToConfigIds = new Map();
+
+ for (const edge of edges) {
+ if (isProviderToConfigEdge(edge, configs)) {
+ const entries = providerToConfigIds.get(edge.source) ?? [];
+ if (!entries.includes(edge.target)) {
+ entries.push(edge.target);
+ }
+ providerToConfigIds.set(edge.source, entries);
+ continue;
+ }
+ if (isConfigToLlmEdge(edge, configs)) {
+ const entries = configToLlmIds.get(edge.source) ?? [];
+ if (!entries.includes(edge.target)) {
+ entries.push(edge.target);
+ }
+ configToLlmIds.set(edge.source, entries);
+ }
+ }
+
+ const modelConfigIds = Object.values(configs)
+ .filter((config) => config.kind === "model_config" && nodesById.has(config.id))
+ .map((config) => config.id);
+ const modelProviderIds = Object.values(configs)
+ .filter((config) => config.kind === "model_provider" && nodesById.has(config.id))
+ .map((config) => config.id);
+
+ const occupiedById = new Map(nodes.map((node) => [node.id, toRect(node)] as const));
+ const clusterGap = 72;
+
+ const placeNode = (nodeId: string, preferred: XYPosition): void => {
+ const currentNode = nodesById.get(nodeId);
+ if (!currentNode) {
+ return;
+ }
+ const width = readNodeWidth(currentNode) ?? DEFAULT_NODE_WIDTH;
+ const height = readNodeHeight(currentNode) ?? DEFAULT_NODE_HEIGHT;
+ occupiedById.delete(nodeId);
+ const position = findNonOverlappingPosition(
+ preferred,
+ width,
+ height,
+ Array.from(occupiedById.values()),
+ );
+ const nextNode = { ...currentNode, position };
+ nodesById.set(nodeId, nextNode);
+ occupiedById.set(nodeId, {
+ x: position.x,
+ y: position.y,
+ width,
+ height,
+ });
+ };
+
+ for (const modelConfigId of modelConfigIds) {
+ const llmIds = configToLlmIds.get(modelConfigId) ?? [];
+ const targetBounds = collectBounds(llmIds, nodesById);
+ const modelConfigNode = nodesById.get(modelConfigId);
+ if (!(targetBounds && modelConfigNode)) {
+ continue;
+ }
+ const width = readNodeWidth(modelConfigNode) ?? DEFAULT_NODE_WIDTH;
+ const height = readNodeHeight(modelConfigNode) ?? DEFAULT_NODE_HEIGHT;
+ const preferred =
+ direction === "LR"
+ ? {
+ x: (targetBounds.minX + targetBounds.maxX) / 2 - width / 2,
+ y: targetBounds.minY - height - clusterGap,
+ }
+ : {
+ x: targetBounds.minX - width - clusterGap,
+ y: (targetBounds.minY + targetBounds.maxY) / 2 - height / 2,
+ };
+ placeNode(modelConfigId, preferred);
+ }
+
+ for (const modelProviderId of modelProviderIds) {
+ const configIds = providerToConfigIds.get(modelProviderId) ?? [];
+ const targetBounds = collectBounds(configIds, nodesById);
+ const modelProviderNode = nodesById.get(modelProviderId);
+ if (!(targetBounds && modelProviderNode)) {
+ continue;
+ }
+ const width = readNodeWidth(modelProviderNode) ?? DEFAULT_NODE_WIDTH;
+ const height = readNodeHeight(modelProviderNode) ?? DEFAULT_NODE_HEIGHT;
+ const preferred =
+ direction === "LR"
+ ? {
+ x: (targetBounds.minX + targetBounds.maxX) / 2 - width / 2,
+ y: targetBounds.minY - height - clusterGap,
+ }
+ : {
+ x: targetBounds.minX - width - clusterGap,
+ y: (targetBounds.minY + targetBounds.maxY) / 2 - height / 2,
+ };
+ placeNode(modelProviderId, preferred);
+ }
+
+ return nodes.map((node) => nodesById.get(node.id) ?? node);
+}
diff --git a/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts b/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts
index 6d17986595..0473776d7d 100644
--- a/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/helpers/node-updates.ts
@@ -1,3 +1,4 @@
+import type { XYPosition } from "@xyflow/react";
import { DEFAULT_NODE_WIDTH } from "../../constants";
import type {
RecipeNode,
@@ -40,11 +41,13 @@ export function buildNodeUpdate(
state: NodeUpdateState,
config: NodeConfig,
layoutDirection: LayoutDirection,
+ position?: XYPosition,
+ openDialog = true,
): NodeUpdateResult {
const node: RecipeNode = {
id: config.id,
type: "builder",
- position: { x: 0, y: state.nextY },
+ position: position ?? { x: 0, y: state.nextY },
data: nodeDataFromConfig(config, layoutDirection),
style: { width: DEFAULT_NODE_WIDTH },
selected: true,
@@ -54,9 +57,9 @@ export function buildNodeUpdate(
configs: { ...state.configs, [config.id]: config },
nodes: [...state.nodes.map((item) => ({ ...item, selected: false })), node],
nextId: state.nextId + 1,
- nextY: state.nextY + 140,
+ nextY: position ? state.nextY : state.nextY + 140,
activeConfigId: config.id,
- dialogOpen: mode === "dialog",
+ dialogOpen: openDialog && mode === "dialog",
};
}
diff --git a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
index 038705cc27..cae9d42703 100644
--- a/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
+++ b/studio/frontend/src/features/recipe-studio/stores/recipe-studio.ts
@@ -26,10 +26,17 @@ import {
} from "../blocks/registry";
import { deriveDisplayGraph } from "../utils/graph/derive-display-graph";
import { applyRecipeConnection, isValidRecipeConnection } from "../utils/graph";
-import { HANDLE_IDS, remapRecipeEdgeHandlesForLayout } from "../utils/handles";
+import {
+ HANDLE_IDS,
+ normalizeRecipeHandleId,
+ remapRecipeEdgeHandlesForLayout,
+} from "../utils/handles";
import type { RecipeSnapshot } from "../utils/import";
import { getLayoutedElements } from "../utils/layout";
-import { syncPositionsRecord, syncSizesRecord } from "./helpers/aux-sync";
+import {
+ centerModelInfraNodes,
+ optimizeModelInfraEdgeHandles,
+} from "./helpers/model-infra-layout";
import { applyEdgeRemovals, applyNodeRemovals } from "./helpers/removals";
import {
applyRenameToConfigs,
@@ -53,7 +60,6 @@ type RecipeStudioState = {
nodes: RecipeNode[];
edges: Edge[];
auxNodePositions: Record;
- auxNodeSizes: Record;
llmAuxVisibility: Record;
configs: Record;
processors: RecipeProcessorConfig[];
@@ -73,25 +79,24 @@ type RecipeStudioState = {
setLayoutDirection: (direction: LayoutDirection) => void;
applyLayout: () => void;
setLlmAuxVisibility: (id: string, visible: boolean) => void;
- addSamplerNode: (type: SamplerType) => void;
- addSeedNode: (type: SeedBlockType) => void;
- addLlmNode: (type: LlmType) => void;
- addModelProviderNode: () => void;
- addModelConfigNode: () => void;
- addExpressionNode: () => void;
- addMarkdownNoteNode: () => void;
+ addSamplerNode: (
+ type: SamplerType,
+ position?: XYPosition,
+ openDialog?: boolean,
+ ) => void;
+ addSeedNode: (
+ type: SeedBlockType,
+ position?: XYPosition,
+ openDialog?: boolean,
+ ) => void;
+ addLlmNode: (type: LlmType, position?: XYPosition, openDialog?: boolean) => void;
+ addModelProviderNode: (position?: XYPosition, openDialog?: boolean) => void;
+ addModelConfigNode: (position?: XYPosition, openDialog?: boolean) => void;
+ addExpressionNode: (position?: XYPosition, openDialog?: boolean) => void;
+ addMarkdownNoteNode: (position?: XYPosition, openDialog?: boolean) => void;
updateConfig: (id: string, patch: Partial) => void;
loadRecipe: (snapshot: RecipeSnapshot) => void;
setAuxNodePosition: (id: string, position: XYPosition) => void;
- setAuxNodeSize: (
- id: string,
- size: { width: number; height: number },
- ) => void;
- syncAuxNodePositions: (
- activeIds: string[],
- defaults: Record,
- ) => void;
- syncAuxNodeSizes: (activeIds: string[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
onEdgesChange: (changes: EdgeChange[]) => void;
onConnect: (connection: Connection) => void;
@@ -102,7 +107,6 @@ const INITIAL_STATE = {
nodes: [],
edges: [],
auxNodePositions: {},
- auxNodeSizes: {},
llmAuxVisibility: {},
configs: {},
processors: [],
@@ -118,7 +122,6 @@ const INITIAL_STATE = {
| "nodes"
| "edges"
| "auxNodePositions"
- | "auxNodeSizes"
| "llmAuxVisibility"
| "configs"
| "processors"
@@ -135,6 +138,8 @@ function buildAddedNodeState(
state: RecipeStudioState,
kind: BlockKind,
type: BlockType,
+ position?: XYPosition,
+ openDialog = true,
): Partial | RecipeStudioState {
const id = `n${state.nextId}`;
const existing = Object.values(state.configs);
@@ -143,7 +148,13 @@ function buildAddedNodeState(
return state;
}
const config = definition.createConfig(id, existing);
- return buildNodeUpdate(state, config, state.layoutDirection);
+ return buildNodeUpdate(
+ state,
+ config,
+ state.layoutDirection,
+ position,
+ openDialog,
+ );
}
function getAddedNodeContext(
@@ -219,6 +230,17 @@ function connectSemantic(
};
}
+function isModelSemanticEdge(edge: Edge, configs: Record): boolean {
+ const source = configs[edge.source];
+ const target = configs[edge.target];
+ return Boolean(
+ source &&
+ target &&
+ ((source.kind === "model_provider" && target.kind === "model_config") ||
+ (source.kind === "model_config" && target.kind === "llm")),
+ );
+}
+
export const useRecipeStudioStore = create((set, get) => ({
...INITIAL_STATE,
setSheetView: (view) => set({ sheetView: view }),
@@ -230,10 +252,19 @@ export const useRecipeStudioStore = create((set, get) => ({
setLayoutDirection: (direction) =>
set((state) => ({
layoutDirection: direction,
- edges: state.edges.map((edge) => ({
- ...edge,
- ...remapRecipeEdgeHandlesForLayout(edge, direction),
- })),
+ edges: state.edges.map((edge) => {
+ if (isModelSemanticEdge(edge, state.configs)) {
+ return {
+ ...edge,
+ sourceHandle: normalizeRecipeHandleId(edge.sourceHandle),
+ targetHandle: normalizeRecipeHandleId(edge.targetHandle),
+ };
+ }
+ return {
+ ...edge,
+ ...remapRecipeEdgeHandlesForLayout(edge, direction),
+ };
+ }),
nodes: applyLayoutDirectionToNodes(
state.nodes,
state.configs,
@@ -243,13 +274,13 @@ export const useRecipeStudioStore = create((set, get) => ({
applyLayout: () =>
set((state) => {
const isTopBottom = state.layoutDirection === "TB";
+
const displayGraph = deriveDisplayGraph({
nodes: state.nodes,
edges: state.edges,
configs: state.configs,
layoutDirection: state.layoutDirection,
- auxNodePositions: state.auxNodePositions,
- auxNodeSizes: state.auxNodeSizes,
+ auxNodePositions: {},
llmAuxVisibility: state.llmAuxVisibility,
});
const { nodes } = getLayoutedElements(displayGraph.nodes, displayGraph.edges, {
@@ -267,27 +298,23 @@ export const useRecipeStudioStore = create((set, get) => ({
}
return { ...node, position };
});
- const nextAuxNodePositions: Record = {};
- for (const auxId of displayGraph.auxNodeIds) {
- const existing = state.auxNodePositions[auxId];
- const layouted = layoutedPositions.get(auxId);
- if (layouted) {
- nextAuxNodePositions[auxId] = layouted;
- continue;
- }
- if (existing) {
- nextAuxNodePositions[auxId] = existing;
- continue;
- }
- const fallback = displayGraph.auxDefaults[auxId];
- if (fallback) {
- nextAuxNodePositions[auxId] = fallback;
- }
- }
+ const centeredNodes = centerModelInfraNodes(
+ nextNodes,
+ state.edges,
+ state.configs,
+ state.layoutDirection,
+ );
+ const optimizedEdges = optimizeModelInfraEdgeHandles(
+ state.edges,
+ centeredNodes,
+ state.configs,
+ state.layoutDirection,
+ );
return {
- auxNodePositions: nextAuxNodePositions,
+ auxNodePositions: {},
+ edges: optimizedEdges,
nodes: applyLayoutDirectionToNodes(
- nextNodes,
+ centeredNodes,
state.configs,
state.layoutDirection,
),
@@ -305,15 +332,23 @@ export const useRecipeStudioStore = create((set, get) => ({
},
};
}),
- addSamplerNode: (type) =>
- set((state) => buildAddedNodeState(state, "sampler", type)),
- addSeedNode: (type) =>
+ addSamplerNode: (type, position, openDialog = true) =>
+ set((state) =>
+ buildAddedNodeState(state, "sampler", type, position, openDialog),
+ ),
+ addSeedNode: (type, position, openDialog = true) =>
set((state) => {
const existing = Object.values(state.configs).find(
(config) => config.kind === "seed",
);
if (!existing) {
- return buildAddedNodeState(state, "seed", type);
+ return buildAddedNodeState(
+ state,
+ "seed",
+ type,
+ position,
+ openDialog,
+ );
}
let nextSourceType: SeedSourceType = "hf";
if (type === "seed_local") {
@@ -351,13 +386,22 @@ export const useRecipeStudioStore = create((set, get) => ({
state.layoutDirection,
),
activeConfigId: existing.id,
- dialogOpen: true,
+ dialogOpen: openDialog,
};
}),
- addLlmNode: (type) => set((state) => buildAddedNodeState(state, "llm", type)),
- addModelProviderNode: () =>
+ addLlmNode: (type, position, openDialog = true) =>
+ set((state) =>
+ buildAddedNodeState(state, "llm", type, position, openDialog),
+ ),
+ addModelProviderNode: (position, openDialog = true) =>
set((state) => {
- const added = buildAddedNodeState(state, "llm", "model_provider");
+ const added = buildAddedNodeState(
+ state,
+ "llm",
+ "model_provider",
+ position,
+ openDialog,
+ );
const context = getAddedNodeContext(added);
if (!context) {
return added;
@@ -369,7 +413,7 @@ export const useRecipeStudioStore = create((set, get) => ({
config.kind === "model_config" &&
!config.provider.trim(),
);
- if (unboundModelConfigs.length > 0) {
+ if (!position && unboundModelConfigs.length > 0) {
nodes = placeNodeNear(
nodes,
context.newNodeId,
@@ -390,9 +434,15 @@ export const useRecipeStudioStore = create((set, get) => ({
}
return { ...added, nodes, edges, configs };
}),
- addModelConfigNode: () =>
+ addModelConfigNode: (position, openDialog = true) =>
set((state) => {
- const added = buildAddedNodeState(state, "llm", "model_config");
+ const added = buildAddedNodeState(
+ state,
+ "llm",
+ "model_config",
+ position,
+ openDialog,
+ );
const context = getAddedNodeContext(added);
if (!context) {
return added;
@@ -405,7 +455,7 @@ export const useRecipeStudioStore = create((set, get) => ({
const unboundLlms = Object.values(configs).filter(
(config) => config.kind === "llm" && !config.model_alias.trim(),
);
- if (providers.length === 1) {
+ if (!position && providers.length === 1) {
nodes = placeNodeNear(
nodes,
context.newNodeId,
@@ -413,7 +463,7 @@ export const useRecipeStudioStore = create((set, get) => ({
state.layoutDirection,
"after",
);
- } else if (unboundLlms.length > 0) {
+ } else if (!position && unboundLlms.length > 0) {
nodes = placeNodeNear(
nodes,
context.newNodeId,
@@ -444,10 +494,26 @@ export const useRecipeStudioStore = create((set, get) => ({
}
return { ...added, nodes, edges, configs };
}),
- addExpressionNode: () =>
- set((state) => buildAddedNodeState(state, "expression", "expression")),
- addMarkdownNoteNode: () =>
- set((state) => buildAddedNodeState(state, "note", "markdown_note")),
+ addExpressionNode: (position, openDialog = true) =>
+ set((state) =>
+ buildAddedNodeState(
+ state,
+ "expression",
+ "expression",
+ position,
+ openDialog,
+ ),
+ ),
+ addMarkdownNoteNode: (position, openDialog = true) =>
+ set((state) =>
+ buildAddedNodeState(
+ state,
+ "note",
+ "markdown_note",
+ position,
+ openDialog,
+ ),
+ ),
loadRecipe: (snapshot) =>
set((state) => ({
configs: snapshot.configs,
@@ -461,8 +527,7 @@ export const useRecipeStudioStore = create((set, get) => ({
layoutDirection: snapshot.layoutDirection,
nextId: snapshot.nextId,
nextY: snapshot.nextY,
- auxNodePositions: {},
- auxNodeSizes: {},
+ auxNodePositions: snapshot.auxNodePositions ?? {},
llmAuxVisibility: {},
activeConfigId: null,
dialogOpen: false,
@@ -482,36 +547,6 @@ export const useRecipeStudioStore = create((set, get) => ({
},
};
}),
- setAuxNodeSize: (id, size) =>
- set((state) => {
- const width = Math.max(1, size.width);
- const height = Math.max(1, size.height);
- const current = state.auxNodeSizes[id];
- if (current && current.width === width && current.height === height) {
- return state;
- }
- return {
- auxNodeSizes: {
- ...state.auxNodeSizes,
- [id]: { width, height },
- },
- };
- }),
- syncAuxNodePositions: (activeIds, defaults) =>
- set((state) => {
- const next = syncPositionsRecord(state.auxNodePositions, activeIds, defaults);
- if (next === state.auxNodePositions) {
- return state;
- }
- return {
- auxNodePositions: next,
- };
- }),
- syncAuxNodeSizes: (activeIds) =>
- set((state) => {
- const next = syncSizesRecord(state.auxNodeSizes, activeIds);
- return next === state.auxNodeSizes ? state : { auxNodeSizes: next };
- }),
updateConfig: (id, patch) => {
const applyUpdate = (state: RecipeStudioState) => {
const current = state.configs[id];
diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts b/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts
index 424325fc96..ef7cb62661 100644
--- a/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/graph/derive-display-graph.ts
@@ -7,7 +7,6 @@ import {
getDefaultDataTargetHandle,
getDefaultSemanticSourceHandle,
getDefaultSemanticTargetHandle,
- getLlmJudgeScoreHandleId,
HANDLE_IDS,
isDataSourceHandle,
isDataTargetHandle,
@@ -24,15 +23,12 @@ type DisplayGraphInput = {
configs: Record;
layoutDirection: LayoutDirection;
auxNodePositions: Record;
- auxNodeSizes: Record;
llmAuxVisibility: Record;
};
export type DisplayGraph = {
nodes: Array>;
edges: Edge[];
- auxNodeIds: string[];
- auxDefaults: Record;
};
function normalizeEdge(
@@ -99,7 +95,6 @@ function normalizeEdge(
type AuxNodeItem = {
key: string;
- targetHandle: string;
data: RecipeGraphAuxNodeData;
};
@@ -136,42 +131,235 @@ function findNonOverlappingPosition(
preferred: XYPosition,
width: number,
height: number,
- direction: LayoutDirection,
occupied: Rect[],
): XYPosition {
- const primaryStep =
- direction === "TB"
- ? { x: 0, y: -(height + 24) }
- : { x: -(width + 24), y: 0 };
- const lateralUnit =
- direction === "TB"
- ? { x: Math.max(48, Math.round(width * 0.3)), y: 0 }
- : { x: 0, y: Math.max(40, Math.round(height * 0.35)) };
- const lateralPattern = [0, 1, -1, 2, -2];
-
- for (let ring = 0; ring <= 8; ring += 1) {
- for (const lateral of lateralPattern) {
- const candidate = {
- x: preferred.x + primaryStep.x * ring + lateralUnit.x * lateral,
- y: preferred.y + primaryStep.y * ring + lateralUnit.y * lateral,
- };
- const rect = toRect(candidate, width, height);
- if (!occupied.some((other) => intersects(rect, other))) {
- return candidate;
+ const step = 24;
+ for (let ring = 0; ring <= 10; ring += 1) {
+ for (let dx = -ring; dx <= ring; dx += 1) {
+ for (let dy = -ring; dy <= ring; dy += 1) {
+ if (ring > 0 && Math.max(Math.abs(dx), Math.abs(dy)) !== ring) {
+ continue;
+ }
+ const candidate = {
+ x: preferred.x + dx * step,
+ y: preferred.y + dy * step,
+ };
+ const rect = toRect(candidate, width, height);
+ if (!occupied.some((other) => intersects(rect, other))) {
+ return candidate;
+ }
}
}
}
-
return preferred;
}
+type HandleSide = "left" | "right" | "top" | "bottom";
+
+const SIDE_TO_TARGET_HANDLE: Record = {
+ left: HANDLE_IDS.dataIn,
+ right: HANDLE_IDS.dataInRight,
+ top: HANDLE_IDS.dataInTop,
+ bottom: HANDLE_IDS.dataInBottom,
+};
+
+function getTargetSide(
+ handleId: string | null | undefined,
+ direction: LayoutDirection,
+): HandleSide {
+ const normalized = normalizeRecipeHandleId(handleId);
+ if (!normalized) {
+ return direction === "TB" ? "top" : "left";
+ }
+ if (
+ normalized === HANDLE_IDS.dataInRight ||
+ normalized === HANDLE_IDS.semanticInRight
+ ) {
+ return "right";
+ }
+ if (
+ normalized === HANDLE_IDS.dataInBottom ||
+ normalized === HANDLE_IDS.semanticInBottom
+ ) {
+ return "bottom";
+ }
+ if (
+ normalized === HANDLE_IDS.dataInTop ||
+ normalized === HANDLE_IDS.semanticInTop
+ ) {
+ return "top";
+ }
+ return "left";
+}
+
+function getSourceSide(
+ handleId: string | null | undefined,
+ direction: LayoutDirection,
+): HandleSide {
+ const normalized = normalizeRecipeHandleId(handleId);
+ if (!normalized) {
+ return direction === "TB" ? "bottom" : "right";
+ }
+ if (
+ normalized === HANDLE_IDS.dataOutLeft ||
+ normalized === HANDLE_IDS.semanticOutLeft
+ ) {
+ return "left";
+ }
+ if (
+ normalized === HANDLE_IDS.dataOutTop ||
+ normalized === HANDLE_IDS.semanticOutTop
+ ) {
+ return "top";
+ }
+ if (
+ normalized === HANDLE_IDS.dataOutBottom ||
+ normalized === HANDLE_IDS.semanticOutBottom
+ ) {
+ return "bottom";
+ }
+ return "right";
+}
+
+function pickAuxTargetHandle(
+ llmId: string,
+ direction: LayoutDirection,
+ edges: Edge[],
+): string {
+ const occupied = new Set();
+ for (const edge of edges) {
+ if (edge.source.startsWith("aux-") || edge.target.startsWith("aux-")) {
+ continue;
+ }
+ if (edge.target === llmId) {
+ occupied.add(getTargetSide(edge.targetHandle, direction));
+ }
+ if (edge.source === llmId) {
+ occupied.add(getSourceSide(edge.sourceHandle, direction));
+ }
+ }
+
+ const priority: HandleSide[] =
+ direction === "LR"
+ ? ["left", "right", "bottom", "top"]
+ : ["top", "bottom", "right", "left"];
+ for (const side of priority) {
+ if (!occupied.has(side)) {
+ return SIDE_TO_TARGET_HANDLE[side];
+ }
+ }
+
+ const fallback: HandleSide = direction === "LR" ? "bottom" : "right";
+ return SIDE_TO_TARGET_HANDLE[fallback];
+}
+
+function getHandleSideFromTargetHandle(targetHandle: string): HandleSide {
+ if (targetHandle === HANDLE_IDS.dataInRight) {
+ return "right";
+ }
+ if (targetHandle === HANDLE_IDS.dataInTop) {
+ return "top";
+ }
+ if (targetHandle === HANDLE_IDS.dataInBottom) {
+ return "bottom";
+ }
+ return "left";
+}
+
+function pickAuxSourceHandle(
+ auxPosition: XYPosition,
+ auxWidth: number,
+ auxHeight: number,
+ llmPosition: XYPosition,
+ llmWidth: number,
+ llmHeight: number,
+): string {
+ const auxCenter = {
+ x: auxPosition.x + auxWidth / 2,
+ y: auxPosition.y + auxHeight / 2,
+ };
+ const llmCenter = {
+ x: llmPosition.x + llmWidth / 2,
+ y: llmPosition.y + llmHeight / 2,
+ };
+ const dx = llmCenter.x - auxCenter.x;
+ const dy = llmCenter.y - auxCenter.y;
+
+ if (Math.abs(dx) >= Math.abs(dy)) {
+ return dx >= 0 ? HANDLE_IDS.llmInputOutRight : HANDLE_IDS.llmInputOutLeft;
+ }
+ return dy >= 0 ? HANDLE_IDS.llmInputOutBottom : HANDLE_IDS.llmInputOutTop;
+}
+
+type AppendAuxNodeAndEdgeInput = {
+ auxNodes: Node[];
+ auxEdges: Edge[];
+ entry: {
+ item: AuxNodeItem;
+ auxId: string;
+ width: number;
+ height: number;
+ };
+ position: XYPosition;
+ parentNode: Node;
+ parentWidth: number;
+ parentHeight: number;
+ auxTargetHandle: string;
+};
+
+function appendAuxNodeAndEdge({
+ auxNodes,
+ auxEdges,
+ entry,
+ position,
+ parentNode,
+ parentWidth,
+ parentHeight,
+ auxTargetHandle,
+}: AppendAuxNodeAndEdgeInput): void {
+ auxNodes.push({
+ id: entry.auxId,
+ type: "aux",
+ data: entry.item.data,
+ position,
+ width: entry.width,
+ height: entry.height,
+ style: {
+ width: entry.width,
+ height: entry.height,
+ },
+ draggable: true,
+ selectable: true,
+ focusable: true,
+ connectable: false,
+ });
+
+ auxEdges.push({
+ id: `e-${entry.auxId}-${parentNode.id}`,
+ source: entry.auxId,
+ sourceHandle: pickAuxSourceHandle(
+ position,
+ entry.width,
+ entry.height,
+ parentNode.position,
+ parentWidth,
+ parentHeight,
+ ),
+ target: parentNode.id,
+ targetHandle: auxTargetHandle,
+ type: "canvas",
+ data: { path: "auto" },
+ selectable: false,
+ focusable: false,
+ });
+}
+
export function deriveDisplayGraph({
nodes,
edges,
configs,
layoutDirection,
auxNodePositions,
- auxNodeSizes,
llmAuxVisibility,
}: DisplayGraphInput): DisplayGraph {
const displayNodes = nodes.map((node) => {
@@ -190,8 +378,6 @@ export function deriveDisplayGraph({
});
const auxNodes: Node[] = [];
const auxEdges: Edge[] = [];
- const auxDefaults: Record = {};
- const auxNodeIds: string[] = [];
const occupiedRects: Rect[] = displayNodes.map((node) =>
toRect(
node.position,
@@ -209,18 +395,18 @@ export function deriveDisplayGraph({
continue;
}
const llmDirection = node.data.layoutDirection ?? layoutDirection;
+ const auxTargetHandle = pickAuxTargetHandle(node.id, llmDirection, edges);
+ const auxTargetSide = getHandleSideFromTargetHandle(auxTargetHandle);
const items: AuxNodeItem[] = [];
if (config.system_prompt.trim()) {
items.push({
key: "system",
- targetHandle: HANDLE_IDS.llmSystemIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "system_prompt",
title: "System Prompt",
- layoutDirection: llmDirection,
},
});
}
@@ -228,13 +414,11 @@ export function deriveDisplayGraph({
if (config.prompt.trim()) {
items.push({
key: "prompt",
- targetHandle: HANDLE_IDS.llmPromptIn,
data: {
kind: "llm-prompt-input",
llmId: config.id,
field: "prompt",
title: "Prompt",
- layoutDirection: llmDirection,
},
});
}
@@ -243,12 +427,10 @@ export function deriveDisplayGraph({
(config.scores ?? []).forEach((_score, scoreIndex) => {
items.push({
key: `score-${scoreIndex}`,
- targetHandle: getLlmJudgeScoreHandleId(scoreIndex),
data: {
kind: "llm-judge-score",
llmId: config.id,
scoreIndex,
- layoutDirection: llmDirection,
},
});
});
@@ -262,19 +444,20 @@ export function deriveDisplayGraph({
const parentHeight = readNodeHeight(node) ?? DEFAULT_NODE_HEIGHT;
const itemsWithLayout = items.map((item) => {
const auxId = `aux-${node.id}-${item.key}`;
- const savedSize = auxNodeSizes[auxId];
return {
item,
auxId,
- width: savedSize?.width ?? DEFAULT_NODE_WIDTH,
- height: savedSize?.height ?? DEFAULT_NODE_HEIGHT,
+ width: DEFAULT_NODE_WIDTH,
+ height: DEFAULT_NODE_HEIGHT,
};
});
const gap = 24;
const sideOffset = 48;
+ const stackHorizontal =
+ auxTargetSide === "top" || auxTargetSide === "bottom";
- if (llmDirection === "TB") {
+ if (stackHorizontal) {
const totalWidth =
itemsWithLayout.reduce((sum, entry) => sum + entry.width, 0) +
(itemsWithLayout.length - 1) * gap;
@@ -284,51 +467,30 @@ export function deriveDisplayGraph({
for (const entry of itemsWithLayout) {
const preferredPosition = {
x: xCursor,
- y: node.position.y - entry.height - sideOffset,
+ y:
+ auxTargetSide === "top"
+ ? node.position.y - entry.height - sideOffset
+ : node.position.y + parentHeight + sideOffset,
};
const defaultPosition = findNonOverlappingPosition(
preferredPosition,
entry.width,
entry.height,
- llmDirection,
occupiedRects,
);
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
xCursor += entry.width + gap;
- auxNodeIds.push(entry.auxId);
- if (!auxNodePositions[entry.auxId]) {
- auxDefaults[entry.auxId] = defaultPosition;
- }
occupiedRects.push(toRect(position, entry.width, entry.height));
-
- auxNodes.push({
- id: entry.auxId,
- type: "aux",
- data: entry.item.data,
+ appendAuxNodeAndEdge({
+ auxNodes,
+ auxEdges,
+ entry,
position,
- width: entry.width,
- height: entry.height,
- style: {
- width: entry.width,
- height: entry.height,
- },
- draggable: true,
- selectable: true,
- focusable: true,
- connectable: true,
- });
-
- auxEdges.push({
- id: `e-${entry.auxId}-${node.id}`,
- source: entry.auxId,
- sourceHandle: HANDLE_IDS.llmInputOut,
- target: node.id,
- targetHandle: entry.item.targetHandle,
- type: "canvas",
- data: { path: "auto" },
- selectable: false,
- focusable: false,
+ parentNode: node,
+ parentWidth,
+ parentHeight,
+ auxTargetHandle,
});
}
continue;
@@ -338,7 +500,10 @@ export function deriveDisplayGraph({
itemsWithLayout.reduce((sum, entry) => sum + entry.height, 0) +
(itemsWithLayout.length - 1) * gap;
const maxWidth = Math.max(...itemsWithLayout.map((entry) => entry.width));
- const baseX = node.position.x - maxWidth - sideOffset;
+ const baseX =
+ auxTargetSide === "right"
+ ? node.position.x + parentWidth + sideOffset
+ : node.position.x - maxWidth - sideOffset;
let yCursor = node.position.y + (parentHeight - totalHeight) / 2;
for (const entry of itemsWithLayout) {
@@ -350,45 +515,21 @@ export function deriveDisplayGraph({
preferredPosition,
entry.width,
entry.height,
- llmDirection,
occupiedRects,
);
const position = auxNodePositions[entry.auxId] ?? defaultPosition;
yCursor += entry.height + gap;
- auxNodeIds.push(entry.auxId);
- if (!auxNodePositions[entry.auxId]) {
- auxDefaults[entry.auxId] = defaultPosition;
- }
occupiedRects.push(toRect(position, entry.width, entry.height));
-
- auxNodes.push({
- id: entry.auxId,
- type: "aux",
- data: entry.item.data,
+ appendAuxNodeAndEdge({
+ auxNodes,
+ auxEdges,
+ entry,
position,
- width: entry.width,
- height: entry.height,
- style: {
- width: entry.width,
- height: entry.height,
- },
- draggable: true,
- selectable: true,
- focusable: true,
- connectable: true,
- });
-
- auxEdges.push({
- id: `e-${entry.auxId}-${node.id}`,
- source: entry.auxId,
- sourceHandle: HANDLE_IDS.llmInputOut,
- target: node.id,
- targetHandle: entry.item.targetHandle,
- type: "canvas",
- data: { path: "auto" },
- selectable: false,
- focusable: false,
+ parentNode: node,
+ parentWidth,
+ parentHeight,
+ auxTargetHandle,
});
}
}
@@ -398,7 +539,5 @@ export function deriveDisplayGraph({
edges: [...edges, ...auxEdges].map((edge) =>
normalizeEdge(edge, configs, layoutDirection),
),
- auxNodeIds,
- auxDefaults,
};
}
diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/fit-view.ts b/studio/frontend/src/features/recipe-studio/utils/graph/fit-view.ts
new file mode 100644
index 0000000000..8b0175a1df
--- /dev/null
+++ b/studio/frontend/src/features/recipe-studio/utils/graph/fit-view.ts
@@ -0,0 +1,17 @@
+import type { Node } from "@xyflow/react";
+
+function isMarkdownNoteNode(node: Node): boolean {
+ if (node.type !== "builder") {
+ return false;
+ }
+ if (!node.data || typeof node.data !== "object") {
+ return false;
+ }
+ return (node.data as { kind?: string }).kind === "note";
+}
+
+export function getFitNodeIdsIgnoringNotes(nodes: Node[]): Array<{ id: string }> {
+ const nodesWithoutNotes = nodes.filter((node) => !isMarkdownNoteNode(node));
+ const targetNodes = nodesWithoutNotes.length > 0 ? nodesWithoutNotes : nodes;
+ return targetNodes.map((node) => ({ id: node.id }));
+}
diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
index 82450a766e..67ecf4e29d 100644
--- a/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/graph/recipe-graph-connection.ts
@@ -1,10 +1,12 @@
import { type Connection, type Edge, addEdge } from "@xyflow/react";
import type { NodeConfig, SamplerConfig } from "../../types";
import {
+ HANDLE_IDS,
isDataSourceHandle,
isDataTargetHandle,
isSemanticSourceHandle,
isSemanticTargetHandle,
+ normalizeRecipeHandleId,
} from "../handles";
import { isSemanticRelation } from "./relations";
import {
@@ -127,6 +129,97 @@ function isCompetingIncomingEdge(
return source.kind === "sampler" && source.sampler_type === "datetime";
}
+function isModelSemanticRelation(source: NodeConfig, target: NodeConfig): boolean {
+ return (
+ (source.kind === "model_provider" && target.kind === "model_config") ||
+ (source.kind === "model_config" && target.kind === "llm")
+ );
+}
+
+function countHandleUsage(
+ edges: Edge[],
+ nodeId: string,
+ handleId: string,
+ lane: "source" | "target",
+): number {
+ return edges.reduce((count, edge) => {
+ const edgeNodeId = lane === "source" ? edge.source : edge.target;
+ if (edgeNodeId !== nodeId) {
+ return count;
+ }
+ const edgeHandleId =
+ lane === "source"
+ ? normalizeRecipeHandleId(edge.sourceHandle)
+ : normalizeRecipeHandleId(edge.targetHandle);
+ return edgeHandleId === handleId ? count + 1 : count;
+ }, 0);
+}
+
+function pickLeastUsedHandle(
+ candidates: string[],
+ requested: string | null,
+ usageFor: (handleId: string) => number,
+): string {
+ let bestHandle = candidates[0];
+ let bestCount = Number.POSITIVE_INFINITY;
+ const requestedNormalized = requested
+ ? normalizeRecipeHandleId(requested)
+ : null;
+
+ for (const candidate of candidates) {
+ const usage = usageFor(candidate);
+ if (usage < bestCount) {
+ bestHandle = candidate;
+ bestCount = usage;
+ continue;
+ }
+ if (usage === bestCount && requestedNormalized === candidate) {
+ bestHandle = candidate;
+ }
+ }
+
+ return bestHandle;
+}
+
+function chooseModelSemanticHandles(
+ connection: Connection,
+ source: NodeConfig,
+ target: NodeConfig,
+ edges: Edge[],
+): Connection {
+ if (!isModelSemanticRelation(source, target)) {
+ return connection;
+ }
+
+ const sourceCandidates = [HANDLE_IDS.semanticOut, HANDLE_IDS.semanticOutBottom];
+ const targetCandidates =
+ target.kind === "model_config"
+ ? [HANDLE_IDS.semanticIn, HANDLE_IDS.semanticInTop]
+ : [
+ HANDLE_IDS.dataIn,
+ HANDLE_IDS.dataInTop,
+ HANDLE_IDS.dataInRight,
+ HANDLE_IDS.dataInBottom,
+ ];
+
+ const sourceHandle = pickLeastUsedHandle(
+ sourceCandidates,
+ connection.sourceHandle ?? null,
+ (handleId) => countHandleUsage(edges, source.id, handleId, "source"),
+ );
+ const targetHandle = pickLeastUsedHandle(
+ targetCandidates,
+ connection.targetHandle ?? null,
+ (handleId) => countHandleUsage(edges, target.id, handleId, "target"),
+ );
+
+ return {
+ ...connection,
+ sourceHandle,
+ targetHandle,
+ };
+}
+
export function isValidRecipeConnection(
connection: Connection,
configs: Record,
@@ -177,8 +270,14 @@ export function applyRecipeConnection(
!isCompetingIncomingEdge(edge, target.id, singleRefRelation, configs),
)
: edges;
+ const resolvedConnection = chooseModelSemanticHandles(
+ connection,
+ source,
+ target,
+ nextBaseEdges,
+ );
const nextEdges = addEdge(
- { ...connection, type: semanticRelation ? "semantic" : "canvas" },
+ { ...resolvedConnection, type: semanticRelation ? "semantic" : "canvas" },
nextBaseEdges,
);
if (source.kind === "model_provider" && target.kind === "model_config") {
diff --git a/studio/frontend/src/features/recipe-studio/utils/handle-layout.ts b/studio/frontend/src/features/recipe-studio/utils/handle-layout.ts
index 9f4af72ce9..467d62f5bf 100644
--- a/studio/frontend/src/features/recipe-studio/utils/handle-layout.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/handle-layout.ts
@@ -1,36 +1,5 @@
-import { Position } from "@xyflow/react";
-import type { LayoutDirection } from "../types";
-
export const NODE_HANDLE_CLASS =
"pointer-events-auto !size-2.5 !border-border/80 !bg-muted shadow-sm hover:!border-primary/70 hover:!bg-primary/20";
export const AUX_HANDLE_CLASS =
"!size-2 !border-border/80 !bg-muted/80 shadow-sm";
-
-export type NodeHandleLayout = {
- isTopBottom: boolean;
- dataInPosition: Position;
- dataOutPosition: Position;
- semanticInPosition: Position;
- semanticOutPosition: Position;
-};
-
-export function getNodeHandleLayout(
- direction: LayoutDirection,
-): NodeHandleLayout {
- const isTopBottom = direction === "TB";
- return {
- isTopBottom,
- dataInPosition: isTopBottom ? Position.Top : Position.Left,
- dataOutPosition: isTopBottom ? Position.Bottom : Position.Right,
- semanticInPosition: isTopBottom ? Position.Left : Position.Top,
- semanticOutPosition: isTopBottom ? Position.Right : Position.Bottom,
- };
-}
-
-export function getAuxSourceHandlePosition(
- direction: LayoutDirection,
-): Position {
- return direction === "TB" ? Position.Bottom : Position.Right;
-}
-
diff --git a/studio/frontend/src/features/recipe-studio/utils/handles.ts b/studio/frontend/src/features/recipe-studio/utils/handles.ts
index d7b154cc91..4e8b85e3ab 100644
--- a/studio/frontend/src/features/recipe-studio/utils/handles.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/handles.ts
@@ -23,17 +23,14 @@ export const HANDLE_IDS = {
semanticOutBottom: "semantic-out-bottom",
semanticOutRight: "semantic-out-right",
// llm prompt/scorer lanes
- llmPromptIn: "llm-prompt-in",
- llmSystemIn: "llm-system-in",
- llmInputOut: "llm-input-out",
+ llmInputOutLeft: "llm-input-out-left",
+ llmInputOutRight: "llm-input-out-right",
+ llmInputOutTop: "llm-input-out-top",
+ llmInputOutBottom: "llm-input-out-bottom",
} as const;
export type RecipeHandleId = (typeof HANDLE_IDS)[keyof typeof HANDLE_IDS];
-export function getLlmJudgeScoreHandleId(index: number): string {
- return `llm-judge-score-in-${index}`;
-}
-
const LEGACY_HANDLE_ALIAS_MAP: Record = {
[HANDLE_IDS.semanticInLeft]: HANDLE_IDS.semanticIn,
[HANDLE_IDS.semanticOutRight]: HANDLE_IDS.semanticOut,
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
index e2ea6d754a..dcec8b071e 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/importer.ts
@@ -453,7 +453,7 @@ export function importRecipePayload(input: string): ImportResult {
return { errors, snapshot: null };
}
- const { layouts, edges: uiEdges, layoutDirection } = parseUi(ui);
+ const { layouts, auxNodes, edges: uiEdges, layoutDirection } = parseUi(ui);
const resolvedLayoutDirection = layoutDirection ?? "LR";
const nodes = buildNodes(configs, layouts);
const edges = buildEdges(
@@ -462,6 +462,15 @@ export function importRecipePayload(input: string): ImportResult {
uiEdges,
resolvedLayoutDirection,
);
+ const auxNodePositions = Object.fromEntries(
+ auxNodes.flatMap((item) => {
+ const llmId = nameToId.get(item.llm);
+ if (!llmId) {
+ return [];
+ }
+ return [[`aux-${llmId}-${item.key}`, { x: item.x, y: item.y }]];
+ }),
+ );
const maxY = nodes.reduce(
(acc, node) => Math.max(acc, node.position.y),
@@ -474,6 +483,7 @@ export function importRecipePayload(input: string): ImportResult {
configs: Object.fromEntries(configs.map((config) => [config.id, config])),
nodes,
edges,
+ auxNodePositions,
processors,
layoutDirection: resolvedLayoutDirection,
nextId,
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/types.ts b/studio/frontend/src/features/recipe-studio/utils/import/types.ts
index 9b5502a5ba..281a4e1ad5 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/types.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/types.ts
@@ -1,4 +1,4 @@
-import type { Edge } from "@xyflow/react";
+import type { Edge, XYPosition } from "@xyflow/react";
import type {
LayoutDirection,
RecipeNode,
@@ -10,6 +10,7 @@ export type RecipeSnapshot = {
configs: Record;
nodes: RecipeNode[];
edges: Edge[];
+ auxNodePositions: Record;
processors: RecipeProcessorConfig[];
layoutDirection: LayoutDirection;
nextId: number;
diff --git a/studio/frontend/src/features/recipe-studio/utils/import/ui.ts b/studio/frontend/src/features/recipe-studio/utils/import/ui.ts
index 102fb4016e..76698fc8f2 100644
--- a/studio/frontend/src/features/recipe-studio/utils/import/ui.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/import/ui.ts
@@ -7,14 +7,23 @@ import { isRecord, readString } from "./helpers";
type UiInput = {
nodes?: unknown;
edges?: unknown;
+ aux_nodes?: unknown;
layout_direction?: unknown;
layoutDirection?: unknown;
};
+type ParsedAuxNode = {
+ llm: string;
+ key: string;
+ x: number;
+ y: number;
+};
+
export function parseUi(
ui: UiInput | null,
): {
layouts: Map;
+ auxNodes: ParsedAuxNode[];
edges: Array<{
from: string;
to: string;
@@ -25,6 +34,7 @@ export function parseUi(
layoutDirection: "LR" | "TB" | null;
} {
const layouts = new Map();
+ const auxNodes: ParsedAuxNode[] = [];
const edges: Array<{
from: string;
to: string;
@@ -72,6 +82,21 @@ export function parseUi(
}
}
}
+ if (ui && Array.isArray(ui.aux_nodes)) {
+ for (const node of ui.aux_nodes) {
+ if (!isRecord(node)) {
+ continue;
+ }
+ const llm = readString(node.llm);
+ const key = readString(node.key);
+ const x = typeof node.x === "number" ? node.x : null;
+ const y = typeof node.y === "number" ? node.y : null;
+ if (!(llm && key && x !== null && y !== null)) {
+ continue;
+ }
+ auxNodes.push({ llm, key, x, y });
+ }
+ }
const layoutDirectionRaw =
readString(ui?.layout_direction) ?? readString(ui?.layoutDirection);
const layoutDirection =
@@ -81,7 +106,12 @@ export function parseUi(
? "LR"
: null;
- return { layouts, edges: edges.length > 0 ? edges : null, layoutDirection };
+ return {
+ layouts,
+ auxNodes,
+ edges: edges.length > 0 ? edges : null,
+ layoutDirection,
+ };
}
export function buildNodes(
diff --git a/studio/frontend/src/features/recipe-studio/utils/layout.ts b/studio/frontend/src/features/recipe-studio/utils/layout.ts
index da3ffb5cd8..519637172d 100644
--- a/studio/frontend/src/features/recipe-studio/utils/layout.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/layout.ts
@@ -1,6 +1,8 @@
import dagre from "@dagrejs/dagre";
import type { Edge, Node } from "@xyflow/react";
+import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../constants";
import type { LayoutDirection } from "../types";
+import { readNodeHeight, readNodeWidth } from "./rf-node-dimensions";
type LayoutOptions = {
direction?: LayoutDirection;
@@ -21,8 +23,8 @@ export function getLayoutedElements(
nodesep = 80,
ranksep = 80,
edgesep = 28,
- nodeWidth = 220,
- nodeHeight = 64,
+ nodeWidth = DEFAULT_NODE_WIDTH,
+ nodeHeight = DEFAULT_NODE_HEIGHT,
} = options;
const graph = new dagre.graphlib.Graph();
@@ -36,8 +38,8 @@ export function getLayoutedElements(
});
nodes.forEach((node) => {
- const width = node.measured?.width ?? nodeWidth;
- const height = node.measured?.height ?? nodeHeight;
+ const width = readNodeWidth(node) ?? nodeWidth;
+ const height = readNodeHeight(node) ?? nodeHeight;
graph.setNode(node.id, { width, height });
});
@@ -54,8 +56,8 @@ export function getLayoutedElements(
const layoutedNodes = nodes.map((node) => {
const pos = graph.node(node.id);
- const width = node.measured?.width ?? nodeWidth;
- const height = node.measured?.height ?? nodeHeight;
+ const width = readNodeWidth(node) ?? nodeWidth;
+ const height = readNodeHeight(node) ?? nodeHeight;
return {
...node,
position: {
diff --git a/studio/frontend/src/features/recipe-studio/utils/node-data.ts b/studio/frontend/src/features/recipe-studio/utils/node-data.ts
index 96e9a0f4e2..76b1f5ad89 100644
--- a/studio/frontend/src/features/recipe-studio/utils/node-data.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/node-data.ts
@@ -41,18 +41,18 @@ export function nodeDataFromConfig(
}
if (config.kind === "seed") {
const seedSourceType = config.seed_source_type ?? "hf";
- const subtype =
+ const sourceLabel =
seedSourceType === "hf"
- ? "Hugging Face"
+ ? "Hugging Face dataset"
: seedSourceType === "local"
- ? "Local File"
- : "Unstructured";
+ ? "Structured file"
+ : "Unstructured document";
return {
title: "Seed",
kind: "seed",
- subtype,
+ subtype: sourceLabel,
blockType: "seed",
- name: config.name,
+ name: sourceLabel,
layoutDirection,
};
}
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
index 606f6a3cd4..385e214b4c 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/build-payload.ts
@@ -1,4 +1,4 @@
-import type { Edge } from "@xyflow/react";
+import type { Edge, XYPosition } from "@xyflow/react";
import type {
LayoutDirection,
ModelConfig,
@@ -70,6 +70,7 @@ export function buildRecipePayload(
edges: Edge[],
processors: RecipeProcessorConfig[] = [],
layoutDirection: LayoutDirection = "LR",
+ auxNodePositions: Record = {},
): RecipePayloadResult {
const errors: string[] = [];
const columns: Record[] = [];
@@ -270,6 +271,27 @@ export function buildRecipePayload(
},
];
});
+ const uiAuxNodes = Object.entries(auxNodePositions).flatMap(
+ ([auxId, position]) => {
+ const match = /^aux-([^-]+)-(.+)$/.exec(auxId);
+ if (!match) {
+ return [];
+ }
+ const [, llmId, key] = match;
+ const llmConfig = configs[llmId];
+ if (!(llmConfig && llmConfig.kind === "llm")) {
+ return [];
+ }
+ return [
+ {
+ llm: llmConfig.name,
+ key,
+ x: position.x,
+ y: position.y,
+ },
+ ];
+ },
+ );
const recipeProcessors = buildProcessors(processors, errors);
const seedConfig = firstSeed ? buildSeedConfig(firstSeed, errors) : undefined;
const seedDropProcessor = firstSeed
@@ -306,6 +328,7 @@ export function buildRecipePayload(
nodes: uiNodes,
edges: uiEdges,
layout_direction: layoutDirection,
+ ...(uiAuxNodes.length > 0 && { aux_nodes: uiAuxNodes }),
...(firstSeed && { seed_source_type: firstSeed.seed_source_type }),
...(firstSeed && { seed_columns: firstSeed.seed_columns ?? [] }),
...(firstSeed && {
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-model.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-model.ts
index 9181086d66..b3d361bb75 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/builders-model.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-model.ts
@@ -19,7 +19,7 @@ export function buildModelProvider(
name: config.name,
endpoint: config.endpoint,
// biome-ignore lint/style/useNamingConvention: api schema
- provider_type: config.provider_type,
+ provider_type: "openai",
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: config.api_key_env?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts b/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts
index 464f770a0c..a07e6c2ee8 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/builders-seed.ts
@@ -14,9 +14,6 @@ export function buildSeedConfig(
): Record | undefined {
const seedSourceType = config.seed_source_type ?? "hf";
const path = config.hf_path.trim();
- if (!path) {
- return undefined;
- }
const endpoint = config.hf_endpoint?.trim() || "https://huggingface.co";
const token = config.hf_token?.trim() || null;
diff --git a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
index e97f8db970..ceaec9c9b0 100644
--- a/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/payload/types.ts
@@ -52,6 +52,13 @@ export type RecipePayload = {
layout_direction?: "LR" | "TB";
// ui-only, used to preserve seed block mode across imports/refresh
seed_source_type?: "hf" | "local" | "unstructured";
+ // ui-only, persisted aux node positions by llm name + aux key
+ aux_nodes?: Array<{
+ llm: string;
+ key: string;
+ x: number;
+ y: number;
+ }>;
// ui-only, seed metadata cached for refresh/import UX
seed_columns?: string[];
seed_drop_columns?: string[];
diff --git a/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts b/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts
index bf6ebc397f..fa3fff8ddf 100644
--- a/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/reactflow-changes.ts
@@ -5,43 +5,25 @@ import type {
NodeChange,
XYPosition,
} from "@xyflow/react";
-import type { RecipeGraphAuxNodeData } from "../components/recipe-graph-aux-node";
-import type { RecipeNodeData } from "../types";
-type AnyNode = Node;
-
-export function applyAuxNodeChanges(
- changes: NodeChange[],
+export function applyAuxNodeChanges(
+ changes: NodeChange[],
actions: {
setAuxNodePosition: (id: string, position: XYPosition) => void;
- setAuxNodeSize: (
- id: string,
- size: { width: number; height: number },
- ) => void;
},
): void {
for (const change of changes) {
if (!("id" in change) || !change.id.startsWith("aux-")) {
continue;
}
- if (change.type === "position") {
- const nextPosition = change.position ?? change.positionAbsolute;
- if (nextPosition) {
- actions.setAuxNodePosition(change.id, nextPosition);
- }
+ if (change.type !== "position") {
continue;
}
- if (
- change.type === "dimensions" &&
- change.dimensions &&
- change.dimensions.width > 0 &&
- change.dimensions.height > 0
- ) {
- actions.setAuxNodeSize(change.id, {
- width: change.dimensions.width,
- height: change.dimensions.height,
- });
+ const nextPosition = change.position ?? change.positionAbsolute;
+ if (!nextPosition) {
+ continue;
}
+ actions.setAuxNodePosition(change.id, nextPosition);
}
}
@@ -62,4 +44,3 @@ export function filterEdgeChangesByIds(
(change): change is EdgeChange => "id" in change && ids.has(change.id),
);
}
-
diff --git a/studio/frontend/src/features/recipe-studio/utils/refs.ts b/studio/frontend/src/features/recipe-studio/utils/refs.ts
index 91f0e8e869..50f6c5b25f 100644
--- a/studio/frontend/src/features/recipe-studio/utils/refs.ts
+++ b/studio/frontend/src/features/recipe-studio/utils/refs.ts
@@ -1,4 +1,7 @@
const JINJA_REF_RE = /{{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*}}/g;
+const JINJA_EXPR_RE = /{{\s*([^{}]+?)\s*}}/g;
+const SIMPLE_JINJA_EXPR_RE = /^[a-zA-Z_][a-zA-Z0-9_.]*$/;
+const PLAIN_JINJA_EXPR_RE = /^[a-zA-Z0-9_.\s-]+$/;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -17,6 +20,37 @@ export function extractRefs(template: string): string[] {
return Array.from(refs);
}
+export function findInvalidJinjaReferences(
+ template: string,
+ validReferences: string[],
+): string[] {
+ if (!template) {
+ return [];
+ }
+ const validSet = new Set(
+ validReferences.map((name) => name.trim()).filter(Boolean),
+ );
+ const invalid = new Set();
+
+ for (const match of template.matchAll(JINJA_EXPR_RE)) {
+ const expr = (match[1] ?? "").trim();
+ if (!expr) {
+ continue;
+ }
+ if (SIMPLE_JINJA_EXPR_RE.test(expr)) {
+ if (!validSet.has(expr)) {
+ invalid.add(expr);
+ }
+ continue;
+ }
+ if (PLAIN_JINJA_EXPR_RE.test(expr)) {
+ invalid.add(expr);
+ }
+ }
+
+ return Array.from(invalid);
+}
+
export function replaceRef(
template: string,
from: string,