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.
Files changed (67) hide show
  1. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/CHANGELOG.md +16 -0
  2. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/PKG-INFO +4 -2
  3. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/README.md +3 -1
  4. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/README.zh-CN.md +2 -0
  5. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/docs/implementation-contract.md +2 -0
  6. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/__init__.py +1 -1
  7. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/cli.py +1 -1
  8. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/server.py +27 -5
  9. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/app.js +13 -56
  10. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/index.html +10 -10
  11. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/styles.css +4 -4
  12. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/browser_smoke.cjs +3 -3
  13. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/browser_updates.cjs +3 -3
  14. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/browser_workbench.cjs +35 -25
  15. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_cli.py +7 -0
  16. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_server.py +44 -1
  17. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/.gitignore +0 -0
  18. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/CONTRIBUTING.md +0 -0
  19. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/LICENSE +0 -0
  20. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/docs/release-review.md +0 -0
  21. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/hatch_build.py +0 -0
  22. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/package-lock.json +0 -0
  23. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/package.json +0 -0
  24. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/pyproject.toml +0 -0
  25. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/scripts/build-ui.cjs +0 -0
  26. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/scripts/vendor-voyage.cjs +0 -0
  27. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/skills/ablation/SKILL.md +0 -0
  28. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/skills/ablation/references/zh.md +0 -0
  29. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/__main__.py +0 -0
  30. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/demo.py +0 -0
  31. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/engine.py +0 -0
  32. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/events.py +0 -0
  33. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/installation.py +0 -0
  34. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/models.py +0 -0
  35. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/observation.py +0 -0
  36. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/replay.py +0 -0
  37. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/runner.py +0 -0
  38. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/sources.py +0 -0
  39. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/storage.py +0 -0
  40. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/updates.py +0 -0
  41. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/bifrost-LICENSE.txt +0 -0
  42. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/bifrost-NOTICE.txt +0 -0
  43. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/favicon.svg +0 -0
  44. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/messages.css +0 -0
  45. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/messages.js +0 -0
  46. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/run-results.js +0 -0
  47. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/theme.js +0 -0
  48. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/voyage-toolbar.js +0 -0
  49. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/src/ablation_workbench/web/voyage.css +0 -0
  50. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/messages.test.cjs +0 -0
  51. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/run-results.test.cjs +0 -0
  52. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_engine.py +0 -0
  53. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_installation.py +0 -0
  54. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_observation.py +0 -0
  55. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_release_workflows.py +0 -0
  56. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_runner.py +0 -0
  57. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_sources.py +0 -0
  58. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/tests/test_updates.py +0 -0
  59. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/appearance.js +0 -0
  60. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/messages-data.cjs +0 -0
  61. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/messages.jsx +0 -0
  62. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/theme.js +0 -0
  63. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/messages.css +0 -0
  64. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/portable.tsx +0 -0
  65. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/responsesItemRow.tsx +0 -0
  66. {ablation_workbench-0.3.0 → ablation_workbench-0.3.2}/ui/vendor/bifrost/responsesItems.ts +0 -0
  67. {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.0
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. A selector switches between the runs in that batch. 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.
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. A selector switches between the runs in that batch. 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.
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.
@@ -1,3 +1,3 @@
1
1
  """Local prompt experiment workbench."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.2"
@@ -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=0)
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 = parsed.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
- server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
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=0, open_browser=True):
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,drawerTransition=0,drawerAnimations=[];
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 cloneOutgoingDrawer() {
641
- const panel=$('#run-drawer-panel'),viewport=$('#run-drawer-viewport');
642
- const snapshot=source=>{
643
- const clone=source.cloneNode(true),sources=[source,...source.querySelectorAll('*')],copies=[clone,...clone.querySelectorAll('*')];
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),backOrder=batch.filter(item=>item.id!==run.id).map(item=>item.id).reverse();
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=selected?1:backOrder.indexOf(item.id)+2;
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
- const owner=selected?$('#run-drawer-panel'):sheet;if(button.parentElement!==owner)owner.prepend(button);
706
- sheet.hidden=selected;sheet.inert=selected;sheet.dataset.visible=String(!selected);sheet.style.zIndex=batch.length-depth+1;sheet.style.setProperty('--bookmark-depth',depth);
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(changed||opening)animateRunDrawer(origin,outgoing,opening);
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:inherit;pointer-events:none}
231
- .run-drawer-spine:hover{background:var(--blue-bg)}
232
- .run-drawer-spine[aria-current=true],.run-drawer-spine[aria-pressed=true]{border-left-color:var(--acc);background:var(--blue-bg);color:var(--fg)}
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(--blue-bg)}
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(),1);
208
- assert.equal(await page.locator('#run-drawer-panel > .run-drawer-spine').count(),1);
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&&current.id===geometry.active&&current.pressed==='true','Current bookmark is attached directly to the live page');
77
+ assert(!current.main&&current.id===geometry.active&&current.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<=180&&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');
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.includes('running'),'Selecting a physical bookmark starts whole-page motion');
213
- assert(switching.sameBookmark,'The chosen actual bookmark DOM node moves into the live page');
214
- const [start,quarter,middle,end]=switching.samples;
215
- assert(start.width<end.width-100&&quarter.width>start.width+20,'Incoming whole page expands from the chosen bookmark');
216
- assert(switching.samples.every(sample=>Math.abs(sample.incoming.width-end.incoming.width)<2),'Page content keeps a fixed reading width while its viewport opens');
217
- assert(Math.abs(start.bookmark.left-switching.origin.left)<2&&Math.abs(start.bookmark.top-switching.origin.top)<2,'Chosen bookmark starts at its original physical position');
218
- assert(switching.samples.every(sample=>Math.abs(sample.bookmark.right-sample.left)<2&&Math.abs(sample.bookmark.top-sample.top-12)<2),'Incoming page and attached bookmark move together');
219
- assert(start.outgoing&&quarter.outgoing,'Previous foreground remains visible while it retreats');
220
- assert(quarter.outgoing.width<start.outgoing.width-20&&end.outgoing.width<5,'Previous whole page collapses into its attached index sticker');
221
- assert(switching.samples.every(sample=>Math.abs(sample.outgoing.bookmark.right-sample.outgoing.left)<2&&Math.abs(sample.outgoing.bookmark.top-sample.outgoing.top-12)<2),'Outgoing page and its bookmark move together');
222
- assert.deepEqual({inert:switching.backplateContent.inert,hidden:switching.backplateContent.hidden,ids:switching.backplateContent.ids},{inert:true,hidden:'true',ids:0});
223
- assert(switching.backplateContent.content>30,'Stable backplate retains actual previous page content');
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<=1&&rapid.second<=1,'Rapid switching replaces the previous outgoing clone');
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(Math.abs(rawTransition.outgoingContent.rawScrollTop-rawScroll)<2,'Outgoing raw clone retains the reading position');
257
- assert.equal(rawTransition.keyboardUsesCurrentPanel,true);
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});await page.locator('.run-batch-open').first().click();
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,transitionCloneSafe:true,stableContentCoverage:true,rapidSwitchClose:true,reducedMotion:true,allTwelveBookmarks:true,fullWidthContent:true,compact631:true,readingPosition:true,keyboardClose:true,mobile:true,pageErrors:errors}));
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")