This commit is contained in:
orin
2026-07-30 08:45:38 +08:00
commit 25b24ac4cd
1333 changed files with 170092 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Web Console
Web 控制台提供一句话、快捷功能模式与手势控制入口,不在浏览器中生成 CAN。所有动作统一提交给后端编排、配置校验、安全门禁和执行链路。
当前页面包括:
- 一句话联动座椅、空调、天幕和氛围灯,支持 Enter 执行和 GPT/本地规则切换。
- 观影、午休、零重力快捷模式,以及当前副驾驶姿态的命名保存入口。
- 六阶段执行链路、整车联合孪生、领域状态、配置、CAN 计划和执行结果。
- 右侧手势控制抽屉,包含摄像头画面、关键点骨架、保持进度、动作速查与自定义映射。
UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模型私有思维过程。
当前座椅阶段一控制台为无构建静态页面,由 `cognitive_agent.server` 同源提供 API 和资源。运行:
```bash
make dev
```
局域网演示使用:
```bash
make dev-lan
```
其他设备访问启动日志打印的 `http://<LAN-IP>:8000`。页面与后端 API 由同一服务提供,无需单独配置前端地址。
公网入口由后端统一认证,前端不保存密码或 API Key。未认证访问自动进入
`/login`,主控制台提供退出按钮。固定入口是
`https://511.kendeji.fun`,应用容器只监听服务器回环地址,再由 Caddy 提供
HTTPS。部署方式见
[`deploy/server/README.md`](../../deploy/server/README.md)。
页面支持 GPT/本地解析切换、六阶段流水线、主副驾热舒适、五轴位置状态和 3D 座椅孪生、GPT 结构化能力请求、决策与校验轨迹、FunctionConfig、CommandPlan、虚拟帧和 ExecutionReport。硬件执行在服务端固定关闭,不是仅靠前端按钮限制。
## 手势资源与安全规则
手势模型随当前工程交付,默认位置为:
```text
assets/gesture/gesture_recognizer.task
../../config/gesture/default.yaml
```
后端提供 `/api/gesture/runtime` 和 `/api/gesture/model`,前端以二进制缓冲区加载模型,避免依赖外部 `seat_gesture_control` 目录。MediaPipe Web 运行时从 jsDelivr 按需加载;摄像头帧和手部关键点只在当前浏览器中处理。
安全规则:张掌保持 1 秒激活;动作保持 600 ms 且至少连续 8 帧才执行;换动作不重新锁止;握拳立即停止但不解除激活;3 秒无有效动作自动锁止;单次连续运动最多 1.5 秒;检测到多只手时不执行。自定义映射保存在浏览器 `localStorage`。
摄像头仅能在安全来源使用:本机访问 `http://127.0.0.1:8000`,远程访问必须使用 HTTPS。关闭抽屉、页面隐藏或离开页面时会立即停止摄像头轨道。
+3368
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

+657
View File
@@ -0,0 +1,657 @@
const VISION_VERSION = "0.10.14";
const VISION_MODULE = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${VISION_VERSION}/vision_bundle.mjs`;
const VISION_WASM = `https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@${VISION_VERSION}/wasm`;
const CONNECTIONS = [
[0, 1], [1, 2], [2, 3], [3, 4],
[0, 5], [5, 6], [6, 7], [7, 8],
[5, 9], [9, 10], [10, 11], [11, 12],
[9, 13], [13, 14], [14, 15], [15, 16],
[13, 17], [0, 17], [17, 18], [18, 19], [19, 20],
];
const LONG_FINGERS = [[5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16], [17, 18, 19, 20]];
const TOKEN_NAMES = {
OTHER: "未识别",
OPEN_PALM: "张开手掌",
CLOSED_FIST: "握拳",
POINT_UP: "食指向上",
POINT_DOWN: "食指向下",
POINT_RIGHT: "食指向右",
POINT_LEFT: "食指向左",
THUMB_UP: "拇指向上",
THUMB_DOWN: "拇指向下",
GUN_UP: "手枪手势向上",
GUN_DOWN: "手枪手势向下",
VICTORY: "V 手势",
VICTORY_RIGHT: "V 手势向右",
VICTORY_LEFT: "V 手势向左",
I_LOVE_YOU: "ILY 手势",
};
const ACTIONS = {
SEAT_UP: { label: "坐垫前端升高", short: "坐垫 ↑", utterance: "副驾驶座椅坐垫前端升高一点" },
SEAT_DOWN: { label: "坐垫前端降低", short: "坐垫 ↓", utterance: "副驾驶座椅坐垫前端降低一点" },
SEAT_FORWARD: { label: "座椅向前", short: "座椅 →", utterance: "副驾驶座椅向前移动一点" },
SEAT_BACKWARD: { label: "座椅向后", short: "座椅 ←", utterance: "副驾驶座椅向后移动一点" },
BACKREST_FORWARD: { label: "靠背向前", short: "靠背 ↑", utterance: "副驾驶靠背向前调一点" },
BACKREST_BACKWARD: { label: "靠背向后", short: "靠背 ↓", utterance: "副驾驶靠背向后调一点" },
LEGREST_EXTEND: { label: "腿托伸出", short: "腿托伸", utterance: "副驾驶腿托伸出一点" },
LEGREST_RETRACT: { label: "腿托收回", short: "腿托收", utterance: "副驾驶腿托收回一点" },
};
const GUIDE_TOKENS = [
"POINT_UP", "POINT_DOWN", "POINT_RIGHT", "POINT_LEFT",
"THUMB_UP", "THUMB_DOWN", "GUN_UP", "GUN_DOWN",
];
const state = {
runtime: null,
recognizer: null,
stream: null,
frameRequest: null,
lastVideoTime: -1,
lastRecognizedToken: null,
customMapping: loadCustomMapping(),
customizeOpen: false,
engine: null,
};
const $ = (selector) => document.querySelector(selector);
function loadCustomMapping() {
try {
const value = JSON.parse(localStorage.getItem("cognitive-agent.gesture-mapping") || "{}");
return value && typeof value === "object" ? value : {};
} catch {
return {};
}
}
function notify(message) {
if (window.CognitiveAgentConsole?.notify) {
window.CognitiveAgentConsole.notify(message);
return;
}
const toast = $("#toast");
if (!toast) return;
toast.textContent = message;
toast.classList.add("visible");
window.setTimeout(() => toast.classList.remove("visible"), 2600);
}
function distance(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function angle(a, vertex, c) {
const first = [a.x - vertex.x, a.y - vertex.y];
const second = [c.x - vertex.x, c.y - vertex.y];
const denominator = Math.hypot(...first) * Math.hypot(...second);
if (denominator <= 1e-9) return 0;
const cosine = Math.max(-1, Math.min(1, (first[0] * second[0] + first[1] * second[1]) / denominator));
return Math.acos(cosine) * 180 / Math.PI;
}
function fingerExtended(points, [mcpIndex, pipIndex, dipIndex, tipIndex]) {
const [mcp, pip, dip, tip] = [mcpIndex, pipIndex, dipIndex, tipIndex].map((index) => points[index]);
return angle(mcp, pip, dip) >= 150
&& angle(pip, dip, tip) >= 145
&& distance(points[0], tip) >= distance(points[0], pip) * 1.08;
}
function fingerStates(points) {
return LONG_FINGERS.map((indices) => fingerExtended(points, indices));
}
function plausibleHand(points) {
if (!Array.isArray(points) || points.length !== 21) return false;
if (points.some((point) => !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < -0.15 || point.x > 1.15 || point.y < -0.15 || point.y > 1.15)) return false;
const xs = points.map((point) => point.x);
const ys = points.map((point) => point.y);
if (Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)) < 0.12) return false;
const palmWidth = distance(points[5], points[17]);
if (palmWidth < 0.025) return false;
const edges = [distance(points[5], points[9]), distance(points[9], points[13]), distance(points[13], points[17])];
if (Math.min(...edges) < 0.006 || Math.max(...edges) / Math.min(...edges) > 3.5) return false;
return CONNECTIONS.filter(([first, second]) => distance(points[first], points[second]) > 0.004).length >= 16;
}
function pinchRatio(points) {
return distance(points[4], points[8]) / Math.max(distance(points[5], points[17]), 1e-6);
}
function pointingDirection(points) {
const [index, middle, ring, pinky] = fingerStates(points);
if (!index || middle || ring || pinky) return null;
const origin = points[5];
const tip = points[8];
// The camera preview is mirrored, so horizontal intent follows display space.
const dx = -(tip.x - origin.x);
const dy = tip.y - origin.y;
if (Math.hypot(dx, dy) < distance(points[5], points[17]) * 0.65) return null;
if (Math.abs(dx) >= Math.abs(dy)) return dx > 0 ? "RIGHT" : "LEFT";
return dy > 0 ? "DOWN" : "UP";
}
function thumbIsExtended(points) {
if (!Array.isArray(points) || points.length !== 21) return false;
const palmWidth = Math.max(distance(points[5], points[17]), 1e-6);
const thumbLength = distance(points[2], points[4]);
return angle(points[1], points[2], points[3]) >= 145
&& angle(points[2], points[3], points[4]) >= 145
&& thumbLength >= palmWidth * 0.65;
}
function thumbVerticalDirection(points) {
if (fingerStates(points).some(Boolean)) return null;
const dx = points[4].x - points[2].x;
const dy = points[4].y - points[2].y;
const length = Math.hypot(dx, dy);
const palmWidth = Math.max(distance(points[5], points[17]), 1e-6);
if (length < palmWidth * 0.65 || Math.abs(dy) < Math.abs(dx) * 1.6 || !thumbIsExtended(points)) return null;
return dy > 0 ? "DOWN" : "UP";
}
function gunDirection(points) {
const direction = pointingDirection(points);
if (!direction || !["UP", "DOWN"].includes(direction)) return null;
return thumbIsExtended(points) ? direction : null;
}
function victoryDirection(points) {
const states = fingerStates(points);
if (states.join(",") !== "true,true,false,false") return null;
const originX = (points[5].x + points[9].x) / 2;
const originY = (points[5].y + points[9].y) / 2;
const tipX = (points[8].x + points[12].x) / 2;
const tipY = (points[8].y + points[12].y) / 2;
const dx = -(tipX - originX);
const dy = tipY - originY;
if (Math.hypot(dx, dy) < distance(points[5], points[17]) * 0.65 || Math.abs(dx) < Math.abs(dy) * 1.25) return null;
return dx > 0 ? "RIGHT" : "LEFT";
}
function geometryGesture(points) {
if (!plausibleHand(points)) return null;
const states = fingerStates(points);
const pinch = pinchRatio(points);
const extendedThumb = thumbIsExtended(points);
const gun = gunDirection(points);
if (gun) return `GUN_${gun}`;
const point = pointingDirection(points);
if (point && !extendedThumb && pinch >= 0.75) return `POINT_${point}`;
if (states.join(",") === "true,true,false,false" && pinch >= 1.1) {
const direction = victoryDirection(points);
return direction ? `VICTORY_${direction}` : "VICTORY";
}
if (states.join(",") === "true,false,false,true" && pinch >= 1.1) return "I_LOVE_YOU";
if (states.every(Boolean) && pinch >= 1.0) return "OPEN_PALM";
if (states.every((value) => !value)) {
const thumb = thumbVerticalDirection(points);
if (thumb && pinch >= 0.72) return `THUMB_${thumb}`;
if (!extendedThumb && pinch <= 0.62) return "CLOSED_FIST";
}
return null;
}
function normalizedCategory(value) {
return String(value || "None").trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");
}
function classifyHand(points, categories = []) {
const geometry = geometryGesture(points);
const top = categories[0] || {};
const runnerUp = categories[1] || {};
const name = normalizedCategory(top.categoryName || top.displayName);
const score = Number(top.score || 0);
const margin = Math.max(0, score - Number(runnerUp.score || 0));
if (!geometry) return { token: "OTHER", score, reason: "手型未通过严格几何校验" };
if (geometry.startsWith("GUN_")) {
return ["pointing_up", "thumb_up", "thumb_down", "none", ""].includes(name)
? { token: geometry, score: Math.max(score, 0.78), reason: "模型与手枪几何一致" }
: { token: "OTHER", score, reason: "模型类别与手枪几何冲突" };
}
if (geometry.startsWith("POINT_")) {
return ["pointing_up", "none", ""].includes(name)
? { token: geometry, score: Math.max(score, 0.75), reason: "严格指向几何" }
: { token: "OTHER", score, reason: "模型类别与指向几何冲突" };
}
if (["VICTORY_LEFT", "VICTORY_RIGHT"].includes(geometry)) {
return ["victory", "none", ""].includes(name)
? { token: geometry, score: Math.max(score, 0.75), reason: "严格 V 字方向几何" }
: { token: "OTHER", score, reason: "模型类别与 V 字方向冲突" };
}
if (["THUMB_UP", "THUMB_DOWN"].includes(geometry)) {
const expected = geometry.toLowerCase();
return (["none", "", expected].includes(name) && (name === "none" || name === "" || score >= 0.6))
? { token: geometry, score: Math.max(score, 0.6), reason: "模型与拇指几何一致" }
: { token: "OTHER", score, reason: "模型类别与拇指几何冲突" };
}
const expectedNames = {
OPEN_PALM: "open_palm", CLOSED_FIST: "closed_fist", VICTORY: "victory", I_LOVE_YOU: "iloveyou",
};
const threshold = geometry === "OPEN_PALM" ? 0.62 : 0.7;
if (name !== expectedNames[geometry] || score < threshold || (Number(runnerUp.score || 0) > 0 && margin < 0.12)) {
return { token: "OTHER", score, reason: "模型类别、分数或几何不一致" };
}
return { token: geometry, score, reason: "模型与几何一致" };
}
function mappingForToken(token) {
const defaultAction = state.runtime?.actions?.[token]?.action;
return state.customMapping[token] || defaultAction || null;
}
class GestureEngine {
constructor(config, onAction) {
this.config = config;
this.onAction = onAction;
this.armed = false;
this.currentAction = null;
this.candidate = null;
this.candidateSince = 0;
this.candidateFrames = 0;
this.lastActionAt = 0;
this.motionStartedAt = 0;
this.armSince = 0;
this.armFrames = 0;
this.neutralSince = 0;
this.lockedAction = null;
this.fistLatched = false;
}
resetCandidate() {
this.candidate = null;
this.candidateSince = 0;
this.candidateFrames = 0;
}
setArmed(value, now = performance.now()) {
this.armed = value;
this.currentAction = null;
this.motionStartedAt = 0;
this.lockedAction = null;
this.resetCandidate();
this.lastActionAt = now;
updateEngineUI(this, value ? "已激活,可直接切换动作" : "已锁止,张开手掌激活");
}
stopMotion(message) {
this.currentAction = null;
this.motionStartedAt = 0;
this.resetCandidate();
updateEngineUI(this, message);
}
update(poses, now) {
const fist = poses.some((pose) => pose.token === "CLOSED_FIST");
if (fist) {
if (!this.fistLatched || this.currentAction) this.stopMotion("握拳已停止,控制保持激活");
this.fistLatched = true;
if (this.armed && now - this.lastActionAt >= this.config.armed_idle_timeout_ms) this.setArmed(false, now);
return;
}
this.fistLatched = false;
const primary = poses.length === 1 ? poses[0] : null;
if (!this.armed) {
if (primary?.token === "OPEN_PALM") {
if (!this.armSince) {
this.armSince = now;
this.armFrames = 1;
} else {
this.armFrames += 1;
}
const progress = Math.min(1, (now - this.armSince) / this.config.arm_hold_ms);
setGestureProgress(progress);
if (progress >= 1 && this.armFrames >= this.config.min_action_frames) {
this.armSince = 0;
this.armFrames = 0;
this.setArmed(true, now);
notify("手势控制已激活");
}
} else {
this.armSince = 0;
this.armFrames = 0;
setGestureProgress(0);
}
return;
}
const action = primary ? mappingForToken(primary.token) : null;
if (!action) {
if (this.currentAction) this.stopMotion("动作已停止,控制保持激活");
this.resetCandidate();
if (this.lockedAction) {
if (!this.neutralSince) this.neutralSince = now;
if (now - this.neutralSince >= this.config.neutral_release_ms) this.lockedAction = null;
}
if (now - this.lastActionAt >= this.config.armed_idle_timeout_ms) {
this.setArmed(false, now);
notify("连续 3 秒无有效动作,手势控制已安全锁止");
}
setGestureProgress(0);
return;
}
this.lastActionAt = now;
this.neutralSince = 0;
if (this.lockedAction && action !== this.lockedAction) this.lockedAction = null;
if (this.lockedAction === action) {
updateEngineUI(this, "该动作已达到单次时限,请先释放手势");
setGestureProgress(0);
return;
}
if (this.currentAction && action !== this.currentAction) this.stopMotion("正在确认新动作…");
if (this.currentAction === action) {
setGestureProgress(1);
if (now - this.motionStartedAt >= this.config.max_continuous_motion_ms) {
this.lockedAction = action;
this.stopMotion("动作达到 1.5 秒上限,请释放或更换动作");
}
return;
}
if (this.candidate !== action) {
this.candidate = action;
this.candidateSince = now;
this.candidateFrames = 1;
} else {
this.candidateFrames += 1;
}
const progress = Math.min(1, (now - this.candidateSince) / this.config.action_hold_ms);
setGestureProgress(progress);
updateEngineUI(this, `保持手势 · ${Math.round(progress * 100)}%`);
if (progress >= 1 && this.candidateFrames >= this.config.min_action_frames) {
this.currentAction = action;
this.motionStartedAt = now;
this.resetCandidate();
updateEngineUI(this, `${ACTIONS[action]?.label || action} · 已执行`);
this.onAction(action);
}
}
}
function setGestureProgress(value) {
const bar = $("#gestureProgressBar");
if (bar) bar.style.transform = `scaleX(${Math.max(0, Math.min(1, value))})`;
}
function updateEngineUI(engine, message) {
const pill = $("#gestureStatePill");
if (!pill) return;
pill.className = `gesture-state-pill ${engine.armed ? (engine.currentAction ? "moving" : "armed") : "idle"}`;
pill.querySelector("b").textContent = engine.armed ? (engine.currentAction ? "执行中" : "已激活") : "已锁止";
$("#gestureSafetyText").textContent = message;
$("#gestureArmButton").textContent = engine.armed ? "已激活" : "激活控制";
}
function executeGestureAction(action) {
const definition = ACTIONS[action];
if (!definition) return;
const executionMode = window.CognitiveAgentConsole?.getExecutionMode?.() || "dry_run";
const autoSubmit = executionMode !== "hardware";
const accepted = window.CognitiveAgentConsole?.executeUtterance?.(definition.utterance, { autoSubmit });
if (accepted === false) {
notify("上一条指令仍在处理中,本次手势未重复提交");
} else if (!autoSubmit) {
notify("真实台架模式需要人工授权,指令已填入输入框");
} else {
notify(`手势指令:${definition.label}`);
}
}
function drawLandmarks(result) {
const canvas = $("#gestureCanvas");
const video = $("#gestureVideo");
if (!canvas || !video.videoWidth) return;
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
}
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
for (const points of result.landmarks || []) {
context.lineWidth = Math.max(2, canvas.width / 360);
context.strokeStyle = "rgba(114, 255, 196, .9)";
for (const [first, second] of CONNECTIONS) {
context.beginPath();
context.moveTo(points[first].x * canvas.width, points[first].y * canvas.height);
context.lineTo(points[second].x * canvas.width, points[second].y * canvas.height);
context.stroke();
}
context.fillStyle = "#f8ff73";
for (const point of points) {
context.beginPath();
context.arc(point.x * canvas.width, point.y * canvas.height, Math.max(3, canvas.width / 230), 0, Math.PI * 2);
context.fill();
}
}
}
function renderRecognition(poses) {
const detected = $("#gestureDetected");
const expected = $("#gestureExpected");
if (!poses.length) {
detected.textContent = "未检测到手";
expected.textContent = state.engine?.armed ? "保持激活" : "张掌激活";
return;
}
detected.textContent = poses.map((pose) => TOKEN_NAMES[pose.token] || pose.token).join(" + ");
const action = poses.length === 1 ? mappingForToken(poses[0].token) : null;
expected.textContent = action ? ACTIONS[action]?.label || action : poses.length > 1 ? "多手画面已锁止" : poses[0].reason;
const usable = poses.find((pose) => !["OTHER", "OPEN_PALM", "CLOSED_FIST"].includes(pose.token));
if (usable) state.lastRecognizedToken = usable.token;
if (state.customizeOpen) updateCustomizeToken();
}
async function recognizeLoop() {
if (!state.stream || !state.recognizer) return;
const video = $("#gestureVideo");
const now = performance.now();
if (video.readyState >= 2 && video.currentTime !== state.lastVideoTime) {
state.lastVideoTime = video.currentTime;
const result = state.recognizer.recognizeForVideo(video, now);
drawLandmarks(result);
const poses = (result.landmarks || []).map((points, index) => classifyHand(points, result.gestures?.[index] || []));
renderRecognition(poses);
state.engine.update(poses, now);
}
state.frameRequest = requestAnimationFrame(recognizeLoop);
}
async function loadGestureRuntime() {
const response = await fetch("/api/gesture/runtime", { cache: "no-store" });
if (!response.ok) throw new Error("无法读取手势能力配置");
state.runtime = await response.json();
state.engine = new GestureEngine(state.runtime.safety, executeGestureAction);
renderMappingGrid();
if (!state.runtime.model_available) throw new Error("内置手势模型不可用,请检查工程资源");
}
async function loadRecognizer() {
if (state.recognizer) return;
$("#gestureSafetyText").textContent = "正在加载手势模型…";
const { FilesetResolver, GestureRecognizer } = await import(VISION_MODULE);
const fileset = await FilesetResolver.forVisionTasks(VISION_WASM);
const modelResponse = await fetch("/api/gesture/model", { cache: "force-cache" });
if (!modelResponse.ok) throw new Error("内置手势模型加载失败");
const modelAssetBuffer = new Uint8Array(await modelResponse.arrayBuffer());
state.recognizer = await GestureRecognizer.createFromOptions(fileset, {
baseOptions: { modelAssetBuffer, delegate: "CPU" },
runningMode: "VIDEO",
numHands: 2,
minHandDetectionConfidence: 0.65,
minHandPresenceConfidence: 0.65,
minTrackingConfidence: 0.60,
});
}
async function startCamera() {
const button = $("#gestureStartButton");
button.disabled = true;
button.querySelector("span").textContent = "正在启动…";
try {
if (!state.runtime) await loadGestureRuntime();
await loadRecognizer();
state.stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: "user" },
audio: false,
});
const video = $("#gestureVideo");
video.srcObject = state.stream;
await video.play();
$("#gestureCameraEmpty").hidden = true;
$("#gestureLiveBadge").hidden = false;
$("#gestureArmButton").disabled = false;
$("#gestureStopButton").disabled = false;
button.classList.add("active");
button.querySelector("span").textContent = "关闭摄像头";
$("#gestureSafetyText").textContent = "张开手掌并保持 1 秒以激活";
state.frameRequest = requestAnimationFrame(recognizeLoop);
} catch (error) {
$("#gestureSafetyText").textContent = error.message || "摄像头启动失败";
notify(error.message || "摄像头启动失败");
stopCamera();
} finally {
button.disabled = false;
}
}
function stopCamera() {
if (state.frameRequest) cancelAnimationFrame(state.frameRequest);
state.frameRequest = null;
state.stream?.getTracks().forEach((track) => track.stop());
state.stream = null;
state.lastVideoTime = -1;
const video = $("#gestureVideo");
if (video) video.srcObject = null;
const canvas = $("#gestureCanvas");
canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
$("#gestureCameraEmpty").hidden = false;
$("#gestureLiveBadge").hidden = true;
$("#gestureArmButton").disabled = true;
$("#gestureStopButton").disabled = true;
const button = $("#gestureStartButton");
button.classList.remove("active");
button.querySelector("span").textContent = "开启摄像头";
state.engine?.setArmed(false);
$("#gestureDetected").textContent = "—";
$("#gestureExpected").textContent = "等待启动";
$("#gestureSafetyText").textContent = "摄像头尚未开启";
}
function toggleCamera() {
if (state.stream) stopCamera(); else startCamera();
}
function renderMappingGrid() {
const grid = $("#gestureMapGrid");
if (!grid || !state.runtime) return;
grid.innerHTML = GUIDE_TOKENS.map((token) => {
const action = mappingForToken(token);
return `<div><span>${TOKEN_NAMES[token]}</span><i>→</i><b>${ACTIONS[action]?.short || "未绑定"}</b></div>`;
}).join("");
const select = $("#gestureActionSelect");
select.innerHTML = Object.entries(ACTIONS).map(([value, item]) => `<option value="${value}">${item.label}</option>`).join("");
}
function updateCustomizeToken() {
const token = state.lastRecognizedToken;
$("#gestureCustomizeToken").textContent = token ? TOKEN_NAMES[token] || token : "请先摆出手势";
if (token) {
const action = mappingForToken(token);
if (action) $("#gestureActionSelect").value = action;
}
}
function toggleCustomize() {
state.customizeOpen = !state.customizeOpen;
$("#gestureCustomizePanel").hidden = !state.customizeOpen;
$("#gestureCustomizeButton").classList.toggle("active", state.customizeOpen);
updateCustomizeToken();
}
function bindCurrentGesture() {
const token = state.lastRecognizedToken;
if (!token || ["OTHER", "OPEN_PALM", "CLOSED_FIST"].includes(token)) {
notify("请先保持一个可自定义的有效手势");
return;
}
state.customMapping[token] = $("#gestureActionSelect").value;
localStorage.setItem("cognitive-agent.gesture-mapping", JSON.stringify(state.customMapping));
renderMappingGrid();
notify(`${TOKEN_NAMES[token] || token} 的功能已保存`);
}
function resetMapping() {
state.customMapping = {};
localStorage.removeItem("cognitive-agent.gesture-mapping");
renderMappingGrid();
notify("已恢复默认手势映射");
}
function openDrawer() {
$("#gestureDrawer").classList.add("open");
$("#gestureDrawer").setAttribute("aria-hidden", "false");
$("#gestureBackdrop").hidden = false;
requestAnimationFrame(() => $("#gestureBackdrop").classList.add("visible"));
$("#gestureToggleButton").setAttribute("aria-expanded", "true");
document.body.classList.add("gesture-open");
}
function closeDrawer() {
stopCamera();
$("#gestureDrawer").classList.remove("open");
$("#gestureDrawer").setAttribute("aria-hidden", "true");
$("#gestureBackdrop").classList.remove("visible");
window.setTimeout(() => { $("#gestureBackdrop").hidden = true; }, 240);
$("#gestureToggleButton").setAttribute("aria-expanded", "false");
document.body.classList.remove("gesture-open");
}
function initModeRail() {
document.querySelectorAll("[data-mode-command]").forEach((button) => button.addEventListener("click", () => {
window.CognitiveAgentConsole?.executeUtterance?.(button.dataset.modeCommand, { autoSubmit: true });
}));
$("#saveModeToggle")?.addEventListener("click", () => {
const composer = $("#saveModeComposer");
composer.hidden = !composer.hidden;
if (!composer.hidden) $("#saveModeName").focus();
});
$("#saveModeConfirm")?.addEventListener("click", () => {
const name = $("#saveModeName").value.trim();
if (!name) return notify("请先输入模式名称");
window.CognitiveAgentConsole?.executeUtterance?.(`将当前副驾驶座椅姿态保存为${name}模式`, { autoSubmit: true });
$("#saveModeComposer").hidden = true;
$("#saveModeName").value = "";
});
}
document.addEventListener("DOMContentLoaded", async () => {
$("#gestureToggleButton")?.addEventListener("click", openDrawer);
$("#gestureCloseButton")?.addEventListener("click", closeDrawer);
$("#gestureBackdrop")?.addEventListener("click", closeDrawer);
$("#gestureStartButton")?.addEventListener("click", toggleCamera);
$("#gestureArmButton")?.addEventListener("click", () => state.engine?.setArmed(!state.engine.armed));
$("#gestureStopButton")?.addEventListener("click", () => state.engine?.setArmed(false));
$("#gestureCustomizeButton")?.addEventListener("click", toggleCustomize);
$("#gestureBindButton")?.addEventListener("click", bindCurrentGesture);
$("#gestureResetMapping")?.addEventListener("click", resetMapping);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && $("#gestureDrawer")?.classList.contains("open")) closeDrawer();
});
document.addEventListener("visibilitychange", () => { if (document.hidden && state.stream) stopCamera(); });
window.addEventListener("beforeunload", stopCamera);
initModeRail();
try {
await loadGestureRuntime();
$("#gestureSafetyText").textContent = state.runtime.available ? "能力就绪,开启摄像头开始识别" : "内置手势模型不可用";
} catch (error) {
$("#gestureSafetyText").textContent = error.message;
$("#gestureStartButton").disabled = true;
}
});
+498
View File
@@ -0,0 +1,498 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<title>智舱随行 · 一句话与手势控制</title>
<link rel="stylesheet" href="/styles.css" />
<script src="https://unpkg.com/lucide@0.468.0/dist/umd/lucide.min.js" defer></script>
<script src="https://unpkg.com/three@0.147.0/build/three.min.js" defer></script>
<script src="https://unpkg.com/three@0.147.0/examples/js/libs/fflate.min.js" defer></script>
<script src="https://unpkg.com/three@0.147.0/examples/js/loaders/FBXLoader.js" defer></script>
<script src="/app.js?v=unified-cabin-12" defer></script>
<script type="module" src="/gesture-control.js?v=gesture-core-3"></script>
</head>
<body>
<header class="topbar">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true"><i data-lucide="brain-circuit"></i></span>
<div>
<h1>Cognitive Agent</h1>
<p>CABIN ORCHESTRATION <span>/</span> MULTI-DOMAIN</p>
</div>
</div>
<div class="runtime-badges" aria-label="运行环境">
<button class="gesture-entry" id="gestureToggleButton" type="button" aria-controls="gestureDrawer" aria-expanded="false">
<span class="gesture-entry-dot"></span><i data-lucide="scan-face"></i>手势控制
</button>
<span class="status-badge neutral" id="executionBadge"><i data-lucide="flask-conical"></i>DRY-RUN</span>
<span class="status-badge" id="modelBadge"><i data-lucide="sparkles"></i>模型检测中</span>
<span class="status-badge danger" id="hardwareBadge"><i data-lucide="shield-off"></i>硬件检测中</span>
<button class="status-badge auth-logout" id="logoutButton" type="button" title="退出登录" aria-label="退出登录" hidden>
<i data-lucide="log-out"></i>
</button>
</div>
</header>
<div class="workspace">
<aside class="command-panel">
<div class="panel-heading">
<span class="section-kicker">NATURAL CABIN CONTROL</span>
<h2>说一句,就调好了</h2>
<p class="panel-intro">座椅、空调、天幕与氛围灯,一句话联动完成。</p>
</div>
<div class="mode-rail" aria-label="快捷功能模式">
<div class="mode-rail-heading"><span>快捷模式</span><button id="saveModeToggle" type="button"><i data-lucide="bookmark-plus"></i>保存当前</button></div>
<div class="mode-chips">
<button type="button" data-mode-command="进入观影模式:副驾驶调整为舒适观影姿态,空调24度、风量2档,天幕调暗,氛围灯切换成暖橙色"><i data-lucide="clapperboard"></i>观影</button>
<button type="button" data-mode-command="进入午休模式:副驾驶靠背后倾,腿托伸出,空调25度,天幕最暗,氛围灯切换为柔和暖色"><i data-lucide="moon-star"></i>午休</button>
<button type="button" data-mode-command="副驾驶调整为零重力模式"><i data-lucide="orbit"></i>零重力</button>
</div>
<div class="save-mode-composer" id="saveModeComposer" hidden>
<input id="saveModeName" maxlength="24" placeholder="给当前座椅姿态起个名字" />
<button id="saveModeConfirm" type="button">保存</button>
</div>
</div>
<form id="requestForm">
<div class="field-label-row">
<label for="utterance">自然语言指令</label>
<button class="context-reset" id="newConversation" type="button" title="开始新会话" aria-label="开始新会话">
<i data-lucide="message-square-plus"></i>
</button>
</div>
<div class="command-composer">
<textarea id="utterance" name="utterance" maxlength="1000" placeholder="例如:有点热,把副驾驶调舒服些,再把天幕调暗">进入观影状态:空调24度、风量2档,天幕调暗,氛围灯换成暖橙色</textarea>
<span class="composer-hint">Enter 执行 · Shift + Enter 换行</span>
</div>
<div class="field-group">
<span class="field-label">意图解析器</span>
<div class="segmented" role="group" aria-label="意图解析器">
<button type="button" class="segment active" data-provider="openai">
<i data-lucide="sparkles"></i>GPT
</button>
<button type="button" class="segment" data-provider="local">
<i data-lucide="braces"></i>本地规则
</button>
</div>
<p class="provider-note" id="providerNote">正在读取运行时配置</p>
</div>
<div class="field-group">
<span class="field-label">执行目标</span>
<div class="segmented" role="group" aria-label="执行目标">
<button type="button" class="mode-option active" data-execution-mode="dry_run">
<i data-lucide="flask-conical"></i>虚拟 CAN
</button>
<button type="button" class="mode-option" id="hardwareMode" data-execution-mode="hardware" disabled>
<i data-lucide="radio-tower"></i>真实台架
</button>
</div>
<p class="provider-note" id="hardwareNote">正在检查 CAN 适配器与 IAR 固件配置</p>
<p class="provider-note" id="hardwareAliasNote">主驾和副驾暂时共享同一组实体电机、加热与通风执行器</p>
</div>
<div class="field-group approval-field" id="approvalField" hidden>
<label class="field-label" for="approvalToken">硬件执行授权令牌</label>
<input id="approvalToken" name="approval_token" type="password" inputmode="numeric" maxlength="6" autocomplete="off" spellcheck="false" placeholder="请输入 123456" />
<p class="provider-note">演示授权令牌为 123456,仅用于本次安全校验,不会写入日志、会话或执行制品。</p>
<p class="provider-note" id="autoFlashNote">正在检查自动烧录配置</p>
</div>
<button class="primary-action" id="runButton" type="submit">
<i data-lucide="play"></i>
<span>生成并执行</span>
</button>
</form>
<div class="example-group">
<span class="field-label">示例请求</span>
<button type="button" class="example-command" data-example="空调打开,车内温度调到24度,风量3档">
<i data-lucide="fan"></i><span>空调 24℃ · 风量 3 档</span><i data-lucide="chevron-right"></i>
</button>
<button type="button" class="example-command" data-example="天幕透光率调到30%,遮阳帘打开一半">
<i data-lucide="panels-top-left"></i><span>天幕透光 30% · 遮阳帘 50%</span><i data-lucide="chevron-right"></i>
</button>
<button type="button" class="example-command" data-example="打开氛围灯,调成冰蓝色,亮度60%">
<i data-lucide="lamp-ceiling"></i><span>冰蓝氛围灯 · 亮度 60%</span><i data-lucide="chevron-right"></i>
</button>
<button type="button" class="example-command featured-example" data-example="进入观影状态:副驾调整为舒适观影姿态,空调24度、风量2档,天幕调暗,氛围灯换成暖橙色">
<i data-lucide="clapperboard"></i><span>跨域观影场景</span><i data-lucide="chevron-right"></i>
</button>
<button type="button" class="example-command" data-example="副驾有点热,有点闷">
<i data-lucide="wind"></i><span>副驾有点热,有点闷</span><i data-lucide="chevron-right"></i>
</button>
<button type="button" class="example-command" data-example="副驾靠背向后移动5度">
<i data-lucide="move-horizontal"></i><span>副驾靠背向后 5°</span><i data-lucide="chevron-right"></i>
</button>
<button type="button" class="example-command" data-example="副驾调整为零重力模式,要求靠背座椅夹角128度,坐垫脚托夹角133度">
<i data-lucide="compass"></i><span>副驾零重力精确夹角</span><i data-lucide="chevron-right"></i>
</button>
<button type="button" class="example-command" data-example="将当前状态保存为零重力模式">
<i data-lucide="bookmark-plus"></i><span>保存当前座椅姿态</span><i data-lucide="chevron-right"></i>
</button>
<button type="button" class="example-command warning-example" data-example="副驾座椅加热开到三档">
<i data-lucide="triangle-alert"></i><span>三档请求(档位虚拟)</span><i data-lucide="chevron-right"></i>
</button>
</div>
<div class="safety-strip" id="safetyStrip">
<i data-lucide="shield-check"></i>
<div>
<strong id="safetyTitle">虚拟执行边界</strong>
<span id="safetyText">四域 CAN 均为 dry-run,实车发送被强制关闭。</span>
</div>
</div>
</aside>
<main class="main-panel">
<section class="request-header">
<div>
<span class="section-kicker">ORCHESTRATION RUN</span>
<div class="request-title-row">
<h2 id="resultTitle">等待请求</h2>
<span class="run-status idle" id="runStatus">IDLE</span>
</div>
<p id="requestMeta">Cabin / reference_vehicle / dry-run</p>
</div>
<div class="matrix-stat">
<i data-lucide="table-properties"></i>
<div><strong id="matrixCount">10 / 57</strong><span>报文 / 信号</span></div>
</div>
</section>
<section class="pipeline" aria-label="执行链路">
<div class="pipeline-step" data-stage="intent_parse"><span>01</span><i data-lucide="message-square-text"></i><b>意图抽取</b></div>
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
<div class="pipeline-step" data-stage="function_generate"><span>02</span><i data-lucide="file-code-2"></i><b>配置生成</b></div>
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
<div class="pipeline-step" data-stage="domain_validate"><span>03</span><i data-lucide="badge-check"></i><b>双层校验</b></div>
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
<div class="pipeline-step" data-stage="compile"><span>04</span><i data-lucide="binary"></i><b>CAN 编译</b></div>
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
<div class="pipeline-step" data-stage="safety_check"><span>05</span><i data-lucide="shield-check"></i><b>安全门禁</b></div>
<i class="pipeline-arrow" data-lucide="chevron-right"></i>
<div class="pipeline-step" data-stage="execute"><span>06</span><i data-lucide="radio-tower"></i><b id="executeStageLabel">虚拟执行</b></div>
</section>
<section class="cabin-band" aria-labelledby="cabinStateTitle">
<div class="section-title-row cabin-heading">
<div>
<span class="section-kicker">JOINT DEVICE STATE</span>
<h3 id="cabinStateTitle">联合设备状态</h3>
</div>
<span class="simulation-label" id="feedbackModeLabel">MATRIX-BACKED · SIMULATED FEEDBACK</span>
</div>
<div class="domain-grid">
<article class="domain-panel climate-panel" data-domain-panel="climate">
<header>
<span class="domain-icon"><i data-lucide="fan"></i></span>
<div><strong>空调</strong><small>CLIMATE · 0x064</small></div>
<span class="device-state" id="climatePower">OFF</span>
</header>
<div class="climate-display">
<div class="zone-temperature">
<span>主驾</span><strong id="driverTemperature">24<sup>℃</sup></strong>
</div>
<div class="airflow-visual" id="airflowVisual" data-level="0" aria-label="空调风量">
<i></i><i></i><i></i><i></i><i></i><i></i><i></i>
</div>
<div class="zone-temperature passenger-temperature">
<span>副驾</span><strong id="passengerTemperature">24<sup>℃</sup></strong>
</div>
</div>
<dl class="domain-readouts">
<div><dt>风量</dt><dd id="fanLevel">0 档</dd></div>
<div><dt>循环</dt><dd id="cycleMode">外循环</dd></div>
<div><dt>A/C</dt><dd id="acState">关闭</dd></div>
<div><dt>前除霜</dt><dd id="defrostState">关闭</dd></div>
</dl>
</article>
<article class="domain-panel roof-panel" data-domain-panel="roof">
<header>
<span class="domain-icon"><i data-lucide="panels-top-left"></i></span>
<div><strong>智能天幕</strong><small>ROOF · 0x4C1 / 0x3E6</small></div>
<span class="device-state" id="roofState">READY</span>
</header>
<div class="roof-visual" aria-label="天幕透光率与遮阳帘位置">
<div class="roof-glass" id="roofGlass">
<span class="roof-shade" id="roofShade"></span>
<i></i><i></i><i></i>
</div>
</div>
<dl class="domain-readouts">
<div><dt>透光率</dt><dd id="transmittanceValue">100%</dd></div>
<div><dt>遮阳帘</dt><dd id="shadePositionValue">0%</dd></div>
<div><dt>自动调光</dt><dd id="automaticDimming">关闭</dd></div>
<div><dt>电机反馈</dt><dd id="roofMotorState">已停止</dd></div>
</dl>
</article>
<article class="domain-panel ambient-panel" data-domain-panel="ambient_light">
<header>
<span class="domain-icon"><i data-lucide="lamp-ceiling"></i></span>
<div><strong>氛围灯</strong><small>AMBIENT · RGB 16.8M</small></div>
<span class="device-state" id="ambientPower">OFF</span>
</header>
<div class="ambient-visual" id="ambientVisual" aria-label="副驾手套箱上方九段氛围灯排灯">
<div class="ambient-dashboard-sketch" aria-hidden="true">
<span class="ambient-dashboard-top"></span>
<span class="ambient-center-screen"></span>
<span class="ambient-glovebox"></span>
<span class="ambient-glovebox-lamp">
<i class="ambient-line"></i><i class="ambient-line"></i><i class="ambient-line"></i>
<i class="ambient-line"></i><i class="ambient-line"></i><i class="ambient-line"></i>
<i class="ambient-line"></i><i class="ambient-line"></i><i class="ambient-line"></i>
</span>
</div>
<small class="ambient-location-label">副驾 · 手套箱上方排灯</small>
</div>
<dl class="domain-readouts">
<div><dt>颜色</dt><dd><i class="color-swatch" id="ambientSwatch"></i><span id="ambientColor">#74D9FF</span></dd></div>
<div><dt>亮度</dt><dd id="ambientBrightness">0%</dd></div>
<div><dt>模式</dt><dd id="ambientMode">关闭</dd></div>
<div><dt>控制精度</dt><dd>24-bit RGB</dd></div>
</dl>
</article>
</div>
</section>
<section class="twin-band" aria-labelledby="twinTitle">
<div class="twin-heading">
<div>
<span class="section-kicker">FULL VEHICLE DIGITAL TWIN</span>
<h3 id="twinTitle">整车孪生界面</h3>
<div class="preset-indicator" id="presetIndicator" hidden>
<i data-lucide="bookmark-check"></i><span></span>
</div>
</div>
<div class="twin-toolbar">
<span class="twin-focus-badge"><i data-lucide="scan"></i>透明车体 · 实体设备</span>
<button class="icon-button twin-reset" id="twinReset" type="button" title="复位 3D 视角" aria-label="复位 3D 视角">
<i data-lucide="rotate-3d"></i>
</button>
</div>
</div>
<div class="twin-stage" id="seatTwin" data-selected-seat="passenger">
<canvas id="seatTwinCanvas" data-vehicle-model="byd-tang-2024-reference" data-seat-layout="five-seat" data-active-seat="front-right" aria-label="透明轮廓整车、固定主驾和后排、动态副驾、全景天幕、仪表台空调出风口与副驾手套箱上方氛围灯三维状态"></canvas>
<div class="vehicle-system-hud" aria-label="整车孪生系统状态">
<span data-twin-system="roof"><i data-lucide="panels-top-left"></i><small>全景天幕</small><b id="twinRoofState">透光 100%</b></span>
<span data-twin-system="climate"><i data-lucide="wind"></i><small>仪表台 HVAC</small><b id="twinClimateState">关闭</b></span>
<span data-twin-system="ambient"><i data-lucide="lamp-ceiling"></i><small>手套箱氛围灯</small><b id="twinAmbientState">关闭</b></span>
</div>
<div class="twin-seat-label passenger"><i></i><span>副驾动态座椅</span><small>ACTIVE · FRONT RIGHT</small></div>
<div class="twin-callout-layer" aria-hidden="true">
<div class="twin-callout" data-callout="longitudinal"><span>水平位置</span><b>50%</b><small>导轨</small></div>
<div class="twin-callout" data-callout="cushion_tilt"><span>坐垫俯仰</span><b>7°</b><small>50%</small></div>
<div class="twin-callout" data-callout="backrest_recline"><span>靠背夹角</span><b>20.5°</b><small>20% · 相对竖直</small></div>
<div class="twin-callout" data-callout="legrest_height"><span>腿托夹角</span><b>78.5°</b><small>0% · 相对地板</small></div>
<div class="twin-callout" data-callout="legrest_extension"><span>腿托伸缩</span><b>0%</b><small>行程</small></div>
<div class="twin-callout included-angle-callout" data-callout="backrest_included_angle" hidden><span>靠背-坐垫夹角</span><b>104°</b><small>本轮指定夹角</small></div>
<div class="twin-callout included-angle-callout" data-callout="legrest_included_angle" hidden><span>坐垫-腿托夹角</span><b>95°</b><small>本轮指定夹角</small></div>
</div>
<div class="twin-orientation"><i data-lucide="move-down"></i><span>车头方向</span></div>
<aside class="twin-readout" aria-live="polite">
<div class="twin-readout-heading">
<div><strong id="twinSeatName">副驾</strong><span id="twinSeatZone">front_right</span></div>
<span class="twin-feedback"><i></i>FEEDBACK</span>
</div>
<div class="twin-axis-list">
<div data-twin-axis="longitudinal"><span>水平位置</span><b>50%</b><i></i></div>
<div data-twin-axis="cushion_tilt"><span>坐垫俯仰</span><b>50%</b><i></i></div>
<div data-twin-axis="backrest_recline"><span>靠背夹角</span><b>20% · 104°</b><i></i></div>
<div data-twin-axis="legrest_height"><span>腿托夹角</span><b>0% · 95°</b><i></i></div>
<div data-twin-axis="legrest_extension"><span>腿托伸缩</span><b>0%</b><i></i></div>
</div>
<div class="twin-thermal-row" aria-label="座椅热舒适状态">
<span data-twin-function="ventilation" data-level="0">
<i data-lucide="wind"></i>
<span class="twin-thermal-copy"><small>通风</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
<span data-twin-function="heating" data-level="0">
<i data-lucide="flame"></i>
<span class="twin-thermal-copy"><small>加热</small><b>关闭</b></span>
<span class="twin-level-meter" aria-hidden="true"><i></i><i></i><i></i></span>
</span>
</div>
</aside>
<div class="twin-fallback" id="twinFallback"><i data-lucide="loader-circle"></i><span>正在加载整车参考模型</span></div>
</div>
</section>
<section class="state-band">
<div class="seat-state-section">
<div class="section-title-row">
<div><span class="section-kicker">ACTUATOR STATE</span><h3>前排座椅状态</h3></div>
<span class="simulation-label" id="seatFeedbackModeLabel">SIMULATED FEEDBACK</span>
</div>
<div class="seat-grid">
<article class="seat-card" data-seat="driver">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>主驾</strong><span>front_left</span></div></div>
<div class="thermal-states">
<span data-function="ventilation"><i data-lucide="wind"></i>通风 <b>--</b></span>
<span data-function="heating"><i data-lucide="flame"></i>加热 <b>--</b></span>
</div>
<div class="motion-state-grid">
<div class="motion-axis" data-axis="longitudinal"><span>水平</span><b>--</b><i></i></div>
<div class="motion-axis" data-axis="cushion_tilt"><span>坐垫俯仰</span><b>--</b><i></i></div>
<div class="motion-axis" data-axis="backrest_recline"><span>靠背</span><b>--</b><i></i></div>
<div class="motion-axis" data-axis="legrest_height"><span>腿托升降</span><b>--</b><i></i></div>
<div class="motion-axis" data-axis="legrest_extension"><span>腿托伸缩</span><b>--</b><i></i></div>
</div>
</article>
<article class="seat-card" data-seat="passenger">
<div class="seat-identity"><i data-lucide="armchair"></i><div><strong>副驾</strong><span>front_right</span></div></div>
<div class="thermal-states">
<span data-function="ventilation"><i data-lucide="wind"></i>通风 <b>--</b></span>
<span data-function="heating"><i data-lucide="flame"></i>加热 <b>--</b></span>
</div>
<div class="motion-state-grid">
<div class="motion-axis" data-axis="longitudinal"><span>水平</span><b>--</b><i></i></div>
<div class="motion-axis" data-axis="cushion_tilt"><span>坐垫俯仰</span><b>--</b><i></i></div>
<div class="motion-axis" data-axis="backrest_recline"><span>靠背</span><b>--</b><i></i></div>
<div class="motion-axis" data-axis="legrest_height"><span>腿托升降</span><b>--</b><i></i></div>
<div class="motion-axis" data-axis="legrest_extension"><span>腿托伸缩</span><b>--</b><i></i></div>
</div>
</article>
</div>
</div>
<div class="coverage-section">
<span class="section-kicker">MATRIX COVERAGE</span>
<h3>当前控制边界</h3>
<dl>
<div><dt>空调 18–32℃ / 0–7 档</dt><dd class="available">可编译</dd></div>
<div><dt>天幕 / 遮阳帘 0–100%</dt><dd class="available">可编译</dd></div>
<div><dt>氛围灯 RGB / 亮度</dt><dd class="available">可编译</dd></div>
<div><dt>副驾座椅统一执行器</dt><dd class="simulated">t2g_primary_seat</dd></div>
<div><dt>真实台架 CAN 下发</dt><dd class="blocked" id="hardwareCoverage">未配置</dd></div>
</dl>
</div>
</section>
<section class="detail-section result-workspace" aria-labelledby="resultWorkspaceTitle">
<header class="result-workspace-heading">
<div><span class="section-kicker">ORCHESTRATION WORKSPACE</span><h3 id="resultWorkspaceTitle">编排结果工作台</h3><p>四类结果在同一页面连续呈现,也可以通过下方入口快速定位。</p></div>
<span class="result-workspace-badge"><i data-lucide="route"></i>REQUEST → CONFIG → CAN → RESULT</span>
</header>
<nav class="tabs result-nav" aria-label="结果快速导航">
<button class="tab active" type="button" data-tab="trace"><span class="tab-index">01</span><i data-lucide="list-tree"></i><span class="tab-copy"><strong>决策轨迹</strong><small id="traceNavMeta">等待请求</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="config"><span class="tab-index">02</span><i data-lucide="layout-grid"></i><span class="tab-copy"><strong>配置卡片</strong><small id="configNavMeta">等待生成</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="plan"><span class="tab-index">03</span><i data-lucide="binary"></i><span class="tab-copy"><strong>CAN 计划</strong><small id="planNavMeta">等待编译</small></span><i data-lucide="arrow-down"></i></button>
<button class="tab" type="button" data-tab="result"><span class="tab-index">04</span><i data-lucide="circle-check-big"></i><span class="tab-copy"><strong>执行结果</strong><small id="resultNavMeta">尚未执行</small></span><i data-lucide="arrow-down"></i></button>
</nav>
<div class="tab-panel active" id="panel-trace" role="tabpanel">
<div class="workspace-panel-title"><div><span class="section-kicker">01 · DECISION TRACE</span><h3>决策轨迹与校验</h3><p>从自然语言识别到领域路由,集中查看每一步决策依据。</p></div><span class="workspace-panel-state" id="tracePanelState">IDLE</span></div>
<div class="trace-layout">
<div>
<div class="detail-heading"><h3>可审计决策摘要</h3><span id="providerUsed">--</span></div>
<ol class="decision-list" id="decisionList"><li>提交请求后显示识别目标、能力选择和约束来源。</li></ol>
</div>
<div>
<div class="detail-heading"><h3>阶段事件</h3><span id="traceDuration">0 ms</span></div>
<div class="trace-list" id="traceList"><div class="empty-line">暂无执行事件</div></div>
</div>
</div>
<div class="validation-block">
<div class="detail-heading"><h3>校验结果</h3><span id="validationCount">0 条</span></div>
<div id="validationList" class="validation-list"><div class="empty-line">等待配置生成</div></div>
</div>
</div>
<div class="tab-panel config-panel" id="panel-config" role="tabpanel">
<div class="config-panel-heading">
<div><span class="section-kicker">02 · INSTRUCTION → HARDWARE</span><h3>指令配置与硬件映射</h3><p>每张卡片对应一个控制对象,依次展示目标参数和最终硬件计划。</p></div>
<div class="config-actions"><button class="config-action" type="button" data-copy="config"><i data-lucide="copy"></i>复制 YAML</button><button class="config-action" type="button" data-download="config"><i data-lucide="download"></i>下载配置</button></div>
</div>
<div class="config-card-grid" id="configCards">
<div class="config-empty"><i data-lucide="panel-top-dashed"></i><strong>等待生成配置</strong><span>提交车舱控制指令后,这里会显示控制对象、目标参数和硬件信号。</span></div>
</div>
<details class="config-source-details">
<summary><span><i data-lucide="braces"></i>查看原始配置</span><small id="configFileName">functions/seat.yaml</small></summary>
<pre id="configCode"><code># 等待生成 FunctionConfig</code></pre>
</details>
</div>
<div class="tab-panel plan-panel" id="panel-plan" role="tabpanel">
<div class="workspace-panel-title plan-panel-heading"><div><span class="section-kicker">03 · CAN COMMAND PLAN</span><h3>CAN 编译计划</h3><p>快速核对帧数量、首帧载荷、反馈帧和执行模式。</p></div><button class="config-action" type="button" data-copy="plan"><i data-lucide="copy"></i>复制计划</button></div>
<div class="frame-summary" id="frameSummary"><span>Frame ID</span><b>--</b><span>Payload</span><code>--</code><span>Mode</span><b>DRY-RUN</b></div>
<details class="config-source-details plan-source-details">
<summary><span><i data-lucide="binary"></i>查看原始 CAN 计划</span><small id="planFileName">command-plans/seat.yaml</small></summary>
<pre id="planCode"><code># 等待 CAN 编译</code></pre>
</details>
</div>
<div class="tab-panel result-panel" id="panel-result" role="tabpanel">
<div class="workspace-panel-title"><div><span class="section-kicker">04 · EXECUTION RESULT</span><h3>执行结果与硬件回读</h3><p>汇总每个领域的执行状态、CAN 回读和失败原因。</p></div><span class="workspace-panel-state" id="resultPanelState">IDLE</span></div>
<div class="execution-summary" id="executionSummary">
<i data-lucide="circle-dashed"></i><div><strong>尚未执行</strong><span>运行结果将在此处显示</span></div>
</div>
<div class="result-list" id="resultList"></div>
</div>
</section>
</main>
</div>
<div class="gesture-backdrop" id="gestureBackdrop" hidden></div>
<aside class="gesture-drawer" id="gestureDrawer" aria-hidden="true" aria-labelledby="gestureDrawerTitle">
<header class="gesture-drawer-header">
<div>
<span class="section-kicker">VISION CONTROL</span>
<h2 id="gestureDrawerTitle">手势控制</h2>
<p>本机识别,动作通过当前编排链路执行。</p>
</div>
<button class="gesture-close" id="gestureCloseButton" type="button" aria-label="关闭手势控制"><i data-lucide="x"></i></button>
</header>
<div class="gesture-status-strip">
<span class="gesture-state-pill idle" id="gestureStatePill"><i></i><b>未启动</b></span>
<span id="gestureSafetyText">摄像头尚未开启</span>
</div>
<div class="gesture-camera-stage">
<video id="gestureVideo" playsinline muted></video>
<canvas id="gestureCanvas"></canvas>
<div class="gesture-camera-empty" id="gestureCameraEmpty">
<span><i data-lucide="scan-face"></i></span>
<strong>用手势控制副驾驶座椅</strong>
<p>画面只在本机处理,不上传视频。</p>
</div>
<div class="gesture-live-badge" id="gestureLiveBadge" hidden><i></i>LIVE</div>
</div>
<div class="gesture-now">
<div><span>当前手势</span><strong id="gestureDetected">—</strong></div>
<i data-lucide="arrow-right"></i>
<div><span>期望动作</span><strong id="gestureExpected">等待启动</strong></div>
</div>
<div class="gesture-progress"><i id="gestureProgressBar"></i></div>
<div class="gesture-actions">
<button class="gesture-primary" id="gestureStartButton" type="button"><i data-lucide="camera"></i><span>开启摄像头</span></button>
<button id="gestureArmButton" type="button" disabled><i data-lucide="hand"></i>激活控制</button>
<button id="gestureStopButton" type="button" disabled><i data-lucide="octagon"></i>停止</button>
</div>
<section class="gesture-guide-card">
<div class="gesture-card-heading"><div><span>动作速查</span><small>张掌激活 · 握拳只停止</small></div><button id="gestureCustomizeButton" type="button"><i data-lucide="sliders-horizontal"></i>自定义</button></div>
<div class="gesture-map-grid" id="gestureMapGrid"></div>
<div class="gesture-customize" id="gestureCustomizePanel" hidden>
<div><span>识别到</span><strong id="gestureCustomizeToken">请先摆出手势</strong></div>
<select id="gestureActionSelect" aria-label="选择座椅动作"></select>
<button id="gestureBindButton" type="button">绑定当前手势</button>
<button id="gestureResetMapping" type="button">恢复默认</button>
</div>
</section>
<footer class="gesture-footnote"><i data-lucide="shield-check"></i><span>换动作无需重新张掌;连续 3 秒无有效动作后自动锁止。</span></footer>
</aside>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
:root {
color: #17232b;
background: #e8efed;
font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif;
letter-spacing: 0;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
linear-gradient(rgba(217, 229, 225, 0.7) 1px, transparent 1px),
linear-gradient(90deg, rgba(217, 229, 225, 0.7) 1px, transparent 1px),
#eef3f1;
background-size: 34px 34px;
}
.login-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login-panel {
width: min(420px, 100%);
padding: 28px;
background: #fff;
border: 1px solid #cbd7d3;
border-top: 4px solid #08766e;
box-shadow: 0 18px 50px rgba(24, 42, 48, 0.12);
}
.login-brand {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 24px;
border-bottom: 1px solid #d9e1de;
}
.login-brand > span {
width: 42px;
height: 42px;
display: grid;
place-items: center;
color: #fff;
background: #08766e;
border-radius: 5px;
font-size: 13px;
font-weight: 800;
}
.login-brand strong,
.login-brand small {
display: block;
}
.login-brand strong {
font-size: 18px;
}
.login-brand small {
margin-top: 4px;
color: #67747b;
font: 8px "SFMono-Regular", Consolas, monospace;
}
.login-heading {
margin: 30px 0 24px;
}
.login-heading p {
margin: 0 0 7px;
color: #08766e;
font: 750 9px "SFMono-Regular", Consolas, monospace;
}
.login-heading h1 {
margin: 0;
font-size: 24px;
}
form {
display: grid;
}
label {
margin: 0 0 7px;
color: #4f5f66;
font-size: 11px;
font-weight: 700;
}
input {
width: 100%;
height: 44px;
margin-bottom: 18px;
padding: 0 12px;
color: #17232b;
background: #f7f9f8;
border: 1px solid #bbc8c4;
border-radius: 4px;
font: inherit;
letter-spacing: 0;
}
input:focus {
outline: 3px solid rgba(50, 105, 168, 0.16);
border-color: #3269a8;
}
.login-message {
min-height: 17px;
margin: -4px 0 10px;
color: #bd3f49;
font-size: 11px;
}
button {
height: 44px;
color: #fff;
background: #08766e;
border: 0;
border-radius: 4px;
font: inherit;
font-weight: 750;
letter-spacing: 0;
cursor: pointer;
}
button:hover {
background: #075c57;
}
button:disabled {
opacity: 0.65;
cursor: wait;
}
footer {
display: flex;
align-items: center;
gap: 8px;
margin-top: 26px;
color: #67747b;
font: 8px "SFMono-Regular", Consolas, monospace;
}
footer i {
width: 7px;
height: 7px;
border-radius: 50%;
background: #08766e;
box-shadow: 0 0 0 4px rgba(8, 118, 110, 0.12);
}
@media (max-width: 460px) {
.login-shell {
padding: 14px;
}
.login-panel {
padding: 22px;
}
}
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<title>登录 · Cognitive Agent</title>
<link rel="stylesheet" href="/login.css" />
<script src="/login.js" defer></script>
</head>
<body>
<main class="login-shell">
<section class="login-panel" aria-labelledby="loginTitle">
<div class="login-brand">
<span aria-hidden="true">CA</span>
<div>
<strong>Cognitive Agent</strong>
<small>SEAT DOMAIN · CONTROL CONSOLE</small>
</div>
</div>
<div class="login-heading">
<p>RESTRICTED ACCESS</p>
<h1 id="loginTitle">体验账号登录</h1>
</div>
<form id="loginForm">
<label for="username">用户名</label>
<input id="username" name="username" type="text" maxlength="64" autocomplete="username" required autofocus />
<label for="password">密码</label>
<input id="password" name="password" type="password" maxlength="256" autocomplete="current-password" required />
<p class="login-message" id="loginMessage" role="alert" aria-live="polite"></p>
<button id="loginButton" type="submit">
<span>登录控制台</span>
</button>
</form>
<footer>
<i aria-hidden="true"></i>
<span>DRY-RUN · HARDWARE CAN DISABLED</span>
</footer>
</section>
</main>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
function nextPath() {
const candidate = new URLSearchParams(window.location.search).get("next");
return candidate?.startsWith("/") && !candidate.startsWith("//") ? candidate : "/";
}
async function readAuthStatus() {
const response = await fetch("/api/auth/status", { cache: "no-store" });
if (!response.ok) return;
const status = await response.json();
if (status.authenticated) window.location.replace(nextPath());
}
async function submitLogin(event) {
event.preventDefault();
const button = document.querySelector("#loginButton");
const message = document.querySelector("#loginMessage");
button.disabled = true;
message.textContent = "";
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: document.querySelector("#username").value,
password: document.querySelector("#password").value,
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.message || "登录失败");
window.location.replace(nextPath());
} catch (error) {
message.textContent = error.message;
document.querySelector("#password").select();
} finally {
button.disabled = false;
}
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelector("#loginForm").addEventListener("submit", submitLogin);
readAuthStatus().catch(() => {});
});
+4324
View File
File diff suppressed because it is too large Load Diff