Zayne Rea Sprague commited on
Commit
8b41737
·
0 Parent(s):

Initial deploy: aggregate trace visualizer

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +7 -0
  2. .gitignore +7 -0
  3. Dockerfile +39 -0
  4. README.md +19 -0
  5. backend/__init__.py +0 -0
  6. backend/api/__init__.py +0 -0
  7. backend/api/arena_datasets.py +280 -0
  8. backend/api/harbor_datasets.py +287 -0
  9. backend/api/model_datasets.py +332 -0
  10. backend/api/presets.py +189 -0
  11. backend/api/rlm_datasets.py +263 -0
  12. backend/app.py +36 -0
  13. backend/requirements.txt +5 -0
  14. frontend/index.html +12 -0
  15. frontend/package-lock.json +2766 -0
  16. frontend/package.json +25 -0
  17. frontend/postcss.config.js +6 -0
  18. frontend/src/App.tsx +73 -0
  19. frontend/src/arena/ArenaApp.tsx +211 -0
  20. frontend/src/arena/api.ts +71 -0
  21. frontend/src/arena/components/EpisodeBar.tsx +87 -0
  22. frontend/src/arena/components/EpisodeNav.tsx +81 -0
  23. frontend/src/arena/components/Sidebar.tsx +274 -0
  24. frontend/src/arena/components/TranscriptPanel.tsx +198 -0
  25. frontend/src/arena/store.ts +180 -0
  26. frontend/src/arena/types.ts +51 -0
  27. frontend/src/arena/utils/traceHighlight.ts +39 -0
  28. frontend/src/harbor/HarborApp.tsx +156 -0
  29. frontend/src/harbor/api.ts +55 -0
  30. frontend/src/harbor/components/ChatBubble.tsx +334 -0
  31. frontend/src/harbor/components/InfoBar.tsx +66 -0
  32. frontend/src/harbor/components/InstanceList.tsx +152 -0
  33. frontend/src/harbor/components/InstanceNav.tsx +58 -0
  34. frontend/src/harbor/components/MetricsSummary.tsx +51 -0
  35. frontend/src/harbor/components/Sidebar.tsx +225 -0
  36. frontend/src/harbor/components/StepDetail.tsx +74 -0
  37. frontend/src/harbor/components/TrajectoryView.tsx +142 -0
  38. frontend/src/harbor/store.ts +231 -0
  39. frontend/src/harbor/types.ts +105 -0
  40. frontend/src/index.css +62 -0
  41. frontend/src/main.tsx +10 -0
  42. frontend/src/model/ModelApp.tsx +228 -0
  43. frontend/src/model/api.ts +63 -0
  44. frontend/src/model/components/InfoBar.tsx +91 -0
  45. frontend/src/model/components/QuestionNav.tsx +112 -0
  46. frontend/src/model/components/Sidebar.tsx +378 -0
  47. frontend/src/model/components/TracePanel.tsx +171 -0
  48. frontend/src/model/store.ts +253 -0
  49. frontend/src/model/types.ts +55 -0
  50. frontend/src/model/utils/promptParser.ts +93 -0
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ frontend/node_modules
3
+ frontend/dist
4
+ .git
5
+ __pycache__
6
+ *.pyc
7
+ .env
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules/
2
+ frontend/dist/
3
+ __pycache__/
4
+ *.pyc
5
+ .env
6
+ backend/presets/
7
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build stage for React frontend
2
+ FROM node:18-alpine as frontend-build
3
+ WORKDIR /app/frontend
4
+ COPY frontend/package*.json ./
5
+ RUN npm ci
6
+ COPY frontend/ ./
7
+ RUN npm run build
8
+
9
+ # Production stage
10
+ FROM python:3.11-slim
11
+
12
+ # Install nginx
13
+ RUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Set working directory
16
+ WORKDIR /app
17
+
18
+ # Copy Python requirements and install
19
+ COPY backend/requirements.txt ./backend/
20
+ RUN pip install --no-cache-dir -r backend/requirements.txt
21
+
22
+ # Copy backend code
23
+ COPY backend/ ./backend/
24
+
25
+ # Copy built frontend from build stage
26
+ COPY --from=frontend-build /app/frontend/dist ./frontend/dist/
27
+
28
+ # Copy nginx configuration
29
+ COPY nginx.conf /etc/nginx/nginx.conf
30
+
31
+ # Copy startup script
32
+ COPY start.sh ./
33
+ RUN chmod +x start.sh
34
+
35
+ # HuggingFace Spaces runs on port 7860
36
+ EXPOSE 7860
37
+
38
+ # Run startup script
39
+ CMD ["./start.sh"]
README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Aggregate Trace Visualizer
3
+ emoji: 📊
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # Aggregate Trace Visualizer
11
+
12
+ A unified interface for four trace visualization tools:
13
+
14
+ - **Model Trace** - Analyze reasoning traces from model responses (think tags, backtracks, restarts)
15
+ - **Arena** - Explore multi-agent game episodes and transcripts
16
+ - **RLM** - Navigate hierarchical RLM call traces (GEPA iterations, RLM calls)
17
+ - **Harbor** - View SWE-bench agent trajectories (ATIF + raw message formats)
18
+
19
+ Each visualizer loads datasets from HuggingFace and supports preset configurations stored in `reasoning-degeneration-dev/AGG_VIS_PRESETS`.
backend/__init__.py ADDED
File without changes
backend/api/__init__.py ADDED
File without changes
backend/api/arena_datasets.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import hashlib
3
+ import os
4
+ from flask import Blueprint, request, jsonify
5
+ from datasets import load_dataset, Dataset
6
+
7
+ bp = Blueprint("arena_datasets", __name__, url_prefix="/api/arena/datasets")
8
+
9
+ # In-memory cache: id -> dataset info
10
+ _cache: dict[str, dict] = {}
11
+
12
+
13
+ def _make_id(repo: str, split: str) -> str:
14
+ key = f"{repo}:{split}"
15
+ return hashlib.md5(key.encode()).hexdigest()[:12]
16
+
17
+
18
+ def _load_hf_dataset(repo: str, split: str) -> Dataset:
19
+ if os.path.exists(repo):
20
+ return Dataset.from_parquet(repo)
21
+ return load_dataset(repo, split=split)
22
+
23
+
24
+ def _detect_arena_dataset(columns: list[str]) -> bool:
25
+ """Check if this looks like an arena evaluation dataset."""
26
+ required = {"game_id", "env_id", "transcript"}
27
+ return required.issubset(set(columns))
28
+
29
+
30
+ def _analyze_action(text: str) -> dict:
31
+ """Split <think> tags from action text and compute analytics."""
32
+ if not text:
33
+ return {"think_text": "", "action_text": "", "think_len": 0, "action_len": 0,
34
+ "backtracks": 0, "restarts": 0}
35
+
36
+ think_end = text.find("</think>")
37
+ if think_end > 0:
38
+ think_text = text[:think_end + 8]
39
+ action_text = text[think_end + 8:].strip()
40
+ else:
41
+ think_text = ""
42
+ action_text = text
43
+
44
+ t = text.lower()
45
+ backtracks = sum(t.count(w) for w in
46
+ ["wait,", "wait ", "hmm", "let me try", "try again",
47
+ "another approach", "let me reconsider"])
48
+ restarts = sum(t.count(w) for w in
49
+ ["start over", "fresh approach", "different approach", "from scratch"])
50
+
51
+ return {
52
+ "think_text": think_text,
53
+ "action_text": action_text,
54
+ "think_len": len(think_text),
55
+ "action_len": len(action_text),
56
+ "backtracks": backtracks,
57
+ "restarts": restarts,
58
+ }
59
+
60
+
61
+ def _dedup_observation(text: str, prev_text: str) -> str:
62
+ """Remove content duplicated from the previous observation.
63
+
64
+ TextArena accumulates the full chat history in each observation,
65
+ so turn N's observation repeats everything from turns 0..N-1
66
+ plus echoed [Player] actions. We strip the repeated prefix and
67
+ the echoed player actions, keeping only new [GAME]/[Moderator]
68
+ content for this turn.
69
+ """
70
+ import re
71
+
72
+ if not text:
73
+ return ""
74
+ if not prev_text:
75
+ return text
76
+
77
+ new_part = None
78
+
79
+ # The previous observation text should appear as a prefix of the
80
+ # current one. Strip it to get only what's new.
81
+ if text.startswith(prev_text):
82
+ new_part = text[len(prev_text):].strip()
83
+ else:
84
+ # Fallback: find the longest common prefix
85
+ min_len = min(len(text), len(prev_text))
86
+ common = 0
87
+ for i in range(min_len):
88
+ if text[i] == prev_text[i]:
89
+ common = i + 1
90
+ else:
91
+ break
92
+
93
+ if common > len(prev_text) * 0.8:
94
+ new_part = text[common:].strip()
95
+
96
+ if not new_part:
97
+ return text
98
+
99
+ # After stripping the observation prefix, the remaining text typically
100
+ # starts with echoed [Player] actions (already shown in action bubbles),
101
+ # followed by new [GAME] or [Moderator] content. Strip the echoed
102
+ # player actions to keep only the new game content.
103
+ game_marker = re.search(r'\[GAME\]|\[Moderator\]', new_part)
104
+ if game_marker:
105
+ game_content = new_part[game_marker.start():].strip()
106
+ return game_content if game_content else new_part
107
+
108
+ return new_part
109
+
110
+
111
+ def _get_env_ids(ds: Dataset) -> list[str]:
112
+ """Get sorted unique env_ids from dataset."""
113
+ return sorted(set(ds["env_id"]))
114
+
115
+
116
+ def _group_episodes_by_env(ds: Dataset) -> dict[str, list[int]]:
117
+ """Group row indices by env_id."""
118
+ groups: dict[str, list[int]] = {}
119
+ for i in range(len(ds)):
120
+ env_id = ds[i]["env_id"]
121
+ if env_id not in groups:
122
+ groups[env_id] = []
123
+ groups[env_id].append(i)
124
+ return groups
125
+
126
+
127
+ @bp.route("/load", methods=["POST"])
128
+ def load_dataset_endpoint():
129
+ data = request.get_json()
130
+ repo = data.get("repo", "").strip()
131
+ if not repo:
132
+ return jsonify({"error": "repo is required"}), 400
133
+
134
+ split = data.get("split", "train")
135
+
136
+ try:
137
+ ds = _load_hf_dataset(repo, split)
138
+ except Exception as e:
139
+ return jsonify({"error": f"Failed to load dataset: {e}"}), 400
140
+
141
+ columns = ds.column_names
142
+ if not _detect_arena_dataset(columns):
143
+ return jsonify({
144
+ "error": f"Not an arena dataset. Expected columns: game_id, env_id, transcript. Found: {columns}"
145
+ }), 400
146
+
147
+ env_ids = _get_env_ids(ds)
148
+ episode_groups = _group_episodes_by_env(ds)
149
+ ds_id = _make_id(repo, split)
150
+ short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo
151
+
152
+ # Extract model name from first row
153
+ model_name = ds[0].get("model", "unknown") if len(ds) > 0 else "unknown"
154
+
155
+ # Compute win/loss/error counts
156
+ wins = sum(1 for r in ds["reward"] if r is not None and r > 0)
157
+ losses = sum(1 for i in range(len(ds)) if ds[i]["reward"] is not None and ds[i]["reward"] <= 0)
158
+ errors = sum(1 for e in ds["error"] if e is not None)
159
+
160
+ _cache[ds_id] = {
161
+ "dataset": ds,
162
+ "repo": repo,
163
+ "split": split,
164
+ "n_rows": len(ds),
165
+ "env_ids": env_ids,
166
+ "episode_groups": episode_groups,
167
+ "model_name": model_name,
168
+ "stats": {"wins": wins, "losses": losses, "errors": errors},
169
+ }
170
+
171
+ return jsonify({
172
+ "id": ds_id,
173
+ "repo": repo,
174
+ "name": short_name,
175
+ "split": split,
176
+ "columns": columns,
177
+ "n_rows": len(ds),
178
+ "env_ids": env_ids,
179
+ "episodes_per_env": {env: len(idxs) for env, idxs in episode_groups.items()},
180
+ "model_name": model_name,
181
+ "stats": {"wins": wins, "losses": losses, "errors": errors},
182
+ })
183
+
184
+
185
+ @bp.route("/", methods=["GET"])
186
+ def list_datasets():
187
+ result = []
188
+ for ds_id, info in _cache.items():
189
+ result.append({
190
+ "id": ds_id,
191
+ "repo": info["repo"],
192
+ "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"],
193
+ "split": info["split"],
194
+ "n_rows": info["n_rows"],
195
+ "env_ids": info["env_ids"],
196
+ "model_name": info["model_name"],
197
+ })
198
+ return jsonify(result)
199
+
200
+
201
+ @bp.route("/<ds_id>/episode/<env_id>/<int:idx>", methods=["GET"])
202
+ def get_episode(ds_id, env_id, idx):
203
+ """Get a single episode by env_id and episode index within that env."""
204
+ if ds_id not in _cache:
205
+ return jsonify({"error": "Dataset not loaded"}), 404
206
+
207
+ info = _cache[ds_id]
208
+ ds = info["dataset"]
209
+ episode_groups = info["episode_groups"]
210
+
211
+ if env_id not in episode_groups:
212
+ return jsonify({"error": f"env_id '{env_id}' not found"}), 404
213
+
214
+ indices = episode_groups[env_id]
215
+ if idx < 0 or idx >= len(indices):
216
+ return jsonify({"error": f"Episode index {idx} out of range (0-{len(indices)-1})"}), 400
217
+
218
+ row_idx = indices[idx]
219
+ row = ds[row_idx]
220
+
221
+ # Parse transcript JSON
222
+ transcript_raw = row.get("transcript", "[]")
223
+ try:
224
+ transcript = json.loads(transcript_raw) if isinstance(transcript_raw, str) else transcript_raw
225
+ except json.JSONDecodeError:
226
+ transcript = []
227
+
228
+ # Analyze each turn: dedup observations, split think tags from actions
229
+ analyzed_turns = []
230
+ prev_obs_raw = ""
231
+ for turn in transcript:
232
+ action_analysis = _analyze_action(turn.get("action", ""))
233
+ obs_raw = turn.get("observation", "")
234
+ obs_deduped = _dedup_observation(obs_raw, prev_obs_raw)
235
+ prev_obs_raw = obs_raw
236
+ analyzed_turns.append({
237
+ "turn": turn.get("turn", 0),
238
+ "player_id": turn.get("player_id", 0),
239
+ "observation": obs_deduped,
240
+ "action": turn.get("action", ""),
241
+ "think_text": action_analysis["think_text"],
242
+ "action_text": action_analysis["action_text"],
243
+ "think_len": action_analysis["think_len"],
244
+ "backtracks": action_analysis["backtracks"],
245
+ })
246
+
247
+ # Determine outcome
248
+ reward = row.get("reward")
249
+ error = row.get("error")
250
+ if error:
251
+ outcome = "error"
252
+ elif reward is not None and reward > 0:
253
+ outcome = "win"
254
+ elif reward is not None:
255
+ outcome = "loss"
256
+ else:
257
+ outcome = "unknown"
258
+
259
+ return jsonify({
260
+ "game_id": row.get("game_id", ""),
261
+ "env_id": row.get("env_id", ""),
262
+ "model": row.get("model", ""),
263
+ "opponent_model": row.get("opponent_model"),
264
+ "player_id": row.get("player_id", 0),
265
+ "reward": reward,
266
+ "num_turns": row.get("num_turns", len(transcript)),
267
+ "error": error,
268
+ "outcome": outcome,
269
+ "transcript": analyzed_turns,
270
+ "system_prompt": row.get("system_prompt", None),
271
+ "episode_idx": idx,
272
+ "total_episodes": len(indices),
273
+ })
274
+
275
+
276
+ @bp.route("/<ds_id>", methods=["DELETE"])
277
+ def unload_dataset(ds_id):
278
+ if ds_id in _cache:
279
+ del _cache[ds_id]
280
+ return jsonify({"status": "ok"})
backend/api/harbor_datasets.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import hashlib
3
+ from flask import Blueprint, request, jsonify
4
+ from datasets import load_dataset
5
+
6
+ bp = Blueprint("harbor_datasets", __name__, url_prefix="/api/harbor/datasets")
7
+
8
+ _cache: dict[str, dict] = {}
9
+
10
+
11
+ def _make_id(repo: str, split: str) -> str:
12
+ key = f"{repo}:{split}"
13
+ return hashlib.md5(key.encode()).hexdigest()[:12]
14
+
15
+
16
+ def _parse_trajectory(traj_json: str) -> dict:
17
+ """Parse ATIF-v1.2 trajectory JSON into structured steps."""
18
+ if not traj_json:
19
+ return {"steps": [], "agent_info": {}, "final_metrics": {}}
20
+
21
+ try:
22
+ traj = json.loads(traj_json) if isinstance(traj_json, str) else traj_json
23
+ except (json.JSONDecodeError, TypeError):
24
+ return {"steps": [], "agent_info": {}, "final_metrics": {}}
25
+
26
+ steps = []
27
+ for step in traj.get("steps", []):
28
+ parsed = {
29
+ "index": len(steps),
30
+ "source": step.get("source", "unknown"),
31
+ "message": step.get("message", ""),
32
+ "timestamp": step.get("timestamp"),
33
+ }
34
+ if step.get("source") == "agent":
35
+ parsed["reasoning"] = step.get("reasoning_content", "")
36
+ parsed["tool_calls"] = []
37
+ for tc in step.get("tool_calls", []):
38
+ tool_call = {
39
+ "function": tc.get("function_name", ""),
40
+ "arguments": tc.get("arguments", {}),
41
+ }
42
+ cmd = tc.get("arguments", {}).get("command", "")
43
+ if cmd:
44
+ tool_call["command"] = cmd
45
+ parsed["tool_calls"].append(tool_call)
46
+
47
+ obs = step.get("observation", {})
48
+ if obs:
49
+ if isinstance(obs, dict) and "results" in obs:
50
+ results = obs["results"]
51
+ if results:
52
+ parsed["observation"] = results[0].get("content", "") if isinstance(results[0], dict) else str(results[0])
53
+ else:
54
+ parsed["observation"] = ""
55
+ elif isinstance(obs, str):
56
+ parsed["observation"] = obs
57
+ else:
58
+ parsed["observation"] = json.dumps(obs)
59
+
60
+ parsed["metrics"] = step.get("metrics", {})
61
+ elif step.get("source") == "system":
62
+ pass # message is enough
63
+ elif step.get("source") == "user":
64
+ pass # message is enough
65
+
66
+ steps.append(parsed)
67
+
68
+ return {
69
+ "steps": steps,
70
+ "agent_info": traj.get("agent", {}),
71
+ "final_metrics": traj.get("final_metrics", {}),
72
+ }
73
+
74
+
75
+ def _parse_trajectory_raw(traj_raw: str) -> list[dict]:
76
+ """Parse trajectory_raw into chat-style steps.
77
+
78
+ Handles two formats:
79
+ 1. Flat list of OpenAI messages
80
+ 2. Dict with {info, messages, trajectory_format} (mini-swe-agent format)
81
+ """
82
+ if not traj_raw:
83
+ return []
84
+
85
+ try:
86
+ parsed = json.loads(traj_raw) if isinstance(traj_raw, str) else traj_raw
87
+ except (json.JSONDecodeError, TypeError):
88
+ return []
89
+
90
+ # Extract messages list and optional info
91
+ info = {}
92
+ if isinstance(parsed, dict):
93
+ info = parsed.get("info", {})
94
+ messages = parsed.get("messages", [])
95
+ elif isinstance(parsed, list):
96
+ messages = parsed
97
+ else:
98
+ return []
99
+
100
+ steps = []
101
+ for i, msg in enumerate(messages):
102
+ if not isinstance(msg, dict):
103
+ continue
104
+
105
+ role = msg.get("role", "unknown")
106
+ content = msg.get("content", "")
107
+
108
+ step = {
109
+ "index": i,
110
+ "role": role,
111
+ "content": content if isinstance(content, str) else json.dumps(content) if content else "",
112
+ }
113
+
114
+ # Assistant messages may have tool_calls (OpenAI format)
115
+ if role == "assistant" and "tool_calls" in msg:
116
+ tool_calls = msg["tool_calls"]
117
+ step["tool_calls"] = []
118
+ for tc in (tool_calls if isinstance(tool_calls, list) else []):
119
+ fn = tc.get("function", {})
120
+ call = {
121
+ "id": tc.get("id", ""),
122
+ "function": fn.get("name", ""),
123
+ "arguments_raw": fn.get("arguments", ""),
124
+ }
125
+ try:
126
+ args = json.loads(fn.get("arguments", "{}"))
127
+ call["arguments"] = args
128
+ if "command" in args:
129
+ call["command"] = args["command"]
130
+ except (json.JSONDecodeError, TypeError):
131
+ call["arguments"] = {}
132
+ step["tool_calls"].append(call)
133
+
134
+ # Tool messages have tool_call_id
135
+ if role == "tool":
136
+ step["tool_call_id"] = msg.get("tool_call_id", "")
137
+
138
+ steps.append(step)
139
+
140
+ # Attach info as metadata on first step if available
141
+ if steps and info:
142
+ steps[0]["_info"] = info
143
+
144
+ return steps
145
+
146
+
147
+ def _build_instance_summary(row: dict) -> dict:
148
+ """Build a summary for one instance row."""
149
+ return {
150
+ "instance_id": row.get("instance_id", ""),
151
+ "resolved": row.get("resolved", False),
152
+ "reward": row.get("reward", 0),
153
+ "model": row.get("model", ""),
154
+ "agent": row.get("agent", ""),
155
+ "duration_seconds": row.get("duration_seconds", 0),
156
+ "error": row.get("error", ""),
157
+ }
158
+
159
+
160
+ @bp.route("/load", methods=["POST"])
161
+ def load_dataset_endpoint():
162
+ data = request.get_json()
163
+ repo = data.get("repo", "").strip()
164
+ if not repo:
165
+ return jsonify({"error": "repo is required"}), 400
166
+
167
+ split = data.get("split", "train")
168
+
169
+ try:
170
+ ds = load_dataset(repo, split=split)
171
+ except Exception as e:
172
+ return jsonify({"error": f"Failed to load dataset: {e}"}), 400
173
+
174
+ ds_id = _make_id(repo, split)
175
+
176
+ # Build instance summaries and index
177
+ instances = []
178
+ instance_index = {}
179
+ for i in range(len(ds)):
180
+ row = ds[i]
181
+ summary = _build_instance_summary(row)
182
+ instances.append(summary)
183
+ instance_index[row.get("instance_id", "")] = i
184
+
185
+ _cache[ds_id] = {
186
+ "repo": repo,
187
+ "split": split,
188
+ "dataset": ds,
189
+ "instances": instances,
190
+ "instance_index": instance_index,
191
+ }
192
+
193
+ short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo
194
+
195
+ return jsonify({
196
+ "id": ds_id,
197
+ "repo": repo,
198
+ "name": short_name,
199
+ "split": split,
200
+ "instances": instances,
201
+ "n_instances": len(instances),
202
+ })
203
+
204
+
205
+ @bp.route("/", methods=["GET"])
206
+ def list_datasets():
207
+ result = []
208
+ for ds_id, info in _cache.items():
209
+ result.append({
210
+ "id": ds_id,
211
+ "repo": info["repo"],
212
+ "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"],
213
+ "split": info["split"],
214
+ "n_instances": len(info["instances"]),
215
+ "instances": info["instances"],
216
+ })
217
+ return jsonify(result)
218
+
219
+
220
+ @bp.route("/<ds_id>/instances", methods=["GET"])
221
+ def get_instances(ds_id):
222
+ if ds_id not in _cache:
223
+ return jsonify({"error": "Dataset not loaded"}), 404
224
+ return jsonify(_cache[ds_id]["instances"])
225
+
226
+
227
+ @bp.route("/<ds_id>/instance/<path:instance_id>", methods=["GET"])
228
+ def get_instance(ds_id, instance_id):
229
+ """Get full parsed trajectory for one instance."""
230
+ if ds_id not in _cache:
231
+ return jsonify({"error": "Dataset not loaded"}), 404
232
+
233
+ info = _cache[ds_id]
234
+ if instance_id not in info["instance_index"]:
235
+ return jsonify({"error": f"Instance {instance_id} not found"}), 404
236
+
237
+ idx = info["instance_index"][instance_id]
238
+ row = info["dataset"][idx]
239
+
240
+ # Parse ATIF trajectory
241
+ atif = _parse_trajectory(row.get("trajectory", ""))
242
+
243
+ # Parse raw trajectory (OpenAI messages)
244
+ raw_steps = _parse_trajectory_raw(row.get("trajectory_raw", ""))
245
+
246
+ return jsonify({
247
+ "instance_id": instance_id,
248
+ "resolved": row.get("resolved", False),
249
+ "reward": row.get("reward", 0),
250
+ "model": row.get("model", ""),
251
+ "agent": row.get("agent", ""),
252
+ "duration_seconds": row.get("duration_seconds", 0),
253
+ "error": row.get("error", ""),
254
+ "atif": atif,
255
+ "raw_steps": raw_steps,
256
+ "n_atif_steps": len(atif["steps"]),
257
+ "n_raw_steps": len(raw_steps),
258
+ })
259
+
260
+
261
+ @bp.route("/<ds_id>/instance/<path:instance_id>/raw", methods=["GET"])
262
+ def get_instance_raw(ds_id, instance_id):
263
+ """Get raw logs: agent_stdout, setup_stderr, verifier_report."""
264
+ if ds_id not in _cache:
265
+ return jsonify({"error": "Dataset not loaded"}), 404
266
+
267
+ info = _cache[ds_id]
268
+ if instance_id not in info["instance_index"]:
269
+ return jsonify({"error": f"Instance {instance_id} not found"}), 404
270
+
271
+ idx = info["instance_index"][instance_id]
272
+ row = info["dataset"][idx]
273
+
274
+ return jsonify({
275
+ "instance_id": instance_id,
276
+ "agent_stdout": row.get("agent_stdout", ""),
277
+ "setup_stderr": row.get("setup_stderr", ""),
278
+ "verifier_report": row.get("verifier_report", ""),
279
+ "verifier_stdout": row.get("verifier_stdout", ""),
280
+ })
281
+
282
+
283
+ @bp.route("/<ds_id>", methods=["DELETE"])
284
+ def unload_dataset(ds_id):
285
+ if ds_id in _cache:
286
+ del _cache[ds_id]
287
+ return jsonify({"status": "ok"})
backend/api/model_datasets.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import hashlib
4
+ from flask import Blueprint, request, jsonify
5
+ from datasets import load_dataset, Dataset
6
+
7
+ bp = Blueprint("model_datasets", __name__, url_prefix="/api/model/datasets")
8
+
9
+ # In-memory cache: id -> {dataset, repo, column, split, n_rows, n_samples}
10
+ _cache: dict[str, dict] = {}
11
+
12
+
13
+ def _make_id(repo: str, column: str, split: str) -> str:
14
+ key = f"{repo}:{column}:{split}"
15
+ return hashlib.md5(key.encode()).hexdigest()[:12]
16
+
17
+
18
+ def _load_hf_dataset(repo: str, split: str) -> Dataset:
19
+ if os.path.exists(repo):
20
+ return Dataset.from_parquet(repo)
21
+ return load_dataset(repo, split=split)
22
+
23
+
24
+ def _detect_response_column(columns: list[str], preferred: str) -> str:
25
+ if preferred in columns:
26
+ return preferred
27
+ for fallback in ["model_responses", "response", "responses", "output", "outputs"]:
28
+ if fallback in columns:
29
+ return fallback
30
+ return preferred
31
+
32
+
33
+ def _detect_prompt_column(columns: list[str], preferred: str) -> str | None:
34
+ if preferred in columns:
35
+ return preferred
36
+ for fallback in ["formatted_prompt", "prompt", "question", "input"]:
37
+ if fallback in columns:
38
+ return fallback
39
+ return None
40
+
41
+
42
+ def _compute_question_fingerprint(ds: Dataset, n: int = 5) -> str:
43
+ """Hash first N question texts to fingerprint the question set."""
44
+ questions = []
45
+ for i in range(min(n, len(ds))):
46
+ row = ds[i]
47
+ for qcol in ["question", "prompt", "input", "formatted_prompt"]:
48
+ if qcol in row:
49
+ questions.append(str(row[qcol] or "")[:200])
50
+ break
51
+ return hashlib.md5("||".join(questions).encode()).hexdigest()[:8]
52
+
53
+
54
+ def _count_samples(ds: Dataset, column: str) -> int:
55
+ if len(ds) == 0:
56
+ return 0
57
+ first = ds[0][column]
58
+ if isinstance(first, list):
59
+ return len(first)
60
+ return 1
61
+
62
+
63
+ def _flatten_evals(evals) -> list[bool]:
64
+ if not isinstance(evals, list):
65
+ return [bool(evals)]
66
+ return [
67
+ bool(e[-1]) if isinstance(e, list) and len(e) > 0
68
+ else (bool(e) if not isinstance(e, list) else False)
69
+ for e in evals
70
+ ]
71
+
72
+
73
+ def _extract_reasoning(meta: dict | None) -> str | None:
74
+ """Extract reasoning/thinking content from response metadata's raw_response."""
75
+ if not meta or not isinstance(meta, dict):
76
+ return None
77
+ raw = meta.get("raw_response")
78
+ if not raw or not isinstance(raw, dict):
79
+ return None
80
+ try:
81
+ msg = raw["choices"][0]["message"]
82
+ return (
83
+ msg.get("reasoning_content")
84
+ or msg.get("thinking")
85
+ or msg.get("reasoning")
86
+ )
87
+ except (KeyError, IndexError, TypeError):
88
+ return None
89
+
90
+
91
+ def _merge_reasoning_into_response(response: str, reasoning: str | None) -> str:
92
+ """Prepend <think>{reasoning}</think> to response if reasoning exists
93
+ and isn't already present in the response."""
94
+ if not reasoning:
95
+ return response or ""
96
+ response = response or ""
97
+ # Don't double-add if response already contains the thinking
98
+ if "<think>" in response:
99
+ return response
100
+ return f"<think>{reasoning}</think>\n{response}"
101
+
102
+
103
+ def _analyze_trace(text: str) -> dict:
104
+ if not text:
105
+ return dict(total_len=0, think_len=0, answer_len=0,
106
+ backtracks=0, restarts=0, think_text="", answer_text="")
107
+ think_end = text.find("</think>")
108
+ if think_end > 0:
109
+ # Keep raw tags so display is 1:1 with HuggingFace data
110
+ think_text = text[:think_end + 8] # include </think>
111
+ answer_text = text[think_end + 8:].strip()
112
+ else:
113
+ think_text = text
114
+ answer_text = ""
115
+ t = text.lower()
116
+ backtracks = sum(t.count(w) for w in
117
+ ["wait,", "wait ", "hmm", "let me try", "try again",
118
+ "another approach", "let me reconsider"])
119
+ restarts = sum(t.count(w) for w in
120
+ ["start over", "fresh approach", "different approach", "from scratch"])
121
+ return dict(total_len=len(text), think_len=len(think_text),
122
+ answer_len=len(answer_text), backtracks=backtracks,
123
+ restarts=restarts, think_text=think_text, answer_text=answer_text)
124
+
125
+
126
+ @bp.route("/load", methods=["POST"])
127
+ def load_dataset_endpoint():
128
+ data = request.get_json()
129
+ repo = data.get("repo", "").strip()
130
+ if not repo:
131
+ return jsonify({"error": "repo is required"}), 400
132
+
133
+ split = data.get("split", "train")
134
+ preferred_column = data.get("column", "model_responses")
135
+ preferred_prompt_column = data.get("prompt_column", "formatted_prompt")
136
+
137
+ try:
138
+ ds = _load_hf_dataset(repo, split)
139
+ except Exception as e:
140
+ return jsonify({"error": f"Failed to load dataset: {e}"}), 400
141
+
142
+ columns = ds.column_names
143
+ column = _detect_response_column(columns, preferred_column)
144
+ prompt_column = _detect_prompt_column(columns, preferred_prompt_column)
145
+
146
+ if column not in columns:
147
+ return jsonify({
148
+ "error": f"Column '{column}' not found. Available: {columns}"
149
+ }), 400
150
+
151
+ n_samples = _count_samples(ds, column)
152
+ ds_id = _make_id(repo, column, split)
153
+ fingerprint = _compute_question_fingerprint(ds)
154
+
155
+ _cache[ds_id] = {
156
+ "dataset": ds,
157
+ "repo": repo,
158
+ "column": column,
159
+ "prompt_column": prompt_column,
160
+ "split": split,
161
+ "n_rows": len(ds),
162
+ "n_samples": n_samples,
163
+ "question_fingerprint": fingerprint,
164
+ }
165
+
166
+ short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo
167
+
168
+ return jsonify({
169
+ "id": ds_id,
170
+ "repo": repo,
171
+ "name": short_name,
172
+ "column": column,
173
+ "prompt_column": prompt_column,
174
+ "columns": columns,
175
+ "split": split,
176
+ "n_rows": len(ds),
177
+ "n_samples": n_samples,
178
+ "question_fingerprint": fingerprint,
179
+ })
180
+
181
+
182
+ @bp.route("/", methods=["GET"])
183
+ def list_datasets():
184
+ result = []
185
+ for ds_id, info in _cache.items():
186
+ result.append({
187
+ "id": ds_id,
188
+ "repo": info["repo"],
189
+ "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"],
190
+ "column": info["column"],
191
+ "split": info["split"],
192
+ "n_rows": info["n_rows"],
193
+ "n_samples": info["n_samples"],
194
+ "question_fingerprint": info.get("question_fingerprint", ""),
195
+ })
196
+ return jsonify(result)
197
+
198
+
199
+ @bp.route("/<ds_id>/question/<int:idx>", methods=["GET"])
200
+ def get_question(ds_id, idx):
201
+ if ds_id not in _cache:
202
+ return jsonify({"error": "Dataset not loaded"}), 404
203
+
204
+ info = _cache[ds_id]
205
+ ds = info["dataset"]
206
+ column = info["column"]
207
+
208
+ if idx < 0 or idx >= len(ds):
209
+ return jsonify({"error": f"Index {idx} out of range (0-{len(ds)-1})"}), 400
210
+
211
+ row = ds[idx]
212
+ responses_raw = row[column]
213
+ if not isinstance(responses_raw, list):
214
+ responses_raw = [responses_raw]
215
+
216
+ # Check for {column}__metadata to recover reasoning/thinking content
217
+ meta_column = f"{column}__metadata"
218
+ response_metas = None
219
+ if meta_column in row:
220
+ response_metas = row[meta_column]
221
+ if not isinstance(response_metas, list):
222
+ response_metas = [response_metas]
223
+
224
+ # Merge reasoning from metadata into responses
225
+ merged_responses = []
226
+ for i, resp in enumerate(responses_raw):
227
+ meta = response_metas[i] if response_metas and i < len(response_metas) else None
228
+ reasoning = _extract_reasoning(meta)
229
+ merged_responses.append(_merge_reasoning_into_response(resp, reasoning))
230
+ responses_raw = merged_responses
231
+
232
+ # Prompt text from configured prompt column
233
+ prompt_text = ""
234
+ prompt_col = info.get("prompt_column")
235
+ if prompt_col and prompt_col in row:
236
+ val = row[prompt_col]
237
+ if isinstance(val, str):
238
+ prompt_text = val
239
+ elif isinstance(val, list):
240
+ prompt_text = json.dumps(val)
241
+ elif val is not None:
242
+ prompt_text = str(val)
243
+
244
+ question = ""
245
+ for qcol in ["question", "prompt", "input", "formatted_prompt"]:
246
+ if qcol in row:
247
+ question = row[qcol] or ""
248
+ break
249
+
250
+ eval_correct = []
251
+ if "eval_correct" in row:
252
+ eval_correct = _flatten_evals(row["eval_correct"])
253
+
254
+ # Check extractions with column-aware name
255
+ extractions = []
256
+ extractions_col = f"{column}__extractions"
257
+ for ecol in [extractions_col, "response__extractions"]:
258
+ if ecol in row:
259
+ ext = row[ecol]
260
+ if isinstance(ext, list):
261
+ extractions = [str(e) for e in ext]
262
+ break
263
+
264
+ metadata = {}
265
+ if "metadata" in row:
266
+ metadata = row["metadata"] if isinstance(row["metadata"], dict) else {}
267
+
268
+ analyses = [_analyze_trace(r or "") for r in responses_raw]
269
+
270
+ return jsonify({
271
+ "question": question,
272
+ "prompt_text": prompt_text,
273
+ "responses": [r or "" for r in responses_raw],
274
+ "eval_correct": eval_correct,
275
+ "extractions": extractions,
276
+ "metadata": metadata,
277
+ "analyses": analyses,
278
+ "n_samples": len(responses_raw),
279
+ "index": idx,
280
+ })
281
+
282
+
283
+ @bp.route("/<ds_id>/summary", methods=["GET"])
284
+ def get_summary(ds_id):
285
+ if ds_id not in _cache:
286
+ return jsonify({"error": "Dataset not loaded"}), 404
287
+
288
+ info = _cache[ds_id]
289
+ ds = info["dataset"]
290
+ n_rows = info["n_rows"]
291
+ n_samples = info["n_samples"]
292
+
293
+ if "eval_correct" not in ds.column_names:
294
+ return jsonify({
295
+ "n_rows": n_rows,
296
+ "n_samples": n_samples,
297
+ "has_eval": False,
298
+ })
299
+
300
+ pass_at = {}
301
+ for k in [1, 2, 4, 8]:
302
+ if k > n_samples:
303
+ break
304
+ correct = sum(1 for i in range(n_rows)
305
+ if any(_flatten_evals(ds[i]["eval_correct"])[:k]))
306
+ pass_at[k] = {"correct": correct, "total": n_rows,
307
+ "rate": correct / n_rows if n_rows > 0 else 0}
308
+
309
+ total_samples = n_rows * n_samples
310
+ total_correct = sum(
311
+ sum(_flatten_evals(ds[i]["eval_correct"]))
312
+ for i in range(n_rows)
313
+ )
314
+
315
+ return jsonify({
316
+ "n_rows": n_rows,
317
+ "n_samples": n_samples,
318
+ "has_eval": True,
319
+ "sample_accuracy": {
320
+ "correct": total_correct,
321
+ "total": total_samples,
322
+ "rate": total_correct / total_samples if total_samples > 0 else 0,
323
+ },
324
+ "pass_at": pass_at,
325
+ })
326
+
327
+
328
+ @bp.route("/<ds_id>", methods=["DELETE"])
329
+ def unload_dataset(ds_id):
330
+ if ds_id in _cache:
331
+ del _cache[ds_id]
332
+ return jsonify({"status": "ok"})
backend/api/presets.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import uuid
4
+ import tempfile
5
+ import threading
6
+ from flask import Blueprint, request, jsonify
7
+
8
+ bp = Blueprint("presets", __name__, url_prefix="/api/presets")
9
+
10
+ PRESETS_REPO = "reasoning-degeneration-dev/AGG_VIS_PRESETS"
11
+ VALID_TYPES = {"model", "arena", "rlm", "harbor"}
12
+ LOCAL_PRESETS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "presets")
13
+
14
+ # In-memory cache: vis_type -> list[dict]
15
+ _cache: dict[str, list[dict]] = {}
16
+ _cache_loaded: set[str] = set()
17
+ _lock = threading.Lock()
18
+
19
+
20
+ def _ensure_local_dir():
21
+ os.makedirs(LOCAL_PRESETS_DIR, exist_ok=True)
22
+
23
+
24
+ def _local_path(vis_type: str) -> str:
25
+ _ensure_local_dir()
26
+ return os.path.join(LOCAL_PRESETS_DIR, f"{vis_type}_presets.json")
27
+
28
+
29
+ def _download_presets(vis_type: str) -> list[dict]:
30
+ """Download presets from HuggingFace, falling back to local file."""
31
+ try:
32
+ from huggingface_hub import hf_hub_download
33
+ path = hf_hub_download(
34
+ PRESETS_REPO,
35
+ f"{vis_type}_presets.json",
36
+ repo_type="dataset",
37
+ )
38
+ with open(path) as f:
39
+ presets = json.load(f)
40
+ # Cache locally for offline fallback
41
+ with open(_local_path(vis_type), "w") as f:
42
+ json.dump(presets, f, indent=2)
43
+ return presets
44
+ except Exception:
45
+ # Fall back to local cache
46
+ local = _local_path(vis_type)
47
+ if os.path.exists(local):
48
+ with open(local) as f:
49
+ return json.load(f)
50
+ return []
51
+
52
+
53
+ def _upload_presets(vis_type: str, presets: list[dict]):
54
+ """Upload presets to HuggingFace (best-effort, non-blocking)."""
55
+ # Always save locally first
56
+ with open(_local_path(vis_type), "w") as f:
57
+ json.dump(presets, f, indent=2)
58
+
59
+ def _do_upload():
60
+ try:
61
+ from huggingface_hub import HfApi
62
+ api = HfApi()
63
+ # Ensure repo exists
64
+ try:
65
+ api.create_repo(
66
+ PRESETS_REPO,
67
+ repo_type="dataset",
68
+ exist_ok=True,
69
+ )
70
+ except Exception:
71
+ pass
72
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as f:
73
+ json.dump(presets, f, indent=2)
74
+ tmp = f.name
75
+ api.upload_file(
76
+ path_or_fileobj=tmp,
77
+ path_in_repo=f"{vis_type}_presets.json",
78
+ repo_id=PRESETS_REPO,
79
+ repo_type="dataset",
80
+ )
81
+ os.unlink(tmp)
82
+ except Exception as e:
83
+ print(f"[presets] HF upload failed for {vis_type}: {e}")
84
+
85
+ threading.Thread(target=_do_upload, daemon=True).start()
86
+
87
+
88
+ def _get_presets(vis_type: str) -> list[dict]:
89
+ """Get presets for a visualizer type, downloading if needed."""
90
+ with _lock:
91
+ if vis_type not in _cache_loaded:
92
+ _cache[vis_type] = _download_presets(vis_type)
93
+ _cache_loaded.add(vis_type)
94
+ return list(_cache.get(vis_type, []))
95
+
96
+
97
+ def _set_presets(vis_type: str, presets: list[dict]):
98
+ """Update presets in cache and sync to HF."""
99
+ with _lock:
100
+ _cache[vis_type] = presets
101
+ _cache_loaded.add(vis_type)
102
+ _upload_presets(vis_type, presets)
103
+
104
+
105
+ @bp.route("/<vis_type>", methods=["GET"])
106
+ def list_presets(vis_type):
107
+ if vis_type not in VALID_TYPES:
108
+ return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400
109
+ return jsonify(_get_presets(vis_type))
110
+
111
+
112
+ @bp.route("/<vis_type>", methods=["POST"])
113
+ def create_preset(vis_type):
114
+ if vis_type not in VALID_TYPES:
115
+ return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400
116
+
117
+ data = request.get_json()
118
+ name = data.get("name", "").strip()
119
+
120
+ if not name:
121
+ return jsonify({"error": "name is required"}), 400
122
+
123
+ preset = {
124
+ "id": uuid.uuid4().hex[:8],
125
+ "name": name,
126
+ }
127
+ # Include type-specific fields
128
+ repo = data.get("repo", "").strip()
129
+ if not repo:
130
+ return jsonify({"error": "repo is required"}), 400
131
+ preset["repo"] = repo
132
+ preset["split"] = data.get("split", "train")
133
+
134
+ if vis_type == "model":
135
+ preset["column"] = data.get("column", "model_responses")
136
+ elif vis_type == "rlm":
137
+ preset["config"] = data.get("config", "rlm_call_traces")
138
+
139
+ presets = _get_presets(vis_type)
140
+ presets.append(preset)
141
+ _set_presets(vis_type, presets)
142
+
143
+ return jsonify(preset), 201
144
+
145
+
146
+ @bp.route("/<vis_type>/<preset_id>", methods=["PUT"])
147
+ def update_preset(vis_type, preset_id):
148
+ if vis_type not in VALID_TYPES:
149
+ return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400
150
+
151
+ data = request.get_json()
152
+ presets = _get_presets(vis_type)
153
+
154
+ for p in presets:
155
+ if p["id"] == preset_id:
156
+ if "name" in data:
157
+ p["name"] = data["name"].strip()
158
+ if "column" in data:
159
+ p["column"] = data["column"]
160
+ if "split" in data:
161
+ p["split"] = data["split"]
162
+ if "config" in data:
163
+ p["config"] = data["config"]
164
+ _set_presets(vis_type, presets)
165
+ return jsonify(p)
166
+
167
+ return jsonify({"error": "not found"}), 404
168
+
169
+
170
+ @bp.route("/<vis_type>/<preset_id>", methods=["DELETE"])
171
+ def delete_preset(vis_type, preset_id):
172
+ if vis_type not in VALID_TYPES:
173
+ return jsonify({"error": f"Invalid type. Must be one of: {VALID_TYPES}"}), 400
174
+
175
+ presets = _get_presets(vis_type)
176
+ presets = [p for p in presets if p["id"] != preset_id]
177
+ _set_presets(vis_type, presets)
178
+ return jsonify({"status": "ok"})
179
+
180
+
181
+ @bp.route("/sync", methods=["POST"])
182
+ def sync_presets():
183
+ """Force re-download presets from HF."""
184
+ with _lock:
185
+ _cache.clear()
186
+ _cache_loaded.clear()
187
+ for vt in VALID_TYPES:
188
+ _get_presets(vt)
189
+ return jsonify({"status": "ok"})
backend/api/rlm_datasets.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import hashlib
3
+ from flask import Blueprint, request, jsonify
4
+ from datasets import load_dataset
5
+
6
+ bp = Blueprint("rlm_datasets", __name__, url_prefix="/api/rlm/datasets")
7
+
8
+ _cache: dict[str, dict] = {}
9
+
10
+
11
+ def _make_id(repo: str, config: str, split: str) -> str:
12
+ key = f"{repo}:{config}:{split}"
13
+ return hashlib.md5(key.encode()).hexdigest()[:12]
14
+
15
+
16
+ def _build_hierarchy(rows: list[dict]) -> dict:
17
+ """Reconstruct hierarchy from flat rlm_call_traces rows."""
18
+ gepa_iters: dict[int, dict] = {}
19
+
20
+ for row in rows:
21
+ gi = row.get("gepa_iter", 0)
22
+ rci = row.get("rlm_call_idx", 0)
23
+ ri = row.get("rlm_iter", 0)
24
+
25
+ if gi not in gepa_iters:
26
+ gepa_iters[gi] = {
27
+ "gepa_iter": gi,
28
+ "rlm_calls": {},
29
+ "total_input_tokens": 0,
30
+ "total_output_tokens": 0,
31
+ "total_execution_time": 0.0,
32
+ "final_answer": None,
33
+ }
34
+
35
+ gi_data = gepa_iters[gi]
36
+ if rci not in gi_data["rlm_calls"]:
37
+ gi_data["rlm_calls"][rci] = {
38
+ "rlm_call_idx": rci,
39
+ "iterations": [],
40
+ }
41
+
42
+ # Parse code blocks
43
+ code_blocks = []
44
+ cbj = row.get("code_blocks_json", "")
45
+ if cbj and cbj != "[]":
46
+ try:
47
+ code_blocks = json.loads(cbj) if isinstance(cbj, str) else cbj
48
+ except (json.JSONDecodeError, TypeError):
49
+ code_blocks = []
50
+
51
+ iteration = {
52
+ "rlm_iter": ri,
53
+ "prompt": row.get("prompt", ""),
54
+ "response": row.get("response", ""),
55
+ "model": row.get("model", ""),
56
+ "input_tokens": row.get("input_tokens", 0),
57
+ "output_tokens": row.get("output_tokens", 0),
58
+ "execution_time": row.get("execution_time", 0.0),
59
+ "has_code_blocks": row.get("has_code_blocks", False),
60
+ "code_blocks": code_blocks,
61
+ "final_answer": row.get("final_answer"),
62
+ "subcall_id": row.get("subcall_id"),
63
+ "parent_id": row.get("parent_id"),
64
+ "timestamp": row.get("timestamp", ""),
65
+ }
66
+
67
+ gi_data["rlm_calls"][rci]["iterations"].append(iteration)
68
+ gi_data["total_input_tokens"] += iteration["input_tokens"] or 0
69
+ gi_data["total_output_tokens"] += iteration["output_tokens"] or 0
70
+ gi_data["total_execution_time"] += iteration["execution_time"] or 0.0
71
+
72
+ if iteration["final_answer"]:
73
+ gi_data["final_answer"] = iteration["final_answer"]
74
+
75
+ # Sort and convert dicts to lists
76
+ result = []
77
+ for gi_key in sorted(gepa_iters.keys()):
78
+ gi_data = gepa_iters[gi_key]
79
+ rlm_calls = []
80
+ for rci_key in sorted(gi_data["rlm_calls"].keys()):
81
+ call = gi_data["rlm_calls"][rci_key]
82
+ call["iterations"].sort(key=lambda x: x["rlm_iter"])
83
+ rlm_calls.append(call)
84
+ gi_data["rlm_calls"] = rlm_calls
85
+ result.append(gi_data)
86
+
87
+ return {"gepa_iterations": result}
88
+
89
+
90
+ @bp.route("/load", methods=["POST"])
91
+ def load_dataset_endpoint():
92
+ data = request.get_json()
93
+ repo = data.get("repo", "").strip()
94
+ if not repo:
95
+ return jsonify({"error": "repo is required"}), 400
96
+
97
+ config = data.get("config", "rlm_call_traces")
98
+ split = data.get("split", "train")
99
+
100
+ try:
101
+ ds = load_dataset(repo, config, split=split)
102
+ except Exception as e:
103
+ return jsonify({"error": f"Failed to load dataset: {e}"}), 400
104
+
105
+ ds_id = _make_id(repo, config, split)
106
+ rows = [ds[i] for i in range(len(ds))]
107
+ hierarchy = _build_hierarchy(rows)
108
+
109
+ # Extract metadata from first row
110
+ first_row = rows[0] if rows else {}
111
+ metadata = {
112
+ "run_id": first_row.get("run_id", ""),
113
+ "method": first_row.get("method", ""),
114
+ "k": first_row.get("k", 0),
115
+ "model": first_row.get("model", ""),
116
+ }
117
+
118
+ _cache[ds_id] = {
119
+ "repo": repo,
120
+ "config": config,
121
+ "split": split,
122
+ "hierarchy": hierarchy,
123
+ "metadata": metadata,
124
+ "n_rows": len(rows),
125
+ }
126
+
127
+ short_name = repo.rsplit("/", 1)[-1] if "/" in repo else repo
128
+
129
+ return jsonify({
130
+ "id": ds_id,
131
+ "repo": repo,
132
+ "name": short_name,
133
+ "config": config,
134
+ "split": split,
135
+ "metadata": metadata,
136
+ "n_gepa_iters": len(hierarchy["gepa_iterations"]),
137
+ "n_rows": len(rows),
138
+ })
139
+
140
+
141
+ @bp.route("/", methods=["GET"])
142
+ def list_datasets():
143
+ result = []
144
+ for ds_id, info in _cache.items():
145
+ result.append({
146
+ "id": ds_id,
147
+ "repo": info["repo"],
148
+ "name": info["repo"].rsplit("/", 1)[-1] if "/" in info["repo"] else info["repo"],
149
+ "config": info["config"],
150
+ "split": info["split"],
151
+ "metadata": info["metadata"],
152
+ "n_rows": info["n_rows"],
153
+ "n_gepa_iters": len(info["hierarchy"]["gepa_iterations"]),
154
+ })
155
+ return jsonify(result)
156
+
157
+
158
+ @bp.route("/<ds_id>/overview", methods=["GET"])
159
+ def get_overview(ds_id):
160
+ """Level 1: Summary of all GEPA iterations."""
161
+ if ds_id not in _cache:
162
+ return jsonify({"error": "Dataset not loaded"}), 404
163
+
164
+ info = _cache[ds_id]
165
+ hierarchy = info["hierarchy"]
166
+
167
+ summaries = []
168
+ for gi in hierarchy["gepa_iterations"]:
169
+ total_rlm_iters = sum(len(c["iterations"]) for c in gi["rlm_calls"])
170
+ summaries.append({
171
+ "gepa_iter": gi["gepa_iter"],
172
+ "n_rlm_calls": len(gi["rlm_calls"]),
173
+ "n_rlm_iters": total_rlm_iters,
174
+ "total_input_tokens": gi["total_input_tokens"],
175
+ "total_output_tokens": gi["total_output_tokens"],
176
+ "total_execution_time": gi["total_execution_time"],
177
+ "has_final_answer": gi["final_answer"] is not None,
178
+ "final_answer_preview": (gi["final_answer"] or "")[:200],
179
+ })
180
+
181
+ return jsonify({
182
+ "metadata": info["metadata"],
183
+ "gepa_iterations": summaries,
184
+ })
185
+
186
+
187
+ @bp.route("/<ds_id>/gepa/<int:gepa_iter>", methods=["GET"])
188
+ def get_gepa_iteration(ds_id, gepa_iter):
189
+ """Level 2: RLM timeline for a specific GEPA iteration."""
190
+ if ds_id not in _cache:
191
+ return jsonify({"error": "Dataset not loaded"}), 404
192
+
193
+ info = _cache[ds_id]
194
+ hierarchy = info["hierarchy"]
195
+
196
+ gi_data = None
197
+ for gi in hierarchy["gepa_iterations"]:
198
+ if gi["gepa_iter"] == gepa_iter:
199
+ gi_data = gi
200
+ break
201
+
202
+ if gi_data is None:
203
+ return jsonify({"error": f"GEPA iteration {gepa_iter} not found"}), 404
204
+
205
+ # Return full RLM call data with iterations (truncate prompts for timeline view)
206
+ rlm_calls = []
207
+ for call in gi_data["rlm_calls"]:
208
+ iters = []
209
+ for it in call["iterations"]:
210
+ iters.append({
211
+ "rlm_iter": it["rlm_iter"],
212
+ "model": it["model"],
213
+ "input_tokens": it["input_tokens"],
214
+ "output_tokens": it["output_tokens"],
215
+ "execution_time": it["execution_time"],
216
+ "has_code_blocks": it["has_code_blocks"],
217
+ "n_code_blocks": len(it["code_blocks"]),
218
+ "response_preview": (it["response"] or "")[:300],
219
+ "has_final_answer": it["final_answer"] is not None,
220
+ "timestamp": it["timestamp"],
221
+ })
222
+ rlm_calls.append({
223
+ "rlm_call_idx": call["rlm_call_idx"],
224
+ "iterations": iters,
225
+ })
226
+
227
+ return jsonify({
228
+ "gepa_iter": gepa_iter,
229
+ "total_input_tokens": gi_data["total_input_tokens"],
230
+ "total_output_tokens": gi_data["total_output_tokens"],
231
+ "total_execution_time": gi_data["total_execution_time"],
232
+ "final_answer": gi_data["final_answer"],
233
+ "rlm_calls": rlm_calls,
234
+ })
235
+
236
+
237
+ @bp.route("/<ds_id>/gepa/<int:gepa_iter>/rlm/<int:rlm_call_idx>/<int:rlm_iter>", methods=["GET"])
238
+ def get_rlm_iteration(ds_id, gepa_iter, rlm_call_idx, rlm_iter):
239
+ """Level 3: Full detail for a specific RLM iteration."""
240
+ if ds_id not in _cache:
241
+ return jsonify({"error": "Dataset not loaded"}), 404
242
+
243
+ info = _cache[ds_id]
244
+ hierarchy = info["hierarchy"]
245
+
246
+ for gi in hierarchy["gepa_iterations"]:
247
+ if gi["gepa_iter"] != gepa_iter:
248
+ continue
249
+ for call in gi["rlm_calls"]:
250
+ if call["rlm_call_idx"] != rlm_call_idx:
251
+ continue
252
+ for it in call["iterations"]:
253
+ if it["rlm_iter"] == rlm_iter:
254
+ return jsonify(it)
255
+
256
+ return jsonify({"error": "RLM iteration not found"}), 404
257
+
258
+
259
+ @bp.route("/<ds_id>", methods=["DELETE"])
260
+ def unload_dataset(ds_id):
261
+ if ds_id in _cache:
262
+ del _cache[ds_id]
263
+ return jsonify({"status": "ok"})
backend/app.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask
2
+ from flask_cors import CORS
3
+
4
+
5
+ def create_app():
6
+ app = Flask(__name__, static_folder="../frontend/dist", static_url_path="/")
7
+ CORS(app)
8
+
9
+ from backend.api import model_datasets, arena_datasets, rlm_datasets, harbor_datasets, presets
10
+ app.register_blueprint(model_datasets.bp)
11
+ app.register_blueprint(arena_datasets.bp)
12
+ app.register_blueprint(rlm_datasets.bp)
13
+ app.register_blueprint(harbor_datasets.bp)
14
+ app.register_blueprint(presets.bp)
15
+
16
+ @app.route("/api/health")
17
+ def health():
18
+ return {"status": "ok"}
19
+
20
+ @app.route("/", defaults={"path": ""})
21
+ @app.route("/<path:path>")
22
+ def serve_frontend(path):
23
+ return app.send_static_file("index.html")
24
+
25
+ return app
26
+
27
+
28
+ app = create_app()
29
+
30
+
31
+ def main():
32
+ app.run(debug=True, port=8080)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
backend/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask>=3.0.0
2
+ flask-cors>=4.0.0
3
+ datasets>=2.14.0
4
+ gunicorn>=21.0.0
5
+ huggingface_hub>=0.20.0
frontend/index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Aggregate Trace Visualizer</title>
7
+ </head>
8
+ <body class="bg-gray-950 text-gray-100">
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
frontend/package-lock.json ADDED
@@ -0,0 +1,2766 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "agg-visualizer",
3
+ "version": "0.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "agg-visualizer",
9
+ "version": "0.1.0",
10
+ "dependencies": {
11
+ "react": "^18.3.1",
12
+ "react-dom": "^18.3.1"
13
+ },
14
+ "devDependencies": {
15
+ "@types/react": "^18.3.12",
16
+ "@types/react-dom": "^18.3.1",
17
+ "@vitejs/plugin-react": "^4.3.4",
18
+ "autoprefixer": "^10.4.20",
19
+ "postcss": "^8.4.49",
20
+ "tailwindcss": "^3.4.15",
21
+ "typescript": "^5.6.3",
22
+ "vite": "^6.0.3"
23
+ }
24
+ },
25
+ "node_modules/@alloc/quick-lru": {
26
+ "version": "5.2.0",
27
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
28
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
29
+ "dev": true,
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=10"
33
+ },
34
+ "funding": {
35
+ "url": "https://github.com/sponsors/sindresorhus"
36
+ }
37
+ },
38
+ "node_modules/@babel/code-frame": {
39
+ "version": "7.29.0",
40
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
41
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
42
+ "dev": true,
43
+ "license": "MIT",
44
+ "dependencies": {
45
+ "@babel/helper-validator-identifier": "^7.28.5",
46
+ "js-tokens": "^4.0.0",
47
+ "picocolors": "^1.1.1"
48
+ },
49
+ "engines": {
50
+ "node": ">=6.9.0"
51
+ }
52
+ },
53
+ "node_modules/@babel/compat-data": {
54
+ "version": "7.29.0",
55
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
56
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
57
+ "dev": true,
58
+ "license": "MIT",
59
+ "engines": {
60
+ "node": ">=6.9.0"
61
+ }
62
+ },
63
+ "node_modules/@babel/core": {
64
+ "version": "7.29.0",
65
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
66
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
67
+ "dev": true,
68
+ "license": "MIT",
69
+ "dependencies": {
70
+ "@babel/code-frame": "^7.29.0",
71
+ "@babel/generator": "^7.29.0",
72
+ "@babel/helper-compilation-targets": "^7.28.6",
73
+ "@babel/helper-module-transforms": "^7.28.6",
74
+ "@babel/helpers": "^7.28.6",
75
+ "@babel/parser": "^7.29.0",
76
+ "@babel/template": "^7.28.6",
77
+ "@babel/traverse": "^7.29.0",
78
+ "@babel/types": "^7.29.0",
79
+ "@jridgewell/remapping": "^2.3.5",
80
+ "convert-source-map": "^2.0.0",
81
+ "debug": "^4.1.0",
82
+ "gensync": "^1.0.0-beta.2",
83
+ "json5": "^2.2.3",
84
+ "semver": "^6.3.1"
85
+ },
86
+ "engines": {
87
+ "node": ">=6.9.0"
88
+ },
89
+ "funding": {
90
+ "type": "opencollective",
91
+ "url": "https://opencollective.com/babel"
92
+ }
93
+ },
94
+ "node_modules/@babel/generator": {
95
+ "version": "7.29.1",
96
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
97
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
98
+ "dev": true,
99
+ "license": "MIT",
100
+ "dependencies": {
101
+ "@babel/parser": "^7.29.0",
102
+ "@babel/types": "^7.29.0",
103
+ "@jridgewell/gen-mapping": "^0.3.12",
104
+ "@jridgewell/trace-mapping": "^0.3.28",
105
+ "jsesc": "^3.0.2"
106
+ },
107
+ "engines": {
108
+ "node": ">=6.9.0"
109
+ }
110
+ },
111
+ "node_modules/@babel/helper-compilation-targets": {
112
+ "version": "7.28.6",
113
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
114
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
115
+ "dev": true,
116
+ "license": "MIT",
117
+ "dependencies": {
118
+ "@babel/compat-data": "^7.28.6",
119
+ "@babel/helper-validator-option": "^7.27.1",
120
+ "browserslist": "^4.24.0",
121
+ "lru-cache": "^5.1.1",
122
+ "semver": "^6.3.1"
123
+ },
124
+ "engines": {
125
+ "node": ">=6.9.0"
126
+ }
127
+ },
128
+ "node_modules/@babel/helper-globals": {
129
+ "version": "7.28.0",
130
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
131
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
132
+ "dev": true,
133
+ "license": "MIT",
134
+ "engines": {
135
+ "node": ">=6.9.0"
136
+ }
137
+ },
138
+ "node_modules/@babel/helper-module-imports": {
139
+ "version": "7.28.6",
140
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
141
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
142
+ "dev": true,
143
+ "license": "MIT",
144
+ "dependencies": {
145
+ "@babel/traverse": "^7.28.6",
146
+ "@babel/types": "^7.28.6"
147
+ },
148
+ "engines": {
149
+ "node": ">=6.9.0"
150
+ }
151
+ },
152
+ "node_modules/@babel/helper-module-transforms": {
153
+ "version": "7.28.6",
154
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
155
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
156
+ "dev": true,
157
+ "license": "MIT",
158
+ "dependencies": {
159
+ "@babel/helper-module-imports": "^7.28.6",
160
+ "@babel/helper-validator-identifier": "^7.28.5",
161
+ "@babel/traverse": "^7.28.6"
162
+ },
163
+ "engines": {
164
+ "node": ">=6.9.0"
165
+ },
166
+ "peerDependencies": {
167
+ "@babel/core": "^7.0.0"
168
+ }
169
+ },
170
+ "node_modules/@babel/helper-plugin-utils": {
171
+ "version": "7.28.6",
172
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
173
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
174
+ "dev": true,
175
+ "license": "MIT",
176
+ "engines": {
177
+ "node": ">=6.9.0"
178
+ }
179
+ },
180
+ "node_modules/@babel/helper-string-parser": {
181
+ "version": "7.27.1",
182
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
183
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
184
+ "dev": true,
185
+ "license": "MIT",
186
+ "engines": {
187
+ "node": ">=6.9.0"
188
+ }
189
+ },
190
+ "node_modules/@babel/helper-validator-identifier": {
191
+ "version": "7.28.5",
192
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
193
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
194
+ "dev": true,
195
+ "license": "MIT",
196
+ "engines": {
197
+ "node": ">=6.9.0"
198
+ }
199
+ },
200
+ "node_modules/@babel/helper-validator-option": {
201
+ "version": "7.27.1",
202
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
203
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
204
+ "dev": true,
205
+ "license": "MIT",
206
+ "engines": {
207
+ "node": ">=6.9.0"
208
+ }
209
+ },
210
+ "node_modules/@babel/helpers": {
211
+ "version": "7.28.6",
212
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
213
+ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
214
+ "dev": true,
215
+ "license": "MIT",
216
+ "dependencies": {
217
+ "@babel/template": "^7.28.6",
218
+ "@babel/types": "^7.28.6"
219
+ },
220
+ "engines": {
221
+ "node": ">=6.9.0"
222
+ }
223
+ },
224
+ "node_modules/@babel/parser": {
225
+ "version": "7.29.0",
226
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
227
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
228
+ "dev": true,
229
+ "license": "MIT",
230
+ "dependencies": {
231
+ "@babel/types": "^7.29.0"
232
+ },
233
+ "bin": {
234
+ "parser": "bin/babel-parser.js"
235
+ },
236
+ "engines": {
237
+ "node": ">=6.0.0"
238
+ }
239
+ },
240
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
241
+ "version": "7.27.1",
242
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
243
+ "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
244
+ "dev": true,
245
+ "license": "MIT",
246
+ "dependencies": {
247
+ "@babel/helper-plugin-utils": "^7.27.1"
248
+ },
249
+ "engines": {
250
+ "node": ">=6.9.0"
251
+ },
252
+ "peerDependencies": {
253
+ "@babel/core": "^7.0.0-0"
254
+ }
255
+ },
256
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
257
+ "version": "7.27.1",
258
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
259
+ "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
260
+ "dev": true,
261
+ "license": "MIT",
262
+ "dependencies": {
263
+ "@babel/helper-plugin-utils": "^7.27.1"
264
+ },
265
+ "engines": {
266
+ "node": ">=6.9.0"
267
+ },
268
+ "peerDependencies": {
269
+ "@babel/core": "^7.0.0-0"
270
+ }
271
+ },
272
+ "node_modules/@babel/template": {
273
+ "version": "7.28.6",
274
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
275
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
276
+ "dev": true,
277
+ "license": "MIT",
278
+ "dependencies": {
279
+ "@babel/code-frame": "^7.28.6",
280
+ "@babel/parser": "^7.28.6",
281
+ "@babel/types": "^7.28.6"
282
+ },
283
+ "engines": {
284
+ "node": ">=6.9.0"
285
+ }
286
+ },
287
+ "node_modules/@babel/traverse": {
288
+ "version": "7.29.0",
289
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
290
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
291
+ "dev": true,
292
+ "license": "MIT",
293
+ "dependencies": {
294
+ "@babel/code-frame": "^7.29.0",
295
+ "@babel/generator": "^7.29.0",
296
+ "@babel/helper-globals": "^7.28.0",
297
+ "@babel/parser": "^7.29.0",
298
+ "@babel/template": "^7.28.6",
299
+ "@babel/types": "^7.29.0",
300
+ "debug": "^4.3.1"
301
+ },
302
+ "engines": {
303
+ "node": ">=6.9.0"
304
+ }
305
+ },
306
+ "node_modules/@babel/types": {
307
+ "version": "7.29.0",
308
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
309
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
310
+ "dev": true,
311
+ "license": "MIT",
312
+ "dependencies": {
313
+ "@babel/helper-string-parser": "^7.27.1",
314
+ "@babel/helper-validator-identifier": "^7.28.5"
315
+ },
316
+ "engines": {
317
+ "node": ">=6.9.0"
318
+ }
319
+ },
320
+ "node_modules/@esbuild/aix-ppc64": {
321
+ "version": "0.25.12",
322
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
323
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
324
+ "cpu": [
325
+ "ppc64"
326
+ ],
327
+ "dev": true,
328
+ "license": "MIT",
329
+ "optional": true,
330
+ "os": [
331
+ "aix"
332
+ ],
333
+ "engines": {
334
+ "node": ">=18"
335
+ }
336
+ },
337
+ "node_modules/@esbuild/android-arm": {
338
+ "version": "0.25.12",
339
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
340
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
341
+ "cpu": [
342
+ "arm"
343
+ ],
344
+ "dev": true,
345
+ "license": "MIT",
346
+ "optional": true,
347
+ "os": [
348
+ "android"
349
+ ],
350
+ "engines": {
351
+ "node": ">=18"
352
+ }
353
+ },
354
+ "node_modules/@esbuild/android-arm64": {
355
+ "version": "0.25.12",
356
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
357
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
358
+ "cpu": [
359
+ "arm64"
360
+ ],
361
+ "dev": true,
362
+ "license": "MIT",
363
+ "optional": true,
364
+ "os": [
365
+ "android"
366
+ ],
367
+ "engines": {
368
+ "node": ">=18"
369
+ }
370
+ },
371
+ "node_modules/@esbuild/android-x64": {
372
+ "version": "0.25.12",
373
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
374
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
375
+ "cpu": [
376
+ "x64"
377
+ ],
378
+ "dev": true,
379
+ "license": "MIT",
380
+ "optional": true,
381
+ "os": [
382
+ "android"
383
+ ],
384
+ "engines": {
385
+ "node": ">=18"
386
+ }
387
+ },
388
+ "node_modules/@esbuild/darwin-arm64": {
389
+ "version": "0.25.12",
390
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
391
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
392
+ "cpu": [
393
+ "arm64"
394
+ ],
395
+ "dev": true,
396
+ "license": "MIT",
397
+ "optional": true,
398
+ "os": [
399
+ "darwin"
400
+ ],
401
+ "engines": {
402
+ "node": ">=18"
403
+ }
404
+ },
405
+ "node_modules/@esbuild/darwin-x64": {
406
+ "version": "0.25.12",
407
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
408
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
409
+ "cpu": [
410
+ "x64"
411
+ ],
412
+ "dev": true,
413
+ "license": "MIT",
414
+ "optional": true,
415
+ "os": [
416
+ "darwin"
417
+ ],
418
+ "engines": {
419
+ "node": ">=18"
420
+ }
421
+ },
422
+ "node_modules/@esbuild/freebsd-arm64": {
423
+ "version": "0.25.12",
424
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
425
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
426
+ "cpu": [
427
+ "arm64"
428
+ ],
429
+ "dev": true,
430
+ "license": "MIT",
431
+ "optional": true,
432
+ "os": [
433
+ "freebsd"
434
+ ],
435
+ "engines": {
436
+ "node": ">=18"
437
+ }
438
+ },
439
+ "node_modules/@esbuild/freebsd-x64": {
440
+ "version": "0.25.12",
441
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
442
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
443
+ "cpu": [
444
+ "x64"
445
+ ],
446
+ "dev": true,
447
+ "license": "MIT",
448
+ "optional": true,
449
+ "os": [
450
+ "freebsd"
451
+ ],
452
+ "engines": {
453
+ "node": ">=18"
454
+ }
455
+ },
456
+ "node_modules/@esbuild/linux-arm": {
457
+ "version": "0.25.12",
458
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
459
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
460
+ "cpu": [
461
+ "arm"
462
+ ],
463
+ "dev": true,
464
+ "license": "MIT",
465
+ "optional": true,
466
+ "os": [
467
+ "linux"
468
+ ],
469
+ "engines": {
470
+ "node": ">=18"
471
+ }
472
+ },
473
+ "node_modules/@esbuild/linux-arm64": {
474
+ "version": "0.25.12",
475
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
476
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
477
+ "cpu": [
478
+ "arm64"
479
+ ],
480
+ "dev": true,
481
+ "license": "MIT",
482
+ "optional": true,
483
+ "os": [
484
+ "linux"
485
+ ],
486
+ "engines": {
487
+ "node": ">=18"
488
+ }
489
+ },
490
+ "node_modules/@esbuild/linux-ia32": {
491
+ "version": "0.25.12",
492
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
493
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
494
+ "cpu": [
495
+ "ia32"
496
+ ],
497
+ "dev": true,
498
+ "license": "MIT",
499
+ "optional": true,
500
+ "os": [
501
+ "linux"
502
+ ],
503
+ "engines": {
504
+ "node": ">=18"
505
+ }
506
+ },
507
+ "node_modules/@esbuild/linux-loong64": {
508
+ "version": "0.25.12",
509
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
510
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
511
+ "cpu": [
512
+ "loong64"
513
+ ],
514
+ "dev": true,
515
+ "license": "MIT",
516
+ "optional": true,
517
+ "os": [
518
+ "linux"
519
+ ],
520
+ "engines": {
521
+ "node": ">=18"
522
+ }
523
+ },
524
+ "node_modules/@esbuild/linux-mips64el": {
525
+ "version": "0.25.12",
526
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
527
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
528
+ "cpu": [
529
+ "mips64el"
530
+ ],
531
+ "dev": true,
532
+ "license": "MIT",
533
+ "optional": true,
534
+ "os": [
535
+ "linux"
536
+ ],
537
+ "engines": {
538
+ "node": ">=18"
539
+ }
540
+ },
541
+ "node_modules/@esbuild/linux-ppc64": {
542
+ "version": "0.25.12",
543
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
544
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
545
+ "cpu": [
546
+ "ppc64"
547
+ ],
548
+ "dev": true,
549
+ "license": "MIT",
550
+ "optional": true,
551
+ "os": [
552
+ "linux"
553
+ ],
554
+ "engines": {
555
+ "node": ">=18"
556
+ }
557
+ },
558
+ "node_modules/@esbuild/linux-riscv64": {
559
+ "version": "0.25.12",
560
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
561
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
562
+ "cpu": [
563
+ "riscv64"
564
+ ],
565
+ "dev": true,
566
+ "license": "MIT",
567
+ "optional": true,
568
+ "os": [
569
+ "linux"
570
+ ],
571
+ "engines": {
572
+ "node": ">=18"
573
+ }
574
+ },
575
+ "node_modules/@esbuild/linux-s390x": {
576
+ "version": "0.25.12",
577
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
578
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
579
+ "cpu": [
580
+ "s390x"
581
+ ],
582
+ "dev": true,
583
+ "license": "MIT",
584
+ "optional": true,
585
+ "os": [
586
+ "linux"
587
+ ],
588
+ "engines": {
589
+ "node": ">=18"
590
+ }
591
+ },
592
+ "node_modules/@esbuild/linux-x64": {
593
+ "version": "0.25.12",
594
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
595
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
596
+ "cpu": [
597
+ "x64"
598
+ ],
599
+ "dev": true,
600
+ "license": "MIT",
601
+ "optional": true,
602
+ "os": [
603
+ "linux"
604
+ ],
605
+ "engines": {
606
+ "node": ">=18"
607
+ }
608
+ },
609
+ "node_modules/@esbuild/netbsd-arm64": {
610
+ "version": "0.25.12",
611
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
612
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
613
+ "cpu": [
614
+ "arm64"
615
+ ],
616
+ "dev": true,
617
+ "license": "MIT",
618
+ "optional": true,
619
+ "os": [
620
+ "netbsd"
621
+ ],
622
+ "engines": {
623
+ "node": ">=18"
624
+ }
625
+ },
626
+ "node_modules/@esbuild/netbsd-x64": {
627
+ "version": "0.25.12",
628
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
629
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
630
+ "cpu": [
631
+ "x64"
632
+ ],
633
+ "dev": true,
634
+ "license": "MIT",
635
+ "optional": true,
636
+ "os": [
637
+ "netbsd"
638
+ ],
639
+ "engines": {
640
+ "node": ">=18"
641
+ }
642
+ },
643
+ "node_modules/@esbuild/openbsd-arm64": {
644
+ "version": "0.25.12",
645
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
646
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
647
+ "cpu": [
648
+ "arm64"
649
+ ],
650
+ "dev": true,
651
+ "license": "MIT",
652
+ "optional": true,
653
+ "os": [
654
+ "openbsd"
655
+ ],
656
+ "engines": {
657
+ "node": ">=18"
658
+ }
659
+ },
660
+ "node_modules/@esbuild/openbsd-x64": {
661
+ "version": "0.25.12",
662
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
663
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
664
+ "cpu": [
665
+ "x64"
666
+ ],
667
+ "dev": true,
668
+ "license": "MIT",
669
+ "optional": true,
670
+ "os": [
671
+ "openbsd"
672
+ ],
673
+ "engines": {
674
+ "node": ">=18"
675
+ }
676
+ },
677
+ "node_modules/@esbuild/openharmony-arm64": {
678
+ "version": "0.25.12",
679
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
680
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
681
+ "cpu": [
682
+ "arm64"
683
+ ],
684
+ "dev": true,
685
+ "license": "MIT",
686
+ "optional": true,
687
+ "os": [
688
+ "openharmony"
689
+ ],
690
+ "engines": {
691
+ "node": ">=18"
692
+ }
693
+ },
694
+ "node_modules/@esbuild/sunos-x64": {
695
+ "version": "0.25.12",
696
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
697
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
698
+ "cpu": [
699
+ "x64"
700
+ ],
701
+ "dev": true,
702
+ "license": "MIT",
703
+ "optional": true,
704
+ "os": [
705
+ "sunos"
706
+ ],
707
+ "engines": {
708
+ "node": ">=18"
709
+ }
710
+ },
711
+ "node_modules/@esbuild/win32-arm64": {
712
+ "version": "0.25.12",
713
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
714
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
715
+ "cpu": [
716
+ "arm64"
717
+ ],
718
+ "dev": true,
719
+ "license": "MIT",
720
+ "optional": true,
721
+ "os": [
722
+ "win32"
723
+ ],
724
+ "engines": {
725
+ "node": ">=18"
726
+ }
727
+ },
728
+ "node_modules/@esbuild/win32-ia32": {
729
+ "version": "0.25.12",
730
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
731
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
732
+ "cpu": [
733
+ "ia32"
734
+ ],
735
+ "dev": true,
736
+ "license": "MIT",
737
+ "optional": true,
738
+ "os": [
739
+ "win32"
740
+ ],
741
+ "engines": {
742
+ "node": ">=18"
743
+ }
744
+ },
745
+ "node_modules/@esbuild/win32-x64": {
746
+ "version": "0.25.12",
747
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
748
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
749
+ "cpu": [
750
+ "x64"
751
+ ],
752
+ "dev": true,
753
+ "license": "MIT",
754
+ "optional": true,
755
+ "os": [
756
+ "win32"
757
+ ],
758
+ "engines": {
759
+ "node": ">=18"
760
+ }
761
+ },
762
+ "node_modules/@jridgewell/gen-mapping": {
763
+ "version": "0.3.13",
764
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
765
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
766
+ "dev": true,
767
+ "license": "MIT",
768
+ "dependencies": {
769
+ "@jridgewell/sourcemap-codec": "^1.5.0",
770
+ "@jridgewell/trace-mapping": "^0.3.24"
771
+ }
772
+ },
773
+ "node_modules/@jridgewell/remapping": {
774
+ "version": "2.3.5",
775
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
776
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
777
+ "dev": true,
778
+ "license": "MIT",
779
+ "dependencies": {
780
+ "@jridgewell/gen-mapping": "^0.3.5",
781
+ "@jridgewell/trace-mapping": "^0.3.24"
782
+ }
783
+ },
784
+ "node_modules/@jridgewell/resolve-uri": {
785
+ "version": "3.1.2",
786
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
787
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
788
+ "dev": true,
789
+ "license": "MIT",
790
+ "engines": {
791
+ "node": ">=6.0.0"
792
+ }
793
+ },
794
+ "node_modules/@jridgewell/sourcemap-codec": {
795
+ "version": "1.5.5",
796
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
797
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
798
+ "dev": true,
799
+ "license": "MIT"
800
+ },
801
+ "node_modules/@jridgewell/trace-mapping": {
802
+ "version": "0.3.31",
803
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
804
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
805
+ "dev": true,
806
+ "license": "MIT",
807
+ "dependencies": {
808
+ "@jridgewell/resolve-uri": "^3.1.0",
809
+ "@jridgewell/sourcemap-codec": "^1.4.14"
810
+ }
811
+ },
812
+ "node_modules/@nodelib/fs.scandir": {
813
+ "version": "2.1.5",
814
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
815
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
816
+ "dev": true,
817
+ "license": "MIT",
818
+ "dependencies": {
819
+ "@nodelib/fs.stat": "2.0.5",
820
+ "run-parallel": "^1.1.9"
821
+ },
822
+ "engines": {
823
+ "node": ">= 8"
824
+ }
825
+ },
826
+ "node_modules/@nodelib/fs.stat": {
827
+ "version": "2.0.5",
828
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
829
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
830
+ "dev": true,
831
+ "license": "MIT",
832
+ "engines": {
833
+ "node": ">= 8"
834
+ }
835
+ },
836
+ "node_modules/@nodelib/fs.walk": {
837
+ "version": "1.2.8",
838
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
839
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
840
+ "dev": true,
841
+ "license": "MIT",
842
+ "dependencies": {
843
+ "@nodelib/fs.scandir": "2.1.5",
844
+ "fastq": "^1.6.0"
845
+ },
846
+ "engines": {
847
+ "node": ">= 8"
848
+ }
849
+ },
850
+ "node_modules/@rolldown/pluginutils": {
851
+ "version": "1.0.0-beta.27",
852
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
853
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
854
+ "dev": true,
855
+ "license": "MIT"
856
+ },
857
+ "node_modules/@rollup/rollup-android-arm-eabi": {
858
+ "version": "4.58.0",
859
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz",
860
+ "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==",
861
+ "cpu": [
862
+ "arm"
863
+ ],
864
+ "dev": true,
865
+ "license": "MIT",
866
+ "optional": true,
867
+ "os": [
868
+ "android"
869
+ ]
870
+ },
871
+ "node_modules/@rollup/rollup-android-arm64": {
872
+ "version": "4.58.0",
873
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz",
874
+ "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==",
875
+ "cpu": [
876
+ "arm64"
877
+ ],
878
+ "dev": true,
879
+ "license": "MIT",
880
+ "optional": true,
881
+ "os": [
882
+ "android"
883
+ ]
884
+ },
885
+ "node_modules/@rollup/rollup-darwin-arm64": {
886
+ "version": "4.58.0",
887
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz",
888
+ "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==",
889
+ "cpu": [
890
+ "arm64"
891
+ ],
892
+ "dev": true,
893
+ "license": "MIT",
894
+ "optional": true,
895
+ "os": [
896
+ "darwin"
897
+ ]
898
+ },
899
+ "node_modules/@rollup/rollup-darwin-x64": {
900
+ "version": "4.58.0",
901
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz",
902
+ "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==",
903
+ "cpu": [
904
+ "x64"
905
+ ],
906
+ "dev": true,
907
+ "license": "MIT",
908
+ "optional": true,
909
+ "os": [
910
+ "darwin"
911
+ ]
912
+ },
913
+ "node_modules/@rollup/rollup-freebsd-arm64": {
914
+ "version": "4.58.0",
915
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz",
916
+ "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==",
917
+ "cpu": [
918
+ "arm64"
919
+ ],
920
+ "dev": true,
921
+ "license": "MIT",
922
+ "optional": true,
923
+ "os": [
924
+ "freebsd"
925
+ ]
926
+ },
927
+ "node_modules/@rollup/rollup-freebsd-x64": {
928
+ "version": "4.58.0",
929
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz",
930
+ "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==",
931
+ "cpu": [
932
+ "x64"
933
+ ],
934
+ "dev": true,
935
+ "license": "MIT",
936
+ "optional": true,
937
+ "os": [
938
+ "freebsd"
939
+ ]
940
+ },
941
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
942
+ "version": "4.58.0",
943
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz",
944
+ "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==",
945
+ "cpu": [
946
+ "arm"
947
+ ],
948
+ "dev": true,
949
+ "license": "MIT",
950
+ "optional": true,
951
+ "os": [
952
+ "linux"
953
+ ]
954
+ },
955
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
956
+ "version": "4.58.0",
957
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz",
958
+ "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==",
959
+ "cpu": [
960
+ "arm"
961
+ ],
962
+ "dev": true,
963
+ "license": "MIT",
964
+ "optional": true,
965
+ "os": [
966
+ "linux"
967
+ ]
968
+ },
969
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
970
+ "version": "4.58.0",
971
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz",
972
+ "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==",
973
+ "cpu": [
974
+ "arm64"
975
+ ],
976
+ "dev": true,
977
+ "license": "MIT",
978
+ "optional": true,
979
+ "os": [
980
+ "linux"
981
+ ]
982
+ },
983
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
984
+ "version": "4.58.0",
985
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz",
986
+ "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==",
987
+ "cpu": [
988
+ "arm64"
989
+ ],
990
+ "dev": true,
991
+ "license": "MIT",
992
+ "optional": true,
993
+ "os": [
994
+ "linux"
995
+ ]
996
+ },
997
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
998
+ "version": "4.58.0",
999
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz",
1000
+ "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==",
1001
+ "cpu": [
1002
+ "loong64"
1003
+ ],
1004
+ "dev": true,
1005
+ "license": "MIT",
1006
+ "optional": true,
1007
+ "os": [
1008
+ "linux"
1009
+ ]
1010
+ },
1011
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
1012
+ "version": "4.58.0",
1013
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz",
1014
+ "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==",
1015
+ "cpu": [
1016
+ "loong64"
1017
+ ],
1018
+ "dev": true,
1019
+ "license": "MIT",
1020
+ "optional": true,
1021
+ "os": [
1022
+ "linux"
1023
+ ]
1024
+ },
1025
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
1026
+ "version": "4.58.0",
1027
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz",
1028
+ "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==",
1029
+ "cpu": [
1030
+ "ppc64"
1031
+ ],
1032
+ "dev": true,
1033
+ "license": "MIT",
1034
+ "optional": true,
1035
+ "os": [
1036
+ "linux"
1037
+ ]
1038
+ },
1039
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
1040
+ "version": "4.58.0",
1041
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz",
1042
+ "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==",
1043
+ "cpu": [
1044
+ "ppc64"
1045
+ ],
1046
+ "dev": true,
1047
+ "license": "MIT",
1048
+ "optional": true,
1049
+ "os": [
1050
+ "linux"
1051
+ ]
1052
+ },
1053
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1054
+ "version": "4.58.0",
1055
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz",
1056
+ "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==",
1057
+ "cpu": [
1058
+ "riscv64"
1059
+ ],
1060
+ "dev": true,
1061
+ "license": "MIT",
1062
+ "optional": true,
1063
+ "os": [
1064
+ "linux"
1065
+ ]
1066
+ },
1067
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
1068
+ "version": "4.58.0",
1069
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz",
1070
+ "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==",
1071
+ "cpu": [
1072
+ "riscv64"
1073
+ ],
1074
+ "dev": true,
1075
+ "license": "MIT",
1076
+ "optional": true,
1077
+ "os": [
1078
+ "linux"
1079
+ ]
1080
+ },
1081
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
1082
+ "version": "4.58.0",
1083
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz",
1084
+ "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==",
1085
+ "cpu": [
1086
+ "s390x"
1087
+ ],
1088
+ "dev": true,
1089
+ "license": "MIT",
1090
+ "optional": true,
1091
+ "os": [
1092
+ "linux"
1093
+ ]
1094
+ },
1095
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1096
+ "version": "4.58.0",
1097
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz",
1098
+ "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==",
1099
+ "cpu": [
1100
+ "x64"
1101
+ ],
1102
+ "dev": true,
1103
+ "license": "MIT",
1104
+ "optional": true,
1105
+ "os": [
1106
+ "linux"
1107
+ ]
1108
+ },
1109
+ "node_modules/@rollup/rollup-linux-x64-musl": {
1110
+ "version": "4.58.0",
1111
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz",
1112
+ "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==",
1113
+ "cpu": [
1114
+ "x64"
1115
+ ],
1116
+ "dev": true,
1117
+ "license": "MIT",
1118
+ "optional": true,
1119
+ "os": [
1120
+ "linux"
1121
+ ]
1122
+ },
1123
+ "node_modules/@rollup/rollup-openbsd-x64": {
1124
+ "version": "4.58.0",
1125
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz",
1126
+ "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==",
1127
+ "cpu": [
1128
+ "x64"
1129
+ ],
1130
+ "dev": true,
1131
+ "license": "MIT",
1132
+ "optional": true,
1133
+ "os": [
1134
+ "openbsd"
1135
+ ]
1136
+ },
1137
+ "node_modules/@rollup/rollup-openharmony-arm64": {
1138
+ "version": "4.58.0",
1139
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz",
1140
+ "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==",
1141
+ "cpu": [
1142
+ "arm64"
1143
+ ],
1144
+ "dev": true,
1145
+ "license": "MIT",
1146
+ "optional": true,
1147
+ "os": [
1148
+ "openharmony"
1149
+ ]
1150
+ },
1151
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1152
+ "version": "4.58.0",
1153
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz",
1154
+ "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==",
1155
+ "cpu": [
1156
+ "arm64"
1157
+ ],
1158
+ "dev": true,
1159
+ "license": "MIT",
1160
+ "optional": true,
1161
+ "os": [
1162
+ "win32"
1163
+ ]
1164
+ },
1165
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1166
+ "version": "4.58.0",
1167
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz",
1168
+ "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==",
1169
+ "cpu": [
1170
+ "ia32"
1171
+ ],
1172
+ "dev": true,
1173
+ "license": "MIT",
1174
+ "optional": true,
1175
+ "os": [
1176
+ "win32"
1177
+ ]
1178
+ },
1179
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1180
+ "version": "4.58.0",
1181
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz",
1182
+ "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==",
1183
+ "cpu": [
1184
+ "x64"
1185
+ ],
1186
+ "dev": true,
1187
+ "license": "MIT",
1188
+ "optional": true,
1189
+ "os": [
1190
+ "win32"
1191
+ ]
1192
+ },
1193
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1194
+ "version": "4.58.0",
1195
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz",
1196
+ "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==",
1197
+ "cpu": [
1198
+ "x64"
1199
+ ],
1200
+ "dev": true,
1201
+ "license": "MIT",
1202
+ "optional": true,
1203
+ "os": [
1204
+ "win32"
1205
+ ]
1206
+ },
1207
+ "node_modules/@types/babel__core": {
1208
+ "version": "7.20.5",
1209
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1210
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1211
+ "dev": true,
1212
+ "license": "MIT",
1213
+ "dependencies": {
1214
+ "@babel/parser": "^7.20.7",
1215
+ "@babel/types": "^7.20.7",
1216
+ "@types/babel__generator": "*",
1217
+ "@types/babel__template": "*",
1218
+ "@types/babel__traverse": "*"
1219
+ }
1220
+ },
1221
+ "node_modules/@types/babel__generator": {
1222
+ "version": "7.27.0",
1223
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1224
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1225
+ "dev": true,
1226
+ "license": "MIT",
1227
+ "dependencies": {
1228
+ "@babel/types": "^7.0.0"
1229
+ }
1230
+ },
1231
+ "node_modules/@types/babel__template": {
1232
+ "version": "7.4.4",
1233
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1234
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1235
+ "dev": true,
1236
+ "license": "MIT",
1237
+ "dependencies": {
1238
+ "@babel/parser": "^7.1.0",
1239
+ "@babel/types": "^7.0.0"
1240
+ }
1241
+ },
1242
+ "node_modules/@types/babel__traverse": {
1243
+ "version": "7.28.0",
1244
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1245
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1246
+ "dev": true,
1247
+ "license": "MIT",
1248
+ "dependencies": {
1249
+ "@babel/types": "^7.28.2"
1250
+ }
1251
+ },
1252
+ "node_modules/@types/estree": {
1253
+ "version": "1.0.8",
1254
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
1255
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
1256
+ "dev": true,
1257
+ "license": "MIT"
1258
+ },
1259
+ "node_modules/@types/prop-types": {
1260
+ "version": "15.7.15",
1261
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1262
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1263
+ "dev": true,
1264
+ "license": "MIT"
1265
+ },
1266
+ "node_modules/@types/react": {
1267
+ "version": "18.3.28",
1268
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
1269
+ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
1270
+ "dev": true,
1271
+ "license": "MIT",
1272
+ "dependencies": {
1273
+ "@types/prop-types": "*",
1274
+ "csstype": "^3.2.2"
1275
+ }
1276
+ },
1277
+ "node_modules/@types/react-dom": {
1278
+ "version": "18.3.7",
1279
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1280
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1281
+ "dev": true,
1282
+ "license": "MIT",
1283
+ "peerDependencies": {
1284
+ "@types/react": "^18.0.0"
1285
+ }
1286
+ },
1287
+ "node_modules/@vitejs/plugin-react": {
1288
+ "version": "4.7.0",
1289
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1290
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1291
+ "dev": true,
1292
+ "license": "MIT",
1293
+ "dependencies": {
1294
+ "@babel/core": "^7.28.0",
1295
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1296
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1297
+ "@rolldown/pluginutils": "1.0.0-beta.27",
1298
+ "@types/babel__core": "^7.20.5",
1299
+ "react-refresh": "^0.17.0"
1300
+ },
1301
+ "engines": {
1302
+ "node": "^14.18.0 || >=16.0.0"
1303
+ },
1304
+ "peerDependencies": {
1305
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1306
+ }
1307
+ },
1308
+ "node_modules/any-promise": {
1309
+ "version": "1.3.0",
1310
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
1311
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
1312
+ "dev": true,
1313
+ "license": "MIT"
1314
+ },
1315
+ "node_modules/anymatch": {
1316
+ "version": "3.1.3",
1317
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
1318
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
1319
+ "dev": true,
1320
+ "license": "ISC",
1321
+ "dependencies": {
1322
+ "normalize-path": "^3.0.0",
1323
+ "picomatch": "^2.0.4"
1324
+ },
1325
+ "engines": {
1326
+ "node": ">= 8"
1327
+ }
1328
+ },
1329
+ "node_modules/arg": {
1330
+ "version": "5.0.2",
1331
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
1332
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
1333
+ "dev": true,
1334
+ "license": "MIT"
1335
+ },
1336
+ "node_modules/autoprefixer": {
1337
+ "version": "10.4.24",
1338
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
1339
+ "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
1340
+ "dev": true,
1341
+ "funding": [
1342
+ {
1343
+ "type": "opencollective",
1344
+ "url": "https://opencollective.com/postcss/"
1345
+ },
1346
+ {
1347
+ "type": "tidelift",
1348
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
1349
+ },
1350
+ {
1351
+ "type": "github",
1352
+ "url": "https://github.com/sponsors/ai"
1353
+ }
1354
+ ],
1355
+ "license": "MIT",
1356
+ "dependencies": {
1357
+ "browserslist": "^4.28.1",
1358
+ "caniuse-lite": "^1.0.30001766",
1359
+ "fraction.js": "^5.3.4",
1360
+ "picocolors": "^1.1.1",
1361
+ "postcss-value-parser": "^4.2.0"
1362
+ },
1363
+ "bin": {
1364
+ "autoprefixer": "bin/autoprefixer"
1365
+ },
1366
+ "engines": {
1367
+ "node": "^10 || ^12 || >=14"
1368
+ },
1369
+ "peerDependencies": {
1370
+ "postcss": "^8.1.0"
1371
+ }
1372
+ },
1373
+ "node_modules/baseline-browser-mapping": {
1374
+ "version": "2.10.0",
1375
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
1376
+ "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
1377
+ "dev": true,
1378
+ "license": "Apache-2.0",
1379
+ "bin": {
1380
+ "baseline-browser-mapping": "dist/cli.cjs"
1381
+ },
1382
+ "engines": {
1383
+ "node": ">=6.0.0"
1384
+ }
1385
+ },
1386
+ "node_modules/binary-extensions": {
1387
+ "version": "2.3.0",
1388
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
1389
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
1390
+ "dev": true,
1391
+ "license": "MIT",
1392
+ "engines": {
1393
+ "node": ">=8"
1394
+ },
1395
+ "funding": {
1396
+ "url": "https://github.com/sponsors/sindresorhus"
1397
+ }
1398
+ },
1399
+ "node_modules/braces": {
1400
+ "version": "3.0.3",
1401
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
1402
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
1403
+ "dev": true,
1404
+ "license": "MIT",
1405
+ "dependencies": {
1406
+ "fill-range": "^7.1.1"
1407
+ },
1408
+ "engines": {
1409
+ "node": ">=8"
1410
+ }
1411
+ },
1412
+ "node_modules/browserslist": {
1413
+ "version": "4.28.1",
1414
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
1415
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
1416
+ "dev": true,
1417
+ "funding": [
1418
+ {
1419
+ "type": "opencollective",
1420
+ "url": "https://opencollective.com/browserslist"
1421
+ },
1422
+ {
1423
+ "type": "tidelift",
1424
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1425
+ },
1426
+ {
1427
+ "type": "github",
1428
+ "url": "https://github.com/sponsors/ai"
1429
+ }
1430
+ ],
1431
+ "license": "MIT",
1432
+ "dependencies": {
1433
+ "baseline-browser-mapping": "^2.9.0",
1434
+ "caniuse-lite": "^1.0.30001759",
1435
+ "electron-to-chromium": "^1.5.263",
1436
+ "node-releases": "^2.0.27",
1437
+ "update-browserslist-db": "^1.2.0"
1438
+ },
1439
+ "bin": {
1440
+ "browserslist": "cli.js"
1441
+ },
1442
+ "engines": {
1443
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1444
+ }
1445
+ },
1446
+ "node_modules/camelcase-css": {
1447
+ "version": "2.0.1",
1448
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
1449
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
1450
+ "dev": true,
1451
+ "license": "MIT",
1452
+ "engines": {
1453
+ "node": ">= 6"
1454
+ }
1455
+ },
1456
+ "node_modules/caniuse-lite": {
1457
+ "version": "1.0.30001770",
1458
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
1459
+ "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==",
1460
+ "dev": true,
1461
+ "funding": [
1462
+ {
1463
+ "type": "opencollective",
1464
+ "url": "https://opencollective.com/browserslist"
1465
+ },
1466
+ {
1467
+ "type": "tidelift",
1468
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1469
+ },
1470
+ {
1471
+ "type": "github",
1472
+ "url": "https://github.com/sponsors/ai"
1473
+ }
1474
+ ],
1475
+ "license": "CC-BY-4.0"
1476
+ },
1477
+ "node_modules/chokidar": {
1478
+ "version": "3.6.0",
1479
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
1480
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
1481
+ "dev": true,
1482
+ "license": "MIT",
1483
+ "dependencies": {
1484
+ "anymatch": "~3.1.2",
1485
+ "braces": "~3.0.2",
1486
+ "glob-parent": "~5.1.2",
1487
+ "is-binary-path": "~2.1.0",
1488
+ "is-glob": "~4.0.1",
1489
+ "normalize-path": "~3.0.0",
1490
+ "readdirp": "~3.6.0"
1491
+ },
1492
+ "engines": {
1493
+ "node": ">= 8.10.0"
1494
+ },
1495
+ "funding": {
1496
+ "url": "https://paulmillr.com/funding/"
1497
+ },
1498
+ "optionalDependencies": {
1499
+ "fsevents": "~2.3.2"
1500
+ }
1501
+ },
1502
+ "node_modules/chokidar/node_modules/glob-parent": {
1503
+ "version": "5.1.2",
1504
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1505
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1506
+ "dev": true,
1507
+ "license": "ISC",
1508
+ "dependencies": {
1509
+ "is-glob": "^4.0.1"
1510
+ },
1511
+ "engines": {
1512
+ "node": ">= 6"
1513
+ }
1514
+ },
1515
+ "node_modules/commander": {
1516
+ "version": "4.1.1",
1517
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
1518
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
1519
+ "dev": true,
1520
+ "license": "MIT",
1521
+ "engines": {
1522
+ "node": ">= 6"
1523
+ }
1524
+ },
1525
+ "node_modules/convert-source-map": {
1526
+ "version": "2.0.0",
1527
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1528
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1529
+ "dev": true,
1530
+ "license": "MIT"
1531
+ },
1532
+ "node_modules/cssesc": {
1533
+ "version": "3.0.0",
1534
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
1535
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
1536
+ "dev": true,
1537
+ "license": "MIT",
1538
+ "bin": {
1539
+ "cssesc": "bin/cssesc"
1540
+ },
1541
+ "engines": {
1542
+ "node": ">=4"
1543
+ }
1544
+ },
1545
+ "node_modules/csstype": {
1546
+ "version": "3.2.3",
1547
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1548
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1549
+ "dev": true,
1550
+ "license": "MIT"
1551
+ },
1552
+ "node_modules/debug": {
1553
+ "version": "4.4.3",
1554
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1555
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1556
+ "dev": true,
1557
+ "license": "MIT",
1558
+ "dependencies": {
1559
+ "ms": "^2.1.3"
1560
+ },
1561
+ "engines": {
1562
+ "node": ">=6.0"
1563
+ },
1564
+ "peerDependenciesMeta": {
1565
+ "supports-color": {
1566
+ "optional": true
1567
+ }
1568
+ }
1569
+ },
1570
+ "node_modules/didyoumean": {
1571
+ "version": "1.2.2",
1572
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
1573
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
1574
+ "dev": true,
1575
+ "license": "Apache-2.0"
1576
+ },
1577
+ "node_modules/dlv": {
1578
+ "version": "1.1.3",
1579
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
1580
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
1581
+ "dev": true,
1582
+ "license": "MIT"
1583
+ },
1584
+ "node_modules/electron-to-chromium": {
1585
+ "version": "1.5.302",
1586
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
1587
+ "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
1588
+ "dev": true,
1589
+ "license": "ISC"
1590
+ },
1591
+ "node_modules/esbuild": {
1592
+ "version": "0.25.12",
1593
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
1594
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
1595
+ "dev": true,
1596
+ "hasInstallScript": true,
1597
+ "license": "MIT",
1598
+ "bin": {
1599
+ "esbuild": "bin/esbuild"
1600
+ },
1601
+ "engines": {
1602
+ "node": ">=18"
1603
+ },
1604
+ "optionalDependencies": {
1605
+ "@esbuild/aix-ppc64": "0.25.12",
1606
+ "@esbuild/android-arm": "0.25.12",
1607
+ "@esbuild/android-arm64": "0.25.12",
1608
+ "@esbuild/android-x64": "0.25.12",
1609
+ "@esbuild/darwin-arm64": "0.25.12",
1610
+ "@esbuild/darwin-x64": "0.25.12",
1611
+ "@esbuild/freebsd-arm64": "0.25.12",
1612
+ "@esbuild/freebsd-x64": "0.25.12",
1613
+ "@esbuild/linux-arm": "0.25.12",
1614
+ "@esbuild/linux-arm64": "0.25.12",
1615
+ "@esbuild/linux-ia32": "0.25.12",
1616
+ "@esbuild/linux-loong64": "0.25.12",
1617
+ "@esbuild/linux-mips64el": "0.25.12",
1618
+ "@esbuild/linux-ppc64": "0.25.12",
1619
+ "@esbuild/linux-riscv64": "0.25.12",
1620
+ "@esbuild/linux-s390x": "0.25.12",
1621
+ "@esbuild/linux-x64": "0.25.12",
1622
+ "@esbuild/netbsd-arm64": "0.25.12",
1623
+ "@esbuild/netbsd-x64": "0.25.12",
1624
+ "@esbuild/openbsd-arm64": "0.25.12",
1625
+ "@esbuild/openbsd-x64": "0.25.12",
1626
+ "@esbuild/openharmony-arm64": "0.25.12",
1627
+ "@esbuild/sunos-x64": "0.25.12",
1628
+ "@esbuild/win32-arm64": "0.25.12",
1629
+ "@esbuild/win32-ia32": "0.25.12",
1630
+ "@esbuild/win32-x64": "0.25.12"
1631
+ }
1632
+ },
1633
+ "node_modules/escalade": {
1634
+ "version": "3.2.0",
1635
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1636
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1637
+ "dev": true,
1638
+ "license": "MIT",
1639
+ "engines": {
1640
+ "node": ">=6"
1641
+ }
1642
+ },
1643
+ "node_modules/fast-glob": {
1644
+ "version": "3.3.3",
1645
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
1646
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
1647
+ "dev": true,
1648
+ "license": "MIT",
1649
+ "dependencies": {
1650
+ "@nodelib/fs.stat": "^2.0.2",
1651
+ "@nodelib/fs.walk": "^1.2.3",
1652
+ "glob-parent": "^5.1.2",
1653
+ "merge2": "^1.3.0",
1654
+ "micromatch": "^4.0.8"
1655
+ },
1656
+ "engines": {
1657
+ "node": ">=8.6.0"
1658
+ }
1659
+ },
1660
+ "node_modules/fast-glob/node_modules/glob-parent": {
1661
+ "version": "5.1.2",
1662
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
1663
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
1664
+ "dev": true,
1665
+ "license": "ISC",
1666
+ "dependencies": {
1667
+ "is-glob": "^4.0.1"
1668
+ },
1669
+ "engines": {
1670
+ "node": ">= 6"
1671
+ }
1672
+ },
1673
+ "node_modules/fastq": {
1674
+ "version": "1.20.1",
1675
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
1676
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
1677
+ "dev": true,
1678
+ "license": "ISC",
1679
+ "dependencies": {
1680
+ "reusify": "^1.0.4"
1681
+ }
1682
+ },
1683
+ "node_modules/fill-range": {
1684
+ "version": "7.1.1",
1685
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
1686
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
1687
+ "dev": true,
1688
+ "license": "MIT",
1689
+ "dependencies": {
1690
+ "to-regex-range": "^5.0.1"
1691
+ },
1692
+ "engines": {
1693
+ "node": ">=8"
1694
+ }
1695
+ },
1696
+ "node_modules/fraction.js": {
1697
+ "version": "5.3.4",
1698
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
1699
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
1700
+ "dev": true,
1701
+ "license": "MIT",
1702
+ "engines": {
1703
+ "node": "*"
1704
+ },
1705
+ "funding": {
1706
+ "type": "github",
1707
+ "url": "https://github.com/sponsors/rawify"
1708
+ }
1709
+ },
1710
+ "node_modules/fsevents": {
1711
+ "version": "2.3.3",
1712
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1713
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1714
+ "dev": true,
1715
+ "hasInstallScript": true,
1716
+ "license": "MIT",
1717
+ "optional": true,
1718
+ "os": [
1719
+ "darwin"
1720
+ ],
1721
+ "engines": {
1722
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1723
+ }
1724
+ },
1725
+ "node_modules/function-bind": {
1726
+ "version": "1.1.2",
1727
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
1728
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
1729
+ "dev": true,
1730
+ "license": "MIT",
1731
+ "funding": {
1732
+ "url": "https://github.com/sponsors/ljharb"
1733
+ }
1734
+ },
1735
+ "node_modules/gensync": {
1736
+ "version": "1.0.0-beta.2",
1737
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1738
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1739
+ "dev": true,
1740
+ "license": "MIT",
1741
+ "engines": {
1742
+ "node": ">=6.9.0"
1743
+ }
1744
+ },
1745
+ "node_modules/glob-parent": {
1746
+ "version": "6.0.2",
1747
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
1748
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
1749
+ "dev": true,
1750
+ "license": "ISC",
1751
+ "dependencies": {
1752
+ "is-glob": "^4.0.3"
1753
+ },
1754
+ "engines": {
1755
+ "node": ">=10.13.0"
1756
+ }
1757
+ },
1758
+ "node_modules/hasown": {
1759
+ "version": "2.0.2",
1760
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
1761
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
1762
+ "dev": true,
1763
+ "license": "MIT",
1764
+ "dependencies": {
1765
+ "function-bind": "^1.1.2"
1766
+ },
1767
+ "engines": {
1768
+ "node": ">= 0.4"
1769
+ }
1770
+ },
1771
+ "node_modules/is-binary-path": {
1772
+ "version": "2.1.0",
1773
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
1774
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
1775
+ "dev": true,
1776
+ "license": "MIT",
1777
+ "dependencies": {
1778
+ "binary-extensions": "^2.0.0"
1779
+ },
1780
+ "engines": {
1781
+ "node": ">=8"
1782
+ }
1783
+ },
1784
+ "node_modules/is-core-module": {
1785
+ "version": "2.16.1",
1786
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
1787
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
1788
+ "dev": true,
1789
+ "license": "MIT",
1790
+ "dependencies": {
1791
+ "hasown": "^2.0.2"
1792
+ },
1793
+ "engines": {
1794
+ "node": ">= 0.4"
1795
+ },
1796
+ "funding": {
1797
+ "url": "https://github.com/sponsors/ljharb"
1798
+ }
1799
+ },
1800
+ "node_modules/is-extglob": {
1801
+ "version": "2.1.1",
1802
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
1803
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
1804
+ "dev": true,
1805
+ "license": "MIT",
1806
+ "engines": {
1807
+ "node": ">=0.10.0"
1808
+ }
1809
+ },
1810
+ "node_modules/is-glob": {
1811
+ "version": "4.0.3",
1812
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
1813
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
1814
+ "dev": true,
1815
+ "license": "MIT",
1816
+ "dependencies": {
1817
+ "is-extglob": "^2.1.1"
1818
+ },
1819
+ "engines": {
1820
+ "node": ">=0.10.0"
1821
+ }
1822
+ },
1823
+ "node_modules/is-number": {
1824
+ "version": "7.0.0",
1825
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
1826
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
1827
+ "dev": true,
1828
+ "license": "MIT",
1829
+ "engines": {
1830
+ "node": ">=0.12.0"
1831
+ }
1832
+ },
1833
+ "node_modules/jiti": {
1834
+ "version": "1.21.7",
1835
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
1836
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
1837
+ "dev": true,
1838
+ "license": "MIT",
1839
+ "bin": {
1840
+ "jiti": "bin/jiti.js"
1841
+ }
1842
+ },
1843
+ "node_modules/js-tokens": {
1844
+ "version": "4.0.0",
1845
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1846
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1847
+ "license": "MIT"
1848
+ },
1849
+ "node_modules/jsesc": {
1850
+ "version": "3.1.0",
1851
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1852
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1853
+ "dev": true,
1854
+ "license": "MIT",
1855
+ "bin": {
1856
+ "jsesc": "bin/jsesc"
1857
+ },
1858
+ "engines": {
1859
+ "node": ">=6"
1860
+ }
1861
+ },
1862
+ "node_modules/json5": {
1863
+ "version": "2.2.3",
1864
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1865
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1866
+ "dev": true,
1867
+ "license": "MIT",
1868
+ "bin": {
1869
+ "json5": "lib/cli.js"
1870
+ },
1871
+ "engines": {
1872
+ "node": ">=6"
1873
+ }
1874
+ },
1875
+ "node_modules/lilconfig": {
1876
+ "version": "3.1.3",
1877
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
1878
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
1879
+ "dev": true,
1880
+ "license": "MIT",
1881
+ "engines": {
1882
+ "node": ">=14"
1883
+ },
1884
+ "funding": {
1885
+ "url": "https://github.com/sponsors/antonk52"
1886
+ }
1887
+ },
1888
+ "node_modules/lines-and-columns": {
1889
+ "version": "1.2.4",
1890
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
1891
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
1892
+ "dev": true,
1893
+ "license": "MIT"
1894
+ },
1895
+ "node_modules/loose-envify": {
1896
+ "version": "1.4.0",
1897
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1898
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1899
+ "license": "MIT",
1900
+ "dependencies": {
1901
+ "js-tokens": "^3.0.0 || ^4.0.0"
1902
+ },
1903
+ "bin": {
1904
+ "loose-envify": "cli.js"
1905
+ }
1906
+ },
1907
+ "node_modules/lru-cache": {
1908
+ "version": "5.1.1",
1909
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1910
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1911
+ "dev": true,
1912
+ "license": "ISC",
1913
+ "dependencies": {
1914
+ "yallist": "^3.0.2"
1915
+ }
1916
+ },
1917
+ "node_modules/merge2": {
1918
+ "version": "1.4.1",
1919
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
1920
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
1921
+ "dev": true,
1922
+ "license": "MIT",
1923
+ "engines": {
1924
+ "node": ">= 8"
1925
+ }
1926
+ },
1927
+ "node_modules/micromatch": {
1928
+ "version": "4.0.8",
1929
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
1930
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
1931
+ "dev": true,
1932
+ "license": "MIT",
1933
+ "dependencies": {
1934
+ "braces": "^3.0.3",
1935
+ "picomatch": "^2.3.1"
1936
+ },
1937
+ "engines": {
1938
+ "node": ">=8.6"
1939
+ }
1940
+ },
1941
+ "node_modules/ms": {
1942
+ "version": "2.1.3",
1943
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1944
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1945
+ "dev": true,
1946
+ "license": "MIT"
1947
+ },
1948
+ "node_modules/mz": {
1949
+ "version": "2.7.0",
1950
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
1951
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
1952
+ "dev": true,
1953
+ "license": "MIT",
1954
+ "dependencies": {
1955
+ "any-promise": "^1.0.0",
1956
+ "object-assign": "^4.0.1",
1957
+ "thenify-all": "^1.0.0"
1958
+ }
1959
+ },
1960
+ "node_modules/nanoid": {
1961
+ "version": "3.3.11",
1962
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
1963
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
1964
+ "dev": true,
1965
+ "funding": [
1966
+ {
1967
+ "type": "github",
1968
+ "url": "https://github.com/sponsors/ai"
1969
+ }
1970
+ ],
1971
+ "license": "MIT",
1972
+ "bin": {
1973
+ "nanoid": "bin/nanoid.cjs"
1974
+ },
1975
+ "engines": {
1976
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1977
+ }
1978
+ },
1979
+ "node_modules/node-releases": {
1980
+ "version": "2.0.27",
1981
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
1982
+ "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
1983
+ "dev": true,
1984
+ "license": "MIT"
1985
+ },
1986
+ "node_modules/normalize-path": {
1987
+ "version": "3.0.0",
1988
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
1989
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
1990
+ "dev": true,
1991
+ "license": "MIT",
1992
+ "engines": {
1993
+ "node": ">=0.10.0"
1994
+ }
1995
+ },
1996
+ "node_modules/object-assign": {
1997
+ "version": "4.1.1",
1998
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1999
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
2000
+ "dev": true,
2001
+ "license": "MIT",
2002
+ "engines": {
2003
+ "node": ">=0.10.0"
2004
+ }
2005
+ },
2006
+ "node_modules/object-hash": {
2007
+ "version": "3.0.0",
2008
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
2009
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
2010
+ "dev": true,
2011
+ "license": "MIT",
2012
+ "engines": {
2013
+ "node": ">= 6"
2014
+ }
2015
+ },
2016
+ "node_modules/path-parse": {
2017
+ "version": "1.0.7",
2018
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
2019
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
2020
+ "dev": true,
2021
+ "license": "MIT"
2022
+ },
2023
+ "node_modules/picocolors": {
2024
+ "version": "1.1.1",
2025
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
2026
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
2027
+ "dev": true,
2028
+ "license": "ISC"
2029
+ },
2030
+ "node_modules/picomatch": {
2031
+ "version": "2.3.1",
2032
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
2033
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
2034
+ "dev": true,
2035
+ "license": "MIT",
2036
+ "engines": {
2037
+ "node": ">=8.6"
2038
+ },
2039
+ "funding": {
2040
+ "url": "https://github.com/sponsors/jonschlinkert"
2041
+ }
2042
+ },
2043
+ "node_modules/pify": {
2044
+ "version": "2.3.0",
2045
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
2046
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
2047
+ "dev": true,
2048
+ "license": "MIT",
2049
+ "engines": {
2050
+ "node": ">=0.10.0"
2051
+ }
2052
+ },
2053
+ "node_modules/pirates": {
2054
+ "version": "4.0.7",
2055
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
2056
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
2057
+ "dev": true,
2058
+ "license": "MIT",
2059
+ "engines": {
2060
+ "node": ">= 6"
2061
+ }
2062
+ },
2063
+ "node_modules/postcss": {
2064
+ "version": "8.5.6",
2065
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
2066
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
2067
+ "dev": true,
2068
+ "funding": [
2069
+ {
2070
+ "type": "opencollective",
2071
+ "url": "https://opencollective.com/postcss/"
2072
+ },
2073
+ {
2074
+ "type": "tidelift",
2075
+ "url": "https://tidelift.com/funding/github/npm/postcss"
2076
+ },
2077
+ {
2078
+ "type": "github",
2079
+ "url": "https://github.com/sponsors/ai"
2080
+ }
2081
+ ],
2082
+ "license": "MIT",
2083
+ "dependencies": {
2084
+ "nanoid": "^3.3.11",
2085
+ "picocolors": "^1.1.1",
2086
+ "source-map-js": "^1.2.1"
2087
+ },
2088
+ "engines": {
2089
+ "node": "^10 || ^12 || >=14"
2090
+ }
2091
+ },
2092
+ "node_modules/postcss-import": {
2093
+ "version": "15.1.0",
2094
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
2095
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
2096
+ "dev": true,
2097
+ "license": "MIT",
2098
+ "dependencies": {
2099
+ "postcss-value-parser": "^4.0.0",
2100
+ "read-cache": "^1.0.0",
2101
+ "resolve": "^1.1.7"
2102
+ },
2103
+ "engines": {
2104
+ "node": ">=14.0.0"
2105
+ },
2106
+ "peerDependencies": {
2107
+ "postcss": "^8.0.0"
2108
+ }
2109
+ },
2110
+ "node_modules/postcss-js": {
2111
+ "version": "4.1.0",
2112
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
2113
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
2114
+ "dev": true,
2115
+ "funding": [
2116
+ {
2117
+ "type": "opencollective",
2118
+ "url": "https://opencollective.com/postcss/"
2119
+ },
2120
+ {
2121
+ "type": "github",
2122
+ "url": "https://github.com/sponsors/ai"
2123
+ }
2124
+ ],
2125
+ "license": "MIT",
2126
+ "dependencies": {
2127
+ "camelcase-css": "^2.0.1"
2128
+ },
2129
+ "engines": {
2130
+ "node": "^12 || ^14 || >= 16"
2131
+ },
2132
+ "peerDependencies": {
2133
+ "postcss": "^8.4.21"
2134
+ }
2135
+ },
2136
+ "node_modules/postcss-load-config": {
2137
+ "version": "6.0.1",
2138
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
2139
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
2140
+ "dev": true,
2141
+ "funding": [
2142
+ {
2143
+ "type": "opencollective",
2144
+ "url": "https://opencollective.com/postcss/"
2145
+ },
2146
+ {
2147
+ "type": "github",
2148
+ "url": "https://github.com/sponsors/ai"
2149
+ }
2150
+ ],
2151
+ "license": "MIT",
2152
+ "dependencies": {
2153
+ "lilconfig": "^3.1.1"
2154
+ },
2155
+ "engines": {
2156
+ "node": ">= 18"
2157
+ },
2158
+ "peerDependencies": {
2159
+ "jiti": ">=1.21.0",
2160
+ "postcss": ">=8.0.9",
2161
+ "tsx": "^4.8.1",
2162
+ "yaml": "^2.4.2"
2163
+ },
2164
+ "peerDependenciesMeta": {
2165
+ "jiti": {
2166
+ "optional": true
2167
+ },
2168
+ "postcss": {
2169
+ "optional": true
2170
+ },
2171
+ "tsx": {
2172
+ "optional": true
2173
+ },
2174
+ "yaml": {
2175
+ "optional": true
2176
+ }
2177
+ }
2178
+ },
2179
+ "node_modules/postcss-nested": {
2180
+ "version": "6.2.0",
2181
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
2182
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
2183
+ "dev": true,
2184
+ "funding": [
2185
+ {
2186
+ "type": "opencollective",
2187
+ "url": "https://opencollective.com/postcss/"
2188
+ },
2189
+ {
2190
+ "type": "github",
2191
+ "url": "https://github.com/sponsors/ai"
2192
+ }
2193
+ ],
2194
+ "license": "MIT",
2195
+ "dependencies": {
2196
+ "postcss-selector-parser": "^6.1.1"
2197
+ },
2198
+ "engines": {
2199
+ "node": ">=12.0"
2200
+ },
2201
+ "peerDependencies": {
2202
+ "postcss": "^8.2.14"
2203
+ }
2204
+ },
2205
+ "node_modules/postcss-selector-parser": {
2206
+ "version": "6.1.2",
2207
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
2208
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
2209
+ "dev": true,
2210
+ "license": "MIT",
2211
+ "dependencies": {
2212
+ "cssesc": "^3.0.0",
2213
+ "util-deprecate": "^1.0.2"
2214
+ },
2215
+ "engines": {
2216
+ "node": ">=4"
2217
+ }
2218
+ },
2219
+ "node_modules/postcss-value-parser": {
2220
+ "version": "4.2.0",
2221
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
2222
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
2223
+ "dev": true,
2224
+ "license": "MIT"
2225
+ },
2226
+ "node_modules/queue-microtask": {
2227
+ "version": "1.2.3",
2228
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
2229
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
2230
+ "dev": true,
2231
+ "funding": [
2232
+ {
2233
+ "type": "github",
2234
+ "url": "https://github.com/sponsors/feross"
2235
+ },
2236
+ {
2237
+ "type": "patreon",
2238
+ "url": "https://www.patreon.com/feross"
2239
+ },
2240
+ {
2241
+ "type": "consulting",
2242
+ "url": "https://feross.org/support"
2243
+ }
2244
+ ],
2245
+ "license": "MIT"
2246
+ },
2247
+ "node_modules/react": {
2248
+ "version": "18.3.1",
2249
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
2250
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
2251
+ "license": "MIT",
2252
+ "dependencies": {
2253
+ "loose-envify": "^1.1.0"
2254
+ },
2255
+ "engines": {
2256
+ "node": ">=0.10.0"
2257
+ }
2258
+ },
2259
+ "node_modules/react-dom": {
2260
+ "version": "18.3.1",
2261
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
2262
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
2263
+ "license": "MIT",
2264
+ "dependencies": {
2265
+ "loose-envify": "^1.1.0",
2266
+ "scheduler": "^0.23.2"
2267
+ },
2268
+ "peerDependencies": {
2269
+ "react": "^18.3.1"
2270
+ }
2271
+ },
2272
+ "node_modules/react-refresh": {
2273
+ "version": "0.17.0",
2274
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
2275
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
2276
+ "dev": true,
2277
+ "license": "MIT",
2278
+ "engines": {
2279
+ "node": ">=0.10.0"
2280
+ }
2281
+ },
2282
+ "node_modules/read-cache": {
2283
+ "version": "1.0.0",
2284
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
2285
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
2286
+ "dev": true,
2287
+ "license": "MIT",
2288
+ "dependencies": {
2289
+ "pify": "^2.3.0"
2290
+ }
2291
+ },
2292
+ "node_modules/readdirp": {
2293
+ "version": "3.6.0",
2294
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
2295
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
2296
+ "dev": true,
2297
+ "license": "MIT",
2298
+ "dependencies": {
2299
+ "picomatch": "^2.2.1"
2300
+ },
2301
+ "engines": {
2302
+ "node": ">=8.10.0"
2303
+ }
2304
+ },
2305
+ "node_modules/resolve": {
2306
+ "version": "1.22.11",
2307
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
2308
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
2309
+ "dev": true,
2310
+ "license": "MIT",
2311
+ "dependencies": {
2312
+ "is-core-module": "^2.16.1",
2313
+ "path-parse": "^1.0.7",
2314
+ "supports-preserve-symlinks-flag": "^1.0.0"
2315
+ },
2316
+ "bin": {
2317
+ "resolve": "bin/resolve"
2318
+ },
2319
+ "engines": {
2320
+ "node": ">= 0.4"
2321
+ },
2322
+ "funding": {
2323
+ "url": "https://github.com/sponsors/ljharb"
2324
+ }
2325
+ },
2326
+ "node_modules/reusify": {
2327
+ "version": "1.1.0",
2328
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
2329
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
2330
+ "dev": true,
2331
+ "license": "MIT",
2332
+ "engines": {
2333
+ "iojs": ">=1.0.0",
2334
+ "node": ">=0.10.0"
2335
+ }
2336
+ },
2337
+ "node_modules/rollup": {
2338
+ "version": "4.58.0",
2339
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz",
2340
+ "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==",
2341
+ "dev": true,
2342
+ "license": "MIT",
2343
+ "dependencies": {
2344
+ "@types/estree": "1.0.8"
2345
+ },
2346
+ "bin": {
2347
+ "rollup": "dist/bin/rollup"
2348
+ },
2349
+ "engines": {
2350
+ "node": ">=18.0.0",
2351
+ "npm": ">=8.0.0"
2352
+ },
2353
+ "optionalDependencies": {
2354
+ "@rollup/rollup-android-arm-eabi": "4.58.0",
2355
+ "@rollup/rollup-android-arm64": "4.58.0",
2356
+ "@rollup/rollup-darwin-arm64": "4.58.0",
2357
+ "@rollup/rollup-darwin-x64": "4.58.0",
2358
+ "@rollup/rollup-freebsd-arm64": "4.58.0",
2359
+ "@rollup/rollup-freebsd-x64": "4.58.0",
2360
+ "@rollup/rollup-linux-arm-gnueabihf": "4.58.0",
2361
+ "@rollup/rollup-linux-arm-musleabihf": "4.58.0",
2362
+ "@rollup/rollup-linux-arm64-gnu": "4.58.0",
2363
+ "@rollup/rollup-linux-arm64-musl": "4.58.0",
2364
+ "@rollup/rollup-linux-loong64-gnu": "4.58.0",
2365
+ "@rollup/rollup-linux-loong64-musl": "4.58.0",
2366
+ "@rollup/rollup-linux-ppc64-gnu": "4.58.0",
2367
+ "@rollup/rollup-linux-ppc64-musl": "4.58.0",
2368
+ "@rollup/rollup-linux-riscv64-gnu": "4.58.0",
2369
+ "@rollup/rollup-linux-riscv64-musl": "4.58.0",
2370
+ "@rollup/rollup-linux-s390x-gnu": "4.58.0",
2371
+ "@rollup/rollup-linux-x64-gnu": "4.58.0",
2372
+ "@rollup/rollup-linux-x64-musl": "4.58.0",
2373
+ "@rollup/rollup-openbsd-x64": "4.58.0",
2374
+ "@rollup/rollup-openharmony-arm64": "4.58.0",
2375
+ "@rollup/rollup-win32-arm64-msvc": "4.58.0",
2376
+ "@rollup/rollup-win32-ia32-msvc": "4.58.0",
2377
+ "@rollup/rollup-win32-x64-gnu": "4.58.0",
2378
+ "@rollup/rollup-win32-x64-msvc": "4.58.0",
2379
+ "fsevents": "~2.3.2"
2380
+ }
2381
+ },
2382
+ "node_modules/run-parallel": {
2383
+ "version": "1.2.0",
2384
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
2385
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
2386
+ "dev": true,
2387
+ "funding": [
2388
+ {
2389
+ "type": "github",
2390
+ "url": "https://github.com/sponsors/feross"
2391
+ },
2392
+ {
2393
+ "type": "patreon",
2394
+ "url": "https://www.patreon.com/feross"
2395
+ },
2396
+ {
2397
+ "type": "consulting",
2398
+ "url": "https://feross.org/support"
2399
+ }
2400
+ ],
2401
+ "license": "MIT",
2402
+ "dependencies": {
2403
+ "queue-microtask": "^1.2.2"
2404
+ }
2405
+ },
2406
+ "node_modules/scheduler": {
2407
+ "version": "0.23.2",
2408
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
2409
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
2410
+ "license": "MIT",
2411
+ "dependencies": {
2412
+ "loose-envify": "^1.1.0"
2413
+ }
2414
+ },
2415
+ "node_modules/semver": {
2416
+ "version": "6.3.1",
2417
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2418
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2419
+ "dev": true,
2420
+ "license": "ISC",
2421
+ "bin": {
2422
+ "semver": "bin/semver.js"
2423
+ }
2424
+ },
2425
+ "node_modules/source-map-js": {
2426
+ "version": "1.2.1",
2427
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2428
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2429
+ "dev": true,
2430
+ "license": "BSD-3-Clause",
2431
+ "engines": {
2432
+ "node": ">=0.10.0"
2433
+ }
2434
+ },
2435
+ "node_modules/sucrase": {
2436
+ "version": "3.35.1",
2437
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
2438
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
2439
+ "dev": true,
2440
+ "license": "MIT",
2441
+ "dependencies": {
2442
+ "@jridgewell/gen-mapping": "^0.3.2",
2443
+ "commander": "^4.0.0",
2444
+ "lines-and-columns": "^1.1.6",
2445
+ "mz": "^2.7.0",
2446
+ "pirates": "^4.0.1",
2447
+ "tinyglobby": "^0.2.11",
2448
+ "ts-interface-checker": "^0.1.9"
2449
+ },
2450
+ "bin": {
2451
+ "sucrase": "bin/sucrase",
2452
+ "sucrase-node": "bin/sucrase-node"
2453
+ },
2454
+ "engines": {
2455
+ "node": ">=16 || 14 >=14.17"
2456
+ }
2457
+ },
2458
+ "node_modules/supports-preserve-symlinks-flag": {
2459
+ "version": "1.0.0",
2460
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
2461
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
2462
+ "dev": true,
2463
+ "license": "MIT",
2464
+ "engines": {
2465
+ "node": ">= 0.4"
2466
+ },
2467
+ "funding": {
2468
+ "url": "https://github.com/sponsors/ljharb"
2469
+ }
2470
+ },
2471
+ "node_modules/tailwindcss": {
2472
+ "version": "3.4.19",
2473
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
2474
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
2475
+ "dev": true,
2476
+ "license": "MIT",
2477
+ "dependencies": {
2478
+ "@alloc/quick-lru": "^5.2.0",
2479
+ "arg": "^5.0.2",
2480
+ "chokidar": "^3.6.0",
2481
+ "didyoumean": "^1.2.2",
2482
+ "dlv": "^1.1.3",
2483
+ "fast-glob": "^3.3.2",
2484
+ "glob-parent": "^6.0.2",
2485
+ "is-glob": "^4.0.3",
2486
+ "jiti": "^1.21.7",
2487
+ "lilconfig": "^3.1.3",
2488
+ "micromatch": "^4.0.8",
2489
+ "normalize-path": "^3.0.0",
2490
+ "object-hash": "^3.0.0",
2491
+ "picocolors": "^1.1.1",
2492
+ "postcss": "^8.4.47",
2493
+ "postcss-import": "^15.1.0",
2494
+ "postcss-js": "^4.0.1",
2495
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
2496
+ "postcss-nested": "^6.2.0",
2497
+ "postcss-selector-parser": "^6.1.2",
2498
+ "resolve": "^1.22.8",
2499
+ "sucrase": "^3.35.0"
2500
+ },
2501
+ "bin": {
2502
+ "tailwind": "lib/cli.js",
2503
+ "tailwindcss": "lib/cli.js"
2504
+ },
2505
+ "engines": {
2506
+ "node": ">=14.0.0"
2507
+ }
2508
+ },
2509
+ "node_modules/thenify": {
2510
+ "version": "3.3.1",
2511
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
2512
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
2513
+ "dev": true,
2514
+ "license": "MIT",
2515
+ "dependencies": {
2516
+ "any-promise": "^1.0.0"
2517
+ }
2518
+ },
2519
+ "node_modules/thenify-all": {
2520
+ "version": "1.6.0",
2521
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
2522
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
2523
+ "dev": true,
2524
+ "license": "MIT",
2525
+ "dependencies": {
2526
+ "thenify": ">= 3.1.0 < 4"
2527
+ },
2528
+ "engines": {
2529
+ "node": ">=0.8"
2530
+ }
2531
+ },
2532
+ "node_modules/tinyglobby": {
2533
+ "version": "0.2.15",
2534
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
2535
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
2536
+ "dev": true,
2537
+ "license": "MIT",
2538
+ "dependencies": {
2539
+ "fdir": "^6.5.0",
2540
+ "picomatch": "^4.0.3"
2541
+ },
2542
+ "engines": {
2543
+ "node": ">=12.0.0"
2544
+ },
2545
+ "funding": {
2546
+ "url": "https://github.com/sponsors/SuperchupuDev"
2547
+ }
2548
+ },
2549
+ "node_modules/tinyglobby/node_modules/fdir": {
2550
+ "version": "6.5.0",
2551
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
2552
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
2553
+ "dev": true,
2554
+ "license": "MIT",
2555
+ "engines": {
2556
+ "node": ">=12.0.0"
2557
+ },
2558
+ "peerDependencies": {
2559
+ "picomatch": "^3 || ^4"
2560
+ },
2561
+ "peerDependenciesMeta": {
2562
+ "picomatch": {
2563
+ "optional": true
2564
+ }
2565
+ }
2566
+ },
2567
+ "node_modules/tinyglobby/node_modules/picomatch": {
2568
+ "version": "4.0.3",
2569
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
2570
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
2571
+ "dev": true,
2572
+ "license": "MIT",
2573
+ "engines": {
2574
+ "node": ">=12"
2575
+ },
2576
+ "funding": {
2577
+ "url": "https://github.com/sponsors/jonschlinkert"
2578
+ }
2579
+ },
2580
+ "node_modules/to-regex-range": {
2581
+ "version": "5.0.1",
2582
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
2583
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
2584
+ "dev": true,
2585
+ "license": "MIT",
2586
+ "dependencies": {
2587
+ "is-number": "^7.0.0"
2588
+ },
2589
+ "engines": {
2590
+ "node": ">=8.0"
2591
+ }
2592
+ },
2593
+ "node_modules/ts-interface-checker": {
2594
+ "version": "0.1.13",
2595
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
2596
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
2597
+ "dev": true,
2598
+ "license": "Apache-2.0"
2599
+ },
2600
+ "node_modules/typescript": {
2601
+ "version": "5.9.3",
2602
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2603
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2604
+ "dev": true,
2605
+ "license": "Apache-2.0",
2606
+ "bin": {
2607
+ "tsc": "bin/tsc",
2608
+ "tsserver": "bin/tsserver"
2609
+ },
2610
+ "engines": {
2611
+ "node": ">=14.17"
2612
+ }
2613
+ },
2614
+ "node_modules/update-browserslist-db": {
2615
+ "version": "1.2.3",
2616
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
2617
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
2618
+ "dev": true,
2619
+ "funding": [
2620
+ {
2621
+ "type": "opencollective",
2622
+ "url": "https://opencollective.com/browserslist"
2623
+ },
2624
+ {
2625
+ "type": "tidelift",
2626
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
2627
+ },
2628
+ {
2629
+ "type": "github",
2630
+ "url": "https://github.com/sponsors/ai"
2631
+ }
2632
+ ],
2633
+ "license": "MIT",
2634
+ "dependencies": {
2635
+ "escalade": "^3.2.0",
2636
+ "picocolors": "^1.1.1"
2637
+ },
2638
+ "bin": {
2639
+ "update-browserslist-db": "cli.js"
2640
+ },
2641
+ "peerDependencies": {
2642
+ "browserslist": ">= 4.21.0"
2643
+ }
2644
+ },
2645
+ "node_modules/util-deprecate": {
2646
+ "version": "1.0.2",
2647
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
2648
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
2649
+ "dev": true,
2650
+ "license": "MIT"
2651
+ },
2652
+ "node_modules/vite": {
2653
+ "version": "6.4.1",
2654
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
2655
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
2656
+ "dev": true,
2657
+ "license": "MIT",
2658
+ "dependencies": {
2659
+ "esbuild": "^0.25.0",
2660
+ "fdir": "^6.4.4",
2661
+ "picomatch": "^4.0.2",
2662
+ "postcss": "^8.5.3",
2663
+ "rollup": "^4.34.9",
2664
+ "tinyglobby": "^0.2.13"
2665
+ },
2666
+ "bin": {
2667
+ "vite": "bin/vite.js"
2668
+ },
2669
+ "engines": {
2670
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
2671
+ },
2672
+ "funding": {
2673
+ "url": "https://github.com/vitejs/vite?sponsor=1"
2674
+ },
2675
+ "optionalDependencies": {
2676
+ "fsevents": "~2.3.3"
2677
+ },
2678
+ "peerDependencies": {
2679
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
2680
+ "jiti": ">=1.21.0",
2681
+ "less": "*",
2682
+ "lightningcss": "^1.21.0",
2683
+ "sass": "*",
2684
+ "sass-embedded": "*",
2685
+ "stylus": "*",
2686
+ "sugarss": "*",
2687
+ "terser": "^5.16.0",
2688
+ "tsx": "^4.8.1",
2689
+ "yaml": "^2.4.2"
2690
+ },
2691
+ "peerDependenciesMeta": {
2692
+ "@types/node": {
2693
+ "optional": true
2694
+ },
2695
+ "jiti": {
2696
+ "optional": true
2697
+ },
2698
+ "less": {
2699
+ "optional": true
2700
+ },
2701
+ "lightningcss": {
2702
+ "optional": true
2703
+ },
2704
+ "sass": {
2705
+ "optional": true
2706
+ },
2707
+ "sass-embedded": {
2708
+ "optional": true
2709
+ },
2710
+ "stylus": {
2711
+ "optional": true
2712
+ },
2713
+ "sugarss": {
2714
+ "optional": true
2715
+ },
2716
+ "terser": {
2717
+ "optional": true
2718
+ },
2719
+ "tsx": {
2720
+ "optional": true
2721
+ },
2722
+ "yaml": {
2723
+ "optional": true
2724
+ }
2725
+ }
2726
+ },
2727
+ "node_modules/vite/node_modules/fdir": {
2728
+ "version": "6.5.0",
2729
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
2730
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
2731
+ "dev": true,
2732
+ "license": "MIT",
2733
+ "engines": {
2734
+ "node": ">=12.0.0"
2735
+ },
2736
+ "peerDependencies": {
2737
+ "picomatch": "^3 || ^4"
2738
+ },
2739
+ "peerDependenciesMeta": {
2740
+ "picomatch": {
2741
+ "optional": true
2742
+ }
2743
+ }
2744
+ },
2745
+ "node_modules/vite/node_modules/picomatch": {
2746
+ "version": "4.0.3",
2747
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
2748
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
2749
+ "dev": true,
2750
+ "license": "MIT",
2751
+ "engines": {
2752
+ "node": ">=12"
2753
+ },
2754
+ "funding": {
2755
+ "url": "https://github.com/sponsors/jonschlinkert"
2756
+ }
2757
+ },
2758
+ "node_modules/yallist": {
2759
+ "version": "3.1.1",
2760
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2761
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2762
+ "dev": true,
2763
+ "license": "ISC"
2764
+ }
2765
+ }
2766
+ }
frontend/package.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "agg-visualizer",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "react": "^18.3.1",
13
+ "react-dom": "^18.3.1"
14
+ },
15
+ "devDependencies": {
16
+ "@types/react": "^18.3.12",
17
+ "@types/react-dom": "^18.3.1",
18
+ "@vitejs/plugin-react": "^4.3.4",
19
+ "autoprefixer": "^10.4.20",
20
+ "postcss": "^8.4.49",
21
+ "tailwindcss": "^3.4.15",
22
+ "typescript": "^5.6.3",
23
+ "vite": "^6.0.3"
24
+ }
25
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
frontend/src/App.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, lazy, Suspense } from "react";
2
+
3
+ const ModelApp = lazy(() => import("./model/ModelApp"));
4
+ const ArenaApp = lazy(() => import("./arena/ArenaApp"));
5
+ const RlmApp = lazy(() => import("./rlm/RlmApp"));
6
+ const HarborApp = lazy(() => import("./harbor/HarborApp"));
7
+
8
+ type TabId = "model" | "arena" | "rlm" | "harbor";
9
+
10
+ const TABS: { id: TabId; label: string; color: string; activeClass: string }[] = [
11
+ { id: "model", label: "Model Trace", color: "blue", activeClass: "border-blue-500 text-blue-400" },
12
+ { id: "arena", label: "Arena", color: "purple", activeClass: "border-purple-500 text-purple-400" },
13
+ { id: "rlm", label: "RLM", color: "orange", activeClass: "border-orange-500 text-orange-400" },
14
+ { id: "harbor", label: "Harbor", color: "teal", activeClass: "border-teal-500 text-teal-400" },
15
+ ];
16
+
17
+ export default function App() {
18
+ const [activeTab, setActiveTab] = useState<TabId>("model");
19
+
20
+ return (
21
+ <div className="h-screen flex flex-col bg-gray-950 text-gray-100">
22
+ {/* Tab bar */}
23
+ <div className="flex items-center border-b border-gray-800 bg-gray-900 px-2 shrink-0">
24
+ {TABS.map((tab) => (
25
+ <button
26
+ key={tab.id}
27
+ onClick={() => setActiveTab(tab.id)}
28
+ className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${
29
+ activeTab === tab.id
30
+ ? tab.activeClass
31
+ : "border-transparent text-gray-500 hover:text-gray-300"
32
+ }`}
33
+ >
34
+ {tab.label}
35
+ </button>
36
+ ))}
37
+ <div className="ml-auto text-xs text-gray-600 px-3">Aggregate Trace Visualizer</div>
38
+ </div>
39
+
40
+ {/* Active visualizer */}
41
+ <div className="flex-1 overflow-hidden">
42
+ <Suspense
43
+ fallback={
44
+ <div className="flex items-center justify-center h-full text-gray-500">
45
+ Loading...
46
+ </div>
47
+ }
48
+ >
49
+ {activeTab === "model" && (
50
+ <div className="theme-model h-full">
51
+ <ModelApp />
52
+ </div>
53
+ )}
54
+ {activeTab === "arena" && (
55
+ <div className="theme-arena h-full">
56
+ <ArenaApp />
57
+ </div>
58
+ )}
59
+ {activeTab === "rlm" && (
60
+ <div className="theme-rlm h-full">
61
+ <RlmApp />
62
+ </div>
63
+ )}
64
+ {activeTab === "harbor" && (
65
+ <div className="theme-harbor h-full">
66
+ <HarborApp />
67
+ </div>
68
+ )}
69
+ </Suspense>
70
+ </div>
71
+ </div>
72
+ );
73
+ }
frontend/src/arena/ArenaApp.tsx ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useCallback, useRef, useState } from "react";
2
+ import { useAppState } from "./store";
3
+ import Sidebar from "./components/Sidebar";
4
+ import TranscriptPanel, { type DragHandleProps } from "./components/TranscriptPanel";
5
+ import EpisodeBar from "./components/EpisodeBar";
6
+ import EpisodeNav from "./components/EpisodeNav";
7
+ import type { DatasetInfo, EpisodeData, Preset } from "./types";
8
+ import { api } from "./api";
9
+
10
+ export default function ArenaApp() {
11
+ const state = useAppState();
12
+
13
+ const handleLoadPreset = useCallback(async (preset: Preset) => {
14
+ await state.addDataset(preset.repo, preset.split, preset.id, preset.name);
15
+ }, [state.addDataset]);
16
+
17
+ const handleSavePreset = useCallback(async (name: string, repo: string, split?: string) => {
18
+ const preset = await api.createPreset(name, repo, split);
19
+ state.setPresets(prev => [...prev, preset]);
20
+ }, []);
21
+
22
+ const handleDeletePreset = useCallback(async (id: string) => {
23
+ await api.deletePreset(id);
24
+ state.setPresets(prev => prev.filter(p => p.id !== id));
25
+ }, []);
26
+
27
+ const handleUpdatePreset = useCallback(async (presetId: string, datasetId: string, updates: { name?: string }) => {
28
+ const updated = await api.updatePreset(presetId, updates);
29
+ state.setPresets(prev => prev.map(p => p.id === presetId ? updated : p));
30
+ if (updates.name) {
31
+ state.updateDatasetPresetName(datasetId, updates.name);
32
+ }
33
+ }, [state.updateDatasetPresetName]);
34
+
35
+ // Keyboard shortcuts: j/k for episode navigation
36
+ useEffect(() => {
37
+ const handler = (e: KeyboardEvent) => {
38
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
39
+ switch (e.key) {
40
+ case "j":
41
+ state.setEpisodeIdx(prev => Math.min(state.maxEpisodes - 1, prev + 1));
42
+ break;
43
+ case "k":
44
+ state.setEpisodeIdx(prev => Math.max(0, prev - 1));
45
+ break;
46
+ }
47
+ };
48
+ window.addEventListener("keydown", handler);
49
+ return () => window.removeEventListener("keydown", handler);
50
+ }, [state.maxEpisodes, state.setEpisodeIdx]);
51
+
52
+ return (
53
+ <div className="h-full flex overflow-hidden">
54
+ <Sidebar
55
+ datasets={state.datasets}
56
+ presets={state.presets}
57
+ loading={state.loading}
58
+ allEnvIds={state.allEnvIds}
59
+ currentEnvId={state.currentEnvId}
60
+ onAddDataset={state.addDataset}
61
+ onRemoveDataset={state.removeDataset}
62
+ onToggleDataset={state.toggleDataset}
63
+ onSetCurrentEnv={state.setCurrentEnvId}
64
+ onLoadPreset={handleLoadPreset}
65
+ onSavePreset={handleSavePreset}
66
+ onDeletePreset={handleDeletePreset}
67
+ onUpdatePreset={handleUpdatePreset}
68
+ />
69
+
70
+ <div className="flex-1 flex flex-col min-w-0">
71
+ {/* Error banner */}
72
+ {state.error && (
73
+ <div className="px-4 py-2 bg-red-900/50 border-b border-red-700 text-red-300 text-sm flex items-center justify-between">
74
+ <span>{state.error}</span>
75
+ <button onClick={() => state.setError(null)} className="text-red-400 hover:text-red-300 ml-2">Dismiss</button>
76
+ </div>
77
+ )}
78
+
79
+ <EpisodeBar
80
+ activeDatasets={state.activeDatasets}
81
+ currentEnvId={state.currentEnvId}
82
+ episodeIdx={state.episodeIdx}
83
+ maxEpisodes={state.maxEpisodes}
84
+ getEpisodeData={state.getEpisodeData}
85
+ />
86
+
87
+ {/* Transcript panels */}
88
+ <PanelContainer
89
+ datasets={state.orderedActiveDatasets}
90
+ getEpisodeData={state.getEpisodeData}
91
+ onReorder={state.reorderPanels}
92
+ />
93
+
94
+ <EpisodeNav
95
+ episodeIdx={state.episodeIdx}
96
+ maxEpisodes={state.maxEpisodes}
97
+ filter={state.filter}
98
+ onEpisodeChange={state.setEpisodeIdx}
99
+ onFilterChange={state.setFilter}
100
+ />
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+
107
+ /* ── Drag-to-reorder panel container ── */
108
+
109
+ interface PanelContainerProps {
110
+ datasets: DatasetInfo[];
111
+ getEpisodeData: (dsId: string) => EpisodeData | undefined;
112
+ onReorder: (fromId: string, toId: string) => void;
113
+ }
114
+
115
+ function PanelContainer({ datasets, getEpisodeData, onReorder }: PanelContainerProps) {
116
+ const [draggedId, setDraggedId] = useState<string | null>(null);
117
+ const [overId, setOverId] = useState<string | null>(null);
118
+ const dragCounter = useRef<Record<string, number>>({});
119
+
120
+ const handleDragStart = useCallback((e: React.DragEvent, id: string) => {
121
+ setDraggedId(id);
122
+ e.dataTransfer.effectAllowed = "move";
123
+ const ghost = document.createElement("canvas");
124
+ ghost.width = 1;
125
+ ghost.height = 1;
126
+ e.dataTransfer.setDragImage(ghost, 0, 0);
127
+ }, []);
128
+
129
+ const handleDragEnd = useCallback(() => {
130
+ setDraggedId(null);
131
+ setOverId(null);
132
+ dragCounter.current = {};
133
+ }, []);
134
+
135
+ const handleDragEnter = useCallback((e: React.DragEvent, id: string) => {
136
+ e.preventDefault();
137
+ dragCounter.current[id] = (dragCounter.current[id] || 0) + 1;
138
+ setOverId(id);
139
+ }, []);
140
+
141
+ const handleDragLeave = useCallback((_e: React.DragEvent, id: string) => {
142
+ dragCounter.current[id] = (dragCounter.current[id] || 0) - 1;
143
+ if (dragCounter.current[id] <= 0) {
144
+ dragCounter.current[id] = 0;
145
+ setOverId(prev => prev === id ? null : prev);
146
+ }
147
+ }, []);
148
+
149
+ const handleDragOver = useCallback((e: React.DragEvent) => {
150
+ e.preventDefault();
151
+ e.dataTransfer.dropEffect = "move";
152
+ }, []);
153
+
154
+ const handleDrop = useCallback((e: React.DragEvent, targetId: string) => {
155
+ e.preventDefault();
156
+ if (draggedId && draggedId !== targetId) {
157
+ onReorder(draggedId, targetId);
158
+ }
159
+ setDraggedId(null);
160
+ setOverId(null);
161
+ dragCounter.current = {};
162
+ }, [draggedId, onReorder]);
163
+
164
+ if (datasets.length === 0) {
165
+ return (
166
+ <div className="flex-1 flex gap-2 p-2 overflow-x-auto min-h-0">
167
+ <div className="flex-1 flex items-center justify-center text-gray-500">
168
+ <div className="text-center">
169
+ <p className="text-lg mb-2">No repos active</p>
170
+ <p className="text-sm">Add an arena HuggingFace repo from the sidebar to get started</p>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ return (
178
+ <div className="flex-1 flex gap-2 p-2 overflow-x-auto min-h-0">
179
+ {datasets.map(ds => {
180
+ const isDragged = draggedId === ds.id;
181
+ const isOver = overId === ds.id && draggedId !== null && draggedId !== ds.id;
182
+
183
+ const handleProps: DragHandleProps = {
184
+ draggable: true,
185
+ onDragStart: e => handleDragStart(e, ds.id),
186
+ onDragEnd: handleDragEnd,
187
+ };
188
+
189
+ return (
190
+ <div
191
+ key={ds.id}
192
+ onDragEnter={e => handleDragEnter(e, ds.id)}
193
+ onDragLeave={e => handleDragLeave(e, ds.id)}
194
+ onDragOver={handleDragOver}
195
+ onDrop={e => handleDrop(e, ds.id)}
196
+ className={`flex-1 min-w-0 transition-all duration-150 ${
197
+ isDragged ? "opacity-30 scale-[0.97]" : ""
198
+ } ${isOver ? "panel-drop-target" : ""}`}
199
+ >
200
+ <TranscriptPanel
201
+ datasetName={ds.presetName || ds.name}
202
+ repoName={ds.presetName ? ds.name : undefined}
203
+ data={getEpisodeData(ds.id)}
204
+ dragHandleProps={handleProps}
205
+ />
206
+ </div>
207
+ );
208
+ })}
209
+ </div>
210
+ );
211
+ }
frontend/src/arena/api.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatasetInfo, EpisodeData, Preset } from "./types";
2
+
3
+ const BASE = "/api/arena";
4
+ const PRESETS_BASE = "/api/presets/arena";
5
+
6
+ async function fetchJSON<T>(url: string, opts?: RequestInit): Promise<T> {
7
+ const res = await fetch(`${BASE}${url}`, {
8
+ headers: { "Content-Type": "application/json" },
9
+ ...opts,
10
+ });
11
+ if (!res.ok) {
12
+ const err = await res.json().catch(() => ({ error: res.statusText }));
13
+ throw new Error(err.error || res.statusText);
14
+ }
15
+ return res.json();
16
+ }
17
+
18
+ async function fetchPresetsJSON<T>(url: string, opts?: RequestInit): Promise<T> {
19
+ const res = await fetch(`${PRESETS_BASE}${url}`, {
20
+ headers: { "Content-Type": "application/json" },
21
+ ...opts,
22
+ });
23
+ if (!res.ok) {
24
+ const err = await res.json().catch(() => ({ error: res.statusText }));
25
+ throw new Error(err.error || res.statusText);
26
+ }
27
+ return res.json();
28
+ }
29
+
30
+ export const api = {
31
+ loadDataset(repo: string, split?: string) {
32
+ return fetchJSON<DatasetInfo & { episodes_per_env: Record<string, number> }>("/datasets/load", {
33
+ method: "POST",
34
+ body: JSON.stringify({ repo, split }),
35
+ });
36
+ },
37
+
38
+ listDatasets() {
39
+ return fetchJSON<DatasetInfo[]>("/datasets/");
40
+ },
41
+
42
+ getEpisode(dsId: string, envId: string, idx: number) {
43
+ return fetchJSON<EpisodeData>(`/datasets/${dsId}/episode/${encodeURIComponent(envId)}/${idx}`);
44
+ },
45
+
46
+ unloadDataset(dsId: string) {
47
+ return fetchJSON<{ status: string }>(`/datasets/${dsId}`, { method: "DELETE" });
48
+ },
49
+
50
+ listPresets() {
51
+ return fetchPresetsJSON<Preset[]>("");
52
+ },
53
+
54
+ createPreset(name: string, repo: string, split?: string) {
55
+ return fetchPresetsJSON<Preset>("", {
56
+ method: "POST",
57
+ body: JSON.stringify({ name, repo, split }),
58
+ });
59
+ },
60
+
61
+ updatePreset(id: string, updates: { name?: string; split?: string }) {
62
+ return fetchPresetsJSON<Preset>(`/${id}`, {
63
+ method: "PUT",
64
+ body: JSON.stringify(updates),
65
+ });
66
+ },
67
+
68
+ deletePreset(id: string) {
69
+ return fetchPresetsJSON<{ status: string }>(`/${id}`, { method: "DELETE" });
70
+ },
71
+ };
frontend/src/arena/components/EpisodeBar.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatasetInfo, EpisodeData } from "../types";
2
+
3
+ interface EpisodeBarProps {
4
+ activeDatasets: DatasetInfo[];
5
+ currentEnvId: string | null;
6
+ episodeIdx: number;
7
+ maxEpisodes: number;
8
+ getEpisodeData: (dsId: string) => EpisodeData | undefined;
9
+ }
10
+
11
+ export default function EpisodeBar({ activeDatasets, currentEnvId, episodeIdx, maxEpisodes, getEpisodeData }: EpisodeBarProps) {
12
+ if (!currentEnvId || activeDatasets.length === 0) {
13
+ return (
14
+ <div className="px-4 py-3 border-b border-gray-700 bg-gray-900/80">
15
+ <p className="text-sm text-gray-500 italic">Load an arena dataset to begin</p>
16
+ </div>
17
+ );
18
+ }
19
+
20
+ const firstData = getEpisodeData(activeDatasets[0].id);
21
+
22
+ return (
23
+ <div className="px-4 py-3 border-b border-gray-700 bg-gray-900/80">
24
+ {/* Episode info */}
25
+ <div className="flex items-center gap-3 mb-2">
26
+ <span className="text-sm font-semibold text-gray-200">{currentEnvId}</span>
27
+ {firstData && (
28
+ <span className="text-xs text-gray-500">
29
+ {firstData.game_id}
30
+ </span>
31
+ )}
32
+ <span className="text-xs text-gray-600">
33
+ Episode {episodeIdx + 1} / {maxEpisodes}
34
+ </span>
35
+ </div>
36
+
37
+ {/* Per-dataset outcome for this episode */}
38
+ <div className="flex items-center gap-3 mb-2 flex-wrap">
39
+ {activeDatasets.map(ds => {
40
+ const ep = getEpisodeData(ds.id);
41
+ const outcome = ep?.outcome || "unknown";
42
+ const colors = {
43
+ win: "text-green-400",
44
+ loss: "text-red-400",
45
+ error: "text-yellow-400",
46
+ unknown: "text-gray-500",
47
+ };
48
+ return (
49
+ <span key={ds.id} className="text-[11px]">
50
+ <span className="text-gray-500">{ds.presetName || ds.name}: </span>
51
+ <span className={colors[outcome]}>
52
+ {outcome.toUpperCase()}
53
+ {ep?.reward !== null && ep?.reward !== undefined && ` (${ep.reward})`}
54
+ </span>
55
+ </span>
56
+ );
57
+ })}
58
+ </div>
59
+
60
+ {/* Episode indicator squares */}
61
+ {maxEpisodes > 1 && (
62
+ <div className="flex items-center gap-1 flex-wrap">
63
+ <span className="text-[10px] text-gray-500 mr-1">Episodes:</span>
64
+ {Array.from({ length: Math.min(maxEpisodes, 50) }, (_, i) => {
65
+ const isSelected = i === episodeIdx;
66
+ return (
67
+ <span
68
+ key={i}
69
+ className={`inline-block w-3 h-3 rounded-sm text-[8px] text-center leading-3 font-mono ${
70
+ isSelected
71
+ ? "bg-purple-600 text-white ring-1 ring-purple-400"
72
+ : "bg-gray-800 text-gray-600"
73
+ }`}
74
+ title={`Episode ${i + 1}`}
75
+ >
76
+ {i + 1}
77
+ </span>
78
+ );
79
+ })}
80
+ {maxEpisodes > 50 && (
81
+ <span className="text-[10px] text-gray-600">... +{maxEpisodes - 50} more</span>
82
+ )}
83
+ </div>
84
+ )}
85
+ </div>
86
+ );
87
+ }
frontend/src/arena/components/EpisodeNav.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FilterMode } from "../types";
2
+
3
+ interface EpisodeNavProps {
4
+ episodeIdx: number;
5
+ maxEpisodes: number;
6
+ filter: FilterMode;
7
+ onEpisodeChange: (idx: number) => void;
8
+ onFilterChange: (filter: FilterMode) => void;
9
+ }
10
+
11
+ const FILTERS: { value: FilterMode; label: string }[] = [
12
+ { value: "all", label: "All" },
13
+ { value: "wins", label: "Wins" },
14
+ { value: "losses", label: "Losses" },
15
+ { value: "errors", label: "Errors" },
16
+ ];
17
+
18
+ export default function EpisodeNav({
19
+ episodeIdx, maxEpisodes, filter,
20
+ onEpisodeChange, onFilterChange,
21
+ }: EpisodeNavProps) {
22
+ const prevEp = () => onEpisodeChange(Math.max(0, episodeIdx - 1));
23
+ const nextEp = () => onEpisodeChange(Math.min(maxEpisodes - 1, episodeIdx + 1));
24
+
25
+ return (
26
+ <div className="px-4 py-2 border-t border-gray-700 bg-gray-900/80 flex items-center justify-between flex-wrap gap-2">
27
+ {/* Episode navigation */}
28
+ <div className="flex items-center gap-2">
29
+ <button
30
+ onClick={prevEp}
31
+ disabled={episodeIdx <= 0}
32
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 rounded border border-gray-600 text-gray-300 transition-colors"
33
+ >
34
+ &larr; Prev
35
+ </button>
36
+ <div className="flex items-center gap-1">
37
+ <span className="text-xs text-gray-500">Ep</span>
38
+ <input
39
+ type="number"
40
+ value={episodeIdx}
41
+ onChange={e => {
42
+ const v = parseInt(e.target.value);
43
+ if (!isNaN(v) && v >= 0 && v < maxEpisodes) onEpisodeChange(v);
44
+ }}
45
+ 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"
46
+ />
47
+ <span className="text-xs text-gray-500">/ {maxEpisodes > 0 ? maxEpisodes - 1 : 0}</span>
48
+ </div>
49
+ <button
50
+ onClick={nextEp}
51
+ disabled={episodeIdx >= maxEpisodes - 1}
52
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 rounded border border-gray-600 text-gray-300 transition-colors"
53
+ >
54
+ Next &rarr;
55
+ </button>
56
+ </div>
57
+
58
+ {/* Filter */}
59
+ <div className="flex items-center gap-1">
60
+ {FILTERS.map(f => (
61
+ <button
62
+ key={f.value}
63
+ onClick={() => onFilterChange(f.value)}
64
+ className={`px-2 py-1 text-[10px] rounded border transition-colors ${
65
+ filter === f.value
66
+ ? "bg-purple-600 border-purple-500 text-white"
67
+ : "bg-gray-800 border-gray-600 text-gray-400 hover:bg-gray-700"
68
+ }`}
69
+ >
70
+ {f.label}
71
+ </button>
72
+ ))}
73
+ </div>
74
+
75
+ {/* Keyboard hints */}
76
+ <div className="text-[10px] text-gray-600">
77
+ <kbd className="px-1 bg-gray-800 rounded">j</kbd>/<kbd className="px-1 bg-gray-800 rounded">k</kbd> episode
78
+ </div>
79
+ </div>
80
+ );
81
+ }
frontend/src/arena/components/Sidebar.tsx ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { DatasetInfo, Preset } from "../types";
3
+
4
+ const ENV_COLORS = [
5
+ { bg: "bg-purple-500", border: "border-purple-500", text: "text-purple-400", label: "text-purple-300" },
6
+ { bg: "bg-emerald-500", border: "border-emerald-500", text: "text-emerald-400", label: "text-emerald-300" },
7
+ { bg: "bg-amber-500", border: "border-amber-500", text: "text-amber-400", label: "text-amber-300" },
8
+ { bg: "bg-purple-500", border: "border-purple-500", text: "text-purple-400", label: "text-purple-300" },
9
+ { bg: "bg-rose-500", border: "border-rose-500", text: "text-rose-400", label: "text-rose-300" },
10
+ { bg: "bg-cyan-500", border: "border-cyan-500", text: "text-cyan-400", label: "text-cyan-300" },
11
+ ];
12
+
13
+ interface SidebarProps {
14
+ datasets: DatasetInfo[];
15
+ presets: Preset[];
16
+ loading: Record<string, boolean>;
17
+ allEnvIds: string[];
18
+ currentEnvId: string | null;
19
+ onAddDataset: (repo: string, split?: string) => void;
20
+ onRemoveDataset: (id: string) => void;
21
+ onToggleDataset: (id: string) => void;
22
+ onSetCurrentEnv: (envId: string) => void;
23
+ onLoadPreset: (preset: Preset) => void;
24
+ onSavePreset: (name: string, repo: string, split?: string) => void;
25
+ onDeletePreset: (id: string) => void;
26
+ onUpdatePreset: (presetId: string, datasetId: string, updates: { name?: string }) => void;
27
+ }
28
+
29
+ export default function Sidebar({
30
+ datasets, presets, loading, allEnvIds, currentEnvId,
31
+ onAddDataset, onRemoveDataset, onToggleDataset, onSetCurrentEnv,
32
+ onLoadPreset, onSavePreset, onDeletePreset, onUpdatePreset,
33
+ }: SidebarProps) {
34
+ const [showAddModal, setShowAddModal] = useState(false);
35
+ const [repoInput, setRepoInput] = useState("");
36
+ const [splitInput, setSplitInput] = useState("train");
37
+ const [presetSearch, setPresetSearch] = useState("");
38
+ const [savingPresetForId, setSavingPresetForId] = useState<string | null>(null);
39
+ const [presetName, setPresetName] = useState("");
40
+ const [editingDatasetId, setEditingDatasetId] = useState<string | null>(null);
41
+ const [editPresetName, setEditPresetName] = useState("");
42
+
43
+ const handleAdd = () => {
44
+ if (!repoInput.trim()) return;
45
+ onAddDataset(repoInput.trim(), splitInput.trim() || undefined);
46
+ setRepoInput("");
47
+ setShowAddModal(false);
48
+ };
49
+
50
+ const handleSavePresetForRepo = (ds: DatasetInfo) => {
51
+ if (!presetName.trim()) return;
52
+ onSavePreset(presetName.trim(), ds.repo, ds.split);
53
+ setPresetName("");
54
+ setSavingPresetForId(null);
55
+ };
56
+
57
+ const getEnvColor = (envId: string) => {
58
+ const idx = allEnvIds.indexOf(envId);
59
+ return ENV_COLORS[idx % ENV_COLORS.length];
60
+ };
61
+
62
+ return (
63
+ <div className="w-72 min-w-72 bg-gray-900 border-r border-gray-700 flex flex-col h-full">
64
+ {/* Presets */}
65
+ <div className="p-3 border-b border-gray-700">
66
+ <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Presets</h3>
67
+ {presets.length === 0 ? (
68
+ <p className="text-xs text-gray-500 italic">No presets saved</p>
69
+ ) : (
70
+ <>
71
+ {presets.length > 6 && (
72
+ <input
73
+ type="text"
74
+ value={presetSearch}
75
+ onChange={(e) => setPresetSearch(e.target.value)}
76
+ placeholder="Search presets..."
77
+ 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"
78
+ />
79
+ )}
80
+ <div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto">
81
+ {presets
82
+ .filter(p => !presetSearch || p.name.toLowerCase().includes(presetSearch.toLowerCase()))
83
+ .map(p => (
84
+ <div key={p.id} className="group relative">
85
+ <button
86
+ onClick={() => onLoadPreset(p)}
87
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 rounded border border-gray-600 text-gray-300 transition-colors"
88
+ title={`${p.repo} (${p.split ?? "train"})`}
89
+ >
90
+ {p.name}
91
+ </button>
92
+ <div className="hidden group-hover:flex absolute top-full left-0 mt-1 z-10 gap-1">
93
+ <button
94
+ onClick={() => onDeletePreset(p.id)}
95
+ className="px-1.5 py-0.5 text-[10px] bg-red-900 hover:bg-red-800 rounded text-red-300"
96
+ >
97
+ Delete
98
+ </button>
99
+ </div>
100
+ </div>
101
+ ))}
102
+ </div>
103
+ </>
104
+ )}
105
+ </div>
106
+
107
+ {/* Env selector */}
108
+ {allEnvIds.length > 1 && (
109
+ <div className="p-3 border-b border-gray-700">
110
+ <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Environments</h3>
111
+ <div className="space-y-1">
112
+ {allEnvIds.map(envId => {
113
+ const color = getEnvColor(envId);
114
+ const isActive = envId === currentEnvId;
115
+ return (
116
+ <button
117
+ key={envId}
118
+ onClick={() => onSetCurrentEnv(envId)}
119
+ className={`w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs transition-colors ${
120
+ isActive ? "bg-gray-800 text-gray-200" : "text-gray-500 hover:bg-gray-800/50"
121
+ }`}
122
+ >
123
+ <span className={`w-2 h-2 rounded-full ${color.bg} shrink-0`} />
124
+ <span className="truncate">{envId}</span>
125
+ {isActive && <span className="text-[9px] text-gray-600 ml-auto">viewing</span>}
126
+ </button>
127
+ );
128
+ })}
129
+ </div>
130
+ </div>
131
+ )}
132
+
133
+ {/* Datasets */}
134
+ <div className="flex-1 overflow-y-auto p-3">
135
+ <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Loaded Repos</h3>
136
+ {datasets.length === 0 ? (
137
+ <p className="text-xs text-gray-500 italic">No repos loaded. Add one below.</p>
138
+ ) : (
139
+ <div className="space-y-1">
140
+ {datasets.map(ds => (
141
+ <div key={ds.id}>
142
+ <div
143
+ onClick={() => {
144
+ if (ds.presetId) {
145
+ setEditingDatasetId(editingDatasetId === ds.id ? null : ds.id);
146
+ setEditPresetName(ds.presetName || "");
147
+ }
148
+ }}
149
+ className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm transition-colors ${
150
+ ds.active ? "bg-gray-800" : "bg-gray-900 opacity-60"
151
+ } ${ds.presetId ? "cursor-pointer" : ""}`}
152
+ >
153
+ <input
154
+ type="checkbox"
155
+ checked={ds.active}
156
+ onChange={() => onToggleDataset(ds.id)}
157
+ onClick={e => e.stopPropagation()}
158
+ className="rounded border-gray-600 bg-gray-800 text-purple-500 focus:ring-purple-500 focus:ring-offset-0"
159
+ />
160
+ <div className="flex-1 min-w-0">
161
+ <div className="text-gray-200 truncate text-xs font-medium" title={ds.repo}>
162
+ {ds.presetName || ds.name}
163
+ </div>
164
+ <div className="text-[10px] text-gray-500">
165
+ {ds.model_name} | {ds.n_rows} eps | W:{ds.stats.wins} L:{ds.stats.losses} E:{ds.stats.errors}
166
+ </div>
167
+ </div>
168
+ <button
169
+ onClick={e => { e.stopPropagation(); setSavingPresetForId(savingPresetForId === ds.id ? null : ds.id); setPresetName(""); }}
170
+ className={`transition-colors shrink-0 ${savingPresetForId === ds.id ? "text-purple-400" : "text-gray-600 hover:text-purple-400"}`}
171
+ title="Save as preset"
172
+ >
173
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
174
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
175
+ </svg>
176
+ </button>
177
+ <button
178
+ onClick={e => { e.stopPropagation(); onRemoveDataset(ds.id); }}
179
+ className="text-gray-600 hover:text-red-400 transition-colors shrink-0"
180
+ title="Remove"
181
+ >
182
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
183
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
184
+ </svg>
185
+ </button>
186
+ </div>
187
+ {savingPresetForId === ds.id && (
188
+ <div className="flex gap-1 mt-1 ml-6">
189
+ <input
190
+ type="text"
191
+ value={presetName}
192
+ onChange={e => setPresetName(e.target.value)}
193
+ onKeyDown={e => { if (e.key === "Enter") handleSavePresetForRepo(ds); if (e.key === "Escape") setSavingPresetForId(null); }}
194
+ placeholder="Preset name..."
195
+ 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"
196
+ autoFocus
197
+ />
198
+ <button onClick={() => handleSavePresetForRepo(ds)} className="px-2 py-1 text-xs bg-purple-600 hover:bg-purple-500 rounded text-white">Save</button>
199
+ </div>
200
+ )}
201
+ </div>
202
+ ))}
203
+ </div>
204
+ )}
205
+ </div>
206
+
207
+ {/* Preset edit panel */}
208
+ {editingDatasetId && (() => {
209
+ const editDs = datasets.find(d => d.id === editingDatasetId);
210
+ if (!editDs?.presetId) return null;
211
+ return (
212
+ <div className="p-3 border-t border-gray-700 space-y-2">
213
+ <div className="text-[10px] text-gray-500 uppercase font-semibold tracking-wider">Edit Preset</div>
214
+ <input
215
+ type="text" value={editPresetName} onChange={e => setEditPresetName(e.target.value)}
216
+ onKeyDown={e => {
217
+ if (e.key === "Enter" && editPresetName.trim()) { onUpdatePreset(editDs.presetId!, editDs.id, { name: editPresetName.trim() }); setEditingDatasetId(null); }
218
+ if (e.key === "Escape") setEditingDatasetId(null);
219
+ }}
220
+ placeholder="Preset name..."
221
+ 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"
222
+ autoFocus
223
+ />
224
+ <div className="flex gap-2">
225
+ <button onClick={() => { if (editPresetName.trim()) { onUpdatePreset(editDs.presetId!, editDs.id, { name: editPresetName.trim() }); setEditingDatasetId(null); } }}
226
+ disabled={!editPresetName.trim()} className="flex-1 px-2 py-1 text-xs bg-purple-600 hover:bg-purple-500 disabled:bg-gray-700 disabled:text-gray-500 rounded text-white transition-colors">Save</button>
227
+ <button onClick={() => { onDeletePreset(editDs.presetId!); setEditingDatasetId(null); }}
228
+ className="px-2 py-1 text-xs bg-red-900 hover:bg-red-800 rounded text-red-300 transition-colors">Delete</button>
229
+ <button onClick={() => setEditingDatasetId(null)}
230
+ className="px-2 py-1 text-xs bg-gray-700 hover:bg-gray-600 rounded text-gray-300 transition-colors">Cancel</button>
231
+ </div>
232
+ </div>
233
+ );
234
+ })()}
235
+
236
+ {/* Add repo */}
237
+ <div className="p-3 border-t border-gray-700">
238
+ {!showAddModal ? (
239
+ <button
240
+ onClick={() => { setEditingDatasetId(null); setShowAddModal(true); setRepoInput(""); setSplitInput("train"); }}
241
+ className="w-full px-3 py-2 text-sm bg-purple-600 hover:bg-purple-500 rounded text-white font-medium transition-colors"
242
+ >
243
+ + Add Repo
244
+ </button>
245
+ ) : (
246
+ <div className="space-y-2">
247
+ <input
248
+ type="text" value={repoInput} onChange={e => setRepoInput(e.target.value)}
249
+ onKeyDown={e => e.key === "Enter" && handleAdd()}
250
+ placeholder="org/dataset-name"
251
+ 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"
252
+ autoFocus
253
+ />
254
+ <div className="flex gap-2">
255
+ <input
256
+ type="text" value={splitInput} onChange={e => setSplitInput(e.target.value)}
257
+ placeholder="Split"
258
+ 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"
259
+ />
260
+ </div>
261
+ <div className="flex gap-2">
262
+ <button onClick={handleAdd} disabled={!repoInput.trim() || loading[repoInput.trim()]}
263
+ className="flex-1 px-2 py-1.5 text-sm bg-purple-600 hover:bg-purple-500 disabled:bg-gray-700 disabled:text-gray-500 rounded text-white transition-colors">
264
+ {loading[repoInput.trim()] ? "Loading..." : "Load"}
265
+ </button>
266
+ <button onClick={() => setShowAddModal(false)}
267
+ className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-300 transition-colors">Cancel</button>
268
+ </div>
269
+ </div>
270
+ )}
271
+ </div>
272
+ </div>
273
+ );
274
+ }
frontend/src/arena/components/TranscriptPanel.tsx ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { EpisodeData, TranscriptTurn } from "../types";
3
+ import { highlightTrace } from "../utils/traceHighlight";
4
+
5
+ export interface DragHandleProps {
6
+ draggable: true;
7
+ onDragStart: (e: React.DragEvent) => void;
8
+ onDragEnd: (e: React.DragEvent) => void;
9
+ }
10
+
11
+ interface TranscriptPanelProps {
12
+ datasetName: string;
13
+ repoName?: string;
14
+ data: EpisodeData | undefined;
15
+ dragHandleProps?: DragHandleProps;
16
+ }
17
+
18
+ const OUTCOME_STYLES = {
19
+ win: { bg: "bg-green-900", text: "text-green-300", label: "WIN" },
20
+ loss: { bg: "bg-red-900", text: "text-red-300", label: "LOSS" },
21
+ error: { bg: "bg-yellow-900", text: "text-yellow-300", label: "ERROR" },
22
+ unknown: { bg: "bg-gray-700", text: "text-gray-300", label: "?" },
23
+ };
24
+
25
+ const PLAYER_COLORS: Record<number, { bubble: string; label: string; name: string }> = {
26
+ 0: { bubble: "bg-purple-900/60 border-purple-700", label: "text-purple-400", name: "Player 0" },
27
+ 1: { bubble: "bg-orange-900/60 border-orange-700", label: "text-orange-400", name: "Player 1" },
28
+ 2: { bubble: "bg-purple-900/60 border-purple-700", label: "text-purple-400", name: "Player 2" },
29
+ 3: { bubble: "bg-teal-900/60 border-teal-700", label: "text-teal-400", name: "Player 3" },
30
+ };
31
+
32
+ function getPlayerColor(playerId: number) {
33
+ return PLAYER_COLORS[playerId] || PLAYER_COLORS[0];
34
+ }
35
+
36
+ export default function TranscriptPanel({ datasetName, repoName, data, dragHandleProps }: TranscriptPanelProps) {
37
+ if (!data) {
38
+ return (
39
+ <div className="h-full border border-gray-700 rounded-lg flex items-center justify-center">
40
+ <div className="text-gray-500 text-sm">No data</div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ const outcomeStyle = OUTCOME_STYLES[data.outcome];
46
+ const borderColor = data.outcome === "win" ? "border-green-600"
47
+ : data.outcome === "loss" ? "border-red-600"
48
+ : data.outcome === "error" ? "border-yellow-600"
49
+ : "border-gray-700";
50
+
51
+ return (
52
+ <div className={`h-full border-2 ${borderColor} rounded-lg flex flex-col bg-gray-900/50`}>
53
+ {/* Header */}
54
+ <div className="px-3 py-2 border-b border-gray-700 shrink-0">
55
+ <div className="flex items-center justify-between mb-1">
56
+ <div className="flex items-center gap-2 min-w-0">
57
+ <span className="text-sm font-semibold text-gray-200 truncate" title={repoName || datasetName}>
58
+ {datasetName}
59
+ </span>
60
+ <span className={`px-1.5 py-0.5 text-[10px] rounded font-medium ${outcomeStyle.bg} ${outcomeStyle.text}`}>
61
+ {outcomeStyle.label}
62
+ </span>
63
+ </div>
64
+ <div className="flex items-center gap-1.5 shrink-0 ml-2">
65
+ {dragHandleProps && (
66
+ <span
67
+ {...dragHandleProps}
68
+ title="Drag to reorder"
69
+ className="drag-handle text-gray-600 hover:text-gray-400 transition-colors"
70
+ >
71
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
72
+ <circle cx="5" cy="3" r="1.5" />
73
+ <circle cx="11" cy="3" r="1.5" />
74
+ <circle cx="5" cy="8" r="1.5" />
75
+ <circle cx="11" cy="8" r="1.5" />
76
+ <circle cx="5" cy="13" r="1.5" />
77
+ <circle cx="11" cy="13" r="1.5" />
78
+ </svg>
79
+ </span>
80
+ )}
81
+ </div>
82
+ </div>
83
+ <div className="flex items-center gap-3 text-[10px] text-gray-500">
84
+ <span>{data.model}</span>
85
+ <span>{data.num_turns} turns</span>
86
+ {data.reward !== null && <span>reward: {data.reward}</span>}
87
+ {data.opponent_model && <span>vs {data.opponent_model}</span>}
88
+ </div>
89
+ </div>
90
+
91
+ {/* Error banner */}
92
+ {data.error && (
93
+ <div className="px-3 py-1.5 bg-red-900/30 border-b border-red-800/50 text-xs text-red-300">
94
+ Error: {data.error}
95
+ </div>
96
+ )}
97
+
98
+ {/* Transcript chat */}
99
+ <div className="flex-1 overflow-y-auto transcript-scroll px-3 py-2 space-y-3">
100
+ {/* System prompt */}
101
+ {data.system_prompt && (
102
+ <SystemPromptBubble text={data.system_prompt} />
103
+ )}
104
+ {data.transcript.map((turn, i) => (
105
+ <TurnBubble key={i} turn={turn} hasMultiplePlayers={data.opponent_model !== null} />
106
+ ))}
107
+ </div>
108
+ </div>
109
+ );
110
+ }
111
+
112
+
113
+ function SystemPromptBubble({ text }: { text: string }) {
114
+ const [expanded, setExpanded] = useState(true);
115
+
116
+ return (
117
+ <div className="mb-2">
118
+ <button
119
+ onClick={() => setExpanded(!expanded)}
120
+ className="flex items-center gap-1.5 text-[10px] text-purple-400 hover:text-purple-300 transition-colors mb-1 font-semibold uppercase tracking-wider"
121
+ >
122
+ <span>{expanded ? "\u25BC" : "\u25B6"}</span>
123
+ <span>System Prompt</span>
124
+ </button>
125
+ {expanded && (
126
+ <div className="bg-purple-950/40 border border-purple-800/50 rounded-lg px-3 py-2">
127
+ <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-purple-200">
128
+ {text}
129
+ </pre>
130
+ </div>
131
+ )}
132
+ </div>
133
+ );
134
+ }
135
+
136
+
137
+ function TurnBubble({ turn, hasMultiplePlayers }: { turn: TranscriptTurn; hasMultiplePlayers: boolean }) {
138
+ const [thinkExpanded, setThinkExpanded] = useState(false);
139
+ const playerColor = getPlayerColor(turn.player_id);
140
+ const thinkSegments = highlightTrace(turn.think_text);
141
+
142
+ return (
143
+ <div>
144
+ {/* Turn number marker */}
145
+ <div className="text-[10px] text-gray-600 mb-1">Turn {turn.turn}</div>
146
+
147
+ {/* Observation (environment message) — left aligned */}
148
+ {turn.observation && (
149
+ <div className="flex justify-start mb-1.5">
150
+ <div className="max-w-[90%]">
151
+ <div className="text-[10px] text-gray-500 mb-0.5 font-semibold uppercase tracking-wider">ENV</div>
152
+ <div className="bg-gray-800 border border-gray-700 rounded-lg rounded-tl-none px-3 py-2">
153
+ <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-300">
154
+ {turn.observation}
155
+ </pre>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ )}
160
+
161
+ {/* Action (model response) — right aligned */}
162
+ <div className="flex justify-end">
163
+ <div className="max-w-[90%]">
164
+ <div className={`text-[10px] mb-0.5 font-semibold uppercase tracking-wider text-right ${playerColor.label}`}>
165
+ {hasMultiplePlayers ? `${playerColor.name} (${turn.player_id === 0 ? "model" : "opponent"})` : "Model"}
166
+ </div>
167
+
168
+ <div className={`border rounded-lg rounded-tr-none px-3 py-2 ${playerColor.bubble}`}>
169
+ {/* Think section — collapsible */}
170
+ {turn.think_text && (
171
+ <div className="mb-2">
172
+ <button
173
+ onClick={() => setThinkExpanded(!thinkExpanded)}
174
+ className="flex items-center gap-1 text-[10px] text-gray-500 hover:text-gray-400 transition-colors mb-1"
175
+ >
176
+ <span>{thinkExpanded ? "\u25BC" : "\u25B6"}</span>
177
+ <span>Thinking ({turn.think_len.toLocaleString()} chars{turn.backtracks > 0 ? `, ${turn.backtracks} backtracks` : ""})</span>
178
+ </button>
179
+ {thinkExpanded && (
180
+ <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono border-l-2 border-gray-600 pl-2 mt-1">
181
+ {thinkSegments.map((seg, i) => (
182
+ <span key={i} className={seg.className}>{seg.text}</span>
183
+ ))}
184
+ </pre>
185
+ )}
186
+ </div>
187
+ )}
188
+
189
+ {/* Action text (the actual game move) */}
190
+ <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-100 font-medium">
191
+ {turn.action_text || turn.action || "(no action)"}
192
+ </pre>
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
frontend/src/arena/store.ts ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useEffect, useMemo } from "react";
2
+ import type { DatasetInfo, EpisodeData, Preset, FilterMode } from "./types";
3
+ import { api } from "./api";
4
+
5
+ export function useAppState() {
6
+ const [datasets, setDatasets] = useState<DatasetInfo[]>([]);
7
+ const [presets, setPresets] = useState<Preset[]>([]);
8
+ const [filter, setFilter] = useState<FilterMode>("all");
9
+ const [episodeDataMap, setEpisodeDataMap] = useState<Record<string, EpisodeData>>({});
10
+ const [loading, setLoading] = useState<Record<string, boolean>>({});
11
+ const [error, setError] = useState<string | null>(null);
12
+
13
+ // Current env_id being viewed (grouping key)
14
+ const [currentEnvId, setCurrentEnvId] = useState<string | null>(null);
15
+ // Episode index within the current env_id
16
+ const [episodeIdx, setEpisodeIdx] = useState(0);
17
+
18
+ // Load presets on mount
19
+ useEffect(() => {
20
+ api.listPresets().then(setPresets).catch(() => {});
21
+ }, []);
22
+
23
+ // All unique env_ids across all datasets
24
+ const allEnvIds = useMemo(() => {
25
+ const envSet = new Set<string>();
26
+ for (const ds of datasets) {
27
+ for (const envId of ds.env_ids) {
28
+ envSet.add(envId);
29
+ }
30
+ }
31
+ return Array.from(envSet).sort();
32
+ }, [datasets]);
33
+
34
+ // Auto-select env_id if not set
35
+ useEffect(() => {
36
+ if (currentEnvId && allEnvIds.includes(currentEnvId)) return;
37
+ if (allEnvIds.length > 0) {
38
+ setCurrentEnvId(allEnvIds[0]);
39
+ setEpisodeIdx(0);
40
+ } else {
41
+ setCurrentEnvId(null);
42
+ }
43
+ }, [allEnvIds, currentEnvId]);
44
+
45
+ // Active datasets = datasets that are active and have the current env_id
46
+ const activeDatasets = useMemo(
47
+ () => datasets.filter(d => d.active && currentEnvId && d.env_ids.includes(currentEnvId)),
48
+ [datasets, currentEnvId]
49
+ );
50
+
51
+ // Max episodes for current env across active datasets
52
+ const maxEpisodes = useMemo(() => {
53
+ if (!currentEnvId || activeDatasets.length === 0) return 0;
54
+ return Math.min(
55
+ ...activeDatasets.map(d => d.episodes_per_env[currentEnvId] || 0)
56
+ );
57
+ }, [activeDatasets, currentEnvId]);
58
+
59
+ // Panel ordering
60
+ const [panelOrder, setPanelOrder] = useState<string[]>([]);
61
+
62
+ useEffect(() => {
63
+ const activeIds = new Set(activeDatasets.map(d => d.id));
64
+ setPanelOrder(prev => {
65
+ const kept = prev.filter(id => activeIds.has(id));
66
+ const newIds = activeDatasets.map(d => d.id).filter(id => !prev.includes(id));
67
+ const merged = [...kept, ...newIds];
68
+ if (merged.length === prev.length && merged.every((id, i) => id === prev[i])) return prev;
69
+ return merged;
70
+ });
71
+ }, [activeDatasets]);
72
+
73
+ const orderedActiveDatasets = useMemo(() => {
74
+ const map = new Map(activeDatasets.map(d => [d.id, d]));
75
+ return panelOrder.map(id => map.get(id)).filter((d): d is DatasetInfo => d !== undefined);
76
+ }, [activeDatasets, panelOrder]);
77
+
78
+ const reorderPanels = useCallback((fromId: string, toId: string) => {
79
+ if (fromId === toId) return;
80
+ setPanelOrder(prev => {
81
+ const order = [...prev];
82
+ const fromIdx = order.indexOf(fromId);
83
+ const toIdx = order.indexOf(toId);
84
+ if (fromIdx === -1 || toIdx === -1) return prev;
85
+ order.splice(fromIdx, 1);
86
+ order.splice(toIdx, 0, fromId);
87
+ return order;
88
+ });
89
+ }, []);
90
+
91
+ // Update URL
92
+ useEffect(() => {
93
+ const params = new URLSearchParams();
94
+ const activeRepos = datasets.filter(d => d.active);
95
+ if (activeRepos.length > 0) {
96
+ params.set("repos", activeRepos.map(d => d.repo).join(","));
97
+ }
98
+ if (currentEnvId) params.set("env", currentEnvId);
99
+ params.set("ep", String(episodeIdx));
100
+ if (filter !== "all") params.set("filter", filter);
101
+ const newUrl = `${window.location.pathname}?${params.toString()}`;
102
+ window.history.replaceState({}, "", newUrl);
103
+ }, [datasets, currentEnvId, episodeIdx, filter]);
104
+
105
+ // Fetch episode data for active datasets when episode or env changes
106
+ useEffect(() => {
107
+ if (!currentEnvId) return;
108
+ activeDatasets.forEach(ds => {
109
+ const key = `${ds.id}:${currentEnvId}:${episodeIdx}`;
110
+ if (!episodeDataMap[key]) {
111
+ api.getEpisode(ds.id, currentEnvId, episodeIdx).then(data => {
112
+ setEpisodeDataMap(prev => ({ ...prev, [key]: data }));
113
+ }).catch(() => {});
114
+ }
115
+ });
116
+ }, [episodeIdx, currentEnvId, activeDatasets]);
117
+
118
+ const getEpisodeData = useCallback((dsId: string): EpisodeData | undefined => {
119
+ if (!currentEnvId) return undefined;
120
+ return episodeDataMap[`${dsId}:${currentEnvId}:${episodeIdx}`];
121
+ }, [episodeDataMap, currentEnvId, episodeIdx]);
122
+
123
+ const addDataset = useCallback(async (repo: string, split?: string, presetId?: string, presetName?: string) => {
124
+ setLoading(prev => ({ ...prev, [repo]: true }));
125
+ setError(null);
126
+ try {
127
+ const result = await api.loadDataset(repo, split);
128
+ const dsInfo: DatasetInfo = {
129
+ ...result,
130
+ active: true,
131
+ presetId,
132
+ presetName,
133
+ };
134
+
135
+ setDatasets(prev => {
136
+ if (prev.some(d => d.id === dsInfo.id)) return prev;
137
+ return [...prev, dsInfo];
138
+ });
139
+
140
+ // Switch to first env_id of the new dataset
141
+ if (dsInfo.env_ids.length > 0) {
142
+ setCurrentEnvId(dsInfo.env_ids[0]);
143
+ setEpisodeIdx(0);
144
+ }
145
+ } catch (e: unknown) {
146
+ setError(e instanceof Error ? e.message : "Failed to load dataset");
147
+ } finally {
148
+ setLoading(prev => ({ ...prev, [repo]: false }));
149
+ }
150
+ }, []);
151
+
152
+ const removeDataset = useCallback(async (id: string) => {
153
+ await api.unloadDataset(id).catch(() => {});
154
+ setDatasets(prev => prev.filter(d => d.id !== id));
155
+ }, []);
156
+
157
+ const toggleDataset = useCallback((id: string) => {
158
+ setDatasets(prev => prev.map(d => (d.id === id ? { ...d, active: !d.active } : d)));
159
+ }, []);
160
+
161
+ const updateDatasetPresetName = useCallback((dsId: string, name: string) => {
162
+ setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetName: name } : d));
163
+ }, []);
164
+
165
+ const clearDatasetPreset = useCallback((dsId: string) => {
166
+ setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetId: undefined, presetName: undefined } : d));
167
+ }, []);
168
+
169
+ return {
170
+ datasets, presets, setPresets,
171
+ episodeIdx, setEpisodeIdx,
172
+ filter, setFilter,
173
+ loading, error, setError,
174
+ activeDatasets, orderedActiveDatasets, maxEpisodes,
175
+ addDataset, removeDataset, toggleDataset,
176
+ updateDatasetPresetName, clearDatasetPreset,
177
+ getEpisodeData, reorderPanels,
178
+ allEnvIds, currentEnvId, setCurrentEnvId,
179
+ };
180
+ }
frontend/src/arena/types.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface DatasetInfo {
2
+ id: string;
3
+ repo: string;
4
+ name: string;
5
+ split: string;
6
+ columns: string[];
7
+ n_rows: number;
8
+ env_ids: string[];
9
+ episodes_per_env: Record<string, number>;
10
+ model_name: string;
11
+ stats: { wins: number; losses: number; errors: number };
12
+ active: boolean;
13
+ presetId?: string;
14
+ presetName?: string;
15
+ }
16
+
17
+ export interface TranscriptTurn {
18
+ turn: number;
19
+ player_id: number;
20
+ observation: string;
21
+ action: string;
22
+ think_text: string;
23
+ action_text: string;
24
+ think_len: number;
25
+ backtracks: number;
26
+ }
27
+
28
+ export interface EpisodeData {
29
+ game_id: string;
30
+ env_id: string;
31
+ model: string;
32
+ opponent_model: string | null;
33
+ player_id: number;
34
+ reward: number | null;
35
+ num_turns: number;
36
+ error: string | null;
37
+ outcome: "win" | "loss" | "error" | "unknown";
38
+ transcript: TranscriptTurn[];
39
+ system_prompt: string | null;
40
+ episode_idx: number;
41
+ total_episodes: number;
42
+ }
43
+
44
+ export interface Preset {
45
+ id: string;
46
+ name: string;
47
+ repo: string;
48
+ split?: string;
49
+ }
50
+
51
+ export type FilterMode = "all" | "wins" | "losses" | "errors";
frontend/src/arena/utils/traceHighlight.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface HighlightSegment {
2
+ text: string;
3
+ className: string;
4
+ }
5
+
6
+ export function highlightTrace(text: string): HighlightSegment[] {
7
+ if (!text) return [];
8
+
9
+ const segments: HighlightSegment[] = [];
10
+ // Strip <think> and </think> tags for display
11
+ const cleaned = text.replace(/<\/?think>/g, "");
12
+ const lines = cleaned.split("\n");
13
+
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const line = lines[i];
16
+ const lo = line.toLowerCase().trim();
17
+
18
+ let className = "text-gray-400";
19
+
20
+ if (lo.startsWith("wait") || lo.startsWith("hmm") || lo.startsWith("but wait")) {
21
+ className = "text-yellow-400";
22
+ } else if (lo.startsWith("let me try") || lo.startsWith("let me reconsider") || lo.startsWith("let me think")) {
23
+ className = "text-cyan-400";
24
+ } else if (lo.startsWith("so the answer") || lo.startsWith("therefore") || lo.startsWith("the final") || lo.startsWith("i should")) {
25
+ className = "text-green-400 font-bold";
26
+ } else if (lo.startsWith("i give up") || lo.startsWith("i can't") || lo.startsWith("i'm stuck")) {
27
+ className = "text-red-400 font-bold";
28
+ } else if (line.includes("=") && /[+\-*/]/.test(line)) {
29
+ className = "text-gray-200";
30
+ }
31
+
32
+ segments.push({ text: line, className });
33
+ if (i < lines.length - 1) {
34
+ segments.push({ text: "\n", className: "" });
35
+ }
36
+ }
37
+
38
+ return segments;
39
+ }
frontend/src/harbor/HarborApp.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect } from "react";
2
+ import { useAppState } from "./store";
3
+ import { Sidebar } from "./components/Sidebar";
4
+ import { InstanceList } from "./components/InstanceList";
5
+ import { InfoBar } from "./components/InfoBar";
6
+ import { TrajectoryView } from "./components/TrajectoryView";
7
+ import { InstanceNav } from "./components/InstanceNav";
8
+
9
+ export default function HarborApp() {
10
+ const store = useAppState();
11
+ const { state } = store;
12
+
13
+ useEffect(() => {
14
+ store.loadPresets();
15
+ }, []);
16
+
17
+ // Keyboard shortcuts
18
+ useEffect(() => {
19
+ const handler = (e: KeyboardEvent) => {
20
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
21
+
22
+ if (e.key === "Escape" && state.viewMode === "detail") {
23
+ store.selectInstance(null);
24
+ return;
25
+ }
26
+
27
+ if (state.viewMode === "detail" && state.selectedInstanceId) {
28
+ const filtered = getFilteredInstances();
29
+ const currentIdx = filtered.findIndex(
30
+ (g) => g.instance_id === state.selectedInstanceId
31
+ );
32
+ if (e.key === "j" && currentIdx < filtered.length - 1) {
33
+ store.selectInstance(filtered[currentIdx + 1].instance_id);
34
+ } else if (e.key === "k" && currentIdx > 0) {
35
+ store.selectInstance(filtered[currentIdx - 1].instance_id);
36
+ }
37
+ }
38
+ };
39
+ window.addEventListener("keydown", handler);
40
+ return () => window.removeEventListener("keydown", handler);
41
+ });
42
+
43
+ function getFilteredInstances() {
44
+ let groups = state.groupedInstances;
45
+ if (state.filterResolved === "resolved") {
46
+ groups = groups.filter((g) =>
47
+ g.datasets.some((d) => d.summary.resolved)
48
+ );
49
+ } else if (state.filterResolved === "unresolved") {
50
+ groups = groups.filter((g) =>
51
+ g.datasets.every((d) => !d.summary.resolved)
52
+ );
53
+ }
54
+ if (state.searchQuery) {
55
+ const q = state.searchQuery.toLowerCase();
56
+ groups = groups.filter((g) => g.instance_id.toLowerCase().includes(q));
57
+ }
58
+ return groups;
59
+ }
60
+
61
+ const filtered = getFilteredInstances();
62
+
63
+ // Get details for selected instance
64
+ const selectedDetails = state.selectedInstanceId
65
+ ? state.datasets
66
+ .filter((ds) =>
67
+ ds.instances.some(
68
+ (inst) => inst.instance_id === state.selectedInstanceId
69
+ )
70
+ )
71
+ .map((ds) => ({
72
+ ds,
73
+ detail: state.instanceDetails[`${ds.id}:${state.selectedInstanceId}`],
74
+ }))
75
+ .filter((d) => d.detail)
76
+ : [];
77
+
78
+ return (
79
+ <div className="flex h-full overflow-hidden">
80
+ <Sidebar store={store} />
81
+
82
+ <div className="flex-1 flex flex-col overflow-hidden">
83
+ {state.error && (
84
+ <div className="bg-red-900/50 border-b border-red-700 px-4 py-2 text-sm text-red-200 flex justify-between items-center">
85
+ <span>{state.error}</span>
86
+ <button
87
+ onClick={() => store.setError(null)}
88
+ className="text-red-400 hover:text-red-200 ml-4"
89
+ >
90
+ dismiss
91
+ </button>
92
+ </div>
93
+ )}
94
+
95
+ {state.viewMode === "list" && (
96
+ <InstanceList
97
+ groups={filtered}
98
+ totalGroups={state.groupedInstances.length}
99
+ onSelect={(id) => store.selectInstance(id)}
100
+ filterResolved={state.filterResolved}
101
+ onFilterChange={store.setFilterResolved}
102
+ searchQuery={state.searchQuery}
103
+ onSearchChange={store.setSearchQuery}
104
+ loading={state.loading}
105
+ />
106
+ )}
107
+
108
+ {state.viewMode === "detail" && state.selectedInstanceId && (
109
+ <>
110
+ <InfoBar
111
+ instanceId={state.selectedInstanceId}
112
+ details={selectedDetails.map((d) => d.detail!)}
113
+ onBack={() => store.selectInstance(null)}
114
+ trajectoryMode={state.trajectoryMode}
115
+ onTrajectoryModeChange={store.setTrajectoryMode}
116
+ />
117
+
118
+ <div className="flex-1 flex overflow-hidden">
119
+ {selectedDetails.length === 0 && state.loading && (
120
+ <div className="flex-1 flex items-center justify-center text-gray-400">
121
+ Loading trajectory...
122
+ </div>
123
+ )}
124
+ {selectedDetails.map(({ ds, detail }) => (
125
+ <TrajectoryView
126
+ key={ds.id}
127
+ dataset={ds}
128
+ detail={detail!}
129
+ mode={state.trajectoryMode}
130
+ isSingle={selectedDetails.length === 1}
131
+ />
132
+ ))}
133
+ </div>
134
+
135
+ <InstanceNav
136
+ instances={filtered}
137
+ currentId={state.selectedInstanceId}
138
+ onSelect={(id) => store.selectInstance(id)}
139
+ />
140
+ </>
141
+ )}
142
+
143
+ {state.datasets.length === 0 && state.viewMode === "list" && (
144
+ <div className="flex-1 flex items-center justify-center text-gray-500">
145
+ <div className="text-center">
146
+ <div className="text-lg mb-2">No datasets loaded</div>
147
+ <div className="text-sm">
148
+ Load a HuggingFace repo from the sidebar or select a preset
149
+ </div>
150
+ </div>
151
+ </div>
152
+ )}
153
+ </div>
154
+ </div>
155
+ );
156
+ }
frontend/src/harbor/api.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatasetInfo, InstanceDetail, InstanceRawLogs, Preset } from "./types";
2
+
3
+ const BASE = "/api/harbor";
4
+ const PRESETS_BASE = "/api/presets/harbor";
5
+
6
+ async function fetchJson<T>(url: string, opts?: RequestInit): Promise<T> {
7
+ const res = await fetch(url, opts);
8
+ if (!res.ok) {
9
+ const body = await res.json().catch(() => ({}));
10
+ throw new Error(body.error || `HTTP ${res.status}`);
11
+ }
12
+ return res.json();
13
+ }
14
+
15
+ // Datasets
16
+ export async function loadDataset(repo: string, split = "train"): Promise<DatasetInfo> {
17
+ return fetchJson(`${BASE}/datasets/load`, {
18
+ method: "POST",
19
+ headers: { "Content-Type": "application/json" },
20
+ body: JSON.stringify({ repo, split }),
21
+ });
22
+ }
23
+
24
+ export async function listDatasets(): Promise<DatasetInfo[]> {
25
+ return fetchJson(`${BASE}/datasets/`);
26
+ }
27
+
28
+ export async function getInstance(dsId: string, instanceId: string): Promise<InstanceDetail> {
29
+ return fetchJson(`${BASE}/datasets/${dsId}/instance/${instanceId}`);
30
+ }
31
+
32
+ export async function getInstanceRaw(dsId: string, instanceId: string): Promise<InstanceRawLogs> {
33
+ return fetchJson(`${BASE}/datasets/${dsId}/instance/${instanceId}/raw`);
34
+ }
35
+
36
+ export async function unloadDataset(dsId: string): Promise<void> {
37
+ await fetchJson(`${BASE}/datasets/${dsId}`, { method: "DELETE" });
38
+ }
39
+
40
+ // Presets
41
+ export async function listPresets(): Promise<Preset[]> {
42
+ return fetchJson(PRESETS_BASE);
43
+ }
44
+
45
+ export async function createPreset(name: string, repo: string, split = "train"): Promise<Preset> {
46
+ return fetchJson(PRESETS_BASE, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({ name, repo, split }),
50
+ });
51
+ }
52
+
53
+ export async function deletePreset(id: string): Promise<void> {
54
+ await fetchJson(`${PRESETS_BASE}/${id}`, { method: "DELETE" });
55
+ }
frontend/src/harbor/components/ChatBubble.tsx ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { RawStep, RawToolCall, AtifStep } from "../types";
3
+
4
+ // ---- Raw message bubble ----
5
+
6
+ interface RawBubbleProps {
7
+ step: RawStep;
8
+ // Map of tool_call_id → tool response content for pairing
9
+ toolResponses?: Map<string, string>;
10
+ }
11
+
12
+ export function RawBubble({ step, toolResponses }: RawBubbleProps) {
13
+ const [expanded, setExpanded] = useState(false);
14
+
15
+ if (step.role === "system") {
16
+ return (
17
+ <SystemBubble
18
+ content={step.content}
19
+ expanded={expanded}
20
+ onToggle={() => setExpanded(!expanded)}
21
+ label="System"
22
+ />
23
+ );
24
+ }
25
+
26
+ if (step.role === "user") {
27
+ return (
28
+ <div className="flex justify-start mb-3">
29
+ <div className="max-w-[85%] rounded-lg px-4 py-3 bg-emerald-900/30 border border-emerald-800/50">
30
+ <div className="text-xs font-medium text-emerald-400 mb-1">Task / Environment</div>
31
+ <ContentBlock
32
+ content={step.content}
33
+ expanded={expanded}
34
+ onToggle={() => setExpanded(!expanded)}
35
+ maxPreview={600}
36
+ />
37
+ </div>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ if (step.role === "assistant") {
43
+ return (
44
+ <div className="flex justify-end mb-3">
45
+ <div className="max-w-[85%] rounded-lg px-4 py-3 bg-blue-900/30 border border-blue-800/50">
46
+ <div className="text-xs font-medium text-blue-400 mb-1">Agent</div>
47
+
48
+ {step.content && (
49
+ <ContentBlock
50
+ content={step.content}
51
+ expanded={expanded}
52
+ onToggle={() => setExpanded(!expanded)}
53
+ maxPreview={400}
54
+ />
55
+ )}
56
+
57
+ {step.tool_calls && step.tool_calls.length > 0 && (
58
+ <div className="mt-2 space-y-2">
59
+ {step.tool_calls.map((tc, i) => (
60
+ <ToolCallBlock
61
+ key={i}
62
+ toolCall={tc}
63
+ response={toolResponses?.get(tc.id)}
64
+ />
65
+ ))}
66
+ </div>
67
+ )}
68
+ </div>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ if (step.role === "tool") {
74
+ // Tool responses are shown inline with their tool_call above when possible
75
+ // Only render standalone if not paired
76
+ return (
77
+ <div className="flex justify-start mb-3">
78
+ <div className="max-w-[85%] rounded-lg px-3 py-2 bg-gray-800 border border-gray-700">
79
+ <div className="text-xs font-medium text-gray-400 mb-1">
80
+ Tool Output
81
+ </div>
82
+ <ContentBlock
83
+ content={step.content}
84
+ expanded={expanded}
85
+ onToggle={() => setExpanded(!expanded)}
86
+ maxPreview={500}
87
+ mono
88
+ />
89
+ </div>
90
+ </div>
91
+ );
92
+ }
93
+
94
+ // Unknown role
95
+ return (
96
+ <div className="flex justify-center mb-3">
97
+ <div className="rounded-lg px-4 py-2 bg-gray-800/50 text-xs text-gray-500">
98
+ [{step.role}] {(step.content || "").slice(0, 200)}
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ // ---- ATIF step bubble ----
105
+
106
+ interface AtifBubbleProps {
107
+ step: AtifStep;
108
+ }
109
+
110
+ export function AtifBubble({ step }: AtifBubbleProps) {
111
+ const [expanded, setExpanded] = useState(false);
112
+
113
+ if (step.source === "system") {
114
+ return (
115
+ <SystemBubble
116
+ content={step.message}
117
+ expanded={expanded}
118
+ onToggle={() => setExpanded(!expanded)}
119
+ label="System"
120
+ />
121
+ );
122
+ }
123
+
124
+ if (step.source === "user") {
125
+ return (
126
+ <div className="flex justify-start mb-3">
127
+ <div className="max-w-[85%] rounded-lg px-4 py-3 bg-emerald-900/30 border border-emerald-800/50">
128
+ <div className="text-xs font-medium text-emerald-400 mb-1">Task</div>
129
+ <ContentBlock
130
+ content={step.message}
131
+ expanded={expanded}
132
+ onToggle={() => setExpanded(!expanded)}
133
+ maxPreview={600}
134
+ />
135
+ </div>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ if (step.source === "agent") {
141
+ return (
142
+ <div className="flex justify-end mb-3">
143
+ <div className="max-w-[85%] space-y-2">
144
+ {/* Reasoning */}
145
+ {step.reasoning && (
146
+ <div className="rounded-lg px-4 py-3 bg-violet-900/20 border border-violet-800/30">
147
+ <div className="text-xs font-medium text-violet-400 mb-1">Reasoning</div>
148
+ <ContentBlock
149
+ content={step.reasoning}
150
+ expanded={expanded}
151
+ onToggle={() => setExpanded(!expanded)}
152
+ maxPreview={300}
153
+ />
154
+ </div>
155
+ )}
156
+
157
+ {/* Message / action */}
158
+ <div className="rounded-lg px-4 py-3 bg-blue-900/30 border border-blue-800/50">
159
+ <div className="text-xs font-medium text-blue-400 mb-1">Agent</div>
160
+ {step.message && (
161
+ <ContentBlock
162
+ content={step.message}
163
+ expanded={expanded}
164
+ onToggle={() => setExpanded(!expanded)}
165
+ maxPreview={400}
166
+ />
167
+ )}
168
+
169
+ {step.tool_calls && step.tool_calls.length > 0 && (
170
+ <div className="mt-2 space-y-1">
171
+ {step.tool_calls.map((tc, i) => (
172
+ <div
173
+ key={i}
174
+ className="rounded bg-amber-900/30 border border-amber-800/30 px-3 py-2"
175
+ >
176
+ <div className="text-xs text-amber-400 font-medium">
177
+ {tc.function}
178
+ </div>
179
+ {tc.command && (
180
+ <pre className="code-block text-amber-200 mt-1 whitespace-pre-wrap break-all">
181
+ {tc.command}
182
+ </pre>
183
+ )}
184
+ </div>
185
+ ))}
186
+ </div>
187
+ )}
188
+ </div>
189
+
190
+ {/* Observation */}
191
+ {step.observation && (
192
+ <div className="rounded-lg px-3 py-2 bg-gray-800 border border-gray-700 self-start">
193
+ <div className="text-xs font-medium text-gray-400 mb-1">Output</div>
194
+ <ContentBlock
195
+ content={step.observation}
196
+ expanded={expanded}
197
+ onToggle={() => setExpanded(!expanded)}
198
+ maxPreview={500}
199
+ mono
200
+ />
201
+ </div>
202
+ )}
203
+
204
+ {/* Step metrics */}
205
+ {step.metrics && Object.keys(step.metrics).length > 0 && (
206
+ <div className="flex gap-2 flex-wrap">
207
+ {Object.entries(step.metrics).map(([k, v]) => (
208
+ <span
209
+ key={k}
210
+ className="px-1.5 py-0.5 rounded text-xs bg-gray-800 text-gray-500"
211
+ >
212
+ {k}: {typeof v === "number" ? v.toFixed(2) : String(v)}
213
+ </span>
214
+ ))}
215
+ </div>
216
+ )}
217
+ </div>
218
+ </div>
219
+ );
220
+ }
221
+
222
+ return null;
223
+ }
224
+
225
+ // ---- Shared components ----
226
+
227
+ function SystemBubble({
228
+ content,
229
+ expanded,
230
+ onToggle,
231
+ label,
232
+ }: {
233
+ content: string;
234
+ expanded: boolean;
235
+ onToggle: () => void;
236
+ label: string;
237
+ }) {
238
+ return (
239
+ <div className="flex justify-center mb-3">
240
+ <div className="max-w-[90%] rounded-lg px-4 py-2 bg-violet-900/20 border border-violet-800/30">
241
+ <button
242
+ onClick={onToggle}
243
+ className="text-xs font-medium text-violet-400 hover:text-violet-300 w-full text-left"
244
+ >
245
+ {label} {expanded ? "[-]" : `[+] (${content.length} chars)`}
246
+ </button>
247
+ {expanded && (
248
+ <div className="mt-2 text-xs text-gray-300 whitespace-pre-wrap break-words max-h-96 overflow-y-auto">
249
+ {content}
250
+ </div>
251
+ )}
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+
257
+ function ContentBlock({
258
+ content,
259
+ expanded,
260
+ onToggle,
261
+ maxPreview,
262
+ mono,
263
+ }: {
264
+ content: string;
265
+ expanded: boolean;
266
+ onToggle: () => void;
267
+ maxPreview: number;
268
+ mono?: boolean;
269
+ }) {
270
+ const isLong = content.length > maxPreview;
271
+ const display = expanded || !isLong ? content : content.slice(0, maxPreview) + "...";
272
+
273
+ return (
274
+ <div>
275
+ <div
276
+ className={`text-sm text-gray-200 whitespace-pre-wrap break-words ${
277
+ mono ? "code-block" : ""
278
+ } ${expanded ? "max-h-[600px] overflow-y-auto" : ""}`}
279
+ >
280
+ {display}
281
+ </div>
282
+ {isLong && (
283
+ <button
284
+ onClick={onToggle}
285
+ className="text-xs text-blue-400 hover:text-blue-300 mt-1"
286
+ >
287
+ {expanded ? "Show less" : `Show all (${content.length} chars)`}
288
+ </button>
289
+ )}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ function ToolCallBlock({
295
+ toolCall,
296
+ response,
297
+ }: {
298
+ toolCall: RawToolCall;
299
+ response?: string;
300
+ }) {
301
+ const [expanded, setExpanded] = useState(false);
302
+ const cmd = toolCall.command || toolCall.arguments_raw;
303
+
304
+ return (
305
+ <div className="rounded bg-amber-900/30 border border-amber-800/30 px-3 py-2">
306
+ <div className="flex items-center justify-between">
307
+ <span className="text-xs text-amber-400 font-medium">
308
+ {toolCall.function}
309
+ </span>
310
+ <button
311
+ onClick={() => setExpanded(!expanded)}
312
+ className="text-xs text-gray-500 hover:text-gray-300"
313
+ >
314
+ {expanded ? "[-]" : "[+]"}
315
+ </button>
316
+ </div>
317
+
318
+ {cmd && (
319
+ <pre className="code-block text-amber-200 mt-1 whitespace-pre-wrap break-all max-h-32 overflow-y-auto">
320
+ {expanded ? cmd : cmd.length > 300 ? cmd.slice(0, 300) + "..." : cmd}
321
+ </pre>
322
+ )}
323
+
324
+ {expanded && response && (
325
+ <div className="mt-2 rounded bg-gray-800/80 px-2 py-1.5 max-h-64 overflow-y-auto">
326
+ <div className="text-xs text-gray-400 mb-1">Output:</div>
327
+ <pre className="code-block text-gray-300 whitespace-pre-wrap break-all">
328
+ {response}
329
+ </pre>
330
+ </div>
331
+ )}
332
+ </div>
333
+ );
334
+ }
frontend/src/harbor/components/InfoBar.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { InstanceDetail, TrajectoryMode } from "../types";
2
+ import { MetricsSummary } from "./MetricsSummary";
3
+
4
+ interface Props {
5
+ instanceId: string;
6
+ details: InstanceDetail[];
7
+ onBack: () => void;
8
+ trajectoryMode: TrajectoryMode;
9
+ onTrajectoryModeChange: (mode: TrajectoryMode) => void;
10
+ }
11
+
12
+ export function InfoBar({
13
+ instanceId,
14
+ details,
15
+ onBack,
16
+ trajectoryMode,
17
+ onTrajectoryModeChange,
18
+ }: Props) {
19
+ const parts = instanceId.split("__");
20
+ const repo = parts[0] || "";
21
+ const issue = parts.slice(1).join("__") || "";
22
+
23
+ return (
24
+ <div className="bg-gray-900 border-b border-gray-800 px-4 py-3">
25
+ <div className="flex items-center justify-between">
26
+ <div className="flex items-center gap-3">
27
+ <button
28
+ onClick={onBack}
29
+ className="text-gray-400 hover:text-gray-200 text-sm"
30
+ title="Back to list (Esc)"
31
+ >
32
+ &larr; Back
33
+ </button>
34
+ <div>
35
+ <span className="text-xs text-gray-500">{repo} / </span>
36
+ <span className="text-sm font-medium text-gray-100">{issue}</span>
37
+ </div>
38
+ </div>
39
+
40
+ <div className="flex items-center gap-4">
41
+ {/* Trajectory mode toggle */}
42
+ <div className="flex gap-1">
43
+ {(["raw", "atif"] as const).map((mode) => (
44
+ <button
45
+ key={mode}
46
+ onClick={() => onTrajectoryModeChange(mode)}
47
+ className={`px-2 py-1 rounded text-xs font-medium ${
48
+ trajectoryMode === mode
49
+ ? "bg-blue-600 text-white"
50
+ : "bg-gray-800 text-gray-400 hover:text-gray-200"
51
+ }`}
52
+ >
53
+ {mode === "raw" ? "Raw Messages" : "ATIF Steps"}
54
+ </button>
55
+ ))}
56
+ </div>
57
+
58
+ {/* Metrics from all loaded details */}
59
+ {details.map((d, i) => (
60
+ <MetricsSummary key={i} detail={d} />
61
+ ))}
62
+ </div>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
frontend/src/harbor/components/InstanceList.tsx ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { GroupedInstance } from "../types";
2
+
3
+ interface Props {
4
+ groups: GroupedInstance[];
5
+ totalGroups: number;
6
+ onSelect: (instanceId: string) => void;
7
+ filterResolved: "all" | "resolved" | "unresolved";
8
+ onFilterChange: (f: "all" | "resolved" | "unresolved") => void;
9
+ searchQuery: string;
10
+ onSearchChange: (q: string) => void;
11
+ loading: boolean;
12
+ }
13
+
14
+ export function InstanceList({
15
+ groups,
16
+ totalGroups,
17
+ onSelect,
18
+ filterResolved,
19
+ onFilterChange,
20
+ searchQuery,
21
+ onSearchChange,
22
+ loading,
23
+ }: Props) {
24
+ return (
25
+ <div className="flex-1 flex flex-col overflow-hidden">
26
+ {/* Toolbar */}
27
+ <div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800 bg-gray-900/50">
28
+ <input
29
+ value={searchQuery}
30
+ onChange={(e) => onSearchChange(e.target.value)}
31
+ placeholder="Search instances..."
32
+ 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"
33
+ />
34
+
35
+ <div className="flex gap-1">
36
+ {(["all", "resolved", "unresolved"] as const).map((f) => (
37
+ <button
38
+ key={f}
39
+ onClick={() => onFilterChange(f)}
40
+ className={`px-2.5 py-1 rounded text-xs font-medium ${
41
+ filterResolved === f
42
+ ? "bg-blue-600 text-white"
43
+ : "bg-gray-800 text-gray-400 hover:text-gray-200"
44
+ }`}
45
+ >
46
+ {f}
47
+ </button>
48
+ ))}
49
+ </div>
50
+
51
+ <span className="text-xs text-gray-500">
52
+ {groups.length === totalGroups
53
+ ? `${groups.length} instances`
54
+ : `${groups.length} / ${totalGroups} instances`}
55
+ </span>
56
+
57
+ {loading && (
58
+ <span className="text-xs text-blue-400 animate-pulse">Loading...</span>
59
+ )}
60
+ </div>
61
+
62
+ {/* Instance grid */}
63
+ <div className="flex-1 overflow-y-auto p-4">
64
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
65
+ {groups.map((g) => (
66
+ <InstanceCard key={g.instance_id} group={g} onClick={() => onSelect(g.instance_id)} />
67
+ ))}
68
+ </div>
69
+ {groups.length === 0 && !loading && (
70
+ <div className="text-center text-gray-500 mt-12">
71
+ No instances match your filters
72
+ </div>
73
+ )}
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function InstanceCard({
80
+ group,
81
+ onClick,
82
+ }: {
83
+ group: GroupedInstance;
84
+ onClick: () => void;
85
+ }) {
86
+ const anyResolved = group.datasets.some((d) => d.summary.resolved);
87
+ const allResolved = group.datasets.every((d) => d.summary.resolved);
88
+
89
+ // Parse instance_id: "repo__issue-number"
90
+ const parts = group.instance_id.split("__");
91
+ const repo = parts[0] || "";
92
+ const issue = parts.slice(1).join("__") || "";
93
+
94
+ return (
95
+ <button
96
+ onClick={onClick}
97
+ className="text-left bg-gray-900 border border-gray-800 rounded-lg p-3 hover:border-gray-600 hover:bg-gray-800/50 transition-colors"
98
+ >
99
+ <div className="flex items-start justify-between gap-2">
100
+ <div className="min-w-0 flex-1">
101
+ <div className="text-xs text-gray-500 truncate">{repo}</div>
102
+ <div className="text-sm text-gray-200 font-medium truncate mt-0.5">
103
+ {issue}
104
+ </div>
105
+ </div>
106
+ <span
107
+ className={`shrink-0 px-1.5 py-0.5 rounded text-xs font-medium ${
108
+ allResolved
109
+ ? "bg-emerald-900/50 text-emerald-300"
110
+ : anyResolved
111
+ ? "bg-yellow-900/50 text-yellow-300"
112
+ : "bg-red-900/50 text-red-300"
113
+ }`}
114
+ >
115
+ {allResolved ? "pass" : anyResolved ? "partial" : "fail"}
116
+ </span>
117
+ </div>
118
+
119
+ <div className="mt-2 flex flex-wrap gap-1">
120
+ {group.datasets.map((d) => (
121
+ <span
122
+ key={d.ds_id}
123
+ className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${
124
+ d.summary.resolved
125
+ ? "bg-emerald-900/30 text-emerald-400"
126
+ : "bg-gray-800 text-gray-500"
127
+ }`}
128
+ title={d.repo}
129
+ >
130
+ <span
131
+ className={`w-1.5 h-1.5 rounded-full ${
132
+ d.summary.resolved ? "bg-emerald-400" : "bg-gray-600"
133
+ }`}
134
+ />
135
+ {d.name.length > 30 ? d.name.slice(0, 28) + ".." : d.name}
136
+ </span>
137
+ ))}
138
+ </div>
139
+
140
+ {group.datasets.length > 0 && (
141
+ <div className="mt-2 text-xs text-gray-500">
142
+ {group.datasets.length} agent{group.datasets.length > 1 ? "s" : ""}
143
+ {group.datasets[0].summary.duration_seconds > 0 && (
144
+ <span className="ml-2">
145
+ {Math.round(group.datasets[0].summary.duration_seconds)}s
146
+ </span>
147
+ )}
148
+ </div>
149
+ )}
150
+ </button>
151
+ );
152
+ }
frontend/src/harbor/components/InstanceNav.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { GroupedInstance } from "../types";
2
+
3
+ interface Props {
4
+ instances: GroupedInstance[];
5
+ currentId: string;
6
+ onSelect: (id: string) => void;
7
+ }
8
+
9
+ export function InstanceNav({ instances, currentId, onSelect }: Props) {
10
+ const currentIdx = instances.findIndex((g) => g.instance_id === currentId);
11
+
12
+ return (
13
+ <div className="bg-gray-900 border-t border-gray-800 px-4 py-2 flex items-center justify-between">
14
+ <button
15
+ onClick={() => currentIdx > 0 && onSelect(instances[currentIdx - 1].instance_id)}
16
+ disabled={currentIdx <= 0}
17
+ className="px-3 py-1 rounded text-xs bg-gray-800 text-gray-300 hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed"
18
+ >
19
+ &larr; Prev (k)
20
+ </button>
21
+
22
+ <div className="flex items-center gap-2">
23
+ <span className="text-xs text-gray-500">
24
+ {currentIdx + 1} / {instances.length}
25
+ </span>
26
+
27
+ {/* Dot navigation for nearby instances */}
28
+ <div className="flex gap-0.5">
29
+ {instances.slice(Math.max(0, currentIdx - 5), currentIdx + 6).map((g) => (
30
+ <button
31
+ key={g.instance_id}
32
+ onClick={() => onSelect(g.instance_id)}
33
+ className={`w-2 h-2 rounded-full ${
34
+ g.instance_id === currentId
35
+ ? "bg-blue-400"
36
+ : g.datasets.every((d) => d.summary.resolved)
37
+ ? "bg-emerald-600"
38
+ : "bg-gray-600"
39
+ }`}
40
+ title={g.instance_id}
41
+ />
42
+ ))}
43
+ </div>
44
+ </div>
45
+
46
+ <button
47
+ onClick={() =>
48
+ currentIdx < instances.length - 1 &&
49
+ onSelect(instances[currentIdx + 1].instance_id)
50
+ }
51
+ disabled={currentIdx >= instances.length - 1}
52
+ className="px-3 py-1 rounded text-xs bg-gray-800 text-gray-300 hover:bg-gray-700 disabled:opacity-30 disabled:cursor-not-allowed"
53
+ >
54
+ Next (j) &rarr;
55
+ </button>
56
+ </div>
57
+ );
58
+ }
frontend/src/harbor/components/MetricsSummary.tsx ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { InstanceDetail } from "../types";
2
+
3
+ interface Props {
4
+ detail: InstanceDetail;
5
+ }
6
+
7
+ export function MetricsSummary({ detail }: Props) {
8
+ const fm = detail.atif?.final_metrics || {};
9
+
10
+ return (
11
+ <div className="flex items-center gap-2">
12
+ <span
13
+ className={`px-2 py-0.5 rounded text-xs font-medium ${
14
+ detail.resolved
15
+ ? "bg-emerald-900/50 text-emerald-300"
16
+ : "bg-red-900/50 text-red-300"
17
+ }`}
18
+ >
19
+ {detail.resolved ? "RESOLVED" : "FAILED"}
20
+ </span>
21
+
22
+ {detail.reward !== undefined && detail.reward > 0 && (
23
+ <span className="px-2 py-0.5 rounded text-xs bg-purple-900/50 text-purple-300">
24
+ reward: {detail.reward}
25
+ </span>
26
+ )}
27
+
28
+ {detail.duration_seconds > 0 && (
29
+ <span className="px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-400">
30
+ {Math.round(detail.duration_seconds)}s
31
+ </span>
32
+ )}
33
+
34
+ {fm.total_cost !== undefined && (
35
+ <span className="px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-400">
36
+ ${fm.total_cost?.toFixed(3)}
37
+ </span>
38
+ )}
39
+
40
+ {detail.n_raw_steps > 0 && (
41
+ <span className="px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-400">
42
+ {detail.n_raw_steps} msgs
43
+ </span>
44
+ )}
45
+
46
+ <span className="text-xs text-gray-500 truncate max-w-[120px]" title={detail.agent}>
47
+ {detail.agent || detail.model}
48
+ </span>
49
+ </div>
50
+ );
51
+ }
frontend/src/harbor/components/Sidebar.tsx ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { Preset } from "../types";
3
+
4
+ interface Props {
5
+ store: {
6
+ state: {
7
+ datasets: { id: string; repo: string; name: string; split: string; n_instances: number }[];
8
+ presets: Preset[];
9
+ loading: boolean;
10
+ };
11
+ loadDataset: (repo: string, split?: string) => Promise<void>;
12
+ unloadDataset: (dsId: string) => Promise<void>;
13
+ createPreset: (name: string, repo: string, split?: string) => Promise<void>;
14
+ deletePreset: (id: string) => Promise<void>;
15
+ loadPreset: (preset: Preset) => Promise<void>;
16
+ };
17
+ }
18
+
19
+ export function Sidebar({ store }: Props) {
20
+ const [repoInput, setRepoInput] = useState("");
21
+ const [splitInput, setSplitInput] = useState("train");
22
+ const [showAddForm, setShowAddForm] = useState(false);
23
+ // Per-dataset save-as-preset state
24
+ const [savingForDsId, setSavingForDsId] = useState<string | null>(null);
25
+ const [presetName, setPresetName] = useState("");
26
+ const [presetSearch, setPresetSearch] = useState("");
27
+
28
+ const handleLoad = async () => {
29
+ const repo = repoInput.trim();
30
+ if (!repo) return;
31
+ await store.loadDataset(repo, splitInput.trim() || "train");
32
+ setRepoInput("");
33
+ setShowAddForm(false);
34
+ };
35
+
36
+ const handleSavePreset = async (ds: { repo: string; split: string }) => {
37
+ if (!presetName.trim()) return;
38
+ await store.createPreset(presetName.trim(), ds.repo, ds.split);
39
+ setPresetName("");
40
+ setSavingForDsId(null);
41
+ };
42
+
43
+ return (
44
+ <div className="w-72 bg-gray-900 border-r border-gray-800 flex flex-col h-full overflow-hidden">
45
+ {/* Header */}
46
+ <div className="p-4 border-b border-gray-800">
47
+ <h1 className="text-lg font-semibold text-gray-100">Harbor Trace Viz</h1>
48
+ <p className="text-xs text-gray-500 mt-1">Harbor agent trajectory viewer</p>
49
+ </div>
50
+
51
+ {/* Presets */}
52
+ <div className="p-3 border-b border-gray-800">
53
+ <div className="flex items-center justify-between mb-2">
54
+ <span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
55
+ Presets
56
+ </span>
57
+ </div>
58
+
59
+ {store.state.presets.length === 0 ? (
60
+ <div className="text-xs text-gray-600 italic">No saved presets</div>
61
+ ) : (
62
+ <>
63
+ {store.state.presets.length > 6 && (
64
+ <input
65
+ type="text"
66
+ value={presetSearch}
67
+ onChange={(e) => setPresetSearch(e.target.value)}
68
+ placeholder="Search presets..."
69
+ 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"
70
+ />
71
+ )}
72
+ <div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto">
73
+ {store.state.presets
74
+ .filter((p) =>
75
+ !presetSearch ||
76
+ p.name.toLowerCase().includes(presetSearch.toLowerCase()) ||
77
+ p.repo.toLowerCase().includes(presetSearch.toLowerCase())
78
+ )
79
+ .map((p) => (
80
+ <div key={p.id} className="group relative">
81
+ <button
82
+ onClick={() => store.loadPreset(p)}
83
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 rounded border border-gray-600 text-gray-300 transition-colors"
84
+ title={`${p.repo} (${p.split})`}
85
+ >
86
+ {p.name}
87
+ </button>
88
+ <div className="hidden group-hover:flex absolute top-full left-0 mt-1 z-10 gap-1">
89
+ <button
90
+ onClick={() => store.deletePreset(p.id)}
91
+ className="px-1.5 py-0.5 text-[10px] bg-red-900 hover:bg-red-800 rounded text-red-300"
92
+ >
93
+ Delete
94
+ </button>
95
+ </div>
96
+ </div>
97
+ ))}
98
+ </div>
99
+ </>
100
+ )}
101
+ </div>
102
+
103
+ {/* Loaded datasets */}
104
+ <div className="flex-1 overflow-y-auto p-3">
105
+ <div className="text-xs font-medium text-gray-400 uppercase tracking-wide mb-2">
106
+ Loaded ({store.state.datasets.length})
107
+ </div>
108
+ {store.state.datasets.map((ds) => (
109
+ <div key={ds.id}>
110
+ <div className="group flex items-start justify-between py-2 border-b border-gray-800/50">
111
+ <div className="flex-1 min-w-0">
112
+ <div className="text-xs text-gray-200 truncate font-medium" title={ds.repo}>
113
+ {ds.name}
114
+ </div>
115
+ <div className="text-xs text-gray-500 mt-0.5">
116
+ {ds.n_instances} instances
117
+ </div>
118
+ </div>
119
+ {/* Save as preset */}
120
+ <button
121
+ onClick={() => {
122
+ setSavingForDsId(savingForDsId === ds.id ? null : ds.id);
123
+ setPresetName("");
124
+ }}
125
+ className={`text-xs ml-2 mt-0.5 transition-colors shrink-0 ${
126
+ savingForDsId === ds.id
127
+ ? "text-teal-400"
128
+ : "text-gray-600 hover:text-teal-400 opacity-0 group-hover:opacity-100"
129
+ }`}
130
+ title="Save as preset"
131
+ >
132
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
133
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
134
+ </svg>
135
+ </button>
136
+ {/* Remove */}
137
+ <button
138
+ onClick={() => store.unloadDataset(ds.id)}
139
+ className="text-gray-600 hover:text-red-400 text-xs opacity-0 group-hover:opacity-100 ml-1 mt-0.5 shrink-0"
140
+ title="Remove"
141
+ >
142
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
143
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
144
+ </svg>
145
+ </button>
146
+ </div>
147
+ {/* Inline save-as-preset form */}
148
+ {savingForDsId === ds.id && (
149
+ <div className="flex gap-1 mt-1 mb-2">
150
+ <input
151
+ type="text"
152
+ value={presetName}
153
+ onChange={(e) => setPresetName(e.target.value)}
154
+ onKeyDown={(e) => {
155
+ if (e.key === "Enter") handleSavePreset(ds);
156
+ if (e.key === "Escape") setSavingForDsId(null);
157
+ }}
158
+ placeholder="Preset name..."
159
+ 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"
160
+ autoFocus
161
+ />
162
+ <button
163
+ onClick={() => handleSavePreset(ds)}
164
+ className="px-2 py-1 text-xs bg-teal-600 hover:bg-teal-500 rounded text-white"
165
+ >
166
+ Save
167
+ </button>
168
+ </div>
169
+ )}
170
+ </div>
171
+ ))}
172
+ </div>
173
+
174
+ {/* Add repo */}
175
+ <div className="p-3 border-t border-gray-800">
176
+ {!showAddForm ? (
177
+ <button
178
+ onClick={() => {
179
+ setShowAddForm(true);
180
+ setRepoInput("");
181
+ setSplitInput("train");
182
+ }}
183
+ className="w-full px-3 py-2 text-sm bg-teal-600 hover:bg-teal-500 rounded text-white font-medium transition-colors"
184
+ >
185
+ + Add Repo
186
+ </button>
187
+ ) : (
188
+ <div className="space-y-2">
189
+ <input
190
+ type="text"
191
+ value={repoInput}
192
+ onChange={(e) => setRepoInput(e.target.value)}
193
+ onKeyDown={(e) => e.key === "Enter" && handleLoad()}
194
+ placeholder="org/repo-name"
195
+ 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"
196
+ autoFocus
197
+ />
198
+ <input
199
+ type="text"
200
+ value={splitInput}
201
+ onChange={(e) => setSplitInput(e.target.value)}
202
+ placeholder="Split"
203
+ 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"
204
+ />
205
+ <div className="flex gap-2">
206
+ <button
207
+ onClick={handleLoad}
208
+ disabled={store.state.loading || !repoInput.trim()}
209
+ className="flex-1 px-2 py-1.5 text-sm bg-teal-600 hover:bg-teal-500 disabled:bg-gray-700 disabled:text-gray-500 rounded text-white transition-colors"
210
+ >
211
+ {store.state.loading ? "Loading..." : "Load"}
212
+ </button>
213
+ <button
214
+ onClick={() => setShowAddForm(false)}
215
+ className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-300 transition-colors"
216
+ >
217
+ Cancel
218
+ </button>
219
+ </div>
220
+ </div>
221
+ )}
222
+ </div>
223
+ </div>
224
+ );
225
+ }
frontend/src/harbor/components/StepDetail.tsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { InstanceRawLogs } from "../types";
3
+ import * as api from "../api";
4
+
5
+ interface Props {
6
+ dsId: string;
7
+ instanceId: string;
8
+ }
9
+
10
+ export function StepDetail({ dsId, instanceId }: Props) {
11
+ const [logs, setLogs] = useState<InstanceRawLogs | null>(null);
12
+ const [loading, setLoading] = useState(false);
13
+ const [activeTab, setActiveTab] = useState<
14
+ "agent_stdout" | "setup_stderr" | "verifier_report" | "verifier_stdout"
15
+ >("agent_stdout");
16
+
17
+ const loadLogs = async () => {
18
+ setLoading(true);
19
+ try {
20
+ const data = await api.getInstanceRaw(dsId, instanceId);
21
+ setLogs(data);
22
+ } catch {
23
+ // ignore
24
+ }
25
+ setLoading(false);
26
+ };
27
+
28
+ if (!logs) {
29
+ return (
30
+ <div className="p-4 border-t border-gray-800">
31
+ <button
32
+ onClick={loadLogs}
33
+ disabled={loading}
34
+ className="px-3 py-1.5 bg-gray-800 rounded text-xs text-gray-300 hover:bg-gray-700 disabled:opacity-50"
35
+ >
36
+ {loading ? "Loading..." : "Load Raw Logs"}
37
+ </button>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ const tabs = [
43
+ { key: "agent_stdout" as const, label: "Agent Stdout", content: logs.agent_stdout },
44
+ { key: "setup_stderr" as const, label: "Setup Stderr", content: logs.setup_stderr },
45
+ { key: "verifier_report" as const, label: "Verifier Report", content: logs.verifier_report },
46
+ { key: "verifier_stdout" as const, label: "Verifier Stdout", content: logs.verifier_stdout },
47
+ ].filter((t) => t.content);
48
+
49
+ return (
50
+ <div className="border-t border-gray-800">
51
+ <div className="flex border-b border-gray-800">
52
+ {tabs.map((tab) => (
53
+ <button
54
+ key={tab.key}
55
+ onClick={() => setActiveTab(tab.key)}
56
+ className={`px-3 py-2 text-xs font-medium ${
57
+ activeTab === tab.key
58
+ ? "text-blue-400 border-b-2 border-blue-400"
59
+ : "text-gray-500 hover:text-gray-300"
60
+ }`}
61
+ >
62
+ {tab.label}
63
+ </button>
64
+ ))}
65
+ </div>
66
+
67
+ <div className="max-h-96 overflow-y-auto p-3">
68
+ <pre className="code-block text-gray-300 whitespace-pre-wrap break-words">
69
+ {tabs.find((t) => t.key === activeTab)?.content || "No content"}
70
+ </pre>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
frontend/src/harbor/components/TrajectoryView.tsx ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useRef, useEffect } from "react";
2
+ import type { DatasetInfo, InstanceDetail, TrajectoryMode, RawStep } from "../types";
3
+ import { RawBubble, AtifBubble } from "./ChatBubble";
4
+ import { StepDetail } from "./StepDetail";
5
+
6
+ interface Props {
7
+ dataset: DatasetInfo;
8
+ detail: InstanceDetail;
9
+ mode: TrajectoryMode;
10
+ isSingle: boolean;
11
+ }
12
+
13
+ export function TrajectoryView({ dataset, detail, mode, isSingle }: Props) {
14
+ const scrollRef = useRef<HTMLDivElement>(null);
15
+
16
+ useEffect(() => {
17
+ if (scrollRef.current) {
18
+ scrollRef.current.scrollTop = 0;
19
+ }
20
+ }, [detail.instance_id, mode]);
21
+
22
+ // Build tool_call_id → response map for raw mode
23
+ const toolResponseMap = new Map<string, string>();
24
+ if (mode === "raw") {
25
+ for (const step of detail.raw_steps) {
26
+ if (step.role === "tool" && step.tool_call_id) {
27
+ toolResponseMap.set(step.tool_call_id, step.content);
28
+ }
29
+ }
30
+ }
31
+
32
+ // For raw mode, group assistant messages with their tool responses
33
+ // to avoid showing tool responses twice
34
+ const pairedToolCallIds = new Set<string>();
35
+ if (mode === "raw") {
36
+ for (const step of detail.raw_steps) {
37
+ if (step.role === "assistant" && step.tool_calls) {
38
+ for (const tc of step.tool_calls) {
39
+ if (toolResponseMap.has(tc.id)) {
40
+ pairedToolCallIds.add(tc.id);
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+
47
+ // Filter out standalone tool messages that are already paired
48
+ const filteredRawSteps: RawStep[] =
49
+ mode === "raw"
50
+ ? detail.raw_steps.filter((step) => {
51
+ if (step.role === "tool" && step.tool_call_id) {
52
+ return !pairedToolCallIds.has(step.tool_call_id);
53
+ }
54
+ return true;
55
+ })
56
+ : [];
57
+
58
+ return (
59
+ <div
60
+ className={`flex flex-col overflow-hidden ${
61
+ isSingle ? "flex-1" : "flex-1 border-r border-gray-800 last:border-r-0"
62
+ }`}
63
+ >
64
+ {/* Header */}
65
+ <div className="px-4 py-2 bg-gray-900/30 border-b border-gray-800 flex items-center justify-between">
66
+ <div className="flex items-center gap-2">
67
+ <span
68
+ className={`w-2 h-2 rounded-full ${
69
+ detail.resolved ? "bg-emerald-400" : "bg-red-400"
70
+ }`}
71
+ />
72
+ <span className="text-xs text-gray-300 truncate" title={dataset.repo}>
73
+ {dataset.name}
74
+ </span>
75
+ </div>
76
+ <div className="flex items-center gap-2">
77
+ <span className="text-xs text-gray-500">
78
+ {detail.agent || detail.model}
79
+ </span>
80
+ {detail.duration_seconds > 0 && (
81
+ <span className="text-xs text-gray-600">
82
+ {Math.round(detail.duration_seconds)}s
83
+ </span>
84
+ )}
85
+ </div>
86
+ </div>
87
+
88
+ {/* Chat stream */}
89
+ <div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-1">
90
+ {mode === "raw" && (
91
+ <>
92
+ {filteredRawSteps.map((step) => (
93
+ <RawBubble
94
+ key={step.index}
95
+ step={step}
96
+ toolResponses={toolResponseMap}
97
+ />
98
+ ))}
99
+ {filteredRawSteps.length === 0 && (
100
+ <div className="text-center text-gray-500 text-sm mt-8">
101
+ No raw trajectory data available
102
+ </div>
103
+ )}
104
+ </>
105
+ )}
106
+
107
+ {mode === "atif" && (
108
+ <>
109
+ {detail.atif.steps.map((step) => (
110
+ <AtifBubble key={step.index} step={step} />
111
+ ))}
112
+ {detail.atif.steps.length === 0 && (
113
+ <div className="text-center text-gray-500 text-sm mt-8">
114
+ No ATIF trajectory data available.
115
+ {detail.n_raw_steps > 0 && " Try Raw Messages mode."}
116
+ </div>
117
+ )}
118
+ </>
119
+ )}
120
+
121
+ {/* Result badge at bottom */}
122
+ <div className="flex justify-center pt-4">
123
+ <span
124
+ className={`px-4 py-2 rounded-lg text-sm font-medium ${
125
+ detail.resolved
126
+ ? "bg-emerald-900/50 text-emerald-300 border border-emerald-700"
127
+ : "bg-red-900/50 text-red-300 border border-red-700"
128
+ }`}
129
+ >
130
+ {detail.resolved ? "RESOLVED" : "FAILED"}
131
+ {detail.error && (
132
+ <span className="ml-2 text-xs opacity-70">({detail.error})</span>
133
+ )}
134
+ </span>
135
+ </div>
136
+ </div>
137
+
138
+ {/* Raw logs */}
139
+ <StepDetail dsId={dataset.id} instanceId={detail.instance_id} />
140
+ </div>
141
+ );
142
+ }
frontend/src/harbor/store.ts ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useState } from "react";
2
+ import type {
3
+ DatasetInfo,
4
+ GroupedInstance,
5
+ InstanceDetail,
6
+ Preset,
7
+ ViewMode,
8
+ TrajectoryMode,
9
+ } from "./types";
10
+ import * as api from "./api";
11
+
12
+ export interface AppState {
13
+ datasets: DatasetInfo[];
14
+ presets: Preset[];
15
+ groupedInstances: GroupedInstance[];
16
+ selectedInstanceId: string | null;
17
+ instanceDetails: Record<string, InstanceDetail>; // keyed by `${dsId}:${instanceId}`
18
+ viewMode: ViewMode;
19
+ trajectoryMode: TrajectoryMode;
20
+ loading: boolean;
21
+ error: string | null;
22
+ filterResolved: "all" | "resolved" | "unresolved";
23
+ searchQuery: string;
24
+ }
25
+
26
+ const initialState: AppState = {
27
+ datasets: [],
28
+ presets: [],
29
+ groupedInstances: [],
30
+ selectedInstanceId: null,
31
+ instanceDetails: {},
32
+ viewMode: "list",
33
+ trajectoryMode: "raw",
34
+ loading: false,
35
+ error: null,
36
+ filterResolved: "all",
37
+ searchQuery: "",
38
+ };
39
+
40
+ function buildGroupedInstances(datasets: DatasetInfo[]): GroupedInstance[] {
41
+ const map = new Map<string, GroupedInstance>();
42
+ for (const ds of datasets) {
43
+ for (const inst of ds.instances) {
44
+ if (!map.has(inst.instance_id)) {
45
+ map.set(inst.instance_id, { instance_id: inst.instance_id, datasets: [] });
46
+ }
47
+ map.get(inst.instance_id)!.datasets.push({
48
+ ds_id: ds.id,
49
+ repo: ds.repo,
50
+ name: ds.name,
51
+ summary: inst,
52
+ });
53
+ }
54
+ }
55
+ const groups = Array.from(map.values());
56
+ groups.sort((a, b) => a.instance_id.localeCompare(b.instance_id));
57
+ return groups;
58
+ }
59
+
60
+ export function useAppState() {
61
+ const [state, setState] = useState<AppState>(initialState);
62
+
63
+ const setError = useCallback((error: string | null) => {
64
+ setState((s) => ({ ...s, error }));
65
+ }, []);
66
+
67
+ const loadDataset = useCallback(async (repo: string, split = "train") => {
68
+ setState((s) => ({ ...s, loading: true, error: null }));
69
+ try {
70
+ const ds = await api.loadDataset(repo, split);
71
+ setState((s) => {
72
+ const exists = s.datasets.find((d) => d.id === ds.id);
73
+ const datasets = exists
74
+ ? s.datasets.map((d) => (d.id === ds.id ? ds : d))
75
+ : [...s.datasets, ds];
76
+ return {
77
+ ...s,
78
+ datasets,
79
+ groupedInstances: buildGroupedInstances(datasets),
80
+ loading: false,
81
+ };
82
+ });
83
+ } catch (e: unknown) {
84
+ setState((s) => ({
85
+ ...s,
86
+ loading: false,
87
+ error: e instanceof Error ? e.message : String(e),
88
+ }));
89
+ }
90
+ }, []);
91
+
92
+ const unloadDataset = useCallback(async (dsId: string) => {
93
+ try {
94
+ await api.unloadDataset(dsId);
95
+ setState((s) => {
96
+ const datasets = s.datasets.filter((d) => d.id !== dsId);
97
+ // Remove cached details for this dataset
98
+ const instanceDetails = { ...s.instanceDetails };
99
+ for (const key of Object.keys(instanceDetails)) {
100
+ if (key.startsWith(`${dsId}:`)) {
101
+ delete instanceDetails[key];
102
+ }
103
+ }
104
+ return {
105
+ ...s,
106
+ datasets,
107
+ groupedInstances: buildGroupedInstances(datasets),
108
+ instanceDetails,
109
+ };
110
+ });
111
+ } catch (e: unknown) {
112
+ setState((s) => ({
113
+ ...s,
114
+ error: e instanceof Error ? e.message : String(e),
115
+ }));
116
+ }
117
+ }, []);
118
+
119
+ const selectInstance = useCallback(
120
+ async (instanceId: string | null) => {
121
+ setState((s) => ({
122
+ ...s,
123
+ selectedInstanceId: instanceId,
124
+ viewMode: instanceId ? "detail" : "list",
125
+ }));
126
+ if (!instanceId) return;
127
+
128
+ // Load details for all datasets that have this instance
129
+ setState((s) => ({ ...s, loading: true }));
130
+ try {
131
+ const { datasets } = state;
132
+ const promises: Promise<void>[] = [];
133
+ for (const ds of datasets) {
134
+ const hasInstance = ds.instances.some(
135
+ (inst) => inst.instance_id === instanceId
136
+ );
137
+ if (!hasInstance) continue;
138
+
139
+ const cacheKey = `${ds.id}:${instanceId}`;
140
+ // Skip if already cached
141
+ if (state.instanceDetails[cacheKey]) continue;
142
+
143
+ promises.push(
144
+ api.getInstance(ds.id, instanceId).then((detail) => {
145
+ setState((s) => ({
146
+ ...s,
147
+ instanceDetails: {
148
+ ...s.instanceDetails,
149
+ [cacheKey]: detail,
150
+ },
151
+ }));
152
+ })
153
+ );
154
+ }
155
+ await Promise.all(promises);
156
+ setState((s) => ({ ...s, loading: false }));
157
+ } catch (e: unknown) {
158
+ setState((s) => ({
159
+ ...s,
160
+ loading: false,
161
+ error: e instanceof Error ? e.message : String(e),
162
+ }));
163
+ }
164
+ },
165
+ [state.datasets, state.instanceDetails]
166
+ );
167
+
168
+ const loadPresets = useCallback(async () => {
169
+ try {
170
+ const presets = await api.listPresets();
171
+ setState((s) => ({ ...s, presets }));
172
+ } catch {
173
+ // Presets might not exist yet
174
+ }
175
+ }, []);
176
+
177
+ const createPreset = useCallback(
178
+ async (name: string, repo: string, split = "train") => {
179
+ const preset = await api.createPreset(name, repo, split);
180
+ setState((s) => ({ ...s, presets: [...s.presets, preset] }));
181
+ },
182
+ []
183
+ );
184
+
185
+ const deletePreset = useCallback(async (id: string) => {
186
+ await api.deletePreset(id);
187
+ setState((s) => ({ ...s, presets: s.presets.filter((p) => p.id !== id) }));
188
+ }, []);
189
+
190
+ const loadPreset = useCallback(
191
+ async (preset: Preset) => {
192
+ await loadDataset(preset.repo, preset.split);
193
+ },
194
+ [loadDataset]
195
+ );
196
+
197
+ const setViewMode = useCallback((viewMode: ViewMode) => {
198
+ setState((s) => ({ ...s, viewMode }));
199
+ }, []);
200
+
201
+ const setTrajectoryMode = useCallback((trajectoryMode: TrajectoryMode) => {
202
+ setState((s) => ({ ...s, trajectoryMode }));
203
+ }, []);
204
+
205
+ const setFilterResolved = useCallback(
206
+ (filterResolved: "all" | "resolved" | "unresolved") => {
207
+ setState((s) => ({ ...s, filterResolved }));
208
+ },
209
+ []
210
+ );
211
+
212
+ const setSearchQuery = useCallback((searchQuery: string) => {
213
+ setState((s) => ({ ...s, searchQuery }));
214
+ }, []);
215
+
216
+ return {
217
+ state,
218
+ loadDataset,
219
+ unloadDataset,
220
+ selectInstance,
221
+ loadPresets,
222
+ createPreset,
223
+ deletePreset,
224
+ loadPreset,
225
+ setViewMode,
226
+ setTrajectoryMode,
227
+ setFilterResolved,
228
+ setSearchQuery,
229
+ setError,
230
+ };
231
+ }
frontend/src/harbor/types.ts ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Dataset / repo level
2
+ export interface DatasetInfo {
3
+ id: string;
4
+ repo: string;
5
+ name: string;
6
+ split: string;
7
+ instances: InstanceSummary[];
8
+ n_instances: number;
9
+ }
10
+
11
+ // Instance summary (list level)
12
+ export interface InstanceSummary {
13
+ instance_id: string;
14
+ resolved: boolean;
15
+ reward: number;
16
+ model: string;
17
+ agent: string;
18
+ duration_seconds: number;
19
+ error: string;
20
+ }
21
+
22
+ // ATIF step
23
+ export interface AtifStep {
24
+ index: number;
25
+ source: "system" | "user" | "agent" | "unknown";
26
+ message: string;
27
+ timestamp?: string;
28
+ reasoning?: string;
29
+ tool_calls?: AtifToolCall[];
30
+ observation?: string;
31
+ metrics?: Record<string, number>;
32
+ }
33
+
34
+ export interface AtifToolCall {
35
+ function: string;
36
+ arguments?: Record<string, unknown>;
37
+ command?: string;
38
+ }
39
+
40
+ export interface AtifTrajectory {
41
+ steps: AtifStep[];
42
+ agent_info: Record<string, unknown>;
43
+ final_metrics: Record<string, number>;
44
+ }
45
+
46
+ // Raw trajectory step (OpenAI messages format)
47
+ export interface RawStep {
48
+ index: number;
49
+ role: "system" | "user" | "assistant" | "tool" | "unknown";
50
+ content: string;
51
+ tool_calls?: RawToolCall[];
52
+ tool_call_id?: string;
53
+ }
54
+
55
+ export interface RawToolCall {
56
+ id: string;
57
+ function: string;
58
+ arguments_raw: string;
59
+ arguments: Record<string, unknown>;
60
+ command?: string;
61
+ }
62
+
63
+ // Full instance detail
64
+ export interface InstanceDetail {
65
+ instance_id: string;
66
+ resolved: boolean;
67
+ reward: number;
68
+ model: string;
69
+ agent: string;
70
+ duration_seconds: number;
71
+ error: string;
72
+ atif: AtifTrajectory;
73
+ raw_steps: RawStep[];
74
+ n_atif_steps: number;
75
+ n_raw_steps: number;
76
+ }
77
+
78
+ // Raw logs
79
+ export interface InstanceRawLogs {
80
+ instance_id: string;
81
+ agent_stdout: string;
82
+ setup_stderr: string;
83
+ verifier_report: string;
84
+ verifier_stdout: string;
85
+ }
86
+
87
+ // Preset (single repo per preset, matching other visualizers)
88
+ export interface Preset {
89
+ id: string;
90
+ name: string;
91
+ repo: string;
92
+ split: string;
93
+ }
94
+
95
+ // Grouped instance across repos
96
+ export interface GroupedInstance {
97
+ instance_id: string;
98
+ datasets: { ds_id: string; repo: string; name: string; summary: InstanceSummary }[];
99
+ }
100
+
101
+ // View mode
102
+ export type ViewMode = "list" | "detail";
103
+
104
+ // Trajectory display mode
105
+ export type TrajectoryMode = "raw" | "atif";
frontend/src/index.css ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* Custom scrollbar for trace panels */
6
+ .trace-scroll::-webkit-scrollbar {
7
+ width: 6px;
8
+ }
9
+ .trace-scroll::-webkit-scrollbar-track {
10
+ background: transparent;
11
+ }
12
+ .trace-scroll::-webkit-scrollbar-thumb {
13
+ background: #4b5563;
14
+ border-radius: 3px;
15
+ }
16
+ .trace-scroll::-webkit-scrollbar-thumb:hover {
17
+ background: #6b7280;
18
+ }
19
+
20
+ /* Drag-to-reorder panel feedback — themed per visualizer */
21
+ .theme-model .panel-drop-target {
22
+ outline: 2px dashed #60a5fa;
23
+ outline-offset: -2px;
24
+ border-radius: 0.5rem;
25
+ background: rgba(96, 165, 250, 0.05);
26
+ }
27
+
28
+ .theme-arena .panel-drop-target {
29
+ outline: 2px dashed #a78bfa;
30
+ outline-offset: -2px;
31
+ border-radius: 0.5rem;
32
+ background: rgba(167, 139, 250, 0.05);
33
+ }
34
+
35
+ .theme-rlm .panel-drop-target {
36
+ outline: 2px dashed #fb923c;
37
+ outline-offset: -2px;
38
+ border-radius: 0.5rem;
39
+ background: rgba(251, 146, 60, 0.05);
40
+ }
41
+
42
+ .theme-harbor .panel-drop-target {
43
+ outline: 2px dashed #2dd4bf;
44
+ outline-offset: -2px;
45
+ border-radius: 0.5rem;
46
+ background: rgba(45, 212, 191, 0.05);
47
+ }
48
+
49
+ /* Code block styling (used by Harbor visualizer) */
50
+ .code-block {
51
+ font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
52
+ font-size: 0.8rem;
53
+ line-height: 1.4;
54
+ }
55
+
56
+ .drag-handle {
57
+ cursor: grab;
58
+ }
59
+
60
+ .drag-handle:active {
61
+ cursor: grabbing;
62
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import App from "./App";
4
+ import "./index.css";
5
+
6
+ ReactDOM.createRoot(document.getElementById("root")!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
frontend/src/model/ModelApp.tsx ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useCallback, useRef, useState } from "react";
2
+ import { useAppState } from "./store";
3
+ import Sidebar from "./components/Sidebar";
4
+ import TracePanel, { type DragHandleProps } from "./components/TracePanel";
5
+ import InfoBar from "./components/InfoBar";
6
+ import QuestionNav from "./components/QuestionNav";
7
+ import type { DatasetInfo, QuestionData, Preset } from "./types";
8
+ import { api } from "./api";
9
+
10
+ export default function ModelApp() {
11
+ const state = useAppState();
12
+
13
+ const handleLoadPreset = useCallback(async (preset: Preset) => {
14
+ await state.addDataset(preset.repo, preset.column, preset.split, undefined, preset.id, preset.name);
15
+ }, [state.addDataset]);
16
+
17
+ const handleSavePreset = useCallback(async (name: string, repo: string, column: string, split?: string) => {
18
+ const preset = await api.createPreset(name, repo, column, split);
19
+ state.setPresets((prev) => [...prev, preset]);
20
+ }, []);
21
+
22
+ const handleDeletePreset = useCallback(async (id: string, datasetId?: string) => {
23
+ await api.deletePreset(id);
24
+ state.setPresets((prev) => prev.filter((p) => p.id !== id));
25
+ if (datasetId) {
26
+ state.clearDatasetPreset(datasetId);
27
+ }
28
+ }, [state.clearDatasetPreset]);
29
+
30
+ const handleUpdatePreset = useCallback(async (presetId: string, datasetId: string, updates: { name?: string }) => {
31
+ const updated = await api.updatePreset(presetId, updates);
32
+ state.setPresets(prev => prev.map(p => p.id === presetId ? updated : p));
33
+ if (updates.name) {
34
+ state.updateDatasetPresetName(datasetId, updates.name);
35
+ }
36
+ }, [state.updateDatasetPresetName]);
37
+
38
+ // Keyboard shortcuts
39
+ useEffect(() => {
40
+ const handler = (e: KeyboardEvent) => {
41
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
42
+ switch (e.key) {
43
+ case "j":
44
+ state.setQuestionIdx((prev) => Math.min(state.maxQuestions - 1, prev + 1));
45
+ break;
46
+ case "k":
47
+ state.setQuestionIdx((prev) => Math.max(0, prev - 1));
48
+ break;
49
+ case "l":
50
+ state.setSampleIdx((prev) => Math.min(state.maxSamples - 1, prev + 1));
51
+ break;
52
+ case "h":
53
+ state.setSampleIdx((prev) => Math.max(0, prev - 1));
54
+ break;
55
+ }
56
+ };
57
+ window.addEventListener("keydown", handler);
58
+ return () => window.removeEventListener("keydown", handler);
59
+ }, [state.maxQuestions, state.maxSamples, state.setQuestionIdx, state.setSampleIdx]);
60
+
61
+ return (
62
+ <div className="h-full flex overflow-hidden">
63
+ <Sidebar
64
+ datasets={state.datasets}
65
+ presets={state.presets}
66
+ loading={state.loading}
67
+ groups={state.groups}
68
+ groupIds={state.groupIds}
69
+ currentGroupId={state.currentGroupId}
70
+ onAddDataset={state.addDataset}
71
+ onRemoveDataset={state.removeDataset}
72
+ onToggleDataset={state.toggleDataset}
73
+ onSetCurrentGroup={state.setCurrentGroupId}
74
+ onLoadPreset={handleLoadPreset}
75
+ onSavePreset={handleSavePreset}
76
+ onDeletePreset={handleDeletePreset}
77
+ onUpdatePreset={handleUpdatePreset}
78
+ />
79
+
80
+ <div className="flex-1 flex flex-col min-w-0">
81
+ {/* Error banner */}
82
+ {state.error && (
83
+ <div className="px-4 py-2 bg-red-900/50 border-b border-red-700 text-red-300 text-sm flex items-center justify-between">
84
+ <span>{state.error}</span>
85
+ <button onClick={() => state.setError(null)} className="text-red-400 hover:text-red-300 ml-2">
86
+ Dismiss
87
+ </button>
88
+ </div>
89
+ )}
90
+
91
+ <InfoBar
92
+ activeDatasets={state.activeDatasets}
93
+ questionIdx={state.questionIdx}
94
+ sampleIdx={state.sampleIdx}
95
+ getQuestionData={state.getQuestionData}
96
+ />
97
+
98
+ {/* Trace panels (drag to reorder) */}
99
+ <PanelContainer
100
+ datasets={state.orderedActiveDatasets}
101
+ getQuestionData={state.getQuestionData}
102
+ sampleIdx={state.sampleIdx}
103
+ onReorder={state.reorderPanels}
104
+ />
105
+
106
+ <QuestionNav
107
+ questionIdx={state.questionIdx}
108
+ sampleIdx={state.sampleIdx}
109
+ maxQuestions={state.maxQuestions}
110
+ maxSamples={state.maxSamples}
111
+ filter={state.filter}
112
+ onQuestionChange={state.setQuestionIdx}
113
+ onSampleChange={state.setSampleIdx}
114
+ onFilterChange={state.setFilter}
115
+ />
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ /* ── Drag-to-reorder panel container ── */
122
+
123
+ interface PanelContainerProps {
124
+ datasets: DatasetInfo[];
125
+ getQuestionData: (dsId: string) => QuestionData | undefined;
126
+ sampleIdx: number;
127
+ onReorder: (fromId: string, toId: string) => void;
128
+ }
129
+
130
+ function PanelContainer({ datasets, getQuestionData, sampleIdx, onReorder }: PanelContainerProps) {
131
+ const [draggedId, setDraggedId] = useState<string | null>(null);
132
+ const [overId, setOverId] = useState<string | null>(null);
133
+ const dragCounter = useRef<Record<string, number>>({});
134
+
135
+ const handleDragStart = useCallback((e: React.DragEvent, id: string) => {
136
+ setDraggedId(id);
137
+ e.dataTransfer.effectAllowed = "move";
138
+ // Use a transparent 1x1 image so the browser doesn't clone the panel
139
+ const ghost = document.createElement("canvas");
140
+ ghost.width = 1;
141
+ ghost.height = 1;
142
+ e.dataTransfer.setDragImage(ghost, 0, 0);
143
+ }, []);
144
+
145
+ const handleDragEnd = useCallback(() => {
146
+ setDraggedId(null);
147
+ setOverId(null);
148
+ dragCounter.current = {};
149
+ }, []);
150
+
151
+ const handleDragEnter = useCallback((e: React.DragEvent, id: string) => {
152
+ e.preventDefault();
153
+ dragCounter.current[id] = (dragCounter.current[id] || 0) + 1;
154
+ setOverId(id);
155
+ }, []);
156
+
157
+ const handleDragLeave = useCallback((_e: React.DragEvent, id: string) => {
158
+ dragCounter.current[id] = (dragCounter.current[id] || 0) - 1;
159
+ if (dragCounter.current[id] <= 0) {
160
+ dragCounter.current[id] = 0;
161
+ setOverId(prev => prev === id ? null : prev);
162
+ }
163
+ }, []);
164
+
165
+ const handleDragOver = useCallback((e: React.DragEvent) => {
166
+ e.preventDefault();
167
+ e.dataTransfer.dropEffect = "move";
168
+ }, []);
169
+
170
+ const handleDrop = useCallback((e: React.DragEvent, targetId: string) => {
171
+ e.preventDefault();
172
+ if (draggedId && draggedId !== targetId) {
173
+ onReorder(draggedId, targetId);
174
+ }
175
+ setDraggedId(null);
176
+ setOverId(null);
177
+ dragCounter.current = {};
178
+ }, [draggedId, onReorder]);
179
+
180
+ if (datasets.length === 0) {
181
+ return (
182
+ <div className="flex-1 flex gap-2 p-2 overflow-x-auto min-h-0">
183
+ <div className="flex-1 flex items-center justify-center text-gray-500">
184
+ <div className="text-center">
185
+ <p className="text-lg mb-2">No repos active</p>
186
+ <p className="text-sm">Add a HuggingFace repo from the sidebar to get started</p>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ );
191
+ }
192
+
193
+ return (
194
+ <div className="flex-1 flex gap-2 p-2 overflow-x-auto min-h-0">
195
+ {datasets.map((ds) => {
196
+ const isDragged = draggedId === ds.id;
197
+ const isOver = overId === ds.id && draggedId !== null && draggedId !== ds.id;
198
+
199
+ const handleProps: DragHandleProps = {
200
+ draggable: true,
201
+ onDragStart: (e) => handleDragStart(e, ds.id),
202
+ onDragEnd: handleDragEnd,
203
+ };
204
+
205
+ return (
206
+ <div
207
+ key={ds.id}
208
+ onDragEnter={(e) => handleDragEnter(e, ds.id)}
209
+ onDragLeave={(e) => handleDragLeave(e, ds.id)}
210
+ onDragOver={handleDragOver}
211
+ onDrop={(e) => handleDrop(e, ds.id)}
212
+ className={`flex-1 min-w-0 transition-all duration-150 ${
213
+ isDragged ? "opacity-30 scale-[0.97]" : ""
214
+ } ${isOver ? "panel-drop-target" : ""}`}
215
+ >
216
+ <TracePanel
217
+ datasetName={ds.presetName || ds.name}
218
+ repoName={ds.presetName ? ds.name : undefined}
219
+ data={getQuestionData(ds.id)}
220
+ sampleIdx={sampleIdx}
221
+ dragHandleProps={handleProps}
222
+ />
223
+ </div>
224
+ );
225
+ })}
226
+ </div>
227
+ );
228
+ }
frontend/src/model/api.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatasetInfo, QuestionData, DatasetSummary, Preset } from "./types";
2
+
3
+ const BASE = "/api/model";
4
+ const PRESETS_BASE = "/api/presets/model";
5
+
6
+ async function fetchJSON<T>(url: string, opts?: RequestInit): Promise<T> {
7
+ const res = await fetch(url, {
8
+ headers: { "Content-Type": "application/json" },
9
+ ...opts,
10
+ });
11
+ if (!res.ok) {
12
+ const err = await res.json().catch(() => ({ error: res.statusText }));
13
+ throw new Error(err.error || res.statusText);
14
+ }
15
+ return res.json();
16
+ }
17
+
18
+ export const api = {
19
+ loadDataset(repo: string, column?: string, split?: string, promptColumn?: string) {
20
+ return fetchJSON<DatasetInfo & { columns: string[]; question_fingerprint: string }>(`${BASE}/datasets/load`, {
21
+ method: "POST",
22
+ body: JSON.stringify({ repo, column, split, prompt_column: promptColumn }),
23
+ });
24
+ },
25
+
26
+ listDatasets() {
27
+ return fetchJSON<DatasetInfo[]>(`${BASE}/datasets/`);
28
+ },
29
+
30
+ getQuestion(dsId: string, idx: number) {
31
+ return fetchJSON<QuestionData>(`${BASE}/datasets/${dsId}/question/${idx}`);
32
+ },
33
+
34
+ getSummary(dsId: string) {
35
+ return fetchJSON<DatasetSummary>(`${BASE}/datasets/${dsId}/summary`);
36
+ },
37
+
38
+ unloadDataset(dsId: string) {
39
+ return fetchJSON<{ status: string }>(`${BASE}/datasets/${dsId}`, { method: "DELETE" });
40
+ },
41
+
42
+ listPresets() {
43
+ return fetchJSON<Preset[]>(`${PRESETS_BASE}`);
44
+ },
45
+
46
+ createPreset(name: string, repo: string, column: string, split?: string) {
47
+ return fetchJSON<Preset>(`${PRESETS_BASE}`, {
48
+ method: "POST",
49
+ body: JSON.stringify({ name, repo, column, split }),
50
+ });
51
+ },
52
+
53
+ updatePreset(id: string, updates: { name?: string; column?: string; split?: string }) {
54
+ return fetchJSON<Preset>(`${PRESETS_BASE}/${id}`, {
55
+ method: "PUT",
56
+ body: JSON.stringify(updates),
57
+ });
58
+ },
59
+
60
+ deletePreset(id: string) {
61
+ return fetchJSON<{ status: string }>(`${PRESETS_BASE}/${id}`, { method: "DELETE" });
62
+ },
63
+ };
frontend/src/model/components/InfoBar.tsx ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DatasetInfo, QuestionData } from "../types";
2
+
3
+ interface InfoBarProps {
4
+ activeDatasets: DatasetInfo[];
5
+ questionIdx: number;
6
+ sampleIdx: number;
7
+ getQuestionData: (dsId: string) => QuestionData | undefined;
8
+ }
9
+
10
+ export default function InfoBar({ activeDatasets, questionIdx, sampleIdx, getQuestionData }: InfoBarProps) {
11
+ let questionText = "";
12
+ let nSamples = 0;
13
+ const firstData = activeDatasets.length > 0 ? getQuestionData(activeDatasets[0].id) : undefined;
14
+ if (firstData) {
15
+ questionText = firstData.question;
16
+ nSamples = firstData.n_samples;
17
+ }
18
+
19
+ if (!questionText) {
20
+ return (
21
+ <div className="px-4 py-3 border-b border-gray-700 bg-gray-900/80">
22
+ <p className="text-sm text-gray-500 italic">Load repos and select a question to begin</p>
23
+ </div>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <div className="px-4 py-3 border-b border-gray-700 bg-gray-900/80">
29
+ {/* Question text */}
30
+ <div className="text-sm text-gray-200 font-medium mb-2 leading-relaxed max-h-24 overflow-y-auto">
31
+ Q{questionIdx}: {questionText}
32
+ </div>
33
+
34
+ {/* Sample bar */}
35
+ {nSamples > 1 && (
36
+ <div className="flex items-center gap-1 flex-wrap">
37
+ <span className="text-[10px] text-gray-500 mr-1">Samples:</span>
38
+ {Array.from({ length: nSamples }, (_, i) => {
39
+ const results = activeDatasets.map((ds) => {
40
+ const d = getQuestionData(ds.id);
41
+ return d?.eval_correct[i];
42
+ });
43
+ const allCorrect = results.every((r) => r === true);
44
+ const someCorrect = results.some((r) => r === true);
45
+ const noneCorrect = results.every((r) => r === false);
46
+
47
+ let bgColor = "bg-gray-700";
48
+ if (allCorrect) bgColor = "bg-green-700";
49
+ else if (someCorrect) bgColor = "bg-yellow-700";
50
+ else if (noneCorrect) bgColor = "bg-red-900";
51
+
52
+ const isSelected = i === sampleIdx;
53
+
54
+ return (
55
+ <span
56
+ key={i}
57
+ className={`inline-block w-4 h-4 rounded-sm text-[9px] text-center leading-4 font-mono ${bgColor} ${
58
+ isSelected ? "ring-2 ring-blue-400 ring-offset-1 ring-offset-gray-900" : ""
59
+ }`}
60
+ title={`Sample ${i + 1}: ${results.map((r, j) => `${activeDatasets[j]?.name}=${r ? "correct" : "wrong"}`).join(", ")}`}
61
+ >
62
+ {i + 1}
63
+ </span>
64
+ );
65
+ })}
66
+ <span className="text-[10px] text-gray-600 ml-2">
67
+ <span className="inline-block w-2.5 h-2.5 rounded-sm bg-green-700 mr-0.5 align-middle" /> all
68
+ <span className="inline-block w-2.5 h-2.5 rounded-sm bg-yellow-700 mr-0.5 ml-1.5 align-middle" /> some
69
+ <span className="inline-block w-2.5 h-2.5 rounded-sm bg-red-900 mr-0.5 ml-1.5 align-middle" /> none
70
+ </span>
71
+ </div>
72
+ )}
73
+
74
+ {/* Per-repo correctness for current sample */}
75
+ <div className="flex items-center gap-3 mt-1.5 flex-wrap">
76
+ {activeDatasets.map((ds) => {
77
+ const d = getQuestionData(ds.id);
78
+ const correct = d?.eval_correct[sampleIdx];
79
+ return (
80
+ <span key={ds.id} className="text-[11px]">
81
+ <span className="text-gray-500">{ds.name}: </span>
82
+ <span className={correct ? "text-green-400" : "text-red-400"}>
83
+ {correct === undefined ? "?" : correct ? "Correct" : "Wrong"}
84
+ </span>
85
+ </span>
86
+ );
87
+ })}
88
+ </div>
89
+ </div>
90
+ );
91
+ }
frontend/src/model/components/QuestionNav.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { FilterMode } from "../types";
2
+
3
+ interface QuestionNavProps {
4
+ questionIdx: number;
5
+ sampleIdx: number;
6
+ maxQuestions: number;
7
+ maxSamples: number;
8
+ filter: FilterMode;
9
+ onQuestionChange: (idx: number) => void;
10
+ onSampleChange: (idx: number) => void;
11
+ onFilterChange: (filter: FilterMode) => void;
12
+ }
13
+
14
+ const FILTERS: { value: FilterMode; label: string }[] = [
15
+ { value: "all", label: "All" },
16
+ { value: "improvements", label: "Improvements" },
17
+ { value: "regressions", label: "Regressions" },
18
+ { value: "both-correct", label: "Both Correct" },
19
+ { value: "both-wrong", label: "Both Wrong" },
20
+ ];
21
+
22
+ export default function QuestionNav({
23
+ questionIdx, sampleIdx, maxQuestions, maxSamples,
24
+ filter, onQuestionChange, onSampleChange, onFilterChange,
25
+ }: QuestionNavProps) {
26
+ const prevQ = () => onQuestionChange(Math.max(0, questionIdx - 1));
27
+ const nextQ = () => onQuestionChange(Math.min(maxQuestions - 1, questionIdx + 1));
28
+ const prevS = () => onSampleChange(Math.max(0, sampleIdx - 1));
29
+ const nextS = () => onSampleChange(Math.min(maxSamples - 1, sampleIdx + 1));
30
+
31
+ return (
32
+ <div className="px-4 py-2 border-t border-gray-700 bg-gray-900/80 flex items-center justify-between flex-wrap gap-2">
33
+ {/* Question navigation */}
34
+ <div className="flex items-center gap-2">
35
+ <button
36
+ onClick={prevQ}
37
+ disabled={questionIdx <= 0}
38
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 rounded border border-gray-600 text-gray-300 transition-colors"
39
+ >
40
+ &larr; Prev Q
41
+ </button>
42
+ <div className="flex items-center gap-1">
43
+ <span className="text-xs text-gray-500">Q</span>
44
+ <input
45
+ type="number"
46
+ value={questionIdx}
47
+ onChange={(e) => {
48
+ const v = parseInt(e.target.value);
49
+ if (!isNaN(v) && v >= 0 && v < maxQuestions) onQuestionChange(v);
50
+ }}
51
+ 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"
52
+ />
53
+ <span className="text-xs text-gray-500">/ {maxQuestions > 0 ? maxQuestions - 1 : 0}</span>
54
+ </div>
55
+ <button
56
+ onClick={nextQ}
57
+ disabled={questionIdx >= maxQuestions - 1}
58
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 rounded border border-gray-600 text-gray-300 transition-colors"
59
+ >
60
+ Next Q &rarr;
61
+ </button>
62
+ </div>
63
+
64
+ {/* Sample navigation */}
65
+ {maxSamples > 1 && (
66
+ <div className="flex items-center gap-2">
67
+ <button
68
+ onClick={prevS}
69
+ disabled={sampleIdx <= 0}
70
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 rounded border border-gray-600 text-gray-300 transition-colors"
71
+ >
72
+ &larr; Prev S
73
+ </button>
74
+ <span className="text-xs text-gray-400">
75
+ Sample {sampleIdx + 1}/{maxSamples}
76
+ </span>
77
+ <button
78
+ onClick={nextS}
79
+ disabled={sampleIdx >= maxSamples - 1}
80
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-40 rounded border border-gray-600 text-gray-300 transition-colors"
81
+ >
82
+ Next S &rarr;
83
+ </button>
84
+ </div>
85
+ )}
86
+
87
+ {/* Filter */}
88
+ <div className="flex items-center gap-1">
89
+ {FILTERS.map((f) => (
90
+ <button
91
+ key={f.value}
92
+ onClick={() => onFilterChange(f.value)}
93
+ className={`px-2 py-1 text-[10px] rounded border transition-colors ${
94
+ filter === f.value
95
+ ? "bg-blue-600 border-blue-500 text-white"
96
+ : "bg-gray-800 border-gray-600 text-gray-400 hover:bg-gray-700"
97
+ }`}
98
+ >
99
+ {f.label}
100
+ </button>
101
+ ))}
102
+ </div>
103
+
104
+ {/* Keyboard hints */}
105
+ <div className="text-[10px] text-gray-600">
106
+ <kbd className="px-1 bg-gray-800 rounded">j</kbd>/<kbd className="px-1 bg-gray-800 rounded">k</kbd> question
107
+ {" "}
108
+ <kbd className="px-1 bg-gray-800 rounded">h</kbd>/<kbd className="px-1 bg-gray-800 rounded">l</kbd> sample
109
+ </div>
110
+ </div>
111
+ );
112
+ }
frontend/src/model/components/Sidebar.tsx ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { DatasetInfo, Preset } from "../types";
3
+
4
+ // Consistent group colors for visual distinction
5
+ const GROUP_COLORS = [
6
+ { bg: "bg-blue-500", border: "border-blue-500", text: "text-blue-400", label: "text-blue-300" },
7
+ { bg: "bg-emerald-500", border: "border-emerald-500", text: "text-emerald-400", label: "text-emerald-300" },
8
+ { bg: "bg-amber-500", border: "border-amber-500", text: "text-amber-400", label: "text-amber-300" },
9
+ { bg: "bg-purple-500", border: "border-purple-500", text: "text-purple-400", label: "text-purple-300" },
10
+ { bg: "bg-rose-500", border: "border-rose-500", text: "text-rose-400", label: "text-rose-300" },
11
+ { bg: "bg-cyan-500", border: "border-cyan-500", text: "text-cyan-400", label: "text-cyan-300" },
12
+ ];
13
+
14
+ interface SidebarProps {
15
+ datasets: DatasetInfo[];
16
+ presets: Preset[];
17
+ loading: Record<string, boolean>;
18
+ groups: Record<string, DatasetInfo[]>;
19
+ groupIds: string[];
20
+ currentGroupId: string | null;
21
+ onAddDataset: (repo: string, column?: string, split?: string, promptColumn?: string) => void;
22
+ onRemoveDataset: (id: string) => void;
23
+ onToggleDataset: (id: string) => void;
24
+ onSetCurrentGroup: (groupId: string) => void;
25
+ onLoadPreset: (preset: Preset) => void;
26
+ onSavePreset: (name: string, repo: string, column: string, split?: string) => void;
27
+ onDeletePreset: (id: string, datasetId?: string) => void;
28
+ onUpdatePreset: (presetId: string, datasetId: string, updates: { name?: string }) => void;
29
+ }
30
+
31
+ export default function Sidebar({
32
+ datasets, presets, loading,
33
+ groups, groupIds, currentGroupId,
34
+ onAddDataset, onRemoveDataset, onToggleDataset, onSetCurrentGroup,
35
+ onLoadPreset, onSavePreset, onDeletePreset, onUpdatePreset,
36
+ }: SidebarProps) {
37
+ const [showAddModal, setShowAddModal] = useState(false);
38
+ const [repoInput, setRepoInput] = useState("");
39
+ const [columnInput, setColumnInput] = useState("model_responses");
40
+ const [splitInput, setSplitInput] = useState("train");
41
+ const [promptColumnInput, setPromptColumnInput] = useState("formatted_prompt");
42
+ const [presetSearch, setPresetSearch] = useState("");
43
+ // Track which dataset is currently being saved as a preset (by dataset id)
44
+ const [savingPresetForId, setSavingPresetForId] = useState<string | null>(null);
45
+ const [presetName, setPresetName] = useState("");
46
+ // Track which dataset is selected for preset editing
47
+ const [editingDatasetId, setEditingDatasetId] = useState<string | null>(null);
48
+ const [editPresetName, setEditPresetName] = useState("");
49
+
50
+ const handleAdd = () => {
51
+ if (!repoInput.trim()) return;
52
+ onAddDataset(
53
+ repoInput.trim(),
54
+ columnInput.trim() || undefined,
55
+ splitInput.trim() || undefined,
56
+ promptColumnInput.trim() || undefined,
57
+ );
58
+ setRepoInput("");
59
+ setShowAddModal(false);
60
+ };
61
+
62
+ const handleSavePresetForRepo = (ds: DatasetInfo) => {
63
+ if (!presetName.trim()) return;
64
+ onSavePreset(presetName.trim(), ds.repo, ds.column, ds.split);
65
+ setPresetName("");
66
+ setSavingPresetForId(null);
67
+ };
68
+
69
+ const getGroupColor = (groupId: string) => {
70
+ const idx = groupIds.indexOf(groupId);
71
+ return GROUP_COLORS[idx % GROUP_COLORS.length];
72
+ };
73
+
74
+ return (
75
+ <div className="w-72 min-w-72 bg-gray-900 border-r border-gray-700 flex flex-col h-full">
76
+ {/* Presets section */}
77
+ <div className="p-3 border-b border-gray-700">
78
+ <div className="flex items-center justify-between mb-2">
79
+ <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Presets</h3>
80
+ </div>
81
+ {presets.length === 0 ? (
82
+ <p className="text-xs text-gray-500 italic">No presets saved</p>
83
+ ) : (
84
+ <>
85
+ {presets.length > 6 && (
86
+ <input
87
+ type="text"
88
+ value={presetSearch}
89
+ onChange={(e) => setPresetSearch(e.target.value)}
90
+ placeholder="Search presets..."
91
+ 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"
92
+ />
93
+ )}
94
+ <div className="flex flex-wrap gap-1 max-h-32 overflow-y-auto">
95
+ {presets
96
+ .filter((p) => !presetSearch || p.name.toLowerCase().includes(presetSearch.toLowerCase()) || p.repo.toLowerCase().includes(presetSearch.toLowerCase()))
97
+ .map((p) => (
98
+ <div key={p.id} className="group relative">
99
+ <button
100
+ onClick={() => onLoadPreset(p)}
101
+ className="px-2 py-1 text-xs bg-gray-800 hover:bg-gray-700 rounded border border-gray-600 text-gray-300 transition-colors"
102
+ title={`${p.repo} (${p.column}, ${p.split ?? "train"})`}
103
+ >
104
+ {p.name}
105
+ </button>
106
+ <div className="hidden group-hover:flex absolute top-full left-0 mt-1 z-10 gap-1">
107
+ <button
108
+ onClick={() => onDeletePreset(p.id)}
109
+ className="px-1.5 py-0.5 text-[10px] bg-red-900 hover:bg-red-800 rounded text-red-300"
110
+ >
111
+ Delete
112
+ </button>
113
+ </div>
114
+ </div>
115
+ ))}
116
+ </div>
117
+ </>
118
+ )}
119
+ </div>
120
+
121
+ {/* Datasets section — grouped by question fingerprint */}
122
+ <div className="flex-1 overflow-y-auto p-3">
123
+ <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Loaded Repos</h3>
124
+ {datasets.length === 0 ? (
125
+ <p className="text-xs text-gray-500 italic">No repos loaded. Add one below.</p>
126
+ ) : (
127
+ <div className="space-y-3">
128
+ {groupIds.map((gid) => {
129
+ const color = getGroupColor(gid);
130
+ const groupDatasets = groups[gid];
131
+ const isCurrentGroup = gid === currentGroupId;
132
+
133
+ return (
134
+ <div key={gid}>
135
+ {/* Group header — clickable to switch group */}
136
+ <button
137
+ onClick={() => onSetCurrentGroup(gid)}
138
+ className={`w-full flex items-center gap-1.5 mb-1 px-1 py-0.5 rounded transition-colors ${
139
+ isCurrentGroup ? "bg-gray-800" : "hover:bg-gray-800/50"
140
+ }`}
141
+ >
142
+ <span className={`inline-block w-2 h-2 rounded-full ${color.bg} shrink-0`} />
143
+ <span className={`text-[10px] font-semibold uppercase tracking-wider ${
144
+ isCurrentGroup ? color.label : "text-gray-500"
145
+ }`}>
146
+ Group {groupIds.indexOf(gid) + 1}
147
+ <span className="normal-case font-normal ml-1 text-gray-600">
148
+ ({groupDatasets.length} repo{groupDatasets.length !== 1 ? "s" : ""})
149
+ </span>
150
+ </span>
151
+ {isCurrentGroup && (
152
+ <span className="text-[9px] text-gray-600 ml-auto">viewing</span>
153
+ )}
154
+ </button>
155
+
156
+ {/* Repos in this group */}
157
+ <div className={`space-y-1 border-l-2 ml-1 pl-2 ${
158
+ isCurrentGroup ? color.border : "border-gray-700"
159
+ }`}>
160
+ {groupDatasets.map((ds) => (
161
+ <div key={ds.id}>
162
+ <div
163
+ onClick={() => {
164
+ if (ds.presetId) {
165
+ setEditingDatasetId(editingDatasetId === ds.id ? null : ds.id);
166
+ setEditPresetName(ds.presetName || "");
167
+ setShowAddModal(false);
168
+ }
169
+ }}
170
+ className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm transition-colors ${
171
+ ds.active ? "bg-gray-800" : "bg-gray-900 opacity-60"
172
+ } ${editingDatasetId === ds.id ? "ring-1 ring-blue-500" : ""} ${ds.presetId ? "cursor-pointer" : ""}`}
173
+ >
174
+ <input
175
+ type="checkbox"
176
+ checked={ds.active}
177
+ onChange={() => onToggleDataset(ds.id)}
178
+ onClick={(e) => e.stopPropagation()}
179
+ className="rounded border-gray-600 bg-gray-800 text-blue-500 focus:ring-blue-500 focus:ring-offset-0"
180
+ />
181
+ <div className="flex-1 min-w-0">
182
+ <div className="text-gray-200 truncate text-xs font-medium" title={ds.presetName ? `${ds.presetName}\n${ds.repo}` : ds.repo}>
183
+ {ds.presetName || ds.name}
184
+ </div>
185
+ <div className="text-[10px] text-gray-500">
186
+ {ds.column} | {ds.n_rows} rows | {ds.n_samples} samples
187
+ </div>
188
+ </div>
189
+ {/* Save as preset */}
190
+ <button
191
+ onClick={(e) => {
192
+ e.stopPropagation();
193
+ setSavingPresetForId(savingPresetForId === ds.id ? null : ds.id);
194
+ setPresetName("");
195
+ }}
196
+ className={`transition-colors shrink-0 ${
197
+ savingPresetForId === ds.id
198
+ ? "text-blue-400"
199
+ : "text-gray-600 hover:text-blue-400"
200
+ }`}
201
+ title="Save as preset"
202
+ >
203
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
204
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
205
+ </svg>
206
+ </button>
207
+ {/* Remove */}
208
+ <button
209
+ onClick={(e) => { e.stopPropagation(); onRemoveDataset(ds.id); }}
210
+ className="text-gray-600 hover:text-red-400 transition-colors shrink-0"
211
+ title="Remove"
212
+ >
213
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
214
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
215
+ </svg>
216
+ </button>
217
+ </div>
218
+ {/* Inline preset name input */}
219
+ {savingPresetForId === ds.id && (
220
+ <div className="flex gap-1 mt-1 ml-6">
221
+ <input
222
+ type="text"
223
+ value={presetName}
224
+ onChange={(e) => setPresetName(e.target.value)}
225
+ onKeyDown={(e) => {
226
+ if (e.key === "Enter") handleSavePresetForRepo(ds);
227
+ if (e.key === "Escape") setSavingPresetForId(null);
228
+ }}
229
+ placeholder="Preset name..."
230
+ 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"
231
+ autoFocus
232
+ />
233
+ <button
234
+ onClick={() => handleSavePresetForRepo(ds)}
235
+ className="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-500 rounded text-white"
236
+ >
237
+ Save
238
+ </button>
239
+ </div>
240
+ )}
241
+ </div>
242
+ ))}
243
+ </div>
244
+ </div>
245
+ );
246
+ })}
247
+ </div>
248
+ )}
249
+ </div>
250
+
251
+ {/* Preset edit panel */}
252
+ {editingDatasetId && (() => {
253
+ const editDs = datasets.find(d => d.id === editingDatasetId);
254
+ if (!editDs?.presetId) return null;
255
+ return (
256
+ <div className="p-3 border-t border-gray-700 space-y-2">
257
+ <div className="text-[10px] text-gray-500 uppercase font-semibold tracking-wider">Edit Preset</div>
258
+ <input
259
+ type="text"
260
+ value={editPresetName}
261
+ onChange={(e) => setEditPresetName(e.target.value)}
262
+ onKeyDown={(e) => {
263
+ if (e.key === "Enter" && editPresetName.trim()) {
264
+ onUpdatePreset(editDs.presetId!, editDs.id, { name: editPresetName.trim() });
265
+ setEditingDatasetId(null);
266
+ }
267
+ if (e.key === "Escape") setEditingDatasetId(null);
268
+ }}
269
+ placeholder="Preset name..."
270
+ 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"
271
+ autoFocus
272
+ />
273
+ <div className="flex gap-2">
274
+ <button
275
+ onClick={() => {
276
+ if (editPresetName.trim()) {
277
+ onUpdatePreset(editDs.presetId!, editDs.id, { name: editPresetName.trim() });
278
+ setEditingDatasetId(null);
279
+ }
280
+ }}
281
+ disabled={!editPresetName.trim()}
282
+ className="flex-1 px-2 py-1 text-xs bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 rounded text-white transition-colors"
283
+ >
284
+ Save
285
+ </button>
286
+ <button
287
+ onClick={() => {
288
+ onDeletePreset(editDs.presetId!, editDs.id);
289
+ setEditingDatasetId(null);
290
+ }}
291
+ className="px-2 py-1 text-xs bg-red-900 hover:bg-red-800 rounded text-red-300 transition-colors"
292
+ >
293
+ Delete
294
+ </button>
295
+ <button
296
+ onClick={() => setEditingDatasetId(null)}
297
+ className="px-2 py-1 text-xs bg-gray-700 hover:bg-gray-600 rounded text-gray-300 transition-colors"
298
+ >
299
+ Cancel
300
+ </button>
301
+ </div>
302
+ </div>
303
+ );
304
+ })()}
305
+
306
+ {/* Add repo section */}
307
+ <div className="p-3 border-t border-gray-700">
308
+ {!showAddModal ? (
309
+ <button
310
+ onClick={() => {
311
+ setEditingDatasetId(null);
312
+ setShowAddModal(true);
313
+ setRepoInput("");
314
+ setColumnInput("model_responses");
315
+ setSplitInput("train");
316
+ setPromptColumnInput("formatted_prompt");
317
+ }}
318
+ className="w-full px-3 py-2 text-sm bg-blue-600 hover:bg-blue-500 rounded text-white font-medium transition-colors"
319
+ >
320
+ + Add Repo
321
+ </button>
322
+ ) : (
323
+ <div className="space-y-2">
324
+ <input
325
+ type="text"
326
+ value={repoInput}
327
+ onChange={(e) => setRepoInput(e.target.value)}
328
+ onKeyDown={(e) => e.key === "Enter" && handleAdd()}
329
+ placeholder="org/dataset-name"
330
+ 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"
331
+ autoFocus
332
+ />
333
+ <div className="flex gap-2">
334
+ <input
335
+ type="text"
336
+ value={columnInput}
337
+ onChange={(e) => setColumnInput(e.target.value)}
338
+ placeholder="Column"
339
+ 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"
340
+ />
341
+ <input
342
+ type="text"
343
+ value={splitInput}
344
+ onChange={(e) => setSplitInput(e.target.value)}
345
+ placeholder="Split"
346
+ 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"
347
+ />
348
+ </div>
349
+ <div className="flex gap-2">
350
+ <input
351
+ type="text"
352
+ value={promptColumnInput}
353
+ onChange={(e) => setPromptColumnInput(e.target.value)}
354
+ placeholder="Prompt col"
355
+ 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"
356
+ />
357
+ </div>
358
+ <div className="flex gap-2">
359
+ <button
360
+ onClick={handleAdd}
361
+ disabled={!repoInput.trim() || loading[repoInput.trim()]}
362
+ className="flex-1 px-2 py-1.5 text-sm bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 rounded text-white transition-colors"
363
+ >
364
+ {loading[repoInput.trim()] ? "Loading..." : "Load"}
365
+ </button>
366
+ <button
367
+ onClick={() => setShowAddModal(false)}
368
+ className="px-3 py-1.5 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-300 transition-colors"
369
+ >
370
+ Cancel
371
+ </button>
372
+ </div>
373
+ </div>
374
+ )}
375
+ </div>
376
+ </div>
377
+ );
378
+ }
frontend/src/model/components/TracePanel.tsx ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import type { QuestionData } from "../types";
3
+ import { highlightTrace } from "../utils/traceHighlight";
4
+ import { parsePrompt, type ParsedMessage } from "../utils/promptParser";
5
+
6
+ export interface DragHandleProps {
7
+ draggable: true;
8
+ onDragStart: (e: React.DragEvent) => void;
9
+ onDragEnd: (e: React.DragEvent) => void;
10
+ }
11
+
12
+ interface TracePanelProps {
13
+ datasetName: string;
14
+ repoName?: string;
15
+ data: QuestionData | undefined;
16
+ sampleIdx: number;
17
+ isLoading?: boolean;
18
+ dragHandleProps?: DragHandleProps;
19
+ }
20
+
21
+ export default function TracePanel({ datasetName, repoName, data, sampleIdx, isLoading, dragHandleProps }: TracePanelProps) {
22
+ const [promptExpanded, setPromptExpanded] = useState(false);
23
+
24
+ if (isLoading) {
25
+ return (
26
+ <div className="h-full border border-gray-700 rounded-lg flex items-center justify-center">
27
+ <div className="text-gray-500 text-sm">Loading...</div>
28
+ </div>
29
+ );
30
+ }
31
+
32
+ if (!data) {
33
+ return (
34
+ <div className="h-full border border-gray-700 rounded-lg flex items-center justify-center">
35
+ <div className="text-gray-500 text-sm">No data</div>
36
+ </div>
37
+ );
38
+ }
39
+
40
+ const isCorrect = data.eval_correct[sampleIdx];
41
+ const analysis = data.analyses[sampleIdx];
42
+ const extraction = data.extractions?.[sampleIdx];
43
+
44
+ const borderColor = isCorrect === undefined
45
+ ? "border-gray-700"
46
+ : isCorrect
47
+ ? "border-green-600"
48
+ : "border-red-600";
49
+
50
+ const thinkSegments = highlightTrace(analysis?.think_text || "");
51
+ const answerText = analysis?.answer_text || "";
52
+
53
+ const promptMessages = data.prompt_text ? parsePrompt(data.prompt_text) : [];
54
+
55
+ return (
56
+ <div className={`h-full border-2 ${borderColor} rounded-lg flex flex-col bg-gray-900/50`}>
57
+ {/* Header */}
58
+ <div className="px-3 py-2 border-b border-gray-700 flex items-center justify-between shrink-0">
59
+ <div className="flex items-center gap-2 min-w-0">
60
+ <span className="text-sm font-semibold text-gray-200 truncate" title={repoName ? `${datasetName}\n${repoName}` : datasetName}>{datasetName}</span>
61
+ {isCorrect !== undefined && (
62
+ <span className={`px-1.5 py-0.5 text-[10px] rounded font-medium ${
63
+ isCorrect ? "bg-green-900 text-green-300" : "bg-red-900 text-red-300"
64
+ }`}>
65
+ {isCorrect ? "CORRECT" : "WRONG"}
66
+ </span>
67
+ )}
68
+ </div>
69
+ <div className="flex items-center gap-1.5 shrink-0 ml-2">
70
+ <span className="text-[10px] text-gray-500">
71
+ {analysis && (
72
+ <>Think: {analysis.think_len.toLocaleString()} | BT: {analysis.backtracks}</>
73
+ )}
74
+ </span>
75
+ {dragHandleProps && (
76
+ <span
77
+ {...dragHandleProps}
78
+ title="Drag to reorder"
79
+ className="drag-handle text-gray-600 hover:text-gray-400 transition-colors"
80
+ >
81
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
82
+ <circle cx="5" cy="3" r="1.5" />
83
+ <circle cx="11" cy="3" r="1.5" />
84
+ <circle cx="5" cy="8" r="1.5" />
85
+ <circle cx="11" cy="8" r="1.5" />
86
+ <circle cx="5" cy="13" r="1.5" />
87
+ <circle cx="11" cy="13" r="1.5" />
88
+ </svg>
89
+ </span>
90
+ )}
91
+ </div>
92
+ </div>
93
+
94
+ {/* Extraction / extracted answer */}
95
+ {extraction && (
96
+ <div className="px-3 py-1.5 border-b border-gray-700/50 bg-gray-800/30 overflow-x-auto whitespace-nowrap">
97
+ <span className="text-[10px] text-gray-500 uppercase font-medium">Extracted: </span>
98
+ <span className="text-xs text-gray-300 font-mono">{extraction}</span>
99
+ </div>
100
+ )}
101
+
102
+ {/* Trace content */}
103
+ <div className="flex-1 overflow-y-auto trace-scroll px-3 py-2">
104
+ {/* Prompt section — collapsible */}
105
+ {promptMessages.length > 0 && (
106
+ <div className="mb-3">
107
+ <button
108
+ onClick={() => setPromptExpanded(!promptExpanded)}
109
+ className="flex items-center gap-1 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1 hover:text-gray-300 transition-colors"
110
+ >
111
+ <span className="text-[10px]">{promptExpanded ? "\u25BC" : "\u25B6"}</span>
112
+ Prompt ({promptMessages.length} message{promptMessages.length !== 1 ? "s" : ""})
113
+ </button>
114
+ {promptExpanded && (
115
+ <div className="space-y-1.5">
116
+ {promptMessages.map((msg, i) => (
117
+ <PromptMessage key={i} message={msg} />
118
+ ))}
119
+ </div>
120
+ )}
121
+ </div>
122
+ )}
123
+
124
+ {/* Thinking section */}
125
+ <div className="mb-3">
126
+ <div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">
127
+ Thinking ({analysis?.think_len.toLocaleString() || 0} chars)
128
+ </div>
129
+ <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono">
130
+ {thinkSegments.map((seg, i) => (
131
+ <span key={i} className={seg.className}>{seg.text}</span>
132
+ ))}
133
+ </pre>
134
+ </div>
135
+
136
+ {/* Answer section */}
137
+ {answerText && (
138
+ <div>
139
+ <div className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">
140
+ Answer ({analysis?.answer_len.toLocaleString() || 0} chars)
141
+ </div>
142
+ <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-100 font-bold">
143
+ {answerText}
144
+ </pre>
145
+ </div>
146
+ )}
147
+ </div>
148
+ </div>
149
+ );
150
+ }
151
+
152
+ const ROLE_STYLES: Record<string, { border: string; label: string; bg: string }> = {
153
+ system: { border: "border-l-purple-500", label: "text-purple-400", bg: "bg-purple-500/5" },
154
+ user: { border: "border-l-blue-500", label: "text-blue-400", bg: "bg-blue-500/5" },
155
+ assistant: { border: "border-l-green-500", label: "text-green-400", bg: "bg-green-500/5" },
156
+ prompt: { border: "border-l-gray-500", label: "text-gray-400", bg: "bg-gray-500/5" },
157
+ };
158
+
159
+ function PromptMessage({ message }: { message: ParsedMessage }) {
160
+ const style = ROLE_STYLES[message.role] || ROLE_STYLES.prompt;
161
+ return (
162
+ <div className={`border-l-2 ${style.border} ${style.bg} rounded-r pl-2 py-1`}>
163
+ <div className={`text-[10px] font-semibold uppercase tracking-wider ${style.label}`}>
164
+ {message.role}
165
+ </div>
166
+ <pre className="text-xs leading-relaxed whitespace-pre-wrap font-mono text-gray-300 max-h-60 overflow-y-auto">
167
+ {message.content}
168
+ </pre>
169
+ </div>
170
+ );
171
+ }
frontend/src/model/store.ts ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback, useEffect, useMemo } from "react";
2
+ import type { DatasetInfo, QuestionData, Preset, FilterMode } from "./types";
3
+ import { api } from "./api";
4
+
5
+ interface GroupIndices {
6
+ questionIdx: number;
7
+ sampleIdx: number;
8
+ }
9
+
10
+ export function useAppState() {
11
+ const [datasets, setDatasets] = useState<DatasetInfo[]>([]);
12
+ const [presets, setPresets] = useState<Preset[]>([]);
13
+ const [filter, setFilter] = useState<FilterMode>("all");
14
+ const [questionDataMap, setQuestionDataMap] = useState<Record<string, QuestionData>>({});
15
+ const [loading, setLoading] = useState<Record<string, boolean>>({});
16
+ const [error, setError] = useState<string | null>(null);
17
+
18
+ // Per-group navigation indices
19
+ const [groupIndices, setGroupIndices] = useState<Record<string, GroupIndices>>({});
20
+ // Which group is currently displayed (fingerprint)
21
+ const [currentGroupId, setCurrentGroupId] = useState<string | null>(null);
22
+
23
+ // Load presets on mount
24
+ useEffect(() => {
25
+ api.listPresets().then(setPresets).catch(() => {});
26
+ }, []);
27
+
28
+ // Sync URL state on mount
29
+ useEffect(() => {
30
+ const params = new URLSearchParams(window.location.search);
31
+ const q = parseInt(params.get("q") || "0");
32
+ const s = parseInt(params.get("s") || "0");
33
+ const f = (params.get("filter") || "all") as FilterMode;
34
+ setFilter(f);
35
+ // q and s will be applied once the first group is set
36
+ if (!isNaN(q) || !isNaN(s)) {
37
+ // Store initial URL indices to apply to first group loaded
38
+ (window as unknown as Record<string, unknown>).__initialQ = isNaN(q) ? 0 : q;
39
+ (window as unknown as Record<string, unknown>).__initialS = isNaN(s) ? 0 : s;
40
+ }
41
+ }, []);
42
+
43
+ // Derive groups from datasets by fingerprint
44
+ const groups = useMemo(() => {
45
+ const map: Record<string, DatasetInfo[]> = {};
46
+ for (const ds of datasets) {
47
+ const fp = ds.questionFingerprint;
48
+ if (!map[fp]) map[fp] = [];
49
+ map[fp].push(ds);
50
+ }
51
+ return map;
52
+ }, [datasets]);
53
+
54
+ const groupIds = useMemo(() => Object.keys(groups).sort(), [groups]);
55
+
56
+ // Auto-set currentGroupId if not set or invalid
57
+ useEffect(() => {
58
+ if (currentGroupId && groups[currentGroupId]) return;
59
+ // Pick first group that has active datasets, or first group overall
60
+ const activeGroup = groupIds.find(gid => groups[gid].some(d => d.active));
61
+ if (activeGroup) {
62
+ setCurrentGroupId(activeGroup);
63
+ } else if (groupIds.length > 0) {
64
+ setCurrentGroupId(groupIds[0]);
65
+ } else {
66
+ setCurrentGroupId(null);
67
+ }
68
+ }, [groupIds, groups, currentGroupId]);
69
+
70
+ // Active datasets = active datasets in current group
71
+ const activeDatasets = useMemo(
72
+ () => datasets.filter(d => d.active && d.questionFingerprint === currentGroupId),
73
+ [datasets, currentGroupId]
74
+ );
75
+
76
+ // Panel ordering: track display order of active dataset IDs
77
+ const [panelOrder, setPanelOrder] = useState<string[]>([]);
78
+
79
+ // Keep panelOrder in sync with activeDatasets: add new IDs at end, remove stale ones
80
+ useEffect(() => {
81
+ const activeIds = new Set(activeDatasets.map(d => d.id));
82
+ setPanelOrder(prev => {
83
+ const kept = prev.filter(id => activeIds.has(id));
84
+ const newIds = activeDatasets.map(d => d.id).filter(id => !prev.includes(id));
85
+ const merged = [...kept, ...newIds];
86
+ // Only update if changed to avoid unnecessary renders
87
+ if (merged.length === prev.length && merged.every((id, i) => id === prev[i])) return prev;
88
+ return merged;
89
+ });
90
+ }, [activeDatasets]);
91
+
92
+ // Ordered active datasets according to panelOrder
93
+ const orderedActiveDatasets = useMemo(() => {
94
+ const map = new Map(activeDatasets.map(d => [d.id, d]));
95
+ return panelOrder.map(id => map.get(id)).filter((d): d is DatasetInfo => d !== undefined);
96
+ }, [activeDatasets, panelOrder]);
97
+
98
+ const reorderPanels = useCallback((fromId: string, toId: string) => {
99
+ if (fromId === toId) return;
100
+ setPanelOrder(prev => {
101
+ const order = [...prev];
102
+ const fromIdx = order.indexOf(fromId);
103
+ const toIdx = order.indexOf(toId);
104
+ if (fromIdx === -1 || toIdx === -1) return prev;
105
+ order.splice(fromIdx, 1);
106
+ order.splice(toIdx, 0, fromId);
107
+ return order;
108
+ });
109
+ }, []);
110
+
111
+ // Current group's indices
112
+ const currentIndices = currentGroupId ? groupIndices[currentGroupId] : undefined;
113
+ const questionIdx = currentIndices?.questionIdx ?? 0;
114
+ const sampleIdx = currentIndices?.sampleIdx ?? 0;
115
+
116
+ const setQuestionIdx = useCallback((val: number | ((prev: number) => number)) => {
117
+ if (!currentGroupId) return;
118
+ setGroupIndices(prev => {
119
+ const cur = prev[currentGroupId] ?? { questionIdx: 0, sampleIdx: 0 };
120
+ const newQ = typeof val === "function" ? val(cur.questionIdx) : val;
121
+ return { ...prev, [currentGroupId]: { ...cur, questionIdx: newQ } };
122
+ });
123
+ }, [currentGroupId]);
124
+
125
+ const setSampleIdx = useCallback((val: number | ((prev: number) => number)) => {
126
+ if (!currentGroupId) return;
127
+ setGroupIndices(prev => {
128
+ const cur = prev[currentGroupId] ?? { questionIdx: 0, sampleIdx: 0 };
129
+ const newS = typeof val === "function" ? val(cur.sampleIdx) : val;
130
+ return { ...prev, [currentGroupId]: { ...cur, sampleIdx: newS } };
131
+ });
132
+ }, [currentGroupId]);
133
+
134
+ // Update URL when state changes
135
+ useEffect(() => {
136
+ const params = new URLSearchParams();
137
+ const activeRepos = datasets.filter((d) => d.active);
138
+ if (activeRepos.length > 0) {
139
+ params.set("repos", activeRepos.map((d) => d.repo).join(","));
140
+ params.set("cols", activeRepos.map((d) => d.column).join(","));
141
+ params.set("pcols", activeRepos.map((d) => d.promptColumn || "formatted_prompt").join(","));
142
+ }
143
+ params.set("q", String(questionIdx));
144
+ params.set("s", String(sampleIdx));
145
+ if (filter !== "all") params.set("filter", filter);
146
+ const newUrl = `${window.location.pathname}?${params.toString()}`;
147
+ window.history.replaceState({}, "", newUrl);
148
+ }, [datasets, questionIdx, sampleIdx, filter]);
149
+
150
+ // Fetch question data for active datasets in current group when question changes
151
+ useEffect(() => {
152
+ activeDatasets.forEach((ds) => {
153
+ const key = `${ds.id}:${questionIdx}`;
154
+ if (!questionDataMap[key]) {
155
+ api.getQuestion(ds.id, questionIdx).then((data) => {
156
+ setQuestionDataMap((prev) => ({ ...prev, [key]: data }));
157
+ }).catch(() => {});
158
+ }
159
+ });
160
+ }, [questionIdx, activeDatasets]);
161
+
162
+ const addDataset = useCallback(async (
163
+ repo: string, column?: string, split?: string, promptColumn?: string,
164
+ presetId?: string, presetName?: string,
165
+ ) => {
166
+ setLoading((prev) => ({ ...prev, [repo]: true }));
167
+ setError(null);
168
+ try {
169
+ const { question_fingerprint, ...rest } = await api.loadDataset(repo, column, split, promptColumn);
170
+ const fp = question_fingerprint ?? "";
171
+ const dsInfo: DatasetInfo = {
172
+ ...rest,
173
+ questionFingerprint: fp,
174
+ active: true,
175
+ presetId,
176
+ presetName,
177
+ };
178
+
179
+ setDatasets((prev) => {
180
+ if (prev.some((d) => d.id === dsInfo.id)) return prev;
181
+ return [...prev, dsInfo];
182
+ });
183
+
184
+ // Initialize group indices if new group, or inherit existing
185
+ setGroupIndices(prev => {
186
+ if (prev[fp]) return prev; // Group already exists, new repo inherits its indices
187
+ // New group — check for initial URL params or start at 0
188
+ const win = window as unknown as Record<string, unknown>;
189
+ const initQ = typeof win.__initialQ === "number" ? win.__initialQ : 0;
190
+ const initS = typeof win.__initialS === "number" ? win.__initialS : 0;
191
+ // Only use initial params for the very first group
192
+ const isFirstGroup = Object.keys(prev).length === 0;
193
+ return {
194
+ ...prev,
195
+ [fp]: { questionIdx: isFirstGroup ? initQ : 0, sampleIdx: isFirstGroup ? initS : 0 },
196
+ };
197
+ });
198
+
199
+ // Switch to the new dataset's group
200
+ setCurrentGroupId(fp);
201
+ } catch (e: unknown) {
202
+ setError(e instanceof Error ? e.message : "Failed to load dataset");
203
+ } finally {
204
+ setLoading((prev) => ({ ...prev, [repo]: false }));
205
+ }
206
+ }, []);
207
+
208
+ const removeDataset = useCallback(async (id: string) => {
209
+ await api.unloadDataset(id).catch(() => {});
210
+ setDatasets((prev) => prev.filter((d) => d.id !== id));
211
+ }, []);
212
+
213
+ const toggleDataset = useCallback((id: string) => {
214
+ setDatasets((prev) => {
215
+ const updated = prev.map((d) => (d.id === id ? { ...d, active: !d.active } : d));
216
+ // If toggling ON a dataset from a different group, switch to that group
217
+ const toggled = updated.find(d => d.id === id);
218
+ if (toggled && toggled.active) {
219
+ setCurrentGroupId(toggled.questionFingerprint);
220
+ }
221
+ return updated;
222
+ });
223
+ }, []);
224
+
225
+ const updateDatasetPresetName = useCallback((dsId: string, name: string) => {
226
+ setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetName: name } : d));
227
+ }, []);
228
+
229
+ const clearDatasetPreset = useCallback((dsId: string) => {
230
+ setDatasets(prev => prev.map(d => d.id === dsId ? { ...d, presetId: undefined, presetName: undefined } : d));
231
+ }, []);
232
+
233
+ const maxQuestions = Math.min(...activeDatasets.map((d) => d.n_rows), Infinity);
234
+ const maxSamples = Math.max(...activeDatasets.map((d) => d.n_samples), 0);
235
+
236
+ const getQuestionData = (dsId: string): QuestionData | undefined => {
237
+ return questionDataMap[`${dsId}:${questionIdx}`];
238
+ };
239
+
240
+ return {
241
+ datasets, presets, setPresets,
242
+ questionIdx, setQuestionIdx,
243
+ sampleIdx, setSampleIdx,
244
+ filter, setFilter,
245
+ loading, error, setError,
246
+ activeDatasets, orderedActiveDatasets, maxQuestions, maxSamples,
247
+ addDataset, removeDataset, toggleDataset,
248
+ updateDatasetPresetName, clearDatasetPreset,
249
+ getQuestionData, reorderPanels,
250
+ // Group state
251
+ groups, groupIds, currentGroupId, setCurrentGroupId,
252
+ };
253
+ }
frontend/src/model/types.ts ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface DatasetInfo {
2
+ id: string;
3
+ repo: string;
4
+ name: string;
5
+ column: string;
6
+ columns: string[];
7
+ split: string;
8
+ promptColumn: string | null;
9
+ n_rows: number;
10
+ n_samples: number;
11
+ active: boolean;
12
+ questionFingerprint: string;
13
+ presetId?: string;
14
+ presetName?: string;
15
+ }
16
+
17
+ export interface TraceAnalysis {
18
+ total_len: number;
19
+ think_len: number;
20
+ answer_len: number;
21
+ backtracks: number;
22
+ restarts: number;
23
+ think_text: string;
24
+ answer_text: string;
25
+ }
26
+
27
+ export interface QuestionData {
28
+ question: string;
29
+ prompt_text: string;
30
+ responses: string[];
31
+ eval_correct: boolean[];
32
+ extractions: string[];
33
+ metadata: Record<string, unknown>;
34
+ analyses: TraceAnalysis[];
35
+ n_samples: number;
36
+ index: number;
37
+ }
38
+
39
+ export interface DatasetSummary {
40
+ n_rows: number;
41
+ n_samples: number;
42
+ has_eval: boolean;
43
+ sample_accuracy?: { correct: number; total: number; rate: number };
44
+ pass_at?: Record<number, { correct: number; total: number; rate: number }>;
45
+ }
46
+
47
+ export interface Preset {
48
+ id: string;
49
+ name: string;
50
+ repo: string;
51
+ column: string;
52
+ split?: string;
53
+ }
54
+
55
+ export type FilterMode = "all" | "improvements" | "regressions" | "both-correct" | "both-wrong";
frontend/src/model/utils/promptParser.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface ParsedMessage {
2
+ role: string;
3
+ content: string;
4
+ }
5
+
6
+ export function parsePrompt(text: string): ParsedMessage[] {
7
+ if (!text || !text.trim()) return [];
8
+
9
+ // Try 1: JSON array of {role, content} objects
10
+ try {
11
+ const parsed = JSON.parse(text);
12
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].role !== undefined) {
13
+ return parsed.map((m: Record<string, unknown>) => ({
14
+ role: String(m.role || "unknown"),
15
+ content: String(m.content ?? ""),
16
+ }));
17
+ }
18
+ } catch {
19
+ // Not JSON
20
+ }
21
+
22
+ // Try 2: ChatML — <|im_start|>role\ncontent<|im_end|>
23
+ if (text.includes("<|im_start|>")) {
24
+ const parts = text.split("<|im_start|>").filter(Boolean);
25
+ return parts.map((part) => {
26
+ const nlIdx = part.indexOf("\n");
27
+ const role = nlIdx > 0 ? part.slice(0, nlIdx).trim() : "unknown";
28
+ const content = (nlIdx > 0 ? part.slice(nlIdx + 1) : part)
29
+ .replace(/<\|im_end\|>/g, "")
30
+ .trim();
31
+ return { role, content };
32
+ });
33
+ }
34
+
35
+ // Try 3: Generic chat template — <|system|>, <|user|>, <|assistant|>
36
+ if (/<\|(system|user|assistant)\|>/.test(text)) {
37
+ const regex = /<\|(system|user|assistant)\|>/g;
38
+ const positions: { role: string; start: number; tagEnd: number }[] = [];
39
+ let match;
40
+ while ((match = regex.exec(text)) !== null) {
41
+ positions.push({
42
+ role: match[1],
43
+ start: match.index,
44
+ tagEnd: match.index + match[0].length,
45
+ });
46
+ }
47
+ return positions.map((pos, i) => {
48
+ const end = i + 1 < positions.length ? positions[i + 1].start : text.length;
49
+ return { role: pos.role, content: text.slice(pos.tagEnd, end).trim() };
50
+ });
51
+ }
52
+
53
+ // Try 4: Llama-style — <<SYS>>, [INST], [/INST]
54
+ if (text.includes("[INST]") || text.includes("<<SYS>>")) {
55
+ const messages: ParsedMessage[] = [];
56
+ const sysMatch = text.match(/<<SYS>>([\s\S]*?)<<\/SYS>>/);
57
+ if (sysMatch) {
58
+ messages.push({ role: "system", content: sysMatch[1].trim() });
59
+ }
60
+ // Split on [INST] and [/INST] markers
61
+ const withoutSys = text.replace(/<<SYS>>[\s\S]*?<<\/SYS>>/g, "");
62
+ const segments = withoutSys.split(/\[INST\]|\[\/INST\]/).map((s) => s.trim()).filter(Boolean);
63
+ let isUser = true;
64
+ for (const seg of segments) {
65
+ messages.push({ role: isUser ? "user" : "assistant", content: seg });
66
+ isUser = !isUser;
67
+ }
68
+ return messages.length > 0 ? messages : [{ role: "prompt", content: text }];
69
+ }
70
+
71
+ // Try 5: Plain labeled — "System:", "User:", "Assistant:", "Human:"
72
+ if (/^(System|User|Assistant|Human):\s/m.test(text)) {
73
+ const regex = /^(System|User|Assistant|Human):\s*/gm;
74
+ const positions: { role: string; contentStart: number }[] = [];
75
+ let match;
76
+ while ((match = regex.exec(text)) !== null) {
77
+ const role = match[1].toLowerCase() === "human" ? "user" : match[1].toLowerCase();
78
+ positions.push({ role, contentStart: match.index + match[0].length });
79
+ }
80
+ return positions.map((pos, i) => {
81
+ const end = i + 1 < positions.length
82
+ ? text.lastIndexOf("\n", positions[i + 1].contentStart - positions[i + 1].role.length - 2)
83
+ : text.length;
84
+ return {
85
+ role: pos.role,
86
+ content: text.slice(pos.contentStart, end > pos.contentStart ? end : text.length).trim(),
87
+ };
88
+ });
89
+ }
90
+
91
+ // Fallback: single prompt block
92
+ return [{ role: "prompt", content: text }];
93
+ }