ablation-workbench 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/CHANGELOG.md +16 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/PKG-INFO +4 -2
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/README.md +3 -1
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/README.zh-CN.md +2 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/docs/implementation-contract.md +2 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/__init__.py +1 -1
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/cli.py +1 -1
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/server.py +27 -5
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/app.js +13 -56
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/index.html +10 -10
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/styles.css +4 -4
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/browser_smoke.cjs +3 -3
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/browser_updates.cjs +3 -3
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/browser_workbench.cjs +35 -25
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_cli.py +7 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_server.py +44 -1
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/.gitignore +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/CONTRIBUTING.md +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/LICENSE +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/docs/release-review.md +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/hatch_build.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/package-lock.json +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/package.json +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/pyproject.toml +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/scripts/build-ui.cjs +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/scripts/vendor-voyage.cjs +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/skills/ablation/SKILL.md +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/skills/ablation/references/zh.md +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/__main__.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/demo.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/engine.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/events.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/installation.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/models.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/observation.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/replay.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/runner.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/sources.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/storage.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/updates.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/bifrost-LICENSE.txt +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/bifrost-NOTICE.txt +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/favicon.svg +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/messages.css +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/messages.js +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/run-results.js +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/theme.js +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/voyage-toolbar.js +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/voyage.css +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/messages.test.cjs +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/run-results.test.cjs +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_engine.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_installation.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_observation.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_release_workflows.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_runner.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_sources.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_updates.py +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/appearance.js +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/messages-data.cjs +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/messages.jsx +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/theme.js +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/messages.css +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/portable.tsx +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/responsesItemRow.tsx +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/responsesItems.ts +0 -0
- {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/voyage-toolbar.jsx +0 -0
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v0.3.2 (2026-10-30)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- 固定回放书签位置并保持悬停背景不透明 ([#14](https://github.com/yiminspace/ablation/pull/14),
|
|
10
|
+
[`4b6c727`](https://github.com/yiminspace/ablation/commit/4b6c727127cdc4e633e4320bef39846d21465da6))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## v0.3.1 (2026-10-29)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
- 固定 GUI 默认端口并使用 /ablation 路径 ([#13](https://github.com/yiminspace/ablation/pull/13),
|
|
18
|
+
[`5259162`](https://github.com/yiminspace/ablation/commit/525916204ec73f0d7bfc25952867cbd9b595ba54))
|
|
19
|
+
|
|
20
|
+
|
|
5
21
|
## v0.3.0 (2026-10-28)
|
|
6
22
|
|
|
7
23
|
### Features
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ablation-workbench
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: A local CLI and review GUI for reproducible prompt experiments
|
|
5
5
|
Project-URL: Homepage, https://github.com/yiminspace/ablation
|
|
6
6
|
Project-URL: Repository, https://github.com/yiminspace/ablation
|
|
@@ -54,6 +54,8 @@ ablation --workspace ./experiments import request.json --format request
|
|
|
54
54
|
ablation --workspace ./experiments gui
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
The GUI starts at `http://127.0.0.1:8766/ablation/`. It tries subsequent ports only when the default port is occupied. `--port PORT` binds an explicit port without fallback; `--port 0` requests a random port.
|
|
58
|
+
|
|
57
59
|
The example addresses are placeholders. Replace them with your own endpoints and supply credentials through the named environment variables. Configuration stores variable names, not secret values. Source retrieval and model inference use separate endpoint and credential settings.
|
|
58
60
|
|
|
59
61
|
Choose an explicit import format:
|
|
@@ -119,7 +121,7 @@ The initial view fixes the imported original request on the left and shows the m
|
|
|
119
121
|
|
|
120
122
|
The summary counts historical runs for the selected versions and scenario, so a later passing replay does not hide earlier failures. The response diff shows the latest completed outputs; inspect individual runs for earlier evidence.
|
|
121
123
|
|
|
122
|
-
The sidebar starts collapsed and lists saved experiment sessions by title and full session ID, with search and replay counts. Each session retains all replay batches, scenarios, repeats, outcomes, and log links. The initial view prioritizes the diff, with the complete batch history on the later **运行记录** tab; **运行记录** lists all runs compactly. Clicking anywhere on a batch row opens a narrow, full-height right drawer with Messages, checks, and raw JSON views.
|
|
124
|
+
The sidebar starts collapsed and lists saved experiment sessions by title and full session ID, with search and replay counts. Each session retains all replay batches, scenarios, repeats, outcomes, and log links. The initial view prioritizes the diff, with the complete batch history on the later **运行记录** tab; **运行记录** lists all runs compactly. Clicking anywhere on a batch row opens a narrow, full-height right drawer with Messages, checks, and raw JSON views. Fixed index bookmarks switch between the runs in that batch without moving the stack; selection highlights the active bookmark, and hover backgrounds stay opaque. Its timeline includes recorded inputs, tool calls/results, screenshot thumbnails that open at full size, and the new model output. Opening a run positions the timeline at its highlighted new output; a locate button returns to it. The timeline adapts Bifrost's Apache-2.0 components; the pinned source, modifications, and license ship with the web assets. External log links remain available for provenance. The GUI uses Voyage components with GitHub as the default theme. The top-right standard VoyageToolbar provides light/dark and theme selection. Its feedback and language placeholders remain disabled until those product capabilities are configured. All styles and the toolbar are bundled locally for offline use; `npm run build:ui` synchronizes the pinned assets and `npm run check:ui` verifies them.
|
|
123
125
|
|
|
124
126
|
Replay invokes a single model request. Returned tool calls are inspected as data and never executed. A model response does not prove that an external action occurred.
|
|
125
127
|
|
|
@@ -34,6 +34,8 @@ ablation --workspace ./experiments import request.json --format request
|
|
|
34
34
|
ablation --workspace ./experiments gui
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
The GUI starts at `http://127.0.0.1:8766/ablation/`. It tries subsequent ports only when the default port is occupied. `--port PORT` binds an explicit port without fallback; `--port 0` requests a random port.
|
|
38
|
+
|
|
37
39
|
The example addresses are placeholders. Replace them with your own endpoints and supply credentials through the named environment variables. Configuration stores variable names, not secret values. Source retrieval and model inference use separate endpoint and credential settings.
|
|
38
40
|
|
|
39
41
|
Choose an explicit import format:
|
|
@@ -99,7 +101,7 @@ The initial view fixes the imported original request on the left and shows the m
|
|
|
99
101
|
|
|
100
102
|
The summary counts historical runs for the selected versions and scenario, so a later passing replay does not hide earlier failures. The response diff shows the latest completed outputs; inspect individual runs for earlier evidence.
|
|
101
103
|
|
|
102
|
-
The sidebar starts collapsed and lists saved experiment sessions by title and full session ID, with search and replay counts. Each session retains all replay batches, scenarios, repeats, outcomes, and log links. The initial view prioritizes the diff, with the complete batch history on the later **运行记录** tab; **运行记录** lists all runs compactly. Clicking anywhere on a batch row opens a narrow, full-height right drawer with Messages, checks, and raw JSON views.
|
|
104
|
+
The sidebar starts collapsed and lists saved experiment sessions by title and full session ID, with search and replay counts. Each session retains all replay batches, scenarios, repeats, outcomes, and log links. The initial view prioritizes the diff, with the complete batch history on the later **运行记录** tab; **运行记录** lists all runs compactly. Clicking anywhere on a batch row opens a narrow, full-height right drawer with Messages, checks, and raw JSON views. Fixed index bookmarks switch between the runs in that batch without moving the stack; selection highlights the active bookmark, and hover backgrounds stay opaque. Its timeline includes recorded inputs, tool calls/results, screenshot thumbnails that open at full size, and the new model output. Opening a run positions the timeline at its highlighted new output; a locate button returns to it. The timeline adapts Bifrost's Apache-2.0 components; the pinned source, modifications, and license ship with the web assets. External log links remain available for provenance. The GUI uses Voyage components with GitHub as the default theme. The top-right standard VoyageToolbar provides light/dark and theme selection. Its feedback and language placeholders remain disabled until those product capabilities are configured. All styles and the toolbar are bundled locally for offline use; `npm run build:ui` synchronizes the pinned assets and `npm run check:ui` verifies them.
|
|
103
105
|
|
|
104
106
|
Replay invokes a single model request. Returned tool calls are inspected as data and never executed. A model response does not prove that an external action occurred.
|
|
105
107
|
|
|
@@ -31,6 +31,8 @@ ablation demo --open
|
|
|
31
31
|
|
|
32
32
|
完整配置、命令与示例见 [English README](README.md)。
|
|
33
33
|
|
|
34
|
+
GUI 默认地址为 `http://127.0.0.1:8766/ablation/`,仅在端口被占用时依次尝试后续端口。`--port PORT` 指定端口且不自动回退;`--port 0` 显式使用随机端口。
|
|
35
|
+
|
|
34
36
|
## 接入数据源
|
|
35
37
|
|
|
36
38
|
请求文件可以直接导入;HTTP 记录可使用 `request.body` 和 `response.body` 保存 JSON 正文。日志默认读取 `params` 中的请求字段,并用顶层 `model`、`input`、`messages`、`tools` 覆盖同名字段。任意嵌套结构可配置 `--source-request-pointer` 选出完整请求,或通过 `--source-fields-json` 将顶层请求字段映射到原始记录中的 JSON Pointer。`--source-response-pointer` 用于历史响应;没有响应时省略。映射路径不存在会报错。
|
|
@@ -43,6 +43,8 @@ Scenario origin is `source`, `real`, `derived`, or `synthetic`; the built-in dem
|
|
|
43
43
|
|
|
44
44
|
Loopback only, validate Host/Origin, require `X-Ablation-Client: local` on mutations. Errors `{error:{code,message}}` with non-2xx. No API reads arbitrary paths. Files imported through GUI are uploaded JSON content. Recognized credential fields and configured secret values are redacted. Source URLs reject credential query fields; patches that introduce credentials into the effective request are rejected. Prompt and response text still require review before sharing.
|
|
45
45
|
|
|
46
|
+
The GUI lives at `/ablation/`; the routes below use that prefix (for example, `/ablation/api/experiments`). Legacy unprefixed API routes remain compatible. Default GUI startup binds port 8766, trying subsequent ports only on address-in-use errors. An explicit `--port` does not fall back.
|
|
47
|
+
|
|
46
48
|
- GET `/api/experiments` -> `{experiments:[...]}`; GET `/api/experiments/<id>` -> experiment.
|
|
47
49
|
- POST `/api/import` source + title -> experiment; POST `/api/demo` -> synthetic example experiment.
|
|
48
50
|
- GET `/api/experiments/<id>/diff?base=...&candidate=...&scenario=...&artifact=...` -> compare.
|
|
@@ -92,7 +92,7 @@ def parser():
|
|
|
92
92
|
demo.add_argument("--open", action="store_true")
|
|
93
93
|
gui = sub.add_parser("gui", help="Serve the local review workspace")
|
|
94
94
|
gui.add_argument("experiment", nargs="?")
|
|
95
|
-
gui.add_argument("--port", type=int, default
|
|
95
|
+
gui.add_argument("--port", type=int, help="Bind this port (default: 8766, then the next free port; 0: random)")
|
|
96
96
|
gui.add_argument("--no-open", action="store_true")
|
|
97
97
|
candidate = sub.add_parser("candidate", help="Create an immutable candidate")
|
|
98
98
|
candidate.add_argument("experiment"); candidate.add_argument("--label", required=True)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
|
+
import errno
|
|
4
5
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
5
6
|
import json
|
|
6
7
|
import mimetypes
|
|
@@ -24,6 +25,14 @@ from .runner import AgentRunner, workspace_lock
|
|
|
24
25
|
ACTIVE = {"queued", "running", "cancelling"}
|
|
25
26
|
MAX_BODY = 32 * 1024 * 1024
|
|
26
27
|
WEB = Path(__file__).parent / "web"
|
|
28
|
+
DEFAULT_PORT = 8766
|
|
29
|
+
BASE_PATH = "/ablation"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def route_path(path):
|
|
33
|
+
if path == BASE_PATH or path.startswith(BASE_PATH + "/"):
|
|
34
|
+
return path[len(BASE_PATH):] or "/"
|
|
35
|
+
return path
|
|
27
36
|
|
|
28
37
|
|
|
29
38
|
def now():
|
|
@@ -218,7 +227,11 @@ def make_server(workbench, port=0):
|
|
|
218
227
|
try:
|
|
219
228
|
if not self.valid_local(): self.error(ValueError("Local origin required"), 403); return
|
|
220
229
|
parsed = urlsplit(self.path)
|
|
221
|
-
path
|
|
230
|
+
if parsed.path in ("/", "/index.html", BASE_PATH):
|
|
231
|
+
target = BASE_PATH + "/" + ("?" + parsed.query if parsed.query else "")
|
|
232
|
+
self.send_bytes(b"", 302, extra={"Location": target})
|
|
233
|
+
return
|
|
234
|
+
path = route_path(parsed.path)
|
|
222
235
|
if path == "/api/events":
|
|
223
236
|
self.send_events()
|
|
224
237
|
return
|
|
@@ -258,7 +271,7 @@ def make_server(workbench, port=0):
|
|
|
258
271
|
def do_POST(self):
|
|
259
272
|
try:
|
|
260
273
|
if not self.valid_local(mutation=True): self.error(ValueError("Local origin and client header required"), 403); return
|
|
261
|
-
path = urlsplit(self.path).path
|
|
274
|
+
path = route_path(urlsplit(self.path).path)
|
|
262
275
|
body = self.read_body()
|
|
263
276
|
if path == "/api/import":
|
|
264
277
|
source = body.get("source", {})
|
|
@@ -297,7 +310,16 @@ def make_server(workbench, port=0):
|
|
|
297
310
|
self.send_json(result)
|
|
298
311
|
except (ValueError, OSError, RuntimeError, KeyError, TypeError) as exc: self.error(exc)
|
|
299
312
|
|
|
300
|
-
|
|
313
|
+
# Bind directly so another process cannot claim the port between a probe and bind.
|
|
314
|
+
selected_port = DEFAULT_PORT if port is None else port
|
|
315
|
+
while True:
|
|
316
|
+
try:
|
|
317
|
+
server = ThreadingHTTPServer(("127.0.0.1", selected_port), Handler)
|
|
318
|
+
break
|
|
319
|
+
except OSError as exc:
|
|
320
|
+
if port is not None or exc.errno != errno.EADDRINUSE or selected_port >= 65535:
|
|
321
|
+
raise
|
|
322
|
+
selected_port += 1
|
|
301
323
|
server.daemon_threads = True
|
|
302
324
|
server.agent_runner = agent
|
|
303
325
|
server.replay_jobs = jobs
|
|
@@ -319,10 +341,10 @@ def close_jobs(server):
|
|
|
319
341
|
time.sleep(0.1)
|
|
320
342
|
|
|
321
343
|
|
|
322
|
-
def serve(workbench, experiment_id=None, port=
|
|
344
|
+
def serve(workbench, experiment_id=None, port=None, open_browser=True):
|
|
323
345
|
if experiment_id: workbench.get(experiment_id)
|
|
324
346
|
server = make_server(workbench, port)
|
|
325
|
-
url = f"http://127.0.0.1:{server.server_port}/" + (f"?experiment={experiment_id}" if experiment_id else "")
|
|
347
|
+
url = f"http://127.0.0.1:{server.server_port}{BASE_PATH}/" + (f"?experiment={experiment_id}" if experiment_id else "")
|
|
326
348
|
print(json.dumps({"url": url, "workspace": str(workbench.workspace)}), flush=True)
|
|
327
349
|
if open_browser: webbrowser.open(url)
|
|
328
350
|
from .updates import start_update_checker
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
const drawerMemory=new Map();
|
|
16
16
|
const drawerMemoryKey=() => `${state.experiment?.id}|${drawerRunId}`;
|
|
17
17
|
let drawerTargetTool;
|
|
18
|
-
let drawerBatchKey=null,
|
|
18
|
+
let drawerBatchKey=null,drawerAnimations=[];
|
|
19
19
|
|
|
20
20
|
function readStorage(key) { try { return JSON.parse(localStorage.getItem(key)); } catch { return null; } }
|
|
21
21
|
function persist() {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
async function api(path, body, method = body === undefined ? 'GET' : 'POST') {
|
|
36
36
|
const options = {method, headers:{Accept:'application/json'}};
|
|
37
37
|
if (body !== undefined) { options.headers['Content-Type']='application/json'; options.headers['X-Ablation-Client']='local'; options.body=JSON.stringify(body); }
|
|
38
|
-
const response = await fetch(path, options);
|
|
38
|
+
const response = await fetch('/ablation' + path, options);
|
|
39
39
|
let value;
|
|
40
40
|
try { value = await response.json(); } catch { throw new Error(`服务返回了无法读取的内容(HTTP ${response.status})`); }
|
|
41
41
|
if (!response.ok) throw new Error(value.error?.message || value.error || `请求失败(HTTP ${response.status})`);
|
|
@@ -203,7 +203,7 @@
|
|
|
203
203
|
function experimentDetails() {
|
|
204
204
|
const e=state.experiment,source=e.source || {};
|
|
205
205
|
const synthetic=e.synthetic || source.kind==='demo' || source.fidelity==='synthetic';
|
|
206
|
-
openModal('experiment-details','实验详情',`<dl class="config-list"><dt>数据来源</dt><dd>${synthetic?'合成示例':esc(source.kind || '未标注')}<br><span class="muted">${esc(source.ref || '')}</span></dd><dt>来源完整度</dt><dd>${esc(source.fidelity || '未标注')}</dd><dt>实验编号</dt><dd><code>${esc(e.id)}</code></dd><dt>记录版本</dt><dd>r${esc(e.revision)}</dd><dt>创建时间</dt><dd>${esc(formatTime(e.created_at))}</dd><dt>原始请求指纹</dt><dd><code>${esc(e.baseline?.hash || '未提供')}</code></dd></dl>${source.notes?.length?`<details class="preview-details"><summary>来源说明</summary><p class="dialog-explanation">${esc(Array.isArray(source.notes)?source.notes.join(' '):source.notes)}</p></details>`:''}<div class="form-actions"><a class="vg-btn btn" id="export-link" href="${endpoint('/export')}" download>导出实验</a><button class="vg-btn btn" data-action="close-modal">关闭</button></div>`);
|
|
206
|
+
openModal('experiment-details','实验详情',`<dl class="config-list"><dt>数据来源</dt><dd>${synthetic?'合成示例':esc(source.kind || '未标注')}<br><span class="muted">${esc(source.ref || '')}</span></dd><dt>来源完整度</dt><dd>${esc(source.fidelity || '未标注')}</dd><dt>实验编号</dt><dd><code>${esc(e.id)}</code></dd><dt>记录版本</dt><dd>r${esc(e.revision)}</dd><dt>创建时间</dt><dd>${esc(formatTime(e.created_at))}</dd><dt>原始请求指纹</dt><dd><code>${esc(e.baseline?.hash || '未提供')}</code></dd></dl>${source.notes?.length?`<details class="preview-details"><summary>来源说明</summary><p class="dialog-explanation">${esc(Array.isArray(source.notes)?source.notes.join(' '):source.notes)}</p></details>`:''}<div class="form-actions"><a class="vg-btn btn" id="export-link" href="/ablation${endpoint('/export')}" download>导出实验</a><button class="vg-btn btn" data-action="close-modal">关闭</button></div>`);
|
|
207
207
|
}
|
|
208
208
|
function switchTab(tab) {
|
|
209
209
|
state.tab=tab;
|
|
@@ -566,7 +566,7 @@
|
|
|
566
566
|
}
|
|
567
567
|
function startUpdateEvents() {
|
|
568
568
|
if(updateEvents || !window.EventSource)return;
|
|
569
|
-
const source=new EventSource('/api/events');
|
|
569
|
+
const source=new EventSource('/ablation/api/events');
|
|
570
570
|
updateEvents=source;
|
|
571
571
|
source.onopen=()=>{
|
|
572
572
|
if(updateEvents!==source)return;
|
|
@@ -628,7 +628,6 @@
|
|
|
628
628
|
if(focus)(open?$('[aria-current="true"]',picker):$('#run-drawer-all'))?.focus({preventScroll:true});
|
|
629
629
|
}
|
|
630
630
|
function stopDrawerMotion() {
|
|
631
|
-
drawerTransition++;
|
|
632
631
|
drawerAnimations.forEach(animation=>animation.cancel());drawerAnimations=[];
|
|
633
632
|
$$('.run-drawer-outgoing,.run-drawer-backplate',$('#run-drawer')).forEach(sheet=>sheet.remove());
|
|
634
633
|
$$('[data-motion-hidden]',$('#run-drawer')).forEach(label=>label.removeAttribute('data-motion-hidden'));
|
|
@@ -637,49 +636,10 @@
|
|
|
637
636
|
function liveBookmarks() {
|
|
638
637
|
return $$('.run-drawer-spine',$('#run-drawer')).filter(button=>!button.closest('.run-drawer-outgoing,.run-drawer-backplate'));
|
|
639
638
|
}
|
|
640
|
-
function
|
|
641
|
-
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
const positions=sources.map((element,index)=>element.scrollTop||element.scrollLeft?[index,element.scrollTop,element.scrollLeft]:null).filter(Boolean);
|
|
645
|
-
copies.forEach(element=>{element.removeAttribute('id');element.removeAttribute('autofocus');element.removeAttribute('aria-controls');element.removeAttribute('aria-labelledby');});
|
|
646
|
-
return {clone,restore:()=>positions.forEach(([index,top,left])=>{copies[index].scrollTop=top;copies[index].scrollLeft=left;})};
|
|
647
|
-
};
|
|
648
|
-
const page=snapshot(panel),body=snapshot(viewport),clone=page.clone,rect=panel.getBoundingClientRect();
|
|
649
|
-
clone.classList.add('run-drawer-outgoing');clone.inert=true;clone.setAttribute('aria-hidden','true');
|
|
650
|
-
clone.dataset.outgoingRun=drawerRunId;
|
|
651
|
-
clone.style.width=`${rect.width}px`;clone.style.left=`${rect.left}px`;clone.style.top=`${rect.top}px`;
|
|
652
|
-
$('.run-drawer-surface',clone).style.width=`${rect.width}px`;
|
|
653
|
-
const backplate=document.createElement('div');backplate.className='run-drawer-backplate';backplate.inert=true;backplate.setAttribute('aria-hidden','true');
|
|
654
|
-
backplate.style.width=`${rect.width}px`;backplate.append(body.clone);
|
|
655
|
-
$('.run-drawer-surface',backplate).style.width=`${rect.width}px`;
|
|
656
|
-
$('#run-drawer').append(backplate,clone);page.restore();body.restore();
|
|
657
|
-
return clone;
|
|
658
|
-
}
|
|
659
|
-
function animateRunDrawer(origin,outgoing,opening) {
|
|
660
|
-
const panel=$('#run-drawer-panel'),surface=$('#run-drawer-surface'),rect=panel.getBoundingClientRect();
|
|
661
|
-
if(matchMedia('(prefers-reduced-motion: reduce)').matches){outgoing?.remove();return;}
|
|
662
|
-
const token=drawerTransition,duration=320,easing='cubic-bezier(.22,.9,.22,1)';
|
|
663
|
-
const active=$('.run-drawer-spine',panel),activeRect=active.getBoundingClientRect();
|
|
664
|
-
const labelTop=activeRect.top-rect.top,step=activeRect.width;
|
|
665
|
-
surface.style.width=`${rect.width}px`;
|
|
666
|
-
if(opening){
|
|
667
|
-
drawerAnimations.push(panel.animate([{transform:`translateX(${Math.min(96,rect.width)}px)`,opacity:.6},{transform:'translateX(0)',opacity:1}],{duration:280,easing}));
|
|
668
|
-
}else{
|
|
669
|
-
drawerAnimations.push(panel.animate([{left:`${origin?origin.right:rect.left}px`,top:`${origin?origin.top-labelTop:rect.top}px`,width:'0px'},{left:`${rect.left}px`,top:`${rect.top}px`,width:`${rect.width}px`}],{duration,easing}));
|
|
670
|
-
drawerAnimations.push(surface.animate([{opacity:.8},{opacity:1}],{duration:120,delay:60}));
|
|
671
|
-
if(outgoing){
|
|
672
|
-
const old=outgoing.getBoundingClientRect(),oldLabel=$('.run-drawer-spine',outgoing).getBoundingClientRect();
|
|
673
|
-
const previous=liveBookmarks().find(button=>button.dataset.runDetail===outgoing.dataset.outgoingRun),target=previous?.getBoundingClientRect();
|
|
674
|
-
if(previous)previous.dataset.motionHidden='true';
|
|
675
|
-
drawerAnimations.push(outgoing.animate([{left:`${old.left}px`,top:`${old.top}px`,width:`${old.width}px`,opacity:1},{left:`${target?target.right:rect.left}px`,top:`${target?target.top-(oldLabel.top-old.top):0}px`,width:'0px',opacity:.9}],{duration,easing,fill:'forwards'}));
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
Promise.allSettled(drawerAnimations.map(animation=>animation.finished)).then(()=>{
|
|
679
|
-
if(token!==drawerTransition)return;
|
|
680
|
-
surface.style.width='';outgoing?.remove();$('.run-drawer-backplate',$('#run-drawer'))?.remove();
|
|
681
|
-
$$('[data-motion-hidden]',$('#run-drawer')).forEach(label=>label.removeAttribute('data-motion-hidden'));drawerAnimations=[];
|
|
682
|
-
});
|
|
639
|
+
function animateRunDrawer() {
|
|
640
|
+
if(matchMedia('(prefers-reduced-motion: reduce)').matches)return;
|
|
641
|
+
const panel=$('#run-drawer-panel'),width=panel.getBoundingClientRect().width;
|
|
642
|
+
drawerAnimations.push(panel.animate([{transform:`translateX(${Math.min(96,width)}px)`},{transform:'translateX(0)'}],{duration:280,easing:'cubic-bezier(.22,.9,.22,1)'}));
|
|
683
643
|
}
|
|
684
644
|
function renderRunStack(batch,run) {
|
|
685
645
|
const stack=$('#run-drawer-stack'),key=`${state.experiment.id}|${run.batch_id || run.id}`;
|
|
@@ -689,21 +649,20 @@
|
|
|
689
649
|
const width=$('#run-drawer').clientWidth,n=batch.length,preferredEdge=width>=1000?(n<=6?36:28):(n<=6?28:20);
|
|
690
650
|
const minimumPanel=width>=1000?560:width>=480?360:220;
|
|
691
651
|
const edge=Math.max(0,Math.min(preferredEdge,Math.max(14,(width-minimumPanel)/n),(width-Math.min(160,width))/n));
|
|
692
|
-
const panelWidth=Math.min(1040,width-n*edge)
|
|
652
|
+
const panelWidth=Math.min(1040,width-n*edge);
|
|
693
653
|
$('#run-drawer').style.setProperty('--run-panel-width',`${panelWidth}px`);
|
|
694
654
|
$('#run-drawer').style.setProperty('--run-bookmark-step',`${edge}px`);
|
|
695
655
|
$('#run-drawer').style.setProperty('--run-index-size',edge<18?'10px':'11px');
|
|
696
|
-
$('#run-drawer-panel').style.setProperty('--bookmark-depth',1);
|
|
697
656
|
const statusText=item=>activeStatus(item.status)?'运行中':item.error?'执行错误':item.passed===false?'未通过':item.passed===true?'通过':'未评分';
|
|
698
657
|
for(const [index,item] of batch.entries()){
|
|
699
658
|
const name=item.candidate==='baseline'?'原提示词':label(state.experiment.candidates,item.candidate),context=`${name} · ${label(state.experiment.scenarios,item.scenario)} · 第 ${item.repeat ?? 1} 次 · ${statusText(item)}`;
|
|
700
659
|
let sheet=$$('[data-stack-run]',stack).find(element=>element.dataset.stackRun===item.id);
|
|
701
660
|
if(!sheet){sheet=document.createElement('section');sheet.className='run-drawer-sheet';sheet.dataset.stackRun=item.id;sheet.setAttribute('role','presentation');stack.append(sheet);}
|
|
702
|
-
const selected=item.id===run.id,depth=
|
|
661
|
+
const selected=item.id===run.id,depth=batch.length-index;
|
|
703
662
|
let button=liveBookmarks().find(button=>button.dataset.runDetail===item.id);
|
|
704
663
|
if(!button){button=document.createElement('button');button.className='run-drawer-spine';button.dataset.runDetail=item.id;button.innerHTML=`<span class="spine-number">${index+1}</span><span class="spine-status-dot" aria-hidden="true"></span>`;}
|
|
705
|
-
|
|
706
|
-
sheet.
|
|
664
|
+
if(button.parentElement!==sheet)sheet.prepend(button);
|
|
665
|
+
sheet.dataset.visible='true';sheet.style.zIndex=batch.length-depth+1;sheet.style.setProperty('--bookmark-depth',depth);
|
|
707
666
|
button.id=`run-drawer-bookmark-${item.id}`;button.dataset.bookmarkIndex=index;button.disabled=false;button.title=context;button.setAttribute('aria-label',`回放 ${index+1}:${context}`);button.removeAttribute('role');button.removeAttribute('aria-selected');button.setAttribute('aria-current',selected?'page':'false');button.setAttribute('aria-pressed',String(selected));button.tabIndex=selected?0:-1;
|
|
708
667
|
if(selected)$('#run-drawer-surface').setAttribute('aria-labelledby',button.id);
|
|
709
668
|
$('.spine-status-dot',button).className=`spine-status-dot ${item.passed===false||item.error?'error':item.passed===true?'success':'warning'}`;
|
|
@@ -754,9 +713,7 @@
|
|
|
754
713
|
if(!state.experiment?.runs.some(run=>run.id===runId))return;
|
|
755
714
|
const changed=drawerRunId!==runId,opening=!$('#run-drawer').open,pickerWasOpen=!$('#run-drawer-picker').hidden;
|
|
756
715
|
stopDrawerMotion();
|
|
757
|
-
const bookmark=liveBookmarks().find(button=>button.dataset.runDetail===runId),origin=bookmark?.getBoundingClientRect();
|
|
758
716
|
saveDrawerPosition();
|
|
759
|
-
const outgoing=!opening&&changed&&!matchMedia('(prefers-reduced-motion: reduce)').matches?cloneOutgoingDrawer():null;
|
|
760
717
|
setRunPicker(false);
|
|
761
718
|
if(changed) {
|
|
762
719
|
globalThis.AblationMessages.clear();$('#run-drawer-messages').replaceChildren();
|
|
@@ -772,7 +729,7 @@
|
|
|
772
729
|
$(`#run-drawer-surface .run-drawer-tabs [data-run-view="${name}"]`).setAttribute('aria-selected',String(name===drawerView));
|
|
773
730
|
}
|
|
774
731
|
renderRunDrawer();restoreDrawerPosition();
|
|
775
|
-
if(
|
|
732
|
+
if(opening)animateRunDrawer();
|
|
776
733
|
if(!opening)(pickerWasOpen?$('#run-drawer-all'):liveBookmarks().find(button=>button.dataset.runDetail===runId))?.focus({preventScroll:true});
|
|
777
734
|
const run=state.experiment.runs.find(item=>item.id===runId);
|
|
778
735
|
$$('.run-batch-open').forEach(button=>button.setAttribute('aria-current',String(button.closest('[data-batch-group]').dataset.batchGroup===(run.batch_id || run.id))));
|
|
@@ -5,20 +5,20 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<meta name="color-scheme" content="light dark">
|
|
7
7
|
<title>Ablation · Prompt workbench</title>
|
|
8
|
-
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any">
|
|
9
|
-
<script src="/theme.js"></script>
|
|
10
|
-
<link rel="stylesheet" href="/voyage.css">
|
|
11
|
-
<link rel="stylesheet" href="/styles.css">
|
|
12
|
-
<link rel="stylesheet" href="/messages.css">
|
|
13
|
-
<script src="/messages.js" defer></script>
|
|
14
|
-
<script src="/run-results.js" defer></script>
|
|
15
|
-
<script src="/app.js" defer></script>
|
|
16
|
-
<script src="/voyage-toolbar.js" defer></script>
|
|
8
|
+
<link rel="icon" href="/ablation/favicon.svg" type="image/svg+xml" sizes="any">
|
|
9
|
+
<script src="/ablation/theme.js"></script>
|
|
10
|
+
<link rel="stylesheet" href="/ablation/voyage.css">
|
|
11
|
+
<link rel="stylesheet" href="/ablation/styles.css">
|
|
12
|
+
<link rel="stylesheet" href="/ablation/messages.css">
|
|
13
|
+
<script src="/ablation/messages.js" defer></script>
|
|
14
|
+
<script src="/ablation/run-results.js" defer></script>
|
|
15
|
+
<script src="/ablation/app.js" defer></script>
|
|
16
|
+
<script src="/ablation/voyage-toolbar.js" defer></script>
|
|
17
17
|
</head>
|
|
18
18
|
<body class="focused-workbench vg-app vg-fullscreen">
|
|
19
19
|
<a class="skip-link" href="#main">跳到实验内容</a>
|
|
20
20
|
<header class="global-header vg-header">
|
|
21
|
-
<div class="header-start"><button class="vg-iconbtn history-toggle" data-action="toggle-history" aria-controls="session-history" aria-expanded="false" aria-label="展开会话历史" title="展开会话历史"><svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="2.5" y="3" width="15" height="14" rx="2"/><path d="M7 3v14M4.5 6h.5m-.5 3h.5m-.5 3h.5"/></svg></button><a href="/" class="brand" aria-label="Ablation 首页" title="Ablation · 提示词原版与变体对比"><svg class="brand-mark" viewBox="0 0 32 32" width="29" height="29" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="11" height="22" rx="3" opacity=".65"/><path d="M5.5 11h4m-4 5h4m-4 5h4" opacity=".65"/><rect class="brand-variant" x="19" y="5" width="11" height="22" rx="3"/><path class="brand-variant" d="M22.5 11h4m-4 10h4"/><path d="M15 16h2"/></svg><strong>ablation</strong></a></div>
|
|
21
|
+
<div class="header-start"><button class="vg-iconbtn history-toggle" data-action="toggle-history" aria-controls="session-history" aria-expanded="false" aria-label="展开会话历史" title="展开会话历史"><svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><rect x="2.5" y="3" width="15" height="14" rx="2"/><path d="M7 3v14M4.5 6h.5m-.5 3h.5m-.5 3h.5"/></svg></button><a href="/ablation/" class="brand" aria-label="Ablation 首页" title="Ablation · 提示词原版与变体对比"><svg class="brand-mark" viewBox="0 0 32 32" width="29" height="29" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="5" width="11" height="22" rx="3" opacity=".65"/><path d="M5.5 11h4m-4 5h4m-4 5h4" opacity=".65"/><rect class="brand-variant" x="19" y="5" width="11" height="22" rx="3"/><path class="brand-variant" d="M22.5 11h4m-4 10h4"/><path d="M15 16h2"/></svg><strong>ablation</strong></a></div>
|
|
22
22
|
<div class="header-actions"><button class="vg-btn btn" data-action="import">导入数据</button><button class="vg-btn btn quiet" id="update-indicator" data-action="updates" hidden>有可用更新</button><div id="voyage-toolbar" aria-label="外观与工具"></div><button class="vg-iconbtn" data-action="settings" aria-label="设置与版本" title="设置与版本"><svg viewBox="0 0 20 20" width="17" height="17" aria-hidden="true"><path d="M3 5h14M3 10h14M3 15h14M7 3v4m6 1v4m-7 1v4" stroke="currentColor" stroke-width="1.5" fill="none"/></svg></button></div>
|
|
23
23
|
</header>
|
|
24
24
|
<div id="upgrade-banner" class="upgrade-banner" role="status" aria-live="polite" hidden><span>Ablation 已升级到 <strong id="upgrade-version"></strong>,刷新页面以加载新版界面。</span><button class="vg-btn btn" data-action="reload-ui">刷新页面</button></div>
|
|
@@ -227,11 +227,11 @@
|
|
|
227
227
|
.run-drawer-sheet[data-visible=false]{visibility:hidden;opacity:0;pointer-events:none}
|
|
228
228
|
.run-drawer-spine{position:absolute;left:calc(-1*var(--bookmark-depth,1)*var(--run-bookmark-step));top:calc(12px + (var(--bookmark-depth,1) - 1)*3px);display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:8px;width:var(--run-bookmark-step);height:120px;min-width:0;border:1px solid var(--border);border-right:0;border-radius:6px 0 0 6px;background:var(--bg);color:var(--muted);padding:18px 0 0;text-align:center;box-shadow:-3px 2px 8px #0002;pointer-events:auto;overflow:visible}
|
|
229
229
|
.run-drawer-spine[data-motion-hidden=true]{visibility:hidden}
|
|
230
|
-
.run-drawer-spine:after{content:'';position:absolute;left:100%;top:-1px;bottom:-1px;width:calc((var(--bookmark-depth,1) - 1)*var(--run-bookmark-step) + 1px);border-block:1px solid var(--border);background:
|
|
231
|
-
.run-drawer-spine:hover{background:var(--
|
|
232
|
-
.run-drawer-spine[aria-current=true],.run-drawer-spine[aria-pressed=true]{border-left-color:var(--acc);background:var(--
|
|
230
|
+
.run-drawer-spine:after{content:'';position:absolute;left:100%;top:-1px;bottom:-1px;width:calc((var(--bookmark-depth,1) - 1)*var(--run-bookmark-step) + 1px);border-block:1px solid var(--border);background:var(--bg);pointer-events:none}
|
|
231
|
+
.run-drawer-spine:hover{background:color-mix(in srgb,var(--acc) 8%,var(--bg));opacity:1}
|
|
232
|
+
.run-drawer-spine[aria-current=true],.run-drawer-spine[aria-pressed=true]{border-left-color:var(--acc);background:color-mix(in srgb,var(--acc) 14%,var(--bg));color:var(--fg)}
|
|
233
233
|
.run-drawer-spine[aria-current=true] .spine-number,.run-drawer-spine[aria-pressed=true] .spine-number{color:var(--fg)}
|
|
234
|
-
.run-drawer-spine:focus-visible{outline-offset:-3px;background:var(--
|
|
234
|
+
.run-drawer-spine:focus-visible{outline-offset:-3px;background:color-mix(in srgb,var(--acc) 14%,var(--bg))}
|
|
235
235
|
.spine-number{font:var(--run-index-size,11px)/1.5 var(--mono);font-variant-numeric:tabular-nums;color:var(--muted);flex:none;white-space:nowrap}
|
|
236
236
|
.spine-status-dot{display:block;width:6px;height:6px;border-radius:50%;background:var(--muted);flex:none}
|
|
237
237
|
.spine-status-dot.success{background:var(--green)}
|
|
@@ -27,7 +27,7 @@ async function main(){
|
|
|
27
27
|
child=spawn(python,['-m','ablation_workbench','--workspace',workspace,'gui','--no-open'],{cwd:process.env.ABLATION_USE_INSTALLED?workspace:root,env:appEnv,stdio:['ignore','pipe','pipe']});
|
|
28
28
|
let output='',errors='';child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>errors+=b);
|
|
29
29
|
const url=await until(()=>{if(child.exitCode!==null)throw Error(errors);try{return JSON.parse(output.trim()).url;}catch{return null;}});
|
|
30
|
-
const api=async(route,body)=>{const response=await fetch(new URL(route,url),{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json','X-Ablation-Client':'local'},body:body===undefined?undefined:JSON.stringify(body)});const data=await response.json();assert(response.ok,JSON.stringify(data));return data;};
|
|
30
|
+
const api=async(route,body)=>{const response=await fetch(new URL('/ablation'+route,url),{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json','X-Ablation-Client':'local'},body:body===undefined?undefined:JSON.stringify(body)});const data=await response.json();assert(response.ok,JSON.stringify(data));return data;};
|
|
31
31
|
browser=await chromium.launch({headless:true});
|
|
32
32
|
const context=await browser.newContext({viewport:{width:1440,height:1000},permissions:['clipboard-read','clipboard-write']});
|
|
33
33
|
const page=await context.newPage(),pageErrors=[];page.on('pageerror',e=>pageErrors.push(e.message));
|
|
@@ -204,8 +204,8 @@ async function main(){
|
|
|
204
204
|
await page.locator('.batch-copy strong').first().click();
|
|
205
205
|
await page.locator('.run-drawer-tabs [data-run-view="messages"]').click();
|
|
206
206
|
await page.locator('#run-drawer .bifrost-messages').waitFor();
|
|
207
|
-
assert.equal(await page.locator('#run-drawer-stack .run-drawer-sheet[data-visible="true"]').count(),
|
|
208
|
-
assert.equal(await page.locator('#run-drawer-panel > .run-drawer-spine').count(),
|
|
207
|
+
assert.equal(await page.locator('#run-drawer-stack .run-drawer-sheet[data-visible="true"]').count(),2);
|
|
208
|
+
assert.equal(await page.locator('#run-drawer-panel > .run-drawer-spine').count(),0);
|
|
209
209
|
const baselineSpine=page.locator(`#run-drawer-stack .run-drawer-sheet[data-visible="true"] [data-run-detail="${exp.runs[0].id}"]`);
|
|
210
210
|
if(await page.locator('#run-drawer-panel').getAttribute('data-run-id')!==exp.runs[0].id)await baselineSpine.locator('.spine-number').click();
|
|
211
211
|
await page.locator('.run-drawer-tabs [data-run-view="messages"]').click();
|
|
@@ -57,13 +57,13 @@ async function stop(service){send(service,{type:'stop'});await until(()=>service
|
|
|
57
57
|
async function main(){
|
|
58
58
|
let service=await start();
|
|
59
59
|
const initial=service.version,parts=initial.split('.').map(Number);parts[2]+=1;
|
|
60
|
-
const next=parts.join('.'),url=`http://127.0.0.1:${service.port}/`;
|
|
60
|
+
const next=parts.join('.'),url=`http://127.0.0.1:${service.port}/ablation/`;
|
|
61
61
|
browser=await chromium.launch({headless:true});
|
|
62
62
|
const page=await browser.newPage({viewport:{width:1280,height:900}}),errors=[];
|
|
63
63
|
page.on('pageerror',error=>errors.push(error.message));
|
|
64
64
|
let updates=0,jobs=0,connections=0,versions=0;
|
|
65
|
-
page.on('request',request=>{const p=new URL(request.url()).pathname;if(p==='/api/update')updates++;if(p==='/api/jobs')jobs++;});
|
|
66
|
-
page.on('response',response=>{const p=new URL(response.url()).pathname;if(p==='/api/events'&&response.ok())connections++;if(p==='/api/version'&&response.ok())versions++;});
|
|
65
|
+
page.on('request',request=>{const p=new URL(request.url()).pathname;if(p==='/ablation/api/update')updates++;if(p==='/ablation/api/jobs')jobs++;});
|
|
66
|
+
page.on('response',response=>{const p=new URL(response.url()).pathname;if(p==='/ablation/api/events'&&response.ok())connections++;if(p==='/ablation/api/version'&&response.ok())versions++;});
|
|
67
67
|
await page.clock.install();
|
|
68
68
|
await page.goto(url);
|
|
69
69
|
await page.locator('#empty-state').waitFor({state:'visible'});
|
|
@@ -74,7 +74,7 @@ async function assertPhysicalBookmarks(page,count){
|
|
|
74
74
|
assert.equal(geometry.bookmarks.length,count,`All ${count} page bookmarks remain visible`);
|
|
75
75
|
assert.equal(geometry.bookmarks.filter(item=>item.current==='page').length,1,'Exactly one ordinary bookmark is current');
|
|
76
76
|
const current=geometry.bookmarks.find(item=>item.current==='page');
|
|
77
|
-
assert(current.main&¤t.id===geometry.active&¤t.pressed==='true','Current bookmark
|
|
77
|
+
assert(!current.main&¤t.id===geometry.active&¤t.pressed==='true','Current bookmark stays in its fixed stack position');
|
|
78
78
|
assert.notEqual(geometry.stackRole,'tablist','Bookmarks are attached page controls rather than a separate navbar');
|
|
79
79
|
assert(Math.abs(geometry.panel.top)<1&&geometry.panel.height>=geometry.viewport.height-1,'Live page remains top-aligned and full height');
|
|
80
80
|
assert(geometry.panel.left>=0&&geometry.panel.right<=geometry.viewport.width+1,'Live page fits the right side of the viewport');
|
|
@@ -82,7 +82,7 @@ async function assertPhysicalBookmarks(page,count){
|
|
|
82
82
|
assert(['hidden','clip'].includes(geometry.clip.overflow),'Page viewport clips content during physical expansion');
|
|
83
83
|
assert(geometry.bookmarks.every(item=>item.owner===item.id&&item.role!=='tab'),'Each ordinary button belongs to its corresponding page');
|
|
84
84
|
assert(geometry.bookmarks.every(item=>Math.abs(item.frame.left-geometry.panel.left)<2&&Math.abs(item.frame.top)<1&&Math.abs(item.frame.width-geometry.panel.width)<2&&item.frame.height>=geometry.viewport.height-1),'Background pages share the live page rectangle; there are no detached full-height peer rails');
|
|
85
|
-
assert(geometry.bookmarks.every(item=>item.label.height>=100&&item.label.height<=140&&item.label.top>=0&&item.label.bottom<=
|
|
85
|
+
assert(geometry.bookmarks.every(item=>item.label.height>=100&&item.label.height<=140&&item.label.top>=0&&item.label.bottom<=Math.min(geometry.viewport.height,132+(count-1)*3)&&item.label.right<=geometry.panel.left+2&&item.horizontal&&item.status&&item.reachable&&item.number.left>=-1&&item.number.right<=geometry.viewport.width+1),'Every attached bookmark/status protrudes only in the top-left and remains directly clickable');
|
|
86
86
|
assert(geometry.bookmarks.every(item=>item.neck.content!=='none'&&item.neck.width>=geometry.panel.left-item.label.right-2),'Bookmark necks connect each index sticker to its page');
|
|
87
87
|
return geometry;
|
|
88
88
|
}
|
|
@@ -135,6 +135,7 @@ async function sampleMotion(page,selector){
|
|
|
135
135
|
async function main(){
|
|
136
136
|
server=http.createServer((req,res)=>{
|
|
137
137
|
const url=new URL(req.url,'http://127.0.0.1');
|
|
138
|
+
url.pathname=url.pathname.replace(/^\/ablation(?=\/|$)/,'')||'/';
|
|
138
139
|
if(req.method!=='GET'){mutations.push(req.url);res.writeHead(405);res.end();return;}
|
|
139
140
|
if(url.pathname==='/api/events'){res.writeHead(200,{'Content-Type':'text/event-stream'});res.write('retry: 1000\n\n');return;}
|
|
140
141
|
const json=data=>{res.writeHead(200,{'Content-Type':'application/json','Cache-Control':'no-store'});res.end(JSON.stringify(data));};
|
|
@@ -153,7 +154,7 @@ async function main(){
|
|
|
153
154
|
});
|
|
154
155
|
server.on('connection',socket=>{sockets.add(socket);socket.on('close',()=>sockets.delete(socket));});
|
|
155
156
|
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
156
|
-
const base=`http://127.0.0.1:${server.address().port}/`;
|
|
157
|
+
const base=`http://127.0.0.1:${server.address().port}/ablation/`;
|
|
157
158
|
browser=await chromium.launch({headless:true});
|
|
158
159
|
const page=await browser.newPage({viewport:{width:1440,height:1000},reducedMotion:'no-preference'});
|
|
159
160
|
page.on('pageerror',error=>errors.push(error.message));
|
|
@@ -208,31 +209,29 @@ async function main(){
|
|
|
208
209
|
await assertPhysicalBookmarks(page,6);
|
|
209
210
|
const edgeSelector='#run-drawer-stack .run-drawer-sheet[data-visible="true"] > .run-drawer-spine[data-run-detail]';
|
|
210
211
|
const edgeRun=await page.locator(edgeSelector).first().getAttribute('data-run-detail');
|
|
212
|
+
const before=await assertPhysicalBookmarks(page,6);
|
|
211
213
|
const switching=await sampleMotion(page,edgeSelector);
|
|
212
|
-
assert(switching.states.
|
|
213
|
-
assert(switching.
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
assert
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
assert(
|
|
224
|
-
assert(switching.samples.every(sample=>sample.backplate&&sample.backplate.left<=end.left+1&&sample.backplate.right>=end.left+end.width-1&&sample.backplate.top<=1&&sample.backplate.height>=999&&sample.backplate.opaque),'Stable opaque content covers the right edge throughout the physical page transition');
|
|
214
|
+
assert.equal(switching.states.length,0,'Selecting a bookmark updates content without page motion');
|
|
215
|
+
assert.equal(switching.outgoingContent,null,'Switching needs no fading page clone');
|
|
216
|
+
const after=await assertPhysicalBookmarks(page,6);
|
|
217
|
+
const positions=geometry=>geometry.bookmarks.map(item=>({id:item.id,label:item.label})).sort((a,b)=>a.id.localeCompare(b.id));
|
|
218
|
+
assert.deepEqual(positions(after),positions(before),'All bookmark positions and sizes stay fixed after selection');
|
|
219
|
+
await page.locator(edgeSelector).first().hover();
|
|
220
|
+
const hover=await page.locator(edgeSelector).first().evaluate(button=>{
|
|
221
|
+
const style=getComputedStyle(button);const canvas=document.createElement('canvas');canvas.width=canvas.height=1;
|
|
222
|
+
const context=canvas.getContext('2d');context.fillStyle=style.backgroundColor;context.fillRect(0,0,1,1);
|
|
223
|
+
return {opacity:Number(style.opacity),alpha:context.getImageData(0,0,1,1).data[3]};
|
|
224
|
+
});
|
|
225
|
+
assert.deepEqual(hover,{opacity:1,alpha:255},'Hover background stays fully opaque');
|
|
225
226
|
assert.equal(await page.locator('#run-drawer-panel').getAttribute('data-run-id'),edgeRun);
|
|
226
|
-
assert.equal(switching.keyboardUsesCurrentPanel,true,'Keyboard End selects real panel tab while clone exists');
|
|
227
|
-
assert.deepEqual({inert:switching.outgoingContent.inert,hidden:switching.outgoingContent.hidden,ids:switching.outgoingContent.ids},{inert:true,hidden:'true',ids:0});
|
|
228
227
|
const rapid=await page.evaluate(()=>{
|
|
229
|
-
const next=()=>[...document.querySelectorAll('#run-drawer-stack .run-drawer-sheet[data-visible="true"] > .run-drawer-spine[data-run-detail]')].find(button=>getComputedStyle(button).visibility!=='hidden');
|
|
228
|
+
const next=()=>[...document.querySelectorAll('#run-drawer-stack .run-drawer-sheet[data-visible="true"] > .run-drawer-spine[data-run-detail]')].find(button=>getComputedStyle(button).visibility!=='hidden'&&button.getAttribute('aria-current')!=='page');
|
|
230
229
|
next().click();const first=document.querySelectorAll('.run-drawer-outgoing').length;
|
|
231
230
|
next().click();const second=document.querySelectorAll('.run-drawer-outgoing').length;
|
|
232
231
|
const active=document.querySelector('#run-drawer-panel').dataset.runId;
|
|
233
232
|
document.querySelector('[data-action="close-run-drawer"]').click();return {first,second,active};
|
|
234
233
|
});
|
|
235
|
-
assert(rapid.first
|
|
234
|
+
assert(rapid.first===0&&rapid.second===0,'Rapid switching creates no animated clones');
|
|
236
235
|
await page.locator('#run-drawer').waitFor({state:'hidden'});
|
|
237
236
|
await until(()=>page.evaluate(()=>!document.querySelector('.run-drawer-outgoing,.run-drawer-backplate')&&![...document.querySelectorAll('[data-motion-hidden]')].some(element=>getComputedStyle(element).visibility==='hidden')&&!document.getAnimations().some(animation=>animation.playState==='running'&&animation.effect?.target?.closest('#run-drawer'))));
|
|
238
237
|
await page.locator('.run-batch-open').first().click();await settleDrawer(page);
|
|
@@ -253,9 +252,8 @@ async function main(){
|
|
|
253
252
|
await raw.evaluate(node=>node.scrollTop=240);
|
|
254
253
|
const rawScroll=await raw.evaluate(node=>node.scrollTop);assert(rawScroll>0);
|
|
255
254
|
const rawTransition=await sampleMotion(page,'#run-drawer-stack .run-drawer-sheet[data-visible="true"] [data-run-detail="run_fixture_2"]');
|
|
256
|
-
assert
|
|
257
|
-
assert.equal(rawTransition.
|
|
258
|
-
assert.deepEqual({inert:rawTransition.outgoingContent.inert,hidden:rawTransition.outgoingContent.hidden,ids:rawTransition.outgoingContent.ids},{inert:true,hidden:'true',ids:0});
|
|
255
|
+
assert.equal(rawTransition.states.length,0,'Raw view switches immediately without page motion');
|
|
256
|
+
assert.equal(rawTransition.outgoingContent,null);
|
|
259
257
|
await page.locator('.run-drawer-tabs [data-run-view="checks"]').click();
|
|
260
258
|
// A missing tool and a non-tool assertion still lead to the saved model output.
|
|
261
259
|
for(const index of [1,2]){
|
|
@@ -317,16 +315,28 @@ async function main(){
|
|
|
317
315
|
assert.equal(await page.locator('#run-drawer-panel').getAttribute('data-run-id'),'run_fixture_12');
|
|
318
316
|
await page.keyboard.press('Escape');await page.locator('#run-drawer').waitFor({state:'hidden'});
|
|
319
317
|
}
|
|
318
|
+
await page.setViewportSize({width:1920,height:1000});
|
|
319
|
+
for(let index=13;index<=30;index++)fixture.runs.push({...fixture.runs[5],id:`run_fixture_${index}`,repeat:index-3});
|
|
320
|
+
fixture.revision++;
|
|
321
|
+
await until(async()=>await page.locator('.history-batch').getAttribute('data-run-count')==='30');
|
|
322
|
+
await page.locator('.run-batch-open').first().click();await settleDrawer(page);
|
|
323
|
+
const thirty=await assertPhysicalBookmarks(page,30);
|
|
324
|
+
for(const runId of ['run_fixture_30','run_fixture_1','run_fixture_15']){
|
|
325
|
+
await selectReplay(page,runId);
|
|
326
|
+
const selected=await assertPhysicalBookmarks(page,30);
|
|
327
|
+
assert.deepEqual(positions(selected),positions(thirty),'Thirty replay bookmarks retain their geometry across selection');
|
|
328
|
+
}
|
|
320
329
|
if(process.env.ABLATION_SCREENSHOTS){
|
|
321
330
|
fs.mkdirSync(process.env.ABLATION_SCREENSHOTS,{recursive:true});
|
|
322
|
-
await page.setViewportSize({width:1440,height:1000});
|
|
331
|
+
await page.setViewportSize({width:1440,height:1000});
|
|
332
|
+
if(!await page.locator('#run-drawer').evaluate(node=>node.open))await page.locator('.run-batch-open').first().click();
|
|
323
333
|
await page.screenshot({path:path.join(process.env.ABLATION_SCREENSHOTS,'replay-failure-details.png'),fullPage:true});
|
|
324
334
|
await page.locator('.run-drawer-tabs [data-run-view="messages"]').click();
|
|
325
335
|
await page.locator('.bf-message-scroll').waitFor();
|
|
326
336
|
await page.screenshot({path:path.join(process.env.ABLATION_SCREENSHOTS,'stacked-replay-drawers.png'),fullPage:true});
|
|
327
337
|
}
|
|
328
338
|
assert.deepEqual(mutations,[]);assert.deepEqual(externalRequests,[]);assert.deepEqual(errors,[]);
|
|
329
|
-
console.log(JSON.stringify({latestPolling:true,historicalInspection:true,draftsPreserved:true,failureReasons:true,physicalPageBookmarks:true,normalMotion:true,
|
|
339
|
+
console.log(JSON.stringify({latestPolling:true,historicalInspection:true,draftsPreserved:true,failureReasons:true,physicalPageBookmarks:true,normalMotion:true,fixedBookmarkPositions:true,opaqueHover:true,rapidSwitchClose:true,reducedMotion:true,allTwelveBookmarks:true,allThirtyBookmarks:true,fullWidthContent:true,compact631:true,readingPosition:true,keyboardClose:true,mobile:true,pageErrors:errors}));
|
|
330
340
|
}
|
|
331
341
|
main().catch(error=>{console.error(error);process.exitCode=1;}).finally(async()=>{
|
|
332
342
|
if(browser)await browser.close();
|
|
@@ -60,6 +60,13 @@ class CLITests(unittest.TestCase):
|
|
|
60
60
|
self.assertNotIn("sk-fake-secret", error.getvalue())
|
|
61
61
|
self.assertFalse(Path(directory, "config.json").exists())
|
|
62
62
|
|
|
63
|
+
def test_gui_port_defaults_and_explicit_override(self):
|
|
64
|
+
with tempfile.TemporaryDirectory() as directory, patch("ablation_workbench.server.serve") as serve:
|
|
65
|
+
for options, port in (([], None), (["--port", "9000"], 9000), (["--port", "0"], 0)):
|
|
66
|
+
status, _, error = self.invoke(directory, ["gui", "--no-open", *options])
|
|
67
|
+
self.assertEqual(status, 0, error)
|
|
68
|
+
self.assertEqual(serve.call_args.kwargs["port"], port)
|
|
69
|
+
|
|
63
70
|
def test_generic_profile_mapping_flags_and_local_import(self):
|
|
64
71
|
with tempfile.TemporaryDirectory() as directory:
|
|
65
72
|
status, _, error = self.invoke(directory, ["config", "profile", "source", "--source-base-url", "https://logs.example", "--source-path", "/records/{ref}", "--source-request-pointer", "/payload", "--source-response-pointer", "/result", "--source-fields-json", '{"model":"/deployment"}', "--token-env", "EXAMPLE_SOURCE_TOKEN"])
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import errno
|
|
4
|
+
import socket
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
import subprocess
|
|
5
7
|
import sys
|
|
@@ -46,6 +48,47 @@ class ServerTests(unittest.TestCase):
|
|
|
46
48
|
with urlopen(req) as res: return res.status, json.load(res)
|
|
47
49
|
except HTTPError as exc: return exc.code, json.load(exc)
|
|
48
50
|
|
|
51
|
+
def test_default_port_is_stable_and_falls_back_only_when_occupied(self):
|
|
52
|
+
with socket.socket() as occupied:
|
|
53
|
+
occupied.bind(("127.0.0.1", 0))
|
|
54
|
+
port = occupied.getsockname()[1]
|
|
55
|
+
with patch("ablation_workbench.server.DEFAULT_PORT", port):
|
|
56
|
+
fallback = make_server(self.workbench, port=None)
|
|
57
|
+
try:
|
|
58
|
+
self.assertGreater(fallback.server_port, port)
|
|
59
|
+
finally:
|
|
60
|
+
fallback.server_close()
|
|
61
|
+
with self.assertRaises(OSError) as error:
|
|
62
|
+
make_server(self.workbench, port=port)
|
|
63
|
+
self.assertEqual(error.exception.errno, errno.EADDRINUSE)
|
|
64
|
+
with patch("ablation_workbench.server.DEFAULT_PORT", port):
|
|
65
|
+
for _ in range(2):
|
|
66
|
+
server = make_server(self.workbench, port=None)
|
|
67
|
+
self.assertEqual(server.server_port, port)
|
|
68
|
+
server.server_close()
|
|
69
|
+
|
|
70
|
+
def test_default_port_does_not_hide_other_bind_errors(self):
|
|
71
|
+
with patch("ablation_workbench.server.ThreadingHTTPServer", side_effect=OSError(errno.EACCES, "denied")) as bind:
|
|
72
|
+
with self.assertRaises(OSError):
|
|
73
|
+
make_server(self.workbench, port=None)
|
|
74
|
+
self.assertEqual(bind.call_count, 1)
|
|
75
|
+
|
|
76
|
+
def test_ablation_entry_assets_and_api(self):
|
|
77
|
+
for entry in ("/", "/ablation", "/ablation/"):
|
|
78
|
+
with urlopen(self.url + entry + "?experiment=saved") as response:
|
|
79
|
+
self.assertEqual(response.url, self.url + "/ablation/?experiment=saved")
|
|
80
|
+
html = response.read().decode()
|
|
81
|
+
self.assertIn('src="/ablation/app.js"', html)
|
|
82
|
+
self.assertIn('href="/ablation/"', html)
|
|
83
|
+
with urlopen(self.url + "/ablation/app.js") as response:
|
|
84
|
+
self.assertIn(b"fetch('/ablation' + path", response.read())
|
|
85
|
+
self.assertEqual(self.request("/ablation/api/version"), (200, {"version": __version__}))
|
|
86
|
+
status, experiment = self.request("/ablation/api/demo", {})
|
|
87
|
+
self.assertEqual(status, 200)
|
|
88
|
+
self.assertEqual(self.request("/ablation/api/experiments/" + experiment["id"])[0], 200)
|
|
89
|
+
self.assertEqual(self.request("/ablation/api/demo", {}, {"Origin": "https://other.example"})[0], 403)
|
|
90
|
+
self.assertEqual(self.request("/ablation-other/api/version")[0], 404)
|
|
91
|
+
|
|
49
92
|
def test_gui_serves_bundled_theme_without_external_asset_requests(self):
|
|
50
93
|
for asset, content_type in (("favicon.svg", "image/svg+xml"), ("voyage.css", "text/css"), ("theme.js", "javascript"), ("voyage-toolbar.js", "javascript"), ("messages.js", "javascript"), ("messages.css", "text/css"), ("run-results.js", "javascript"), ("bifrost-LICENSE.txt", "text/plain"), ("bifrost-NOTICE.txt", "text/plain")):
|
|
51
94
|
with self.subTest(asset=asset), urlopen(self.url + "/" + asset) as response:
|
|
@@ -97,7 +140,7 @@ class ServerTests(unittest.TestCase):
|
|
|
97
140
|
fetch.assert_not_called()
|
|
98
141
|
|
|
99
142
|
def test_sse_sends_update_hint_and_closes_on_shutdown(self):
|
|
100
|
-
with urlopen(self.url + "/api/events", timeout=3) as response:
|
|
143
|
+
with urlopen(self.url + "/ablation/api/events", timeout=3) as response:
|
|
101
144
|
self.assertIn("text/event-stream", response.headers["Content-Type"])
|
|
102
145
|
self.assertEqual(response.readline(), b"retry: 1000\n")
|
|
103
146
|
self.assertEqual(response.readline(), b"\n")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/installation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/bifrost-LICENSE.txt
RENAMED
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/bifrost-NOTICE.txt
RENAMED
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/favicon.svg
RENAMED
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/messages.css
RENAMED
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/messages.js
RENAMED
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/run-results.js
RENAMED
|
File without changes
|
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/voyage-toolbar.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/responsesItemRow.tsx
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|