init
@@ -0,0 +1,4 @@
|
||||
# Applications
|
||||
|
||||
`apps/` 只负责进程入口和用户接口,不承载领域逻辑。API 和 Web 共享 `contracts/`,通过 application use case 调用中央运行时。
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Orchestrator API
|
||||
|
||||
建议采用 FastAPI 实现,进程入口最终放在本目录,业务逻辑放在 `src/cognitive_agent/`。
|
||||
|
||||
首期 API 边界:
|
||||
|
||||
- `POST /v1/requests`:提交一句话请求,只创建任务。
|
||||
- `GET /v1/requests/{request_id}`:查询意图、配置、校验和执行状态。
|
||||
- `GET /v1/requests/{request_id}/events`:SSE 推送 `TraceEvent`。
|
||||
- `POST /v1/requests/{request_id}/confirm`:处理歧义或高风险动作确认。
|
||||
- `POST /v1/requests/{request_id}/dry-run`:编译并使用虚拟 CAN 执行。
|
||||
- `POST /v1/requests/{request_id}/execute`:显式请求 HIL/实车执行,仍受平台和权限门禁。
|
||||
- `GET /v1/requests/{request_id}/artifacts/{name}`:读取允许公开的配置/报告。
|
||||
|
||||
API 不返回模型私有思维链;事件接口返回结构化阶段、摘要、证据引用和结果。
|
||||
|
||||
座椅阶段一已实现轻量同源演示 API:
|
||||
|
||||
- `GET /api/health`
|
||||
- `GET /api/runtime`
|
||||
- `POST /api/requests`,body 为 `{"utterance": "...", "provider": "openai|local|auto"}`
|
||||
- `GET /api/requests/{request_id}`
|
||||
|
||||
这是阶段一开发入口;后续持久任务、SSE、确认和真实执行端点仍按上述正式 API 边界演进。
|
||||
|
||||
@@ -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。关闭抽屉、页面隐藏或离开页面时会立即停止摄像头轨道。
|
||||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 795 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 226 B |
|
After Width: | Height: | Size: 223 B |
|
After Width: | Height: | Size: 223 B |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 543 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 302 KiB |
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(() => {});
|
||||
});
|
||||