feat: add local hardware approval leases

This commit is contained in:
vee
2026-08-03 10:20:55 +08:00
parent 85b4740d29
commit a952f37ceb
14 changed files with 1089 additions and 25 deletions
+133 -4
View File
@@ -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() {
? '<i data-lucide="shield-check"></i>硬件已配置'
: '<i data-lucide="shield-off"></i>硬件锁定';
$("#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();
+39
View File
@@ -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; } }
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>真实台架本地决策台</title>
<link rel="stylesheet" href="/hardware-control.css">
</head>
<body>
<main class="shell">
<header>
<div>
<p class="eyebrow">LOCAL BENCH AUTHORITY</p>
<h1>真实台架本地决策台</h1>
<p class="lead">只有本机可以批准、拒绝或停止终端的真实台架使用权。</p>
</div>
<a href="/">返回控制台</a>
</header>
<section class="policy">
<span class="dot"></span>
<div><strong>单终端租约</strong><small>获批终端可连续测试;最后一次真实执行后空闲 180 秒自动停止。</small></div>
<span id="clock">正在连接…</span>
</section>
<section class="card active-card">
<div class="section-title">
<div><p class="eyebrow">ACTIVE LEASE</p><h2>当前使用者</h2></div>
<button id="revokeButton" class="danger" type="button" hidden>立即停止使用</button>
</div>
<div id="activeLease" class="empty">当前无人获批使用真实台架。</div>
</section>
<section class="card">
<div class="section-title">
<div><p class="eyebrow">PENDING REQUESTS</p><h2>等待审批</h2></div>
<span id="pendingCount" class="count">0</span>
</div>
<div id="pendingRequests" class="request-list empty">暂时没有申请。</div>
</section>
<p id="message" class="message" role="status"></p>
</main>
<script src="/hardware-control.js" defer></script>
</body>
</html>
+104
View File
@@ -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 = `
<div class="identity"><strong>${escapeHtml(lease.label)}</strong><code>${escapeHtml(lease.client_id)}</code><span>来源 ${escapeHtml(lease.client_address)} · 最近执行 ${escapeHtml(lease.last_activity_at)}</span></div>
<div class="timer">${Number(lease.idle_remaining_seconds)}s</div>`;
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) => `
<article class="request">
<div class="identity"><strong>${escapeHtml(request.label)}</strong><code>${escapeHtml(request.client_id)}</code><span>来源 ${escapeHtml(request.client_address)} · ${escapeHtml(request.requested_at)}</span></div>
<div class="actions">
<button type="button" data-action="approve" data-client-id="${escapeHtml(request.client_id)}">同意</button>
<button type="button" class="secondary" data-action="deny" data-client-id="${escapeHtml(request.client_id)}">拒绝</button>
</div>
</article>`).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);
+11 -1
View File
@@ -15,7 +15,7 @@
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-1" defer></script>
<script src="/app.js?v=seat-hil-8" defer></script>
<script src="/app.js?v=seat-hil-9" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-1"></script>
<script src="/pwa.js?v=android-pad-1" defer></script>
</head>
@@ -106,6 +106,16 @@
<p class="provider-note" id="hardwareAliasNote">所有座椅能力仅控制副驾实体电机、加热与通风执行器</p>
</div>
<div class="field-group hardware-access-field" id="hardwareAccessField" hidden>
<label class="field-label" for="hardwareClientLabel">真实台架使用权</label>
<input id="hardwareClientLabel" maxlength="64" autocomplete="off" placeholder="终端名称,例如:张三-测试电脑" />
<div class="hardware-access-actions">
<button id="requestHardwareAccess" type="button">向本地申请</button>
<button id="releaseHardwareAccess" type="button" class="secondary" hidden>停止使用</button>
</div>
<p class="provider-note hardware-access-status" id="hardwareAccessStatus">尚未申请;本次真实执行不会进入 CAN 链路。</p>
</div>
<div class="field-group simulation-fault-field" id="simulationFaultField" hidden>
<label class="field-label" for="simulationFault">故障注入场景</label>
<select id="simulationFault" aria-label="选择模拟硬件故障">
+2 -2
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v8";
const CACHE_VERSION = "cognitive-agent-pad-v9";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,7 +6,7 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=android-pad-2",
"/network.js?v=android-pad-1",
"/app.js?v=seat-hil-8",
"/app.js?v=seat-hil-9",
"/gesture-control.js?v=android-pad-1",
"/pwa.js?v=android-pad-1",
"/manifest.webmanifest",
+48
View File
@@ -4357,6 +4357,54 @@ pre {
}
}
.hardware-access-field {
padding: 12px;
border: 1px solid rgba(38, 102, 159, 0.24);
border-radius: 7px;
background: rgba(38, 102, 159, 0.055);
}
.hardware-access-field input {
width: 100%;
min-height: 40px;
margin-top: 8px;
padding: 9px 11px;
color: var(--ink);
background: white;
border: 1px solid var(--line-strong);
border-radius: 6px;
font: inherit;
}
.hardware-access-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.hardware-access-actions button {
min-height: 38px;
border: 0;
border-radius: 5px;
color: white;
background: var(--blue);
font: inherit;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.hardware-access-actions button.secondary {
color: var(--ink);
background: white;
border: 1px solid var(--line-strong);
}
.hardware-access-status[data-state="approved"] { color: var(--teal); font-weight: 750; }
.hardware-access-status[data-state="pending"] { color: #9a6800; font-weight: 750; }
.hardware-access-status[data-state="expired"] { color: var(--danger); font-weight: 750; }
/* Android PAD, touch and installable-app refinements ---------------------- */
.execution-mode-options {
grid-template-columns: repeat(3, minmax(0, 1fr));