diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..13eb922f01c823c358369f5801b925540fe65b2f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +frontend/node_modules +frontend/dist +.git +__pycache__ +*.pyc +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..91a3c0624b47b0d00ab8475a17ba0c5d62d5451e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +frontend/dist/ +__pycache__/ +*.pyc +.env +backend/presets/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..1d2edf9db67135bf4ae3984a159b6032375eaa5a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Build stage for React frontend +FROM node:18-alpine as frontend-build +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + +# Production stage +FROM python:3.11-slim + +# Install nginx +RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy Python requirements and install +COPY backend/requirements.txt ./backend/ +RUN pip install --no-cache-dir -r backend/requirements.txt + +# Copy backend code +COPY backend/ ./backend/ + +# Copy built frontend from build stage +COPY --from=frontend-build /app/frontend/dist ./frontend/dist/ + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy startup script +COPY start.sh ./ +RUN chmod +x start.sh + +# HuggingFace Spaces runs on port 7860 +EXPOSE 7860 + +# Run startup script +CMD ["./start.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a7c1a0d369c4de362c382023c4a79afa3c2317df --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +--- +title: Aggregate Trace Visualizer +emoji: 📊 +colorFrom: blue +colorTo: purple +sdk: docker +pinned: false +--- + +# Aggregate Trace Visualizer + +A unified interface for four trace visualization tools: + +- **Model Trace** - Analyze reasoning traces from model responses (think tags, backtracks, restarts) +- **Arena** - Explore multi-agent game episodes and transcripts +- **RLM** - Navigate hierarchical RLM call traces (GEPA iterations, RLM calls) +- **Harbor** - View SWE-bench agent trajectories (ATIF + raw message formats) + +Each visualizer loads datasets from HuggingFace and supports preset configurations stored in `reasoning-degeneration-dev/AGG_VIS_PRESETS`. diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/api/arena_datasets.py b/backend/api/arena_datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..e424a3106955809aec35dbcc512c691e68ac5935 --- /dev/null +++ b/backend/api/arena_datasets.py @@ -0,0 +1,280 @@ +import json +import hashlib +import os +from flask import Blueprint, request, jsonify +from datasets import load_dataset, Dataset + +bp = Blueprint("arena_datasets", __name__, url_prefix="/api/arena/datasets") + +# In-memory cache: id -> dataset info +_cache: dict[str, dict] = {} + + +def _make_id(repo: str, split: str) -> str: + key = f"{repo}:{split}" + return hashlib.md5(key.encode()).hexdigest()[:12] + + +def _load_hf_dataset(repo: str, split: str) -> Dataset: + if os.path.exists(repo): + return Dataset.from_parquet(repo) + return load_dataset(repo, split=split) + + +def _detect_arena_dataset(columns: list[str]) -> bool: + """Check if this looks like an arena evaluation dataset.""" + required = {"game_id", "env_id", "transcript"} + return required.issubset(set(columns)) + + +def _analyze_action(text: str) -> dict: + """Split tags from action text and compute analytics.""" + if not text: + return {"think_text": "", "action_text": "", "think_len": 0, "action_len": 0, + "backtracks": 0, "restarts": 0} + + think_end = text.find("") + if think_end > 0: + think_text = text[:think_end + 8] + action_text = text[think_end + 8:].strip() + else: + think_text = "" + action_text = text + + t = text.lower() + backtracks = sum(t.count(w) for w in + ["wait,", "wait ", "hmm", "let me try", "try again", + "another approach", "let me reconsider"]) + restarts = sum(t.count(w) for w in + ["start over", "fresh approach", "different approach", "from scratch"]) + + return { + "think_text": think_text, + "action_text": action_text, + "think_len": len(think_text), + "action_len": len(action_text), + "backtracks": backtracks, + "restarts": restarts, + } + + +def _dedup_observation(text: str, prev_text: str) -> str: + """Remove content duplicated from the previous observation. + + TextArena accumulates the full chat history in each observation, + so turn N's observation repeats everything from turns 0..N-1 + plus echoed [Player] actions. We strip the repeated prefix and + the echoed player actions, keeping only new [GAME]/[Moderator] + content for this turn. + """ + import re + + if not text: + return "" + if not prev_text: + return text + + new_part = None + + # The previous observation text should appear as a prefix of the + # current one. Strip it to get only what's new. + if text.startswith(prev_text): + new_part = text[len(prev_text):].strip() + else: + # Fallback: find the longest common prefix + min_len = min(len(text), len(prev_text)) + common = 0 + for i in range(min_len): + if text[i] == prev_text[i]: + common = i + 1 + else: + break + + if common > len(prev_text) * 0.8: + new_part = text[common:].strip() + + if not new_part: + return text + + # After stripping the observation prefix, the remaining text typically + # starts with echoed [Player] actions (already shown in action bubbles), + # followed by new [GAME] or [Moderator] content. Strip the echoed + # player actions to keep only the new game content. + game_marker = re.search(r'\[GAME\]|\[Moderator\]', new_part) + if game_marker: + game_content = new_part[game_marker.start():].strip() + return game_content if game_content else new_part + + return new_part + + +def _get_env_ids(ds: Dataset) -> list[str]: + """Get sorted unique env_ids from dataset.""" + return sorted(set(ds["env_id"])) + + +def _group_episodes_by_env(ds: Dataset) -> dict[str, list[int]]: + """Group row indices by env_id.""" + groups: dict[str, list[int]] = {} + for i in range(len(ds)): + env_id = ds[i]["env_id"] + if env_id not in groups: + groups[env_id] = [] + groups[env_id].append(i) + return groups + + +@bp.route("/load", methods=["POST"]) +def load_dataset_endpoint(): + data = request.get_json() + repo = data.get("repo", "").strip() + if not repo: + return jsonify({"error": "repo is required"}), 400 + + split = data.get("split", "train") + + try: + ds = _load_hf_dataset(repo, split) + except Exception as e: + return jsonify({"error": f"Failed to load dataset: {e}"}), 400 + + columns = ds.column_names + if not _detect_arena_dataset(columns): + return jsonify({ + "error": f"Not an arena dataset. Expected columns: game_id, env_id, transcript. Found: {columns}" + }), 400 + + env_ids = _get_env_ids(ds) + episode_groups = _group_episodes_by_env(ds) + ds_id = _make_id(repo, split) + short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo + + # Extract model name from first row + model_name = ds[0].get("model", "unknown") if len(ds) > 0 else "unknown" + + # Compute win/loss/error counts + wins = sum(1 for r in ds["reward"] if r is not None and r > 0) + losses = sum(1 for i in range(len(ds)) if ds[i]["reward"] is not None and ds[i]["reward"] <= 0) + errors = sum(1 for e in ds["error"] if e is not None) + + _cache[ds_id] = { + "dataset": ds, + "repo": repo, + "split": split, + "n_rows": len(ds), + "env_ids": env_ids, + "episode_groups": episode_groups, + "model_name": model_name, + "stats": {"wins": wins, "losses": losses, "errors": errors}, + } + + return jsonify({ + "id": ds_id, + "repo": repo, + "name": short_name, + "split": split, + "columns": columns, + "n_rows": len(ds), + "env_ids": env_ids, + "episodes_per_env": {env: len(idxs) for env, idxs in episode_groups.items()}, + "model_name": model_name, + "stats": {"wins": wins, "losses": losses, "errors": errors}, + }) + + +@bp.route("/", methods=["GET"]) +def list_datasets(): + result = [] + for ds_id, info in _cache.items(): + result.append({ + "id": ds_id, + "repo": info["repo"], + "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"], + "split": info["split"], + "n_rows": info["n_rows"], + "env_ids": info["env_ids"], + "model_name": info["model_name"], + }) + return jsonify(result) + + +@bp.route("//episode//", methods=["GET"]) +def get_episode(ds_id, env_id, idx): + """Get a single episode by env_id and episode index within that env.""" + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + + info = _cache[ds_id] + ds = info["dataset"] + episode_groups = info["episode_groups"] + + if env_id not in episode_groups: + return jsonify({"error": f"env_id '{env_id}' not found"}), 404 + + indices = episode_groups[env_id] + if idx < 0 or idx >= len(indices): + return jsonify({"error": f"Episode index {idx} out of range (0-{len(indices)-1})"}), 400 + + row_idx = indices[idx] + row = ds[row_idx] + + # Parse transcript JSON + transcript_raw = row.get("transcript", "[]") + try: + transcript = json.loads(transcript_raw) if isinstance(transcript_raw, str) else transcript_raw + except json.JSONDecodeError: + transcript = [] + + # Analyze each turn: dedup observations, split think tags from actions + analyzed_turns = [] + prev_obs_raw = "" + for turn in transcript: + action_analysis = _analyze_action(turn.get("action", "")) + obs_raw = turn.get("observation", "") + obs_deduped = _dedup_observation(obs_raw, prev_obs_raw) + prev_obs_raw = obs_raw + analyzed_turns.append({ + "turn": turn.get("turn", 0), + "player_id": turn.get("player_id", 0), + "observation": obs_deduped, + "action": turn.get("action", ""), + "think_text": action_analysis["think_text"], + "action_text": action_analysis["action_text"], + "think_len": action_analysis["think_len"], + "backtracks": action_analysis["backtracks"], + }) + + # Determine outcome + reward = row.get("reward") + error = row.get("error") + if error: + outcome = "error" + elif reward is not None and reward > 0: + outcome = "win" + elif reward is not None: + outcome = "loss" + else: + outcome = "unknown" + + return jsonify({ + "game_id": row.get("game_id", ""), + "env_id": row.get("env_id", ""), + "model": row.get("model", ""), + "opponent_model": row.get("opponent_model"), + "player_id": row.get("player_id", 0), + "reward": reward, + "num_turns": row.get("num_turns", len(transcript)), + "error": error, + "outcome": outcome, + "transcript": analyzed_turns, + "system_prompt": row.get("system_prompt", None), + "episode_idx": idx, + "total_episodes": len(indices), + }) + + +@bp.route("/", methods=["DELETE"]) +def unload_dataset(ds_id): + if ds_id in _cache: + del _cache[ds_id] + return jsonify({"status": "ok"}) diff --git a/backend/api/harbor_datasets.py b/backend/api/harbor_datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..605a51117e91fc891436c39124d32e60d9866190 --- /dev/null +++ b/backend/api/harbor_datasets.py @@ -0,0 +1,287 @@ +import json +import hashlib +from flask import Blueprint, request, jsonify +from datasets import load_dataset + +bp = Blueprint("harbor_datasets", __name__, url_prefix="/api/harbor/datasets") + +_cache: dict[str, dict] = {} + + +def _make_id(repo: str, split: str) -> str: + key = f"{repo}:{split}" + return hashlib.md5(key.encode()).hexdigest()[:12] + + +def _parse_trajectory(traj_json: str) -> dict: + """Parse ATIF-v1.2 trajectory JSON into structured steps.""" + if not traj_json: + return {"steps": [], "agent_info": {}, "final_metrics": {}} + + try: + traj = json.loads(traj_json) if isinstance(traj_json, str) else traj_json + except (json.JSONDecodeError, TypeError): + return {"steps": [], "agent_info": {}, "final_metrics": {}} + + steps = [] + for step in traj.get("steps", []): + parsed = { + "index": len(steps), + "source": step.get("source", "unknown"), + "message": step.get("message", ""), + "timestamp": step.get("timestamp"), + } + if step.get("source") == "agent": + parsed["reasoning"] = step.get("reasoning_content", "") + parsed["tool_calls"] = [] + for tc in step.get("tool_calls", []): + tool_call = { + "function": tc.get("function_name", ""), + "arguments": tc.get("arguments", {}), + } + cmd = tc.get("arguments", {}).get("command", "") + if cmd: + tool_call["command"] = cmd + parsed["tool_calls"].append(tool_call) + + obs = step.get("observation", {}) + if obs: + if isinstance(obs, dict) and "results" in obs: + results = obs["results"] + if results: + parsed["observation"] = results[0].get("content", "") if isinstance(results[0], dict) else str(results[0]) + else: + parsed["observation"] = "" + elif isinstance(obs, str): + parsed["observation"] = obs + else: + parsed["observation"] = json.dumps(obs) + + parsed["metrics"] = step.get("metrics", {}) + elif step.get("source") == "system": + pass # message is enough + elif step.get("source") == "user": + pass # message is enough + + steps.append(parsed) + + return { + "steps": steps, + "agent_info": traj.get("agent", {}), + "final_metrics": traj.get("final_metrics", {}), + } + + +def _parse_trajectory_raw(traj_raw: str) -> list[dict]: + """Parse trajectory_raw into chat-style steps. + + Handles two formats: + 1. Flat list of OpenAI messages + 2. Dict with {info, messages, trajectory_format} (mini-swe-agent format) + """ + if not traj_raw: + return [] + + try: + parsed = json.loads(traj_raw) if isinstance(traj_raw, str) else traj_raw + except (json.JSONDecodeError, TypeError): + return [] + + # Extract messages list and optional info + info = {} + if isinstance(parsed, dict): + info = parsed.get("info", {}) + messages = parsed.get("messages", []) + elif isinstance(parsed, list): + messages = parsed + else: + return [] + + steps = [] + for i, msg in enumerate(messages): + if not isinstance(msg, dict): + continue + + role = msg.get("role", "unknown") + content = msg.get("content", "") + + step = { + "index": i, + "role": role, + "content": content if isinstance(content, str) else json.dumps(content) if content else "", + } + + # Assistant messages may have tool_calls (OpenAI format) + if role == "assistant" and "tool_calls" in msg: + tool_calls = msg["tool_calls"] + step["tool_calls"] = [] + for tc in (tool_calls if isinstance(tool_calls, list) else []): + fn = tc.get("function", {}) + call = { + "id": tc.get("id", ""), + "function": fn.get("name", ""), + "arguments_raw": fn.get("arguments", ""), + } + try: + args = json.loads(fn.get("arguments", "{}")) + call["arguments"] = args + if "command" in args: + call["command"] = args["command"] + except (json.JSONDecodeError, TypeError): + call["arguments"] = {} + step["tool_calls"].append(call) + + # Tool messages have tool_call_id + if role == "tool": + step["tool_call_id"] = msg.get("tool_call_id", "") + + steps.append(step) + + # Attach info as metadata on first step if available + if steps and info: + steps[0]["_info"] = info + + return steps + + +def _build_instance_summary(row: dict) -> dict: + """Build a summary for one instance row.""" + return { + "instance_id": row.get("instance_id", ""), + "resolved": row.get("resolved", False), + "reward": row.get("reward", 0), + "model": row.get("model", ""), + "agent": row.get("agent", ""), + "duration_seconds": row.get("duration_seconds", 0), + "error": row.get("error", ""), + } + + +@bp.route("/load", methods=["POST"]) +def load_dataset_endpoint(): + data = request.get_json() + repo = data.get("repo", "").strip() + if not repo: + return jsonify({"error": "repo is required"}), 400 + + split = data.get("split", "train") + + try: + ds = load_dataset(repo, split=split) + except Exception as e: + return jsonify({"error": f"Failed to load dataset: {e}"}), 400 + + ds_id = _make_id(repo, split) + + # Build instance summaries and index + instances = [] + instance_index = {} + for i in range(len(ds)): + row = ds[i] + summary = _build_instance_summary(row) + instances.append(summary) + instance_index[row.get("instance_id", "")] = i + + _cache[ds_id] = { + "repo": repo, + "split": split, + "dataset": ds, + "instances": instances, + "instance_index": instance_index, + } + + short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo + + return jsonify({ + "id": ds_id, + "repo": repo, + "name": short_name, + "split": split, + "instances": instances, + "n_instances": len(instances), + }) + + +@bp.route("/", methods=["GET"]) +def list_datasets(): + result = [] + for ds_id, info in _cache.items(): + result.append({ + "id": ds_id, + "repo": info["repo"], + "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"], + "split": info["split"], + "n_instances": len(info["instances"]), + "instances": info["instances"], + }) + return jsonify(result) + + +@bp.route("//instances", methods=["GET"]) +def get_instances(ds_id): + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + return jsonify(_cache[ds_id]["instances"]) + + +@bp.route("//instance/", methods=["GET"]) +def get_instance(ds_id, instance_id): + """Get full parsed trajectory for one instance.""" + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + + info = _cache[ds_id] + if instance_id not in info["instance_index"]: + return jsonify({"error": f"Instance {instance_id} not found"}), 404 + + idx = info["instance_index"][instance_id] + row = info["dataset"][idx] + + # Parse ATIF trajectory + atif = _parse_trajectory(row.get("trajectory", "")) + + # Parse raw trajectory (OpenAI messages) + raw_steps = _parse_trajectory_raw(row.get("trajectory_raw", "")) + + return jsonify({ + "instance_id": instance_id, + "resolved": row.get("resolved", False), + "reward": row.get("reward", 0), + "model": row.get("model", ""), + "agent": row.get("agent", ""), + "duration_seconds": row.get("duration_seconds", 0), + "error": row.get("error", ""), + "atif": atif, + "raw_steps": raw_steps, + "n_atif_steps": len(atif["steps"]), + "n_raw_steps": len(raw_steps), + }) + + +@bp.route("//instance//raw", methods=["GET"]) +def get_instance_raw(ds_id, instance_id): + """Get raw logs: agent_stdout, setup_stderr, verifier_report.""" + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + + info = _cache[ds_id] + if instance_id not in info["instance_index"]: + return jsonify({"error": f"Instance {instance_id} not found"}), 404 + + idx = info["instance_index"][instance_id] + row = info["dataset"][idx] + + return jsonify({ + "instance_id": instance_id, + "agent_stdout": row.get("agent_stdout", ""), + "setup_stderr": row.get("setup_stderr", ""), + "verifier_report": row.get("verifier_report", ""), + "verifier_stdout": row.get("verifier_stdout", ""), + }) + + +@bp.route("/", methods=["DELETE"]) +def unload_dataset(ds_id): + if ds_id in _cache: + del _cache[ds_id] + return jsonify({"status": "ok"}) diff --git a/backend/api/model_datasets.py b/backend/api/model_datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..2f288bfe147a88ea7714807c6599743d407637ff --- /dev/null +++ b/backend/api/model_datasets.py @@ -0,0 +1,332 @@ +import json +import os +import hashlib +from flask import Blueprint, request, jsonify +from datasets import load_dataset, Dataset + +bp = Blueprint("model_datasets", __name__, url_prefix="/api/model/datasets") + +# In-memory cache: id -> {dataset, repo, column, split, n_rows, n_samples} +_cache: dict[str, dict] = {} + + +def _make_id(repo: str, column: str, split: str) -> str: + key = f"{repo}:{column}:{split}" + return hashlib.md5(key.encode()).hexdigest()[:12] + + +def _load_hf_dataset(repo: str, split: str) -> Dataset: + if os.path.exists(repo): + return Dataset.from_parquet(repo) + return load_dataset(repo, split=split) + + +def _detect_response_column(columns: list[str], preferred: str) -> str: + if preferred in columns: + return preferred + for fallback in ["model_responses", "response", "responses", "output", "outputs"]: + if fallback in columns: + return fallback + return preferred + + +def _detect_prompt_column(columns: list[str], preferred: str) -> str | None: + if preferred in columns: + return preferred + for fallback in ["formatted_prompt", "prompt", "question", "input"]: + if fallback in columns: + return fallback + return None + + +def _compute_question_fingerprint(ds: Dataset, n: int = 5) -> str: + """Hash first N question texts to fingerprint the question set.""" + questions = [] + for i in range(min(n, len(ds))): + row = ds[i] + for qcol in ["question", "prompt", "input", "formatted_prompt"]: + if qcol in row: + questions.append(str(row[qcol] or "")[:200]) + break + return hashlib.md5("||".join(questions).encode()).hexdigest()[:8] + + +def _count_samples(ds: Dataset, column: str) -> int: + if len(ds) == 0: + return 0 + first = ds[0][column] + if isinstance(first, list): + return len(first) + return 1 + + +def _flatten_evals(evals) -> list[bool]: + if not isinstance(evals, list): + return [bool(evals)] + return [ + bool(e[-1]) if isinstance(e, list) and len(e) > 0 + else (bool(e) if not isinstance(e, list) else False) + for e in evals + ] + + +def _extract_reasoning(meta: dict | None) -> str | None: + """Extract reasoning/thinking content from response metadata's raw_response.""" + if not meta or not isinstance(meta, dict): + return None + raw = meta.get("raw_response") + if not raw or not isinstance(raw, dict): + return None + try: + msg = raw["choices"][0]["message"] + return ( + msg.get("reasoning_content") + or msg.get("thinking") + or msg.get("reasoning") + ) + except (KeyError, IndexError, TypeError): + return None + + +def _merge_reasoning_into_response(response: str, reasoning: str | None) -> str: + """Prepend {reasoning} to response if reasoning exists + and isn't already present in the response.""" + if not reasoning: + return response or "" + response = response or "" + # Don't double-add if response already contains the thinking + if "" in response: + return response + return f"{reasoning}\n{response}" + + +def _analyze_trace(text: str) -> dict: + if not text: + return dict(total_len=0, think_len=0, answer_len=0, + backtracks=0, restarts=0, think_text="", answer_text="") + think_end = text.find("") + if think_end > 0: + # Keep raw tags so display is 1:1 with HuggingFace data + think_text = text[:think_end + 8] # include + answer_text = text[think_end + 8:].strip() + else: + think_text = text + answer_text = "" + t = text.lower() + backtracks = sum(t.count(w) for w in + ["wait,", "wait ", "hmm", "let me try", "try again", + "another approach", "let me reconsider"]) + restarts = sum(t.count(w) for w in + ["start over", "fresh approach", "different approach", "from scratch"]) + return dict(total_len=len(text), think_len=len(think_text), + answer_len=len(answer_text), backtracks=backtracks, + restarts=restarts, think_text=think_text, answer_text=answer_text) + + +@bp.route("/load", methods=["POST"]) +def load_dataset_endpoint(): + data = request.get_json() + repo = data.get("repo", "").strip() + if not repo: + return jsonify({"error": "repo is required"}), 400 + + split = data.get("split", "train") + preferred_column = data.get("column", "model_responses") + preferred_prompt_column = data.get("prompt_column", "formatted_prompt") + + try: + ds = _load_hf_dataset(repo, split) + except Exception as e: + return jsonify({"error": f"Failed to load dataset: {e}"}), 400 + + columns = ds.column_names + column = _detect_response_column(columns, preferred_column) + prompt_column = _detect_prompt_column(columns, preferred_prompt_column) + + if column not in columns: + return jsonify({ + "error": f"Column '{column}' not found. Available: {columns}" + }), 400 + + n_samples = _count_samples(ds, column) + ds_id = _make_id(repo, column, split) + fingerprint = _compute_question_fingerprint(ds) + + _cache[ds_id] = { + "dataset": ds, + "repo": repo, + "column": column, + "prompt_column": prompt_column, + "split": split, + "n_rows": len(ds), + "n_samples": n_samples, + "question_fingerprint": fingerprint, + } + + short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo + + return jsonify({ + "id": ds_id, + "repo": repo, + "name": short_name, + "column": column, + "prompt_column": prompt_column, + "columns": columns, + "split": split, + "n_rows": len(ds), + "n_samples": n_samples, + "question_fingerprint": fingerprint, + }) + + +@bp.route("/", methods=["GET"]) +def list_datasets(): + result = [] + for ds_id, info in _cache.items(): + result.append({ + "id": ds_id, + "repo": info["repo"], + "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"], + "column": info["column"], + "split": info["split"], + "n_rows": info["n_rows"], + "n_samples": info["n_samples"], + "question_fingerprint": info.get("question_fingerprint", ""), + }) + return jsonify(result) + + +@bp.route("//question/", methods=["GET"]) +def get_question(ds_id, idx): + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + + info = _cache[ds_id] + ds = info["dataset"] + column = info["column"] + + if idx < 0 or idx >= len(ds): + return jsonify({"error": f"Index {idx} out of range (0-{len(ds)-1})"}), 400 + + row = ds[idx] + responses_raw = row[column] + if not isinstance(responses_raw, list): + responses_raw = [responses_raw] + + # Check for {column}__metadata to recover reasoning/thinking content + meta_column = f"{column}__metadata" + response_metas = None + if meta_column in row: + response_metas = row[meta_column] + if not isinstance(response_metas, list): + response_metas = [response_metas] + + # Merge reasoning from metadata into responses + merged_responses = [] + for i, resp in enumerate(responses_raw): + meta = response_metas[i] if response_metas and i < len(response_metas) else None + reasoning = _extract_reasoning(meta) + merged_responses.append(_merge_reasoning_into_response(resp, reasoning)) + responses_raw = merged_responses + + # Prompt text from configured prompt column + prompt_text = "" + prompt_col = info.get("prompt_column") + if prompt_col and prompt_col in row: + val = row[prompt_col] + if isinstance(val, str): + prompt_text = val + elif isinstance(val, list): + prompt_text = json.dumps(val) + elif val is not None: + prompt_text = str(val) + + question = "" + for qcol in ["question", "prompt", "input", "formatted_prompt"]: + if qcol in row: + question = row[qcol] or "" + break + + eval_correct = [] + if "eval_correct" in row: + eval_correct = _flatten_evals(row["eval_correct"]) + + # Check extractions with column-aware name + extractions = [] + extractions_col = f"{column}__extractions" + for ecol in [extractions_col, "response__extractions"]: + if ecol in row: + ext = row[ecol] + if isinstance(ext, list): + extractions = [str(e) for e in ext] + break + + metadata = {} + if "metadata" in row: + metadata = row["metadata"] if isinstance(row["metadata"], dict) else {} + + analyses = [_analyze_trace(r or "") for r in responses_raw] + + return jsonify({ + "question": question, + "prompt_text": prompt_text, + "responses": [r or "" for r in responses_raw], + "eval_correct": eval_correct, + "extractions": extractions, + "metadata": metadata, + "analyses": analyses, + "n_samples": len(responses_raw), + "index": idx, + }) + + +@bp.route("//summary", methods=["GET"]) +def get_summary(ds_id): + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + + info = _cache[ds_id] + ds = info["dataset"] + n_rows = info["n_rows"] + n_samples = info["n_samples"] + + if "eval_correct" not in ds.column_names: + return jsonify({ + "n_rows": n_rows, + "n_samples": n_samples, + "has_eval": False, + }) + + pass_at = {} + for k in [1, 2, 4, 8]: + if k > n_samples: + break + correct = sum(1 for i in range(n_rows) + if any(_flatten_evals(ds[i]["eval_correct"])[:k])) + pass_at[k] = {"correct": correct, "total": n_rows, + "rate": correct / n_rows if n_rows > 0 else 0} + + total_samples = n_rows * n_samples + total_correct = sum( + sum(_flatten_evals(ds[i]["eval_correct"])) + for i in range(n_rows) + ) + + return jsonify({ + "n_rows": n_rows, + "n_samples": n_samples, + "has_eval": True, + "sample_accuracy": { + "correct": total_correct, + "total": total_samples, + "rate": total_correct / total_samples if total_samples > 0 else 0, + }, + "pass_at": pass_at, + }) + + +@bp.route("/", methods=["DELETE"]) +def unload_dataset(ds_id): + if ds_id in _cache: + del _cache[ds_id] + return jsonify({"status": "ok"}) diff --git a/backend/api/presets.py b/backend/api/presets.py new file mode 100644 index 0000000000000000000000000000000000000000..0b6f64b5cfefd99d01e2a581be56071b554062e4 --- /dev/null +++ b/backend/api/presets.py @@ -0,0 +1,189 @@ +import json +import os +import uuid +import tempfile +import threading +from flask import Blueprint, request, jsonify + +bp = Blueprint("presets", __name__, url_prefix="/api/presets") + +PRESETS_REPO = "reasoning-degeneration-dev/AGG_VIS_PRESETS" +VALID_TYPES = {"model", "arena", "rlm", "harbor"} +LOCAL_PRESETS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "presets") + +# In-memory cache: vis_type -> list[dict] +_cache: dict[str, list[dict]] = {} +_cache_loaded: set[str] = set() +_lock = threading.Lock() + + +def _ensure_local_dir(): + os.makedirs(LOCAL_PRESETS_DIR, exist_ok=True) + + +def _local_path(vis_type: str) -> str: + _ensure_local_dir() + return os.path.join(LOCAL_PRESETS_DIR, f"{vis_type}_presets.json") + + +def _download_presets(vis_type: str) -> list[dict]: + """Download presets from HuggingFace, falling back to local file.""" + try: + from huggingface_hub import hf_hub_download + path = hf_hub_download( + PRESETS_REPO, + f"{vis_type}_presets.json", + repo_type="dataset", + ) + with open(path) as f: + presets = json.load(f) + # Cache locally for offline fallback + with open(_local_path(vis_type), "w") as f: + json.dump(presets, f, indent=2) + return presets + except Exception: + # Fall back to local cache + local = _local_path(vis_type) + if os.path.exists(local): + with open(local) as f: + return json.load(f) + return [] + + +def _upload_presets(vis_type: str, presets: list[dict]): + """Upload presets to HuggingFace (best-effort, non-blocking).""" + # Always save locally first + with open(_local_path(vis_type), "w") as f: + json.dump(presets, f, indent=2) + + def _do_upload(): + try: + from huggingface_hub import HfApi + api = HfApi() + # Ensure repo exists + try: + api.create_repo( + PRESETS_REPO, + repo_type="dataset", + exist_ok=True, + ) + except Exception: + pass + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f: + json.dump(presets, f, indent=2) + tmp = f.name + api.upload_file( + path_or_fileobj=tmp, + path_in_repo=f"{vis_type}_presets.json", + repo_id=PRESETS_REPO, + repo_type="dataset", + ) + os.unlink(tmp) + except Exception as e: + print(f"[presets] HF upload failed for {vis_type}: {e}") + + threading.Thread(target=_do_upload, daemon=True).start() + + +def _get_presets(vis_type: str) -> list[dict]: + """Get presets for a visualizer type, downloading if needed.""" + with _lock: + if vis_type not in _cache_loaded: + _cache[vis_type] = _download_presets(vis_type) + _cache_loaded.add(vis_type) + return list(_cache.get(vis_type, [])) + + +def _set_presets(vis_type: str, presets: list[dict]): + """Update presets in cache and sync to HF.""" + with _lock: + _cache[vis_type] = presets + _cache_loaded.add(vis_type) + _upload_presets(vis_type, presets) + + +@bp.route("/", methods=["GET"]) +def list_presets(vis_type): + if vis_type not in VALID_TYPES: + return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400 + return jsonify(_get_presets(vis_type)) + + +@bp.route("/", methods=["POST"]) +def create_preset(vis_type): + if vis_type not in VALID_TYPES: + return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400 + + data = request.get_json() + name = data.get("name", "").strip() + + if not name: + return jsonify({"error": "name is required"}), 400 + + preset = { + "id": uuid.uuid4().hex[:8], + "name": name, + } + # Include type-specific fields + repo = data.get("repo", "").strip() + if not repo: + return jsonify({"error": "repo is required"}), 400 + preset["repo"] = repo + preset["split"] = data.get("split", "train") + + if vis_type == "model": + preset["column"] = data.get("column", "model_responses") + elif vis_type == "rlm": + preset["config"] = data.get("config", "rlm_call_traces") + + presets = _get_presets(vis_type) + presets.append(preset) + _set_presets(vis_type, presets) + + return jsonify(preset), 201 + + +@bp.route("//", methods=["PUT"]) +def update_preset(vis_type, preset_id): + if vis_type not in VALID_TYPES: + return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400 + + data = request.get_json() + presets = _get_presets(vis_type) + + for p in presets: + if p["id"] == preset_id: + if "name" in data: + p["name"] = data["name"].strip() + if "column" in data: + p["column"] = data["column"] + if "split" in data: + p["split"] = data["split"] + if "config" in data: + p["config"] = data["config"] + _set_presets(vis_type, presets) + return jsonify(p) + + return jsonify({"error": "not found"}), 404 + + +@bp.route("//", methods=["DELETE"]) +def delete_preset(vis_type, preset_id): + if vis_type not in VALID_TYPES: + return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400 + + presets = _get_presets(vis_type) + presets = [p for p in presets if p["id"] != preset_id] + _set_presets(vis_type, presets) + return jsonify({"status": "ok"}) + + +@bp.route("/sync", methods=["POST"]) +def sync_presets(): + """Force re-download presets from HF.""" + with _lock: + _cache.clear() + _cache_loaded.clear() + for vt in VALID_TYPES: + _get_presets(vt) + return jsonify({"status": "ok"}) diff --git a/backend/api/rlm_datasets.py b/backend/api/rlm_datasets.py new file mode 100644 index 0000000000000000000000000000000000000000..b666b173d47ea7534e4a685898a4f9444df81266 --- /dev/null +++ b/backend/api/rlm_datasets.py @@ -0,0 +1,263 @@ +import json +import hashlib +from flask import Blueprint, request, jsonify +from datasets import load_dataset + +bp = Blueprint("rlm_datasets", __name__, url_prefix="/api/rlm/datasets") + +_cache: dict[str, dict] = {} + + +def _make_id(repo: str, config: str, split: str) -> str: + key = f"{repo}:{config}:{split}" + return hashlib.md5(key.encode()).hexdigest()[:12] + + +def _build_hierarchy(rows: list[dict]) -> dict: + """Reconstruct hierarchy from flat rlm_call_traces rows.""" + gepa_iters: dict[int, dict] = {} + + for row in rows: + gi = row.get("gepa_iter", 0) + rci = row.get("rlm_call_idx", 0) + ri = row.get("rlm_iter", 0) + + if gi not in gepa_iters: + gepa_iters[gi] = { + "gepa_iter": gi, + "rlm_calls": {}, + "total_input_tokens": 0, + "total_output_tokens": 0, + "total_execution_time": 0.0, + "final_answer": None, + } + + gi_data = gepa_iters[gi] + if rci not in gi_data["rlm_calls"]: + gi_data["rlm_calls"][rci] = { + "rlm_call_idx": rci, + "iterations": [], + } + + # Parse code blocks + code_blocks = [] + cbj = row.get("code_blocks_json", "") + if cbj and cbj != "[]": + try: + code_blocks = json.loads(cbj) if isinstance(cbj, str) else cbj + except (json.JSONDecodeError, TypeError): + code_blocks = [] + + iteration = { + "rlm_iter": ri, + "prompt": row.get("prompt", ""), + "response": row.get("response", ""), + "model": row.get("model", ""), + "input_tokens": row.get("input_tokens", 0), + "output_tokens": row.get("output_tokens", 0), + "execution_time": row.get("execution_time", 0.0), + "has_code_blocks": row.get("has_code_blocks", False), + "code_blocks": code_blocks, + "final_answer": row.get("final_answer"), + "subcall_id": row.get("subcall_id"), + "parent_id": row.get("parent_id"), + "timestamp": row.get("timestamp", ""), + } + + gi_data["rlm_calls"][rci]["iterations"].append(iteration) + gi_data["total_input_tokens"] += iteration["input_tokens"] or 0 + gi_data["total_output_tokens"] += iteration["output_tokens"] or 0 + gi_data["total_execution_time"] += iteration["execution_time"] or 0.0 + + if iteration["final_answer"]: + gi_data["final_answer"] = iteration["final_answer"] + + # Sort and convert dicts to lists + result = [] + for gi_key in sorted(gepa_iters.keys()): + gi_data = gepa_iters[gi_key] + rlm_calls = [] + for rci_key in sorted(gi_data["rlm_calls"].keys()): + call = gi_data["rlm_calls"][rci_key] + call["iterations"].sort(key=lambda x: x["rlm_iter"]) + rlm_calls.append(call) + gi_data["rlm_calls"] = rlm_calls + result.append(gi_data) + + return {"gepa_iterations": result} + + +@bp.route("/load", methods=["POST"]) +def load_dataset_endpoint(): + data = request.get_json() + repo = data.get("repo", "").strip() + if not repo: + return jsonify({"error": "repo is required"}), 400 + + config = data.get("config", "rlm_call_traces") + split = data.get("split", "train") + + try: + ds = load_dataset(repo, config, split=split) + except Exception as e: + return jsonify({"error": f"Failed to load dataset: {e}"}), 400 + + ds_id = _make_id(repo, config, split) + rows = [ds[i] for i in range(len(ds))] + hierarchy = _build_hierarchy(rows) + + # Extract metadata from first row + first_row = rows[0] if rows else {} + metadata = { + "run_id": first_row.get("run_id", ""), + "method": first_row.get("method", ""), + "k": first_row.get("k", 0), + "model": first_row.get("model", ""), + } + + _cache[ds_id] = { + "repo": repo, + "config": config, + "split": split, + "hierarchy": hierarchy, + "metadata": metadata, + "n_rows": len(rows), + } + + short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo + + return jsonify({ + "id": ds_id, + "repo": repo, + "name": short_name, + "config": config, + "split": split, + "metadata": metadata, + "n_gepa_iters": len(hierarchy["gepa_iterations"]), + "n_rows": len(rows), + }) + + +@bp.route("/", methods=["GET"]) +def list_datasets(): + result = [] + for ds_id, info in _cache.items(): + result.append({ + "id": ds_id, + "repo": info["repo"], + "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"], + "config": info["config"], + "split": info["split"], + "metadata": info["metadata"], + "n_rows": info["n_rows"], + "n_gepa_iters": len(info["hierarchy"]["gepa_iterations"]), + }) + return jsonify(result) + + +@bp.route("//overview", methods=["GET"]) +def get_overview(ds_id): + """Level 1: Summary of all GEPA iterations.""" + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + + info = _cache[ds_id] + hierarchy = info["hierarchy"] + + summaries = [] + for gi in hierarchy["gepa_iterations"]: + total_rlm_iters = sum(len(c["iterations"]) for c in gi["rlm_calls"]) + summaries.append({ + "gepa_iter": gi["gepa_iter"], + "n_rlm_calls": len(gi["rlm_calls"]), + "n_rlm_iters": total_rlm_iters, + "total_input_tokens": gi["total_input_tokens"], + "total_output_tokens": gi["total_output_tokens"], + "total_execution_time": gi["total_execution_time"], + "has_final_answer": gi["final_answer"] is not None, + "final_answer_preview": (gi["final_answer"] or "")[:200], + }) + + return jsonify({ + "metadata": info["metadata"], + "gepa_iterations": summaries, + }) + + +@bp.route("//gepa/", methods=["GET"]) +def get_gepa_iteration(ds_id, gepa_iter): + """Level 2: RLM timeline for a specific GEPA iteration.""" + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + + info = _cache[ds_id] + hierarchy = info["hierarchy"] + + gi_data = None + for gi in hierarchy["gepa_iterations"]: + if gi["gepa_iter"] == gepa_iter: + gi_data = gi + break + + if gi_data is None: + return jsonify({"error": f"GEPA iteration {gepa_iter} not found"}), 404 + + # Return full RLM call data with iterations (truncate prompts for timeline view) + rlm_calls = [] + for call in gi_data["rlm_calls"]: + iters = [] + for it in call["iterations"]: + iters.append({ + "rlm_iter": it["rlm_iter"], + "model": it["model"], + "input_tokens": it["input_tokens"], + "output_tokens": it["output_tokens"], + "execution_time": it["execution_time"], + "has_code_blocks": it["has_code_blocks"], + "n_code_blocks": len(it["code_blocks"]), + "response_preview": (it["response"] or "")[:300], + "has_final_answer": it["final_answer"] is not None, + "timestamp": it["timestamp"], + }) + rlm_calls.append({ + "rlm_call_idx": call["rlm_call_idx"], + "iterations": iters, + }) + + return jsonify({ + "gepa_iter": gepa_iter, + "total_input_tokens": gi_data["total_input_tokens"], + "total_output_tokens": gi_data["total_output_tokens"], + "total_execution_time": gi_data["total_execution_time"], + "final_answer": gi_data["final_answer"], + "rlm_calls": rlm_calls, + }) + + +@bp.route("//gepa//rlm//", methods=["GET"]) +def get_rlm_iteration(ds_id, gepa_iter, rlm_call_idx, rlm_iter): + """Level 3: Full detail for a specific RLM iteration.""" + if ds_id not in _cache: + return jsonify({"error": "Dataset not loaded"}), 404 + + info = _cache[ds_id] + hierarchy = info["hierarchy"] + + for gi in hierarchy["gepa_iterations"]: + if gi["gepa_iter"] != gepa_iter: + continue + for call in gi["rlm_calls"]: + if call["rlm_call_idx"] != rlm_call_idx: + continue + for it in call["iterations"]: + if it["rlm_iter"] == rlm_iter: + return jsonify(it) + + return jsonify({"error": "RLM iteration not found"}), 404 + + +@bp.route("/", methods=["DELETE"]) +def unload_dataset(ds_id): + if ds_id in _cache: + del _cache[ds_id] + return jsonify({"status": "ok"}) diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000000000000000000000000000000000000..ec9613054e7c1d0b7d8390893b0f15f23f025ec1 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,36 @@ +from flask import Flask +from flask_cors import CORS + + +def create_app(): + app = Flask(__name__, static_folder="../frontend/dist", static_url_path="/") + CORS(app) + + from backend.api import model_datasets, arena_datasets, rlm_datasets, harbor_datasets, presets + app.register_blueprint(model_datasets.bp) + app.register_blueprint(arena_datasets.bp) + app.register_blueprint(rlm_datasets.bp) + app.register_blueprint(harbor_datasets.bp) + app.register_blueprint(presets.bp) + + @app.route("/api/health") + def health(): + return {"status": "ok"} + + @app.route("/", defaults={"path": ""}) + @app.route("/") + def serve_frontend(path): + return app.send_static_file("index.html") + + return app + + +app = create_app() + + +def main(): + app.run(debug=True, port=8080) + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..0a0e8c632c8ab8f7d9691510cdfb182f78647705 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +flask>=3.0.0 +flask-cors>=4.0.0 +datasets>=2.14.0 +gunicorn>=21.0.0 +huggingface_hub>=0.20.0 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e43a6cc30f2c8b7face4f8d8d3e4fa3c88488b88 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Aggregate Trace Visualizer + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..a3ff7b75f0e5a46d9b70fbfe478b614dfc094dc8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2766 @@ +{ + "name": "agg-visualizer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agg-visualizer", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5e20748f9c42997adb4645c00a173d58b0ecd7c0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "agg-visualizer", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "^5.6.3", + "vite": "^6.0.3" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2aa7205d4b402a1bdfbe07110c61df920b370066 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8ae127d208a02037528c9a165d049a8540f6ec3 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,73 @@ +import { useState, lazy, Suspense } from "react"; + +const ModelApp = lazy(() => import("./model/ModelApp")); +const ArenaApp = lazy(() => import("./arena/ArenaApp")); +const RlmApp = lazy(() => import("./rlm/RlmApp")); +const HarborApp = lazy(() => import("./harbor/HarborApp")); + +type TabId = "model" | "arena" | "rlm" | "harbor"; + +const TABS: { id: TabId; label: string; color: string; activeClass: string }[] = [ + { id: "model", label: "Model Trace", color: "blue", activeClass: "border-blue-500 text-blue-400" }, + { id: "arena", label: "Arena", color: "purple", activeClass: "border-purple-500 text-purple-400" }, + { id: "rlm", label: "RLM", color: "orange", activeClass: "border-orange-500 text-orange-400" }, + { id: "harbor", label: "Harbor", color: "teal", activeClass: "border-teal-500 text-teal-400" }, +]; + +export default function App() { + const [activeTab, setActiveTab] = useState("model"); + + return ( +
+ {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
Aggregate Trace Visualizer
+
+ + {/* Active visualizer */} +
+ + Loading... +
+ } + > + {activeTab === "model" && ( +
+ +
+ )} + {activeTab === "arena" && ( +
+ +
+ )} + {activeTab === "rlm" && ( +
+ +
+ )} + {activeTab === "harbor" && ( +
+ +
+ )} + +
+ + ); +} diff --git a/frontend/src/arena/ArenaApp.tsx b/frontend/src/arena/ArenaApp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..efebe2b9713afde43d16e3cdc4c3868729af9f13 --- /dev/null +++ b/frontend/src/arena/ArenaApp.tsx @@ -0,0 +1,211 @@ +import { useEffect, useCallback, useRef, useState } from "react"; +import { useAppState } from "./store"; +import Sidebar from "./components/Sidebar"; +import TranscriptPanel, { type DragHandleProps } from "./components/TranscriptPanel"; +import EpisodeBar from "./components/EpisodeBar"; +import EpisodeNav from "./components/EpisodeNav"; +import type { DatasetInfo, EpisodeData, Preset } from "./types"; +import { api } from "./api"; + +export default function ArenaApp() { + const state = useAppState(); + + const handleLoadPreset = useCallback(async (preset: Preset) => { + await state.addDataset(preset.repo, preset.split, preset.id, preset.name); + }, [state.addDataset]); + + const handleSavePreset = useCallback(async (name: string, repo: string, split?: string) => { + const preset = await api.createPreset(name, repo, split); + state.setPresets(prev => [...prev, preset]); + }, []); + + const handleDeletePreset = useCallback(async (id: string) => { + await api.deletePreset(id); + state.setPresets(prev => prev.filter(p => p.id !== id)); + }, []); + + const handleUpdatePreset = useCallback(async (presetId: string, datasetId: string, updates: { name?: string }) => { + const updated = await api.updatePreset(presetId, updates); + state.setPresets(prev => prev.map(p => p.id === presetId ? updated : p)); + if (updates.name) { + state.updateDatasetPresetName(datasetId, updates.name); + } + }, [state.updateDatasetPresetName]); + + // Keyboard shortcuts: j/k for episode navigation + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + switch (e.key) { + case "j": + state.setEpisodeIdx(prev => Math.min(state.maxEpisodes - 1, prev + 1)); + break; + case "k": + state.setEpisodeIdx(prev => Math.max(0, prev - 1)); + break; + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [state.maxEpisodes, state.setEpisodeIdx]); + + return ( +
+ + +
+ {/* Error banner */} + {state.error && ( +
+ {state.error} + +
+ )} + + + + {/* Transcript panels */} + + + +
+
+ ); +} + + +/* ── Drag-to-reorder panel container ── */ + +interface PanelContainerProps { + datasets: DatasetInfo[]; + getEpisodeData: (dsId: string) => EpisodeData | undefined; + onReorder: (fromId: string, toId: string) => void; +} + +function PanelContainer({ datasets, getEpisodeData, onReorder }: PanelContainerProps) { + const [draggedId, setDraggedId] = useState(null); + const [overId, setOverId] = useState(null); + const dragCounter = useRef>({}); + + const handleDragStart = useCallback((e: React.DragEvent, id: string) => { + setDraggedId(id); + e.dataTransfer.effectAllowed = "move"; + const ghost = document.createElement("canvas"); + ghost.width = 1; + ghost.height = 1; + e.dataTransfer.setDragImage(ghost, 0, 0); + }, []); + + const handleDragEnd = useCallback(() => { + setDraggedId(null); + setOverId(null); + dragCounter.current = {}; + }, []); + + const handleDragEnter = useCallback((e: React.DragEvent, id: string) => { + e.preventDefault(); + dragCounter.current[id] = (dragCounter.current[id] || 0) + 1; + setOverId(id); + }, []); + + const handleDragLeave = useCallback((_e: React.DragEvent, id: string) => { + dragCounter.current[id] = (dragCounter.current[id] || 0) - 1; + if (dragCounter.current[id] <= 0) { + dragCounter.current[id] = 0; + setOverId(prev => prev === id ? null : prev); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetId: string) => { + e.preventDefault(); + if (draggedId && draggedId !== targetId) { + onReorder(draggedId, targetId); + } + setDraggedId(null); + setOverId(null); + dragCounter.current = {}; + }, [draggedId, onReorder]); + + if (datasets.length === 0) { + return ( +
+
+
+

No repos active

+

Add an arena HuggingFace repo from the sidebar to get started

+
+
+
+ ); + } + + return ( +
+ {datasets.map(ds => { + const isDragged = draggedId === ds.id; + const isOver = overId === ds.id && draggedId !== null && draggedId !== ds.id; + + const handleProps: DragHandleProps = { + draggable: true, + onDragStart: e => handleDragStart(e, ds.id), + onDragEnd: handleDragEnd, + }; + + return ( +
handleDragEnter(e, ds.id)} + onDragLeave={e => handleDragLeave(e, ds.id)} + onDragOver={handleDragOver} + onDrop={e => handleDrop(e, ds.id)} + className={`flex-1 min-w-0 transition-all duration-150 ${ + isDragged ? "opacity-30 scale-[0.97]" : "" + } ${isOver ? "panel-drop-target" : ""}`} + > + +
+ ); + })} +
+ ); +} diff --git a/frontend/src/arena/api.ts b/frontend/src/arena/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..e215e3c4ca86cb085c950e7f5b197c94f8f381f9 --- /dev/null +++ b/frontend/src/arena/api.ts @@ -0,0 +1,71 @@ +import type { DatasetInfo, EpisodeData, Preset } from "./types"; + +const BASE = "/api/arena"; +const PRESETS_BASE = "/api/presets/arena"; + +async function fetchJSON(url: string, opts?: RequestInit): Promise { + const res = await fetch(`${BASE}${url}`, { + headers: { "Content-Type": "application/json" }, + ...opts, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + return res.json(); +} + +async function fetchPresetsJSON(url: string, opts?: RequestInit): Promise { + const res = await fetch(`${PRESETS_BASE}${url}`, { + headers: { "Content-Type": "application/json" }, + ...opts, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + return res.json(); +} + +export const api = { + loadDataset(repo: string, split?: string) { + return fetchJSON }>("/datasets/load", { + method: "POST", + body: JSON.stringify({ repo, split }), + }); + }, + + listDatasets() { + return fetchJSON("/datasets/"); + }, + + getEpisode(dsId: string, envId: string, idx: number) { + return fetchJSON(`/datasets/${dsId}/episode/${encodeURIComponent(envId)}/${idx}`); + }, + + unloadDataset(dsId: string) { + return fetchJSON<{ status: string }>(`/datasets/${dsId}`, { method: "DELETE" }); + }, + + listPresets() { + return fetchPresetsJSON(""); + }, + + createPreset(name: string, repo: string, split?: string) { + return fetchPresetsJSON("", { + method: "POST", + body: JSON.stringify({ name, repo, split }), + }); + }, + + updatePreset(id: string, updates: { name?: string; split?: string }) { + return fetchPresetsJSON(`/${id}`, { + method: "PUT", + body: JSON.stringify(updates), + }); + }, + + deletePreset(id: string) { + return fetchPresetsJSON<{ status: string }>(`/${id}`, { method: "DELETE" }); + }, +}; diff --git a/frontend/src/arena/components/EpisodeBar.tsx b/frontend/src/arena/components/EpisodeBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cbe3ea09e6a5fde2e797a274e7ed791a901a5fcf --- /dev/null +++ b/frontend/src/arena/components/EpisodeBar.tsx @@ -0,0 +1,87 @@ +import type { DatasetInfo, EpisodeData } from "../types"; + +interface EpisodeBarProps { + activeDatasets: DatasetInfo[]; + currentEnvId: string | null; + episodeIdx: number; + maxEpisodes: number; + getEpisodeData: (dsId: string) => EpisodeData | undefined; +} + +export default function EpisodeBar({ activeDatasets, currentEnvId, episodeIdx, maxEpisodes, getEpisodeData }: EpisodeBarProps) { + if (!currentEnvId || activeDatasets.length === 0) { + return ( +
+

Load an arena dataset to begin

+
+ ); + } + + const firstData = getEpisodeData(activeDatasets[0].id); + + return ( +
+ {/* Episode info */} +
+ {currentEnvId} + {firstData && ( + + {firstData.game_id} + + )} + + Episode {episodeIdx + 1} / {maxEpisodes} + +
+ + {/* Per-dataset outcome for this episode */} +
+ {activeDatasets.map(ds => { + const ep = getEpisodeData(ds.id); + const outcome = ep?.outcome || "unknown"; + const colors = { + win: "text-green-400", + loss: "text-red-400", + error: "text-yellow-400", + unknown: "text-gray-500", + }; + return ( + + {ds.presetName || ds.name}: + + {outcome.toUpperCase()} + {ep?.reward !== null && ep?.reward !== undefined && ` (${ep.reward})`} + + + ); + })} +
+ + {/* Episode indicator squares */} + {maxEpisodes > 1 && ( +
+ Episodes: + {Array.from({ length: Math.min(maxEpisodes, 50) }, (_, i) => { + const isSelected = i === episodeIdx; + return ( + + {i + 1} + + ); + })} + {maxEpisodes > 50 && ( + ... +{maxEpisodes - 50} more + )} +
+ )} +
+ ); +} diff --git a/frontend/src/arena/components/EpisodeNav.tsx b/frontend/src/arena/components/EpisodeNav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..45c75c81de35f98bd7aba9ea9e68d2066fd54a82 --- /dev/null +++ b/frontend/src/arena/components/EpisodeNav.tsx @@ -0,0 +1,81 @@ +import type { FilterMode } from "../types"; + +interface EpisodeNavProps { + episodeIdx: number; + maxEpisodes: number; + filter: FilterMode; + onEpisodeChange: (idx: number) => void; + onFilterChange: (filter: FilterMode) => void; +} + +const FILTERS: { value: FilterMode; label: string }[] = [ + { value: "all", label: "All" }, + { value: "wins", label: "Wins" }, + { value: "losses", label: "Losses" }, + { value: "errors", label: "Errors" }, +]; + +export default function EpisodeNav({ + episodeIdx, maxEpisodes, filter, + onEpisodeChange, onFilterChange, +}: EpisodeNavProps) { + const prevEp = () => onEpisodeChange(Math.max(0, episodeIdx - 1)); + const nextEp = () => onEpisodeChange(Math.min(maxEpisodes - 1, episodeIdx + 1)); + + return ( +
+ {/* Episode navigation */} +
+ +
+ Ep + { + const v = parseInt(e.target.value); + if (!isNaN(v) && v >= 0 && v < maxEpisodes) onEpisodeChange(v); + }} + className="w-16 px-1.5 py-1 text-xs text-center bg-gray-800 border border-gray-600 rounded text-gray-200 focus:border-purple-500 focus:outline-none" + /> + / {maxEpisodes > 0 ? maxEpisodes - 1 : 0} +
+ +
+ + {/* Filter */} +
+ {FILTERS.map(f => ( + + ))} +
+ + {/* Keyboard hints */} +
+ j/k episode +
+
+ ); +} diff --git a/frontend/src/arena/components/Sidebar.tsx b/frontend/src/arena/components/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a86cabe28568f9b64ccef54d688a229b290ae4d5 --- /dev/null +++ b/frontend/src/arena/components/Sidebar.tsx @@ -0,0 +1,274 @@ +import { useState } from "react"; +import type { DatasetInfo, Preset } from "../types"; + +const ENV_COLORS = [ + { bg: "bg-purple-500", border: "border-purple-500", text: "text-purple-400", label: "text-purple-300" }, + { bg: "bg-emerald-500", border: "border-emerald-500", text: "text-emerald-400", label: "text-emerald-300" }, + { bg: "bg-amber-500", border: "border-amber-500", text: "text-amber-400", label: "text-amber-300" }, + { bg: "bg-purple-500", border: "border-purple-500", text: "text-purple-400", label: "text-purple-300" }, + { bg: "bg-rose-500", border: "border-rose-500", text: "text-rose-400", label: "text-rose-300" }, + { bg: "bg-cyan-500", border: "border-cyan-500", text: "text-cyan-400", label: "text-cyan-300" }, +]; + +interface SidebarProps { + datasets: DatasetInfo[]; + presets: Preset[]; + loading: Record; + allEnvIds: string[]; + currentEnvId: string | null; + onAddDataset: (repo: string, split?: string) => void; + onRemoveDataset: (id: string) => void; + onToggleDataset: (id: string) => void; + onSetCurrentEnv: (envId: string) => void; + onLoadPreset: (preset: Preset) => void; + onSavePreset: (name: string, repo: string, split?: string) => void; + onDeletePreset: (id: string) => void; + onUpdatePreset: (presetId: string, datasetId: string, updates: { name?: string }) => void; +} + +export default function Sidebar({ + datasets, presets, loading, allEnvIds, currentEnvId, + onAddDataset, onRemoveDataset, onToggleDataset, onSetCurrentEnv, + onLoadPreset, onSavePreset, onDeletePreset, onUpdatePreset, +}: SidebarProps) { + const [showAddModal, setShowAddModal] = useState(false); + const [repoInput, setRepoInput] = useState(""); + const [splitInput, setSplitInput] = useState("train"); + const [presetSearch, setPresetSearch] = useState(""); + const [savingPresetForId, setSavingPresetForId] = useState(null); + const [presetName, setPresetName] = useState(""); + const [editingDatasetId, setEditingDatasetId] = useState(null); + const [editPresetName, setEditPresetName] = useState(""); + + const handleAdd = () => { + if (!repoInput.trim()) return; + onAddDataset(repoInput.trim(), splitInput.trim() || undefined); + setRepoInput(""); + setShowAddModal(false); + }; + + const handleSavePresetForRepo = (ds: DatasetInfo) => { + if (!presetName.trim()) return; + onSavePreset(presetName.trim(), ds.repo, ds.split); + setPresetName(""); + setSavingPresetForId(null); + }; + + const getEnvColor = (envId: string) => { + const idx = allEnvIds.indexOf(envId); + return ENV_COLORS[idx % ENV_COLORS.length]; + }; + + return ( +
+ {/* Presets */} +
+

Presets

+ {presets.length === 0 ? ( +

No presets saved

+ ) : ( + <> + {presets.length > 6 && ( + setPresetSearch(e.target.value)} + placeholder="Search presets..." + className="w-full px-2 py-1 mb-2 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-purple-500 focus:outline-none" + /> + )} +
+ {presets + .filter(p => !presetSearch || p.name.toLowerCase().includes(presetSearch.toLowerCase())) + .map(p => ( +
+ +
+ +
+
+ ))} +
+ + )} +
+ + {/* Env selector */} + {allEnvIds.length > 1 && ( +
+

Environments

+
+ {allEnvIds.map(envId => { + const color = getEnvColor(envId); + const isActive = envId === currentEnvId; + return ( + + ); + })} +
+
+ )} + + {/* Datasets */} +
+

Loaded Repos

+ {datasets.length === 0 ? ( +

No repos loaded. Add one below.

+ ) : ( +
+ {datasets.map(ds => ( +
+
{ + if (ds.presetId) { + setEditingDatasetId(editingDatasetId === ds.id ? null : ds.id); + setEditPresetName(ds.presetName || ""); + } + }} + className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm transition-colors ${ + ds.active ? "bg-gray-800" : "bg-gray-900 opacity-60" + } ${ds.presetId ? "cursor-pointer" : ""}`} + > + onToggleDataset(ds.id)} + onClick={e => e.stopPropagation()} + className="rounded border-gray-600 bg-gray-800 text-purple-500 focus:ring-purple-500 focus:ring-offset-0" + /> +
+
+ {ds.presetName || ds.name} +
+
+ {ds.model_name} | {ds.n_rows} eps | W:{ds.stats.wins} L:{ds.stats.losses} E:{ds.stats.errors} +
+
+ + +
+ {savingPresetForId === ds.id && ( +
+ setPresetName(e.target.value)} + onKeyDown={e => { if (e.key === "Enter") handleSavePresetForRepo(ds); if (e.key === "Escape") setSavingPresetForId(null); }} + placeholder="Preset name..." + className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-purple-500 focus:outline-none" + autoFocus + /> + +
+ )} +
+ ))} +
+ )} +
+ + {/* Preset edit panel */} + {editingDatasetId && (() => { + const editDs = datasets.find(d => d.id === editingDatasetId); + if (!editDs?.presetId) return null; + return ( +
+
Edit Preset
+ setEditPresetName(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter" && editPresetName.trim()) { onUpdatePreset(editDs.presetId!, editDs.id, { name: editPresetName.trim() }); setEditingDatasetId(null); } + if (e.key === "Escape") setEditingDatasetId(null); + }} + placeholder="Preset name..." + className="w-full px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-purple-500 focus:outline-none" + autoFocus + /> +
+ + + +
+
+ ); + })()} + + {/* Add repo */} +
+ {!showAddModal ? ( + + ) : ( +
+ setRepoInput(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleAdd()} + placeholder="org/dataset-name" + className="w-full px-2 py-1.5 text-sm bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-purple-500 focus:outline-none" + autoFocus + /> +
+ setSplitInput(e.target.value)} + placeholder="Split" + className="w-20 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-purple-500 focus:outline-none" + /> +
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/arena/components/TranscriptPanel.tsx b/frontend/src/arena/components/TranscriptPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1dbfa142b3d0f13df2b2099e68b91f4736382f0c --- /dev/null +++ b/frontend/src/arena/components/TranscriptPanel.tsx @@ -0,0 +1,198 @@ +import { useState } from "react"; +import type { EpisodeData, TranscriptTurn } from "../types"; +import { highlightTrace } from "../utils/traceHighlight"; + +export interface DragHandleProps { + draggable: true; + onDragStart: (e: React.DragEvent) => void; + onDragEnd: (e: React.DragEvent) => void; +} + +interface TranscriptPanelProps { + datasetName: string; + repoName?: string; + data: EpisodeData | undefined; + dragHandleProps?: DragHandleProps; +} + +const OUTCOME_STYLES = { + win: { bg: "bg-green-900", text: "text-green-300", label: "WIN" }, + loss: { bg: "bg-red-900", text: "text-red-300", label: "LOSS" }, + error: { bg: "bg-yellow-900", text: "text-yellow-300", label: "ERROR" }, + unknown: { bg: "bg-gray-700", text: "text-gray-300", label: "?" }, +}; + +const PLAYER_COLORS: Record = { + 0: { bubble: "bg-purple-900/60 border-purple-700", label: "text-purple-400", name: "Player 0" }, + 1: { bubble: "bg-orange-900/60 border-orange-700", label: "text-orange-400", name: "Player 1" }, + 2: { bubble: "bg-purple-900/60 border-purple-700", label: "text-purple-400", name: "Player 2" }, + 3: { bubble: "bg-teal-900/60 border-teal-700", label: "text-teal-400", name: "Player 3" }, +}; + +function getPlayerColor(playerId: number) { + return PLAYER_COLORS[playerId] || PLAYER_COLORS[0]; +} + +export default function TranscriptPanel({ datasetName, repoName, data, dragHandleProps }: TranscriptPanelProps) { + if (!data) { + return ( +
+
No data
+
+ ); + } + + const outcomeStyle = OUTCOME_STYLES[data.outcome]; + const borderColor = data.outcome === "win" ? "border-green-600" + : data.outcome === "loss" ? "border-red-600" + : data.outcome === "error" ? "border-yellow-600" + : "border-gray-700"; + + return ( +
+ {/* Header */} +
+
+
+ + {datasetName} + + + {outcomeStyle.label} + +
+
+ {dragHandleProps && ( + + + + + + + + + + + )} +
+
+
+ {data.model} + {data.num_turns} turns + {data.reward !== null && reward: {data.reward}} + {data.opponent_model && vs {data.opponent_model}} +
+
+ + {/* Error banner */} + {data.error && ( +
+ Error: {data.error} +
+ )} + + {/* Transcript chat */} +
+ {/* System prompt */} + {data.system_prompt && ( + + )} + {data.transcript.map((turn, i) => ( + + ))} +
+
+ ); +} + + +function SystemPromptBubble({ text }: { text: string }) { + const [expanded, setExpanded] = useState(true); + + return ( +
+ + {expanded && ( +
+
+            {text}
+          
+
+ )} +
+ ); +} + + +function TurnBubble({ turn, hasMultiplePlayers }: { turn: TranscriptTurn; hasMultiplePlayers: boolean }) { + const [thinkExpanded, setThinkExpanded] = useState(false); + const playerColor = getPlayerColor(turn.player_id); + const thinkSegments = highlightTrace(turn.think_text); + + return ( +
+ {/* Turn number marker */} +
Turn {turn.turn}
+ + {/* Observation (environment message) — left aligned */} + {turn.observation && ( +
+
+
ENV
+
+
+                {turn.observation}
+              
+
+
+
+ )} + + {/* Action (model response) — right aligned */} +
+
+
+ {hasMultiplePlayers ? `${playerColor.name} (${turn.player_id === 0 ? "model" : "opponent"})` : "Model"} +
+ +
+ {/* Think section — collapsible */} + {turn.think_text && ( +
+ + {thinkExpanded && ( +
+                    {thinkSegments.map((seg, i) => (
+                      {seg.text}
+                    ))}
+                  
+ )} +
+ )} + + {/* Action text (the actual game move) */} +
+              {turn.action_text || turn.action || "(no action)"}
+            
+
+
+
+
+ ); +} diff --git a/frontend/src/arena/store.ts b/frontend/src/arena/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..ceb9158ffea319699fd65f7f517181af63dc23b8 --- /dev/null +++ b/frontend/src/arena/store.ts @@ -0,0 +1,180 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import type { DatasetInfo, EpisodeData, Preset, FilterMode } from "./types"; +import { api } from "./api"; + +export function useAppState() { + const [datasets, setDatasets] = useState([]); + const [presets, setPresets] = useState([]); + const [filter, setFilter] = useState("all"); + const [episodeDataMap, setEpisodeDataMap] = useState>({}); + const [loading, setLoading] = useState>({}); + const [error, setError] = useState(null); + + // Current env_id being viewed (grouping key) + const [currentEnvId, setCurrentEnvId] = useState(null); + // Episode index within the current env_id + const [episodeIdx, setEpisodeIdx] = useState(0); + + // Load presets on mount + useEffect(() => { + api.listPresets().then(setPresets).catch(() => {}); + }, []); + + // All unique env_ids across all datasets + const allEnvIds = useMemo(() => { + const envSet = new Set(); + for (const ds of datasets) { + for (const envId of ds.env_ids) { + envSet.add(envId); + } + } + return Array.from(envSet).sort(); + }, [datasets]); + + // Auto-select env_id if not set + useEffect(() => { + if (currentEnvId && allEnvIds.includes(currentEnvId)) return; + if (allEnvIds.length > 0) { + setCurrentEnvId(allEnvIds[0]); + setEpisodeIdx(0); + } else { + setCurrentEnvId(null); + } + }, [allEnvIds, currentEnvId]); + + // Active datasets = datasets that are active and have the current env_id + const activeDatasets = useMemo( + () => datasets.filter(d => d.active && currentEnvId && d.env_ids.includes(currentEnvId)), + [datasets, currentEnvId] + ); + + // Max episodes for current env across active datasets + const maxEpisodes = useMemo(() => { + if (!currentEnvId || activeDatasets.length === 0) return 0; + return Math.min( + ...activeDatasets.map(d => d.episodes_per_env[currentEnvId] || 0) + ); + }, [activeDatasets, currentEnvId]); + + // Panel ordering + const [panelOrder, setPanelOrder] = useState([]); + + useEffect(() => { + const activeIds = new Set(activeDatasets.map(d => d.id)); + setPanelOrder(prev => { + const kept = prev.filter(id => activeIds.has(id)); + const newIds = activeDatasets.map(d => d.id).filter(id => !prev.includes(id)); + const merged = [...kept, ...newIds]; + if (merged.length === prev.length && merged.every((id, i) => id === prev[i])) return prev; + return merged; + }); + }, [activeDatasets]); + + const orderedActiveDatasets = useMemo(() => { + const map = new Map(activeDatasets.map(d => [d.id, d])); + return panelOrder.map(id => map.get(id)).filter((d): d is DatasetInfo => d !== undefined); + }, [activeDatasets, panelOrder]); + + const reorderPanels = useCallback((fromId: string, toId: string) => { + if (fromId === toId) return; + setPanelOrder(prev => { + const order = [...prev]; + const fromIdx = order.indexOf(fromId); + const toIdx = order.indexOf(toId); + if (fromIdx === -1 || toIdx === -1) return prev; + order.splice(fromIdx, 1); + order.splice(toIdx, 0, fromId); + return order; + }); + }, []); + + // Update URL + useEffect(() => { + const params = new URLSearchParams(); + const activeRepos = datasets.filter(d => d.active); + if (activeRepos.length > 0) { + params.set("repos", activeRepos.map(d => d.repo).join(",")); + } + if (currentEnvId) params.set("env", currentEnvId); + params.set("ep", String(episodeIdx)); + if (filter !== "all") params.set("filter", filter); + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, "", newUrl); + }, [datasets, currentEnvId, episodeIdx, filter]); + + // Fetch episode data for active datasets when episode or env changes + useEffect(() => { + if (!currentEnvId) return; + activeDatasets.forEach(ds => { + const key = `${ds.id}:${currentEnvId}:${episodeIdx}`; + if (!episodeDataMap[key]) { + api.getEpisode(ds.id, currentEnvId, episodeIdx).then(data => { + setEpisodeDataMap(prev => ({ ...prev, [key]: data })); + }).catch(() => {}); + } + }); + }, [episodeIdx, currentEnvId, activeDatasets]); + + const getEpisodeData = useCallback((dsId: string): EpisodeData | undefined => { + if (!currentEnvId) return undefined; + return episodeDataMap[`${dsId}:${currentEnvId}:${episodeIdx}`]; + }, [episodeDataMap, currentEnvId, episodeIdx]); + + const addDataset = useCallback(async (repo: string, split?: string, presetId?: string, presetName?: string) => { + setLoading(prev => ({ ...prev, [repo]: true })); + setError(null); + try { + const result = await api.loadDataset(repo, split); + const dsInfo: DatasetInfo = { + ...result, + active: true, + presetId, + presetName, + }; + + setDatasets(prev => { + if (prev.some(d => d.id === dsInfo.id)) return prev; + return [...prev, dsInfo]; + }); + + // Switch to first env_id of the new dataset + if (dsInfo.env_ids.length > 0) { + setCurrentEnvId(dsInfo.env_ids[0]); + setEpisodeIdx(0); + } + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load dataset"); + } finally { + setLoading(prev => ({ ...prev, [repo]: false })); + } + }, []); + + const removeDataset = useCallback(async (id: string) => { + await api.unloadDataset(id).catch(() => {}); + setDatasets(prev => prev.filter(d => d.id !== id)); + }, []); + + const toggleDataset = useCallback((id: string) => { + setDatasets(prev => prev.map(d => (d.id === id ? { ...d, active: !d.active } : d))); + }, []); + + const updateDatasetPresetName = useCallback((dsId: string, name: string) => { + setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetName: name } : d)); + }, []); + + const clearDatasetPreset = useCallback((dsId: string) => { + setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetId: undefined, presetName: undefined } : d)); + }, []); + + return { + datasets, presets, setPresets, + episodeIdx, setEpisodeIdx, + filter, setFilter, + loading, error, setError, + activeDatasets, orderedActiveDatasets, maxEpisodes, + addDataset, removeDataset, toggleDataset, + updateDatasetPresetName, clearDatasetPreset, + getEpisodeData, reorderPanels, + allEnvIds, currentEnvId, setCurrentEnvId, + }; +} diff --git a/frontend/src/arena/types.ts b/frontend/src/arena/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..83f6a36188d17c179d2b08516a4ae7a98ced152b --- /dev/null +++ b/frontend/src/arena/types.ts @@ -0,0 +1,51 @@ +export interface DatasetInfo { + id: string; + repo: string; + name: string; + split: string; + columns: string[]; + n_rows: number; + env_ids: string[]; + episodes_per_env: Record; + model_name: string; + stats: { wins: number; losses: number; errors: number }; + active: boolean; + presetId?: string; + presetName?: string; +} + +export interface TranscriptTurn { + turn: number; + player_id: number; + observation: string; + action: string; + think_text: string; + action_text: string; + think_len: number; + backtracks: number; +} + +export interface EpisodeData { + game_id: string; + env_id: string; + model: string; + opponent_model: string | null; + player_id: number; + reward: number | null; + num_turns: number; + error: string | null; + outcome: "win" | "loss" | "error" | "unknown"; + transcript: TranscriptTurn[]; + system_prompt: string | null; + episode_idx: number; + total_episodes: number; +} + +export interface Preset { + id: string; + name: string; + repo: string; + split?: string; +} + +export type FilterMode = "all" | "wins" | "losses" | "errors"; diff --git a/frontend/src/arena/utils/traceHighlight.ts b/frontend/src/arena/utils/traceHighlight.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb00e145f7599af50a03594035d5e64135993626 --- /dev/null +++ b/frontend/src/arena/utils/traceHighlight.ts @@ -0,0 +1,39 @@ +export interface HighlightSegment { + text: string; + className: string; +} + +export function highlightTrace(text: string): HighlightSegment[] { + if (!text) return []; + + const segments: HighlightSegment[] = []; + // Strip and tags for display + const cleaned = text.replace(/<\/?think>/g, ""); + const lines = cleaned.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lo = line.toLowerCase().trim(); + + let className = "text-gray-400"; + + if (lo.startsWith("wait") || lo.startsWith("hmm") || lo.startsWith("but wait")) { + className = "text-yellow-400"; + } else if (lo.startsWith("let me try") || lo.startsWith("let me reconsider") || lo.startsWith("let me think")) { + className = "text-cyan-400"; + } else if (lo.startsWith("so the answer") || lo.startsWith("therefore") || lo.startsWith("the final") || lo.startsWith("i should")) { + className = "text-green-400 font-bold"; + } else if (lo.startsWith("i give up") || lo.startsWith("i can't") || lo.startsWith("i'm stuck")) { + className = "text-red-400 font-bold"; + } else if (line.includes("=") && /[+\-*/]/.test(line)) { + className = "text-gray-200"; + } + + segments.push({ text: line, className }); + if (i < lines.length - 1) { + segments.push({ text: "\n", className: "" }); + } + } + + return segments; +} diff --git a/frontend/src/harbor/HarborApp.tsx b/frontend/src/harbor/HarborApp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f0bb792790c1ce9f2f8ce5d18dfc4296a3b6078a --- /dev/null +++ b/frontend/src/harbor/HarborApp.tsx @@ -0,0 +1,156 @@ +import { useEffect } from "react"; +import { useAppState } from "./store"; +import { Sidebar } from "./components/Sidebar"; +import { InstanceList } from "./components/InstanceList"; +import { InfoBar } from "./components/InfoBar"; +import { TrajectoryView } from "./components/TrajectoryView"; +import { InstanceNav } from "./components/InstanceNav"; + +export default function HarborApp() { + const store = useAppState(); + const { state } = store; + + useEffect(() => { + store.loadPresets(); + }, []); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + + if (e.key === "Escape" && state.viewMode === "detail") { + store.selectInstance(null); + return; + } + + if (state.viewMode === "detail" && state.selectedInstanceId) { + const filtered = getFilteredInstances(); + const currentIdx = filtered.findIndex( + (g) => g.instance_id === state.selectedInstanceId + ); + if (e.key === "j" && currentIdx < filtered.length - 1) { + store.selectInstance(filtered[currentIdx + 1].instance_id); + } else if (e.key === "k" && currentIdx > 0) { + store.selectInstance(filtered[currentIdx - 1].instance_id); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }); + + function getFilteredInstances() { + let groups = state.groupedInstances; + if (state.filterResolved === "resolved") { + groups = groups.filter((g) => + g.datasets.some((d) => d.summary.resolved) + ); + } else if (state.filterResolved === "unresolved") { + groups = groups.filter((g) => + g.datasets.every((d) => !d.summary.resolved) + ); + } + if (state.searchQuery) { + const q = state.searchQuery.toLowerCase(); + groups = groups.filter((g) => g.instance_id.toLowerCase().includes(q)); + } + return groups; + } + + const filtered = getFilteredInstances(); + + // Get details for selected instance + const selectedDetails = state.selectedInstanceId + ? state.datasets + .filter((ds) => + ds.instances.some( + (inst) => inst.instance_id === state.selectedInstanceId + ) + ) + .map((ds) => ({ + ds, + detail: state.instanceDetails[`${ds.id}:${state.selectedInstanceId}`], + })) + .filter((d) => d.detail) + : []; + + return ( +
+ + +
+ {state.error && ( +
+ {state.error} + +
+ )} + + {state.viewMode === "list" && ( + store.selectInstance(id)} + filterResolved={state.filterResolved} + onFilterChange={store.setFilterResolved} + searchQuery={state.searchQuery} + onSearchChange={store.setSearchQuery} + loading={state.loading} + /> + )} + + {state.viewMode === "detail" && state.selectedInstanceId && ( + <> + d.detail!)} + onBack={() => store.selectInstance(null)} + trajectoryMode={state.trajectoryMode} + onTrajectoryModeChange={store.setTrajectoryMode} + /> + +
+ {selectedDetails.length === 0 && state.loading && ( +
+ Loading trajectory... +
+ )} + {selectedDetails.map(({ ds, detail }) => ( + + ))} +
+ + store.selectInstance(id)} + /> + + )} + + {state.datasets.length === 0 && state.viewMode === "list" && ( +
+
+
No datasets loaded
+
+ Load a HuggingFace repo from the sidebar or select a preset +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/harbor/api.ts b/frontend/src/harbor/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..8469f3c5f15b1dbd364d48e90bcc6d03fd300465 --- /dev/null +++ b/frontend/src/harbor/api.ts @@ -0,0 +1,55 @@ +import type { DatasetInfo, InstanceDetail, InstanceRawLogs, Preset } from "./types"; + +const BASE = "/api/harbor"; +const PRESETS_BASE = "/api/presets/harbor"; + +async function fetchJson(url: string, opts?: RequestInit): Promise { + const res = await fetch(url, opts); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + return res.json(); +} + +// Datasets +export async function loadDataset(repo: string, split = "train"): Promise { + return fetchJson(`${BASE}/datasets/load`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ repo, split }), + }); +} + +export async function listDatasets(): Promise { + return fetchJson(`${BASE}/datasets/`); +} + +export async function getInstance(dsId: string, instanceId: string): Promise { + return fetchJson(`${BASE}/datasets/${dsId}/instance/${instanceId}`); +} + +export async function getInstanceRaw(dsId: string, instanceId: string): Promise { + return fetchJson(`${BASE}/datasets/${dsId}/instance/${instanceId}/raw`); +} + +export async function unloadDataset(dsId: string): Promise { + await fetchJson(`${BASE}/datasets/${dsId}`, { method: "DELETE" }); +} + +// Presets +export async function listPresets(): Promise { + return fetchJson(PRESETS_BASE); +} + +export async function createPreset(name: string, repo: string, split = "train"): Promise { + return fetchJson(PRESETS_BASE, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, repo, split }), + }); +} + +export async function deletePreset(id: string): Promise { + await fetchJson(`${PRESETS_BASE}/${id}`, { method: "DELETE" }); +} diff --git a/frontend/src/harbor/components/ChatBubble.tsx b/frontend/src/harbor/components/ChatBubble.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a123806f213c51bf5d92d89c18bd460f22b3b956 --- /dev/null +++ b/frontend/src/harbor/components/ChatBubble.tsx @@ -0,0 +1,334 @@ +import { useState } from "react"; +import type { RawStep, RawToolCall, AtifStep } from "../types"; + +// ---- Raw message bubble ---- + +interface RawBubbleProps { + step: RawStep; + // Map of tool_call_id → tool response content for pairing + toolResponses?: Map; +} + +export function RawBubble({ step, toolResponses }: RawBubbleProps) { + const [expanded, setExpanded] = useState(false); + + if (step.role === "system") { + return ( + setExpanded(!expanded)} + label="System" + /> + ); + } + + if (step.role === "user") { + return ( +
+
+
Task / Environment
+ setExpanded(!expanded)} + maxPreview={600} + /> +
+
+ ); + } + + if (step.role === "assistant") { + return ( +
+
+
Agent
+ + {step.content && ( + setExpanded(!expanded)} + maxPreview={400} + /> + )} + + {step.tool_calls && step.tool_calls.length > 0 && ( +
+ {step.tool_calls.map((tc, i) => ( + + ))} +
+ )} +
+
+ ); + } + + if (step.role === "tool") { + // Tool responses are shown inline with their tool_call above when possible + // Only render standalone if not paired + return ( +
+
+
+ Tool Output +
+ setExpanded(!expanded)} + maxPreview={500} + mono + /> +
+
+ ); + } + + // Unknown role + return ( +
+
+ [{step.role}] {(step.content || "").slice(0, 200)} +
+
+ ); +} + +// ---- ATIF step bubble ---- + +interface AtifBubbleProps { + step: AtifStep; +} + +export function AtifBubble({ step }: AtifBubbleProps) { + const [expanded, setExpanded] = useState(false); + + if (step.source === "system") { + return ( + setExpanded(!expanded)} + label="System" + /> + ); + } + + if (step.source === "user") { + return ( +
+
+
Task
+ setExpanded(!expanded)} + maxPreview={600} + /> +
+
+ ); + } + + if (step.source === "agent") { + return ( +
+
+ {/* Reasoning */} + {step.reasoning && ( +
+
Reasoning
+ setExpanded(!expanded)} + maxPreview={300} + /> +
+ )} + + {/* Message / action */} +
+
Agent
+ {step.message && ( + setExpanded(!expanded)} + maxPreview={400} + /> + )} + + {step.tool_calls && step.tool_calls.length > 0 && ( +
+ {step.tool_calls.map((tc, i) => ( +
+
+ {tc.function} +
+ {tc.command && ( +
+                        {tc.command}
+                      
+ )} +
+ ))} +
+ )} +
+ + {/* Observation */} + {step.observation && ( +
+
Output
+ setExpanded(!expanded)} + maxPreview={500} + mono + /> +
+ )} + + {/* Step metrics */} + {step.metrics && Object.keys(step.metrics).length > 0 && ( +
+ {Object.entries(step.metrics).map(([k, v]) => ( + + {k}: {typeof v === "number" ? v.toFixed(2) : String(v)} + + ))} +
+ )} +
+
+ ); + } + + return null; +} + +// ---- Shared components ---- + +function SystemBubble({ + content, + expanded, + onToggle, + label, +}: { + content: string; + expanded: boolean; + onToggle: () => void; + label: string; +}) { + return ( +
+
+ + {expanded && ( +
+ {content} +
+ )} +
+
+ ); +} + +function ContentBlock({ + content, + expanded, + onToggle, + maxPreview, + mono, +}: { + content: string; + expanded: boolean; + onToggle: () => void; + maxPreview: number; + mono?: boolean; +}) { + const isLong = content.length > maxPreview; + const display = expanded || !isLong ? content : content.slice(0, maxPreview) + "..."; + + return ( +
+
+ {display} +
+ {isLong && ( + + )} +
+ ); +} + +function ToolCallBlock({ + toolCall, + response, +}: { + toolCall: RawToolCall; + response?: string; +}) { + const [expanded, setExpanded] = useState(false); + const cmd = toolCall.command || toolCall.arguments_raw; + + return ( +
+
+ + {toolCall.function} + + +
+ + {cmd && ( +
+          {expanded ? cmd : cmd.length > 300 ? cmd.slice(0, 300) + "..." : cmd}
+        
+ )} + + {expanded && response && ( +
+
Output:
+
+            {response}
+          
+
+ )} +
+ ); +} diff --git a/frontend/src/harbor/components/InfoBar.tsx b/frontend/src/harbor/components/InfoBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df0641b7c506f8810b81efdef92fec6973dbe808 --- /dev/null +++ b/frontend/src/harbor/components/InfoBar.tsx @@ -0,0 +1,66 @@ +import type { InstanceDetail, TrajectoryMode } from "../types"; +import { MetricsSummary } from "./MetricsSummary"; + +interface Props { + instanceId: string; + details: InstanceDetail[]; + onBack: () => void; + trajectoryMode: TrajectoryMode; + onTrajectoryModeChange: (mode: TrajectoryMode) => void; +} + +export function InfoBar({ + instanceId, + details, + onBack, + trajectoryMode, + onTrajectoryModeChange, +}: Props) { + const parts = instanceId.split("__"); + const repo = parts[0] || ""; + const issue = parts.slice(1).join("__") || ""; + + return ( +
+
+
+ +
+ {repo} / + {issue} +
+
+ +
+ {/* Trajectory mode toggle */} +
+ {(["raw", "atif"] as const).map((mode) => ( + + ))} +
+ + {/* Metrics from all loaded details */} + {details.map((d, i) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/harbor/components/InstanceList.tsx b/frontend/src/harbor/components/InstanceList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f95ec095449898bdb167d34d45ca8e8dedc59306 --- /dev/null +++ b/frontend/src/harbor/components/InstanceList.tsx @@ -0,0 +1,152 @@ +import type { GroupedInstance } from "../types"; + +interface Props { + groups: GroupedInstance[]; + totalGroups: number; + onSelect: (instanceId: string) => void; + filterResolved: "all" | "resolved" | "unresolved"; + onFilterChange: (f: "all" | "resolved" | "unresolved") => void; + searchQuery: string; + onSearchChange: (q: string) => void; + loading: boolean; +} + +export function InstanceList({ + groups, + totalGroups, + onSelect, + filterResolved, + onFilterChange, + searchQuery, + onSearchChange, + loading, +}: Props) { + return ( +
+ {/* Toolbar */} +
+ onSearchChange(e.target.value)} + placeholder="Search instances..." + className="flex-1 max-w-sm bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> + +
+ {(["all", "resolved", "unresolved"] as const).map((f) => ( + + ))} +
+ + + {groups.length === totalGroups + ? `${groups.length} instances` + : `${groups.length} / ${totalGroups} instances`} + + + {loading && ( + Loading... + )} +
+ + {/* Instance grid */} +
+
+ {groups.map((g) => ( + onSelect(g.instance_id)} /> + ))} +
+ {groups.length === 0 && !loading && ( +
+ No instances match your filters +
+ )} +
+
+ ); +} + +function InstanceCard({ + group, + onClick, +}: { + group: GroupedInstance; + onClick: () => void; +}) { + const anyResolved = group.datasets.some((d) => d.summary.resolved); + const allResolved = group.datasets.every((d) => d.summary.resolved); + + // Parse instance_id: "repo__issue-number" + const parts = group.instance_id.split("__"); + const repo = parts[0] || ""; + const issue = parts.slice(1).join("__") || ""; + + return ( + + ); +} diff --git a/frontend/src/harbor/components/InstanceNav.tsx b/frontend/src/harbor/components/InstanceNav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..74cdf55635136a3121b9ac9cd87a616517a4e2ca --- /dev/null +++ b/frontend/src/harbor/components/InstanceNav.tsx @@ -0,0 +1,58 @@ +import type { GroupedInstance } from "../types"; + +interface Props { + instances: GroupedInstance[]; + currentId: string; + onSelect: (id: string) => void; +} + +export function InstanceNav({ instances, currentId, onSelect }: Props) { + const currentIdx = instances.findIndex((g) => g.instance_id === currentId); + + return ( +
+ + +
+ + {currentIdx + 1} / {instances.length} + + + {/* Dot navigation for nearby instances */} +
+ {instances.slice(Math.max(0, currentIdx - 5), currentIdx + 6).map((g) => ( +
+
+ + +
+ ); +} diff --git a/frontend/src/harbor/components/MetricsSummary.tsx b/frontend/src/harbor/components/MetricsSummary.tsx new file mode 100644 index 0000000000000000000000000000000000000000..07ea05120ef3b7b93f2977848902f5033eba2b3e --- /dev/null +++ b/frontend/src/harbor/components/MetricsSummary.tsx @@ -0,0 +1,51 @@ +import type { InstanceDetail } from "../types"; + +interface Props { + detail: InstanceDetail; +} + +export function MetricsSummary({ detail }: Props) { + const fm = detail.atif?.final_metrics || {}; + + return ( +
+ + {detail.resolved ? "RESOLVED" : "FAILED"} + + + {detail.reward !== undefined && detail.reward > 0 && ( + + reward: {detail.reward} + + )} + + {detail.duration_seconds > 0 && ( + + {Math.round(detail.duration_seconds)}s + + )} + + {fm.total_cost !== undefined && ( + + ${fm.total_cost?.toFixed(3)} + + )} + + {detail.n_raw_steps > 0 && ( + + {detail.n_raw_steps} msgs + + )} + + + {detail.agent || detail.model} + +
+ ); +} diff --git a/frontend/src/harbor/components/Sidebar.tsx b/frontend/src/harbor/components/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b7c4b2ed7aeb6ddb3fdaae92e79cb9c3067e13e --- /dev/null +++ b/frontend/src/harbor/components/Sidebar.tsx @@ -0,0 +1,225 @@ +import { useState } from "react"; +import type { Preset } from "../types"; + +interface Props { + store: { + state: { + datasets: { id: string; repo: string; name: string; split: string; n_instances: number }[]; + presets: Preset[]; + loading: boolean; + }; + loadDataset: (repo: string, split?: string) => Promise; + unloadDataset: (dsId: string) => Promise; + createPreset: (name: string, repo: string, split?: string) => Promise; + deletePreset: (id: string) => Promise; + loadPreset: (preset: Preset) => Promise; + }; +} + +export function Sidebar({ store }: Props) { + const [repoInput, setRepoInput] = useState(""); + const [splitInput, setSplitInput] = useState("train"); + const [showAddForm, setShowAddForm] = useState(false); + // Per-dataset save-as-preset state + const [savingForDsId, setSavingForDsId] = useState(null); + const [presetName, setPresetName] = useState(""); + const [presetSearch, setPresetSearch] = useState(""); + + const handleLoad = async () => { + const repo = repoInput.trim(); + if (!repo) return; + await store.loadDataset(repo, splitInput.trim() || "train"); + setRepoInput(""); + setShowAddForm(false); + }; + + const handleSavePreset = async (ds: { repo: string; split: string }) => { + if (!presetName.trim()) return; + await store.createPreset(presetName.trim(), ds.repo, ds.split); + setPresetName(""); + setSavingForDsId(null); + }; + + return ( +
+ {/* Header */} +
+

Harbor Trace Viz

+

Harbor agent trajectory viewer

+
+ + {/* Presets */} +
+
+ + Presets + +
+ + {store.state.presets.length === 0 ? ( +
No saved presets
+ ) : ( + <> + {store.state.presets.length > 6 && ( + setPresetSearch(e.target.value)} + placeholder="Search presets..." + className="w-full px-2 py-1 mb-2 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none" + /> + )} +
+ {store.state.presets + .filter((p) => + !presetSearch || + p.name.toLowerCase().includes(presetSearch.toLowerCase()) || + p.repo.toLowerCase().includes(presetSearch.toLowerCase()) + ) + .map((p) => ( +
+ +
+ +
+
+ ))} +
+ + )} +
+ + {/* Loaded datasets */} +
+
+ Loaded ({store.state.datasets.length}) +
+ {store.state.datasets.map((ds) => ( +
+
+
+
+ {ds.name} +
+
+ {ds.n_instances} instances +
+
+ {/* Save as preset */} + + {/* Remove */} + +
+ {/* Inline save-as-preset form */} + {savingForDsId === ds.id && ( +
+ setPresetName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSavePreset(ds); + if (e.key === "Escape") setSavingForDsId(null); + }} + placeholder="Preset name..." + className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none" + autoFocus + /> + +
+ )} +
+ ))} +
+ + {/* Add repo */} +
+ {!showAddForm ? ( + + ) : ( +
+ setRepoInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLoad()} + placeholder="org/repo-name" + className="w-full px-2 py-1.5 text-sm bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none" + autoFocus + /> + setSplitInput(e.target.value)} + placeholder="Split" + className="w-full px-2 py-1 text-xs bg-gray-800 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:border-teal-500 focus:outline-none" + /> +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/harbor/components/StepDetail.tsx b/frontend/src/harbor/components/StepDetail.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca6fd7f2ecb34e57c1fd82ecbd76e6ae64686800 --- /dev/null +++ b/frontend/src/harbor/components/StepDetail.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import type { InstanceRawLogs } from "../types"; +import * as api from "../api"; + +interface Props { + dsId: string; + instanceId: string; +} + +export function StepDetail({ dsId, instanceId }: Props) { + const [logs, setLogs] = useState(null); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState< + "agent_stdout" | "setup_stderr" | "verifier_report" | "verifier_stdout" + >("agent_stdout"); + + const loadLogs = async () => { + setLoading(true); + try { + const data = await api.getInstanceRaw(dsId, instanceId); + setLogs(data); + } catch { + // ignore + } + setLoading(false); + }; + + if (!logs) { + return ( +
+ +
+ ); + } + + const tabs = [ + { key: "agent_stdout" as const, label: "Agent Stdout", content: logs.agent_stdout }, + { key: "setup_stderr" as const, label: "Setup Stderr", content: logs.setup_stderr }, + { key: "verifier_report" as const, label: "Verifier Report", content: logs.verifier_report }, + { key: "verifier_stdout" as const, label: "Verifier Stdout", content: logs.verifier_stdout }, + ].filter((t) => t.content); + + return ( +
+
+ {tabs.map((tab) => ( + + ))} +
+ +
+
+          {tabs.find((t) => t.key === activeTab)?.content || "No content"}
+        
+
+
+ ); +} diff --git a/frontend/src/harbor/components/TrajectoryView.tsx b/frontend/src/harbor/components/TrajectoryView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6319ca2580a4322766baacb37d8af6fe07ab394d --- /dev/null +++ b/frontend/src/harbor/components/TrajectoryView.tsx @@ -0,0 +1,142 @@ +import { useRef, useEffect } from "react"; +import type { DatasetInfo, InstanceDetail, TrajectoryMode, RawStep } from "../types"; +import { RawBubble, AtifBubble } from "./ChatBubble"; +import { StepDetail } from "./StepDetail"; + +interface Props { + dataset: DatasetInfo; + detail: InstanceDetail; + mode: TrajectoryMode; + isSingle: boolean; +} + +export function TrajectoryView({ dataset, detail, mode, isSingle }: Props) { + const scrollRef = useRef(null); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0; + } + }, [detail.instance_id, mode]); + + // Build tool_call_id → response map for raw mode + const toolResponseMap = new Map(); + if (mode === "raw") { + for (const step of detail.raw_steps) { + if (step.role === "tool" && step.tool_call_id) { + toolResponseMap.set(step.tool_call_id, step.content); + } + } + } + + // For raw mode, group assistant messages with their tool responses + // to avoid showing tool responses twice + const pairedToolCallIds = new Set(); + if (mode === "raw") { + for (const step of detail.raw_steps) { + if (step.role === "assistant" && step.tool_calls) { + for (const tc of step.tool_calls) { + if (toolResponseMap.has(tc.id)) { + pairedToolCallIds.add(tc.id); + } + } + } + } + } + + // Filter out standalone tool messages that are already paired + const filteredRawSteps: RawStep[] = + mode === "raw" + ? detail.raw_steps.filter((step) => { + if (step.role === "tool" && step.tool_call_id) { + return !pairedToolCallIds.has(step.tool_call_id); + } + return true; + }) + : []; + + return ( +
+ {/* Header */} +
+
+ + + {dataset.name} + +
+
+ + {detail.agent || detail.model} + + {detail.duration_seconds > 0 && ( + + {Math.round(detail.duration_seconds)}s + + )} +
+
+ + {/* Chat stream */} +
+ {mode === "raw" && ( + <> + {filteredRawSteps.map((step) => ( + + ))} + {filteredRawSteps.length === 0 && ( +
+ No raw trajectory data available +
+ )} + + )} + + {mode === "atif" && ( + <> + {detail.atif.steps.map((step) => ( + + ))} + {detail.atif.steps.length === 0 && ( +
+ No ATIF trajectory data available. + {detail.n_raw_steps > 0 && " Try Raw Messages mode."} +
+ )} + + )} + + {/* Result badge at bottom */} +
+ + {detail.resolved ? "RESOLVED" : "FAILED"} + {detail.error && ( + ({detail.error}) + )} + +
+
+ + {/* Raw logs */} + +
+ ); +} diff --git a/frontend/src/harbor/store.ts b/frontend/src/harbor/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..a57872879fea6195e4030bb220f7e4ee0cb8bb9d --- /dev/null +++ b/frontend/src/harbor/store.ts @@ -0,0 +1,231 @@ +import { useCallback, useState } from "react"; +import type { + DatasetInfo, + GroupedInstance, + InstanceDetail, + Preset, + ViewMode, + TrajectoryMode, +} from "./types"; +import * as api from "./api"; + +export interface AppState { + datasets: DatasetInfo[]; + presets: Preset[]; + groupedInstances: GroupedInstance[]; + selectedInstanceId: string | null; + instanceDetails: Record; // keyed by `${dsId}:${instanceId}` + viewMode: ViewMode; + trajectoryMode: TrajectoryMode; + loading: boolean; + error: string | null; + filterResolved: "all" | "resolved" | "unresolved"; + searchQuery: string; +} + +const initialState: AppState = { + datasets: [], + presets: [], + groupedInstances: [], + selectedInstanceId: null, + instanceDetails: {}, + viewMode: "list", + trajectoryMode: "raw", + loading: false, + error: null, + filterResolved: "all", + searchQuery: "", +}; + +function buildGroupedInstances(datasets: DatasetInfo[]): GroupedInstance[] { + const map = new Map(); + for (const ds of datasets) { + for (const inst of ds.instances) { + if (!map.has(inst.instance_id)) { + map.set(inst.instance_id, { instance_id: inst.instance_id, datasets: [] }); + } + map.get(inst.instance_id)!.datasets.push({ + ds_id: ds.id, + repo: ds.repo, + name: ds.name, + summary: inst, + }); + } + } + const groups = Array.from(map.values()); + groups.sort((a, b) => a.instance_id.localeCompare(b.instance_id)); + return groups; +} + +export function useAppState() { + const [state, setState] = useState(initialState); + + const setError = useCallback((error: string | null) => { + setState((s) => ({ ...s, error })); + }, []); + + const loadDataset = useCallback(async (repo: string, split = "train") => { + setState((s) => ({ ...s, loading: true, error: null })); + try { + const ds = await api.loadDataset(repo, split); + setState((s) => { + const exists = s.datasets.find((d) => d.id === ds.id); + const datasets = exists + ? s.datasets.map((d) => (d.id === ds.id ? ds : d)) + : [...s.datasets, ds]; + return { + ...s, + datasets, + groupedInstances: buildGroupedInstances(datasets), + loading: false, + }; + }); + } catch (e: unknown) { + setState((s) => ({ + ...s, + loading: false, + error: e instanceof Error ? e.message : String(e), + })); + } + }, []); + + const unloadDataset = useCallback(async (dsId: string) => { + try { + await api.unloadDataset(dsId); + setState((s) => { + const datasets = s.datasets.filter((d) => d.id !== dsId); + // Remove cached details for this dataset + const instanceDetails = { ...s.instanceDetails }; + for (const key of Object.keys(instanceDetails)) { + if (key.startsWith(`${dsId}:`)) { + delete instanceDetails[key]; + } + } + return { + ...s, + datasets, + groupedInstances: buildGroupedInstances(datasets), + instanceDetails, + }; + }); + } catch (e: unknown) { + setState((s) => ({ + ...s, + error: e instanceof Error ? e.message : String(e), + })); + } + }, []); + + const selectInstance = useCallback( + async (instanceId: string | null) => { + setState((s) => ({ + ...s, + selectedInstanceId: instanceId, + viewMode: instanceId ? "detail" : "list", + })); + if (!instanceId) return; + + // Load details for all datasets that have this instance + setState((s) => ({ ...s, loading: true })); + try { + const { datasets } = state; + const promises: Promise[] = []; + for (const ds of datasets) { + const hasInstance = ds.instances.some( + (inst) => inst.instance_id === instanceId + ); + if (!hasInstance) continue; + + const cacheKey = `${ds.id}:${instanceId}`; + // Skip if already cached + if (state.instanceDetails[cacheKey]) continue; + + promises.push( + api.getInstance(ds.id, instanceId).then((detail) => { + setState((s) => ({ + ...s, + instanceDetails: { + ...s.instanceDetails, + [cacheKey]: detail, + }, + })); + }) + ); + } + await Promise.all(promises); + setState((s) => ({ ...s, loading: false })); + } catch (e: unknown) { + setState((s) => ({ + ...s, + loading: false, + error: e instanceof Error ? e.message : String(e), + })); + } + }, + [state.datasets, state.instanceDetails] + ); + + const loadPresets = useCallback(async () => { + try { + const presets = await api.listPresets(); + setState((s) => ({ ...s, presets })); + } catch { + // Presets might not exist yet + } + }, []); + + const createPreset = useCallback( + async (name: string, repo: string, split = "train") => { + const preset = await api.createPreset(name, repo, split); + setState((s) => ({ ...s, presets: [...s.presets, preset] })); + }, + [] + ); + + const deletePreset = useCallback(async (id: string) => { + await api.deletePreset(id); + setState((s) => ({ ...s, presets: s.presets.filter((p) => p.id !== id) })); + }, []); + + const loadPreset = useCallback( + async (preset: Preset) => { + await loadDataset(preset.repo, preset.split); + }, + [loadDataset] + ); + + const setViewMode = useCallback((viewMode: ViewMode) => { + setState((s) => ({ ...s, viewMode })); + }, []); + + const setTrajectoryMode = useCallback((trajectoryMode: TrajectoryMode) => { + setState((s) => ({ ...s, trajectoryMode })); + }, []); + + const setFilterResolved = useCallback( + (filterResolved: "all" | "resolved" | "unresolved") => { + setState((s) => ({ ...s, filterResolved })); + }, + [] + ); + + const setSearchQuery = useCallback((searchQuery: string) => { + setState((s) => ({ ...s, searchQuery })); + }, []); + + return { + state, + loadDataset, + unloadDataset, + selectInstance, + loadPresets, + createPreset, + deletePreset, + loadPreset, + setViewMode, + setTrajectoryMode, + setFilterResolved, + setSearchQuery, + setError, + }; +} diff --git a/frontend/src/harbor/types.ts b/frontend/src/harbor/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..9cfcc12f7f49cc4a96421d2a74243b71b751e1ee --- /dev/null +++ b/frontend/src/harbor/types.ts @@ -0,0 +1,105 @@ +// Dataset / repo level +export interface DatasetInfo { + id: string; + repo: string; + name: string; + split: string; + instances: InstanceSummary[]; + n_instances: number; +} + +// Instance summary (list level) +export interface InstanceSummary { + instance_id: string; + resolved: boolean; + reward: number; + model: string; + agent: string; + duration_seconds: number; + error: string; +} + +// ATIF step +export interface AtifStep { + index: number; + source: "system" | "user" | "agent" | "unknown"; + message: string; + timestamp?: string; + reasoning?: string; + tool_calls?: AtifToolCall[]; + observation?: string; + metrics?: Record; +} + +export interface AtifToolCall { + function: string; + arguments?: Record; + command?: string; +} + +export interface AtifTrajectory { + steps: AtifStep[]; + agent_info: Record; + final_metrics: Record; +} + +// Raw trajectory step (OpenAI messages format) +export interface RawStep { + index: number; + role: "system" | "user" | "assistant" | "tool" | "unknown"; + content: string; + tool_calls?: RawToolCall[]; + tool_call_id?: string; +} + +export interface RawToolCall { + id: string; + function: string; + arguments_raw: string; + arguments: Record; + command?: string; +} + +// Full instance detail +export interface InstanceDetail { + instance_id: string; + resolved: boolean; + reward: number; + model: string; + agent: string; + duration_seconds: number; + error: string; + atif: AtifTrajectory; + raw_steps: RawStep[]; + n_atif_steps: number; + n_raw_steps: number; +} + +// Raw logs +export interface InstanceRawLogs { + instance_id: string; + agent_stdout: string; + setup_stderr: string; + verifier_report: string; + verifier_stdout: string; +} + +// Preset (single repo per preset, matching other visualizers) +export interface Preset { + id: string; + name: string; + repo: string; + split: string; +} + +// Grouped instance across repos +export interface GroupedInstance { + instance_id: string; + datasets: { ds_id: string; repo: string; name: string; summary: InstanceSummary }[]; +} + +// View mode +export type ViewMode = "list" | "detail"; + +// Trajectory display mode +export type TrajectoryMode = "raw" | "atif"; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..8a27a6a878d5f597f47a510784a8616cc31d6b25 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,62 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom scrollbar for trace panels */ +.trace-scroll::-webkit-scrollbar { + width: 6px; +} +.trace-scroll::-webkit-scrollbar-track { + background: transparent; +} +.trace-scroll::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 3px; +} +.trace-scroll::-webkit-scrollbar-thumb:hover { + background: #6b7280; +} + +/* Drag-to-reorder panel feedback — themed per visualizer */ +.theme-model .panel-drop-target { + outline: 2px dashed #60a5fa; + outline-offset: -2px; + border-radius: 0.5rem; + background: rgba(96, 165, 250, 0.05); +} + +.theme-arena .panel-drop-target { + outline: 2px dashed #a78bfa; + outline-offset: -2px; + border-radius: 0.5rem; + background: rgba(167, 139, 250, 0.05); +} + +.theme-rlm .panel-drop-target { + outline: 2px dashed #fb923c; + outline-offset: -2px; + border-radius: 0.5rem; + background: rgba(251, 146, 60, 0.05); +} + +.theme-harbor .panel-drop-target { + outline: 2px dashed #2dd4bf; + outline-offset: -2px; + border-radius: 0.5rem; + background: rgba(45, 212, 191, 0.05); +} + +/* Code block styling (used by Harbor visualizer) */ +.code-block { + font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace; + font-size: 0.8rem; + line-height: 1.4; +} + +.drag-handle { + cursor: grab; +} + +.drag-handle:active { + cursor: grabbing; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b67590a0691533907c66b9af18228a5e4310fd3 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + +); diff --git a/frontend/src/model/ModelApp.tsx b/frontend/src/model/ModelApp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d3bfdb58640f06a2f88186e997e879995a381725 --- /dev/null +++ b/frontend/src/model/ModelApp.tsx @@ -0,0 +1,228 @@ +import { useEffect, useCallback, useRef, useState } from "react"; +import { useAppState } from "./store"; +import Sidebar from "./components/Sidebar"; +import TracePanel, { type DragHandleProps } from "./components/TracePanel"; +import InfoBar from "./components/InfoBar"; +import QuestionNav from "./components/QuestionNav"; +import type { DatasetInfo, QuestionData, Preset } from "./types"; +import { api } from "./api"; + +export default function ModelApp() { + const state = useAppState(); + + const handleLoadPreset = useCallback(async (preset: Preset) => { + await state.addDataset(preset.repo, preset.column, preset.split, undefined, preset.id, preset.name); + }, [state.addDataset]); + + const handleSavePreset = useCallback(async (name: string, repo: string, column: string, split?: string) => { + const preset = await api.createPreset(name, repo, column, split); + state.setPresets((prev) => [...prev, preset]); + }, []); + + const handleDeletePreset = useCallback(async (id: string, datasetId?: string) => { + await api.deletePreset(id); + state.setPresets((prev) => prev.filter((p) => p.id !== id)); + if (datasetId) { + state.clearDatasetPreset(datasetId); + } + }, [state.clearDatasetPreset]); + + const handleUpdatePreset = useCallback(async (presetId: string, datasetId: string, updates: { name?: string }) => { + const updated = await api.updatePreset(presetId, updates); + state.setPresets(prev => prev.map(p => p.id === presetId ? updated : p)); + if (updates.name) { + state.updateDatasetPresetName(datasetId, updates.name); + } + }, [state.updateDatasetPresetName]); + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + switch (e.key) { + case "j": + state.setQuestionIdx((prev) => Math.min(state.maxQuestions - 1, prev + 1)); + break; + case "k": + state.setQuestionIdx((prev) => Math.max(0, prev - 1)); + break; + case "l": + state.setSampleIdx((prev) => Math.min(state.maxSamples - 1, prev + 1)); + break; + case "h": + state.setSampleIdx((prev) => Math.max(0, prev - 1)); + break; + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [state.maxQuestions, state.maxSamples, state.setQuestionIdx, state.setSampleIdx]); + + return ( +
+ + +
+ {/* Error banner */} + {state.error && ( +
+ {state.error} + +
+ )} + + + + {/* Trace panels (drag to reorder) */} + + + +
+
+ ); +} + +/* ── Drag-to-reorder panel container ── */ + +interface PanelContainerProps { + datasets: DatasetInfo[]; + getQuestionData: (dsId: string) => QuestionData | undefined; + sampleIdx: number; + onReorder: (fromId: string, toId: string) => void; +} + +function PanelContainer({ datasets, getQuestionData, sampleIdx, onReorder }: PanelContainerProps) { + const [draggedId, setDraggedId] = useState(null); + const [overId, setOverId] = useState(null); + const dragCounter = useRef>({}); + + const handleDragStart = useCallback((e: React.DragEvent, id: string) => { + setDraggedId(id); + e.dataTransfer.effectAllowed = "move"; + // Use a transparent 1x1 image so the browser doesn't clone the panel + const ghost = document.createElement("canvas"); + ghost.width = 1; + ghost.height = 1; + e.dataTransfer.setDragImage(ghost, 0, 0); + }, []); + + const handleDragEnd = useCallback(() => { + setDraggedId(null); + setOverId(null); + dragCounter.current = {}; + }, []); + + const handleDragEnter = useCallback((e: React.DragEvent, id: string) => { + e.preventDefault(); + dragCounter.current[id] = (dragCounter.current[id] || 0) + 1; + setOverId(id); + }, []); + + const handleDragLeave = useCallback((_e: React.DragEvent, id: string) => { + dragCounter.current[id] = (dragCounter.current[id] || 0) - 1; + if (dragCounter.current[id] <= 0) { + dragCounter.current[id] = 0; + setOverId(prev => prev === id ? null : prev); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetId: string) => { + e.preventDefault(); + if (draggedId && draggedId !== targetId) { + onReorder(draggedId, targetId); + } + setDraggedId(null); + setOverId(null); + dragCounter.current = {}; + }, [draggedId, onReorder]); + + if (datasets.length === 0) { + return ( +
+
+
+

No repos active

+

Add a HuggingFace repo from the sidebar to get started

+
+
+
+ ); + } + + return ( +
+ {datasets.map((ds) => { + const isDragged = draggedId === ds.id; + const isOver = overId === ds.id && draggedId !== null && draggedId !== ds.id; + + const handleProps: DragHandleProps = { + draggable: true, + onDragStart: (e) => handleDragStart(e, ds.id), + onDragEnd: handleDragEnd, + }; + + return ( +
handleDragEnter(e, ds.id)} + onDragLeave={(e) => handleDragLeave(e, ds.id)} + onDragOver={handleDragOver} + onDrop={(e) => handleDrop(e, ds.id)} + className={`flex-1 min-w-0 transition-all duration-150 ${ + isDragged ? "opacity-30 scale-[0.97]" : "" + } ${isOver ? "panel-drop-target" : ""}`} + > + +
+ ); + })} +
+ ); +} diff --git a/frontend/src/model/api.ts b/frontend/src/model/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8c3fdbbb8b286488aeedfe424b543c1ed162b97 --- /dev/null +++ b/frontend/src/model/api.ts @@ -0,0 +1,63 @@ +import type { DatasetInfo, QuestionData, DatasetSummary, Preset } from "./types"; + +const BASE = "/api/model"; +const PRESETS_BASE = "/api/presets/model"; + +async function fetchJSON(url: string, opts?: RequestInit): Promise { + const res = await fetch(url, { + headers: { "Content-Type": "application/json" }, + ...opts, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + return res.json(); +} + +export const api = { + loadDataset(repo: string, column?: string, split?: string, promptColumn?: string) { + return fetchJSON(`${BASE}/datasets/load`, { + method: "POST", + body: JSON.stringify({ repo, column, split, prompt_column: promptColumn }), + }); + }, + + listDatasets() { + return fetchJSON(`${BASE}/datasets/`); + }, + + getQuestion(dsId: string, idx: number) { + return fetchJSON(`${BASE}/datasets/${dsId}/question/${idx}`); + }, + + getSummary(dsId: string) { + return fetchJSON(`${BASE}/datasets/${dsId}/summary`); + }, + + unloadDataset(dsId: string) { + return fetchJSON<{ status: string }>(`${BASE}/datasets/${dsId}`, { method: "DELETE" }); + }, + + listPresets() { + return fetchJSON(`${PRESETS_BASE}`); + }, + + createPreset(name: string, repo: string, column: string, split?: string) { + return fetchJSON(`${PRESETS_BASE}`, { + method: "POST", + body: JSON.stringify({ name, repo, column, split }), + }); + }, + + updatePreset(id: string, updates: { name?: string; column?: string; split?: string }) { + return fetchJSON(`${PRESETS_BASE}/${id}`, { + method: "PUT", + body: JSON.stringify(updates), + }); + }, + + deletePreset(id: string) { + return fetchJSON<{ status: string }>(`${PRESETS_BASE}/${id}`, { method: "DELETE" }); + }, +}; diff --git a/frontend/src/model/components/InfoBar.tsx b/frontend/src/model/components/InfoBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..57ff243cadd26fd5c743e94050f3478c4a8a8b3b --- /dev/null +++ b/frontend/src/model/components/InfoBar.tsx @@ -0,0 +1,91 @@ +import type { DatasetInfo, QuestionData } from "../types"; + +interface InfoBarProps { + activeDatasets: DatasetInfo[]; + questionIdx: number; + sampleIdx: number; + getQuestionData: (dsId: string) => QuestionData | undefined; +} + +export default function InfoBar({ activeDatasets, questionIdx, sampleIdx, getQuestionData }: InfoBarProps) { + let questionText = ""; + let nSamples = 0; + const firstData = activeDatasets.length > 0 ? getQuestionData(activeDatasets[0].id) : undefined; + if (firstData) { + questionText = firstData.question; + nSamples = firstData.n_samples; + } + + if (!questionText) { + return ( +
+

Load repos and select a question to begin

+
+ ); + } + + return ( +
+ {/* Question text */} +
+ Q{questionIdx}: {questionText} +
+ + {/* Sample bar */} + {nSamples > 1 && ( +
+ Samples: + {Array.from({ length: nSamples }, (_, i) => { + const results = activeDatasets.map((ds) => { + const d = getQuestionData(ds.id); + return d?.eval_correct[i]; + }); + const allCorrect = results.every((r) => r === true); + const someCorrect = results.some((r) => r === true); + const noneCorrect = results.every((r) => r === false); + + let bgColor = "bg-gray-700"; + if (allCorrect) bgColor = "bg-green-700"; + else if (someCorrect) bgColor = "bg-yellow-700"; + else if (noneCorrect) bgColor = "bg-red-900"; + + const isSelected = i === sampleIdx; + + return ( + `${activeDatasets[j]?.name}=${r ? "correct" : "wrong"}`).join(", ")}`} + > + {i + 1} + + ); + })} + + all + some + none + +
+ )} + + {/* Per-repo correctness for current sample */} +
+ {activeDatasets.map((ds) => { + const d = getQuestionData(ds.id); + const correct = d?.eval_correct[sampleIdx]; + return ( + + {ds.name}: + + {correct === undefined ? "?" : correct ? "Correct" : "Wrong"} + + + ); + })} +
+
+ ); +} diff --git a/frontend/src/model/components/QuestionNav.tsx b/frontend/src/model/components/QuestionNav.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e92ca5f32b4fd07bb4de78317d30c25a72de58fb --- /dev/null +++ b/frontend/src/model/components/QuestionNav.tsx @@ -0,0 +1,112 @@ +import type { FilterMode } from "../types"; + +interface QuestionNavProps { + questionIdx: number; + sampleIdx: number; + maxQuestions: number; + maxSamples: number; + filter: FilterMode; + onQuestionChange: (idx: number) => void; + onSampleChange: (idx: number) => void; + onFilterChange: (filter: FilterMode) => void; +} + +const FILTERS: { value: FilterMode; label: string }[] = [ + { value: "all", label: "All" }, + { value: "improvements", label: "Improvements" }, + { value: "regressions", label: "Regressions" }, + { value: "both-correct", label: "Both Correct" }, + { value: "both-wrong", label: "Both Wrong" }, +]; + +export default function QuestionNav({ + questionIdx, sampleIdx, maxQuestions, maxSamples, + filter, onQuestionChange, onSampleChange, onFilterChange, +}: QuestionNavProps) { + const prevQ = () => onQuestionChange(Math.max(0, questionIdx - 1)); + const nextQ = () => onQuestionChange(Math.min(maxQuestions - 1, questionIdx + 1)); + const prevS = () => onSampleChange(Math.max(0, sampleIdx - 1)); + const nextS = () => onSampleChange(Math.min(maxSamples - 1, sampleIdx + 1)); + + return ( +
+ {/* Question navigation */} +
+ +
+ Q + { + const v = parseInt(e.target.value); + if (!isNaN(v) && v >= 0 && v < maxQuestions) onQuestionChange(v); + }} + className="w-16 px-1.5 py-1 text-xs text-center bg-gray-800 border border-gray-600 rounded text-gray-200 focus:border-blue-500 focus:outline-none" + /> + / {maxQuestions > 0 ? maxQuestions - 1 : 0} +
+ +
+ + {/* Sample navigation */} + {maxSamples > 1 && ( +
+ + + Sample {sampleIdx + 1}/{maxSamples} + + +
+ )} + + {/* Filter */} +
+ {FILTERS.map((f) => ( + + ))} +
+ + {/* Keyboard hints */} +
+ j/k question + {" "} + h/l sample +
+
+ ); +} diff --git a/frontend/src/model/components/Sidebar.tsx b/frontend/src/model/components/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..348d1f72fcb6573082b36ddd0fd5f552d1c88121 --- /dev/null +++ b/frontend/src/model/components/Sidebar.tsx @@ -0,0 +1,378 @@ +import { useState } from "react"; +import type { DatasetInfo, Preset } from "../types"; + +// Consistent group colors for visual distinction +const GROUP_COLORS = [ + { bg: "bg-blue-500", border: "border-blue-500", text: "text-blue-400", label: "text-blue-300" }, + { bg: "bg-emerald-500", border: "border-emerald-500", text: "text-emerald-400", label: "text-emerald-300" }, + { bg: "bg-amber-500", border: "border-amber-500", text: "text-amber-400", label: "text-amber-300" }, + { bg: "bg-purple-500", border: "border-purple-500", text: "text-purple-400", label: "text-purple-300" }, + { bg: "bg-rose-500", border: "border-rose-500", text: "text-rose-400", label: "text-rose-300" }, + { bg: "bg-cyan-500", border: "border-cyan-500", text: "text-cyan-400", label: "text-cyan-300" }, +]; + +interface SidebarProps { + datasets: DatasetInfo[]; + presets: Preset[]; + loading: Record; + groups: Record; + groupIds: string[]; + currentGroupId: string | null; + onAddDataset: (repo: string, column?: string, split?: string, promptColumn?: string) => void; + onRemoveDataset: (id: string) => void; + onToggleDataset: (id: string) => void; + onSetCurrentGroup: (groupId: string) => void; + onLoadPreset: (preset: Preset) => void; + onSavePreset: (name: string, repo: string, column: string, split?: string) => void; + onDeletePreset: (id: string, datasetId?: string) => void; + onUpdatePreset: (presetId: string, datasetId: string, updates: { name?: string }) => void; +} + +export default function Sidebar({ + datasets, presets, loading, + groups, groupIds, currentGroupId, + onAddDataset, onRemoveDataset, onToggleDataset, onSetCurrentGroup, + onLoadPreset, onSavePreset, onDeletePreset, onUpdatePreset, +}: SidebarProps) { + const [showAddModal, setShowAddModal] = useState(false); + const [repoInput, setRepoInput] = useState(""); + const [columnInput, setColumnInput] = useState("model_responses"); + const [splitInput, setSplitInput] = useState("train"); + const [promptColumnInput, setPromptColumnInput] = useState("formatted_prompt"); + const [presetSearch, setPresetSearch] = useState(""); + // Track which dataset is currently being saved as a preset (by dataset id) + const [savingPresetForId, setSavingPresetForId] = useState(null); + const [presetName, setPresetName] = useState(""); + // Track which dataset is selected for preset editing + const [editingDatasetId, setEditingDatasetId] = useState(null); + const [editPresetName, setEditPresetName] = useState(""); + + const handleAdd = () => { + if (!repoInput.trim()) return; + onAddDataset( + repoInput.trim(), + columnInput.trim() || undefined, + splitInput.trim() || undefined, + promptColumnInput.trim() || undefined, + ); + setRepoInput(""); + setShowAddModal(false); + }; + + const handleSavePresetForRepo = (ds: DatasetInfo) => { + if (!presetName.trim()) return; + onSavePreset(presetName.trim(), ds.repo, ds.column, ds.split); + setPresetName(""); + setSavingPresetForId(null); + }; + + const getGroupColor = (groupId: string) => { + const idx = groupIds.indexOf(groupId); + return GROUP_COLORS[idx % GROUP_COLORS.length]; + }; + + return ( +
+ {/* Presets section */} +
+
+

Presets

+
+ {presets.length === 0 ? ( +

No presets saved

+ ) : ( + <> + {presets.length > 6 && ( + setPresetSearch(e.target.value)} + placeholder="Search presets..." + className="w-full px-2 py-1 mb-2 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none" + /> + )} +
+ {presets + .filter((p) => !presetSearch || p.name.toLowerCase().includes(presetSearch.toLowerCase()) || p.repo.toLowerCase().includes(presetSearch.toLowerCase())) + .map((p) => ( +
+ +
+ +
+
+ ))} +
+ + )} +
+ + {/* Datasets section — grouped by question fingerprint */} +
+

Loaded Repos

+ {datasets.length === 0 ? ( +

No repos loaded. Add one below.

+ ) : ( +
+ {groupIds.map((gid) => { + const color = getGroupColor(gid); + const groupDatasets = groups[gid]; + const isCurrentGroup = gid === currentGroupId; + + return ( +
+ {/* Group header — clickable to switch group */} + + + {/* Repos in this group */} +
+ {groupDatasets.map((ds) => ( +
+
{ + if (ds.presetId) { + setEditingDatasetId(editingDatasetId === ds.id ? null : ds.id); + setEditPresetName(ds.presetName || ""); + setShowAddModal(false); + } + }} + className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm transition-colors ${ + ds.active ? "bg-gray-800" : "bg-gray-900 opacity-60" + } ${editingDatasetId === ds.id ? "ring-1 ring-blue-500" : ""} ${ds.presetId ? "cursor-pointer" : ""}`} + > + onToggleDataset(ds.id)} + onClick={(e) => e.stopPropagation()} + className="rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500 focus:ring-offset-0" + /> +
+
+ {ds.presetName || ds.name} +
+
+ {ds.column} | {ds.n_rows} rows | {ds.n_samples} samples +
+
+ {/* Save as preset */} + + {/* Remove */} + +
+ {/* Inline preset name input */} + {savingPresetForId === ds.id && ( +
+ setPresetName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSavePresetForRepo(ds); + if (e.key === "Escape") setSavingPresetForId(null); + }} + placeholder="Preset name..." + className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none" + autoFocus + /> + +
+ )} +
+ ))} +
+
+ ); + })} +
+ )} +
+ + {/* Preset edit panel */} + {editingDatasetId && (() => { + const editDs = datasets.find(d => d.id === editingDatasetId); + if (!editDs?.presetId) return null; + return ( +
+
Edit Preset
+ setEditPresetName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && editPresetName.trim()) { + onUpdatePreset(editDs.presetId!, editDs.id, { name: editPresetName.trim() }); + setEditingDatasetId(null); + } + if (e.key === "Escape") setEditingDatasetId(null); + }} + placeholder="Preset name..." + className="w-full px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none" + autoFocus + /> +
+ + + +
+
+ ); + })()} + + {/* Add repo section */} +
+ {!showAddModal ? ( + + ) : ( +
+ setRepoInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + placeholder="org/dataset-name" + className="w-full px-2 py-1.5 text-sm bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none" + autoFocus + /> +
+ setColumnInput(e.target.value)} + placeholder="Column" + className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none" + /> + setSplitInput(e.target.value)} + placeholder="Split" + className="w-16 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none" + /> +
+
+ setPromptColumnInput(e.target.value)} + placeholder="Prompt col" + className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none" + /> +
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/model/components/TracePanel.tsx b/frontend/src/model/components/TracePanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..447fd6ad13a5e94708927491ed4ffe016340e9dc --- /dev/null +++ b/frontend/src/model/components/TracePanel.tsx @@ -0,0 +1,171 @@ +import { useState } from "react"; +import type { QuestionData } from "../types"; +import { highlightTrace } from "../utils/traceHighlight"; +import { parsePrompt, type ParsedMessage } from "../utils/promptParser"; + +export interface DragHandleProps { + draggable: true; + onDragStart: (e: React.DragEvent) => void; + onDragEnd: (e: React.DragEvent) => void; +} + +interface TracePanelProps { + datasetName: string; + repoName?: string; + data: QuestionData | undefined; + sampleIdx: number; + isLoading?: boolean; + dragHandleProps?: DragHandleProps; +} + +export default function TracePanel({ datasetName, repoName, data, sampleIdx, isLoading, dragHandleProps }: TracePanelProps) { + const [promptExpanded, setPromptExpanded] = useState(false); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!data) { + return ( +
+
No data
+
+ ); + } + + const isCorrect = data.eval_correct[sampleIdx]; + const analysis = data.analyses[sampleIdx]; + const extraction = data.extractions?.[sampleIdx]; + + const borderColor = isCorrect === undefined + ? "border-gray-700" + : isCorrect + ? "border-green-600" + : "border-red-600"; + + const thinkSegments = highlightTrace(analysis?.think_text || ""); + const answerText = analysis?.answer_text || ""; + + const promptMessages = data.prompt_text ? parsePrompt(data.prompt_text) : []; + + return ( +
+ {/* Header */} +
+
+ {datasetName} + {isCorrect !== undefined && ( + + {isCorrect ? "CORRECT" : "WRONG"} + + )} +
+
+ + {analysis && ( + <>Think: {analysis.think_len.toLocaleString()} | BT: {analysis.backtracks} + )} + + {dragHandleProps && ( + + + + + + + + + + + )} +
+
+ + {/* Extraction / extracted answer */} + {extraction && ( +
+ Extracted: + {extraction} +
+ )} + + {/* Trace content */} +
+ {/* Prompt section — collapsible */} + {promptMessages.length > 0 && ( +
+ + {promptExpanded && ( +
+ {promptMessages.map((msg, i) => ( + + ))} +
+ )} +
+ )} + + {/* Thinking section */} +
+
+ Thinking ({analysis?.think_len.toLocaleString() || 0} chars) +
+
+            {thinkSegments.map((seg, i) => (
+              {seg.text}
+            ))}
+          
+
+ + {/* Answer section */} + {answerText && ( +
+
+ Answer ({analysis?.answer_len.toLocaleString() || 0} chars) +
+
+              {answerText}
+            
+
+ )} +
+
+ ); +} + +const ROLE_STYLES: Record = { + system: { border: "border-l-purple-500", label: "text-purple-400", bg: "bg-purple-500/5" }, + user: { border: "border-l-blue-500", label: "text-blue-400", bg: "bg-blue-500/5" }, + assistant: { border: "border-l-green-500", label: "text-green-400", bg: "bg-green-500/5" }, + prompt: { border: "border-l-gray-500", label: "text-gray-400", bg: "bg-gray-500/5" }, +}; + +function PromptMessage({ message }: { message: ParsedMessage }) { + const style = ROLE_STYLES[message.role] || ROLE_STYLES.prompt; + return ( +
+
+ {message.role} +
+
+        {message.content}
+      
+
+ ); +} diff --git a/frontend/src/model/store.ts b/frontend/src/model/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad360e4051be86f9e57f383a1f88a8b1098be051 --- /dev/null +++ b/frontend/src/model/store.ts @@ -0,0 +1,253 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import type { DatasetInfo, QuestionData, Preset, FilterMode } from "./types"; +import { api } from "./api"; + +interface GroupIndices { + questionIdx: number; + sampleIdx: number; +} + +export function useAppState() { + const [datasets, setDatasets] = useState([]); + const [presets, setPresets] = useState([]); + const [filter, setFilter] = useState("all"); + const [questionDataMap, setQuestionDataMap] = useState>({}); + const [loading, setLoading] = useState>({}); + const [error, setError] = useState(null); + + // Per-group navigation indices + const [groupIndices, setGroupIndices] = useState>({}); + // Which group is currently displayed (fingerprint) + const [currentGroupId, setCurrentGroupId] = useState(null); + + // Load presets on mount + useEffect(() => { + api.listPresets().then(setPresets).catch(() => {}); + }, []); + + // Sync URL state on mount + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const q = parseInt(params.get("q") || "0"); + const s = parseInt(params.get("s") || "0"); + const f = (params.get("filter") || "all") as FilterMode; + setFilter(f); + // q and s will be applied once the first group is set + if (!isNaN(q) || !isNaN(s)) { + // Store initial URL indices to apply to first group loaded + (window as unknown as Record).__initialQ = isNaN(q) ? 0 : q; + (window as unknown as Record).__initialS = isNaN(s) ? 0 : s; + } + }, []); + + // Derive groups from datasets by fingerprint + const groups = useMemo(() => { + const map: Record = {}; + for (const ds of datasets) { + const fp = ds.questionFingerprint; + if (!map[fp]) map[fp] = []; + map[fp].push(ds); + } + return map; + }, [datasets]); + + const groupIds = useMemo(() => Object.keys(groups).sort(), [groups]); + + // Auto-set currentGroupId if not set or invalid + useEffect(() => { + if (currentGroupId && groups[currentGroupId]) return; + // Pick first group that has active datasets, or first group overall + const activeGroup = groupIds.find(gid => groups[gid].some(d => d.active)); + if (activeGroup) { + setCurrentGroupId(activeGroup); + } else if (groupIds.length > 0) { + setCurrentGroupId(groupIds[0]); + } else { + setCurrentGroupId(null); + } + }, [groupIds, groups, currentGroupId]); + + // Active datasets = active datasets in current group + const activeDatasets = useMemo( + () => datasets.filter(d => d.active && d.questionFingerprint === currentGroupId), + [datasets, currentGroupId] + ); + + // Panel ordering: track display order of active dataset IDs + const [panelOrder, setPanelOrder] = useState([]); + + // Keep panelOrder in sync with activeDatasets: add new IDs at end, remove stale ones + useEffect(() => { + const activeIds = new Set(activeDatasets.map(d => d.id)); + setPanelOrder(prev => { + const kept = prev.filter(id => activeIds.has(id)); + const newIds = activeDatasets.map(d => d.id).filter(id => !prev.includes(id)); + const merged = [...kept, ...newIds]; + // Only update if changed to avoid unnecessary renders + if (merged.length === prev.length && merged.every((id, i) => id === prev[i])) return prev; + return merged; + }); + }, [activeDatasets]); + + // Ordered active datasets according to panelOrder + const orderedActiveDatasets = useMemo(() => { + const map = new Map(activeDatasets.map(d => [d.id, d])); + return panelOrder.map(id => map.get(id)).filter((d): d is DatasetInfo => d !== undefined); + }, [activeDatasets, panelOrder]); + + const reorderPanels = useCallback((fromId: string, toId: string) => { + if (fromId === toId) return; + setPanelOrder(prev => { + const order = [...prev]; + const fromIdx = order.indexOf(fromId); + const toIdx = order.indexOf(toId); + if (fromIdx === -1 || toIdx === -1) return prev; + order.splice(fromIdx, 1); + order.splice(toIdx, 0, fromId); + return order; + }); + }, []); + + // Current group's indices + const currentIndices = currentGroupId ? groupIndices[currentGroupId] : undefined; + const questionIdx = currentIndices?.questionIdx ?? 0; + const sampleIdx = currentIndices?.sampleIdx ?? 0; + + const setQuestionIdx = useCallback((val: number | ((prev: number) => number)) => { + if (!currentGroupId) return; + setGroupIndices(prev => { + const cur = prev[currentGroupId] ?? { questionIdx: 0, sampleIdx: 0 }; + const newQ = typeof val === "function" ? val(cur.questionIdx) : val; + return { ...prev, [currentGroupId]: { ...cur, questionIdx: newQ } }; + }); + }, [currentGroupId]); + + const setSampleIdx = useCallback((val: number | ((prev: number) => number)) => { + if (!currentGroupId) return; + setGroupIndices(prev => { + const cur = prev[currentGroupId] ?? { questionIdx: 0, sampleIdx: 0 }; + const newS = typeof val === "function" ? val(cur.sampleIdx) : val; + return { ...prev, [currentGroupId]: { ...cur, sampleIdx: newS } }; + }); + }, [currentGroupId]); + + // Update URL when state changes + useEffect(() => { + const params = new URLSearchParams(); + const activeRepos = datasets.filter((d) => d.active); + if (activeRepos.length > 0) { + params.set("repos", activeRepos.map((d) => d.repo).join(",")); + params.set("cols", activeRepos.map((d) => d.column).join(",")); + params.set("pcols", activeRepos.map((d) => d.promptColumn || "formatted_prompt").join(",")); + } + params.set("q", String(questionIdx)); + params.set("s", String(sampleIdx)); + if (filter !== "all") params.set("filter", filter); + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.replaceState({}, "", newUrl); + }, [datasets, questionIdx, sampleIdx, filter]); + + // Fetch question data for active datasets in current group when question changes + useEffect(() => { + activeDatasets.forEach((ds) => { + const key = `${ds.id}:${questionIdx}`; + if (!questionDataMap[key]) { + api.getQuestion(ds.id, questionIdx).then((data) => { + setQuestionDataMap((prev) => ({ ...prev, [key]: data })); + }).catch(() => {}); + } + }); + }, [questionIdx, activeDatasets]); + + const addDataset = useCallback(async ( + repo: string, column?: string, split?: string, promptColumn?: string, + presetId?: string, presetName?: string, + ) => { + setLoading((prev) => ({ ...prev, [repo]: true })); + setError(null); + try { + const { question_fingerprint, ...rest } = await api.loadDataset(repo, column, split, promptColumn); + const fp = question_fingerprint ?? ""; + const dsInfo: DatasetInfo = { + ...rest, + questionFingerprint: fp, + active: true, + presetId, + presetName, + }; + + setDatasets((prev) => { + if (prev.some((d) => d.id === dsInfo.id)) return prev; + return [...prev, dsInfo]; + }); + + // Initialize group indices if new group, or inherit existing + setGroupIndices(prev => { + if (prev[fp]) return prev; // Group already exists, new repo inherits its indices + // New group — check for initial URL params or start at 0 + const win = window as unknown as Record; + const initQ = typeof win.__initialQ === "number" ? win.__initialQ : 0; + const initS = typeof win.__initialS === "number" ? win.__initialS : 0; + // Only use initial params for the very first group + const isFirstGroup = Object.keys(prev).length === 0; + return { + ...prev, + [fp]: { questionIdx: isFirstGroup ? initQ : 0, sampleIdx: isFirstGroup ? initS : 0 }, + }; + }); + + // Switch to the new dataset's group + setCurrentGroupId(fp); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load dataset"); + } finally { + setLoading((prev) => ({ ...prev, [repo]: false })); + } + }, []); + + const removeDataset = useCallback(async (id: string) => { + await api.unloadDataset(id).catch(() => {}); + setDatasets((prev) => prev.filter((d) => d.id !== id)); + }, []); + + const toggleDataset = useCallback((id: string) => { + setDatasets((prev) => { + const updated = prev.map((d) => (d.id === id ? { ...d, active: !d.active } : d)); + // If toggling ON a dataset from a different group, switch to that group + const toggled = updated.find(d => d.id === id); + if (toggled && toggled.active) { + setCurrentGroupId(toggled.questionFingerprint); + } + return updated; + }); + }, []); + + const updateDatasetPresetName = useCallback((dsId: string, name: string) => { + setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetName: name } : d)); + }, []); + + const clearDatasetPreset = useCallback((dsId: string) => { + setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetId: undefined, presetName: undefined } : d)); + }, []); + + const maxQuestions = Math.min(...activeDatasets.map((d) => d.n_rows), Infinity); + const maxSamples = Math.max(...activeDatasets.map((d) => d.n_samples), 0); + + const getQuestionData = (dsId: string): QuestionData | undefined => { + return questionDataMap[`${dsId}:${questionIdx}`]; + }; + + return { + datasets, presets, setPresets, + questionIdx, setQuestionIdx, + sampleIdx, setSampleIdx, + filter, setFilter, + loading, error, setError, + activeDatasets, orderedActiveDatasets, maxQuestions, maxSamples, + addDataset, removeDataset, toggleDataset, + updateDatasetPresetName, clearDatasetPreset, + getQuestionData, reorderPanels, + // Group state + groups, groupIds, currentGroupId, setCurrentGroupId, + }; +} diff --git a/frontend/src/model/types.ts b/frontend/src/model/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..36c98965c11ef8dc67a6e4bcdb27dc25d2d02f35 --- /dev/null +++ b/frontend/src/model/types.ts @@ -0,0 +1,55 @@ +export interface DatasetInfo { + id: string; + repo: string; + name: string; + column: string; + columns: string[]; + split: string; + promptColumn: string | null; + n_rows: number; + n_samples: number; + active: boolean; + questionFingerprint: string; + presetId?: string; + presetName?: string; +} + +export interface TraceAnalysis { + total_len: number; + think_len: number; + answer_len: number; + backtracks: number; + restarts: number; + think_text: string; + answer_text: string; +} + +export interface QuestionData { + question: string; + prompt_text: string; + responses: string[]; + eval_correct: boolean[]; + extractions: string[]; + metadata: Record; + analyses: TraceAnalysis[]; + n_samples: number; + index: number; +} + +export interface DatasetSummary { + n_rows: number; + n_samples: number; + has_eval: boolean; + sample_accuracy?: { correct: number; total: number; rate: number }; + pass_at?: Record; +} + +export interface Preset { + id: string; + name: string; + repo: string; + column: string; + split?: string; +} + +export type FilterMode = "all" | "improvements" | "regressions" | "both-correct" | "both-wrong"; diff --git a/frontend/src/model/utils/promptParser.ts b/frontend/src/model/utils/promptParser.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dba38ce100c73c6205304ec660932237631ab29 --- /dev/null +++ b/frontend/src/model/utils/promptParser.ts @@ -0,0 +1,93 @@ +export interface ParsedMessage { + role: string; + content: string; +} + +export function parsePrompt(text: string): ParsedMessage[] { + if (!text || !text.trim()) return []; + + // Try 1: JSON array of {role, content} objects + try { + const parsed = JSON.parse(text); + if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].role !== undefined) { + return parsed.map((m: Record) => ({ + role: String(m.role || "unknown"), + content: String(m.content ?? ""), + })); + } + } catch { + // Not JSON + } + + // Try 2: ChatML — <|im_start|>role\ncontent<|im_end|> + if (text.includes("<|im_start|>")) { + const parts = text.split("<|im_start|>").filter(Boolean); + return parts.map((part) => { + const nlIdx = part.indexOf("\n"); + const role = nlIdx > 0 ? part.slice(0, nlIdx).trim() : "unknown"; + const content = (nlIdx > 0 ? part.slice(nlIdx + 1) : part) + .replace(/<\|im_end\|>/g, "") + .trim(); + return { role, content }; + }); + } + + // Try 3: Generic chat template — <|system|>, <|user|>, <|assistant|> + if (/<\|(system|user|assistant)\|>/.test(text)) { + const regex = /<\|(system|user|assistant)\|>/g; + const positions: { role: string; start: number; tagEnd: number }[] = []; + let match; + while ((match = regex.exec(text)) !== null) { + positions.push({ + role: match[1], + start: match.index, + tagEnd: match.index + match[0].length, + }); + } + return positions.map((pos, i) => { + const end = i + 1 < positions.length ? positions[i + 1].start : text.length; + return { role: pos.role, content: text.slice(pos.tagEnd, end).trim() }; + }); + } + + // Try 4: Llama-style — <>, [INST], [/INST] + if (text.includes("[INST]") || text.includes("<>")) { + const messages: ParsedMessage[] = []; + const sysMatch = text.match(/<>([\s\S]*?)<<\/SYS>>/); + if (sysMatch) { + messages.push({ role: "system", content: sysMatch[1].trim() }); + } + // Split on [INST] and [/INST] markers + const withoutSys = text.replace(/<>[\s\S]*?<<\/SYS>>/g, ""); + const segments = withoutSys.split(/\[INST\]|\[\/INST\]/).map((s) => s.trim()).filter(Boolean); + let isUser = true; + for (const seg of segments) { + messages.push({ role: isUser ? "user" : "assistant", content: seg }); + isUser = !isUser; + } + return messages.length > 0 ? messages : [{ role: "prompt", content: text }]; + } + + // Try 5: Plain labeled — "System:", "User:", "Assistant:", "Human:" + if (/^(System|User|Assistant|Human):\s/m.test(text)) { + const regex = /^(System|User|Assistant|Human):\s*/gm; + const positions: { role: string; contentStart: number }[] = []; + let match; + while ((match = regex.exec(text)) !== null) { + const role = match[1].toLowerCase() === "human" ? "user" : match[1].toLowerCase(); + positions.push({ role, contentStart: match.index + match[0].length }); + } + return positions.map((pos, i) => { + const end = i + 1 < positions.length + ? text.lastIndexOf("\n", positions[i + 1].contentStart - positions[i + 1].role.length - 2) + : text.length; + return { + role: pos.role, + content: text.slice(pos.contentStart, end > pos.contentStart ? end : text.length).trim(), + }; + }); + } + + // Fallback: single prompt block + return [{ role: "prompt", content: text }]; +} diff --git a/frontend/src/model/utils/traceHighlight.ts b/frontend/src/model/utils/traceHighlight.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cd503d176fece8be32f9981b9416b56d347d512 --- /dev/null +++ b/frontend/src/model/utils/traceHighlight.ts @@ -0,0 +1,37 @@ +export interface HighlightSegment { + text: string; + className: string; +} + +export function highlightTrace(text: string): HighlightSegment[] { + if (!text) return [{ text: "(no response)", className: "text-gray-500 italic" }]; + + const segments: HighlightSegment[] = []; + const lines = text.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lo = line.toLowerCase().trim(); + + let className = "text-gray-300"; + + if (lo.startsWith("wait") || lo.startsWith("hmm") || lo.startsWith("but wait")) { + className = "text-yellow-400"; + } else if (lo.startsWith("let me try") || lo.startsWith("let me reconsider") || lo.startsWith("let me think")) { + className = "text-cyan-400"; + } else if (lo.startsWith("so the answer") || lo.startsWith("so the expression") || lo.startsWith("therefore") || lo.startsWith("the final")) { + className = "text-green-400 font-bold"; + } else if (lo.startsWith("i give up") || lo.startsWith("i can't find") || lo.startsWith("i'm stuck") || lo.startsWith("i'm sorry")) { + className = "text-red-400 font-bold"; + } else if (line.includes("=") && /[+\-*/]/.test(line)) { + className = "text-gray-100"; + } + + segments.push({ text: line, className }); + if (i < lines.length - 1) { + segments.push({ text: "\n", className: "" }); + } + } + + return segments; +} diff --git a/frontend/src/rlm/RlmApp.tsx b/frontend/src/rlm/RlmApp.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c2a7954a3b4d7d66ba687818131275346b594ca --- /dev/null +++ b/frontend/src/rlm/RlmApp.tsx @@ -0,0 +1,133 @@ +import { useEffect } from "react"; +import { useAppState } from "./store"; +import Sidebar from "./components/Sidebar"; +import Panel from "./components/Panel"; + +function RlmApp() { + const state = useAppState(); + + const handleSelectDataset = (id: string) => { + state.navigatePanel("A", { datasetId: id, level: 1 }); + }; + + // Keyboard shortcuts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement + ) + return; + + switch (e.key) { + case "Escape": + state.goUp("A"); + break; + case "c": + state.toggleComparison(); + break; + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [state.goUp, state.toggleComparison]); + + return ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Error banner */} + {state.error && ( +
+ {state.error} + +
+ )} + + {/* Toolbar */} +
+
+ {state.activeDatasets.length} experiment{state.activeDatasets.length !== 1 ? "s" : ""} loaded +
+ +
+ + {/* Panels */} +
+ {state.panelA ? ( +
+ d.id === state.panelA?.datasetId)} + panelLabel={state.comparisonMode ? "A" : undefined} + onNavigate={(nav) => state.navigatePanel("A", nav)} + onGoUp={() => state.goUp("A")} + fetchOverview={state.fetchOverview} + fetchGepaIter={state.fetchGepaIter} + fetchRlmDetail={state.fetchRlmDetail} + /> +
+ ) : ( +
+
+

No experiment loaded

+

Add an experiment from the sidebar to get started

+
+
+ )} + + {state.comparisonMode && state.panelB && ( +
+ d.id === state.panelB?.datasetId)} + panelLabel="B" + datasets={state.datasets} + onNavigate={(nav) => state.navigatePanel("B", nav)} + onGoUp={() => state.goUp("B")} + onSwitchDataset={(id) => state.navigatePanel("B", { datasetId: id, level: 1 })} + fetchOverview={state.fetchOverview} + fetchGepaIter={state.fetchGepaIter} + fetchRlmDetail={state.fetchRlmDetail} + /> +
+ )} +
+ + {/* Keyboard hints */} +
+ Esc: Go up + C: Toggle compare +
+
+
+ ); +} + +export default RlmApp; diff --git a/frontend/src/rlm/api.ts b/frontend/src/rlm/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..40cc0a2f3715ab9dff9c5e04eb16f41c016baba3 --- /dev/null +++ b/frontend/src/rlm/api.ts @@ -0,0 +1,74 @@ +const BASE = "/api/rlm"; + +async function fetchJson(url: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${url}`, { + headers: { "Content-Type": "application/json" }, + ...init, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `HTTP ${res.status}`); + } + return res.json(); +} + +const PRESETS_BASE = "/api/presets/rlm"; +async function fetchPresetsJson(url: string, init?: RequestInit): Promise { + const res = await fetch(`${PRESETS_BASE}${url}`, { + headers: { "Content-Type": "application/json" }, ...init, + }); + if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } + return res.json(); +} + +export const api = { + loadDataset: (repo: string, config?: string, split?: string) => + fetchJson<{ + id: string; + repo: string; + name: string; + config: string; + split: string; + metadata: Record; + n_gepa_iters: number; + n_rows: number; + }>("/datasets/load", { + method: "POST", + body: JSON.stringify({ + repo, + config: config || "rlm_call_traces", + split: split || "train", + }), + }), + + getOverview: (dsId: string) => + fetchJson>(`/datasets/${dsId}/overview`), + + getGepaIter: (dsId: string, gepaIter: number) => + fetchJson>(`/datasets/${dsId}/gepa/${gepaIter}`), + + getRlmIter: (dsId: string, gepaIter: number, rlmCallIdx: number, rlmIter: number) => + fetchJson>( + `/datasets/${dsId}/gepa/${gepaIter}/rlm/${rlmCallIdx}/${rlmIter}` + ), + + unloadDataset: (dsId: string) => + fetchJson<{ status: string }>(`/datasets/${dsId}`, { method: "DELETE" }), + + listPresets: () => fetchPresetsJson[]>(""), + + createPreset: (preset: { name: string; repo: string; config: string; split: string }) => + fetchPresetsJson>("", { + method: "POST", + body: JSON.stringify(preset), + }), + + updatePreset: (id: string, data: { name: string }) => + fetchPresetsJson>(`/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }), + + deletePreset: (id: string) => + fetchPresetsJson<{ status: string }>(`/${id}`, { method: "DELETE" }), +}; diff --git a/frontend/src/rlm/components/Breadcrumb.tsx b/frontend/src/rlm/components/Breadcrumb.tsx new file mode 100644 index 0000000000000000000000000000000000000000..465efee44f068ca86cef7f53b11fb48f77291615 --- /dev/null +++ b/frontend/src/rlm/components/Breadcrumb.tsx @@ -0,0 +1,58 @@ +import type { PanelNav, DatasetInfo } from "../types"; + +interface BreadcrumbProps { + nav: PanelNav; + dataset: DatasetInfo | undefined; + onNavigate: (nav: PanelNav) => void; +} + +export default function Breadcrumb({ nav, dataset, onNavigate }: BreadcrumbProps) { + const parts: { label: string; nav: PanelNav }[] = []; + + if (dataset) { + parts.push({ + label: dataset.name, + nav: { datasetId: nav.datasetId, level: 1 }, + }); + } + + if (nav.level >= 2 && nav.gepaIter !== undefined) { + parts.push({ + label: `GEPA Iter ${nav.gepaIter}`, + nav: { datasetId: nav.datasetId, level: 2, gepaIter: nav.gepaIter }, + }); + } + + if (nav.level >= 3 && nav.rlmIter !== undefined) { + parts.push({ + label: `RLM ${nav.rlmCallIdx ?? 0}:${nav.rlmIter}`, + nav: { + datasetId: nav.datasetId, + level: 3, + gepaIter: nav.gepaIter, + rlmCallIdx: nav.rlmCallIdx, + rlmIter: nav.rlmIter, + }, + }); + } + + return ( +
+ {parts.map((p, i) => ( + + {i > 0 && /} + {i < parts.length - 1 ? ( + + ) : ( + {p.label} + )} + + ))} +
+ ); +} diff --git a/frontend/src/rlm/components/DatasetSelector.tsx b/frontend/src/rlm/components/DatasetSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..58151720d594517db61bb11279787584f81db626 --- /dev/null +++ b/frontend/src/rlm/components/DatasetSelector.tsx @@ -0,0 +1,23 @@ +import type { DatasetInfo } from "../types"; + +interface DatasetSelectorProps { + datasets: DatasetInfo[]; + currentId: string; + onSelect: (id: string) => void; +} + +export default function DatasetSelector({ datasets, currentId, onSelect }: DatasetSelectorProps) { + return ( + + ); +} diff --git a/frontend/src/rlm/components/GepaIterLevel.tsx b/frontend/src/rlm/components/GepaIterLevel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..014bfdd10918b8dffc3842f2eddeed6b150fd615 --- /dev/null +++ b/frontend/src/rlm/components/GepaIterLevel.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import type { GepaIterData, PanelNav } from "../types"; + +interface GepaIterLevelProps { + datasetId: string; + gepaIter: number; + fetchGepaIter: (dsId: string, gepaIter: number) => Promise; + onDrillDown: (nav: PanelNav) => void; +} + +export default function GepaIterLevel({ + datasetId, + gepaIter, + fetchGepaIter, + onDrillDown, +}: GepaIterLevelProps) { + const [data, setData] = useState(null); + + useEffect(() => { + fetchGepaIter(datasetId, gepaIter).then(setData).catch(() => {}); + }, [datasetId, gepaIter, fetchGepaIter]); + + if (!data) return
Loading GEPA iteration...
; + + return ( +
+ {/* Stats row */} +
+ + Total tokens:{" "} + + {((data.total_input_tokens + data.total_output_tokens) / 1000).toFixed(1)}k + + + + Time: {data.total_execution_time.toFixed(1)}s + + + RLM Calls: {data.rlm_calls.length} + +
+ + {/* RLM iteration timeline */} + {data.rlm_calls.map((call) => ( +
+ {data.rlm_calls.length > 1 && ( +
+ RLM Call {call.rlm_call_idx} +
+ )} + +
+ {call.iterations.map((it) => ( +
+ onDrillDown({ + datasetId, + level: 3, + gepaIter, + rlmCallIdx: call.rlm_call_idx, + rlmIter: it.rlm_iter, + }) + } + > +
+ + iter {it.rlm_iter} + +
+ {it.has_code_blocks && ( + + {it.n_code_blocks} code + + )} + {it.has_final_answer && ( + + FINAL + + )} +
+
+ +
+ {((it.input_tokens + it.output_tokens) / 1000).toFixed(1)}k tok + {it.execution_time.toFixed(1)}s +
+ +
+ {it.response_preview || "(empty)"} +
+
+ ))} +
+
+ ))} + + {/* Final answer if present */} + {data.final_answer && ( +
+
Final Answer
+
+ {data.final_answer} +
+
+ )} +
+ ); +} diff --git a/frontend/src/rlm/components/OverviewLevel.tsx b/frontend/src/rlm/components/OverviewLevel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..adb6583caa1a6b6662e6a54c42a963618cd71bd6 --- /dev/null +++ b/frontend/src/rlm/components/OverviewLevel.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; +import type { OverviewData, PanelNav } from "../types"; + +interface OverviewLevelProps { + datasetId: string; + fetchOverview: (dsId: string) => Promise; + onDrillDown: (nav: PanelNav) => void; +} + +export default function OverviewLevel({ datasetId, fetchOverview, onDrillDown }: OverviewLevelProps) { + const [data, setData] = useState(null); + + useEffect(() => { + fetchOverview(datasetId).then(setData).catch(() => {}); + }, [datasetId, fetchOverview]); + + if (!data) return
Loading overview...
; + + return ( +
+ {/* Experiment metadata */} +
+ Model: {data.metadata.model} + Method: {data.metadata.method} + k: {data.metadata.k} + Run: {data.metadata.run_id} +
+ + {/* GEPA iteration cards */} +
+ {data.gepa_iterations.map((gi) => ( +
+ onDrillDown({ + datasetId, + level: 2, + gepaIter: gi.gepa_iter, + }) + } + > +
+
+ + GEPA {gi.gepa_iter} + + {gi.has_final_answer && ( + + FINAL + + )} +
+ + {gi.total_execution_time.toFixed(1)}s + +
+ +
+ {gi.n_rlm_iters} RLM iters + {gi.n_rlm_calls} calls + + {((gi.total_input_tokens + gi.total_output_tokens) / 1000).toFixed(1)}k tokens + +
+ + {gi.final_answer_preview && ( +
+ {gi.final_answer_preview} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/rlm/components/Panel.tsx b/frontend/src/rlm/components/Panel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b97052245cfd2f7be2c9f9f41249212b37a4847 --- /dev/null +++ b/frontend/src/rlm/components/Panel.tsx @@ -0,0 +1,100 @@ +import type { PanelNav, DatasetInfo, OverviewData, GepaIterData, RlmIterDetail } from "../types"; +import Breadcrumb from "./Breadcrumb"; +import OverviewLevel from "./OverviewLevel"; +import GepaIterLevel from "./GepaIterLevel"; +import RlmDetailLevel from "./RlmDetailLevel"; +import DatasetSelector from "./DatasetSelector"; + +interface PanelProps { + nav: PanelNav; + dataset: DatasetInfo | undefined; + panelLabel?: string; + datasets?: DatasetInfo[]; + onNavigate: (nav: PanelNav) => void; + onGoUp: () => void; + onSwitchDataset?: (id: string) => void; + fetchOverview: (dsId: string) => Promise; + fetchGepaIter: (dsId: string, gepaIter: number) => Promise; + fetchRlmDetail: ( + dsId: string, + gepaIter: number, + rlmCallIdx: number, + rlmIter: number + ) => Promise; +} + +export default function Panel({ + nav, + dataset, + panelLabel, + datasets, + onNavigate, + onGoUp, + onSwitchDataset, + fetchOverview, + fetchGepaIter, + fetchRlmDetail, +}: PanelProps) { + return ( +
+ {/* Panel header */} +
+ {nav.level > 1 && ( + + )} + {panelLabel && ( + + {panelLabel} + + )} + + {panelLabel === "B" && datasets && onSwitchDataset && ( +
+ onSwitchDataset(id)} + /> +
+ )} +
+ + {/* Panel content */} +
+ {nav.level === 1 && ( + + )} + {nav.level === 2 && nav.gepaIter !== undefined && ( + + )} + {nav.level === 3 && + nav.gepaIter !== undefined && + nav.rlmCallIdx !== undefined && + nav.rlmIter !== undefined && ( + + )} +
+
+ ); +} diff --git a/frontend/src/rlm/components/RlmDetailLevel.tsx b/frontend/src/rlm/components/RlmDetailLevel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3bb82f1cdd79103c598e9ed411efdc2f47fba38d --- /dev/null +++ b/frontend/src/rlm/components/RlmDetailLevel.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import type { RlmIterDetail } from "../types"; + +interface RlmDetailLevelProps { + datasetId: string; + gepaIter: number; + rlmCallIdx: number; + rlmIter: number; + fetchRlmDetail: ( + dsId: string, + gepaIter: number, + rlmCallIdx: number, + rlmIter: number + ) => Promise; +} + +function parsePromptMessages(promptStr: string): { role: string; content: string }[] { + try { + const parsed = JSON.parse(promptStr); + if (Array.isArray(parsed)) return parsed; + } catch { /* not JSON */ } + return [{ role: "raw", content: promptStr }]; +} + +const roleColors: Record = { + system: "border-violet-500 bg-violet-950", + user: "border-emerald-500 bg-emerald-950", + assistant: "border-sky-500 bg-sky-950", + raw: "border-gray-500 bg-gray-900", +}; + +export default function RlmDetailLevel({ + datasetId, + gepaIter, + rlmCallIdx, + rlmIter, + fetchRlmDetail, +}: RlmDetailLevelProps) { + const [data, setData] = useState(null); + const [promptExpanded, setPromptExpanded] = useState(false); + + useEffect(() => { + fetchRlmDetail(datasetId, gepaIter, rlmCallIdx, rlmIter) + .then(setData) + .catch(() => {}); + }, [datasetId, gepaIter, rlmCallIdx, rlmIter, fetchRlmDetail]); + + if (!data) return
Loading RLM detail...
; + + const messages = parsePromptMessages(data.prompt); + + return ( +
+ {/* Stats */} +
+ Model: {data.model} + In: {(data.input_tokens / 1000).toFixed(1)}k + Out: {(data.output_tokens / 1000).toFixed(1)}k + Time: {data.execution_time.toFixed(1)}s +
+ + {/* Prompt section (collapsible) */} +
+ + {promptExpanded && ( +
+ {messages.map((msg, i) => ( +
+
{msg.role}
+
+ {msg.content.length > 8000 ? msg.content.slice(0, 8000) + "\n...(truncated)" : msg.content} +
+
+ ))} +
+ )} +
+ + {/* Response */} +
+
Response
+
+
+ {data.response} +
+
+
+ + {/* Code Blocks */} + {data.code_blocks.length > 0 && ( +
+
+ Code Blocks ({data.code_blocks.length}) +
+
+ {data.code_blocks.map((cb, i) => ( +
+
+ python + Block {i + 1} +
+
+                  {cb.code}
+                
+ {cb.stdout && ( +
+
stdout
+
+                      {cb.stdout}
+                    
+
+ )} +
+ ))} +
+
+ )} + + {/* Final Answer */} + {data.final_answer && ( +
+
Final Answer
+
+ {data.final_answer} +
+
+ )} +
+ ); +} diff --git a/frontend/src/rlm/components/Sidebar.tsx b/frontend/src/rlm/components/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..67f2ea258ebea674a7c697d1c46a96ff33dfd643 --- /dev/null +++ b/frontend/src/rlm/components/Sidebar.tsx @@ -0,0 +1,390 @@ +import { useState } from "react"; +import type { DatasetInfo, Preset } from "../types"; +import { api } from "../api"; + +interface SidebarProps { + datasets: DatasetInfo[]; + presets: Preset[]; + setPresets: (p: Preset[]) => void; + loading: Record; + onAddDataset: (repo: string, config?: string, split?: string, presetId?: string, presetName?: string) => void; + onRemoveDataset: (id: string) => void; + onToggleDataset: (id: string) => void; + onSelectDataset: (id: string) => void; + onUpdateDatasetPresetName: (dsId: string, name: string) => void; + onClearDatasetPreset: (dsId: string) => void; +} + +export default function Sidebar({ + datasets, + presets, + setPresets, + loading, + onAddDataset, + onRemoveDataset, + onToggleDataset, + onSelectDataset, + onUpdateDatasetPresetName, + onClearDatasetPreset, +}: SidebarProps) { + const [showAddForm, setShowAddForm] = useState(false); + const [repo, setRepo] = useState(""); + const [config, setConfig] = useState("rlm_call_traces"); + const [split, setSplit] = useState("train"); + const [presetSearch, setPresetSearch] = useState(""); + + // Inline preset saving + const [savingPresetForId, setSavingPresetForId] = useState(null); + const [presetName, setPresetName] = useState(""); + + // Preset editing panel + const [editingDatasetId, setEditingDatasetId] = useState(null); + const [editPresetName, setEditPresetName] = useState(""); + + const handleAdd = () => { + if (!repo.trim()) return; + onAddDataset(repo.trim(), config, split); + setRepo(""); + setShowAddForm(false); + }; + + const handleLoadPreset = (p: Preset) => { + onAddDataset(p.repo, p.config, p.split || "train", p.id, p.name); + }; + + const handleSavePresetForRepo = async (ds: DatasetInfo) => { + if (!presetName.trim()) return; + try { + const preset = (await api.createPreset({ + name: presetName.trim(), + repo: ds.repo, + config: ds.config, + split: ds.split, + })) as unknown as Preset; + setPresets([...presets, preset]); + onUpdateDatasetPresetName(ds.id, presetName.trim()); + // Also store the preset ID on the dataset - we do this by updating the dataset + // The parent will handle linking via presetId + } catch { + /* ignore */ + } + setPresetName(""); + setSavingPresetForId(null); + }; + + const handleUpdatePreset = async (presetId: string, dsId: string) => { + if (!editPresetName.trim()) return; + try { + await api.updatePreset(presetId, { name: editPresetName.trim() }); + setPresets( + presets.map((p) => (p.id === presetId ? { ...p, name: editPresetName.trim() } : p)) + ); + onUpdateDatasetPresetName(dsId, editPresetName.trim()); + } catch { + /* ignore */ + } + setEditingDatasetId(null); + }; + + const handleDeletePreset = async (id: string, dsId?: string) => { + await api.deletePreset(id).catch(() => {}); + setPresets(presets.filter((p) => p.id !== id)); + if (dsId) { + onClearDatasetPreset(dsId); + } + setEditingDatasetId(null); + }; + + const filteredPresets = presetSearch + ? presets.filter( + (p) => + p.name.toLowerCase().includes(presetSearch.toLowerCase()) || + p.repo.toLowerCase().includes(presetSearch.toLowerCase()) + ) + : presets; + + return ( +
+ {/* Header */} +
+

RLM Trace Visualizer

+
+ + {/* Presets section */} +
+
+ Presets +
+ {presets.length === 0 ? ( +

No presets saved

+ ) : ( + <> + {presets.length > 6 && ( + setPresetSearch(e.target.value)} + placeholder="Search presets..." + className="w-full px-2 py-1 mb-2 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-orange-500 focus:outline-none" + /> + )} +
+ {filteredPresets.map((p) => ( +
+ +
+ +
+
+ ))} +
+ + )} +
+ + {/* Loaded Experiments */} +
+
+ Loaded Experiments +
+ {datasets.length === 0 ? ( +

No experiments loaded

+ ) : ( +
+ {datasets.map((ds) => ( +
+
{ + if (ds.presetId) { + setEditingDatasetId(editingDatasetId === ds.id ? null : ds.id); + setEditPresetName(ds.presetName || ""); + setShowAddForm(false); + } + onSelectDataset(ds.id); + }} + className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm transition-colors cursor-pointer ${ + ds.active ? "bg-gray-800" : "bg-gray-900 opacity-60" + } ${editingDatasetId === ds.id ? "ring-1 ring-orange-500" : "hover:bg-gray-800"}`} + > + onToggleDataset(ds.id)} + onClick={(e) => e.stopPropagation()} + className="accent-orange-500 shrink-0" + /> +
+
+ {ds.presetName || ds.name} +
+
+ {ds.metadata.model} | k={ds.metadata.k} | {ds.n_gepa_iters} iters +
+
+ {/* Save as preset bookmark */} + + {/* Remove */} + +
+ + {/* Inline preset name input */} + {savingPresetForId === ds.id && ( +
+ setPresetName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSavePresetForRepo(ds); + if (e.key === "Escape") setSavingPresetForId(null); + }} + placeholder="Preset name..." + className="flex-1 px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-orange-500 focus:outline-none" + autoFocus + /> + +
+ )} +
+ ))} +
+ )} +
+ + {/* Preset edit panel */} + {editingDatasetId && + (() => { + const editDs = datasets.find((d) => d.id === editingDatasetId); + if (!editDs?.presetId) return null; + return ( +
+
+ Edit Preset +
+ setEditPresetName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && editPresetName.trim()) { + handleUpdatePreset(editDs.presetId!, editDs.id); + } + if (e.key === "Escape") setEditingDatasetId(null); + }} + placeholder="Preset name..." + className="w-full px-2 py-1 text-xs bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:border-orange-500 focus:outline-none" + autoFocus + /> +
+ + + +
+
+ ); + })()} + + {/* Add Experiment Form */} +
+ {showAddForm ? ( +
+ setRepo(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + autoFocus + /> +
+ setConfig(e.target.value)} + /> + setSplit(e.target.value)} + /> +
+
+ + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/rlm/store.ts b/frontend/src/rlm/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..3699e04c7ac499b1db521b98e3a2c9aacb1f6839 --- /dev/null +++ b/frontend/src/rlm/store.ts @@ -0,0 +1,180 @@ +import { useState, useCallback, useEffect, useMemo } from "react"; +import type { + DatasetInfo, + Preset, + PanelNav, + OverviewData, + GepaIterData, + RlmIterDetail, +} from "./types"; +import { api } from "./api"; + +export function useAppState() { + const [datasets, setDatasets] = useState([]); + const [presets, setPresets] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState>({}); + + // Dual panel navigation + const [panelA, setPanelA] = useState(null); + const [panelB, setPanelB] = useState(null); + const [comparisonMode, setComparisonMode] = useState(false); + + // Data caches + const [overviewCache, setOverviewCache] = useState>({}); + const [gepaIterCache, setGepaIterCache] = useState>({}); + const [rlmDetailCache, setRlmDetailCache] = useState>({}); + + // Load presets on mount + useEffect(() => { + api.listPresets().then((data) => setPresets(data as unknown as Preset[])).catch(() => {}); + }, []); + + const activeDatasets = useMemo(() => datasets.filter((d) => d.active), [datasets]); + + // Data fetching helpers + const fetchOverview = useCallback(async (dsId: string) => { + if (overviewCache[dsId]) return overviewCache[dsId]; + const data = (await api.getOverview(dsId)) as unknown as OverviewData; + setOverviewCache((prev) => ({ ...prev, [dsId]: data })); + return data; + }, [overviewCache]); + + const fetchGepaIter = useCallback( + async (dsId: string, gepaIter: number) => { + const key = `${dsId}:${gepaIter}`; + if (gepaIterCache[key]) return gepaIterCache[key]; + const data = (await api.getGepaIter(dsId, gepaIter)) as unknown as GepaIterData; + setGepaIterCache((prev) => ({ ...prev, [key]: data })); + return data; + }, + [gepaIterCache] + ); + + const fetchRlmDetail = useCallback( + async (dsId: string, gepaIter: number, rlmCallIdx: number, rlmIter: number) => { + const key = `${dsId}:${gepaIter}:${rlmCallIdx}:${rlmIter}`; + if (rlmDetailCache[key]) return rlmDetailCache[key]; + const data = (await api.getRlmIter(dsId, gepaIter, rlmCallIdx, rlmIter)) as unknown as RlmIterDetail; + setRlmDetailCache((prev) => ({ ...prev, [key]: data })); + return data; + }, + [rlmDetailCache] + ); + + // Dataset operations + const addDataset = useCallback( + async (repo: string, config?: string, split?: string, presetId?: string, presetName?: string) => { + setLoading((prev) => ({ ...prev, [repo]: true })); + setError(null); + try { + const result = await api.loadDataset(repo, config, split); + const dsInfo: DatasetInfo = { + id: result.id, + repo: result.repo, + name: result.name, + config: result.config, + split: result.split, + metadata: result.metadata as unknown as DatasetInfo["metadata"], + n_gepa_iters: result.n_gepa_iters, + n_rows: result.n_rows, + active: true, + presetId, + presetName, + }; + + setDatasets((prev) => { + if (prev.some((d) => d.id === dsInfo.id)) return prev; + return [...prev, dsInfo]; + }); + + // Auto-set panel A if not set + setPanelA((prev) => prev || { datasetId: dsInfo.id, level: 1 }); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load dataset"); + } finally { + setLoading((prev) => ({ ...prev, [repo]: false })); + } + }, + [] + ); + + const removeDataset = useCallback(async (id: string) => { + await api.unloadDataset(id).catch(() => {}); + setDatasets((prev) => prev.filter((d) => d.id !== id)); + setPanelA((prev) => (prev?.datasetId === id ? null : prev)); + setPanelB((prev) => (prev?.datasetId === id ? null : prev)); + }, []); + + const toggleDataset = useCallback((id: string) => { + setDatasets((prev) => prev.map((d) => (d.id === id ? { ...d, active: !d.active } : d))); + }, []); + + // Navigation + const navigatePanel = useCallback( + (panel: "A" | "B", nav: PanelNav) => { + if (panel === "A") setPanelA(nav); + else setPanelB(nav); + }, + [] + ); + + const goUp = useCallback((panel: "A" | "B") => { + const setter = panel === "A" ? setPanelA : setPanelB; + setter((prev) => { + if (!prev) return prev; + if (prev.level === 3) return { ...prev, level: 2, rlmIter: undefined }; + if (prev.level === 2) return { ...prev, level: 1, gepaIter: undefined, rlmCallIdx: undefined }; + return prev; + }); + }, []); + + const updateDatasetPresetName = useCallback((dsId: string, name: string) => { + setDatasets((prev) => prev.map((d) => (d.id === dsId ? { ...d, presetName: name } : d))); + }, []); + + const clearDatasetPreset = useCallback((dsId: string) => { + setDatasets((prev) => + prev.map((d) => (d.id === dsId ? { ...d, presetId: undefined, presetName: undefined } : d)) + ); + }, []); + + const toggleComparison = useCallback(() => { + setComparisonMode((prev) => { + if (!prev && panelA) { + // Entering comparison: initialize panel B same as A + setPanelB({ ...panelA }); + } else if (prev) { + setPanelB(null); + } + return !prev; + }); + }, [panelA]); + + return { + datasets, + presets, + setPresets, + error, + setError, + loading, + activeDatasets, + panelA, + panelB, + comparisonMode, + addDataset, + removeDataset, + toggleDataset, + updateDatasetPresetName, + clearDatasetPreset, + navigatePanel, + goUp, + toggleComparison, + fetchOverview, + fetchGepaIter, + fetchRlmDetail, + overviewCache, + gepaIterCache, + rlmDetailCache, + }; +} diff --git a/frontend/src/rlm/types.ts b/frontend/src/rlm/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..714f3b0f5d6a81247e4be0be3666cc0ff71565ab --- /dev/null +++ b/frontend/src/rlm/types.ts @@ -0,0 +1,100 @@ +export interface DatasetInfo { + id: string; + repo: string; + name: string; + config: string; + split: string; + metadata: ExperimentMetadata; + n_gepa_iters: number; + n_rows: number; + active: boolean; + presetId?: string; + presetName?: string; +} + +export interface ExperimentMetadata { + run_id: string; + method: string; + k: number; + model: string; +} + +export interface GepaIterSummary { + gepa_iter: number; + n_rlm_calls: number; + n_rlm_iters: number; + total_input_tokens: number; + total_output_tokens: number; + total_execution_time: number; + has_final_answer: boolean; + final_answer_preview: string; +} + +export interface OverviewData { + metadata: ExperimentMetadata; + gepa_iterations: GepaIterSummary[]; +} + +export interface RlmIterSummary { + rlm_iter: number; + model: string; + input_tokens: number; + output_tokens: number; + execution_time: number; + has_code_blocks: boolean; + n_code_blocks: number; + response_preview: string; + has_final_answer: boolean; + timestamp: string; +} + +export interface RlmCallSummary { + rlm_call_idx: number; + iterations: RlmIterSummary[]; +} + +export interface GepaIterData { + gepa_iter: number; + total_input_tokens: number; + total_output_tokens: number; + total_execution_time: number; + final_answer: string | null; + rlm_calls: RlmCallSummary[]; +} + +export interface CodeBlock { + code: string; + stdout?: string; +} + +export interface RlmIterDetail { + rlm_iter: number; + prompt: string; + response: string; + model: string; + input_tokens: number; + output_tokens: number; + execution_time: number; + has_code_blocks: boolean; + code_blocks: CodeBlock[]; + final_answer: string | null; + subcall_id: string | null; + parent_id: string | null; + timestamp: string; +} + +export interface Preset { + id: string; + name: string; + repo: string; + config: string; + split?: string; +} + +export interface PanelNav { + datasetId: string; + level: 1 | 2 | 3; + gepaIter?: number; + rlmCallIdx?: number; + rlmIter?: number; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..11f02fe2a0061d6e6e1f271b21da95423b448b32 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000000000000000000000000000000000000..614c86b487fa1bb02b80dcf725f9438d772f010a --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000000000000000000000000000000000000..75e2ff7ac0c03a332f4fa6e1fb437b30d4a00f83 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo new file mode 100644 index 0000000000000000000000000000000000000000..38a9f508f20babd8e3f71955e2858d92b83e8bf0 --- /dev/null +++ b/frontend/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/arena/arenaapp.tsx","./src/arena/api.ts","./src/arena/store.ts","./src/arena/types.ts","./src/arena/components/episodebar.tsx","./src/arena/components/episodenav.tsx","./src/arena/components/sidebar.tsx","./src/arena/components/transcriptpanel.tsx","./src/arena/utils/tracehighlight.ts","./src/harbor/harborapp.tsx","./src/harbor/api.ts","./src/harbor/store.ts","./src/harbor/types.ts","./src/harbor/components/chatbubble.tsx","./src/harbor/components/infobar.tsx","./src/harbor/components/instancelist.tsx","./src/harbor/components/instancenav.tsx","./src/harbor/components/metricssummary.tsx","./src/harbor/components/sidebar.tsx","./src/harbor/components/stepdetail.tsx","./src/harbor/components/trajectoryview.tsx","./src/model/modelapp.tsx","./src/model/api.ts","./src/model/store.ts","./src/model/types.ts","./src/model/components/infobar.tsx","./src/model/components/questionnav.tsx","./src/model/components/sidebar.tsx","./src/model/components/tracepanel.tsx","./src/model/utils/promptparser.ts","./src/model/utils/tracehighlight.ts","./src/rlm/rlmapp.tsx","./src/rlm/api.ts","./src/rlm/store.ts","./src/rlm/types.ts","./src/rlm/components/breadcrumb.tsx","./src/rlm/components/datasetselector.tsx","./src/rlm/components/gepaiterlevel.tsx","./src/rlm/components/overviewlevel.tsx","./src/rlm/components/panel.tsx","./src/rlm/components/rlmdetaillevel.tsx","./src/rlm/components/sidebar.tsx"],"version":"5.9.3"} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..82a8007eb973e068a4d47c608a7f8e259e76dcf3 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..9af3b4e81174095d1c41d7c65ebe65540f076eaa --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:8080", + changeOrigin: true, + }, + }, + }, + build: { + outDir: "dist", + }, +}); diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..edca1f4b629162445eaa5ce9c83b478dbcd898a7 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,61 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /tmp/nginx_error.log; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Temp paths for non-root operation + client_body_temp_path /tmp/client_body; + proxy_temp_path /tmp/proxy; + fastcgi_temp_path /tmp/fastcgi; + uwsgi_temp_path /tmp/uwsgi; + scgi_temp_path /tmp/scgi; + + access_log /tmp/nginx_access.log; + + sendfile on; + keepalive_timeout 65; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + server { + listen 7860; + server_name localhost; + + root /app/frontend/dist; + index index.html; + + # API proxy to Flask backend + location /api/ { + proxy_pass http://127.0.0.1:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + + # Serve Vite build assets with cache + location /assets/ { + alias /app/frontend/dist/assets/; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA routing - serve index.html for all other routes + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/start.sh b/start.sh new file mode 100644 index 0000000000000000000000000000000000000000..4d92dee697f871bd3dab0226080487b2fc0bca27 --- /dev/null +++ b/start.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +# Start Flask backend in background +echo "Starting Flask backend..." +cd /app +gunicorn --bind 127.0.0.1:5000 --workers 1 --timeout 120 backend.app:app & +BACKEND_PID=$! + +# Wait for backend to be ready +echo "Waiting for backend to start..." +sleep 3 + +# Start nginx in foreground +echo "Starting nginx..." +nginx -g 'daemon off;'