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() { 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.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 = ""; renderAgentChatStatus("idle"); renderCodeAgentSummary(); renderConversation(); renderDrafts(); renderDevicePodPanel(state.liveSurface); }); } async function submitAgentMessage(value, options = {}) { if (!value || 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 requestedThreadId = options.threadId === undefined ? threadIdForNextRequest() : options.threadId; const pendingContinuity = pendingSessionContinuity({ conversationId: activeConversationId, sessionId: requestedSessionId, threadId: requestedThreadId, 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: requestedThreadId, 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: requestedThreadId, sessionContinuity: pendingContinuity, retryInput: value, retryOf: options.retryOf ?? null, sourceKind: "PENDING", createdAt: new Date().toISOString() }; state.chatMessages.push(userMessage, pendingMessage); state.chatPending = true; state.currentRequest = { traceId, conversationId: activeConversationId, sessionId: requestedSessionId, threadId: requestedThreadId, messageId: pendingMessage.id, input: value, lastActivityAt: Date.now(), lastActivityIso: new Date().toISOString(), traceEventCount: 0, waitingFor: "agent/chat", lastEventLabel: "request:submitted" }; el.commandInput.value = ""; renderAgentChatStatus("running"); renderCodeAgentSummary(); renderConversation(); renderDrafts(); renderDevicePodPanel(state.liveSurface); const stopTraceStream = subscribeRunnerTrace(traceId, pendingMessage.id); try { const result = await sendAgentMessage(value, activeConversationId, traceId, requestedSessionId, requestedThreadId); 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, requestedThreadId, retryInput: value, retryOf: options.retryOf ?? null }); if (result.availability) { state.codeAgentAvailability = result.availability; } if (!["completed", "source"].includes(updatedMessage?.status)) { el.commandInput.value = value; } renderAgentChatStatus(updatedMessage?.status, state.chatMessages[index]); renderCodeAgentSummary(); } 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: requestedThreadId, 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; renderAgentChatStatus(failedStatus, state.chatMessages[index]); renderCodeAgentSummary(); } finally { if (state.currentRequest?.traceId === traceId) { state.currentRequest = null; state.chatPending = false; } else if (!state.currentRequest) { state.chatPending = false; } stopTraceStream(); renderCodeAgentSummary(); renderConversation(); renderDrafts(); renderDevicePodPanel(state.liveSurface); } } 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 || options.requestedThreadId || 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(), requestedThreadId = threadIdForNextRequest()) { const sessionId = requestedSessionId || undefined; const threadId = requestedThreadId || 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 }) }); 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.status === 202 || response.data?.shortConnection === true) { return waitForAgentMessageResult(traceId, response.data); } return { ...response.data, traceId: response.data?.traceId || traceId }; } 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 }; }