1640 lines
64 KiB
TypeScript
1640 lines
64 KiB
TypeScript
function initDevicePodPanel() {
|
||
el.devicePodSelect.addEventListener("change", () => {
|
||
state.devicePod.selectedDevicePodId = el.devicePodSelect.value || "device-pod-71-freq";
|
||
loadLiveSurface().then(renderLiveSurface);
|
||
});
|
||
el.deviceEventFollow.addEventListener("click", () => {
|
||
setDeviceEventFollow(!state.devicePod.followEvents, { scroll: true });
|
||
});
|
||
el.deviceEventJump.addEventListener("click", () => {
|
||
state.devicePod.unreadEvents = 0;
|
||
setDeviceEventFollow(true);
|
||
renderDeviceEventUnreadHint();
|
||
scrollDeviceEventsToBottom();
|
||
});
|
||
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
|
||
el.deviceEventScroll.addEventListener(eventName, markDeviceEventScrollIntent, { passive: true });
|
||
}
|
||
el.deviceEventScroll.addEventListener("keydown", (event) => {
|
||
if (isScrollIntentKey(event.key)) markDeviceEventScrollIntent();
|
||
});
|
||
el.deviceEventScroll.addEventListener("scroll", () => {
|
||
if (!deviceEventNearBottom()) setDeviceEventFollow(false);
|
||
}, { passive: true });
|
||
el.devicePodSummary.addEventListener("click", (event) => {
|
||
const tile = event.target.closest("[data-device-detail]");
|
||
if (!tile) return;
|
||
showDeviceDetail(tile.dataset.deviceTitle || "Pod Summary", state.devicePod.details.get("pod"));
|
||
});
|
||
el.devicePodSummary.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
const tile = event.target.closest("[data-device-detail]");
|
||
if (!tile) return;
|
||
event.preventDefault();
|
||
showDeviceDetail(tile.dataset.deviceTitle || "Pod Summary", state.devicePod.details.get("pod"));
|
||
});
|
||
el.devicePodInterfaces.addEventListener("click", (event) => {
|
||
const tile = event.target.closest("[data-device-detail]");
|
||
if (!tile) return;
|
||
showDeviceDetail(tile.dataset.deviceTitle || tile.dataset.deviceDetail || "Device Pod", state.devicePod.details.get(tile.dataset.deviceDetail));
|
||
});
|
||
}
|
||
|
||
function initCommandBar() {
|
||
syncCommandInputHeight();
|
||
el.commandInput.addEventListener("input", syncCommandInputHeight);
|
||
el.commandInput.addEventListener("input", () => renderAgentChatStatus(deriveAgentChatStatus(), latestChatResult()));
|
||
el.commandInput.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
|
||
event.preventDefault();
|
||
el.commandForm.requestSubmit();
|
||
});
|
||
el.commandForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const value = el.commandInput.value.trim();
|
||
await submitAgentMessage(value);
|
||
});
|
||
|
||
el.commandClear.addEventListener("click", () => {
|
||
for (const close of state.traceStreams.values()) close();
|
||
state.traceStreams.clear();
|
||
state.traceDetailsOpen.clear();
|
||
state.conversationRenderVersion += 1;
|
||
state.conversationScrollUserActiveUntil = 0;
|
||
state.conversationScrollPosition = { top: 0, left: 0 };
|
||
state.traceScrollPositions.clear();
|
||
state.traceScrollPinnedToBottom.clear();
|
||
state.traceScrollUserActiveUntil.clear();
|
||
state.traceProgrammaticScrollWrites = 0;
|
||
state.fullTraceReplayInFlight.clear();
|
||
state.chatMessages = [];
|
||
state.conversationId = null;
|
||
state.sessionId = null;
|
||
state.threadId = null;
|
||
state.sessionStatus = null;
|
||
state.currentRequest = null;
|
||
state.canceledTraces.clear();
|
||
state.chatPending = false;
|
||
clearCodeAgentSessionState();
|
||
el.commandInput.value = "";
|
||
syncCommandInputHeight();
|
||
renderAgentChatStatus("idle");
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderDevicePodPanel(state.liveSurface);
|
||
});
|
||
}
|
||
|
||
function syncCommandInputHeight() {
|
||
el.commandInput.style.height = "auto";
|
||
const styles = getComputedStyle(el.commandInput);
|
||
const lineHeight = Number.parseFloat(styles.lineHeight) || 18;
|
||
const paddingTop = Number.parseFloat(styles.paddingTop) || 0;
|
||
const paddingBottom = Number.parseFloat(styles.paddingBottom) || 0;
|
||
const maxHeight = Math.ceil(lineHeight * 5 + paddingTop + paddingBottom);
|
||
const nextHeight = Math.min(el.commandInput.scrollHeight, maxHeight);
|
||
el.commandInput.style.height = `${Math.max(nextHeight, 38)}px`;
|
||
el.commandInput.style.overflowY = el.commandInput.scrollHeight > maxHeight ? "auto" : "hidden";
|
||
}
|
||
|
||
async function submitAgentMessage(value, options = {}) {
|
||
if (!value) return;
|
||
const composer = currentComposerState();
|
||
if (composer.submitMode === "steer") {
|
||
await submitAgentSteer(value, { ...options, composer });
|
||
return;
|
||
}
|
||
if (state.chatPending) return;
|
||
const traceId = options.traceId ?? nextProtocolId("trc");
|
||
const activeConversationId = options.conversationId ?? state.conversationId ?? nextProtocolId("cnv");
|
||
const requestedSessionId = options.sessionId === undefined ? sessionIdForNextRequest() : options.sessionId;
|
||
const threadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId;
|
||
const pendingContinuity = pendingSessionContinuity({
|
||
conversationId: activeConversationId,
|
||
sessionId: requestedSessionId,
|
||
threadId,
|
||
retryOf: options.retryOf
|
||
});
|
||
state.conversationId = activeConversationId;
|
||
const userMessage = {
|
||
id: nextProtocolId("msg"),
|
||
role: "user",
|
||
title: `${options.retryOf ? "重试" : "用户"} ${shortTime(new Date().toISOString())}`,
|
||
text: value,
|
||
status: "sent",
|
||
traceId,
|
||
conversationId: activeConversationId,
|
||
sessionId: requestedSessionId,
|
||
threadId,
|
||
retryOf: options.retryOf ?? null,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
const pendingMessage = {
|
||
id: nextProtocolId("msg"),
|
||
role: "agent",
|
||
title: options.retryOf ? "Code Agent 重试中" : "Code Agent 处理中",
|
||
text: `正在处理这次 Code Agent 请求;复杂问题可能需要几分钟。页面会保留 trace/session 并等待后端返回成功、结构化 blocker 或真实超时;不会把旧 4500ms 轻量探测窗口当作最终失败。`,
|
||
status: "running",
|
||
traceId,
|
||
conversationId: activeConversationId,
|
||
sessionId: requestedSessionId,
|
||
threadId,
|
||
sessionContinuity: pendingContinuity,
|
||
retryInput: value,
|
||
retryOf: options.retryOf ?? null,
|
||
sourceKind: "PENDING",
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
state.chatMessages.push(userMessage, pendingMessage);
|
||
state.activeTraceId = traceId;
|
||
state.chatPending = true;
|
||
state.currentRequest = {
|
||
traceId,
|
||
conversationId: activeConversationId,
|
||
sessionId: requestedSessionId,
|
||
threadId,
|
||
messageId: pendingMessage.id,
|
||
input: value,
|
||
lastActivityAt: Date.now(),
|
||
lastActivityIso: new Date().toISOString(),
|
||
traceEventCount: 0,
|
||
waitingFor: "agent/chat",
|
||
lastEventLabel: "request:submitted"
|
||
};
|
||
el.commandInput.value = "";
|
||
syncCommandInputHeight();
|
||
renderAgentChatStatus("running");
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderDevicePodPanel(state.liveSurface);
|
||
|
||
const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id);
|
||
|
||
try {
|
||
const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, threadId);
|
||
stopTraceStream();
|
||
if (state.canceledTraces.has(traceId)) return;
|
||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||
const updatedMessage = applyCodeAgentResultToMessage(pendingMessage.id, result, {
|
||
baseMessage: pendingMessage,
|
||
pendingContinuity,
|
||
retryInput: value,
|
||
retryOf: options.retryOf ?? null
|
||
});
|
||
if (result.availability) {
|
||
state.codeAgentAvailability = result.availability;
|
||
}
|
||
if (result.workspaceId) state.workspaceId = result.workspaceId;
|
||
if (Number.isInteger(result.workspaceRevision)) state.workspaceRevision = result.workspaceRevision;
|
||
if (!["completed", "source"].includes(updatedMessage?.status)) {
|
||
el.commandInput.value = value;
|
||
syncCommandInputHeight();
|
||
}
|
||
renderAgentChatStatus(updatedMessage?.status, state.chatMessages[index]);
|
||
renderCodeAgentSummary();
|
||
persistCodeAgentSessionState();
|
||
} catch (error) {
|
||
stopTraceStream();
|
||
if (state.canceledTraces.has(traceId)) return;
|
||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||
const presentation = agentFailurePresentation(error, { traceId });
|
||
const failedStatus = errorStatusFromPresentation(presentation, error);
|
||
state.chatMessages[index] = {
|
||
...pendingMessage,
|
||
title: presentation.title,
|
||
text: presentation.text,
|
||
status: failedStatus,
|
||
traceId: error.traceId || traceId,
|
||
threadId,
|
||
sessionContinuity: failedSessionContinuity(pendingContinuity, error),
|
||
updatedAt: new Date().toISOString(),
|
||
retryInput: value,
|
||
error: {
|
||
code: error.code || "request_failed",
|
||
category: presentation.category,
|
||
layer: error.layer,
|
||
blocker: error.blocker,
|
||
retryable: error.retryable,
|
||
userMessage: error.userMessage,
|
||
message: error.message,
|
||
timeoutMs: error.timeoutMs,
|
||
hardTimeoutMs: error.hardTimeoutMs,
|
||
idleMs: error.idleMs,
|
||
lastActivityAt: error.lastActivityAt,
|
||
waitingFor: error.waitingFor,
|
||
providerStatus: error.providerStatus,
|
||
missingConfig: error.missingConfig,
|
||
route: error.route,
|
||
toolName: error.toolName
|
||
},
|
||
runnerTrace: latestTraceSnapshot(traceId)
|
||
};
|
||
state.sessionStatus = state.chatMessages[index].runnerTrace?.sessionStatus ?? failedStatus;
|
||
el.commandInput.value = value;
|
||
syncCommandInputHeight();
|
||
renderAgentChatStatus(failedStatus, state.chatMessages[index]);
|
||
renderCodeAgentSummary();
|
||
persistCodeAgentSessionState();
|
||
} finally {
|
||
if (state.currentRequest?.traceId === traceId) {
|
||
state.currentRequest = null;
|
||
state.activeTraceId = null;
|
||
state.chatPending = false;
|
||
} else if (!state.currentRequest) {
|
||
state.chatPending = false;
|
||
}
|
||
stopTraceStream();
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderDevicePodPanel(state.liveSurface);
|
||
}
|
||
}
|
||
|
||
async function submitAgentSteer(value, options = {}) {
|
||
const composer = options.composer ?? currentComposerState();
|
||
const targetTraceId = composer.targetTraceId;
|
||
if (!targetTraceId) return;
|
||
const activeConversationId = options.conversationId ?? composer.conversationId ?? state.conversationId;
|
||
const sessionId = options.sessionId === undefined ? composer.sessionId ?? state.sessionId : options.sessionId;
|
||
const threadId = options.threadId === undefined ? composer.threadId ?? state.threadId : options.threadId;
|
||
const steerTraceId = options.steerTraceId ?? nextProtocolId("trc_steer");
|
||
const userMessage = {
|
||
id: nextProtocolId("msg"),
|
||
role: "user",
|
||
title: `引导 ${shortTime(new Date().toISOString())}`,
|
||
text: value,
|
||
status: "sent",
|
||
traceId: targetTraceId,
|
||
steerTraceId,
|
||
conversationId: activeConversationId,
|
||
sessionId,
|
||
threadId,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
state.chatMessages.push(userMessage);
|
||
el.commandInput.value = "";
|
||
syncCommandInputHeight();
|
||
renderAgentChatStatus("running", latestChatResult());
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderDevicePodPanel(state.liveSurface);
|
||
try {
|
||
const result = await sendAgentSteer(value, targetTraceId, steerTraceId, activeConversationId, sessionId, threadId);
|
||
annotateSteerResult(targetTraceId, result, { steerTraceId, value });
|
||
} catch (error) {
|
||
annotateSteerFailure(targetTraceId, error, { steerTraceId, value });
|
||
el.commandInput.value = value;
|
||
syncCommandInputHeight();
|
||
} finally {
|
||
renderAgentChatStatus(deriveAgentChatStatus(), latestChatResult());
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderDevicePodPanel(state.liveSurface);
|
||
}
|
||
}
|
||
|
||
function annotateSteerResult(targetTraceId, result, { steerTraceId, value }) {
|
||
const index = state.chatMessages.findIndex((message) => message.role === "agent" && message.traceId === targetTraceId && message.status === "running");
|
||
if (index < 0) return;
|
||
state.chatMessages[index] = {
|
||
...state.chatMessages[index],
|
||
steerTraceId,
|
||
lastSteerMessage: value,
|
||
lastSteerResult: result,
|
||
traceReplayStatus: result?.status ? `steer ${result.status}` : "steer accepted",
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
function annotateSteerFailure(targetTraceId, error, { steerTraceId, value }) {
|
||
const index = state.chatMessages.findIndex((message) => message.role === "agent" && message.traceId === targetTraceId && message.status === "running");
|
||
if (index < 0) return;
|
||
state.chatMessages[index] = {
|
||
...state.chatMessages[index],
|
||
steerTraceId,
|
||
lastSteerMessage: value,
|
||
traceReplayStatus: `steer 失败:${error?.userMessage ?? error?.message ?? "请求失败"}`,
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
}
|
||
|
||
function sessionIdForNextRequest() {
|
||
return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.sessionId;
|
||
}
|
||
|
||
function threadIdForNextRequest() {
|
||
return isTerminalSessionStatus(state.sessionStatus) ? undefined : state.threadId;
|
||
}
|
||
|
||
function isTerminalSessionStatus(status) {
|
||
return ["failed", "interrupted", "timeout", "error", "canceled", "expired"].includes(String(status ?? "").toLowerCase());
|
||
}
|
||
|
||
function applyCodeAgentResultToMessage(messageId, result, options = {}) {
|
||
const index = state.chatMessages.findIndex((message) => message.id === messageId);
|
||
if (index < 0 || !result || typeof result !== "object") return null;
|
||
const current = options.baseMessage ?? state.chatMessages[index];
|
||
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
|
||
state.sessionId = result.sessionId || result.session?.sessionId || state.sessionId || state.conversationId;
|
||
const resultThreadId = threadIdFrom(result);
|
||
if (resultThreadId) state.threadId = resultThreadId;
|
||
state.sessionStatus = result.sessionLifecycleStatus ?? result.sessionSummary?.status ?? result.session?.lifecycleStatus ?? result.session?.status ?? result.runnerTrace?.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus ?? result.status ?? state.sessionStatus;
|
||
const pendingContinuity = options.pendingContinuity ?? current.sessionContinuity ?? pendingSessionContinuity({
|
||
conversationId: current.conversationId || options.fallbackConversationId || state.conversationId,
|
||
sessionId: current.sessionId || state.sessionId,
|
||
threadId: current.threadId || options.fallbackThreadId || state.threadId,
|
||
retryOf: current.retryOf
|
||
});
|
||
const sessionContinuity = completedSessionContinuity(result, pendingContinuity);
|
||
const resultBlockedError = structuredBlockedErrorFromResult(result);
|
||
const continuityBlocker = codeAgentContinuityBlocker(result, sessionContinuity);
|
||
const blockedError = resultBlockedError ?? continuityBlocker;
|
||
const completion = classifyCodeAgentCompletion(result, { blockedError });
|
||
const status = completion.status;
|
||
state.chatMessages[index] = {
|
||
...current,
|
||
title: completion.title,
|
||
text: completion.replied
|
||
? result.reply?.content || "Code Agent 没有返回文本。"
|
||
: blockedError
|
||
? failureMessage({ ...result, error: blockedError })
|
||
: result.status === "completed"
|
||
? untrustedCompletionMessage(result)
|
||
: failureMessage(result),
|
||
status,
|
||
traceId: result.traceId || current.traceId,
|
||
conversationId: result.conversationId || result.sessionId || state.conversationId,
|
||
sessionId: result.sessionId || result.session?.sessionId || result.sessionReuse?.sessionId || state.sessionId,
|
||
threadId: resultThreadId || current.threadId || state.threadId,
|
||
sessionContinuity,
|
||
messageId: result.messageId,
|
||
provider: result.provider,
|
||
model: result.model,
|
||
backend: result.backend,
|
||
workspace: result.workspace,
|
||
sandbox: result.sandbox,
|
||
session: result.session,
|
||
sessionLifecycleStatus: result.sessionLifecycleStatus,
|
||
sessionLifecycle: result.sessionLifecycle,
|
||
sessionSummary: result.sessionSummary,
|
||
sessionMode: result.sessionMode,
|
||
sessionReuse: result.sessionReuse,
|
||
implementationType: result.implementationType,
|
||
runnerLimitations: result.runnerLimitations,
|
||
codexStdioFeasibility: result.codexStdioFeasibility,
|
||
longLivedSessionGate: result.longLivedSessionGate,
|
||
toolCalls: result.toolCalls,
|
||
skills: result.skills,
|
||
runner: result.runner,
|
||
runnerTrace: result.runnerTrace ?? current.runnerTrace,
|
||
conversationFacts: result.conversationFacts,
|
||
responseType: result.responseType,
|
||
capabilityLevel: result.capabilityLevel,
|
||
sourceKind: completion.sourceKind,
|
||
providerTrace: result.providerTrace,
|
||
blocker: result.blocker,
|
||
blockers: result.blockers,
|
||
updatedAt: result.updatedAt ?? new Date().toISOString(),
|
||
retryInput: options.retryInput ?? current.retryInput,
|
||
retryOf: options.retryOf ?? current.retryOf ?? null,
|
||
error: blockedError ?? result.error ?? (result.status === "completed" && !completion.replied
|
||
? {
|
||
code: "untrusted_completion",
|
||
message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。"
|
||
}
|
||
: undefined),
|
||
availability: result.availability,
|
||
traceReplayStatus: current.traceReplayStatus
|
||
};
|
||
maybeReplayFullTraceForMessage(state.chatMessages[index]);
|
||
return state.chatMessages[index];
|
||
}
|
||
|
||
function errorStatusFromPresentation(presentation, error) {
|
||
if (presentation.category === "timeout") return "timeout";
|
||
if (presentation.category === "canceled" || error?.code === "codex_stdio_canceled") return "canceled";
|
||
return "failed";
|
||
}
|
||
|
||
function pendingSessionContinuity({ conversationId, sessionId, threadId, retryOf = null } = {}) {
|
||
const hasReusableSession = Boolean(nonEmptyString(sessionId));
|
||
const hasReusableThread = Boolean(nonEmptyString(threadId));
|
||
const status = hasReusableSession || hasReusableThread
|
||
? "continued"
|
||
: retryOf ? "degraded" : "new";
|
||
return {
|
||
status,
|
||
tone: status === "continued" ? "ok" : status === "degraded" ? "warn" : "source",
|
||
label: status === "continued" ? "继续当前会话" : status === "degraded" ? "重试降级" : "新会话待建立",
|
||
summary: status === "continued"
|
||
? "本次请求会带上已保存的 conversation/session/thread 上下文。"
|
||
: status === "degraded"
|
||
? "上一轮未留下可复用 session/thread;重试会保留输入和 conversation,并等待后端建立新会话。"
|
||
: "这是当前浏览器会话中的首轮请求,等待后端分配 session/thread。",
|
||
requested: {
|
||
conversationId: nonEmptyString(conversationId),
|
||
sessionId: nonEmptyString(sessionId),
|
||
threadId: nonEmptyString(threadId),
|
||
retryOf: nonEmptyString(retryOf)
|
||
},
|
||
returned: {
|
||
conversationId: null,
|
||
sessionId: null,
|
||
threadId: null
|
||
},
|
||
missing: [],
|
||
changed: []
|
||
};
|
||
}
|
||
|
||
function completedSessionContinuity(result, pending = null) {
|
||
const lifecycle = result?.sessionSummary ?? result?.sessionLifecycle ?? null;
|
||
if (lifecycle?.degraded === true || lifecycle?.fallback === true) {
|
||
const requested = pending?.requested ?? {};
|
||
return {
|
||
status: "degraded",
|
||
tone: "warn",
|
||
label: lifecycle.label ?? "会话降级",
|
||
summary: lifecycle.userMessage ?? "当前响应为会话降级;输入已保留,可重新发送建立新会话。",
|
||
requested,
|
||
returned: {
|
||
conversationId: nonEmptyString(result?.conversationId),
|
||
sessionId: sessionIdFrom(result),
|
||
threadId: threadIdFrom(result)
|
||
},
|
||
missing: [],
|
||
changed: [],
|
||
reused: lifecycle.reused ?? null
|
||
};
|
||
}
|
||
const requested = pending?.requested ?? {};
|
||
const returned = {
|
||
conversationId: nonEmptyString(result?.conversationId),
|
||
sessionId: sessionIdFrom(result),
|
||
threadId: threadIdFrom(result)
|
||
};
|
||
const missing = [];
|
||
const changed = [];
|
||
if (!returned.conversationId) missing.push("conversationId");
|
||
if (requiresCodexSessionEvidence(result) && !returned.sessionId) missing.push("sessionId");
|
||
if (requiresCodexThreadEvidence(result) && !returned.threadId) missing.push("threadId");
|
||
if (requiresCodexProviderTrace(result) && !hasProviderTrace(result)) missing.push("providerTrace");
|
||
if (requested.conversationId && returned.conversationId && requested.conversationId !== returned.conversationId) changed.push("conversationId");
|
||
if (requested.sessionId && returned.sessionId && requested.sessionId !== returned.sessionId) changed.push("sessionId");
|
||
if (requested.threadId && returned.threadId && requested.threadId !== returned.threadId) changed.push("threadId");
|
||
const reused = result?.sessionReuse?.reused ?? result?.session?.reused ?? result?.runnerTrace?.sessionReused ?? null;
|
||
const requestedReusable = Boolean(requested.sessionId || requested.threadId);
|
||
const status = missing.length > 0 || changed.length > 0 || (requestedReusable && reused === false)
|
||
? "degraded"
|
||
: requestedReusable ? "continued" : "new";
|
||
const label = status === "continued" ? "继续当前会话" : status === "new" ? "新会话已建立" : "新会话/会话降级";
|
||
const summary = status === "continued"
|
||
? "后端确认复用当前 conversation/session/thread。"
|
||
: status === "new"
|
||
? "后端已为当前 conversation 建立新的 session/thread。"
|
||
: `后端未完整确认原 session/thread 复用;${continuityIssueText({ missing, changed, reused })}`;
|
||
return {
|
||
status,
|
||
tone: status === "continued" ? "ok" : status === "new" ? "source" : "warn",
|
||
label,
|
||
summary,
|
||
requested,
|
||
returned,
|
||
missing,
|
||
changed,
|
||
reused
|
||
};
|
||
}
|
||
|
||
function failedSessionContinuity(pending = null, error = null) {
|
||
const base = pending ?? pendingSessionContinuity();
|
||
const missing = [...(base.missing ?? [])];
|
||
if (!base.requested?.sessionId) missing.push("sessionId");
|
||
if (!base.requested?.threadId) missing.push("threadId");
|
||
const code = normalizeErrorCode(error?.code);
|
||
const label = code === "session_busy"
|
||
? "会话忙/请等待"
|
||
: code === "session_expired"
|
||
? "会话已过期"
|
||
: ["session_failed", "session_error", "session_timeout"].includes(code)
|
||
? "需新建会话"
|
||
: "会话受阻";
|
||
const summary = code === "session_busy"
|
||
? "上一轮仍在处理;输入已保留,请等待当前请求完成或取消后重试。"
|
||
: code === "session_expired"
|
||
? "当前 session 已过期;输入已保留,可重新发送建立新的 Code Agent session。"
|
||
: `本次请求没有完成可复用会话确认;输入已保留。${error?.code ? ` code=${error.code}` : ""}`;
|
||
return {
|
||
...base,
|
||
status: "blocked",
|
||
tone: "blocked",
|
||
label,
|
||
summary,
|
||
missing: [...new Set(missing)]
|
||
};
|
||
}
|
||
|
||
function continuityIssueText({ missing = [], changed = [], reused = null } = {}) {
|
||
const parts = [
|
||
missing.length ? `缺失 ${missing.join("、")}` : null,
|
||
changed.length ? `${changed.join("、")} 已变化` : null,
|
||
reused === false ? "sessionReuse=false" : null
|
||
].filter(Boolean);
|
||
return parts.length ? parts.join(";") : "会话复用证据不足";
|
||
}
|
||
|
||
function codeAgentContinuityBlocker(result, continuity) {
|
||
if (
|
||
!result ||
|
||
result.status !== "completed" ||
|
||
isSourceFixtureCompletion(result) ||
|
||
isTextFallbackChatResult(result) ||
|
||
!isTrustedCodeAgentProvider(result.provider)
|
||
) {
|
||
return null;
|
||
}
|
||
const missing = [
|
||
...(!hasProviderTrace(result) ? ["providerTrace"] : []),
|
||
...(!sessionIdFrom(result) ? ["sessionId"] : []),
|
||
...(requiresCodexThreadEvidence(result) && !threadIdFrom(result) ? ["threadId"] : [])
|
||
];
|
||
if (missing.length === 0) return null;
|
||
return {
|
||
code: "code_agent_session_evidence_missing",
|
||
category: "session_blocked",
|
||
layer: "cloud-web",
|
||
retryable: true,
|
||
missingFields: missing,
|
||
traceId: result.traceId,
|
||
message: `completed response missing Code Agent session evidence: ${missing.join(", ")}`,
|
||
userMessage: `Code Agent completed 但会话证据缺失:${missing.join("、")};本次按会话降级处理,输入已保留,可重试建立新会话。`,
|
||
blocker: {
|
||
code: "code_agent_session_evidence_missing",
|
||
category: "session_blocked",
|
||
layer: "cloud-web",
|
||
retryable: true,
|
||
missingFields: missing,
|
||
traceId: result.traceId,
|
||
userMessage: `Code Agent completed 但会话证据缺失:${missing.join("、")};本次按会话降级处理,输入已保留,可重试建立新会话。`,
|
||
sessionContinuity: continuity
|
||
}
|
||
};
|
||
}
|
||
|
||
function hasProviderTrace(value) {
|
||
return Boolean(value?.providerTrace && typeof value.providerTrace === "object" && Object.keys(value.providerTrace).length > 0);
|
||
}
|
||
|
||
function requiresCodexProviderTrace(value) {
|
||
return isTrustedCodeAgentProvider(value?.provider) && !isSourceFixtureCompletion(value);
|
||
}
|
||
|
||
function requiresCodexSessionEvidence(value) {
|
||
return isTrustedCodeAgentProvider(value?.provider);
|
||
}
|
||
|
||
function requiresCodexThreadEvidence(value) {
|
||
return isCodexStdioLongLivedShape(value);
|
||
}
|
||
|
||
function isCodexStdioLongLivedShape(value) {
|
||
return (
|
||
isTrustedCodeAgentProvider(value?.provider) ||
|
||
String(value?.runner?.kind ?? value?.runnerTrace?.runnerKind ?? "").trim() === CODEX_APP_SERVER_RUNNER_KIND ||
|
||
value?.runner?.codexStdio === true ||
|
||
value?.session?.codexStdio === true
|
||
) && (
|
||
String(value?.sessionMode ?? value?.session?.sessionMode ?? value?.runnerTrace?.sessionMode ?? "").trim() === CODEX_APP_SERVER_SESSION_MODE ||
|
||
String(value?.implementationType ?? value?.session?.implementationType ?? "").trim() === CODEX_APP_SERVER_IMPLEMENTATION_TYPE
|
||
);
|
||
}
|
||
|
||
function sessionIdFrom(value) {
|
||
return firstNonEmptyString(value?.sessionId, value?.session?.sessionId, value?.sessionReuse?.sessionId, value?.runner?.sessionId, value?.runnerTrace?.sessionId);
|
||
}
|
||
|
||
function threadIdFrom(value) {
|
||
return firstNonEmptyString(value?.threadId, value?.session?.threadId, value?.sessionReuse?.threadId, value?.providerTrace?.threadId, value?.runnerTrace?.threadId);
|
||
}
|
||
|
||
function firstNonEmptyString(...values) {
|
||
for (const value of values) {
|
||
const text = nonEmptyString(value);
|
||
if (text) return text;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function nonEmptyString(value) {
|
||
const text = String(value ?? "").trim();
|
||
return text ? text : null;
|
||
}
|
||
|
||
function subscribeRunnerTrace(traceId, messageId) {
|
||
if (typeof EventSource !== "function") return pollRunnerTrace(traceId, messageId);
|
||
const existing = state.traceStreams.get(traceId);
|
||
if (existing) existing();
|
||
let stopped = false;
|
||
let fallbackStop = null;
|
||
let firstEventSeen = false;
|
||
let fallbackTimer = null;
|
||
const source = new EventSource(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}/stream`);
|
||
const close = () => {
|
||
stopped = true;
|
||
if (fallbackTimer) window.clearTimeout(fallbackTimer);
|
||
source.close();
|
||
if (fallbackStop) fallbackStop();
|
||
if (state.traceStreams.get(traceId) === close) state.traceStreams.delete(traceId);
|
||
};
|
||
const markEventSeen = () => {
|
||
firstEventSeen = true;
|
||
if (fallbackTimer) {
|
||
window.clearTimeout(fallbackTimer);
|
||
fallbackTimer = null;
|
||
}
|
||
};
|
||
const startPolling = () => {
|
||
if (stopped || fallbackStop) return;
|
||
source.close();
|
||
fallbackStop = pollRunnerTrace(traceId, messageId);
|
||
state.traceStreams.set(traceId, close);
|
||
};
|
||
fallbackTimer = window.setTimeout(() => {
|
||
if (!firstEventSeen) startPolling();
|
||
}, TRACE_STREAM_FALLBACK_MS);
|
||
source.addEventListener("snapshot", (event) => {
|
||
markEventSeen();
|
||
updateMessageTrace(messageId, parseTraceEventData(event.data));
|
||
});
|
||
source.addEventListener("runnerTrace", (event) => {
|
||
markEventSeen();
|
||
const payload = parseTraceEventData(event.data);
|
||
updateMessageTrace(messageId, payload?.snapshot ?? payload);
|
||
});
|
||
source.addEventListener("heartbeat", (event) => {
|
||
markEventSeen();
|
||
updateMessageTrace(messageId, parseTraceEventData(event.data), { quiet: true });
|
||
});
|
||
source.onerror = () => {
|
||
startPolling();
|
||
};
|
||
state.traceStreams.set(traceId, close);
|
||
return close;
|
||
}
|
||
|
||
function pollRunnerTrace(traceId, messageId) {
|
||
let stopped = false;
|
||
let timer = null;
|
||
const tick = async () => {
|
||
if (stopped) return;
|
||
try {
|
||
const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, {
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 3000),
|
||
timeoutName: "Code Agent trace"
|
||
});
|
||
if (response.ok) updateMessageTrace(messageId, response.data);
|
||
} catch {
|
||
// Keep polling; the main /v1/agent/chat request owns terminal error handling.
|
||
}
|
||
if (!stopped) timer = window.setTimeout(tick, TRACE_POLL_INTERVAL_MS);
|
||
};
|
||
tick();
|
||
const stop = () => {
|
||
stopped = true;
|
||
if (timer) window.clearTimeout(timer);
|
||
if (state.traceStreams.get(traceId) === stop) state.traceStreams.delete(traceId);
|
||
};
|
||
state.traceStreams.set(traceId, stop);
|
||
return stop;
|
||
}
|
||
|
||
function parseTraceEventData(data) {
|
||
try {
|
||
return data ? JSON.parse(data) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function updateMessageTrace(messageId, snapshot, options = {}) {
|
||
if (!snapshot || typeof snapshot !== "object" || !Array.isArray(snapshot.events)) return;
|
||
const index = state.chatMessages.findIndex((message) => message.id === messageId);
|
||
if (index < 0) return;
|
||
const current = state.chatMessages[index];
|
||
const runnerTrace = runnerTraceFromSnapshot(snapshot, current.runnerTrace);
|
||
const sessionId = snapshot.sessionId ?? runnerTrace.sessionId ?? current.sessionId;
|
||
const threadId = snapshot.threadId ?? runnerTrace.threadId ?? current.threadId;
|
||
noteCurrentRequestTraceActivity(snapshot);
|
||
const nextMessage = {
|
||
...current,
|
||
runnerTrace,
|
||
traceId: snapshot.traceId ?? current.traceId,
|
||
sessionId,
|
||
threadId,
|
||
updatedAt: snapshot.updatedAt ?? current.updatedAt
|
||
};
|
||
state.chatMessages[index] = nextMessage;
|
||
if (sessionId) state.sessionId = sessionId;
|
||
if (threadId) state.threadId = threadId;
|
||
if (runnerTrace.sessionStatus) state.sessionStatus = runnerTrace.sessionStatus;
|
||
if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && sessionId) {
|
||
state.currentRequest.sessionId = sessionId;
|
||
}
|
||
if (state.currentRequest?.traceId === (snapshot.traceId ?? current.traceId) && threadId) {
|
||
state.currentRequest.threadId = threadId;
|
||
}
|
||
if (runnerTrace.eventsCompacted === true && runnerTrace.fullTraceLoaded !== true) {
|
||
maybeReplayFullTraceForMessage(nextMessage);
|
||
}
|
||
if (shouldReconcileCodeAgentResult(nextMessage, runnerTrace)) {
|
||
reconcileCodeAgentResult(messageId, { quiet: options.quiet === true });
|
||
}
|
||
if (options.quiet !== true) {
|
||
renderCodeAgentSummary();
|
||
patchMessageTracePanel(nextMessage);
|
||
renderDevicePodPanel(state.liveSurface);
|
||
}
|
||
}
|
||
|
||
function noteCurrentRequestTraceActivity(snapshot) {
|
||
const request = state.currentRequest;
|
||
if (!request || request.traceId !== snapshot.traceId) return;
|
||
const eventCount = Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length;
|
||
const assistantChunkCount = traceAssistantStreamChunkCount(snapshot);
|
||
const traceUpdatedAt = Date.parse(snapshot.updatedAt ?? "");
|
||
const previousUpdatedAt = Date.parse(request.lastTraceUpdatedAt ?? "");
|
||
const hasAssistantActivity = assistantChunkCount > (request.assistantChunkCount ?? 0);
|
||
const hasTimestampActivity = Number.isFinite(traceUpdatedAt) && (!Number.isFinite(previousUpdatedAt) || traceUpdatedAt > previousUpdatedAt);
|
||
if (eventCount <= (request.traceEventCount ?? 0) && !hasAssistantActivity && !hasTimestampActivity) {
|
||
if (snapshot.waitingFor) request.waitingFor = snapshot.waitingFor;
|
||
return;
|
||
}
|
||
request.traceEventCount = eventCount;
|
||
request.assistantChunkCount = assistantChunkCount;
|
||
request.lastActivityAt = Date.now();
|
||
request.lastActivityIso = new Date(request.lastActivityAt).toISOString();
|
||
request.lastTraceUpdatedAt = snapshot.updatedAt ?? request.lastTraceUpdatedAt;
|
||
request.waitingFor = snapshot.waitingFor ?? request.waitingFor;
|
||
request.lastEventLabel = snapshot.lastEvent?.label ?? snapshot.events.at(-1)?.label ?? request.lastEventLabel;
|
||
}
|
||
|
||
function traceAssistantStreamChunkCount(trace) {
|
||
return Array.isArray(trace?.assistantStreams)
|
||
? trace.assistantStreams.reduce((total, stream) => total + Math.max(0, Number(stream?.chunkCount ?? 0)), 0)
|
||
: 0;
|
||
}
|
||
|
||
function latestTraceSnapshot(traceId) {
|
||
for (const message of [...state.chatMessages].reverse()) {
|
||
if (message.traceId === traceId && message.runnerTrace) return message.runnerTrace;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function runnerTraceFromSnapshot(snapshot, previous = null) {
|
||
return {
|
||
...(previous && typeof previous === "object" ? previous : {}),
|
||
traceId: snapshot.traceId,
|
||
runnerKind: snapshot.runnerKind ?? previous?.runnerKind,
|
||
workspace: snapshot.workspace ?? previous?.workspace,
|
||
sandbox: snapshot.sandbox ?? previous?.sandbox,
|
||
sessionMode: snapshot.sessionMode ?? previous?.sessionMode,
|
||
sessionId: snapshot.sessionId ?? previous?.sessionId,
|
||
threadId: snapshot.threadId ?? previous?.threadId,
|
||
turnId: snapshot.turnId ?? previous?.turnId,
|
||
sessionStatus: snapshot.sessionStatus ?? previous?.sessionStatus,
|
||
implementationType: snapshot.implementationType ?? previous?.implementationType,
|
||
startedAt: snapshot.startedAt ?? previous?.startedAt,
|
||
finishedAt: snapshot.finishedAt ?? previous?.finishedAt,
|
||
updatedAt: snapshot.updatedAt ?? previous?.updatedAt,
|
||
eventCount: Number.isInteger(snapshot.eventCount) ? snapshot.eventCount : snapshot.events.length,
|
||
eventsCompacted: snapshot.eventsCompacted === true,
|
||
eventWindow: snapshot.eventWindow ?? previous?.eventWindow,
|
||
fullTraceLoaded: snapshot.fullTraceLoaded === true || (snapshot.eventsCompacted !== true && Number.isInteger(snapshot.eventCount) && snapshot.eventCount === snapshot.events.length),
|
||
elapsedMs: snapshot.elapsedMs ?? previous?.elapsedMs,
|
||
waitingFor: snapshot.waitingFor ?? previous?.waitingFor,
|
||
events: snapshot.events,
|
||
assistantStreams: Array.isArray(snapshot.assistantStreams) ? snapshot.assistantStreams : previous?.assistantStreams ?? [],
|
||
eventLabels: snapshot.eventLabels ?? snapshot.events.map((item) => item.label).filter(Boolean),
|
||
lastEvent: snapshot.lastEvent ?? snapshot.events.at(-1) ?? null,
|
||
outputTruncated: snapshot.outputTruncated === true,
|
||
valuesPrinted: false
|
||
};
|
||
}
|
||
|
||
|
||
function initGateControls() {
|
||
el.gateStatusFilter.addEventListener("change", renderGateTable);
|
||
el.gateSearch.addEventListener("input", renderGateTable);
|
||
el.gateRefresh.addEventListener("click", () => {
|
||
loadGateDiagnostics();
|
||
});
|
||
}
|
||
|
||
function renderStaticWorkbench() {
|
||
el.routePath.textContent = "当前浏览器会话只保存任务草稿,Device Pod 动作需通过受控后端流程。";
|
||
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderDevicePodPanel(null);
|
||
renderGateTable();
|
||
renderCodeAgentSummary();
|
||
}
|
||
|
||
function renderDrafts() {
|
||
el.routePath.textContent = state.chatPending
|
||
? "Code Agent 请求进行中;输入和 trace 会保留,Device Pod 仍只显示 summary。"
|
||
: "当前浏览器会话只保存任务草稿,Device Pod 动作需通过受控后端流程。";
|
||
}
|
||
|
||
function renderProbePending() {
|
||
el.liveStatus.textContent = "等待验证";
|
||
el.liveStatus.className = "tone-pending";
|
||
el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/device-pods;未完成前不标为通过。";
|
||
renderLiveBuilds(null);
|
||
renderCodeAgentSummary();
|
||
}
|
||
|
||
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc"), requestedSessionId = sessionIdForNextRequest(), threadIdForRequest = threadIdForNextRequest()) {
|
||
const sessionId = requestedSessionId || undefined;
|
||
const threadId = threadIdForRequest || undefined;
|
||
const response = await fetchJson("/v1/agent/chat", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Trace-Id": traceId,
|
||
"Prefer": "respond-async",
|
||
"X-HWLAB-Short-Connection": "1"
|
||
},
|
||
timeoutMs: CODE_AGENT_SUBMIT_TIMEOUT_MS,
|
||
timeoutName: "Code Agent",
|
||
body: JSON.stringify({
|
||
message,
|
||
conversationId,
|
||
sessionId,
|
||
threadId,
|
||
traceId,
|
||
providerProfile: CODE_AGENT_PROVIDER_PROFILE,
|
||
gatewayShellTimeoutMs: GATEWAY_SHELL_TIMEOUT_MS,
|
||
shortConnection: true,
|
||
projectId: WORKBENCH_PROJECT_ID,
|
||
workspaceId: state.workspaceId,
|
||
expectedWorkspaceRevision: state.workspaceRevision,
|
||
updatedByClient: "cloud-web"
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
if (isBlockedAgentResponse(response.data, response.status)) {
|
||
return normalizeBlockedAgentResult(response.data, response.status, traceId, response.error);
|
||
}
|
||
const error = agentErrorFromHttpResponse(response, traceId);
|
||
throw error;
|
||
}
|
||
if (response.data?.workspaceId) state.workspaceId = response.data.workspaceId;
|
||
if (Number.isInteger(response.data?.workspaceRevision)) state.workspaceRevision = response.data.workspaceRevision;
|
||
if (response.status === 202 || response.data?.shortConnection === true) {
|
||
return waitForAgentMessageResult(traceId, response.data);
|
||
}
|
||
return {
|
||
...response.data,
|
||
traceId: response.data?.traceId || traceId
|
||
};
|
||
}
|
||
|
||
async function sendAgentSteer(message, targetTraceId, steerTraceId, conversationId, sessionId, threadId) {
|
||
const response = await fetchJson("/v1/agent/chat/steer", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Trace-Id": targetTraceId,
|
||
"Prefer": "respond-async",
|
||
"X-HWLAB-Short-Connection": "1"
|
||
},
|
||
timeoutMs: CODE_AGENT_SUBMIT_TIMEOUT_MS,
|
||
timeoutName: "Code Agent steer",
|
||
body: JSON.stringify({
|
||
traceId: targetTraceId,
|
||
targetTraceId,
|
||
steerTraceId,
|
||
message,
|
||
conversationId,
|
||
sessionId,
|
||
threadId
|
||
})
|
||
});
|
||
if (!response.ok) throw agentErrorFromHttpResponse(response, targetTraceId);
|
||
return {
|
||
...response.data,
|
||
traceId: response.data?.traceId || targetTraceId,
|
||
steerTraceId: response.data?.steerTraceId || steerTraceId
|
||
};
|
||
}
|
||
|
||
async function waitForAgentMessageResult(traceId, accepted = {}) {
|
||
const startedAt = Date.now();
|
||
const resultPath = accepted?.resultUrl || `/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
||
let transientPollErrors = 0;
|
||
while (true) {
|
||
const activity = readActivityRef(() => state.currentRequest?.traceId === traceId ? state.currentRequest : null, startedAt);
|
||
const idleMs = Math.max(0, Date.now() - activity.lastActivityAt);
|
||
if (idleMs >= CODE_AGENT_TIMEOUT_MS) {
|
||
const error = new Error(`Code Agent 超过 ${CODE_AGENT_TIMEOUT_MS}ms 无新事件`);
|
||
error.code = "client_trace_idle_timeout";
|
||
error.timeoutMs = CODE_AGENT_TIMEOUT_MS;
|
||
error.idleMs = idleMs;
|
||
error.lastActivityAt = activity.lastActivityIso;
|
||
error.waitingFor = activity.waitingFor;
|
||
error.traceId = traceId;
|
||
throw error;
|
||
}
|
||
const response = await fetchJson(resultPath, {
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 3000),
|
||
timeoutName: "Code Agent result"
|
||
});
|
||
if (response.ok && response.status === 200 && response.data?.status && response.data.status !== "running") {
|
||
return {
|
||
...response.data,
|
||
traceId: response.data.traceId || traceId
|
||
};
|
||
}
|
||
if (response.ok) {
|
||
transientPollErrors = 0;
|
||
}
|
||
if (!response.ok && response.status && response.status !== 404) {
|
||
if (isTransientCodeAgentResultPollError(response)) {
|
||
transientPollErrors += 1;
|
||
await refreshTraceAfterResultPollError(traceId);
|
||
await wait(Math.min(TRACE_POLL_INTERVAL_MS * Math.min(transientPollErrors, 5), 5000));
|
||
continue;
|
||
}
|
||
const error = agentErrorFromHttpResponse(response, traceId);
|
||
throw error;
|
||
}
|
||
await wait(Math.min(TRACE_POLL_INTERVAL_MS, 1000));
|
||
}
|
||
}
|
||
|
||
function isTransientCodeAgentResultPollError(response) {
|
||
if (response?.timeout === true) return true;
|
||
const status = Number(response?.status ?? 0);
|
||
if ([408, 425, 429, 500, 502, 503, 504].includes(status)) return true;
|
||
return /响应不是 JSON|非 JSON|空响应|请求失败/u.test(String(response?.error ?? ""));
|
||
}
|
||
|
||
async function refreshTraceAfterResultPollError(traceId) {
|
||
const messageId = state.currentRequest?.traceId === traceId ? state.currentRequest.messageId : null;
|
||
if (!messageId) return;
|
||
try {
|
||
const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(traceId)}`, {
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 3000),
|
||
timeoutName: "Code Agent trace"
|
||
});
|
||
if (response.ok) updateMessageTrace(messageId, response.data, { quiet: true });
|
||
} catch {
|
||
// Result polling owns the terminal decision; trace refresh is best-effort.
|
||
}
|
||
}
|
||
|
||
function shouldReconcileCodeAgentResult(message, runnerTrace = message?.runnerTrace) {
|
||
if (!message?.traceId || message.role !== "agent") return false;
|
||
if (state.resultReconciliationInFlight.has(message.traceId)) return false;
|
||
const status = String(message.status ?? "").toLowerCase();
|
||
if (["completed", "canceled"].includes(status)) return false;
|
||
if (["failed", "timeout", "error"].includes(status)) return traceHasTerminalResultEvent(runnerTrace) || isRecoverableCodeAgentTerminalMessage(message);
|
||
return traceHasTerminalResultEvent(runnerTrace);
|
||
}
|
||
|
||
function shouldReconcileRestoredCodeAgentResult(message) {
|
||
if (!message?.traceId || message.role !== "agent") return false;
|
||
if (state.resultReconciliationInFlight.has(message.traceId)) return false;
|
||
const status = String(message.status ?? "").toLowerCase();
|
||
if (["completed", "canceled"].includes(status)) return false;
|
||
return traceHasTerminalResultEvent(message.runnerTrace);
|
||
}
|
||
|
||
function isRecoverableCodeAgentTerminalMessage(message) {
|
||
const code = normalizeErrorCode(message?.error?.code);
|
||
return [
|
||
"client_trace_idle_timeout",
|
||
"client_timeout",
|
||
"proxy_timeout",
|
||
"cloud_api_proxy_timeout",
|
||
"code_agent_background_failed",
|
||
"code_agent_session_evidence_missing"
|
||
].includes(code) || isCodeAgentTimeoutError(message?.error, message?.text);
|
||
}
|
||
|
||
function traceHasTerminalResultEvent(runnerTrace) {
|
||
const events = Array.isArray(runnerTrace?.events) ? runnerTrace.events : [];
|
||
const terminalLabel = runnerTrace?.lastEvent?.label ?? events.at(-1)?.label;
|
||
if (/^(?:result|session):(completed|failed|canceled|session_busy)/u.test(String(terminalLabel ?? ""))) return true;
|
||
return events.some((event) => /^(?:result|session):(completed|failed|canceled|session_busy)/u.test(String(event?.label ?? "")));
|
||
}
|
||
|
||
async function reconcileCodeAgentResult(messageId, options = {}) {
|
||
const index = state.chatMessages.findIndex((message) => message.id === messageId);
|
||
if (index < 0) return false;
|
||
const message = state.chatMessages[index];
|
||
if (!message.traceId || state.resultReconciliationInFlight.has(message.traceId)) return false;
|
||
state.resultReconciliationInFlight.add(message.traceId);
|
||
try {
|
||
const response = await fetchJson(`/v1/agent/chat/result/${encodeURIComponent(message.traceId)}`, {
|
||
timeoutMs: Math.min(API_TIMEOUT_MS, 3000),
|
||
timeoutName: "Code Agent result reconcile"
|
||
});
|
||
if (!response.ok || !response.data || response.data.status === "running") return false;
|
||
const updated = applyCodeAgentResultToMessage(messageId, response.data, {
|
||
retryInput: message.retryInput,
|
||
retryOf: message.retryOf,
|
||
fallbackConversationId: message.conversationId,
|
||
fallbackThreadId: message.threadId
|
||
});
|
||
if (!updated) return false;
|
||
renderAgentChatStatus(updated.status, updated);
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderDevicePodPanel(state.liveSurface);
|
||
return true;
|
||
} finally {
|
||
state.resultReconciliationInFlight.delete(message.traceId);
|
||
}
|
||
}
|
||
|
||
function wait(ms) {
|
||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||
}
|
||
|
||
async function loadLiveSurface() {
|
||
const selectedDevicePodId = state.devicePod.selectedDevicePodId || "device-pod-71-freq";
|
||
const encodedPodId = encodeURIComponent(selectedDevicePodId);
|
||
const [healthLive, liveBuilds, restIndex, devicePods, devicePodStatus, devicePodEvents, devicePodChipId, devicePodUart, devicePodUartTail, health, adapter] = await Promise.all([
|
||
liveSurfaceFetch("/health/live"),
|
||
liveSurfaceFetch("/v1/live-builds"),
|
||
liveSurfaceFetch("/v1"),
|
||
liveSurfaceFetch("/v1/device-pods"),
|
||
liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/status`),
|
||
liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/events?limit=120`),
|
||
liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/debug-probe/chip-id`),
|
||
liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/io-probe/uart/1`),
|
||
liveSurfaceFetch(`/v1/device-pods/${encodedPodId}/io-probe/uart/1/tail?maxBytes=12000`),
|
||
liveSurfaceRpc("system.health"),
|
||
liveSurfaceRpc("cloud.adapter.describe")
|
||
]);
|
||
|
||
return {
|
||
observedAt: new Date().toISOString(),
|
||
healthLive,
|
||
liveBuilds,
|
||
restIndex,
|
||
devicePods,
|
||
devicePodStatus,
|
||
devicePodEvents,
|
||
devicePodChipId,
|
||
devicePodUart,
|
||
devicePodUartTail,
|
||
health,
|
||
adapter
|
||
};
|
||
}
|
||
|
||
function liveSurfaceFetch(path) {
|
||
return fetchJson(path, {
|
||
timeoutMs: LIVE_SURFACE_TIMEOUT_MS,
|
||
timeoutName: `工作台实况 ${path}`
|
||
});
|
||
}
|
||
|
||
function liveSurfaceRpc(method, params = {}) {
|
||
return callRpc(method, params, {
|
||
timeoutMs: LIVE_SURFACE_TIMEOUT_MS,
|
||
timeoutName: `工作台实况 /json-rpc ${method}`
|
||
});
|
||
}
|
||
|
||
async function loadGateDiagnostics() {
|
||
if (state.gateDiagnostics.loading) return;
|
||
state.gateDiagnostics.loading = true;
|
||
state.gateDiagnostics.error = null;
|
||
renderGateTable();
|
||
const response = await fetchJson("/v1/diagnostics/gate", {
|
||
timeoutMs: LIVE_SURFACE_TIMEOUT_MS,
|
||
timeoutName: "内部复核 live 聚合"
|
||
});
|
||
state.gateDiagnostics.loading = false;
|
||
if (response.ok && Array.isArray(response.data?.rows)) {
|
||
state.gateDiagnostics.payload = response.data;
|
||
state.gateDiagnostics.rows = response.data.rows.map(normalizeGateRow);
|
||
state.gateDiagnostics.loadedAt = new Date().toISOString();
|
||
} else {
|
||
state.gateDiagnostics.payload = null;
|
||
state.gateDiagnostics.rows = [gateLiveBlockerRow(response)];
|
||
state.gateDiagnostics.error = response.error ?? "内部复核 live 后端聚合不可用";
|
||
state.gateDiagnostics.loadedAt = new Date().toISOString();
|
||
}
|
||
renderGateTable();
|
||
}
|
||
|
||
async function fetchJson(path, options = {}) {
|
||
const {
|
||
timeoutMs = API_TIMEOUT_MS,
|
||
timeoutName = path,
|
||
activityRef = null,
|
||
...fetchOptions
|
||
} = options;
|
||
const controller = new AbortController();
|
||
let timer = null;
|
||
let timeoutDetail = null;
|
||
const startedAt = Date.now();
|
||
const activityTimeout = Boolean(activityRef);
|
||
const scheduleTimeout = () => {
|
||
const activity = readActivityRef(activityRef, startedAt);
|
||
const idleMs = Math.max(0, Date.now() - activity.lastActivityAt);
|
||
const remainingMs = timeoutMs - idleMs;
|
||
if (remainingMs <= 0) {
|
||
timeoutDetail = { ...activity, idleMs };
|
||
controller.abort();
|
||
return;
|
||
}
|
||
timer = window.setTimeout(scheduleTimeout, Math.max(1, remainingMs));
|
||
};
|
||
scheduleTimeout();
|
||
try {
|
||
const response = await fetch(path, {
|
||
...fetchOptions,
|
||
headers: {
|
||
Accept: "application/json",
|
||
...(fetchOptions.headers ?? {})
|
||
},
|
||
signal: controller.signal
|
||
});
|
||
const text = await response.text();
|
||
let data = null;
|
||
try {
|
||
data = text ? JSON.parse(text) : null;
|
||
} catch (error) {
|
||
if (response.ok) throw error;
|
||
return {
|
||
ok: false,
|
||
path,
|
||
status: response.status,
|
||
data: null,
|
||
error: `${path} HTTP ${response.status}:响应不是 JSON`
|
||
};
|
||
}
|
||
if (!response.ok) {
|
||
return {
|
||
ok: false,
|
||
path,
|
||
status: response.status,
|
||
data,
|
||
error: `${path} HTTP ${response.status}:${messageFromPayload(data)}`
|
||
};
|
||
}
|
||
return { ok: true, path, status: response.status, data };
|
||
} catch (error) {
|
||
const timedOut = error.name === "AbortError";
|
||
const timeoutState = timeoutDetail ?? readActivityRef(activityRef, startedAt);
|
||
const idleMs = timedOut ? Math.max(0, Date.now() - timeoutState.lastActivityAt) : undefined;
|
||
return {
|
||
ok: false,
|
||
path,
|
||
timeout: timedOut,
|
||
timeoutMs,
|
||
idleMs,
|
||
lastActivityAt: timedOut ? timeoutState.lastActivityIso : undefined,
|
||
waitingFor: timedOut ? timeoutState.waitingFor : undefined,
|
||
error: timedOut
|
||
? activityTimeout
|
||
? `${timeoutName} 超过 ${timeoutMs}ms 无新事件`
|
||
: `${timeoutName} 请求超时 ${timeoutMs}ms`
|
||
: `请求失败:${error.message}`
|
||
};
|
||
} finally {
|
||
if (timer) window.clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function readActivityRef(activityRef, fallbackMs = Date.now()) {
|
||
const ref = typeof activityRef === "function" ? activityRef() : activityRef;
|
||
const lastActivityAt = Number(ref?.lastActivityAt);
|
||
const safeLastActivityAt = Number.isFinite(lastActivityAt) && lastActivityAt > 0 ? lastActivityAt : fallbackMs;
|
||
return {
|
||
lastActivityAt: safeLastActivityAt,
|
||
lastActivityIso: typeof ref?.lastActivityIso === "string" ? ref.lastActivityIso : new Date(safeLastActivityAt).toISOString(),
|
||
waitingFor: typeof ref?.waitingFor === "string" ? ref.waitingFor : null,
|
||
lastEventLabel: typeof ref?.lastEventLabel === "string" ? ref.lastEventLabel : null
|
||
};
|
||
}
|
||
|
||
async function callRpc(method, params = {}, options = {}) {
|
||
const id = nextProtocolId("req");
|
||
const traceId = nextProtocolId("trc");
|
||
const response = await fetchJson("/json-rpc", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json"
|
||
},
|
||
body: JSON.stringify({
|
||
jsonrpc: "2.0",
|
||
id,
|
||
method,
|
||
params,
|
||
meta: {
|
||
traceId,
|
||
serviceId: "hwlab-cloud-web",
|
||
environment: "dev"
|
||
}
|
||
}),
|
||
timeoutMs: options?.timeoutMs ?? API_TIMEOUT_MS,
|
||
timeoutName: options?.timeoutName ?? `/json-rpc ${method}`
|
||
});
|
||
|
||
if (!response.ok) {
|
||
return { ...response, method };
|
||
}
|
||
if (response.data?.error) {
|
||
return {
|
||
ok: false,
|
||
path: response.path,
|
||
status: response.status,
|
||
method,
|
||
error: `RPC 错误 ${method}:${response.data.error.message}`,
|
||
envelope: response.data
|
||
};
|
||
}
|
||
return {
|
||
ok: true,
|
||
path: response.path,
|
||
status: response.status,
|
||
method,
|
||
data: response.data?.result,
|
||
envelope: response.data
|
||
};
|
||
}
|
||
|
||
|
||
function nextProtocolId(prefix) {
|
||
rpcSequence += 1;
|
||
return `${prefix}_cloud-web-workbench-${Date.now()}-${rpcSequence}`;
|
||
}
|
||
|
||
function renderLiveSurface(live) {
|
||
state.liveSurface = live;
|
||
const coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter];
|
||
const surfaceStatus = workbenchApiSurfaceStatus(live, coreProbes);
|
||
const runtimeSummary = runtimeSummaryFrom(live);
|
||
|
||
el.liveStatus.textContent = surfaceStatus.label;
|
||
el.liveStatus.className = `tone-${toneClass(surfaceStatus.tone)}`;
|
||
state.codeAgentAvailability = codeAgentAvailabilityFrom(live);
|
||
el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary();
|
||
|
||
renderLiveBuilds(live.liveBuilds);
|
||
renderAgentChatStatus(deriveAgentChatStatus());
|
||
renderCodeAgentSummary();
|
||
renderDevicePodPanel(live);
|
||
renderConversation();
|
||
renderDrafts();
|
||
}
|
||
|
||
function renderLiveBuilds(response) {
|
||
const payload = response?.ok ? response.data : null;
|
||
const services = Array.isArray(payload?.services) ? payload.services : [];
|
||
const latest = payload?.latest ?? null;
|
||
|
||
if (latest?.build?.createdAt) {
|
||
el.liveBuildLatest.textContent = [
|
||
`最新镜像构建时间:${formatBeijingTime(latest.build.createdAt)}`,
|
||
latest.name || latest.serviceId || "未知服务",
|
||
`tag ${latest.image?.tag || "unknown"}`,
|
||
`commit ${shortToken(latest.commit?.id)}`,
|
||
`revision ${shortToken(latest.revision)}`,
|
||
`来源 ${liveBuildSourceLabel(latest.build?.metadataSource)}`
|
||
].join(" · ");
|
||
} else {
|
||
el.liveBuildLatest.textContent = "最新镜像构建时间:构建时间不可用";
|
||
}
|
||
|
||
if (!response) {
|
||
replaceChildren(
|
||
el.liveBuildList,
|
||
liveBuildUnavailableRow("等待实时元数据聚合返回。")
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!response.ok) {
|
||
replaceChildren(
|
||
el.liveBuildList,
|
||
liveBuildUnavailableRow(`构建时间不可用:${response.error || "实时元数据聚合不可用"}`)
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!services.length) {
|
||
replaceChildren(
|
||
el.liveBuildList,
|
||
liveBuildUnavailableRow("构建时间不可用:当前实时元数据未返回微服务明细。")
|
||
);
|
||
return;
|
||
}
|
||
|
||
replaceChildren(el.liveBuildList, ...services.map(liveBuildRow));
|
||
}
|
||
|
||
function liveBuildRow(service) {
|
||
const row = document.createElement("div");
|
||
row.className = `live-build-row live-build-row-${toneClass(service.status)}`;
|
||
|
||
const header = document.createElement("div");
|
||
header.className = "live-build-row-head";
|
||
header.append(
|
||
textSpan(service.name || service.serviceId || "未知服务", "live-build-service"),
|
||
textSpan(liveBuildTimeText(service), "live-build-time")
|
||
);
|
||
|
||
const meta = document.createElement("div");
|
||
meta.className = "live-build-meta";
|
||
meta.append(
|
||
textSpan(`tag ${service.image?.tag || "unknown"}`),
|
||
textSpan(`commit ${shortToken(service.commit?.id)}`),
|
||
textSpan(`revision ${shortToken(service.revision)}`),
|
||
textSpan(`来源 ${liveBuildSourceLabel(service.build?.metadataSource)}`)
|
||
);
|
||
|
||
const desired = liveBuildDesiredStateText(service);
|
||
if (desired) meta.append(textSpan(desired));
|
||
|
||
const reasonText = liveBuildReasonText(service);
|
||
if (reasonText) {
|
||
row.append(header, meta, textSpan(reasonText, "live-build-reason"));
|
||
} else {
|
||
row.append(header, meta);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
function liveBuildUnavailableRow(reason) {
|
||
return liveBuildRow({
|
||
serviceId: "live-build-metadata",
|
||
name: "实时元数据",
|
||
kind: "hwlab",
|
||
status: "unavailable",
|
||
build: {
|
||
createdAt: null,
|
||
unavailableReason: reason
|
||
},
|
||
image: {
|
||
tag: "unknown"
|
||
},
|
||
commit: {
|
||
id: "unknown"
|
||
},
|
||
revision: "unknown"
|
||
});
|
||
}
|
||
|
||
function liveBuildTimeText(service) {
|
||
if (service.kind === "external") return "外部镜像";
|
||
const createdAt = service.build?.createdAt;
|
||
return createdAt ? formatBeijingTime(createdAt) : "构建时间不可用";
|
||
}
|
||
|
||
function liveBuildReasonText(service) {
|
||
if (service.kind === "external") return "外部镜像或非 HWLAB 构建产物,不参与最新 HWLAB 构建时间计算。";
|
||
if (service.build?.liveHealthMissingReason) return service.build.liveHealthMissingReason;
|
||
if (service.build?.unavailableReason) return service.build.unavailableReason;
|
||
if (service.error?.message) return `构建时间不可用:${service.error.message}`;
|
||
return "";
|
||
}
|
||
|
||
function liveBuildSourceLabel(source) {
|
||
const text = String(source ?? "").trim();
|
||
if (text.includes("artifact-catalog")) return "artifact catalog";
|
||
if (text.includes("deploy")) return "deploy metadata";
|
||
if (text.includes("runtime-env") || text.includes("health")) return "live health";
|
||
if (text.includes("external")) return "external image";
|
||
if (!text || text === "unavailable") return "不可用";
|
||
return text;
|
||
}
|
||
|
||
function liveBuildDesiredStateText(service) {
|
||
if (!service.desiredState || service.kind === "external") return "";
|
||
const parts = [];
|
||
if (Number.isFinite(Number(service.desiredState.replicas))) {
|
||
parts.push(`replicas ${Number(service.desiredState.replicas)}`);
|
||
}
|
||
if (service.desiredState.healthPath) parts.push(service.desiredState.healthPath);
|
||
return parts.length ? `desired ${parts.join(" / ")}` : "";
|
||
}
|
||
|
||
function renderDevicePodPanel(live) {
|
||
state.devicePod.list = live?.devicePods ?? null;
|
||
state.devicePod.status = live?.devicePodStatus ?? null;
|
||
state.devicePod.events = live?.devicePodEvents ?? null;
|
||
state.devicePod.chipId = live?.devicePodChipId ?? null;
|
||
state.devicePod.uart = live?.devicePodUart ?? null;
|
||
state.devicePod.uartTail = live?.devicePodUartTail ?? null;
|
||
renderDevicePodSelector();
|
||
renderDevicePodSummary();
|
||
renderDevicePodInterfaces();
|
||
renderDeviceEventStream();
|
||
}
|
||
|
||
function renderDevicePodSelector() {
|
||
const payload = state.devicePod.list?.ok ? state.devicePod.list.data : null;
|
||
const pods = Array.isArray(payload?.devicePods) && payload.devicePods.length > 0
|
||
? payload.devicePods
|
||
: [{ devicePodId: state.devicePod.selectedDevicePodId || "device-pod-71-freq" }];
|
||
const current = state.devicePod.selectedDevicePodId || pods[0]?.devicePodId || "device-pod-71-freq";
|
||
const existing = [...el.devicePodSelect.options].map((option) => option.value).join("\n");
|
||
const next = pods.map((pod) => pod.devicePodId).join("\n");
|
||
if (existing !== next) {
|
||
replaceChildren(el.devicePodSelect, ...pods.map((pod) => {
|
||
const option = document.createElement("option");
|
||
option.value = pod.devicePodId;
|
||
option.textContent = pod.devicePodId;
|
||
return option;
|
||
}));
|
||
}
|
||
el.devicePodSelect.value = current;
|
||
}
|
||
|
||
function renderDevicePodSummary() {
|
||
const response = state.devicePod.status;
|
||
const payload = response?.ok ? response.data : null;
|
||
const pod = payload?.devicePod ?? null;
|
||
const summary = payload?.summary ?? null;
|
||
const status = pod?.status ?? summary?.status ?? (response?.ok ? "ok" : "blocked");
|
||
const tone = response?.ok ? toneForDeviceStatus(status, summary?.blocker ?? pod?.blocker) : "blocked";
|
||
el.devicePodStatusTag.textContent = response?.ok ? statusLabel(status) : "未接入";
|
||
el.devicePodStatusTag.className = `state-tag tone-${toneClass(tone)}`;
|
||
el.devicePodId.textContent = pod?.devicePodId ?? state.devicePod.selectedDevicePodId ?? "device-pod-71-freq";
|
||
el.devicePodTarget.textContent = pod?.target?.targetId ?? summary?.targetId ?? "未观测";
|
||
el.devicePodProfile.textContent = shortHash(pod?.profile?.profileHash ?? summary?.profileHash ?? "未观测");
|
||
el.devicePodFreshness.textContent = freshnessLabel(pod?.freshness ?? summary?.freshness);
|
||
state.devicePod.details.set("pod", payload ?? response ?? { status: "waiting" });
|
||
}
|
||
|
||
function renderDevicePodInterfaces() {
|
||
const pod = state.devicePod.status?.ok ? state.devicePod.status.data?.devicePod : null;
|
||
const chip = state.devicePod.chipId?.ok ? state.devicePod.chipId.data : null;
|
||
const uart = state.devicePod.uart?.ok ? state.devicePod.uart.data : null;
|
||
const tail = state.devicePod.uartTail?.ok ? state.devicePod.uartTail.data : null;
|
||
const tiles = [
|
||
{ key: "target", title: "Target", value: pod?.target?.targetId ?? "未观测", detail: pod?.target?.lock?.status ? `lock=${pod.target.lock.status}` : "等待 Device Pod status", tone: pod ? "ok" : "pending", raw: pod?.target ?? null },
|
||
{ key: "workspace", title: "Workspace", value: pod?.projectWorkspace?.latestBuild?.artifact ?? "未观测", detail: pod?.projectWorkspace?.workspaceRoot ?? "等待 workspace 摘要", tone: pod?.projectWorkspace?.status ?? "pending", raw: pod?.projectWorkspace ?? null },
|
||
{ key: "debug", title: "Debug", value: chip?.chipId ?? pod?.debugInterface?.chipId ?? "未观测", detail: chip?.probe ?? pod?.debugInterface?.probe ?? "等待 probe 摘要", tone: chip?.status ?? pod?.debugInterface?.status ?? "pending", raw: chip ?? pod?.debugInterface ?? null },
|
||
{ key: "io", title: "IO", value: uart?.uart?.status ?? pod?.ioInterface?.uart1?.status ?? "未观测", detail: tail?.text ? compactOneLine(tail.text, 92) : "等待 UART1 tail", tone: uart?.status ?? pod?.ioInterface?.status ?? "pending", raw: { uart, tail, ioInterface: pod?.ioInterface ?? null } }
|
||
];
|
||
state.devicePod.details.clear();
|
||
state.devicePod.details.set("pod", state.devicePod.status?.data ?? state.devicePod.status ?? null);
|
||
replaceChildren(el.devicePodInterfaces, ...tiles.map(deviceSummaryTile));
|
||
}
|
||
|
||
function deviceSummaryTile(tile) {
|
||
state.devicePod.details.set(tile.key, tile.raw ?? tile);
|
||
const article = document.createElement("article");
|
||
article.className = `summary-tile tone-border-${toneClass(tile.tone)}`;
|
||
article.tabIndex = 0;
|
||
article.dataset.deviceDetail = tile.key;
|
||
article.dataset.deviceTitle = tile.title;
|
||
article.setAttribute("role", "button");
|
||
article.setAttribute("aria-label", `查看 ${tile.title} 详情`);
|
||
article.addEventListener("keydown", (event) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
event.preventDefault();
|
||
showDeviceDetail(tile.title, state.devicePod.details.get(tile.key));
|
||
});
|
||
article.append(textSpan(tile.title, "summary-title"));
|
||
article.append(textSpan(tile.value, "summary-value mono wrap"));
|
||
article.append(textSpan(tile.detail, "summary-detail"));
|
||
return article;
|
||
}
|
||
|
||
function renderDeviceEventStream() {
|
||
const payload = state.devicePod.events?.ok ? state.devicePod.events.data : null;
|
||
const lines = Array.isArray(payload?.lines) ? payload.lines : [];
|
||
const nextText = lines.length > 0 ? lines.join("\n") : "等待 /v1/device-pods 事件流。";
|
||
const wasNearBottom = deviceEventNearBottom();
|
||
const previousLineCount = state.devicePod.lastEventLineCount;
|
||
const nextLineCount = lines.length;
|
||
if (el.deviceEventText.textContent !== nextText) {
|
||
el.deviceEventText.textContent = nextText;
|
||
if (state.devicePod.followEvents && wasNearBottom && !deviceEventUserActive()) {
|
||
scrollDeviceEventsToBottom();
|
||
} else if (nextLineCount > previousLineCount) {
|
||
state.devicePod.unreadEvents += nextLineCount - previousLineCount;
|
||
}
|
||
}
|
||
state.devicePod.lastEventLineCount = nextLineCount;
|
||
renderDeviceEventUnreadHint();
|
||
}
|
||
|
||
function markDeviceEventScrollIntent() {
|
||
state.devicePod.eventScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS;
|
||
}
|
||
|
||
function deviceEventUserActive() {
|
||
return Date.now() < state.devicePod.eventScrollUserActiveUntil;
|
||
}
|
||
|
||
function setDeviceEventFollow(enabled, options = {}) {
|
||
state.devicePod.followEvents = enabled === true;
|
||
el.deviceEventFollow.setAttribute("aria-pressed", state.devicePod.followEvents ? "true" : "false");
|
||
el.deviceEventFollow.textContent = state.devicePod.followEvents ? "跟随" : "暂停跟随";
|
||
if (state.devicePod.followEvents && options.scroll === true) scrollDeviceEventsToBottom();
|
||
}
|
||
|
||
function deviceEventNearBottom() {
|
||
return scrollBottomGap(el.deviceEventScroll) < 24;
|
||
}
|
||
|
||
function scrollDeviceEventsToBottom() {
|
||
el.deviceEventScroll.scrollTop = el.deviceEventScroll.scrollHeight;
|
||
}
|
||
|
||
function renderDeviceEventUnreadHint() {
|
||
const count = state.devicePod.unreadEvents;
|
||
el.deviceEventJump.hidden = count <= 0;
|
||
el.deviceEventJump.textContent = `有 ${count} 条新事件`;
|
||
}
|
||
|
||
function showDeviceDetail(title, payload) {
|
||
el.deviceDetailTitle.textContent = title || "Device Pod 详情";
|
||
el.deviceDetailBody.textContent = JSON.stringify(payload ?? { status: "empty" }, null, 2);
|
||
if (typeof el.deviceDetailDialog.showModal === "function") {
|
||
el.deviceDetailDialog.showModal();
|
||
} else {
|
||
el.deviceDetailDialog.setAttribute("open", "");
|
||
}
|
||
}
|
||
|
||
function toneForDeviceStatus(status, blocker = null) {
|
||
if (blocker) return "blocked";
|
||
const value = String(status ?? "").toLowerCase();
|
||
if (["ok", "ready", "live", "pass", "healthy"].includes(value)) return "ok";
|
||
if (["blocked", "failed", "error"].includes(value)) return "blocked";
|
||
return "pending";
|
||
}
|
||
|
||
function freshnessLabel(freshness) {
|
||
if (!freshness) return "未观测";
|
||
const ageMs = Number(freshness.ageMs);
|
||
const age = Number.isFinite(ageMs) ? `${Math.round(ageMs / 1000)}s` : "未知";
|
||
return `${freshness.stale ? "过期" : "新鲜"} / age=${age}`;
|
||
}
|
||
|
||
function shortHash(value) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return "未观测";
|
||
return text.length > 26 ? `${text.slice(0, 23)}...` : text;
|
||
}
|
||
|
||
function compactOneLine(value, maxLength = 120) {
|
||
const text = String(value ?? "").replace(/\s+/gu, " ").trim();
|
||
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
||
}
|
||
|
||
function formatBeijingTime(value) {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return "构建时间不可用";
|
||
const parts = Object.fromEntries(new Intl.DateTimeFormat("zh-CN", {
|
||
timeZone: "Asia/Shanghai",
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
hour12: false
|
||
}).formatToParts(date).map((part) => [part.type, part.value]));
|
||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} 北京时间`;
|
||
}
|
||
|
||
function shortToken(value) {
|
||
const text = String(value ?? "unknown").trim() || "unknown";
|
||
return text.length > 12 ? text.slice(0, 12) : text;
|
||
}
|
||
|
||
function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) {
|
||
const status = classifyWorkbenchLiveStatus({
|
||
...live,
|
||
healthLive: live.healthLive ?? coreProbes[0],
|
||
restIndex: live.restIndex ?? coreProbes[1],
|
||
health: live.health ?? coreProbes[2],
|
||
adapter: live.adapter ?? coreProbes[3]
|
||
});
|
||
return {
|
||
...status,
|
||
methodTone: status.kind === "pass" ? "live" : status.kind
|
||
};
|
||
}
|