Merge branch 'nightly' into feat/gguf-llama-cpp-inference

This commit is contained in:
Roland Tannous 2026-02-25 16:06:03 +04:00 committed by GitHub
commit 01082b84e5
238 changed files with 28393 additions and 2371 deletions

11
.gitignore vendored
View file

@ -20,6 +20,7 @@ unsloth_compiled_cache/
# ML artifacts (large files)
outputs/
exports/
/datasets/
unsloth_training_checkpoints/
*.gguf
*.safetensors
@ -52,6 +53,16 @@ resources/
tmp/
auth.db
studio/frontend/package-lock.json
# Local working docs
**/CLAUDE.md
**/claude.md
**/AGENT.md
**/agent.md
docs/canvas-lab-architecture.md
studio/frontend/test/
studio/tests/
studio/backend/tests/
log_rtx.txt
log.txt
setup_leo.sh

View file

@ -155,16 +155,20 @@ fi
BEST_VER=$("$BEST_PY" --version 2>&1 | awk '{print $2}')
echo "✅ Using $BEST_PY ($BEST_VER) — compatible (≤ 3.12.x)"
if [ "$IS_COLAB" = true ]; then
# Colab: install packages directly without venv
REQ_ROOT="$SCRIPT_DIR/studio/backend/requirements"
SINGLE_ENV_CONSTRAINTS="$REQ_ROOT/single-env/constraints.txt"
SINGLE_ENV_DATA_DESIGNER="$REQ_ROOT/single-env/data-designer.txt"
SINGLE_ENV_DATA_DESIGNER_DEPS="$REQ_ROOT/single-env/data-designer-deps.txt"
SINGLE_ENV_PATCH="$REQ_ROOT/single-env/patch_metadata.py"
install_python_stack() {
run_quiet "pip upgrade" pip install --upgrade pip
echo " Installing unsloth-zoo + unsloth..."
run_quiet "pip install unsloth" pip install -r "$SCRIPT_DIR/studio/backend/requirements/base.txt"
run_quiet "pip install unsloth" pip install --no-cache-dir -c "$SINGLE_ENV_CONSTRAINTS" -r "$REQ_ROOT/base.txt"
echo " Installing additional unsloth dependencies..."
run_quiet "pip install extras" pip install --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/extras.txt"
run_quiet "pip install extras" pip install --no-deps --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/extras-no-deps.txt"
run_quiet "pip install torchao+transformers" pip install --force-reinstall --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/overrides.txt"
run_quiet "pip install triton_kernels" pip install --no-deps -r "$SCRIPT_DIR/studio/backend/requirements/triton-kernels.txt"
run_quiet "pip install extras" pip install --no-cache-dir -c "$SINGLE_ENV_CONSTRAINTS" -r "$REQ_ROOT/extras.txt"
run_quiet "pip install torchao+transformers" pip install --force-reinstall --no-cache-dir -c "$SINGLE_ENV_CONSTRAINTS" -r "$REQ_ROOT/overrides.txt"
run_quiet "pip install triton_kernels" pip install --no-deps --no-cache-dir -r "$REQ_ROOT/triton-kernels.txt"
# Patch: override llama_cpp.py with fix from unsloth-zoo branch
LLAMA_CPP_DST="$(pip show unsloth-zoo | grep -i '^Location:' | awk '{print $2}')/unsloth_zoo/llama_cpp.py"
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth-zoo/refs/heads/main/unsloth_zoo/llama_cpp.py" \
@ -174,32 +178,25 @@ if [ "$IS_COLAB" = true ]; then
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth/80e0108a684c882965a02a8ed851e3473c1145ab/unsloth/models/vision.py" \
-o "$VISION_DST"
echo " Installing studio dependencies..."
run_quiet "pip install studio" pip install -r "$SCRIPT_DIR/studio/backend/requirements/studio.txt"
run_quiet "pip install studio" pip install --no-cache-dir -c "$SINGLE_ENV_CONSTRAINTS" -r "$REQ_ROOT/studio.txt"
echo " Installing data-designer dependencies..."
run_quiet "pip install data-designer deps" pip install --no-cache-dir -c "$SINGLE_ENV_CONSTRAINTS" -r "$SINGLE_ENV_DATA_DESIGNER_DEPS"
echo " Installing data-designer..."
run_quiet "pip install data-designer" pip install --no-cache-dir --no-deps -c "$SINGLE_ENV_CONSTRAINTS" -r "$SINGLE_ENV_DATA_DESIGNER"
run_quiet "patch single-env metadata" python "$SINGLE_ENV_PATCH"
run_quiet "pip check" pip check
echo "✅ Python dependencies installed"
}
if [ "$IS_COLAB" = true ]; then
# Colab: install packages directly without venv
install_python_stack
else
# Local: create venv (always start fresh to preserve correct install order)
rm -rf .venv
"$BEST_PY" -m venv .venv
source .venv/bin/activate
run_quiet "pip upgrade" pip install --upgrade pip
echo " Installing unsloth-zoo + unsloth..."
run_quiet "pip install unsloth" pip install -r "$SCRIPT_DIR/studio/backend/requirements/base.txt"
echo " Installing additional unsloth dependencies..."
run_quiet "pip install extras" pip install --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/extras.txt"
run_quiet "pip install extras" pip install --no-deps --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/extras-no-deps.txt"
run_quiet "pip install torchao+transformers" pip install --force-reinstall --no-cache-dir -r "$SCRIPT_DIR/studio/backend/requirements/overrides.txt"
run_quiet "pip install triton_kernels" pip install --no-deps -r "$SCRIPT_DIR/studio/backend/requirements/triton-kernels.txt"
# Patch: override llama_cpp.py with fix from unsloth-zoo branch
LLAMA_CPP_DST="$(pip show unsloth-zoo | grep -i '^Location:' | awk '{print $2}')/unsloth_zoo/llama_cpp.py"
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth-zoo/refs/heads/main/unsloth_zoo/llama_cpp.py" \
-o "$LLAMA_CPP_DST"
# Patch: override vision.py with fix from unsloth PR: https://github.com/unslothai/unsloth/pull/4091 until next pypi release
VISION_DST="$(pip show unsloth | grep -i '^Location:' | awk '{print $2}')/unsloth/models/vision.py"
curl -sSL "https://raw.githubusercontent.com/unslothai/unsloth/80e0108a684c882965a02a8ed851e3473c1145ab/unsloth/models/vision.py" \
-o "$VISION_DST"
echo " Installing studio dependencies..."
run_quiet "pip install studio" pip install -r "$SCRIPT_DIR/studio/backend/requirements/studio.txt"
echo "✅ Python dependencies installed"
install_python_stack
# ── 7. WSL: pre-install GGUF build dependencies ──
# On WSL, sudo requires a password and can't be entered during GGUF export

View file

@ -0,0 +1,7 @@
"""
Data Recipe core (DataDesigner wrapper + job runner).
"""
from .jobs import JobManager, get_job_manager
__all__ = ["JobManager", "get_job_manager"]

View file

@ -0,0 +1,4 @@
from .manager import JobManager, get_job_manager
__all__ = ["JobManager", "get_job_manager"]

View file

@ -0,0 +1,469 @@
from __future__ import annotations
import asyncio
import json
import queue
import threading
import time
import uuid
from pathlib import Path
from collections import deque
from dataclasses import dataclass
from typing import Any
import multiprocessing as mp
from .parse import apply_update, coerce_event, parse_log_message
from .types import Job
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:
replay: list[dict]
_q: queue.Queue
_next_id: int = 0
async def next_event(self, *, timeout_sec: float) -> dict | None:
"""Wait for next event (SSE), w/ timeout so we can check disconnects."""
try:
return await asyncio.to_thread(self._q.get, True, timeout_sec)
except queue.Empty:
return None
def format_sse(self, event: dict) -> bytes:
"""Turn event dict into SSE bytes (id/event/data)."""
event_id = event.get("seq")
if event_id is None:
self._next_id += 1
event_id = self._next_id
body = json.dumps(event, separators=(",", ":"), ensure_ascii=False)
event_type = event.get("type") or "message"
return (
f"id: {event_id}\n"
f"event: {event_type}\n"
f"data: {body}\n\n"
).encode("utf-8")
class JobManager:
def __init__(self) -> None:
"""Single-job runner (in-mem). Simple on purpose, not a whole platform."""
self._lock = threading.Lock()
self._job: Job | None = None
self._proc: mp.Process | None = None
self._mp_q: Any | None = None
self._events: deque[dict] = deque(maxlen=5000)
self._subs: list[queue.Queue] = []
self._pump_thread: threading.Thread | None = None
self._seq: int = 0
def start(self, *, recipe: dict, run: dict) -> str:
"""Spawn the job subprocess (one at a time, no cap)."""
llm_columns = recipe.get("columns") or []
llm_column_count = 0
if isinstance(llm_columns, list):
for column in llm_columns:
if not isinstance(column, dict):
continue
column_type = str(column.get("column_type") or "").strip().lower()
if column_type.startswith("llm"):
llm_column_count += 1
if llm_column_count <= 0:
llm_column_count = 1
with self._lock:
if self._proc is not None and self._proc.is_alive():
raise RuntimeError("job already running")
job_id = uuid.uuid4().hex
self._job = Job(job_id=job_id, status="pending", started_at=time.time())
self._job.progress_columns_total = llm_column_count
self._events.clear()
self._seq = 0
run_payload = dict(run)
run_payload["_job_id"] = job_id
mp_q = _CTX.Queue()
proc = _CTX.Process(
target=run_job_process,
kwargs={"event_queue": mp_q, "recipe": recipe, "run": run_payload},
daemon=True,
)
proc.start()
self._mp_q = mp_q
self._proc = proc
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})
return job_id
def cancel(self, job_id: str) -> bool:
"""Hard stop. We terminate the subprocess. Quick + reliable."""
with self._lock:
if self._job is None or self._job.job_id != job_id:
return False
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})
try:
self._proc.terminate()
except Exception:
pass
return True
def get_status(self, job_id: str) -> dict | None:
"""UI-friendly snapshot. Poll this if you don't want SSE."""
with self._lock:
if self._job is None or self._job.job_id != job_id:
return None
job = self._job
return {
"job_id": job.job_id,
"status": job.status,
"stage": job.stage,
"current_column": job.current_column,
"batch": {"idx": job.batch.idx, "total": job.batch.total},
"progress": {
"done": job.progress.done,
"total": job.progress.total,
"percent": job.progress.percent,
"eta_sec": job.progress.eta_sec,
"rate": job.progress.rate,
"ok": job.progress.ok,
"failed": job.progress.failed,
},
"column_progress": {
"done": job.column_progress.done,
"total": job.column_progress.total,
"percent": job.column_progress.percent,
"eta_sec": job.column_progress.eta_sec,
"rate": job.column_progress.rate,
"ok": job.column_progress.ok,
"failed": job.column_progress.failed,
},
"model_usage": {
name: {
"model": usage.model,
"tokens": {
"input": usage.input_tokens,
"output": usage.output_tokens,
"total": usage.total_tokens,
"tps": usage.tps,
},
"requests": {
"success": usage.requests_success,
"failed": usage.requests_failed,
"total": usage.requests_total,
"rpm": usage.rpm,
},
}
for name, usage in job.model_usage.items()
},
"rows": job.rows,
"cols": job.cols,
"error": job.error,
"has_analysis": job.analysis is not None,
"dataset_rows": None if job.dataset is None else len(job.dataset),
"artifact_path": job.artifact_path,
"started_at": job.started_at,
"finished_at": job.finished_at,
}
def get_current_status(self) -> dict | None:
"""Single-job convenience (last/current)."""
job_id = self.get_current_job_id()
if job_id is None:
return None
return self.get_status(job_id)
def get_current_job_id(self) -> str | None:
"""Return current job_id (or None)."""
with self._lock:
return None if self._job is None else self._job.job_id
def get_analysis(self, job_id: str) -> dict | None:
"""Final profiling output (only after job completes)."""
with self._lock:
if self._job is None or self._job.job_id != job_id:
return None
return self._job.analysis
def get_dataset(
self,
job_id: str,
*,
limit: int,
offset: int = 0,
) -> dict[str, Any] | None:
"""Load dataset page (offset + limit) and include total rows."""
with self._lock:
if self._job is None or self._job.job_id != job_id:
return None
in_memory_dataset = self._job.dataset
artifact_path = self._job.artifact_path
job_status = self._job.status
if in_memory_dataset is not None:
total = len(in_memory_dataset)
rows = in_memory_dataset[offset:offset + limit]
return {"dataset": rows, "total": total}
if not artifact_path:
if job_status in {"completed", "error", "cancelled"}:
return {"error": "artifact path missing"}
return None
try:
base_dataset_path = Path(artifact_path)
parquet_dir = base_dataset_path / "parquet-files"
if not parquet_dir.exists():
return {"error": f"dataset path missing: {parquet_dir}"}
return self._load_dataset_page(parquet_dir=parquet_dir, limit=limit, offset=offset)
except Exception as exc:
return {"error": f"dataset load failed: {exc}"}
@staticmethod
def _load_dataset_page(
*,
parquet_dir: Path,
limit: int,
offset: int,
) -> dict[str, Any]:
dataset_page = JobManager._load_dataset_page_with_duckdb(
parquet_dir=parquet_dir,
limit=limit,
offset=offset,
)
if dataset_page is not None:
return dataset_page
return JobManager._load_dataset_page_with_data_designer(
parquet_dir=parquet_dir,
limit=limit,
offset=offset,
)
@staticmethod
def _load_dataset_page_with_duckdb(
*,
parquet_dir: Path,
limit: int,
offset: int,
) -> dict[str, Any] | None:
parquet_glob = str((parquet_dir / "*.parquet").resolve())
try:
import duckdb # type: ignore
except Exception:
return None
try:
conn = duckdb.connect(":memory:")
try:
total_row = conn.execute(
"SELECT COUNT(*) FROM read_parquet(?)",
[parquet_glob],
).fetchone()
total = int(total_row[0] if total_row else 0)
dataframe = conn.execute(
(
"SELECT *, row_number() OVER (PARTITION BY filename) AS __row_num__ "
"FROM read_parquet(?, filename=true) "
"ORDER BY filename, __row_num__ "
"LIMIT ? OFFSET ?"
),
[parquet_glob, int(limit), int(offset)],
).fetchdf()
finally:
conn.close()
except Exception:
return None
for helper_col in ("filename", "__row_num__"):
if helper_col in dataframe.columns:
dataframe = dataframe.drop(columns=[helper_col])
rows = dataframe.to_dict(orient="records")
return {"dataset": _to_jsonable(rows), "total": total}
@staticmethod
def _load_dataset_page_with_data_designer(
*,
parquet_dir: Path,
limit: int,
offset: int,
) -> dict[str, Any]:
from data_designer.config.utils.io_helpers import read_parquet_dataset
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}
def subscribe(self, job_id: str, *, after_seq: int | None = None) -> Subscription | None:
"""SSE subscribe: get replay buffer + live events stream."""
with self._lock:
if self._job is None or self._job.job_id != job_id:
return None
q: queue.Queue = queue.Queue(maxsize=2000)
self._subs.append(q)
if after_seq is None:
replay = list(self._events)
else:
replay = [e for e in self._events if int(e.get("seq") or 0) > after_seq]
return Subscription(replay=replay, _q=q)
def unsubscribe(self, sub: Subscription) -> None:
"""Drop SSE subscriber (client disconnected)."""
with self._lock:
self._subs = [q for q in self._subs if q is not sub._q]
def _emit(self, event: dict) -> None:
"""Broadcast event to replay buffer + all subscribers."""
self._seq += 1
event["seq"] = self._seq
self._events.append(event)
stale: list[queue.Queue] = []
for q in self._subs:
try:
q.put_nowait(event)
except Exception:
stale.append(q)
if stale:
self._subs = [q for q in self._subs if q not in stale]
def _snapshot(self) -> tuple[Job, mp.Process, Any] | None:
"""Grab pointers for the pump loop (avoid holding lock too long)."""
with self._lock:
if self._job is None or self._proc is None or self._mp_q is None:
return None
return self._job, self._proc, self._mp_q
@staticmethod
def _read_queue_with_timeout(q: Any, *, timeout_sec: float) -> dict | None:
"""Try read 1 event from mp queue. Timeout = pump stays responsive."""
try:
return coerce_event(q.get(timeout=timeout_sec))
except queue.Empty:
return None
except Exception:
return None
@staticmethod
def _drain_queue(q: Any) -> list[dict]:
"""Drain mp queue fast (used on process exit)."""
events: list[dict] = []
while True:
try:
events.append(coerce_event(q.get_nowait()))
except queue.Empty:
return events
except Exception:
return events
def _pump_loop(self) -> None:
"""Background thread: consumes worker events + updates job snapshot."""
while True:
snap = self._snapshot()
if snap is None:
return
job, proc, mp_q = snap
event = self._read_queue_with_timeout(mp_q, timeout_sec=0.25)
if event is not None:
self._handle_event(job, event)
continue
if proc.is_alive():
continue
for e in self._drain_queue(mp_q):
self._handle_event(job, e)
with self._lock:
if self._job and self._job.status in {"pending", "active", "cancelling"}:
if self._job.status == "cancelling":
self._job.status = "cancelled"
else:
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,
}
)
return
def _handle_event(self, job: Job, event: dict) -> None:
"""Apply event -> job state + forward to SSE."""
et = event.get("type")
msg = event.get("message") if et == "log" else None
with self._lock:
if self._job is None or self._job.job_id != job.job_id:
return
if et == "job.started":
self._job.status = "active"
if et == "job.completed":
self._job.status = "completed"
self._job.finished_at = time.time()
self._job.analysis = event.get("analysis")
self._job.artifact_path = event.get("artifact_path")
self._job.dataset = event.get("dataset")
self._job.processor_artifacts = event.get("processor_artifacts")
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":
self._job.status = "error"
self._job.finished_at = time.time()
self._job.error = event.get("error") or "error"
if msg:
upd = parse_log_message(msg)
if upd:
apply_update(self._job, upd)
self._emit(event)
_JOB_MANAGER: JobManager | None = None
def get_job_manager() -> JobManager:
"""Singleton JobManager (we only run 1 job anyway)."""
global _JOB_MANAGER
if _JOB_MANAGER is None:
_JOB_MANAGER = JobManager()
return _JOB_MANAGER

View file

@ -0,0 +1,241 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from .types import Job, ModelUsage, Progress
@dataclass(frozen=True)
class ParsedUpdate:
stage: str | None = None
current_column: str | None = None
progress: Progress | None = None
rows: int | None = None
cols: int | None = None
batch_idx: int | None = None
batch_total: int | None = None
usage_model: str | None = None
usage_input_tokens: int | None = None
usage_output_tokens: int | None = None
usage_total_tokens: int | None = None
usage_tps: float | None = None
usage_requests_success: int | None = None
usage_requests_failed: int | None = None
usage_requests_total: int | None = None
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
_RE_SAMPLERS = re.compile(
r"Preparing samplers to generate (?P<rows>\d+) records across (?P<cols>\d+) columns"
)
_RE_COLCFG = re.compile(r"model config for column '(?P<col>[^']+)'")
_RE_PROCESSING_COL = re.compile(r"Processing .* column '(?P<col>[^']+)'")
_RE_PROGRESS = re.compile(
r"progress: (?P<done>\d+)/(?P<total>\d+) \((?P<pct>\d+)%\) complete, "
r"(?P<ok>\d+) ok, (?P<failed>\d+) failed, (?P<rate>[0-9.]+) rec/s, eta (?P<eta>[0-9.]+)s"
)
_RE_BATCH = re.compile(r"Processing batch (?P<idx>\d+) of (?P<total>\d+)")
_RE_USAGE_MODEL = re.compile(r"model:\s*(?P<model>.+)$")
_RE_USAGE_TOKENS = re.compile(
r"tokens:\s*input=(?P<input>\d+),\s*output=(?P<output>\d+),\s*total=(?P<total>\d+),\s*tps=(?P<tps>[0-9.]+)"
)
_RE_USAGE_REQUESTS = re.compile(
r"requests:\s*success=(?P<success>\d+),\s*failed=(?P<failed>\d+),\s*total=(?P<total>\d+),\s*rpm=(?P<rpm>[0-9.]+)"
)
def parse_log_message(msg: str) -> ParsedUpdate | None:
m = _RE_SAMPLERS.search(msg)
if m:
return ParsedUpdate(
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")
if "Running health checks for models" in msg:
return ParsedUpdate(stage="healthcheck")
if "Preview generation in progress" in msg:
return ParsedUpdate(stage="preview")
if "Creating Data Designer dataset" in msg:
return ParsedUpdate(stage="create")
if "Measuring dataset column statistics" in msg:
return ParsedUpdate(stage="profiling")
m = _RE_COLCFG.search(msg)
if m:
col = m.group("col")
return ParsedUpdate(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)
m = _RE_PROGRESS.search(msg)
if m:
p = Progress(
done=int(m.group("done")),
total=int(m.group("total")),
percent=float(m.group("pct")),
ok=int(m.group("ok")),
failed=int(m.group("failed")),
rate=float(m.group("rate")),
eta_sec=float(m.group("eta")),
)
return ParsedUpdate(stage="generating", progress=p)
m = _RE_BATCH.search(msg)
if m:
return ParsedUpdate(
stage="batch",
batch_idx=int(m.group("idx")),
batch_total=int(m.group("total")),
)
if "Model usage summary" in msg:
return ParsedUpdate(usage_section_start=True)
m = _RE_USAGE_MODEL.search(msg)
if m and "|-- model:" in msg:
return ParsedUpdate(usage_model=str(m.group("model")).strip())
m = _RE_USAGE_TOKENS.search(msg)
if m:
return ParsedUpdate(
usage_input_tokens=int(m.group("input")),
usage_output_tokens=int(m.group("output")),
usage_total_tokens=int(m.group("total")),
usage_tps=float(m.group("tps")),
)
m = _RE_USAGE_REQUESTS.search(msg)
if m:
return ParsedUpdate(
usage_requests_success=int(m.group("success")),
usage_requests_failed=int(m.group("failed")),
usage_requests_total=int(m.group("total")),
usage_rpm=float(m.group("rpm")),
)
return None
def apply_update(job: Job, update: ParsedUpdate) -> None:
if update.stage is not 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:
job._seen_generation_columns.append(update.current_column)
if update.rows is not None:
job.rows = update.rows
if update.cols is not None:
job.cols = update.cols
if update.progress is not None:
job.column_progress = update.progress
job.progress = _compute_overall_progress(job, update.progress)
if update.batch_idx is not None:
job.batch.idx = update.batch_idx
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.
job._in_usage_summary = False
if update.usage_section_start is not None:
job._in_usage_summary = update.usage_section_start
if update.usage_section_start:
job._current_usage_model = None
if not job._in_usage_summary:
return
if update.usage_model is not None:
name = update.usage_model.strip().strip("'").strip('"')
job._current_usage_model = name
if name not in job.model_usage:
job.model_usage[name] = ModelUsage(model=name)
if job._current_usage_model is None:
return
usage = job.model_usage.get(job._current_usage_model)
if usage is None:
return
if update.usage_input_tokens is not None:
usage.input_tokens = update.usage_input_tokens
if update.usage_output_tokens is not None:
usage.output_tokens = update.usage_output_tokens
if update.usage_total_tokens is not None:
usage.total_tokens = update.usage_total_tokens
if update.usage_tps is not None:
usage.tps = update.usage_tps
if update.usage_requests_success is not None:
usage.requests_success = update.usage_requests_success
if update.usage_requests_failed is not None:
usage.requests_failed = update.usage_requests_failed
if update.usage_requests_total is not None:
usage.requests_total = update.usage_requests_total
if update.usage_rpm is not None:
usage.rpm = update.usage_rpm
def _compute_overall_progress(job: Job, column_progress: Progress) -> Progress:
if not job.rows:
return column_progress
total_rows = max(1, int(job.rows))
current_done = 0 if column_progress.done is None else int(column_progress.done)
current_done = max(0, min(current_done, total_rows))
total_columns = max(1, int(job.progress_columns_total or 1))
if job.current_column:
job._column_done[job.current_column] = current_done
if len(job._column_done) == 0:
done = current_done
else:
sum_done = sum(max(0, min(value, total_rows)) for value in job._column_done.values())
done = int(sum_done / total_columns)
prev_done = int(job.progress.done or 0)
if done < prev_done:
done = prev_done
if done > total_rows:
done = total_rows
percent = (done / total_rows) * 100 if total_rows > 0 else 100.0
prev_percent = float(job.progress.percent or 0.0)
if percent < prev_percent:
percent = prev_percent
return Progress(
done=done,
total=total_rows,
percent=percent,
eta_sec=column_progress.eta_sec,
rate=column_progress.rate,
ok=column_progress.ok,
failed=column_progress.failed,
)
def coerce_event(obj: Any) -> dict:
# worker sends dict already
return obj if isinstance(obj, dict) else {"type": "log", "message": str(obj)}

View file

@ -0,0 +1,72 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
JobStatus = Literal[
"created",
"pending",
"active",
"cancelling",
"cancelled",
"error",
"completed",
]
@dataclass
class Progress:
done: int | None = None
total: int | None = None
percent: float | None = None
eta_sec: float | None = None
rate: float | None = None
ok: int | None = None
failed: int | None = None
@dataclass
class BatchProgress:
idx: int | None = None
total: int | None = None
@dataclass
class ModelUsage:
model: str
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
tps: float | None = None
requests_success: int | None = None
requests_failed: int | None = None
requests_total: int | None = None
rpm: float | None = None
@dataclass
class Job:
job_id: str
status: JobStatus = "created"
stage: str | None = None
current_column: str | None = None
progress: Progress = field(default_factory=Progress)
column_progress: Progress = field(default_factory=Progress)
batch: BatchProgress = field(default_factory=BatchProgress)
rows: int | None = None
cols: int | None = None
error: str | None = None
started_at: float | None = None
finished_at: float | None = None
analysis: dict[str, Any] | None = None
artifact_path: str | None = None
dataset: list[dict[str, Any]] | None = None
processor_artifacts: dict[str, Any] | None = None
model_usage: dict[str, ModelUsage] = field(default_factory=dict)
progress_columns_total: int | None = None
_current_usage_model: str | None = None
_in_usage_summary: bool = False
_seen_generation_columns: list[str] = field(default_factory=list)
_column_done: dict[str, int] = field(default_factory=dict)

View file

@ -0,0 +1,169 @@
from __future__ import annotations
import logging
import shutil
import time
import traceback
from pathlib import Path
from typing import Any
from ..service import build_config_builder, create_data_designer
_PROJECT_ROOT = Path(__file__).resolve().parents[5]
_ARTIFACT_ROOT = _PROJECT_ROOT / "studio" / "backend" / "assets" / "datasets"
class _QueueLogHandler(logging.Handler):
def __init__(self, event_queue):
super().__init__()
self._q = event_queue
def emit(self, record: logging.LogRecord) -> None:
try:
event = {
"type": "log",
"ts": record.created,
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
self._q.put(event)
except Exception:
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,
recipe: dict[str, Any],
run: dict[str, Any],
) -> None:
"""
Subprocess entrypoint.
Sends events to `event_queue`.
"""
event_queue.put({"type": "job.started", "ts": time.time()})
try:
from data_designer.config.run_config import RunConfig
rows = int(run.get("rows") or 1000)
job_id = str(run.get("_job_id") or "").strip()
if not job_id:
job_id = f"{int(time.time())}"
dataset_name = f"recipe_{job_id}"
merge_batches = bool(run.get("merge_batches"))
_ARTIFACT_ROOT.mkdir(parents=True, exist_ok=True)
run_config_raw = run.get("run_config") or {}
builder = build_config_builder(recipe)
designer = create_data_designer(recipe, artifact_path=str(_ARTIFACT_ROOT))
# DataDesigner configures root logging in DataDesigner.__init__.
# Attach queue logger directly to `data_designer` so parser events survive root resets.
handler = _QueueLogHandler(event_queue)
handler.setLevel(logging.INFO)
data_designer_logger = logging.getLogger("data_designer")
data_designer_logger.addHandler(handler)
data_designer_logger.setLevel(logging.INFO)
data_designer_logger.propagate = True
if run_config_raw:
designer.set_run_config(RunConfig.model_validate(run_config_raw))
execution_type = str(run.get("execution_type") or "full").strip().lower()
if execution_type == "preview":
results = designer.preview(builder, num_records=rows)
analysis = (
None
if results.analysis is None
else _to_jsonable(results.analysis.model_dump(mode="json"))
)
dataset = (
[]
if results.dataset is None
else _to_jsonable(results.dataset.to_dict(orient="records"))
)
processor_artifacts = (
None
if results.processor_artifacts is None
else _to_jsonable(results.processor_artifacts)
)
event_queue.put(
{
"type": "job.completed",
"ts": time.time(),
"analysis": analysis,
"dataset": dataset,
"processor_artifacts": processor_artifacts,
"artifact_path": None,
"execution_type": execution_type,
}
)
else:
results = designer.create(builder, num_records=rows, dataset_name=dataset_name)
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",
"ts": time.time(),
"analysis": analysis,
"artifact_path": artifact_path,
"execution_type": execution_type,
}
)
except Exception as exc:
event_queue.put(
{
"type": "job.error",
"ts": time.time(),
"error": str(exc),
"stack": traceback.format_exc(limit=20),
}
)
def _merge_batches_to_single_parquet(base_dataset_path: Path) -> None:
parquet_dir = base_dataset_path / "parquet-files"
parquet_files = sorted(parquet_dir.glob("*.parquet"))
if len(parquet_files) <= 1:
return
try:
from data_designer.config.utils.io_helpers import read_parquet_dataset
except Exception:
return
dataframe = read_parquet_dataset(parquet_dir)
shutil.rmtree(parquet_dir)
parquet_dir.mkdir(parents=True, exist_ok=True)
dataframe.to_parquet(parquet_dir / "batch_00000.parquet", index=False)

View file

@ -0,0 +1,174 @@
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
def build_model_providers(recipe: dict[str, Any]):
from data_designer.config.default_model_settings import get_default_providers
from data_designer.config.models import ModelProvider
providers: list[ModelProvider] = []
for provider in recipe.get("model_providers", []):
api_key = provider.get("api_key")
api_key_env = provider.get("api_key_env")
if not api_key and api_key_env:
api_key = os.getenv(api_key_env)
providers.append(
ModelProvider(
name=provider["name"],
endpoint=provider["endpoint"],
provider_type=provider.get("provider_type", "openai"),
api_key=api_key,
extra_headers=provider.get("extra_headers"),
extra_body=provider.get("extra_body"),
)
)
# DataDesigner currently expects at least one provider even if they only use static samplers,
# but it's fine it gives a warning only.
return providers or get_default_providers()
def build_mcp_providers(
recipe: dict[str, Any],
) -> list:
from data_designer.config.mcp import LocalStdioMCPProvider, MCPProvider
providers: list[MCPProvider | LocalStdioMCPProvider] = []
for provider in recipe.get("mcp_providers", []):
if not isinstance(provider, dict):
continue
provider_type = provider.get("provider_type")
if provider_type == "stdio":
env = provider.get("env")
if not isinstance(env, dict):
env = {}
args = provider.get("args")
if not isinstance(args, list):
args = []
providers.append(
LocalStdioMCPProvider(
name=str(provider.get("name", "")),
command=str(provider.get("command", "")),
args=[str(value) for value in args],
env={str(key): str(value) for key, value in env.items()},
)
)
continue
if provider_type in {"sse", "streamable_http"}:
api_key = provider.get("api_key")
api_key_env = provider.get("api_key_env")
if not api_key and api_key_env:
api_key = os.getenv(str(api_key_env))
providers.append(
MCPProvider(
name=str(provider.get("name", "")),
endpoint=str(provider.get("endpoint", "")),
api_key=str(api_key) if api_key else None,
)
)
return providers
def build_config_builder(recipe: dict[str, Any]):
from data_designer.config import DataDesignerConfigBuilder
from data_designer.config.processors import ProcessorType
recipe_core = {
key: value
for key, value in recipe.items()
if key not in {"model_providers", "mcp_providers"}
}
builder = DataDesignerConfigBuilder.from_config({"data_designer": recipe_core})
# DataDesignerConfigBuilder.from_config currently skips processors.
# Re-attach explicitly so drop_columns/schema_transform survive API payload.
for processor in recipe_core.get("processors") or []:
if not isinstance(processor, dict):
continue
processor_type_raw = processor.get("processor_type")
if not isinstance(processor_type_raw, str):
continue
kwargs = {k: v for k, v in processor.items() if k != "processor_type"}
builder.add_processor(
processor_type=ProcessorType(processor_type_raw),
**kwargs,
)
return builder
def create_data_designer(
recipe: dict[str, Any],
*,
artifact_path: str | None = None,
):
from data_designer.interface.data_designer import DataDesigner
return DataDesigner(
artifact_path=artifact_path,
model_providers=build_model_providers(recipe),
mcp_providers=build_mcp_providers(recipe),
)
def validate_recipe(recipe: dict[str, Any]) -> None:
builder = build_config_builder(recipe)
designer = create_data_designer(recipe)
designer.validate(builder)
def preview_recipe(
recipe: dict[str, Any],
num_records: int,
) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any] | None]:
builder = build_config_builder(recipe)
designer = create_data_designer(recipe)
results = designer.preview(builder, num_records=num_records)
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]
artifacts = (
None
if results.processor_artifacts is None
else _to_jsonable(results.processor_artifacts)
)
analysis = (
None
if results.analysis is None
else _to_jsonable(results.analysis.model_dump(mode="json"))
)
return dataset, artifacts, analysis

View file

@ -2,6 +2,7 @@
"""
Export backend - handles model exporting in various formats
"""
import json
import logging
import os
from pathlib import Path
@ -200,6 +201,18 @@ class ExportBackend:
logger.error(traceback.format_exc())
return False, f"Failed to load checkpoint: {str(e)}"
def _write_export_metadata(self, save_directory: str):
"""Write export_metadata.json with base model info for Chat page discovery."""
try:
base_model = get_base_model_from_lora(self.current_checkpoint) if self.current_checkpoint else None
metadata = {"base_model": base_model}
metadata_path = os.path.join(save_directory, "export_metadata.json")
with open(metadata_path, "w") as f:
json.dump(metadata, f, indent=2)
logger.info(f"Wrote export metadata to {metadata_path}")
except Exception as e:
logger.warning(f"Could not write export metadata: {e}")
def export_merged_model(self,
save_directory: str,
format_type: str = "16-bit (FP16)",
@ -244,6 +257,9 @@ class ExportBackend:
self.current_tokenizer,
save_method=save_method
)
# Write export metadata so the Chat page can identify the base model
self._write_export_metadata(save_directory)
logger.info(f"Model saved successfully to {save_directory}")
# Push to hub if requested
@ -297,6 +313,9 @@ class ExportBackend:
self.current_model.save_pretrained(save_directory)
self.current_tokenizer.save_pretrained(save_directory)
# Write export metadata so the Chat page can identify the base model
self._write_export_metadata(save_directory)
logger.info(f"Model saved successfully to {save_directory}")
# Push to hub if requested

View file

@ -6,8 +6,10 @@ from unsloth.chat_templates import get_chat_template
from transformers import TextStreamer
from peft import PeftModel, PeftModelForCausalLM
import json
import sys
import torch
from pathlib import Path
from typing import Optional, Union, Generator, Tuple
from utils.models import ModelConfig, get_base_model_from_lora
from utils.paths import is_model_cached
@ -112,7 +114,18 @@ class InferenceBackend:
# In that case, load the real processor from the base model.
from transformers import ProcessorMixin
if not (isinstance(processor, ProcessorMixin) or hasattr(processor, "image_processor")):
# For LoRA adapters, use the base model. For local merged exports,
# read export_metadata.json to find the original base model.
processor_source = config.base_model if config.is_lora else config.identifier
if not config.is_lora and config.is_local:
_meta_path = Path(config.path) / "export_metadata.json"
try:
if _meta_path.exists():
_meta = json.loads(_meta_path.read_text())
if _meta.get("base_model"):
processor_source = _meta["base_model"]
except Exception:
pass
logger.warning(
f"FastVisionModel returned {type(processor).__name__} (no image_processor) "
f"for '{model_name}' — loading proper processor from '{processor_source}'"

View file

@ -352,7 +352,8 @@ class UnslothTrainer:
custom_format_mapping: dict = None,
subset: str = None,
train_split: str = "train",
eval_split: str = None) -> Optional[tuple]:
eval_split: str = None,
eval_steps: float = 0.00) -> Optional[tuple]:
"""
Load and prepare dataset for training.
@ -367,6 +368,7 @@ class UnslothTrainer:
dataset = None
eval_dataset = None
has_separate_eval_source = False # True if eval comes from a separate HF split
eval_enabled = eval_steps is not None and eval_steps > 0
if local_datasets:
# Load local datasets
@ -419,23 +421,26 @@ class UnslothTrainer:
print(f"Loaded dataset from Hugging Face: {dataset_source}\n")
# Resolve eval split from a separate HF split (explicit or auto-detected)
if eval_split:
# Explicit eval split provided - load it directly
print(f"Loading explicit eval split: '{eval_split}'\n")
eval_load_kwargs = {"path": dataset_source, "split": eval_split}
if subset:
eval_load_kwargs["name"] = subset
eval_dataset = load_dataset(**eval_load_kwargs)
has_separate_eval_source = True
print(f"Loaded eval split '{eval_split}' with {len(eval_dataset)} rows\n")
else:
# Auto-detect eval split from HF (returns a separate dataset, or None)
eval_dataset = self._auto_detect_eval_split_from_hf(
dataset_source=dataset_source,
subset=subset,
)
if eval_dataset is not None:
if eval_enabled:
if eval_split:
# Explicit eval split provided - load it directly
print(f"Loading explicit eval split: '{eval_split}'\n")
eval_load_kwargs = {"path": dataset_source, "split": eval_split}
if subset:
eval_load_kwargs["name"] = subset
eval_dataset = load_dataset(**eval_load_kwargs)
has_separate_eval_source = True
print(f"Loaded eval split '{eval_split}' with {len(eval_dataset)} rows\n")
else:
# Auto-detect eval split from HF (returns a separate dataset, or None)
eval_dataset = self._auto_detect_eval_split_from_hf(
dataset_source=dataset_source,
subset=subset,
)
if eval_dataset is not None:
has_separate_eval_source = True
else:
print("Eval disabled (eval_steps <= 0), skipping eval split detection\n")
if dataset is None:
raise ValueError("No dataset provided")
@ -481,7 +486,7 @@ class UnslothTrainer:
)
eval_dataset = eval_info["dataset"]
print(f"Eval dataset formatted successfully\n")
elif not has_separate_eval_source:
elif eval_enabled and not has_separate_eval_source:
# No separate eval source — split the already-formatted dataset
formatted_dataset = dataset_info["dataset"]
split_result = self._resolve_eval_split_from_dataset(formatted_dataset)
@ -552,7 +557,7 @@ class UnslothTrainer:
def start_training(self,
dataset: Dataset,
eval_dataset: Dataset = None,
eval_steps: float = 0.01,
eval_steps: float = 0.00,
output_dir: str = "./outputs",
num_epochs: int = 3,
learning_rate: float = 5e-5,
@ -752,12 +757,16 @@ class UnslothTrainer:
# ========== EVAL CONFIGURATION ==========
eval_dataset = training_args.get('eval_dataset', None)
eval_steps_val = training_args.get('eval_steps', 0.01)
eval_steps_val = training_args.get('eval_steps', 0.00)
if eval_dataset is not None:
config_args["eval_strategy"] = "steps"
config_args["eval_steps"] = eval_steps_val
print(f"Evaluation enabled: eval_steps={eval_steps_val} (fraction of total steps)\n")
print(f"Eval dataset: {len(eval_dataset)} rows\n")
if eval_steps_val > 0:
config_args["eval_strategy"] = "steps"
config_args["eval_steps"] = eval_steps_val
print(f"✅ Evaluation enabled: eval_steps={eval_steps_val} (fraction of total steps)\n")
print(f"Eval dataset: {len(eval_dataset)} rows\n")
else:
print(f"⚠️ Eval dataset provided but eval_steps={eval_steps_val} (disabled)\n")
print("To enable evaluation, set eval_steps > 0.0\n")
else:
print("No eval dataset — evaluation disabled\n")

View file

@ -115,7 +115,7 @@ class TrainingBackend:
subset: str = None,
train_split: str = "train",
eval_split: str = None,
eval_steps: float = 0.01,
eval_steps: float = 0.00,
is_dataset_multimodal: bool = False) -> bool:
"""
Start training.
@ -223,6 +223,7 @@ class TrainingBackend:
subset=subset,
train_split=train_split,
eval_split=eval_split,
eval_steps=eval_steps,
)
# Unpack: load_and_format_dataset returns (dataset, eval_dataset)
@ -232,10 +233,6 @@ class TrainingBackend:
dataset = dataset_result
eval_dataset = None
# If user set eval_steps to 0, disable evaluation entirely
if eval_steps is not None and float(eval_steps) <= 0:
eval_dataset = None
# Track whether eval is enabled for status reporting
self.eval_enabled = eval_dataset is not None

View file

@ -8,12 +8,20 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, HTMLResponse, Response
from pathlib import Path
from datetime import datetime
# Import routers
from routes import training_router, models_router, inference_router, datasets_router, auth_router, export_router
from routes import (
auth_router,
data_recipe_router,
datasets_router,
export_router,
inference_router,
models_router,
training_router,
)
from auth import storage
from utils.hardware import detect_hardware, get_device, DeviceType
import utils.hardware.hardware as _hw_module
@ -82,6 +90,7 @@ app.include_router(training_router, prefix="/api/train", tags=["training"])
app.include_router(models_router, prefix="/api/models", tags=["models"])
app.include_router(inference_router, prefix="/api/inference", tags=["inference"])
app.include_router(datasets_router, prefix="/api/datasets", tags=["datasets"])
app.include_router(data_recipe_router, prefix="/api/data-recipe", tags=["data-recipe"])
app.include_router(export_router, prefix="/api/export", tags=["export"])
@ -147,27 +156,44 @@ async def get_hardware_info():
def setup_frontend(app: FastAPI, build_path: Path):
"""Mount frontend static files (optional)"""
if build_path.exists():
# Mount assets
assets_dir = build_path / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
if not build_path.exists():
return False
@app.get("/")
async def serve_root():
return FileResponse(build_path / "index.html", headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
# Mount assets
assets_dir = build_path / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
if full_path.startswith("api"):
return {"error": "API endpoint not found"}
@app.get("/")
async def serve_root():
content = (build_path / "index.html").read_bytes()
return Response(
content=content,
media_type="text/html",
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
)
file_path = build_path / full_path
if file_path.is_file():
return FileResponse(file_path)
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
if full_path.startswith("api"):
return {"error": "API endpoint not found"}
return FileResponse(build_path / "index.html", headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
file_path = (build_path / full_path).resolve()
return True
return False
# Block path traversal — ensure resolved path stays inside build_path
if not str(file_path).startswith(str(build_path.resolve())):
return Response(status_code=403)
if file_path.is_file():
return FileResponse(file_path)
# Serve index.html as bytes — avoids Content-Length mismatch
content = (build_path / "index.html").read_bytes()
return Response(
content=content,
media_type="text/html",
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
)
return True

View file

@ -52,6 +52,13 @@ from .responses import (
LoRABaseModelResponse,
VisionCheckResponse,
)
from .data_recipe import (
RecipePayload,
PreviewResponse,
ValidateError,
ValidateResponse,
JobCreateResponse,
)
__all__ = [
# Training schemas
@ -98,4 +105,10 @@ __all__ = [
"TrainingMetricsResponse",
"LoRABaseModelResponse",
"VisionCheckResponse",
# Data recipe
"RecipePayload",
"PreviewResponse",
"ValidateError",
"ValidateResponse",
"JobCreateResponse",
]

View file

@ -0,0 +1,60 @@
"""
Pydantic schemas for Data Recipe (DataDesigner) API.
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class RecipePayload(BaseModel):
recipe: dict[str, Any] = Field(default_factory=dict)
run: dict[str, Any] | None = None
ui: dict[str, Any] | None = None
class PreviewResponse(BaseModel):
dataset: list[dict[str, Any]] = Field(default_factory=list)
processor_artifacts: dict[str, Any] | None = None
analysis: dict[str, Any] | None = None
class ValidateError(BaseModel):
message: str
path: str | None = None
code: str | None = None
class ValidateResponse(BaseModel):
valid: bool
errors: list[ValidateError] = Field(default_factory=list)
raw_detail: str | None = None
class JobCreateResponse(BaseModel):
job_id: str
class SeedInspectRequest(BaseModel):
dataset_name: str = Field(min_length=1)
hf_token: str | None = None
subset: str | None = None
split: str | None = "train"
preview_size: int = Field(default=10, ge=1, le=50)
class SeedInspectUploadRequest(BaseModel):
filename: str = Field(min_length=1)
content_base64: str = Field(min_length=1)
preview_size: int = Field(default=10, ge=1, le=50)
class SeedInspectResponse(BaseModel):
dataset_name: str
resolved_path: str
columns: list[str] = Field(default_factory=list)
preview_rows: list[dict[str, Any]] = Field(default_factory=list)
split: str | None = None
subset: str | None = None

View file

@ -58,10 +58,12 @@ class ModelDetails(BaseModel):
class LoRAInfo(BaseModel):
"""LoRA adapter information"""
"""LoRA adapter or exported model information"""
display_name: str = Field(..., description="Display name for the LoRA")
adapter_path: str = Field(..., description="Path to the LoRA adapter")
adapter_path: str = Field(..., description="Path to the LoRA adapter or exported model")
base_model: Optional[str] = Field(None, description="Base model identifier")
source: Optional[str] = Field(None, description="'training' or 'exported'")
export_type: Optional[str] = Field(None, description="'lora' or 'merged' (for exports)")
class LoRAScanResponse(BaseModel):

View file

@ -21,7 +21,7 @@ class TrainingStartRequest(BaseModel):
subset: Optional[str] = None
train_split: Optional[str] = Field("train", description="Training split name")
eval_split: Optional[str] = Field(None, description="Eval split name. None = auto-detect")
eval_steps: float = Field(0.01, description="Fraction of total steps between evals (0-1)")
eval_steps: float = Field(0.00, description="Fraction of total steps between evals (0-1)")
@model_validator(mode="before")
@classmethod

View file

@ -51,6 +51,6 @@ addict
easydict
einops
tabulate
fastmcp>=2.0.0
fastmcp>=3.0.2
openai>=2.7.2
websockets>=13.0,<14
websockets>=15.0.1

View file

@ -0,0 +1,16 @@
# Single-env pins for unsloth + studio + data-designer
# Keep compatible with unsloth transformers bounds.
transformers==4.57.1
trl==0.23.1
huggingface-hub==0.36.2
# Studio stack
datasets==4.3.0
pyarrow==23.0.1
# FastMCP/OpenEnv compat
fastmcp>=3.0.2
mcp>=1.24,<2
websockets>=15.0.1
pandas==2.3.3

View file

@ -0,0 +1,18 @@
# Data Designer runtime deps installed explicitly (single-env mode).
anyascii<1,>=0.3.3
duckdb<2,>=1.1.3
faker<21,>=20.1.0
httpx<1,>=0.27.2
httpx-retries<1,>=0.4.2
json-repair<1,>=0.48.0
jsonpath-rust-bindings<2,>=1.0
jsonschema<5,>=4.0.0
litellm<1.80.12,>=1.73.6
lxml<7,>=6.0.2
marko<3,>=2.1.2
networkx<4,>=3.0
python-json-logger<4,>=3
ruff<1,>=0.14.10
scipy<2,>=1.11.0
sqlfluff<4,>=3.2.0
tiktoken<1,>=0.8.0

View file

@ -0,0 +1,5 @@
# Install Data Designer in same env as Unsloth.
data-designer==0.5.1
data-designer-config==0.5.1
data-designer-engine==0.5.1
prompt-toolkit>=3,<4

View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Relax strict metadata pins so pip check matches known working single-env stack.
Why:
- data-designer pins huggingface-hub>=1.0.1 and pyarrow<20.
- unsloth/transformers pins huggingface-hub<1.
- studio datasets pins pyarrow>=21.
Runtime works in this app with hub 0.36.x + pyarrow 23.x, but metadata conflicts.
"""
from __future__ import annotations
import importlib.metadata as im
import re
from pathlib import Path
TARGETS = (
"data-designer",
"data-designer-engine",
"data-designer-config",
)
PATCHES: tuple[tuple[re.Pattern[str], str], ...] = (
(
re.compile(r"^Requires-Dist: huggingface-hub<2,>=1\.0\.1$", re.MULTILINE),
"Requires-Dist: huggingface-hub<2,>=0.34.0",
),
(
re.compile(r"^Requires-Dist: pyarrow<20,>=19\.0\.1$", re.MULTILINE),
"Requires-Dist: pyarrow>=21.0.0",
),
)
def metadata_path(dist_name: str) -> Path | None:
try:
dist = im.distribution(dist_name)
except im.PackageNotFoundError:
return None
for f in dist.files or []:
sf = str(f)
if sf.endswith(".dist-info/METADATA"):
return Path(dist.locate_file(f))
return None
def patch_file(path: Path) -> bool:
original = path.read_text(encoding="utf-8")
updated = original
for pattern, repl in PATCHES:
updated = pattern.sub(repl, updated)
if updated == original:
return False
path.write_text(updated, encoding="utf-8")
return True
def main() -> int:
changed = 0
checked = 0
for name in TARGETS:
p = metadata_path(name)
if p is None:
continue
checked += 1
if patch_file(p):
changed += 1
print(f"single-env metadata patch: checked={checked}, changed={changed}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -11,4 +11,4 @@ pyjwt
easydict
addict
gradio>=4.0.0
huggingface-hub==0.36.0
huggingface-hub==0.36.2

View file

@ -7,6 +7,7 @@ from routes.models import router as models_router
from routes.inference import router as inference_router
from routes.datasets import router as datasets_router
from routes.auth import router as auth_router
from routes.data_recipe import router as data_recipe_router
from routes.export import router as export_router
__all__ = [
@ -15,5 +16,6 @@ __all__ = [
"inference_router",
"datasets_router",
"auth_router",
"data_recipe_router",
"export_router",
]
]

View file

@ -0,0 +1,521 @@
"""
Data Recipe routes (DataDesigner runner).
"""
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
# 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,
)
router = APIRouter()
DATA_EXTS = (".parquet", ".jsonl", ".json", ".csv")
DEFAULT_SPLIT = "train"
LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl"}
SEED_UPLOAD_DIR = Path.home() / ".cache" / "unsloth" / "data-recipe" / "seed-uploads"
def _serialize_preview_value(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(key): _serialize_preview_value(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_serialize_preview_value(item) for item in value]
return str(value)
def _serialize_preview_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{str(key): _serialize_preview_value(value) for key, value in row.items()}
for row in rows
]
def _normalize_optional_text(value: str | None) -> str | None:
if value is None:
return None
trimmed = value.strip()
return trimmed if trimmed else None
def _list_hf_data_files(*, dataset_name: str, token: str | None) -> list[str]:
try:
from huggingface_hub import HfApi
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:
return []
def _select_best_file(data_files: list[str], split: str | None) -> str | None:
if not data_files:
return None
if not split:
return data_files[0]
split_lower = split.lower()
def score(path: str) -> tuple[int, int]:
name = path.lower()
if f"/{split_lower}/" in name:
return (0, len(path))
if (
f"_{split_lower}." in name
or f"-{split_lower}." in name
or f"/{split_lower}." in name
or f"/{split_lower}_" in name
or f"/{split_lower}-" in name
):
return (1, len(path))
return (2, len(path))
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)
if not selected:
return None
ext = Path(selected).suffix.lower()
if ext not in DATA_EXTS:
return f"datasets/{dataset_name}/{selected}"
parent = Path(selected).parent.as_posix()
if not parent or parent == ".":
return f"datasets/{dataset_name}/**/*{ext}"
return f"datasets/{dataset_name}/{parent}/**/*{ext}"
def _build_stream_load_kwargs(
*,
dataset_name: str,
split: str,
subset: str | None,
token: str | None,
data_file: str | None = None,
) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"path": dataset_name,
"split": split,
"streaming": True,
}
if data_file:
kwargs["data_files"] = [data_file]
if subset:
kwargs["name"] = subset
if token:
kwargs["token"] = token
return kwargs
def _load_preview_rows(
*,
load_dataset_fn,
load_kwargs: dict[str, Any],
preview_size: int,
) -> list[dict[str, Any]]:
streamed_ds = load_dataset_fn(**load_kwargs)
return [row for row in islice(streamed_ds, preview_size)]
def _extract_columns(rows: list[dict[str, Any]]) -> list[str]:
columns_seen: dict[str, None] = {}
for row in rows:
for key in row.keys():
columns_seen[str(key)] = None
return list(columns_seen.keys())
def _sanitize_filename(filename: str) -> str:
name = Path(filename).name.strip().replace("\x00", "")
if not name:
return "seed_upload"
return name
def _decode_base64_payload(content_base64: str) -> bytes:
raw = content_base64.strip()
if "," in raw and raw.lower().startswith("data:"):
raw = raw.split(",", 1)[1]
try:
return base64.b64decode(raw, validate=True)
except binascii.Error as exc:
raise HTTPException(status_code=400, detail="invalid base64 payload") from exc
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:
raise HTTPException(status_code=500, detail=f"seed inspect dependencies unavailable: {exc}") from exc
ext = path.suffix.lower()
try:
if ext == ".csv":
df = pd.read_csv(path, nrows=preview_size)
elif ext == ".jsonl":
df = pd.read_json(path, lines=True).head(preview_size)
elif ext == ".json":
try:
df = pd.read_json(path, lines=True).head(preview_size)
except Exception:
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:
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()
if not dataset_name or dataset_name.count("/") < 1:
raise HTTPException(status_code=400, detail="dataset_name must be a Hugging Face repo id like org/repo")
try:
from datasets import load_dataset
except Exception 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
subset = _normalize_optional_text(payload.subset)
token = _normalize_optional_text(payload.hf_token)
preview_size = int(payload.preview_size)
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)
if selected_file:
try:
single_file_kwargs = _build_stream_load_kwargs(
dataset_name=dataset_name,
split=DEFAULT_SPLIT,
subset=subset,
token=token,
data_file=selected_file,
)
preview_rows = _load_preview_rows(
load_dataset_fn=load_dataset,
load_kwargs=single_file_kwargs,
preview_size=preview_size,
)
except Exception:
preview_rows = []
if not preview_rows:
try:
split_kwargs = _build_stream_load_kwargs(
dataset_name=dataset_name,
split=split,
subset=subset,
token=token,
)
preview_rows = _load_preview_rows(
load_dataset_fn=load_dataset,
load_kwargs=split_kwargs,
preview_size=preview_size,
)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"seed inspect failed: {exc}") from exc
if not preview_rows:
raise HTTPException(status_code=422, detail="dataset appears empty or unreadable")
preview_rows = _serialize_preview_rows(preview_rows)
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)
if not resolved_path:
raise HTTPException(status_code=422, detail="unable to resolve seed dataset path")
return SeedInspectResponse(
dataset_name=dataset_name,
resolved_path=resolved_path,
columns=columns,
preview_rows=preview_rows,
split=split,
subset=subset,
)
@router.post("/seed/inspect-upload", response_model=SeedInspectResponse)
def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectResponse:
filename = _sanitize_filename(payload.filename)
ext = Path(filename).suffix.lower()
if ext not in LOCAL_UPLOAD_EXTS:
allowed = ", ".join(sorted(LOCAL_UPLOAD_EXTS))
raise HTTPException(status_code=400, detail=f"unsupported file type: {ext}. allowed: {allowed}")
file_bytes = _decode_base64_payload(payload.content_base64)
if not file_bytes:
raise HTTPException(status_code=400, detail="empty upload payload")
max_size_bytes = 50 * 1024 * 1024
if len(file_bytes) > max_size_bytes:
raise HTTPException(status_code=413, detail="file too large (max 50MB)")
SEED_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
stored_name = f"{uuid4().hex}_{filename}"
stored_path = SEED_UPLOAD_DIR / stored_name
stored_path.write_bytes(file_bytes)
preview_rows = _read_preview_rows_from_local_file(
stored_path,
int(payload.preview_size),
)
if not preview_rows:
raise HTTPException(status_code=422, detail="dataset appears empty or unreadable")
columns = _extract_columns(preview_rows)
return SeedInspectResponse(
dataset_name=filename,
resolved_path=str(stored_path),
columns=columns,
preview_rows=preview_rows,
split=None,
subset=None,
)
@router.post("/validate", response_model=ValidateResponse)
def validate(payload: RecipePayload) -> ValidateResponse:
recipe = payload.recipe
if not recipe.get("columns"):
return ValidateResponse(
valid=False,
errors=[ValidateError(message="Recipe must include columns.")],
)
try:
validate_recipe(recipe)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except Exception as exc:
detail = str(exc).strip() or "Validation failed."
parsed_errors = _collect_validation_errors(recipe)
return ValidateResponse(
valid=False,
errors=parsed_errors or [ValidateError(message=detail)],
raw_detail=detail,
)
return ValidateResponse(valid=True)
@router.post("/jobs", response_class=JSONResponse, response_model=JobCreateResponse)
def create_job(payload: RecipePayload):
recipe = payload.recipe
if not recipe.get("columns"):
raise HTTPException(status_code=400, detail="Recipe must include columns.")
run: dict[str, Any] = payload.run or {}
run.pop("artifact_path", None)
run.pop("dataset_name", None)
execution_type = str(run.get("execution_type") or "full").strip().lower()
if execution_type not in {"preview", "full"}:
raise HTTPException(status_code=400, detail="invalid execution_type: must be 'preview' or 'full'")
run["execution_type"] = execution_type
run_config_raw = run.get("run_config")
if run_config_raw is not None:
try:
from data_designer.config.run_config import RunConfig
RunConfig.model_validate(run_config_raw)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"invalid run_config: {exc}") from exc
mgr = get_job_manager()
try:
job_id = mgr.start(recipe=recipe, run=run)
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"job_id": job_id}
@router.get("/jobs/{job_id}/status")
def job_status(job_id: str):
mgr = get_job_manager()
state = mgr.get_status(job_id)
if state is None:
raise HTTPException(status_code=404, detail="job not found")
return state
@router.get("/jobs/current")
def current_job():
mgr = get_job_manager()
state = mgr.get_current_status()
if state is None:
raise HTTPException(status_code=404, detail="no job")
return state
@router.post("/jobs/{job_id}/cancel")
def cancel_job(job_id: str):
mgr = get_job_manager()
ok = mgr.cancel(job_id)
if not ok:
raise HTTPException(status_code=404, detail="job not found")
return mgr.get_status(job_id)
@router.get("/jobs/{job_id}/analysis")
def job_analysis(job_id: str):
mgr = get_job_manager()
analysis = mgr.get_analysis(job_id)
if analysis is None:
raise HTTPException(status_code=404, detail="analysis not ready")
return analysis
@router.get("/jobs/{job_id}/dataset")
def job_dataset(
job_id: str,
limit: int = Query(default=20, ge=1, le=500),
offset: int = Query(default=0, ge=0),
):
mgr = get_job_manager()
result = mgr.get_dataset(job_id, limit=limit, offset=offset)
if result is None:
raise HTTPException(status_code=404, detail="dataset not ready")
if "error" in result:
raise HTTPException(status_code=422, detail=result["error"])
return {
"dataset": result["dataset"],
"total": result["total"],
"limit": limit,
"offset": offset,
}
@router.get("/jobs/{job_id}/events")
async def job_events(request: Request, job_id: str):
mgr = get_job_manager()
last_id = request.headers.get("last-event-id")
after_seq: int | None = None
if last_id:
try:
after_seq = int(str(last_id).strip())
except Exception:
after_seq = None
# EventSource can't set custom headers on first connect after a full page refresh,
# so allow resume via query param too: /events?after=<seq>
after_q = request.query_params.get("after")
if after_q:
try:
after_seq = int(str(after_q).strip())
except Exception:
pass
sub = mgr.subscribe(job_id, after_seq=after_seq)
if sub is None:
raise HTTPException(status_code=404, detail="job not found")
async def gen():
try:
for event in sub.replay:
yield sub.format_sse(event)
while True:
if await request.is_disconnected():
break
event = await sub.next_event(timeout_sec=1.0)
if event is None:
continue
yield sub.format_sse(event)
finally:
mgr.unsubscribe(sub)
return StreamingResponse(gen(), media_type="text/event-stream")

View file

@ -188,18 +188,22 @@ def check_format(request: CheckFormatRequest):
# Generate preview samples
preview_samples = None
if not result["requires_manual_mapping"]:
try:
format_result = format_dataset(
preview_slice,
format_type="auto",
custom_format_mapping=result.get("suggested_mapping"),
num_proc=1, # Only 10 preview rows — no need for multiprocessing
)
processed = format_result["dataset"]
preview_samples = _serialize_preview_rows(processed)
except Exception as e:
logger.warning(f"Processed preview generation failed (non-fatal): {e}")
if result.get("suggested_mapping"):
# Heuristic-detected: show raw data so columns match the API response.
# Processing (column stripping) happens at training time, not preview.
preview_samples = _serialize_preview_rows(preview_slice)
else:
try:
format_result = format_dataset(
preview_slice,
format_type="auto",
num_proc=1, # Only 10 preview rows — no need for multiprocessing
)
processed = format_result["dataset"]
preview_samples = _serialize_preview_rows(processed)
except Exception as e:
logger.warning(f"Processed preview generation failed (non-fatal): {e}")
preview_samples = _serialize_preview_rows(preview_slice)
else:
preview_samples = _serialize_preview_rows(preview_slice)

View file

@ -18,6 +18,7 @@ from auth.authentication import get_current_subject
try:
from utils.models import (
scan_trained_loras,
scan_exported_models,
load_model_defaults,
get_base_model_from_lora,
is_vision_model,
@ -34,6 +35,7 @@ except ImportError:
sys.path.insert(0, str(parent_backend))
from utils.models import (
scan_trained_loras,
scan_exported_models,
load_model_defaults,
get_base_model_from_lora,
is_vision_model,
@ -312,35 +314,45 @@ async def get_model_config(
@router.get("/loras")
async def scan_loras(
outputs_dir: str = Query(default="./outputs", description="Directory to scan for LoRA adapters"),
exports_dir: str = Query(default="./exports", description="Directory to scan for exported models"),
current_subject: str = Depends(get_current_subject),
):
"""
Scan for trained LoRA adapters in the outputs directory.
This endpoint wraps the backend scan_trained_loras function.
Scan for trained LoRA adapters and exported models.
Returns both training outputs (from outputs_dir) and exported models
(from exports_dir) in a single list, distinguished by source field.
"""
try:
# Call backend scan function
trained_loras = scan_trained_loras(outputs_dir=outputs_dir)
# Convert to LoRAInfo objects
lora_list = []
# Scan training outputs
trained_loras = scan_trained_loras(outputs_dir=outputs_dir)
for display_name, adapter_path in trained_loras:
# Get base model if available
base_model = get_base_model_from_lora(adapter_path)
lora_info = LoRAInfo(
lora_list.append(LoRAInfo(
display_name=display_name,
adapter_path=adapter_path,
base_model=base_model
)
lora_list.append(lora_info)
base_model=base_model,
source="training",
))
# Scan exported models (merged, LoRA, base — skips GGUF)
exported = scan_exported_models(exports_dir=exports_dir)
for display_name, model_path, export_type, base_model in exported:
lora_list.append(LoRAInfo(
display_name=display_name,
adapter_path=model_path,
base_model=base_model,
source="exported",
export_type=export_type,
))
return LoRAScanResponse(
loras=lora_list,
outputs_dir=outputs_dir
)
except Exception as e:
logger.error(f"Error scanning LoRAs: {e}", exc_info=True)
raise HTTPException(

View file

@ -126,38 +126,76 @@ def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
"multimodal_columns": None,
}
# Normalise any format-specific role to canonical chatml (user/assistant/system)
_TO_CHATML = {
"user": "user", "human": "user", "instruction": "user",
"assistant": "assistant", "gpt": "assistant", "output": "assistant",
"system": "system", "input": "system",
}
_CHATML_ROLE_ORDER = ("system", "user", "assistant")
_CHATML_TO_ALPACA = {"user": "instruction", "system": "input", "assistant": "output"}
def _apply_user_mapping(dataset, mapping: dict, batch_size: int = 1000):
"""
Apply user-provided column mapping to convert dataset to conversations format.
Args:
dataset: HuggingFace dataset
mapping: Dict like {"question": "user", "answer": "assistant", "context": "system"}
batch_size: Batch size for processing
Accepts chatml (user/assistant/system), sharegpt (human/gpt/system), and
alpaca (instruction/input/output) role names all normalised to chatml output.
Returns:
Dataset with single 'conversations' column (no extra columns preserved)
Dataset with single 'conversations' column
"""
# Pre-compute: group columns by canonical chatml role
role_groups: dict[str, list[str]] = {r: [] for r in _CHATML_ROLE_ORDER}
for col_name, role in mapping.items():
canonical = _TO_CHATML.get(role)
if canonical:
role_groups[canonical].append(col_name)
def _convert(examples):
num_examples = len(examples[list(examples.keys())[0]])
num = len(next(iter(examples.values())))
conversations = []
for i in range(num_examples):
for i in range(num):
convo = []
role_order = ['system', 'user', 'assistant']
for target_role in role_order:
for col_name, role in mapping.items():
if role == target_role and col_name in examples:
content = examples[col_name][i]
# User explicitly mapped - always include even if empty
convo.append({"role": role, "content": str(content) if content else ""})
for chatml_role in _CHATML_ROLE_ORDER:
for col in role_groups[chatml_role]:
if col in examples:
content = examples[col][i]
convo.append({"role": chatml_role, "content": str(content) if content else ""})
conversations.append(convo)
# ONLY return conversations - no extra columns
return {"conversations": conversations}
return dataset.map(_convert, batched=True, batch_size=batch_size, remove_columns=dataset.column_names)
def _apply_user_mapping_alpaca(dataset, mapping: dict, batch_size: int = 1000):
"""
Apply user-provided column mapping to convert dataset to Alpaca format.
Accepts any format's role names — normalises via _TO_CHATML, then maps
user instruction, system input, assistant output.
Returns:
Dataset with instruction/input/output columns
"""
col_for: dict[str, str | None] = {"instruction": None, "input": None, "output": None}
for col_name, role in mapping.items():
canonical = _TO_CHATML.get(role)
alpaca_field = _CHATML_TO_ALPACA.get(canonical) if canonical else None
if alpaca_field:
col_for[alpaca_field] = col_name
def _convert(examples):
num = len(next(iter(examples.values())))
instructions, inputs, outputs = [], [], []
for i in range(num):
for field, dest in (("instruction", instructions), ("input", inputs), ("output", outputs)):
col = col_for[field]
val = str(examples[col][i]) if col and col in examples and examples[col][i] else ""
dest.append(val)
return {"instruction": instructions, "input": inputs, "output": outputs}
return dataset.map(_convert, batched=True, batch_size=batch_size, remove_columns=dataset.column_names)
@ -191,20 +229,30 @@ def format_dataset(
# Detect multimodal first (needed for all flows)
multimodal_info = detect_multimodal_dataset(dataset)
# NEW: If user provided explicit mapping, skip detection and apply directly
# If user provided explicit mapping, skip detection and apply in the requested format
if custom_format_mapping:
try:
mapped_dataset = _apply_user_mapping(dataset, custom_format_mapping, batch_size)
if format_type == "alpaca":
mapped_dataset = _apply_user_mapping_alpaca(dataset, custom_format_mapping, batch_size)
final_format = "alpaca"
chat_column = None
else:
# auto / chatml / sharegpt / conversational — all produce chatml conversations
# (sharegpt is always standardized to role/content internally)
mapped_dataset = _apply_user_mapping(dataset, custom_format_mapping, batch_size)
final_format = "chatml_conversations"
chat_column = "conversations"
return {
"dataset": mapped_dataset,
"detected_format": "user_mapped",
"final_format": "chatml_conversations",
"chat_column": "conversations",
"final_format": final_format,
"chat_column": chat_column,
"is_standardized": True,
"requires_manual_mapping": False,
"is_multimodal": multimodal_info["is_multimodal"],
"multimodal_info": multimodal_info,
"warnings": [f"Applied user-provided column mapping: {custom_format_mapping}"]
"warnings": [f"Applied user-provided column mapping ({format_type}): {custom_format_mapping}"]
}
except Exception as e:
return {
@ -224,7 +272,7 @@ def format_dataset(
detected = detect_dataset_format(dataset)
warnings = []
# Add multimodal warning if detected
# Add multimodal warning if detected
if multimodal_info["is_multimodal"]:
warnings.append(
f"Multimodal dataset detected. Found columns: {multimodal_info['multimodal_columns']}"
@ -309,48 +357,25 @@ def format_dataset(
conversations = []
num_examples = len(examples[list(examples.keys())[0]])
# NEW: Check if this is user-provided or auto-detected
is_user_provided = custom_format_mapping is not None # Passed explicitly
# Preserve non-mapped columns ONLY if auto-detected
preserved_columns = {}
if not is_user_provided: # Only preserve for auto-detection
all_columns = set(examples.keys())
mapped_columns = set(custom_mapping.keys())
non_mapped_columns = all_columns - mapped_columns
for col in non_mapped_columns:
preserved_columns[col] = examples[col]
# Preserve non-mapped columns
all_columns = set(examples.keys())
mapped_columns = set(custom_mapping.keys())
preserved_columns = {
col: examples[col]
for col in all_columns - mapped_columns
}
for i in range(num_examples):
convo = []
# Enforce standard role order
role_order = ['system', 'user', 'assistant']
for target_role in role_order:
for target_role in ['system', 'user', 'assistant']:
for col_name, role in custom_mapping.items():
if role == target_role and col_name in examples:
content = examples[col_name][i]
# NEW: Different behavior based on mapping source
if is_user_provided:
# User explicitly mapped this - always include even if empty
convo.append({"role": role, "content": str(content) if content else ""})
else:
# Auto-detected - skip empty (original behavior)
if content and str(content).strip():
convo.append({"role": role, "content": str(content)})
if content and str(content).strip():
convo.append({"role": role, "content": str(content)})
conversations.append(convo)
result = {"conversations": conversations}
# Only add preserved columns if auto-detected
if not is_user_provided:
result.update(preserved_columns)
return result
return {"conversations": conversations, **preserved_columns}
try:
@ -459,7 +484,7 @@ def format_dataset(
}
# CHATML MODE: Convert to ChatML
elif format_type in ["chatml", "conversational"]:
elif format_type in ["chatml", "conversational", "sharegpt"]:
if detected["format"] == "alpaca":
converted = convert_alpaca_to_chatml(dataset, batch_size, num_proc)
@ -508,36 +533,38 @@ def format_dataset(
else:
warnings.append(f"Unknown format, attempting standardization")
try:
standardized = standardize_chat_format(
dataset, tokenizer, aliases_for_system,
aliases_for_user, aliases_for_assistant,
batch_size, num_proc
)
return {
"dataset": standardized,
"detected_format": "unknown",
"final_format": f"chatml_{detected['chat_column']}",
"chat_column": detected["chat_column"],
"is_standardized": True,
"requires_manual_mapping": False,
"is_multimodal": multimodal_info["is_multimodal"],
"multimodal_info": multimodal_info,
"warnings": warnings
}
except Exception as e:
warnings.append(f"Standardization failed: {e}")
return {
"dataset": dataset,
"detected_format": "unknown",
"final_format": "unknown",
"chat_column": detected["chat_column"],
"is_standardized": False,
"requires_manual_mapping": True,
"is_multimodal": multimodal_info["is_multimodal"],
"multimodal_info": multimodal_info,
"warnings": warnings
}
if detected["chat_column"]:
try:
standardized = standardize_chat_format(
dataset, tokenizer, aliases_for_system,
aliases_for_user, aliases_for_assistant,
batch_size, num_proc
)
return {
"dataset": standardized,
"detected_format": "unknown",
"final_format": f"chatml_{detected['chat_column']}",
"chat_column": detected["chat_column"],
"is_standardized": True,
"requires_manual_mapping": False,
"is_multimodal": multimodal_info["is_multimodal"],
"multimodal_info": multimodal_info,
"warnings": warnings
}
except Exception as e:
warnings.append(f"Standardization failed: {e}")
return {
"dataset": dataset,
"detected_format": "unknown",
"final_format": "unknown",
"chat_column": detected["chat_column"],
"is_standardized": False,
"requires_manual_mapping": True,
"is_multimodal": multimodal_info["is_multimodal"],
"multimodal_info": multimodal_info,
"warnings": warnings
}
else:
raise ValueError(f"Unknown format_type: {format_type}")
@ -768,8 +795,10 @@ def format_and_template_dataset(
)
# Step 2: Apply chat template
if "gemma" in model_name.lower() and not dataset_info["is_multimodal"] and (format_type != "alpaca" or (format_type == "auto" and dataset_info["detected_format"] != "alpaca")):
print("remove_bos_prefix is true")
# Gemma emits a leading <bos> that must be stripped for text-only chatml/sharegpt.
is_alpaca = format_type == "alpaca" or (format_type == "auto" and dataset_info["detected_format"] == "alpaca")
is_gemma = "gemma" in model_name.lower()
if is_gemma and not dataset_info["is_multimodal"] and not is_alpaca:
remove_bos_prefix = True
template_result = apply_chat_template_to_dataset(
dataset_info=dataset_info,
@ -791,14 +820,24 @@ def format_and_template_dataset(
all_warnings = dataset_info.get("warnings", []) + template_result.get("warnings", [])
all_errors = template_result.get("errors", [])
# If format_dataset returned "unknown" but apply_chat_template rescued
# it via heuristic detection, update final_format to reflect reality.
final_format = dataset_info["final_format"]
requires_manual = dataset_info.get("requires_manual_mapping", False)
if final_format == "unknown" and template_result["success"]:
out_ds = template_result["dataset"]
if hasattr(out_ds, "column_names") and "text" in out_ds.column_names:
final_format = "chatml_conversations"
requires_manual = False
return {
"dataset": template_result["dataset"],
"detected_format": dataset_info["detected_format"],
"final_format": dataset_info["final_format"],
"final_format": final_format,
"chat_column": dataset_info.get("chat_column"),
"is_vlm": False, # This is LLM flow
"success": template_result["success"],
"requires_manual_mapping": dataset_info.get("requires_manual_mapping", False),
"requires_manual_mapping": requires_manual,
"warnings": all_warnings,
"errors": all_errors,
"summary": summary,

View file

@ -6,6 +6,7 @@ from .model_config import (
GgufVariantInfo,
is_vision_model,
scan_trained_loras,
scan_exported_models,
load_model_defaults,
get_base_model_from_lora,
load_model_config,
@ -20,6 +21,7 @@ __all__ = [
'GgufVariantInfo',
'is_vision_model',
'scan_trained_loras',
'scan_exported_models',
'load_model_defaults',
'get_base_model_from_lora',
'load_model_config',

View file

@ -638,6 +638,90 @@ def scan_trained_loras(outputs_dir: str = "./outputs") -> List[Tuple[str, str]]:
logger.error(f"Error scanning outputs folder: {e}")
return []
def scan_exported_models(exports_dir: str = "./exports") -> List[Tuple[str, str, str, Optional[str]]]:
"""
Scan exports folder for exported models (merged, LoRA, base).
Skips GGUF-only exports (not loadable by Unsloth inference backend).
The exports directory is two levels deep: {run}/{checkpoint}/
Returns:
List of tuples: [(display_name, model_path, export_type, base_model), ...]
export_type: "lora" | "merged"
"""
results = []
exports_path = Path(exports_dir)
if not exports_path.exists():
return results
try:
for run_dir in exports_path.iterdir():
if not run_dir.is_dir():
continue
for checkpoint_dir in run_dir.iterdir():
if not checkpoint_dir.is_dir():
continue
adapter_config = checkpoint_dir / "adapter_config.json"
config_file = checkpoint_dir / "config.json"
has_weights = (
any(checkpoint_dir.glob("*.safetensors"))
or any(checkpoint_dir.glob("*.bin"))
)
has_gguf = any(checkpoint_dir.glob("*.gguf"))
base_model = None
export_type = None
if adapter_config.exists():
export_type = "lora"
try:
cfg = json.loads(adapter_config.read_text())
base_model = cfg.get("base_model_name_or_path")
except Exception:
pass
elif config_file.exists() and has_weights:
export_type = "merged"
# Read base model from export_metadata.json (written at export time)
export_meta = checkpoint_dir / "export_metadata.json"
try:
if export_meta.exists():
meta = json.loads(export_meta.read_text())
base_model = meta.get("base_model")
except Exception:
pass
elif has_gguf:
# GGUF-only — not loadable by current inference backend
continue
else:
continue
# Fallback: read base model from the original training run's
# adapter_config.json in ./outputs/{run_name}/
if not base_model:
outputs_adapter_cfg = Path("./outputs") / run_dir.name / "adapter_config.json"
try:
if outputs_adapter_cfg.exists():
cfg = json.loads(outputs_adapter_cfg.read_text())
base_model = cfg.get("base_model_name_or_path")
except Exception:
pass
display_name = f"{run_dir.name} / {checkpoint_dir.name}"
model_path = str(checkpoint_dir)
results.append((display_name, model_path, export_type, base_model))
logger.debug(f"Found exported model: {display_name} ({export_type})")
results.sort(key=lambda x: Path(x[1]).stat().st_mtime, reverse=True)
logger.info(f"Found {len(results)} exported models in {exports_dir}")
return results
except Exception as e:
logger.error(f"Error scanning exports folder: {e}")
return []
def get_base_model_from_lora(lora_path: str) -> Optional[str]:
"""
Read the base model name from a LoRA adapter's config.

View file

@ -1,28 +1,31 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
test/
*.local
.env
.env.*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
._*
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.omx/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
._*
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/src/features/recipe-studio/AGENTS.md
/docs

37
studio/frontend/AGENTS.md Normal file
View file

@ -0,0 +1,37 @@
# Repository Guidelines
## Project Structure & Module Organization
- `src/` is app code; entry is `src/main.tsx`, global styles in `src/index.css`.
- `src/app/` holds app shell and routing; `src/features/` is feature slices w/ public `index.ts` exports.
- Shared UI lives in `src/components/` (shadcn in `src/components/ui/`).
- Shared logic in `src/hooks/`, `src/stores/`, `src/utils/`, `src/lib/`, and types in `src/types/`.
- Static assets: `src/assets/` and `public/`.
- `test/` is a Python harness for payload validation and preview; not a JS test suite.
## Build, Test, and Development Commands
- `bun run dev`: start Vite dev server.
- `bun run build`: typecheck + build to `dist/`.
- `bun run preview`: serve the production build locally.
- `bun run lint`: ESLint checks for TS/React.
- `bun run typecheck`: `tsc` no-emit verification.
- `bun run biome:check` / `bun run biome:fix`: format + lint w/ Biome.
- Optional harness: `python test/scripts/validate_payload.py test/data/ui_payload.json`.
## Coding Style & Naming Conventions
- TypeScript + React, 2-space indent (Biome).
- Prefer explicit, compact code; avoid heavy abstraction.
- Use path alias `@/` for app imports.
- Feature boundaries enforced: import from `@/features/<name>` only, not deep paths.
- Components in `PascalCase`, hooks in `useCamelCase`, files in `kebab-case` or `camelCase` per local convention.
## Testing Guidelines
- No frontend test runner configured yet; add one if needed.
- `test/` is for API payload validation and preview flows; add samples as `test/data/ui_payload_*.json`.
## Commit & Pull Request Guidelines
- Commit history shows short, imperative messages; optional prefix like `refactor:`; keep it terse.
- PRs should include: clear summary, linked issue (if any), and UI screenshots/gifs for visual changes.
- Call out new deps, config, or required env changes in the PR body.
## Agent Notes
- Keep changes minimal, focused, and easy to review.

View file

@ -1,66 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
LLM fine-tuning platform UI (Unsloth-branded). React/TypeScript frontend with a skeletal Python backend. The frontend is the active development focus.
## Commands
All commands run from `frontend/`:
```bash
bun install # install dependencies
bun run dev # start Vite dev server
bun run build # typecheck + production build
bun run typecheck # TypeScript type checking only
bun run lint # ESLint
bun run biome:check # Biome linter + formatter check
bun run biome:fix # Biome auto-fix
```
Package manager is **Bun** (not npm/yarn).
## Architecture
### Frontend (`frontend/src/`)
**Feature-based module architecture** with enforced boundaries:
- `features/` — self-contained feature modules (chat, onboarding, studio)
- `components/ui/` — shadcn/ui primitives (linting/formatting disabled for these)
- `components/assistant-ui/` — AI chat thread components
- `components/layout/` — layout shells (dashboard)
- `stores/` — Zustand stores (training wizard state)
- `config/` — constants (model lists, hyperparameters, env)
- `types/` — shared TypeScript types
- `app/` — router and root layout (TanStack React Router)
### Import Rules (ESLint-enforced)
Cross-feature imports are **prohibited**. Import from feature barrel (`@/features/[name]`), never from internal paths (`@/features/chat/some-component`).
### Key Technology Choices
| Concern | Choice |
|---------|--------|
| Routing | TanStack React Router |
| State | Zustand |
| Styling | Tailwind CSS + shadcn/ui (radix-maia style, HugeIcons) |
| Animation | Framer Motion |
| Chat UI | @assistant-ui/react with streaming |
| Local DB | Dexie (IndexedDB) for chat threads/messages |
| Charts | Recharts |
### Backend (`backend/`)
Placeholder Python structure. Frontend expects an inference server at the URL in `frontend/.env` (`VITE_INFERENCE_URL`) serving POST `/api/chat/generate` with streaming responses.
## Code Style
- Biome handles formatting (2-space indent) and import organization
- `src/components/ui/**` is excluded from Biome linting/formatting (generated shadcn code)
- Path alias: `@` maps to `frontend/src/`
- Prefer KISS and DRY

View file

@ -4,6 +4,8 @@
"ignore": [
"dist",
"node_modules",
"test",
"test/**",
"**/._*",
"._*",
"**/.DS_Store",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -13,77 +13,81 @@
"biome:fix": "biome check . --write"
},
"dependencies": {
"@assistant-ui/react": "^0.12.3",
"@assistant-ui/react-markdown": "^0.12.1",
"@assistant-ui/react-streamdown": "^0.1.0",
"@base-ui/react": "^1.1.0",
"@assistant-ui/react": "^0.12.10",
"@assistant-ui/react-markdown": "^0.12.3",
"@assistant-ui/react-streamdown": "^0.1.2",
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
"@dagrejs/graphlib": "^3.0.4",
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/space-grotesk": "^5.2.10",
"@hugeicons/core-free-icons": "^3.1.1",
"@hugeicons/react": "^1.1.4",
"@huggingface/hub": "^2.8.0",
"@hugeicons/react": "^1.1.5",
"@huggingface/hub": "^2.9.0",
"@langchain/core": "^1.1.27",
"@langchain/textsplitters": "^1.0.1",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@streamdown/cjk": "^1.0.1",
"@streamdown/code": "^1.0.1",
"@streamdown/math": "^1.0.1",
"@streamdown/mermaid": "^1.0.1",
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-router": "^1.156.0",
"@streamdown/cjk": "^1.0.2",
"@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-router": "^1.159.10",
"@tanstack/react-table": "^8.21.3",
"@toolwind/corner-shape": "^0.0.8-3",
"@types/canvas-confetti": "^1.9.0",
"@xyflow/react": "^12.10.0",
"assistant-stream": "^0.3.0",
"assistant-stream": "^0.3.2",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dexie": "^4.2.1",
"framer-motion": "^11.15.0",
"katex": "^0.16.22",
"lucide-react": "^0.563.0",
"dexie": "^4.3.0",
"framer-motion": "^11.18.2",
"js-yaml": "^4.1.1",
"katex": "^0.16.28",
"lucide-react": "^0.575.0",
"mammoth": "^1.11.0",
"motion": "^12.29.2",
"motion": "^12.34.0",
"next": "^16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-resizable-panels": "^4.4.1",
"recharts": "2.15.4",
"react": "^19.2.4",
"react-day-picker": "^9.13.2",
"react-dom": "^19.2.4",
"react-resizable-panels": "^4.6.4",
"recharts": "3.7.0",
"remark-gfm": "^4.0.1",
"shadcn": "^3.7.0",
"shadcn": "^3.8.4",
"sonner": "^2.0.7",
"streamdown": "^2.1.0",
"streamdown": "^2.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"tw-shimmer": "^0.4.4",
"tw-shimmer": "^0.4.6",
"unpdf": "^1.4.0",
"zustand": "^5.0.10"
"zustand": "^5.0.11"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@eslint/js": "^9.39.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
"typescript-eslint": "^8.55.0",
"vite": "^7.3.1"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View file

@ -10,7 +10,7 @@ export function AppProvider({ children }: AppProviderProps) {
return (
<ThemeProvider attribute="class" defaultTheme="light">
{children}
<Toaster position="top-right" />
<Toaster position="top-right" visibleToasts={2} expand={true} />
</ThemeProvider>
);
}

View file

@ -1,11 +1,13 @@
import { createRouter } from "@tanstack/react-router";
import { Route as rootRoute } from "./routes/__root";
import { Route as dataRecipesRoute } from "./routes/data-recipes";
import { Route as dataRecipeRoute } from "./routes/data-recipes.$recipeId";
import { Route as chatRoute } from "./routes/chat";
import { Route as exportRoute } from "./routes/export";
import { Route as gridTestRoute } from "./routes/grid-test";
import { Route as indexRoute } from "./routes/index";
import { Route as loginRoute } from "./routes/login";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as exportRoute } from "./routes/export";
import { Route as signupRoute } from "./routes/signup";
import { Route as studioRoute } from "./routes/studio";
@ -18,6 +20,8 @@ const routeTree = rootRoute.addChildren([
studioRoute,
chatRoute,
exportRoute,
dataRecipesRoute,
dataRecipeRoute,
]);
export const router = createRouter({ routeTree });

View file

@ -0,0 +1,23 @@
import { createRoute } from "@tanstack/react-router";
import type { ReactElement } from "react";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const EditRecipePage = lazy(() =>
import("@/features/data-recipes").then((m) => ({
default: m.EditRecipePage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/data-recipes/$recipeId",
beforeLoad: () => requireAuth(),
component: DataRecipeEditorRoute,
});
function DataRecipeEditorRoute(): ReactElement {
const { recipeId } = Route.useParams();
return <EditRecipePage recipeId={recipeId} />;
}

View file

@ -0,0 +1,17 @@
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const DataRecipesPage = lazy(() =>
import("@/features/data-recipes").then((m) => ({
default: m.DataRecipesPage,
})),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/data-recipes",
beforeLoad: () => requireAuth(),
component: DataRecipesPage,
});

View file

@ -1,20 +1,30 @@
"use client";
import { INTERNAL } from "@assistant-ui/react";
import { StreamdownTextPrimitive } from "@assistant-ui/react-streamdown";
import { INTERNAL, useMessagePartText } from "@assistant-ui/react";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
const { withSmoothContextProvider } = INTERNAL;
const { withSmoothContextProvider, useSmoothStatus } = INTERNAL;
const MarkdownTextImpl = () => {
const { text } = useMessagePartText();
const status = useSmoothStatus();
return (
<StreamdownTextPrimitive
plugins={{ code, math, mermaid }}
controls={true}
/>
<div data-status={status.type}>
<Streamdown
mode="streaming"
isAnimating={status.type === "running"}
plugins={{ code, math, mermaid }}
controls={true}
shikiTheme={["github-light", "github-dark"]}
>
{text}
</Streamdown>
</div>
);
};

View file

@ -62,7 +62,7 @@ function ModelSelectorTrigger({
className={cn(
"flex items-center gap-2 transition-colors",
variant === "outline" &&
"rounded-full border border-border/60 hover:bg-accent",
"rounded-full border border-border/60 hover:bg-accent",
variant === "ghost" && "rounded-md hover:bg-accent",
variant === "muted" && "rounded-md bg-muted hover:bg-muted/80",
size === "sm" && "h-8 px-3 text-xs",
@ -183,9 +183,20 @@ export function ModelSelector({
all.set(model.id, model);
}
for (const lora of loraModels) {
// Strip "/ suffix" from display name (e.g. "foo_123/foo" → "foo_123")
const displayName = lora.name.includes("/")
? lora.name.split("/")[0].trim()
: lora.name;
// Show type tag instead of base model name
const isExported = lora.source === "exported";
const isMerged = lora.exportType === "merged";
const tag = isExported
? isMerged ? "Merged · Exported" : "LoRA"
: "LoRA";
all.set(lora.id, {
...lora,
description: lora.baseModel || lora.description,
name: displayName,
description: tag,
});
}
return all;

View file

@ -522,15 +522,26 @@ export function LoraModelPicker({
<div key={baseModel}>
{index > 0 ? <div className="my-1" /> : null}
<ListLabel>{baseModel}</ListLabel>
{adapters.map((adapter) => (
<ModelRow
key={adapter.id}
label={adapter.name}
meta="LoRA"
selected={value === adapter.id}
onClick={() => onSelect(adapter.id, { source: "lora", isLora: true })}
/>
))}
{adapters.map((adapter) => {
const isExported = adapter.source === "exported";
const isMerged = adapter.exportType === "merged";
const tag = isExported
? isMerged ? "Merged" : "LoRA"
: "LoRA";
const meta = isExported ? `${tag} · Exported` : tag;
return (
<ModelRow
key={adapter.id}
label={adapter.name}
meta={meta}
selected={value === adapter.id}
onClick={() => onSelect(adapter.id, {
source: isExported ? "exported" : "lora",
isLora: !isMerged,
})}
/>
);
})}
</div>
))
)}

View file

@ -10,10 +10,12 @@ export interface ModelOption {
export interface LoraModelOption extends ModelOption {
baseModel?: string;
updatedAt?: number;
source?: "training" | "exported";
exportType?: "lora" | "merged";
}
export interface ModelSelectorChangeMeta {
source: "hub" | "lora";
source: "hub" | "lora" | "exported";
isLora: boolean;
ggufVariant?: string;
}

View file

@ -70,7 +70,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
}}
/>
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mt-auto flex w-full flex-col gap-4 overflow-visible bg-background pb-4 md:pb-4 before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-20 before:bg-gradient-to-t before:from-background before:to-transparent">
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mt-auto flex w-full flex-col gap-4 overflow-visible bg-background pb-4 md:pb-4">
<ThreadScrollToBottom />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
{!hideComposer && <ComposerAnimated />}

View file

@ -0,0 +1,46 @@
import { cn } from "@/lib/utils";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { memo, type ReactElement } from "react";
import { Streamdown } from "streamdown";
import "katex/dist/katex.min.css";
const MARKDOWN_PLUGINS = { code, math, mermaid } as const;
type MarkdownPreviewProps = {
markdown: string;
className?: string;
plain?: boolean;
};
function MarkdownPreviewImpl({
markdown,
className,
plain = false,
}: MarkdownPreviewProps): ReactElement {
const markdownClassName =
"w-full max-w-none min-w-0 space-y-2 [overflow-wrap:anywhere] [&_*]:max-w-none [&_p]:w-full [&_ul]:w-full [&_ol]:w-full [&_li]:w-full [&_h1]:w-full [&_h2]:w-full [&_h3]:w-full [&_h4]:w-full [&_h5]:w-full [&_h6]:w-full [&_pre]:w-full [&_table]:w-full [&_p]:break-words [&_li]:break-words [&_code]:break-words [&_pre]:whitespace-pre-wrap [&_pre]:break-words";
return (
<div
className={cn(
plain
? "h-full w-full min-w-0 overflow-auto p-2 text-xs leading-relaxed pointer-events-none select-none"
: "nodrag max-h-56 w-full min-w-0 overflow-auto rounded-md border border-border/60 bg-muted/20 p-2 text-xs leading-relaxed",
className,
)}
>
<Streamdown
mode="static"
plugins={MARKDOWN_PLUGINS}
controls={false}
className={markdownClassName}
>
{markdown.trim() ? markdown : "_Empty note_"}
</Streamdown>
</div>
);
}
export const MarkdownPreview = memo(MarkdownPreviewImpl);

View file

@ -3,6 +3,7 @@ import {
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import {
Sheet,
SheetContent,
@ -13,9 +14,9 @@ import {
import { cn } from "@/lib/utils";
import {
AiChat02Icon,
Analytics01Icon,
ArrowRight01Icon,
Book03Icon,
ChefHatIcon,
CursorInfo02Icon,
PackageIcon,
ZapIcon,
@ -29,25 +30,24 @@ import { TOUR_OPEN_EVENT } from "@/features/tour";
const NAV_ITEMS = [
{ label: "Studio", href: "/studio", icon: ZapIcon, enabled: true },
{ label: "Evaluate", href: "/evaluate", icon: Analytics01Icon, enabled: false },
{ label: "Recipes", href: "/data-recipes", icon: ChefHatIcon, enabled: true },
{ label: "Export", href: "/export", icon: PackageIcon, enabled: true },
{ label: "Chat", href: "/chat", icon: AiChat02Icon, enabled: true },
];
function getTourId(pathname: string): "studio" | "chat" | "export" | null {
if (pathname === "/studio") return "studio";
if (pathname === "/chat") return "chat";
if (pathname === "/export") return "export";
return null;
}
export function Navbar() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning);
const [logoHovered, setLogoHovered] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const tourId =
pathname === "/studio"
? "studio"
: pathname === "/chat"
? "chat"
: pathname === "/export"
? "export"
: null;
const tourId = getTourId(pathname);
const openTour = () => {
if (!tourId) return;
@ -60,35 +60,18 @@ export function Navbar() {
<header className="relative top-0 z-40 h-16 w-full">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-4 sm:px-6">
{/* Left: logo */}
<div
className="relative flex items-center gap-2.5 cursor-pointer select-none"
onMouseEnter={() => setLogoHovered(true)}
onMouseLeave={() => setLogoHovered(false)}
>
<motion.img
src="https://unsloth.ai/cgi/image/unsloth_sticker_no_shadow_ldN4V4iydw00qSIIWDCUv.png?width=96&quality=80&format=auto"
alt="unsloth"
className="size-10"
animate={{ rotate: logoHovered ? 360 : 0 }}
transition={{ duration: 0.5, ease: [0.165, 0.84, 0.44, 1] }}
<Link to="/studio" className="flex items-center select-none">
<img
src="/blacklogo.png"
alt="Unsloth"
className="h-9 w-auto dark:hidden"
/>
<span className="text-xl font-bold tracking-wide font-heading sm:text-2xl">
unsloth
</span>
<AnimatePresence>
{logoHovered && (
<motion.img
src="/Sloth emojis/large sloth wave.png"
alt="hi!"
className="absolute -bottom-10 left-1 size-10 pointer-events-none"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 10, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.215, 0.61, 0.355, 1] }}
/>
)}
</AnimatePresence>
</div>
<img
src="/whitelogo.png"
alt="Unsloth"
className="hidden h-9 w-auto dark:block"
/>
</Link>
{/* Center: pill nav */}
<nav
@ -96,7 +79,8 @@ export function Navbar() {
className="hidden items-center rounded-full border border-border bg-card p-1 ring-1 ring-foreground/5 md:flex"
>
{NAV_ITEMS.map((item) => {
const active = pathname === item.href;
const active =
pathname === item.href || pathname.startsWith(`${item.href}/`);
const disabledByTraining =
isTrainingRunning && item.href !== "/studio";
if (!item.enabled || disabledByTraining) {
@ -145,7 +129,7 @@ export function Navbar() {
>
<HugeiconsIcon
icon={item.icon}
className="size-3.5 -mt-px fill-current"
className="size-3.5 -mt-px"
/>
</motion.span>
)}
@ -159,6 +143,11 @@ export function Navbar() {
{/* Right: docs/tour desktop */}
<div className="hidden items-center gap-2 md:flex">
<AnimatedThemeToggler
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground [&_svg]:size-4"
title="Toggle theme"
aria-label="Toggle theme"
/>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
<a
@ -197,10 +186,11 @@ export function Navbar() {
<button
type="button"
onClick={openTour}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="flex h-9 items-center gap-1.5 rounded-md px-3 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Tour"
>
<HugeiconsIcon icon={CursorInfo02Icon} className="size-4" />
<span className="text-sm font-medium">Tour</span>
</button>
) : null}
</div>

View file

@ -52,7 +52,7 @@ export function SectionCard({
return (
<div
className={cn(
"bg-card corner-squircle rounded-3xl ring-1 ring-foreground/10 flex flex-col gap-5 p-5 relative transition-all duration-300 ease-in-out",
"bg-card corner-squircle rounded-3xl ring-1 ring-foreground/10 flex flex-col gap-5 p-5 relative overflow-hidden transition-all duration-300 ease-in-out",
featured && styles.border,
className,
)}

View file

@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { flushSync } from "react-dom"
import { cn } from "@/lib/utils"
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
duration?: number
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"))
}
updateTheme()
const observer = new MutationObserver(updateTheme)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
})
return () => observer.disconnect()
}, [])
const toggleTheme = useCallback(async () => {
if (!buttonRef.current) return
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = !isDark
setIsDark(newTheme)
document.documentElement.classList.toggle("dark")
localStorage.setItem("theme", newTheme ? "dark" : "light")
})
}).ready
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top)
)
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
}
)
}, [isDark, duration])
return (
<button
ref={buttonRef}
onClick={toggleTheme}
className={cn(className)}
{...props}
>
{isDark ? <Sun /> : <Moon />}
<span className="sr-only">Toggle theme</span>
</button>
)
}

View file

@ -100,30 +100,30 @@ ${colorConfig
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
formatter,
color,
nameKey,
labelKey,
}: Partial<RechartsPrimitive.TooltipContentProps<any, any>> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
@ -248,20 +248,20 @@ function ChartTooltipContent({
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;

View file

@ -6,7 +6,8 @@ import { Combobox as ComboboxPrimitive } from "@base-ui/react";
import * as React from "react";
import { createContext, useContext, useState } from "react";
import { Button } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import { useDialogPortalContainer } from "@/components/ui/dialog";
import {
InputGroup,
InputGroupAddon,
@ -139,24 +140,28 @@ function ComboboxInput({
);
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>): React.ReactElement {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
container,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
> & {
container?: HTMLElement | null;
}): React.ReactElement {
const dialogContainer = useDialogPortalContainer();
return (
<ComboboxPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}

View file

@ -22,15 +22,24 @@ interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
className?: string;
onRowClick?: (row: TData, rowIndex: number, rowId: string) => void;
getRowClassName?: (
row: TData,
rowIndex: number,
rowId: string,
) => string | undefined;
}
export function DataTable<TData, TValue>({
columns,
data,
className,
onRowClick,
getRowClassName,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
@ -81,7 +90,9 @@ export function DataTable<TData, TValue>({
? "bg-background"
: "bg-muted/20",
"hover:bg-primary/[0.03]",
getRowClassName?.(row.original, idx, row.id),
)}
onClick={() => onRowClick?.(row.original, idx, row.id)}
>
{row.getVisibleCells().map((cell) => (
<TableCell

View file

@ -1,165 +1,191 @@
"use client";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("gap-2 flex flex-col", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"gap-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
"use client";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { createContext, useContext } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
const DialogPortalContainerContext = createContext<HTMLElement | null>(null);
export function useDialogPortalContainer(): HTMLElement | null {
return useContext(DialogPortalContainerContext);
}
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
position = "fixed",
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay> & {
position?: "fixed" | "absolute";
}) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 inset-0 isolate z-50",
position === "fixed" ? "fixed" : "absolute",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
container,
position = "fixed",
overlayClassName,
overlayPosition,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
container?: HTMLElement | null;
position?: "fixed" | "absolute";
overlayClassName?: string;
overlayPosition?: "fixed" | "absolute";
}) {
const resolvedContainer = container ?? null;
return (
<DialogPortalContainerContext.Provider value={resolvedContainer}>
<DialogPortal container={resolvedContainer ?? undefined}>
<DialogOverlay
className={overlayClassName}
position={overlayPosition ?? position}
/>
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 ring-foreground/5 grid max-w-[calc(100%-2rem)] gap-6 rounded-4xl p-6 text-sm ring-1 duration-100 sm:max-w-md top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2",
position === "fixed" ? "fixed" : "absolute",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
</DialogPortalContainerContext.Provider>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("gap-2 flex flex-col", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View file

@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"gap-4 rounded-lg border-dashed p-12 flex w-full min-w-0 flex-1 flex-col items-center justify-center text-center text-balance",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"gap-2 flex max-w-sm flex-col items-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-sm/relaxed text-muted-foreground [&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"gap-4 text-sm flex w-full max-w-sm min-w-0 flex-col items-center text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}

View file

@ -4,7 +4,8 @@ import { Select as SelectPrimitive } from "radix-ui";
import type * as React from "react";
import { createContext, useContext, useState } from "react";
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { useDialogPortalContainer } from "@/components/ui/dialog";
import {
ArrowDown01Icon,
ArrowUp01Icon,
@ -91,18 +92,22 @@ function SelectTrigger({
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
container,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content> & {
container?: HTMLElement | null;
}) {
const dialogContainer = useDialogPortalContainer();
return (
<SelectPrimitive.Portal container={container ?? dialogContainer ?? undefined}>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
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 min-w-36 rounded-xl p-1 corner-squircle duration-100 relative z-50 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto ",
position === "popper" &&

View file

@ -1,146 +1,158 @@
import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
overlayClassName,
container,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
overlayClassName?: string;
container?: HTMLElement | null;
}) {
return (
<SheetPortal container={container ?? undefined}>
<SheetOverlay className={overlayClassName} />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("gap-1.5 p-6 flex flex-col", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("gap-2 p-6 mt-auto flex flex-col", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground text-base font-medium", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
import { Dialog as SheetPrimitive } from "radix-ui";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Cancel01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
position = "fixed",
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay> & {
position?: "fixed" | "absolute";
}) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/80 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs inset-0 z-50",
position === "fixed" ? "fixed" : "absolute",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
container,
position = "fixed",
overlayClassName,
overlayPosition,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
container?: HTMLElement | null;
position?: "fixed" | "absolute";
overlayClassName?: string;
overlayPosition?: "fixed" | "absolute";
}) {
return (
<SheetPortal container={container ?? undefined}>
<SheetOverlay
className={overlayClassName}
position={overlayPosition ?? position}
/>
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 z-50 flex flex-col bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
position === "fixed" ? "fixed" : "absolute",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("gap-1.5 p-6 flex flex-col", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("gap-2 p-6 mt-auto flex flex-col", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground text-base font-medium", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View file

@ -0,0 +1,61 @@
import * as React from "react"
import { cn } from "@/lib/utils"
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Width of the border in pixels
* @default 1
*/
borderWidth?: number
/**
* Duration of the animation in seconds
* @default 14
*/
duration?: number
/**
* Color of the border, can be a single color or an array of colors
* @default "#000000"
*/
shineColor?: string | string[]
}
/**
* Shine Border
*
* An animated background border effect component with configurable properties.
*/
export function ShineBorder({
borderWidth = 1,
duration = 14,
shineColor = "#000000",
className,
style,
...props
}: ShineBorderProps) {
return (
<div
style={
{
"--border-width": `${borderWidth}px`,
"--duration": `${duration}s`,
backgroundImage: `radial-gradient(transparent,transparent, ${
Array.isArray(shineColor) ? shineColor.join(",") : shineColor
},transparent,transparent)`,
backgroundSize: "300% 300%",
mask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
WebkitMask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
WebkitMaskComposite: "xor",
maskComposite: "exclude",
padding: "var(--border-width)",
...style,
} as React.CSSProperties
}
className={cn(
"motion-safe:animate-shine pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position]",
className
)}
{...props}
/>
)
}

View file

@ -43,10 +43,10 @@ function TooltipContent({
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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 rounded-2xl px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-4xl bg-foreground text-background border border-foreground/40 shadow-lg z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
className,
)}
className={cn(
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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 rounded-2xl corner-squircle px-3 py-1.5 text-xs **:data-[slot=kbd]:rounded-4xl bg-foreground text-background border border-foreground/40 shadow-lg z-50 w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin)",
className,
)}
{...props}
>
{children}

View file

@ -103,7 +103,7 @@ export const DEFAULT_HYPERPARAMS = {
warmupSteps: 5,
maxSteps: 0,
saveSteps: 0,
evalSteps: 0.01,
evalSteps: 0.00,
packing: false,
trainOnCompletions: false,
gradientCheckpointing: "unsloth" as const,

View file

@ -33,6 +33,7 @@ import {
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { GuidedTour, useGuidedTourController } from "@/features/tour";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { db } from "./db";
@ -50,7 +51,7 @@ import {
SharedComposer,
} from "./shared-composer";
import { ThreadSidebar } from "./thread-sidebar";
import type { ChatView } from "./types";
import type { ChatView, MessageRecord } from "./types";
import { buildChatTourSteps } from "./tour";
type LoraCandidate = {
@ -90,6 +91,40 @@ function pickBestLoraForBase(
return partial ?? sorted[0];
}
function messageHasImage(message: MessageRecord): boolean {
const contentParts = Array.isArray(message.content) ? message.content : [];
if (contentParts.some((part) => part.type === "image")) {
return true;
}
const attachments = Array.isArray(message.attachments) ? message.attachments : [];
for (const attachment of attachments) {
const parts = Array.isArray(attachment.content) ? attachment.content : [];
for (const part of parts as Array<{ type?: string }>) {
if (part?.type === "image") {
return true;
}
}
}
return false;
}
async function resolveActiveSingleThreadId(view: ChatView): Promise<string | undefined> {
if (view.mode !== "single") {
return undefined;
}
if (view.threadId) {
return view.threadId;
}
// New-thread flow keeps threadId undefined in local view state.
// Fall back to most recent regular base thread.
const candidates = await db.threads.where("modelType").equals("base").toArray();
const latest = candidates
.filter((thread) => !thread.archived && !thread.pairId)
.sort((a, b) => b.createdAt - a.createdAt)[0];
return latest?.id;
}
const SingleContent = memo(function SingleContent({
threadId,
newThreadNonce,
@ -208,7 +243,7 @@ function InlineSidebar({
return (
<div
className="group shrink-0 h-full"
className="group shrink-0 h-full pb-3.5"
data-state={state}
data-collapsible={collapsed ? "offcanvas" : ""}
data-side={side}
@ -216,11 +251,11 @@ function InlineSidebar({
<aside
data-sidebar="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground h-full overflow-hidden rounded-2xl corner-squircle transition-[width] duration-200 ease-linear",
"bg-muted/70 text-sidebar-foreground h-full overflow-hidden rounded-2xl corner-squircle transition-[width] duration-200 ease-linear",
!collapsed &&
(side === "left"
? "border-r border-0 border-sidebar-border"
: "border-l border-0 border-sidebar-border"),
? "border-r border-sidebar-border/70"
: "border-l border-sidebar-border/70"),
collapsed ? "w-0" : "w-(--sidebar-width)",
)}
>
@ -304,10 +339,33 @@ export function ChatPage(): ReactElement {
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
if (!value || value === currentCheckpoint) return;
setView({ mode: "single", newThreadNonce: crypto.randomUUID() });
void (async () => {
if (currentCheckpoint) {
await ejectModel();
let switchNote: string | undefined;
const activeThreadId = await resolveActiveSingleThreadId(view);
if (activeThreadId) {
const thread = await db.threads.get(activeThreadId);
if (thread?.modelId && thread.modelId !== value) {
const messages = await db.messages
.where("threadId")
.equals(activeThreadId)
.toArray();
const hasImage = messages.some(messageHasImage);
const targetModel = modelsFromStore.find((model) => model.id === value);
const nonVisionWithImages = hasImage && targetModel?.isVision === false;
switchNote = nonVisionWithImages
? "Full chat history will be sent to the new model. This chat has images; text-only models may fail."
: hasImage
? "Full chat history will be sent to the new model. This chat includes images."
: "Full chat history will be sent to the new model.";
}
}
if (switchNote) {
toast.warning("Model changed for this chat", {
description: switchNote,
duration: 6000,
});
}
await selectModel({
id: value,
@ -316,7 +374,7 @@ export function ChatPage(): ReactElement {
});
})();
},
[selectModel, ejectModel],
[modelsFromStore, selectModel, view],
);
const handleEject = useCallback(() => {
void ejectModel();
@ -365,36 +423,8 @@ export function ChatPage(): ReactElement {
const handleThreadSelect = useCallback(
(nextView: ChatView) => {
setView(nextView);
const threadId =
nextView.mode === "single" ? nextView.threadId : undefined;
const pairId =
nextView.mode === "compare" ? nextView.pairId : undefined;
void (async () => {
let thread: import("./types").ThreadRecord | undefined;
if (threadId) {
thread = await db.threads.get(threadId);
} else if (pairId) {
thread = await db.threads
.where("pairId")
.equals(pairId)
.first();
}
const threadModelId = thread?.modelId;
if (!threadModelId) return;
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
if (threadModelId === currentCheckpoint) return;
if (currentCheckpoint) {
await ejectModel();
}
await selectModel({ id: threadModelId });
})();
},
[ejectModel, selectModel],
[],
);
const models = useMemo<ModelOption[]>(
@ -414,6 +444,8 @@ export function ChatPage(): ReactElement {
name: lora.name,
baseModel: lora.baseModel,
updatedAt: lora.updatedAt,
source: lora.source,
exportType: lora.exportType,
})),
[lorasFromStore],
);

View file

@ -195,7 +195,7 @@ export function ChatSettingsPanel({
return (
<aside
className={`shrink-0 h-full overflow-hidden bg-sidebar rounded-2xl corner-squircle transition-[width] duration-200 ease-linear ${open ? "w-[17rem] border-sidebar-border" : "w-0"}`}
className={`shrink-0 self-start h-[calc(100%-0.875rem)] overflow-hidden bg-muted/70 rounded-2xl corner-squircle transition-[width] duration-200 ease-linear ${open ? "w-[17rem] border-l border-sidebar-border/70" : "w-0"}`}
>
<div className="flex h-full w-[17rem] flex-col">
<div className="flex items-center gap-2 px-4 py-3">
@ -320,7 +320,7 @@ export function ChatSettingsPanel({
label="Max Tokens"
value={params.maxTokens}
min={64}
max={4096}
max={4092}
step={64}
onChange={set("maxTokens")}
/>

View file

@ -21,6 +21,7 @@ type SelectedModelInput = {
id: string;
isLora?: boolean;
ggufVariant?: string;
loadingDescription?: string;
};
const LORA_SUFFIX_RE = /_(\d{9,})$/;
@ -73,6 +74,8 @@ function toLoraSummary(lora: {
display_name: string;
adapter_path: string;
base_model?: string | null;
source?: "training" | "exported" | null;
export_type?: "lora" | "merged" | null;
}): ChatLoraSummary {
const idTail = lora.adapter_path.split("/").filter(Boolean).at(-1) ?? "";
const updatedAt =
@ -83,6 +86,8 @@ function toLoraSummary(lora: {
name: stripTrailingEpoch(lora.display_name),
baseModel: lora.base_model || "Unknown base model",
updatedAt,
source: lora.source ?? undefined,
exportType: lora.export_type ?? undefined,
};
}
@ -162,11 +167,22 @@ export function useChatModelRuntime() {
typeof selection === "string" ? undefined : selection.isLora;
const ggufVariant =
typeof selection === "string" ? undefined : selection.ggufVariant;
const extraLoadingDescription =
typeof selection === "string" ? undefined : selection.loadingDescription;
const model = models.find((entry) => entry.id === modelId);
const lora = loras.find((entry) => entry.id === modelId);
const isLora =
explicitIsLora ?? model?.isLora ?? (lora ? true : false);
const displayName = model?.name || lora?.name || modelId;
const currentCheckpoint =
useChatRuntimeStore.getState().params.checkpoint;
const loadingDescription = [
currentCheckpoint ? "Unloading previous model first." : null,
extraLoadingDescription ?? null,
"This may include downloading. Large models can take a while.",
]
.filter(Boolean)
.join(" ");
setModelsError(null);
setLoadingModel({ id: modelId, displayName });
@ -201,8 +217,7 @@ export function useChatModelRuntime() {
success: `${displayName} loaded`,
error: (err) =>
err instanceof Error ? err.message : "Failed to load model",
description:
"This may include downloading. Large models can take a while.",
description: loadingDescription,
});
} catch (error) {
setLoadingModel(null);
@ -228,7 +243,7 @@ export function useChatModelRuntime() {
await toast.promise(performUnload(), {
loading: "Unloading model",
success: "Model unloaded",
success: { message: "Model unloaded", duration: 1200 },
error: (err) =>
err instanceof Error ? err.message : "Failed to unload model",
description: "Releases VRAM and resets inference state.",

View file

@ -15,6 +15,8 @@ export interface BackendLoraInfo {
display_name: string;
adapter_path: string;
base_model?: string | null;
source?: "training" | "exported" | null;
export_type?: "lora" | "merged" | null;
}
export interface ListLorasResponse {

View file

@ -15,7 +15,7 @@ export const DEFAULT_INFERENCE_PARAMS: InferenceParams = {
topK: 50,
minP: 0.01,
repetitionPenalty: 1.1,
maxTokens: 512,
maxTokens: 4092,
systemPrompt: "",
checkpoint: "",
};
@ -34,4 +34,6 @@ export interface ChatLoraSummary {
name: string;
baseModel: string;
updatedAt?: number;
source?: "training" | "exported";
exportType?: "lora" | "merged";
}

View file

@ -0,0 +1,79 @@
import { createEmptyRecipePayload } from "@/features/recipe-studio";
import { normalizeNonEmptyName } from "@/utils";
import Dexie, { type EntityTable, liveQuery } from "dexie";
import { useEffect, useState } from "react";
import type { RecipeRecord, SaveRecipeInput } from "../types";
const db = new Dexie("unsloth-data-recipes") as Dexie & {
recipes: EntityTable<RecipeRecord, "id">;
};
db.version(1).stores({
recipes: "id, name, updatedAt, createdAt",
});
export function listRecipes(): Promise<RecipeRecord[]> {
return db.recipes.orderBy("updatedAt").reverse().toArray();
}
export function getRecipe(id: string): Promise<RecipeRecord | undefined> {
return db.recipes.get(id);
}
export async function saveRecipe(
input: SaveRecipeInput,
): Promise<RecipeRecord> {
const now = Date.now();
const id = input.id ?? crypto.randomUUID();
const existing = input.id ? await db.recipes.get(input.id) : undefined;
const record: RecipeRecord = {
id,
name: normalizeNonEmptyName(input.name),
payload: input.payload,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
learningRecipeId: input.learningRecipeId ?? existing?.learningRecipeId,
learningRecipeTitle:
input.learningRecipeTitle ?? existing?.learningRecipeTitle,
};
await db.recipes.put(record);
return record;
}
export async function deleteRecipe(id: string): Promise<void> {
await db.recipes.delete(id);
}
export function createRecipeDraft(): Promise<RecipeRecord> {
return saveRecipe({
name: "Unnamed",
payload: createEmptyRecipePayload(),
});
}
export function createRecipeFromLearningRecipe(input: {
templateId: string;
templateTitle: string;
payload: RecipeRecord["payload"];
}): Promise<RecipeRecord> {
return saveRecipe({
name: input.templateTitle,
payload: input.payload,
learningRecipeId: input.templateId,
learningRecipeTitle: input.templateTitle,
});
}
export function useRecipes(): RecipeRecord[] {
const [recipes, setRecipes] = useState<RecipeRecord[]>([]);
useEffect(() => {
const sub = liveQuery(() => listRecipes()).subscribe({
next: (value) => setRecipes(value),
error: (error) => console.error("data-recipes liveQuery:", error),
});
return () => sub.unsubscribe();
}, []);
return recipes;
}

View file

@ -0,0 +1,2 @@
export { DataRecipesPage } from "./pages/data-recipes-page";
export { EditRecipePage } from "./pages/edit-recipe-page";

View file

@ -0,0 +1,280 @@
{
"recipe": {
"model_providers": [
{
"name": "provider_1",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"extra_headers": {},
"extra_body": {}
}
],
"mcp_providers": [],
"model_configs": [
{
"alias": "model_1",
"model": "mistralai/ministral-8b-2512",
"provider": "provider_1",
"inference_parameters": {
"temperature": 0.7,
"max_tokens": 2048
}
}
],
"tool_configs": [],
"columns": [
{
"column_type": "sampler",
"name": "domain",
"drop": true,
"sampler_type": "category",
"params": {
"values": [
"Tech Support",
"Personal Finance",
"Learning"
]
}
},
{
"column_type": "sampler",
"name": "topic",
"drop": true,
"sampler_type": "subcategory",
"params": {
"category": "domain",
"values": {
"Tech Support": [
"Wi-Fi keeps disconnecting",
"Laptop running very slow",
"Cannot install app update"
],
"Personal Finance": [
"Monthly budget planning",
"Credit card debt payoff",
"Emergency fund setup"
],
"Learning": [
"Exam study plan",
"Learn Python basics",
"Improve English writing"
]
}
}
},
{
"column_type": "sampler",
"name": "conversation_length",
"drop": true,
"sampler_type": "category",
"params": {
"values": [
"4",
"6"
]
}
},
{
"column_type": "llm-text",
"name": "user_goal",
"drop": false,
"model_alias": "model_1",
"prompt": "Write one user goal for a chat assistant.\nDomain: {{ domain }}\nTopic: {{ topic }}\nConversation length target: {{ conversation_length }} messages total.\nRules:\n- 1 sentence.\n- Specific and practical.\n- Output only the goal text.",
"system_prompt": "You write realistic user goals for assistant conversations.\n",
"with_trace": "none"
},
{
"column_type": "llm-structured",
"name": "output_format",
"drop": false,
"model_alias": "model_1",
"prompt": "Generate a realistic multi-turn conversation.\nUser goal:\n{{ user_goal }}\nConstraints:\n- Exactly {{ conversation_length }} messages total.\n- Alternate roles strictly: user, assistant, user, assistant...\n- First message must be user.\n- Last message must be assistant.\n- Keep responses grounded in {{ domain }} / {{ topic }}.\n- End naturally with resolution or clear next step.\n- No markdown, no extra keys.",
"output_format": {
"type": "object",
"properties": {
"conversation": {
"type": "array",
"minItems": 4,
"maxItems": 6,
"items": {
"type": "object",
"properties": {
"role": {
"type": "string",
"enum": [
"user",
"assistant"
]
},
"content": {
"type": "string",
"minLength": 1
}
},
"required": [
"role",
"content"
],
"additionalProperties": false
}
}
},
"required": [
"conversation"
],
"additionalProperties": false
}
}
],
"processors": []
},
"run": {
"rows": 5,
"preview": true,
"output_formats": [
"jsonl"
]
},
"ui": {
"nodes": [
{
"id": "provider_1",
"x": -1056.848383841495,
"y": 519.6373927070263,
"width": 400
},
{
"id": "model_1",
"x": -543.7221365246206,
"y": 488.2975724283656,
"width": 400
},
{
"id": "domain",
"x": 0,
"y": 140,
"width": 400
},
{
"id": "topic",
"x": 0,
"y": 280,
"width": 400
},
{
"id": "conversation_length",
"x": 466.61510192672256,
"y": 139.68271861864798,
"width": 400
},
{
"id": "user_goal",
"x": 1.412158386197035,
"y": 508.77123580445596,
"width": 400
},
{
"id": "output_format",
"x": 1.1486983549970375,
"y": 754.4221089431811,
"width": 400
},
{
"id": "note_1",
"x": 210.01377182764494,
"y": -262.9440547613487,
"width": 400,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "### Start with controlled chat context\nThis recipe uses sampler columns to shape each conversation:\n\n- `domain`\n- `topic`\n- `conversation_length` (4 or 6 messages)\n\n**Why this helps**:\n\n- You get varied conversations without manual writing\n- Each row stays grounded in a clear scenario\n- You can scale quickly while keeping data quality consistent",
"note_color": "#FFE4E6",
"note_opacity": "35"
},
{
"id": "note_2",
"x": 515.9369583007435,
"y": 454.3936030274385,
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "The **LLM Text** block (`user_goal`) creates one realistic user intent from sampler context.\n\n**It should be**:\n\n- **specific**\n- **practical**\n- **short**\n\nThis goal becomes the anchor for the full multi-turn conversation.",
"note_color": "#FFE4E6",
"note_opacity": "35"
},
{
"id": "note_3",
"x": -12.952616065779239,
"y": 912.1316336111515,
"width": 400,
"node_type": "markdown_note",
"name": "note_3",
"markdown": "The **LLM Structured** block (`output_format`) generates the conversation as strict JSON.\n\nIn this recipe, schema enforces:\n\n- `conversation` array\n- message objects with `role` + `content`\n- role enum: `user` / `assistant`\n- no extra keys\n\nPrompt constraints also enforce:\n\n- exact length (`{{ conversation_length }}`)\n- alternating roles\n- first user message, last assistant message\n- natural ending\n\nThis is key for training data: same shape, less cleanup.",
"note_color": "#FFE4E6",
"note_opacity": "35"
},
{
"id": "note_4",
"x": -519.9585237323188,
"y": 81.84144119564277,
"width": 400,
"node_type": "markdown_note",
"name": "note_4",
"markdown": "Sampler columns are useful during generation but usually noisy in final export.\n\nSet helper columns to `drop=true`, keep only core outputs such as:\n\n- `user_goal`\n- `output_format`\n\nTip: Keep final schema close to your training format, not your generation scaffolding.\n",
"note_color": "#FFE4E6",
"note_opacity": "35"
}
],
"edges": [
{
"from": "domain",
"to": "topic",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "domain",
"to": "conversation_length",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "topic",
"to": "user_goal",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "user_goal",
"to": "output_format",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "provider_1",
"to": "model_1",
"type": "semantic",
"source_handle": "semantic-out",
"target_handle": "semantic-in"
},
{
"from": "model_1",
"to": "user_goal",
"type": "semantic",
"source_handle": "semantic-out",
"target_handle": "data-in"
},
{
"from": "model_1",
"to": "output_format",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in"
}
],
"layout_direction": "LR"
}
}

View file

@ -0,0 +1,134 @@
import type { RecipePayload } from "@/features/recipe-studio";
const structuredOutputsJinjaUrl = new URL(
"./structured-outputs-jinja.json",
import.meta.url,
).href;
const pdfGroundedQaUrl = new URL("./pdf-grounded-qa.json", import.meta.url)
.href;
const instructionFromAnswerUrl = new URL(
"./instruction-from-answer.json",
import.meta.url,
).href;
const textToPythonUrl = new URL("./text-to-python.json", import.meta.url).href;
const textToSqlUrl = new URL("./text-to-sql.json", import.meta.url).href;
const conversationUrl = new URL("./conversation.json", import.meta.url).href;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function toRecordArray(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is Record<string, unknown> =>
isRecord(item),
);
}
function coerceRecipePayload(value: unknown): RecipePayload {
if (!isRecord(value)) {
throw new Error("Template payload is invalid JSON object.");
}
const recipeSource = isRecord(value.recipe) ? value.recipe : value;
if (!Array.isArray(recipeSource.columns)) {
throw new Error("Template payload must include recipe.columns.");
}
if (isRecord(value.recipe) && isRecord(value.run) && isRecord(value.ui)) {
return value as unknown as RecipePayload;
}
const recipe: RecipePayload["recipe"] = {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: toRecordArray(recipeSource.model_providers),
// biome-ignore lint/style/useNamingConvention: api schema
mcp_providers: toRecordArray(recipeSource.mcp_providers),
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: toRecordArray(recipeSource.model_configs),
// biome-ignore lint/style/useNamingConvention: api schema
seed_config: isRecord(recipeSource.seed_config)
? recipeSource.seed_config
: undefined,
// biome-ignore lint/style/useNamingConvention: api schema
tool_configs: toRecordArray(recipeSource.tool_configs),
columns: toRecordArray(recipeSource.columns),
processors: toRecordArray(recipeSource.processors),
};
return {
recipe,
run: {
rows: 5,
preview: true,
// biome-ignore lint/style/useNamingConvention: api schema
output_formats: ["jsonl"],
},
ui: {
nodes: [],
edges: [],
},
};
}
async function loadPayloadFromUrl(url: string): Promise<RecipePayload> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch template payload (${response.status})`);
}
const json = (await response.json()) as unknown;
return coerceRecipePayload(json);
}
export type LearningRecipeDef = {
id: string;
title: string;
description: string;
loadPayload: () => Promise<RecipePayload>;
};
export const LEARNING_RECIPES: LearningRecipeDef[] = [
{
id: "structured-outputs-jinja",
title: "Structured Outputs + Jinja Expressions",
description:
"Support ticket triage with structured JSON outputs and Jinja conditionals.",
loadPayload: () => loadPayloadFromUrl(structuredOutputsJinjaUrl),
},
{
id: "pdf-grounded-qa",
title: "PDF Document QA",
description: "Build grounded question-answer examples from PDF chunks.",
loadPayload: () => loadPayloadFromUrl(pdfGroundedQaUrl),
},
{
id: "instruction-from-answer",
title: "Instruction from Answer",
description:
"Use seed answer columns to generate high-quality instruction targets.",
loadPayload: () => loadPayloadFromUrl(instructionFromAnswerUrl),
},
{
id: "text-to-python",
title: "Text to Python",
description:
"Generate instruction-to-code data with category sampling and LLM judging.",
loadPayload: () => loadPayloadFromUrl(textToPythonUrl),
},
{
id: "text-to-sql",
title: "Text to SQL",
description:
"Generate SQL tasks and runnable SQL outputs with prompt-driven generation.",
loadPayload: () => loadPayloadFromUrl(textToSqlUrl),
},
{
id: "conversation",
title: "Multi-Turn Chat",
description:
"Generate realistic user-assistant conversations with structured message output.",
loadPayload: () => loadPayloadFromUrl(conversationUrl),
},
];

View file

@ -0,0 +1,194 @@
{
"recipe": {
"model_providers": [
{
"name": "openai_provider",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"extra_headers": {},
"extra_body": {}
}
],
"mcp_providers": [],
"model_configs": [
{
"alias": "ministral",
"model": "mistralai/ministral-8b-2512",
"provider": "openai_provider",
"inference_parameters": {
"temperature": 0.7,
"max_tokens": 1024
}
}
],
"seed_config": {
"source": {
"seed_type": "hf",
"path": "datasets/unsloth/alpaca-cleaned/**/*.json",
"token": null,
"endpoint": "https://huggingface.co"
},
"sampling_strategy": "ordered",
"selection_strategy": {
"start": 1,
"end": 100
}
},
"tool_configs": [],
"columns": [
{
"column_type": "llm-text",
"name": "generated_instruction",
"drop": false,
"model_alias": "ministral",
"prompt": "Based on this target answer:\n{{ output }}\n\nWrite one high-quality plain text short and brief user instruction that this answer would satisfy.\nReturn only the instruction.",
"with_trace": "none"
}
],
"processors": []
},
"run": {
"rows": 5,
"preview": true,
"output_formats": [
"jsonl"
]
},
"ui": {
"nodes": [
{
"id": "note_1",
"x": -567.3566303099885,
"y": 38.88875727651093,
"width": 400,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "#### Hugginface seed block\nThis recipe uses a ** HuggingFace dataset ** as seed data.\nYou provide dataset identity, load columns, then generate new fields from seed columns.\n\n##### Setup:\n\n1. Paste dataset id as `org/repo` (example: `unsloth/alpaca-cleaned`)\n2. Add token only if dataset is gated/private\n3. Load columns + preview rows so variables are available in prompts\n\n##### Why this matters:\n- Seed columns can drive generation quality\n- You can reference seed values directly in prompts (for example `{{ output }}`)",
"note_color": "#DCFCE7",
"note_opacity": "35"
},
{
"id": "note_2",
"x": -74.04047072330651,
"y": -265.3540670633283,
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "##### Drop columns behavior:\n\n- You can mark specific seed columns to **drop from final output**\n- Those columns are still used during generation\n- They are removed only from exported final dataset\n\n##### Example:\n- Keep `generated_instruction` from llm-text block\n- Drop original `instruction`, `input`, `output` from the hugginface dataset from final artifact\n- Result: clean training output while still using source columns as generation context\n",
"note_color": "#DCFCE7",
"note_opacity": "35"
},
{
"id": "seed",
"x": -76.07288662013991,
"y": 143.39449780463954,
"width": 400
},
{
"id": "openai_provider",
"x": 461.00000000000006,
"y": -489.8750000000001,
"width": 400
},
{
"id": "ministral",
"x": 463.272022949692,
"y": -191.13601147484601,
"width": 400
},
{
"id": "generated_instruction",
"x": 464,
"y": 109.00000000000003,
"width": 400
}
],
"edges": [
{
"from": "seed",
"to": "generated_instruction",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "ministral",
"to": "generated_instruction",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "openai_provider",
"to": "ministral",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "semantic-in-top"
}
],
"layout_direction": "LR",
"seed_source_type": "hf",
"seed_columns": [
"instruction",
"input",
"output"
],
"seed_drop_columns": [],
"seed_preview_rows": [
{
"instruction": "Give three tips for staying healthy.",
"input": "",
"output": "1. Eat a balanced and nutritious diet: Make sure your meals are inclusive of a variety of fruits and vegetables, lean protein, whole grains, and healthy fats. This helps to provide your body with the essential nutrients to function at its best and can help prevent chronic diseases.\n\n2. Engage in regular physical activity: Exercise is crucial for maintaining strong bones, muscles, and cardiovascular health. Aim for at least 150 minutes of moderate aerobic exercise or 75 minutes of vigorous exercise each week.\n\n3. Get enough sleep: Getting enough quality sleep is crucial for physical and mental well-being. It helps to regulate mood, improve cognitive function, and supports healthy growth and immune function. Aim for 7-9 hours of sleep each night."
},
{
"instruction": "What are the three primary colors?",
"input": "",
"output": "The three primary colors are red, blue, and yellow. These colors are called primary because they cannot be created by mixing other colors and all other colors can be made by combining them in various proportions. In the additive color system, used for light, the primary colors are red, green, and blue (RGB)."
},
{
"instruction": "Describe the structure of an atom.",
"input": "",
"output": "An atom is the basic building block of all matter and is made up of three types of particles: protons, neutrons, and electrons. The structure of an atom can be described as a nucleus at the center surrounded by a cloud of electrons.\n\nThe nucleus of an atom is made up of protons and neutrons. Protons are positively charged particles and neutrons are neutral particles with no charge. Both of these particles are located in the nucleus of the atom, which is at the center of the atom and contains most of the atom's mass.\n\nSurrounding the nucleus of the atom is a cloud of electrons. Electrons are negatively charged particles that are in constant motion around the nucleus. The electron cloud is divided into shells or orbitals, and each shell can hold a certain number of electrons. The number of electrons in the outermost shell, called the valence shell, determines the chemical properties of the atom. \n\nIn a neutral atom, the number of protons in the nucleus is equal to the number of electrons in the electron cloud, so the positive and negative charges balance out and the atom has no overall charge. The number of protons, also called the atomic number, determines what element the atom is."
},
{
"instruction": "How can we reduce air pollution?",
"input": "",
"output": "There are several ways to reduce air pollution, including:\n\n1. Reduce energy consumption: By conserving energy, we reduce the amount of pollution emitted from power plants. Some ways to achieve this include: using energy-efficient appliances and lighting, insulating homes, and shutting off lights and electronics when not in use.\n\n2. Use public transportation or carpool: Cars and trucks are major sources of pollution. By using public transportation, carpooling or biking, you can reduce the number of cars on the road, which in turn reduces emissions.\n\n3. Be responsible with waste: When waste is burned or decomposed, they can release harmful gases into the air. Dispose of hazardous waste correctly and recycle materials like plastics, paper, and glass when possible.\n\n4. Support clean energy sources: Renewable energy sources like solar and wind power generate little or no pollution. Support clean energy companies and projects, and consider installing renewable energy systems in your home.\n\n5. Drive efficiently: If you need to drive, make sure your vehicle is well maintained and drive efficiently by avoiding rapid acceleration and braking, and reducing idling.\n\n6. Be mindful of product choices: Some products and practices release more pollution than others. Choose products with eco-friendly packaging and try to minimize your use of aerosol sprays, which can release harmful chemicals into the air."
},
{
"instruction": "Pretend you are a project manager of a construction company. Describe a time when you had to make a difficult decision.",
"input": "",
"output": "I had to make a difficult decision when I was working as a project manager at a construction company. I was in charge of a project that needed to be completed by a certain date in order to meet the clients expectations. However, due to unexpected delays, we were not able to meet the deadline and so I had to make a difficult decision. I decided to extend the deadline, but I had to stretch the teams resources even further and increase the budget. Although it was a risky decision, I ultimately decided to go ahead with it to ensure that the project was completed on time and that the clients expectations were met. The project was eventually successfully completed and this was seen as a testament to my leadership and decision-making abilities."
},
{
"instruction": "Write a concise summary of the following:\n\"Commodore 64 (commonly known as the C64 or CBM 64) was manufactured by Commodore Business Machine (CBM) in August 1982 with a starting price of $595. It was an 8-bit home computer with remarkable market success. Between 1983-1986, C64 sales amounted to about 17 million units sold, making them the best-selling single personal computer model of all time in 1983-1986. \n\nAdditionally, the Commodore 64 dominated the market with between 30% and 40% share and 2 million units sold per year, outselling the IBM PC clones, Apple Computers, and Atari computers. Adding to their success, Sam Tramiel (former Atari president), during an interview in 1989, said they were building 400,000 C64s a month for a couple of years. \"",
"input": "",
"output": "The Commodore 64 was a highly successful 8-bit home computer manufactured by Commodore Business Machine (CBM) in 1982, with sales amounting to approximately 17 million units sold between 1983-1986. It dominated the market with between 30% and 40% share and outsold its competitors, including IBM PC clones, Apple Computers, and Atari computers. At its peak, CBM was building 400,000 C64s a month for a couple of years."
},
{
"instruction": "Explain why the following fraction is equivalent to 1/4",
"input": "4/16",
"output": "The fraction 4/16 is equivalent to 1/4 because both fractions represent the same value. A fraction can be simplified by dividing both the numerator and the denominator by a common factor. In this case, 4 is a common factor of both the numerator and the denominator of 4/16. When we divide both by 4, we get 4/4 = 1 and 16/4 = 4, so the simplified fraction is 1/4. Alternatively, we can think of this in terms of multiplication. For example, if we multiply the numerator and denominator of the fraction 1/4 by 4, we get (1x4)/(4x4), or 4/16. Since both fractions can be derived from the other through multiplication or division by the same number, they represent the same value and are equivalent."
},
{
"instruction": "Write a short story in third person narration about a protagonist who has to make an important career decision.",
"input": "",
"output": "Sophie sat at her desk, staring blankly at the computer screen. Her mind was racing as she weighed the options in front of her. She had been offered a promotion to a senior management position within her company, but it meant moving across the country and leaving her friends and family behind. At the same time, her dream job at a rival company had finally opened up, and she had been invited for an interview. \n\nThe thought of leaving her comfortable life behind, to take a chance on something new and uncertain, made Sophie feel anxious, but also excited. She had always been one to take risks, and it had paid off in the past. But now, with such a big decision to make, Sophie couldn't help but feel overwhelmed.\n\nAfter much deliberation, Sophie made her choice. She packed her bags, said goodbye to her loved ones, and moved across the country for the new job. It was a difficult first few months, adjusting to a new city and a new company culture. But as time went by, Sophie realized that she had made the best decision for her career. The job was challenging, but also rewarding, and she was surrounded by passionate, driven colleagues who inspired her to grow and develop.\n\nIn the end, Sophie's bold move paid off. She was thriving in her new role, and had made new friends and connections in her new city. Looking back, Sophie was grateful for the opportunity to take a risk and make a change, and was confident that she had made the right decision for her career."
},
{
"instruction": "Evaluate this sentence for spelling and grammar mistakes",
"input": "He finnished his meal and left the resturant",
"output": "There are two spelling errors in the sentence. The corrected sentence should be: \"He finished his meal and left the restaurant.\""
},
{
"instruction": "How did Julius Caesar die?",
"input": "",
"output": "Julius Caesar, the Roman Military general, and statesman was assassinated on March 15, 44 BC. He was stabbed to death at a meeting of the Senate in the Theatre of Pompey by a group of senators who called themselves the Liberatores. Led by Gaius Cassius Longinus, and Marcus Junius Brutus, they believed that Caesar's dictatorship was subverting the Roman Republic and sought to restore the traditional republican government. The exact number of assailants is not known, but it is believed that a group of as many as 60 conspirators participated in the assassination, and each of them took turns in stabbing Caesar. The day of his death is still known as the Ides of March."
}
],
"local_file_name": "",
"unstructured_file_name": "",
"unstructured_chunk_size": "1200",
"unstructured_chunk_overlap": "200"
}
}

View file

@ -0,0 +1,197 @@
{
"recipe": {
"model_providers": [
{
"name": "provider_1",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"extra_headers": {},
"extra_body": {}
}
],
"mcp_providers": [],
"model_configs": [
{
"alias": "provider_column",
"model": "mistralai/ministral-8b-2512",
"provider": "provider_1",
"inference_parameters": {
"temperature": 0.7,
"max_tokens": 256
}
}
],
"seed_config": {
"source": {
"seed_type": "local",
"path": "/home/wasim/.cache/unsloth/data-recipe/seed-uploads/dc8ef7c169ed43399b7d37164495150f_50 page sample PDF.indd.jsonl"
},
"sampling_strategy": "ordered",
"selection_strategy": null
},
"tool_configs": [],
"columns": [
{
"column_type": "llm-structured",
"name": "llm_structured_1",
"drop": false,
"model_alias": "provider_column",
"prompt": "Given ONLY this chunk: {{ chunk_text }} generate one answerable question, answer, and exact supporting quote from chunk. If not answerable, skip.",
"output_format": {
"type": "object",
"additionalProperties": false,
"required": [
"question",
"answer",
"evidence_quote"
],
"properties": {
"question": {
"type": "string"
},
"answer": {
"type": "string"
},
"evidence_quote": {
"type": "string"
}
}
}
}
],
"processors": []
},
"run": {
"rows": 5,
"preview": true,
"output_formats": [
"jsonl"
]
},
"ui": {
"nodes": [
{
"id": "note_1",
"x": -180.01113025994076,
"y": 43.27382247773167,
"width": 400,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "This recipe uses **seed data** from external documents.\nInstead of starting from empty generation, we load real source text first.\n\nIn this flow, the seed source is **Unstructured Documents**:\n\n- Upload: `.pdf`, `.docx`, `.txt`\n- Text is extracted and split on client into chunks\n- Each chunk becomes a row-like seed record (`chunk_text`) that you can reference in prompts with `{{ chunk_text }} `",
"note_color": "#F3E8FF",
"note_opacity": "35"
},
{
"id": "note_2",
"x": 283.8131688769869,
"y": -333.10847089567505,
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "##### Chunking settings:\n\n- **Chunk size**: how much text per chunk\n- **Chunk overlap**: shared text between neighboring chunks to preserve context\n\n##### Sampling settings:\n\n- **Ordered**: keep original document order\n- **Shuffle**: randomize chunk order\n- **Selection index / selection settings**: choose which part/subset of seed data to use",
"note_color": "#F3E8FF",
"note_opacity": "35"
},
{
"id": "note_3",
"x": 303.52241671566657,
"y": 299.62272507131615,
"width": 400,
"node_type": "markdown_note",
"name": "note_3",
"markdown": "- LLM prompt: `{{ chunk_text }}`\n- Expression block: combine/format values using `{{ chunk_text }}`\n- Processor templates: use `{{ chunk_text }}` during transforms\n\nTip:\n- Start with medium chunk size + small overlap.\n- Increase overlap only if answers lose context between chunks.",
"note_color": "#F3E8FF",
"note_opacity": "35"
},
{
"id": "seed",
"x": 295.56977201312833,
"y": 108.19964868337735,
"width": 400
},
{
"id": "provider_1",
"x": 960.0115722892822,
"y": -465.06410256410254,
"width": 400
},
{
"id": "provider_column",
"x": 959.90231990232,
"y": -180.56654456654456,
"width": 400
},
{
"id": "llm_structured_1",
"x": 960,
"y": 108.25,
"width": 400
}
],
"edges": [
{
"from": "seed",
"to": "llm_structured_1",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "provider_1",
"to": "provider_column",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "semantic-in-top"
},
{
"from": "provider_column",
"to": "llm_structured_1",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in-top"
}
],
"layout_direction": "LR",
"seed_source_type": "unstructured",
"seed_columns": [
"chunk_text"
],
"seed_drop_columns": [],
"seed_preview_rows": [
{
"chunk_text": "[Citation Needed] The Best of Wikipedias Worst Writing Conor Lastowka and Josh Fruhlinger Boring Legal Fine Print Each entry in this book contains material from Wikipedia, although the text we use may not represent the current version of any article. The URL at the bottom of each page will direct you to the source Wikipedia article; use the articles History tab to find a list of contributors. All material in this book that is taken from Wikipedia is licensed under the Creative Commons-Attribution Share Alike 3.0 license. Heres a quick human-readable summary of your rights to use this content: You are free: to Share—to copy, distribute and transmit the work, and to Remix—to adapt the work Under the following conditions: Attribution—You must attribute the work in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the work.) Share Alike—If you alter, transform, or build upon this work, you may distribute the resulting work only under the same, similar or a compatible license. With the understanding that: Waiver—Any of the above conditions can be waived if you get permission from the copyright holder. Other Rights—In"
},
{
"chunk_text": "only under the same, similar or a compatible license. With the understanding that: Waiver—Any of the above conditions can be waived if you get permission from the copyright holder. Other Rights—In no way are any of the following rights affected by the license: your fair dealing or fair use rights; the authors moral rights; and rights other persons may have either in the work itself or in how the work is used, such as publicity or privacy rights. Notice—For any reuse or distribution, you must make clear to others the license terms of this work. The best way to do this is with a link to: http://creativecommons.org/licenses/by-sa/3.0/ Italicized material beneath each Wikipedia entry is © 2011 Conor Lastowka and Josh Fruhlinger. Copy-edited by Lauren Lastowka Cover design by Jaime Robinson ISBN # 978-1466346987 This book is dedicated to every person who wrote an entry that appears in it. May your citations always be needed. 6 Introduction Wikipedia. Whether youve used it to settle an argument, plagiarized a history report from it, or simply replaced the entire text of the biography of a respected humanitarian with the single word “dogballs,” its an inescapable part of the Internet"
},
{
"chunk_text": "plagiarized a history report from it, or simply replaced the entire text of the biography of a respected humanitarian with the single word “dogballs,” its an inescapable part of the Internet experience. Since its launch in 2001, it has rapidly risen to become the seventh most popular website, with over 365 million readers (Source: Wikipedia). If youre like us, when you want to know the name of the kangaroo on Shirt Tales or just want to confirm that Mother Teresa was a dogballs who helped the farts (Source: Wikipedia), The Encyclopedia That Anyone Can Edit will probably be the first place you check. But heres the thing about letting anybody edit your encyclopedia: it means that anybody can edit your encyclopedia. And while in theory this means that one day Stephen Hawking might decide to weigh in on the entry for string theory, in reality it means that somebody who deeply cares about pro wrestling is going to call someone else a Nazi when they revert his edits about Wrestlemania XI on Razor Ramons page. And so we arrive at a cosmic intersection, where an obscure topic of dubious relevance is written about by the type of weirdo who logs on to Wikipedia to write about obscure"
},
{
"chunk_text": "XI on Razor Ramons page. And so we arrive at a cosmic intersection, where an obscure topic of dubious relevance is written about by the type of weirdo who logs on to Wikipedia to write about obscure topics of dubious relevance. Were these authors re-watching their video of Wrestlemania XI instead of completing basic 8th grade English assignments? Its very likely. Does this 7 stop them from attempting to emulate the academic tone of the great encyclopedias of the past as they describe a large mammalian species from the Star Wars universe that shares a common ancestor with the Wookies? It does not. The result? Some really terrible Wikipedia writing. For the past two years, we have collected this writing on our blog, [Citation Needed]. Fascinated and delighted by the brilliantly bad writing we encountered in our Wikipedia browsing, we set out to curate The Best of Wikipedias Worst Writing. Starting the blog was a no-brainer; our only concern was whether, after a few months of our daily mining, the well of awful Wikipedia writing would eventually run dry. By the time you read this, we will have published our thousandth entry. We started a podcast. Instead of drying up, the ocean of"
},
{
"chunk_text": "mining, the well of awful Wikipedia writing would eventually run dry. By the time you read this, we will have published our thousandth entry. We started a podcast. Instead of drying up, the ocean of ineptitude has proven far more vast than we ever could have imagined. Through our own browsing, and with the help of a dedicated group of readers who are exploring the topics they submit for God knows what reason, weve continually lowered and re-lowered the bar for bad Wikipedia writing. Now, lets get one thing straight: we love each and every entry written in this book. If you are one of the authors who have chosen to use your valuable time on this planet to write straight-faced exegeses on the subject of forgotten action figures from the seventies, we hope you dont take offense. And if you do, we have an acceptable retort prepared for you: “You guys ran a blog about Wikipedia for two years, who the hell are you to talk?” Feel free to use it! Others may criticize us for not doing our part to help Wikipedia become “better” by revising these passages. Nothing that does not involve electrodes near our genitals would make us more miserable. In our opinion, many of the passages in this"
},
{
"chunk_text": "to help Wikipedia become “better” by revising these passages. Nothing that does not involve electrodes near our genitals would make us more miserable. In our opinion, many of the passages in this book stand alone as works of art. Think of us as photographers preserving the memory of the great street art of the world before the joyless police come and whitewash over it. (Is that an official police responsibility? It seems beneath them. If its not, but theyre still forced to do it, that might explain the joylessness.) The point is, if youre moved to correct these entries, were powerless to stop you. Theyve already given us joy, and were just happy to have encountered them. Enough introduction. Here are over two hundred of our favorite bad Wikipedia articles of all time. Comments in italics are ours. Everything else is a faithful reproduction of the way the entry stood at the moment we or our informants encountered it. We hope you will laugh, cry, maybe even learn something, and always remember to dogballs. —Conor Lastowka & Josh Fruhlinger citationneeded.tumblr.com 8 9 In barely one decade, Jimmy Wales has succeeded in establishing a worldwide network of knowledge. Wikipedia,"
},
{
"chunk_text": "remember to dogballs. —Conor Lastowka & Josh Fruhlinger citationneeded.tumblr.com 8 9 In barely one decade, Jimmy Wales has succeeded in establishing a worldwide network of knowledge. Wikipedia, his online encyclopaedia, accessible on the Internet for free, has become a symbol of a radical change in the media economy. Moreover, it revolutionized the access to knowledge as mans most important resource and thus contributed to democratizing knowledge. The Gottlieb Duttweiler Institute, awarding the 2011 Gottlieb Duttweiler Prize to Wikipedia founder Jimmy Wales I saw the Beavis and Butt-Head episode that had Hogans “Real American” music on there. I dont quite remembering it being critiqued by Beavis and Butt-Head. They sounded more like they liked the music and I dont really remember any criticism of it (except for when it was going, when Butt-Head said “homework sucks”, but Im not quite sure if he was referring to music or not). Wikipedia discussion page for Hulk Hogan 10 11 http://en.wikipedia.org/wiki/Polybius_(video_game) Want a Citation for this one? Please see the following 206 pages. The Roach story contained a number of inconsistencies: some of it seems to be directly"
},
{
"chunk_text": "Want a Citation for this one? Please see the following 206 pages. The Roach story contained a number of inconsistencies: some of it seems to be directly sourced from Wikipedia- all in all, an entirely untrustworthy source. Polybius (video game) 12 http://en.wikipedia.org/wiki/General_Mills_monster-themed_breakfast_cereals You can imagine the marketing team having their first meeting after the cereals release. “We have good news and bad news. The good news is, your latest cereal is very, very popular. The bad news is, its not in any way due to the character you came up with, the box design you slaved over, the costly ad campaign, or the hours you put in coming up with free toy ideas. Gentlemen, you should probably sit down....” Franken Berry was very popular when first introduced possibly because the initial batches of the cereal used a dye that didnt break down in the body, causing many childrens feces to be bright pink, a symptom sometimes referred to as “Frankenberry Stool.” General Mills monster-themed breakfast cereals 13 http://en.wikipedia.org/wiki/Skiffle Because if your encyclopedia cant provide you with an unsourced claim that it admits is only one of several"
},
{
"chunk_text": "General Mills monster-themed breakfast cereals 13 http://en.wikipedia.org/wiki/Skiffle Because if your encyclopedia cant provide you with an unsourced claim that it admits is only one of several theories put forth about the subject, and then go on to inform you in the very same sentence that other unidentified parties disagree with that claim, then what the hell good is it? Skiffle is often said to have developed from New Orleans jazz, but this has been disputed. Skiffle 14 http://en.wikipedia.org/wiki/House_Party_%28film%29 “HP4: Coda or Mistake?” was by far the most contentious panel at HoPaCon 2009, with impassioned arguments echoing through the halls of the Kansas City International Airport Days Inn. In 2001, Immature (now going by IMx) starred in a direct-to-video sequel, House Party 4: Down to the Last Minute, which does not feature Kid or Play. The film is not considered a part of the House Party canon amongst fans. [citation needed] House Party (film) 15 http://en.wikipedia.org/wiki/Inglewood,_California So it would appear that, due to the presence of the anti- drug organization D.A.R.E. in Inglewood, there are in fact parts of Inglewood that are attempting to do good."
},
{
"chunk_text": "So it would appear that, due to the presence of the anti- drug organization D.A.R.E. in Inglewood, there are in fact parts of Inglewood that are attempting to do good. Thus the claim that Inglewood is “always” up to no good can be assumed to be false, or at the very least a gross exaggeration. Also, many have speculated that the so-called “Doctor” Dre never actually received a PhD. D.A.R.E. America has its headquarters in Inglewood. Despite this, in the 1996 rap hit “California Love”, Dr. Dre remarks that Inglewood is “always up to no good”. Inglewood, California 16 http://en.wikipedia.org/wiki/Tyler_Perry People who feel that Tyler Perrys 65” stature is severely diminished by his wearing a wig? The line in the sand has been drawn. Another comical aspect is provided by Perrys 6- 5” stature, which is in no way diminished by his wearing a wig. Tyler Perry 17 http://en.wikipedia.org/wiki/Bondage_bed Do not attempt to affix your bondage partner to this question mark using chains and shackles! It is purely metaphorical! It is possible to buy inflatable bondage beds; however, a question mark must remain over how effective they are. Bondage bed 18"
}
],
"local_file_name": "",
"unstructured_file_name": "50 page sample PDF.indd.pdf",
"unstructured_chunk_size": "1200",
"unstructured_chunk_overlap": "200"
}
}

View file

@ -0,0 +1,362 @@
{
"recipe": {
"model_providers": [
{
"name": "provider_column",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"extra_headers": {},
"extra_body": {}
}
],
"mcp_providers": [],
"model_configs": [
{
"alias": "ministral",
"model": "mistralai/ministral-8b-2512",
"provider": "provider_column",
"inference_parameters": {
"temperature": 0.7,
"max_tokens": 256
}
}
],
"tool_configs": [],
"columns": [
{
"column_type": "sampler",
"name": "user",
"drop": true,
"sampler_type": "person_from_faker",
"params": {}
},
{
"column_type": "sampler",
"name": "platform",
"drop": false,
"sampler_type": "category",
"params": {
"values": [
"web",
"mobile",
"cli"
]
}
},
{
"column_type": "sampler",
"name": "impact_scope",
"drop": false,
"sampler_type": "category",
"params": {
"values": [
"single_user",
"team",
"org_wide"
]
}
},
{
"column_type": "expression",
"name": "user_first_name",
"drop": false,
"expr": "{{ user.first_name }}",
"dtype": "str"
},
{
"column_type": "expression",
"name": "user_full_name",
"drop": false,
"expr": "{{ user.first_name }} {{ user.last_name }}",
"dtype": "str"
},
{
"column_type": "llm-structured",
"name": "ticket",
"drop": false,
"model_alias": "ministral",
"prompt": "Create a realistic support ticket from {{ user_full_name }} using the {{ platform }} platform. Impact scope is {{ impact_scope }}.\n",
"output_format": {
"type": "object",
"additionalProperties": false,
"required": [
"issue_title",
"issue_summary",
"category",
"priority"
],
"properties": {
"issue_title": {
"type": "string",
"description": "Short title of issue"
},
"issue_summary": {
"type": "string",
"description": "1-2 sentence summary"
},
"category": {
"type": "string",
"enum": [
"account",
"billing",
"api",
"infra"
],
"description": "Issue category"
},
"priority": {
"type": "string",
"enum": [
"P1",
"P2",
"P3"
],
"description": "Urgency level"
}
}
}
},
{
"column_type": "expression",
"name": "sla_target",
"drop": false,
"expr": "{% if impact_scope == 'org_wide' %}15m\n{% elif impact_scope == 'team' %}1h\n{% else %}4h\n{% endif %}",
"dtype": "str"
},
{
"column_type": "llm-structured",
"name": "agent_reply",
"drop": false,
"model_alias": "ministral",
"prompt": "Write a concise support reply for ticket '{{ ticket.issue_title }}'. Category: {{ ticket.category }}. Priority: {{ ticket.priority }}. SLA target: {{ sla_target }}. {% if ticket.priority == 'P1' %}Tone must be urgent and action-first.{% else %}Tone must be calm and instructional.{% endif %}",
"output_format": {
"type": "object",
"additionalProperties": false,
"required": [
"response",
"next_action"
],
"properties": {
"response": {
"type": "string",
"description": "Support response to user"
},
"next_action": {
"type": "string",
"enum": [
"ask_logs",
"reset_credentials",
"escalate",
"provide_steps"
],
"description": "Primary next action"
}
}
}
}
],
"processors": []
},
"run": {
"rows": 5,
"preview": true,
"output_formats": [
"jsonl"
]
},
"ui": {
"nodes": [
{
"id": "note_1",
"x": 1084.767431711644,
"y": -293.4482850247655,
"width": 782,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "## Expression columns \nAre like lightweight spreadsheet formulas.\nUse them when you want to transform existing columns quickly, without calling an LLM.\n\n### What you can do:\n\n- Use values from other columns: `{{ first_name }} {{ last_name }}`\n- Clean/format text: `{{ city | upper }}`, `{{ product_name | trim }}`\n- Conditional logic:\n - `{% if order_total >= 100 %}VIP{% elif order_total >= 50 %}Standard{% else %}Starter{% endif %}`\n- Simple math:\n - `{{ quantity * unit_price }}`\n - `{{ (subtotal - discount) | round(2) }}`\n\n### Good rule:\n- If the value can be computed from existing data, use Expression first.\n- Use LLM only when you need true language generation.",
"note_color": "#CFFAFE",
"note_opacity": "35"
},
{
"id": "note_2",
"x": 1944,
"y": 760.9999999999999,
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "### LLM Structured block\nGenerates JSON that matches your Output Format schema.\nThink of Output Format as a contract for what the model must return.\n\n#### Prompt tips:\n\n- Reference existing columns with Jinja: `{{ column_name }}`\n- You can reference nested values too: `{{ customer.first_name }}`\n- Be explicit about what each field should contain.\n\n#### Example prompt pattern:\n\n```text\nCreate a support ticket summary.\nCustomer: {{ customer_name }}\nIssue text: {{ issue_text }}\n\nReturn data for:\n- priority\n- short_title\n- resolution_steps\n```",
"note_color": "#CFFAFE",
"note_opacity": "35"
},
{
"id": "note_3",
"x": 2381.178207301403,
"y": 790.2196835690842,
"width": 638,
"node_type": "markdown_note",
"name": "note_3",
"markdown": "## Example output format shape (concept):\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"priority\": { \"type\": \"string\" },\n \"short_title\": { \"type\": \"string\" },\n \"resolution_steps\": { \"type\": \"array\", \"items\": { \"type\": \"string\" } }\n },\n \"required\": [\"priority\", \"short_title\", \"resolution_steps\"]\n}\n```",
"note_color": "#CFFAFE",
"note_opacity": "35"
},
{
"id": "note_4",
"x": 2405.796928768747,
"y": -362.26583299682716,
"width": 399,
"node_type": "markdown_note",
"name": "note_4",
"markdown": "### Model provider & Config\nEvery LLM block needs a model alias.\nThat alias comes from a Model Config.\nModel Config points to a Model Provider.\n\n#### Minimum setup:\n\n1. Create **Model Provider**\n - Set endpoint/provider type\n - Prefer env var auth (`api_key_env`) over hardcoded keys\n\n2. Create **Model Config**\n - Set alias (example: `model_1`)\n - Set model id\n - Link to provider\n - Tune params (temperature, max_tokens, etc.)\n\n3. In each LLM block\n - Set `model_alias` to that alias\n\nIf alias/provider link is missing, validation/run will fail.",
"note_color": "#CFFAFE",
"note_opacity": "35"
},
{
"id": "provider_column",
"x": 1947.2039072039072,
"y": 32.08363858363858,
"width": 400
},
{
"id": "ministral",
"x": 1947.0573870573871,
"y": 271.94139194139194,
"width": 400
},
{
"id": "user",
"x": 0,
"y": 656.5,
"width": 400
},
{
"id": "platform",
"x": 480,
"y": 656.5,
"width": 400
},
{
"id": "impact_scope",
"x": 960,
"y": 656.5,
"width": 400
},
{
"id": "user_first_name",
"x": 1440,
"y": 895,
"width": 400
},
{
"id": "user_full_name",
"x": 1440,
"y": 269,
"width": 400
},
{
"id": "ticket",
"x": 1946.9108669108673,
"y": 657.2161172161173,
"width": 400
},
{
"id": "sla_target",
"x": 1440,
"y": 582,
"width": 400
},
{
"id": "agent_reply",
"x": 2384.5665445665445,
"y": 657.449938949939,
"width": 400
}
],
"edges": [
{
"from": "platform",
"to": "impact_scope",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "user",
"to": "user_first_name",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "user_full_name",
"to": "ticket",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "user",
"to": "platform",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "user",
"to": "user_full_name",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "user_first_name",
"to": "ticket",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "impact_scope",
"to": "sla_target",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "sla_target",
"to": "ticket",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "ticket",
"to": "agent_reply",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "provider_column",
"to": "ministral",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "semantic-in-top"
},
{
"from": "ministral",
"to": "ticket",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "ministral",
"to": "agent_reply",
"type": "semantic",
"source_handle": "semantic-out",
"target_handle": "data-in-top"
}
],
"layout_direction": "LR"
}
}

View file

@ -0,0 +1,237 @@
{
"recipe": {
"model_providers": [
{
"name": "provider_1",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"extra_headers": {},
"extra_body": {}
}
],
"mcp_providers": [],
"model_configs": [
{
"alias": "model_1",
"model": "mistralai/ministral-8b-2512",
"provider": "provider_1",
"inference_parameters": {
"temperature": 0.7,
"max_tokens": 2048
}
}
],
"tool_configs": [],
"columns": [
{
"column_type": "sampler",
"name": "domain",
"drop": false,
"sampler_type": "category",
"params": {
"values": [
"Data Processing",
"Web API",
"Automation"
]
}
},
{
"column_type": "sampler",
"name": "task_type",
"drop": false,
"sampler_type": "subcategory",
"params": {
"category": "domain",
"values": {
"Data Processing": [
"CSV cleaning",
"JSON transform",
"deduplicate rows"
],
"Web API": [
"GET endpoint",
"POST validation",
"pagination helper"
],
"Automation": [
"file organizer",
"log parser",
"daily report script"
]
}
}
},
{
"column_type": "llm-text",
"name": "instruction",
"drop": false,
"model_alias": "model_1",
"prompt": "Write one clear Python coding instruction.\nDomain: {{ domain }}\nTask type: {{ task_type }}\n\nKeep it practical and specific.\nReturn only the instruction without any code.",
"with_trace": "none"
},
{
"column_type": "llm-code",
"name": "code_implementation",
"drop": false,
"model_alias": "model_1",
"prompt": "Write Python code for:\n{{ instruction }}\n\nRequirements:\n- runnable script or function\n- include needed imports\n- short comments only where useful\n- no markdown fences",
"code_lang": "python"
},
{
"column_type": "llm-judge",
"name": "code_judge_result",
"drop": false,
"model_alias": "model_1",
"prompt": "Evaluate generated Python code against the instruction.\n\nInstruction:\n{{ instruction }}\n\nCode:\n{{ code_implementation }}",
"scores": [
{
"name": "Correctness",
"description": "Follows instruction and is executable",
"options": {
"0": "bad",
"1": "partial",
"2": "good",
"3": "excellent"
}
}
]
}
],
"processors": []
},
"run": {
"rows": 5,
"preview": true,
"output_formats": [
"jsonl"
]
},
"ui": {
"nodes": [
{
"id": "provider_1",
"x": 1032.6798211423347,
"y": -450.4885376732656,
"width": 400
},
{
"id": "model_1",
"x": 1538.0273166472973,
"y": -483.2003290046642,
"width": 400
},
{
"id": "domain",
"x": 0,
"y": 24,
"width": 400
},
{
"id": "task_type",
"x": 480,
"y": 24,
"width": 400
},
{
"id": "instruction",
"x": 958.8989453654599,
"y": -9.971266983459952,
"width": 400
},
{
"id": "code_implementation",
"x": 1538.788058529745,
"y": -45.56493974435071,
"width": 400
},
{
"id": "code_judge_result",
"x": 2040.9251520522098,
"y": -13.362336454344792,
"width": 400
},
{
"id": "note_1",
"x": 1482.1328175027095,
"y": 242.4370179053253,
"width": 568,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "The **LLM Code** block is where Python code is generated from your instruction/prompt.\n\n##### How it works in this recipe:\n\n- You provide a clear prompt (often using Jinja references from earlier columns)\n- The model returns a response\n- The block extracts code content directly for the output column\n\n##### Current status:\n\n- We are **not** running Python lint/syntax validation in this recipe yet (Soon)\n- Validation support is planned and will be added\n\n##### What this means:\n\n- You may get mostly correct code, but some rows can still have syntax/style issues\n- Keep prompts specific and constrained to reduce bad outputs\n\n##### Tip:\n\n- Ask for one self-contained function/script\n- Ask for required imports\n- Ask for no markdown fences if you want cleaner extraction\n",
"note_color": "#FEF3C7",
"note_opacity": "35"
},
{
"id": "note_2",
"x": 2513.2527820497985,
"y": -235.2544980991115,
"width": 471,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "The **LLM Judge** block evaluates generated outputs with rubric-style scores.\n\n##### Important:\n\n- A judge can have **one or many scores**\n- Each score has:\n - a name (for example: `Correctness`)\n - a description\n - options (value + meaning)\n\n##### Example multi-score setup:\n\n- Correctness\n- Readability\n- Efficiency\n\n##### Why use multiple scores:\n\n- You get richer quality signals than a single pass/fail\n- Easier filtering and weighting later in training data prep\n\n##### Practical pattern:\n\n1. Generate code with LLM Code\n2. Judge with 2-4 focused scores\n3. Keep high-quality rows based on score thresholds\n",
"note_color": "#FEF3C7",
"note_opacity": "35"
}
],
"edges": [
{
"from": "domain",
"to": "task_type",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "task_type",
"to": "instruction",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "provider_1",
"to": "model_1",
"type": "semantic",
"source_handle": "semantic-out",
"target_handle": "semantic-in"
},
{
"from": "instruction",
"to": "code_implementation",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "model_1",
"to": "instruction",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "model_1",
"to": "code_implementation",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "code_implementation",
"to": "code_judge_result",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "model_1",
"to": "code_judge_result",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in-top"
}
],
"layout_direction": "LR"
}
}

View file

@ -0,0 +1,275 @@
{
"recipe": {
"model_providers": [
{
"name": "provider_1",
"endpoint": "https://openrouter.ai/api/v1",
"provider_type": "openai",
"extra_headers": {},
"extra_body": {}
}
],
"mcp_providers": [],
"model_configs": [
{
"alias": "model_1",
"model": "mistralai/ministral-8b-2512",
"provider": "provider_1",
"inference_parameters": {
"temperature": 0.7,
"max_tokens": 2048
}
}
],
"tool_configs": [],
"columns": [
{
"column_type": "sampler",
"name": "domain",
"drop": true,
"sampler_type": "category",
"params": {
"values": [
"Ecommerce",
"Customer Support",
"Finance"
]
}
},
{
"column_type": "sampler",
"name": "topic",
"drop": true,
"sampler_type": "subcategory",
"params": {
"category": "domain",
"values": {
"Ecommerce": [
"Orders and Revenue",
"Returns and Refunds",
"Product Performance"
],
"Customer Support": [
"Ticket Resolution",
"SLA Compliance",
"Agent Productivity"
],
"Finance": [
"Invoices and Payments",
"Subscription Churn",
"Monthly Cashflow"
]
}
}
},
{
"column_type": "sampler",
"name": "sql_task_type",
"drop": true,
"sampler_type": "category",
"params": {
"values": [
"Filtering",
"Aggregation",
"Join Analysis",
"Trend Reporting"
]
}
},
{
"column_type": "sampler",
"name": "instruction_phrase",
"drop": true,
"sampler_type": "category",
"params": {
"values": [
"Write a SQL query that",
"Create a SQL statement to",
"Develop a SQL query to"
]
}
},
{
"column_type": "llm-text",
"name": "sql_prompt",
"drop": false,
"model_alias": "model_1",
"prompt": "Generate one natural-language SQL task.\nContext:\n- Domain: {{ domain }}\n- Topic: {{ topic }}\n- Task type: {{ sql_task_type }}\nRules:\n- Must start exactly with: \"{{ instruction_phrase }}\"\n- Make it specific and practical.\n- Mention expected business outcome.\n- Keep it 1-2 sentences.\n- Do not include SQL code.\n- Output only the instruction text.",
"system_prompt": "You create clear, realistic business SQL tasks for training data.\n",
"with_trace": "none"
},
{
"column_type": "llm-code",
"name": "sql",
"drop": false,
"model_alias": "model_1",
"prompt": "Write SQL for this instruction:\n{{ sql_prompt }}\nReturn ONE SQL script with this exact structure:\n-- SCHEMA\n[CREATE TABLE statements]\n[INSERT statements with sample rows]\n-- QUERY\n[final SELECT query solving the instruction]\nRules:\n- Use 2-3 tables max.\n- Use realistic snake_case names.\n- Include 5-8 rows of sample data per table.\n- Query must match task type \"{{ sql_task_type }}\".\n- Use only tables/columns you created.\n- No markdown fences.\n- No explanation text outside SQL comments shown above.",
"system_prompt": "You are an expert SQL engineer. Produce correct, runnable SQL only.\n",
"code_lang": "sql:ansi"
}
],
"processors": []
},
"run": {
"rows": 5,
"preview": true,
"output_formats": [
"jsonl"
]
},
"ui": {
"nodes": [
{
"id": "provider_1",
"x": -1092.2003193114556,
"y": 715.157165665104,
"width": 400
},
{
"id": "model_1",
"x": -546.1001596557278,
"y": 681.8114012018752,
"width": 400
},
{
"id": "domain",
"x": -18.379173679952572,
"y": 137.70260329000595,
"width": 400
},
{
"id": "topic",
"x": -18.6022437080035,
"y": 373.0271253737222,
"width": 400
},
{
"id": "sql_task_type",
"x": 477.85851567876665,
"y": 137.4202046707293,
"width": 400
},
{
"id": "instruction_phrase",
"x": -477.8585156787667,
"y": 138.42770371608808,
"width": 400
},
{
"id": "sql_prompt",
"x": -18.188598798124787,
"y": 701.5157165665104,
"width": 400
},
{
"id": "sql",
"x": -18.188598798124758,
"y": 950.647309355259,
"width": 400
},
{
"id": "note_1",
"x": -103.00586025666547,
"y": -332.088439142397,
"width": 600,
"node_type": "markdown_note",
"name": "note_1",
"markdown": "##### This recipe starts with **sampler columns** to create controlled SQL task context:\n\n- `domain`\n- `topic` (subcategory from `domain`)\n- `sql_task_type`\n- `instruction_phrase`\n\n##### Why this is useful:\n\n- You get diverse tasks without writing every prompt by hand\n- You can steer business context + task pattern in a predictable way\n- LLM prompts become cleaner because context is already structured",
"note_color": "#DBEAFE",
"note_opacity": "35"
},
{
"id": "note_2",
"x": 517.0372102151987,
"y": 600.4949327304814,
"width": 400,
"node_type": "markdown_note",
"name": "note_2",
"markdown": "The **LLM Text** block (`sql_prompt`) turns sampler context into one clean natural-language SQL task.\n\n##### Prompt pattern in this recipe:\n\n- references prior columns with Jinja (`{{ domain }}`, `{{ topic }}`, etc.)\n- enforces start phrase with `{{ instruction_phrase }}`\n- returns instruction text only (no SQL yet)\n\n##### Tip:\n\n- Keep this instruction block concise and specific\n- Save implementation details for the next SQL generation block",
"note_color": "#DBEAFE",
"note_opacity": "35"
},
{
"id": "note_3",
"x": 12.635681904967385,
"y": 1224.7626182706356,
"width": 400,
"node_type": "markdown_note",
"name": "note_3",
"markdown": "The **LLM Code** block (`sql`) generates SQL script from `{{ sql_prompt }}`.\n\n##### In this recipe it returns:\n\n- schema section (`CREATE TABLE`)\n- sample seed rows (`INSERT`)\n- final query (`SELECT`)\n\n##### Current status:\n\n- SQL validation block is **not** included yet in this learning recipe\n- We will add SQL validation later",
"note_color": "#DBEAFE",
"note_opacity": "35"
},
{
"id": "note_4",
"x": -1044,
"y": 108.64730935525904,
"width": 400,
"node_type": "markdown_note",
"name": "note_4",
"markdown": "Sampler columns are useful during generation, but often noisy in final output.\n\nSet helper columns to **drop=true** (like in this recipe), keep only output columns you want to export.\n\n#### Final keep we have set here:\n\n- `sql_prompt`\n- `sql`\n\n",
"note_color": "#DBEAFE",
"note_opacity": "35"
}
],
"edges": [
{
"from": "domain",
"to": "topic",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "domain",
"to": "sql_task_type",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "instruction_phrase",
"to": "domain",
"type": "canvas",
"source_handle": "data-out",
"target_handle": "data-in"
},
{
"from": "topic",
"to": "sql_prompt",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "sql_prompt",
"to": "sql",
"type": "canvas",
"source_handle": "data-out-bottom",
"target_handle": "data-in-top"
},
{
"from": "provider_1",
"to": "model_1",
"type": "semantic",
"source_handle": "semantic-out",
"target_handle": "semantic-in"
},
{
"from": "model_1",
"to": "sql_prompt",
"type": "semantic",
"source_handle": "semantic-out",
"target_handle": "data-in"
},
{
"from": "model_1",
"to": "sql",
"type": "semantic",
"source_handle": "semantic-out-bottom",
"target_handle": "data-in"
}
],
"layout_direction": "LR"
}
}

View file

@ -0,0 +1,498 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { ShineBorder } from "@/components/ui/shine-border";
import { toastError } from "@/shared/toast";
import {
AiChat02Icon,
ArrowDown01Icon,
CodeIcon,
CookBookIcon,
Database02Icon,
Delete02Icon,
DocumentAttachmentIcon,
FunctionIcon,
Plant01Icon,
PlusSignIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useNavigate } from "@tanstack/react-router";
import type { ReactElement } from "react";
import { useState } from "react";
import {
createRecipeDraft,
createRecipeFromLearningRecipe,
deleteRecipe,
useRecipes,
} from "../data/recipes-db";
import { LEARNING_RECIPES } from "../learning-recipes";
type TemplateCard = {
title: string;
description: string;
icon: typeof CookBookIcon;
difficulty: "Easy" | "Starter" | "Intermediate" | "Advanced";
learningBadges: string[];
surfaceClassName: string;
shineColor: string[];
learningRecipeId?: string;
};
const TEMPLATE_CARDS: TemplateCard[] = [
{
title: "Structured Outputs + Jinja Expressions",
description:
"Support ticket triage dataset with structured JSON outputs and Jinja if/else refs.",
icon: FunctionIcon,
difficulty: "Advanced",
learningBadges: ["Structured LLM", "Expression", "Jinja"],
surfaceClassName:
"from-cyan-500/15 via-sky-500/5 to-transparent dark:from-cyan-400/30 dark:via-sky-400/14 dark:to-cyan-950/16",
shineColor: [
"rgb(6 182 212 / 0.45)",
"rgb(56 189 248 / 0.4)",
"rgb(34 211 238 / 0.45)",
],
learningRecipeId: "structured-outputs-jinja",
},
{
title: "PDF Document QA",
description:
"Unstructured PDF chunks transformed into grounded question-answer training pairs.",
icon: DocumentAttachmentIcon,
difficulty: "Easy",
learningBadges: ["Unstructured", "LLM Text"],
surfaceClassName:
"from-violet-500/15 via-fuchsia-500/5 to-transparent dark:from-violet-400/30 dark:via-fuchsia-400/14 dark:to-violet-950/16",
shineColor: [
"rgb(139 92 246 / 0.45)",
"rgb(217 70 239 / 0.4)",
"rgb(168 85 247 / 0.45)",
],
learningRecipeId: "pdf-grounded-qa",
},
{
title: "Instruction from Answer",
description:
"Start from seed answer fields and generate matching user instructions for SFT pairs.",
icon: Plant01Icon,
difficulty: "Easy",
learningBadges: ["Seed Dataset", "LLM Text", "Prompting"],
surfaceClassName:
"from-emerald-500/15 via-green-500/5 to-transparent dark:from-emerald-400/30 dark:via-green-400/14 dark:to-emerald-950/16",
shineColor: [
"rgb(16 185 129 / 0.45)",
"rgb(34 197 94 / 0.4)",
"rgb(52 211 153 / 0.45)",
],
learningRecipeId: "instruction-from-answer",
},
{
title: "Text to Python",
description:
"Instruction-to-code pairs for training models that generate clean Python implementations.",
icon: CodeIcon,
difficulty: "Intermediate",
learningBadges: ["LLM Judge", "LLM Code", "Subcategory", "Category"],
surfaceClassName:
"from-amber-500/15 via-orange-500/5 to-transparent dark:from-amber-400/30 dark:via-orange-400/14 dark:to-amber-950/16",
shineColor: [
"rgb(245 158 11 / 0.45)",
"rgb(249 115 22 / 0.4)",
"rgb(251 146 60 / 0.45)",
],
learningRecipeId: "text-to-python",
},
{
title: "Text to SQL",
description:
"Natural language to SQL pairs, including schema-aware query construction patterns.",
icon: Database02Icon,
difficulty: "Intermediate",
learningBadges: ["LLM Code", "Prompting", "Drop Columns"],
surfaceClassName:
"from-blue-500/15 via-indigo-500/5 to-transparent dark:from-blue-400/30 dark:via-indigo-400/14 dark:to-blue-950/16",
shineColor: [
"rgb(59 130 246 / 0.45)",
"rgb(99 102 241 / 0.4)",
"rgb(96 165 250 / 0.45)",
],
learningRecipeId: "text-to-sql",
},
{
title: "Multi-Turn Chat",
description:
"Role-based multi-turn conversations for assistant behavior, memory, and response quality.",
icon: AiChat02Icon,
difficulty: "Easy",
learningBadges: ["Structured LLM", "LLM Text"],
surfaceClassName:
"from-rose-500/15 via-pink-500/5 to-transparent dark:from-rose-400/30 dark:via-pink-400/14 dark:to-rose-950/16",
shineColor: [
"rgb(244 63 94 / 0.45)",
"rgb(236 72 153 / 0.4)",
"rgb(251 113 133 / 0.45)",
],
learningRecipeId: "conversation",
},
];
const LEARNING_RECIPE_BY_ID = new Map(
LEARNING_RECIPES.map((recipe) => [recipe.id, recipe]),
);
function formatRelativeTime(value: number): string {
const now = Date.now();
const diffMs = Math.max(0, now - value);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const week = 7 * day;
if (diffMs < minute) {
return "just now";
}
if (diffMs < hour) {
const minutes = Math.floor(diffMs / minute);
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
}
if (diffMs < day) {
const hours = Math.floor(diffMs / hour);
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
}
if (diffMs < week) {
const days = Math.floor(diffMs / day);
return `${days} day${days === 1 ? "" : "s"} ago`;
}
const weeks = Math.floor(diffMs / week);
return `${weeks} week${weeks === 1 ? "" : "s"} ago`;
}
function LearningRecipeCards({
onSelect,
loadingTemplateId,
}: {
onSelect: (template: TemplateCard) => void;
loadingTemplateId: string | null;
}): ReactElement {
return (
<div className="grid w-full gap-4 sm:grid-cols-2 xl:grid-cols-3">
{TEMPLATE_CARDS.map((template) => {
const learningRecipe = template.learningRecipeId
? LEARNING_RECIPE_BY_ID.get(template.learningRecipeId)
: undefined;
const isReady = Boolean(learningRecipe);
const isLoading =
template.learningRecipeId !== undefined &&
loadingTemplateId === template.learningRecipeId;
const isDisabled = !isReady || isLoading || Boolean(loadingTemplateId);
const visibleLearningBadges = template.learningBadges.slice(0, 4);
const extraLearningBadgeCount = Math.max(0, template.learningBadges.length - 4);
return (
<button
key={template.title}
type="button"
disabled={isDisabled}
onClick={() => onSelect(template)}
className={`group shadow-border relative overflow-hidden rounded-2xl bg-gradient-to-br text-left transition-transform ${template.surfaceClassName} enabled:cursor-pointer enabled:hover:-translate-y-0.5 enabled:hover:shadow-md disabled:cursor-not-allowed disabled:opacity-70`}
>
<ShineBorder
borderWidth={1.2}
duration={13}
shineColor={template.shineColor}
/>
<div className="relative flex h-full min-h-40 flex-col justify-between gap-3 p-4">
<Badge
className="absolute right-3 top-3"
variant={
template.difficulty === "Advanced" ? "secondary" : "outline"
}
>
{template.difficulty}
</Badge>
<div className="inline-flex size-10 items-center justify-center rounded-xl border border-foreground/10 bg-background/80">
<HugeiconsIcon
icon={template.icon}
className="size-5 text-foreground/90"
/>
</div>
<div className="space-y-1">
<p className="line-clamp-2 text-sm font-semibold leading-tight text-foreground">
{template.title}
</p>
<p className="line-clamp-2 text-xs text-muted-foreground">
{template.description}
</p>
</div>
<div className="flex items-center gap-1 overflow-hidden whitespace-nowrap">
{isLoading ? (
<Badge variant="outline">Loading...</Badge>
) : (
<>
{visibleLearningBadges.map((badge) => (
<Badge
key={`${template.title}-${badge}`}
variant="outline"
className="h-5 shrink-0 px-1.5 text-[10px]"
>
{badge}
</Badge>
))}
{extraLearningBadgeCount > 0 ? (
<Badge variant="outline" className="h-5 shrink-0 px-1.5 text-[10px]">
+{extraLearningBadgeCount}
</Badge>
) : null}
{!isReady ? (
<Badge variant="secondary" className="h-5 shrink-0 px-1.5 text-[10px]">
Soon
</Badge>
) : null}
</>
)}
</div>
</div>
</button>
);
})}
</div>
);
}
export function DataRecipesPage(): ReactElement {
const navigate = useNavigate();
const recipes = useRecipes();
const [creatingRecipe, setCreatingRecipe] = useState(false);
const [learningDialogOpen, setLearningDialogOpen] = useState(false);
const [loadingTemplateId, setLoadingTemplateId] = useState<string | null>(
null,
);
async function openNewRecipe(): Promise<void> {
if (creatingRecipe || loadingTemplateId) {
return;
}
setCreatingRecipe(true);
try {
const recipe = await createRecipeDraft();
await navigate({
to: "/data-recipes/$recipeId",
params: { recipeId: recipe.id },
});
} finally {
setCreatingRecipe(false);
}
}
async function openLearningRecipe(template: TemplateCard): Promise<void> {
if (creatingRecipe || loadingTemplateId) {
return;
}
if (!template.learningRecipeId) {
toastError("Learning recipe not ready yet.");
return;
}
const recipeTemplate = LEARNING_RECIPE_BY_ID.get(template.learningRecipeId);
if (!recipeTemplate) {
toastError("Learning recipe not found.");
return;
}
setLoadingTemplateId(template.learningRecipeId);
try {
const payload = await recipeTemplate.loadPayload();
const recipe = await createRecipeFromLearningRecipe({
templateId: recipeTemplate.id,
templateTitle: recipeTemplate.title,
payload,
});
setLearningDialogOpen(false);
await navigate({
to: "/data-recipes/$recipeId",
params: { recipeId: recipe.id },
});
} catch (error) {
toastError(
"Failed to start learning recipe.",
error instanceof Error ? error.message : undefined,
);
} finally {
setLoadingTemplateId(null);
}
}
function openRecipe(recipeId: string): void {
navigate({
to: "/data-recipes/$recipeId",
params: { recipeId },
}).catch(() => undefined);
}
async function handleDeleteRecipe(recipeId: string): Promise<void> {
await deleteRecipe(recipeId);
}
const isBusy = creatingRecipe || Boolean(loadingTemplateId);
return (
<div className="min-h-screen bg-background">
<main className="mx-auto w-full max-w-7xl px-6 py-8">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Data Recipes
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Create and manage local recipe workflows.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild={true}>
<Button type="button" disabled={isBusy}>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
New Recipe
<HugeiconsIcon icon={ArrowDown01Icon} className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
openNewRecipe().catch(() => undefined);
}}
>
<HugeiconsIcon icon={PlusSignIcon} className="size-4" />
Start Empty
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setLearningDialogOpen(true);
}}
>
<HugeiconsIcon icon={CookBookIcon} className="size-4" />
Start from Learning Recipe
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{recipes.length === 0 ? (
<Empty className="mt-8 border border-dashed border-border/70">
<EmptyHeader>
<EmptyMedia variant="icon">
<HugeiconsIcon icon={CookBookIcon} className="size-5" />
</EmptyMedia>
<EmptyTitle>No recipes yet</EmptyTitle>
<EmptyDescription>
Browse Learning Recipes below to understand how recipe workflows
work.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="max-w-6xl items-stretch">
{/*<Button*/}
{/* type="button"*/}
{/* variant="secondary"*/}
{/* className="mx-auto"*/}
{/* onClick={() => setLearningDialogOpen(true)}*/}
{/* disabled={isBusy}*/}
{/*>*/}
{/* <HugeiconsIcon icon={CookBookIcon} className="size-4" />*/}
{/* Start Tutorial*/}
{/*</Button>*/}
<LearningRecipeCards
onSelect={(template) => {
openLearningRecipe(template).catch(() => undefined);
}}
loadingTemplateId={loadingTemplateId}
/>
</EmptyContent>
</Empty>
) : (
<div className="mt-8 space-y-2">
{recipes.map((recipe) => (
<div
key={recipe.id}
className="flex items-center gap-3 rounded-xl border bg-card px-4 py-3"
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => openRecipe(recipe.id)}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/20">
<HugeiconsIcon
icon={CookBookIcon}
className="size-4 text-muted-foreground"
/>
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">
{recipe.name}
</p>
{recipe.learningRecipeId ? (
<Badge variant="outline">Learning Recipe</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">
Last updated {formatRelativeTime(recipe.updatedAt)} |
Created {formatRelativeTime(recipe.createdAt)}
</p>
</div>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={() => {
handleDeleteRecipe(recipe.id).catch(() => undefined);
}}
aria-label={`Delete ${recipe.name}`}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />
</Button>
</div>
))}
</div>
)}
</main>
<Dialog open={learningDialogOpen} onOpenChange={setLearningDialogOpen}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Learning Recipes</DialogTitle>
<DialogDescription>
Start from a prebuilt recipe to learn patterns, then edit and run.
</DialogDescription>
</DialogHeader>
<LearningRecipeCards
onSelect={(template) => {
openLearningRecipe(template).catch(() => undefined);
}}
loadingTemplateId={loadingTemplateId}
/>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,105 @@
import { Button } from "@/components/ui/button";
import { RecipeStudioPage, type RecipePayload } from "@/features/recipe-studio";
import { useNavigate } from "@tanstack/react-router";
import type { ReactElement } from "react";
import { useCallback, useEffect, useState } from "react";
import { getRecipe, saveRecipe } from "../data/recipes-db";
import type { RecipeRecord } from "../types";
type EditRecipePageProps = {
recipeId: string;
};
type LoadState =
| { status: "loading" }
| { status: "missing" }
| { status: "ready"; record: RecipeRecord };
function RecipeLoadState({
title,
description,
onBack,
}: {
title: string;
description: string;
onBack: () => void;
}): ReactElement {
return (
<div className="min-h-screen bg-background">
<main className="mx-auto flex min-h-[70vh] w-full max-w-4xl items-center justify-center px-6 py-8">
<div className="w-full rounded-2xl border bg-card p-8 text-center">
<h1 className="text-lg font-semibold">{title}</h1>
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
<Button type="button" variant="outline" className="mt-5" onClick={onBack}>
Back to Recipes
</Button>
</div>
</main>
</div>
);
}
export function EditRecipePage({ recipeId }: EditRecipePageProps): ReactElement {
const navigate = useNavigate();
const [loadState, setLoadState] = useState<LoadState>({ status: "loading" });
useEffect(() => {
let active = true;
void getRecipe(recipeId).then((record) => {
if (!active) {
return;
}
if (!record) {
setLoadState({ status: "missing" });
return;
}
setLoadState({ status: "ready", record });
});
return () => {
active = false;
};
}, [recipeId]);
const handlePersist = useCallback(
async (input: { id: string | null; name: string; payload: RecipePayload }) => {
const record = await saveRecipe({
id: input.id ?? recipeId,
name: input.name,
payload: input.payload,
});
return { id: record.id, updatedAt: record.updatedAt };
},
[recipeId],
);
if (loadState.status === "loading") {
return (
<RecipeLoadState
title="Loading recipe..."
description="Please wait while we load your recipe."
onBack={() => void navigate({ to: "/data-recipes" })}
/>
);
}
if (loadState.status === "missing") {
return (
<RecipeLoadState
title="Recipe not found"
description="This recipe may have been deleted."
onBack={() => void navigate({ to: "/data-recipes" })}
/>
);
}
return (
<RecipeStudioPage
key={loadState.record.id}
recipeId={loadState.record.id}
initialRecipeName={loadState.record.name}
initialPayload={loadState.record.payload}
initialSavedAt={loadState.record.updatedAt}
onPersistRecipe={handlePersist}
/>
);
}

View file

@ -0,0 +1,19 @@
import type { RecipePayload } from "@/features/recipe-studio";
export type RecipeRecord = {
id: string;
name: string;
payload: RecipePayload;
createdAt: number;
updatedAt: number;
learningRecipeId?: string;
learningRecipeTitle?: string;
};
export type SaveRecipeInput = {
id?: string | null;
name: string;
payload: RecipePayload;
learningRecipeId?: string;
learningRecipeTitle?: string;
};

View file

@ -75,6 +75,8 @@ export function DatasetStep() {
setDatasetSubset,
datasetSplit,
setDatasetSplit,
datasetEvalSplit,
setDatasetEvalSplit,
uploadedFile,
setUploadedFile,
} = useTrainingConfigStore(
@ -91,6 +93,8 @@ export function DatasetStep() {
setDatasetSubset: s.setDatasetSubset,
datasetSplit: s.datasetSplit,
setDatasetSplit: s.setDatasetSplit,
datasetEvalSplit: s.datasetEvalSplit,
setDatasetEvalSplit: s.setDatasetEvalSplit,
uploadedFile: s.uploadedFile,
setUploadedFile: s.setUploadedFile,
})),
@ -304,6 +308,8 @@ export function DatasetStep() {
setDatasetSubset={setDatasetSubset}
datasetSplit={datasetSplit}
setDatasetSplit={setDatasetSplit}
datasetEvalSplit={datasetEvalSplit}
setDatasetEvalSplit={setDatasetEvalSplit}
/>
</>
) : (

View file

@ -0,0 +1,315 @@
const DEFAULT_BASE = "/api/data-recipe";
export const DATA_DESIGNER_API_BASE =
import.meta.env.VITE_DATA_DESIGNER_API ?? DEFAULT_BASE;
export type JobCreateResponse = {
// biome-ignore lint/style/useNamingConvention: api schema
job_id: string;
};
export type JobStatusResponse = {
// biome-ignore lint/style/useNamingConvention: api schema
job_id: string;
status: string;
stage?: string | null;
// biome-ignore lint/style/useNamingConvention: api schema
current_column?: string | null;
batch?: {
idx?: number | null;
total?: number | null;
};
progress?: {
done?: number | null;
total?: number | null;
percent?: number | null;
// biome-ignore lint/style/useNamingConvention: api schema
eta_sec?: number | null;
rate?: number | null;
ok?: number | null;
failed?: number | null;
};
// biome-ignore lint/style/useNamingConvention: api schema
column_progress?: {
done?: number | null;
total?: number | null;
percent?: number | null;
// biome-ignore lint/style/useNamingConvention: api schema
eta_sec?: number | null;
rate?: number | null;
ok?: number | null;
failed?: number | null;
};
// biome-ignore lint/style/useNamingConvention: api schema
model_usage?: Record<string, unknown>;
rows?: number | null;
cols?: number | null;
error?: string | null;
// biome-ignore lint/style/useNamingConvention: api schema
has_analysis?: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
dataset_rows?: number | null;
// biome-ignore lint/style/useNamingConvention: api schema
artifact_path?: string | null;
// biome-ignore lint/style/useNamingConvention: api schema
started_at?: number | null;
// biome-ignore lint/style/useNamingConvention: api schema
finished_at?: number | null;
};
export type JobDatasetResponse = {
dataset?: unknown[];
total?: number;
limit?: number;
offset?: number;
};
export type JobEvent = {
event: string;
id: number | null;
payload: Record<string, unknown>;
};
export type SeedInspectRequest = {
// biome-ignore lint/style/useNamingConvention: api schema
dataset_name: string;
// biome-ignore lint/style/useNamingConvention: api schema
hf_token?: string;
subset?: string;
split?: string;
// biome-ignore lint/style/useNamingConvention: api schema
preview_size?: number;
};
export type SeedInspectUploadRequest = {
filename: string;
// base64 payload without data URL prefix
content_base64: string;
// biome-ignore lint/style/useNamingConvention: api schema
preview_size?: number;
};
export type SeedInspectResponse = {
// biome-ignore lint/style/useNamingConvention: api schema
dataset_name: string;
// biome-ignore lint/style/useNamingConvention: api schema
resolved_path: string;
columns: string[];
// biome-ignore lint/style/useNamingConvention: api schema
preview_rows: Record<string, unknown>[];
split?: string | null;
subset?: string | null;
};
export type ValidateError = {
message: string;
path?: string | null;
code?: string | null;
};
export type ValidateResponse = {
valid: boolean;
errors: ValidateError[];
// biome-ignore lint/style/useNamingConvention: api schema
raw_detail?: string | null;
};
async function parseErrorResponse(response: Response): Promise<string> {
const text = (await response.text()).trim();
if (!text) {
return "Request failed.";
}
try {
const parsed = JSON.parse(text) as {
detail?: string;
message?: string;
// biome-ignore lint/style/useNamingConvention: api schema
raw_detail?: string;
};
return (
parsed.detail ??
parsed.message ??
parsed.raw_detail ??
text
);
} catch {
return text;
}
}
async function postJson<T>(path: string, payload: unknown): Promise<T> {
const response = await fetch(`${DATA_DESIGNER_API_BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(await parseErrorResponse(response));
}
return response.json();
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${DATA_DESIGNER_API_BASE}${path}`);
if (!response.ok) {
throw new Error(await parseErrorResponse(response));
}
return response.json();
}
function parseJobEvent(rawEvent: string): JobEvent | null {
const lines = rawEvent.split(/\r?\n/);
let eventName = "message";
let id: number | null = null;
const dataLines: string[] = [];
for (const line of lines) {
if (!line) {
continue;
}
if (line.startsWith("event:")) {
eventName = line.slice(6).trim() || "message";
continue;
}
if (line.startsWith("id:")) {
const value = Number(line.slice(3).trim());
id = Number.isFinite(value) ? value : null;
continue;
}
if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
}
if (dataLines.length === 0) {
return null;
}
let payload: Record<string, unknown>;
try {
payload = JSON.parse(dataLines.join("\n")) as Record<string, unknown>;
} catch {
return null;
}
return {
event: eventName,
id,
payload,
};
}
export async function validateRecipe(
payload: unknown,
): Promise<ValidateResponse> {
return postJson<ValidateResponse>("/validate", payload);
}
export async function createRecipeJob(payload: unknown): Promise<JobCreateResponse> {
return postJson<JobCreateResponse>("/jobs", payload);
}
export async function getRecipeJobStatus(jobId: string): Promise<JobStatusResponse> {
return getJson<JobStatusResponse>(`/jobs/${jobId}/status`);
}
export async function getRecipeJobAnalysis(
jobId: string,
): Promise<Record<string, unknown>> {
return getJson<Record<string, unknown>>(`/jobs/${jobId}/analysis`);
}
export async function getRecipeJobDataset(
jobId: string,
options?: {
limit?: number;
offset?: number;
},
): Promise<JobDatasetResponse> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
return getJson<JobDatasetResponse>(
`/jobs/${jobId}/dataset?limit=${limit}&offset=${offset}`,
);
}
export async function cancelRecipeJob(jobId: string): Promise<JobStatusResponse> {
return postJson<JobStatusResponse>(`/jobs/${jobId}/cancel`, {});
}
export async function inspectSeedDataset(
payload: SeedInspectRequest,
): Promise<SeedInspectResponse> {
return postJson<SeedInspectResponse>("/seed/inspect", payload);
}
export async function inspectSeedUpload(
payload: SeedInspectUploadRequest,
): Promise<SeedInspectResponse> {
return postJson<SeedInspectResponse>("/seed/inspect-upload", payload);
}
export async function streamRecipeJobEvents(options: {
jobId: string;
signal: AbortSignal;
lastEventId?: number | null;
onOpen?: () => void;
onEvent: (event: JobEvent) => void;
}): Promise<void> {
const headers = new Headers();
let query = "";
if (typeof options.lastEventId === "number") {
headers.set("Last-Event-ID", String(options.lastEventId));
query = `?after=${options.lastEventId}`;
}
const response = await fetch(
`${DATA_DESIGNER_API_BASE}/jobs/${options.jobId}/events${query}`,
{
method: "GET",
headers,
signal: options.signal,
},
);
if (!response.ok) {
throw new Error(await parseErrorResponse(response));
}
if (!response.body) {
throw new Error("Job stream unavailable.");
}
options.onOpen?.();
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let separatorIndex = buffer.search(/\r?\n\r?\n/);
while (separatorIndex >= 0) {
const rawEvent = buffer.slice(0, separatorIndex);
const separatorLength = buffer[separatorIndex] === "\r" ? 4 : 2;
buffer = buffer.slice(separatorIndex + separatorLength);
if (rawEvent.startsWith("retry:")) {
separatorIndex = buffer.search(/\r?\n\r?\n/);
continue;
}
const parsed = parseJobEvent(rawEvent);
if (parsed) {
options.onEvent(parsed);
}
separatorIndex = buffer.search(/\r?\n\r?\n/);
}
}
}
// NOTE: tools + seed inspect/preview endpoints removed from harness.

View file

@ -0,0 +1,344 @@
import {
BalanceScaleIcon,
Clock01Icon,
CodeIcon,
CodeSimpleIcon,
DiceFaces03Icon,
DocumentAttachmentIcon,
DocumentCodeIcon,
EqualSignIcon,
FingerPrintIcon,
FunctionIcon,
Parabola02Icon,
PencilEdit02Icon,
Plant01Icon,
Shield02Icon,
Tag01Icon,
TagsIcon,
UserAccountIcon,
} from "@hugeicons/core-free-icons";
import type { LlmType, NodeConfig, SamplerType, SeedSourceType } from "../types";
import {
makeExpressionConfig,
makeLlmConfig,
makeMarkdownNoteConfig,
makeModelConfig,
makeModelProviderConfig,
makeSamplerConfig,
makeSeedConfig,
} from "../utils";
export type BlockKind = "sampler" | "llm" | "expression" | "seed" | "note";
export type BlockType =
| SamplerType
| LlmType
| "expression"
| "markdown_note"
| "seed"
| "seed_hf"
| "seed_local"
| "seed_unstructured"
| "model_provider"
| "model_config";
export type SeedBlockType = "seed_hf" | "seed_local" | "seed_unstructured";
type IconType = typeof CodeIcon;
export type BlockGroup = {
kind: BlockKind;
title: string;
description: string;
icon: IconType;
};
export type BlockDialogKey =
| "seed"
| "markdown_note"
| "category"
| "subcategory"
| "uniform"
| "gaussian"
| "bernoulli"
| "datetime"
| "timedelta"
| "uuid"
| "person"
| "llm"
| "model_provider"
| "model_config"
| "expression";
export type BlockDefinition = {
kind: BlockKind;
type: BlockType;
title: string;
description: string;
icon: IconType;
dialogKey: BlockDialogKey;
createConfig: (id: string, existing: NodeConfig[]) => NodeConfig;
};
export const BLOCK_GROUPS: BlockGroup[] = [
{
kind: "sampler",
title: "Samplers",
description: "Fast deterministic columns from distributions and categories.",
icon: DiceFaces03Icon,
},
{
kind: "seed",
title: "Seed",
description: "Bootstrap generation from an existing dataset.",
icon: Plant01Icon,
},
{
kind: "llm",
title: "LLM + Models",
description: "Generation, providers, and model aliases.",
icon: PencilEdit02Icon,
},
{
kind: "expression",
title: "Expression",
description: "Derive columns with Jinja templates.",
icon: FunctionIcon,
},
{
kind: "note",
title: "Notes",
description: "Add markdown notes to document your flow.",
icon: PencilEdit02Icon,
},
];
const BLOCK_DEFINITIONS: BlockDefinition[] = [
{
kind: "seed",
type: "seed_hf",
title: "Hugginface dataset",
description: "Load real rows from HF and use them as generation context.",
icon: Plant01Icon,
dialogKey: "seed",
createConfig: (id, existing) => makeSeedConfig(id, existing, "hf"),
},
{
kind: "seed",
type: "seed_local",
title: "Local file",
description: "Upload CSV/JSON/JSONL and use rows as seed context.",
icon: DocumentCodeIcon,
dialogKey: "seed",
createConfig: (id, existing) => makeSeedConfig(id, existing, "local"),
},
{
kind: "seed",
type: "seed_unstructured",
title: "Unstructured documents",
description: "Upload PDF/DOCX/TXT, chunk to text rows, then seed.",
icon: DocumentAttachmentIcon,
dialogKey: "seed",
createConfig: (id, existing) => makeSeedConfig(id, existing, "unstructured"),
},
{
kind: "sampler",
type: "category",
title: "Category",
description: "Define categorical values with optional weights and conditions.",
icon: Tag01Icon,
dialogKey: "category",
createConfig: (id, existing) => makeSamplerConfig(id, "category", existing),
},
{
kind: "sampler",
type: "subcategory",
title: "Subcategory",
description: "Define hierarchical values mapped to a parent category.",
icon: TagsIcon,
dialogKey: "subcategory",
createConfig: (id, existing) => makeSamplerConfig(id, "subcategory", existing),
},
{
kind: "sampler",
type: "uniform",
title: "Uniform",
description: "Sample evenly between low and high.",
icon: EqualSignIcon,
dialogKey: "uniform",
createConfig: (id, existing) => makeSamplerConfig(id, "uniform", existing),
},
{
kind: "sampler",
type: "gaussian",
title: "Gaussian",
description: "Sample from a normal distribution (mean/stddev).",
icon: Parabola02Icon,
dialogKey: "gaussian",
createConfig: (id, existing) => makeSamplerConfig(id, "gaussian", existing),
},
{
kind: "sampler",
type: "bernoulli",
title: "Bernoulli",
description: "Sample binary outcomes from probability p.",
icon: EqualSignIcon,
dialogKey: "bernoulli",
createConfig: (id, existing) => makeSamplerConfig(id, "bernoulli", existing),
},
{
kind: "sampler",
type: "datetime",
title: "Datetime",
description: "Sample timestamps within a start/end range.",
icon: Clock01Icon,
dialogKey: "datetime",
createConfig: (id, existing) => makeSamplerConfig(id, "datetime", existing),
},
{
kind: "sampler",
type: "timedelta",
title: "Timedelta",
description: "Sample time offsets from a reference datetime column.",
icon: Clock01Icon,
dialogKey: "timedelta",
createConfig: (id, existing) => makeSamplerConfig(id, "timedelta", existing),
},
{
kind: "sampler",
type: "uuid",
title: "UUID",
description: "Generate unique identifiers with optional formatting.",
icon: FingerPrintIcon,
dialogKey: "uuid",
createConfig: (id, existing) => makeSamplerConfig(id, "uuid", existing),
},
{
kind: "sampler",
type: "person",
title: "Person",
description: "Generate realistic synthetic people with faker attributes.",
icon: UserAccountIcon,
dialogKey: "person",
createConfig: (id, existing) => makeSamplerConfig(id, "person", existing),
},
{
kind: "llm",
type: "text",
title: "LLM Text",
description: "Generate natural language text from prompt templates.",
icon: PencilEdit02Icon,
dialogKey: "llm",
createConfig: (id, existing) => makeLlmConfig(id, "text", existing),
},
{
kind: "llm",
type: "structured",
title: "LLM Structured",
description: "Generate JSON constrained to a schema.",
icon: CodeIcon,
dialogKey: "llm",
createConfig: (id, existing) => makeLlmConfig(id, "structured", existing),
},
{
kind: "llm",
type: "code",
title: "LLM Code",
description: "Generate code in a chosen language with clean extraction.",
icon: CodeSimpleIcon,
dialogKey: "llm",
createConfig: (id, existing) => makeLlmConfig(id, "code", existing),
},
{
kind: "llm",
type: "judge",
title: "LLM Judge",
description: "Score generated outputs with rubric-based criteria.",
icon: BalanceScaleIcon,
dialogKey: "llm",
createConfig: (id, existing) => makeLlmConfig(id, "judge", existing),
},
{
kind: "llm",
type: "model_provider",
title: "Model Provider",
description: "Define endpoint and auth settings for model access.",
icon: Shield02Icon,
dialogKey: "model_provider",
createConfig: (id, existing) => makeModelProviderConfig(id, existing),
},
{
kind: "llm",
type: "model_config",
title: "Model Config",
description: "Bind alias to model, provider, and inference settings.",
icon: Plant01Icon,
dialogKey: "model_config",
createConfig: (id, existing) => makeModelConfig(id, existing),
},
{
kind: "expression",
type: "expression",
title: "Expression",
description: "Transform/combine columns using Jinja expressions.",
icon: FunctionIcon,
dialogKey: "expression",
createConfig: (id, existing) => makeExpressionConfig(id, existing),
},
{
kind: "note",
type: "markdown_note",
title: "Markdown note",
description: "UI-only markdown notes on canvas, not sent to backend.",
icon: PencilEdit02Icon,
dialogKey: "markdown_note",
createConfig: (id, existing) => makeMarkdownNoteConfig(id, existing),
},
];
export function getBlocksForKind(kind: BlockKind): BlockDefinition[] {
return BLOCK_DEFINITIONS.filter((block) => block.kind === kind);
}
export function getBlockDefinition(
kind: BlockKind,
type: BlockType,
): BlockDefinition | null {
return (
BLOCK_DEFINITIONS.find((block) => block.kind === kind && block.type === type) ??
null
);
}
export function getBlockDefinitionForConfig(
config: NodeConfig | null,
): BlockDefinition | null {
if (!config) {
return null;
}
if (config.kind === "seed") {
const seedType: Record<SeedSourceType, SeedBlockType> = {
hf: "seed_hf",
local: "seed_local",
unstructured: "seed_unstructured",
};
return getBlockDefinition("seed", seedType[config.seed_source_type ?? "hf"]);
}
if (config.kind === "sampler") {
const samplerType =
config.sampler_type === "person_from_faker" ? "person" : config.sampler_type;
return getBlockDefinition("sampler", samplerType);
}
if (config.kind === "llm") {
return getBlockDefinition("llm", config.llm_type);
}
if (config.kind === "model_provider") {
return getBlockDefinition("llm", "model_provider");
}
if (config.kind === "model_config") {
return getBlockDefinition("llm", "model_config");
}
if (config.kind === "markdown_note") {
return getBlockDefinition("note", "markdown_note");
}
return getBlockDefinition("expression", "expression");
}

View file

@ -0,0 +1,15 @@
export type {
BlockDefinition,
BlockDialogKey,
BlockGroup,
BlockKind,
BlockType,
SeedBlockType,
} from "./definitions";
export {
BLOCK_GROUPS,
getBlockDefinition,
getBlockDefinitionForConfig,
getBlocksForKind,
} from "./definitions";
export { renderBlockDialog } from "./render-dialog";

View file

@ -0,0 +1,117 @@
import type { ReactElement } from "react";
import type { NodeConfig, SamplerConfig } from "../types";
import { getBlockDefinitionForConfig } from "./definitions";
import { ExpressionDialog } from "../dialogs/expression/expression-dialog";
import { LlmDialog } from "../dialogs/llm/llm-dialog";
import { ModelConfigDialog } from "../dialogs/models/model-config-dialog";
import { ModelProviderDialog } from "../dialogs/models/model-provider-dialog";
import { SeedDialog } from "../dialogs/seed/seed-dialog";
import { CategoryDialog } from "../dialogs/samplers/category-dialog";
import { DatetimeDialog } from "../dialogs/samplers/datetime-dialog";
import { BernoulliDialog } from "../dialogs/samplers/bernoulli-dialog";
import { GaussianDialog } from "../dialogs/samplers/gaussian-dialog";
import { PersonDialog } from "../dialogs/samplers/person-dialog";
import { SubcategoryDialog } from "../dialogs/samplers/subcategory-dialog";
import { TimedeltaDialog } from "../dialogs/samplers/timedelta-dialog";
import { UniformDialog } from "../dialogs/samplers/uniform-dialog";
import { UuidDialog } from "../dialogs/samplers/uuid-dialog";
import { MarkdownNoteDialog } from "../dialogs/markdown-note/markdown-note-dialog";
export function renderBlockDialog(
config: NodeConfig | null,
open: boolean,
categoryOptions: SamplerConfig[],
modelConfigAliases: string[],
modelProviderOptions: string[],
datetimeOptions: string[],
onUpdate: (id: string, patch: Partial<NodeConfig>) => void,
): ReactElement | null {
const definition = getBlockDefinitionForConfig(config);
if (!definition || !config) {
return null;
}
const update = (patch: Partial<NodeConfig>) => onUpdate(config.id, patch);
switch (definition.dialogKey) {
case "seed":
return config.kind === "seed" ? (
<SeedDialog config={config} onUpdate={update} open={open} />
) : null;
case "category":
return config.kind === "sampler" && config.sampler_type === "category" ? (
<CategoryDialog key={config.id} config={config} onUpdate={update} />
) : null;
case "subcategory":
return config.kind === "sampler" && config.sampler_type === "subcategory" ? (
<SubcategoryDialog
config={config}
categoryOptions={categoryOptions}
onUpdate={update}
/>
) : null;
case "uniform":
return config.kind === "sampler" && config.sampler_type === "uniform" ? (
<UniformDialog config={config} onUpdate={update} />
) : null;
case "gaussian":
return config.kind === "sampler" && config.sampler_type === "gaussian" ? (
<GaussianDialog config={config} onUpdate={update} />
) : null;
case "bernoulli":
return config.kind === "sampler" && config.sampler_type === "bernoulli" ? (
<BernoulliDialog config={config} onUpdate={update} />
) : null;
case "datetime":
return config.kind === "sampler" && config.sampler_type === "datetime" ? (
<DatetimeDialog config={config} onUpdate={update} />
) : null;
case "timedelta":
return config.kind === "sampler" && config.sampler_type === "timedelta" ? (
<TimedeltaDialog
config={config}
datetimeOptions={datetimeOptions}
onUpdate={update}
/>
) : null;
case "uuid":
return config.kind === "sampler" && config.sampler_type === "uuid" ? (
<UuidDialog config={config} onUpdate={update} />
) : null;
case "person":
return config.kind === "sampler" &&
(config.sampler_type === "person" ||
config.sampler_type === "person_from_faker") ? (
<PersonDialog config={config} onUpdate={update} />
) : null;
case "llm":
return config.kind === "llm" ? (
<LlmDialog
config={config}
modelConfigAliases={modelConfigAliases}
modelProviderOptions={modelProviderOptions}
onUpdate={update}
/>
) : null;
case "model_provider":
return config.kind === "model_provider" ? (
<ModelProviderDialog config={config} onUpdate={update} />
) : null;
case "model_config":
return config.kind === "model_config" ? (
<ModelConfigDialog
config={config}
providerOptions={modelProviderOptions}
onUpdate={update}
/>
) : null;
case "expression":
return config.kind === "expression" ? (
<ExpressionDialog config={config} onUpdate={update} />
) : null;
case "markdown_note":
return config.kind === "markdown_note" ? (
<MarkdownNoteDialog config={config} onUpdate={update} />
) : null;
}
}

View file

@ -0,0 +1,331 @@
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
ArrowLeft02Icon,
ArrowRight01Icon,
CodeIcon,
Copy02Icon,
type Database02Icon,
PlusSignIcon,
Tick02Icon,
Upload01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { 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 SeedBlockType,
} from "../blocks/registry";
type SheetView =
| "root"
| "sampler"
| "seed"
| "llm"
| "expression"
| "note"
| "processor";
type SheetKind = "sampler" | "seed" | "llm" | "expression" | "note";
type RootSheetView = Exclude<SheetView, "root">;
type RootGroup = {
kind: RootSheetView;
title: string;
description: string;
icon: typeof Database02Icon;
};
type BlockSheetProps = {
container: HTMLDivElement | null;
sheetView: SheetView;
onViewChange: (sheetView: SheetView) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onAddSampler: (type: SamplerType) => void;
onAddSeed: (type: SeedBlockType) => void;
onAddLlm: (type: LlmType) => void;
onAddModelProvider: () => void;
onAddModelConfig: () => void;
onAddExpression: () => void;
onAddMarkdownNote: () => void;
onOpenProcessors: () => void;
copied: boolean;
onCopy: () => void;
onImport: () => void;
};
function getSheetTitle(sheetView: SheetView): string {
if (sheetView === "root") {
return "Add a block";
}
if (sheetView === "sampler") {
return "Sampler blocks";
}
if (sheetView === "seed") {
return "Seed blocks";
}
if (sheetView === "expression") {
return "Expression blocks";
}
if (sheetView === "note") {
return "Note blocks";
}
if (sheetView === "processor") {
return "Processor blocks";
}
return "LLM blocks";
}
const VIEW_KIND: Record<SheetView, SheetKind | null> = {
root: null,
sampler: "sampler",
seed: "seed",
llm: "llm",
expression: "expression",
note: "note",
processor: null,
};
const ROOT_GROUPS: RootGroup[] = [
...BLOCK_GROUPS,
{
kind: "processor",
title: "Processors",
description: "Output schema + post batch.",
icon: CodeIcon,
},
];
function BlockSheetButton({
icon,
title,
description,
onClick,
isActive = false,
}: {
icon: typeof Database02Icon;
title: string;
description: string;
onClick: () => void;
isActive?: boolean;
}): ReactElement {
return (
<button
type="button"
onClick={onClick}
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"
}`}
>
<div className="flex size-9 items-center justify-center rounded-xl text-foreground/70">
<HugeiconsIcon icon={icon} className="size-5" />
</div>
<div className="flex-1">
<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"
/>
</button>
);
}
export function BlockSheet({
container,
sheetView,
onViewChange,
open,
onOpenChange,
onAddSampler,
onAddSeed,
onAddLlm,
onAddModelProvider,
onAddModelConfig,
onAddExpression,
onAddMarkdownNote,
onOpenProcessors,
copied,
onCopy,
onImport,
}: BlockSheetProps): ReactElement {
const sheetTitle = getSheetTitle(sheetView);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
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 setSheetOpen = (nextOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(nextOpen);
}
onOpenChange?.(nextOpen);
};
return (
<div className="flex flex-col items-end gap-2">
<Sheet
open={sheetOpen}
onOpenChange={(nextOpen) => {
setSheetOpen(nextOpen);
if (nextOpen) {
onViewChange("root");
}
}}
>
<SheetTrigger asChild={true}>
<Button
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
variant="ghost"
>
<HugeiconsIcon
icon={PlusSignIcon}
className="size-5 text-muted-foreground group-hover:text-primary"
/>
</Button>
</SheetTrigger>
<SheetContent
side="right"
container={container}
position="absolute"
overlayPosition="absolute"
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">
<div className="flex items-center gap-2">
{sheetView !== "root" && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => onViewChange("root")}
>
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
</Button>
)}
<SheetTitle>{sheetTitle}</SheetTitle>
</div>
</SheetHeader>
<div className=" py-4">
<div className="mt-4 flex flex-col gap-2">
{sheetView === "root" &&
ROOT_GROUPS.map((item, index) => (
<BlockSheetButton
key={item.kind}
icon={item.icon}
title={item.title}
description={item.description}
isActive={index === 0}
onClick={() => {
if (item.kind === "processor") {
setSheetOpen(false);
onOpenProcessors();
return;
}
if (item.kind === "seed" && seedBlocks.length === 1) {
setSheetOpen(false);
onAddSeed(seedBlocks[0].type as SeedBlockType);
return;
}
if (item.kind === "expression" && expressionBlocks.length === 1) {
setSheetOpen(false);
onAddExpression();
return;
}
if (item.kind === "note" && noteBlocks.length === 1) {
setSheetOpen(false);
onAddMarkdownNote();
return;
}
onViewChange(item.kind);
}}
/>
))}
{sheetView === "processor" && (
<BlockSheetButton
icon={CodeIcon}
title="Schema Transform"
description="Transform final dataset schema."
isActive={true}
onClick={onOpenProcessors}
/>
)}
{sheetView !== "root" &&
sheetView !== "processor" &&
getBlocksForKind(VIEW_KIND[sheetView] ?? "sampler").map(
(item, index) => (
<BlockSheetButton
key={item.type}
icon={item.icon}
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();
}
}}
/>
),
)}
</div>
</div>
</SheetContent>
</Sheet>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={onImport}
>
<HugeiconsIcon
icon={Upload01Icon}
className="size-5 text-muted-foreground group-hover:text-primary"
/>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={onCopy}
>
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy02Icon}
className="size-5 text-muted-foreground group-hover:text-primary"
/>
</Button>
</div>
);
}

View file

@ -0,0 +1,98 @@
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";
type ChipInputProps = {
values: string[];
onAdd: (value: string) => void;
onRemove: (index: number) => void;
placeholder?: string;
suggestions?: string[];
};
export function ChipInput({
values,
onAdd,
onRemove,
placeholder = "Type and press Enter",
suggestions,
}: ChipInputProps): ReactElement {
const [draft, setDraft] = useState("");
const listId = useId();
const suggestionSet = useMemo(
() => new Set((suggestions ?? []).map((value) => value.trim())),
[suggestions],
);
function addValue(rawValue: string, allowAny: boolean): void {
const trimmed = rawValue.trim();
if (!trimmed) {
return;
}
if (!allowAny && !suggestionSet.has(trimmed)) {
return;
}
onAdd(trimmed);
setDraft("");
}
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
addValue(draft, true);
}
if (event.key === "Backspace" && !draft && values.length > 0) {
onRemove(values.length - 1);
}
};
function handleChange(nextDraft: string): void {
setDraft(nextDraft);
if (suggestionSet.has(nextDraft.trim())) {
addValue(nextDraft, false);
}
}
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]">
{values.map((value, index) => (
<span
key={`${value}-${index}`}
className="bg-muted-foreground/10 text-foreground flex h-[calc(--spacing(5.5))] w-fit items-center justify-center gap-1 rounded-4xl pr-0 pl-2 text-xs font-medium whitespace-nowrap"
>
{value}
<Button
type="button"
variant="ghost"
size="icon-xs"
className="-ml-1 opacity-50 hover:opacity-100"
onClick={() => onRemove(index)}
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
className="pointer-events-none"
/>
</Button>
</span>
))}
<input
className="nodrag min-w-16 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder={values.length === 0 ? placeholder : ""}
value={draft}
list={suggestions && suggestions.length > 0 ? listId : undefined}
onChange={(event) => handleChange(event.target.value)}
onBlur={() => addValue(draft, false)}
onKeyDown={handleKeyDown}
/>
{suggestions && suggestions.length > 0 && (
<datalist id={listId}>
{suggestions.map((value) => (
<option key={value} value={value} />
))}
</datalist>
)}
</div>
);
}

View file

@ -0,0 +1,60 @@
import { type ReactElement, useCallback } from "react";
import {
Panel,
useReactFlow,
useUpdateNodeInternals,
} from "@xyflow/react";
import { Button } from "@/components/ui/button";
type LayoutControlsProps = {
direction: "LR" | "TB";
onLayout: () => void;
onToggleDirection: () => void;
};
export function LayoutControls({
direction,
onLayout,
onToggleDirection,
}: LayoutControlsProps): ReactElement {
const { fitView, getNodes } = useReactFlow();
const updateNodeInternals = useUpdateNodeInternals();
const refreshNodeInternals = useCallback(() => {
const nodeIds = getNodes().map((node) => node.id);
if (nodeIds.length > 0) {
updateNodeInternals(nodeIds);
}
}, [getNodes, updateNodeInternals]);
const handleLayout = useCallback(() => {
onLayout();
requestAnimationFrame(() => {
refreshNodeInternals();
requestAnimationFrame(() => {
fitView({ duration: 250 });
});
});
}, [fitView, onLayout, refreshNodeInternals]);
const handleToggleDirection = useCallback(() => {
onToggleDirection();
requestAnimationFrame(() => {
refreshNodeInternals();
requestAnimationFrame(() => {
refreshNodeInternals();
});
});
}, [onToggleDirection, refreshNodeInternals]);
return (
<Panel position="top-left" className="m-3 flex items-center gap-2">
<Button size="sm" className="corner-squircle" variant="secondary" onClick={handleLayout}>
Auto layout
</Button>
<Button size="sm" className="corner-squircle" variant="outline" onClick={handleToggleDirection}>
{direction}
</Button>
</Panel>
);
}

View file

@ -0,0 +1,74 @@
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 { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-button-class";
type ViewportControlsProps = {
interactive: boolean;
onToggleInteractive: () => void;
};
export function ViewportControls({
interactive,
onToggleInteractive,
}: ViewportControlsProps): ReactElement {
const { zoomIn, zoomOut, fitView } = useReactFlow();
const handleZoomIn = useCallback(() => {
zoomIn({ duration: 150 });
}, [zoomIn]);
const handleZoomOut = useCallback(() => {
zoomOut({ duration: 150 });
}, [zoomOut]);
const handleFitView = useCallback(() => {
fitView({ duration: 250 });
}, [fitView]);
return (
<Panel position="bottom-left" className="m-3 flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleZoomIn}
aria-label="Zoom in"
>
<Plus className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleZoomOut}
aria-label="Zoom out"
>
<Minus className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={handleFitView}
aria-label="Fit view"
>
<Maximize2 className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={RECIPE_FLOATING_ICON_BUTTON_CLASS}
onClick={onToggleInteractive}
aria-label={interactive ? "Lock interaction" : "Unlock interaction"}
>
{interactive ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
</Button>
</Panel>
);
}

View file

@ -0,0 +1,54 @@
import type { ReactElement } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AnalysisColumnStat } from "./executions-view-helpers";
type ExecutionColumnsTabProps = {
analysisColumns: AnalysisColumnStat[];
};
export function ExecutionColumnsTab({
analysisColumns,
}: ExecutionColumnsTabProps): ReactElement {
return (
<div className="mt-3 rounded-xl border p-3">
<p className="mb-2 text-sm font-semibold">Column statistics</p>
{analysisColumns.length === 0 ? (
<p className="text-xs text-muted-foreground">No column statistics yet.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Column</TableHead>
<TableHead>Type</TableHead>
<TableHead>Data type</TableHead>
<TableHead>Unique</TableHead>
<TableHead>Nulls</TableHead>
<TableHead>Input tok avg</TableHead>
<TableHead>Output tok avg</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analysisColumns.map((column) => (
<TableRow key={column.column_name}>
<TableCell>{column.column_name}</TableCell>
<TableCell>{column.column_type}</TableCell>
<TableCell>{column.simple_dtype}</TableCell>
<TableCell>{column.num_unique ?? "--"}</TableCell>
<TableCell>{column.num_null ?? "--"}</TableCell>
<TableCell>{column.input_tokens_mean ?? "--"}</TableCell>
<TableCell>{column.output_tokens_mean ?? "--"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
);
}

View file

@ -0,0 +1,157 @@
import type { ReactElement } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import { DataTable } from "@/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { isExecutionInProgress } from "../../executions/execution-helpers";
import type { RecipeExecutionRecord } from "../../execution-types";
import { formatCellValue, isExpandableCellValue } from "./executions-view-helpers";
type ExecutionDataTabProps = {
execution: RecipeExecutionRecord;
datasetColumnNames: string[];
hiddenDatasetColumns: string[];
canPageDataset: boolean;
currentDatasetPage: number;
totalPages: number;
tableColumns: ColumnDef<Record<string, unknown>>[];
datasetRowsForTable: Record<string, unknown>[];
visibleDatasetColumnNames: string[];
expandedDatasetRows: Record<string, boolean>;
selectedExecutionIdSafe: string | null;
onSetHiddenColumns: (updater: (current: string[]) => string[]) => void;
onPrevPage: () => void;
onNextPage: () => void;
onToggleRowExpanded: (rowId: string) => void;
};
export function ExecutionDataTab({
execution,
datasetColumnNames,
hiddenDatasetColumns,
canPageDataset,
currentDatasetPage,
totalPages,
tableColumns,
datasetRowsForTable,
visibleDatasetColumnNames,
expandedDatasetRows,
selectedExecutionIdSafe,
onSetHiddenColumns,
onPrevPage,
onNextPage,
onToggleRowExpanded,
}: ExecutionDataTabProps): ReactElement {
return (
<div className="mt-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold">Dataset sample</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{datasetColumnNames.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant="outline">
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Visible columns</DropdownMenuLabel>
{datasetColumnNames.map((columnName) => (
<DropdownMenuCheckboxItem
key={columnName}
checked={!hiddenDatasetColumns.includes(columnName)}
onSelect={(event) => {
event.preventDefault();
}}
onCheckedChange={(checked) => {
onSetHiddenColumns((currentColumns) => {
if (checked) {
return currentColumns.filter((name) => name !== columnName);
}
return [...currentColumns, columnName];
});
}}
>
{columnName}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{canPageDataset && (
<>
<span>
Page {currentDatasetPage}/{totalPages}
</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={
isExecutionInProgress(execution.status) || currentDatasetPage <= 1
}
onClick={onPrevPage}
>
Prev
</Button>
<Button
type="button"
size="sm"
variant="outline"
disabled={
isExecutionInProgress(execution.status) ||
currentDatasetPage >= totalPages
}
onClick={onNextPage}
>
Next
</Button>
</>
)}
</div>
</div>
{execution.dataset.length === 0 ? (
<p className="text-xs text-muted-foreground">No rows returned.</p>
) : tableColumns.length === 0 ? (
<p className="text-xs text-muted-foreground">
All columns hidden. Use Columns to show at least one.
</p>
) : (
<div className="max-h-[55vh] overflow-auto">
<DataTable
columns={tableColumns}
data={datasetRowsForTable}
getRowClassName={(row, _rowIndex, rowId) => {
const canExpand = visibleDatasetColumnNames.some((columnName) =>
isExpandableCellValue(formatCellValue(row[columnName])),
);
if (!canExpand) {
return undefined;
}
return cn(
"cursor-pointer",
expandedDatasetRows[rowId] ? "bg-primary/[0.05]" : "hover:bg-primary/[0.06]",
);
}}
onRowClick={(row, _rowIndex, rowId) => {
const canExpand = visibleDatasetColumnNames.some((columnName) =>
isExpandableCellValue(formatCellValue(row[columnName])),
);
if (!canExpand || !selectedExecutionIdSafe) {
return;
}
onToggleRowExpanded(rowId);
}}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,212 @@
import type { ReactElement, RefObject, UIEvent } from "react";
import {
Database01Icon,
Database02Icon,
Flag02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { isExecutionInProgress } from "../../executions/execution-helpers";
import type { RecipeExecutionRecord } from "../../execution-types";
import type { ModelUsageRow } from "./executions-view-helpers";
import { formatMetricValue } from "./executions-view-helpers";
type ExecutionOverviewTabProps = {
execution: RecipeExecutionRecord;
showSummaryCards: boolean;
recordsMetric: number | null;
totalMetric: number | null;
runDuration: string;
columnCount: number;
llmColumnCount: number;
nullRate: number | null;
sideEffects: string[];
lowUniquenessColumns: string[];
modelUsageRows: ModelUsageRow[];
terminalLines: string[];
terminalRef: RefObject<HTMLDivElement | null>;
onTerminalScroll: (event: UIEvent<HTMLDivElement>) => void;
};
export function ExecutionOverviewTab({
execution,
showSummaryCards,
recordsMetric,
totalMetric,
runDuration,
columnCount,
llmColumnCount,
nullRate,
sideEffects,
lowUniquenessColumns,
modelUsageRows,
terminalLines,
terminalRef,
onTerminalScroll,
}: ExecutionOverviewTabProps): ReactElement {
return (
<div className="mt-3 space-y-3">
{showSummaryCards && (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="h-full rounded-lg bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-muted-foreground">Run summary</p>
<HugeiconsIcon
icon={Database01Icon}
className="size-4 text-muted-foreground"
/>
</div>
<div className="space-y-1.5 text-xs">
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Records</span>
<span className="font-semibold">
{formatMetricValue(recordsMetric)} / {formatMetricValue(totalMetric)}
</span>
</p>
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Duration</span>
<span className="font-semibold">{runDuration}</span>
</p>
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Columns analyzed</span>
<span className="font-semibold">{formatMetricValue(columnCount)}</span>
</p>
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Final stage</span>
<span className="font-semibold">{execution.stage ?? "--"}</span>
</p>
</div>
</div>
<div className="h-full rounded-lg bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-muted-foreground">Insights</p>
<HugeiconsIcon
icon={Database02Icon}
className="size-4 text-muted-foreground"
/>
</div>
<div className="space-y-1.5 text-xs">
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">LLM columns</span>
<span className="font-semibold">{formatMetricValue(llmColumnCount)}</span>
</p>
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Null rate</span>
<span className="font-semibold">{nullRate?.toFixed(1) ?? "--"}%</span>
</p>
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Dropped columns</span>
<span className="font-semibold">{formatMetricValue(sideEffects.length)}</span>
</p>
{sideEffects.length > 0 && (
<div className="pt-0.5">
<div className="flex flex-wrap gap-1.5">
{sideEffects.map((name) => (
<Badge key={name} variant="outline">
{name}
</Badge>
))}
</div>
</div>
)}
<p className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Low uniqueness flags</span>
<span className="font-semibold">
{formatMetricValue(lowUniquenessColumns.length)}
</span>
</p>
{lowUniquenessColumns.length > 0 && (
<div className="pt-0.5">
<div className="flex flex-wrap gap-1.5">
{lowUniquenessColumns.slice(0, 3).map((name) => (
<Badge key={name} variant="secondary">
{name}
</Badge>
))}
{lowUniquenessColumns.length > 3 && (
<Badge variant="secondary">
+{lowUniquenessColumns.length - 3} more
</Badge>
)}
</div>
</div>
)}
</div>
</div>
</div>
<div className="rounded-lg bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs text-muted-foreground">Model usage</p>
<HugeiconsIcon icon={Flag02Icon} className="size-4 text-muted-foreground" />
</div>
{modelUsageRows.length === 0 ? (
<p className="text-xs text-muted-foreground">No model usage yet.</p>
) : (
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Input</TableHead>
<TableHead className="text-right">Output</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modelUsageRows.map((usage) => (
<TableRow key={usage.model}>
<TableCell className="max-w-[320px] truncate">{usage.model}</TableCell>
<TableCell className="text-right">
{formatMetricValue(usage.input)}
</TableCell>
<TableCell className="text-right">
{formatMetricValue(usage.output)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</div>
)}
<div className="overflow-hidden rounded-xl corner-squircle border">
<div className="flex items-center justify-between border-b px-3 py-2">
<p className="text-sm font-semibold">Terminal output</p>
<p className="text-xs text-muted-foreground">{terminalLines.length} lines</p>
</div>
<div
ref={terminalRef}
className="max-h-72 overflow-auto bg-zinc-900/80 px-3 py-2 font-mono text-xs text-zinc-200"
onScroll={onTerminalScroll}
>
{terminalLines.length === 0 ? (
<p className="text-zinc-400">
{isExecutionInProgress(execution.status)
? "Waiting for logs..."
: "No logs captured."}
</p>
) : (
terminalLines.map((line, index) => (
<p
key={`${index}-${line.slice(0, 24)}`}
className="whitespace-pre-wrap break-words leading-relaxed"
>
{line}
</p>
))
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,18 @@
import type { ReactElement } from "react";
type ExecutionRawTabProps = {
rawExecution: Record<string, unknown> | null;
};
export function ExecutionRawTab({
rawExecution,
}: ExecutionRawTabProps): ReactElement {
return (
<div className="mt-3 rounded-xl border p-3">
<p className="mb-2 text-sm font-semibold">Raw execution</p>
<pre className="max-h-96 overflow-auto rounded-md bg-muted/40 p-3 text-xs">
{JSON.stringify(rawExecution, null, 2)}
</pre>
</div>
);
}

View file

@ -0,0 +1,78 @@
import type { ReactElement } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { RecipeExecutionRecord } from "../../execution-types";
import { isExecutionInProgress } from "../../executions/execution-helpers";
import {
formatStatus,
formatTimestamp,
statusRightBorder,
statusTone,
} from "./executions-view-helpers";
type ExecutionSidebarProps = {
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
onSelectExecution: (id: string) => void;
};
export function ExecutionSidebar({
executions,
selectedExecutionId,
onSelectExecution,
}: ExecutionSidebarProps): ReactElement {
return (
<aside className="w-72 shrink-0 border-r">
<div className="flex items-center justify-between border-b px-3 py-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">
Executions
</p>
</div>
<div className="h-[calc(100%-45px)] overflow-auto p-2">
{executions.length === 0 ? (
<div className="rounded-xl border border-dashed p-3 text-xs text-muted-foreground">
No executions yet.
</div>
) : (
executions.map((execution) => (
<button
key={execution.id}
type="button"
onClick={() => onSelectExecution(execution.id)}
className={cn(
"mb-2 w-full rounded-xl corner-squircle border border-r-4 p-3 text-left",
selectedExecutionId === execution.id
? "border-primary/50 bg-primary/5"
: "hover:bg-muted/40",
statusRightBorder(execution.status),
)}
>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium capitalize">
{execution.kind}
</p>
<Badge
variant="secondary"
className={cn("capitalize", statusTone(execution.status))}
>
{formatStatus(execution.status)}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{execution.rows} rows</p>
{isExecutionInProgress(execution.status) &&
typeof execution.batch?.total === "number" &&
execution.batch.total > 1 && (
<p className="text-xs text-muted-foreground">
Batch {execution.batch.idx ?? "--"}/{execution.batch.total}
</p>
)}
<p className="text-xs text-muted-foreground">
{formatTimestamp(execution.createdAt)}
</p>
</button>
))
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,175 @@
import type {
RecipeExecutionAnalysis,
RecipeExecutionStatus,
} from "../../execution-types";
import { isExecutionInProgress } from "../../executions/execution-helpers";
export type AnalysisColumnStat = {
column_name: string;
column_type: string;
simple_dtype: string;
num_unique: number | null;
num_null: number | null;
input_tokens_mean: number | null;
output_tokens_mean: number | null;
};
export type ModelUsageRow = {
model: string;
input: number | null;
output: number | null;
};
export const PREVIEW_DATASET_PAGE_SIZE = 20;
export const TERMINAL_STICKY_BOTTOM_THRESHOLD_PX = 24;
export function formatTimestamp(value: number): string {
return new Date(value).toLocaleString();
}
export function formatCellValue(value: unknown): string {
if (value === null || value === undefined) {
return "--";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
export function isExpandableCellValue(value: string): boolean {
return value.length > 180;
}
export function truncateCellValue(value: string): string {
if (value.length <= 180) {
return value;
}
return `${value.slice(0, 180).trimEnd()}...`;
}
function parseNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function parseString(value: unknown): string {
return typeof value === "string" && value.length > 0 ? value : "--";
}
export function parseAnalysisColumns(
analysis: RecipeExecutionAnalysis | null,
): AnalysisColumnStat[] {
const items = Array.isArray(analysis?.column_statistics)
? analysis.column_statistics
: [];
return items
.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
}
const row = item as Record<string, unknown>;
return {
column_name: parseString(row.column_name),
column_type: parseString(row.column_type),
simple_dtype: parseString(row.simple_dtype),
num_unique: parseNumber(row.num_unique),
num_null: parseNumber(row.num_null),
input_tokens_mean: parseNumber(row.input_tokens_mean),
output_tokens_mean: parseNumber(row.output_tokens_mean),
};
})
.filter((item): item is AnalysisColumnStat => item !== null);
}
export function statusTone(status: RecipeExecutionStatus): string {
if (status === "completed") {
return "bg-emerald-100 text-emerald-700";
}
if (status === "error" || status === "cancelled") {
return "bg-red-100 text-red-700";
}
if (isExecutionInProgress(status)) {
return "bg-amber-100 text-amber-700";
}
return "bg-muted text-muted-foreground";
}
export function statusRightBorder(status: RecipeExecutionStatus): string {
if (status === "completed") {
return "border-r-emerald-500";
}
if (status === "error" || status === "cancelled") {
return "border-r-red-500";
}
if (isExecutionInProgress(status)) {
return "border-r-amber-500";
}
return "border-r-border";
}
export function formatStatus(status: RecipeExecutionStatus): string {
if (status === "cancelled") {
return "cancelled";
}
return status;
}
export function formatPercent(value: number | null | undefined): string {
if (typeof value !== "number" || Number.isNaN(value)) {
return "--";
}
return `${value.toFixed(1)}%`;
}
export function formatDuration(startedAt: number, finishedAt: number | null): string {
if (!finishedAt || finishedAt <= startedAt) {
return "--";
}
const seconds = Math.round((finishedAt - startedAt) / 1000);
return `${seconds}s`;
}
export function formatMetricValue(value: number | null | undefined): string {
if (typeof value !== "number" || Number.isNaN(value)) {
return "--";
}
return value.toLocaleString();
}
export function parseModelUsageRows(
value: Record<string, unknown> | null,
): ModelUsageRow[] {
if (!value) {
return [];
}
return Object.entries(value)
.map(([name, data]) => {
if (!data || typeof data !== "object" || Array.isArray(data)) {
return null;
}
const modelObj = data as Record<string, unknown>;
const tokens =
modelObj.tokens &&
typeof modelObj.tokens === "object" &&
!Array.isArray(modelObj.tokens)
? (modelObj.tokens as Record<string, unknown>)
: null;
const modelName =
typeof modelObj.model === "string" && modelObj.model.length > 0
? modelObj.model
: name;
return {
model: modelName,
input: parseNumber(tokens?.input),
output: parseNumber(tokens?.output),
};
})
.filter((item): item is ModelUsageRow => item !== null);
}

View file

@ -0,0 +1,559 @@
import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import type { ColumnDef } from "@tanstack/react-table";
import {
CheckmarkCircle02Icon,
Flag02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import type {
RecipeExecutionRecord,
} from "../../execution-types";
import { isExecutionInProgress } from "../../executions/execution-helpers";
import { ExecutionColumnsTab } from "./execution-columns-tab";
import { ExecutionDataTab } from "./execution-data-tab";
import { ExecutionOverviewTab } from "./execution-overview-tab";
import { ExecutionRawTab } from "./execution-raw-tab";
import { ExecutionSidebar } from "./execution-sidebar";
import {
PREVIEW_DATASET_PAGE_SIZE,
TERMINAL_STICKY_BOTTOM_THRESHOLD_PX,
formatCellValue,
formatDuration,
formatPercent,
formatStatus,
formatTimestamp,
isExpandableCellValue,
parseAnalysisColumns,
parseModelUsageRows,
statusTone,
truncateCellValue,
} from "./executions-view-helpers";
type ExecutionsViewProps = {
executions: RecipeExecutionRecord[];
selectedExecutionId: string | null;
currentSignature: string;
onSelectExecution: (id: string) => void;
onCancelExecution: (id: string) => void;
onLoadDatasetPage: (id: string, page: number) => void;
};
export function ExecutionsView({
executions,
selectedExecutionId,
currentSignature,
onSelectExecution,
onCancelExecution,
onLoadDatasetPage,
}: ExecutionsViewProps): ReactElement {
const [detailTab, setDetailTab] = useState("overview");
const [hiddenDatasetColumnsByExecution, setHiddenDatasetColumnsByExecution] = useState<
Record<string, string[]>
>({});
const [expandedDatasetRowsByExecution, setExpandedDatasetRowsByExecution] = useState<
Record<string, Record<string, boolean>>
>({});
const [previewDatasetPageByExecution, setPreviewDatasetPageByExecution] = useState<
Record<string, number>
>({});
const terminalRef = useRef<HTMLDivElement | null>(null);
const shouldStickTerminalToBottomRef = useRef(true);
const selectedExecution = useMemo(
() =>
executions.find((execution) => execution.id === selectedExecutionId) ??
null,
[executions, selectedExecutionId],
);
const isStale = Boolean(
selectedExecution &&
selectedExecution.recipeSignature.length > 0 &&
selectedExecution.recipeSignature !== currentSignature,
);
const selectedExecutionIdSafe = selectedExecution?.id ?? null;
const hiddenDatasetColumns = useMemo(() => {
if (!selectedExecutionIdSafe) {
return [];
}
return hiddenDatasetColumnsByExecution[selectedExecutionIdSafe] ?? [];
}, [hiddenDatasetColumnsByExecution, selectedExecutionIdSafe]);
const expandedDatasetRows = useMemo(() => {
if (!selectedExecutionIdSafe) {
return {};
}
return expandedDatasetRowsByExecution[selectedExecutionIdSafe] ?? {};
}, [expandedDatasetRowsByExecution, selectedExecutionIdSafe]);
const datasetColumnNames = useMemo(() => {
if (!selectedExecution) {
return [];
}
const names = new Set<string>();
for (const row of selectedExecution.dataset) {
for (const key of Object.keys(row)) {
names.add(key);
}
}
return Array.from(names);
}, [selectedExecution]);
const visibleDatasetColumnNames = useMemo(
() =>
datasetColumnNames.filter(
(name) => !hiddenDatasetColumns.includes(name),
),
[datasetColumnNames, hiddenDatasetColumns],
);
const tableColumns = useMemo<ColumnDef<Record<string, unknown>>[]>(() => {
if (!selectedExecution) {
return [];
}
return visibleDatasetColumnNames.map((name) => ({
accessorKey: name,
header: name,
cell: ({ getValue, row }) => {
const rawValue = getValue();
const value = formatCellValue(rawValue);
const rowExpanded = Boolean(expandedDatasetRows[row.id]);
const rowHasExpandableCell = visibleDatasetColumnNames.some((columnName) =>
isExpandableCellValue(formatCellValue(row.original[columnName])),
);
const showTruncated = rowHasExpandableCell && !rowExpanded;
return (
<div className="max-w-[32rem]">
<p className="whitespace-pre-wrap break-all">
{showTruncated ? truncateCellValue(value) : value}
</p>
</div>
);
},
}));
}, [expandedDatasetRows, selectedExecution, visibleDatasetColumnNames]);
const analysisColumns = useMemo(
() => parseAnalysisColumns(selectedExecution?.analysis ?? null),
[selectedExecution?.analysis],
);
const modelUsageRows = useMemo(
() => parseModelUsageRows(selectedExecution?.model_usage ?? null),
[selectedExecution?.model_usage],
);
const sideEffects = useMemo(() => {
const values = selectedExecution?.analysis?.side_effect_column_names;
return Array.isArray(values)
? values.filter((value): value is string => typeof value === "string")
: [];
}, [selectedExecution?.analysis?.side_effect_column_names]);
const canCancel = Boolean(
selectedExecution?.jobId && isExecutionInProgress(selectedExecution.status),
);
const datasetPage = selectedExecution?.datasetPage ?? 1;
const datasetPageSize = selectedExecution?.datasetPageSize ?? 20;
const datasetTotal = selectedExecution?.datasetTotal ?? 0;
const previewPageRaw = selectedExecutionIdSafe
? previewDatasetPageByExecution[selectedExecutionIdSafe] ?? 1
: 1;
const previewTotalPages = useMemo(() => {
if (!selectedExecution || selectedExecution.kind !== "preview") {
return 1;
}
return Math.max(
1,
Math.ceil(selectedExecution.dataset.length / PREVIEW_DATASET_PAGE_SIZE),
);
}, [selectedExecution]);
const previewPage = Math.min(previewPageRaw, previewTotalPages);
const totalPages =
selectedExecution?.kind === "preview"
? previewTotalPages
: Math.max(1, Math.ceil(datasetTotal / datasetPageSize));
const canPageDataset =
selectedExecution?.kind === "preview" ||
(selectedExecution?.kind === "full" && Boolean(selectedExecution.jobId));
const datasetRowsForTable = useMemo(() => {
if (!selectedExecution) {
return [];
}
if (selectedExecution.kind !== "preview") {
return selectedExecution.dataset;
}
const start = (previewPage - 1) * PREVIEW_DATASET_PAGE_SIZE;
return selectedExecution.dataset.slice(start, start + PREVIEW_DATASET_PAGE_SIZE);
}, [previewPage, selectedExecution]);
const currentDatasetPage = selectedExecution?.kind === "preview" ? previewPage : datasetPage;
const recordsMetric = useMemo(() => {
if (!selectedExecution || selectedExecution.status !== "completed") {
return null;
}
if (typeof selectedExecution.analysis?.num_records === "number") {
return selectedExecution.analysis.num_records;
}
if (selectedExecution.datasetTotal > 0) {
return selectedExecution.datasetTotal;
}
if (selectedExecution.dataset.length > 0) {
return selectedExecution.dataset.length;
}
return null;
}, [selectedExecution]);
const totalMetric = useMemo(() => {
if (!selectedExecution || selectedExecution.status !== "completed") {
return null;
}
if (typeof selectedExecution.analysis?.target_num_records === "number") {
return selectedExecution.analysis.target_num_records;
}
return selectedExecution.rows > 0 ? selectedExecution.rows : null;
}, [selectedExecution]);
const columnCount = analysisColumns.length;
const llmColumnCount = useMemo(
() =>
analysisColumns.reduce(
(acc, column) => (column.column_type.startsWith("llm") ? acc + 1 : acc),
0,
),
[analysisColumns],
);
const totalNulls = useMemo(
() =>
analysisColumns.reduce(
(acc, column) => acc + (typeof column.num_null === "number" ? column.num_null : 0),
0,
),
[analysisColumns],
);
const nullRate = useMemo(() => {
if (
typeof recordsMetric !== "number" ||
recordsMetric <= 0 ||
columnCount <= 0
) {
return null;
}
return (totalNulls / (recordsMetric * columnCount)) * 100;
}, [columnCount, recordsMetric, totalNulls]);
const lowUniquenessColumns = useMemo(() => {
if (typeof recordsMetric !== "number" || recordsMetric <= 0) {
return [];
}
return analysisColumns
.filter(
(column) =>
typeof column.num_unique === "number" &&
column.num_unique / recordsMetric < 0.5,
)
.map((column) => column.column_name);
}, [analysisColumns, recordsMetric]);
const runDuration = useMemo(() => {
if (!selectedExecution) {
return "--";
}
return formatDuration(selectedExecution.createdAt, selectedExecution.finishedAt);
}, [selectedExecution]);
const showSummaryCards = selectedExecution?.status === "completed";
const showProgressPanel =
selectedExecution?.status === "completed" ||
(selectedExecution ? isExecutionInProgress(selectedExecution.status) : false);
const progressComplete = selectedExecution?.status === "completed";
const progressPercent = selectedExecution?.progress?.percent ?? (progressComplete ? 100 : 0);
const batchTotal = selectedExecution?.batch?.total ?? null;
const batchIdx = selectedExecution?.batch?.idx ?? null;
const showBatchProgress = typeof batchTotal === "number" && batchTotal > 1;
const terminalLines = selectedExecution?.log_lines ?? [];
const rawExecution = useMemo(() => {
if (!selectedExecution) {
return null;
}
const next = { ...selectedExecution } as Record<string, unknown>;
delete next.dataset;
delete next.log_lines;
return next;
}, [selectedExecution]);
useEffect(() => {
if (!terminalRef.current) {
return;
}
shouldStickTerminalToBottomRef.current = true;
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}, [selectedExecution?.id]);
useEffect(() => {
if (!terminalRef.current) {
return;
}
if (!shouldStickTerminalToBottomRef.current) {
return;
}
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}, [terminalLines.length]);
return (
<div className="flex h-full min-h-0">
<ExecutionSidebar
executions={executions}
selectedExecutionId={selectedExecutionId}
onSelectExecution={onSelectExecution}
/>
<section className="min-w-0 flex-1 overflow-auto p-4">
{!selectedExecution ? (
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
Select an execution.
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium capitalize">{selectedExecution.kind} execution</span>
<Badge
variant="secondary"
className={cn("capitalize", statusTone(selectedExecution.status))}
>
{formatStatus(selectedExecution.status)}
</Badge>
<span>{selectedExecution.rows} rows</span>
<span>Started {formatTimestamp(selectedExecution.createdAt)}</span>
<span>
Duration {formatDuration(selectedExecution.createdAt, selectedExecution.finishedAt)}
</span>
{selectedExecution.stage && (
<span>
Stage: {selectedExecution.stage}
{selectedExecution.current_column
? ` | Column: ${selectedExecution.current_column}`
: ""}
</span>
)}
{showBatchProgress && (
<span>
Batch {batchIdx ?? "--"}/{batchTotal}
</span>
)}
{isStale && <Badge variant="outline">Recipe changed since this run</Badge>}
</div>
{showProgressPanel && (
<div
className={cn(
"space-y-3 rounded-xl border p-3",
progressComplete
? "border-emerald-200 bg-emerald-50/50 dark:border-emerald-900/50 dark:bg-emerald-950/25"
: "border-amber-200 bg-amber-50/50 dark:border-amber-900/50 dark:bg-amber-950/25",
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HugeiconsIcon
icon={progressComplete ? CheckmarkCircle02Icon : Flag02Icon}
className={cn(
"size-4",
progressComplete
? "text-emerald-700 dark:text-emerald-300"
: "text-amber-700 dark:text-amber-300",
)}
/>
<p
className={cn(
"text-sm font-semibold",
progressComplete
? "text-emerald-900 dark:text-emerald-100"
: "text-amber-900 dark:text-amber-100",
)}
>
{progressComplete ? "Run completed" : "Run in progress"}
</p>
</div>
<p
className={cn(
"text-xs",
progressComplete
? "text-emerald-800 dark:text-emerald-200"
: "text-amber-800 dark:text-amber-200",
)}
>
{formatPercent(progressPercent)}
</p>
</div>
<Progress value={progressPercent} />
<div
className={cn(
"grid gap-2 text-xs md:grid-cols-4",
progressComplete
? "text-emerald-900 dark:text-emerald-100"
: "text-amber-900 dark:text-amber-100",
)}
>
<p>Done: {selectedExecution.progress?.done ?? "--"}</p>
<p>Total: {selectedExecution.progress?.total ?? "--"}</p>
<p>Rate: {selectedExecution.progress?.rate ?? "--"} rec/s</p>
<p>ETA: {selectedExecution.progress?.eta_sec ?? "--"} s</p>
</div>
{selectedExecution.current_column && selectedExecution.column_progress && (
<p
className={cn(
"text-xs",
progressComplete
? "text-emerald-900 dark:text-emerald-100"
: "text-amber-900 dark:text-amber-100",
)}
>
Column {selectedExecution.current_column}:{" "}
{selectedExecution.column_progress.done ?? "--"}/
{selectedExecution.column_progress.total ?? "--"} (
{formatPercent(selectedExecution.column_progress.percent)})
</p>
)}
{showBatchProgress && (
<p
className={cn(
"text-xs",
progressComplete
? "text-emerald-900 dark:text-emerald-100"
: "text-amber-900 dark:text-amber-100",
)}
>
Processed batch: {batchIdx ?? "--"}/{batchTotal}
</p>
)}
</div>
)}
{(selectedExecution.status === "error" ||
selectedExecution.status === "cancelled") && (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-semibold text-destructive">
{selectedExecution.status === "cancelled"
? "Execution cancelled"
: "Execution failed"}
</p>
<p className="text-xs text-destructive">
{selectedExecution.error ?? "Unknown error."}
</p>
</div>
)}
{(selectedExecution.status === "completed" ||
isExecutionInProgress(selectedExecution.status)) && (
<Tabs value={detailTab} onValueChange={setDetailTab}>
<div className="flex items-center justify-between gap-2">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="columns">Columns</TabsTrigger>
<TabsTrigger value="data">Data</TabsTrigger>
<TabsTrigger value="raw">Raw</TabsTrigger>
</TabsList>
{canCancel && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onCancelExecution(selectedExecution.id)}
>
Cancel
</Button>
)}
</div>
<TabsContent value="overview">
<ExecutionOverviewTab
execution={selectedExecution}
showSummaryCards={showSummaryCards}
recordsMetric={recordsMetric}
totalMetric={totalMetric}
runDuration={runDuration}
columnCount={columnCount}
llmColumnCount={llmColumnCount}
nullRate={nullRate}
sideEffects={sideEffects}
lowUniquenessColumns={lowUniquenessColumns}
modelUsageRows={modelUsageRows}
terminalLines={terminalLines}
terminalRef={terminalRef}
onTerminalScroll={(event) => {
const element = event.currentTarget;
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
shouldStickTerminalToBottomRef.current =
distanceFromBottom <= TERMINAL_STICKY_BOTTOM_THRESHOLD_PX;
}}
/>
</TabsContent>
<TabsContent value="columns">
<ExecutionColumnsTab analysisColumns={analysisColumns} />
</TabsContent>
<TabsContent value="data">
<ExecutionDataTab
execution={selectedExecution}
datasetColumnNames={datasetColumnNames}
hiddenDatasetColumns={hiddenDatasetColumns}
canPageDataset={canPageDataset}
currentDatasetPage={currentDatasetPage}
totalPages={totalPages}
tableColumns={tableColumns}
datasetRowsForTable={datasetRowsForTable}
visibleDatasetColumnNames={visibleDatasetColumnNames}
expandedDatasetRows={expandedDatasetRows}
selectedExecutionIdSafe={selectedExecutionIdSafe}
onSetHiddenColumns={(updater) => {
const selectedId = selectedExecution.id;
setHiddenDatasetColumnsByExecution((current) => {
const currentColumns = current[selectedId] ?? [];
return {
...current,
[selectedId]: updater(currentColumns),
};
});
}}
onPrevPage={() => {
if (selectedExecution.kind === "preview") {
const selectedId = selectedExecution.id;
setPreviewDatasetPageByExecution((current) => ({
...current,
[selectedId]: Math.max(1, currentDatasetPage - 1),
}));
return;
}
onLoadDatasetPage(selectedExecution.id, currentDatasetPage - 1);
}}
onNextPage={() => {
if (selectedExecution.kind === "preview") {
const selectedId = selectedExecution.id;
setPreviewDatasetPageByExecution((current) => ({
...current,
[selectedId]: Math.min(totalPages, currentDatasetPage + 1),
}));
return;
}
onLoadDatasetPage(selectedExecution.id, currentDatasetPage + 1);
}}
onToggleRowExpanded={(rowId) => {
setExpandedDatasetRowsByExecution((current) => {
const rows = current[selectedExecution.id] ?? {};
return {
...current,
[selectedExecution.id]: {
...rows,
[rowId]: !rows[rowId],
},
};
});
}}
/>
</TabsContent>
<TabsContent value="raw">
<ExecutionRawTab rawExecution={rawExecution} />
</TabsContent>
</Tabs>
)}
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { useUpdateNodeInternals } from "@xyflow/react";
import { useEffect, useMemo, useRef } from "react";
type InternalsSyncProps = {
nodeIds: string[];
};
export function InternalsSync({ nodeIds }: InternalsSyncProps): null {
const updateNodeInternals = useUpdateNodeInternals();
const idsKey = useMemo(() => nodeIds.join("|"), [nodeIds]);
const nodeIdsRef = useRef(nodeIds);
nodeIdsRef.current = nodeIds;
useEffect(() => {
if (!idsKey) {
return;
}
const raf = requestAnimationFrame(() => {
updateNodeInternals(nodeIdsRef.current);
});
return () => cancelAnimationFrame(raf);
}, [idsKey, updateNodeInternals]);
return null;
}

View file

@ -0,0 +1,89 @@
import { Badge } from "@/components/ui/badge";
import { type ReactElement, useLayoutEffect, useRef, useState } from "react";
type InlineCategoryBadgesProps = {
values: string[];
};
export function InlineCategoryBadges({
values,
}: InlineCategoryBadgesProps): ReactElement {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(values.length);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const badges = Array.from(container.children) as HTMLElement[];
if (badges.length === 0) {
const id = requestAnimationFrame(() => setVisibleCount(0));
return () => cancelAnimationFrame(id);
}
const containerWidth = container.clientWidth;
// Reserve space for the "+N" badge (~36px)
const overflowBadgeWidth = 36;
let count = 0;
let usedWidth = 0;
for (const badge of badges) {
const badgeWidth = badge.scrollWidth + 4; // 4px for gap
if (usedWidth + badgeWidth > containerWidth - overflowBadgeWidth && count < badges.length - 1) {
break;
}
if (usedWidth + badgeWidth > containerWidth) {
break;
}
usedWidth += badgeWidth;
count++;
}
const id = requestAnimationFrame(() => setVisibleCount(count || 1));
return () => cancelAnimationFrame(id);
}, [values]);
if (values.length === 0) {
return <p className="text-xs text-muted-foreground">No values</p>;
}
const overflow = values.length - visibleCount;
return (
<div className="relative">
{/* Hidden measurer */}
<div
ref={containerRef}
className="pointer-events-none invisible absolute inset-x-0 top-0 flex flex-nowrap gap-1"
aria-hidden
>
{values.map((v, i) => (
<Badge
key={`m-${v}-${i}`}
variant="secondary"
className="corner-squircle h-4 shrink-0 px-1.5 text-[10px]"
>
{v}
</Badge>
))}
</div>
{/* Visible badges */}
<div className="flex flex-wrap gap-1">
{values.slice(0, visibleCount).map((v, i) => (
<Badge
key={`${v}-${i}`}
variant="secondary"
className="corner-squircle h-4 px-1.5 text-[10px]"
>
{v}
</Badge>
))}
{overflow > 0 && (
<Badge variant="outline" className="corner-squircle h-4 px-1.5 text-[10px]">
+{overflow}
</Badge>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,83 @@
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ReactElement } from "react";
import { useRecipeStudioStore } from "../../stores/recipe-studio";
import type { ExpressionConfig, ExpressionDtype } from "../../types";
import { getAvailableVariableEntries } from "../../utils/variables";
import { InlineField } from "./inline-field";
type InlineExpressionProps = {
config: ExpressionConfig;
onUpdate: (patch: Partial<ExpressionConfig>) => void;
};
const DTYPE_OPTIONS: ExpressionDtype[] = ["str", "int", "float", "bool"];
export function InlineExpression({
config,
onUpdate,
}: InlineExpressionProps): ReactElement {
const configs = useRecipeStudioStore((state) => state.configs);
const vars = getAvailableVariableEntries(configs, config.id);
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-[130px_1fr]">
<InlineField label="Output type">
<Select
value={config.dtype}
onValueChange={(value) =>
onUpdate({ dtype: value as ExpressionDtype })
}
>
<SelectTrigger className="nodrag h-8 w-full text-xs">
<SelectValue placeholder="dtype" />
</SelectTrigger>
<SelectContent>
{DTYPE_OPTIONS.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
</InlineField>
<InlineField label="Expression">
<Input
className="nodrag h-8 w-full text-xs"
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>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more