diff --git a/app/legacy/onboarding.js b/app/legacy/onboarding.js index 481f9b9e..c602d0a2 100644 --- a/app/legacy/onboarding.js +++ b/app/legacy/onboarding.js @@ -791,7 +791,13 @@ // A layer open above the wizard (Settings, a floating window, the restart overlay) owns Esc. if (document.querySelector(".restart-overlay, .sw-overlay, .fw:not([hidden])")) return; const t = document.activeElement; - if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT")) return; + if (t && t.tagName === "SELECT") return; + // Typed text is not thrown away by a stray Esc: the first one leaves the field, the next closes. + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA") && String(t.value || "").trim()) { + e.preventDefault(); + t.blur(); + return; + } e.preventDefault(); leave(); } @@ -843,6 +849,7 @@ const saved = (resume || carry) ? restore() : null; if (saved && resume && saved.resumable) { Object.assign(ctx, saved.ctx || {}); + save(false); // resumed: from here an ordinary run, closable for good idx = Math.min(Math.max(saved.idx || 0, 0), steps.length - 1); // The project survived the restart; the in-memory handle did not. const pid = saved.ctx?.projectId; diff --git a/modules/system/taste/store.py b/modules/system/taste/store.py index 074f4f7b..73582258 100644 --- a/modules/system/taste/store.py +++ b/modules/system/taste/store.py @@ -46,7 +46,8 @@ MAX_ROWS = 200 def valid(key) -> bool: - return isinstance(key, str) and bool(_KEY.match(key)) and not key.endswith((".", " ")) + return isinstance(key, str) and bool(_KEY.match(key)) and not key.endswith((".", " ")) \ + and not key.lower().endswith(".json") # `latest.json` lives beside the keys: a key cannot share its name def _dir(key): @@ -165,6 +166,8 @@ def rate(prompt_id, rating, axis=None): `rating` None clears: the row is removed, nothing is learned from that clip. `axis` only goes with "disliked" (see AXES); any other rating drops it. """ + if not isinstance(prompt_id, str) or not prompt_id: + raise ValueError("a rating names the clip it is about (no prompt id was given)") if rating is not None and rating not in REWARD: raise ValueError(f"{rating!r} is not a rating") if axis is not None and axis not in AXES: @@ -293,14 +296,22 @@ def _sound_rows(data): the wrong shape would break every later rating.""" if not isinstance(data, dict) or not isinstance(data.get("rows"), list): return False + shapes = {} for row in data["rows"]: if not isinstance(row, dict) or isinstance(row.get("reward"), bool) or not isinstance(row.get("reward"), (int, float)): return False + if not isinstance(row.get("prompt_id"), str) or not row["prompt_id"]: + return False if row.get("axis") not in (None, *AXES): return False captured = row.get("rows") if not isinstance(captured, dict) or not all(isinstance(v, torch.Tensor) for v in captured.values()): return False + # What is stacked later must agree: one name, one shape (a key trained across two model + # families would otherwise abort every sampling that reads it). + for name, v in captured.items(): + if shapes.setdefault(name, tuple(v.shape)) != tuple(v.shape): + return False return True diff --git a/modules/system/taste/tests/test_taste_routes.py b/modules/system/taste/tests/test_taste_routes.py index 67409261..f04adef5 100644 --- a/modules/system/taste/tests/test_taste_routes.py +++ b/modules/system/taste/tests/test_taste_routes.py @@ -124,3 +124,15 @@ def test_a_damaged_zip_and_an_empty_key_are_sentences_not_500s(server): # noqa: status, out = _raw(server, "GET", "/funpack/api/m/taste/keys/fox/export") assert status == 400 and b"nothing to export" in out assert not store.valid("a ") + + +def test_reserved_names_shapes_and_missing_prompt_ids_are_refused(server): # noqa: F811 + good = {"rows": [{"prompt_id": "p", "reward": 1.0, "rows": {"1": torch.ones(2)}}]} + assert _raw(server, "POST", "/funpack/api/m/taste/keys/import?name=latest.json", _zip_of({"reins.pt": _torch_bytes(good)}))[0] == 400 + mixed = {"rows": [{"prompt_id": "p", "reward": 1.0, "rows": {"1": torch.ones(2)}}, + {"prompt_id": "q", "reward": -1.0, "rows": {"1": torch.ones(3)}}]} + assert _raw(server, "POST", "/funpack/api/m/taste/keys/import?name=x", _zip_of({"reins.pt": _torch_bytes(mixed)}))[0] == 400 + nopid = {"rows": [{"reward": 1.0, "rows": {"1": torch.ones(2)}}]} + assert _raw(server, "POST", "/funpack/api/m/taste/keys/import?name=x", _zip_of({"reins.pt": _torch_bytes(nopid)}))[0] == 400 + status, out = _request(server, "POST", "/funpack/api/m/taste/rate", {"rating": "liked"}) + assert status == 400