@my-life-buddies/cli 0.13.3 → 0.14.1

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.
package/dist/web/app.css CHANGED
@@ -326,7 +326,8 @@
326
326
  .empty-state p { max-width: 30rem; margin: 0; color: var(--color-ink-soft); font-size: var(--text-sm); line-height: 1.65; }
327
327
 
328
328
  .waterfall { display: grid; gap: var(--space-md); margin-block-start: var(--space-xl); padding-block-end: var(--space-lg); border-block-end: var(--rule-hair) solid var(--color-rule); }
329
- .waterfall-row { display: grid; grid-template-columns: 6rem minmax(5rem, 1fr) 4.5rem; align-items: center; gap: var(--space-md); font-size: var(--text-xs); }
329
+ .timing-heading { display:flex; justify-content:space-between; flex-wrap:wrap; gap:6px 16px; color:var(--color-muted); font-size:11px; line-height:1.7; }
330
+ .waterfall-row { display: grid; grid-template-columns: 6rem minmax(2rem, 1fr) 8.5rem; align-items: center; gap: var(--space-md); font-size: var(--text-xs); }
330
331
  .waterfall-track { height: 0.375rem; overflow: hidden; border-radius: var(--radius-pill); background: var(--color-paper-soft); }
331
332
  .waterfall-fill { display: block; height: 100%; min-width: var(--space-2xs); transform-origin: left; border-radius: inherit; background: var(--color-ink); }
332
333
  .waterfall-row strong { font-family: var(--font-mono); font-size: 0.6875rem; font-weight: 500; text-align: end; }
@@ -553,20 +554,21 @@ body[data-hosted-preview] .hosted-page-heading p { display:flex; align-items:cen
553
554
  body[data-hosted-preview] .hosted-page-heading p span:first-child { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; max-width:60%; }
554
555
  body[data-hosted-preview] .hosted-page-heading h1 { margin:0; font-size:26px; line-height:1.3; letter-spacing:-.035em; font-weight:650; }
555
556
  body[data-hosted-preview] .local-connection { border-top:1px solid var(--color-rule); }
556
- body[data-hosted-preview] .debugger > .sidebar { flex:none; position:static; width:auto; padding:0 20px; border:0; border-bottom:1px solid var(--color-rule); }
557
+ /* Hosted tabs are normal flex content, not the standalone sticky sidebar. */
558
+ body[data-hosted-preview] .debugger > .sidebar { flex:none; position:static; z-index:auto; width:auto; padding:0 20px; border:0; border-bottom:1px solid var(--color-rule); }
557
559
  body[data-hosted-preview] .sidebar-section { margin:0; }
558
560
  body[data-hosted-preview] .debug-tabs { display:flex; flex-wrap:nowrap; gap:20px; margin:0; }
559
561
  body[data-hosted-preview] .debug-tabs button { width:auto; min-height:44px; padding:10px 0; font-size:13px; gap:7px; border-radius:0; white-space:nowrap; }
560
562
  body[data-hosted-preview] .debug-tabs button svg { width:15px; height:15px; }
561
563
  body[data-hosted-preview] .debug-tabs button[aria-selected="true"] { background:transparent; box-shadow:inset 0 -2px var(--color-accent); }
562
564
  body[data-hosted-preview] .run-strip { flex:none; padding:18px 20px 14px; border-bottom:1px solid var(--color-rule); }
563
- body[data-hosted-preview] .run-heading { align-items:center; flex-wrap:nowrap; gap:16px; }
565
+ body[data-hosted-preview] .run-heading { align-items:center; flex-wrap:wrap; gap:16px; }
564
566
  body[data-hosted-preview] .run-identity { flex:1; }
565
567
  body[data-hosted-preview] .run-identity > .eyebrow { display:none; }
566
568
  body[data-hosted-preview] .run-title-line { margin:0; flex-wrap:nowrap; gap:8px; }
567
569
  body[data-hosted-preview] .run-title-line h2 { font-size:17px; letter-spacing:0; }
568
570
  body[data-hosted-preview] .status-pill { font-size:11px; padding:3px 7px; }
569
- body[data-hosted-preview] .run-controls { flex:0 1 270px; gap:6px; }
571
+ body[data-hosted-preview] .run-controls { flex:0 1 340px; min-width:min(100%,340px); max-width:100%; gap:6px; }
570
572
  body[data-hosted-preview] .run-picker-label { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
571
573
  body[data-hosted-preview] :is(.run-picker,.run-step) { min-height:30px; border-radius:6px; font-size:11px; padding-block:4px; }
572
574
  body[data-hosted-preview] .run-step { width:30px; }
@@ -594,7 +596,7 @@ body[data-hosted-preview] .run-summary-copy { margin:16px 0 0; font-size:13px; l
594
596
  .run-progress li[data-stage="active"] { border-color:var(--color-accent); color:var(--color-accent); font-weight:600; }
595
597
  .run-progress li[data-stage="error"] { border-color:var(--color-error); color:var(--color-error); }
596
598
  body[data-hosted-preview] .waterfall { margin:0; padding:0; border:0; gap:12px; }
597
- body[data-hosted-preview] .waterfall-row { grid-template-columns:70px minmax(0,1fr) 64px; gap:16px; font-size:12px; }
599
+ body[data-hosted-preview] .waterfall-row { grid-template-columns:84px minmax(0,1fr) 132px; gap:12px; font-size:12px; }
598
600
  body[data-hosted-preview] .waterfall-track { height:3px; background:transparent; }
599
601
  body[data-hosted-preview] .waterfall-fill { min-width:0; background:var(--color-accent); opacity:.45; }
600
602
  body[data-hosted-preview] .workbench-state { flex:none; padding:26px 24px; border-bottom:1px solid var(--color-rule); }
@@ -602,7 +604,7 @@ body[data-hosted-preview] .workbench-state h2 { font-size:18px; font-weight:600;
602
604
  body[data-hosted-preview] .workbench-state p { max-width:480px; color:var(--color-muted); font-size:13px; line-height:1.8; margin:0; }
603
605
  body[data-hosted-preview] .state-mark { display:block; width:32px; height:3px; margin-bottom:20px; background:var(--color-accent); border-radius:2px; }
604
606
  body[data-hosted-preview][data-has-runs="false"] .run-strip { display:none; }
605
- body[data-hosted-preview][data-has-runs="false"] #panel-overview { display:none; }
607
+
606
608
  body[data-hosted-preview][data-dev-phase="disconnected"][data-has-runs="false"] .workbench-state { padding-top:56px; padding-bottom:56px; }
607
609
  body[data-hosted-preview][data-dev-phase="disconnected"] .topbar-actions { display:none; }
608
610
  body[data-hosted-preview][data-dev-phase="disconnected"] :is(.run-progress li,.replying-indicator i) { animation-play-state:paused; }
@@ -660,3 +662,60 @@ body[data-hosted-preview] .pairing-panel { top:80px; }
660
662
  body[data-hosted-preview] .run-controls { flex-basis:100%; }
661
663
  body[data-hosted-preview] .simulator-area { padding-inline:12px; }
662
664
  }
665
+
666
+ /* Runtime diagnostics share the existing Preview typography and semantic colors. */
667
+ .diagnostic-source { flex:none; color:var(--color-muted); font-size:11px; }
668
+ .diagnostic-filters { display:flex; flex-wrap:wrap; gap:12px; margin:20px 0 12px; }
669
+ .diagnostic-filters label { display:flex; flex:1 1 130px; min-width:0; flex-direction:column; gap:6px; color:var(--color-ink-soft); font-size:11px; }
670
+ .diagnostic-filters .diagnostic-search { flex:1.5 1 160px; }
671
+ .diagnostic-filters :is(select,input) { width:100%; min-width:0; min-height:36px; padding:7px 10px; border:1px solid var(--color-rule-strong); border-radius:6px; background:var(--color-paper-raised); color:var(--color-ink); font:inherit; font-size:12px; }
672
+ .diagnostic-filters :is(select,input):focus-visible { outline:2px solid var(--color-accent); outline-offset:2px; }
673
+ .diagnostic-note { color:var(--color-muted); font-size:11px; line-height:1.8; margin:12px 0 16px; }
674
+ .diagnostic-entry { border-bottom:1px solid var(--color-rule); min-width:0; }
675
+ .diagnostic-entry > summary { display:flex; align-items:start; gap:10px; padding:16px 0; cursor:pointer; list-style:none; }
676
+ .diagnostic-entry > summary::-webkit-details-marker { display:none; }
677
+ .diagnostic-entry > summary::after { content:'›'; color:var(--color-muted); font-size:18px; line-height:20px; flex:none; }
678
+ .diagnostic-entry[open] > summary::after { transform:rotate(90deg); }
679
+ .diagnostic-entry > summary:hover .diagnostic-heading strong { color:var(--color-accent); }
680
+ .diagnostic-entry > summary:focus-visible { outline:2px solid var(--color-accent); outline-offset:3px; border-radius:3px; }
681
+ .diagnostic-heading { display:flex; flex-direction:column; gap:5px; min-width:0; flex:1; }
682
+ .diagnostic-heading strong { font-size:13px; font-weight:550; line-height:1.5; overflow-wrap:anywhere; }
683
+ .diagnostic-preview { color:var(--color-muted); font-size:12px; line-height:1.6; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
684
+ .diagnostic-badge { flex:none; color:var(--color-ink-soft); background:var(--color-paper-soft); border-radius:4px; padding:3px 5px; min-width:36px; font:10px/1.4 var(--font-mono); text-align:center; }
685
+ .diagnostic-badge[data-kind="response"] { color:var(--color-accent); background:var(--color-accent-soft, var(--color-paper-soft)); }
686
+ .diagnostic-entry[data-level="error"] > summary .diagnostic-badge { color:var(--color-error); background:var(--color-error-soft); }
687
+ .diagnostic-entry[data-level="warn"] > summary .diagnostic-badge { color:var(--color-warning); background:var(--color-warning-soft); }
688
+ .diagnostic-entry time { color:var(--color-muted); font:11px/1.8 var(--font-mono); white-space:nowrap; }
689
+ .diagnostic-body { padding:0 0 18px; }
690
+ .diagnostic-meta { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:4px 12px; color:var(--color-muted); font-size:11px; margin:0 0 12px; }
691
+ .diagnostic-meta > span { overflow-wrap:anywhere; min-width:0; }
692
+ .diagnostic-meta button { font-size:11px; }
693
+ .diagnostic-section { border:1px solid var(--color-rule); border-radius:6px; margin:8px 0 0; overflow:hidden; }
694
+ .diagnostic-section > summary { cursor:pointer; padding:10px 12px; font-size:12px; color:var(--color-ink-soft); }
695
+ .diagnostic-section[open] > summary { border-bottom:1px solid var(--color-rule); }
696
+ .diagnostic-section pre { margin:0; padding:14px; max-height:420px; overflow:auto; white-space:pre-wrap; overflow-wrap:anywhere; font:11px/1.85 var(--font-mono); background:var(--color-paper-soft); tab-size:2; }
697
+ .diagnostic-metrics { display:flex; flex-wrap:wrap; gap:16px 24px; margin:18px 0; }
698
+ .diagnostic-metrics div { min-width:70px; }
699
+ .diagnostic-metrics dt { color:var(--color-muted); font-size:10px; margin-bottom:6px; }
700
+ .diagnostic-metrics dd { margin:0; font:12px var(--font-mono); color:var(--color-ink); }
701
+ .metric-help { max-width:220px; margin:6px 0 0; color:var(--color-muted); font:11px/1.7 var(--font-body); white-space:normal; overflow-wrap:anywhere; }
702
+ .run-metrics dd:has(.metric-help) { overflow:visible; white-space:normal; }
703
+ .diagnostic-warning { color:var(--color-error); background:var(--color-error-soft); padding:10px 12px; font-size:12px; line-height:1.8; border-radius:6px; overflow-wrap:anywhere; }
704
+ .diagnostic-empty { padding:44px 0 52px; }
705
+ .diagnostic-empty strong { font-size:15px; font-weight:550; }
706
+ .diagnostic-empty p { max-width:420px; color:var(--color-muted); font-size:12px; line-height:1.9; }
707
+ .diagnostic-explanation { margin-top:24px; color:var(--color-muted); font-size:11px; line-height:1.9; }
708
+ .diagnostic-explanation summary { cursor:pointer; }
709
+ .diagnostic-explanation p { margin:10px 0; }
710
+ .diagnostic-history { margin-top:24px; padding-top:16px; border-top:1px solid var(--color-rule); }
711
+ .diagnostic-history > summary { cursor:pointer; font-size:13px; color:var(--color-ink-soft); }
712
+ .diagnostic-history #context-source { font-size:10px; margin-left:8px; color:var(--color-muted); }
713
+ #panel-overview .run-strip, body[data-hosted-preview] #panel-overview .run-strip { padding:0 0 16px; }
714
+ #panel-protocol .protocol-log { display:block; border:0; }
715
+ @media (max-width:650px) {
716
+ .diagnostic-filters { gap:10px; }
717
+ .diagnostic-filters .diagnostic-search { flex-basis:100%; }
718
+ .diagnostic-entry > summary { gap:7px; }
719
+ .diagnostic-entry time { font-size:10px; }
720
+ .diagnostic-source { display:none; }
721
+ }
package/dist/web/app.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const connectionUrl = hosted ? new URL(`../api/buddies/${encodeURIComponent(hostedBuddyId)}/preview`, location.href) : undefined;
7
7
  let connectionInstance = "", connectionReady = !hosted, connectionBusy = false, devPollBusy = false;
8
8
  let connectionChecked = false, connectionFailure = "", devAction = "";
9
+ let eventSource, eventSourceInstance = "", liveEventsActive = false, pollingInterval = 0;
9
10
  function previewApiUrl(path) {
10
11
  if (!hosted) return path;
11
12
  const url = new URL(connectionUrl.href + path);
@@ -271,8 +272,8 @@
271
272
  elements.resetConversation.disabled = phase !== "ready" || Boolean(active) || state.resetting || state.sending || state.resetUnavailable;
272
273
  elements.resetConversation.title = state.resetUnavailable ? "当前平台暂不支持重开会话" : active ? "请等待当前回复完成后再清空" : "结束当前开发版对话,开始一段全新会话";
273
274
  elements.devControl.disabled ||= state.resetting;
274
- elements.messageInput.disabled = phase !== "ready" || !state.simulatorLoaded || Boolean(active) || state.resetting || state.sending;
275
- elements.sendMessage.hidden = Boolean(active);
275
+ elements.messageInput.disabled = phase !== "ready" || !state.simulatorLoaded || state.resetting || state.sending;
276
+ elements.sendMessage.hidden = false;
276
277
  updateSendButton();
277
278
  syncPolling();
278
279
  if (
@@ -304,10 +305,10 @@
304
305
  : phase === "failed" ? ["调试未能启动", "查看上方错误信息,修复工程后点击「重试启动」。"]
305
306
  : phase === "idle" ? ["工程已连接", "点击上方「启动 DEV」,开始体验搭子。"]
306
307
  : !state.simulatorLoaded ? ["正在读取对话", "同步当前会话,完成后即可发送消息。"]
307
- : state.sending ? ["正在发送消息", "提交成功后,这里会显示本次执行过程。"]
308
- : active ? ["等待执行记录", "消息已提交,正在同步本次执行过程。"]
309
- : ["开始一轮调试", history ? "右侧保留了历史对话。本地执行记录从本次连接开始,发一条新消息即可查看。" : "在右侧发一条消息,观察搭子的回复与执行过程。"];
310
- guide.hidden = phase === "ready" && runs.length > 0;
308
+ : state.sending ? ["正在发送消息", "提交成功后,这里会显示消息与模型日志。"]
309
+ : active ? ["等待执行记录", "消息已提交,正在同步消息与模型日志。"]
310
+ : ["开始一轮调试", history ? "右侧保留了历史对话。本地调试记录从本次连接开始,发一条新消息即可查看。" : "在右侧发一条消息,观察搭子的回复与模型日志。"];
311
+ guide.hidden = phase === "ready" && (runs.length > 0 || (state.debug.diagnostics?.records || []).some(record => record.kind !== "log"));
311
312
  $("#workbench-state-title").textContent = copy[0];
312
313
  $("#workbench-state-copy").textContent = copy[1];
313
314
  $("#connection-description").textContent = !connectionChecked ? "检查中" : phase === "disconnected" ? "等待连接" : "仅用于本地调试";
@@ -321,7 +322,7 @@
321
322
  if (!state.project) elements.buddyName.textContent = new URL(location.href).searchParams.get("buddyName") || "我的搭子";
322
323
  const empty = elements.chatMessages.querySelector(".chat-empty");
323
324
  if (empty) empty.textContent = phase === "ready" ? "发一条消息,开始体验" : phase === "disconnected" ? "连接后,在这里体验搭子的回复" : "启动 DEV 后,开始对话";
324
- elements.messageInput.placeholder = active ? "等待当前回复完成" : phase === "ready" ? "给搭子发一条消息" : "等待调试就绪";
325
+ elements.messageInput.placeholder = active ? "可以继续补充消息" : phase === "ready" ? "给搭子发一条消息" : "等待调试就绪";
325
326
  }
326
327
 
327
328
  function runOrdinal(run, index, runs) {
@@ -332,7 +333,7 @@
332
333
 
333
334
  function runOptionLabel(run, index, runs) {
334
335
  const date = dayKey(run.startedAt) === dayKey(new Date()) ? "" : `${dayLabel(run.startedAt) || ""} `;
335
- return `第 ${runOrdinal(run, index, runs)} 轮 · ${date}${formatTime(run.startedAt)} · ${runStatusCopy(run.status)}`;
336
+ return `第 ${runOrdinal(run, index, runs)} 轮 · ${date}${formatTime(run.startedAt)} · ${(run.completion === "no_reply" && run.status === "completed" ? "未观察到回复" : runStatusCopy(run.status))}`;
336
337
  }
337
338
 
338
339
  let runPickerSignature = "";
@@ -372,10 +373,10 @@
372
373
  elements.runId.title = run?.runId || "";
373
374
  elements.copyRunId.disabled = !run;
374
375
  elements.runStatus.dataset.status = run?.status || "idle";
375
- elements.runStatus.textContent = runStatusCopy(run?.status || "idle");
376
- elements.metricAccept.textContent = formatDuration(run?.acceptLatencyMs);
377
- elements.metricFirst.textContent = formatDuration(run?.firstTextLatencyMs);
378
- elements.metricResponse.textContent = formatDuration(run?.responseTimeMs);
376
+ elements.runStatus.textContent = run?.completion === "no_reply" && run.status === "completed" ? "未观察到回复" : runStatusCopy(run?.status || "idle");
377
+ elements.metricAccept.textContent = formatElapsedSeconds(run?.acceptLatencyMs);
378
+ elements.metricFirst.textContent = formatElapsedSeconds(run?.firstTextLatencyMs);
379
+ elements.metricResponse.textContent = run?.completion === "no_reply" ? "未观察到回复" : formatElapsedSeconds(replyEndLatency(run));
379
380
  elements.metricMessages.textContent = state.simulatorLoaded ? String(state.simulator.messages?.length || 0) : "读取中";
380
381
  renderOverview(run);
381
382
  renderTimeline(run);
@@ -405,6 +406,18 @@
405
406
  }
406
407
  }
407
408
 
409
+ function formatElapsedSeconds(value) {
410
+ return Number.isFinite(value) ? `${Number((value / 1000).toFixed(3))} 秒` : "未记录";
411
+ }
412
+
413
+ function replyEndLatency(run) {
414
+ if (run?.status !== "completed") return undefined;
415
+ // updatedAt records Preview observing the terminal state. responseTimeMs and
416
+ // completedAt use the platform message timestamp and are not reply-end timings.
417
+ const elapsed = Date.parse(run.updatedAt) - Date.parse(run.startedAt);
418
+ return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : undefined;
419
+ }
420
+
408
421
  function renderOverview(run) {
409
422
  elements.overviewEmpty.hidden = Boolean(run);
410
423
  elements.overviewContent.hidden = !run;
@@ -421,16 +434,19 @@
421
434
  ["消息已提交", "done"],
422
435
  ["等待回复", hasText || finished ? "done" : "active"],
423
436
  [finished && !hasText ? "未记录流式输出" : "生成回复", hasText ? (finished ? "done" : "active") : finished ? "skipped" : "waiting"],
424
- [run.status === "failed" ? "执行失败" : run.status === "cancelled" ? "已停止" : noReply ? "未产生回复" : "完成", finished ? (interrupted ? "error" : "done") : "waiting"],
437
+ [run.status === "failed" ? "执行失败" : run.status === "cancelled" ? "已停止" : noReply ? "未观察到回复" : "完成", finished ? (interrupted ? "error" : "done") : "waiting"],
425
438
  ];
426
439
  if (interrupted) stages.forEach(stage => { if (stage[1] === "active") stage[1] = "error"; });
427
440
  for (const [label, status] of stages) progress.append(node("li", { "data-stage": status }, label));
428
441
  }
429
442
  clear(elements.waterfall);
430
443
  // Only report observed timings. A finished run with missing telemetry never keeps counting.
431
- const timingRows = [["消息接收", run.acceptLatencyMs], ["首段文字", run.firstTextLatencyMs], ["总耗时", run.responseTimeMs]];
444
+ const timingRows = [["消息被接收", run.acceptLatencyMs], ["首字耗时", run.firstTextLatencyMs], ["回复结束", noReply ? undefined : replyEndLatency(run)]];
432
445
  const maximum = Math.max(1, ...timingRows.map(([,value]) => Number.isFinite(value) ? value : 0));
433
446
  elements.waterfall.hidden = !timingRows.some(([,value]) => Number.isFinite(value));
447
+ const timingHeading = node("div", { class: "timing-heading" });
448
+ timingHeading.append(node("span", {}, "起点:Preview 发起发送(0 秒)"), node("span", {}, "累计时间,不可相加"));
449
+ elements.waterfall.append(timingHeading);
434
450
  for (const [label, duration] of timingRows) {
435
451
  if (!Number.isFinite(duration)) continue;
436
452
  const row = node("div", { class: "waterfall-row" });
@@ -439,26 +455,29 @@
439
455
  const fill = node("span", { class: "waterfall-fill" });
440
456
  fill.style.transform = `scaleX(${duration / maximum})`;
441
457
  track.append(fill);
442
- row.append(track, node("strong", {}, formatDuration(duration)));
458
+ row.append(track, node("strong", {}, `发送后 ${formatElapsedSeconds(duration)}`));
443
459
  elements.waterfall.append(row);
444
460
  }
445
461
  elements.runSummaryCopy.textContent = run.status === "failed"
446
- ? "本轮执行失败。查看执行时间线定位问题,或展开协议日志中的错误详情。"
462
+ ? "本轮执行失败。请查看运行日志中的错误详情。"
447
463
  : run.status === "cancelled" ? "本轮已停止。已完成的平台写入不会自动撤销。"
448
- : noReply ? "本轮已结束,没有产生新的完整回复。"
449
- : run.status === "completed" ? (measuredFirstText ? `回复已完成,首段文字耗时 ${formatDuration(run.firstTextLatencyMs)}。` : "回复已完成,本轮未记录首段文字耗时。")
464
+ : noReply ? "回复状态已结束,未观察到新的可见回复;不据此判断执行是否成功。"
465
+ : run.status === "completed" ? (measuredFirstText ? "" : "本轮未记录首字耗时。")
450
466
  : run.status === "cancel_requested" ? "正在停止,结束后可以继续发送消息。"
451
467
  : hasText ? "正在生成回复,右侧同步展示输出。" : "消息已提交,等待搭子开始回复。";
468
+ elements.runSummaryCopy.hidden = !elements.runSummaryCopy.textContent;
452
469
  }
453
470
 
454
471
  let timelineSignature = "", contextSignature = "", protocolSignature = "";
455
472
  const expandedLogs = new Map();
473
+ const diagnostics = window.createPreviewDiagnostics?.({ node, formatTime, formatDuration, toast, showError });
474
+ diagnostics?.update(state.debug);
456
475
  function renderTimeline(run) {
457
476
  const signature = JSON.stringify([run?.runId, state.debug.events]);
458
477
  if (signature === timelineSignature) return;
459
478
  timelineSignature = signature;
460
479
  clear(elements.timelineList);
461
- const events = (state.debug.events || []).filter((event) => !run || event.runId === run.runId);
480
+ const events = (state.debug.events || []).filter((event) => event.type !== "run.output.delta" && (!run || event.runId === run.runId));
462
481
  if (!events.length) {
463
482
  elements.timelineList.append(node("li", { class: "timeline-empty" }, run ? "这个本地回合暂无可见事件。" : "发一条消息后,执行事件会出现在这里。"));
464
483
  return;
@@ -496,6 +515,7 @@
496
515
  }
497
516
 
498
517
  function renderProtocol() {
518
+ if (diagnostics) { diagnostics.update(state.debug); return; }
499
519
  const signature = JSON.stringify(state.debug.events);
500
520
  if (signature === protocolSignature) return;
501
521
  protocolSignature = signature;
@@ -540,15 +560,22 @@
540
560
 
541
561
  let messageSignature = "";
542
562
  let followMessages = true;
543
- let liveReply, liveReplyText = "";
563
+ let liveReply, liveReplyBubble, liveReplyText = "";
544
564
  function renderLiveReply() {
545
565
  if (!liveReply) return;
546
566
  const text = state.simulator.streaming?.runId === state.simulator.activeRun?.runId ? state.simulator.streaming?.text || "" : "";
547
567
  if (text === liveReplyText) return;
548
568
  liveReplyText = text;
549
- clear(liveReply);
550
- if (text) { liveReply.className = "message-content"; liveReply.append(node("div", { class: "message-bubble", "aria-label": "搭子正在回复" }, text)); }
569
+ if (text) {
570
+ if (!liveReplyBubble) {
571
+ clear(liveReply);
572
+ liveReplyBubble = node("div", { class: "message-bubble", "aria-label": "搭子正在回复" });
573
+ liveReplyBubble.append(document.createTextNode(text));
574
+ liveReply.append(liveReplyBubble);
575
+ } else liveReplyBubble.firstChild.data = text;
576
+ }
551
577
  else {
578
+ clear(liveReply); liveReplyBubble = undefined;
552
579
  const indicator = node("div", { class: "replying-indicator", "aria-label": "搭子正在准备回复" });
553
580
  indicator.append(node("i"), node("i"), node("i")); liveReply.append(indicator);
554
581
  }
@@ -568,7 +595,7 @@
568
595
  messageSignature = signature;
569
596
  const previousTop = elements.chatMessages.scrollTop;
570
597
  clear(elements.chatMessages);
571
- liveReply = undefined; liveReplyText = "";
598
+ liveReply = undefined; liveReplyBubble = undefined; liveReplyText = "";
572
599
  const messages = state.simulator.messages || [];
573
600
  if (!messages.length && !state.simulator.activeRun) {
574
601
  elements.chatMessages.append(node("div", { class: "chat-empty", "aria-label": "暂无消息" }, hosted ? (state.dev.phase === "ready" ? "发一条消息,开始体验" : "启动 DEV 后,开始对话") : ""));
@@ -645,9 +672,14 @@
645
672
  }
646
673
 
647
674
  function syncPolling() {
675
+ const interval = liveEventsActive ? 30000 : 1200;
676
+ if (state.polling !== undefined && pollingInterval !== interval) {
677
+ window.clearInterval(state.polling); state.polling = undefined;
678
+ }
648
679
  if (connectionReady && state.dev.phase === "ready" && state.polling === undefined) {
649
680
  void refreshSimulator();
650
- state.polling = window.setInterval(() => void refreshSimulator(), 1200);
681
+ pollingInterval = interval;
682
+ state.polling = window.setInterval(() => void refreshSimulator(), interval);
651
683
  } else if ((!connectionReady || state.dev.phase !== "ready") && state.polling !== undefined) {
652
684
  window.clearInterval(state.polling);
653
685
  state.polling = undefined;
@@ -675,7 +707,7 @@
675
707
  }
676
708
 
677
709
  function updateSendButton() {
678
- const ready = connectionReady && !devAction && state.simulatorLoaded && state.dev.phase === "ready" && !state.simulator.activeRun && !state.resetting && !state.sending;
710
+ const ready = connectionReady && !devAction && state.simulatorLoaded && state.dev.phase === "ready" && !state.resetting && !state.sending;
679
711
  elements.sendMessage.disabled = !ready || !elements.messageInput.value.trim();
680
712
  }
681
713
 
@@ -684,7 +716,7 @@
684
716
  async function sendMessage(event) {
685
717
  event.preventDefault();
686
718
  const text = elements.messageInput.value;
687
- if (!text.trim() || !state.simulatorLoaded || devAction || state.dev.phase !== "ready" || state.simulator.activeRun || state.resetting || state.sending) return;
719
+ if (!text.trim() || !state.simulatorLoaded || devAction || state.dev.phase !== "ready" || state.resetting || state.sending) return;
688
720
  state.sending = true;
689
721
  elements.errorBanner.hidden = true;
690
722
  followLatestMessages();
@@ -706,10 +738,10 @@
706
738
  pendingSend = undefined;
707
739
  elements.messageInput.value = "";
708
740
  resizeComposer();
709
- state.simulator.activeRun = { runId: accepted.runId, status: "pending" };
741
+ if (state.simulator.latestRun?.runId !== accepted.runId) state.simulator.activeRun = { runId: accepted.runId, status: "pending" };
710
742
  renderDev();
711
743
  renderMessages();
712
- await refreshSimulator();
744
+ void refreshSimulator();
713
745
  } catch (cause) {
714
746
  showError(cause);
715
747
  } finally {
@@ -774,6 +806,7 @@
774
806
  }
775
807
 
776
808
  async function copyProtocol() {
809
+ if (diagnostics) return diagnostics.copyLogs();
777
810
  const text = (state.debug.events || []).map((event) => [
778
811
  event.timestamp,
779
812
  event.channel,
@@ -792,6 +825,8 @@
792
825
 
793
826
  function applySimulatorState(next) {
794
827
  if ((next.conversationRevision || 0) < (state.simulator.conversationRevision || 0)) return false;
828
+ if (Number.isFinite(next.stateRevision) && Number.isFinite(state.simulator.stateRevision)
829
+ && next.stateRevision < state.simulator.stateRevision) return false;
795
830
  state.simulator = next;
796
831
  state.simulatorLoaded = true;
797
832
  return true;
@@ -831,8 +866,20 @@
831
866
  }
832
867
 
833
868
  function connectEvents() {
834
- const events = new EventSource("/api/events");
869
+ if (eventSource && eventSourceInstance === connectionInstance) return;
870
+ closeEvents();
871
+ const events = new EventSource(previewApiUrl("/api/events"));
872
+ eventSource = events; eventSourceInstance = connectionInstance;
873
+ events.addEventListener("open", () => {
874
+ if (eventSource !== events) return;
875
+ liveEventsActive = true; syncPolling();
876
+ });
877
+ events.addEventListener("error", () => {
878
+ if (eventSource !== events) return;
879
+ liveEventsActive = false; syncPolling();
880
+ });
835
881
  events.addEventListener("dev.state", (event) => {
882
+ if (eventSource !== events) return;
836
883
  try {
837
884
  if (applyDevState(JSON.parse(event.data))) { renderDev(); renderWidgets(); }
838
885
  } catch {
@@ -840,12 +887,15 @@
840
887
  }
841
888
  });
842
889
  events.addEventListener("simulator.state", (event) => {
890
+ if (eventSource !== events || state.resetting) return;
843
891
  try {
844
892
  const value = JSON.parse(event.data);
893
+ const before = JSON.stringify([state.simulator.activeRun, Boolean(state.simulator.streaming?.text), state.simulatorLoaded]);
845
894
  if (!applySimulatorState(value.state)) return;
846
- state.debug = value.debug;
847
- renderDev();
848
- renderDebug();
895
+ if (value.debug) state.debug = value.debug;
896
+ const after = JSON.stringify([state.simulator.activeRun, Boolean(state.simulator.streaming?.text), state.simulatorLoaded]);
897
+ if (before !== after) renderDev();
898
+ if (value.debug) renderDebug();
849
899
  renderMessages();
850
900
  } catch {
851
901
  // Polling remains the reconciliation path for a malformed or missed local event.
@@ -853,6 +903,11 @@
853
903
  });
854
904
  }
855
905
 
906
+ function closeEvents() {
907
+ eventSource?.close(); eventSource = undefined; eventSourceInstance = ""; liveEventsActive = false;
908
+ }
909
+ window.addEventListener("pagehide", closeEvents);
910
+
856
911
  async function boot() {
857
912
  if (hosted && !connectionReady) { void refreshConnection(); return; }
858
913
  try {
@@ -877,6 +932,7 @@
877
932
  }
878
933
 
879
934
  function disconnected(message = "未连接本地工程") {
935
+ closeEvents();
880
936
  connectionReady = false;
881
937
  state.dev = { phase: "idle", revision: -1 };
882
938
  state.simulatorRequest++;
@@ -898,6 +954,7 @@
898
954
  $("#copy-connect-command").disabled = false;
899
955
  if (!value.connected) { disconnected(); return; }
900
956
  const changed = connectionInstance !== value.instanceId;
957
+ if (changed) closeEvents();
901
958
  const newlyConnected = !connectionReady || changed;
902
959
  connectionInstance = value.instanceId;
903
960
  connectionReady = true;
@@ -918,6 +975,7 @@
918
975
  renderDev();
919
976
  await boot();
920
977
  }
978
+ if (value.liveEvents) connectEvents();
921
979
  } catch (cause) {
922
980
  const message = cause.message || "连接已中断,正在重试…";
923
981
  connectionChecked = true; connectionFailure = message;
@@ -932,7 +990,8 @@
932
990
  devPollBusy = true;
933
991
  try {
934
992
  const [dev, debug] = await Promise.all([api("/api/dev"), api("/api/debug")]);
935
- applyDevState(dev.state); state.debug = debug.debug; renderAll();
993
+ if (applyDevState(dev.state)) renderDev();
994
+ state.debug = debug.debug; renderDebug();
936
995
  } catch (cause) {
937
996
  // Polling conflicts are connection transitions, already explained by the workbench state.
938
997
  if (!connectionReady || cause?.status === 409) void refreshConnection();
@@ -0,0 +1,184 @@
1
+ (() => {
2
+ "use strict";
3
+ window.createPreviewDiagnostics = ({ node, formatTime, formatDuration, toast, showError }) => {
4
+ const $ = id => document.getElementById(id);
5
+ const models = $("model-records"), logs = $("protocol-log");
6
+ const modelConversation = $("models-conversation"), logConversation = $("logs-conversation");
7
+ const kind = $("models-kind"), level = $("logs-level");
8
+ const modelSearch = $("models-search"), logSearch = $("logs-search");
9
+ let snapshot = {}, conversation = "", visibleLogs = [];
10
+ const cached = new Map(), expanded = new Map();
11
+ const object = value => value && typeof value === "object" && !Array.isArray(value) ? value : {};
12
+ const json = value => typeof value === "string" ? value : JSON.stringify(value, null, 2);
13
+ const number = value => typeof value === "number" && Number.isFinite(value) && value >= 0;
14
+ const metricHelp = {
15
+ "模型响应耗时": "本次模型请求发出至响应结束。",
16
+ "模型首字耗时": "本次模型请求发出至首段输出。",
17
+ "结束原因": "模型停止原因;toolUse 表示请求调用工具。",
18
+ "输入 Token": "本次调用的输入 Token 数。",
19
+ "输出 Token": "本次调用的输出 Token 数。",
20
+ "缓存读取": "本次调用读取缓存的 Token 数。",
21
+ "缓存写入": "本次调用写入缓存的 Token 数。",
22
+ "总 Token": "本次调用上报的 Token 总数。",
23
+ };
24
+ function section(label, value, key) {
25
+ const details = node("details", { class: "diagnostic-section", "data-detail": key });
26
+ details.append(node("summary", {}, label));
27
+ details.append(node("pre", {}, json(value)));
28
+ return details;
29
+ }
30
+ function metrics(pairs) {
31
+ const list = node("dl", { class: "diagnostic-metrics" });
32
+ for (const [label, value] of pairs) {
33
+ const item = node("div");
34
+ const detail = node("dd", {}, value);
35
+ if (metricHelp[label]) {
36
+ detail.append(node("p", { class: "metric-help" }, metricHelp[label]));
37
+ }
38
+ item.append(node("dt", {}, label), detail);
39
+ list.append(item);
40
+ }
41
+ return list;
42
+ }
43
+ async function copy(text) {
44
+ try { await navigator.clipboard.writeText(text); toast("日志已复制"); }
45
+ catch { showError(new Error("浏览器未允许复制,请展开原始日志后手动选择")); }
46
+ }
47
+ function describe(record) {
48
+ const data = object(record.data), payload = object(data.payload);
49
+ if (record.kind === "request") {
50
+ const messages = payload.messages || payload.input;
51
+ const count = Array.isArray(messages) ? `${messages.length} 条上下文` : "请求上下文";
52
+ return `${typeof payload.model === "string" ? payload.model : "模型未上报"} · ${count}${Array.isArray(payload.tools) ? ` · ${payload.tools.length} 个工具` : ""}`;
53
+ }
54
+ const content = Array.isArray(data.content) ? data.content : [];
55
+ const texts = content.filter(item => item?.type === "text").map(item => item.text).filter(text => typeof text === "string");
56
+ const tools = content.filter(item => item?.type === "toolCall").map(item => item.name).filter(name => typeof name === "string");
57
+ return typeof data.error === "string" ? data.error : tools.length ? `请求工具:${tools.join("、")}` : texts.join(" ") || record.title;
58
+ }
59
+ function row(record, model) {
60
+ const data = object(record.data);
61
+ const details = node("details", { class: "diagnostic-entry", "data-record": record.id, "data-detail": "entry" });
62
+ details.dataset.level = data.error || data.stopReason === "error" ? "error" : record.level;
63
+ const summary = node("summary");
64
+ summary.append(node("span", { class: "diagnostic-badge", "data-kind": record.kind }, model ? record.kind === "request" ? "请求" : "响应" : record.level.toUpperCase()));
65
+ const heading = node("span", { class: "diagnostic-heading" });
66
+ heading.append(node("strong", {}, model ? record.kind === "request" ? "模型请求" : "模型响应" : record.title));
67
+ if (model) heading.append(node("span", { class: "diagnostic-preview" }, describe(record)));
68
+ summary.append(heading, node("time", { dateTime: record.timestamp, title: record.timestamp }, formatTime(record.timestamp)));
69
+ details.append(summary);
70
+ const body = node("div", { class: "diagnostic-body" });
71
+ const meta = node("div", { class: "diagnostic-meta" });
72
+ meta.append(node("span", {}, `${record.convoKey || "未标注会话"} · ${record.source}`));
73
+ const button = node("button", { type: "button", class: "text-button" }, "复制原始日志");
74
+ button.addEventListener("click", () => void copy(record.raw));
75
+ meta.append(button); body.append(meta);
76
+ if (record.truncated || (model && !record.data)) {
77
+ body.append(node("p", { class: "diagnostic-warning" }, record.truncated
78
+ ? "日志已截断,无法还原完整数据。请查看原始日志。"
79
+ : "尚未收到完整数据,或日志格式无法解析。已保留原始日志。"));
80
+ }
81
+ if (model && record.data) {
82
+ if (record.kind === "request") {
83
+ const payload = object(data.payload);
84
+ const messages = payload.messages ?? payload.input;
85
+ const system = payload.system ?? (Array.isArray(messages) ? messages.filter(item => ["system", "developer"].includes(item?.role)) : undefined);
86
+ if (system != null && (!Array.isArray(system) || system.length)) body.append(section("系统提示词", system, "system"));
87
+ if (messages != null) body.append(section(`请求上下文${Array.isArray(messages) ? ` · ${messages.length} 条` : ""}`, messages, "messages"));
88
+ if (payload.tools != null) body.append(section(`可用工具${Array.isArray(payload.tools) ? ` · ${payload.tools.length} 个` : ""}`, payload.tools, "tools"));
89
+ const { messages: _messages, input: _input, system: _system, tools: _tools, ...parameters } = payload;
90
+ body.append(section("模型与请求参数", parameters, "parameters"));
91
+ body.append(section("完整请求体", data.payload ?? data, "payload"));
92
+ } else {
93
+ const usage = object(data.usage);
94
+ body.append(metrics([
95
+ ["模型响应耗时", number(data.ms) ? formatDuration(data.ms) : "未上报"],
96
+ ["模型首字耗时", number(data.firstTokenMs) ? formatDuration(data.firstTokenMs) : "未上报"],
97
+ ["结束原因", typeof data.stopReason === "string" ? data.stopReason : "未上报"],
98
+ ]));
99
+ const tokenFields = [["输入 Token", "input"], ["输出 Token", "output"], ["缓存读取", "cacheRead"], ["缓存写入", "cacheWrite"], ["总 Token", "totalTokens"]];
100
+ body.append(metrics(tokenFields.map(([label, key]) => [label, number(usage[key]) ? String(usage[key]) : "未上报"])));
101
+ if (typeof data.error === "string" && data.error) body.append(node("p", { class: "diagnostic-warning" }, data.error));
102
+ const content = Array.isArray(data.content) ? data.content : [];
103
+ content.forEach((block, index) => {
104
+ if (!block || typeof block !== "object") return;
105
+ const label = block.type === "text" ? "响应正文" : block.type === "thinking" ? "思考内容(日志已提供)" : block.type === "toolCall" ? `工具调用请求 · ${block.name || "未命名"}` : `响应片段 · ${block.type || index + 1}`;
106
+ body.append(section(label, block.type === "text" ? block.text : block, `content-${index}`));
107
+ });
108
+ if (data.content != null && !content.length) body.append(section("响应内容", data.content, "content"));
109
+ if (data.usage != null) body.append(section("完整用量", data.usage, "usage"));
110
+ }
111
+ }
112
+ body.append(section("原始日志", record.raw, "raw"));
113
+ details.append(body);
114
+ return details;
115
+ }
116
+ function reconcile(container, records, model, emptyText) {
117
+ const prefix = model ? "model:" : "log:";
118
+ const wanted = new Set();
119
+ records.forEach((record, index) => {
120
+ const key = prefix + record.id;
121
+ wanted.add(key);
122
+ const signature = JSON.stringify(record);
123
+ let entry = cached.get(key);
124
+ if (!entry || entry.signature !== signature) {
125
+ if (entry) expanded.set(key, [...entry.node.querySelectorAll("details"), entry.node].filter(item => item.open).map(item => item.dataset.detail));
126
+ const element = row(record, model);
127
+ for (const item of [...element.querySelectorAll("details"), element]) item.open = (expanded.get(key) || []).includes(item.dataset.detail);
128
+ entry?.node.remove();
129
+ entry = { node: element, signature };
130
+ cached.set(key, entry);
131
+ }
132
+ if (container.children[index] !== entry.node) container.insertBefore(entry.node, container.children[index] || null);
133
+ });
134
+ for (const child of [...container.children]) if (!wanted.has(prefix + child.dataset.record)) child.remove();
135
+ if (!records.length) {
136
+ const empty = node("div", { class: "diagnostic-empty" });
137
+ empty.append(node("strong", {}, emptyText[0]), node("p", {}, emptyText[1]));
138
+ container.append(empty);
139
+ }
140
+ }
141
+ function updateConversations(records) {
142
+ const keys = [...new Set(records.map(record => record.convoKey).filter(Boolean))].sort();
143
+ if (conversation && !keys.includes(conversation)) keys.push(conversation);
144
+ for (const select of [modelConversation, logConversation]) {
145
+ if (document.activeElement === select) continue;
146
+ const signature = JSON.stringify(keys);
147
+ if (select.dataset.options !== signature) {
148
+ select.replaceChildren(node("option", { value: "" }, "全部会话"), ...keys.map(key => node("option", { value: key }, key)));
149
+ select.dataset.options = signature;
150
+ }
151
+ select.value = conversation;
152
+ }
153
+ }
154
+ function render() {
155
+ const runtime = snapshot.diagnostics?.records || [];
156
+ updateConversations(runtime);
157
+ const matches = (record, search) => (!conversation || record.convoKey === conversation) && (!search || record.raw.toLocaleLowerCase().includes(search.toLocaleLowerCase()));
158
+ const modelRecords = runtime.filter(record => record.kind === "request" || record.kind === "response");
159
+ const filteredModels = modelRecords.filter(record => (!kind.value || record.kind === kind.value) && matches(record, modelSearch.value.trim()));
160
+ reconcile(models, filteredModels, true, modelRecords.length
161
+ ? ["没有匹配的模型记录", "试试其他会话、类型或关键词。"]
162
+ : ["等待模型调用", "启动 DEV 并发送消息后,Runtime 提供的模型请求与响应会出现在这里。旧版本或未输出这些日志的 Runtime 可先查看运行日志。"]);
163
+ $("models-count").textContent = `${filteredModels.length} 条记录${snapshot.diagnostics?.dropped ? ` · 已淘汰 ${snapshot.diagnostics.dropped} 条较早日志` : ""}`;
164
+ const events = snapshot.events || [];
165
+ const deltas = events.filter(event => event.type === "run.output.delta").length;
166
+ const other = events.filter(event => event.type !== "run.output.delta" && (!snapshot.diagnostics || !/^agent\.(stdout|stderr)$/.test(event.type))).map(event => ({
167
+ id: event.eventId, timestamp: event.timestamp, source: event.channel, level: /failed|error|stderr/.test(event.type) ? "error" : "info", title: event.summary || event.type,
168
+ kind: "log", raw: `${event.timestamp} ${event.channel} ${event.type}${event.runId ? ` ${event.runId}` : ""}\n${event.summary || ""}`,
169
+ }));
170
+ const allLogs = [...runtime, ...other].sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
171
+ visibleLogs = allLogs.filter(record => (!level.value || record.level === level.value || level.value === "error" && (record.data?.error || record.data?.stopReason === "error")) && matches(record, logSearch.value.trim()));
172
+ reconcile(logs, visibleLogs, false, allLogs.length ? ["没有匹配的日志", "调整会话、级别或搜索条件后再试。"] : ["等待运行日志", "启动 DEV 后,这里会保留进程输出与连接事件。"]);
173
+ $("logs-count").textContent = `${visibleLogs.length} 条可见日志${deltas ? ` · 已折叠 ${deltas} 条文字分片事件` : ""}${snapshot.diagnostics?.dropped ? ` · 已淘汰 ${snapshot.diagnostics.dropped} 条较早日志` : ""}`;
174
+ const retained = new Set([...runtime.map(record => record.id), ...other.map(record => record.id)]);
175
+ for (const key of cached.keys()) if (!retained.has(key.slice(key.indexOf(":") + 1))) { cached.delete(key); expanded.delete(key); }
176
+ }
177
+ for (const select of [modelConversation, logConversation]) {
178
+ select.addEventListener("change", () => { conversation = select.value; render(); });
179
+ select.addEventListener("blur", render);
180
+ }
181
+ for (const input of [kind, level, modelSearch, logSearch]) input.addEventListener(input.type === "search" ? "input" : "change", render);
182
+ return { update(next) { snapshot = next; render(); }, copyLogs() { return copy(visibleLogs.map(record => record.raw).join("\n\n")); } };
183
+ };
184
+ })();