5269 lines
207 KiB
JavaScript
5269 lines
207 KiB
JavaScript
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"));
|
||
}
|