@my-life-buddies/cli 0.13.0 → 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/README.md +19 -5
- package/dist/bin/buddy.js +21 -6
- package/dist/bin/buddy.js.map +2 -2
- package/dist/bin/core.js +452 -192
- package/dist/bin/core.js.map +4 -4
- package/dist/bin/preview.js +281 -19
- package/dist/bin/preview.js.map +4 -4
- package/dist/web/app.css +199 -31
- package/dist/web/app.js +258 -69
- package/dist/web/diagnostics.js +184 -0
- package/dist/web/index.html +38 -40
- package/package.json +3 -3
- package/resources/agent-template/package-lock.json +4 -4
- package/resources/agent-template/package.json +2 -2
package/dist/web/app.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
const hostedBuddyId = hosted ? new URL(location.href).searchParams.get("buddyId") : "";
|
|
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
|
+
let connectionChecked = false, connectionFailure = "", devAction = "";
|
|
9
|
+
let eventSource, eventSourceInstance = "", liveEventsActive = false, pollingInterval = 0;
|
|
8
10
|
function previewApiUrl(path) {
|
|
9
11
|
if (!hosted) return path;
|
|
10
12
|
const url = new URL(connectionUrl.href + path);
|
|
@@ -64,6 +66,7 @@
|
|
|
64
66
|
buddy: undefined,
|
|
65
67
|
dev: { phase: "idle", revision: 0 },
|
|
66
68
|
simulator: { messages: [] },
|
|
69
|
+
simulatorLoaded: false,
|
|
67
70
|
debug: {
|
|
68
71
|
source: "observed",
|
|
69
72
|
generatedAt: new Date().toISOString(),
|
|
@@ -109,7 +112,7 @@
|
|
|
109
112
|
},
|
|
110
113
|
});
|
|
111
114
|
const value = await response.json().catch(() => ({}));
|
|
112
|
-
if (hosted && (!connectionReady || instance !== connectionInstance)) throw new Error("本地连接已改变,请等待重新连接");
|
|
115
|
+
if (hosted && (!connectionReady || instance !== connectionInstance)) throw Object.assign(new Error("本地连接已改变,请等待重新连接"), {status:409, code:"PREVIEW_CONNECTION_CHANGED"});
|
|
113
116
|
if (!response.ok) {
|
|
114
117
|
const error = new Error((typeof value?.error === "string" ? value.error : value?.error?.message) || `请求失败(HTTP ${response.status})`);
|
|
115
118
|
error.code = value?.error?.code || "PREVIEW_REQUEST_FAILED";
|
|
@@ -134,14 +137,14 @@
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
function formatDuration(value) {
|
|
137
|
-
if (!Number.isFinite(value)) return "
|
|
140
|
+
if (!Number.isFinite(value)) return "未记录";
|
|
138
141
|
if (value < 1000) return `${Math.round(value)} ms`;
|
|
139
142
|
return `${(value / 1000).toFixed(value < 10_000 ? 2 : 1)} s`;
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
function formatTime(value, seconds = true) {
|
|
143
146
|
const date = new Date(value);
|
|
144
|
-
if (!Number.isFinite(date.getTime())) return "
|
|
147
|
+
if (!Number.isFinite(date.getTime())) return "未记录";
|
|
145
148
|
return new Intl.DateTimeFormat("zh-CN", {
|
|
146
149
|
hour: "2-digit",
|
|
147
150
|
minute: "2-digit",
|
|
@@ -197,6 +200,7 @@
|
|
|
197
200
|
function applyDevState(next) {
|
|
198
201
|
if (!next || !Number.isFinite(next.revision)) return false;
|
|
199
202
|
if (Number.isFinite(state.dev?.revision) && next.revision < state.dev.revision) return false;
|
|
203
|
+
if (next.phase === "ready" && state.dev.phase !== "ready") state.simulatorLoaded = false;
|
|
200
204
|
state.dev = next;
|
|
201
205
|
if (next.ready?.buddy) state.buddy = next.ready.buddy;
|
|
202
206
|
return true;
|
|
@@ -244,18 +248,20 @@
|
|
|
244
248
|
let pairingRequest = 0;
|
|
245
249
|
|
|
246
250
|
function renderDev() {
|
|
247
|
-
const phase = connectionReady ? state.dev.phase : "disconnected";
|
|
251
|
+
const phase = connectionReady ? (devAction === "start" ? "starting" : devAction === "stop" ? "stopping" : state.dev.phase) : "disconnected";
|
|
252
|
+
renderWorkbench();
|
|
248
253
|
if (!elements.pairingPanel.hidden && pairingRevision !== state.dev.revision) void togglePairing(true);
|
|
249
254
|
elements.connectionState.dataset.phase = phase;
|
|
250
255
|
elements.connectionLabel.textContent = phase === "disconnected" ? "未连接本地工程" : phaseCopy(phase);
|
|
251
|
-
elements.devControl.disabled = !connectionReady || phase === "starting" || phase === "stopping";
|
|
256
|
+
elements.devControl.disabled = !connectionReady || Boolean(devAction) || phase === "starting" || phase === "stopping";
|
|
252
257
|
elements.devControl.dataset.action = phase === "ready" ? "stop" : "start";
|
|
253
|
-
elements.devControl.textContent = phase === "ready" ? "停止 DEV" : phase === "starting" ? "正在启动…" : phase === "stopping" ? "正在停止…" : "启动 DEV";
|
|
258
|
+
elements.devControl.textContent = devAction === "start" ? "正在启动…" : devAction === "stop" ? "正在停止…" : phase === "failed" ? "重试启动" : phase === "ready" ? "停止 DEV" : phase === "starting" ? "正在启动…" : phase === "stopping" ? "正在停止…" : "启动 DEV";
|
|
254
259
|
elements.environmentAgent.textContent = phase === "ready"
|
|
255
260
|
? `${state.dev.ready?.agentId || "已就绪"} · CLI 托管`
|
|
256
261
|
: phaseCopy(phase);
|
|
257
262
|
|
|
258
263
|
const active = state.simulator.activeRun;
|
|
264
|
+
elements.pairDevice.disabled = !connectionReady || phase !== "ready";
|
|
259
265
|
elements.buddyStatus.textContent = phase !== "ready"
|
|
260
266
|
? phaseCopy(phase)
|
|
261
267
|
: active
|
|
@@ -266,8 +272,8 @@
|
|
|
266
272
|
elements.resetConversation.disabled = phase !== "ready" || Boolean(active) || state.resetting || state.sending || state.resetUnavailable;
|
|
267
273
|
elements.resetConversation.title = state.resetUnavailable ? "当前平台暂不支持重开会话" : active ? "请等待当前回复完成后再清空" : "结束当前开发版对话,开始一段全新会话";
|
|
268
274
|
elements.devControl.disabled ||= state.resetting;
|
|
269
|
-
elements.messageInput.disabled = phase !== "ready" ||
|
|
270
|
-
elements.sendMessage.hidden =
|
|
275
|
+
elements.messageInput.disabled = phase !== "ready" || !state.simulatorLoaded || state.resetting || state.sending;
|
|
276
|
+
elements.sendMessage.hidden = false;
|
|
271
277
|
updateSendButton();
|
|
272
278
|
syncPolling();
|
|
273
279
|
if (
|
|
@@ -280,6 +286,45 @@
|
|
|
280
286
|
}
|
|
281
287
|
}
|
|
282
288
|
|
|
289
|
+
function renderWorkbench() {
|
|
290
|
+
if (!hosted) return;
|
|
291
|
+
const phase = connectionReady ? (devAction === "start" ? "starting" : devAction === "stop" ? "stopping" : state.dev.phase) : "disconnected";
|
|
292
|
+
const runs = state.debug.runs || [];
|
|
293
|
+
const active = state.simulator.activeRun;
|
|
294
|
+
document.body.dataset.connection = connectionReady ? "connected" : "disconnected";
|
|
295
|
+
document.body.dataset.devPhase = phase;
|
|
296
|
+
document.body.dataset.hasRuns = String(runs.length > 0);
|
|
297
|
+
document.body.dataset.busy = String(phase === "starting" || phase === "stopping" || state.sending || Boolean(active));
|
|
298
|
+
const guide = $("#workbench-state");
|
|
299
|
+
const history = Boolean(state.simulator.messages?.length);
|
|
300
|
+
$("#debug-buddy-name").textContent = state.buddy?.name || new URL(location.href).searchParams.get("buddyName") || "我的搭子";
|
|
301
|
+
const copy = !connectionChecked ? ["正在检查连接", "读取本地工程的连接状态。"]
|
|
302
|
+
: phase === "disconnected" ? [connectionInstance ? "本地连接已断开" : "连接本地工程", connectionFailure || (connectionInstance ? "正在等待重新连接。保留当前记录,连接恢复后继续调试。" : "点击上方「连接工程」,复制指令到终端运行。")]
|
|
303
|
+
: phase === "starting" ? ["正在启动调试", "正在准备运行环境,启动完成后即可发送消息。"]
|
|
304
|
+
: phase === "stopping" ? ["正在停止调试", "等待运行环境退出,当前记录会保留。"]
|
|
305
|
+
: phase === "failed" ? ["调试未能启动", "查看上方错误信息,修复工程后点击「重试启动」。"]
|
|
306
|
+
: phase === "idle" ? ["工程已连接", "点击上方「启动 DEV」,开始体验搭子。"]
|
|
307
|
+
: !state.simulatorLoaded ? ["正在读取对话", "同步当前会话,完成后即可发送消息。"]
|
|
308
|
+
: state.sending ? ["正在发送消息", "提交成功后,这里会显示消息与模型日志。"]
|
|
309
|
+
: active ? ["等待执行记录", "消息已提交,正在同步消息与模型日志。"]
|
|
310
|
+
: ["开始一轮调试", history ? "右侧保留了历史对话。本地调试记录从本次连接开始,发一条新消息即可查看。" : "在右侧发一条消息,观察搭子的回复与模型日志。"];
|
|
311
|
+
guide.hidden = phase === "ready" && (runs.length > 0 || (state.debug.diagnostics?.records || []).some(record => record.kind !== "log"));
|
|
312
|
+
$("#workbench-state-title").textContent = copy[0];
|
|
313
|
+
$("#workbench-state-copy").textContent = copy[1];
|
|
314
|
+
$("#connection-description").textContent = !connectionChecked ? "检查中" : phase === "disconnected" ? "等待连接" : "仅用于本地调试";
|
|
315
|
+
const connectButton = $("#get-connect-command");
|
|
316
|
+
connectButton.className = `button ${connectionReady ? "button-secondary" : "button-primary"}`;
|
|
317
|
+
connectButton.textContent = connectionReady ? "连接指令" : connectionInstance ? "重新连接" : "连接工程";
|
|
318
|
+
const hint = $("#preview-hint");
|
|
319
|
+
hint.textContent = phase !== "ready" ? (phase === "disconnected" ? "连接工程后开始体验" : phase === "starting" ? "正在启动,稍后即可发送" : "启动 DEV 后开始体验")
|
|
320
|
+
: !state.simulatorLoaded ? "正在读取对话,请稍候" : state.resetting ? "正在清空对话,请稍候" : state.sending ? "正在发送,请稍候"
|
|
321
|
+
: active ? (state.simulator.streaming?.text ? "正在生成回复" : "消息已送达,等待回复") : "Enter 发送,Shift + Enter 换行";
|
|
322
|
+
if (!state.project) elements.buddyName.textContent = new URL(location.href).searchParams.get("buddyName") || "我的搭子";
|
|
323
|
+
const empty = elements.chatMessages.querySelector(".chat-empty");
|
|
324
|
+
if (empty) empty.textContent = phase === "ready" ? "发一条消息,开始体验" : phase === "disconnected" ? "连接后,在这里体验搭子的回复" : "启动 DEV 后,开始对话";
|
|
325
|
+
elements.messageInput.placeholder = active ? "可以继续补充消息" : phase === "ready" ? "给搭子发一条消息" : "等待调试就绪";
|
|
326
|
+
}
|
|
327
|
+
|
|
283
328
|
function runOrdinal(run, index, runs) {
|
|
284
329
|
// Older running preview servers have no ordinal yet. Never infer a message
|
|
285
330
|
// association from list positions or timestamps; this is a display number only.
|
|
@@ -288,7 +333,7 @@
|
|
|
288
333
|
|
|
289
334
|
function runOptionLabel(run, index, runs) {
|
|
290
335
|
const date = dayKey(run.startedAt) === dayKey(new Date()) ? "" : `${dayLabel(run.startedAt) || ""} `;
|
|
291
|
-
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))}`;
|
|
292
337
|
}
|
|
293
338
|
|
|
294
339
|
let runPickerSignature = "";
|
|
@@ -328,11 +373,11 @@
|
|
|
328
373
|
elements.runId.title = run?.runId || "";
|
|
329
374
|
elements.copyRunId.disabled = !run;
|
|
330
375
|
elements.runStatus.dataset.status = run?.status || "idle";
|
|
331
|
-
elements.runStatus.textContent = runStatusCopy(run?.status || "idle");
|
|
332
|
-
elements.metricAccept.textContent =
|
|
333
|
-
elements.metricFirst.textContent =
|
|
334
|
-
elements.metricResponse.textContent = run?.completion === "no_reply" ? "
|
|
335
|
-
elements.metricMessages.textContent = String(state.simulator.messages?.length || 0);
|
|
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));
|
|
380
|
+
elements.metricMessages.textContent = state.simulatorLoaded ? String(state.simulator.messages?.length || 0) : "读取中";
|
|
336
381
|
renderOverview(run);
|
|
337
382
|
renderTimeline(run);
|
|
338
383
|
}
|
|
@@ -361,52 +406,78 @@
|
|
|
361
406
|
}
|
|
362
407
|
}
|
|
363
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
|
+
|
|
364
421
|
function renderOverview(run) {
|
|
365
422
|
elements.overviewEmpty.hidden = Boolean(run);
|
|
366
423
|
elements.overviewContent.hidden = !run;
|
|
367
424
|
if (!run) return;
|
|
425
|
+
const finished = ["completed", "failed", "cancelled"].includes(run.status);
|
|
426
|
+
const measuredFirstText = Number.isFinite(run.firstTextLatencyMs);
|
|
427
|
+
const hasText = measuredFirstText || (state.simulator.streaming?.runId === run.runId && Boolean(state.simulator.streaming.text));
|
|
428
|
+
const noReply = run.completion === "no_reply";
|
|
429
|
+
const interrupted = ["failed", "cancelled", "cancel_requested"].includes(run.status);
|
|
430
|
+
const progress = $("#run-progress");
|
|
431
|
+
if (progress) {
|
|
432
|
+
clear(progress);
|
|
433
|
+
const stages = [
|
|
434
|
+
["消息已提交", "done"],
|
|
435
|
+
["等待回复", hasText || finished ? "done" : "active"],
|
|
436
|
+
[finished && !hasText ? "未记录流式输出" : "生成回复", hasText ? (finished ? "done" : "active") : finished ? "skipped" : "waiting"],
|
|
437
|
+
[run.status === "failed" ? "执行失败" : run.status === "cancelled" ? "已停止" : noReply ? "未观察到回复" : "完成", finished ? (interrupted ? "error" : "done") : "waiting"],
|
|
438
|
+
];
|
|
439
|
+
if (interrupted) stages.forEach(stage => { if (stage[1] === "active") stage[1] = "error"; });
|
|
440
|
+
for (const [label, status] of stages) progress.append(node("li", { "data-stage": status }, label));
|
|
441
|
+
}
|
|
368
442
|
clear(elements.waterfall);
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
["App Server 接收", accept],
|
|
379
|
-
[first === undefined ? "等待首段文字" : "首段文字前", waitingForText],
|
|
380
|
-
[run.status === "completed" ? "完整回复生成" : "回复生成中", streaming],
|
|
381
|
-
];
|
|
382
|
-
for (const [label, duration] of rows) {
|
|
443
|
+
// Only report observed timings. A finished run with missing telemetry never keeps counting.
|
|
444
|
+
const timingRows = [["消息被接收", run.acceptLatencyMs], ["首字耗时", run.firstTextLatencyMs], ["回复结束", noReply ? undefined : replyEndLatency(run)]];
|
|
445
|
+
const maximum = Math.max(1, ...timingRows.map(([,value]) => Number.isFinite(value) ? value : 0));
|
|
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);
|
|
450
|
+
for (const [label, duration] of timingRows) {
|
|
451
|
+
if (!Number.isFinite(duration)) continue;
|
|
383
452
|
const row = node("div", { class: "waterfall-row" });
|
|
384
453
|
row.append(node("span", {}, label));
|
|
385
454
|
const track = node("span", { class: "waterfall-track" });
|
|
386
455
|
const fill = node("span", { class: "waterfall-fill" });
|
|
387
|
-
fill.style.transform = `scaleX(${
|
|
456
|
+
fill.style.transform = `scaleX(${duration / maximum})`;
|
|
388
457
|
track.append(fill);
|
|
389
|
-
row.append(track, node("strong", {},
|
|
458
|
+
row.append(track, node("strong", {}, `发送后 ${formatElapsedSeconds(duration)}`));
|
|
390
459
|
elements.waterfall.append(row);
|
|
391
460
|
}
|
|
392
461
|
elements.runSummaryCopy.textContent = run.status === "failed"
|
|
393
|
-
? "
|
|
394
|
-
: run.status === "cancelled"
|
|
395
|
-
|
|
396
|
-
: run.
|
|
397
|
-
|
|
398
|
-
:
|
|
399
|
-
|
|
400
|
-
: run.status === "cancel_requested"
|
|
401
|
-
? "停止请求已经发往 App Server;输入框会在本地回合真正结束后重新开放。"
|
|
402
|
-
: first === undefined
|
|
403
|
-
? "当前本地回合正在等待第一段真实输出。收到后,模拟器会立即开始追加文字。"
|
|
404
|
-
: "当前本地回合正在生成回复,调试器与 App 模拟器会同步展示文字增量。";
|
|
462
|
+
? "本轮执行失败。请查看运行日志中的错误详情。"
|
|
463
|
+
: run.status === "cancelled" ? "本轮已停止。已完成的平台写入不会自动撤销。"
|
|
464
|
+
: noReply ? "回复状态已结束,未观察到新的可见回复;不据此判断执行是否成功。"
|
|
465
|
+
: run.status === "completed" ? (measuredFirstText ? "" : "本轮未记录首字耗时。")
|
|
466
|
+
: run.status === "cancel_requested" ? "正在停止,结束后可以继续发送消息。"
|
|
467
|
+
: hasText ? "正在生成回复,右侧同步展示输出。" : "消息已提交,等待搭子开始回复。";
|
|
468
|
+
elements.runSummaryCopy.hidden = !elements.runSummaryCopy.textContent;
|
|
405
469
|
}
|
|
406
470
|
|
|
471
|
+
let timelineSignature = "", contextSignature = "", protocolSignature = "";
|
|
472
|
+
const expandedLogs = new Map();
|
|
473
|
+
const diagnostics = window.createPreviewDiagnostics?.({ node, formatTime, formatDuration, toast, showError });
|
|
474
|
+
diagnostics?.update(state.debug);
|
|
407
475
|
function renderTimeline(run) {
|
|
476
|
+
const signature = JSON.stringify([run?.runId, state.debug.events]);
|
|
477
|
+
if (signature === timelineSignature) return;
|
|
478
|
+
timelineSignature = signature;
|
|
408
479
|
clear(elements.timelineList);
|
|
409
|
-
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));
|
|
410
481
|
if (!events.length) {
|
|
411
482
|
elements.timelineList.append(node("li", { class: "timeline-empty" }, run ? "这个本地回合暂无可见事件。" : "发一条消息后,执行事件会出现在这里。"));
|
|
412
483
|
return;
|
|
@@ -424,6 +495,9 @@
|
|
|
424
495
|
}
|
|
425
496
|
|
|
426
497
|
function renderContext() {
|
|
498
|
+
const signature = JSON.stringify(state.simulator.messages);
|
|
499
|
+
if (signature === contextSignature) return;
|
|
500
|
+
contextSignature = signature;
|
|
427
501
|
clear(elements.contextList);
|
|
428
502
|
elements.contextSource.textContent = "平台聊天记录";
|
|
429
503
|
const messages = state.simulator.messages || [];
|
|
@@ -433,7 +507,7 @@
|
|
|
433
507
|
}
|
|
434
508
|
for (const message of messages) {
|
|
435
509
|
const row = node("article", { class: "context-row", "data-role": message.role });
|
|
436
|
-
row.append(node("span", { class: "context-role" }, message.role));
|
|
510
|
+
row.append(node("span", { class: "context-role" }, message.role === "user" ? "用户" : message.role === "assistant" ? "搭子" : message.role));
|
|
437
511
|
row.append(node("p", { class: "context-text" }, message.text));
|
|
438
512
|
row.append(node("time", { class: "context-time", dateTime: message.createdAt }, formatTime(message.createdAt)));
|
|
439
513
|
elements.contextList.append(row);
|
|
@@ -441,13 +515,34 @@
|
|
|
441
515
|
}
|
|
442
516
|
|
|
443
517
|
function renderProtocol() {
|
|
518
|
+
if (diagnostics) { diagnostics.update(state.debug); return; }
|
|
519
|
+
const signature = JSON.stringify(state.debug.events);
|
|
520
|
+
if (signature === protocolSignature) return;
|
|
521
|
+
protocolSignature = signature;
|
|
444
522
|
clear(elements.protocolLog);
|
|
445
523
|
const events = state.debug.events || [];
|
|
446
524
|
if (!events.length) {
|
|
447
525
|
elements.protocolLog.append(node("p", { class: "timeline-empty" }, "本机还没有协议事件。"));
|
|
448
526
|
return;
|
|
449
527
|
}
|
|
450
|
-
for (const event of events) {
|
|
528
|
+
for (const [index, event] of events.entries()) {
|
|
529
|
+
if (hosted) {
|
|
530
|
+
const key = event.eventId || `${event.timestamp}:${event.type}:${index}`;
|
|
531
|
+
const row = node("details", { class: "protocol-entry" });
|
|
532
|
+
const error = event.type === "agent.stderr" || /failed|error/.test(event.type);
|
|
533
|
+
row.open = expandedLogs.get(key) ?? error;
|
|
534
|
+
row.dataset.level = error ? "error" : "info";
|
|
535
|
+
row.addEventListener("toggle", () => expandedLogs.set(key, row.open));
|
|
536
|
+
const summary = node("summary");
|
|
537
|
+
summary.append(node("time", { dateTime: event.timestamp }, formatTime(event.timestamp)));
|
|
538
|
+
summary.append(node("span", { class: "log-summary" }, (event.summary || event.type).split("\n")[0]));
|
|
539
|
+
summary.append(node("span", { class: "log-channel" }, event.channel));
|
|
540
|
+
row.append(summary);
|
|
541
|
+
row.append(node("p", { class: "log-meta" }, `${event.type}${event.runId ? ` / ${event.runId}` : ""}${Number.isFinite(event.durationMs) ? ` / ${formatDuration(event.durationMs)}` : ""}`));
|
|
542
|
+
if (event.summary?.includes("\n")) row.append(node("pre", { class: "runtime-log-text" }, event.summary));
|
|
543
|
+
elements.protocolLog.append(row);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
451
546
|
const row = node("div", { class: "protocol-row" });
|
|
452
547
|
row.append(node("time", { class: "protocol-time", dateTime: event.timestamp }, formatTime(event.timestamp)));
|
|
453
548
|
row.append(node("span", { class: "protocol-channel" }, event.channel));
|
|
@@ -465,6 +560,27 @@
|
|
|
465
560
|
|
|
466
561
|
let messageSignature = "";
|
|
467
562
|
let followMessages = true;
|
|
563
|
+
let liveReply, liveReplyBubble, liveReplyText = "";
|
|
564
|
+
function renderLiveReply() {
|
|
565
|
+
if (!liveReply) return;
|
|
566
|
+
const text = state.simulator.streaming?.runId === state.simulator.activeRun?.runId ? state.simulator.streaming?.text || "" : "";
|
|
567
|
+
if (text === liveReplyText) return;
|
|
568
|
+
liveReplyText = 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
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
clear(liveReply); liveReplyBubble = undefined;
|
|
579
|
+
const indicator = node("div", { class: "replying-indicator", "aria-label": "搭子正在准备回复" });
|
|
580
|
+
indicator.append(node("i"), node("i"), node("i")); liveReply.append(indicator);
|
|
581
|
+
}
|
|
582
|
+
if (followMessages) followLatestMessages();
|
|
583
|
+
}
|
|
468
584
|
|
|
469
585
|
function followLatestMessages() {
|
|
470
586
|
followMessages = true;
|
|
@@ -474,14 +590,15 @@
|
|
|
474
590
|
|
|
475
591
|
function renderMessages() {
|
|
476
592
|
renderWidgets();
|
|
477
|
-
const signature = JSON.stringify([state.simulator.messages,
|
|
478
|
-
if (signature === messageSignature) return;
|
|
593
|
+
const signature = JSON.stringify([state.simulator.messages, state.simulator.activeRun?.runId]);
|
|
594
|
+
if (signature === messageSignature) { renderLiveReply(); return; }
|
|
479
595
|
messageSignature = signature;
|
|
480
596
|
const previousTop = elements.chatMessages.scrollTop;
|
|
481
597
|
clear(elements.chatMessages);
|
|
598
|
+
liveReply = undefined; liveReplyBubble = undefined; liveReplyText = "";
|
|
482
599
|
const messages = state.simulator.messages || [];
|
|
483
600
|
if (!messages.length && !state.simulator.activeRun) {
|
|
484
|
-
elements.chatMessages.append(node("div", { class: "chat-empty", "aria-label": "暂无消息" }));
|
|
601
|
+
elements.chatMessages.append(node("div", { class: "chat-empty", "aria-label": "暂无消息" }, hosted ? (state.dev.phase === "ready" ? "发一条消息,开始体验" : "启动 DEV 后,开始对话") : ""));
|
|
485
602
|
}
|
|
486
603
|
let renderedDay;
|
|
487
604
|
for (const message of messages) {
|
|
@@ -499,9 +616,10 @@
|
|
|
499
616
|
}
|
|
500
617
|
if (state.simulator.activeRun) {
|
|
501
618
|
const row = node("div", { class: "message-row typing-row", "data-role": "assistant" });
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
619
|
+
liveReply = node("div", { class: "message-content" });
|
|
620
|
+
liveReplyText = "\0";
|
|
621
|
+
renderLiveReply();
|
|
622
|
+
row.append(messageAvatar("assistant"), liveReply);
|
|
505
623
|
elements.chatMessages.append(row);
|
|
506
624
|
}
|
|
507
625
|
if (followMessages) followLatestMessages();
|
|
@@ -515,6 +633,7 @@
|
|
|
515
633
|
renderRuns();
|
|
516
634
|
renderContext();
|
|
517
635
|
renderProtocol();
|
|
636
|
+
renderWorkbench();
|
|
518
637
|
}
|
|
519
638
|
|
|
520
639
|
function renderAll() {
|
|
@@ -545,16 +664,22 @@
|
|
|
545
664
|
renderContext();
|
|
546
665
|
await refreshDebug();
|
|
547
666
|
} catch (cause) {
|
|
548
|
-
if (cause?.
|
|
667
|
+
if (hosted && (!connectionReady || cause?.status === 409)) void refreshConnection();
|
|
668
|
+
else if (cause?.code !== "SIMULATOR_NOT_READY") showError(cause);
|
|
549
669
|
} finally {
|
|
550
670
|
state.pollingBusy = false;
|
|
551
671
|
}
|
|
552
672
|
}
|
|
553
673
|
|
|
554
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
|
+
}
|
|
555
679
|
if (connectionReady && state.dev.phase === "ready" && state.polling === undefined) {
|
|
556
680
|
void refreshSimulator();
|
|
557
|
-
|
|
681
|
+
pollingInterval = interval;
|
|
682
|
+
state.polling = window.setInterval(() => void refreshSimulator(), interval);
|
|
558
683
|
} else if ((!connectionReady || state.dev.phase !== "ready") && state.polling !== undefined) {
|
|
559
684
|
window.clearInterval(state.polling);
|
|
560
685
|
state.polling = undefined;
|
|
@@ -563,20 +688,26 @@
|
|
|
563
688
|
|
|
564
689
|
async function controlDev() {
|
|
565
690
|
if (!connectionReady || state.dev.phase === "starting" || state.dev.phase === "stopping") return;
|
|
566
|
-
|
|
691
|
+
if (devAction) return;
|
|
692
|
+
devAction = state.dev.phase === "ready" ? "stop" : "start";
|
|
693
|
+
elements.errorBanner.hidden = true;
|
|
694
|
+
renderDev();
|
|
567
695
|
try {
|
|
568
|
-
const path =
|
|
696
|
+
const path = `/api/dev/${devAction}`;
|
|
569
697
|
const value = await api(path, { method: "POST" });
|
|
570
698
|
applyDevState(value.state);
|
|
571
699
|
if (state.dev.phase !== "ready" && path.endsWith("stop")) state.simulator = { messages: state.simulator.messages || [] };
|
|
572
700
|
renderAll();
|
|
573
701
|
} catch (cause) {
|
|
574
702
|
showError(cause);
|
|
703
|
+
} finally {
|
|
704
|
+
devAction = "";
|
|
705
|
+
renderDev();
|
|
575
706
|
}
|
|
576
707
|
}
|
|
577
708
|
|
|
578
709
|
function updateSendButton() {
|
|
579
|
-
const ready = state.dev.phase === "ready" && !state.
|
|
710
|
+
const ready = connectionReady && !devAction && state.simulatorLoaded && state.dev.phase === "ready" && !state.resetting && !state.sending;
|
|
580
711
|
elements.sendMessage.disabled = !ready || !elements.messageInput.value.trim();
|
|
581
712
|
}
|
|
582
713
|
|
|
@@ -585,8 +716,9 @@
|
|
|
585
716
|
async function sendMessage(event) {
|
|
586
717
|
event.preventDefault();
|
|
587
718
|
const text = elements.messageInput.value;
|
|
588
|
-
if (!text.trim() || state.dev.phase !== "ready" || state.
|
|
719
|
+
if (!text.trim() || !state.simulatorLoaded || devAction || state.dev.phase !== "ready" || state.resetting || state.sending) return;
|
|
589
720
|
state.sending = true;
|
|
721
|
+
elements.errorBanner.hidden = true;
|
|
590
722
|
followLatestMessages();
|
|
591
723
|
renderDev();
|
|
592
724
|
elements.messageInput.disabled = true;
|
|
@@ -606,10 +738,10 @@
|
|
|
606
738
|
pendingSend = undefined;
|
|
607
739
|
elements.messageInput.value = "";
|
|
608
740
|
resizeComposer();
|
|
609
|
-
state.simulator.activeRun = { runId: accepted.runId, status: "pending" };
|
|
741
|
+
if (state.simulator.latestRun?.runId !== accepted.runId) state.simulator.activeRun = { runId: accepted.runId, status: "pending" };
|
|
610
742
|
renderDev();
|
|
611
743
|
renderMessages();
|
|
612
|
-
|
|
744
|
+
void refreshSimulator();
|
|
613
745
|
} catch (cause) {
|
|
614
746
|
showError(cause);
|
|
615
747
|
} finally {
|
|
@@ -674,6 +806,7 @@
|
|
|
674
806
|
}
|
|
675
807
|
|
|
676
808
|
async function copyProtocol() {
|
|
809
|
+
if (diagnostics) return diagnostics.copyLogs();
|
|
677
810
|
const text = (state.debug.events || []).map((event) => [
|
|
678
811
|
event.timestamp,
|
|
679
812
|
event.channel,
|
|
@@ -692,7 +825,10 @@
|
|
|
692
825
|
|
|
693
826
|
function applySimulatorState(next) {
|
|
694
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;
|
|
695
830
|
state.simulator = next;
|
|
831
|
+
state.simulatorLoaded = true;
|
|
696
832
|
return true;
|
|
697
833
|
}
|
|
698
834
|
|
|
@@ -700,6 +836,7 @@
|
|
|
700
836
|
if (elements.resetConversation.disabled) return;
|
|
701
837
|
if (!window.confirm("清空当前对话并重新开始?\n这会结束当前开发版会话,重置聊天上下文、记忆和该会话的主动服务。旧记录仅在服务端留档;搭子资料和工程文件会保留。")) return;
|
|
702
838
|
state.resetting = true;
|
|
839
|
+
elements.errorBanner.hidden = true;
|
|
703
840
|
widgets?.reset();
|
|
704
841
|
state.simulatorRequest++;
|
|
705
842
|
elements.resetConversation.textContent = "正在清空…";
|
|
@@ -729,8 +866,20 @@
|
|
|
729
866
|
}
|
|
730
867
|
|
|
731
868
|
function connectEvents() {
|
|
732
|
-
|
|
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
|
+
});
|
|
733
881
|
events.addEventListener("dev.state", (event) => {
|
|
882
|
+
if (eventSource !== events) return;
|
|
734
883
|
try {
|
|
735
884
|
if (applyDevState(JSON.parse(event.data))) { renderDev(); renderWidgets(); }
|
|
736
885
|
} catch {
|
|
@@ -738,12 +887,15 @@
|
|
|
738
887
|
}
|
|
739
888
|
});
|
|
740
889
|
events.addEventListener("simulator.state", (event) => {
|
|
890
|
+
if (eventSource !== events || state.resetting) return;
|
|
741
891
|
try {
|
|
742
892
|
const value = JSON.parse(event.data);
|
|
893
|
+
const before = JSON.stringify([state.simulator.activeRun, Boolean(state.simulator.streaming?.text), state.simulatorLoaded]);
|
|
743
894
|
if (!applySimulatorState(value.state)) return;
|
|
744
|
-
state.debug = value.debug;
|
|
745
|
-
|
|
746
|
-
|
|
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();
|
|
747
899
|
renderMessages();
|
|
748
900
|
} catch {
|
|
749
901
|
// Polling remains the reconciliation path for a malformed or missed local event.
|
|
@@ -751,6 +903,11 @@
|
|
|
751
903
|
});
|
|
752
904
|
}
|
|
753
905
|
|
|
906
|
+
function closeEvents() {
|
|
907
|
+
eventSource?.close(); eventSource = undefined; eventSourceInstance = ""; liveEventsActive = false;
|
|
908
|
+
}
|
|
909
|
+
window.addEventListener("pagehide", closeEvents);
|
|
910
|
+
|
|
754
911
|
async function boot() {
|
|
755
912
|
if (hosted && !connectionReady) { void refreshConnection(); return; }
|
|
756
913
|
try {
|
|
@@ -775,13 +932,14 @@
|
|
|
775
932
|
}
|
|
776
933
|
|
|
777
934
|
function disconnected(message = "未连接本地工程") {
|
|
935
|
+
closeEvents();
|
|
778
936
|
connectionReady = false;
|
|
779
937
|
state.dev = { phase: "idle", revision: -1 };
|
|
780
938
|
state.simulatorRequest++;
|
|
781
939
|
widgets?.reset();
|
|
782
940
|
void togglePairing(false);
|
|
783
941
|
renderDev(); syncPolling();
|
|
784
|
-
$("#local-status").textContent =
|
|
942
|
+
$("#local-status").textContent = "本地工程";
|
|
785
943
|
}
|
|
786
944
|
|
|
787
945
|
async function refreshConnection() {
|
|
@@ -791,24 +949,39 @@
|
|
|
791
949
|
const response = await fetch(connectionUrl, { signal: AbortSignal.timeout(5000) });
|
|
792
950
|
const value = await response.json();
|
|
793
951
|
if (!response.ok) throw new Error(value.error || "连接状态读取失败");
|
|
952
|
+
connectionChecked = true; connectionFailure = "";
|
|
794
953
|
$("#connect-command").textContent = value.command;
|
|
795
954
|
$("#copy-connect-command").disabled = false;
|
|
796
955
|
if (!value.connected) { disconnected(); return; }
|
|
797
956
|
const changed = connectionInstance !== value.instanceId;
|
|
957
|
+
if (changed) closeEvents();
|
|
798
958
|
const newlyConnected = !connectionReady || changed;
|
|
799
959
|
connectionInstance = value.instanceId;
|
|
800
960
|
connectionReady = true;
|
|
801
|
-
|
|
961
|
+
const projectName = (value.projectRoot || "").split(/[\\/]/).filter(Boolean).at(-1) || "本地工程";
|
|
962
|
+
$("#local-status").textContent = projectName;
|
|
963
|
+
$("#local-status").title = value.projectRoot || "本地工程";
|
|
964
|
+
$("#machine-name").textContent = value.machineName || "未提供";
|
|
965
|
+
$("#project-root").textContent = value.projectRoot || "未提供";
|
|
802
966
|
if (newlyConnected) {
|
|
967
|
+
$("#connect-instructions").hidden = true;
|
|
968
|
+
$("#get-connect-command").setAttribute("aria-expanded", "false");
|
|
803
969
|
state.dev = { phase: "idle", revision: -1 };
|
|
804
970
|
if (changed) {
|
|
805
|
-
state.simulator = { messages: [] }; state.debug = { runs: [], events: [], messages: [] };
|
|
971
|
+
state.simulator = { messages: [] }; state.simulatorLoaded = false; state.debug = { runs: [], events: [], messages: [] };
|
|
806
972
|
state.selectedRunId = ""; pendingSend = undefined; state.buddy = undefined;
|
|
807
973
|
}
|
|
808
974
|
elements.errorBanner.hidden = true;
|
|
975
|
+
renderDev();
|
|
809
976
|
await boot();
|
|
810
977
|
}
|
|
811
|
-
|
|
978
|
+
if (value.liveEvents) connectEvents();
|
|
979
|
+
} catch (cause) {
|
|
980
|
+
const message = cause.message || "连接已中断,正在重试…";
|
|
981
|
+
connectionChecked = true; connectionFailure = message;
|
|
982
|
+
disconnected(message);
|
|
983
|
+
if (!$("#connect-instructions").hidden) { $("#connect-command").textContent = message; $("#copy-connect-command").disabled = true; }
|
|
984
|
+
}
|
|
812
985
|
finally { connectionBusy = false; }
|
|
813
986
|
}
|
|
814
987
|
|
|
@@ -817,15 +990,31 @@
|
|
|
817
990
|
devPollBusy = true;
|
|
818
991
|
try {
|
|
819
992
|
const [dev, debug] = await Promise.all([api("/api/dev"), api("/api/debug")]);
|
|
820
|
-
applyDevState(dev.state)
|
|
821
|
-
|
|
993
|
+
if (applyDevState(dev.state)) renderDev();
|
|
994
|
+
state.debug = debug.debug; renderDebug();
|
|
995
|
+
} catch (cause) {
|
|
996
|
+
// Polling conflicts are connection transitions, already explained by the workbench state.
|
|
997
|
+
if (!connectionReady || cause?.status === 409) void refreshConnection();
|
|
998
|
+
else showError(cause);
|
|
999
|
+
}
|
|
822
1000
|
finally { devPollBusy = false; }
|
|
823
1001
|
}
|
|
824
1002
|
|
|
825
1003
|
if (hosted) {
|
|
826
1004
|
renderDev();
|
|
1005
|
+
const engineeringDetails = $(".connection-details");
|
|
1006
|
+
document.addEventListener("pointerdown", event => {
|
|
1007
|
+
if (engineeringDetails.open && !engineeringDetails.contains(event.target)) engineeringDetails.open = false;
|
|
1008
|
+
});
|
|
1009
|
+
document.addEventListener("keydown", event => {
|
|
1010
|
+
if (event.key === "Escape" && engineeringDetails.open) {
|
|
1011
|
+
engineeringDetails.open = false;
|
|
1012
|
+
engineeringDetails.querySelector("summary").focus();
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
827
1015
|
$("#get-connect-command").addEventListener("click", () => {
|
|
828
1016
|
$("#connect-instructions").hidden = !$("#connect-instructions").hidden;
|
|
1017
|
+
$("#get-connect-command").setAttribute("aria-expanded", String(!$("#connect-instructions").hidden));
|
|
829
1018
|
void refreshConnection();
|
|
830
1019
|
});
|
|
831
1020
|
$("#copy-connect-command").addEventListener("click", async () => {
|