diff --git a/apps/web-console/app.js b/apps/web-console/app.js
index 6df02e3..5d62da1 100755
--- a/apps/web-console/app.js
+++ b/apps/web-console/app.js
@@ -10,6 +10,20 @@ function loadConversationId() {
return conversationId;
}
+function loadHardwareClientId() {
+ const key = "cognitive-agent.hardware-client-id";
+ const stored = localStorage.getItem(key);
+ if (/^hwc-[a-z0-9-]{8,80}$/.test(stored || "")) return stored;
+ const clientId = `hwc-${crypto.randomUUID()}`;
+ localStorage.setItem(key, clientId);
+ return clientId;
+}
+
+function defaultHardwareClientLabel(clientId) {
+ return localStorage.getItem("cognitive-agent.hardware-client-label")
+ || `测试终端-${clientId.slice(-6)}`;
+}
+
const state = {
provider: "openai",
executionMode: "dry_run",
@@ -23,6 +37,9 @@ const state = {
seatFeedbackSource: "simulated",
availableSeatAxes: null,
connectionStatus: window.CognitiveAgentNetwork?.getState?.().status || "connecting",
+ hardwareClientId: loadHardwareClientId(),
+ hardwareAccess: null,
+ hardwareAccessPoller: null,
};
const $ = (selector, root = document) => root.querySelector(selector);
@@ -2509,6 +2526,89 @@ function showToast(message) {
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
}
+function isLocalDecisionHost() {
+ return ["127.0.0.1", "localhost", "::1"].includes(window.location.hostname);
+}
+
+function renderHardwareAccess(status) {
+ state.hardwareAccess = status;
+ const statusNode = $("#hardwareAccessStatus");
+ const requestButton = $("#requestHardwareAccess");
+ const releaseButton = $("#releaseHardwareAccess");
+ if (!statusNode || !requestButton || !releaseButton) return;
+ const accessState = status?.state || "none";
+ statusNode.dataset.state = accessState;
+ if (accessState === "approved") {
+ statusNode.textContent = `本地已同意。剩余空闲时间 ${status.idle_remaining_seconds ?? 180} 秒;每次真实执行后重新计时。`;
+ requestButton.hidden = true;
+ releaseButton.hidden = false;
+ } else if (accessState === "pending") {
+ statusNode.textContent = `正在等待本地审批,当前排队第 ${status.queue_position || 1} 位;未获批前不会进入 CAN 链路。`;
+ requestButton.textContent = "更新申请";
+ requestButton.hidden = false;
+ releaseButton.hidden = false;
+ } else if (accessState === "expired") {
+ statusNode.textContent = "因 3 分钟未执行测试,使用权已自动停止;再次使用请重新申请。";
+ requestButton.textContent = "重新申请";
+ requestButton.hidden = false;
+ releaseButton.hidden = true;
+ } else {
+ statusNode.textContent = "尚未申请;真实台架指令不会进入 CAN 链路。";
+ requestButton.textContent = "向本地申请";
+ requestButton.hidden = false;
+ releaseButton.hidden = true;
+ }
+}
+
+async function loadHardwareAccessStatus({ silent = false } = {}) {
+ try {
+ const query = new URLSearchParams({ client_id: state.hardwareClientId });
+ const response = await apiRequest(`/api/hardware/access/status?${query}`, { cache: "no-store" });
+ if (response.status === 401) return;
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.message || payload.error);
+ renderHardwareAccess(payload);
+ } catch (error) {
+ if (!silent) showToast(`使用权状态读取失败:${error.message}`);
+ }
+}
+
+async function requestHardwareAccess() {
+ const input = $("#hardwareClientLabel");
+ const label = input.value.trim() || defaultHardwareClientLabel(state.hardwareClientId);
+ input.value = label;
+ localStorage.setItem("cognitive-agent.hardware-client-label", label);
+ try {
+ const response = await apiRequest("/api/hardware/access/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ client_id: state.hardwareClientId, label }),
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.message || payload.error);
+ renderHardwareAccess(payload);
+ showToast(payload.state === "approved" ? "当前使用权仍然有效" : "申请已发送到台架本机,请等待审批");
+ } catch (error) {
+ showToast(`申请失败:${error.message}`);
+ }
+}
+
+async function releaseHardwareAccess() {
+ try {
+ const response = await apiRequest("/api/hardware/access/release", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ client_id: state.hardwareClientId }),
+ });
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.message || payload.error);
+ renderHardwareAccess(payload);
+ showToast("已停止使用真实台架;再次使用需要重新申请");
+ } catch (error) {
+ showToast(`停止使用失败:${error.message}`);
+ }
+}
+
function selectProvider(provider) {
state.provider = provider;
$$(".segment[data-provider]").forEach((button) => {
@@ -2551,6 +2651,7 @@ function selectExecutionMode(mode) {
const simulatedHardware = mode === "simulated_hardware";
const approvalRequired = Boolean(state.runtime?.hardware?.approval_required);
$("#approvalField").hidden = !hardware || !approvalRequired;
+ $("#hardwareAccessField").hidden = !hardware;
$("#simulationFaultField").hidden = !simulatedHardware;
$("#executionBadge").className = `status-badge ${hardware ? "" : simulatedHardware ? "warning" : "neutral"}`.trim();
$("#executionBadge").innerHTML = hardware
@@ -2791,10 +2892,13 @@ async function loadRuntime() {
const flashText = hardware.auto_flash_enabled ? "自动烧录已启用" : "自动烧录未启用";
const approvalText = hardware.approval_required ? "需授权令牌" : "调试免令牌";
const concurrencyText = hardware.concurrency_policy === "single_flight_fail_fast"
- ? " · 单请求独占"
+ ? " · 动作防冲突"
+ : "";
+ const accessText = hardware.access_policy === "local_approval_idle_lease"
+ ? ` · 本地审批 · 空闲 ${hardware.access_inactivity_timeout_seconds || 180} 秒自动停止`
: "";
$("#hardwareNote").textContent = hardware.configured
- ? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}${concurrencyText}`
+ ? `${hardware.backend} · ${hardware.bitrate / 1000} kbit/s · ${profileText} · ${flashText} · ${approvalText}${accessText}${concurrencyText}`
: `真实执行已锁定:${profileText};请完成服务端启用、适配器、固件镜像${hardware.approval_required ? "和令牌" : ""}配置`;
$("#hardwareAliasNote").textContent = "副驾座椅 → t2g_primary_seat(统一电机、加热和通风执行器)";
$("#autoFlashNote").textContent = hardware.auto_flash_enabled
@@ -2805,7 +2909,7 @@ async function loadRuntime() {
? '硬件已配置'
: '硬件锁定';
$("#hardwareCoverage").textContent = hardware.configured
- ? `可选 · ${approvalText}${concurrencyText}`
+ ? `可选 · ${approvalText}${accessText}${concurrencyText}`
: "已阻断";
$("#hardwareCoverage").className = hardware.configured ? "available" : "blocked";
const modeAvailable = state.executionMode === "hardware"
@@ -2828,6 +2932,7 @@ async function synchronizePhysicalSeatState() {
const query = new URLSearchParams({
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime.default_vehicle_id || "",
+ hardware_client_id: state.hardwareClientId,
});
try {
const response = await apiRequest(`/api/hardware/seat-state?${query}`, {
@@ -3647,6 +3752,14 @@ async function submitRequest(event) {
showToast("请输入车舱控制请求");
return;
}
+ if (state.executionMode === "hardware"
+ && !isLocalDecisionHost()
+ && state.hardwareAccess?.state !== "approved") {
+ showToast(state.hardwareAccess?.state === "pending"
+ ? "申请仍在等待本地审批"
+ : "请先申请并获得本地真实台架使用权");
+ return;
+ }
const button = $("#runButton");
button.disabled = true;
button.classList.add("loading");
@@ -3674,6 +3787,9 @@ async function submitRequest(event) {
conversation_id: state.conversationId,
vehicle_id: state.vehicleId || state.runtime?.default_vehicle_id,
execution_mode: state.executionMode,
+ ...(state.executionMode === "hardware"
+ ? { hardware_client_id: state.hardwareClientId }
+ : {}),
...(state.executionMode === "simulated_hardware"
? { simulation_fault: $("#simulationFault").value }
: {}),
@@ -3711,7 +3827,12 @@ async function submitRequest(event) {
} catch (error) {
window.clearInterval(state.progressTimer);
resetPipeline();
- if (error.code === "hardware_busy") {
+ if (error.code === "hardware_access_required") {
+ setRunStatus("failed", "ACCESS REQUIRED");
+ $("#resultTitle").textContent = "真实台架使用权无效";
+ await loadHardwareAccessStatus({ silent: true });
+ showToast(error.message);
+ } else if (error.code === "hardware_busy") {
setRunStatus("failed", "BENCH BUSY");
$("#resultTitle").textContent = "真实台架正在使用";
const retryHint = error.retryAfter > 0 ? ` 建议 ${error.retryAfter} 秒后重试。` : "";
@@ -3833,6 +3954,9 @@ document.addEventListener("DOMContentLoaded", async () => {
refreshIcons();
initSeatTwin();
$("#requestForm").addEventListener("submit", submitRequest);
+ $("#hardwareClientLabel").value = defaultHardwareClientLabel(state.hardwareClientId);
+ $("#requestHardwareAccess").addEventListener("click", requestHardwareAccess);
+ $("#releaseHardwareAccess").addEventListener("click", releaseHardwareAccess);
$("#utterance").addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
@@ -3867,6 +3991,11 @@ document.addEventListener("DOMContentLoaded", async () => {
$$('[data-download]').forEach((button) => button.addEventListener("click", () => downloadArtifact(button.dataset.download)));
await loadAuthStatus();
await loadRuntime();
+ await loadHardwareAccessStatus({ silent: true });
+ state.hardwareAccessPoller = window.setInterval(
+ () => loadHardwareAccessStatus({ silent: true }),
+ 3000,
+ );
await synchronizePhysicalSeatState();
if (new URLSearchParams(window.location.search).get("autorun") === "1") {
$("#requestForm").requestSubmit();
diff --git a/apps/web-console/hardware-control.css b/apps/web-console/hardware-control.css
new file mode 100644
index 0000000..a3b476a
--- /dev/null
+++ b/apps/web-console/hardware-control.css
@@ -0,0 +1,39 @@
+:root { color-scheme: dark; font-family: Inter, "Microsoft YaHei", system-ui, sans-serif; background: #070b12; color: #edf4ff; }
+* { box-sizing: border-box; }
+body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 18% 0%, #17294b 0, transparent 35%), #070b12; }
+.shell { width: min(1040px, calc(100% - 40px)); margin: 0 auto; padding: 54px 0 80px; }
+header, .section-title, .policy, .request, .lease-details { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
+header { margin-bottom: 34px; }
+h1, h2, p { margin: 0; }
+h1 { font-size: clamp(30px, 5vw, 48px); letter-spacing: -.04em; }
+h2 { font-size: 22px; }
+.eyebrow { color: #65a8ff; font-size: 12px; letter-spacing: .16em; font-weight: 800; margin-bottom: 8px; }
+.lead, small { color: #8d9caf; }
+.lead { margin-top: 12px; }
+a, button { font: inherit; }
+a { color: #cfe4ff; text-decoration: none; border: 1px solid #2b3a50; border-radius: 10px; padding: 10px 14px; }
+.policy, .card { background: rgba(15, 23, 36, .88); border: 1px solid #26354a; border-radius: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.22); }
+.policy { padding: 18px 22px; justify-content: flex-start; margin-bottom: 18px; }
+.policy div { display: grid; gap: 3px; flex: 1; }
+.dot { width: 10px; height: 10px; border-radius: 50%; background: #41d390; box-shadow: 0 0 16px #41d390; }
+#clock { color: #8d9caf; font-variant-numeric: tabular-nums; }
+.card { padding: 24px; margin-top: 18px; }
+.active-card { border-color: #315a86; }
+.section-title { margin-bottom: 20px; }
+.count { min-width: 30px; height: 30px; display: grid; place-items: center; border-radius: 999px; background: #1a2d48; color: #7fbbff; font-weight: 800; }
+.empty { padding: 26px 4px; color: #74849a; text-align: center; }
+.request-list { display: grid; gap: 12px; }
+.request { padding: 16px; border-radius: 13px; border: 1px solid #26364d; background: #0b121e; }
+.identity { display: grid; gap: 5px; min-width: 0; }
+.identity strong { font-size: 17px; }
+.identity code, .identity span { color: #8394aa; font-size: 12px; overflow-wrap: anywhere; }
+.actions { display: flex; gap: 8px; }
+button { color: #f4f8ff; background: #1577db; border: 0; border-radius: 9px; padding: 10px 14px; cursor: pointer; font-weight: 750; }
+button:hover { filter: brightness(1.12); }
+button:disabled { opacity: .45; cursor: wait; }
+button.secondary { background: #243348; }
+button.danger { background: #b83449; }
+.lease-details { justify-content: flex-start; flex-wrap: wrap; padding: 18px; border-radius: 13px; background: #0b121e; border: 1px solid #284f78; }
+.lease-details .timer { margin-left: auto; color: #4ce0a0; font-weight: 850; font-size: 22px; font-variant-numeric: tabular-nums; }
+.message { margin-top: 18px; min-height: 24px; color: #7fbbff; }
+@media (max-width: 680px) { .shell { width: min(100% - 24px, 1040px); padding-top: 26px; } header, .request, .lease-details { align-items: flex-start; flex-direction: column; } .actions { width: 100%; } .actions button { flex: 1; } .lease-details .timer { margin-left: 0; } }
diff --git a/apps/web-console/hardware-control.html b/apps/web-console/hardware-control.html
new file mode 100644
index 0000000..d584c52
--- /dev/null
+++ b/apps/web-console/hardware-control.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ 真实台架本地决策台
+
+
+
+
+
+
+
+
+ 单终端租约获批终端可连续测试;最后一次真实执行后空闲 180 秒自动停止。
+ 正在连接…
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web-console/hardware-control.js b/apps/web-console/hardware-control.js
new file mode 100644
index 0000000..d586c94
--- /dev/null
+++ b/apps/web-console/hardware-control.js
@@ -0,0 +1,104 @@
+const activeLease = document.querySelector("#activeLease");
+const pendingRequests = document.querySelector("#pendingRequests");
+const pendingCount = document.querySelector("#pendingCount");
+const revokeButton = document.querySelector("#revokeButton");
+const message = document.querySelector("#message");
+let snapshot = null;
+
+function escapeHtml(value) {
+ const node = document.createElement("span");
+ node.textContent = String(value ?? "");
+ return node.innerHTML;
+}
+
+function showMessage(value, error = false) {
+ message.textContent = value;
+ message.style.color = error ? "#ff8797" : "#7fbbff";
+}
+
+async function api(path, body) {
+ const response = await fetch(path, {
+ method: body ? "POST" : "GET",
+ credentials: "same-origin",
+ headers: body ? { "Content-Type": "application/json" } : {},
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ if (response.status === 401) {
+ location.replace(`/login?next=${encodeURIComponent(location.pathname)}`);
+ throw new Error("请先登录本机服务");
+ }
+ const payload = await response.json();
+ if (!response.ok) throw new Error(payload.message || payload.error);
+ return payload;
+}
+
+function render(data) {
+ snapshot = data;
+ pendingCount.textContent = String(data.pending.length);
+ if (data.active) {
+ const lease = data.active;
+ activeLease.className = "lease-details";
+ activeLease.innerHTML = `
+ ${escapeHtml(lease.label)}${escapeHtml(lease.client_id)}来源 ${escapeHtml(lease.client_address)} · 最近执行 ${escapeHtml(lease.last_activity_at)}
+ ${Number(lease.idle_remaining_seconds)}s
`;
+ revokeButton.hidden = false;
+ revokeButton.dataset.clientId = lease.client_id;
+ } else {
+ activeLease.className = "empty";
+ activeLease.textContent = "当前无人获批使用真实台架。";
+ revokeButton.hidden = true;
+ }
+ if (!data.pending.length) {
+ pendingRequests.className = "request-list empty";
+ pendingRequests.textContent = "暂时没有申请。";
+ return;
+ }
+ pendingRequests.className = "request-list";
+ pendingRequests.innerHTML = data.pending.map((request) => `
+
+ ${escapeHtml(request.label)}${escapeHtml(request.client_id)}来源 ${escapeHtml(request.client_address)} · ${escapeHtml(request.requested_at)}
+
+
+
+
+ `).join("");
+}
+
+async function refresh() {
+ try {
+ render(await api("/api/local/hardware-access"));
+ document.querySelector("#clock").textContent = new Date().toLocaleTimeString("zh-CN", { hour12: false });
+ } catch (error) {
+ showMessage(error.message, true);
+ }
+}
+
+pendingRequests.addEventListener("click", async (event) => {
+ const button = event.target.closest("button[data-action]");
+ if (!button) return;
+ button.disabled = true;
+ try {
+ const action = button.dataset.action;
+ render(await api(`/api/local/hardware-access/${action}`, { client_id: button.dataset.clientId }));
+ showMessage(action === "approve" ? "已授予该终端真实台架使用权。" : "已拒绝该终端的申请。");
+ } catch (error) {
+ showMessage(error.message, true);
+ } finally {
+ button.disabled = false;
+ }
+});
+
+revokeButton.addEventListener("click", async () => {
+ revokeButton.disabled = true;
+ try {
+ render(await api("/api/local/hardware-access/revoke", { client_id: revokeButton.dataset.clientId }));
+ showMessage("已停止该终端的真实台架使用权;再次使用必须重新申请。");
+ } catch (error) {
+ showMessage(error.message, true);
+ } finally {
+ revokeButton.disabled = false;
+ }
+});
+
+refresh();
+setInterval(refresh, 2000);
diff --git a/apps/web-console/index.html b/apps/web-console/index.html
index c8c6978..894be1b 100755
--- a/apps/web-console/index.html
+++ b/apps/web-console/index.html
@@ -15,7 +15,7 @@
-
+
@@ -106,6 +106,16 @@
所有座椅能力仅控制副驾实体电机、加热与通风执行器
+
+
+
+
+
+
+
+
尚未申请;本次真实执行不会进入 CAN 链路。
+
+