Merge branch 'nightly' into feat/gguf-llama-cpp-inference
This commit is contained in:
commit
01082b84e5
238 changed files with 28393 additions and 2371 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
51
setup.sh
51
setup.sh
|
|
@ -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
|
||||
|
|
|
|||
7
studio/backend/core/data_recipe/__init__.py
Normal file
7
studio/backend/core/data_recipe/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Data Recipe core (DataDesigner wrapper + job runner).
|
||||
"""
|
||||
|
||||
from .jobs import JobManager, get_job_manager
|
||||
|
||||
__all__ = ["JobManager", "get_job_manager"]
|
||||
4
studio/backend/core/data_recipe/jobs/__init__.py
Normal file
4
studio/backend/core/data_recipe/jobs/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .manager import JobManager, get_job_manager
|
||||
|
||||
__all__ = ["JobManager", "get_job_manager"]
|
||||
|
||||
469
studio/backend/core/data_recipe/jobs/manager.py
Normal file
469
studio/backend/core/data_recipe/jobs/manager.py
Normal 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
|
||||
241
studio/backend/core/data_recipe/jobs/parse.py
Normal file
241
studio/backend/core/data_recipe/jobs/parse.py
Normal 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)}
|
||||
72
studio/backend/core/data_recipe/jobs/types.py
Normal file
72
studio/backend/core/data_recipe/jobs/types.py
Normal 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)
|
||||
169
studio/backend/core/data_recipe/jobs/worker.py
Normal file
169
studio/backend/core/data_recipe/jobs/worker.py
Normal 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)
|
||||
174
studio/backend/core/data_recipe/service.py
Normal file
174
studio/backend/core/data_recipe/service.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}'"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
60
studio/backend/models/data_recipe.py
Normal file
60
studio/backend/models/data_recipe.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
studio/backend/requirements/single-env/constraints.txt
Normal file
16
studio/backend/requirements/single-env/constraints.txt
Normal 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
|
||||
|
|
@ -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
|
||||
5
studio/backend/requirements/single-env/data-designer.txt
Normal file
5
studio/backend/requirements/single-env/data-designer.txt
Normal 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
|
||||
74
studio/backend/requirements/single-env/patch_metadata.py
Normal file
74
studio/backend/requirements/single-env/patch_metadata.py
Normal 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())
|
||||
|
|
@ -11,4 +11,4 @@ pyjwt
|
|||
easydict
|
||||
addict
|
||||
gradio>=4.0.0
|
||||
huggingface-hub==0.36.0
|
||||
huggingface-hub==0.36.2
|
||||
|
|
@ -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",
|
||||
]
|
||||
]
|
||||
|
|
|
|||
521
studio/backend/routes/data_recipe.py
Normal file
521
studio/backend/routes/data_recipe.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
47
studio/frontend/.gitignore
vendored
47
studio/frontend/.gitignore
vendored
|
|
@ -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
37
studio/frontend/AGENTS.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
"ignore": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"test",
|
||||
"test/**",
|
||||
"**/._*",
|
||||
"._*",
|
||||
"**/.DS_Store",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
2644
studio/frontend/data-designer.openapi (1).yaml
Normal file
2644
studio/frontend/data-designer.openapi (1).yaml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
studio/frontend/public/blacklogo.png
Normal file
BIN
studio/frontend/public/blacklogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
BIN
studio/frontend/public/unsloth-gem.png
Normal file
BIN
studio/frontend/public/unsloth-gem.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
BIN
studio/frontend/public/whitelogo.png
Normal file
BIN
studio/frontend/public/whitelogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
23
studio/frontend/src/app/routes/data-recipes.$recipeId.tsx
Normal file
23
studio/frontend/src/app/routes/data-recipes.$recipeId.tsx
Normal 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} />;
|
||||
}
|
||||
17
studio/frontend/src/app/routes/data-recipes.tsx
Normal file
17
studio/frontend/src/app/routes/data-recipes.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
46
studio/frontend/src/components/markdown/markdown-preview.tsx
Normal file
46
studio/frontend/src/components/markdown/markdown-preview.tsx
Normal 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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
82
studio/frontend/src/components/ui/animated-theme-toggler.tsx
Normal file
82
studio/frontend/src/components/ui/animated-theme-toggler.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
104
studio/frontend/src/components/ui/empty.tsx
Normal file
104
studio/frontend/src/components/ui/empty.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -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" &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
61
studio/frontend/src/components/ui/shine-border.tsx
Normal file
61
studio/frontend/src/components/ui/shine-border.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
79
studio/frontend/src/features/data-recipes/data/recipes-db.ts
Normal file
79
studio/frontend/src/features/data-recipes/data/recipes-db.ts
Normal 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;
|
||||
}
|
||||
2
studio/frontend/src/features/data-recipes/index.ts
Normal file
2
studio/frontend/src/features/data-recipes/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { DataRecipesPage } from "./pages/data-recipes-page";
|
||||
export { EditRecipePage } from "./pages/edit-recipe-page";
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
];
|
||||
|
|
@ -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 client’s 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 team’s 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 client’s 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 Wikipedia’s 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 article’s 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. Here’s 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 author’s 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 you’ve 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,” it’s 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,” it’s 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 you’re 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 here’s 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 Ramon’s 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 Ramon’s 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? It’s 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 Wikipedia’s 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, we’ve continually lowered and re-lowered the bar for bad Wikipedia writing. Now, let’s 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 don’t 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 it’s not, but they’re still forced to do it, that might explain the joylessness.) The point is, if you’re moved to correct these entries, we’re powerless to stop you. They’ve already given us joy, and we’re 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 man’s 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 Hogan’s “Real American” music on there. I don’t quite remembering it being critiqued by Beavis and Butt-Head. They sounded more like they liked the music and I don’t really remember any criticism of it (except for when it was going, when Butt-Head said “homework sucks”, but I’m 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 cereal’s release. “We have good news and bad news. The good news is, your latest cereal is very, very popular. The bad news is, it’s 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 didn’t break down in the body, causing many children’s 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 can’t 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 can’t 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 Perry’s 6’5” stature is severely diminished by his wearing a wig? The line in the sand has been drawn. Another comical aspect is provided by Perry’s 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
studio/frontend/src/features/data-recipes/types.ts
Normal file
19
studio/frontend/src/features/data-recipes/types.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
315
studio/frontend/src/features/recipe-studio/api/index.ts
Normal file
315
studio/frontend/src/features/recipe-studio/api/index.ts
Normal 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.
|
||||
344
studio/frontend/src/features/recipe-studio/blocks/definitions.ts
Normal file
344
studio/frontend/src/features/recipe-studio/blocks/definitions.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue