Files
pikasTech-HWLAB/web/hwlab-cloud-web/app-device-pod.ts
T
2026-06-02 12:25:16 +08:00

1640 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
};
}