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:
commit
d9de1aaf1b
54 changed files with 2556 additions and 1150 deletions
30
studio/backend/core/data_recipe/jobs/constants.py
Normal file
30
studio/backend/core/data_recipe/jobs/constants.py
Normal 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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
30
studio/backend/core/data_recipe/jsonable.py
Normal file
30
studio/backend/core/data_recipe/jsonable.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
23
studio/backend/routes/data_recipe/__init__.py
Normal file
23
studio/backend/routes/data_recipe/__init__.py
Normal 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"]
|
||||
143
studio/backend/routes/data_recipe/jobs.py
Normal file
143
studio/backend/routes/data_recipe/jobs.py
Normal 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")
|
||||
|
|
@ -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")
|
||||
90
studio/backend/routes/data_recipe/validate.py
Normal file
90
studio/backend/routes/data_recipe/validate.py
Normal 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)
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ export function getConfigUiMode(
|
|||
}
|
||||
return "dialog";
|
||||
}
|
||||
if (config.kind === "seed") {
|
||||
return "inline";
|
||||
}
|
||||
if (config.kind === "expression") {
|
||||
return "inline";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue