Merge pull request #272 from unslothai/feature/data-reciper-enchansments

feat(recipe-studio): UX + layout polish & WIP data-reciper client & backend finalization p1
This commit is contained in:
Wasim Yousef Said 2026-02-26 05:10:37 -08:00 committed by GitHub
commit d9de1aaf1b
54 changed files with 2556 additions and 1150 deletions

View file

@ -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"

View file

@ -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"

View file

@ -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<rows>\d+) records across (?P<cols>\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:

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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")

View file

@ -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=<seq>
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")

View file

@ -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)

View file

@ -162,20 +162,20 @@ function ComboboxContent({
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border *:data-[slot=input-group]:bg-input/30 max-h-72 min-w-36 overflow-hidden rounded-xl corner-squircle duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
className,
)}
{...props}
/>
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-[120] pointer-events-auto"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 shadow-border ring-1 ring-border *:data-[slot=input-group]:bg-input/30 max-h-72 min-w-36 overflow-hidden rounded-xl corner-squircle duration-100 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-9 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:shadow-none group/combobox-content relative pointer-events-auto max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) data-[chips=true]:min-w-(--anchor-width)",
className,
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
);

View file

@ -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",

View file

@ -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<HTMLButtonElement>) => void;
trailing?: "chevron" | "drag" | "none";
}): ReactElement {
return (
<button
type="button"
onClick={onClick}
draggable={draggable}
onDragStart={onDragStart}
className={`flex w-full items-center gap-3 border-l-2 bg-background px-3 py-3 text-left transition hover:bg-muted/35 ${
isActive
? "border-emerald-500"
: "border-transparent hover:border-border/60"
}`}
} ${draggable ? "cursor-grab active:cursor-grabbing" : ""}`}
>
<div className="flex size-9 items-center justify-center rounded-xl text-foreground/70">
<HugeiconsIcon icon={icon} className="size-5" />
@ -134,10 +165,18 @@ function BlockSheetButton({
<p className="text-sm font-semibold text-foreground">{title}</p>
<p className="text-[11px] text-muted-foreground">{description}</p>
</div>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
{trailing === "chevron" ? (
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3.5 text-muted-foreground"
/>
) : trailing === "drag" ? (
<HugeiconsIcon
icon={DragDropVerticalIcon}
strokeWidth={3.5}
className="size-5 text-foreground"
/>
) : null}
</button>
);
}
@ -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<HTMLButtonElement>) => {
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 (
<div className="flex flex-col items-end gap-2">
@ -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"
>
<SheetHeader className="border-b border-border/60 px-6 py-5">
<SheetHeader className="px-6 py-5">
<div className="flex items-center gap-2">
{sheetView !== "root" && (
<Button
@ -220,17 +355,52 @@ export function BlockSheet({
)}
<SheetTitle>{sheetTitle}</SheetTitle>
</div>
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search blocks..."
className="corner-squircle mt-3 h-9"
/>
</SheetHeader>
<div className=" py-4">
<div className="mt-4 flex flex-col gap-2">
{sheetView === "root" &&
ROOT_GROUPS.map((item, index) => (
{isRootView &&
hasSearch &&
rootSearchBlocks.map((item, index) => (
<BlockSheetButton
key={`${item.kind}:${item.type}`}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
draggable={true}
onDragStart={buildDragStart(item.kind, item.type)}
trailing={getTrailing(item.kind)}
onClick={() => onBlockClick(item.kind, item.type)}
/>
))}
{isRootView &&
!hasSearch &&
rootGroups.map((item, index) => (
<BlockSheetButton
key={item.kind}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
draggable={item.kind === "expression" || item.kind === "note"}
onDragStart={
item.kind === "expression" && expressionBlocks[0]
? buildDragStart("expression", expressionBlocks[0].type)
: item.kind === "note" && noteBlocks[0]
? buildDragStart("note", noteBlocks[0].type)
: undefined
}
trailing={
item.kind === "expression" || item.kind === "note"
? "drag"
: "chevron"
}
onClick={() => {
if (item.kind === "processor") {
setSheetOpen(false);
@ -256,18 +426,20 @@ export function BlockSheet({
}}
/>
))}
{sheetView === "processor" && (
<BlockSheetButton
icon={CodeIcon}
title="Schema Transform"
description="Transform final dataset schema."
isActive={true}
onClick={onOpenProcessors}
/>
{isProcessorView && (
(!hasSearch ||
matchesSearch(PROCESSOR_TITLE, PROCESSOR_DESCRIPTION)) && (
<BlockSheetButton
icon={CodeIcon}
title={PROCESSOR_TITLE}
description={PROCESSOR_DESCRIPTION}
isActive={true}
onClick={onOpenProcessors}
/>
)
)}
{sheetView !== "root" &&
sheetView !== "processor" &&
getBlocksForKind(VIEW_KIND[sheetView] ?? "sampler").map(
{isScopedBlockView &&
scopedBlocks.map(
(item, index) => (
<BlockSheetButton
key={item.type}
@ -275,29 +447,18 @@ export function BlockSheet({
title={item.title}
description={item.description}
isActive={index === 0}
onClick={() => {
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 && (
<p className="px-3 py-2 text-xs text-muted-foreground">
No blocks match.
</p>
)}
</div>
</div>
</SheetContent>

View file

@ -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<HTMLDivElement | null>(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 (
<div className="bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex min-h-9 flex-wrap items-center gap-1.5 rounded-4xl border bg-clip-padding px-1.5 py-1.5 text-sm transition-colors focus-within:ring-[3px]">
<div
ref={containerRef}
className={`bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex min-h-9 flex-wrap items-center gap-1.5 border bg-clip-padding px-1.5 py-1.5 text-sm transition-colors focus-within:ring-[3px] ${isWrapped ? "corner-squircle rounded-xl" : "rounded-4xl"}`}
>
{values.map((value, index) => (
<span
key={`${value}-${index}`}

View file

@ -5,6 +5,7 @@ import {
useUpdateNodeInternals,
} from "@xyflow/react";
import { Button } from "@/components/ui/button";
import { getFitNodeIdsIgnoringNotes } from "../../utils/graph/fit-view";
type LayoutControlsProps = {
direction: "LR" | "TB";
@ -32,20 +33,29 @@ export function LayoutControls({
requestAnimationFrame(() => {
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 (
<Panel position="top-left" className="m-3 flex items-center gap-2">

View file

@ -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 (
<Panel position="bottom-left" className="m-3 flex items-center gap-2">

View file

@ -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 (
<div className="space-y-3">
@ -52,32 +57,14 @@ export function InlineExpression({
<InlineField label="Expression">
<Input
className="nodrag h-8 w-full text-xs"
aria-invalid={invalidRefs.length > 0}
placeholder="{{ column_name }}"
value={config.expr}
onChange={(event) => onUpdate({ expr: event.target.value })}
/>
</InlineField>
</div>
{vars.length > 0 && (
<div className="space-y-1">
<p className="text-[10px] font-medium text-muted-foreground">Available references</p>
<div className="flex flex-wrap gap-1">
{vars.map((v) => (
<Badge
key={`${v.source}:${v.name}`}
variant="secondary"
className={
v.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]"
}
>
{v.name}
</Badge>
))}
</div>
</div>
)}
<AvailableReferencesInline entries={vars} />
</div>
);
}

View file

@ -14,19 +14,6 @@ export function InlineModel(props: InlineModelProps): ReactElement {
if (props.config.kind === "model_provider") {
return (
<div className="grid gap-3 sm:grid-cols-2">
<InlineField label="Provider type">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="openai-compatible"
value={props.config.provider_type}
onChange={(event) =>
props.onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: event.target.value,
})
}
/>
</InlineField>
<InlineField label="Endpoint">
<Input
className="nodrag h-8 w-full text-xs"
@ -35,6 +22,19 @@ export function InlineModel(props: InlineModelProps): ReactElement {
onChange={(event) => props.onUpdate({ endpoint: event.target.value })}
/>
</InlineField>
<InlineField label="API key">
<Input
className="nodrag h-8 w-full text-xs"
placeholder="Optional"
value={props.config.api_key ?? ""}
onChange={(event) =>
props.onUpdate({
// biome-ignore lint/style/useNamingConvention: api schema
api_key: event.target.value,
})
}
/>
</InlineField>
</div>
);
}

View file

@ -27,6 +27,9 @@ export function getConfigUiMode(
}
return "dialog";
}
if (config.kind === "seed") {
return "inline";
}
if (config.kind === "expression") {
return "inline";
}

View file

@ -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<SeedConfig>) => void;
};
export function InlineSeed({ config, onUpdate }: InlineSeedProps): ReactElement {
const mode = config.seed_source_type ?? "hf";
if (mode === "hf") {
return (
<div className="space-y-2">
<InlineField label="Dataset">
<HfDatasetCombobox
value={config.hf_repo_id}
accessToken={config.hf_token?.trim() || undefined}
onValueChange={(next) =>
onUpdate({
hf_repo_id: next,
hf_path: "",
seed_columns: [],
seed_drop_columns: [],
seed_preview_rows: [],
})
}
placeholder="org/repo"
/>
</InlineField>
<p className="text-[11px] text-muted-foreground">
Load columns in dialog.
</p>
</div>
);
}
const isLocal = mode === "local";
const fileName = isLocal
? config.local_file_name?.trim()
: config.unstructured_file_name?.trim();
return (
<div className="corner-squircle flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2 py-2">
<div className="corner-squircle rounded-md bg-primary/10 p-1.5 text-primary">
<HugeiconsIcon
icon={isLocal ? DocumentCodeIcon : DocumentAttachmentIcon}
className="size-3.5"
/>
</div>
<div className="min-w-0">
<p className="truncate text-xs font-medium">
{fileName || "No file selected"}
</p>
<p className="text-[11px] text-muted-foreground">
{isLocal ? "Structured file" : "Unstructured document"} · configure in dialog
</p>
</div>
<HugeiconsIcon icon={Plant01Icon} className="ml-auto size-3.5 text-muted-foreground/60" />
</div>
);
}

View file

@ -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 (
<div className="space-y-1">
<p className="text-[10px] font-medium text-muted-foreground">Available references</p>
<div className="flex flex-wrap gap-1">
{vars.map((v) => (
<Badge
key={`${v.source}:${v.name}`}
variant="secondary"
className={
v.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]"
}
>
{v.name}
</Badge>
))}
</div>
</div>
);
function AuxVariableBadges({
entries,
}: {
entries: ReturnType<typeof getAvailableVariableEntries>;
}): ReactElement | null {
return <AvailableReferencesInline entries={entries} />;
}
function AuxNodeBase({
@ -93,6 +70,7 @@ function AuxNodeBase({
data,
}: NodeProps<RecipeGraphAuxNodeType>): 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 = (
<>
<Handle
id={HANDLE_IDS.llmInputOutLeft}
type="source"
position={Position.Left}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
<Handle
id={HANDLE_IDS.llmInputOutRight}
type="source"
position={Position.Right}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
<Handle
id={HANDLE_IDS.llmInputOutTop}
type="source"
position={Position.Top}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
<Handle
id={HANDLE_IDS.llmInputOutBottom}
type="source"
position={Position.Bottom}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
</>
);
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 (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<NodeResizer
isVisible={true}
minWidth={MIN_NODE_WIDTH}
minHeight={120}
maxWidth={MAX_NODE_WIDTH}
maxHeight={520}
color="var(--primary)"
lineClassName="!border-transparent !shadow-none"
lineStyle={{ opacity: 0 }}
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
handleStyle={{ opacity: 0 }}
/>
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">{data.title}</BaseNodeHeaderTitle>
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<Textarea
className="corner-squircle nodrag max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
className="corner-squircle nodrag nowheel max-h-40 min-h-[88px] w-full resize-none overflow-y-auto text-xs"
aria-invalid={hasInvalidRefs}
value={value}
onChange={(event) =>
updateConfig(data.llmId, {
@ -135,16 +141,9 @@ function AuxNodeBase({
} as Partial<LlmConfig>)
}
/>
<AuxVariableBadges llmId={data.llmId} />
<AuxVariableBadges entries={variableEntries} />
</BaseNodeContent>
<Handle
id={HANDLE_IDS.llmInputOut}
type="source"
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
{sourceHandles}
</BaseNode>
);
}
@ -190,18 +189,6 @@ function AuxNodeBase({
return (
<BaseNode className="corner-squircle w-full min-w-0 rounded-lg border-border/60 bg-card shadow-sm">
<NodeResizer
isVisible={true}
minWidth={MIN_NODE_WIDTH}
minHeight={120}
maxWidth={MAX_NODE_WIDTH}
maxHeight={640}
color="var(--primary)"
lineClassName="!border-transparent !shadow-none"
lineStyle={{ opacity: 0 }}
handleClassName="!h-3 !w-3 !border-transparent !bg-transparent"
handleStyle={{ opacity: 0 }}
/>
<BaseNodeHeader className="border-b border-border/50 px-3 py-2">
<BaseNodeHeaderTitle className="text-xs">
{score.name.trim() || `Scorer ${data.scoreIndex + 1}`}
@ -218,7 +205,7 @@ function AuxNodeBase({
onChange={(event) => updateScore({ name: event.target.value })}
/>
<Textarea
className="corner-squircle nodrag max-h-32 min-h-[56px] w-full resize-none overflow-y-auto text-xs"
className="corner-squircle nodrag nowheel max-h-32 min-h-[56px] w-full resize-none overflow-y-auto text-xs"
placeholder="Score description"
value={score.description}
onChange={(event) => updateScore({ description: event.target.value })}
@ -260,14 +247,7 @@ function AuxNodeBase({
</Button>
</div>
</BaseNodeContent>
<Handle
id={HANDLE_IDS.llmInputOut}
type="source"
position={sourcePosition}
isConnectable={false}
isConnectableStart={false}
className={AUX_HANDLE_CLASS}
/>
{sourceHandles}
</BaseNode>
);
}

View file

@ -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 <InlineExpression config={config} onUpdate={onUpdate} />;
}
if (config.kind === "seed") {
return <InlineSeed config={config} onUpdate={onUpdate} />;
}
}
if (config?.kind === "sampler" && config.sampler_type === "category") {
@ -268,89 +270,6 @@ function renderNodeBody(
return <p className="text-xs text-muted-foreground">{summary}</p>;
}
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 (
<div className="flex flex-wrap gap-2 pb-1">
{items.map((item) => (
<div
key={item.id}
className="pointer-events-none relative flex min-w-[80px] flex-1 justify-center pt-2"
>
<Handle
id={item.id}
type="target"
position={Position.Top}
className={NODE_HANDLE_CLASS}
style={{ left: "50%", top: 0, transform: "translate(-50%, -50%)" }}
/>
<span className="text-[10px] text-muted-foreground">{item.label}</span>
</div>
))}
</div>
);
}
return (
<div className="space-y-1 pb-1">
{items.map((item) => (
<div key={item.id} className="pointer-events-none relative min-w-0 pl-3">
<Handle
id={item.id}
type="target"
position={Position.Left}
className={NODE_HANDLE_CLASS}
style={{ left: -3, top: "50%", transform: "translate(-50%, -50%)" }}
/>
<span className="block truncate text-[10px] text-muted-foreground">
{item.label}
</span>
</div>
))}
</div>
);
}
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({
</BaseNodeHeader>
<BaseNodeContent className="gap-2 px-3 py-2">
<LlmInputHandles items={llmInputHandles} layoutDirection={layoutDirection} />
{nodeBody}
</BaseNodeContent>
@ -506,6 +423,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOutLeft}
title="Data output"
type="source"
position={Position.Left}
className="absolute inset-0 pointer-events-none opacity-0"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataInTop}
title="Data input"
@ -515,6 +441,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOutTop}
title="Data output"
type="source"
position={Position.Top}
className="absolute inset-0 pointer-events-none opacity-0"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOut}
title="Data output"
@ -524,6 +459,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataInRight}
title="Data input"
type="target"
position={Position.Right}
className="absolute inset-0 pointer-events-none opacity-0"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataOutBottom}
title="Data output"
@ -533,6 +477,15 @@ function RecipeGraphNodeBase({
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
<LabeledHandle
id={HANDLE_IDS.dataInBottom}
title="Data input"
type="target"
position={Position.Bottom}
className="absolute inset-0 pointer-events-none opacity-0"
labelClassName="sr-only"
handleClassName={NODE_HANDLE_CLASS}
/>
</>
)}

View file

@ -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<HTMLDivElement | null>(null);
const measureRefs = useRef<Array<HTMLSpanElement | null>>([]);
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 (
<div className="space-y-1">
<p className="text-[10px] font-medium text-muted-foreground">
Available references
</p>
<div ref={wrapperRef} className="relative">
{!expanded && (
<div className="invisible pointer-events-none absolute inset-0 -z-10">
<div className="flex flex-wrap gap-1">
{entries.map((entry, index) => (
<Badge
// biome-ignore lint/suspicious/noArrayIndexKey: static measurement mirror
key={`${entry.source}:${entry.name}:${index}`}
ref={(node) => {
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}
</Badge>
))}
</div>
</div>
)}
<div className="flex flex-wrap gap-1">
{shown.map((entry) => (
<Badge
key={`${entry.source}:${entry.name}`}
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}
</Badge>
))}
{!expanded && hiddenCount > 0 && (
<button
type="button"
className="corner-squircle h-4 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(true)}
>
+{hiddenCount} more
</button>
)}
{expanded && collapsedCount < entries.length && (
<button
type="button"
className="corner-squircle h-4 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
onClick={() => setExpanded(false)}
>
Show less
</button>
)}
</div>
</div>
</div>
);
}

View file

@ -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<HTMLDivElement>(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 (
<div
ref={anchorRef}
className={className}
onKeyDown={(event) => {
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);
}
}}
>
<Combobox
items={items}
filteredItems={items}
filter={null}
value={value.trim() ? value : null}
onValueChange={(next) => onValueChange(next ?? "")}
onInputValueChange={(next) => {
if (selectingRef.current) {
selectingRef.current = false;
return;
}
setInputValue(next);
}}
itemToStringValue={(item) => item}
autoHighlight={true}
>
<ComboboxInput
id={inputId}
className="nodrag w-full"
placeholder={placeholder}
/>
<ComboboxContent anchor={anchorRef}>
{isLoading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Spinner className="size-3.5" />
Searching...
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
)}
<ComboboxList>
{(id: string) => (
<ComboboxItem
key={id}
value={id}
onPointerDown={() => {
selectingRef.current = true;
}}
>
{id}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
{error && (
<p className="mt-1 text-xs text-destructive">
{error}
</p>
)}
</div>
);
}

View file

@ -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 = <K extends keyof ExpressionConfig>(
key: K,
value: ExpressionConfig[K],
@ -71,10 +88,19 @@ export function ExpressionDialog({
<Textarea
id={exprId}
className="corner-squircle nodrag"
aria-invalid={invalidExprRefs.length > 0}
placeholder="{{ category_1 }} - {{ subcategory_1 }}"
value={config.expr}
onChange={(event) => updateField("expr", event.target.value)}
/>
{invalidExprRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidExprText}
{invalidExprRefs.length > 3
? ` +${invalidExprRefs.length - 3} more`
: ""}
</p>
)}
<p className="text-xs text-muted-foreground">
Use Jinja2. Reference columns like {"{{ column_name }}"}.
</p>

View file

@ -50,7 +50,7 @@ export function ImportDialog({
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl"
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl shadow-border"
>
<DialogHeader>
<DialogTitle>Import recipe</DialogTitle>
@ -63,7 +63,7 @@ export function ImportDialog({
/>
<Textarea
id={payloadId}
className="corner-squircle nodrag min-h-[220px]"
className="corner-squircle nodrag min-h-[220px] max-h-[450px]"
placeholder='{"recipe": { "columns": [] }}'
value={value}
onChange={(event) => setValue(event.target.value)}

View file

@ -14,8 +14,11 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { type ReactElement, type RefObject } from "react";
import { type ReactElement, type RefObject, useMemo } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { LlmConfig } 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";
@ -54,6 +57,7 @@ export function LlmGeneralTab({
modelAliasAnchorRef,
onUpdate,
}: LlmGeneralTabProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const modelAliasId = `${config.id}-model-alias`;
const codeLangId = `${config.id}-code-lang`;
const promptId = `${config.id}-prompt`;
@ -61,6 +65,26 @@ export function LlmGeneralTab({
const systemPromptId = `${config.id}-system-prompt`;
const hasModelConfigs = modelConfigAliases.length > 0;
const hasModelProviders = modelProviderOptions.length > 0;
const validReferences = useMemo(
() => getAvailableVariables(configs, config.id),
[configs, config.id],
);
const invalidPromptRefs = useMemo(
() => findInvalidJinjaReferences(config.prompt, validReferences),
[config.prompt, validReferences],
);
const invalidSystemRefs = useMemo(
() => findInvalidJinjaReferences(config.system_prompt, validReferences),
[config.system_prompt, validReferences],
);
const invalidPromptText = invalidPromptRefs
.slice(0, 3)
.map((ref) => `{{ ${ref} }}`)
.join(", ");
const invalidSystemText = invalidSystemRefs
.slice(0, 3)
.map((ref) => `{{ ${ref} }}`)
.join(", ");
return (
<div className="space-y-4">
@ -147,10 +171,19 @@ export function LlmGeneralTab({
/>
<Textarea
id={promptId}
className="corner-squircle nodrag"
className="corner-squircle nodrag max-h-[450px] overflow-auto"
aria-invalid={invalidPromptRefs.length > 0}
value={config.prompt}
onChange={(event) => onUpdate({ prompt: event.target.value })}
/>
{invalidPromptRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidPromptText}
{invalidPromptRefs.length > 3
? ` +${invalidPromptRefs.length - 3} more`
: ""}
</p>
)}
</div>
{config.llm_type === "structured" && (
<div className="grid gap-2">
@ -177,10 +210,19 @@ export function LlmGeneralTab({
/>
<Textarea
id={systemPromptId}
className="corner-squircle nodrag"
className="corner-squircle nodrag max-h-[450px] overflow-auto"
aria-invalid={invalidSystemRefs.length > 0}
value={config.system_prompt}
onChange={(event) => onUpdate({ system_prompt: event.target.value })}
/>
{invalidSystemRefs.length > 0 && (
<p className="text-xs text-destructive">
Unknown reference: {invalidSystemText}
{invalidSystemRefs.length > 3
? ` +${invalidSystemRefs.length - 3} more`
: ""}
</p>
)}
</div>
</div>
);

View file

@ -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 })}
/>
<div className="grid gap-2">
<FieldLabel
label="Provider type"
htmlFor={providerTypeId}
hint="Provider adapter type, e.g. openai or openrouter."
/>
<Input
id={providerTypeId}
className="nodrag"
placeholder="openai"
value={config.provider_type}
onChange={(event) =>
updateField("provider_type", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Endpoint"
@ -63,20 +52,6 @@ export function ModelProviderDialog({
onChange={(event) => updateField("endpoint", event.target.value)}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="API key env (optional)"
htmlFor={apiKeyEnvId}
hint="Env var name to read secret key from runtime."
/>
<Input
id={apiKeyEnvId}
className="nodrag"
placeholder="OPENAI_API_KEY"
value={config.api_key_env ?? ""}
onChange={(event) => updateField("api_key_env", event.target.value)}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="API key (optional)"
@ -90,36 +65,61 @@ export function ModelProviderDialog({
onChange={(event) => updateField("api_key", event.target.value)}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Extra headers (JSON)"
htmlFor={extraHeadersId}
hint="Optional request headers merged into every call."
/>
<Textarea
id={extraHeadersId}
className="corner-squircle nodrag"
placeholder='{"X-Header": "value"}'
value={config.extra_headers ?? ""}
onChange={(event) =>
updateField("extra_headers", event.target.value)
}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Extra body (JSON)"
htmlFor={extraBodyId}
hint="Optional payload fields merged into requests."
/>
<Textarea
id={extraBodyId}
className="corner-squircle nodrag"
placeholder='{"key": "value"}'
value={config.extra_body ?? ""}
onChange={(event) => updateField("extra_body", event.target.value)}
/>
</div>
<Collapsible open={optionalOpen} onOpenChange={setOptionalOpen}>
<CollapsibleTrigger asChild={true}>
<button
type="button"
className="flex w-full items-center justify-between text-left text-xs text-muted-foreground"
>
<span className="font-semibold uppercase">Optional</span>
<span>{optionalOpen ? "Hide" : "Show"}</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<div className="grid gap-2">
<FieldLabel
label="API key env (optional)"
htmlFor={apiKeyEnvId}
hint="Env var name to read secret key from runtime."
/>
<Input
id={apiKeyEnvId}
className="nodrag"
placeholder="OPENAI_API_KEY"
value={config.api_key_env ?? ""}
onChange={(event) => updateField("api_key_env", event.target.value)}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Extra headers (JSON)"
htmlFor={extraHeadersId}
hint="Optional request headers merged into every call."
/>
<Textarea
id={extraHeadersId}
className="corner-squircle nodrag"
placeholder='{"X-Header": "value"}'
value={config.extra_headers ?? ""}
onChange={(event) => updateField("extra_headers", event.target.value)}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Extra body (JSON)"
htmlFor={extraBodyId}
hint="Optional payload fields merged into requests."
/>
<Textarea
id={extraBodyId}
className="corner-squircle nodrag"
placeholder='{"key": "value"}'
value={config.extra_body ?? ""}
onChange={(event) => updateField("extra_body", event.target.value)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}

View file

@ -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"
>
<DialogHeader>
<DialogTitle>{kindLabel} settings</DialogTitle>
@ -294,7 +294,7 @@ export function RunDialog({
</p>
</DialogHeader>
<div className="flex items-center justify-between rounded-xl border bg-muted/20 px-3 py-2 text-sm">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-foreground">Preview mode</span>
<Switch
checked={kind === "preview"}

View file

@ -68,7 +68,7 @@ export function ProcessorsDialog({
position="absolute"
overlayPosition="absolute"
overlayClassName="bg-transparent"
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl"
className="corner-squircle max-h-[650px] overflow-auto sm:max-w-2xl shadow-border"
>
<VisuallyHidden.Root>
<DialogTitle>Processors</DialogTitle>

View file

@ -133,12 +133,15 @@ export function CategoryDialog({
Add values first, then set optional weights.
</p>
) : (
<div className="grid gap-2">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{(config.values ?? []).map((value, index) => (
<div key={`${value}-weight`} className="flex items-center gap-3">
<span className="max-w-20 truncate text-xs text-muted-foreground">
<div key={`${value}-weight`} className="space-y-1">
<p
className="truncate text-xs text-muted-foreground"
title={value}
>
{value}
</span>
</p>
<Input
type="number"
className="nodrag w-full"
@ -236,15 +239,18 @@ export function CategoryDialog({
<p className="text-xs font-semibold uppercase text-muted-foreground">
Rule weights (optional)
</p>
<div className="grid gap-2">
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{(params.values ?? []).map((value, index) => (
<div
key={`${condition}-${value}-${index}-weight`}
className="flex items-center gap-3"
className="space-y-1"
>
<span className="w-28 truncate text-xs text-muted-foreground">
<p
className="truncate text-xs text-muted-foreground"
title={value}
>
{value}
</span>
</p>
<Input
type="number"
className="nodrag"

View file

@ -45,6 +45,7 @@ import type {
SeedSamplingStrategy,
SeedSelectionType,
} from "../../types";
import { HfDatasetCombobox } from "../../components/shared/hf-dataset-combobox";
import { FieldLabel } from "../shared/field-label";
const SAMPLING_OPTIONS: Array<{ value: SeedSamplingStrategy; label: string }> = [
@ -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)."
/>
<div className="flex items-center gap-2">
<Input
id={datasetId}
className="nodrag flex-1"
placeholder="org/repo"
<HfDatasetCombobox
inputId={datasetId}
className="flex-1"
value={config.hf_repo_id}
onChange={(event) =>
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
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<FieldLabel
label="Subset (optional)"
htmlFor={subsetId}
hint="Dataset config/subset name."
/>
<Input
id={subsetId}
className="nodrag"
placeholder="default"
value={config.hf_subset ?? ""}
onChange={(event) => onUpdate({ hf_subset: event.target.value })}
/>
</div>
<div className="grid gap-2">
<FieldLabel
label="Split"
htmlFor={splitId}
hint="Split to inspect (default train)."
/>
<Input
id={splitId}
className="nodrag"
placeholder="train"
value={config.hf_split ?? ""}
onChange={(event) => onUpdate({ hf_split: event.target.value })}
/>
</div>
</div>
</>
)}
{mode === "local" && (
<div className="grid gap-2">
<FieldLabel
label="Local file"
label="Structured file"
hint="Upload CSV, JSON, or JSONL seed file."
/>
<div className="flex items-center gap-2">

View file

@ -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<string, unknown>;
const recipe =
root.recipe && typeof root.recipe === "object"
? (root.recipe as Record<string, unknown>)
: null;
const ui =
root.ui && typeof root.ui === "object"
? (root.ui as Record<string, unknown>)
: null;
const seedConfig =
recipe?.seed_config && typeof recipe.seed_config === "object"
? (recipe.seed_config as Record<string, unknown>)
: null;
const source =
seedConfig?.source && typeof seedConfig.source === "object"
? (seedConfig.source as Record<string, unknown>)
: 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<void> => {
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.");

View file

@ -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<HTMLDivElement | null>(
null,
);
const flowContainerRef = useRef<HTMLDivElement | null>(null);
const [blockSheetOpen, setBlockSheetOpen] = useState(false);
const [activeView, setActiveView] = useState<RecipeStudioView>("editor");
const [processorsOpen, setProcessorsOpen] = useState(false);
@ -179,6 +211,7 @@ export function RecipeStudioPage({
const [reactFlowInstance, setReactFlowInstance] = useState<
ReactFlowInstance<Node<RecipeNodeData | RecipeGraphAuxNodeData>, 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<RecipeNodeData | RecipeGraphAuxNodeData>) => {
@ -250,7 +275,7 @@ export function RecipeStudioPage({
const handleNodesChange = useCallback(
(changes: NodeChange<Node<RecipeNodeData | RecipeGraphAuxNodeData>>[]) => {
applyAuxNodeChanges(changes, { setAuxNodePosition, setAuxNodeSize });
applyAuxNodeChanges(changes, { setAuxNodePosition });
const next = filterNodeChangesByIds(
changes as NodeChange<RecipeBuilderNode>[],
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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();
}}
/>
<div className="h-[75vh] w-full rounded-t-none">
<div className="h-[75vh] w-full rounded-t-none" ref={flowContainerRef}>
{activeView === "editor" ? (
<ReactFlow<Node<RecipeNodeData | RecipeGraphAuxNodeData>, 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"
>
<LayoutControls
@ -474,13 +633,13 @@ export function RecipeStudioPage({
onViewChange={setSheetView}
open={blockSheetOpen}
onOpenChange={setBlockSheetOpen}
onAddSampler={addSamplerNode}
onAddSeed={addSeedNode}
onAddLlm={addLlmNode}
onAddModelProvider={addModelProviderNode}
onAddModelConfig={addModelConfigNode}
onAddExpression={addExpressionNode}
onAddMarkdownNote={addMarkdownNoteNode}
onAddSampler={handleAddSamplerFromSheet}
onAddSeed={handleAddSeedFromSheet}
onAddLlm={handleAddLlmFromSheet}
onAddModelProvider={handleAddModelProviderFromSheet}
onAddModelConfig={handleAddModelConfigFromSheet}
onAddExpression={handleAddExpressionFromSheet}
onAddMarkdownNote={handleAddMarkdownNoteFromSheet}
onOpenProcessors={openProcessorsFromSheet}
copied={copied}
onCopy={copyRecipe}

View file

@ -1,61 +0,0 @@
import type { XYPosition } from "@xyflow/react";
export function syncPositionsRecord(
prev: Record<string, XYPosition>,
activeIds: string[],
defaults: Record<string, XYPosition>,
): Record<string, XYPosition> {
const next: Record<string, XYPosition> = {};
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<string, { width: number; height: number }>,
activeIds: string[],
): Record<string, { width: number; height: number }> {
const active = new Set(activeIds);
const next: Record<string, { width: number; height: number }> = {};
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;
}

View file

@ -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<string, NodeConfig>): 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<string, NodeConfig>): 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<string, number>, nodeId: string, handleId: string): void {
const key = usageKey(nodeId, handleId);
map.set(key, (map.get(key) ?? 0) + 1);
}
function decrementUsage(map: Map<string, number>, 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<string, number>, nodeId: string, handleId: string): number {
return map.get(usageKey(nodeId, handleId)) ?? 0;
}
function pickHandleByUsage(
candidates: string[],
nodeId: string,
usageMap: Map<string, number>,
): 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<string, number>,
targetUsage: Map<string, number>,
): 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<string, RecipeNode>): Bounds | null {
const rects = ids
.map((id) => nodesById.get(id))
.flatMap((node) => (node ? [toRect(node)] : []));
if (rects.length === 0) {
return null;
}
return rects.reduce<Bounds>(
(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<string, NodeConfig>,
direction: LayoutDirection,
): Edge[] {
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
const sourceUsage = new Map<string, number>();
const targetUsage = new Map<string, number>();
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<string, NodeConfig>,
direction: LayoutDirection,
): RecipeNode[] {
const nodesById = new Map(nodes.map((node) => [node.id, node] as const));
const configToLlmIds = new Map<string, string[]>();
const providerToConfigIds = new Map<string, string[]>();
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);
}

View file

@ -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",
};
}

View file

@ -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<string, XYPosition>;
auxNodeSizes: Record<string, { width: number; height: number }>;
llmAuxVisibility: Record<string, boolean>;
configs: Record<string, NodeConfig>;
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<NodeConfig>) => 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<string, XYPosition>,
) => void;
syncAuxNodeSizes: (activeIds: string[]) => void;
onNodesChange: (changes: NodeChange<RecipeNode>[]) => void;
onEdgesChange: (changes: EdgeChange<Edge>[]) => 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> | 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<string, NodeConfig>): 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<RecipeStudioState>((set, get) => ({
...INITIAL_STATE,
setSheetView: (view) => set({ sheetView: view }),
@ -230,10 +252,19 @@ export const useRecipeStudioStore = create<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((set, get) => ({
}
return { ...node, position };
});
const nextAuxNodePositions: Record<string, XYPosition> = {};
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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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];

View file

@ -7,7 +7,6 @@ import {
getDefaultDataTargetHandle,
getDefaultSemanticSourceHandle,
getDefaultSemanticTargetHandle,
getLlmJudgeScoreHandleId,
HANDLE_IDS,
isDataSourceHandle,
isDataTargetHandle,
@ -24,15 +23,12 @@ type DisplayGraphInput = {
configs: Record<string, NodeConfig>;
layoutDirection: LayoutDirection;
auxNodePositions: Record<string, XYPosition>;
auxNodeSizes: Record<string, { width: number; height: number }>;
llmAuxVisibility: Record<string, boolean>;
};
export type DisplayGraph = {
nodes: Array<Node<RecipeNode["data"] | RecipeGraphAuxNodeData>>;
edges: Edge[];
auxNodeIds: string[];
auxDefaults: Record<string, XYPosition>;
};
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<HandleSide, string> = {
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<HandleSide>();
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<RecipeGraphAuxNodeData>[];
auxEdges: Edge[];
entry: {
item: AuxNodeItem;
auxId: string;
width: number;
height: number;
};
position: XYPosition;
parentNode: Node<RecipeNode["data"] | RecipeGraphAuxNodeData>;
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<RecipeGraphAuxNodeData>[] = [];
const auxEdges: Edge[] = [];
const auxDefaults: Record<string, XYPosition> = {};
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,
};
}

View file

@ -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 }));
}

View file

@ -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<string, NodeConfig>,
@ -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") {

View file

@ -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;
}

View file

@ -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<string, string> = {
[HANDLE_IDS.semanticInLeft]: HANDLE_IDS.semanticIn,
[HANDLE_IDS.semanticOutRight]: HANDLE_IDS.semanticOut,

View file

@ -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,

View file

@ -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<string, NodeConfig>;
nodes: RecipeNode[];
edges: Edge[];
auxNodePositions: Record<string, XYPosition>;
processors: RecipeProcessorConfig[];
layoutDirection: LayoutDirection;
nextId: number;

View file

@ -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<string, { x: number; y: number; width?: number }>;
auxNodes: ParsedAuxNode[];
edges: Array<{
from: string;
to: string;
@ -25,6 +34,7 @@ export function parseUi(
layoutDirection: "LR" | "TB" | null;
} {
const layouts = new Map<string, { x: number; y: number; width?: number }>();
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(

View file

@ -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<TNode extends Node>(
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<TNode extends Node>(
});
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<TNode extends Node>(
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: {

View file

@ -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,
};
}

View file

@ -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<string, XYPosition> = {},
): RecipePayloadResult {
const errors: string[] = [];
const columns: Record<string, unknown>[] = [];
@ -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 && {

View file

@ -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

View file

@ -14,9 +14,6 @@ export function buildSeedConfig(
): Record<string, unknown> | 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;

View file

@ -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[];

View file

@ -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<RecipeNodeData | RecipeGraphAuxNodeData>;
export function applyAuxNodeChanges(
changes: NodeChange<AnyNode>[],
export function applyAuxNodeChanges<T extends Node>(
changes: NodeChange<T>[],
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<Edge> => "id" in change && ids.has(change.id),
);
}

View file

@ -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<string>();
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,