dqy08 commited on
Commit
53e5b08
·
1 Parent(s): 12617a9

支持总时间和单步时间设置;追赶调度,排除渲染时间带来的误差;日志改进

Browse files
backend/access_log.py CHANGED
@@ -174,13 +174,14 @@ def log_openai_completions_request(
174
  _request_counter += 1
175
  request_id = _request_counter
176
 
177
- preview = 50
178
  p_preview = prompt[:preview] + "..." if len(prompt) > preview else prompt
179
  details = (
180
  f"req_id={request_id}, model='{model}', "
181
  f"prompt='{p_preview}', chars={len(prompt)}"
182
  )
183
  _log_request("📥 openai completions 请求", details, client_ip)
 
184
  return request_id
185
 
186
 
@@ -202,18 +203,15 @@ def log_prediction_attribute_request(
202
  _request_counter += 1
203
  request_id = _request_counter
204
 
205
- preview = 50
206
- c_preview = context[:preview] + "..." if len(context) > preview else context
207
- if target_prediction is None:
208
- t_preview = "<top-1>"
209
- else:
210
- t_preview = (
211
- target_prediction[:preview] + "..."
212
- if len(target_prediction) > preview
213
- else target_prediction
214
- )
215
  details = (
216
- f"req_id={request_id}, model={model!r}, context='{c_preview}', target='{t_preview}', "
217
  f"context_chars={len(context)}"
218
  )
219
  _log_request("📥 prediction_attribute 请求", details, client_ip)
 
174
  _request_counter += 1
175
  request_id = _request_counter
176
 
177
+ preview = 100
178
  p_preview = prompt[:preview] + "..." if len(prompt) > preview else prompt
179
  details = (
180
  f"req_id={request_id}, model='{model}', "
181
  f"prompt='{p_preview}', chars={len(prompt)}"
182
  )
183
  _log_request("📥 openai completions 请求", details, client_ip)
184
+ _hit_api("chat")
185
  return request_id
186
 
187
 
 
203
  _request_counter += 1
204
  request_id = _request_counter
205
 
206
+ context_preview = 150
207
+ c_preview = (
208
+ context[:context_preview] + "..."
209
+ if len(context) > context_preview
210
+ else context
211
+ )
212
+ target_show = "<top-1>" if target_prediction is None else target_prediction
 
 
 
213
  details = (
214
+ f"req_id={request_id}, model={model!r}, context='{c_preview}', target='{target_show}', "
215
  f"context_chars={len(context)}"
216
  )
217
  _log_request("📥 prediction_attribute 请求", details, client_ip)
backend/api/prediction_attribute.py CHANGED
@@ -71,9 +71,10 @@ def prediction_attribute(attribution_request):
71
 
72
  elapsed = time.perf_counter() - start_time
73
  tokens = len(result.get("token_attribution", []))
 
74
  print(
75
  f"\t📤 API prediction_attribute response: req_id={request_id}, "
76
- f"tokens={tokens}, response_time={elapsed:.4f}s"
77
  )
78
 
79
  return {"success": True, **result}, 200
 
71
 
72
  elapsed = time.perf_counter() - start_time
73
  tokens = len(result.get("token_attribution", []))
74
+ target_token = result.get("target_token")
75
  print(
76
  f"\t📤 API prediction_attribute response: req_id={request_id}, "
77
+ f"target={target_token!r}, tokens={tokens}, response_time={elapsed:.4f}s"
78
  )
79
 
80
  return {"success": True, **result}, 200
backend/completion_generator.py CHANGED
@@ -78,9 +78,7 @@ def _completion_without_generate(
78
 
79
  def _print_completion_stream_delta(text: str, stream_end: bool) -> None:
80
  """接收 TextStreamer 切分好的增量片段,由本模块打印(与默认 TextStreamer 输出一致)。"""
81
- # 仅在verbose时打印
82
- if get_verbose():
83
- print(text, flush=True, end="" if not stream_end else None)
84
 
85
 
86
  def _compose_stream_delta(
@@ -429,12 +427,12 @@ def core_generate_from_text(
429
  effective_max_new = remaining
430
  else:
431
  effective_max_new = min(max_tokens, remaining)
432
- if get_verbose():
433
- print(
434
- f"📌 completion: 推理原文 (tokens={input_len}, ctx_limit={ctx_limit}, max_new={effective_max_new}):\n"
435
- f"{formatted_text}",
436
- end="", # 不换行, 用于和后续打印推理结果拼在一起
437
- )
438
 
439
  prompt_tokens = int(input_len)
440
  # 主要防止:排队等推理锁期间用户已取消,拿到锁后在此短路,避免无意义进入 generate。
 
78
 
79
  def _print_completion_stream_delta(text: str, stream_end: bool) -> None:
80
  """接收 TextStreamer 切分好的增量片段,由本模块打印(与默认 TextStreamer 输出一致)。"""
81
+ print(text, flush=True, end="" if not stream_end else None)
 
 
82
 
83
 
84
  def _compose_stream_delta(
 
427
  effective_max_new = remaining
428
  else:
429
  effective_max_new = min(max_tokens, remaining)
430
+
431
+ print(
432
+ f"📌 completion: 推理原文 (tokens={input_len}, ctx_limit={ctx_limit}, max_new={effective_max_new}):\n"
433
+ f"{formatted_text}",
434
+ end="", # 不换行, 用于和后续打印推理结果拼在一起
435
+ )
436
 
437
  prompt_tokens = int(input_len)
438
  # 主要防止:排队等推理锁期间用户已取消,拿到锁后在此短路,避免无意义进入 generate。
backend/visit_stats.py CHANGED
@@ -63,12 +63,13 @@ def print_visit_summary():
63
  os_cnt[o] += 1
64
  os_order = ("ios", "android", "windows", "macos", "linux", "unknown")
65
  os_pg = [f" {k}: {os_cnt[k]}" for k in os_order if os_cnt[k]]
 
66
 
67
- body = ["========== [访问统计] ==========",
68
  f"进程约 {h:.2f}h | 页面访问IP:{n_ip} | 真实活跃IP:{n_act}", "--- 活跃IP中OS统计 ---",
69
  *(os_pg or [" (尚无)"]), "--- 页面活跃时间统计(秒) ---",
70
- *(pg or [" (尚无)"]), "--- 分析API调用统计 ---",
71
- *[f" {k}: {apis.get(k, 0)}" for k in ("analyze", "analyze_semantic", "prediction_attribute")],
72
  "=" * 42]
73
  print("\n".join(body), flush=True)
74
 
 
63
  os_cnt[o] += 1
64
  os_order = ("ios", "android", "windows", "macos", "linux", "unknown")
65
  os_pg = [f" {k}: {os_cnt[k]}" for k in os_order if os_cnt[k]]
66
+ now = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
67
 
68
+ body = [f"========== [访问统计] {now} ==========",
69
  f"进程约 {h:.2f}h | 页面访问IP:{n_ip} | 真实活跃IP:{n_act}", "--- 活跃IP中OS统计 ---",
70
  *(os_pg or [" (尚无)"]), "--- 页面活跃时间统计(秒) ---",
71
+ *(pg or [" (尚无)"]), "--- API调用统计 ---",
72
+ *[f" {k}: {apis.get(k, 0)}" for k in ("analyze", "analyze_semantic", "prediction_attribute", "chat")],
73
  "=" * 42]
74
  print("\n".join(body), flush=True)
75
 
client/src/css/gen_attribute.scss CHANGED
@@ -345,6 +345,29 @@
345
  text-align: right;
346
  }
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  .attribution-exclude-prompt-patterns-input {
349
  width: 100%;
350
  box-sizing: border-box;
 
345
  text-align: right;
346
  }
347
 
348
+ .gen-attr-dag-replay-speed-row {
349
+ display: flex;
350
+ flex-wrap: wrap;
351
+ align-items: center;
352
+ gap: 6px 10px;
353
+ }
354
+
355
+ select.gen-attr-dag-replay-mode-select {
356
+ width: auto;
357
+ min-width: 7.5rem;
358
+ text-align: left;
359
+ }
360
+
361
+ .gen-attr-dag-replay-value-wrap {
362
+ align-items: center;
363
+ gap: 4px;
364
+
365
+ // 仅非 hidden 时设 flex,否则会覆盖浏览器对 [hidden] 的 display:none(两列输入会同时出现)
366
+ &:not([hidden]) {
367
+ display: inline-flex;
368
+ }
369
+ }
370
+
371
  .attribution-exclude-prompt-patterns-input {
372
  width: 100%;
373
  box-sizing: border-box;
client/src/gen_attribute.html CHANGED
@@ -211,13 +211,28 @@
211
  </span>
212
  </div>
213
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
214
- <span class="semantic-submode-group">
215
- <label class="semantic-submode-label" for="gen_attr_dag_playback_step_ms">DAG play step</label>
216
- <input type="number" id="gen_attr_dag_playback_step_ms" class="gen-attr-dag-measure-width-input"
217
- value="200" min="0" max="10000" step="10"
218
- title="Delay in milliseconds between steps during DAG playback. Stored locally; the value is read when you press play—changing it mid-playback does not affect the current run."
219
  data-i18n="title">
220
- <span class="semantic-submode-label">ms</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  </span>
222
  </div>
223
  </section>
 
211
  </span>
212
  </div>
213
  <div class="gen-attr-dag-measure-width-row semantic-submode-row">
214
+ <span class="semantic-submode-group gen-attr-dag-replay-speed-row">
215
+ <label class="semantic-submode-label" for="gen_attr_dag_replay_mode" data-i18n>DAG replay speed</label>
216
+ <select id="gen_attr_dag_replay_mode" class="gen-attr-dag-replay-mode-select gen-attr-dag-measure-width-input"
217
+ title="Total duration vs. fixed delay per step. Waits shrink if a step is slow; long idle realigns the beat."
 
218
  data-i18n="title">
219
+ <option value="total" data-i18n>Total time</option>
220
+ <option value="step" data-i18n>Step time</option>
221
+ </select>
222
+ <span id="gen_attr_dag_replay_total_wrap" class="gen-attr-dag-replay-value-wrap">
223
+ <input type="number" id="gen_attr_dag_playback_total_s" class="gen-attr-dag-measure-width-input"
224
+ value="7" min="1" max="3600" step="1"
225
+ title="Nominal delay = total seconds ÷ (steps − 1). Saved locally; applied when you press play."
226
+ data-i18n="title">
227
+ <span class="semantic-submode-label">s</span>
228
+ </span>
229
+ <span id="gen_attr_dag_replay_step_wrap" class="gen-attr-dag-replay-value-wrap" hidden>
230
+ <input type="number" id="gen_attr_dag_playback_step_ms" class="gen-attr-dag-measure-width-input"
231
+ value="200" min="0" max="10000" step="10"
232
+ title="Nominal ms between steps. Saved locally; applied when you press play (not mid-run)."
233
+ data-i18n="title">
234
+ <span class="semantic-submode-label">ms</span>
235
+ </span>
236
  </span>
237
  </div>
238
  </section>
client/src/ts/gen_attribute.ts CHANGED
@@ -78,8 +78,13 @@ const GEN_ATTR_MAX_TOKENS_STORAGE_KEY = 'info_radar_gen_attr_max_tokens';
78
  const GEN_ATTR_MAX_TOKENS_DEFAULT = 100;
79
  const GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY = 'info_radar_gen_attr_dag_measure_width';
80
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
 
 
81
  const GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_inactive_edges';
82
 
 
 
 
83
  const GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT = 500;
84
  const GEN_ATTR_DAG_MEASURE_WIDTH_MIN = 200;
85
  const GEN_ATTR_DAG_MEASURE_WIDTH_MAX = 4000;
@@ -88,6 +93,10 @@ const GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT = 200;
88
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_MIN = 0;
89
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_MAX = 10000;
90
 
 
 
 
 
91
  const GENERATE_BTN_LABEL = 'Start';
92
  const STOP_BTN_LABEL = 'Stop';
93
 
@@ -148,6 +157,34 @@ function readStoredDagPlaybackStepMs(): number {
148
  return GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT;
149
  }
150
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  const apiPrefix = URLHandler.parameters['api'] || '';
152
  const bodyElement = d3.select('body').node() as Element;
153
  const { totalSurprisalFormat, api } = initializeCommonApp(apiPrefix, bodyElement);
@@ -203,9 +240,31 @@ const maxTokensInput = document.getElementById('gen_attr_max_tokens') as HTMLInp
203
  const dagMeasureWidthInput = document.getElementById(
204
  'gen_attr_dag_measure_width'
205
  ) as HTMLInputElement | null;
 
206
  const dagPlaybackStepMsInput = document.getElementById(
207
  'gen_attr_dag_playback_step_ms'
208
  ) as HTMLInputElement | null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  const dagHideInactiveEdgesInput = document.getElementById(
210
  'gen_attr_dag_hide_inactive_edges'
211
  ) as HTMLInputElement | null;
@@ -215,8 +274,15 @@ if (modelVariantSelect) modelVariantSelect.value = readStoredModelVariant();
215
  if (maxTokensInput) maxTokensInput.value = String(readStoredMaxTokens());
216
  const initialDagMeasureWidth = readStoredDagMeasureWidth();
217
  if (dagMeasureWidthInput) dagMeasureWidthInput.value = String(initialDagMeasureWidth);
 
 
218
  const initialDagPlaybackStepMs = readStoredDagPlaybackStepMs();
219
  if (dagPlaybackStepMsInput) dagPlaybackStepMsInput.value = String(initialDagPlaybackStepMs);
 
 
 
 
 
220
 
221
  const genAttrResultsNode = genAttrResultsEl.node() as HTMLElement | null;
222
  function applyDagHideInactiveEdges(hide: boolean): void {
@@ -265,6 +331,7 @@ maxTokensInput?.addEventListener('change', () => {
265
  syncSubmitButtonState();
266
  });
267
 
 
268
  dagPlaybackStepMsInput?.addEventListener('change', () => {
269
  const raw = parseInt(dagPlaybackStepMsInput.value, 10);
270
  const ms = Number.isFinite(raw)
@@ -278,6 +345,29 @@ dagPlaybackStepMsInput?.addEventListener('change', () => {
278
  }
279
  });
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  function isSkipChatTemplate(): boolean {
282
  return skipChatTemplateInput?.checked ?? false;
283
  }
@@ -419,14 +509,30 @@ function scheduleDagLastTokenDwell(action: () => void, dwellMs: number = DAG_LAS
419
  }, dwellMs);
420
  }
421
 
422
- /** 仅在点击播放时调用:读当前输入、写回规范化值,返回本轮重放使用的步进间隔。 */
423
- function sampleDagPlaybackStepMsOnPlay(): number {
424
- const raw = parseInt(dagPlaybackStepMsInput?.value ?? '', 10);
425
- const ms = Number.isFinite(raw)
426
- ? clampDagPlaybackStepMs(raw)
427
- : readStoredDagPlaybackStepMs();
428
- if (dagPlaybackStepMsInput) dagPlaybackStepMsInput.value = String(ms);
429
- return ms;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  }
431
 
432
  function stopDagPlayback(): void {
@@ -456,9 +562,12 @@ function handleDagPlaybackToggle(wantPlay: boolean): void {
456
  dagHandle.reset(true);
457
  dagPlaybackNextIndex = 0;
458
  }
459
- const playbackStepMs = sampleDagPlaybackStepMsOnPlay();
460
  dagHandle.setDagPlaybackPlaying(true);
461
 
 
 
 
462
  const isStalePlaybackHandle = (): boolean => {
463
  if (runnerHandle === h) return false;
464
  dagPlaybackTimer = null;
@@ -473,12 +582,22 @@ function handleDagPlaybackToggle(wantPlay: boolean): void {
473
  dagHandle.setDagPlaybackPlaying(false);
474
  };
475
 
476
- const afterPlaybackDelay = (fn: () => void): void => {
 
 
 
 
 
 
 
 
 
 
477
  dagPlaybackTimer = setTimeout(() => {
478
  dagPlaybackTimer = null;
479
  if (isStalePlaybackHandle()) return;
480
- fn();
481
- }, playbackStepMs);
482
  };
483
 
484
  const tick = (): void => {
@@ -503,7 +622,7 @@ function handleDagPlaybackToggle(wantPlay: boolean): void {
503
  });
504
  return;
505
  }
506
- afterPlaybackDelay(tick);
507
  };
508
  tick();
509
  }
 
78
  const GEN_ATTR_MAX_TOKENS_DEFAULT = 100;
79
  const GEN_ATTR_DAG_MEASURE_WIDTH_STORAGE_KEY = 'info_radar_gen_attr_dag_measure_width';
80
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_step_ms';
81
+ const GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY = 'info_radar_gen_attr_dag_replay_pacing_mode';
82
+ const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY = 'info_radar_gen_attr_dag_playback_total_s';
83
  const GEN_ATTR_DAG_HIDE_INACTIVE_EDGES_STORAGE_KEY = 'info_radar_gen_attr_dag_hide_inactive_edges';
84
 
85
+ /** 步进回放节奏:`total`=整段剩余回放总时长内均分间隔;`step`=固定每步间隔(ms)。 */
86
+ type DagReplayPacingMode = 'total' | 'step';
87
+
88
  const GEN_ATTR_DAG_MEASURE_WIDTH_DEFAULT = 500;
89
  const GEN_ATTR_DAG_MEASURE_WIDTH_MIN = 200;
90
  const GEN_ATTR_DAG_MEASURE_WIDTH_MAX = 4000;
 
93
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_MIN = 0;
94
  const GEN_ATTR_DAG_PLAYBACK_STEP_MS_MAX = 10000;
95
 
96
+ const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT = 7;
97
+ const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_MIN = 1;
98
+ const GEN_ATTR_DAG_PLAYBACK_TOTAL_S_MAX = 3600;
99
+
100
  const GENERATE_BTN_LABEL = 'Start';
101
  const STOP_BTN_LABEL = 'Stop';
102
 
 
157
  return GEN_ATTR_DAG_PLAYBACK_STEP_MS_DEFAULT;
158
  }
159
 
160
+ function clampDagPlaybackTotalS(n: number): number {
161
+ return Math.max(
162
+ GEN_ATTR_DAG_PLAYBACK_TOTAL_S_MIN,
163
+ Math.min(GEN_ATTR_DAG_PLAYBACK_TOTAL_S_MAX, Math.round(n))
164
+ );
165
+ }
166
+
167
+ function readStoredDagPlaybackTotalS(): number {
168
+ try {
169
+ const v = localStorage.getItem(GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY);
170
+ const n = v !== null ? parseInt(v, 10) : NaN;
171
+ if (Number.isFinite(n)) return clampDagPlaybackTotalS(n);
172
+ } catch {
173
+ // ignore
174
+ }
175
+ return GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT;
176
+ }
177
+
178
+ function readStoredDagReplayPacingMode(): DagReplayPacingMode {
179
+ try {
180
+ const v = localStorage.getItem(GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY);
181
+ if (v === 'total' || v === 'step') return v;
182
+ } catch {
183
+ // ignore
184
+ }
185
+ return 'total';
186
+ }
187
+
188
  const apiPrefix = URLHandler.parameters['api'] || '';
189
  const bodyElement = d3.select('body').node() as Element;
190
  const { totalSurprisalFormat, api } = initializeCommonApp(apiPrefix, bodyElement);
 
240
  const dagMeasureWidthInput = document.getElementById(
241
  'gen_attr_dag_measure_width'
242
  ) as HTMLInputElement | null;
243
+ /** 步进回放:固定间隔(ms)或总时长(s),由 {@link DagReplayPacingMode} 选择。 */
244
  const dagPlaybackStepMsInput = document.getElementById(
245
  'gen_attr_dag_playback_step_ms'
246
  ) as HTMLInputElement | null;
247
+ const dagReplayModeSelect = document.getElementById(
248
+ 'gen_attr_dag_replay_mode'
249
+ ) as HTMLSelectElement | null;
250
+ const dagPlaybackTotalSInput = document.getElementById(
251
+ 'gen_attr_dag_playback_total_s'
252
+ ) as HTMLInputElement | null;
253
+ const dagReplayTotalWrap = document.getElementById('gen_attr_dag_replay_total_wrap');
254
+ const dagReplayStepWrap = document.getElementById('gen_attr_dag_replay_step_wrap');
255
+
256
+ /** 与 `#gen_attr_dag_replay_mode` 同步;非法或缺失时视为 `total`。 */
257
+ function currentDagReplayPacingMode(): DagReplayPacingMode {
258
+ return dagReplayModeSelect?.value === 'step' ? 'step' : 'total';
259
+ }
260
+
261
+ /** 切换下拉时更新 `hidden`;样式见 `.gen-attr-dag-replay-value-wrap:not([hidden])`。 */
262
+ function applyDagReplaySpeedUi(): void {
263
+ const mode = currentDagReplayPacingMode();
264
+ if (dagReplayTotalWrap) dagReplayTotalWrap.hidden = mode !== 'total';
265
+ if (dagReplayStepWrap) dagReplayStepWrap.hidden = mode !== 'step';
266
+ }
267
+
268
  const dagHideInactiveEdgesInput = document.getElementById(
269
  'gen_attr_dag_hide_inactive_edges'
270
  ) as HTMLInputElement | null;
 
274
  if (maxTokensInput) maxTokensInput.value = String(readStoredMaxTokens());
275
  const initialDagMeasureWidth = readStoredDagMeasureWidth();
276
  if (dagMeasureWidthInput) dagMeasureWidthInput.value = String(initialDagMeasureWidth);
277
+
278
+ // DAG 回放节奏:步长 / 总时长 / 模式下拉 — 自 localStorage 恢复后再同步展示哪块输入
279
  const initialDagPlaybackStepMs = readStoredDagPlaybackStepMs();
280
  if (dagPlaybackStepMsInput) dagPlaybackStepMsInput.value = String(initialDagPlaybackStepMs);
281
+ const initialDagReplayPacingMode = readStoredDagReplayPacingMode();
282
+ if (dagReplayModeSelect) dagReplayModeSelect.value = initialDagReplayPacingMode;
283
+ const initialDagPlaybackTotalS = readStoredDagPlaybackTotalS();
284
+ if (dagPlaybackTotalSInput) dagPlaybackTotalSInput.value = String(initialDagPlaybackTotalS);
285
+ applyDagReplaySpeedUi();
286
 
287
  const genAttrResultsNode = genAttrResultsEl.node() as HTMLElement | null;
288
  function applyDagHideInactiveEdges(hide: boolean): void {
 
331
  syncSubmitButtonState();
332
  });
333
 
334
+ // DAG 回放节奏(与上节「DAG 测量宽度」无关;宽度 listener 在后文)
335
  dagPlaybackStepMsInput?.addEventListener('change', () => {
336
  const raw = parseInt(dagPlaybackStepMsInput.value, 10);
337
  const ms = Number.isFinite(raw)
 
345
  }
346
  });
347
 
348
+ dagReplayModeSelect?.addEventListener('change', () => {
349
+ const mode = currentDagReplayPacingMode();
350
+ try {
351
+ localStorage.setItem(GEN_ATTR_DAG_REPLAY_PACING_MODE_STORAGE_KEY, mode);
352
+ } catch {
353
+ /* ignore */
354
+ }
355
+ applyDagReplaySpeedUi();
356
+ });
357
+
358
+ dagPlaybackTotalSInput?.addEventListener('change', () => {
359
+ const raw = parseInt(dagPlaybackTotalSInput.value, 10);
360
+ const s = Number.isFinite(raw)
361
+ ? clampDagPlaybackTotalS(raw)
362
+ : GEN_ATTR_DAG_PLAYBACK_TOTAL_S_DEFAULT;
363
+ dagPlaybackTotalSInput.value = String(s);
364
+ try {
365
+ localStorage.setItem(GEN_ATTR_DAG_PLAYBACK_TOTAL_S_STORAGE_KEY, String(s));
366
+ } catch {
367
+ /* ignore */
368
+ }
369
+ });
370
+
371
  function isSkipChatTemplate(): boolean {
372
  return skipChatTemplateInput?.checked ?? false;
373
  }
 
509
  }, dwellMs);
510
  }
511
 
512
+ /**
513
+ * 点击播放时:读界面值并写回规范化结果,得到本轮「相邻两步 DAG 更新」之间的延时(ms)。
514
+ * - `step`:固定间隔。
515
+ * - `total`:`totalS` 按**整段 DAG 步数**均分间隔,与「从头回放」相同(`fullStepCount - 1` 段);不管当前 `dagPlaybackNextIndex`。首步立即执行,与末 token dwell 无关。
516
+ */
517
+ function resolveDagPlaybackStepDelayMsOnPlay(fullStepCount: number): number {
518
+ if (currentDagReplayPacingMode() === 'step') {
519
+ const raw = parseInt(dagPlaybackStepMsInput?.value ?? '', 10);
520
+ const ms = Number.isFinite(raw)
521
+ ? clampDagPlaybackStepMs(raw)
522
+ : readStoredDagPlaybackStepMs();
523
+ if (dagPlaybackStepMsInput) dagPlaybackStepMsInput.value = String(ms);
524
+ return ms;
525
+ }
526
+
527
+ const rawS = parseInt(dagPlaybackTotalSInput?.value ?? '', 10);
528
+ const totalS = Number.isFinite(rawS)
529
+ ? clampDagPlaybackTotalS(rawS)
530
+ : readStoredDagPlaybackTotalS();
531
+ if (dagPlaybackTotalSInput) dagPlaybackTotalSInput.value = String(totalS);
532
+
533
+ const transitionCount = Math.max(0, fullStepCount - 1);
534
+ if (transitionCount <= 0) return 0;
535
+ return Math.round((totalS * 1000) / transitionCount);
536
  }
537
 
538
  function stopDagPlayback(): void {
 
562
  dagHandle.reset(true);
563
  dagPlaybackNextIndex = 0;
564
  }
565
+ const stepDelayMs = resolveDagPlaybackStepDelayMsOnPlay(steps.length);
566
  dagHandle.setDagPlaybackPlaying(true);
567
 
568
+ /** 相邻两步「理想触发」之间的名义间隔;与 {@link resolveDagPlaybackStepDelayMsOnPlay} 一致。 */
569
+ let nextDue = performance.now();
570
+
571
  const isStalePlaybackHandle = (): boolean => {
572
  if (runnerHandle === h) return false;
573
  dagPlaybackTimer = null;
 
582
  dagHandle.setDagPlaybackPlaying(false);
583
  };
584
 
585
+ /**
586
+ * 步间节拍:理想时刻 `nextDue` 每次前进 `stepDelayMs`,实际等待 `max(0, nextDue - now)`。
587
+ * 若已迟到(`delay === 0`),则 `nextDue = now + stepDelayMs` 重锚,避免长时间暂停 / 后台节流后连发多步。
588
+ */
589
+ const scheduleNextPlaybackTick = (): void => {
590
+ const now = performance.now();
591
+ nextDue += stepDelayMs;
592
+ let delay = Math.max(0, nextDue - now);
593
+ if (delay === 0) {
594
+ nextDue = now + stepDelayMs;
595
+ }
596
  dagPlaybackTimer = setTimeout(() => {
597
  dagPlaybackTimer = null;
598
  if (isStalePlaybackHandle()) return;
599
+ tick();
600
+ }, delay);
601
  };
602
 
603
  const tick = (): void => {
 
622
  });
623
  return;
624
  }
625
+ scheduleNextPlaybackTick();
626
  };
627
  tick();
628
  }