997b96dc65
Add structured Code Agent blocker taxonomy across /v1/agent/chat, runner/session/tool, and HWLAB M3 Skill CLI paths.\n\nValidated with npm run check and focused server/session/Skill CLI/web tests.
3212 lines
119 KiB
JavaScript
3212 lines
119 KiB
JavaScript
import { gateSummary } from "./gate-summary.mjs";
|
||
import {
|
||
codeAgentFactsFromMessage,
|
||
compactHwlabApiFact,
|
||
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 { wiringPresentationFromSummary } from "./wiring-status.mjs";
|
||
|
||
const DEFAULT_API_TIMEOUT_MS = 4500;
|
||
const DEFAULT_CODE_AGENT_TIMEOUT_MS = 150000;
|
||
const API_TIMEOUT_MS = resolveTimeoutMs("apiTimeoutMs", DEFAULT_API_TIMEOUT_MS, { min: 500, max: 30000 });
|
||
const CODE_AGENT_TIMEOUT_MS = resolveTimeoutMs("codeAgentTimeoutMs", DEFAULT_CODE_AGENT_TIMEOUT_MS, { min: 50, max: 300000 });
|
||
const rpcReadMethods = Object.freeze([
|
||
"system.health",
|
||
"cloud.adapter.describe",
|
||
"audit.event.query",
|
||
"evidence.record.query"
|
||
]);
|
||
|
||
const STATUS_LABELS = Object.freeze({
|
||
active: "活动",
|
||
available: "可用",
|
||
blocked_after_cloud_api: "cloud-api 后阻塞",
|
||
blocked: "待处理",
|
||
completed: "完成",
|
||
connected: "已连接",
|
||
degraded: "降级",
|
||
disabled: "禁用",
|
||
entry: "入口",
|
||
failed: "失败",
|
||
healthy: "健康",
|
||
live: "实况",
|
||
ok: "正常",
|
||
open: "未关闭",
|
||
pass: "通过",
|
||
pending: "等待",
|
||
running: "处理中",
|
||
probing: "探测中",
|
||
ready: "就绪",
|
||
recorded: "已记录",
|
||
sent: "已发送",
|
||
requires: "依赖",
|
||
source: "来源",
|
||
unverified: "未验证",
|
||
"dry-run": "演练",
|
||
"dev-live": "实况已验证"
|
||
});
|
||
|
||
const M3_TRUSTED_ROUTE = Object.freeze({
|
||
fromResourceId: "res_boxsimu_1",
|
||
fromPort: "DO1",
|
||
patchPanelServiceId: "hwlab-patch-panel",
|
||
toResourceId: "res_boxsimu_2",
|
||
toPort: "DI1"
|
||
});
|
||
|
||
const LIVE_M3_ID_FIELDS = Object.freeze(["operationId", "traceId", "auditId", "evidenceId"]);
|
||
const LIVE_M3_PASS_STATUSES = Object.freeze(["pass", "passed", "verified", "succeeded", "trusted", "completed"]);
|
||
const NON_LIVE_ID_VALUES = Object.freeze(["", "n/a", "none", "null", "undefined", "not_observed", "not-observed"]);
|
||
const TRUSTED_CODE_AGENT_PROVIDERS = Object.freeze(["openai-responses", "codex-cli", "codex-readonly-runner", "codex-stdio"]);
|
||
const CODEX_RUNNER_CAPABLE_PROVIDERS = Object.freeze(["codex-stdio"]);
|
||
const CODEX_READONLY_PARTIAL_PROVIDERS = Object.freeze(["codex-readonly-runner"]);
|
||
const CODEX_STDIO_BLOCKER_MARKERS = Object.freeze([
|
||
"codex_stdio_supervisor_disabled",
|
||
"not-codex-stdio",
|
||
"not-write-capable",
|
||
"not-durable-session"
|
||
]);
|
||
const UNTRUSTED_CODE_AGENT_PROVIDER_PATTERN = /\b(?:echo|mock|fixture|stub|sample)\b/iu;
|
||
const LAYOUT_STORAGE_KEY = "hwlab.workbench.layout.v1";
|
||
const EXPLORER_DEFAULT_WIDTH = 292;
|
||
const EXPLORER_MIN_WIDTH = 220;
|
||
const EXPLORER_MAX_WIDTH = 420;
|
||
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 620;
|
||
const RIGHT_SIDEBAR_MIN_WIDTH = 560;
|
||
const RIGHT_SIDEBAR_MAX_WIDTH = 760;
|
||
|
||
const viewIds = new Set([...document.querySelectorAll("[data-view]")].map((view) => view.dataset.view));
|
||
let rpcSequence = 0;
|
||
|
||
const el = {
|
||
shell: query("[data-app-shell]"),
|
||
explorerToggle: byId("explorer-toggle"),
|
||
explorerResize: byId("explorer-resize"),
|
||
rightSidebarResize: byId("right-sidebar-resize"),
|
||
explorerStatus: byId("explorer-status"),
|
||
treeCount: byId("tree-count"),
|
||
resourceTree: byId("resource-tree"),
|
||
capabilityCount: byId("capability-count"),
|
||
explorerCapabilities: byId("explorer-capabilities"),
|
||
routePath: byId("route-path"),
|
||
liveStatus: byId("live-status"),
|
||
liveDetail: byId("live-detail"),
|
||
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"),
|
||
taskList: byId("task-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"),
|
||
commandSend: byId("command-send"),
|
||
commandClear: byId("command-clear"),
|
||
hardwareSourceStatus: byId("hardware-source-status"),
|
||
hardwareList: byId("hardware-list"),
|
||
controlList: byId("control-list"),
|
||
wiringBody: byId("wiring-body"),
|
||
recordsList: byId("records-list"),
|
||
m3ControlForm: byId("m3-control-form"),
|
||
m3ControlStatus: byId("m3-control-status"),
|
||
m3GatewaySelect: byId("m3-gateway-select"),
|
||
m3BoxSelect: byId("m3-box-select"),
|
||
m3PortSelect: byId("m3-port-select"),
|
||
m3ValueSelect: byId("m3-value-select"),
|
||
m3WriteDo: byId("m3-write-do"),
|
||
m3ReadDi: byId("m3-read-di"),
|
||
helpContent: byId("help-content"),
|
||
helpStatus: byId("help-status")
|
||
};
|
||
|
||
const state = {
|
||
conversationId: null,
|
||
sessionId: null,
|
||
codeAgentAvailability: null,
|
||
chatMessages: [],
|
||
chatPending: false,
|
||
liveSurface: null,
|
||
gateDiagnostics: {
|
||
loading: false,
|
||
payload: null,
|
||
rows: [],
|
||
error: null,
|
||
loadedAt: null
|
||
},
|
||
m3Control: {
|
||
contract: null,
|
||
status: null,
|
||
hardwareTab: "overview",
|
||
operation: null,
|
||
pending: false
|
||
},
|
||
layout: {
|
||
explorerWidth: EXPLORER_DEFAULT_WIDTH,
|
||
rightSidebarWidth: RIGHT_SIDEBAR_DEFAULT_WIDTH,
|
||
drag: null,
|
||
rightDrag: null
|
||
}
|
||
};
|
||
|
||
initRoutes();
|
||
initLayoutSizing();
|
||
initExplorerToggle();
|
||
syncMobileExplorer();
|
||
initExplorerResize();
|
||
initRightSidebarResize();
|
||
initSideTabs();
|
||
initHardwareTabs();
|
||
initQuickActions();
|
||
initCommandBar();
|
||
initM3Control();
|
||
initGateControls();
|
||
renderStaticWorkbench();
|
||
renderProbePending();
|
||
renderCodeAgentSummary();
|
||
loadHelpSurface();
|
||
loadLiveSurface().then(renderLiveSurface);
|
||
|
||
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 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 explorerWidth = clampExplorerWidth(persisted?.explorerWidth ?? EXPLORER_DEFAULT_WIDTH);
|
||
const rightSidebarWidth = clampRightSidebarWidth(persisted?.rightSidebarWidth ?? RIGHT_SIDEBAR_DEFAULT_WIDTH);
|
||
setExplorerWidth(explorerWidth, { 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 {
|
||
explorerWidth: Number(payload.explorerWidth),
|
||
rightSidebarWidth: Number(payload.rightSidebarWidth)
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function persistLayout() {
|
||
try {
|
||
window.localStorage?.setItem(
|
||
LAYOUT_STORAGE_KEY,
|
||
JSON.stringify({
|
||
version: 1,
|
||
explorerWidth: state.layout.explorerWidth,
|
||
rightSidebarWidth: state.layout.rightSidebarWidth
|
||
})
|
||
);
|
||
} catch {
|
||
// Browser storage can be unavailable; layout still works for the session.
|
||
}
|
||
}
|
||
|
||
function setExplorerWidth(width, options = {}) {
|
||
const clamped = clampExplorerWidth(width);
|
||
state.layout.explorerWidth = clamped;
|
||
el.shell.style.setProperty("--explorer-width", `${clamped}px`);
|
||
syncExplorerResizeA11y();
|
||
syncRightSidebarResizeA11y();
|
||
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 clampExplorerWidth(value) {
|
||
const numeric = Number(value);
|
||
const width = Number.isFinite(numeric) ? numeric : EXPLORER_DEFAULT_WIDTH;
|
||
const bounds = explorerResizeBounds();
|
||
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 explorerResizeBounds() {
|
||
const styles = getComputedStyle(el.shell);
|
||
const cssMin = readCssPixel(styles.getPropertyValue("--explorer-min-width"), EXPLORER_MIN_WIDTH);
|
||
const cssMax = readCssPixel(styles.getPropertyValue("--explorer-max-width"), EXPLORER_MAX_WIDTH);
|
||
if (window.matchMedia("(max-width: 860px)").matches) {
|
||
return { min: EXPLORER_MIN_WIDTH, max: EXPLORER_MAX_WIDTH };
|
||
}
|
||
if (window.matchMedia("(max-width: 1240px)").matches) {
|
||
const railWidth = readCssPixel(styles.getPropertyValue("--rail-width"), 86);
|
||
const dynamicMax = window.innerWidth - railWidth - 420 - 4;
|
||
return boundedResizeRange(cssMin, cssMax, dynamicMax);
|
||
}
|
||
const railWidth = readCssPixel(styles.getPropertyValue("--rail-width"), 92);
|
||
const rightWidth = Math.min(Math.max(window.innerWidth * 0.45, 560), 740);
|
||
const dynamicMax = window.innerWidth - railWidth - 360 - rightWidth - 4;
|
||
return boundedResizeRange(cssMin, cssMax, dynamicMax);
|
||
}
|
||
|
||
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 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 = readCssPixel(styles.getPropertyValue("--rail-width"), 92);
|
||
const explorerWidth = el.shell.classList.contains("explorer-collapsed") ? 0 : state.layout.explorerWidth;
|
||
const centerMinWidth = el.shell.classList.contains("explorer-collapsed") ? 420 : 360;
|
||
const dynamicMax = window.innerWidth - railWidth - explorerWidth - centerMinWidth - 4;
|
||
return boundedResizeRange(cssMin, cssMax, dynamicMax);
|
||
}
|
||
|
||
function readCssPixel(value, fallback) {
|
||
const parsed = Number.parseFloat(String(value ?? "").trim());
|
||
return Number.isFinite(parsed) ? parsed : fallback;
|
||
}
|
||
|
||
function syncExplorerResizeA11y() {
|
||
const bounds = explorerResizeBounds();
|
||
el.explorerResize.setAttribute("aria-valuemin", String(bounds.min));
|
||
el.explorerResize.setAttribute("aria-valuemax", String(bounds.max));
|
||
el.explorerResize.setAttribute("aria-valuenow", String(state.layout.explorerWidth));
|
||
el.explorerResize.setAttribute("aria-valuetext", `左侧资源树宽度 ${state.layout.explorerWidth} 像素`);
|
||
}
|
||
|
||
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", `右侧硬件状态栏宽度 ${state.layout.rightSidebarWidth} 像素`);
|
||
}
|
||
|
||
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 initExplorerToggle() {
|
||
el.explorerToggle.addEventListener("click", () => {
|
||
const collapsed = !el.shell.classList.contains("explorer-collapsed");
|
||
setExplorerCollapsed(collapsed);
|
||
});
|
||
}
|
||
|
||
function initExplorerResize() {
|
||
window.addEventListener("resize", () => {
|
||
if (canResizeExplorer()) setExplorerWidth(state.layout.explorerWidth, { persist: false });
|
||
syncExplorerResizeA11y();
|
||
});
|
||
el.explorerResize.addEventListener("pointerdown", (event) => {
|
||
if (!canResizeExplorer()) return;
|
||
event.preventDefault();
|
||
el.explorerResize.setPointerCapture?.(event.pointerId);
|
||
state.layout.drag = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startWidth: state.layout.explorerWidth
|
||
};
|
||
el.shell.classList.add("is-resizing");
|
||
});
|
||
el.explorerResize.addEventListener("pointermove", (event) => {
|
||
const drag = state.layout.drag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
setExplorerWidth(drag.startWidth + event.clientX - drag.startX, { persist: false });
|
||
});
|
||
el.explorerResize.addEventListener("pointerup", finishExplorerResize);
|
||
el.explorerResize.addEventListener("pointercancel", finishExplorerResize);
|
||
el.explorerResize.addEventListener("keydown", (event) => {
|
||
if (!canResizeExplorer()) return;
|
||
const keySteps = {
|
||
ArrowLeft: -16,
|
||
ArrowRight: 16,
|
||
PageDown: -48,
|
||
PageUp: 48
|
||
};
|
||
if (Object.hasOwn(keySteps, event.key)) {
|
||
event.preventDefault();
|
||
setExplorerWidth(state.layout.explorerWidth + keySteps[event.key]);
|
||
} else if (event.key === "Home") {
|
||
event.preventDefault();
|
||
setExplorerWidth(explorerResizeBounds().min);
|
||
} else if (event.key === "End") {
|
||
event.preventDefault();
|
||
setExplorerWidth(explorerResizeBounds().max);
|
||
}
|
||
});
|
||
}
|
||
|
||
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 finishExplorerResize(event) {
|
||
const drag = state.layout.drag;
|
||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||
state.layout.drag = null;
|
||
el.shell.classList.remove("is-resizing");
|
||
persistLayout();
|
||
}
|
||
|
||
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 canResizeExplorer() {
|
||
return !window.matchMedia("(max-width: 860px)").matches && !el.shell.classList.contains("explorer-collapsed");
|
||
}
|
||
|
||
function canResizeRightSidebar() {
|
||
return !window.matchMedia("(max-width: 1240px)").matches;
|
||
}
|
||
|
||
function setExplorerCollapsed(collapsed) {
|
||
if (!collapsed) {
|
||
setExplorerWidth(state.layout.explorerWidth, { persist: false });
|
||
}
|
||
el.shell.classList.toggle("explorer-collapsed", collapsed);
|
||
el.explorerToggle.setAttribute("aria-pressed", collapsed ? "true" : "false");
|
||
el.explorerToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||
el.explorerToggle.setAttribute("aria-label", collapsed ? "展开左侧资源树" : "收起左侧资源树");
|
||
el.explorerToggle.title = collapsed ? "展开左侧资源树,恢复导航和硬件资源" : "收起左侧资源树,给工作区更多宽度";
|
||
el.explorerToggle.textContent = collapsed ? "展开资源树" : "收起资源树";
|
||
const resizeDisabled = collapsed || window.matchMedia("(max-width: 860px)").matches;
|
||
el.explorerResize.tabIndex = resizeDisabled ? -1 : 0;
|
||
el.explorerResize.setAttribute("aria-disabled", resizeDisabled ? "true" : "false");
|
||
el.explorerResize.setAttribute("aria-hidden", resizeDisabled ? "true" : "false");
|
||
setRightSidebarWidth(state.layout.rightSidebarWidth, { persist: false });
|
||
}
|
||
|
||
function syncMobileExplorer() {
|
||
const media = window.matchMedia("(max-width: 860px)");
|
||
const apply = () => {
|
||
if (media.matches) {
|
||
setExplorerCollapsed(true);
|
||
} else {
|
||
setExplorerCollapsed(false);
|
||
setExplorerWidth(state.layout.explorerWidth, { persist: false });
|
||
}
|
||
};
|
||
apply();
|
||
media.addEventListener("change", apply);
|
||
}
|
||
|
||
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 initSideTabs() {
|
||
for (const tab of document.querySelectorAll("[data-side-tab]")) {
|
||
tab.addEventListener("click", () => selectSideTab(tab.dataset.sideTab));
|
||
}
|
||
for (const jump of document.querySelectorAll("[data-side-tab-jump]")) {
|
||
jump.addEventListener("click", () => {
|
||
selectSideTab(jump.dataset.sideTabJump);
|
||
collapseExplorerAfterMobileAction();
|
||
});
|
||
}
|
||
}
|
||
|
||
function initQuickActions() {
|
||
for (const button of document.querySelectorAll("[data-focus-command]")) {
|
||
button.addEventListener("click", () => {
|
||
el.commandInput.focus();
|
||
collapseExplorerAfterMobileAction();
|
||
});
|
||
}
|
||
for (const button of document.querySelectorAll("[data-agent-quick-prompt]")) {
|
||
button.addEventListener("click", () => {
|
||
fillAgentQuickPrompt(button);
|
||
collapseExplorerAfterMobileAction();
|
||
});
|
||
}
|
||
}
|
||
|
||
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 collapseExplorerAfterMobileAction() {
|
||
if (window.matchMedia("(max-width: 860px)").matches) {
|
||
setExplorerCollapsed(true);
|
||
}
|
||
}
|
||
|
||
function selectSideTab(tabId) {
|
||
for (const tab of document.querySelectorAll("[data-side-tab]")) {
|
||
const active = tab.dataset.sideTab === tabId;
|
||
tab.classList.toggle("active", active);
|
||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||
}
|
||
for (const panel of document.querySelectorAll("[data-side-panel]")) {
|
||
const active = panel.dataset.sidePanel === tabId;
|
||
panel.classList.toggle("active", active);
|
||
panel.hidden = !active;
|
||
}
|
||
}
|
||
|
||
function initHardwareTabs() {
|
||
for (const tab of document.querySelectorAll("[data-hardware-tab]")) {
|
||
tab.addEventListener("click", () => {
|
||
state.m3Control.hardwareTab = tab.dataset.hardwareTab;
|
||
syncHardwareTabs();
|
||
renderHardwareStatus(state.m3Control.status);
|
||
});
|
||
}
|
||
syncHardwareTabs();
|
||
}
|
||
|
||
function syncHardwareTabs() {
|
||
for (const tab of document.querySelectorAll("[data-hardware-tab]")) {
|
||
const active = tab.dataset.hardwareTab === state.m3Control.hardwareTab;
|
||
tab.classList.toggle("active", active);
|
||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||
}
|
||
}
|
||
|
||
function initCommandBar() {
|
||
el.commandForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const value = el.commandInput.value.trim();
|
||
if (!value || state.chatPending) return;
|
||
const traceId = nextProtocolId("trc");
|
||
const userMessage = {
|
||
id: nextProtocolId("msg"),
|
||
role: "user",
|
||
title: `用户 ${shortTime(new Date().toISOString())}`,
|
||
text: value,
|
||
status: "sent",
|
||
traceId,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
const pendingMessage = {
|
||
id: nextProtocolId("msg"),
|
||
role: "agent",
|
||
title: "Code Agent 处理中",
|
||
text: `正在调用真实 Code Agent / Codex 后端,复杂问题可能需要 1-2 分钟;超时上限 ${Math.round(CODE_AGENT_TIMEOUT_MS / 1000)} 秒,失败时会保留输入供重试。`,
|
||
status: "running",
|
||
traceId,
|
||
createdAt: new Date().toISOString()
|
||
};
|
||
state.chatMessages.push(userMessage, pendingMessage);
|
||
state.chatPending = true;
|
||
el.commandInput.value = "";
|
||
renderAgentChatStatus("running");
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderRecords(state.liveSurface);
|
||
|
||
try {
|
||
const result = await sendAgentMessage(value, state.conversationId, traceId);
|
||
state.conversationId = result.conversationId || result.sessionId || state.conversationId;
|
||
state.sessionId = result.sessionId || state.sessionId || state.conversationId;
|
||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||
const completion = classifyCodeAgentCompletion(result);
|
||
const status = completion.status;
|
||
const structuredBlockedError = structuredBlockedErrorFromResult(result);
|
||
state.chatMessages[index] = {
|
||
...pendingMessage,
|
||
title: completion.title,
|
||
text: completion.replied
|
||
? result.reply?.content || "Code Agent 没有返回文本。"
|
||
: structuredBlockedError
|
||
? failureMessage({ ...result, error: structuredBlockedError })
|
||
: result.status === "completed"
|
||
? untrustedCompletionMessage(result)
|
||
: failureMessage(result),
|
||
status,
|
||
traceId: result.traceId,
|
||
conversationId: result.conversationId || result.sessionId || state.conversationId,
|
||
messageId: result.messageId,
|
||
provider: result.provider,
|
||
model: result.model,
|
||
backend: result.backend,
|
||
workspace: result.workspace,
|
||
sandbox: result.sandbox,
|
||
session: result.session,
|
||
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,
|
||
capabilityLevel: result.capabilityLevel,
|
||
sourceKind: completion.sourceKind,
|
||
providerTrace: result.providerTrace,
|
||
blocker: result.blocker,
|
||
blockers: result.blockers,
|
||
updatedAt: result.updatedAt,
|
||
error: structuredBlockedError ?? result.error ?? (result.status === "completed" && !completion.replied
|
||
? {
|
||
code: "untrusted_completion",
|
||
message: "completed 回复缺少真实 provider/model/trace/conversation 证据,或 provider 属于 echo/mock/stub。"
|
||
}
|
||
: undefined),
|
||
availability: result.availability
|
||
};
|
||
if (result.availability) {
|
||
state.codeAgentAvailability = result.availability;
|
||
}
|
||
if (!completion.replied) {
|
||
el.commandInput.value = value;
|
||
}
|
||
renderAgentChatStatus(status, result);
|
||
renderCodeAgentSummary();
|
||
} catch (error) {
|
||
const index = state.chatMessages.findIndex((message) => message.id === pendingMessage.id);
|
||
const presentation = agentFailurePresentation(error, { traceId });
|
||
state.chatMessages[index] = {
|
||
...pendingMessage,
|
||
title: presentation.title,
|
||
text: presentation.text,
|
||
status: "failed",
|
||
traceId: error.traceId || traceId,
|
||
updatedAt: new Date().toISOString(),
|
||
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,
|
||
providerStatus: error.providerStatus,
|
||
missingConfig: error.missingConfig,
|
||
route: error.route,
|
||
toolName: error.toolName
|
||
}
|
||
};
|
||
el.commandInput.value = value;
|
||
renderAgentChatStatus("failed", state.chatMessages[index]);
|
||
renderCodeAgentSummary();
|
||
} finally {
|
||
state.chatPending = false;
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderRecords(state.liveSurface);
|
||
}
|
||
});
|
||
|
||
el.commandClear.addEventListener("click", () => {
|
||
state.chatMessages = [];
|
||
state.conversationId = null;
|
||
state.sessionId = null;
|
||
state.chatPending = false;
|
||
el.commandInput.value = "";
|
||
renderAgentChatStatus("idle");
|
||
renderCodeAgentSummary();
|
||
renderConversation();
|
||
renderDrafts();
|
||
renderRecords(state.liveSurface);
|
||
});
|
||
}
|
||
|
||
function initM3Control() {
|
||
el.m3ControlForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
await runM3IoAction("do.write");
|
||
});
|
||
el.m3ReadDi.addEventListener("click", async () => {
|
||
await runM3IoAction("di.read");
|
||
});
|
||
}
|
||
|
||
function initGateControls() {
|
||
el.gateStatusFilter.addEventListener("change", renderGateTable);
|
||
el.gateSearch.addEventListener("input", renderGateTable);
|
||
el.gateRefresh.addEventListener("click", () => {
|
||
loadGateDiagnostics();
|
||
});
|
||
}
|
||
|
||
function renderStaticWorkbench() {
|
||
el.explorerStatus.textContent = "只读工作区";
|
||
el.explorerStatus.className = "status-dot tone-source";
|
||
el.routePath.textContent = "当前浏览器会话只保存任务草稿,硬件动作需通过受控后端流程。";
|
||
|
||
renderResourceTree();
|
||
renderCapabilityList();
|
||
renderConversation();
|
||
renderTaskList();
|
||
renderHardwareStatus(null);
|
||
renderDrafts();
|
||
renderM3ControlStatus();
|
||
renderWiringList(null);
|
||
renderRecords(null);
|
||
renderGateTable();
|
||
renderCodeAgentSummary();
|
||
}
|
||
|
||
function renderProbePending() {
|
||
el.liveStatus.textContent = "等待验证";
|
||
el.liveStatus.className = "tone-pending";
|
||
el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。";
|
||
renderCodeAgentSummary();
|
||
}
|
||
|
||
async function sendAgentMessage(message, conversationId, traceId = nextProtocolId("trc")) {
|
||
const sessionId = state.conversationId === conversationId ? state.sessionId : undefined;
|
||
const response = await fetchJson("/v1/agent/chat", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Trace-Id": traceId
|
||
},
|
||
timeoutMs: CODE_AGENT_TIMEOUT_MS,
|
||
timeoutName: "Code Agent",
|
||
body: JSON.stringify({
|
||
message,
|
||
conversationId,
|
||
sessionId,
|
||
traceId,
|
||
projectId: gateSummary.topology.projectId
|
||
})
|
||
});
|
||
|
||
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;
|
||
}
|
||
return {
|
||
...response.data,
|
||
traceId: response.data?.traceId || traceId
|
||
};
|
||
}
|
||
|
||
async function loadLiveSurface() {
|
||
const projectId = gateSummary.topology.projectId;
|
||
const [healthLive, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([
|
||
fetchJson("/health/live"),
|
||
fetchJson("/v1"),
|
||
fetchJson("/v1/m3/io"),
|
||
fetchJson("/v1/m3/status"),
|
||
callRpc("system.health"),
|
||
callRpc("cloud.adapter.describe"),
|
||
callRpc("audit.event.query", { projectId, limit: 6 }),
|
||
callRpc("evidence.record.query", { projectId, limit: 6 })
|
||
]);
|
||
|
||
return {
|
||
observedAt: new Date().toISOString(),
|
||
healthLive,
|
||
restIndex,
|
||
m3Control,
|
||
m3Status,
|
||
health,
|
||
adapter,
|
||
audit,
|
||
evidence
|
||
};
|
||
}
|
||
|
||
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: API_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,
|
||
...fetchOptions
|
||
} = options;
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
|
||
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";
|
||
return {
|
||
ok: false,
|
||
path,
|
||
timeout: timedOut,
|
||
timeoutMs,
|
||
error: timedOut ? `${timeoutName} 请求超时 ${timeoutMs}ms` : `请求失败:${error.message}`
|
||
};
|
||
} finally {
|
||
window.clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function callRpc(method, params = {}) {
|
||
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"
|
||
}
|
||
})
|
||
});
|
||
|
||
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
|
||
};
|
||
}
|
||
|
||
async function runM3IoAction(action) {
|
||
if (state.m3Control.pending) return;
|
||
const traceId = nextProtocolId("trc");
|
||
if (!m3ControlCanOperate()) {
|
||
state.m3Control.operation = {
|
||
status: "blocked",
|
||
action,
|
||
traceId,
|
||
blocker: {
|
||
code: state.m3Control.contract?.readiness?.blocker?.code ?? "m3_control_readiness_blocked",
|
||
zh: m3ControlBlockedReason()
|
||
},
|
||
observedAt: new Date().toISOString(),
|
||
sourceKind: "BLOCKED"
|
||
};
|
||
renderM3ControlStatus();
|
||
renderRecords(state.liveSurface);
|
||
renderDrafts();
|
||
return;
|
||
}
|
||
state.m3Control.pending = true;
|
||
state.m3Control.operation = {
|
||
status: "running",
|
||
action,
|
||
traceId,
|
||
observedAt: new Date().toISOString()
|
||
};
|
||
renderM3ControlStatus();
|
||
renderDrafts();
|
||
renderRecords(state.liveSurface);
|
||
|
||
const body = action === "do.write"
|
||
? {
|
||
action,
|
||
gatewayId: el.m3GatewaySelect.value,
|
||
resourceId: el.m3BoxSelect.value,
|
||
boxId: "boxsimu_1",
|
||
port: el.m3PortSelect.value,
|
||
value: el.m3ValueSelect.value === "true",
|
||
projectId: gateSummary.topology.projectId,
|
||
traceId
|
||
}
|
||
: {
|
||
action,
|
||
gatewayId: "gwsimu_2",
|
||
resourceId: M3_TRUSTED_ROUTE.toResourceId,
|
||
boxId: "boxsimu_2",
|
||
port: M3_TRUSTED_ROUTE.toPort,
|
||
projectId: gateSummary.topology.projectId,
|
||
traceId
|
||
};
|
||
|
||
const response = await fetchJson("/v1/m3/io", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-Trace-Id": traceId,
|
||
"X-Actor-Id": "usr_hwlab_cloud_web"
|
||
},
|
||
body: JSON.stringify(body)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
state.m3Control.operation = {
|
||
status: "blocked",
|
||
action,
|
||
traceId,
|
||
blocker: {
|
||
zh: response.error || "M3 IO 控制请求失败"
|
||
},
|
||
observedAt: new Date().toISOString(),
|
||
sourceKind: "BLOCKED"
|
||
};
|
||
} else {
|
||
state.m3Control.operation = {
|
||
...response.data,
|
||
sourceKind: response.data?.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED"
|
||
};
|
||
}
|
||
const refreshedStatus = await fetchJson("/v1/m3/status");
|
||
if (refreshedStatus.ok) {
|
||
state.m3Control.status = refreshedStatus.data;
|
||
}
|
||
state.m3Control.pending = false;
|
||
renderM3ControlStatus();
|
||
renderHardwareStatus(state.m3Control.status);
|
||
renderWiringList(state.m3Control.status);
|
||
renderRecords(state.liveSurface);
|
||
renderDrafts();
|
||
}
|
||
|
||
function nextProtocolId(prefix) {
|
||
rpcSequence += 1;
|
||
return `${prefix}_cloud-web-workbench-${Date.now()}-${rpcSequence}`;
|
||
}
|
||
|
||
function renderLiveSurface(live) {
|
||
state.liveSurface = live;
|
||
state.m3Control.contract = live.m3Control?.ok ? live.m3Control.data : null;
|
||
state.m3Control.status = live.m3Status?.ok ? live.m3Status.data : null;
|
||
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();
|
||
|
||
renderAgentChatStatus(deriveAgentChatStatus());
|
||
renderCodeAgentSummary();
|
||
renderHardwareStatus(state.m3Control.status);
|
||
renderWiringList(state.m3Control.status ?? runtimeSummary);
|
||
renderRecords(live);
|
||
renderM3ControlStatus();
|
||
renderConversation();
|
||
renderDrafts();
|
||
}
|
||
|
||
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 renderResourceTree() {
|
||
const items = [
|
||
{
|
||
label: gateSummary.topology.projectId,
|
||
meta: "当前项目",
|
||
tone: "source",
|
||
children: [
|
||
...gateSummary.topology.gateways.map((gateway) => ({
|
||
label: gateway.gatewaySessionId,
|
||
meta: `${gateway.serviceId} / ${statusLabel(gateway.status)}`,
|
||
tone: gateway.status
|
||
})),
|
||
...gateSummary.topology.boxResources.map((resource) => ({
|
||
label: resource.resourceId,
|
||
meta: `${resourceTypeLabel(resource.resourceType)} / ${statusLabel(resource.state)}`,
|
||
tone: resource.state
|
||
})),
|
||
{
|
||
label: gateSummary.topology.patchPanel.patchPanelStatusId,
|
||
meta: `${gateSummary.topology.patchPanel.serviceId} / ${statusLabel(gateSummary.topology.patchPanel.state)}`,
|
||
tone: gateSummary.topology.patchPanel.state
|
||
}
|
||
]
|
||
},
|
||
{
|
||
label: gateSummary.agent.agentSessionId,
|
||
meta: `${gateSummary.agent.agentServiceId} / Agent 会话`,
|
||
tone: gateSummary.agent.agentStatus,
|
||
children: [
|
||
{
|
||
label: gateSummary.agent.workerSessionId,
|
||
meta: `${gateSummary.agent.workerServiceId} / ${statusLabel(gateSummary.agent.workerStatus)}`,
|
||
tone: gateSummary.agent.workerStatus
|
||
}
|
||
]
|
||
}
|
||
];
|
||
|
||
el.treeCount.textContent = String(items.reduce((count, item) => count + 1 + (item.children?.length ?? 0), 0));
|
||
replaceChildren(el.resourceTree, ...items.map(treeGroup));
|
||
}
|
||
|
||
function treeGroup(item) {
|
||
const details = document.createElement("details");
|
||
details.open = true;
|
||
const summary = document.createElement("summary");
|
||
summary.append(statusLight(item.tone), textSpan(item.label, "tree-label"), textSpan(item.meta, "tree-meta"));
|
||
details.append(summary);
|
||
for (const child of item.children ?? []) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "tree-row";
|
||
row.append(statusLight(child.tone), textSpan(child.label, "tree-label"), textSpan(child.meta, "tree-meta"));
|
||
details.append(row);
|
||
}
|
||
return details;
|
||
}
|
||
|
||
function renderCapabilityList() {
|
||
const capabilities = [
|
||
{
|
||
title: "任务草稿",
|
||
detail: "在底部输入区记录要交给 Agent 的任务,不会直接发送硬件请求。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "资源查看",
|
||
detail: "查看 Gateway-SIMU、BOX-SIMU 与 hwlab-patch-panel 的只读资源状态。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "接线核对",
|
||
detail: "在右侧两列接线表核对互连设备、DO/DI 对应关系与证据记录。",
|
||
tone: "source"
|
||
}
|
||
];
|
||
el.capabilityCount.textContent = String(capabilities.length);
|
||
replaceChildren(el.explorerCapabilities, ...capabilities.map(infoCard));
|
||
}
|
||
|
||
function renderConversation() {
|
||
const introMessages = [
|
||
{
|
||
role: "system",
|
||
title: "界面模式",
|
||
text: "这里是用户工作台:可以整理任务、查看资源、核对接线和可信记录。对话会发送到受控 Code Agent 后端;页面不会直接发送硬件变更。",
|
||
status: "source"
|
||
},
|
||
codeAgentStatusMessage(state.codeAgentAvailability)
|
||
];
|
||
replaceChildren(el.conversationList, ...[...introMessages, ...state.chatMessages].map(messageCard));
|
||
}
|
||
|
||
function renderTaskList() {
|
||
const rows = [
|
||
{
|
||
title: "1. 描述目标",
|
||
detail: "例如要检查哪个 BOX-SIMU、哪个端口、期望看到什么信号。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "2. 核对接线",
|
||
detail: "打开右侧“接线”表,确认 res_boxsimu_1 与 res_boxsimu_2 的 DO/DI 对应关系,以及接线盘和轨迹/证据摘要。",
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "3. 留存记录",
|
||
detail: "在“可信记录”面板查看已有证据;新增硬件动作不从前端直接发起。",
|
||
tone: "source"
|
||
}
|
||
];
|
||
replaceChildren(el.taskList, ...rows.map(infoCard));
|
||
}
|
||
|
||
function renderHardwareStatus(m3Status) {
|
||
syncHardwareTabs();
|
||
const status = m3Status ?? sourceFallbackM3Status();
|
||
const sourceKind = status.sourceKind ?? "UNVERIFIED";
|
||
const trustedGreen = status.trust?.durableStatus === "green";
|
||
const statusTone = status.status === "live" && trustedGreen
|
||
? "dev-live"
|
||
: status.status === "error" || status.status === "blocked"
|
||
? "blocked"
|
||
: sourceKind === "DEV-LIVE"
|
||
? "dev-live"
|
||
: "source";
|
||
|
||
el.hardwareSourceStatus.textContent = hardwareStatusLabel(status);
|
||
el.hardwareSourceStatus.className = `state-tag tone-${toneClass(statusTone)}`;
|
||
|
||
const groups = {
|
||
overview: hardwareOverviewGroups(status),
|
||
gateways: hardwareGatewayGroups(status),
|
||
box1: hardwareBoxGroups(status, M3_TRUSTED_ROUTE.fromResourceId),
|
||
box2: hardwareBoxGroups(status, M3_TRUSTED_ROUTE.toResourceId),
|
||
patch: hardwarePatchPanelGroups(status)
|
||
};
|
||
// Tab bodies are compact Key/Value groups; SOURCE fallback stays unverified.
|
||
replaceChildren(el.hardwareList, ...(groups[state.m3Control.hardwareTab] ?? groups.overview));
|
||
}
|
||
|
||
function hardwareGroup(group) {
|
||
const article = document.createElement("article");
|
||
article.className = "hardware-section";
|
||
article.append(textSpan(group.title, "hardware-section-title"));
|
||
const rows = document.createElement("div");
|
||
rows.className = "hardware-rows";
|
||
rows.append(...group.rows.map(hardwareRow));
|
||
article.append(rows);
|
||
return article;
|
||
}
|
||
|
||
function hardwareRow(item) {
|
||
const row = document.createElement("div");
|
||
row.className = `hardware-row tone-border-${toneClass(item.tone)}`;
|
||
row.dataset.sourceKind = item.sourceKind;
|
||
const head = document.createElement("div");
|
||
head.className = "hardware-row-head";
|
||
head.append(textSpan(item.label, "hardware-row-label"), badge(item.badgeLabel ?? hardwareEvidenceLabel(item.sourceKind), item.sourceKind));
|
||
row.append(head);
|
||
row.append(textSpan(item.value, `hardware-row-value tone-${toneClass(item.tone)}`));
|
||
row.append(textSpan(item.detail, "hardware-row-detail"));
|
||
return row;
|
||
}
|
||
|
||
function hardwareStatusLabel(status) {
|
||
if (!status) return "未验证";
|
||
if (status.status === "live" && status.trust?.durableStatus === "green") return "实况可信";
|
||
if (status.status === "error") return "错误";
|
||
if (status.status === "blocked") return "实况受阻";
|
||
if (status.sourceKind === "DEV-LIVE") return "DEV-LIVE 状态";
|
||
return "未验证";
|
||
}
|
||
|
||
function sourceFallbackM3Status() {
|
||
const observedAt = new Date().toISOString();
|
||
return {
|
||
status: "unverified",
|
||
sourceKind: "SOURCE",
|
||
observedAt,
|
||
chain: {
|
||
sourceGatewayId: "gwsimu_1",
|
||
sourceGatewaySessionId: "gws_gwsimu_1",
|
||
sourceResourceId: M3_TRUSTED_ROUTE.fromResourceId,
|
||
sourceBoxId: "boxsimu_1",
|
||
sourcePort: M3_TRUSTED_ROUTE.fromPort,
|
||
targetGatewayId: "gwsimu_2",
|
||
targetGatewaySessionId: "gws_gwsimu_2",
|
||
targetResourceId: M3_TRUSTED_ROUTE.toResourceId,
|
||
targetBoxId: "boxsimu_2",
|
||
targetPort: M3_TRUSTED_ROUTE.toPort,
|
||
patchPanelServiceId: M3_TRUSTED_ROUTE.patchPanelServiceId
|
||
},
|
||
gateways: [
|
||
{
|
||
id: "gwsimu_1",
|
||
role: "source",
|
||
online: false,
|
||
sessionId: "gws_gwsimu_1",
|
||
sourceKind: "SOURCE",
|
||
lastSeenAt: observedAt,
|
||
lastError: "等待 cloud-api /v1/m3/status 聚合读取"
|
||
},
|
||
{
|
||
id: "gwsimu_2",
|
||
role: "target",
|
||
online: false,
|
||
sessionId: "gws_gwsimu_2",
|
||
sourceKind: "SOURCE",
|
||
lastSeenAt: observedAt,
|
||
lastError: "等待 cloud-api /v1/m3/status 聚合读取"
|
||
}
|
||
],
|
||
boxes: [
|
||
sourceFallbackBox("boxsimu_1", M3_TRUSTED_ROUTE.fromResourceId, "gwsimu_1", "gws_gwsimu_1", "DO1", "output", observedAt),
|
||
sourceFallbackBox("boxsimu_2", M3_TRUSTED_ROUTE.toResourceId, "gwsimu_2", "gws_gwsimu_2", "DI1", "input", observedAt)
|
||
],
|
||
patchPanel: {
|
||
serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId,
|
||
connectionActive: false,
|
||
connectionVersion: null,
|
||
wiringConfigId: gateSummary.topology.patchPanel.wiringConfigId ?? null,
|
||
lastSyncAt: observedAt,
|
||
lastError: "SOURCE 拓扑 fallback,未验证 DEV-LIVE 接线盘状态",
|
||
sourceKind: "SOURCE"
|
||
},
|
||
trust: {
|
||
operationId: null,
|
||
traceId: null,
|
||
auditId: null,
|
||
evidenceId: null,
|
||
durableStatus: "blocked",
|
||
blocker: "unverified_cloud_api_m3_status"
|
||
},
|
||
blocker: {
|
||
code: "unverified_cloud_api_m3_status",
|
||
layer: "cloud-api",
|
||
zh: "等待 cloud-api /v1/m3/status 聚合读取;SOURCE 拓扑不能冒充实况硬件状态。"
|
||
}
|
||
};
|
||
}
|
||
|
||
function sourceFallbackBox(id, resourceId, gatewayId, gatewaySessionId, port, direction, observedAt) {
|
||
return {
|
||
id,
|
||
resourceId,
|
||
gatewayId,
|
||
gatewaySessionId,
|
||
online: false,
|
||
sourceKind: "SOURCE",
|
||
observedAt,
|
||
ports: {
|
||
[port]: {
|
||
value: null,
|
||
direction,
|
||
source: "SOURCE fallback",
|
||
sourceKind: "SOURCE",
|
||
observedAt,
|
||
lastError: "未验证"
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function hardwareOverviewGroups(status) {
|
||
const box1 = findM3Box(status, M3_TRUSTED_ROUTE.fromResourceId);
|
||
const box2 = findM3Box(status, M3_TRUSTED_ROUTE.toResourceId);
|
||
const do1 = box1?.ports?.DO1;
|
||
const di1 = box2?.ports?.DI1;
|
||
const patch = status.patchPanel ?? {};
|
||
const trust = status.trust ?? {};
|
||
return [
|
||
hardwareKvGroup("总览", [
|
||
["状态", hardwareStatusLabel(status), statusTone(status)],
|
||
["观测时间", status.observedAt ?? "未验证", "source"],
|
||
["Gateway 在线", `${onlineCount(status.gateways)} / ${status.gateways?.length ?? 0}`, onlineCount(status.gateways) === 2 ? "live" : "blocked"],
|
||
["BOX 在线", `${onlineCount(status.boxes)} / ${status.boxes?.length ?? 0}`, onlineCount(status.boxes) === 2 ? "live" : "blocked"],
|
||
["链路", `${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort}`, patch.connectionActive ? "live" : "blocked"],
|
||
["DO1", valueLabel(do1), portTone(do1)],
|
||
["DI1", valueLabel(di1), portTone(di1)],
|
||
["patchPanel connectionActive", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"],
|
||
["runtime durable", trust.durableStatus ?? "blocked", trust.durableStatus === "green" ? "dev-live" : "blocked"],
|
||
["blocker", trust.blocker ?? status.blocker?.code ?? "none", trust.blocker || status.blocker ? "blocked" : "source"]
|
||
]),
|
||
hardwareKvGroup("可信证据", [
|
||
["operationId", trust.operationId ?? "未持久化", trust.operationId ? "source" : "blocked"],
|
||
["traceId", trust.traceId ?? "未持久化", trust.traceId ? "source" : "blocked"],
|
||
["auditId", trust.auditId ?? "未持久化", trust.auditId ? "source" : "blocked"],
|
||
["evidenceId", trust.evidenceId ?? "未持久化", trust.evidenceId ? "source" : "blocked"]
|
||
])
|
||
];
|
||
}
|
||
|
||
function hardwareGatewayGroups(status) {
|
||
return (status.gateways ?? []).map((gateway) =>
|
||
hardwareKvGroup(`${gateway.id} / ${gateway.role}`, [
|
||
["online", String(Boolean(gateway.online)), gateway.online ? "live" : "blocked"],
|
||
["sessionId", gateway.sessionId ?? "未验证", gateway.sessionId ? "source" : "blocked"],
|
||
["lastSeenAt", gateway.lastSeenAt ?? status.observedAt ?? "未验证", gateway.online ? "live" : "source"],
|
||
["source", gateway.sourceKind ?? "UNVERIFIED", sourceKindTone(gateway.sourceKind)],
|
||
["lastError", gateway.lastError ?? "none", gateway.lastError ? "blocked" : "source"]
|
||
])
|
||
);
|
||
}
|
||
|
||
function hardwareBoxGroups(status, resourceId) {
|
||
const box = findM3Box(status, resourceId);
|
||
const portEntries = Object.entries(box?.ports ?? {});
|
||
return [
|
||
hardwareKvGroup(box ? `${box.id} / ${box.resourceId}` : resourceId, [
|
||
["online", String(Boolean(box?.online)), box?.online ? "live" : "blocked"],
|
||
["gateway", `${box?.gatewayId ?? "未验证"} / ${box?.gatewaySessionId ?? "未验证"}`, box?.gatewayId ? "source" : "blocked"],
|
||
["更新时间", box?.observedAt ?? status.observedAt ?? "未验证", sourceKindTone(box?.sourceKind)],
|
||
["来源", box?.sourceKind ?? "UNVERIFIED", sourceKindTone(box?.sourceKind)],
|
||
...portEntries.map(([port, portState]) => [port, valueLabel(portState), portTone(portState)]),
|
||
["AI1", "DISABLED 后续能力", "disabled"],
|
||
["AO1", "DISABLED 后续能力", "disabled"],
|
||
["FREQ", "DISABLED 后续能力", "disabled"]
|
||
])
|
||
];
|
||
}
|
||
|
||
function hardwarePatchPanelGroups(status) {
|
||
const patch = status.patchPanel ?? {};
|
||
return [
|
||
hardwareKvGroup("hwlab-patch-panel", [
|
||
["serviceId", patch.serviceId ?? M3_TRUSTED_ROUTE.patchPanelServiceId, "source"],
|
||
["connectionActive", String(Boolean(patch.connectionActive)), patch.connectionActive ? "live" : "blocked"],
|
||
["connectionVersion", patch.connectionVersion ?? "未验证", patch.connectionVersion !== null && patch.connectionVersion !== undefined ? "source" : "blocked"],
|
||
["wiringConfigId", patch.wiringConfigId ?? "未验证", patch.wiringConfigId ? "source" : "blocked"],
|
||
["lastSyncAt", patch.lastSyncAt ?? status.observedAt ?? "未验证", patch.connectionActive ? "live" : "source"],
|
||
["lastError", patch.lastError ?? "none", patch.lastError ? "blocked" : "source"],
|
||
["来源", patch.sourceKind ?? "UNVERIFIED", sourceKindTone(patch.sourceKind)]
|
||
])
|
||
];
|
||
}
|
||
|
||
function hardwareKvGroup(title, rows) {
|
||
const article = document.createElement("article");
|
||
article.className = "hardware-section hardware-kv-section";
|
||
article.append(textSpan(title, "hardware-section-title"));
|
||
const list = document.createElement("dl");
|
||
list.className = "kv-list";
|
||
for (const [key, value, tone = "source"] of rows) {
|
||
const row = document.createElement("div");
|
||
row.className = `kv-row tone-border-${toneClass(tone)}`;
|
||
row.append(textSpan(key, "kv-key"), textSpan(value, `kv-value tone-${toneClass(tone)}`));
|
||
list.append(row);
|
||
}
|
||
article.append(list);
|
||
return article;
|
||
}
|
||
|
||
function findM3Box(status, resourceId) {
|
||
return (status?.boxes ?? []).find((box) => box.resourceId === resourceId) ?? null;
|
||
}
|
||
|
||
function onlineCount(items = []) {
|
||
return items.filter((item) => item?.online === true).length;
|
||
}
|
||
|
||
function valueLabel(portState) {
|
||
if (!portState) return "未验证";
|
||
if (portState.value === null || portState.value === undefined) return portState.lastError ? `未验证:${portState.lastError}` : "未验证";
|
||
return String(portState.value);
|
||
}
|
||
|
||
function portTone(portState) {
|
||
if (!portState) return "blocked";
|
||
if (portState.sourceKind === "DEV-LIVE") return "live";
|
||
if (portState.sourceKind === "SOURCE") return "source";
|
||
return "blocked";
|
||
}
|
||
|
||
function statusTone(status) {
|
||
if (status.status === "live") return "dev-live";
|
||
if (status.status === "error" || status.status === "blocked") return "blocked";
|
||
if (status.sourceKind === "DEV-LIVE") return "live";
|
||
return "source";
|
||
}
|
||
|
||
function sourceKindTone(sourceKind) {
|
||
const normalized = String(sourceKind ?? "").toUpperCase();
|
||
if (normalized === "DEV-LIVE") return "live";
|
||
if (normalized === "SOURCE") return "source";
|
||
if (normalized === "BLOCKED") return "blocked";
|
||
return "blocked";
|
||
}
|
||
|
||
function trustedM3Link(connections) {
|
||
return (connections ?? []).find(
|
||
(link) =>
|
||
String(link.fromResourceId ?? "") === M3_TRUSTED_ROUTE.fromResourceId &&
|
||
normalizePort(link.fromPort) === "DO1" &&
|
||
String(link.toResourceId ?? "") === M3_TRUSTED_ROUTE.toResourceId &&
|
||
normalizePort(link.toPort) === "DI1" &&
|
||
connectionPatchPanelServiceId(link) === M3_TRUSTED_ROUTE.patchPanelServiceId
|
||
);
|
||
}
|
||
|
||
function m3LinkDetail({ liveM3Evidence, sourceLink, patchPanel, counts }) {
|
||
if (hasTrustedM3LiveEvidence(liveM3Evidence)) {
|
||
return `实况链路:${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort};${m3LiveDetail(liveM3Evidence)}。`;
|
||
}
|
||
if (sourceLink) {
|
||
return `来源拓扑包含目标接线 ${sourceLink.fromResourceId}:${sourceLink.fromPort} -> ${patchPanel.serviceId} -> ${sourceLink.toResourceId}:${sourceLink.toPort},仍在等待完整可信记录。`;
|
||
}
|
||
if (counts) {
|
||
return "只读运行计数已返回,但还不能证明接线盘闭环。";
|
||
}
|
||
if (patchPanel.activeConnectionCount > 0) {
|
||
return `当前只看到非目标接线;目标链路 ${M3_TRUSTED_ROUTE.fromResourceId}:${M3_TRUSTED_ROUTE.fromPort} -> ${M3_TRUSTED_ROUTE.patchPanelServiceId} -> ${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort} 仍在等待可信记录。`;
|
||
}
|
||
return "当前还没有 DO1 -> patch-panel -> DI1 的可信链路记录。";
|
||
}
|
||
|
||
function hardwareEvidenceLabel(sourceKind) {
|
||
const normalized = String(sourceKind ?? "").toUpperCase();
|
||
return (
|
||
{
|
||
SOURCE: "来源拓扑",
|
||
BLOCKED: "待可信记录",
|
||
"DEV-LIVE": "实况已验证",
|
||
"DRY-RUN": "演练记录"
|
||
}[normalized] ?? statusLabel(sourceKind)
|
||
);
|
||
}
|
||
|
||
function linkStatusLabel(status) {
|
||
return (
|
||
{
|
||
live: "live",
|
||
degraded: "degraded",
|
||
blocked: "blocked"
|
||
}[status] ?? statusLabel(status)
|
||
);
|
||
}
|
||
|
||
function normalizePort(port) {
|
||
return String(port ?? "").trim().toUpperCase();
|
||
}
|
||
|
||
function trustedM3LiveEvidenceFrom(summary) {
|
||
if (!isDurableRuntimeReady(summary)) return null;
|
||
const candidates = [
|
||
summary?.m3TrustedLoop,
|
||
summary?.m3HardwareLoop,
|
||
summary?.hardware?.m3TrustedLoop,
|
||
summary?.patchPanel?.m3TrustedLoop,
|
||
summary?.liveM3Evidence
|
||
].filter(Boolean);
|
||
|
||
return candidates.find(hasTrustedM3LiveEvidence) ?? null;
|
||
}
|
||
|
||
function m3ControlLiveEvidence() {
|
||
const operation = state.m3Control.operation;
|
||
if (!operation || operation.status !== "succeeded") return null;
|
||
return {
|
||
status: operation.evidenceState?.status === "green" ? "verified" : "blocked",
|
||
sourceKind: operation.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED",
|
||
live: operation.evidenceState?.status === "green",
|
||
operationId: operation.operationId,
|
||
traceId: operation.traceId,
|
||
auditId: operation.auditId,
|
||
evidenceId: operation.evidenceId,
|
||
patchPanelLiveReport: {
|
||
serviceId: M3_TRUSTED_ROUTE.patchPanelServiceId,
|
||
sourceKind: operation.evidenceState?.status === "green" ? "DEV-LIVE" : "BLOCKED",
|
||
live: operation.evidenceState?.status === "green",
|
||
reportId: operation.evidenceId,
|
||
activeConnections: [
|
||
{
|
||
fromResourceId: M3_TRUSTED_ROUTE.fromResourceId,
|
||
fromPort: M3_TRUSTED_ROUTE.fromPort,
|
||
toResourceId: M3_TRUSTED_ROUTE.toResourceId,
|
||
toPort: M3_TRUSTED_ROUTE.toPort,
|
||
patchPanelServiceId: M3_TRUSTED_ROUTE.patchPanelServiceId
|
||
}
|
||
]
|
||
}
|
||
};
|
||
}
|
||
|
||
function liveRecordTone(record, live) {
|
||
const runtimeSummary = runtimeSummaryFrom(live);
|
||
return isDurableRuntimeReady(runtimeSummary) && hasTrustedM3LiveEvidence(record) ? "dev-live" : "blocked";
|
||
}
|
||
|
||
function liveRecordDurabilityBlocker(live) {
|
||
const runtimeSummary = runtimeSummaryFrom(live);
|
||
if (isDurableRuntimeReady(runtimeSummary)) return null;
|
||
if (runtimeSummary?.durableRequested === true || runtimeSummary?.adapter === "postgres") {
|
||
return `runtime durable adapter 未 ready(status=${runtimeSummary?.status ?? "unknown"},durable=${runtimeSummary?.durable === true});可信记录保持 BLOCKED。`;
|
||
}
|
||
if (runtimeSummary?.durable !== true) {
|
||
return "runtime.durable=false;只读 audit/evidence 不能标为 durable ready 或 DEV-LIVE。";
|
||
}
|
||
return `runtime durable adapter 未 ready(status=${runtimeSummary?.status ?? "unknown"});可信记录保持 BLOCKED。`;
|
||
}
|
||
|
||
function isDurableRuntimeReady(summary) {
|
||
if (!summary || typeof summary !== "object") return false;
|
||
if (summary.durable !== true) return false;
|
||
if (summary.ready === false) return false;
|
||
if (summary.liveRuntimeEvidence !== true) return false;
|
||
return !["blocked", "degraded", "failed"].includes(String(summary.status ?? "").toLowerCase());
|
||
}
|
||
|
||
function hasTrustedM3LiveEvidence(candidate) {
|
||
if (!candidate || typeof candidate !== "object") return false;
|
||
if (candidate.dryRun === true || candidate.fixture === true || candidate.sourceKind === "SOURCE" || candidate.sourceKind === "DRY-RUN") return false;
|
||
if (!hasLiveSourceKind(candidate)) return false;
|
||
if (!LIVE_M3_PASS_STATUSES.includes(String(candidate.status ?? candidate.outcome ?? "verified").toLowerCase())) return false;
|
||
if (!LIVE_M3_ID_FIELDS.every((field) => isLiveIdentifier(candidate[field] ?? candidate.operation?.[field] ?? candidate.identifiers?.[field]))) return false;
|
||
const patchPanelLiveReport = candidate.patchPanelLiveReport ?? candidate.patchPanelReport;
|
||
return hasTrustedPatchPanelLiveReport(patchPanelLiveReport);
|
||
}
|
||
|
||
function hasTrustedPatchPanelLiveReport(report) {
|
||
if (!report || typeof report !== "object") return false;
|
||
if (report.dryRun === true || report.fixture === true || report.sourceKind === "SOURCE" || report.sourceKind === "DRY-RUN") return false;
|
||
if (!hasLiveSourceKind(report)) return false;
|
||
if (report.serviceId !== M3_TRUSTED_ROUTE.patchPanelServiceId) return false;
|
||
if (!isLiveIdentifier(report.reportId ?? report.patchPanelStatusId ?? report.statusId ?? report.evidenceId)) return false;
|
||
return (report.activeConnections ?? report.connections ?? []).some((link) => trustedM3Link([link]));
|
||
}
|
||
|
||
function hasLiveSourceKind(value) {
|
||
return value.live === true || value.sourceKind === "DEV-LIVE" || value.evidenceLevel === "DEV-LIVE";
|
||
}
|
||
|
||
function isLiveIdentifier(value) {
|
||
const text = String(value ?? "").trim();
|
||
return text !== "" && !NON_LIVE_ID_VALUES.includes(text.toLowerCase());
|
||
}
|
||
|
||
function connectionPatchPanelServiceId(link) {
|
||
return String(link.patchPanelServiceId ?? link.patchPanel?.serviceId ?? link.serviceId ?? M3_TRUSTED_ROUTE.patchPanelServiceId);
|
||
}
|
||
|
||
function m3LiveDetail(evidence) {
|
||
return `operation=${evidence.operationId} / trace=${evidence.traceId} / audit=${evidence.auditId} / evidence=${evidence.evidenceId}`;
|
||
}
|
||
|
||
function renderM3ControlStatus() {
|
||
const contract = state.m3Control.contract;
|
||
const operation = state.m3Control.operation;
|
||
const controlReady = m3ControlCanOperate(contract);
|
||
const contractObserved = Boolean(contract);
|
||
const pending = state.m3Control.pending;
|
||
const operationSucceeded = operation?.status === "succeeded";
|
||
const operationBlocked = operation?.status === "blocked";
|
||
const label = pending
|
||
? "执行中"
|
||
: operationSucceeded
|
||
? operation.evidenceState?.status === "green"
|
||
? "控制可达 / 记录完整"
|
||
: "控制可达 / 等待记录"
|
||
: operationBlocked
|
||
? "操作待处理"
|
||
: controlReady
|
||
? "受控路径可执行"
|
||
: contractObserved
|
||
? "受控路径受阻"
|
||
: "等待探测";
|
||
const tone = pending
|
||
? "dry-run"
|
||
: operationSucceeded
|
||
? operation.evidenceState?.status === "green" ? "dev-live" : "degraded"
|
||
: operationBlocked || !controlReady
|
||
? "blocked"
|
||
: "source";
|
||
|
||
el.m3ControlStatus.textContent = label;
|
||
el.m3ControlStatus.className = `state-tag tone-${toneClass(tone)}`;
|
||
el.m3ControlStatus.title = m3ControlStatusTitle({ operation, contract, controlReady });
|
||
const disabled = pending || !controlReady;
|
||
for (const input of [el.m3GatewaySelect, el.m3BoxSelect, el.m3PortSelect, el.m3ValueSelect, el.m3WriteDo, el.m3ReadDi]) {
|
||
input.disabled = disabled;
|
||
}
|
||
el.m3WriteDo.textContent = pending ? "执行中" : "写入 DO1";
|
||
}
|
||
|
||
function m3ControlCanOperate(contract = state.m3Control.contract) {
|
||
return contract?.status === "available" &&
|
||
contract?.readiness?.status === "ready" &&
|
||
contract?.readiness?.controlReady === true &&
|
||
contract?.readiness?.sourceKind === "DEV-LIVE" &&
|
||
contract?.readiness?.evidenceLevel === "DEV-LIVE";
|
||
}
|
||
|
||
function m3ControlStatusTitle({ operation, contract, controlReady }) {
|
||
if (operation?.blocker?.zh) return operation.blocker.zh;
|
||
if (operation?.evidenceState?.reason) return operation.evidenceState.reason;
|
||
if (controlReady) {
|
||
return "M3 IO 只读 readiness 已确认;控制仅通过 cloud-api 受控路径执行,不直连 gateway/box-simu。";
|
||
}
|
||
return m3ControlBlockedReason(contract);
|
||
}
|
||
|
||
function m3ControlBlockedReason(contract = state.m3Control.contract) {
|
||
if (!contract) return "等待 cloud-api 返回 M3 IO 只读 readiness;未确认前控制保持阻塞。";
|
||
return contract.readiness?.blocker?.zh ??
|
||
contract.blockedReason ??
|
||
"M3 IO 只读 readiness 未 green;控制面板保持阻塞,不直连 gateway/box-simu。";
|
||
}
|
||
|
||
function controlRows() {
|
||
const operation = state.m3Control.operation;
|
||
const controlReady = m3ControlCanOperate();
|
||
const operationCards = operation
|
||
? [
|
||
{
|
||
title: `M3 ${operationActionLabel(operation.action)} ${statusLabel(operation.status)}`,
|
||
detail: m3OperationDetail(operation),
|
||
tone: operation.status === "succeeded"
|
||
? operation.evidenceState?.status === "green" ? "dev-live" : "degraded"
|
||
: operation.status === "running"
|
||
? "dry-run"
|
||
: "blocked"
|
||
}
|
||
]
|
||
: [];
|
||
return [
|
||
{
|
||
title: "控制路径",
|
||
detail: controlReady
|
||
? "按钮只走受控后端,再通过 gateway-simu -> box-simu -> hwlab-patch-panel 执行 M3 DO/DI。"
|
||
: `${m3ControlBlockedReason()} 不可用时页面不直连模拟器,也不伪造硬件状态。`,
|
||
tone: controlReady ? "source" : "blocked"
|
||
},
|
||
...operationCards,
|
||
{
|
||
title: "读取目标",
|
||
detail: `${M3_TRUSTED_ROUTE.toResourceId}:${M3_TRUSTED_ROUTE.toPort} 只能通过受控路径读取;浏览器不直连 box-simu。`,
|
||
tone: "source"
|
||
},
|
||
{
|
||
title: "Code Agent 对话",
|
||
detail: codeAgentControlSummary(state.codeAgentAvailability),
|
||
tone: currentConversationTone()
|
||
},
|
||
{
|
||
title: "只读探测",
|
||
detail: "内部复核探测保持只读,并放在二级页面。",
|
||
tone: "source"
|
||
}
|
||
];
|
||
}
|
||
|
||
function operationActionLabel(action) {
|
||
return action === "do.write" ? "DO 写入" : action === "di.read" ? "DI 读取" : "操作";
|
||
}
|
||
|
||
function m3OperationDetail(operation) {
|
||
if (operation.status === "running") {
|
||
return `trace=${operation.traceId} / 正在通过 cloud-api 提交。`;
|
||
}
|
||
const ids = [
|
||
recordField("operation", operation.operationId),
|
||
recordField("trace", operation.traceId),
|
||
recordField("audit", operation.auditId),
|
||
recordField("evidence", operation.evidenceId)
|
||
].filter(Boolean).join(" / ");
|
||
const value = operation.result?.value !== undefined && operation.result?.value !== null
|
||
? ` / value=${String(operation.result.value)}`
|
||
: "";
|
||
const blocker = operation.blocker?.zh || operation.blocker?.message
|
||
? ` / 阻塞原因=${operation.blocker.zh || operation.blocker.message}`
|
||
: "";
|
||
const evidence = operation.evidenceState?.reason ? ` / evidence=${operation.evidenceState.reason}` : "";
|
||
return `${ids}${value}${blocker}${evidence}`;
|
||
}
|
||
|
||
function renderDrafts() {
|
||
const rows = [
|
||
...state.chatMessages.slice(-3).reverse().map((message) => ({
|
||
title: `${roleLabel(message.role)} ${statusLabel(message.status)}`,
|
||
detail: message.text,
|
||
tone: message.status || "source"
|
||
})),
|
||
...(state.chatMessages.length === 0
|
||
? [
|
||
{
|
||
title: "对话状态",
|
||
detail: codeAgentPromptText(state.codeAgentAvailability),
|
||
tone: state.codeAgentAvailability?.status === "blocked" ? "blocked" : "source"
|
||
}
|
||
]
|
||
: []),
|
||
...controlRows()
|
||
];
|
||
replaceChildren(el.controlList, ...rows.map(infoCard));
|
||
}
|
||
|
||
function renderWiringList(summary = null) {
|
||
const presentation = wiringPresentationFromSummary(summary, {
|
||
liveM3Evidence: trustedM3LiveEvidenceFrom(summary)
|
||
});
|
||
|
||
const sourceHeader = byId("wiring-source-device");
|
||
const targetHeader = byId("wiring-target-device");
|
||
if (sourceHeader) sourceHeader.textContent = M3_TRUSTED_ROUTE.fromResourceId;
|
||
if (targetHeader) targetHeader.textContent = M3_TRUSTED_ROUTE.toResourceId;
|
||
|
||
const tr = document.createElement("tr");
|
||
tr.dataset.sourceKind = presentation.rowSourceKind;
|
||
tr.dataset.wiringRow = "io";
|
||
|
||
const sourceCell = cell("", "");
|
||
sourceCell.append(textSpan(M3_TRUSTED_ROUTE.fromPort, "wiring-port mono"));
|
||
sourceCell.append(textSpan(`源端口;经 ${M3_TRUSTED_ROUTE.patchPanelServiceId};状态:${presentation.stateLabel}`, "wiring-detail"));
|
||
|
||
const targetCell = cell("", "");
|
||
targetCell.append(textSpan(M3_TRUSTED_ROUTE.toPort, "wiring-port mono"));
|
||
targetCell.append(textSpan(`目标端口;证据来源:${presentation.sourceLabel};轨迹/证据:${presentation.traceEvidence}`, "wiring-detail mono wrap"));
|
||
|
||
tr.append(sourceCell, targetCell);
|
||
|
||
replaceChildren(el.wiringBody, tr);
|
||
|
||
const summaryElement = byId("wiring-summary");
|
||
if (summaryElement) {
|
||
replaceChildren(
|
||
summaryElement,
|
||
document.createTextNode(`接线盘:${M3_TRUSTED_ROUTE.patchPanelServiceId};状态:`),
|
||
badge(presentation.stateLabel, presentation.badgeTone),
|
||
document.createTextNode(`;证据来源:${presentation.sourceLabel};轨迹/证据:${presentation.traceEvidence}。`)
|
||
);
|
||
}
|
||
}
|
||
|
||
function renderRecords(live) {
|
||
const groups = [
|
||
recordGroup({
|
||
title: "Code Agent 对话记录",
|
||
meta: "trace/messageId",
|
||
cards: codeAgentRecordCards()
|
||
}),
|
||
recordGroup({
|
||
title: "接线/operation",
|
||
meta: "hwlab-patch-panel + operation",
|
||
cards: operationRecordCards()
|
||
}),
|
||
recordGroup({
|
||
title: "audit 记录",
|
||
meta: "audit.event.query + SOURCE 回退",
|
||
cards: auditRecordCards(live)
|
||
}),
|
||
recordGroup({
|
||
title: "evidence 证据",
|
||
meta: "evidence.record.query + SOURCE 回退",
|
||
cards: evidenceRecordCards(live)
|
||
})
|
||
];
|
||
|
||
replaceChildren(el.recordsList, ...groups);
|
||
}
|
||
|
||
function codeAgentRecordCards() {
|
||
const messages = state.chatMessages.filter((message) => message.traceId || message.messageId);
|
||
if (messages.length === 0) {
|
||
return [
|
||
infoCard({
|
||
title: "尚无 Code Agent 请求记录",
|
||
detail: "发送消息后会在这里显示 conversationId、traceId 和 messageId;未发送时仅显示 SOURCE 说明。",
|
||
tone: "source"
|
||
})
|
||
];
|
||
}
|
||
|
||
return messages.slice(-4).reverse().map((message) =>
|
||
infoCard({
|
||
title: `${roleLabel(message.role)} ${statusLabel(message.status)}`,
|
||
detail: [
|
||
sourceKindLabel(message.status === "failed" ? "blocked" : "source"),
|
||
recordField("conversation", state.conversationId),
|
||
recordField("trace", message.traceId),
|
||
recordField("message", message.messageId),
|
||
message.error?.message ? `失败原因=${safeFailureReason(message.error.message)}` : null
|
||
].filter(Boolean).join(" / "),
|
||
tone: message.status === "failed" ? "blocked" : "source"
|
||
})
|
||
);
|
||
}
|
||
|
||
function operationRecordCards() {
|
||
const wiring = gateSummary.topology.patchPanel;
|
||
const controlOperation = state.m3Control.operation;
|
||
const controlCards = controlOperation
|
||
? [
|
||
infoCard({
|
||
title: controlOperation.operationId ?? controlOperation.traceId ?? "m3-control-operation",
|
||
detail: [
|
||
sourceKindLabel(controlOperation.evidenceState?.status === "green" ? "dev-live" : "blocked_after_cloud_api"),
|
||
"via=/v1/m3/io",
|
||
recordField("action", controlOperation.action),
|
||
recordField("trace", controlOperation.traceId),
|
||
recordField("audit", controlOperation.auditId),
|
||
recordField("evidence", controlOperation.evidenceId),
|
||
controlOperation.controlPath?.frontendBypass === false ? "frontendBypass=false" : null,
|
||
controlOperation.evidenceState?.reason
|
||
].filter(Boolean).join(" / "),
|
||
tone: controlOperation.status === "succeeded"
|
||
? controlOperation.evidenceState?.status === "green" ? "dev-live" : "degraded"
|
||
: "blocked"
|
||
})
|
||
]
|
||
: [];
|
||
const wiringCards = wiring.activeConnections.map((link) =>
|
||
infoCard({
|
||
title: `${link.fromResourceId}:${link.fromPort} -> ${link.toResourceId}:${link.toPort}`,
|
||
detail: [
|
||
sourceKindLabel("source"),
|
||
recordField("patch-panel", wiring.serviceId),
|
||
`状态=${statusLabel(wiring.state)}`,
|
||
recordField("statusId", wiring.patchPanelStatusId)
|
||
].filter(Boolean).join(" / "),
|
||
tone: "source"
|
||
})
|
||
);
|
||
const operationCards = gateSummary.operations.map((operation) =>
|
||
infoCard({
|
||
title: operation.operationId,
|
||
detail: [
|
||
sourceKindLabel(operation.dryRun ? "dry-run" : "source"),
|
||
`状态=${statusLabel(operation.status)}`,
|
||
recordField("resource", operation.resourceId),
|
||
recordField("capability", operation.capabilityId),
|
||
recordField("worker", operation.workerSessionId)
|
||
].filter(Boolean).join(" / "),
|
||
tone: operation.dryRun ? "dry-run" : "source"
|
||
})
|
||
);
|
||
return [...controlCards, ...wiringCards, ...operationCards];
|
||
}
|
||
|
||
function auditRecordCards(live) {
|
||
const cards = [];
|
||
if (state.m3Control.operation?.auditEvent) {
|
||
cards.push(auditCard(
|
||
state.m3Control.operation.auditEvent,
|
||
state.m3Control.operation.evidenceState?.status === "green" ? "dev-live" : "blocked",
|
||
state.m3Control.operation.evidenceState?.status === "green" ? null : state.m3Control.operation.evidenceState?.reason
|
||
));
|
||
}
|
||
if (live) {
|
||
if (live.audit.ok) {
|
||
const liveEvents = live.audit.data?.events ?? [];
|
||
if (liveEvents.length === 0) {
|
||
cards.push(infoCard({
|
||
title: "audit.event.query",
|
||
detail: "只读查询成功但无 M3 trusted loop audit;下方显示 SOURCE/DRY-RUN 回退摘要,不能冒充 DEV-LIVE。",
|
||
tone: "source"
|
||
}));
|
||
} else {
|
||
cards.push(...liveEvents.map((event) => auditCard(event, liveRecordTone(event, live), liveRecordDurabilityBlocker(live))));
|
||
}
|
||
} else {
|
||
cards.push(liveFailureCard("audit.event.query", live.audit.error));
|
||
}
|
||
} else {
|
||
cards.push(infoCard({
|
||
title: "audit.event.query",
|
||
detail: "等待同源只读查询;未完成前显示 SOURCE/DRY-RUN 回退摘要。",
|
||
tone: "source"
|
||
}));
|
||
}
|
||
|
||
cards.push(...gateSummary.auditEvents.slice(0, 4).map((event) => auditCard(event, event.dryRun ? "dry-run" : "source")));
|
||
return cards;
|
||
}
|
||
|
||
function evidenceRecordCards(live) {
|
||
const cards = [];
|
||
if (state.m3Control.operation?.evidenceRecord) {
|
||
cards.push(evidenceCard(
|
||
state.m3Control.operation.evidenceRecord,
|
||
state.m3Control.operation.evidenceState?.status === "green" ? "dev-live" : "blocked",
|
||
state.m3Control.operation.evidenceState?.status === "green" ? null : state.m3Control.operation.evidenceState?.reason
|
||
));
|
||
}
|
||
if (live) {
|
||
if (live.evidence.ok) {
|
||
const liveRecords = live.evidence.data?.records ?? [];
|
||
if (liveRecords.length === 0) {
|
||
cards.push(infoCard({
|
||
title: "evidence.record.query",
|
||
detail: "只读查询成功但无 M3 trusted loop evidence;下方显示 SOURCE/DRY-RUN 回退摘要,不能冒充 DEV-LIVE。",
|
||
tone: "source"
|
||
}));
|
||
} else {
|
||
cards.push(...liveRecords.map((record) => evidenceCard(record, liveRecordTone(record, live), liveRecordDurabilityBlocker(live))));
|
||
}
|
||
} else {
|
||
cards.push(liveFailureCard("evidence.record.query", live.evidence.error));
|
||
}
|
||
} else {
|
||
cards.push(infoCard({
|
||
title: "evidence.record.query",
|
||
detail: "等待同源只读查询;未完成前显示 SOURCE/DRY-RUN 回退摘要。",
|
||
tone: "source"
|
||
}));
|
||
}
|
||
|
||
cards.push(...gateSummary.evidenceRecords.map((record) => evidenceCard(record, record.dryRun ? "dry-run" : "source")));
|
||
return cards;
|
||
}
|
||
|
||
function auditCard(event, tone, durabilityBlocker = null) {
|
||
return infoCard({
|
||
title: event.auditId,
|
||
detail: [
|
||
sourceKindLabel(tone),
|
||
durabilityBlocker,
|
||
tone === "blocked" ? "只读 audit 缺少 M3 patch-panel live report / operation / trace / evidence 绑定" : null,
|
||
recordField("action", event.action),
|
||
recordField("target", event.targetId),
|
||
recordField("operation", event.operationId),
|
||
recordField("trace", event.traceId),
|
||
recordField("service", event.serviceId)
|
||
].filter(Boolean).join(" / "),
|
||
tone
|
||
});
|
||
}
|
||
|
||
function evidenceCard(record, tone, durabilityBlocker = null) {
|
||
return infoCard({
|
||
title: record.evidenceId,
|
||
detail: [
|
||
sourceKindLabel(tone),
|
||
durabilityBlocker,
|
||
tone === "blocked" ? "只读 evidence 缺少 M3 patch-panel live report / operation / trace / audit 绑定" : null,
|
||
recordKindLabel(record.kind ?? "record"),
|
||
recordField("operation", record.operationId),
|
||
recordField("trace", record.traceId ?? record.metadata?.traceId),
|
||
recordField("service", record.serviceId)
|
||
].filter(Boolean).join(" / "),
|
||
tone
|
||
});
|
||
}
|
||
|
||
function liveFailureCard(title, reason) {
|
||
return infoCard({
|
||
title,
|
||
detail: `阻塞 BLOCKED:开发实况查询失败,失败原因:${reason || "未知错误"};当前显示 SOURCE 回退,不能冒充 DEV-LIVE。`,
|
||
tone: "blocked"
|
||
});
|
||
}
|
||
|
||
function recordGroup({ title, meta, cards }) {
|
||
const section = document.createElement("section");
|
||
section.className = "record-group";
|
||
const header = document.createElement("div");
|
||
header.className = "record-group-head";
|
||
header.append(textSpan(title, "record-group-title"), textSpan(meta, "record-group-meta"));
|
||
const body = document.createElement("div");
|
||
body.className = "record-group-list";
|
||
body.append(...cards);
|
||
section.append(header, body);
|
||
return section;
|
||
}
|
||
|
||
function recordField(label, value) {
|
||
if (value === undefined || value === null || value === "") return null;
|
||
return `${label}=${value}`;
|
||
}
|
||
|
||
const sensitiveFailureReasonNeedles = Object.freeze([
|
||
["OPENAI", "API", "KEY"].join("_"),
|
||
["hwlab", "code", "agent", "provider"].join("-"),
|
||
["Se", "cret"].join(""),
|
||
["secret", "Ref"].join("")
|
||
]);
|
||
|
||
function safeFailureReason(value) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return "未知错误";
|
||
const lowerText = text.toLowerCase();
|
||
if (sensitiveFailureReasonNeedles.some((needle) => lowerText.includes(needle.toLowerCase()))) {
|
||
return "服务暂不可用";
|
||
}
|
||
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
||
}
|
||
|
||
function sourceKindLabel(tone) {
|
||
return statusLabel(tone);
|
||
}
|
||
|
||
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: "发送失败",
|
||
blocked: "服务受阻"
|
||
};
|
||
el.agentChatStatus.textContent = agentStatusLabel(status, result, labels);
|
||
el.agentChatStatus.className = `state-tag tone-${toneClass(status === "completed" ? "dev-live" : status === "failed" || status === "blocked" ? "blocked" : "source")}`;
|
||
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("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 受阻",
|
||
runner_blocked: "Runner 受阻",
|
||
api_error: "API 错误",
|
||
needs_config: "需要配置",
|
||
capability_unavailable: "能力未开放",
|
||
security_blocked: "安全阻断",
|
||
fallback: "仍是 fallback",
|
||
retryable: "可重试"
|
||
}[presentation.category] ?? labels.failed
|
||
);
|
||
}
|
||
|
||
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) {
|
||
if (isStructuredBlockedChatResult(result)) {
|
||
return {
|
||
status: "failed",
|
||
replied: false,
|
||
sourceKind: "BLOCKED",
|
||
title: agentFailurePresentation(structuredBlockedErrorFromResult(result), { 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");
|
||
return {
|
||
...error,
|
||
code,
|
||
layer: error.layer ?? blocker.layer,
|
||
category: error.category ?? blocker.category,
|
||
blocker,
|
||
retryable: error.retryable ?? blocker.retryable,
|
||
userMessage: error.userMessage ?? blocker.userMessage,
|
||
message: error.message ?? blocker.summary ?? code,
|
||
traceId: error.traceId ?? result?.traceId,
|
||
route: error.route ?? blocker.route,
|
||
toolName: error.toolName ?? blocker.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.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 providerStatus = error?.providerStatus ?? result?.error?.providerStatus;
|
||
|
||
if (isTimeoutFailure(code, message)) {
|
||
return {
|
||
category: "timeout",
|
||
title: "Code Agent 等待超时",
|
||
text: `Code Agent 请求${timeoutMs ? `超过 ${timeoutMs}ms ` : ""}未完成;输入已保留,可稍后重试或缩小问题范围。${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: "Code Agent Runner 忙碌",
|
||
text: `受控只读 runner 正在处理上一轮请求;输入已保留,稍后重试即可。${traceSuffix}`
|
||
};
|
||
}
|
||
|
||
if (["session_expired", "session_reuse_conflict"].includes(code)) {
|
||
return {
|
||
category: "session_blocked",
|
||
title: "Code Agent Session 受阻",
|
||
text: `当前 session 已过期或与 conversation 绑定不一致;输入已保留,可重新发送建立新的受控只读 session。${traceSuffix}`
|
||
};
|
||
}
|
||
|
||
if (["runner_unavailable", "tool_unavailable", "skills_unavailable", "security_blocked"].includes(code)) {
|
||
return {
|
||
category: "runner_blocked",
|
||
title: "Code Agent Runner 受阻",
|
||
text: `受控只读 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 === "runner_busy") return "Code Agent Runner 忙碌";
|
||
if (value === "session_blocked") return "Code Agent Session 受阻";
|
||
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"].includes(code) ||
|
||
/\b(?:timeout|timed out|abort|aborted|超时|请求超过)\b/iu.test(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(textSpan(message.text, "message-copy"));
|
||
const capabilityFacts = codeAgentCapabilityFactsPanel(message);
|
||
if (capabilityFacts) article.append(capabilityFacts);
|
||
const messageEvidence = messageEvidencePanel(message);
|
||
if (messageEvidence) article.append(messageEvidence);
|
||
return article;
|
||
}
|
||
|
||
function codeAgentCapabilityFactsPanel(message) {
|
||
const facts = codeAgentFactsFromMessage(message);
|
||
if (!facts) return null;
|
||
const section = document.createElement("section");
|
||
section.className = `code-agent-facts tone-border-${toneClass(facts.tone)}`;
|
||
section.dataset.codeAgentFactKind = facts.kind;
|
||
section.append(codeAgentFactsHeader(facts));
|
||
|
||
const grid = document.createElement("dl");
|
||
grid.className = "code-agent-fact-grid";
|
||
for (const row of facts.rows) {
|
||
const item = document.createElement("div");
|
||
item.className = "code-agent-fact-row";
|
||
item.append(textSpan(row.label, "code-agent-fact-key"), textSpan(row.value ?? "none", "code-agent-fact-value mono"));
|
||
grid.append(item);
|
||
}
|
||
section.append(grid);
|
||
|
||
if (facts.hwlabApiFacts.length > 0) {
|
||
const apiList = document.createElement("div");
|
||
apiList.className = "hwlab-api-facts";
|
||
apiList.append(textSpan("HWLAB API", "hwlab-api-facts-title"));
|
||
for (const fact of facts.hwlabApiFacts) {
|
||
const code = document.createElement("code");
|
||
code.className = "hwlab-api-fact-code";
|
||
code.textContent = compactHwlabApiFact(fact);
|
||
apiList.append(code);
|
||
}
|
||
section.append(apiList);
|
||
}
|
||
|
||
return section;
|
||
}
|
||
|
||
function codeAgentFactsHeader(facts) {
|
||
const header = document.createElement("div");
|
||
header.className = "code-agent-facts-head";
|
||
header.append(badge(facts.statusLabel, facts.tone));
|
||
header.append(textSpan(facts.summary, "code-agent-facts-summary"));
|
||
return header;
|
||
}
|
||
|
||
function messageEvidencePanel(message) {
|
||
const fields = messageEvidenceFields(message).map(boundedEvidenceField);
|
||
if (fields.length === 0) return null;
|
||
const details = document.createElement("details");
|
||
details.className = "message-evidence";
|
||
details.open = message.status === "completed" || message.status === "source";
|
||
const summary = document.createElement("summary");
|
||
summary.className = "message-meta";
|
||
summary.textContent = messageEvidenceSummary(message, fields);
|
||
const chips = document.createElement("div");
|
||
chips.className = "message-evidence-chips";
|
||
chips.append(...fields.map((field) => textSpan(field, "message-evidence-chip")));
|
||
details.append(summary, chips);
|
||
return details;
|
||
}
|
||
|
||
function messageEvidenceSummary(message, fields) {
|
||
const sourceKind = message.sourceKind ?? (message.status === "completed" ? "DEV-LIVE" : message.status === "failed" ? "BLOCKED" : "SOURCE");
|
||
return `证据 ${sourceKind} / ${fields.slice(0, 3).join(" / ")}`;
|
||
}
|
||
|
||
function messageEvidenceFields(message) {
|
||
return [
|
||
recordField("source", message.sourceKind),
|
||
recordField("provider", message.provider),
|
||
recordField("model", message.model),
|
||
recordField("backend", message.backend),
|
||
recordField("runner", message.runner?.kind),
|
||
recordField("workspace", message.workspace),
|
||
recordField("sandbox", message.sandbox),
|
||
recordField("capability", message.capabilityLevel),
|
||
recordField("session", sessionSummary(message.session)),
|
||
recordField("sessionMode", message.sessionMode),
|
||
recordField("sessionReuse", sessionReuseSummary(message.sessionReuse)),
|
||
recordField("implementation", message.implementationType),
|
||
recordField("limitations", limitationsSummary(message.runnerLimitations)),
|
||
recordField("codexStdio", codexStdioFeasibilitySummary(message.codexStdioFeasibility)),
|
||
recordField("longLivedGate", longLivedSessionGateSummary(message.longLivedSessionGate)),
|
||
recordField("toolCalls", toolCallsSummary(message.toolCalls)),
|
||
recordField("skills", skillsSummary(message.skills)),
|
||
recordField("runnerTrace", runnerTraceSummary(message.runnerTrace)),
|
||
recordField("conversation", message.conversationId),
|
||
recordField("trace", message.traceId),
|
||
recordField("message", message.messageId),
|
||
providerTraceSummary(message.providerTrace)
|
||
].filter(Boolean);
|
||
}
|
||
|
||
function boundedEvidenceField(field) {
|
||
const text = String(field ?? "").replace(/\s+/gu, " ").trim();
|
||
return text.length > 96 ? `${text.slice(0, 93)}...` : text;
|
||
}
|
||
|
||
function providerTraceSummary(providerTrace) {
|
||
if (!providerTrace || typeof providerTrace !== "object") return null;
|
||
const responseId = providerTrace.responseId ?? providerTrace.id;
|
||
if (responseId) return recordField("providerTrace", responseId);
|
||
if (providerTrace.command) return "providerTrace=codex-cli";
|
||
return null;
|
||
}
|
||
|
||
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 sessionReuseSummary(sessionReuse) {
|
||
if (!sessionReuse || typeof sessionReuse !== "object") return null;
|
||
const state = sessionReuse.reused ? "reused" : "new";
|
||
const status = sessionReuse.status ? `:${sessionReuse.status}` : "";
|
||
return `${state}:turn${sessionReuse.turn ?? "?"}${status}`;
|
||
}
|
||
|
||
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.lastTraceId ? `lastTrace=${session.lastTraceId}` : null
|
||
].filter(Boolean);
|
||
return parts.join(":");
|
||
}
|
||
|
||
function limitationsSummary(limitations) {
|
||
if (!Array.isArray(limitations) || limitations.length === 0) return null;
|
||
return limitations.slice(0, 3).join(",");
|
||
}
|
||
|
||
function longLivedSessionGateSummary(gate) {
|
||
if (!gate || typeof gate !== "object") return null;
|
||
const status = gate.status ?? "unknown";
|
||
const blockers = Array.isArray(gate.blockers)
|
||
? gate.blockers.map((blocker) => blocker?.code).filter(Boolean).slice(0, 2).join(",")
|
||
: "";
|
||
return blockers ? `${status}:${blockers}` : status;
|
||
}
|
||
|
||
function codexStdioFeasibilitySummary(feasibility) {
|
||
if (!feasibility || typeof feasibility !== "object") return null;
|
||
const blockers = Array.isArray(feasibility.blockers) ? feasibility.blockers.length : 0;
|
||
return `${feasibility.status ?? "unknown"}:${blockers}`;
|
||
}
|
||
|
||
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 statusLight(tone) {
|
||
const span = document.createElement("span");
|
||
span.className = `tree-light tone-bg-${toneClass(tone)}`;
|
||
return span;
|
||
}
|
||
|
||
function badge(text, tone = text) {
|
||
const span = document.createElement("span");
|
||
span.className = `badge tone-${toneClass(tone)}`;
|
||
span.textContent = text ?? "无";
|
||
return span;
|
||
}
|
||
|
||
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 状态:只读 session registry 可用,Codex stdio 受阻",
|
||
text: `pwd、skills、ls、rg --files 和 cat 会走 controlled-readonly-session-registry;它不是完整 Code Agent。${codeAgentBlockerDetail(availability)}`,
|
||
status: "source"
|
||
};
|
||
}
|
||
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 状态:等待只读探测",
|
||
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 `输入区会调用受控接口;当前只有只读 registry 或 text fallback,不能标成完整 Code Agent。${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 = Array.isArray(availability?.blockers)
|
||
? availability.blockers.map((blocker) => blocker?.code).filter(Boolean)
|
||
: Array.isArray(availability?.codexStdioFeasibility?.blockers)
|
||
? availability.codexStdioFeasibility.blockers.map((blocker) => blocker?.code).filter(Boolean)
|
||
: [];
|
||
if (blockers.length === 0 && availability?.reason) blockers.push(availability.reason);
|
||
return blockers.length > 0 ? ` blocker=${blockers.slice(0, 3).join(",")}。` : "";
|
||
}
|
||
|
||
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 可响应;pwd/skills/ls/rg/cat 可走受控只读 session registry,但它不是 Codex stdio、不可写、非持久 session。${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 (isReadOnlyRunnerPartialEvidence(value) || isTextFallbackChatResult(value)) {
|
||
return false;
|
||
}
|
||
return (
|
||
isTrustedCodeAgentProvider(value?.provider) &&
|
||
Boolean(value?.model) &&
|
||
Boolean(value?.backend) &&
|
||
Boolean(value?.traceId) &&
|
||
Boolean(value?.conversationId || value?.sessionId) &&
|
||
!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-mcp-stdio-runner" &&
|
||
value?.capabilityLevel === "long-lived-codex-stdio-session" &&
|
||
value?.session?.status === "idle" &&
|
||
typeof value?.session?.idleTimeoutMs === "number" &&
|
||
Boolean(value?.session?.lastTraceId) &&
|
||
value?.sessionMode === "codex-mcp-stdio-long-lived" &&
|
||
value?.implementationType === "repo-owned-codex-mcp-stdio-session" &&
|
||
Boolean(value?.sessionReuse) &&
|
||
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) &&
|
||
(value?.sandbox || value?.runner?.sandbox) === "workspace-write" &&
|
||
Array.isArray(value?.toolCalls) &&
|
||
value.toolCalls.some((tool) => tool?.status === "completed") &&
|
||
value?.longLivedSessionGate?.status === "pass" &&
|
||
Boolean(value?.runnerTrace) &&
|
||
Boolean(value?.skills)
|
||
);
|
||
}
|
||
|
||
function isReadOnlyRunnerPartialEvidence(value) {
|
||
return (
|
||
CODEX_READONLY_PARTIAL_PROVIDERS.includes(String(value?.provider ?? "").trim().toLowerCase()) &&
|
||
value?.runner?.kind === "hwlab-readonly-runner" &&
|
||
(value?.capabilityLevel === "read-only-session-tools" || value?.capabilityLevel === "read-only-tools") &&
|
||
value?.sessionMode === "controlled-readonly-session-registry" &&
|
||
value?.implementationType === "controlled-readonly-session-registry" &&
|
||
value?.runner?.codexStdio === false &&
|
||
value?.runner?.writeCapable === false &&
|
||
value?.runner?.durableSession === false &&
|
||
value?.longLivedSessionGate?.status === "blocked"
|
||
);
|
||
}
|
||
|
||
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 === "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" || latestChatResult()?.status === "failed") 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 resourceTypeLabel(type) {
|
||
return (
|
||
{
|
||
board: "板卡",
|
||
relay: "继电器"
|
||
}[String(type ?? "").toLowerCase()] ?? statusLabel(type)
|
||
);
|
||
}
|
||
|
||
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"));
|
||
}
|