Files
pikasTech-HWLAB/web/hwlab-cloud-web/app.mjs
T
2026-05-28 08:06:24 +08:00

5269 lines
207 KiB
JavaScript
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.
import { ensureWorkbenchAuth, initWorkbenchLogout } from "./auth.mjs";
import {
codeAgentRuntimePathFromMessage,
runnerTraceSummary as compactRunnerTraceSummary,
skillsSummary as compactSkillsSummary,
toolCallsSummary as compactToolCallsSummary
} from "./code-agent-facts.mjs";
import { classifyCodeAgentStatusSummary, codeAgentAvailabilityFromLive } from "./code-agent-status.mjs";
import { classifyWorkbenchLiveStatus } from "./live-status.mjs";
import { marked } from "./third_party/marked/marked.esm.js";
import { hardenRenderedMarkdown, renderMessageMarkdown } from "./message-markdown.mjs";
const DEFAULT_API_TIMEOUT_MS = 4500;
const WORKBENCH_PROJECT_ID = "prj_device_pod_workbench";
const DEFAULT_LIVE_SURFACE_TIMEOUT_MS = 12000;
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 1800000;
const MAX_CODE_AGENT_TIMEOUT_MS = 2400000;
const DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS = 60000;
const DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS = 30000;
const DEFAULT_GATEWAY_SHELL_TIMEOUT_MS = 120000;
const CODE_AGENT_TIMEOUT_STORAGE_KEY = "hwlab.workbench.codeAgentTimeoutMs.v1";
const GATEWAY_SHELL_TIMEOUT_STORAGE_KEY = "hwlab.workbench.gatewayShellTimeoutMs.v1";
const CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY = "hwlab.workbench.codeAgentProviderProfile.v1";
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
const CODE_AGENT_PROVIDER_PROFILES = Object.freeze(["deepseek", "codex-api"]);
const CODE_AGENT_SESSION_STORAGE_KEY = "hwlab.workbench.codeAgentSession.v1";
const CODE_AGENT_SESSION_STORAGE_VERSION = 1;
const CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
const CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT = 30;
const CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT = 80;
const CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS = 250;
const TRACE_STREAM_FALLBACK_MS = 2500;
const TRACE_POLL_INTERVAL_MS = 1000;
const FULL_TRACE_REPLAY_TIMEOUT_MS = 15000;
const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 });
const LIVE_SURFACE_TIMEOUT_MS = resolveTimeoutMs("liveSurfaceTimeoutMs", DEFAULT_LIVE_SURFACE_TIMEOUT_MS, { min: 5000, max: 60000 });
let CODE_AGENT_TIMEOUT_MS = resolveUserCodeAgentTimeoutMs();
let GATEWAY_SHELL_TIMEOUT_MS = resolveUserGatewayShellTimeoutMs();
let CODE_AGENT_PROVIDER_PROFILE = resolveUserCodeAgentProviderProfile();
const CODE_AGENT_SUBMIT_TIMEOUT_MS = resolveTimeoutMs("codeAgentSubmitTimeoutMs", DEFAULT_CODE_AGENT_SUBMIT_TIMEOUT_MS, { min: configuredTimeoutMinMs("codeAgentSubmitTimeoutMs", 5000), max: 120000 });
const CODE_AGENT_CANCEL_TIMEOUT_MS = resolveTimeoutMs("codeAgentCancelTimeoutMs", DEFAULT_CODE_AGENT_CANCEL_TIMEOUT_MS, { min: 5000, max: 120000 });
const rpcReadMethods = Object.freeze([
"system.health",
"cloud.adapter.describe"
]);
const STATUS_LABELS = Object.freeze({
active: "活动",
available: "可用",
blocked_after_cloud_api: "cloud-api 后阻塞",
blocked: "待处理",
canceled: "已取消",
completed: "完成",
connected: "已连接",
degraded: "降级",
disabled: "禁用",
entry: "入口",
failed: "失败",
healthy: "健康",
live: "实况",
ok: "正常",
open: "未关闭",
pass: "通过",
pending: "等待",
running: "处理中",
timeout: "超时",
error: "错误",
probing: "探测中",
ready: "就绪",
recorded: "已记录",
sent: "已发送",
requires: "依赖",
source: "来源",
unverified: "未验证",
"dry-run": "演练",
"dev-live": "实况已验证"
});
const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["codex-stdio"]);
const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]);
const CODEX_APP_SERVER_RUNNER_KIND = "codex-app-server-stdio-runner";
const CODEX_APP_SERVER_SESSION_MODE = "codex-app-server-stdio-long-lived";
const CODEX_APP_SERVER_IMPLEMENTATION_TYPE = "repo-owned-codex-app-server-stdio-session";
const CODEX_APP_SERVER_PROTOCOL = "codex-app-server-jsonrpc-stdio";
const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([
"codex_cli_binary_missing",
"runner_lifecycle_missing",
"stdio_protocol_not_wired",
"codex_stdio_supervisor_disabled",
"not-codex-stdio",
"not-write-capable",
"process-local-session-registry"
]);
const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1";
const LEFT_SIDEBAR_DEFAULT_WIDTH = 92;
const LEFT_SIDEBAR_MIN_WIDTH = 72;
const LEFT_SIDEBAR_MAX_WIDTH = 180;
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 728;
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
const RIGHT_SIDEBAR_MAX_WIDTH = 740;
const SCROLL_BOTTOM_PIN_PX = 24;
const SCROLL_USER_ACTIVITY_MS = 700;
const MESSAGE_MARKDOWN_STATUSES = Object.freeze(["completed", "source"]);
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
let rpcSequence = 0;
const el = {
loginShell: byId("login-shell"),
shell: query("[data-app-shell]"),
logoutButton: byId("logout-button"),
leftSidebarToggle: byId("left-sidebar-toggle"),
leftSidebarResize: byId("left-sidebar-resize"),
rightSidebarResize: byId("right-sidebar-resize"),
routePath: byId("route-path"),
liveStatus: byId("live-status"),
liveDetail: byId("live-detail"),
liveBuildSummary: byId("live-build-summary"),
liveBuildToggle: byId("live-build-toggle"),
liveBuildLatest: byId("live-build-latest"),
liveBuildList: byId("live-build-list"),
agentChatStatus: byId("agent-chat-status"),
codeAgentSummary: byId("code-agent-summary"),
codeAgentSummaryIcon: byId("code-agent-summary-icon"),
codeAgentSummaryLabel: byId("code-agent-summary-label"),
codeAgentSummaryCapability: byId("code-agent-summary-capability"),
codeAgentSummaryTrace: byId("code-agent-summary-trace"),
codeAgentSummaryDetail: byId("code-agent-summary-detail"),
conversationList: byId("conversation-list"),
gateSourceStatus: byId("gate-source-status"),
gateStatusFilter: byId("gate-status-filter"),
gateSearch: byId("gate-search"),
gateRefresh: byId("gate-refresh"),
gateReviewBody: byId("gate-review-body"),
commandForm: byId("command-form"),
commandInput: byId("command-input"),
codeAgentProviderProfile: byId("code-agent-provider-profile"),
codeAgentTimeout: byId("code-agent-timeout"),
gatewayShellTimeout: byId("gateway-shell-timeout"),
commandSend: byId("command-send"),
commandClear: byId("command-clear"),
devicePodSelect: byId("device-pod-select"),
devicePodStatusTag: byId("device-pod-status-tag"),
devicePodSummary: byId("device-pod-summary"),
devicePodId: byId("device-pod-id"),
devicePodTarget: byId("device-pod-target"),
devicePodProfile: byId("device-pod-profile"),
devicePodFreshness: byId("device-pod-freshness"),
devicePodInterfaces: byId("device-pod-interfaces"),
deviceEventFollow: byId("device-event-follow"),
deviceEventJump: byId("device-event-jump"),
deviceEventScroll: byId("device-event-scroll"),
deviceEventText: byId("device-event-text"),
deviceDetailDialog: byId("device-detail-dialog"),
deviceDetailTitle: byId("device-detail-title"),
deviceDetailBody: byId("device-detail-body"),
helpContent: byId("help-content"),
helpStatus: byId("help-status")
};
let activeWorkbenchDialog = null;
await ensureWorkbenchAuth({
loginShell: el.loginShell,
appShell: el.shell
});
const state = {
conversationId: null,
sessionId: null,
threadId: null,
codeAgentAvailability: null,
chatMessages: [],
traceStreams: new Map(),
traceDetailsOpen: new Map(),
conversationRenderVersion: 0,
conversationScrollUserActiveUntil: 0,
conversationScrollPosition: { top: 0, left: 0 },
traceScrollPositions: new Map(),
traceScrollUserActiveUntil: new Map(),
traceBodyScrollPositions: new Map(),
traceBodyScrollUserActiveUntil: new Map(),
traceProgrammaticScrollWrites: 0,
fullTraceReplayInFlight: new Set(),
resultReconciliationInFlight: new Set(),
canceledTraces: new Set(),
currentRequest: null,
sessionStatus: null,
chatPending: false,
liveSurface: null,
gateDiagnostics: {
loading: false,
payload: null,
rows: [],
error: null,
loadedAt: null
},
devicePod: {
selectedDevicePodId: "device-pod-71-freq",
list: null,
status: null,
events: null,
chipId: null,
uart: null,
uartTail: null,
followEvents: true,
unreadEvents: 0,
lastEventLineCount: 0,
eventScrollUserActiveUntil: 0,
details: new Map()
},
layout: {
leftSidebarCollapsed: false,
leftSidebarWidth: LEFT_SIDEBAR_DEFAULT_WIDTH,
leftDrag: null,
rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
rightDrag: null
}
};
let codeAgentSessionPersistTimer = null;
restoreCodeAgentSessionState();
initRoutes();
initLayoutSizing();
initLeftSidebarToggle();
initLeftSidebarResize();
initRightSidebarResize();
initDevicePodPanel();
initQuickActions();
initCodeAgentTimeoutControl();
initConversationScrollMemory();
initCommandBar();
initLiveBuildOverlay();
initWorkbenchLogout(el.logoutButton);
el.logoutButton.addEventListener("click", clearCodeAgentSessionState);
initGateControls();
installWorkbenchTestHooks();
renderStaticWorkbench();
renderProbePending();
renderCodeAgentSummary();
loadHelpSurface();
loadLiveSurface().then(renderLiveSurface);
window.setTimeout(refreshRestoredCodeAgentTraces, 0);
function byId(id) {
const element = document.getElementById(id);
if (!element) {
throw new Error(`missing required element #${id}`);
}
return element;
}
function query(selector) {
const element = document.querySelector(selector);
if (!element) {
throw new Error(`missing required element ${selector}`);
}
return element;
}
function resolveTimeoutMs(name, fallback, { min, max }) {
const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {};
const configured = Number(config?.[name]);
if (!Number.isFinite(configured)) return fallback;
return Math.min(Math.max(Math.trunc(configured), min), max);
}
function configuredTimeoutMinMs(name, fallback) {
const config = globalThis.HWLAB_CLOUD_WEB_CONFIG?.timeouts ?? globalThis.HWLAB_CLOUD_WEB_TIMEOUTS ?? {};
const configured = Number(config?.[`${name}MinMs`]);
if (!Number.isFinite(configured)) return fallback;
return Math.max(1, Math.min(Math.trunc(configured), fallback));
}
function clampTimeoutMs(value, { min, max }) {
if (value === null || value === undefined) return null;
if (typeof value === "string" && value.trim() === "") return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return Math.min(Math.max(Math.trunc(numeric), min), max);
}
function resolveUserCodeAgentTimeoutMs() {
const min = configuredTimeoutMinMs("codeAgentTimeoutMs", 30000);
let stored = null;
try {
stored = window.localStorage?.getItem(CODE_AGENT_TIMEOUT_STORAGE_KEY) ?? null;
} catch {
stored = null;
}
return clampTimeoutMs(stored, { min, max: MAX_CODE_AGENT_TIMEOUT_MS })
?? resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min, max: MAX_CODE_AGENT_TIMEOUT_MS });
}
function resolveUserGatewayShellTimeoutMs() {
let stored = null;
try {
stored = window.localStorage?.getItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY) ?? null;
} catch {
stored = null;
}
return clampTimeoutMs(stored, { min: 30000, max: 600000 })
?? resolveTimeoutMs("gatewayShellTimeoutMs", DEFAULT_GATEWAY_SHELL_TIMEOUT_MS, { min: 30000, max: 600000 });
}
function resolveUserCodeAgentProviderProfile() {
let stored = null;
try {
stored = window.localStorage?.getItem(CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY) ?? null;
} catch {
stored = null;
}
return normalizeCodeAgentProviderProfile(stored);
}
function normalizeCodeAgentProviderProfile(value) {
const text = String(value ?? DEFAULT_CODE_AGENT_PROVIDER_PROFILE).trim().toLowerCase();
return CODE_AGENT_PROVIDER_PROFILES.includes(text) ? text : DEFAULT_CODE_AGENT_PROVIDER_PROFILE;
}
function initCodeAgentTimeoutControl() {
syncCodeAgentTimeoutControl();
el.codeAgentProviderProfile.addEventListener("change", () => {
CODE_AGENT_PROVIDER_PROFILE = normalizeCodeAgentProviderProfile(el.codeAgentProviderProfile.value);
try {
window.localStorage?.setItem(CODE_AGENT_PROVIDER_PROFILE_STORAGE_KEY, CODE_AGENT_PROVIDER_PROFILE);
} catch {
// localStorage can be unavailable in hardened browser modes.
}
syncCodeAgentTimeoutControl();
renderCodeAgentSummary();
});
el.codeAgentTimeout.addEventListener("change", () => {
const next = clampTimeoutMs(el.codeAgentTimeout.value, { min: 30000, max: MAX_CODE_AGENT_TIMEOUT_MS }) ?? DEFAULT_CODE_AGENT_TIMEOUT_MS;
CODE_AGENT_TIMEOUT_MS = next;
try {
window.localStorage?.setItem(CODE_AGENT_TIMEOUT_STORAGE_KEY, String(next));
} catch {
// localStorage can be unavailable in hardened browser modes.
}
syncCodeAgentTimeoutControl();
renderCodeAgentSummary();
renderConversation();
});
el.gatewayShellTimeout.addEventListener("change", () => {
const next = clampTimeoutMs(el.gatewayShellTimeout.value, { min: 30000, max: 600000 }) ?? DEFAULT_GATEWAY_SHELL_TIMEOUT_MS;
GATEWAY_SHELL_TIMEOUT_MS = next;
try {
window.localStorage?.setItem(GATEWAY_SHELL_TIMEOUT_STORAGE_KEY, String(next));
} catch {
// localStorage can be unavailable in hardened browser modes.
}
syncCodeAgentTimeoutControl();
renderCodeAgentSummary();
});
}
function syncCodeAgentTimeoutControl() {
el.codeAgentProviderProfile.value = CODE_AGENT_PROVIDER_PROFILE;
el.codeAgentProviderProfile.title = CODE_AGENT_PROVIDER_PROFILE === "codex-api"
? "本次 Code Agent 请求使用原 Codex API / Responses 通道"
: "本次 Code Agent 请求使用 G14 DeepSeek Responses bridge";
el.codeAgentTimeout.value = String(CODE_AGENT_TIMEOUT_MS);
el.codeAgentTimeout.title = `Code Agent 无新事件超过 ${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 后才显示为超时`;
el.gatewayShellTimeout.value = String(GATEWAY_SHELL_TIMEOUT_MS);
el.gatewayShellTimeout.title = `Code Agent 调用 PC gateway wrapper 时默认使用 --timeout-ms ${GATEWAY_SHELL_TIMEOUT_MS}`;
}
function restoreCodeAgentSessionState() {
let payload = null;
try {
const raw = window.localStorage?.getItem(CODE_AGENT_SESSION_STORAGE_KEY);
payload = raw ? JSON.parse(raw) : null;
} catch {
payload = null;
}
if (!payload || payload.version !== CODE_AGENT_SESSION_STORAGE_VERSION) return;
const updatedAtMs = Number(payload.updatedAtMs);
if (!Number.isFinite(updatedAtMs) || Date.now() - updatedAtMs > CODE_AGENT_SESSION_STORAGE_MAX_AGE_MS) {
clearCodeAgentSessionState();
return;
}
state.conversationId = nonEmptyString(payload.conversationId);
state.sessionId = nonEmptyString(payload.sessionId);
state.threadId = nonEmptyString(payload.threadId);
state.sessionStatus = nonEmptyString(payload.sessionStatus);
state.chatMessages = Array.isArray(payload.chatMessages)
? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean)
: [];
reconcileRestoredCodeAgentResults();
}
function restoreStoredChatMessage(message) {
if (!message || typeof message !== "object") return null;
const restored = { ...message };
restored.id = nonEmptyString(restored.id) ?? nextProtocolId("msg");
restored.role = restored.role === "user" ? "user" : restored.role === "system" ? "system" : "agent";
restored.title = nonEmptyString(restored.title) ?? (restored.role === "user" ? "用户" : "Agent");
restored.text = typeof restored.text === "string" ? restored.text : "";
restored.status = nonEmptyString(restored.status) ?? "source";
if (restored.status === "running") restored.status = "source";
restored.conversationId = nonEmptyString(restored.conversationId) ?? state.conversationId;
restored.sessionId = nonEmptyString(restored.sessionId) ?? state.sessionId;
restored.threadId = nonEmptyString(restored.threadId) ?? state.threadId;
restored.traceId = nonEmptyString(restored.traceId);
if (restored.runnerTrace && typeof restored.runnerTrace === "object") {
restored.runnerTrace = restoreStoredRunnerTrace(restored.runnerTrace);
}
return restored;
}
function restoreStoredRunnerTrace(trace) {
return {
...trace,
events: Array.isArray(trace.events) ? trace.events : [],
eventCount: Number.isInteger(trace.eventCount) ? trace.eventCount : Array.isArray(trace.events) ? trace.events.length : 0,
eventsCompacted: trace.eventsCompacted === true,
fullTraceLoaded: trace.fullTraceLoaded === true
};
}
function scheduleCodeAgentSessionPersist() {
if (codeAgentSessionPersistTimer !== null) window.clearTimeout(codeAgentSessionPersistTimer);
codeAgentSessionPersistTimer = window.setTimeout(() => {
codeAgentSessionPersistTimer = null;
persistCodeAgentSessionState();
}, CODE_AGENT_SESSION_PERSIST_DEBOUNCE_MS);
}
function persistCodeAgentSessionState() {
if (!state.conversationId && !state.sessionId && !state.threadId && state.chatMessages.length === 0) {
clearCodeAgentSessionState();
return;
}
const payload = {
version: CODE_AGENT_SESSION_STORAGE_VERSION,
updatedAtMs: Date.now(),
conversationId: state.conversationId,
sessionId: state.sessionId,
threadId: state.threadId,
sessionStatus: state.sessionStatus,
chatMessages: state.chatMessages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT).map(storedChatMessage)
};
try {
window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload));
} catch {
// Keep the in-memory conversation when localStorage is unavailable or full.
}
}
function clearCodeAgentSessionState() {
if (codeAgentSessionPersistTimer !== null) {
window.clearTimeout(codeAgentSessionPersistTimer);
codeAgentSessionPersistTimer = null;
}
try {
window.localStorage?.removeItem(CODE_AGENT_SESSION_STORAGE_KEY);
} catch {
// Ignore storage availability errors.
}
}
function storedChatMessage(message) {
const stored = { ...message };
if (stored.runnerTrace && typeof stored.runnerTrace === "object") {
stored.runnerTrace = storedRunnerTrace(stored.runnerTrace);
}
delete stored.availability;
return stored;
}
function storedRunnerTrace(trace) {
const events = Array.isArray(trace.events) ? trace.events : [];
const storedEvents = trace.fullTraceLoaded === true && events.length <= CODE_AGENT_SESSION_STORAGE_TRACE_EVENT_LIMIT
? events
: [];
const fullTraceLoaded = storedEvents.length > 0 && trace.fullTraceLoaded === true;
return {
...trace,
events: storedEvents,
eventsCompacted: !fullTraceLoaded,
fullTraceLoaded
};
}
function refreshRestoredCodeAgentTraces() {
for (const message of state.chatMessages) {
if (!message.traceId) continue;
if (message.runnerTrace?.fullTraceLoaded === true) continue;
replayFullTrace(message.id, { quiet: true });
}
}
function reconcileRestoredCodeAgentResults() {
for (const message of state.chatMessages) {
if (!message.traceId || message.role !== "agent") continue;
if (!shouldReconcileRestoredCodeAgentResult(message)) continue;
reconcileCodeAgentResult(message.id, { quiet: true });
}
}
function initLiveBuildOverlay() {
el.liveBuildSummary.open = false;
el.liveBuildToggle.addEventListener("click", (event) => {
event.preventDefault();
el.liveBuildSummary.open = false;
openWorkbenchDialog({
title: "构建版本明细",
body: cloneLiveBuildList(),
restoreFocus: el.liveBuildToggle
});
});
}
function cloneLiveBuildList() {
const clone = el.liveBuildList.cloneNode(true);
clone.id = "";
clone.hidden = false;
clone.removeAttribute("hidden");
clone.tabIndex = 0;
clone.classList.remove("live-build-list-template");
return clone;
}
function openWorkbenchDialog({ title, body, restoreFocus = null }) {
closeWorkbenchDialog({ restoreFocus: false });
const layer = document.createElement("div");
layer.className = "workbench-dialog-layer";
const backdrop = document.createElement("button");
backdrop.type = "button";
backdrop.className = "workbench-dialog-backdrop";
backdrop.setAttribute("aria-label", "关闭弹窗");
const dialog = document.createElement("section");
dialog.className = "workbench-dialog";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-label", title);
const head = document.createElement("div");
head.className = "workbench-dialog-head";
const heading = document.createElement("strong");
heading.textContent = title;
const close = document.createElement("button");
close.type = "button";
close.className = "workbench-dialog-close";
close.textContent = "关闭";
close.setAttribute("aria-label", `关闭${title}`);
const content = document.createElement("div");
content.className = "workbench-dialog-content";
if (body) {
body.hidden = false;
body.removeAttribute("hidden");
content.append(body);
}
head.append(heading, close);
dialog.append(head, content);
layer.append(backdrop, dialog);
const closeDialog = () => closeWorkbenchDialog({ restoreFocus: true });
const keydown = (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
closeDialog();
};
close.addEventListener("click", closeDialog);
backdrop.addEventListener("click", closeDialog);
document.addEventListener("keydown", keydown);
document.body.append(layer);
activeWorkbenchDialog = { layer, keydown, restoreFocus };
close.focus();
}
function closeWorkbenchDialog({ restoreFocus = true } = {}) {
if (!activeWorkbenchDialog) return;
const { layer, keydown, restoreFocus: target } = activeWorkbenchDialog;
document.removeEventListener("keydown", keydown);
layer.remove();
activeWorkbenchDialog = null;
if (restoreFocus && target && typeof target.focus === "function") {
target.focus();
}
}
function initRoutes() {
window.addEventListener("hashchange", () => showView(routeFromLocation()));
for (const button of document.querySelectorAll("[data-route]")) {
button.addEventListener("click", () => {
window.location.hash = `/${button.dataset.route}`;
});
}
showView(routeFromLocation());
}
function initLayoutSizing() {
const persisted = readPersistedLayout();
const leftSidebarWidth = clampLeftSidebarWidth(persisted?.leftSidebarWidth ?? LEFT_SIDEBAR_DEFAULT_WIDTH);
const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH);
setLeftSidebarWidth(leftSidebarWidth, { persist: false });
setLeftSidebarCollapsed(persisted?.leftSidebarCollapsed === true, { persist: false });
setRightSidebarWidth(rightSidebarWidth, { persist: false });
}
function readPersistedLayout() {
try {
const raw = window.localStorage?.getItem(LAYOUT_STORAGE_KEY);
if (!raw) return null;
const payload = JSON.parse(raw);
if (payload?.version !== 1) return null;
return {
leftSidebarCollapsed: payload.leftSidebarCollapsed === true,
leftSidebarWidth: Number(payload.leftSidebarWidth),
rightSidebarWidth: Number(payload.rightSidebarWidth)
};
} catch {
return null;
}
}
function persistLayout() {
try {
window.localStorage?.setItem(
LAYOUT_STORAGE_KEY,
JSON.stringify({
version: 1,
leftSidebarCollapsed: state.layout.leftSidebarCollapsed,
leftSidebarWidth: state.layout.leftSidebarWidth,
rightSidebarWidth: state.layout.rightSidebarWidth
})
);
} catch {
// Browser storage can be unavailable; layout still works for the session.
}
}
function initLeftSidebarToggle() {
el.leftSidebarToggle.addEventListener("click", () => {
setLeftSidebarCollapsed(!state.layout.leftSidebarCollapsed);
});
}
function setLeftSidebarCollapsed(collapsed, options = {}) {
state.layout.leftSidebarCollapsed = collapsed === true;
el.shell.classList.toggle("is-left-sidebar-collapsed", state.layout.leftSidebarCollapsed);
el.leftSidebarToggle.setAttribute("aria-expanded", state.layout.leftSidebarCollapsed ? "false" : "true");
el.leftSidebarToggle.setAttribute(
"aria-label",
state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航"
);
el.leftSidebarToggle.title = state.layout.leftSidebarCollapsed ? "展开左侧导航" : "折叠左侧导航";
el.leftSidebarToggle.textContent = state.layout.leftSidebarCollapsed ? "展开" : "收起";
syncLeftSidebarResizeA11y();
if (options.persist !== false) persistLayout();
}
function setLeftSidebarWidth(width, options = {}) {
const clamped = clampLeftSidebarWidth(width);
state.layout.leftSidebarWidth = clamped;
el.shell.style.setProperty("--left-sidebar-width", `${clamped}px`);
syncLeftSidebarResizeA11y();
if (options.persist !== false) persistLayout();
return clamped;
}
function setRightSidebarWidth(width, options = {}) {
const clamped = clampRightSidebarWidth(width);
state.layout.rightSidebarWidth = clamped;
el.shell.style.setProperty("--right-sidebar-width", `${clamped}px`);
syncRightSidebarResizeA11y();
if (options.persist !== false) persistLayout();
return clamped;
}
function clampLeftSidebarWidth(value) {
const numeric = Number(value);
const width = Number.isFinite(numeric) ? numeric : LEFT_SIDEBAR_DEFAULT_WIDTH;
const bounds = leftSidebarResizeBounds();
return Math.min(Math.max(Math.round(width), bounds.min), bounds.max);
}
function clampRightSidebarWidth(value) {
const numeric = Number(value);
const width = Number.isFinite(numeric) ? numeric : RIGHT_SIDEBAR_DEFAULT_WIDTH;
const bounds = rightSidebarResizeBounds();
return Math.min(Math.max(Math.round(width), bounds.min), bounds.max);
}
function boundedResizeRange(min, max, dynamicMax) {
const safeMin = Math.max(0, Math.round(min));
const safeMax = Math.max(safeMin, Math.round(Math.min(max, Number.isFinite(dynamicMax) ? dynamicMax : max)));
return { min: safeMin, max: safeMax };
}
function leftSidebarResizeBounds() {
const styles = getComputedStyle(el.shell);
const cssMin = readCssPixel(styles.getPropertyValue("--left-sidebar-min-width"), LEFT_SIDEBAR_MIN_WIDTH);
const cssMax = readCssPixel(styles.getPropertyValue("--left-sidebar-max-width"), LEFT_SIDEBAR_MAX_WIDTH);
if (window.matchMedia("(max-width: 1240px)").matches) {
const fixedRailWidth = readCssPixel(styles.getPropertyValue("--rail-width"), LEFT_SIDEBAR_DEFAULT_WIDTH);
return { min: Math.round(fixedRailWidth), max: Math.round(fixedRailWidth) };
}
const rightWidth = state.layout.rightSidebarWidth;
const dynamicMax = window.innerWidth - rightWidth - 420 - 4;
return boundedResizeRange(cssMin, cssMax, dynamicMax);
}
function rightSidebarResizeBounds() {
const styles = getComputedStyle(el.shell);
const cssMin = readCssPixel(styles.getPropertyValue("--right-sidebar-min-width"), RIGHT_SIDEBAR_MIN_WIDTH);
const cssMax = readCssPixel(styles.getPropertyValue("--right-sidebar-max-width"), RIGHT_SIDEBAR_MAX_WIDTH);
if (window.matchMedia("(max-width: 1240px)").matches) {
return { min: 0, max: Math.max(0, window.innerWidth) };
}
const railWidth = state.layout.leftSidebarCollapsed
? readCssPixel(styles.getPropertyValue("--rail-collapsed-width"), LEFT_SIDEBAR_DEFAULT_WIDTH)
: state.layout.leftSidebarWidth;
const dynamicMax = window.innerWidth - railWidth - 420 - 4;
return boundedResizeRange(cssMin, cssMax, dynamicMax);
}
function readCssPixel(value, fallback) {
const parsed = Number.parseFloat(String(value ?? "").trim());
return Number.isFinite(parsed) ? parsed : fallback;
}
function syncRightSidebarResizeA11y() {
const bounds = rightSidebarResizeBounds();
const disabled = !canResizeRightSidebar();
el.rightSidebarResize.tabIndex = disabled ? -1 : 0;
el.rightSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false");
el.rightSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false");
el.rightSidebarResize.setAttribute("aria-valuemin", String(bounds.min));
el.rightSidebarResize.setAttribute("aria-valuemax", String(bounds.max));
el.rightSidebarResize.setAttribute("aria-valuenow", String(state.layout.rightSidebarWidth));
el.rightSidebarResize.setAttribute("aria-valuetext", `右侧 Device Pod 看板宽度 ${state.layout.rightSidebarWidth} 像素`);
}
function syncLeftSidebarResizeA11y() {
const bounds = leftSidebarResizeBounds();
const disabled = !canResizeLeftSidebar();
el.leftSidebarResize.tabIndex = disabled ? -1 : 0;
el.leftSidebarResize.setAttribute("aria-disabled", disabled ? "true" : "false");
el.leftSidebarResize.setAttribute("aria-hidden", disabled ? "true" : "false");
el.leftSidebarResize.setAttribute("aria-valuemin", String(bounds.min));
el.leftSidebarResize.setAttribute("aria-valuemax", String(bounds.max));
el.leftSidebarResize.setAttribute("aria-valuenow", String(state.layout.leftSidebarWidth));
el.leftSidebarResize.setAttribute("aria-valuetext", `左侧导航宽度 ${state.layout.leftSidebarWidth} 像素`);
}
function routeFromLocation() {
const hashRoute = window.location.hash.replace(/^#\/?/, "");
if (viewIds.has(hashRoute)) return hashRoute;
if (helpPathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "help";
if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate";
return "workspace";
}
function showView(route) {
const activeRoute = viewIds.has(route) ? route : "workspace";
for (const view of document.querySelectorAll("[data-view]")) {
view.hidden = view.dataset.view !== activeRoute;
}
for (const button of document.querySelectorAll("[data-route]")) {
const active = button.dataset.route === activeRoute;
button.classList.toggle("active", active);
button.setAttribute("aria-current", active ? "page" : "false");
}
if (activeRoute === "gate" && state.gateDiagnostics.rows.length === 0 && !state.gateDiagnostics.loading) {
loadGateDiagnostics();
}
}
function initLeftSidebarResize() {
window.addEventListener("resize", () => {
if (canResizeLeftSidebar()) setLeftSidebarWidth(state.layout.leftSidebarWidth, { persist: false });
syncLeftSidebarResizeA11y();
});
el.leftSidebarResize.addEventListener("pointerdown", (event) => {
if (!canResizeLeftSidebar()) return;
event.preventDefault();
el.leftSidebarResize.setPointerCapture?.(event.pointerId);
state.layout.leftDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: state.layout.leftSidebarWidth
};
el.shell.classList.add("is-resizing");
});
el.leftSidebarResize.addEventListener("pointermove", (event) => {
const drag = state.layout.leftDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
setLeftSidebarWidth(drag.startWidth + event.clientX - drag.startX, { persist: false });
});
el.leftSidebarResize.addEventListener("pointerup", finishLeftSidebarResize);
el.leftSidebarResize.addEventListener("pointercancel", finishLeftSidebarResize);
el.leftSidebarResize.addEventListener("keydown", (event) => {
if (!canResizeLeftSidebar()) return;
const keySteps = {
ArrowLeft: -8,
ArrowRight: 8,
PageUp: 24,
PageDown: -24
};
if (Object.hasOwn(keySteps, event.key)) {
event.preventDefault();
setLeftSidebarWidth(state.layout.leftSidebarWidth + keySteps[event.key]);
} else if (event.key === "Home") {
event.preventDefault();
setLeftSidebarWidth(leftSidebarResizeBounds().min);
} else if (event.key === "End") {
event.preventDefault();
setLeftSidebarWidth(leftSidebarResizeBounds().max);
}
});
}
function finishLeftSidebarResize(event) {
const drag = state.layout.leftDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.layout.leftDrag = null;
el.shell.classList.remove("is-resizing");
persistLayout();
}
function canResizeLeftSidebar() {
return !state.layout.leftSidebarCollapsed && !window.matchMedia("(max-width: 1240px)").matches;
}
function initRightSidebarResize() {
window.addEventListener("resize", () => {
if (canResizeRightSidebar()) setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false });
syncRightSidebarResizeA11y();
});
el.rightSidebarResize.addEventListener("pointerdown", (event) => {
if (!canResizeRightSidebar()) return;
event.preventDefault();
el.rightSidebarResize.setPointerCapture?.(event.pointerId);
state.layout.rightDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startWidth: state.layout.rightSidebarWidth
};
el.shell.classList.add("is-resizing");
});
el.rightSidebarResize.addEventListener("pointermove", (event) => {
const drag = state.layout.rightDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
setRightSidebarWidth(drag.startWidth + drag.startX - event.clientX, { persist: false });
});
el.rightSidebarResize.addEventListener("pointerup", finishRightSidebarResize);
el.rightSidebarResize.addEventListener("pointercancel", finishRightSidebarResize);
el.rightSidebarResize.addEventListener("keydown", (event) => {
if (!canResizeRightSidebar()) return;
const keySteps = {
ArrowLeft: 16,
ArrowRight: -16,
PageUp: 48,
PageDown: -48
};
if (Object.hasOwn(keySteps, event.key)) {
event.preventDefault();
setRightSidebarWidth(state.layout.rightSidebarWidth + keySteps[event.key]);
} else if (event.key === "Home") {
event.preventDefault();
setRightSidebarWidth(rightSidebarResizeBounds().min);
} else if (event.key === "End") {
event.preventDefault();
setRightSidebarWidth(rightSidebarResizeBounds().max);
}
});
}
function finishRightSidebarResize(event) {
const drag = state.layout.rightDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.layout.rightDrag = null;
el.shell.classList.remove("is-resizing");
persistLayout();
}
function canResizeRightSidebar() {
return !window.matchMedia("(max-width: 1240px)").matches;
}
async function loadHelpSurface() {
el.helpStatus.textContent = "加载中";
el.helpStatus.className = "state-tag tone-dry-run";
el.helpContent.dataset.helpState = "loading";
try {
const response = await fetch("/help.md", { cache: "no-store" });
if (!response.ok) {
throw new Error(`help.md HTTP ${response.status}`);
}
const markdown = await response.text();
el.helpContent.innerHTML = marked.parse(markdown, { gfm: true, breaks: false });
el.helpContent.dataset.helpState = "ready";
el.helpStatus.textContent = "已就绪";
el.helpStatus.className = "state-tag tone-source";
} catch (error) {
el.helpContent.dataset.helpState = "error";
el.helpContent.replaceChildren();
const notice = document.createElement("div");
notice.className = "help-error";
notice.textContent = `帮助内容加载失败:${error.message}。默认工作台仍可继续使用。`;
el.helpContent.append(notice);
el.helpStatus.textContent = "加载失败";
el.helpStatus.className = "state-tag tone-blocked";
}
}
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 initQuickActions() {
for (const button of document.querySelectorAll("[data-agent-quick-prompt]")) {
button.addEventListener("click", () => {
fillAgentQuickPrompt(button);
});
}
}
function fillAgentQuickPrompt(button) {
const prompt = button.dataset.promptText ?? "";
if (!prompt) return;
el.commandInput.value = prompt;
el.commandInput.focus();
el.commandInput.setSelectionRange(prompt.length, prompt.length);
}
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
};
}
function renderConversation() {
const renderVersion = ++state.conversationRenderVersion;
captureConversationScrollPosition();
captureTraceScrollPositions();
captureTraceBodyScrollPositions();
const introMessages = [
{
role: "system",
title: "界面模式",
text: "这里是用户工作台:可以整理任务、查看 Device Pod summary 和纯文本事件流。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。",
status: "source"
},
codeAgentStatusMessage(state.codeAgentAvailability)
];
replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard));
restoreConversationScrollPosition();
restoreTraceScrollPositions();
restoreTraceBodyScrollPositions();
scheduleCodeAgentSessionPersist();
window.requestAnimationFrame(() => {
if (renderVersion !== state.conversationRenderVersion) return;
restoreConversationScrollPosition({ deferred: true });
restoreTraceScrollPositions(el.conversationList, { deferred: true });
restoreTraceBodyScrollPositions(el.conversationList, { deferred: true });
});
}
function initConversationScrollMemory() {
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
el.conversationList.addEventListener(eventName, markConversationScrollIntent, { passive: true });
}
el.conversationList.addEventListener("keydown", (event) => {
if (isScrollIntentKey(event.key)) markConversationScrollIntent();
});
el.conversationList.addEventListener("scroll", () => {
captureConversationScrollPosition();
}, { passive: true });
}
function markConversationScrollIntent() {
state.conversationScrollUserActiveUntil = Date.now() + SCROLL_USER_ACTIVITY_MS;
}
function isScrollIntentKey(key) {
return ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(key);
}
function captureConversationScrollPosition() {
state.conversationScrollPosition = {
top: el.conversationList.scrollTop,
left: el.conversationList.scrollLeft,
bottomGap: scrollBottomGap(el.conversationList)
};
}
function restoreConversationScrollPosition(options = {}) {
if (options.deferred === true && Date.now() < state.conversationScrollUserActiveUntil) return;
const position = state.conversationScrollPosition;
if (!position) return;
writeConversationScrollTop(scrollTopForPosition(el.conversationList, position));
el.conversationList.scrollLeft = Math.min(position.left, Math.max(0, el.conversationList.scrollWidth - el.conversationList.clientWidth));
}
function captureTraceScrollPositions(root = el.conversationList) {
for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) {
rememberTraceScrollPosition(list.dataset.traceUiKey, list);
}
}
function rememberTraceScrollPosition(traceUiKey, list) {
if (!traceUiKey || !list) return;
state.traceScrollPositions.set(traceUiKey, {
top: list.scrollTop,
left: list.scrollLeft,
bottomGap: scrollBottomGap(list)
});
}
function restoreTraceScrollPositions(root = el.conversationList, options = {}) {
for (const list of root.querySelectorAll(".message-trace-events[data-trace-ui-key]")) {
if (options.deferred === true && isTraceScrollUserActive(list.dataset.traceUiKey)) continue;
const position = state.traceScrollPositions.get(list.dataset.traceUiKey);
if (!position) continue;
writeTraceScrollTop(list, scrollTopForPosition(list, position));
list.scrollLeft = Math.min(position.left, Math.max(0, list.scrollWidth - list.clientWidth));
}
}
function captureTraceBodyScrollPositions(root = el.conversationList) {
for (const body of root.querySelectorAll(".message-trace-body")) {
rememberTraceBodyScrollPosition(body);
}
}
function traceBodyScrollKey(body) {
const list = body?.closest?.(".message-trace-events[data-trace-ui-key]");
const row = body?.closest?.(".message-trace-row[data-trace-row-id]");
const traceUiKey = list?.dataset?.traceUiKey;
const rowId = row?.dataset?.traceRowId;
return traceUiKey && rowId ? `${traceUiKey}:${rowId}` : null;
}
function rememberTraceBodyScrollPosition(body) {
const key = traceBodyScrollKey(body);
if (!key || !body) return;
state.traceBodyScrollPositions.set(key, elementScrollPosition(body));
}
function restoreTraceBodyScrollPositions(root = el.conversationList, options = {}) {
for (const body of root.querySelectorAll(".message-trace-body")) {
const key = traceBodyScrollKey(body);
if (!key) continue;
if (options.deferred === true && isTraceBodyScrollUserActive(key)) continue;
const position = state.traceBodyScrollPositions.get(key);
if (!position) continue;
restoreElementScrollPosition(body, position);
}
}
function markTraceBodyScrollIntent(body) {
const key = traceBodyScrollKey(body);
if (!key) return;
state.traceBodyScrollUserActiveUntil.set(key, Date.now() + SCROLL_USER_ACTIVITY_MS);
}
function isTraceBodyScrollUserActive(key) {
if (!key) return false;
return Date.now() < Number(state.traceBodyScrollUserActiveUntil.get(key) ?? 0);
}
function elementScrollPosition(element) {
return {
top: element.scrollTop,
left: element.scrollLeft,
bottomGap: scrollBottomGap(element)
};
}
function restoreElementScrollPosition(element, position) {
if (!element || !position) return;
element.scrollTop = scrollTopForPosition(element, position);
element.scrollLeft = Math.min(position.left, Math.max(0, element.scrollWidth - element.clientWidth));
}
function scrollBottomGap(element) {
return Math.max(0, element.scrollHeight - element.clientHeight - element.scrollTop);
}
function scrollTopForPosition(element, position) {
const maxTop = Math.max(0, element.scrollHeight - element.clientHeight);
if (Number(position.bottomGap) <= SCROLL_BOTTOM_PIN_PX) return maxTop;
return Math.min(position.top, maxTop);
}
function markTraceScrollIntent(traceUiKey) {
if (!traceUiKey) return;
state.traceScrollUserActiveUntil.set(traceUiKey, Date.now() + SCROLL_USER_ACTIVITY_MS);
}
function isTraceScrollUserActive(traceUiKey) {
if (!traceUiKey) return false;
return Date.now() < Number(state.traceScrollUserActiveUntil.get(traceUiKey) ?? 0);
}
function writeConversationScrollTop(top) {
if (el.conversationList.scrollTop === top) return;
state.traceProgrammaticScrollWrites += 1;
el.conversationList.scrollTop = top;
}
function writeTraceScrollTop(list, top) {
if (!list || list.scrollTop === top) return;
state.traceProgrammaticScrollWrites += 1;
list.scrollTop = top;
}
function patchMessageTracePanel(message) {
const traceUiKey = messageTraceUiKey(message);
if (!traceUiKey) return false;
const panel = el.conversationList.querySelector(`.message-trace[data-trace-ui-key="${cssEscape(traceUiKey)}"]`);
if (!panel) return false;
const replacement = messageTracePanel(message);
if (!replacement) {
panel.remove();
return true;
}
patchTracePanelElement(panel, replacement);
return true;
}
function patchTracePanelElement(panel, replacement) {
panel.open = replacement.open;
const currentSummary = panel.querySelector("summary");
const nextSummary = replacement.querySelector("summary");
if (currentSummary && nextSummary) {
currentSummary.className = nextSummary.className;
currentSummary.textContent = nextSummary.textContent;
}
const currentToolbar = panel.querySelector(".message-trace-toolbar");
const nextToolbar = replacement.querySelector(".message-trace-toolbar");
if (currentToolbar && nextToolbar) {
currentToolbar.replaceChildren(...nextToolbar.childNodes);
} else if (!currentToolbar && nextToolbar) {
const list = panel.querySelector(".message-trace-events");
panel.insertBefore(nextToolbar, list);
} else if (currentToolbar && !nextToolbar) {
currentToolbar.remove();
}
const currentList = panel.querySelector(".message-trace-events[data-trace-ui-key]");
const nextList = replacement.querySelector(".message-trace-events[data-trace-ui-key]");
if (currentList && nextList) {
captureTraceBodyScrollPositions(currentList);
currentList.dataset.traceMode = nextList.dataset.traceMode ?? "";
patchTraceEventList(currentList, [...nextList.children]);
restoreTraceBodyScrollPositions(currentList);
}
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
return String(value).replace(/["\\]/gu, "\\$&");
}
function renderGateTable() {
const rows = filteredGateRows();
const loading = state.gateDiagnostics.loading;
const payload = state.gateDiagnostics.payload;
const statusTone = gatePayloadTone();
const loadedAt = state.gateDiagnostics.loadedAt;
el.gateSourceStatus.textContent = loading
? "读取 live 后端中"
: payload
? `live 后端 ${rows.length}/${state.gateDiagnostics.rows.length} 行`
: state.gateDiagnostics.error
? "live 后端阻塞"
: "等待 live 后端";
el.gateSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`;
el.gateSourceStatus.title = payload
? `route=${payload.route}; observedAt=${payload.observedAt ?? loadedAt ?? "unknown"}`
: state.gateDiagnostics.error ?? "等待 /v1/diagnostics/gate";
if (loading && state.gateDiagnostics.rows.length === 0) {
const tr = document.createElement("tr");
tr.append(tableCell("正在读取 live 后端聚合。", "wrap", 8));
replaceChildren(el.gateReviewBody, tr);
return;
}
if (rows.length === 0) {
const tr = document.createElement("tr");
tr.append(tableCell("没有匹配当前过滤条件的复核行。", "wrap", 8));
replaceChildren(el.gateReviewBody, tr);
return;
}
replaceChildren(el.gateReviewBody, ...rows.map(gateTableRow));
}
function filteredGateRows() {
const filter = el.gateStatusFilter.value;
const query = el.gateSearch.value.trim().toLowerCase();
return state.gateDiagnostics.rows.filter((row) => {
if (filter !== "all" && row.status !== filter) return false;
if (!query) return true;
return [
row.category,
row.check,
row.status,
row.owner,
row.detail,
...(row.evidence ?? []),
row.updatedAt,
row.next
].join(" ").toLowerCase().includes(query);
});
}
function gateTableRow(row) {
const tr = document.createElement("tr");
tr.dataset.status = row.status;
tr.dataset.statusKey = row.statusKey;
tr.append(tableCell(row.category, "wrap"));
tr.append(tableCell(row.check, "wrap"));
const status = document.createElement("td");
status.append(badge(row.status, row.statusKey));
tr.append(status);
tr.append(tableCell(row.owner, "mono wrap"));
tr.append(tableCell(row.detail, "wrap"));
tr.append(tableCell((row.evidence ?? []).join(" / "), "mono wrap"));
tr.append(tableCell(formatTimestamp(row.updatedAt), "mono wrap"));
tr.append(tableCell(row.next, "wrap"));
return tr;
}
function normalizeGateRow(row) {
const statusKey = normalizeGateStatusKey(row.statusKey ?? row.status);
return {
category: oneLine(row.category || "内部复核"),
check: oneLine(row.check || "未命名检查项"),
status: gateStatusLabel(row.status, statusKey),
statusKey,
owner: oneLine(row.owner || "未标明"),
detail: oneLine(row.detail || "live 后端未返回关键细节"),
evidence: normalizeEvidenceList(row.evidence),
updatedAt: row.updatedAt || new Date().toISOString(),
next: oneLine(row.next || "等待维护者补充下一步。"),
sourceKind: "LIVE-BACKEND"
};
}
function gateLiveBlockerRow(response) {
const now = new Date().toISOString();
return normalizeGateRow({
category: "健康检查",
check: "内部复核 live 后端聚合",
status: "阻塞",
statusKey: response.timeout ? "failed" : "blocked",
owner: "hwlab-cloud-api /v1/diagnostics/gate",
detail: response.error || "live 后端聚合不可用;页面不会回退到 SOURCE 静态拓扑。",
evidence: [
"GET /v1/diagnostics/gate",
response.status ? `HTTP ${response.status}` : null,
response.timeout ? `timeoutMs=${response.timeoutMs}` : null
].filter(Boolean),
updatedAt: now,
next: "先恢复后端聚合接口,再刷新本表。"
});
}
function gatePayloadTone() {
if (state.gateDiagnostics.loading) return "pending";
if (!state.gateDiagnostics.payload) return state.gateDiagnostics.error ? "blocked" : "source";
const rows = state.gateDiagnostics.rows;
if (rows.some((row) => row.statusKey === "failed")) return "failed";
if (rows.some((row) => row.statusKey === "blocked")) return "blocked";
if (rows.some((row) => row.statusKey === "pending")) return "pending";
return "pass";
}
function renderAgentChatStatus(status, result = null) {
const labels = {
idle: "等待输入",
running: "处理中",
completed: "DEV-LIVE 回复",
source: "SOURCE 回复",
failed: "后端失败",
timeout: "等待超时",
canceled: "已取消",
error: "请求错误",
blocked: "服务受阻"
};
el.agentChatStatus.textContent = agentStatusLabel(status, result, labels);
el.agentChatStatus.className = `state-tag tone-${toneClass(agentStatusTone(status, result))}`;
el.commandInput.disabled = status === "running";
el.commandSend.disabled = status === "running";
el.commandSend.textContent = status === "running" ? "发送中" : "发送";
if (result?.conversationId) {
el.agentChatStatus.title = `${result.provider ?? "provider"} / ${result.model ?? "model"} / ${result.backend ?? "backend"}`;
} else {
el.agentChatStatus.removeAttribute("title");
}
}
function renderCodeAgentSummary() {
const summary = currentCodeAgentStatusSummary();
el.codeAgentSummary.className = `code-agent-summary tone-border-${toneClass(summary.tone)}`;
el.codeAgentSummary.dataset.codeAgentStatusKind = summary.kind;
el.codeAgentSummaryIcon.textContent = summary.icon;
el.codeAgentSummaryIcon.className = `code-agent-summary-icon tone-${toneClass(summary.tone)}`;
el.codeAgentSummaryLabel.textContent = summary.label;
el.codeAgentSummaryLabel.className = `code-agent-summary-label tone-${toneClass(summary.tone)}`;
el.codeAgentSummaryCapability.textContent = summary.capabilityLevel;
el.codeAgentSummaryTrace.textContent = `trace ${summary.lastTraceId}`;
replaceChildren(el.codeAgentSummaryDetail, ...codeAgentSummaryRows(summary));
}
function currentCodeAgentStatusSummary() {
return classifyCodeAgentStatusSummary({
availability: state.codeAgentAvailability,
latestMessage: latestChatResult(),
live: state.liveSurface
});
}
function codeAgentSummaryRows(summary) {
return [
codeAgentSummaryRow("codeAgent.status", summary.codeAgentStatus, summary.tone),
codeAgentSummaryRow("provider/mode/backend", summary.providerModeBackend, "source"),
codeAgentSummaryRow("capabilityLevel", summary.capabilityLevel, summary.tone),
codeAgentSummaryRow("session", summary.sessionSummary, sessionSummaryTone(summary)),
codeAgentSummaryRow("sessionId/status", summary.sessionIdStatus, sessionSummaryTone(summary)),
codeAgentSummaryRow("会话提示", summary.sessionLifecycleHint, sessionSummaryTone(summary)),
codeAgentSummaryRow("workspace", summary.workspace, "source"),
codeAgentSummaryRow("sandbox", summary.sandbox, "source"),
codeAgentSummaryRow("runnerKind", summary.runnerKind, summary.kind === "long-lived-session" ? "ok" : "source"),
codeAgentSummaryRow("protocol", summary.protocol, summary.runtimePathTone),
codeAgentSummaryRow("implementationType", summary.implementationType, summary.runtimePathTone),
codeAgentSummaryRow("providerTrace.command", summary.providerTraceCommand, summary.runtimePathTone),
codeAgentSummaryRow("providerTrace.terminalStatus", summary.providerTraceTerminalStatus, summary.runtimePathTone),
codeAgentSummaryRow("运行路径语义", summary.runtimePathLabel, summary.runtimePathTone),
codeAgentSummaryRow("toolCalls", summary.toolCalls, summary.toolCalls === "none" ? "source" : summary.tone),
codeAgentSummaryRow("skills", summary.skills, summary.skills === "none" ? "source" : summary.tone),
codeAgentSummaryRow("conversation facts", conversationFactsSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"),
codeAgentSummaryRow("fact traces", conversationFactTracesSummary(summary.latestMessage?.conversationFacts), summary.latestMessage?.conversationFacts ? "source" : "blocked"),
codeAgentSummaryRow("runnerTrace", summary.runnerTrace, "source"),
codeAgentSummaryRow("readiness blockers", summary.blockersLabel, summary.readinessBlockers.length > 0 ? "blocked" : "source"),
codeAgentSummaryRow("last traceId", summary.lastTraceId, "source"),
codeAgentSummaryRow("当前部署 revision", summary.deploymentRevision, "source")
];
}
function codeAgentSummaryRow(label, value, tone) {
const row = document.createElement("div");
row.className = "code-agent-summary-row";
row.append(textSpan(label, "code-agent-summary-key"), textSpan(value, `code-agent-summary-value tone-${toneClass(tone)}`));
return row;
}
function sessionSummaryTone(summary) {
if (summary.kind === "long-lived-session") return "ok";
if (summary.kind === "blocked" || summary.kind === "skill-cli-api-blocked") return "blocked";
if (["read-only-session-tools", "stateless-one-shot", "fallback-text-chat-only", "degraded"].includes(summary.kind)) return "warn";
return "source";
}
function agentStatusLabel(status, result, labels) {
if (status !== "failed") return labels[status] ?? statusLabel(status);
const presentation = agentFailurePresentation(result?.error, { result });
return (
{
timeout: "等待超时",
provider: "Provider 不可用",
runner_busy: "Runner 忙碌",
session_blocked: "Session 受阻",
canceled: "已取消",
runner_blocked: "Runner 受阻",
api_error: "API 错误",
needs_config: "需要配置",
capability_unavailable: "能力未开放",
security_blocked: "安全阻断",
fallback: "仍是 fallback",
retryable: "可重试"
}[presentation.category] ?? labels.failed
);
}
function agentStatusTone(status, result) {
if (status === "completed") return "dev-live";
if (["failed", "blocked", "timeout", "canceled", "error"].includes(status)) return "blocked";
if (status === "running") return "pending";
return "source";
}
function sourceTitle(result) {
return `Code Agent 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`;
}
function sourceFixtureTitle(result) {
return `Code Agent SOURCE 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`;
}
function textFallbackTitle(result) {
return `Code Agent 文本 fallback 回复 ${shortTime(result.updatedAt ?? new Date().toISOString())}`;
}
function classifyCodeAgentCompletion(result, { blockedError = null } = {}) {
if (result?.status === "canceled") {
return {
status: "canceled",
replied: false,
sourceKind: "BLOCKED",
title: "Code Agent 已取消"
};
}
if (result?.status === "timeout") {
return {
status: "timeout",
replied: false,
sourceKind: "BLOCKED",
title: "Code Agent 无新事件超时"
};
}
if (result?.status === "error") {
return {
status: "failed",
replied: false,
sourceKind: "BLOCKED",
title: "Code Agent 返回错误"
};
}
if (isStructuredBlockedChatResult(result)) {
return {
status: "failed",
replied: false,
sourceKind: "BLOCKED",
title: agentFailurePresentation(structuredBlockedErrorFromResult(result), { result }).title
};
}
if (blockedError) {
return {
status: "failed",
replied: false,
sourceKind: "BLOCKED",
title: agentFailurePresentation(blockedError, { result }).title
};
}
if (isRealCompletedChatResult(result)) {
return {
status: "completed",
replied: true,
sourceKind: "DEV-LIVE",
title: sourceTitle(result)
};
}
if (isSourceFixtureChatResult(result)) {
return {
status: "source",
replied: true,
sourceKind: "SOURCE",
title: sourceFixtureTitle(result)
};
}
if (isTextFallbackChatResult(result)) {
return {
status: "source",
replied: true,
sourceKind: "TEXT-FALLBACK",
title: textFallbackTitle(result)
};
}
return {
status: "failed",
replied: false,
sourceKind: "BLOCKED",
title: result?.status === "completed" && !result?.blocker ? "Code Agent 完成证据不足" : agentFailurePresentation(result?.error, { result }).title
};
}
function isStructuredBlockedChatResult(result) {
const error = result?.error && typeof result.error === "object" ? result.error : {};
return Boolean(
result &&
typeof result === "object" &&
(
result.blocker?.code ||
error.blocker?.code ||
result.capabilityLevel === "hwlab-api-control-blocked"
)
);
}
function structuredBlockedErrorFromResult(result) {
if (!isStructuredBlockedChatResult(result)) return null;
const error = result?.error && typeof result.error === "object" ? result.error : {};
const blocker = error.blocker ?? result?.blocker ?? {};
const code = normalizeErrorCode(error.code ?? blocker.code ?? "code_agent_blocked");
const selectedBlocker = blocker;
return {
...error,
code,
layer: error.layer ?? selectedBlocker.layer,
category: error.category ?? selectedBlocker.category,
blocker: selectedBlocker,
retryable: error.retryable ?? selectedBlocker.retryable,
userMessage: error.userMessage ?? selectedBlocker.userMessage,
message: error.message ?? selectedBlocker.summary ?? selectedBlocker.zh ?? selectedBlocker.message ?? code,
traceId: error.traceId ?? result?.traceId,
route: error.route ?? selectedBlocker.route,
toolName: error.toolName ?? selectedBlocker.toolName
};
}
function failureMessage(result) {
const presentation = agentFailurePresentation(structuredBlockedErrorFromResult(result) ?? result?.error, { result });
if (presentation.category !== "unknown") {
return presentation.text;
}
if (result?.error?.code === "provider_unavailable" || result?.availability?.status === "blocked") {
const availability = result.error?.providerStatus || result.availability?.status !== "blocked"
? codeAgentAvailabilityFromResult(result)
: result.availability ?? codeAgentAvailabilityFromResult(result);
return codeAgentBlockedSummary(availability);
}
const missing = [
...(result.error?.missingCommands ?? []),
...(result.error?.missingEnv ?? [])
].filter(Boolean);
const suffix = missing.length ? ` 缺口:${missing.join("、")}。` : "";
return `Code Agent 调用失败:${result.error?.message ?? "未知错误"}${suffix}请稍后重试或联系维护者补齐后端 provider。`;
}
function agentErrorFromHttpResponse(response, traceId) {
const payloadError = response.data?.error;
const code = response.timeout
? "client_timeout"
: normalizeErrorCode(
(payloadError && typeof payloadError === "object" ? payloadError.code : null) ??
(typeof payloadError === "string" ? payloadError : null) ??
response.data?.code ??
"request_failed"
);
const error = new Error(response.timeout
? `Code Agent 超过 ${response.timeoutMs}ms 无新事件;已保留输入,可稍后重试。`
: payloadError?.userMessage || response.error || "Code Agent 请求失败");
error.traceId = response.data?.traceId || traceId;
error.code = code;
error.layer = payloadError && typeof payloadError === "object" ? payloadError.layer : undefined;
error.blocker = payloadError && typeof payloadError === "object" ? payloadError.blocker : undefined;
error.retryable = payloadError && typeof payloadError === "object" ? payloadError.retryable : response.timeout ? true : undefined;
error.userMessage = payloadError && typeof payloadError === "object" ? payloadError.userMessage : undefined;
error.missingConfig = payloadError && typeof payloadError === "object" ? payloadError.missingConfig : undefined;
error.route = payloadError && typeof payloadError === "object" ? payloadError.route : undefined;
error.toolName = payloadError && typeof payloadError === "object" ? payloadError.toolName : undefined;
error.timeoutMs = response.timeoutMs ?? (payloadError && typeof payloadError === "object" ? payloadError.timeoutMs : undefined);
error.hardTimeoutMs = payloadError && typeof payloadError === "object" ? payloadError.hardTimeoutMs : undefined;
error.idleMs = response.idleMs ?? (payloadError && typeof payloadError === "object" ? payloadError.idleMs : undefined);
error.lastActivityAt = response.lastActivityAt ?? (payloadError && typeof payloadError === "object" ? payloadError.lastActivityAt : undefined);
error.waitingFor = response.waitingFor ?? (payloadError && typeof payloadError === "object" ? payloadError.waitingFor : undefined);
error.providerStatus = payloadError && typeof payloadError === "object" ? payloadError.providerStatus : response.status;
error.category = payloadError && typeof payloadError === "object" ? payloadError.category : undefined;
return error;
}
function agentFailurePresentation(error, { result = null, traceId = null } = {}) {
const code = normalizeErrorCode(error?.code);
const message = String(error?.message ?? result?.error?.message ?? "").trim();
const structuredBlocker = error?.blocker ?? result?.error?.blocker ?? result?.blocker ?? null;
const userMessage = String(error?.userMessage ?? result?.error?.userMessage ?? structuredBlocker?.userMessage ?? "").trim();
const observedTraceId = result?.traceId || error?.traceId || traceId || null;
const traceSuffix = observedTraceId ? ` trace=${observedTraceId}` : "";
const timeoutMs = error?.timeoutMs ?? result?.error?.timeoutMs ?? timeoutMsFromText(message);
const hardTimeoutMs = error?.hardTimeoutMs ?? result?.error?.hardTimeoutMs;
const idleMs = error?.idleMs ?? result?.error?.idleMs;
const lastActivityAt = error?.lastActivityAt ?? result?.error?.lastActivityAt;
const waitingFor = error?.waitingFor ?? result?.error?.waitingFor;
const providerStatus = error?.providerStatus ?? result?.error?.providerStatus;
if (code === "codex_stdio_hard_timeout") {
return {
category: "timeout",
title: "Code Agent 达到硬性上限",
text: `Code Agent 达到硬性总时长上限${hardTimeoutMs ? ` ${hardTimeoutMs}ms` : ""};输入已保留,可稍后重试或缩小问题范围。${traceSuffix}`
};
}
if (isTimeoutFailure(code, message)) {
const detail = [
typeof idleMs === "number" ? `idleMs=${idleMs}` : null,
lastActivityAt ? `lastActivityAt=${lastActivityAt}` : null,
waitingFor ? `waitingFor=${waitingFor}` : null
].filter(Boolean).join("");
return {
category: "timeout",
title: "Code Agent 无新事件超时",
text: `Code Agent ${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}无新事件;输入已保留,可稍后重试或缩小问题范围。${detail ? ` ${detail}。` : ""}${traceSuffix}`
};
}
if (code === "codex_stdio_canceled" || code === "session_canceled") {
return {
category: "canceled",
title: "Code Agent 已取消",
text: `本次 Codex stdio 请求已取消;输入、sessionId 和 traceId 已保留,可重试上一条消息。${traceSuffix}`
};
}
if (userMessage) {
const category = error?.category ?? result?.error?.category ?? structuredBlocker?.category ?? "runner_blocked";
return {
category,
title: titleForBlockerCategory(category, code),
text: `${userMessage}${traceSuffix}`
};
}
if (code === "session_busy") {
return {
category: "runner_busy",
title: "会话忙/请等待",
text: `Codex stdio runner 正在处理上一轮请求;输入已保留,请等待当前请求完成或取消后重试。${traceSuffix}`
};
}
if (code === "session_expired") {
return {
category: "session_blocked",
title: "会话已过期",
text: `当前 session 已过期;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}`
};
}
if (["session_reuse_conflict", "session_failed", "session_interrupted", "session_canceled", "session_timeout", "session_error"].includes(code)) {
return {
category: "session_blocked",
title: "Code Agent Session 受阻",
text: `当前 session 已过期、失败、中断或与 conversation 绑定不一致;输入已保留,可重新发送建立新的 Codex stdio session。${traceSuffix}`
};
}
if ([
"runner_unavailable",
"tool_unavailable",
"skills_unavailable",
"security_blocked",
"codex_stdio_blocked",
"codex_stdio_failed",
"codex_stdio_protocol_blocked",
"codex_stdio_empty_response",
"codex_stdio_command_probe_failed"
].includes(code)) {
return {
category: "runner_blocked",
title: "Code Agent Runner 受阻",
text: `Codex stdio runner 返回 ${code};本次不会 fallback 冒充 Codex session。${message ? `原因:${message}。` : ""}${traceSuffix}`
};
}
if (["invalid_params", "parse_error", "request_failed", "cloud_api_proxy_failed", "upstream_unavailable"].includes(code)) {
return {
category: "api_error",
title: "Code Agent API 错误",
text: `Code Agent API 请求失败;输入已保留,可稍后重试。${message ? `原因:${message}。` : ""}${traceSuffix}`
};
}
if (code === "provider_unavailable" || providerStatus) {
return {
category: "provider",
title: "Code Agent Provider 不可用",
text: providerStatus
? `Code Agent provider 返回 HTTP ${providerStatus};本次未生成回复,输入已保留,可稍后重试。${traceSuffix}`
: `Code Agent provider 暂不可用;本次未生成回复,输入已保留,可稍后重试。${traceSuffix}`
};
}
return {
category: "unknown",
title: "Code Agent 返回失败",
text: `Code Agent 调用失败:${message || "未知错误"}。请稍后重试或联系维护者补齐后端 provider。${traceSuffix}`
};
}
function titleForBlockerCategory(category, code) {
const value = String(category ?? "").trim();
if (value === "needs_config") return "Code Agent 需要配置";
if (value === "security_blocked" || code === "security_blocked") return "Code Agent 安全阻断";
if (value === "fallback" || code === "text_chat_only_fallback") return "Code Agent 仍是 fallback";
if (value === "timeout") return "Code Agent 无新事件超时";
if (value === "canceled" || code === "codex_stdio_canceled") return "Code Agent 已取消";
if (value === "runner_busy") return "Code Agent Runner 忙碌";
if (value === "session_blocked") return "Code Agent Session 受阻";
if (value === "runner_blocked") return "Code Agent Runner 受阻";
if (value === "api_error") return "Code Agent API 错误";
if (value === "capability_unavailable") return "Code Agent 能力未开放";
if (value === "retryable") return "Code Agent 可重试";
return "Code Agent 服务受阻";
}
function normalizeErrorCode(code) {
const normalized = String(code ?? "").trim();
return normalized || "code_agent_failed";
}
function isTimeoutFailure(code, message) {
return ["client_timeout", "proxy_timeout", "cloud_api_proxy_timeout", "codex_stdio_timeout", "codex_stdio_idle_timeout", "codex_stdio_hard_timeout"].includes(code) ||
/\b(?:timeout|timed out|abort|aborted|超时|请求超过|无新事件)\b/iu.test(message);
}
function isCodeAgentTimeoutError(error, fallbackMessage = "") {
const code = normalizeErrorCode(error?.code);
const message = [error?.message, error?.userMessage, fallbackMessage]
.filter(Boolean)
.join(" ");
return isTimeoutFailure(code, message);
}
function timeoutMsFromText(message) {
const match = String(message ?? "").match(/(?:timed out after|timeout after|请求超时|请求超过|超过)\s*(\d+)\s*ms/iu);
if (!match) return null;
const value = Number.parseInt(match[1], 10);
return Number.isInteger(value) && value > 0 ? value : null;
}
function messageCard(message) {
const article = document.createElement("article");
article.className = `message-card message-${toneClass(message.role)} status-${toneClass(message.status)}`;
article.append(textSpan(roleLabel(message.role), "message-role"));
article.append(textSpan(message.title, "message-title"));
article.append(messageCopyNode(message));
const pendingContext = messagePendingContextPanel(message);
if (pendingContext) article.append(pendingContext);
const sessionContext = messageSessionContinuityPanel(message);
if (sessionContext) article.append(sessionContext);
const runtimePath = messageRuntimePathPanel(message);
if (runtimePath) article.append(runtimePath);
const tracePanel = messageTracePanel(message);
if (tracePanel) article.append(tracePanel);
const actions = messageActionsPanel(message);
if (actions) article.append(actions);
return article;
}
function messageCopyNode(message) {
if (!shouldRenderMessageMarkdown(message)) {
return textSpan(message.text, "message-copy");
}
const node = document.createElement("div");
node.className = "message-copy message-copy-markdown";
const markdown = String(message.text ?? "");
try {
node.innerHTML = renderMessageMarkdown(markdown);
} catch (error) {
node.textContent = markdown || `Markdown 渲染失败:${error.message}`;
}
hardenRenderedMarkdown(node);
return node;
}
function shouldRenderMessageMarkdown(message) {
return message?.role === "agent" && MESSAGE_MARKDOWN_STATUSES.includes(String(message.status ?? "").toLowerCase());
}
function messageActionsPanel(message) {
if (message.role !== "agent") return null;
const actions = [];
if (message.status === "running") {
actions.push(actionButton("取消当前请求", "cancel", () => cancelAgentMessage(message.id), "取消当前 in-flight Codex stdio 请求"));
if (message.retryInput) {
actions.push(actionButton("重试上一条", "retry", () => restartRunningAgentMessage(message.id), "先取消当前 trace,再用同一输入重新发送"));
}
}
if (canRetryTerminalAgentMessage(message)) {
actions.push(actionButton("重试上一条", "retry", () => retryAgentMessage(message.id), "保留 conversation/trace 记录并重新发送上一条输入"));
}
if (message.traceId) {
actions.push(actionButton("回放 trace", "trace", () => replayAgentTrace(message.id), "从 runnerTrace store 重新读取真实事件"));
}
if (actions.length === 0 && !message.traceReplayStatus) return null;
const panel = document.createElement("div");
panel.className = "message-actions";
panel.append(...actions);
if (message.traceReplayStatus) {
panel.append(textSpan(message.traceReplayStatus, "message-action-status"));
}
return panel;
}
function actionButton(label, action, onClick, title) {
const button = document.createElement("button");
button.type = "button";
button.className = `message-action message-action-${action}`;
button.textContent = label;
button.title = title;
button.addEventListener("click", onClick);
return button;
}
function canRetryTerminalAgentMessage(message) {
return Boolean(
message?.retryInput &&
["failed", "timeout", "error", "canceled"].includes(String(message.status ?? "").toLowerCase())
);
}
async function restartRunningAgentMessage(messageId) {
const message = state.chatMessages.find((item) => item.id === messageId);
if (!message?.retryInput || message.status !== "running") return;
await cancelAgentMessage(messageId);
const canceled = state.chatMessages.find((item) => item.id === messageId);
if (canceled?.status !== "canceled" || state.chatPending) {
setAgentActionStatus(messageId, "取消未确认,当前 trace 仍在等待后端;已保留输入,可等待真实超时或后端结果后再重试。");
return;
}
await submitAgentMessage(message.retryInput, {
conversationId: canceled.conversationId || state.conversationId || undefined,
sessionId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status)
? undefined
: canceled.sessionId,
threadId: isTerminalSessionStatus(canceled.session?.status ?? canceled.runnerTrace?.sessionStatus ?? canceled.status)
? undefined
: canceled.threadId ?? threadIdFrom(canceled),
retryOf: canceled.traceId
});
}
async function retryAgentMessage(messageId) {
const message = state.chatMessages.find((item) => item.id === messageId);
if (!message?.retryInput) return;
if (state.chatPending) {
setAgentActionStatus(messageId, "当前仍有请求处理中;请先取消当前请求,或等待真实超时/后端结果后再重试。");
return;
}
await submitAgentMessage(message.retryInput, {
conversationId: message.conversationId || state.conversationId || undefined,
sessionId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status)
? undefined
: message.sessionId,
threadId: isTerminalSessionStatus(message.session?.status ?? message.runnerTrace?.sessionStatus ?? message.status)
? undefined
: message.threadId ?? threadIdFrom(message),
retryOf: message.traceId
});
}
function setAgentActionStatus(messageId, text) {
const index = state.chatMessages.findIndex((message) => message.id === messageId);
if (index < 0) return;
state.chatMessages[index] = {
...state.chatMessages[index],
traceReplayStatus: text,
updatedAt: new Date().toISOString()
};
renderConversation();
}
async function cancelAgentMessage(messageId) {
const index = state.chatMessages.findIndex((message) => message.id === messageId);
if (index < 0) return;
const message = state.chatMessages[index];
const traceId = message.traceId;
const sessionId = message.sessionId || message.runnerTrace?.sessionId || state.currentRequest?.sessionId || state.sessionId;
const threadId = message.threadId || message.runnerTrace?.threadId || state.currentRequest?.threadId || state.threadId;
const response = await cancelAgentRequest({
traceId,
conversationId: message.conversationId || state.conversationId,
sessionId,
threadId
});
const payload = response.data ?? {};
const runnerTrace = payload.runnerTrace ?? latestTraceSnapshot(traceId);
if (response.ok && payload.canceled === true) {
state.canceledTraces.add(traceId);
stopRunningTraceStream(traceId);
state.chatPending = false;
state.currentRequest = null;
state.sessionId = payload.sessionId || sessionId || state.sessionId;
state.sessionStatus = payload.session?.status ?? runnerTrace?.sessionStatus ?? "canceled";
state.chatMessages[index] = {
...message,
title: "Code Agent 已取消",
text: payload.userMessage || "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
status: "canceled",
sessionId: payload.sessionId || sessionId || message.sessionId,
threadId: payload.threadId || threadId || message.threadId,
sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "codex_stdio_canceled" }),
session: payload.session ?? message.session,
runnerTrace,
traceReplayStatus: lastTraceEventLabel(runnerTrace),
updatedAt: new Date().toISOString(),
error: payload.error ?? {
code: "codex_stdio_canceled",
category: "canceled",
retryable: true,
message: "user canceled current Code Agent request"
}
};
if (message.retryInput) el.commandInput.value = message.retryInput;
} else {
state.chatMessages[index] = {
...message,
title: "Code Agent 取消受阻",
text: payload.error?.userMessage || response.error || "当前请求没有可取消的 in-flight Codex stdio session;输入和 trace 已保留。",
status: "failed",
threadId,
sessionContinuity: failedSessionContinuity(message.sessionContinuity, { code: "cancel_blocked" }),
runnerTrace,
traceReplayStatus: lastTraceEventLabel(runnerTrace),
updatedAt: new Date().toISOString(),
error: payload.error ?? {
code: "cancel_blocked",
category: "cancel_blocked",
retryable: true,
message: response.error || "cancel blocked"
}
};
if (message.retryInput) el.commandInput.value = message.retryInput;
}
renderAgentChatStatus(state.chatMessages[index].status, state.chatMessages[index]);
renderCodeAgentSummary();
renderConversation();
renderDrafts();
renderDevicePodPanel(state.liveSurface);
}
function stopRunningTraceStream(traceId) {
const id = nonEmptyString(traceId);
if (!id) return;
const close = state.traceStreams.get(id);
if (typeof close === "function") close();
state.traceStreams.delete(id);
}
async function cancelAgentRequest({ traceId, conversationId, sessionId, threadId }) {
return fetchJson("/v1/agent/chat/cancel", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Trace-Id": traceId,
"Prefer": "respond-async"
},
timeoutMs: CODE_AGENT_CANCEL_TIMEOUT_MS,
timeoutName: "Code Agent cancel",
body: JSON.stringify({
traceId,
conversationId,
sessionId,
threadId
})
});
}
async function replayAgentTrace(messageId) {
const index = state.chatMessages.findIndex((message) => message.id === messageId);
if (index < 0) return;
const message = state.chatMessages[index];
if (!message.traceId) return;
const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, {
timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS),
timeoutName: "Code Agent trace replay"
});
if (response.ok) {
const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, message.runnerTrace);
state.chatMessages[index] = {
...message,
runnerTrace,
threadId: runnerTrace.threadId ?? message.threadId,
traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event${lastTraceEventLabel(runnerTrace)}`,
updatedAt: response.data?.updatedAt ?? new Date().toISOString()
};
if (shouldReconcileCodeAgentResult(state.chatMessages[index], runnerTrace)) {
reconcileCodeAgentResult(message.id, { quiet: true });
}
} else {
state.chatMessages[index] = {
...message,
traceReplayStatus: response.error || "trace 回放失败",
updatedAt: new Date().toISOString()
};
}
renderCodeAgentSummary();
renderConversation();
renderDevicePodPanel(state.liveSurface);
}
function maybeReplayFullTraceForMessage(message) {
if (!message || !message.traceId || message.runnerTrace?.eventsCompacted !== true) return;
if (state.fullTraceReplayInFlight.has(message.traceId)) return;
replayFullTrace(message.id, { quiet: true });
}
async function replayFullTrace(messageId, options = {}) {
const index = state.chatMessages.findIndex((message) => message.id === messageId);
if (index < 0) return false;
const message = state.chatMessages[index];
if (!message.traceId) return false;
if (state.fullTraceReplayInFlight.has(message.traceId)) return false;
state.fullTraceReplayInFlight.add(message.traceId);
try {
const response = await fetchJson(`/v1/agent/chat/trace/${encodeURIComponent(message.traceId)}`, {
timeoutMs: Math.min(Math.max(API_TIMEOUT_MS, 5000), FULL_TRACE_REPLAY_TIMEOUT_MS),
timeoutName: "Code Agent full trace replay"
});
if (!response.ok) return false;
const currentIndex = state.chatMessages.findIndex((item) => item.id === messageId);
if (currentIndex < 0) return false;
const current = state.chatMessages[currentIndex];
const runnerTrace = runnerTraceFromSnapshot({ ...response.data, fullTraceLoaded: true }, current.runnerTrace);
state.chatMessages[currentIndex] = {
...current,
runnerTrace,
threadId: runnerTrace.threadId ?? current.threadId,
traceReplayStatus: `完整 trace 已回放:${runnerTrace.events?.length ?? 0} 个原始 event${lastTraceEventLabel(runnerTrace)}`,
updatedAt: response.data?.updatedAt ?? new Date().toISOString()
};
if (shouldReconcileCodeAgentResult(state.chatMessages[currentIndex], runnerTrace)) {
reconcileCodeAgentResult(messageId, { quiet: true });
}
if (options.quiet !== true) {
renderCodeAgentSummary();
renderConversation();
renderDevicePodPanel(state.liveSurface);
} else {
renderConversation();
}
return true;
} finally {
state.fullTraceReplayInFlight.delete(message.traceId);
}
}
function lastTraceEventLabel(runnerTrace) {
const event = runnerTrace?.lastEvent ?? (Array.isArray(runnerTrace?.events) ? runnerTrace.events.at(-1) : null);
if (!event) return "lastEvent=none";
return `lastEvent=${event.label ?? `${event.type ?? "event"}:${event.status ?? "observed"}`}`;
}
function messageSessionContinuityPanel(message) {
if (message.role !== "agent" || message.status === "running") return null;
const continuity = message.sessionContinuity ?? completedSessionContinuity(message, pendingSessionContinuity({
conversationId: message.conversationId,
sessionId: message.sessionId,
threadId: message.threadId,
retryOf: message.retryOf
}));
if (!continuity) return null;
const fields = [
["状态", continuity.label],
["conversation", continuity.returned?.conversationId ?? message.conversationId],
["session", continuity.returned?.sessionId ?? message.sessionId ?? "未确认"],
["thread", continuity.returned?.threadId ?? message.threadId ?? "未确认"],
["lifecycle", message.sessionLifecycleStatus ?? message.sessionSummary?.status ?? message.session?.lifecycleStatus],
["提示", message.sessionSummary?.userMessage ?? message.sessionLifecycle?.userMessage],
["requestedSession", continuity.requested?.sessionId],
["requestedThread", continuity.requested?.threadId],
["retryOf", continuity.requested?.retryOf ?? message.retryOf],
["missing", continuity.missing?.join("、")],
["changed", continuity.changed?.join("、")]
];
const list = document.createElement("div");
list.className = "message-session-grid";
for (const [label, value] of fields) {
if (value === undefined || value === null || value === "") continue;
const item = document.createElement("div");
item.className = "message-session-row";
item.append(textSpan(label, "message-session-key"), textSpan(value, "message-session-value mono"));
if (label === "conversation" || label === "session" || label === "thread") {
item.append(copyButton(value, label));
}
list.append(item);
}
return messageCompactDetails({
className: "message-session-context",
tone: continuity.tone,
badgeLabel: continuity.label,
badgeTone: continuity.tone,
summary: continuity.summary,
dialogTitle: "会话明细",
body: list
});
}
function messagePendingContextPanel(message) {
if (message.status !== "running") return null;
const continuity = message.sessionContinuity ?? pendingSessionContinuity({
conversationId: message.conversationId,
sessionId: message.sessionId,
threadId: message.threadId,
retryOf: message.retryOf
});
const fields = [
["状态", continuity.label],
["traceId", message.traceId],
["conversation", message.conversationId],
["session", message.sessionId ?? message.runnerTrace?.sessionId ?? "等待后端分配"],
["thread", message.threadId ?? message.runnerTrace?.threadId ?? "等待后端分配"],
["sessionStatus", message.runnerTrace?.sessionStatus],
["lifecycle", message.runnerTrace?.sessionLifecycleStatus],
["activityTimeout", `${formatTraceDuration(CODE_AGENT_TIMEOUT_MS)} 无新事件`]
];
const section = document.createElement("section");
section.className = "message-pending-context tone-border-pending";
const header = document.createElement("div");
header.className = "message-pending-head";
header.append(badge("处理中", "pending"), textSpan(continuity.summary, "message-pending-summary"));
const list = document.createElement("div");
list.className = "message-pending-grid";
for (const [label, value] of fields) {
if (value === undefined || value === null || value === "") continue;
const item = document.createElement("div");
item.className = "message-pending-row";
item.append(textSpan(label, "message-pending-key"), textSpan(value, "message-pending-value mono"));
if (label === "traceId" || label === "conversation" || label === "session") {
item.append(copyButton(value, label));
}
list.append(item);
}
section.append(header, list);
return section;
}
function messageRuntimePathPanel(message) {
if (message.role !== "agent") return null;
const runtimePath = codeAgentRuntimePathFromMessage(message);
if (!runtimePath) return null;
const rows = document.createElement("div");
rows.className = "message-runtime-grid";
for (const row of runtimePath.rows) {
const item = document.createElement("div");
item.className = `message-runtime-row${row.missing ? " message-runtime-row-missing" : ""}`;
item.append(textSpan(row.label, "message-runtime-key"), textSpan(row.value, "message-runtime-value mono"));
if (!row.missing && (row.label === "providerTrace.command" || row.label === "providerTrace.terminalStatus" || row.label === "protocol")) {
item.append(copyButton(row.value, row.label));
}
rows.append(item);
}
if (runtimePath.missingFields.length > 0) {
const item = document.createElement("div");
item.className = "message-runtime-row message-runtime-row-missing message-runtime-row-wide";
item.append(
textSpan("缺失字段", "message-runtime-key"),
textSpan(runtimePath.missingFields.join(", "), "message-runtime-value mono")
);
rows.append(item);
}
const details = messageCompactDetails({
className: "message-runtime-path",
tone: runtimePath.tone,
badgeLabel: runtimePath.label,
badgeTone: runtimePath.tone,
summary: runtimePath.summary,
dialogTitle: "运行路径明细",
body: rows
});
details.dataset.runtimePathKind = runtimePath.kind;
return details;
}
function messageCompactDetails({ className, tone, badgeLabel, badgeTone, summary, dialogTitle, body }) {
const section = document.createElement("section");
section.className = `${className} message-compact-details tone-border-${toneClass(tone)}`;
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "message-compact-summary";
trigger.setAttribute("aria-haspopup", "dialog");
trigger.append(
badge(badgeLabel, badgeTone),
textSpan(summary, "message-compact-summary-text")
);
trigger.addEventListener("click", () => {
openWorkbenchDialog({
title: dialogTitle,
body,
restoreFocus: trigger
});
});
section.append(trigger);
return section;
}
function boundedJson(value, maxLength = 6000) {
let text = "";
try {
text = JSON.stringify(value, null, 2);
} catch {
text = String(value ?? "");
}
return text.length > maxLength ? `${text.slice(0, maxLength)}\n...字段已截断` : text;
}
function messageTracePanel(message) {
const trace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null;
if (!trace && !message.traceId) return null;
const details = document.createElement("details");
details.className = "message-trace";
const traceUiKey = messageTraceUiKey(message);
if (traceUiKey) details.dataset.traceUiKey = traceUiKey;
const storedOpen = traceUiKey ? state.traceDetailsOpen.get(traceUiKey) : undefined;
details.open = typeof storedOpen === "boolean" ? storedOpen : defaultTraceDetailsOpen(message);
if (traceUiKey) {
details.addEventListener("toggle", () => {
state.traceDetailsOpen.set(traceUiKey, details.open);
});
}
const summary = document.createElement("summary");
summary.className = "message-meta";
summary.textContent = runnerTraceHeadline(message, trace);
const list = document.createElement("ol");
list.className = "message-trace-events";
if (traceUiKey) {
list.dataset.traceUiKey = traceUiKey;
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
list.addEventListener(eventName, () => markTraceScrollIntent(traceUiKey), { passive: true });
}
list.addEventListener("keydown", (event) => {
if (isScrollIntentKey(event.key)) markTraceScrollIntent(traceUiKey);
});
list.addEventListener("scroll", () => rememberTraceScrollPosition(traceUiKey, list), { passive: true });
}
const events = Array.isArray(trace?.events) ? trace.events : [];
const rows = traceDisplayRows(trace, events);
if (events.length === 0) {
const item = document.createElement("li");
item.textContent = `trace=${message.traceId ?? "pending"};等待后端事件。`;
list.append(item);
} else {
const toolbar = messageTraceToolbar(message, trace, events, rows);
list.dataset.traceMode = "all";
renderTraceEventList(list, rows);
details.append(summary, toolbar, list);
return details;
}
details.append(summary, list);
return details;
}
function messageTraceUiKey(message) {
const traceId = nonEmptyString(message.traceId);
const messageId = nonEmptyString(message.id ?? message.messageId);
if (!traceId && !messageId) return null;
return `${messageId ?? "message"}:${traceId ?? "trace"}`;
}
function defaultTraceDetailsOpen(message) {
return ["running", "completed", "source", "failed", "timeout", "canceled", "error"].includes(message.status);
}
function messageTraceToolbar(message, trace, events, rows) {
const toolbar = document.createElement("div");
toolbar.className = "message-trace-toolbar";
const rawTotal = Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length;
const count = textSpan(messageTraceCountText(trace, rawTotal, events.length, rows.length), "message-trace-count");
toolbar.append(count);
toolbar.append(traceActionButton("复制 JSON", () => copyTextToClipboard(messageTraceJson(message, trace, events)), "复制完整 trace JSON"));
toolbar.append(traceActionButton("下载 trace", () => downloadTraceJson(message, trace, events), "下载完整 trace JSON 文件"));
return toolbar;
}
function messageTraceCountText(trace, rawTotal, loadedTotal, readableTotal) {
if (trace?.eventsCompacted === true && trace?.fullTraceLoaded !== true) {
return `完整 trace 回放中 / 当前可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`;
}
return `显示全部可读事件 ${readableTotal} / 已载入原始 ${loadedTotal} / 后端原始 ${rawTotal}`;
}
function renderTraceEventList(list, rows) {
patchTraceEventList(list, rows.map((row, index) => traceEventRowNode(row, index)));
}
function patchTraceEventList(list, nextItems) {
const currentById = new Map();
for (const current of [...list.children]) {
const rowId = current.dataset.traceRowId;
if (rowId && !currentById.has(rowId)) currentById.set(rowId, current);
}
const retained = new Set();
for (let index = 0; index < nextItems.length; index += 1) {
const next = nextItems[index];
const rowId = next.dataset.traceRowId;
const current = rowId ? currentById.get(rowId) : null;
const item = current && !retained.has(current) ? current : next;
if (item === current) patchTraceRowElement(current, next);
const before = list.children[index] ?? null;
if (before !== item) list.insertBefore(item, before);
retained.add(item);
}
for (const current of [...list.children]) {
if (!retained.has(current)) current.remove();
}
}
function patchTraceRowElement(current, next) {
current.className = next.className;
current.dataset.traceRowKey = next.dataset.traceRowKey ?? "";
current.dataset.traceRowId = next.dataset.traceRowId ?? "";
const currentHeader = current.querySelector(".message-trace-line");
const nextHeader = next.querySelector(".message-trace-line");
if (currentHeader && nextHeader) {
currentHeader.className = nextHeader.className;
if (currentHeader.textContent !== nextHeader.textContent) currentHeader.textContent = nextHeader.textContent;
} else if (!currentHeader && nextHeader) {
current.prepend(nextHeader);
} else if (currentHeader && !nextHeader) {
currentHeader.remove();
}
const currentBody = current.querySelector(".message-trace-body");
const nextBody = next.querySelector(".message-trace-body");
if (currentBody && nextBody) {
patchTraceBodyElement(currentBody, nextBody);
} else if (!currentBody && nextBody) {
current.append(nextBody);
} else if (currentBody && !nextBody) {
currentBody.remove();
}
}
function patchTraceBodyElement(currentBody, nextBody) {
const position = elementScrollPosition(currentBody);
currentBody.className = nextBody.className;
currentBody.dataset.traceBodyId = nextBody.dataset.traceBodyId ?? "";
if (currentBody.textContent !== nextBody.textContent) {
currentBody.textContent = nextBody.textContent;
}
restoreElementScrollPosition(currentBody, position);
rememberTraceBodyScrollPosition(currentBody);
}
function traceEventRowNode(row, index = 0) {
const item = document.createElement("li");
item.className = `message-trace-row tone-border-${toneClass(row.tone)}`;
const rowId = traceRowId(row, index);
item.dataset.traceRowId = rowId;
item.dataset.traceRowKey = traceRowKey(row);
const header = document.createElement("div");
header.className = "message-trace-line";
header.textContent = row.header;
item.append(header);
if (row.body) {
const body = document.createElement("pre");
body.className = "message-trace-body";
body.dataset.traceBodyId = rowId;
installTraceBodyScrollListeners(body);
body.textContent = row.body;
item.append(body);
}
return item;
}
function installTraceBodyScrollListeners(body) {
for (const eventName of ["wheel", "touchstart", "pointerdown"]) {
body.addEventListener(eventName, () => markTraceBodyScrollIntent(body), { passive: true });
}
body.addEventListener("keydown", (event) => {
if (isScrollIntentKey(event.key)) markTraceBodyScrollIntent(body);
});
body.addEventListener("scroll", () => rememberTraceBodyScrollPosition(body), { passive: true });
}
function traceRowId(row, index = 0) {
if (row?.rowId) return String(row.rowId);
if (row?.seq !== null && row?.seq !== undefined) return `event:${row.seq}`;
return `row:${index}:${traceRowKey(row)}`;
}
function traceRowKey(row) {
return `${row.header}\n${row.body ?? ""}\n${row.tone ?? ""}`;
}
function installWorkbenchTestHooks() {
if (!isLocalWorkbenchTestHost() || !new URLSearchParams(window.location.search).has("hwlab-test-hooks")) return;
window.__hwlabWorkbenchTestHooks = {
seedCompletedAgentMessage,
seedTraceMessage,
appendTraceEvents,
reconcileTraceResultFixture,
latestAgentMessageText,
traceScrollMetrics,
traceDomIdentity,
traceBodyDomIdentity,
setTraceBodyScrollTop,
replaceTraceAssistantStream,
setTraceScrollTop,
setConversationScrollTop,
resetProgrammaticScrollWriteCount
};
}
function isLocalWorkbenchTestHost() {
return ["127.0.0.1", "localhost", "::1", "[::1]"].includes(window.location.hostname);
}
function seedCompletedAgentMessage(options = {}) {
state.chatMessages = [{
id: options.messageId ?? "msg_markdown_contract",
role: "agent",
title: options.title ?? "Code Agent 回复",
text: String(options.text ?? ""),
status: options.status ?? "completed",
traceId: options.traceId ?? "trc_markdown_contract",
conversationId: options.conversationId ?? "cnv_markdown_contract",
sessionId: options.sessionId ?? "ses_markdown_contract"
}];
renderConversation();
}
function seedTraceMessage(options = {}) {
const traceId = options.traceId ?? "trc_scroll_contract";
const messageId = options.messageId ?? "msg_scroll_contract";
const count = Math.max(1, Number(options.count ?? 80));
const events = Array.isArray(options.events)
? options.events.map((event, index) => normalizeTestTraceEvent(traceId, event, index + 1))
: Array.from({ length: count }, (_, index) => testTraceEvent(traceId, index + 1));
state.chatMessages = [{
id: messageId,
role: "agent",
title: "Code Agent 处理中",
text: "Trace scroll contract fixture",
status: "running",
traceId,
runnerTrace: {
traceId,
events,
assistantStreams: Array.isArray(options.assistantStreams) ? options.assistantStreams : [],
eventCount: Number.isInteger(options.eventCount) ? options.eventCount : events.length,
eventsCompacted: options.eventsCompacted === true,
eventWindow: options.eventWindow ?? null,
fullTraceLoaded: options.fullTraceLoaded === true,
lastEvent: options.lastEvent ?? events.at(-1),
waitingFor: "turn/completed"
}
}];
renderConversation();
}
function normalizeTestTraceEvent(traceId, event, seq) {
return {
traceId,
seq,
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
...event,
traceId: event?.traceId ?? traceId,
seq: event?.seq ?? seq,
createdAt: event?.createdAt ?? new Date(1779690000000 + seq * 1000).toISOString()
};
}
function appendTraceEvents(count = 1) {
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
if (!message) return;
const trace = message.runnerTrace;
const start = Array.isArray(trace.events) ? trace.events.length : 0;
const additions = Array.from({ length: Math.max(1, Number(count)) }, (_, index) => testTraceEvent(trace.traceId, start + index + 1));
trace.events = [...trace.events, ...additions];
trace.eventCount = trace.events.length;
trace.lastEvent = trace.events.at(-1);
patchMessageTracePanel(message);
}
function replaceTraceAssistantStream(stream = {}) {
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId);
if (!message) return null;
const trace = message.runnerTrace;
trace.assistantStreams = [{
itemId: "assistant-message",
chunkCount: Math.max(1, Number(stream.chunkCount ?? 1)),
createdAt: stream.createdAt ?? new Date(1779699000000).toISOString(),
updatedAt: stream.updatedAt ?? new Date(1779699001000 + Math.max(1, Number(stream.chunkCount ?? 1)) * 1000).toISOString(),
waitingFor: stream.waitingFor ?? "turn/completed",
text: String(stream.text ?? "assistant stream fixture")
}];
patchMessageTracePanel(message);
return traceBodyDomIdentity(".message-trace-events[data-trace-ui-key] li:last-child pre");
}
async function reconcileTraceResultFixture(result) {
const message = state.chatMessages.find((item) => item.runnerTrace?.traceId || item.traceId);
if (!message) return null;
applyCodeAgentResultToMessage(message.id, {
...(result && typeof result === "object" ? result : {}),
traceId: message.traceId,
conversationId: message.conversationId ?? "cnv_fixture",
sessionId: message.sessionId ?? "ses_fixture",
status: "completed",
provider: "codex-stdio",
model: "gpt-test",
backend: "hwlab-cloud-api/codex-app-server-stdio",
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
capabilityLevel: "long-lived-codex-stdio-session",
runner: {
kind: "codex-app-server-stdio-runner",
codexStdio: true,
writeCapable: true,
durableSession: true,
workspace: "/workspace/hwlab",
sandbox: "workspace-write",
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
capabilityLevel: "long-lived-codex-stdio-session"
},
session: {
sessionId: message.sessionId ?? "ses_fixture",
status: "idle",
idleTimeoutMs: 1800000,
lastTraceId: message.traceId,
longLivedSession: true,
durableSession: true,
codexStdio: true,
writeCapable: true
},
sessionMode: "codex-app-server-stdio-long-lived",
implementationType: "repo-owned-codex-app-server-stdio-session",
sessionReuse: { reused: true, sessionId: message.sessionId ?? "ses_fixture" },
longLivedSessionGate: { status: "pass", pass: true },
toolCalls: [{ name: "codex-app-server.thread/resume+turn/start", status: "completed" }],
skills: [],
reply: {
content: "fixture reconciled result",
...(result?.reply && typeof result.reply === "object" ? result.reply : {})
},
providerTrace: {
protocol: "codex-app-server-jsonrpc-stdio",
command: "codex app-server --listen stdio://",
terminalStatus: "completed",
...(result?.providerTrace && typeof result.providerTrace === "object" ? result.providerTrace : {})
},
runnerTrace: message.runnerTrace,
updatedAt: new Date().toISOString()
}, {
baseMessage: message,
retryInput: message.retryInput,
retryOf: message.retryOf
});
renderCodeAgentSummary();
renderConversation();
return latestAgentMessageText();
}
function latestAgentMessageText() {
const message = [...state.chatMessages].reverse().find((item) => item.role === "agent");
return message ? {
title: message.title,
text: message.text,
status: message.status,
traceId: message.traceId,
providerTrace: message.providerTrace ?? null,
sourceKind: message.sourceKind ?? null,
capabilityLevel: message.capabilityLevel ?? null,
error: message.error ?? null
} : null;
}
function testTraceEvent(traceId, seq) {
return {
traceId,
label: "trace:scroll-contract",
type: "trace",
status: "observed",
createdAt: new Date(1779690000000 + seq * 1000).toISOString(),
message: `trace scroll contract event ${seq}\n${"payload ".repeat(18)}`,
waitingFor: "turn/completed"
};
}
function traceScrollMetrics() {
const conversation = el.conversationList;
const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]");
return {
conversationTop: conversation.scrollTop,
conversationBottomGap: scrollBottomGap(conversation),
traceTop: list?.scrollTop ?? null,
traceBottomGap: list ? scrollBottomGap(list) : null,
traceScrollHeight: list?.scrollHeight ?? null,
traceClientHeight: list?.clientHeight ?? null,
traceRowCount: list?.querySelectorAll(".message-trace-row").length ?? 0
};
}
function traceDomIdentity() {
const conversation = el.conversationList;
const panel = conversation.querySelector(".message-trace[data-trace-ui-key]");
const list = conversation.querySelector(".message-trace-events[data-trace-ui-key]");
const firstRow = list?.querySelector(".message-trace-row") ?? null;
if (panel && !panel.__hwlabTraceIdentity) panel.__hwlabTraceIdentity = `panel-${Date.now()}-${Math.random()}`;
if (list && !list.__hwlabTraceIdentity) list.__hwlabTraceIdentity = `list-${Date.now()}-${Math.random()}`;
if (firstRow && !firstRow.__hwlabTraceIdentity) firstRow.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`;
return {
panel: panel?.__hwlabTraceIdentity ?? null,
list: list?.__hwlabTraceIdentity ?? null,
firstRow: firstRow?.__hwlabTraceIdentity ?? null,
rowCount: list?.querySelectorAll(".message-trace-row").length ?? 0,
programmaticScrollWrites: state.traceProgrammaticScrollWrites
};
}
function traceBodyDomIdentity(selector = ".message-trace-events[data-trace-ui-key] li:last-child pre") {
const body = el.conversationList.querySelector(selector);
const row = body?.closest(".message-trace-row") ?? null;
if (body && !body.__hwlabTraceIdentity) body.__hwlabTraceIdentity = `body-${Date.now()}-${Math.random()}`;
if (row && !row.__hwlabTraceIdentity) row.__hwlabTraceIdentity = `row-${Date.now()}-${Math.random()}`;
return {
selector,
body: body?.__hwlabTraceIdentity ?? null,
row: row?.__hwlabTraceIdentity ?? null,
rowId: row?.dataset.traceRowId ?? null,
top: body?.scrollTop ?? null,
left: body?.scrollLeft ?? null,
scrollHeight: body?.scrollHeight ?? null,
clientHeight: body?.clientHeight ?? null,
text: body?.textContent ?? null
};
}
function resetProgrammaticScrollWriteCount() {
state.traceProgrammaticScrollWrites = 0;
return state.traceProgrammaticScrollWrites;
}
function setTraceScrollTop(top, { user = true } = {}) {
const list = el.conversationList.querySelector(".message-trace-events[data-trace-ui-key]");
if (!list) return traceScrollMetrics();
if (user) markTraceScrollIntent(list.dataset.traceUiKey);
list.scrollTop = top;
rememberTraceScrollPosition(list.dataset.traceUiKey, list);
return traceScrollMetrics();
}
function setTraceBodyScrollTop(selector, top, { user = true } = {}) {
const body = el.conversationList.querySelector(selector);
if (!body) return traceBodyDomIdentity(selector);
if (user) markTraceBodyScrollIntent(body);
body.scrollTop = top;
rememberTraceBodyScrollPosition(body);
return traceBodyDomIdentity(selector);
}
function setConversationScrollTop(top, { user = true } = {}) {
if (user) markConversationScrollIntent();
el.conversationList.scrollTop = top;
captureConversationScrollPosition();
return traceScrollMetrics();
}
function traceDisplayRows(trace, events) {
const rows = [];
const assistantRows = traceAssistantStreamRows(trace);
let noisyRun = [];
let toolOutputRun = [];
const flushNoisyRun = () => {
if (noisyRun.length === 0) return;
const row = traceNoiseSummaryRow(trace, noisyRun);
if (row) rows.push(row);
noisyRun = [];
};
const flushToolOutputRun = () => {
if (toolOutputRun.length === 0) return;
const outputRows = traceToolOutputSummaryRows(trace, toolOutputRun);
rows.push(...outputRows);
toolOutputRun = [];
};
for (const event of events) {
if (isNoisyTraceEvent(event)) {
flushToolOutputRun();
noisyRun.push(event);
continue;
}
if (isToolOutputChunkTraceEvent(event)) {
flushNoisyRun();
toolOutputRun.push(event);
continue;
}
flushNoisyRun();
flushToolOutputRun();
const row = traceDisplayRow(trace, event);
if (!row) continue;
rows.push(row);
}
flushNoisyRun();
flushToolOutputRun();
rows.push(...assistantRows);
return rows.length > 0 ? rows : events.map((event) => traceDisplayRow(trace, event, { includeNoise: true })).filter(Boolean);
}
function traceAssistantStreamRows(trace) {
const streams = Array.isArray(trace?.assistantStreams) ? trace.assistantStreams.filter(Boolean) : [];
return streams.map((stream, index) => traceAssistantStreamRow(trace, stream, index)).filter(Boolean);
}
function traceAssistantStreamRow(trace, stream, index = 0) {
const chunkCount = Math.max(0, Number(stream?.chunkCount ?? 0));
if (chunkCount <= 0) return null;
const clock = traceClock(stream.updatedAt ?? stream.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, { createdAt: stream.updatedAt ?? stream.createdAt }));
return {
rowId: `assistant-stream:${stream.itemId ?? stream.messageId ?? index}`,
seq: null,
tone: "source",
header: `${clock} total=${total} stream assistant message x${chunkCount}`,
body: traceAssistantStreamBody(stream, chunkCount)
};
}
function traceAssistantStreamBody(stream, chunkCount) {
const message = cleanTraceText(stream?.text ?? "");
const waitingFor = stream?.waitingFor ? `waiting=${stream.waitingFor}` : null;
return [
`chunks=${chunkCount} assistant message chunks`,
waitingFor,
message ? `message=${message}` : null
].filter(Boolean).join("\n");
}
function traceToolOutputSummaryRows(trace, events) {
const visibleEvents = events.filter(Boolean);
if (visibleEvents.length === 0) return [];
const first = visibleEvents[0];
const combined = visibleEvents.map((event) => rawTraceOutputText(event)).join("");
const parsedItems = parseGatewayJsonRpcOutputs(combined);
if (parsedItems.length > 0) {
return parsedItems.map((parsed, index) => {
const { payload, result, dispatch } = parsed;
const event = visibleEvents[Math.min(index, visibleEvents.length - 1)] ?? first;
const clock = traceClock(event.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, event));
const chunkCount = index === 0 ? visibleEvents.length : 0;
const suffix = chunkCount > 0 ? ` chunks=${chunkCount}` : "";
const status = result.status ?? dispatch.dispatchStatus ?? "unknown";
const exitCode = Number.isInteger(dispatch.exitCode) ? dispatch.exitCode : null;
const ok = status === "succeeded" || status === "completed" || exitCode === 0;
const header = [
`${clock} total=${total}`,
ok ? "ok" : status === "timed_out" ? "timeout" : "fail",
"tool gateway.shell",
`status=${status}`,
result.operationId ? `op=${result.operationId}` : "op=null",
exitCode !== null ? `exit=${exitCode}` : null,
typeof dispatch.durationMs === "number" ? `s=${(dispatch.durationMs / 1000).toFixed(1)}` : null,
suffix.trim() || null
].filter(Boolean).join(" ");
return {
rowId: `gateway-jsonrpc:${event.seq ?? first.seq ?? "unknown"}:${payload.id ?? index}`,
seq: event.seq ?? null,
tone: ok ? "ok" : status === "timed_out" ? "warn" : "blocked",
header,
body: gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw: combined })
};
});
}
const clock = traceClock(first.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, first));
return [{
rowId: `tool-output:${first.seq ?? "unknown"}`,
seq: first.seq ?? null,
tone: "source",
header: `${clock} total=${total} output cmd output chunks=${visibleEvents.length} out=${compactTraceSize(combined)}`,
body: compactTraceTextTail(cleanTraceOutputText(combined), 1600)
}];
}
function traceToolOutputSummaryRow(trace, events) {
return traceToolOutputSummaryRows(trace, events)[0] ?? null;
}
function traceNoiseSummaryRow(trace, events) {
const visibleEvents = events.filter(Boolean);
if (visibleEvents.length === 0) return null;
const last = visibleEvents.at(-1);
const clock = traceClock(last.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, last));
const assistantChunks = visibleEvents.filter((event) => isAssistantChunkTraceEvent(event));
const label = assistantChunks.length === visibleEvents.length
? `assistant message x${visibleEvents.length}`
: `trace noise x${visibleEvents.length}`;
return {
rowId: `trace-noise:${visibleEvents[0]?.seq ?? last.seq ?? "unknown"}:${assistantChunks.length > 0 ? "assistant" : "events"}`,
seq: last.seq ?? null,
tone: "source",
header: `${clock} total=${total} stream ${label}`,
body: traceNoiseSummaryBody(visibleEvents, assistantChunks)
};
}
function traceNoiseSummaryBody(events, assistantChunks) {
if (assistantChunks.length > 0) {
const text = assistantChunks
.map((event) => String(event.chunk ?? ""))
.join("");
const message = cleanTraceText(text);
const waitingFor = events.at(-1)?.waitingFor ? `waiting=${events.at(-1).waitingFor}` : null;
return [
`chunks=${assistantChunks.length} assistant message chunks`,
waitingFor,
message ? `message=${message}` : null
].filter(Boolean).join("\n");
}
const labels = new Map();
for (const event of events) {
const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`);
labels.set(label, (labels.get(label) ?? 0) + 1);
}
return [...labels.entries()]
.slice(0, 6)
.map(([label, count]) => `${readableTraceLabel({ label })} x${count}`)
.join("\n");
}
function compactTraceTextTail(text, maxLength) {
const clean = cleanTraceText(text);
if (clean.length <= maxLength) return clean;
return `...${clean.slice(-maxLength)}`;
}
function traceDisplayRow(trace, event, options = {}) {
if (!options.includeNoise && isNoisyTraceEvent(event)) return null;
if (isCommandExecutionTraceEvent(event)) return traceCommandExecutionRow(trace, event);
const clock = traceClock(event.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, event));
const status = traceStatusToken(event);
const label = readableTraceLabel(event);
const meta = [
status,
event.errorCode ? `error=${event.errorCode}` : null,
typeof event.timeoutMs === "number" ? `timeout=${formatTraceDuration(event.timeoutMs)}` : null,
typeof event.idleMs === "number" ? `idle=${formatTraceDuration(event.idleMs)}` : null,
event.outputSummary ? `out=${compactTraceSize(event.outputSummary)}` : null
].filter(Boolean).join(" ");
const body = traceDisplayBody(event);
return {
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "event"}:${event.createdAt ?? "unknown"}`}`,
seq: event.seq ?? null,
tone: traceEventTone(event),
header: `${clock} total=${total}${meta ? ` ${meta}` : ""}${label ? ` ${label}` : ""}`,
body
};
}
function traceCommandExecutionRow(trace, event) {
const clock = traceClock(event.createdAt);
const total = formatTraceDuration(traceRelativeMs(trace, event));
const status = traceStatusToken(event);
const command = cleanTraceOutputText(event.command ?? "");
const meta = [
status,
"tool cmd",
command ? `:: ${compactTraceTextTail(command, 220)}` : null,
Number.isInteger(event.exitCode) ? `exit=${event.exitCode}` : null,
typeof event.durationMs === "number" ? `s=${(event.durationMs / 1000).toFixed(1)}` : null,
typeof event.outputBytes === "number" ? `out=${formatTraceBytes(event.outputBytes)}` : null,
event.itemId ? `item=${event.itemId}` : null
].filter(Boolean).join(" ");
return {
rowId: `event:${event.seq ?? `${event.label ?? event.type ?? "command"}:${event.createdAt ?? "unknown"}`}`,
seq: event.seq ?? null,
tone: traceEventTone(event),
header: `${clock} total=${total} ${meta}`,
body: traceCommandExecutionBody(event)
};
}
function traceCommandExecutionBody(event) {
const lines = [
event.command ? `command=${compactTraceTextTail(cleanTraceOutputText(event.command), 900)}` : null,
event.stdoutSummary ? `stdout:\n${compactTraceTextTail(cleanTraceOutputText(event.stdoutSummary), 1200)}` : null,
event.stderrSummary ? `stderr:\n${compactTraceTextTail(cleanTraceOutputText(event.stderrSummary), 800)}` : null,
event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null
].filter(Boolean);
return lines.join("\n");
}
function isCommandExecutionTraceEvent(event) {
return event?.type === "tool_call" && event?.toolName === "commandExecution" &&
(event?.label === "item/commandExecution:started" || event?.label === "item/commandExecution:completed");
}
function isNoisyTraceEvent(event) {
const label = String(event?.label ?? "");
if (isToolOutputChunkTraceEvent(event)) return false;
if (isAssistantChunkTraceEvent(event)) return true;
if (/token_count|outputDelta:chunk/iu.test(label)) return true;
if (event?.type === "event" && !event.outputSummary && !event.message && !event.errorCode) return true;
if (event?.label === "request:accepted" && !event.message) return true;
return false;
}
function isAssistantChunkTraceEvent(event) {
const label = String(event?.label ?? "");
return /assistant:chunk/iu.test(label) ||
(event?.type === "assistant_message" && event?.status === "chunk");
}
function isToolOutputChunkTraceEvent(event) {
const label = String(event?.label ?? "");
return label === "item/commandExecution/outputDelta" ||
(event?.type === "tool_call" && event?.status === "output_chunk" && !String(event?.toolName ?? "").startsWith("codex-app-server"));
}
function readableTraceLabel(event) {
const label = String(event?.label ?? `${event?.type ?? "event"}:${event?.status ?? "observed"}`);
if (label === "request:accepted-short-connection") return "submit short-connection";
if (label === "request:accepted") return "request accepted";
if (label.startsWith("tool:codex-app-server.thread/start+turn/start")) return "codex turn start";
if (label.startsWith("tool:codex-app-server.thread/resume+turn/start")) return "codex turn resume";
if (label === "item/commandExecution:started") return "cmd started";
if (label === "item/commandExecution:completed") return "cmd completed";
if (label.startsWith("item/commandExecution/outputDelta")) return "cmd output";
if (label === "turn:waiting:first_assistant_token") return "waiting first assistant token";
if (label.startsWith("turn:completed")) return "turn completed";
if (label.startsWith("result:completed")) return "result ready";
if (label.startsWith("result:failed")) return "result failed";
if (label.startsWith("timeout:")) return "timeout";
if (label.startsWith("cancel:")) return "cancel";
return label
.replace(/^tool:/u, "")
.replace(/^assistant:/u, "assistant ")
.replace(/:completed:completed$/u, " completed")
.replace(/:completed$/u, " completed")
.replace(/:started$/u, " started");
}
function traceDisplayBody(event) {
const lines = [
event.promptSummary ? cleanTraceText(event.promptSummary) : null,
event.outputSummary ? cleanTraceOutputText(event.outputSummary) : null,
event.message ? cleanTraceText(event.message) : null,
event.chunk && !isNoisyTraceEvent(event) ? cleanTraceOutputText(event.chunk) : null,
event.waitingFor && event.status !== "completed" ? `waiting=${event.waitingFor}` : null
].filter(Boolean);
return lines.join("\n").slice(0, 1400);
}
function rawTraceOutputText(event) {
return String(event?.outputSummary ?? event?.chunk ?? event?.message ?? "");
}
function parseGatewayJsonRpcOutputs(text) {
const payloads = parseJsonObjects(text);
return payloads
.filter((payload) => payload && payload.jsonrpc === "2.0" && payload.result && typeof payload.result === "object")
.map((payload) => {
const result = payload.result;
const dispatch = result.dispatch && typeof result.dispatch === "object" ? result.dispatch : {};
if (!("gatewaySessionId" in result) && !("capabilityId" in result) && !("shellExecuted" in dispatch)) return null;
return { payload, result, dispatch };
})
.filter(Boolean);
}
function parseGatewayJsonRpcOutput(text) {
return parseGatewayJsonRpcOutputs(text)[0] ?? null;
}
function parseJsonObjects(text) {
const source = String(text ?? "");
const values = [];
let searchFrom = 0;
while (searchFrom < source.length) {
const start = source.indexOf("{", searchFrom);
if (start < 0) break;
const end = jsonObjectEnd(source, start);
if (end < 0) break;
try {
values.push(JSON.parse(source.slice(start, end + 1)));
searchFrom = end + 1;
} catch {
searchFrom = start + 1;
}
}
return values;
}
function parseFirstJsonObject(text) {
const source = String(text ?? "");
const start = source.indexOf("{");
if (start < 0) return null;
const end = jsonObjectEnd(source, start);
if (end < 0) return null;
try {
return JSON.parse(source.slice(start, end + 1));
} catch {
return null;
}
}
function jsonObjectEnd(source, start) {
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const char = source[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === "{") depth += 1;
if (char === "}") depth -= 1;
if (depth === 0) {
return index;
}
}
return -1;
}
function gatewayJsonRpcDisplayBody({ payload, result, dispatch, raw }) {
const stdout = cleanTraceOutputText(dispatch.stdout);
const stderr = cleanTraceOutputText(dispatch.stderr);
const error = dispatch.error?.message ?? dispatch.error ?? dispatch.message ?? result.message ?? null;
return [
`request=${payload.id ?? "unknown"}`,
`gateway=${result.gatewaySessionId ?? "unknown"} resource=${result.resourceId ?? "unknown"} capability=${result.capabilityId ?? "unknown"}`,
`dispatch=${dispatch.dispatchStatus ?? result.status ?? "unknown"} shellExecuted=${dispatch.shellExecuted === true ? "true" : "false"} timedOut=${dispatch.timedOut === true ? "true" : "false"}`,
error ? `error=${compactTraceTextTail(cleanTraceOutputText(error), 600)}` : null,
dispatch.command ? `command=${compactTraceTextTail(cleanTraceOutputText(dispatch.command), 420)}` : null,
result.auditId ? `audit=${result.auditId}` : null,
result.evidenceId ? `evidence=${result.evidenceId}` : null,
dispatch.stdoutTruncated === true ? "stdoutTruncated=true" : null,
stdout ? `stdout:\n${compactTraceTextTail(stdout, 1200)}` : null,
stderr ? `stderr:\n${compactTraceTextTail(stderr, 800)}` : null,
!stdout && !stderr && raw ? compactTraceTextTail(cleanTraceOutputText(raw), 900) : null
].filter(Boolean).join("\n");
}
function traceStatusToken(event) {
const status = String(event?.status ?? "");
if (status === "completed" || status === "succeeded") return "ok";
if (["failed", "blocked", "error", "timeout", "canceled"].includes(status) || event?.errorCode) return "fail";
if (["started", "running", "accepted"].includes(status)) return "run";
return status || "event";
}
function traceEventTone(event) {
const status = traceStatusToken(event);
if (status === "ok") return "ok";
if (status === "fail") return "blocked";
if (status === "run") return "warn";
return "source";
}
function traceClock(value) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "--:--:--" : date.toISOString().slice(11, 19);
}
function traceRelativeMs(trace, event) {
if (typeof event?.elapsedMs === "number") return event.elapsedMs;
const start = Date.parse(trace?.startedAt ?? trace?.createdAt ?? "");
const current = Date.parse(event?.createdAt ?? "");
return Number.isNaN(start) || Number.isNaN(current) ? 0 : Math.max(0, current - start);
}
function formatTraceDuration(ms) {
const total = Math.max(0, Math.floor(Number(ms) || 0));
const seconds = Math.floor(total / 1000);
const hh = String(Math.floor(seconds / 3600)).padStart(2, "0");
const mm = String(Math.floor((seconds % 3600) / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
function compactTraceSize(value) {
const length = String(value ?? "").length;
if (length >= 1000) return `${(length / 1000).toFixed(length >= 10000 ? 0 : 1)}k`;
return String(length);
}
function formatTraceBytes(value) {
const bytes = Math.max(0, Number(value) || 0);
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KiB`;
return `${bytes}B`;
}
function cleanTraceText(value) {
const text = String(value ?? "").trim();
if (!text) return "";
return text
.replace(/\s*\/\s*/gu, " / ")
.replace(/[ \t]{2,}/gu, " ")
.replace(/\n{3,}/gu, "\n\n");
}
function cleanTraceOutputText(value) {
const text = String(value ?? "").trim();
if (!text) return "";
return text
.replace(/[ \t]{2,}/gu, " ")
.replace(/\n{3,}/gu, "\n\n");
}
function traceActionButton(label, onClick, title) {
const button = document.createElement("button");
button.type = "button";
button.className = "message-trace-action";
button.textContent = label;
button.title = title;
button.addEventListener("click", onClick);
return button;
}
function messageTraceJson(message, trace, events) {
return JSON.stringify({
traceId: trace?.traceId ?? message.traceId ?? null,
messageId: message.id ?? null,
eventCount: Number.isInteger(trace?.eventCount) ? trace.eventCount : events.length,
eventsCompacted: trace?.eventsCompacted === true,
eventWindow: trace?.eventWindow ?? null,
lastEvent: trace?.lastEvent ?? events.at(-1) ?? null,
assistantStreams: Array.isArray(trace?.assistantStreams) ? trace.assistantStreams : [],
events
}, null, 2);
}
function downloadTraceJson(message, trace, events) {
const traceId = String(trace?.traceId ?? message.traceId ?? "trace")
.replace(/[^A-Za-z0-9_.-]+/gu, "-")
.replace(/^-|-$/gu, "")
.slice(0, 96) || "trace";
const blob = new Blob([messageTraceJson(message, trace, events)], { type: "application/json" });
const href = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = `${traceId}.json`;
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(href), 0);
}
function runnerTraceHeadline(message, trace) {
const count = Number.isInteger(trace?.eventCount) ? trace.eventCount : Array.isArray(trace?.events) ? trace.events.length : 0;
const last = trace?.lastEvent?.label ?? trace?.eventLabels?.at?.(-1) ?? "等待事件";
const elapsed = typeof trace?.elapsedMs === "number" ? ` / ${trace.elapsedMs}ms` : "";
const waiting = trace?.waitingFor ? ` / 等待 ${trace.waitingFor}` : "";
const thread = trace?.threadId ?? message.threadId ? ` / thread=${trace?.threadId ?? message.threadId}` : "";
return `运行 trace ${trace?.traceId ?? message.traceId ?? "pending"} / events=${count} / last=${last}${elapsed}${waiting}${thread}`;
}
function traceEventMeta(event) {
return [
event.toolName ? `tool=${event.toolName}` : null,
event.waitingFor ? `waiting=${event.waitingFor}` : null,
typeof event.timeoutMs === "number" ? `timeoutMs=${event.timeoutMs}` : null,
typeof event.hardTimeoutMs === "number" ? `hardTimeoutMs=${event.hardTimeoutMs}` : null,
typeof event.idleMs === "number" ? `idleMs=${event.idleMs}` : null,
event.lastActivityAt ? `lastActivityAt=${event.lastActivityAt}` : null,
typeof event.elapsedMs === "number" ? `${event.elapsedMs}ms` : null,
event.errorCode ? `error=${event.errorCode}` : null,
event.outputSummary,
event.message
].filter(Boolean).join(" / ");
}
function toolCallsSummary(toolCalls) {
const summary = compactToolCallsSummary(toolCalls);
return summary === "none" ? null : summary;
}
function skillsSummary(skills) {
const summary = compactSkillsSummary(skills);
return summary === "none" ? null : summary;
}
function runnerTraceSummary(runnerTrace) {
const summary = compactRunnerTraceSummary(runnerTrace);
return summary === "none" ? null : summary;
}
function conversationFactsSummary(conversationFacts) {
if (!conversationFacts || typeof conversationFacts !== "object") return "none";
return [
recordField("turns", conversationFacts.turnCount),
recordField("sessionId", conversationFacts.sessionId),
recordField("status", conversationFacts.sessionStatus),
recordField("workspace", conversationFacts.workspace),
recordField("mode", conversationFacts.sessionMode),
conversationFacts.valuesRedacted === true ? "valuesRedacted=true" : null
].filter(Boolean).join(" / ") || "none";
}
function conversationFactTracesSummary(conversationFacts) {
if (!conversationFacts || typeof conversationFacts !== "object") return "none";
const traces = Array.isArray(conversationFacts.traceIds) ? conversationFacts.traceIds.filter(Boolean) : [];
return traces.length ? traces.slice(-4).join(",") : conversationFacts.latestTraceId ?? "none";
}
function conversationFactSkillsSummary(conversationFacts) {
const skills = conversationFacts?.latestSkills;
if (!skills || typeof skills !== "object") return "none";
const names = Array.isArray(skills.names) ? skills.names.filter(Boolean).slice(0, 6).join(",") : "";
return [
skills.status ?? "unknown",
recordField("count", skills.totalCount ?? skills.count),
names ? `items=${names}` : null
].filter(Boolean).join(":") || "none";
}
function conversationFactToolsSummary(conversationFacts) {
const toolCalls = Array.isArray(conversationFacts?.recentToolCalls) ? conversationFacts.recentToolCalls : [];
if (toolCalls.length === 0) return "none";
return toolCalls
.map((toolCall) => [toolCall.name ?? "tool", toolCall.status ? `(${toolCall.status})` : null].filter(Boolean).join(""))
.slice(-5)
.join(",");
}
function sessionSummary(session) {
if (!session || typeof session !== "object") return null;
const parts = [
session.sessionId,
session.status,
typeof session.turn === "number" ? `turn${session.turn}` : null,
session.threadId ? `thread=${session.threadId}` : null,
session.lastTraceId ? `lastTrace=${session.lastTraceId}` : null
].filter(Boolean);
return parts.join(":");
}
function statusCard(item) {
const article = document.createElement("article");
article.className = "status-card";
article.append(textSpan(item.title, "metric-label"));
article.append(textSpan(String(item.value), `status-value tone-${toneClass(item.tone)}`));
article.append(textSpan(item.meta, "status-meta"));
return article;
}
function infoCard(item) {
const article = document.createElement("article");
article.className = "info-card";
article.append(badge(statusLabel(item.tone), item.tone));
const body = document.createElement("div");
body.append(textSpan(item.title, "info-title"));
body.append(textSpan(item.detail, "info-detail"));
article.append(body);
return article;
}
function badge(text, tone = text) {
const span = document.createElement("span");
span.className = `badge tone-${toneClass(tone)}`;
span.textContent = text ?? "无";
return span;
}
function copyButton(value, label = "value") {
const button = document.createElement("button");
button.className = "copy-chip";
button.type = "button";
button.textContent = "复制";
button.title = `复制 ${label}`;
button.addEventListener("click", async () => {
await copyTextToClipboard(value);
button.textContent = "已复制";
window.setTimeout(() => {
button.textContent = "复制";
}, 1200);
});
return button;
}
async function copyTextToClipboard(value) {
const text = String(value ?? "");
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const input = document.createElement("textarea");
input.value = text;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.left = "-9999px";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
function safeLink(href, text, className) {
const anchor = document.createElement("a");
anchor.href = href;
anchor.textContent = text ?? href;
anchor.rel = "noreferrer";
if (className) anchor.className = className;
return anchor;
}
function textSpan(text, className) {
const span = document.createElement("span");
span.textContent = text ?? "无";
if (className) span.className = className;
return span;
}
function cell(text, className) {
const td = document.createElement("td");
td.textContent = text ?? "无";
if (className) td.className = className;
return td;
}
function formatTimestamp(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "未知";
return shortTime(date.toISOString());
}
function tableCell(text, className, colspan = 1) {
const td = cell(text, className);
if (colspan > 1) td.colSpan = colspan;
return td;
}
function runtimeSummaryFrom(live) {
return live.health.data?.runtime ?? live.healthLive.data?.runtime ?? live.adapter.data?.runtime ?? live.restIndex.data?.runtime ?? null;
}
function messageFromPayload(payload) {
if (!payload || typeof payload !== "object") return "非 JSON 或空响应";
if (payload.error && typeof payload.error === "object") {
return payload.error.message || payload.error.code || payload.reason || payload.message || JSON.stringify(payload.error).slice(0, 160);
}
if (typeof payload.error === "string") {
return payload.message ? `${payload.error}${payload.message}` : payload.error;
}
return payload.code || payload.message || payload.reason || JSON.stringify(payload).slice(0, 160);
}
function normalizeBlockedAgentResult(payload, httpStatus, traceId, fallbackMessage) {
const { reply, ...safePayload } = payload;
const payloadError = payload.error && typeof payload.error === "object" ? payload.error : {};
const payloadErrorCode = payloadError.code ?? (typeof payload.error === "string" ? payload.error : null);
const providerStatus = payloadError.providerStatus ?? httpStatus;
const error = {
...payloadError,
code: payloadErrorCode ?? "provider_unavailable",
message: payloadError.message ?? fallbackMessage ?? `Code Agent provider HTTP ${httpStatus}`,
providerStatus
};
return {
...safePayload,
status: "failed",
traceId: payload.traceId || traceId,
error,
availability: payload.availability ?? codeAgentAvailabilityFromResult({ error })
};
}
function isBlockedAgentResponse(payload, httpStatus) {
return Boolean(
payload &&
typeof payload === "object" &&
(
isHttpNon2xxStatus(httpStatus) ||
isHttpNon2xxStatus(payload.error?.providerStatus) ||
payload.status === "blocked" ||
payload.error?.code === "provider_unavailable" ||
payload.error?.code === "security_blocked" ||
payload.error?.code === "tool_unavailable" ||
payload.error?.code === "skills_unavailable" ||
payload.availability?.status === "blocked"
)
);
}
function isHttpNon2xxStatus(value) {
return typeof value === "number" && Number.isInteger(value) && (value < 200 || value > 299);
}
function codeAgentAvailabilityFrom(live) {
return sanitizeCodeAgentAvailability(codeAgentAvailabilityFromLive(live));
}
function codeAgentAvailabilityFromResult(result) {
const missingEnv = Array.isArray(result?.error?.missingEnv) ? result.error.missingEnv : [];
const providerStatus = result?.error?.providerStatus;
const credentialBlocked = missingEnv.includes("OPENAI_API_KEY");
return {
status: "blocked",
blocker: credentialBlocked ? "凭证缺口" : "provider_unavailable",
reason: result?.error?.code ?? "provider_unavailable",
summary: providerStatus
? `真实后端已接入,但当前 provider 上游返回 HTTP ${providerStatus};本次保持 BLOCKED/provider_unavailable,不会冒充真实 Code Agent 回复。`
: "真实后端已接入,但当前 DEV provider Secret hwlab-code-agent-provider/openai-api-key 未注入,因此不能返回真实回复。",
missingEnv,
secretRefs: credentialBlocked ? [
{
env: "OPENAI_API_KEY",
secretName: "hwlab-code-agent-provider",
secretKey: "openai-api-key",
redacted: true
}
] : []
};
}
function sanitizeCodeAgentAvailability(availability) {
if (!availability || typeof availability !== "object") return availability;
if (availability.status !== "blocked") return availability;
return {
...availability,
blocker: "服务受阻",
summary: codeAgentBlockedSummary(availability)
};
}
function codeAgentStatusMessage(availability) {
if (availability?.longLivedSessionGate?.status === "pass" && availability?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true) {
return {
role: "system",
title: "Code Agent 状态:Codex stdio 长会话可用",
text: "当前通道可创建和复用 repo-owned Codex stdio session,并暴露 cancel、reap、idle timeout 与 trace capture;硬件控制仍只走 cloud-api/HWLAB API/skill CLI。",
status: "source"
};
}
if (availability?.runner?.ready === true) {
return {
role: "system",
title: "Code Agent 状态:Codex stdio gate 未通过",
text: `同源服务可响应,但完整 Codex stdio 长会话仍未通过 gate;不会用本地技能发现、shell/file 快捷命令或文本 fallback 冒充回复。${codeAgentBlockerDetail(availability)}`,
status: "blocked"
};
}
if (availability?.status === "blocked") {
return {
role: "system",
title: "Code Agent 状态:服务受阻",
text: codeAgentBlockedSummary(availability),
status: "blocked"
};
}
if (String(availability?.sourceKind ?? availability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") {
return {
role: "system",
title: "Code Agent 状态:本地验证记录",
text: "当前只用于本地浏览器验证:可以检查发送、处理中、回复渲染和证据展示,但不会标记为实况完成。",
status: "source"
};
}
if (availability?.status === "available") {
return {
role: "system",
title: "Code Agent 状态:真实通道已接入",
text: "受控对话通道已接入;发送后只有真实完成的回复才会标记为已完成。",
status: "source"
};
}
return {
role: "system",
title: "Code Agent 状态:等待 Codex stdio 探测",
text: "正在确认服务状态;首屏不会宣称 Code Agent 可用,也不会把失败或静态内容当成真实回复。",
status: "source"
};
}
function codeAgentPromptText(availability) {
if (availability?.status === "blocked") {
return "可以继续输入并保留草稿;发送会走受控对话通道。服务恢复前会显示结构化失败,不会冒充真实 Codex 回复。";
}
return "请在底部输入中文消息并点击发送;完成态只来自真实完成的后端回复。";
}
function codeAgentControlSummary(availability) {
if (latestCompletedAgentMessage()) {
return state.conversationId
? `当前会话 ${state.conversationId};最近一次真实完成回复来自受控 Code Agent 接口,并带有可核验记录。`
: "最近一次真实完成回复来自受控 Code Agent 接口,并带有可核验记录。";
}
if (isSourceFixtureCompletedChatMessage(latestChatResult())) {
return "最近一次为本地验证回复;它只证明浏览器对话体验,不作为实况完成证据。";
}
if (availability?.status === "blocked") {
return codeAgentBlockedSummary(availability);
}
if (availability?.partialReady === true || availability?.runner?.ready === true) {
return `输入区会调用受控接口;完整 Codex stdio 长会话尚未可用时只返回结构化失败,不能用本地 shortcut 或 text fallback 冒充回复。${codeAgentBlockerDetail(availability)}`;
}
return "输入区会调用受控 Code Agent 接口;只有真实完成回复才显示为完成,不能因为只有会话编号就当成实况完成。";
}
function codeAgentBlockedSummary(availability) {
const structured = availability?.error?.userMessage ?? availability?.blocker?.userMessage;
if (structured) return structured;
const providerStatus = availability?.error?.providerStatus ?? availability?.providerStatus;
if (providerStatus) {
return `真实后端已接入,但当前上游响应 HTTP ${providerStatus};工作台保持只读和可重试,不会冒充真实 Code Agent 回复。`;
}
const blocker = codeAgentBlockerDetail(availability);
return `真实后端已接入,但 Code Agent 服务暂不可用;完整 Codex stdio Code Agent 仍受阻,工作台保持只读和可重试,不会冒充真实回复。${blocker}`;
}
function codeAgentBlockerDetail(availability) {
const blockers = codeAgentBlockerCodes(availability);
if (blockers.length === 0 && availability?.reason) blockers.push(availability.reason);
if (blockers.length === 0) return "";
const labels = blockers.slice(0, 3).map(codeAgentBlockerChineseLabel);
return ` 阻塞:${labels.join("")}。证据 code=${blockers.slice(0, 3).join(",")}。`;
}
function codeAgentBlockerCodes(availability) {
const values = [
...(Array.isArray(availability?.blockers) ? availability.blockers.map((blocker) => blocker?.code) : []),
...(Array.isArray(availability?.blockerCodes) ? availability.blockerCodes : []),
...(Array.isArray(availability?.longLivedSessionGate?.blockers) ? availability.longLivedSessionGate.blockers.map((blocker) => blocker?.code) : []),
...(Array.isArray(availability?.codexStdioFeasibility?.blockers) ? availability.codexStdioFeasibility.blockers.map((blocker) => blocker?.code) : []),
...(Array.isArray(availability?.runtimeContract?.stdioProtocol?.missingTools) && availability.runtimeContract.stdioProtocol.missingTools.length > 0 ? ["stdio_protocol_not_wired"] : []),
availability?.runtimeContract?.binary?.status === "missing" ? "codex_cli_binary_missing" : null,
availability?.runtimeContract?.lifecycleSupervisor?.status === "blocked" ? "runner_lifecycle_missing" : null
].filter(Boolean).map((code) => String(code));
return [...new Set(values)];
}
function codeAgentBlockerChineseLabel(code) {
return {
codex_cli_binary_missing: "未找到受控 Codex CLI binary",
codex_cli_not_executable: "Codex CLI 无法执行 --version",
codex_cli_native_dependency_missing: "Codex CLI native 依赖缺失",
runner_lifecycle_missing: "缺少 repo-owned lifecycle supervisor",
stdio_protocol_not_wired: "Codex stdio 协议尚未接入",
codex_stdio_supervisor_disabled: "Codex stdio supervisor 未启用",
codex_stdio_blocked_readonly_session_available: "旧 partial runner 不能作为 Codex stdio",
controlled_readonly_not_long_lived_stdio: "非 Codex stdio 长会话",
openai_responses_fallback_not_session: "文本 fallback 不是长会话",
one_shot_runner_not_long_lived: "一次性执行不是可复用 session",
provider_token_boundary: "token 边界未配置",
codex_home_missing: "CODEX_HOME 不存在或不可读",
codex_home_write_blocked: "CODEX_HOME 不可写",
workspace_mount_missing: "工作区挂载不可读",
workspace_write_boundary_blocked: "workspace-write 沙箱不可写",
codex_stdio_egress_boundary: "DEV egress 边界不合规"
}[code] ?? code;
}
function untrustedCompletionMessage(result) {
const provider = result?.provider ?? "unknown";
return `Code Agent 返回 completed,但缺少真实 provider/model/trace/conversation 证据,或 provider=${provider} 属于 echo/mock/stub;本次不会标记为真实完成。SOURCE fixture 只可显示为 SOURCE 回复,不能冒充 DEV-LIVE。`;
}
function codeAgentAvailabilitySummary() {
if (state.codeAgentAvailability?.longLivedSessionGate?.status === "pass" && state.codeAgentAvailability?.codexStdioFeasibility?.canStartLongLivedCodexStdio === true) {
return "同源 API 可响应;Codex stdio 长会话 gate 已通过,支持真实 session 复用和 trace capture。";
}
if (state.codeAgentAvailability?.runner?.ready === true) {
return `同源 API 可响应;完整 Codex stdio 长会话仍受阻,本地 shortcut 与文本 fallback 不会冒充真实 Code Agent 回复。${codeAgentBlockerDetail(state.codeAgentAvailability)}`;
}
if (state.codeAgentAvailability?.status === "blocked") {
return "同源 API 可响应;Code Agent 服务暂不可用,工作台保持只读和可重试。";
}
if (String(state.codeAgentAvailability?.sourceKind ?? state.codeAgentAvailability?.evidenceLevel ?? "").toUpperCase() === "SOURCE") {
return "同源 API 可响应;当前 Code Agent 回复来源是本地验证记录,仅用于浏览器验证,不是实况完成。";
}
if (state.codeAgentAvailability?.status === "available") {
return "同源 API 可响应;Code Agent 真实通道已接入,只有真实完成回复才显示为完成。";
}
return "同源接口可响应。技术复核详情已放在二级页面。";
}
function latestCompletedAgentMessage() {
return [...state.chatMessages].reverse().find((message) =>
message.role === "agent" &&
isRealCompletedChatMessage(message)
) ?? null;
}
function isRealCompletedChatResult(result) {
const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : "";
return result?.status === "completed" && assistantReply.length > 0 && !isSourceFixtureCompletion(result) && hasRealCodeAgentEvidence(result);
}
function isSourceFixtureChatResult(result) {
const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : "";
return result?.status === "completed" && assistantReply.length > 0 && isSourceFixtureCompletion(result);
}
function isTextFallbackChatResult(result) {
const assistantReply = typeof result?.reply?.content === "string" ? result.reply.content.trim() : "";
return result?.status === "completed" &&
assistantReply.length > 0 &&
result?.provider === "openai-responses" &&
(result?.capabilityLevel === "text-chat-only" || result?.runner?.kind === "openai-responses-fallback") &&
result?.longLivedSessionGate?.status !== "pass";
}
function isRealCompletedChatMessage(message) {
return message?.status === "completed" && !message.error && hasRealCodeAgentEvidence(message);
}
function isSourceFixtureCompletedChatMessage(message) {
return message?.status === "source" && !message.error && message.sourceKind === "SOURCE";
}
function hasRealCodeAgentEvidence(value) {
if (isCodexRunnerCapableEvidence(value)) {
return true;
}
if (CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase())) {
return false;
}
if (isTextFallbackChatResult(value)) {
return false;
}
return (
isTrustedCodeAgentProvider(value?.provider) &&
Boolean(value?.model) &&
Boolean(value?.backend) &&
Boolean(value?.traceId) &&
Boolean(value?.conversationId || value?.sessionId) &&
hasProviderTrace(value) &&
Boolean(sessionIdFrom(value)) &&
(!requiresCodexThreadEvidence(value) || Boolean(threadIdFrom(value))) &&
!UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN.test(`${value?.provider ?? ""} ${value?.backend ?? ""}`)
);
}
function isCodexRunnerCapableEvidence(value) {
return (
CODEX_RUNNER_CAPABLE_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) &&
value?.runner?.kind === CODEX_APP_SERVER_RUNNER_KIND &&
value?.capabilityLevel === "long-lived-codex-stdio-session" &&
value?.session?.status === "idle" &&
typeof value?.session?.idleTimeoutMs === "number" &&
Boolean(value?.session?.lastTraceId) &&
value?.sessionMode === CODEX_APP_SERVER_SESSION_MODE &&
value?.implementationType === CODEX_APP_SERVER_IMPLEMENTATION_TYPE &&
Boolean(value?.sessionReuse) &&
hasProviderTrace(value) &&
Boolean(sessionIdFrom(value)) &&
Boolean(threadIdFrom(value)) &&
value?.session?.longLivedSession === true &&
value?.session?.codexStdio === true &&
value?.runner?.codexStdio === true &&
value?.runner?.writeCapable === true &&
value?.runner?.durableSession === true &&
Boolean(value?.workspace || value?.runner?.workspace) &&
["workspace-write", "danger-full-access"].includes(value?.sandbox || value?.runner?.sandbox) &&
Array.isArray(value?.toolCalls) &&
value.toolCalls.some((tool) => tool?.status === "completed") &&
value?.longLivedSessionGate?.status === "pass" &&
value?.providerTrace?.protocol === CODEX_APP_SERVER_PROTOCOL &&
/\bcodex\s+app-server\s+--listen\s+stdio:\/\//u.test(String(value?.providerTrace?.command ?? "")) &&
["completed", "succeeded"].includes(String(value?.providerTrace?.terminalStatus ?? "").toLowerCase()) &&
Boolean(value?.runnerTrace) &&
Boolean(value?.skills)
);
}
function isTrustedCodeAgentProvider(provider) {
const normalized = String(provider ?? "").trim().toLowerCase();
return TRUSTED_CODE_AGENT_PROVIDERS.includes(normalized);
}
function isSourceFixtureCompletion(value) {
const sourceKind = String(value?.sourceKind ?? value?.evidenceLevel ?? value?.providerTrace?.sourceKind ?? "").trim().toUpperCase();
const traceSource = String(value?.providerTrace?.source ?? value?.providerTrace?.mode ?? "").trim().toUpperCase();
return sourceKind === "SOURCE" || traceSource.startsWith("SOURCE");
}
function latestChatResult() {
return [...state.chatMessages].reverse().find((message) => message.role === "agent") ?? null;
}
function deriveAgentChatStatus() {
if (state.chatPending) return "running";
const latest = latestChatResult();
if (latest?.status === "completed") return "completed";
if (latest?.status === "source") return "source";
if (latest?.status === "canceled") return "canceled";
if (latest?.status === "timeout") return "timeout";
if (latest?.status === "error") return "error";
if (latest?.status === "failed") return latest.error?.code === "provider_unavailable" ? "blocked" : "failed";
if (state.codeAgentAvailability?.status === "blocked") return "blocked";
return "idle";
}
function currentConversationTone() {
if (latestCompletedAgentMessage()) return "dev-live";
if (isSourceFixtureCompletedChatMessage(latestChatResult())) return "source";
if (state.codeAgentAvailability?.status === "blocked" || ["failed", "timeout", "canceled", "error"].includes(latestChatResult()?.status)) return "blocked";
return "source";
}
function statusLabel(value) {
const key = String(value ?? "unknown").trim();
const normalized = key.toLowerCase();
return STATUS_LABELS[normalized] ?? key;
}
function gateStatusLabel(value, statusKey) {
const explicit = String(value ?? "").trim();
const labels = {
pass: "通过",
blocked: "阻塞",
failed: "失败",
pending: "待验证",
info: "信息"
};
return labels[statusKey] ?? (["通过", "阻塞", "失败", "待验证", "信息"].includes(explicit) ? explicit : "阻塞");
}
function normalizeGateStatusKey(value) {
const text = String(value ?? "").trim().toLowerCase();
if (["通过", "pass", "passed", "ok", "ready"].includes(text)) return "pass";
if (["失败", "failed", "failure", "error"].includes(text)) return "failed";
if (["待验证", "pending", "loading", "unknown"].includes(text)) return "pending";
if (["信息", "info", "information"].includes(text)) return "info";
return "blocked";
}
function normalizeEvidenceList(value) {
const items = Array.isArray(value) ? value : [value];
const normalized = items.map(oneLine).filter(Boolean);
return normalized.length > 0 ? normalized : ["live 后端未返回 evidence 字段"];
}
function oneLine(value) {
return String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 240);
}
function roleLabel(role) {
return (
{
agent: "Agent",
blocker: "阻塞",
system: "系统",
user: "用户"
}[String(role ?? "").toLowerCase()] ?? statusLabel(role)
);
}
function recordKindLabel(kind) {
return (
{
audit: "审计",
evidence: "证据",
operation: "操作",
record: "记录",
report: "报告",
trace: "轨迹"
}[String(kind ?? "").toLowerCase()] ?? statusLabel(kind)
);
}
function internalGatePathnames() {
return new Set(["/gate", "/diagnostics/gate"]);
}
function helpPathnames() {
return new Set(["/help"]);
}
function valueOrUnavailable(liveValue, sourceValue) {
if (liveValue !== undefined && liveValue !== null) return liveValue;
if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} 来源 SOURCE`;
return "不可用";
}
function shortTime(value) {
return new Date(value).toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "Z");
}
function toneClass(tone) {
return String(tone ?? "unknown").replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
}
function replaceChildren(parent, ...children) {
parent.replaceChildren(...children.filter((child) => child && typeof child !== "string"));
}