Spaces:
Paused
Brand the app, strict per-tester GPU shares, free prompt enhancement
Browse filesWeb app, RosDiff by Orcanion: the logo on the splash (short for returning
visitors, skippable), sign-in, header and favicon; a 10-step beginner tour with
the LiDAR simulator example; the routing line on every result (tier, reason,
Why?); the simulator RosDiff picked as the main button plus "Use another
simulator" (MuJoCo, MuJoCo Warp, Isaac Sim; Gazebo, Webots and PyBullet listed
as not available yet); an estimate card before costly tasks; Helpful / Not
helpful with an optional note on scenes, packages and jobs; a usage dialog with
the rupee budget, the GPU share and, for admins, the team.
GPU budget: MONTHLY_CREDIT_LIMIT_USD (now 29.98) is split equally between
COMPUTE_TESTERS (4), so $7.495 each. A job is admitted only if its worst case
(run until timeout) fits in both the tester's share and the team limit, and
running jobs count at their worst case, so parallel jobs cannot overshoot.
Prompt enhancement: each scene and code request is first rewritten into a
precise brief by OpenCode with a free OpenCode Zen model (ENHANCE_MODEL, default
opencode/deepseek-v4-flash-free), never through OpenRouter. It is logged as an
exempt call, rejected if it drops any number the tester wrote, and skipped on
any failure. Routing and estimates still use the tester's own words.
- .env.example +10 -2
- README.md +42 -10
- api/config.py +24 -1
- api/enhance.py +156 -0
- api/jobs.py +48 -7
- api/main.py +59 -12
- api/opencode_runner.py +20 -10
- api/static/app/app.css +89 -3
- api/static/app/app.js +343 -49
- api/static/app/brand/rosdiff-192.png +0 -0
- api/static/app/brand/rosdiff-32.png +0 -0
- api/static/app/brand/rosdiff-512.webp +0 -0
- api/static/app/brand/rosdiff-64.png +0 -0
- api/static/app/index.html +61 -22
- tests/conftest.py +1 -0
- tests/test_enhance.py +107 -0
- tests/test_jobs.py +27 -9
|
@@ -26,6 +26,12 @@ OPENCODE_BIN=opencode
|
|
| 26 |
OPENCODE_TIMEOUT=600
|
| 27 |
CODE_MAX_ATTEMPTS=2
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# --- Simulation runs: POST /simulate/run (disabled until the backend and R2 are configured)
|
| 30 |
SIM_BACKEND=modal # "modal" (default) or "runpod"
|
| 31 |
|
|
@@ -58,8 +64,10 @@ USD_INR=88 # exchange rate used to convert OpenRouter's d
|
|
| 58 |
CONFIRM_ABOVE_INR=10 # ask the tester before a task estimated to cost more than this
|
| 59 |
ADMIN_TESTERS= # comma-separated tester names who see the team budget
|
| 60 |
|
| 61 |
-
# ---
|
| 62 |
-
MONTHLY_CREDIT_LIMIT_USD=
|
|
|
|
|
|
|
| 63 |
STORAGE_LIMIT_GB=8 # new jobs are refused while the R2 bucket holds more (free tier: 10 GB)
|
| 64 |
# JOB_PRICES=t4=0.00019,l4=0.00025,cpu=0.00004,runpod=0.00031 # USD per second of container time
|
| 65 |
# JOB_OVERHEAD_S=45 # billed start-up + idle seconds added to each job
|
|
|
|
| 26 |
OPENCODE_TIMEOUT=600
|
| 27 |
CODE_MAX_ATTEMPTS=2
|
| 28 |
|
| 29 |
+
# --- Prompt enhancement: OpenCode + a free OpenCode Zen model (never OpenRouter, never billed; best effort)
|
| 30 |
+
PROMPT_ENHANCE=true
|
| 31 |
+
ENHANCE_MODEL=opencode/deepseek-v4-flash-free
|
| 32 |
+
ENHANCE_TIMEOUT=90
|
| 33 |
+
ZEN_API_KEY= # only if the free Zen model asks for a key (opencode.ai/zen)
|
| 34 |
+
|
| 35 |
# --- Simulation runs: POST /simulate/run (disabled until the backend and R2 are configured)
|
| 36 |
SIM_BACKEND=modal # "modal" (default) or "runpod"
|
| 37 |
|
|
|
|
| 64 |
CONFIRM_ABOVE_INR=10 # ask the tester before a task estimated to cost more than this
|
| 65 |
ADMIN_TESTERS= # comma-separated tester names who see the team budget
|
| 66 |
|
| 67 |
+
# --- GPU spend: each job's worst case must fit in the tester's equal share and the team's monthly limit
|
| 68 |
+
MONTHLY_CREDIT_LIMIT_USD=29.98 # the Modal credit for the month; 0 = no limit
|
| 69 |
+
COMPUTE_TESTERS=4 # the credit is split equally: 29.98 / 4 = $7.495 per tester
|
| 70 |
+
# COMPUTE_PER_TESTER_USD= # set to override the equal share
|
| 71 |
STORAGE_LIMIT_GB=8 # new jobs are refused while the R2 bucket holds more (free tier: 10 GB)
|
| 72 |
# JOB_PRICES=t4=0.00019,l4=0.00025,cpu=0.00004,runpod=0.00031 # USD per second of container time
|
| 73 |
# JOB_OVERHEAD_S=45 # billed start-up + idle seconds added to each job
|
|
@@ -22,11 +22,43 @@ No payments, no tiers, no signup — a handful of API keys, one model, and a log
|
|
| 22 |
Robots: Unitree **G1** and **H1** humanoids, Unitree **Go2** quadruped, **Franka Panda** arm (all from MuJoCo
|
| 23 |
Menagerie). Generated ROS 2 code only ever runs inside the sandbox (no network, no secrets).
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
## How it works
|
| 32 |
|
|
@@ -179,9 +211,9 @@ for download (it opens in any Foxglove app).
|
|
| 179 |
(active workers 0, max workers 2, execution timeout 600 s, the four `R2_*` variables set on the endpoint). Put
|
| 180 |
the endpoint ID and an API key in `.env` as `RUNPOD_ENDPOINT_ID` / `RUNPOD_API_KEY`.
|
| 181 |
4. **Report links**: set `REPORT_SECRET` (`openssl rand -hex 32`) and `PUBLIC_BASE_URL` (the address testers use).
|
| 182 |
-
5. **Spend limit**: every job's cost is estimated from its run time (`JOB_PRICES`, `JOB_OVERHEAD_S`).
|
| 183 |
-
|
| 184 |
-
|
| 185 |
(default 8, under R2's free 10 GB) new jobs are refused with HTTP 507 until old files expire. The estimate is not Modal's bill, so also set a budget
|
| 186 |
or usage alert in Modal's workspace settings (*Usage and Billing*). On RunPod, set a spend notification.
|
| 187 |
|
|
@@ -190,8 +222,8 @@ for download (it opens in any Foxglove app).
|
|
| 190 |
|
| 191 |
### Usage visibility
|
| 192 |
|
| 193 |
-
Stage 1 runs are founder-funded for
|
| 194 |
-
|
| 195 |
|
| 196 |
- every `/simulate/run` call is a row in the `runs` table: tester, time, source generation, duration, backend + job
|
| 197 |
ID, status, GPU time (RunPod's billed figure, or the worker's own timing on Modal)
|
|
|
|
| 22 |
Robots: Unitree **G1** and **H1** humanoids, Unitree **Go2** quadruped, **Franka Panda** arm (all from MuJoCo
|
| 23 |
Menagerie). Generated ROS 2 code only ever runs inside the sandbox (no network, no secrets).
|
| 24 |
|
| 25 |
+
### Models, budgets and prompt enhancement
|
| 26 |
+
|
| 27 |
+
Testers never pick a model. **Automatic routing** (`api/routing.py`) rates every request's complexity and sends it
|
| 28 |
+
to the cheapest capable tier; a draft that fails validation is repaired one tier up. Defaults, all on OpenRouter
|
| 29 |
+
(override with `MODEL_REGISTRY`):
|
| 30 |
+
|
| 31 |
+
| Tier | Model | Used for |
|
| 32 |
+
|---|---|---|
|
| 33 |
+
| low | `deepseek/deepseek-v4-flash` | small ROS 2 nodes |
|
| 34 |
+
| mid | `deepseek/deepseek-v4-pro` | most scenes, packages with several parts |
|
| 35 |
+
| high | `z-ai/glm-5.3` | articulated scenes, Nav2 / MoveIt / control stacks, escalated repairs |
|
| 36 |
+
|
| 37 |
+
The web app shows the choice as information ("AI Routing: automatic · Standard tier · Why?"), plus the simulator
|
| 38 |
+
it picked (MuJoCo, MuJoCo Warp for "how often does it…" questions, Isaac Sim for photoreal renders) with a
|
| 39 |
+
**Use another simulator** menu. Gazebo, Webots and PyBullet are listed as not available yet.
|
| 40 |
+
|
| 41 |
+
**Prompt enhancement (free).** Before generating, the tester's words are rewritten into a precise brief by
|
| 42 |
+
OpenCode with a free OpenCode Zen model (`ENHANCE_MODEL`, default `opencode/deepseek-v4-flash-free`). It never
|
| 43 |
+
goes through OpenRouter and costs nothing; the call is logged as exempt. The brief must keep every number the
|
| 44 |
+
tester wrote, or it is thrown away, and any failure (OpenCode missing, timeout, a rotated-out free model) just
|
| 45 |
+
means the original text is used. Routing and cost estimates still use the tester's own words. The app shows the
|
| 46 |
+
brief under "✨ Enhanced prompt". It needs `opencode` installed on the API server (`npm i -g opencode-ai`);
|
| 47 |
+
`PROMPT_ENHANCE=false` switches it off, `ZEN_API_KEY` supplies a Zen key if the free model asks for one.
|
| 48 |
+
|
| 49 |
+
**AI budget (rupees).** One pool, `BUDGET_TOTAL_INR` (₹500), and an equal allocation per tester,
|
| 50 |
+
`BUDGET_PER_USER_INR` (₹125). It covers every billable OpenRouter call: coding, simulation AI, automatic repairs
|
| 51 |
+
("debugging") and testing. Costs come from OpenRouter's reported usage (list prices otherwise). Free and
|
| 52 |
+
OpenRouter-exempt calls are recorded but not deducted. Tasks whose worst case is above `CONFIRM_ABOVE_INR` show
|
| 53 |
+
an estimate and wait for "Run". Testers see their balance in the top bar and under *My usage*; testers listed in
|
| 54 |
+
`ADMIN_TESTERS` also see the whole team.
|
| 55 |
+
|
| 56 |
+
**GPU budget (dollars).** Simulations, sweeps, renders and build checks run on Modal's credit,
|
| 57 |
+
`MONTHLY_CREDIT_LIMIT_USD` (default $29.98), split equally between `COMPUTE_TESTERS` (4): $7.495 each per month
|
| 58 |
+
(`COMPUTE_PER_TESTER_USD` overrides the share). The limit is strict: a job starts only if its **worst case**
|
| 59 |
+
(the cost of running until its timeout) still fits in both the tester's share and the team's limit, and jobs
|
| 60 |
+
still running count at that worst case, so parallel jobs can never overshoot. Finished jobs count at their
|
| 61 |
+
measured cost.
|
| 62 |
|
| 63 |
## How it works
|
| 64 |
|
|
|
|
| 211 |
(active workers 0, max workers 2, execution timeout 600 s, the four `R2_*` variables set on the endpoint). Put
|
| 212 |
the endpoint ID and an API key in `.env` as `RUNPOD_ENDPOINT_ID` / `RUNPOD_API_KEY`.
|
| 213 |
4. **Report links**: set `REPORT_SECRET` (`openssl rand -hex 32`) and `PUBLIC_BASE_URL` (the address testers use).
|
| 214 |
+
5. **Spend limit**: every job's cost is estimated from its run time (`JOB_PRICES`, `JOB_OVERHEAD_S`). A new job is
|
| 215 |
+
refused with HTTP 402 unless its worst case still fits in the tester's equal share and in
|
| 216 |
+
`MONTHLY_CREDIT_LIMIT_USD` (default $29.98; see *GPU budget* above); everything else keeps working. Likewise, while the R2 bucket holds more than `STORAGE_LIMIT_GB`
|
| 217 |
(default 8, under R2's free 10 GB) new jobs are refused with HTTP 507 until old files expire. The estimate is not Modal's bill, so also set a budget
|
| 218 |
or usage alert in Modal's workspace settings (*Usage and Billing*). On RunPod, set a spend notification.
|
| 219 |
|
|
|
|
| 222 |
|
| 223 |
### Usage visibility
|
| 224 |
|
| 225 |
+
Stage 1 runs are founder-funded for 4 testers, each with an equal GPU share (above) and an equal AI budget. On top
|
| 226 |
+
of that:
|
| 227 |
|
| 228 |
- every `/simulate/run` call is a row in the `runs` table: tester, time, source generation, duration, backend + job
|
| 229 |
ID, status, GPU time (RunPod's billed figure, or the worker's own timing on Modal)
|
|
@@ -53,6 +53,9 @@ class Settings:
|
|
| 53 |
public_base_url: str # how testers reach this API (for report links); empty = use the request's host
|
| 54 |
# Spend: estimated from each job's run time; new jobs are refused once the month's estimate reaches the limit.
|
| 55 |
monthly_credit_usd: float # 0 = no limit
|
|
|
|
|
|
|
|
|
|
| 56 |
storage_limit_gb: float # new jobs are refused while the R2 bucket holds more than this (0 = no limit)
|
| 57 |
job_prices: dict # USD per second by hardware: t4, l4, cpu, runpod
|
| 58 |
job_overhead_s: float # billed seconds per job beyond the worker's own timing (start-up, idle shutdown)
|
|
@@ -68,6 +71,20 @@ class Settings:
|
|
| 68 |
admin_testers: tuple # testers who see the team budget view
|
| 69 |
hf_namespace: str # Hugging Face user/org that dataset exports are pushed to (with the worker's HF_TOKEN)
|
| 70 |
foxglove_org: str # optional Foxglove organization slug for the embedded viewer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
@property
|
| 73 |
def interfaces_file(self) -> Path:
|
|
@@ -122,7 +139,9 @@ def get_settings() -> Settings:
|
|
| 122 |
run_burst_warn=int(_env("RUN_BURST_WARN", "10")),
|
| 123 |
report_secret=_env("REPORT_SECRET", ""),
|
| 124 |
public_base_url=_env("PUBLIC_BASE_URL", "").rstrip("/"),
|
| 125 |
-
monthly_credit_usd=float(_env("MONTHLY_CREDIT_LIMIT_USD", "
|
|
|
|
|
|
|
| 126 |
storage_limit_gb=float(_env("STORAGE_LIMIT_GB", "8")),
|
| 127 |
job_prices=_prices(_env("JOB_PRICES", "")),
|
| 128 |
job_overhead_s=float(_env("JOB_OVERHEAD_S", "45")),
|
|
@@ -135,4 +154,8 @@ def get_settings() -> Settings:
|
|
| 135 |
confirm_above_inr=float(_env("CONFIRM_ABOVE_INR", "10")),
|
| 136 |
admin_testers=tuple(n.strip() for n in _env("ADMIN_TESTERS", "").split(",") if n.strip()),
|
| 137 |
foxglove_org=_env("FOXGLOVE_ORG", ""),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
)
|
|
|
|
| 53 |
public_base_url: str # how testers reach this API (for report links); empty = use the request's host
|
| 54 |
# Spend: estimated from each job's run time; new jobs are refused once the month's estimate reaches the limit.
|
| 55 |
monthly_credit_usd: float # 0 = no limit
|
| 56 |
+
# ...and each tester gets an equal share of it, enforced before every job against the job's worst case.
|
| 57 |
+
compute_testers: int # how many testers share the compute credit
|
| 58 |
+
compute_per_tester_usd: float # 0 = monthly_credit_usd / compute_testers
|
| 59 |
storage_limit_gb: float # new jobs are refused while the R2 bucket holds more than this (0 = no limit)
|
| 60 |
job_prices: dict # USD per second by hardware: t4, l4, cpu, runpod
|
| 61 |
job_overhead_s: float # billed seconds per job beyond the worker's own timing (start-up, idle shutdown)
|
|
|
|
| 71 |
admin_testers: tuple # testers who see the team budget view
|
| 72 |
hf_namespace: str # Hugging Face user/org that dataset exports are pushed to (with the worker's HF_TOKEN)
|
| 73 |
foxglove_org: str # optional Foxglove organization slug for the embedded viewer
|
| 74 |
+
# Prompt enhancement through OpenCode with a free OpenCode Zen model (never OpenRouter, never billed).
|
| 75 |
+
prompt_enhance: bool
|
| 76 |
+
enhance_model: str # "<opencode provider>/<model>"
|
| 77 |
+
enhance_timeout: float
|
| 78 |
+
zen_api_key: str # OpenCode Zen key, if the free model asks for one
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def compute_share_usd(self) -> float:
|
| 82 |
+
"""Each tester's monthly GPU/CPU allowance in dollars (0 = no per-tester limit)."""
|
| 83 |
+
if self.compute_per_tester_usd > 0:
|
| 84 |
+
return self.compute_per_tester_usd
|
| 85 |
+
if self.monthly_credit_usd > 0 and self.compute_testers > 0:
|
| 86 |
+
return round(self.monthly_credit_usd / self.compute_testers, 6)
|
| 87 |
+
return 0.0
|
| 88 |
|
| 89 |
@property
|
| 90 |
def interfaces_file(self) -> Path:
|
|
|
|
| 139 |
run_burst_warn=int(_env("RUN_BURST_WARN", "10")),
|
| 140 |
report_secret=_env("REPORT_SECRET", ""),
|
| 141 |
public_base_url=_env("PUBLIC_BASE_URL", "").rstrip("/"),
|
| 142 |
+
monthly_credit_usd=float(_env("MONTHLY_CREDIT_LIMIT_USD", "29.98")),
|
| 143 |
+
compute_testers=int(_env("COMPUTE_TESTERS", "4")),
|
| 144 |
+
compute_per_tester_usd=float(_env("COMPUTE_PER_TESTER_USD", "0")),
|
| 145 |
storage_limit_gb=float(_env("STORAGE_LIMIT_GB", "8")),
|
| 146 |
job_prices=_prices(_env("JOB_PRICES", "")),
|
| 147 |
job_overhead_s=float(_env("JOB_OVERHEAD_S", "45")),
|
|
|
|
| 154 |
confirm_above_inr=float(_env("CONFIRM_ABOVE_INR", "10")),
|
| 155 |
admin_testers=tuple(n.strip() for n in _env("ADMIN_TESTERS", "").split(",") if n.strip()),
|
| 156 |
foxglove_org=_env("FOXGLOVE_ORG", ""),
|
| 157 |
+
prompt_enhance=_env("PROMPT_ENHANCE", "true").lower() in ("1", "true", "yes"),
|
| 158 |
+
enhance_model=_env("ENHANCE_MODEL", "opencode/deepseek-v4-flash-free"),
|
| 159 |
+
enhance_timeout=float(_env("ENHANCE_TIMEOUT", "90")),
|
| 160 |
+
zen_api_key=_env("ZEN_API_KEY", ""),
|
| 161 |
)
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt enhancement: turn a tester's short request into a precise brief before generation.
|
| 2 |
+
|
| 3 |
+
It runs through OpenCode with a free OpenCode Zen model (ENHANCE_MODEL, default opencode/deepseek-v4-flash-free),
|
| 4 |
+
never through OpenRouter, so it costs nothing and is recorded in the ledger as exempt. It is best effort: when
|
| 5 |
+
OpenCode is missing, slow or returns something unusable, generation simply goes ahead with the original text.
|
| 6 |
+
|
| 7 |
+
The enhancer may only make the request clearer and more complete. It keeps every number, name and constraint the
|
| 8 |
+
tester gave, and it must not add goals of its own; the routing tier and the cost estimate are still decided from the
|
| 9 |
+
tester's own words.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
import re
|
| 16 |
+
import time
|
| 17 |
+
from dataclasses import asdict, dataclass
|
| 18 |
+
|
| 19 |
+
from .opencode_runner import OpenCodeError, OpenCodeWorkspace, find_opencode
|
| 20 |
+
|
| 21 |
+
log = logging.getLogger("rosdiff.enhance")
|
| 22 |
+
|
| 23 |
+
MAX_CHARS = 3000
|
| 24 |
+
|
| 25 |
+
SCENE_BRIEF = """You improve requests for a MuJoCo scene generator used by robotics students.
|
| 26 |
+
Rewrite the request below as a precise scene brief:
|
| 27 |
+
- keep every number, unit, object, colour and constraint exactly as given;
|
| 28 |
+
- where a size, position or mass is missing, choose a sensible value and state it (metres, kilograms);
|
| 29 |
+
- say where each object is relative to the robot (the robot stands at the origin, facing +x);
|
| 30 |
+
- mention what should be tested, if the request implies it (e.g. walking up the steps, being pushed);
|
| 31 |
+
- do not add unrelated objects or goals, and do not write MJCF or code.
|
| 32 |
+
Robot: {robot}.
|
| 33 |
+
Reply with only the rewritten brief inside <prompt></prompt>, at most 150 words.
|
| 34 |
+
|
| 35 |
+
Request:
|
| 36 |
+
{text}"""
|
| 37 |
+
|
| 38 |
+
CODE_BRIEF = """You improve requests for a ROS 2 package generator used by robotics students.
|
| 39 |
+
Rewrite the request below as a precise package brief:
|
| 40 |
+
- keep every topic, message type, parameter, rate and threshold exactly as given;
|
| 41 |
+
- where one is missing, choose the standard ROS 2 choice and state it (topic names, message types, QoS, rates);
|
| 42 |
+
- list the node(s), what each subscribes to and publishes, parameters with defaults, and the launch file;
|
| 43 |
+
- do not add features the request did not ask for, and do not write code.
|
| 44 |
+
Target: ROS 2 {distro}, {language}.
|
| 45 |
+
Reply with only the rewritten brief inside <prompt></prompt>, at most 180 words.
|
| 46 |
+
|
| 47 |
+
Request:
|
| 48 |
+
{text}"""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class Enhanced:
|
| 53 |
+
original: str
|
| 54 |
+
text: str # what generation uses: the enhanced brief, or the original when enhancement was skipped
|
| 55 |
+
used: bool
|
| 56 |
+
model: str | None = None
|
| 57 |
+
note: str | None = None # why it was skipped
|
| 58 |
+
usage: dict | None = None
|
| 59 |
+
ms: int = 0
|
| 60 |
+
|
| 61 |
+
def to_dict(self) -> dict:
|
| 62 |
+
return asdict(self)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _extract(events: list[dict]) -> str:
|
| 66 |
+
texts = []
|
| 67 |
+
for e in events:
|
| 68 |
+
part = e.get("part") or {}
|
| 69 |
+
if (e.get("type") == "text" or part.get("type") == "text") and isinstance(part.get("text"), str):
|
| 70 |
+
texts.append(part["text"])
|
| 71 |
+
raw = "\n".join(texts).strip()
|
| 72 |
+
m = re.search(r"<prompt>(.*?)</prompt>", raw, re.S)
|
| 73 |
+
return (m.group(1) if m else raw).strip()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _plausible(original: str, brief: str) -> str | None:
|
| 77 |
+
"""Reason to reject the brief, or None. Every number the tester wrote must survive the rewrite."""
|
| 78 |
+
if len(brief) < 10:
|
| 79 |
+
return "empty answer"
|
| 80 |
+
if len(brief) > MAX_CHARS:
|
| 81 |
+
return "answer too long"
|
| 82 |
+
lost = [n for n in re.findall(r"\d+(?:\.\d+)?", original) if n not in brief]
|
| 83 |
+
if lost:
|
| 84 |
+
return f"the rewrite dropped the value(s) {', '.join(lost[:3])}"
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def enhance(settings, kind: str, text: str, **ctx) -> Enhanced:
|
| 89 |
+
"""kind: 'scene' (ctx: robot) or 'code' (ctx: distro, language)."""
|
| 90 |
+
if not settings.prompt_enhance:
|
| 91 |
+
return Enhanced(text, text, False, note="switched off (PROMPT_ENHANCE=false)")
|
| 92 |
+
if not find_opencode(settings):
|
| 93 |
+
return Enhanced(text, text, False, note="OpenCode is not installed on the server")
|
| 94 |
+
if kind == "scene":
|
| 95 |
+
message = SCENE_BRIEF.format(robot=ctx.get("robot") or "none", text=text)
|
| 96 |
+
else:
|
| 97 |
+
lang = "C++" if ctx.get("language") == "cpp" else "Python"
|
| 98 |
+
message = CODE_BRIEF.format(distro=ctx.get("distro", "humble"), language=lang, text=text)
|
| 99 |
+
provider, _, model = settings.enhance_model.partition("/")
|
| 100 |
+
start = time.monotonic()
|
| 101 |
+
try:
|
| 102 |
+
with OpenCodeWorkspace(settings, config=enhance_config(settings), timeout=settings.enhance_timeout) as ws:
|
| 103 |
+
run = ws.run(message, context="", model=model, provider=provider)
|
| 104 |
+
except OpenCodeError as e:
|
| 105 |
+
log.warning("prompt enhancement skipped: %s", e)
|
| 106 |
+
return Enhanced(text, text, False, settings.enhance_model, str(e), ms=int((time.monotonic() - start) * 1000))
|
| 107 |
+
ms = int((time.monotonic() - start) * 1000)
|
| 108 |
+
if run.error:
|
| 109 |
+
log.warning("prompt enhancement failed: %s", run.error)
|
| 110 |
+
return Enhanced(text, text, False, settings.enhance_model, run.error, run.usage(), ms)
|
| 111 |
+
brief = _extract(run.events)
|
| 112 |
+
if why := _plausible(text, brief):
|
| 113 |
+
return Enhanced(text, text, False, settings.enhance_model, why, run.usage(), ms)
|
| 114 |
+
return Enhanced(text, brief, True, settings.enhance_model, None, run.usage(), ms)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def enhance_config(settings) -> dict:
|
| 118 |
+
"""Only the free Zen model, with no tools: the enhancer reads and answers, nothing else."""
|
| 119 |
+
provider, _, model = settings.enhance_model.partition("/")
|
| 120 |
+
options = {"apiKey": "{env:ZEN_API_KEY}"} if settings.zen_api_key else {}
|
| 121 |
+
return {
|
| 122 |
+
"$schema": "https://opencode.ai/config.json",
|
| 123 |
+
"model": settings.enhance_model,
|
| 124 |
+
"provider": {provider: {"options": options, "models": {model: {"name": model}}}},
|
| 125 |
+
"permission": {
|
| 126 |
+
"edit": "deny",
|
| 127 |
+
"bash": "deny",
|
| 128 |
+
"webfetch": "deny",
|
| 129 |
+
"websearch": "deny",
|
| 130 |
+
"external_directory": "deny",
|
| 131 |
+
},
|
| 132 |
+
"autoupdate": False,
|
| 133 |
+
"share": "disabled",
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def record(logs, tester: str, generation_id: str | None, category: str, e: Enhanced) -> None:
|
| 138 |
+
"""Free calls are still recorded (as exempt), so the ledger shows everything RosDiff asked a model."""
|
| 139 |
+
if not e.model or e.usage is None:
|
| 140 |
+
return
|
| 141 |
+
logs.record_usage(
|
| 142 |
+
tester=tester,
|
| 143 |
+
generation_id=generation_id,
|
| 144 |
+
category=category,
|
| 145 |
+
model=e.model,
|
| 146 |
+
tier="enhance",
|
| 147 |
+
provider="opencode-zen",
|
| 148 |
+
input_tokens=int(e.usage.get("input_tokens") or 0),
|
| 149 |
+
output_tokens=int(e.usage.get("output_tokens") or 0),
|
| 150 |
+
cost_usd=0.0,
|
| 151 |
+
cost_inr=0.0,
|
| 152 |
+
billable=0,
|
| 153 |
+
exempt_reason="opencode_zen_free",
|
| 154 |
+
estimated=0,
|
| 155 |
+
estimate_inr=None,
|
| 156 |
+
)
|
|
@@ -93,6 +93,40 @@ def month_spend(settings, logs) -> float:
|
|
| 93 |
return round(sum(estimated_cost(settings, j) for j in logs.jobs_since(month_start())), 4)
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
_STORAGE_CACHE: dict = {}
|
| 97 |
STORAGE_CHECK_EVERY_S = 300
|
| 98 |
|
|
@@ -133,14 +167,22 @@ def submit(
|
|
| 133 |
function = spec.function.format(**job_input) if "{" in spec.function else spec.function
|
| 134 |
if not s.backend.supports(function):
|
| 135 |
raise JobError(501, f"{spec.label} is not available on the {s.backend.name} backend (use SIM_BACKEND=modal)")
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
raise JobError(
|
| 141 |
402,
|
| 142 |
-
f"
|
| 143 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
)
|
| 145 |
if s.settings.storage_limit_gb > 0:
|
| 146 |
used = stored_gb(s)
|
|
@@ -155,7 +197,6 @@ def submit(
|
|
| 155 |
if today >= s.settings.isaac_daily_limit:
|
| 156 |
raise JobError(429, f"the daily limit of {s.settings.isaac_daily_limit} Isaac Sim renders is reached")
|
| 157 |
|
| 158 |
-
hardware = "runpod" if s.backend.name == "runpod" else spec.hardware
|
| 159 |
s.logs.record_run(
|
| 160 |
run_id=run_id,
|
| 161 |
tester=tester,
|
|
|
|
| 93 |
return round(sum(estimated_cost(settings, j) for j in logs.jobs_since(month_start())), 4)
|
| 94 |
|
| 95 |
|
| 96 |
+
def worst_cost(settings, kind: str, hardware: str | None = None) -> float:
|
| 97 |
+
"""The most a job of this kind can be billed: the backend kills it at its timeout."""
|
| 98 |
+
spec = KINDS[kind]
|
| 99 |
+
return job_cost(settings, hardware or spec.hardware, spec.timeout_s, kind)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def committed_cost(settings, job: dict) -> float:
|
| 103 |
+
"""What a job counts against a limit: its recorded cost once finished, its worst case while it may still run.
|
| 104 |
+
|
| 105 |
+
Counting running jobs at their worst case is what makes the limits strict: several jobs started at once can
|
| 106 |
+
never add up to more than the allowance, however long they end up running."""
|
| 107 |
+
if job.get("cost_usd") is not None or job.get("status") in TERMINAL:
|
| 108 |
+
return estimated_cost(settings, job)
|
| 109 |
+
kind = job.get("kind") or "sim"
|
| 110 |
+
return worst_cost(settings, kind if kind in KINDS else "sim", job.get("hardware"))
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def compute_usage(settings, logs, tester: str | None = None) -> dict:
|
| 114 |
+
"""This month's GPU/CPU spend (committed, in USD) for one tester and for the team, against their limits."""
|
| 115 |
+
jobs = logs.jobs_since(month_start())
|
| 116 |
+
team = round(sum(committed_cost(settings, j) for j in jobs), 4)
|
| 117 |
+
mine = round(sum(committed_cost(settings, j) for j in jobs if j["tester"] == tester), 4) if tester else None
|
| 118 |
+
share = settings.compute_share_usd
|
| 119 |
+
return {
|
| 120 |
+
"currency": "USD",
|
| 121 |
+
"used": mine,
|
| 122 |
+
"share": share or None,
|
| 123 |
+
"remaining": round(max(share - mine, 0.0), 4) if share and mine is not None else None,
|
| 124 |
+
"team_used": team,
|
| 125 |
+
"team_limit": settings.monthly_credit_usd or None,
|
| 126 |
+
"testers": settings.compute_testers,
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
_STORAGE_CACHE: dict = {}
|
| 131 |
STORAGE_CHECK_EVERY_S = 300
|
| 132 |
|
|
|
|
| 167 |
function = spec.function.format(**job_input) if "{" in spec.function else spec.function
|
| 168 |
if not s.backend.supports(function):
|
| 169 |
raise JobError(501, f"{spec.label} is not available on the {s.backend.name} backend (use SIM_BACKEND=modal)")
|
| 170 |
+
hardware = "runpod" if s.backend.name == "runpod" else spec.hardware
|
| 171 |
+
worst = worst_cost(s.settings, kind, hardware)
|
| 172 |
+
limit, share = s.settings.monthly_credit_usd, s.settings.compute_share_usd
|
| 173 |
+
if limit > 0 or share > 0:
|
| 174 |
+
use = compute_usage(s.settings, s.logs, tester)
|
| 175 |
+
if limit > 0 and use["team_used"] + worst > limit:
|
| 176 |
raise JobError(
|
| 177 |
402,
|
| 178 |
+
f"the team's GPU budget for this month is used up (${use['team_used']:.2f} of ${limit:.2f} committed; "
|
| 179 |
+
f"a {spec.label.lower()} can cost up to ${worst:.2f}). Please tell the Orcanion team.",
|
| 180 |
+
)
|
| 181 |
+
if share > 0 and use["used"] + worst > share:
|
| 182 |
+
raise JobError(
|
| 183 |
+
402,
|
| 184 |
+
f"your GPU budget for this month is used up (${use['used']:.2f} of your ${share:.2f} committed; "
|
| 185 |
+
f"a {spec.label.lower()} can cost up to ${worst:.2f}). It resets on the 1st.",
|
| 186 |
)
|
| 187 |
if s.settings.storage_limit_gb > 0:
|
| 188 |
used = stored_gb(s)
|
|
|
|
| 197 |
if today >= s.settings.isaac_daily_limit:
|
| 198 |
raise JobError(429, f"the daily limit of {s.settings.isaac_daily_limit} Isaac Sim renders is reached")
|
| 199 |
|
|
|
|
| 200 |
s.logs.record_run(
|
| 201 |
run_id=run_id,
|
| 202 |
tester=tester,
|
|
@@ -29,7 +29,10 @@ from . import budget
|
|
| 29 |
from .auth import Allowlist, Tester
|
| 30 |
from .codegen import DirectBackend, OpenCodeBackend, generate_code, mentioned_interfaces
|
| 31 |
from .config import Settings, get_settings
|
| 32 |
-
from .
|
|
|
|
|
|
|
|
|
|
| 33 |
from .jobs import submit as submit_job
|
| 34 |
from .llm import LLM, ChatClient, LLMError
|
| 35 |
from .logstore import LogStore
|
|
@@ -56,6 +59,7 @@ class Services:
|
|
| 56 |
backend: SimBackend | None = None # None: /simulate/run is disabled
|
| 57 |
storage: Storage | None = None
|
| 58 |
code_llm_factory: Callable[[], LLM] | None = None # direct ROS 2 code backend; None: same as llm_factory
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
def default_services() -> Services:
|
|
@@ -82,6 +86,7 @@ class SimulateRequest(BaseModel):
|
|
| 82 |
description: str = Field(..., min_length=10, max_length=4000, description="The scenario, in plain language")
|
| 83 |
robot: Literal[ROBOT_NAMES] | None = Field("unitree_g1", description="Robot to include, or null for none")
|
| 84 |
confirm_cost: bool = Field(False, description="Run even if the estimate is above CONFIRM_ABOVE_INR")
|
|
|
|
| 85 |
|
| 86 |
|
| 87 |
class SimulateResponse(BaseModel):
|
|
@@ -95,6 +100,7 @@ class SimulateResponse(BaseModel):
|
|
| 95 |
model: str
|
| 96 |
how_to_run: str
|
| 97 |
routing: dict | None = None # what RosDiff chose and why (tier, reason, simulator)
|
|
|
|
| 98 |
cost: dict | None = None # rupees this task used, the estimate, and the remaining budget
|
| 99 |
|
| 100 |
|
|
@@ -107,6 +113,7 @@ class CodeRequest(BaseModel):
|
|
| 107 |
False, description="Also build, lint, test and launch it in the ROS 2 sandbox (see /code/check)"
|
| 108 |
)
|
| 109 |
confirm_cost: bool = Field(False, description="Run even if the estimate is above CONFIRM_ABOVE_INR")
|
|
|
|
| 110 |
|
| 111 |
|
| 112 |
class CodeResponse(BaseModel):
|
|
@@ -122,6 +129,7 @@ class CodeResponse(BaseModel):
|
|
| 122 |
check: dict | None = None # the sandbox job, when requested: {id, status, report_url} or {error}
|
| 123 |
routing: dict | None = None
|
| 124 |
cost: dict | None = None
|
|
|
|
| 125 |
|
| 126 |
|
| 127 |
class RunRequest(BaseModel):
|
|
@@ -208,10 +216,26 @@ def _check_budget(s: Services, tester: str, plan, confirmed: bool) -> None:
|
|
| 208 |
raise HTTPException(status_code=e.status, detail={"message": str(e), **e.detail}) from e
|
| 209 |
|
| 210 |
|
| 211 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
"""Record every model call of a generation in the rupee ledger; return what this task cost."""
|
| 213 |
registry = {m.id: m for m in load_registry()}
|
| 214 |
before = budget.summary(s.logs, s.settings, tester)["used"]
|
|
|
|
|
|
|
| 215 |
for i, a in enumerate(attempts):
|
| 216 |
spec = registry.get(a.model) or ModelSpec(a.model or "unknown", "?", "unknown", 0.0, 0.0)
|
| 217 |
budget.record_call(
|
|
@@ -294,6 +318,9 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 294 |
"llm_configured": bool(s.settings.llm_api_key),
|
| 295 |
"codegen_backend": s.settings.codegen_backend,
|
| 296 |
"opencode_found": shutil.which(s.settings.opencode_bin) is not None,
|
|
|
|
|
|
|
|
|
|
| 297 |
"interface_catalogue": s.settings.interfaces_file.is_file(),
|
| 298 |
"index": {name: s.store.count(name) for name in ("mujoco", "ros2")},
|
| 299 |
"robots": sorted(ROBOTS),
|
|
@@ -304,11 +331,16 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 304 |
@app.post("/simulate/generate", response_model=SimulateResponse)
|
| 305 |
def simulate(req: SimulateRequest, t: Tester = Depends(tester), s: Services = Depends(svc)) -> SimulateResponse:
|
| 306 |
start = time.monotonic()
|
| 307 |
-
plan = plan_scene(req.description, req.robot, s.settings)
|
| 308 |
_check_budget(s, t.name, plan, req.confirm_cost)
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
try:
|
| 311 |
-
result = generate_scene(
|
| 312 |
except LLMError as e:
|
| 313 |
gen_id = s.logs.record(
|
| 314 |
tester=t.name,
|
|
@@ -317,6 +349,7 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 317 |
error=str(e),
|
| 318 |
duration_ms=int((time.monotonic() - start) * 1000),
|
| 319 |
)
|
|
|
|
| 320 |
raise HTTPException(status_code=502, detail=f"model call failed (log id {gen_id}): {e}") from e
|
| 321 |
gen_id = s.logs.record(
|
| 322 |
tester=t.name,
|
|
@@ -341,7 +374,8 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 341 |
model=result.model,
|
| 342 |
how_to_run=_how_to_run(req.robot),
|
| 343 |
routing=_routing_view(plan, result.attempts),
|
| 344 |
-
cost=_book(s, t.name, gen_id, plan, result.attempts),
|
|
|
|
| 345 |
)
|
| 346 |
|
| 347 |
@app.post("/code/generate", response_model=CodeResponse)
|
|
@@ -354,12 +388,17 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 354 |
req.request, req.distro, req.language, s.settings, len(mentioned_interfaces(req.request, catalogue))
|
| 355 |
)
|
| 356 |
_check_budget(s, t.name, plan, req.confirm_cost)
|
| 357 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
try:
|
| 359 |
if s.settings.codegen_backend == "opencode":
|
| 360 |
with OpenCodeWorkspace(s.settings) as ws:
|
| 361 |
result = generate_code(
|
| 362 |
-
|
| 363 |
req.distro,
|
| 364 |
req.language,
|
| 365 |
OpenCodeBackend(ws),
|
|
@@ -370,7 +409,7 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 370 |
)
|
| 371 |
else:
|
| 372 |
result = generate_code(
|
| 373 |
-
|
| 374 |
req.distro,
|
| 375 |
req.language,
|
| 376 |
DirectBackend((s.code_llm_factory or s.llm_factory)()),
|
|
@@ -387,6 +426,7 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 387 |
error=str(e),
|
| 388 |
duration_ms=int((time.monotonic() - start) * 1000),
|
| 389 |
)
|
|
|
|
| 390 |
raise HTTPException(status_code=502, detail=f"generation failed (log id {gen_id}): {e}") from e
|
| 391 |
gen_id = s.logs.record(
|
| 392 |
tester=t.name,
|
|
@@ -411,7 +451,8 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 411 |
id=gen_id,
|
| 412 |
check=check,
|
| 413 |
routing=_routing_view(plan, result.attempts),
|
| 414 |
-
cost=_book(s, t.name, gen_id, plan, result.attempts),
|
|
|
|
| 415 |
valid=result.valid,
|
| 416 |
files=result.files,
|
| 417 |
validation=result.validation.to_dict(),
|
|
@@ -679,6 +720,7 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 679 |
"budget": {
|
| 680 |
"month_spend_usd_estimate": month_spend(s.settings, s.logs),
|
| 681 |
"monthly_limit_usd": s.settings.monthly_credit_usd or None,
|
|
|
|
| 682 |
},
|
| 683 |
"jobs": [
|
| 684 |
{
|
|
@@ -847,14 +889,19 @@ def create_app(services: Services | None = None) -> FastAPI:
|
|
| 847 |
"tester": t.name,
|
| 848 |
"ai": budget.summary(s.logs, s.settings, t.name),
|
| 849 |
"compute": {
|
|
|
|
| 850 |
"month_spend_usd_estimate": month_spend(s.settings, s.logs),
|
| 851 |
"monthly_limit_usd": s.settings.monthly_credit_usd or None,
|
| 852 |
-
"note": "Simulations and checks run on the cloud GPU credit,
|
|
|
|
| 853 |
},
|
| 854 |
"admin": t.name in s.settings.admin_testers,
|
| 855 |
}
|
| 856 |
if out["admin"]:
|
| 857 |
-
|
|
|
|
|
|
|
|
|
|
| 858 |
return out
|
| 859 |
|
| 860 |
@app.post("/feedback/{gen_id}")
|
|
|
|
| 29 |
from .auth import Allowlist, Tester
|
| 30 |
from .codegen import DirectBackend, OpenCodeBackend, generate_code, mentioned_interfaces
|
| 31 |
from .config import Settings, get_settings
|
| 32 |
+
from .enhance import Enhanced
|
| 33 |
+
from .enhance import enhance as enhance_prompt
|
| 34 |
+
from .enhance import record as record_enhancement
|
| 35 |
+
from .jobs import JobError, compute_usage, month_spend, refresh, start_isaac, start_review
|
| 36 |
from .jobs import submit as submit_job
|
| 37 |
from .llm import LLM, ChatClient, LLMError
|
| 38 |
from .logstore import LogStore
|
|
|
|
| 59 |
backend: SimBackend | None = None # None: /simulate/run is disabled
|
| 60 |
storage: Storage | None = None
|
| 61 |
code_llm_factory: Callable[[], LLM] | None = None # direct ROS 2 code backend; None: same as llm_factory
|
| 62 |
+
enhancer: Callable[..., Enhanced] = enhance_prompt # free prompt enhancement through OpenCode Zen
|
| 63 |
|
| 64 |
|
| 65 |
def default_services() -> Services:
|
|
|
|
| 86 |
description: str = Field(..., min_length=10, max_length=4000, description="The scenario, in plain language")
|
| 87 |
robot: Literal[ROBOT_NAMES] | None = Field("unitree_g1", description="Robot to include, or null for none")
|
| 88 |
confirm_cost: bool = Field(False, description="Run even if the estimate is above CONFIRM_ABOVE_INR")
|
| 89 |
+
enhance: bool = Field(True, description="Sharpen the request first with a free OpenCode Zen model")
|
| 90 |
|
| 91 |
|
| 92 |
class SimulateResponse(BaseModel):
|
|
|
|
| 100 |
model: str
|
| 101 |
how_to_run: str
|
| 102 |
routing: dict | None = None # what RosDiff chose and why (tier, reason, simulator)
|
| 103 |
+
enhanced_prompt: dict | None = None # {used, text, model, note}: the brief the generator actually got
|
| 104 |
cost: dict | None = None # rupees this task used, the estimate, and the remaining budget
|
| 105 |
|
| 106 |
|
|
|
|
| 113 |
False, description="Also build, lint, test and launch it in the ROS 2 sandbox (see /code/check)"
|
| 114 |
)
|
| 115 |
confirm_cost: bool = Field(False, description="Run even if the estimate is above CONFIRM_ABOVE_INR")
|
| 116 |
+
enhance: bool = Field(True, description="Sharpen the request first with a free OpenCode Zen model")
|
| 117 |
|
| 118 |
|
| 119 |
class CodeResponse(BaseModel):
|
|
|
|
| 129 |
check: dict | None = None # the sandbox job, when requested: {id, status, report_url} or {error}
|
| 130 |
routing: dict | None = None
|
| 131 |
cost: dict | None = None
|
| 132 |
+
enhanced_prompt: dict | None = None
|
| 133 |
|
| 134 |
|
| 135 |
class RunRequest(BaseModel):
|
|
|
|
| 216 |
raise HTTPException(status_code=e.status, detail={"message": str(e), **e.detail}) from e
|
| 217 |
|
| 218 |
|
| 219 |
+
def _enhance(s: Services, wanted: bool, kind: str, text: str, **ctx) -> Enhanced:
|
| 220 |
+
if not wanted:
|
| 221 |
+
return Enhanced(text, text, False, note="not requested")
|
| 222 |
+
try:
|
| 223 |
+
return s.enhancer(s.settings, kind, text, **ctx)
|
| 224 |
+
except Exception as e: # enhancement is a nicety: never fail a generation because of it
|
| 225 |
+
log.warning("prompt enhancement crashed: %s", e)
|
| 226 |
+
return Enhanced(text, text, False, note="enhancer error")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _enhanced_view(e: Enhanced) -> dict:
|
| 230 |
+
return {"used": e.used, "text": e.text if e.used else None, "model": e.model, "note": e.note, "ms": e.ms}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _book(s: Services, tester: str, gen_id: str, plan, attempts, brief: Enhanced | None = None) -> dict:
|
| 234 |
"""Record every model call of a generation in the rupee ledger; return what this task cost."""
|
| 235 |
registry = {m.id: m for m in load_registry()}
|
| 236 |
before = budget.summary(s.logs, s.settings, tester)["used"]
|
| 237 |
+
if brief is not None:
|
| 238 |
+
record_enhancement(s.logs, tester, gen_id, plan.category, brief)
|
| 239 |
for i, a in enumerate(attempts):
|
| 240 |
spec = registry.get(a.model) or ModelSpec(a.model or "unknown", "?", "unknown", 0.0, 0.0)
|
| 241 |
budget.record_call(
|
|
|
|
| 318 |
"llm_configured": bool(s.settings.llm_api_key),
|
| 319 |
"codegen_backend": s.settings.codegen_backend,
|
| 320 |
"opencode_found": shutil.which(s.settings.opencode_bin) is not None,
|
| 321 |
+
"prompt_enhance": s.settings.prompt_enhance and shutil.which(s.settings.opencode_bin) is not None,
|
| 322 |
+
"enhance_model": s.settings.enhance_model,
|
| 323 |
+
"gpu_share_usd": s.settings.compute_share_usd or None,
|
| 324 |
"interface_catalogue": s.settings.interfaces_file.is_file(),
|
| 325 |
"index": {name: s.store.count(name) for name in ("mujoco", "ros2")},
|
| 326 |
"robots": sorted(ROBOTS),
|
|
|
|
| 331 |
@app.post("/simulate/generate", response_model=SimulateResponse)
|
| 332 |
def simulate(req: SimulateRequest, t: Tester = Depends(tester), s: Services = Depends(svc)) -> SimulateResponse:
|
| 333 |
start = time.monotonic()
|
| 334 |
+
plan = plan_scene(req.description, req.robot, s.settings) # routed on the tester's own words
|
| 335 |
_check_budget(s, t.name, plan, req.confirm_cost)
|
| 336 |
+
brief = _enhance(s, req.enhance, "scene", req.description, robot=req.robot)
|
| 337 |
+
logged_request = {
|
| 338 |
+
**req.model_dump(exclude={"confirm_cost"}),
|
| 339 |
+
"routing": plan.to_dict(),
|
| 340 |
+
"enhanced": _enhanced_view(brief),
|
| 341 |
+
}
|
| 342 |
try:
|
| 343 |
+
result = generate_scene(brief.text, req.robot, s.llm_factory(), s.store, s.settings, plan=plan)
|
| 344 |
except LLMError as e:
|
| 345 |
gen_id = s.logs.record(
|
| 346 |
tester=t.name,
|
|
|
|
| 349 |
error=str(e),
|
| 350 |
duration_ms=int((time.monotonic() - start) * 1000),
|
| 351 |
)
|
| 352 |
+
record_enhancement(s.logs, t.name, gen_id, "simulation_ai", brief)
|
| 353 |
raise HTTPException(status_code=502, detail=f"model call failed (log id {gen_id}): {e}") from e
|
| 354 |
gen_id = s.logs.record(
|
| 355 |
tester=t.name,
|
|
|
|
| 374 |
model=result.model,
|
| 375 |
how_to_run=_how_to_run(req.robot),
|
| 376 |
routing=_routing_view(plan, result.attempts),
|
| 377 |
+
cost=_book(s, t.name, gen_id, plan, result.attempts, brief),
|
| 378 |
+
enhanced_prompt=_enhanced_view(brief),
|
| 379 |
)
|
| 380 |
|
| 381 |
@app.post("/code/generate", response_model=CodeResponse)
|
|
|
|
| 388 |
req.request, req.distro, req.language, s.settings, len(mentioned_interfaces(req.request, catalogue))
|
| 389 |
)
|
| 390 |
_check_budget(s, t.name, plan, req.confirm_cost)
|
| 391 |
+
brief = _enhance(s, req.enhance, "code", req.request, distro=req.distro, language=req.language)
|
| 392 |
+
logged_request = {
|
| 393 |
+
**req.model_dump(exclude={"confirm_cost"}),
|
| 394 |
+
"routing": plan.to_dict(),
|
| 395 |
+
"enhanced": _enhanced_view(brief),
|
| 396 |
+
}
|
| 397 |
try:
|
| 398 |
if s.settings.codegen_backend == "opencode":
|
| 399 |
with OpenCodeWorkspace(s.settings) as ws:
|
| 400 |
result = generate_code(
|
| 401 |
+
brief.text,
|
| 402 |
req.distro,
|
| 403 |
req.language,
|
| 404 |
OpenCodeBackend(ws),
|
|
|
|
| 409 |
)
|
| 410 |
else:
|
| 411 |
result = generate_code(
|
| 412 |
+
brief.text,
|
| 413 |
req.distro,
|
| 414 |
req.language,
|
| 415 |
DirectBackend((s.code_llm_factory or s.llm_factory)()),
|
|
|
|
| 426 |
error=str(e),
|
| 427 |
duration_ms=int((time.monotonic() - start) * 1000),
|
| 428 |
)
|
| 429 |
+
record_enhancement(s.logs, t.name, gen_id, "coding", brief)
|
| 430 |
raise HTTPException(status_code=502, detail=f"generation failed (log id {gen_id}): {e}") from e
|
| 431 |
gen_id = s.logs.record(
|
| 432 |
tester=t.name,
|
|
|
|
| 451 |
id=gen_id,
|
| 452 |
check=check,
|
| 453 |
routing=_routing_view(plan, result.attempts),
|
| 454 |
+
cost=_book(s, t.name, gen_id, plan, result.attempts, brief),
|
| 455 |
+
enhanced_prompt=_enhanced_view(brief),
|
| 456 |
valid=result.valid,
|
| 457 |
files=result.files,
|
| 458 |
validation=result.validation.to_dict(),
|
|
|
|
| 720 |
"budget": {
|
| 721 |
"month_spend_usd_estimate": month_spend(s.settings, s.logs),
|
| 722 |
"monthly_limit_usd": s.settings.monthly_credit_usd or None,
|
| 723 |
+
"mine": compute_usage(s.settings, s.logs, t.name),
|
| 724 |
},
|
| 725 |
"jobs": [
|
| 726 |
{
|
|
|
|
| 889 |
"tester": t.name,
|
| 890 |
"ai": budget.summary(s.logs, s.settings, t.name),
|
| 891 |
"compute": {
|
| 892 |
+
**compute_usage(s.settings, s.logs, t.name),
|
| 893 |
"month_spend_usd_estimate": month_spend(s.settings, s.logs),
|
| 894 |
"monthly_limit_usd": s.settings.monthly_credit_usd or None,
|
| 895 |
+
"note": "Simulations and checks run on the cloud GPU credit, split equally between testers; "
|
| 896 |
+
"it is separate from the rupee AI budget.",
|
| 897 |
},
|
| 898 |
"admin": t.name in s.settings.admin_testers,
|
| 899 |
}
|
| 900 |
if out["admin"]:
|
| 901 |
+
names = [x.name for x in s.allowlist.testers()]
|
| 902 |
+
out["team"] = budget.team(s.logs, s.settings, names)
|
| 903 |
+
for row in out["team"]:
|
| 904 |
+
row["gpu"] = compute_usage(s.settings, s.logs, row["tester"])["used"]
|
| 905 |
return out
|
| 906 |
|
| 907 |
@app.post("/feedback/{gen_id}")
|
|
@@ -99,11 +99,19 @@ def _router_models(settings: Settings) -> list[str]:
|
|
| 99 |
return list(dict.fromkeys([settings.code_llm_model, *(m.id for m in load_registry())]))
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
class OpenCodeWorkspace:
|
| 103 |
-
"""One request's workspace. Use as a context manager so it is always cleaned up.
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
def __init__(self, settings: Settings):
|
| 106 |
self.settings = settings
|
|
|
|
|
|
|
| 107 |
self.root = Path(tempfile.mkdtemp(prefix="rosdiff_opencode_"))
|
| 108 |
self.ws = self.root / "ws"
|
| 109 |
self.state = self.root / "state"
|
|
@@ -121,7 +129,8 @@ class OpenCodeWorkspace:
|
|
| 121 |
env = {k: v for k, v in os.environ.items() if not k.startswith("OPENCODE_")}
|
| 122 |
env.update(
|
| 123 |
LLM_API_KEY=self.settings.llm_api_key,
|
| 124 |
-
|
|
|
|
| 125 |
OPENCODE_DISABLE_PROJECT_CONFIG="1",
|
| 126 |
OPENCODE_DISABLE_AUTOUPDATE="1",
|
| 127 |
OPENCODE_DISABLE_SHARE="1",
|
|
@@ -136,13 +145,11 @@ class OpenCodeWorkspace:
|
|
| 136 |
)
|
| 137 |
return env
|
| 138 |
|
| 139 |
-
def run(self, message: str, context: str, model: str | None = None) -> OpenCodeRun:
|
| 140 |
"""First call starts a session; later calls continue it (used for repair rounds)."""
|
| 141 |
binary = shutil.which(self.settings.opencode_bin) or self.settings.opencode_bin
|
| 142 |
if not shutil.which(binary):
|
| 143 |
raise OpenCodeError(f"OpenCode binary {self.settings.opencode_bin!r} not found (npm i -g opencode-ai)")
|
| 144 |
-
context_file = self.root / f"context_{self.runs}.md"
|
| 145 |
-
context_file.write_text(context)
|
| 146 |
cmd = [
|
| 147 |
binary,
|
| 148 |
"run",
|
|
@@ -150,13 +157,16 @@ class OpenCodeWorkspace:
|
|
| 150 |
"--dir",
|
| 151 |
str(self.ws),
|
| 152 |
"--model",
|
| 153 |
-
f"
|
| 154 |
"--format",
|
| 155 |
"json",
|
| 156 |
]
|
| 157 |
if self.runs:
|
| 158 |
cmd.append("--continue")
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
| 160 |
self.runs += 1
|
| 161 |
try:
|
| 162 |
proc = subprocess.run(
|
|
@@ -165,10 +175,10 @@ class OpenCodeWorkspace:
|
|
| 165 |
env=self._env(),
|
| 166 |
capture_output=True,
|
| 167 |
text=True,
|
| 168 |
-
timeout=self.
|
| 169 |
)
|
| 170 |
except subprocess.TimeoutExpired as e:
|
| 171 |
-
raise OpenCodeError(f"OpenCode did not finish within {self.
|
| 172 |
events = []
|
| 173 |
for line in proc.stdout.splitlines():
|
| 174 |
line = line.strip()
|
|
|
|
| 99 |
return list(dict.fromkeys([settings.code_llm_model, *(m.id for m in load_registry())]))
|
| 100 |
|
| 101 |
|
| 102 |
+
def find_opencode(settings: Settings) -> str | None:
|
| 103 |
+
return shutil.which(settings.opencode_bin)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
class OpenCodeWorkspace:
|
| 107 |
+
"""One request's workspace. Use as a context manager so it is always cleaned up.
|
| 108 |
+
|
| 109 |
+
`config` replaces the code-generation config (the prompt enhancer uses a free Zen model with no tools)."""
|
| 110 |
|
| 111 |
+
def __init__(self, settings: Settings, config: dict | None = None, timeout: float | None = None):
|
| 112 |
self.settings = settings
|
| 113 |
+
self.config = config
|
| 114 |
+
self.timeout = timeout or settings.opencode_timeout
|
| 115 |
self.root = Path(tempfile.mkdtemp(prefix="rosdiff_opencode_"))
|
| 116 |
self.ws = self.root / "ws"
|
| 117 |
self.state = self.root / "state"
|
|
|
|
| 129 |
env = {k: v for k, v in os.environ.items() if not k.startswith("OPENCODE_")}
|
| 130 |
env.update(
|
| 131 |
LLM_API_KEY=self.settings.llm_api_key,
|
| 132 |
+
ZEN_API_KEY=self.settings.zen_api_key,
|
| 133 |
+
OPENCODE_CONFIG_CONTENT=json.dumps(self.config or opencode_config(self.settings)),
|
| 134 |
OPENCODE_DISABLE_PROJECT_CONFIG="1",
|
| 135 |
OPENCODE_DISABLE_AUTOUPDATE="1",
|
| 136 |
OPENCODE_DISABLE_SHARE="1",
|
|
|
|
| 145 |
)
|
| 146 |
return env
|
| 147 |
|
| 148 |
+
def run(self, message: str, context: str, model: str | None = None, provider: str = "llm") -> OpenCodeRun:
|
| 149 |
"""First call starts a session; later calls continue it (used for repair rounds)."""
|
| 150 |
binary = shutil.which(self.settings.opencode_bin) or self.settings.opencode_bin
|
| 151 |
if not shutil.which(binary):
|
| 152 |
raise OpenCodeError(f"OpenCode binary {self.settings.opencode_bin!r} not found (npm i -g opencode-ai)")
|
|
|
|
|
|
|
| 153 |
cmd = [
|
| 154 |
binary,
|
| 155 |
"run",
|
|
|
|
| 157 |
"--dir",
|
| 158 |
str(self.ws),
|
| 159 |
"--model",
|
| 160 |
+
f"{provider}/{model or self.settings.code_llm_model}",
|
| 161 |
"--format",
|
| 162 |
"json",
|
| 163 |
]
|
| 164 |
if self.runs:
|
| 165 |
cmd.append("--continue")
|
| 166 |
+
if context:
|
| 167 |
+
context_file = self.root / f"context_{self.runs}.md"
|
| 168 |
+
context_file.write_text(context)
|
| 169 |
+
cmd += ["-f", str(context_file)]
|
| 170 |
self.runs += 1
|
| 171 |
try:
|
| 172 |
proc = subprocess.run(
|
|
|
|
| 175 |
env=self._env(),
|
| 176 |
capture_output=True,
|
| 177 |
text=True,
|
| 178 |
+
timeout=self.timeout,
|
| 179 |
)
|
| 180 |
except subprocess.TimeoutExpired as e:
|
| 181 |
+
raise OpenCodeError(f"OpenCode did not finish within {self.timeout:.0f}s") from e
|
| 182 |
events = []
|
| 183 |
for line in proc.stdout.splitlines():
|
| 184 |
line = line.strip()
|
|
@@ -6,7 +6,8 @@
|
|
| 6 |
--border: #e4e4e8;
|
| 7 |
--text: #17171c;
|
| 8 |
--muted: #6b6b76;
|
| 9 |
-
--accent: #
|
|
|
|
| 10 |
--accent-soft: #efedff;
|
| 11 |
--accent-text: #ffffff;
|
| 12 |
--ok: #178a4f;
|
|
@@ -39,7 +40,7 @@
|
|
| 39 |
--border: #2a2a31;
|
| 40 |
--text: #ececf1;
|
| 41 |
--muted: #9a9aa6;
|
| 42 |
-
--accent: #
|
| 43 |
--accent-soft: #25224a;
|
| 44 |
--accent-text: #0e0e11;
|
| 45 |
--ok: #4cc38a;
|
|
@@ -245,9 +246,11 @@ details.xml > summary { cursor: pointer; font-size: 13.5px; color: var(--muted);
|
|
| 245 |
/* ---------- feedback ---------- */
|
| 246 |
.feedback { display: inline-flex; gap: 2px; align-items: center; }
|
| 247 |
.feedback .label { font-size: 12.5px; color: var(--muted); margin-right: 4px; }
|
| 248 |
-
.fb { border: 1px solid transparent; background: none; padding: 4px
|
| 249 |
.fb:hover { background: var(--panel-2); }
|
| 250 |
.fb.chosen { background: var(--accent-soft); border-color: var(--accent); }
|
|
|
|
|
|
|
| 251 |
|
| 252 |
/* ---------- composer ---------- */
|
| 253 |
.composer { padding: 10px 14px 14px; border-top: 1px solid var(--border); background: var(--bg); }
|
|
@@ -304,3 +307,86 @@ details.xml > summary { cursor: pointer; font-size: 13.5px; color: var(--muted);
|
|
| 304 |
.thread { padding: 14px 12px; }
|
| 305 |
.composer { padding: 8px 10px 12px; }
|
| 306 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
--border: #e4e4e8;
|
| 7 |
--text: #17171c;
|
| 8 |
--muted: #6b6b76;
|
| 9 |
+
--accent: #5b5cf6;
|
| 10 |
+
--brand-grad: linear-gradient(135deg, #1fa2ff 0%, #4f5bff 45%, #9b6cff 100%);
|
| 11 |
--accent-soft: #efedff;
|
| 12 |
--accent-text: #ffffff;
|
| 13 |
--ok: #178a4f;
|
|
|
|
| 40 |
--border: #2a2a31;
|
| 41 |
--text: #ececf1;
|
| 42 |
--muted: #9a9aa6;
|
| 43 |
+
--accent: #7c83ff;
|
| 44 |
--accent-soft: #25224a;
|
| 45 |
--accent-text: #0e0e11;
|
| 46 |
--ok: #4cc38a;
|
|
|
|
| 246 |
/* ---------- feedback ---------- */
|
| 247 |
.feedback { display: inline-flex; gap: 2px; align-items: center; }
|
| 248 |
.feedback .label { font-size: 12.5px; color: var(--muted); margin-right: 4px; }
|
| 249 |
+
.fb { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); border: 1px solid transparent; background: none; padding: 4px 8px; border-radius: 8px; cursor: pointer; font-size: 15px; line-height: 1; }
|
| 250 |
.fb:hover { background: var(--panel-2); }
|
| 251 |
.fb.chosen { background: var(--accent-soft); border-color: var(--accent); }
|
| 252 |
+
.fb-label { font-size: 12.5px; }
|
| 253 |
+
@media (max-width: 560px) { .fb-label { display: none; } }
|
| 254 |
|
| 255 |
/* ---------- composer ---------- */
|
| 256 |
.composer { padding: 10px 14px 14px; border-top: 1px solid var(--border); background: var(--bg); }
|
|
|
|
| 307 |
.thread { padding: 14px 12px; }
|
| 308 |
.composer { padding: 8px 10px 12px; }
|
| 309 |
}
|
| 310 |
+
|
| 311 |
+
/* ---------- brand ---------- */
|
| 312 |
+
.logo-img { width: 28px; height: 28px; border-radius: 8px; display: block; }
|
| 313 |
+
.brand .by { font-weight: 500; font-size: 12.5px; color: var(--muted); }
|
| 314 |
+
.btn-primary, .send { background: var(--brand-grad); border-color: transparent; color: #fff; }
|
| 315 |
+
.btn-primary:hover { background: var(--brand-grad); filter: brightness(1.08); }
|
| 316 |
+
.login-logo { width: 84px; height: 84px; border-radius: 20px; display: block; margin: 0 auto 12px; box-shadow: 0 10px 40px rgb(79 91 255 / 35%); }
|
| 317 |
+
.login-brand { font-weight: 700; font-size: 19px; letter-spacing: -0.01em; margin-bottom: 14px; }
|
| 318 |
+
.login-brand span { font-weight: 500; color: var(--muted); font-size: 14px; }
|
| 319 |
+
|
| 320 |
+
/* ---------- startup animation ---------- */
|
| 321 |
+
.splash { position: fixed; inset: 0; z-index: 100; background: #050507; display: grid; place-items: center; transition: opacity .45s ease; }
|
| 322 |
+
.splash.done { opacity: 0; pointer-events: none; }
|
| 323 |
+
.splash-inner { text-align: center; }
|
| 324 |
+
.splash-logo { width: 168px; height: 168px; border-radius: 36px; opacity: 0; transform: scale(.82); filter: drop-shadow(0 0 0 rgb(79 91 255 / 0%));
|
| 325 |
+
animation: logo-in .9s cubic-bezier(.2,.8,.2,1) .1s forwards; }
|
| 326 |
+
.splash-name { color: #f2f3ff; font: 700 34px/1.2 var(--font); letter-spacing: -0.02em; margin-top: 18px; opacity: 0; transform: translateY(8px); animation: rise .6s ease .75s forwards; }
|
| 327 |
+
.splash-by { color: #9aa0c8; font: 500 15px var(--font); letter-spacing: .08em; text-transform: uppercase; margin-top: 6px; opacity: 0; transform: translateY(8px); animation: rise .6s ease 1.1s forwards; }
|
| 328 |
+
.splash.quick .splash-logo { animation-duration: .35s; animation-delay: 0s; }
|
| 329 |
+
.splash.quick .splash-name { animation-duration: .3s; animation-delay: .15s; }
|
| 330 |
+
.splash.quick .splash-by { animation-duration: .3s; animation-delay: .25s; }
|
| 331 |
+
@keyframes logo-in { to { opacity: 1; transform: scale(1); filter: drop-shadow(0 0 38px rgb(79 91 255 / 55%)); } }
|
| 332 |
+
@keyframes rise { to { opacity: 1; transform: none; } }
|
| 333 |
+
@media (prefers-reduced-motion: reduce) {
|
| 334 |
+
.splash-logo, .splash-name, .splash-by { animation: none; opacity: 1; transform: none; }
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/* ---------- budget button ---------- */
|
| 338 |
+
button.budget { border: 1px solid transparent; background: none; cursor: pointer; padding: 4px 8px; border-radius: 99px; font: inherit; font-size: 12.5px; color: var(--muted); }
|
| 339 |
+
button.budget:hover { border-color: var(--border); background: var(--panel-2); }
|
| 340 |
+
|
| 341 |
+
/* ---------- routing line on result cards ---------- */
|
| 342 |
+
.routing { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); margin: 0 0 10px; }
|
| 343 |
+
.routing .auto { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 99px; background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
| 344 |
+
.routing .auto::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--brand-grad); }
|
| 345 |
+
.routing details { display: inline; }
|
| 346 |
+
.routing summary { cursor: pointer; list-style: none; text-decoration: underline dotted; text-underline-offset: 3px; }
|
| 347 |
+
.routing summary::-webkit-details-marker { display: none; }
|
| 348 |
+
.routing .why { display: block; margin-top: 6px; padding: 8px 10px; border-radius: 8px; background: var(--panel-2); color: var(--text); line-height: 1.5; }
|
| 349 |
+
.sim-menu { order: 99; flex-basis: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 2px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 12px; padding: 6px; }
|
| 350 |
+
.sim-menu[hidden] { display: none; }
|
| 351 |
+
.sim-menu > .muted { grid-column: 1 / -1; }
|
| 352 |
+
.sim-opt { display: block; width: 100%; text-align: left; border: 0; background: none; padding: 8px 10px; border-radius: 8px; cursor: pointer; }
|
| 353 |
+
.sim-opt:hover:not(:disabled) { background: var(--panel); }
|
| 354 |
+
.sim-opt:disabled { opacity: .5; cursor: default; }
|
| 355 |
+
.sim-opt b { display: block; font-size: 14px; }
|
| 356 |
+
.sim-opt span { font-size: 12.5px; color: var(--muted); }
|
| 357 |
+
.sim-opt.chosen b::after { content: " ✓"; color: var(--accent); }
|
| 358 |
+
|
| 359 |
+
/* ---------- confirmation & dialogs ---------- */
|
| 360 |
+
.enhanced { margin: 0 0 10px; font-size: 13px; }
|
| 361 |
+
.enhanced summary { cursor: pointer; color: var(--muted); }
|
| 362 |
+
.enhanced .why { display: block; margin-top: 6px; padding: 8px 10px; border-radius: 8px; background: var(--panel-2); line-height: 1.5; white-space: pre-wrap; }
|
| 363 |
+
.confirm { display: flex; flex-direction: column; gap: 8px; }
|
| 364 |
+
.confirm .big { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
|
| 365 |
+
.textarea { width: 100%; border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--text); padding: 10px 12px; font: 14.5px/1.5 var(--font); resize: vertical; }
|
| 366 |
+
.textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
| 367 |
+
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
| 368 |
+
.usage-big { font-size: 26px; font-weight: 700; letter-spacing: -0.02em; }
|
| 369 |
+
.meter { height: 8px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--border); overflow: hidden; margin: 8px 0 14px; }
|
| 370 |
+
.meter span { display: block; height: 100%; background: var(--brand-grad); }
|
| 371 |
+
.usage-table { width: 100%; border-collapse: collapse; font-size: 14px; margin: 6px 0 14px; }
|
| 372 |
+
.usage-table td, .usage-table th { padding: 6px 4px; border-bottom: 1px solid var(--border); text-align: left; }
|
| 373 |
+
.usage-table td:last-child, .usage-table th:last-child { text-align: right; font-variant-numeric: tabular-nums; }
|
| 374 |
+
|
| 375 |
+
/* ---------- tour ---------- */
|
| 376 |
+
.tour { width: min(560px, calc(100% - 32px)); padding: 18px 22px 20px; }
|
| 377 |
+
.tour-top { display: flex; align-items: center; gap: 10px; }
|
| 378 |
+
.tour-top img { border-radius: 8px; }
|
| 379 |
+
.tour-count { font-size: 12.5px; color: var(--muted); margin-right: auto; }
|
| 380 |
+
.tour-slide { min-height: 210px; padding: 14px 2px 6px; }
|
| 381 |
+
.tour-slide .step { font-size: 12px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; background: var(--brand-grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
| 382 |
+
.tour-slide h3 { font-size: 21px; margin: 6px 0 8px; letter-spacing: -0.015em; line-height: 1.25; }
|
| 383 |
+
.tour-slide p { margin: 0 0 10px; color: var(--muted); }
|
| 384 |
+
.tour-demo { border: 1px solid var(--border); border-radius: 12px; padding: 12px; background: var(--panel-2); font-size: 14px; }
|
| 385 |
+
.tour-demo .you { font-weight: 600; margin-bottom: 8px; }
|
| 386 |
+
.tour-demo .line { display: flex; gap: 8px; align-items: baseline; margin-top: 4px; }
|
| 387 |
+
.tour-demo .line b { color: var(--text); }
|
| 388 |
+
.tour-dots { display: flex; gap: 6px; justify-content: center; margin: 4px 0 14px; }
|
| 389 |
+
.tour-dots span { width: 7px; height: 7px; border-radius: 50%; background: var(--border); transition: background .2s, width .2s; }
|
| 390 |
+
.tour-dots span.on { width: 20px; border-radius: 99px; background: var(--accent); }
|
| 391 |
+
.tour-actions { display: flex; justify-content: space-between; }
|
| 392 |
+
@media (max-width: 860px) { .brand .by { display: none; } }
|
|
@@ -76,7 +76,7 @@ function friendly(err) {
|
|
| 76 |
const msg = typeof d === "string" ? d : d?.message || err.message;
|
| 77 |
switch (err.status) {
|
| 78 |
case 401: return "Your key isn't recognised. Sign out and sign in again.";
|
| 79 |
-
case 402: return "This month's shared compute
|
| 80 |
case 429: return "Today's limit for this is reached. Try again tomorrow.";
|
| 81 |
case 503: return "This part isn't switched on on the server yet.";
|
| 82 |
case 507: return "Result storage is full for now. Old results are cleared automatically; try again later.";
|
|
@@ -97,6 +97,7 @@ const state = {
|
|
| 97 |
distro: localStorage.getItem("rosdiff_distro") || "humble",
|
| 98 |
lang: localStorage.getItem("rosdiff_lang") || "python",
|
| 99 |
busy: { sim: false, code: false },
|
|
|
|
| 100 |
};
|
| 101 |
function remember(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }
|
| 102 |
|
|
@@ -205,16 +206,21 @@ function workingCard(steps) {
|
|
| 205 |
return card;
|
| 206 |
}
|
| 207 |
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
const
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
| 213 |
b.addEventListener("click", async () => {
|
|
|
|
| 214 |
try {
|
| 215 |
-
await api(
|
| 216 |
$$(".fb", wrap).forEach((x) => { x.classList.toggle("chosen", x === b); x.setAttribute("aria-pressed", String(x === b)); });
|
| 217 |
-
|
|
|
|
| 218 |
} catch (e) { toast(friendly(e)); }
|
| 219 |
});
|
| 220 |
wrap.append(b);
|
|
@@ -222,6 +228,87 @@ function feedbackBar(genId, current) {
|
|
| 222 |
return wrap;
|
| 223 |
}
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
function errorCard(message) {
|
| 226 |
return h("div", { class: "card" }, h("div", { class: "card-head" }, statusIcon("bad"), h("div", {}, h("div", { class: "title" }, "That didn't work"), h("div", { class: "sub" }, message))));
|
| 227 |
}
|
|
@@ -241,6 +328,10 @@ function sceneCard(gen) {
|
|
| 241 |
h("div", { class: "title" }, ok ? "Scene ready" : "Couldn't build a working scene"),
|
| 242 |
h("div", { class: "sub" }, ok ? `Loads and simulates in MuJoCo · ${robotLabel(gen.robot)}` : "MuJoCo rejected every attempt")));
|
| 243 |
const body = h("div", { class: "card-body" });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
if (ok) {
|
| 245 |
body.append(h("div", { class: "facts" },
|
| 246 |
h("span", { class: "pill" }, `${stats.nbody ?? "?"} bodies`),
|
|
@@ -255,27 +346,56 @@ function sceneCard(gen) {
|
|
| 255 |
}
|
| 256 |
const actions = h("div", { class: "card-actions" });
|
| 257 |
const runs = h("div", { class: "runs" });
|
| 258 |
-
if (ok)
|
| 259 |
-
const run = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("play"), "Run simulation");
|
| 260 |
-
run.addEventListener("click", () => startRun(gen, runs, run));
|
| 261 |
-
actions.append(run);
|
| 262 |
-
}
|
| 263 |
if (gen.mjcf) {
|
| 264 |
const dl = h("button", { class: "btn btn-sm", type: "button" }, icon("download"), "Download");
|
| 265 |
dl.addEventListener("click", () => download(`scene_${gen.id}.xml`, new Blob([gen.mjcf], { type: "application/xml" })));
|
| 266 |
actions.append(dl);
|
| 267 |
}
|
| 268 |
-
actions.append(h("span", { class: "spacer" }), feedbackBar(gen.id, gen.rating));
|
| 269 |
const wrap = h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), runs);
|
| 270 |
return wrap;
|
| 271 |
}
|
| 272 |
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
button.disabled = true;
|
| 275 |
try {
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
scrollDown("sim");
|
| 280 |
} catch (e) {
|
| 281 |
container.append(errorCard(friendly(e)));
|
|
@@ -284,6 +404,18 @@ async function startRun(gen, container, button) {
|
|
| 284 |
}
|
| 285 |
}
|
| 286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
// --- code
|
| 288 |
|
| 289 |
function packageCard(gen) {
|
|
@@ -296,6 +428,10 @@ function packageCard(gen) {
|
|
| 296 |
h("div", { class: "title" }, ok ? "Package ready" : names.length ? "Package written, with problems" : "Couldn't write the package"),
|
| 297 |
h("div", { class: "sub" }, `${names.length} file(s) · ROS 2 ${gen.distro || ""} · ${gen.language === "cpp" ? "C++" : "Python"}`)));
|
| 298 |
const body = h("div", { class: "card-body" });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
const errors = val.errors || [];
|
| 300 |
if (errors.length) body.append(h("div", { class: "error-box" }, errors.slice(0, 6).join("\n")));
|
| 301 |
if ((val.warnings || []).length) body.append(h("p", { class: "note" }, `${val.warnings.length} note(s): ${val.warnings.slice(0, 2).join("; ")}`));
|
|
@@ -332,7 +468,7 @@ function packageCard(gen) {
|
|
| 332 |
});
|
| 333 |
actions.append(check, zip, copy);
|
| 334 |
}
|
| 335 |
-
actions.append(h("span", { class: "spacer" }), feedbackBar(gen.id, gen.rating));
|
| 336 |
return h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), checks);
|
| 337 |
}
|
| 338 |
function score(path) {
|
|
@@ -366,10 +502,11 @@ const JOB_TEXT = {
|
|
| 366 |
export: { wait: "Starting the export…", run: "Building the dataset…", title: "Dataset export" },
|
| 367 |
};
|
| 368 |
|
| 369 |
-
function jobCard(job) {
|
| 370 |
const card = h("div", { class: "card" });
|
| 371 |
card.dataset.job = job.id;
|
| 372 |
const render = (j) => {
|
|
|
|
| 373 |
const text = JOB_TEXT[j.kind] || JOB_TEXT.sim;
|
| 374 |
card.replaceChildren();
|
| 375 |
if (!TERMINAL.has(j.status)) {
|
|
@@ -387,7 +524,12 @@ function jobCard(job) {
|
|
| 387 |
if (j.kind === "sim") card.append(...simResult(j));
|
| 388 |
else if (j.kind === "ros_check") card.append(...checkResult(j));
|
| 389 |
else if (j.kind === "sweep") card.append(...sweepResult(j));
|
| 390 |
-
else
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
};
|
| 392 |
render(job);
|
| 393 |
poll(job.id, render);
|
|
@@ -411,6 +553,7 @@ async function poll(id, render) {
|
|
| 411 |
function reportActions(j, ...extra) {
|
| 412 |
const bar = h("div", { class: "card-actions" }, ...extra);
|
| 413 |
if (j.report_url) bar.append(h("a", { class: "btn btn-sm", href: j.report_url, target: "_blank", rel: "noopener" }, icon("external"), "Full report"));
|
|
|
|
| 414 |
return bar;
|
| 415 |
}
|
| 416 |
|
|
@@ -445,8 +588,9 @@ function simResult(j) {
|
|
| 445 |
extra.append(jobCard({ id: job.id, kind: "sweep", status: job.status, report_url: job.report_url }));
|
| 446 |
} catch (e) { extra.append(errorCard(friendly(e))); stress.disabled = false; }
|
| 447 |
});
|
| 448 |
-
const
|
| 449 |
-
|
|
|
|
| 450 |
}
|
| 451 |
|
| 452 |
function checkResult(j) {
|
|
@@ -532,31 +676,47 @@ async function submit(pane) {
|
|
| 532 |
const input = $(`#input-${pane}`);
|
| 533 |
const text = input.value.trim();
|
| 534 |
if (text.length < 10) { toast("Please describe it in a bit more detail (at least 10 characters)."); input.focus(); return; }
|
| 535 |
-
state.busy[pane] = true;
|
| 536 |
-
$(`#composer-${pane} .send`).disabled = true;
|
| 537 |
const t = thread(pane);
|
| 538 |
if (t.querySelector(".empty")) t.replaceChildren();
|
| 539 |
const meta = pane === "sim" ? robotLabel(state.robot) : `ROS 2 ${cap(state.distro)} · ${state.lang === "cpp" ? "C++" : "Python"}`;
|
| 540 |
t.append(userMessage(text, meta));
|
| 541 |
input.value = "";
|
| 542 |
autosize(input);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
const steps = pane === "sim"
|
| 544 |
-
? ["Reading the MuJoCo documentation…", "Writing the scene…", "Testing it in MuJoCo…", "Fixing anything MuJoCo complained about…", "Almost there…"]
|
| 545 |
-
: ["Looking up the ROS 2 messages it needs…", "Writing the package…", "Checking every message type exists…", "
|
| 546 |
const working = workingCard(steps);
|
| 547 |
t.append(working);
|
| 548 |
scrollDown(pane);
|
| 549 |
try {
|
| 550 |
if (pane === "sim") {
|
| 551 |
-
const r = await api("/simulate/generate", { method: "POST", body
|
| 552 |
working.replaceWith(sceneCard({ ...r, rating: null }));
|
| 553 |
} else {
|
| 554 |
-
const r = await api("/code/generate", { method: "POST", body
|
| 555 |
-
working.replaceWith(packageCard({ ...r, distro:
|
| 556 |
}
|
| 557 |
loadHistory();
|
|
|
|
| 558 |
} catch (e) {
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
} finally {
|
| 561 |
working.stop();
|
| 562 |
state.busy[pane] = false;
|
|
@@ -603,6 +763,10 @@ async function loadHistory() {
|
|
| 603 |
}
|
| 604 |
}
|
| 605 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
async function openGeneration(id, kind, item) {
|
| 607 |
closeSidebar();
|
| 608 |
switchPane(kind);
|
|
@@ -615,12 +779,12 @@ async function openGeneration(id, kind, item) {
|
|
| 615 |
const content = [];
|
| 616 |
if (g.kind === "code") {
|
| 617 |
content.push(userMessage(req.request || "", `ROS 2 ${cap(req.distro || "")} · ${req.language === "cpp" ? "C++" : "Python"}`));
|
| 618 |
-
const card = packageCard({ id: g.id, valid: g.valid, validation: g.validation, files: g.output || {}, distro: req.distro, language: req.language, rating: g.rating });
|
| 619 |
content.push(card);
|
| 620 |
for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
|
| 621 |
} else {
|
| 622 |
content.push(userMessage(req.description || "", robotLabel(req.robot)));
|
| 623 |
-
const card = sceneCard({ id: g.id, valid: g.valid, validation: g.validation, mjcf: g.output, robot: req.robot, error: g.error, rating: g.rating });
|
| 624 |
content.push(card);
|
| 625 |
for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
|
| 626 |
}
|
|
@@ -634,17 +798,14 @@ async function openGeneration(id, kind, item) {
|
|
| 634 |
|
| 635 |
async function refreshBudget() {
|
| 636 |
try {
|
| 637 |
-
const {
|
| 638 |
-
const
|
| 639 |
-
|
| 640 |
-
const el = $("#budget");
|
| 641 |
-
if (!limit) { $("#budget-label").textContent = `$${used.toFixed(2)} used this month`; return; }
|
| 642 |
-
const pct = Math.min(100, (100 * used) / limit);
|
| 643 |
-
$("#budget-label").textContent = `$${used.toFixed(2)} of $${limit.toFixed(0)} this month`;
|
| 644 |
$("#budget-fill").style.width = pct + "%";
|
|
|
|
| 645 |
el.classList.toggle("high", pct >= 70 && pct < 100);
|
| 646 |
el.classList.toggle("full", pct >= 100);
|
| 647 |
-
el.title =
|
| 648 |
} catch { /* not important */ }
|
| 649 |
}
|
| 650 |
|
|
@@ -699,7 +860,9 @@ function setupChrome() {
|
|
| 699 |
$("#user-btn").addEventListener("click", (e) => { e.stopPropagation(); menu.hidden = !menu.hidden; $("#user-btn").setAttribute("aria-expanded", String(!menu.hidden)); });
|
| 700 |
document.addEventListener("click", () => { menu.hidden = true; $("#user-btn").setAttribute("aria-expanded", "false"); });
|
| 701 |
$("#logout-btn").addEventListener("click", () => signOut(false));
|
| 702 |
-
$("#help-btn").addEventListener("click", () =>
|
|
|
|
|
|
|
| 703 |
}
|
| 704 |
|
| 705 |
// ------------------------------------------------------------------ sign in / out
|
|
@@ -725,7 +888,9 @@ async function enter() {
|
|
| 725 |
loadHistory();
|
| 726 |
refreshBudget();
|
| 727 |
setInterval(refreshBudget, 60000);
|
| 728 |
-
|
|
|
|
|
|
|
| 729 |
}
|
| 730 |
|
| 731 |
function setupLogin() {
|
|
@@ -744,12 +909,141 @@ function setupLogin() {
|
|
| 744 |
});
|
| 745 |
}
|
| 746 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
setupLogin();
|
| 748 |
setupChrome();
|
| 749 |
setupComposer();
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
|
|
|
|
|
|
|
|
| 76 |
const msg = typeof d === "string" ? d : d?.message || err.message;
|
| 77 |
switch (err.status) {
|
| 78 |
case 401: return "Your key isn't recognised. Sign out and sign in again.";
|
| 79 |
+
case 402: return /budget/i.test(msg || "") ? msg : "This month's shared compute credit is used up. Please tell the Orcanion team.";
|
| 80 |
case 429: return "Today's limit for this is reached. Try again tomorrow.";
|
| 81 |
case 503: return "This part isn't switched on on the server yet.";
|
| 82 |
case 507: return "Result storage is full for now. Old results are cleared automatically; try again later.";
|
|
|
|
| 97 |
distro: localStorage.getItem("rosdiff_distro") || "humble",
|
| 98 |
lang: localStorage.getItem("rosdiff_lang") || "python",
|
| 99 |
busy: { sim: false, code: false },
|
| 100 |
+
rated: {}, // job id -> helpful | not_helpful (this session)
|
| 101 |
};
|
| 102 |
function remember(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }
|
| 103 |
|
|
|
|
| 206 |
return card;
|
| 207 |
}
|
| 208 |
|
| 209 |
+
// 👍 / 👎 on a scene, a package or a job, with an optional note. Stored with what RosDiff did for it.
|
| 210 |
+
function feedbackBar(type, id, current) {
|
| 211 |
+
const cur = current === "works" || current === "helpful" ? "helpful" : current ? "not_helpful" : null;
|
| 212 |
+
const wrap = h("div", { class: "feedback", role: "group", "aria-label": "Was this helpful?" });
|
| 213 |
+
const opts = [["helpful", "👍", "Helpful"], ["not_helpful", "👎", "Not helpful"]];
|
| 214 |
+
for (const [rating, emoji, label] of opts) {
|
| 215 |
+
const b = h("button", { class: "fb" + (cur === rating ? " chosen" : ""), type: "button", title: label, "aria-pressed": String(cur === rating) },
|
| 216 |
+
h("span", { "aria-hidden": "true" }, emoji), h("span", { class: "fb-label" }, label));
|
| 217 |
b.addEventListener("click", async () => {
|
| 218 |
+
const note = await askNote(rating);
|
| 219 |
try {
|
| 220 |
+
await api("/feedback", { method: "POST", body: { target_type: type, target_id: id, rating, note } });
|
| 221 |
$$(".fb", wrap).forEach((x) => { x.classList.toggle("chosen", x === b); x.setAttribute("aria-pressed", String(x === b)); });
|
| 222 |
+
if (type === "job") state.rated[id] = rating;
|
| 223 |
+
toast(note ? "Thanks, we read every note!" : "Thanks for the feedback!");
|
| 224 |
} catch (e) { toast(friendly(e)); }
|
| 225 |
});
|
| 226 |
wrap.append(b);
|
|
|
|
| 228 |
return wrap;
|
| 229 |
}
|
| 230 |
|
| 231 |
+
function askNote(rating) {
|
| 232 |
+
const d = $("#fb-dialog");
|
| 233 |
+
const ta = $("#fb-note");
|
| 234 |
+
ta.value = "";
|
| 235 |
+
$("#fb-title").textContent = rating === "helpful" ? "Glad it helped! Anything we could do better?" : "Tell us what went wrong or how we can improve";
|
| 236 |
+
return new Promise((resolve) => {
|
| 237 |
+
d.addEventListener("close", () => resolve(d.returnValue === "send" ? ta.value.trim() : ""), { once: true });
|
| 238 |
+
d.returnValue = "";
|
| 239 |
+
d.showModal();
|
| 240 |
+
ta.focus();
|
| 241 |
+
});
|
| 242 |
+
}
|
| 243 |
+
$("#fb-form").addEventListener("submit", (e) => { e.preventDefault(); $("#fb-dialog").close("send"); });
|
| 244 |
+
$("#fb-skip").addEventListener("click", () => $("#fb-dialog").close("skip"));
|
| 245 |
+
|
| 246 |
+
// ------------------------------------------------------------------ routing, simulators, cost
|
| 247 |
+
|
| 248 |
+
const TIER_TEXT = { low: "Fast", mid: "Standard", high: "High reasoning" };
|
| 249 |
+
const SIMS = [
|
| 250 |
+
{ id: "mujoco", label: "MuJoCo", what: "One run with video and a 3D replay", verb: "Run in MuJoCo", on: true },
|
| 251 |
+
{ id: "mujoco_warp", label: "MuJoCo Warp", what: "64 randomized copies at once on the GPU: how often does it work?", verb: "Stress test in MuJoCo Warp", on: true },
|
| 252 |
+
{ id: "isaac_sim", label: "Isaac Sim", what: "Runs in MuJoCo, then a photoreal RTX render of the same motion", verb: "Run + render in Isaac Sim", on: true },
|
| 253 |
+
{ id: "gazebo", label: "Gazebo", what: "Not available in RosDiff yet", on: false },
|
| 254 |
+
{ id: "webots", label: "Webots", what: "Not available in RosDiff yet", on: false },
|
| 255 |
+
{ id: "pybullet", label: "PyBullet", what: "Not available in RosDiff yet", on: false },
|
| 256 |
+
];
|
| 257 |
+
const simInfo = (id) => SIMS.find((x) => x.id === id) || SIMS[0];
|
| 258 |
+
|
| 259 |
+
// "AI Routing: automatic · Standard tier · Why?" — information only; nobody has to pick a model.
|
| 260 |
+
// The sharper brief the generator actually worked from (made for free by OpenCode Zen).
|
| 261 |
+
function enhancedBlock(e) {
|
| 262 |
+
if (!e?.used || !e.text) return null;
|
| 263 |
+
return h("details", { class: "enhanced" }, h("summary", {}, "✨ Enhanced prompt"),
|
| 264 |
+
h("div", { class: "why" }, e.text, h("div", { class: "muted small", style: "margin-top:6px" }, "Written for free with OpenCode Zen; it doesn't use your budget.")));
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
function routingLine(r, cost) {
|
| 268 |
+
if (!r) return null;
|
| 269 |
+
const tiers = r.tiers_used?.length ? r.tiers_used : [r.tier];
|
| 270 |
+
const final = tiers[tiers.length - 1] in TIER_TEXT ? tiers[tiers.length - 1] : r.tier;
|
| 271 |
+
const models = [...new Set((r.models || []).filter(Boolean))];
|
| 272 |
+
const why = h("span", { class: "why" },
|
| 273 |
+
h("div", {}, `Reason: ${r.reason}${r.complexity ? ` (rated ${r.complexity})` : ""}.`),
|
| 274 |
+
h("div", {}, `Capability tier: ${TIER_TEXT[r.tier] || r.tier}` + (r.escalated ? ` → raised to ${TIER_TEXT[final]} to fix a failed check` : "")),
|
| 275 |
+
r.simulator ? h("div", {}, `Simulator: ${simInfo(r.simulator).label}, ${r.simulator_reason}.`) : null,
|
| 276 |
+
models.length ? h("div", { class: "muted small" }, `Model${models.length > 1 ? "s" : ""}: ${models.join(", ")}`) : null,
|
| 277 |
+
cost ? h("div", {}, `This task used ${rupees(cost.inr)} (estimated ${rupees(cost.estimate_inr[0])}–${rupees(cost.estimate_inr[1])}). You have ${rupees(cost.budget.remaining)} of ${rupees(cost.budget.allocation)} left.`) : null);
|
| 278 |
+
return h("div", { class: "routing" },
|
| 279 |
+
h("span", { class: "auto" }, "AI Routing: automatic"),
|
| 280 |
+
h("span", {}, `${TIER_TEXT[final] || final} tier`),
|
| 281 |
+
cost ? h("span", {}, `· used ${rupees(cost.inr)}`) : null,
|
| 282 |
+
h("details", {}, h("summary", {}, "Why?"), why));
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// "Estimated usage: ₹8–₹12. Your remaining budget: ₹42 / ₹125. [Run]" — only for tasks above the threshold.
|
| 286 |
+
function confirmCard(detail, run, cancel) {
|
| 287 |
+
const [lo, hi] = detail.estimate_inr || [0, 0];
|
| 288 |
+
const b = detail.budget || {};
|
| 289 |
+
const go = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("play"), "Run");
|
| 290 |
+
const no = h("button", { class: "btn btn-sm btn-ghost", type: "button" }, "Cancel");
|
| 291 |
+
const card = h("div", { class: "card" },
|
| 292 |
+
h("div", { class: "card-head" }, statusIcon("warn"),
|
| 293 |
+
h("div", {}, h("div", { class: "title" }, "Estimated usage"), h("div", { class: "sub" }, "This is a bigger task, so RosDiff checks with you first."))),
|
| 294 |
+
h("div", { class: "card-body confirm" },
|
| 295 |
+
h("div", {}, "This task is expected to consume approximately"),
|
| 296 |
+
h("div", { class: "big" }, `${rupees(lo)} – ${rupees(hi)}`),
|
| 297 |
+
h("div", { class: "muted small" }, `Your remaining budget: ${rupees(b.remaining ?? 0)} / ${rupees(b.allocation ?? 0)}. Only what it actually uses is charged.`)),
|
| 298 |
+
h("div", { class: "card-actions" }, go, no));
|
| 299 |
+
go.addEventListener("click", () => { go.disabled = no.disabled = true; run(); });
|
| 300 |
+
no.addEventListener("click", () => { go.disabled = no.disabled = true; cancel(); });
|
| 301 |
+
return card;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
document.addEventListener("click", (e) => {
|
| 305 |
+
$$(".sim-menu").forEach((m) => {
|
| 306 |
+
if (m.hidden || m.contains(e.target) || m._owner.contains(e.target)) return;
|
| 307 |
+
m.hidden = true;
|
| 308 |
+
m._owner.setAttribute("aria-expanded", "false");
|
| 309 |
+
});
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
function errorCard(message) {
|
| 313 |
return h("div", { class: "card" }, h("div", { class: "card-head" }, statusIcon("bad"), h("div", {}, h("div", { class: "title" }, "That didn't work"), h("div", { class: "sub" }, message))));
|
| 314 |
}
|
|
|
|
| 328 |
h("div", { class: "title" }, ok ? "Scene ready" : "Couldn't build a working scene"),
|
| 329 |
h("div", { class: "sub" }, ok ? `Loads and simulates in MuJoCo · ${robotLabel(gen.robot)}` : "MuJoCo rejected every attempt")));
|
| 330 |
const body = h("div", { class: "card-body" });
|
| 331 |
+
const route = routingLine(gen.routing, gen.cost);
|
| 332 |
+
if (route) body.append(route);
|
| 333 |
+
const brief = enhancedBlock(gen.enhanced_prompt);
|
| 334 |
+
if (brief) body.append(brief);
|
| 335 |
if (ok) {
|
| 336 |
body.append(h("div", { class: "facts" },
|
| 337 |
h("span", { class: "pill" }, `${stats.nbody ?? "?"} bodies`),
|
|
|
|
| 346 |
}
|
| 347 |
const actions = h("div", { class: "card-actions" });
|
| 348 |
const runs = h("div", { class: "runs" });
|
| 349 |
+
if (ok) actions.append(...simPicker(gen, runs));
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
if (gen.mjcf) {
|
| 351 |
const dl = h("button", { class: "btn btn-sm", type: "button" }, icon("download"), "Download");
|
| 352 |
dl.addEventListener("click", () => download(`scene_${gen.id}.xml`, new Blob([gen.mjcf], { type: "application/xml" })));
|
| 353 |
actions.append(dl);
|
| 354 |
}
|
| 355 |
+
actions.append(h("span", { class: "spacer" }), feedbackBar("generation", gen.id, gen.rating));
|
| 356 |
const wrap = h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), runs);
|
| 357 |
return wrap;
|
| 358 |
}
|
| 359 |
|
| 360 |
+
// The simulator RosDiff chose, as the main button, plus "Use another simulator".
|
| 361 |
+
function simPicker(gen, runs) {
|
| 362 |
+
const auto = gen.routing?.simulator || "mujoco";
|
| 363 |
+
let chosen = auto;
|
| 364 |
+
const run = h("button", { class: "btn btn-primary btn-sm", type: "button" }, icon("play"), h("span", {}, simInfo(chosen).verb));
|
| 365 |
+
run.addEventListener("click", () => startRun(gen, runs, run, chosen));
|
| 366 |
+
const menu = h("div", { class: "sim-menu", role: "menu", hidden: true },
|
| 367 |
+
h("div", { class: "muted small", style: "padding:6px 10px 4px" }, `RosDiff picked ${simInfo(auto).label} for this scene.`));
|
| 368 |
+
const other = h("button", { class: "btn btn-sm btn-ghost", type: "button", "aria-haspopup": "menu", "aria-expanded": "false" }, "Use another simulator");
|
| 369 |
+
const paint = () => $$(".sim-opt", menu).forEach((o) => o.classList.toggle("chosen", o.dataset.sim === chosen));
|
| 370 |
+
for (const sim of SIMS) {
|
| 371 |
+
const opt = h("button", { class: "sim-opt", type: "button", role: "menuitemradio", "data-sim": sim.id, disabled: !sim.on },
|
| 372 |
+
h("b", {}, sim.label + (sim.id === auto ? " · recommended" : "")), h("span", {}, sim.what));
|
| 373 |
+
opt.addEventListener("click", () => {
|
| 374 |
+
chosen = sim.id;
|
| 375 |
+
run.lastChild.textContent = sim.verb;
|
| 376 |
+
paint();
|
| 377 |
+
menu.hidden = true;
|
| 378 |
+
other.setAttribute("aria-expanded", "false");
|
| 379 |
+
});
|
| 380 |
+
menu.append(opt);
|
| 381 |
+
}
|
| 382 |
+
paint();
|
| 383 |
+
other.addEventListener("click", () => { menu.hidden = !menu.hidden; other.setAttribute("aria-expanded", String(!menu.hidden)); });
|
| 384 |
+
menu._owner = other;
|
| 385 |
+
return [run, other, menu]; // the menu opens as a full-width row of the card's action bar
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
async function startRun(gen, container, button, sim = "mujoco") {
|
| 389 |
button.disabled = true;
|
| 390 |
try {
|
| 391 |
+
if (sim === "mujoco_warp") {
|
| 392 |
+
const job = await api("/simulate/sweep", { method: "POST", body: { generation_id: gen.id, n_worlds: 64 } });
|
| 393 |
+
container.append(jobCard({ id: job.id, kind: "sweep", status: job.status, report_url: job.report_url }));
|
| 394 |
+
} else {
|
| 395 |
+
const job = await api("/simulate/run", { method: "POST", body: { generation_id: gen.id, duration_s: 10, controller: "hold" } });
|
| 396 |
+
const then = sim === "isaac_sim" ? (j) => startIsaac(j.id, container) : null;
|
| 397 |
+
container.append(jobCard({ id: job.run_id, kind: "sim", status: job.status, report_url: job.report_url }, then));
|
| 398 |
+
}
|
| 399 |
scrollDown("sim");
|
| 400 |
} catch (e) {
|
| 401 |
container.append(errorCard(friendly(e)));
|
|
|
|
| 404 |
}
|
| 405 |
}
|
| 406 |
|
| 407 |
+
async function startIsaac(runId, container, button) {
|
| 408 |
+
if (button) button.disabled = true;
|
| 409 |
+
try {
|
| 410 |
+
const job = await api(`/simulate/run/${runId}/isaac`, { method: "POST" });
|
| 411 |
+
container.append(jobCard({ id: job.id, kind: "isaac", status: job.status, report_url: job.report_url }));
|
| 412 |
+
scrollDown("sim");
|
| 413 |
+
} catch (e) {
|
| 414 |
+
container.append(errorCard(friendly(e)));
|
| 415 |
+
if (button) button.disabled = false;
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
// --- code
|
| 420 |
|
| 421 |
function packageCard(gen) {
|
|
|
|
| 428 |
h("div", { class: "title" }, ok ? "Package ready" : names.length ? "Package written, with problems" : "Couldn't write the package"),
|
| 429 |
h("div", { class: "sub" }, `${names.length} file(s) · ROS 2 ${gen.distro || ""} · ${gen.language === "cpp" ? "C++" : "Python"}`)));
|
| 430 |
const body = h("div", { class: "card-body" });
|
| 431 |
+
const route = routingLine(gen.routing, gen.cost);
|
| 432 |
+
if (route) body.append(route);
|
| 433 |
+
const brief = enhancedBlock(gen.enhanced_prompt);
|
| 434 |
+
if (brief) body.append(brief);
|
| 435 |
const errors = val.errors || [];
|
| 436 |
if (errors.length) body.append(h("div", { class: "error-box" }, errors.slice(0, 6).join("\n")));
|
| 437 |
if ((val.warnings || []).length) body.append(h("p", { class: "note" }, `${val.warnings.length} note(s): ${val.warnings.slice(0, 2).join("; ")}`));
|
|
|
|
| 468 |
});
|
| 469 |
actions.append(check, zip, copy);
|
| 470 |
}
|
| 471 |
+
actions.append(h("span", { class: "spacer" }), feedbackBar("generation", gen.id, gen.rating));
|
| 472 |
return h("div", { style: "display:flex;flex-direction:column;gap:14px" }, h("div", { class: "card" }, head, body, actions), checks);
|
| 473 |
}
|
| 474 |
function score(path) {
|
|
|
|
| 502 |
export: { wait: "Starting the export…", run: "Building the dataset…", title: "Dataset export" },
|
| 503 |
};
|
| 504 |
|
| 505 |
+
function jobCard(job, onComplete) {
|
| 506 |
const card = h("div", { class: "card" });
|
| 507 |
card.dataset.job = job.id;
|
| 508 |
const render = (j) => {
|
| 509 |
+
if (j.status === "COMPLETED" && onComplete) { const f = onComplete; onComplete = null; f(j); }
|
| 510 |
const text = JOB_TEXT[j.kind] || JOB_TEXT.sim;
|
| 511 |
card.replaceChildren();
|
| 512 |
if (!TERMINAL.has(j.status)) {
|
|
|
|
| 524 |
if (j.kind === "sim") card.append(...simResult(j));
|
| 525 |
else if (j.kind === "ros_check") card.append(...checkResult(j));
|
| 526 |
else if (j.kind === "sweep") card.append(...sweepResult(j));
|
| 527 |
+
else {
|
| 528 |
+
const body = h("div", { class: "card-body" });
|
| 529 |
+
if (j.media?.isaac_video) body.append(h("video", { controls: true, autoplay: true, muted: true, loop: true, playsinline: true, preload: "metadata", src: j.media.isaac_video }));
|
| 530 |
+
card.append(h("div", { class: "card-head" }, statusIcon("ok"), h("div", {}, h("div", { class: "title" }, text.title), h("div", { class: "sub" }, j.summary?.headline || "Done"))),
|
| 531 |
+
...(body.childNodes.length ? [body] : []), reportActions(j));
|
| 532 |
+
}
|
| 533 |
};
|
| 534 |
render(job);
|
| 535 |
poll(job.id, render);
|
|
|
|
| 553 |
function reportActions(j, ...extra) {
|
| 554 |
const bar = h("div", { class: "card-actions" }, ...extra);
|
| 555 |
if (j.report_url) bar.append(h("a", { class: "btn btn-sm", href: j.report_url, target: "_blank", rel: "noopener" }, icon("external"), "Full report"));
|
| 556 |
+
if (TERMINAL.has(j.status)) bar.append(h("span", { class: "spacer" }), feedbackBar("job", j.id, state.rated[j.id]));
|
| 557 |
return bar;
|
| 558 |
}
|
| 559 |
|
|
|
|
| 588 |
extra.append(jobCard({ id: job.id, kind: "sweep", status: job.status, report_url: job.report_url }));
|
| 589 |
} catch (e) { extra.append(errorCard(friendly(e))); stress.disabled = false; }
|
| 590 |
});
|
| 591 |
+
const isaac = h("button", { class: "btn btn-sm", type: "button", title: "Render this exact run photorealistically with NVIDIA Isaac Sim" }, icon("play"), "Isaac Sim render");
|
| 592 |
+
isaac.addEventListener("click", () => startIsaac(j.id, extra, isaac));
|
| 593 |
+
return [head, body, reportActions(j, stress, isaac), extra];
|
| 594 |
}
|
| 595 |
|
| 596 |
function checkResult(j) {
|
|
|
|
| 676 |
const input = $(`#input-${pane}`);
|
| 677 |
const text = input.value.trim();
|
| 678 |
if (text.length < 10) { toast("Please describe it in a bit more detail (at least 10 characters)."); input.focus(); return; }
|
|
|
|
|
|
|
| 679 |
const t = thread(pane);
|
| 680 |
if (t.querySelector(".empty")) t.replaceChildren();
|
| 681 |
const meta = pane === "sim" ? robotLabel(state.robot) : `ROS 2 ${cap(state.distro)} · ${state.lang === "cpp" ? "C++" : "Python"}`;
|
| 682 |
t.append(userMessage(text, meta));
|
| 683 |
input.value = "";
|
| 684 |
autosize(input);
|
| 685 |
+
const body = pane === "sim"
|
| 686 |
+
? { description: text, robot: state.robot }
|
| 687 |
+
: { request: text, distro: state.distro, language: state.lang };
|
| 688 |
+
await generate(pane, body);
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
async function generate(pane, body) {
|
| 692 |
+
state.busy[pane] = true;
|
| 693 |
+
$(`#composer-${pane} .send`).disabled = true;
|
| 694 |
+
const t = thread(pane);
|
| 695 |
const steps = pane === "sim"
|
| 696 |
+
? ["Sharpening your description…", "Choosing the right AI for this scene…", "Reading the MuJoCo documentation…", "Writing the scene…", "Testing it in MuJoCo…", "Fixing anything MuJoCo complained about…", "Almost there…"]
|
| 697 |
+
: ["Sharpening your request…", "Choosing the right AI for this package…", "Looking up the ROS 2 messages it needs…", "Writing the package…", "Checking every message type exists…", "Almost there…"];
|
| 698 |
const working = workingCard(steps);
|
| 699 |
t.append(working);
|
| 700 |
scrollDown(pane);
|
| 701 |
try {
|
| 702 |
if (pane === "sim") {
|
| 703 |
+
const r = await api("/simulate/generate", { method: "POST", body });
|
| 704 |
working.replaceWith(sceneCard({ ...r, rating: null }));
|
| 705 |
} else {
|
| 706 |
+
const r = await api("/code/generate", { method: "POST", body });
|
| 707 |
+
working.replaceWith(packageCard({ ...r, distro: body.distro, language: body.language, rating: null }));
|
| 708 |
}
|
| 709 |
loadHistory();
|
| 710 |
+
refreshBudget();
|
| 711 |
} catch (e) {
|
| 712 |
+
if (e instanceof ApiError && e.status === 409 && e.detail?.needs_confirmation) {
|
| 713 |
+
const card = confirmCard(e.detail,
|
| 714 |
+
() => { card.remove(); generate(pane, { ...body, confirm_cost: true }); },
|
| 715 |
+
() => card.replaceWith(h("p", { class: "note" }, "Cancelled. Nothing was charged.")));
|
| 716 |
+
working.replaceWith(card);
|
| 717 |
+
} else {
|
| 718 |
+
working.replaceWith(errorCard(friendly(e)));
|
| 719 |
+
}
|
| 720 |
} finally {
|
| 721 |
working.stop();
|
| 722 |
state.busy[pane] = false;
|
|
|
|
| 763 |
}
|
| 764 |
}
|
| 765 |
|
| 766 |
+
// Older generations were made before routing existed; they simply show no routing line.
|
| 767 |
+
const savedRouting = (g) => (g.request?.routing ? { ...g.request.routing, models: [g.model] } : null);
|
| 768 |
+
const savedBrief = (g) => g.request?.enhanced || null;
|
| 769 |
+
|
| 770 |
async function openGeneration(id, kind, item) {
|
| 771 |
closeSidebar();
|
| 772 |
switchPane(kind);
|
|
|
|
| 779 |
const content = [];
|
| 780 |
if (g.kind === "code") {
|
| 781 |
content.push(userMessage(req.request || "", `ROS 2 ${cap(req.distro || "")} · ${req.language === "cpp" ? "C++" : "Python"}`));
|
| 782 |
+
const card = packageCard({ id: g.id, valid: g.valid, validation: g.validation, files: g.output || {}, distro: req.distro, language: req.language, rating: g.rating, routing: savedRouting(g), enhanced_prompt: savedBrief(g) });
|
| 783 |
content.push(card);
|
| 784 |
for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
|
| 785 |
} else {
|
| 786 |
content.push(userMessage(req.description || "", robotLabel(req.robot)));
|
| 787 |
+
const card = sceneCard({ id: g.id, valid: g.valid, validation: g.validation, mjcf: g.output, robot: req.robot, error: g.error, rating: g.rating, routing: savedRouting(g), enhanced_prompt: savedBrief(g) });
|
| 788 |
content.push(card);
|
| 789 |
for (const j of g.jobs) card.querySelector(".runs").append(jobCard(j));
|
| 790 |
}
|
|
|
|
| 798 |
|
| 799 |
async function refreshBudget() {
|
| 800 |
try {
|
| 801 |
+
const { ai } = await api("/me/usage");
|
| 802 |
+
const pct = Math.min(100, (100 * ai.used) / (ai.allocation || 1));
|
| 803 |
+
$("#budget-label").textContent = `${rupees(ai.remaining)} left`;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 804 |
$("#budget-fill").style.width = pct + "%";
|
| 805 |
+
const el = $("#budget");
|
| 806 |
el.classList.toggle("high", pct >= 70 && pct < 100);
|
| 807 |
el.classList.toggle("full", pct >= 100);
|
| 808 |
+
el.title = `Your AI budget: ${rupees(ai.used)} of ${rupees(ai.allocation)} used. Click for details.`;
|
| 809 |
} catch { /* not important */ }
|
| 810 |
}
|
| 811 |
|
|
|
|
| 860 |
$("#user-btn").addEventListener("click", (e) => { e.stopPropagation(); menu.hidden = !menu.hidden; $("#user-btn").setAttribute("aria-expanded", String(!menu.hidden)); });
|
| 861 |
document.addEventListener("click", () => { menu.hidden = true; $("#user-btn").setAttribute("aria-expanded", "false"); });
|
| 862 |
$("#logout-btn").addEventListener("click", () => signOut(false));
|
| 863 |
+
$("#help-btn").addEventListener("click", () => openTour());
|
| 864 |
+
$("#usage-btn").addEventListener("click", openUsage);
|
| 865 |
+
$("#budget").addEventListener("click", openUsage);
|
| 866 |
}
|
| 867 |
|
| 868 |
// ------------------------------------------------------------------ sign in / out
|
|
|
|
| 888 |
loadHistory();
|
| 889 |
refreshBudget();
|
| 890 |
setInterval(refreshBudget, 60000);
|
| 891 |
+
let toured = false;
|
| 892 |
+
try { toured = localStorage.getItem("rosdiff_seen_tour") === "1"; } catch { /* ignore */ }
|
| 893 |
+
if (!toured) { remember("rosdiff_seen_tour", "1"); openTour(); }
|
| 894 |
}
|
| 895 |
|
| 896 |
function setupLogin() {
|
|
|
|
| 909 |
});
|
| 910 |
}
|
| 911 |
|
| 912 |
+
// ------------------------------------------------------------------ startup animation
|
| 913 |
+
|
| 914 |
+
function splash() {
|
| 915 |
+
const el = $("#splash");
|
| 916 |
+
let seen = false;
|
| 917 |
+
try { seen = localStorage.getItem("rosdiff_seen_splash") === "1"; } catch { /* ignore */ }
|
| 918 |
+
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
| 919 |
+
if (seen) el.classList.add("quick");
|
| 920 |
+
const hold = reduced ? 400 : seen ? 800 : 2300;
|
| 921 |
+
return new Promise((resolve) => {
|
| 922 |
+
const finish = () => {
|
| 923 |
+
if (el.classList.contains("done")) return;
|
| 924 |
+
el.classList.add("done");
|
| 925 |
+
remember("rosdiff_seen_splash", "1");
|
| 926 |
+
setTimeout(() => { el.remove(); resolve(); }, 450);
|
| 927 |
+
};
|
| 928 |
+
setTimeout(finish, hold);
|
| 929 |
+
el.addEventListener("click", finish);
|
| 930 |
+
addEventListener("keydown", finish, { once: true });
|
| 931 |
+
});
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
// ------------------------------------------------------------------ beginner tour
|
| 935 |
+
|
| 936 |
+
const TOUR = [
|
| 937 |
+
["Describe what you want to build.", "Type it in plain words, like you'd tell a teammate: a world for your robot on the left, a ROS 2 node on the right."],
|
| 938 |
+
["RosDiff understands your project.", "First it sharpens your words into a precise brief (for free). Then it reads the relevant MuJoCo and ROS 2 documentation, the robot's real model and every message definition before it writes anything."],
|
| 939 |
+
["RosDiff picks the right AI for the job.", "Small tasks go to a fast, low-cost model; hard ones to a stronger reasoning model. You never have to choose one."],
|
| 940 |
+
["RosDiff picks the simulator.", null],
|
| 941 |
+
["RosDiff sets up the tools.", "Robot models, ROS 2 (Humble or Jazzy), Nav2, MoveIt 2, ros2_control and the build tools are ready in the cloud. Nothing to install."],
|
| 942 |
+
["RosDiff writes and modifies the code.", "You get a complete package: nodes, launch files, package.xml and build files, checked against the real message types."],
|
| 943 |
+
["RosDiff runs it and tests it.", "“Run in MuJoCo” shows your robot in that world on a cloud GPU. “Build & run” compiles your package and starts it for 10 seconds."],
|
| 944 |
+
["RosDiff learns from failures.", "If a draft fails its checks, RosDiff reads the errors and fixes them, moving up to a stronger AI model when it needs to."],
|
| 945 |
+
["RosDiff manages the cost.", "Each tester has a small AI budget (see the top bar). Bigger tasks show an estimate first. Cloud GPU time has its own equal share per tester; see “My usage”."],
|
| 946 |
+
["You can override tools any time.", "Want a different simulator? Use “Use another simulator” on any scene. And tell us 👍 or 👎 on every result: that's what this test is for."],
|
| 947 |
+
];
|
| 948 |
+
|
| 949 |
+
function tourSlide(i) {
|
| 950 |
+
const [title, text] = TOUR[i];
|
| 951 |
+
const slide = h("div", {}, h("div", { class: "step" }, `Step ${i + 1}`), h("h3", {}, title));
|
| 952 |
+
if (text) slide.append(h("p", {}, text));
|
| 953 |
+
if (i === 3) {
|
| 954 |
+
slide.append(
|
| 955 |
+
h("p", {}, "RosDiff looks at your robot, the physics, the sensors and what you want to test, then chooses the simulator. For example:"),
|
| 956 |
+
h("div", { class: "tour-demo" },
|
| 957 |
+
h("div", { class: "you" }, "“Build and test a mobile robot with a LiDAR driving between obstacles.”"),
|
| 958 |
+
h("div", { class: "line" }, h("span", { class: "routing" }, h("span", { class: "auto" }, "Auto")), h("span", {}, h("b", {}, "MuJoCo"), " — contact-rich rigid-body physics with the real robot model and its range sensors.")),
|
| 959 |
+
h("div", { class: "line muted small" }, "Ask “how often does it crash?” and it switches to MuJoCo Warp (many variations at once); ask for photoreal camera images and Isaac Sim renders it.")),
|
| 960 |
+
h("p", { class: "small", style: "margin-top:10px" }, "RosDiff automatically selects the simulation environment from your project, robot, physics, sensors and testing needs."),
|
| 961 |
+
);
|
| 962 |
+
const tryIt = h("button", { class: "btn btn-sm", type: "button" }, "Try this example");
|
| 963 |
+
tryIt.addEventListener("click", () => {
|
| 964 |
+
$("#tour").close();
|
| 965 |
+
setRobot("unitree_go2");
|
| 966 |
+
switchPane("sim");
|
| 967 |
+
fillComposer("sim", "The Go2 robot dog with a LiDAR drives between four box obstacles 1 to 3 m ahead of it; test that it doesn't hit them.");
|
| 968 |
+
});
|
| 969 |
+
slide.append(tryIt);
|
| 970 |
+
}
|
| 971 |
+
return slide;
|
| 972 |
+
}
|
| 973 |
+
|
| 974 |
+
function openTour(start = 0) {
|
| 975 |
+
const dlg = $("#tour");
|
| 976 |
+
let i = start;
|
| 977 |
+
const dots = $("#tour-dots");
|
| 978 |
+
dots.replaceChildren(...TOUR.map(() => h("span")));
|
| 979 |
+
const show = () => {
|
| 980 |
+
$("#tour-slide").replaceChildren(tourSlide(i));
|
| 981 |
+
$("#tour-count").textContent = `${i + 1} / ${TOUR.length}`;
|
| 982 |
+
$$("span", dots).forEach((d, k) => d.classList.toggle("on", k === i));
|
| 983 |
+
$("#tour-back").style.visibility = i === 0 ? "hidden" : "visible";
|
| 984 |
+
$("#tour-next").textContent = i === TOUR.length - 1 ? "Start building" : "Next";
|
| 985 |
+
};
|
| 986 |
+
$("#tour-next").onclick = () => { if (i < TOUR.length - 1) { i++; show(); } else dlg.close(); };
|
| 987 |
+
$("#tour-back").onclick = () => { if (i > 0) { i--; show(); } };
|
| 988 |
+
$("#tour-close").onclick = () => dlg.close();
|
| 989 |
+
show();
|
| 990 |
+
dlg.showModal();
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
// ------------------------------------------------------------------ usage
|
| 994 |
+
|
| 995 |
+
const rupees = (x) => "₹" + (Math.round(x * 100) / 100).toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
| 996 |
+
|
| 997 |
+
async function openUsage() {
|
| 998 |
+
const body = $("#usage-body");
|
| 999 |
+
body.replaceChildren(h("p", { class: "muted" }, "Loading…"));
|
| 1000 |
+
$("#usage").showModal();
|
| 1001 |
+
try {
|
| 1002 |
+
const u = await api("/me/usage");
|
| 1003 |
+
const a = u.ai;
|
| 1004 |
+
const pct = Math.min(100, (100 * a.used) / (a.allocation || 1));
|
| 1005 |
+
const labels = { coding: "Coding", simulation_ai: "Simulation AI", debugging: "Debugging (automatic fixes)", testing: "Testing" };
|
| 1006 |
+
const rows = Object.entries(a.by_category).map(([k, v]) => h("tr", {}, h("td", {}, labels[k] || k), h("td", {}, rupees(v))));
|
| 1007 |
+
const parts = [
|
| 1008 |
+
h("div", { class: "muted small" }, "AI budget"),
|
| 1009 |
+
h("div", { class: "usage-big" }, `${rupees(a.remaining)} left`),
|
| 1010 |
+
h("div", { class: "muted small" }, `${rupees(a.used)} of ${rupees(a.allocation)} used`),
|
| 1011 |
+
h("div", { class: "meter" }, h("span", { style: `width:${pct}%` })),
|
| 1012 |
+
h("table", { class: "usage-table" }, h("tbody", {}, ...rows)),
|
| 1013 |
+
h("p", { class: "muted small" }, `Free calls (prompt enhancement on OpenCode Zen, OpenRouter-exempt models) are not counted: ${a.exempt_calls} so far. ` +
|
| 1014 |
+
`Team pool: ${rupees(a.pool.used)} of ${rupees(a.pool.total)} used.`),
|
| 1015 |
+
];
|
| 1016 |
+
const c = u.compute;
|
| 1017 |
+
if (c.share) {
|
| 1018 |
+
const gpct = Math.min(100, (100 * c.used) / c.share);
|
| 1019 |
+
parts.push(h("div", { class: "muted small", style: "margin-top:14px" }, "Cloud GPU (simulations, stress tests, renders, build checks)"),
|
| 1020 |
+
h("div", { class: "usage-big" }, `$${c.remaining.toFixed(2)} left`),
|
| 1021 |
+
h("div", { class: "muted small" }, `$${c.used.toFixed(2)} of your $${c.share.toFixed(2)} this month (the $${c.team_limit} credit split equally between ${c.testers} testers). ` +
|
| 1022 |
+
"A job only starts if its longest possible run still fits."),
|
| 1023 |
+
h("div", { class: "meter" }, h("span", { style: `width:${gpct}%` })));
|
| 1024 |
+
}
|
| 1025 |
+
if (u.admin && u.team) {
|
| 1026 |
+
parts.push(h("div", { class: "muted small", style: "margin-top:10px" }, "Team (admin view)"),
|
| 1027 |
+
h("table", { class: "usage-table" },
|
| 1028 |
+
h("thead", {}, h("tr", {}, h("th", {}, "Tester"), h("th", {}, "AI used"), h("th", {}, "AI left"), h("th", {}, "GPU used"))),
|
| 1029 |
+
h("tbody", {}, ...u.team.map((m) => h("tr", {}, h("td", {}, m.tester), h("td", {}, rupees(m.used)), h("td", {}, rupees(m.remaining)), h("td", {}, `$${(m.gpu || 0).toFixed(2)}`))),
|
| 1030 |
+
h("tr", {}, h("td", {}, h("b", {}, "Total")), h("td", {}, h("b", {}, rupees(a.pool.used))), h("td", {}, h("b", {}, rupees(a.pool.remaining))),
|
| 1031 |
+
h("td", {}, h("b", {}, `$${(c.team_used || 0).toFixed(2)}`))))));
|
| 1032 |
+
}
|
| 1033 |
+
body.replaceChildren(...parts);
|
| 1034 |
+
} catch (e) {
|
| 1035 |
+
body.replaceChildren(h("p", { class: "error-text" }, friendly(e)));
|
| 1036 |
+
}
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
setupLogin();
|
| 1040 |
setupChrome();
|
| 1041 |
setupComposer();
|
| 1042 |
+
splash().then(() => {
|
| 1043 |
+
if (state.key) {
|
| 1044 |
+
enter().catch(() => signOut(false));
|
| 1045 |
+
} else {
|
| 1046 |
+
$("#login").hidden = false;
|
| 1047 |
+
$("#key-input").focus();
|
| 1048 |
+
}
|
| 1049 |
+
});
|
|
|
|
|
|
@@ -3,21 +3,34 @@
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
-
<title>RosDiff</title>
|
| 7 |
<meta name="description" content="Describe a robot world or a ROS 2 package; RosDiff builds it and tests it.">
|
| 8 |
-
<link rel="icon"
|
|
|
|
|
|
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 10 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 11 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 12 |
<link rel="stylesheet" href="/static/app/app.css">
|
| 13 |
</head>
|
| 14 |
<body>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
<!-- Sign in -->
|
| 16 |
<div id="login" class="login" hidden>
|
| 17 |
<form class="login-card" id="login-form" autocomplete="off">
|
| 18 |
-
<
|
| 19 |
-
<
|
| 20 |
-
<
|
|
|
|
|
|
|
| 21 |
<label class="sr-only" for="key-input">Tester key</label>
|
| 22 |
<input id="key-input" class="input" type="password" placeholder="rd_…" spellcheck="false" required>
|
| 23 |
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
|
@@ -32,22 +45,23 @@
|
|
| 32 |
<button class="icon-btn only-mobile" id="menu-btn" aria-label="Show history" aria-expanded="false">
|
| 33 |
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
| 34 |
</button>
|
| 35 |
-
<div class="brand"><
|
| 36 |
<nav class="mobile-tabs only-mobile" aria-label="Workspace">
|
| 37 |
<button class="mtab active" data-pane="sim">Simulation</button>
|
| 38 |
<button class="mtab" data-pane="code">Code</button>
|
| 39 |
</nav>
|
| 40 |
<div class="topbar-right">
|
| 41 |
-
<
|
| 42 |
<span class="budget-label" id="budget-label">—</span>
|
| 43 |
<span class="budget-bar"><span id="budget-fill"></span></span>
|
| 44 |
-
</
|
| 45 |
<div class="user">
|
| 46 |
<button class="user-chip" id="user-btn" aria-haspopup="menu" aria-expanded="false">
|
| 47 |
<span class="avatar" id="avatar" aria-hidden="true">?</span><span id="user-name">tester</span>
|
| 48 |
</button>
|
| 49 |
<div class="menu" id="user-menu" role="menu" hidden>
|
| 50 |
-
<button role="menuitem" id="
|
|
|
|
| 51 |
<button role="menuitem" id="logout-btn">Sign out</button>
|
| 52 |
</div>
|
| 53 |
</div>
|
|
@@ -72,7 +86,7 @@
|
|
| 72 |
<span class="pane-icon sim" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 4 7.5v9L12 21l8-4.5v-9L12 3Zm0 0v18M4 7.5l8 4.5 8-4.5"/></svg></span>
|
| 73 |
<div>
|
| 74 |
<h2 id="sim-title">Simulation</h2>
|
| 75 |
-
<p class="muted small">Describe a world.
|
| 76 |
</div>
|
| 77 |
<button class="btn btn-ghost btn-sm" data-clear="sim" aria-label="Start a new simulation thread">New</button>
|
| 78 |
</div>
|
|
@@ -95,7 +109,7 @@
|
|
| 95 |
<span class="pane-icon code" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m8 7-5 5 5 5m8-10 5 5-5 5M14 4l-4 16"/></svg></span>
|
| 96 |
<div>
|
| 97 |
<h2 id="code-title">ROS 2 code</h2>
|
| 98 |
-
<p class="muted small">Describe a node.
|
| 99 |
</div>
|
| 100 |
<button class="btn btn-ghost btn-sm" data-clear="code" aria-label="Start a new code thread">New</button>
|
| 101 |
</div>
|
|
@@ -123,17 +137,42 @@
|
|
| 123 |
</main>
|
| 124 |
</div>
|
| 125 |
|
| 126 |
-
<!--
|
| 127 |
-
<dialog id="
|
| 128 |
-
<
|
| 129 |
-
|
| 130 |
-
<
|
| 131 |
-
<
|
| 132 |
-
|
| 133 |
-
<
|
| 134 |
-
</
|
| 135 |
-
<
|
| 136 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
</dialog>
|
| 138 |
|
| 139 |
<div class="toast" id="toast" role="status" hidden></div>
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="utf-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>RosDiff by Orcanion</title>
|
| 7 |
<meta name="description" content="Describe a robot world or a ROS 2 package; RosDiff builds it and tests it.">
|
| 8 |
+
<link rel="icon" type="image/png" sizes="32x32" href="/static/app/brand/rosdiff-32.png">
|
| 9 |
+
<link rel="apple-touch-icon" href="/static/app/brand/rosdiff-192.png">
|
| 10 |
+
<meta name="theme-color" content="#050507">
|
| 11 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 12 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 13 |
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 14 |
<link rel="stylesheet" href="/static/app/app.css">
|
| 15 |
</head>
|
| 16 |
<body>
|
| 17 |
+
<!-- Startup animation (shortened for returning visitors; click or any key skips) -->
|
| 18 |
+
<div id="splash" class="splash" aria-hidden="true">
|
| 19 |
+
<div class="splash-inner">
|
| 20 |
+
<img class="splash-logo" src="/static/app/brand/rosdiff-512.webp" alt="" width="200" height="200">
|
| 21 |
+
<div class="splash-name">RosDiff</div>
|
| 22 |
+
<div class="splash-by">by Orcanion</div>
|
| 23 |
+
</div>
|
| 24 |
+
</div>
|
| 25 |
+
|
| 26 |
<!-- Sign in -->
|
| 27 |
<div id="login" class="login" hidden>
|
| 28 |
<form class="login-card" id="login-form" autocomplete="off">
|
| 29 |
+
<img class="login-logo" src="/static/app/brand/rosdiff-192.png" alt="RosDiff logo" width="84" height="84">
|
| 30 |
+
<div class="login-brand">RosDiff <span>by Orcanion</span></div>
|
| 31 |
+
<h1>Tell RosDiff what you want to build</h1>
|
| 32 |
+
<p class="muted">It picks the AI, the simulator and the compute, then builds and tests it for you.</p>
|
| 33 |
+
<p class="muted small">Enter the tester key you were given. It starts with <code>rd_</code>.</p>
|
| 34 |
<label class="sr-only" for="key-input">Tester key</label>
|
| 35 |
<input id="key-input" class="input" type="password" placeholder="rd_…" spellcheck="false" required>
|
| 36 |
<button class="btn btn-primary btn-block" type="submit">Sign in</button>
|
|
|
|
| 45 |
<button class="icon-btn only-mobile" id="menu-btn" aria-label="Show history" aria-expanded="false">
|
| 46 |
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
| 47 |
</button>
|
| 48 |
+
<div class="brand"><img class="logo-img" src="/static/app/brand/rosdiff-64.png" alt="" width="28" height="28"><span>RosDiff</span><span class="by">by Orcanion</span><span class="tag">testing</span></div>
|
| 49 |
<nav class="mobile-tabs only-mobile" aria-label="Workspace">
|
| 50 |
<button class="mtab active" data-pane="sim">Simulation</button>
|
| 51 |
<button class="mtab" data-pane="code">Code</button>
|
| 52 |
</nav>
|
| 53 |
<div class="topbar-right">
|
| 54 |
+
<button class="budget" id="budget" type="button" title="Your AI budget. Click for details.">
|
| 55 |
<span class="budget-label" id="budget-label">—</span>
|
| 56 |
<span class="budget-bar"><span id="budget-fill"></span></span>
|
| 57 |
+
</button>
|
| 58 |
<div class="user">
|
| 59 |
<button class="user-chip" id="user-btn" aria-haspopup="menu" aria-expanded="false">
|
| 60 |
<span class="avatar" id="avatar" aria-hidden="true">?</span><span id="user-name">tester</span>
|
| 61 |
</button>
|
| 62 |
<div class="menu" id="user-menu" role="menu" hidden>
|
| 63 |
+
<button role="menuitem" id="usage-btn">My usage</button>
|
| 64 |
+
<button role="menuitem" id="help-btn">Take the tour</button>
|
| 65 |
<button role="menuitem" id="logout-btn">Sign out</button>
|
| 66 |
</div>
|
| 67 |
</div>
|
|
|
|
| 86 |
<span class="pane-icon sim" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 4 7.5v9L12 21l8-4.5v-9L12 3Zm0 0v18M4 7.5l8 4.5 8-4.5"/></svg></span>
|
| 87 |
<div>
|
| 88 |
<h2 id="sim-title">Simulation</h2>
|
| 89 |
+
<p class="muted small">Describe a world. RosDiff picks the simulator, builds it and tests it.</p>
|
| 90 |
</div>
|
| 91 |
<button class="btn btn-ghost btn-sm" data-clear="sim" aria-label="Start a new simulation thread">New</button>
|
| 92 |
</div>
|
|
|
|
| 109 |
<span class="pane-icon code" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="m8 7-5 5 5 5m8-10 5 5-5 5M14 4l-4 16"/></svg></span>
|
| 110 |
<div>
|
| 111 |
<h2 id="code-title">ROS 2 code</h2>
|
| 112 |
+
<p class="muted small">Describe a node. RosDiff writes it, then builds and runs it for real.</p>
|
| 113 |
</div>
|
| 114 |
<button class="btn btn-ghost btn-sm" data-clear="code" aria-label="Start a new code thread">New</button>
|
| 115 |
</div>
|
|
|
|
| 137 |
</main>
|
| 138 |
</div>
|
| 139 |
|
| 140 |
+
<!-- Beginner tour -->
|
| 141 |
+
<dialog id="tour" class="dialog tour" aria-labelledby="tour-title">
|
| 142 |
+
<div class="tour-top">
|
| 143 |
+
<img src="/static/app/brand/rosdiff-64.png" alt="" width="30" height="30">
|
| 144 |
+
<span class="tour-count" id="tour-count">1 / 10</span>
|
| 145 |
+
<button class="icon-btn" id="tour-close" aria-label="Close the tour">
|
| 146 |
+
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17"/></svg>
|
| 147 |
+
</button>
|
| 148 |
+
</div>
|
| 149 |
+
<div id="tour-slide" class="tour-slide" aria-live="polite"></div>
|
| 150 |
+
<div class="tour-dots" id="tour-dots" aria-hidden="true"></div>
|
| 151 |
+
<div class="tour-actions">
|
| 152 |
+
<button class="btn btn-ghost" id="tour-back" type="button">Back</button>
|
| 153 |
+
<button class="btn btn-primary" id="tour-next" type="button">Next</button>
|
| 154 |
+
</div>
|
| 155 |
+
</dialog>
|
| 156 |
+
|
| 157 |
+
<!-- Usage -->
|
| 158 |
+
<dialog id="usage" class="dialog" aria-labelledby="usage-title">
|
| 159 |
+
<h2 id="usage-title">Your RosDiff usage</h2>
|
| 160 |
+
<div id="usage-body"></div>
|
| 161 |
+
<form method="dialog"><button class="btn btn-primary">Close</button></form>
|
| 162 |
+
</dialog>
|
| 163 |
+
|
| 164 |
+
<!-- Feedback note -->
|
| 165 |
+
<dialog id="fb-dialog" class="dialog" aria-labelledby="fb-title">
|
| 166 |
+
<form id="fb-form">
|
| 167 |
+
<h2 id="fb-title">Tell us what went wrong or how to improve</h2>
|
| 168 |
+
<p class="muted small">Optional, but it helps us a lot.</p>
|
| 169 |
+
<label class="sr-only" for="fb-note">Your feedback</label>
|
| 170 |
+
<textarea id="fb-note" class="textarea" rows="4" maxlength="4000" placeholder="e.g. The boxes were floating above the floor…"></textarea>
|
| 171 |
+
<div class="dialog-actions">
|
| 172 |
+
<button class="btn btn-ghost" type="button" id="fb-skip">Skip</button>
|
| 173 |
+
<button class="btn btn-primary" type="submit">Send</button>
|
| 174 |
+
</div>
|
| 175 |
+
</form>
|
| 176 |
</dialog>
|
| 177 |
|
| 178 |
<div class="toast" id="toast" role="status" hidden></div>
|
|
@@ -140,6 +140,7 @@ def settings(tmp_path, menagerie):
|
|
| 140 |
mjcf_sim_seconds=0.2,
|
| 141 |
codegen_backend="direct",
|
| 142 |
auto_review=False,
|
|
|
|
| 143 |
)
|
| 144 |
|
| 145 |
|
|
|
|
| 140 |
mjcf_sim_seconds=0.2,
|
| 141 |
codegen_backend="direct",
|
| 142 |
auto_review=False,
|
| 143 |
+
prompt_enhance=False,
|
| 144 |
)
|
| 145 |
|
| 146 |
|
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt enhancement through OpenCode Zen (free): a fake `opencode` binary stands in, nothing leaves the machine."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import stat
|
| 5 |
+
from dataclasses import replace
|
| 6 |
+
|
| 7 |
+
from conftest import GOOD_SCENE
|
| 8 |
+
from fastapi.testclient import TestClient
|
| 9 |
+
|
| 10 |
+
from api import budget, keys
|
| 11 |
+
from api.auth import Allowlist
|
| 12 |
+
from api.enhance import Enhanced, enhance
|
| 13 |
+
from api.llm import ScriptedLLM
|
| 14 |
+
from api.logstore import LogStore
|
| 15 |
+
from api.main import Services, create_app
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def fake_opencode(tmp_path, answer: str, exit_code: int = 0):
|
| 19 |
+
"""An executable that records its argv/config and prints OpenCode-style JSON events."""
|
| 20 |
+
events = [
|
| 21 |
+
{"type": "text", "part": {"type": "text", "text": answer}},
|
| 22 |
+
{"type": "step_finish", "part": {"tokens": {"input": 400, "output": 120}, "cost": 0}},
|
| 23 |
+
]
|
| 24 |
+
(tmp_path / "events.jsonl").write_text("\n".join(json.dumps(e) for e in events) + "\n")
|
| 25 |
+
script = tmp_path / "opencode"
|
| 26 |
+
script.write_text(
|
| 27 |
+
"#!/usr/bin/env python3\n"
|
| 28 |
+
"import json, os, sys\n"
|
| 29 |
+
f"d = {str(tmp_path)!r}\n"
|
| 30 |
+
"open(d + '/call.json', 'w').write(json.dumps({'argv': sys.argv, "
|
| 31 |
+
"'config': json.loads(os.environ['OPENCODE_CONFIG_CONTENT'])}))\n"
|
| 32 |
+
"print(open(d + '/events.jsonl').read())\n"
|
| 33 |
+
f"sys.exit({exit_code})\n"
|
| 34 |
+
)
|
| 35 |
+
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
| 36 |
+
return script
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def on(settings, tmp_path, answer, **kw):
|
| 40 |
+
return replace(settings, prompt_enhance=True, opencode_bin=str(fake_opencode(tmp_path, answer, **kw)))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_enhances_with_the_free_zen_model_only(settings, tmp_path):
|
| 44 |
+
s = on(settings, tmp_path, "<prompt>A G1 humanoid 1.5 m in front of 3 steps, each 0.15 m high.</prompt>")
|
| 45 |
+
e = enhance(s, "scene", "robot in front of 3 steps 1.5 m away", robot="unitree_g1")
|
| 46 |
+
assert e.used and e.text.startswith("A G1 humanoid") and e.model == "opencode/deepseek-v4-flash-free"
|
| 47 |
+
assert e.usage == {"input_tokens": 400, "output_tokens": 120, "cost": 0.0} # Zen reports it as free
|
| 48 |
+
call = json.loads((tmp_path / "call.json").read_text())
|
| 49 |
+
assert call["argv"][call["argv"].index("--model") + 1] == "opencode/deepseek-v4-flash-free"
|
| 50 |
+
# only the Zen provider, and no tools: it cannot reach OpenRouter or touch files
|
| 51 |
+
assert set(call["config"]["provider"]) == {"opencode"}
|
| 52 |
+
assert all(v == "deny" for v in call["config"]["permission"].values())
|
| 53 |
+
assert "-f" not in call["argv"]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_falls_back_to_the_original_when_numbers_are_lost(settings, tmp_path):
|
| 57 |
+
s = on(settings, tmp_path, "<prompt>A robot in front of some steps.</prompt>")
|
| 58 |
+
e = enhance(s, "scene", "robot in front of 3 steps 1.5 m away", robot="unitree_g1")
|
| 59 |
+
assert not e.used and e.text == "robot in front of 3 steps 1.5 m away" and "dropped" in e.note
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_falls_back_when_opencode_fails_or_is_missing(settings, tmp_path):
|
| 63 |
+
e = enhance(on(settings, tmp_path, "boom", exit_code=1), "code", "publish hello on /chatter every second")
|
| 64 |
+
assert not e.used and e.text == "publish hello on /chatter every second"
|
| 65 |
+
e = enhance(replace(settings, prompt_enhance=True, opencode_bin="no-such-opencode"), "code", "x" * 20)
|
| 66 |
+
assert not e.used and "not installed" in e.note
|
| 67 |
+
assert not enhance(settings, "code", "x" * 20).used # PROMPT_ENHANCE=false
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_api_generates_from_the_brief_and_does_not_bill_it(settings, store):
|
| 71 |
+
key = keys.add(settings.testers_file, "alice")
|
| 72 |
+
llm = ScriptedLLM([f"```xml\n{GOOD_SCENE}\n```"])
|
| 73 |
+
seen = []
|
| 74 |
+
|
| 75 |
+
def fake_enhancer(settings, kind, text, **ctx):
|
| 76 |
+
seen.append((kind, text, ctx))
|
| 77 |
+
brief = text + " The slope is 2 m ahead; the crate weighs 5 kg."
|
| 78 |
+
return Enhanced(text, brief, True, "opencode/deepseek-v4-flash-free", usage={"input_tokens": 300})
|
| 79 |
+
|
| 80 |
+
services = Services(
|
| 81 |
+
settings=settings,
|
| 82 |
+
store=store,
|
| 83 |
+
logs=LogStore(settings.log_db),
|
| 84 |
+
allowlist=Allowlist(settings.testers_file),
|
| 85 |
+
llm_factory=lambda: llm,
|
| 86 |
+
enhancer=fake_enhancer,
|
| 87 |
+
)
|
| 88 |
+
client = TestClient(create_app(services))
|
| 89 |
+
r = client.post("/simulate/generate", json={"description": "G1 beside a 15 degree slope and a crate"},
|
| 90 |
+
headers={"X-API-Key": key}) # fmt: skip
|
| 91 |
+
assert r.status_code == 200, r.text
|
| 92 |
+
body = r.json()
|
| 93 |
+
assert seen == [("scene", "G1 beside a 15 degree slope and a crate", {"robot": "unitree_g1"})]
|
| 94 |
+
assert body["enhanced_prompt"]["used"] and "crate weighs 5 kg" in body["enhanced_prompt"]["text"]
|
| 95 |
+
assert "crate weighs 5 kg" in llm.calls[0][-1].content # the generator got the brief
|
| 96 |
+
row = services.logs.get(body["id"])
|
| 97 |
+
assert row["request"]["description"] == "G1 beside a 15 degree slope and a crate" # original kept
|
| 98 |
+
assert row["request"]["enhanced"]["used"]
|
| 99 |
+
zen = [u for u in services.logs.usage_rows() if u["provider"] == "opencode-zen"]
|
| 100 |
+
assert len(zen) == 1 and zen[0]["billable"] == 0 and zen[0]["exempt_reason"] == "opencode_zen_free"
|
| 101 |
+
ai = budget.summary(services.logs, settings, "alice")
|
| 102 |
+
assert ai["exempt_calls"] == 1 and ai["used"] == body["cost"]["inr"] # only the OpenRouter call is billed
|
| 103 |
+
# opting out skips it
|
| 104 |
+
llm.responses.append(f"```xml\n{GOOD_SCENE}\n```")
|
| 105 |
+
r = client.post("/simulate/generate", json={"description": "G1 beside a 15 degree slope", "enhance": False},
|
| 106 |
+
headers={"X-API-Key": key}) # fmt: skip
|
| 107 |
+
assert r.json()["enhanced_prompt"]["used"] is False and len(seen) == 1
|
|
@@ -5,7 +5,7 @@ import pytest
|
|
| 5 |
from conftest import complete
|
| 6 |
|
| 7 |
from api import jobs
|
| 8 |
-
from api.jobs import JobError, job_cost, month_spend
|
| 9 |
|
| 10 |
|
| 11 |
def start_run(client, key, gen_id):
|
|
@@ -27,20 +27,38 @@ def test_cost_is_recorded_when_a_job_ends(run_env):
|
|
| 27 |
|
| 28 |
def test_monthly_limit_refuses_new_jobs(run_env):
|
| 29 |
client, keys_, backend, services, gen_id = run_env
|
| 30 |
-
|
|
|
|
| 31 |
first = start_run(client, keys_["alice"], gen_id)
|
|
|
|
|
|
|
|
|
|
| 32 |
complete(backend, "job-1", first)
|
| 33 |
-
client.get(f"/jobs/{first}", headers={"X-API-Key": keys_["alice"]}) # records ~$0.
|
| 34 |
assert month_spend(services.settings, services.logs) < 0.05
|
|
|
|
| 35 |
services.logs.update_run(first, cost_usd=0.06)
|
| 36 |
-
|
| 37 |
-
assert
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
r = client.post("/simulate/run", json={"generation_id": gen_id}, headers={"X-API-Key": keys_["alice"]})
|
| 39 |
-
assert r.status_code == 402 and "
|
| 40 |
assert len(backend.submitted) == 1
|
| 41 |
-
|
| 42 |
-
assert
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def test_running_jobs_count_toward_the_budget(run_env):
|
|
|
|
| 5 |
from conftest import complete
|
| 6 |
|
| 7 |
from api import jobs
|
| 8 |
+
from api.jobs import JobError, job_cost, month_spend, worst_cost
|
| 9 |
|
| 10 |
|
| 11 |
def start_run(client, key, gen_id):
|
|
|
|
| 27 |
|
| 28 |
def test_monthly_limit_refuses_new_jobs(run_env):
|
| 29 |
client, keys_, backend, services, gen_id = run_env
|
| 30 |
+
worst = worst_cost(services.settings, "sim", "runpod" if backend.name == "runpod" else None)
|
| 31 |
+
services.settings = replace(services.settings, monthly_credit_usd=worst + 0.05, compute_testers=1)
|
| 32 |
first = start_run(client, keys_["alice"], gen_id)
|
| 33 |
+
# while it runs, the first job holds its worst case: a second one would not fit
|
| 34 |
+
r = client.post("/simulate/run", json={"generation_id": gen_id}, headers={"X-API-Key": keys_["alice"]})
|
| 35 |
+
assert r.status_code == 402 and "budget for this month is used up" in r.json()["detail"]
|
| 36 |
complete(backend, "job-1", first)
|
| 37 |
+
client.get(f"/jobs/{first}", headers={"X-API-Key": keys_["alice"]}) # records ~$0.02: room again
|
| 38 |
assert month_spend(services.settings, services.logs) < 0.05
|
| 39 |
+
start_run(client, keys_["alice"], gen_id)
|
| 40 |
services.logs.update_run(first, cost_usd=0.06)
|
| 41 |
+
body = client.get("/jobs", headers={"X-API-Key": keys_["alice"]}).json()
|
| 42 |
+
assert body["budget"]["monthly_limit_usd"] == pytest.approx(worst + 0.05)
|
| 43 |
+
assert len(backend.submitted) == 2
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_each_tester_gets_an_equal_strict_share(run_env):
|
| 47 |
+
client, keys_, backend, services, gen_id = run_env
|
| 48 |
+
worst = worst_cost(services.settings, "sim", "runpod" if backend.name == "runpod" else None)
|
| 49 |
+
# 4 testers share 4 * (1.5 jobs): each can have one job in flight, never two
|
| 50 |
+
services.settings = replace(services.settings, monthly_credit_usd=4 * 1.5 * worst, compute_testers=4)
|
| 51 |
+
assert services.settings.compute_share_usd == pytest.approx(1.5 * worst)
|
| 52 |
+
first = start_run(client, keys_["alice"], gen_id)
|
| 53 |
r = client.post("/simulate/run", json={"generation_id": gen_id}, headers={"X-API-Key": keys_["alice"]})
|
| 54 |
+
assert r.status_code == 402 and "your GPU budget" in r.json()["detail"]
|
| 55 |
assert len(backend.submitted) == 1
|
| 56 |
+
usage = client.get("/me/usage", headers={"X-API-Key": keys_["alice"]}).json()["compute"]
|
| 57 |
+
assert usage["used"] == pytest.approx(worst, abs=1e-3) and usage["share"] == pytest.approx(1.5 * worst)
|
| 58 |
+
complete(backend, "job-1", first)
|
| 59 |
+
client.get(f"/jobs/{first}", headers={"X-API-Key": keys_["alice"]})
|
| 60 |
+
start_run(client, keys_["alice"], gen_id) # the finished job now counts at its real (small) cost
|
| 61 |
+
assert len(backend.submitted) == 2
|
| 62 |
|
| 63 |
|
| 64 |
def test_running_jobs_count_toward_the_budget(run_env):
|