feat: integrate production cockpit AI assistant

This commit is contained in:
vee
2026-09-08 17:46:07 +08:00
parent 5e228e12ae
commit 93103c2579
66 changed files with 11046 additions and 374 deletions
+2 -2
View File
@@ -8,7 +8,7 @@ Web 控制台是车机端的远程自编排与同步显示界面。网页保留
- AI助手默认展开;左侧控制台、车端设备状态、外设与扩展能力默认收起。四个板块统一通过页面右上角的图标按钮展开或收回,主工作区随面板状态自适应。
- AI助手支持普通问答、工程能力核对和车控工作。能力问答读取运行时权威目录,高置信度冷热请求走确定性快速控制链,其他问题和复杂车控由 Codex App Server 流式处理;车控结果仍以自然语言和可审计的动作进度呈现。
- 网页端可输入一句话指令或点击预设模式,车机端同样可发起任务;两端任务与结果统一同步。
- AI助手语音入口支持双模式:手动点击麦克风按钮会立即以 `manual` 模式录音,不要求唤醒词;浏览器已授权麦克风、助手展开且页面可见时自动进入 `wake` 待机,只有听到“你好,双十一”(兼容无标点与“双11”)后才展示并继续转录。AudioWorklet 采集后,服务端 SenseVoiceSmall 以节流预览返回未语义清洗的实时文字;静音端点立即停止采音,再由 Silero VAD、幂等 TranscriptAssembler 和理解层生成最终 `assistant_text`。普通问答、未知能力、否定和撤销表达均可回填,`commands` 仅作为受信任车控旁路,不能反向过滤助手输入。
- AI助手语音入口支持双模式:手动点击麦克风按钮会立即以 `manual` 模式录音,不要求唤醒词;浏览器已授权麦克风、助手展开且页面可见时自动进入 `wake` 待机,只有听到“你好,小迪”(兼容无标点与字间停顿)后才展示并继续转录。AudioWorklet 采集后,服务端 SenseVoiceSmall 以节流预览返回未语义清洗的实时文字;静音端点立即停止采音,再由 Silero VAD、幂等 TranscriptAssembler 和理解层生成最终 `assistant_text`。普通问答、未知能力、否定和撤销表达均可回填,`commands` 仅作为受信任车控旁路,不能反向过滤助手输入。
- 整车数字孪生位于工作区中央,点击或悬停热点可查看车窗、遮阳帘、主/副驾座椅、阅读灯、氛围灯、前大灯和空调的完整状态。
- 车端设备状态与外设能力板块均可独立隐藏或恢复,面板状态在当前浏览器会话中保留。
- USB 外设接入后同步显示 GSCU 链路、外设名称、类型、已学习能力和最近一次跨设备联动。
@@ -22,7 +22,7 @@ UI 只展示可审计的“决策摘要”和“处理轨迹”,不展示模
- `GET /api/assistant/status`:返回 App Server 状态、可用性和当前会话数。
- `GET /api/assistant/capabilities`:返回由运行时注册表、模式目录、CAN 绑定和已学习外设共同生成的权威能力快照。`service_surfaces` 只提供语音、手势、NFC、自标定和自修复的状态信息,不代表助手可调用;执行资格以 `assistant_callable` 和服务端校验为准。
- `POST /api/assistant/stream`:提交 `message`、`conversation_id`、执行模式和当前车型等上下文,返回 `application/x-ndjson`。前端消费 `message.accepted`、`work.*`、`action.*`、`answer.delta`、`answer.completed` 与 `error` 事件,以增量方式展示回复和控制进度。
- `POST /api/assistant/stream`:提交 `message`、`conversation_id`、执行模式和当前车型等上下文,返回 `application/x-ndjson`。前端消费 `message.accepted`、`work.*`、`action.*`、`answer.delta`、`control.suggestions`、`answer.completed` 与 `error` 事件,以增量方式展示回复和控制进度。不支持的车控请求可随 `control.suggestions` 返回当前车型可执行的替代指令;前端去重后渲染为按钮卡片,点击会作为新指令沿同一受控链路提交。
`POST /api/internal/codex/vehicle-control` 是 App Server MCP 子进程专用的回环接口,要求一次性内部令牌且与原始用户文本绑定,不是浏览器或第三方集成接口。浏览器也不能通过响应内容扩大执行目标、切换执行模式或提交原始 CAN。
+337 -20
View File
@@ -4779,10 +4779,17 @@ function modeSecondsLabel(durationMs) {
}
const MODE_GALLERY_GROUPS = [
{
id: "custom",
title: "自定义模式",
description: "由用户创建或调整,可继续编辑与删除",
icon: "wand-sparkles",
preferredIds: [],
},
{
id: "static",
title: "静态模式",
description: "一次执行并保持目标状态,包含预设与自定义模式",
description: "系统预设,一次执行并保持目标状态",
icon: "layout-grid",
preferredIds: [
"zero_gravity",
@@ -4820,6 +4827,126 @@ const MODE_DOMAIN_LABELS = {
vehicle_status: "车辆状态",
};
const MODE_TARGET_LABELS = {
driver: "主驾",
passenger: "副驾",
front_left: "左前",
front_right: "右前 / 副驾",
rear_left: "左后",
rear_right: "右后",
front_row: "前排",
rear_row: "后排",
vehicle: "全车",
cabin: "全舱",
all: "全部",
};
const MODE_TIMELINE_TARGET_LABELS = {
longitudinal: "前后位置",
cushion_tilt: "坐垫角度",
backrest_recline: "靠背角度",
legrest_height: "腿托高度",
legrest_extension: "腿托伸缩",
};
function modeOperationParameters(operation) {
const parameters = operation?.parameters;
if (Array.isArray(parameters)) {
return parameters
.filter((item) => item && typeof item === "object" && item.name)
.map((item) => [String(item.name), item.value]);
}
if (!parameters || typeof parameters !== "object") return [];
return Object.entries(parameters);
}
function formatModeOperationValue(name, value) {
const valueLabels = {
on: "开启",
off: "关闭",
open: "打开",
close: "关闭",
stop: "停止",
internal: "内循环",
external: "外循环",
};
if (typeof value === "string" && valueLabels[value]) return valueLabels[value];
return formatConfigValue(name, value);
}
function modeOperationView(operation) {
const domain = String(operation?.domain || "");
const capability = String(operation?.capability || operation?.operation || "执行动作");
const scope = String(
operation?.target_scope
|| operation?.target?.instance
|| operation?.target?.zone
|| operation?.target
|| "vehicle",
);
const parameters = modeOperationParameters(operation)
.map(([name, value]) => {
const label = CONFIG_PARAMETER_LABELS[name] || name;
return `${label}:${formatModeOperationValue(name, value)}`;
})
.join(",");
return {
device: MODE_DOMAIN_LABELS[domain] || domain || "车端设备",
target: MODE_TARGET_LABELS[scope] || scope || "全车",
action: CONFIG_CAPABILITY_LABELS[capability] || capability,
parameters: parameters || "无附加参数",
};
}
function modeTimelineTargetSummary(targets) {
if (!targets || typeof targets !== "object") return "按模式目标运行";
return Object.entries(targets).map(([motor, target]) => {
const label = MODE_TIMELINE_TARGET_LABELS[motor] || motor;
if (!target || typeof target !== "object") return `${label}:${target}`;
if (target.position_pct != null) return `${label}:${target.position_pct}%`;
if (target.angle_deg != null) return `${label}:${target.angle_deg}°`;
if (target.hall_pulse != null) return `${label}:${target.hall_pulse} Hall`;
return `${label}:${Object.values(target).join(" / ")}`;
}).join(",");
}
function modeTimelineStepView(step, action) {
const timeout = Number(step?.timeout_ms || 0);
const duration = Number(step?.duration_ms || 0);
const timing = duration > 0
? `持续 ${modeSecondsLabel(duration)}s`
: timeout > 0
? `目标到位,保护上限 ${modeSecondsLabel(timeout)}s`
: "目标到位";
return {
device: "座椅",
target: "副驾",
action,
parameters: `${modeTimelineTargetSummary(step?.targets)};${timing}`,
};
}
function modeExecutionActionViews(mode) {
const operations = Array.isArray(mode?.operations)
? mode.operations.map(modeOperationView)
: [];
const timeline = mode?.timeline;
if (!timeline || typeof timeline !== "object") return operations;
const timelineSteps = [];
if (timeline.entry?.targets) {
timelineSteps.push(modeTimelineStepView(timeline.entry, "进入动态模式初始位置"));
}
if (Array.isArray(timeline.phases)) {
timeline.phases.forEach((phase, index) => {
timelineSteps.push(modeTimelineStepView(
phase,
`循环阶段 ${index + 1}:${phase?.name || phase?.id || "动态调节"}`,
));
});
}
return [...operations, ...timelineSteps];
}
function isUserModeProfile(mode) {
return mode?.origin === "user" || mode?.deletable === true;
}
@@ -4853,9 +4980,11 @@ function modeGalleryBuckets() {
...definition,
modes: orderModeGalleryProfiles(
modes.filter((mode) => (
definition.id === "dynamic"
? isDynamicModeProfile(mode)
: !isDynamicModeProfile(mode)
definition.id === "custom"
? isUserModeProfile(mode)
: definition.id === "dynamic"
? !isUserModeProfile(mode) && isDynamicModeProfile(mode)
: !isUserModeProfile(mode) && !isDynamicModeProfile(mode)
)),
definition.preferredIds,
),
@@ -4978,14 +5107,20 @@ function createModeGalleryEmpty(group) {
empty.dataset.modeGroupEmpty = group.id;
empty.setAttribute("role", "status");
const icon = document.createElement("i");
icon.dataset.lucide = group.id === "dynamic" ? "activity" : "layout-grid";
icon.dataset.lucide = group.id === "custom"
? "wand-sparkles"
: group.id === "dynamic"
? "activity"
: "layout-grid";
const copy = document.createElement("span");
const title = document.createElement("strong");
title.textContent = `暂无${group.title}`;
const description = document.createElement("small");
description.textContent = group.id === "dynamic"
? "支持持续变化的模式会显示在这里"
: "可通过 AI助手创建新的静态模式";
description.textContent = group.id === "custom"
? "可通过 AI助手创建新的功能模式"
: group.id === "dynamic"
? "支持持续变化的系统模式会显示在这里"
: "系统静态预设会显示在这里";
copy.append(title, description);
empty.append(icon, copy);
return empty;
@@ -5020,7 +5155,7 @@ function populateModeGallery() {
const grid = document.createElement("div");
grid.className = "mode-gallery-grid";
if (group.id === "static") grid.append(createModeGalleryAddCard());
if (group.id === "custom") grid.append(createModeGalleryAddCard());
group.modes.forEach((mode) => grid.append(createModeGalleryCard(mode)));
if (!group.modes.length) grid.append(createModeGalleryEmpty(group));
section.append(grid);
@@ -5042,6 +5177,7 @@ function setModeRailExpanded(expanded) {
toggle.title = open ? "收起功能模式" : "展开功能模式";
content.hidden = !open;
$(".mode-rail")?.classList.toggle("is-collapsed", !open);
if (!open) hideModeActionPopover({ immediate: true });
const icon = $("i", toggle);
if (icon) icon.dataset.lucide = open ? "chevron-up" : "chevron-down";
refreshIcons();
@@ -5816,6 +5952,168 @@ function installModeChipScroller() {
window.requestAnimationFrame(updateScrollState);
}
let modeActionPopoverHideTimer = null;
let modeActionPopoverAnchor = null;
function ensureModeActionPopover() {
let popover = $("#modeChipActionPopover");
if (popover) return popover;
popover = document.createElement("aside");
popover.id = "modeChipActionPopover";
popover.className = "mode-chip-action-popover";
popover.setAttribute("role", "tooltip");
popover.hidden = true;
const header = document.createElement("header");
const headingCopy = document.createElement("span");
const eyebrow = document.createElement("small");
eyebrow.textContent = "执行动作预览";
const title = document.createElement("strong");
title.dataset.modeActionTitle = "true";
headingCopy.append(eyebrow, title);
const count = document.createElement("b");
count.dataset.modeActionCount = "true";
header.append(headingCopy, count);
const list = document.createElement("ol");
list.dataset.modeActionList = "true";
popover.append(header, list);
document.body.append(popover);
return popover;
}
function renderModeActionPopover(mode) {
const popover = ensureModeActionPopover();
const actions = modeExecutionActionViews(mode);
$("[data-mode-action-title]", popover).textContent = mode?.name || mode?.id || "功能模式";
$("[data-mode-action-count]", popover).textContent = actions.length
? `${actions.length} 步`
: `${Number(mode?.operation_count || 0)} 项`;
const list = $("[data-mode-action-list]", popover);
list.replaceChildren();
if (!actions.length) {
const empty = document.createElement("li");
empty.className = "mode-chip-action-empty";
empty.textContent = "执行动作明细暂未加载,请刷新后重试。";
list.append(empty);
return popover;
}
actions.forEach((action, index) => {
const item = document.createElement("li");
const step = document.createElement("span");
step.className = "mode-chip-action-step";
step.textContent = String(index + 1).padStart(2, "0");
const copy = document.createElement("span");
copy.className = "mode-chip-action-copy";
const actionName = document.createElement("strong");
actionName.textContent = action.action;
const metadata = document.createElement("small");
const device = document.createElement("span");
device.innerHTML = "<b>设备</b>";
device.append(document.createTextNode(action.device));
const target = document.createElement("span");
target.innerHTML = "<b>目标</b>";
target.append(document.createTextNode(action.target));
const parameters = document.createElement("span");
parameters.innerHTML = "<b>参数</b>";
parameters.append(document.createTextNode(action.parameters));
metadata.append(device, target, parameters);
copy.append(actionName, metadata);
item.append(step, copy);
list.append(item);
});
return popover;
}
function positionModeActionPopover(anchor, popover) {
const margin = 12;
const gap = 8;
const anchorRect = anchor.getBoundingClientRect();
const popoverRect = popover.getBoundingClientRect();
const maximumLeft = Math.max(margin, window.innerWidth - popoverRect.width - margin);
const left = Math.max(
margin,
Math.min(maximumLeft, anchorRect.left + (anchorRect.width - popoverRect.width) / 2),
);
const availableBelow = window.innerHeight - anchorRect.bottom - margin;
const availableAbove = anchorRect.top - margin;
const placeAbove = availableBelow < Math.min(popoverRect.height, 320)
&& availableAbove > availableBelow;
const desiredTop = placeAbove
? anchorRect.top - popoverRect.height - gap
: anchorRect.bottom + gap;
const maximumTop = Math.max(margin, window.innerHeight - popoverRect.height - margin);
popover.style.left = `${Math.max(margin, Math.min(maximumLeft, left))}px`;
popover.style.top = `${Math.max(margin, Math.min(maximumTop, desiredTop))}px`;
popover.classList.toggle("is-above", placeAbove);
}
function showModeActionPopover(button) {
const mode = modeProfiles().find((item) => item.id === button?.dataset.modeProfileId);
if (!mode) return;
window.clearTimeout(modeActionPopoverHideTimer);
modeActionPopoverHideTimer = null;
const popover = renderModeActionPopover(mode);
modeActionPopoverAnchor = button;
button.setAttribute("aria-describedby", popover.id);
popover.hidden = false;
window.requestAnimationFrame(() => {
if (!popover.hidden && modeActionPopoverAnchor === button) {
positionModeActionPopover(button, popover);
}
});
}
function hideModeActionPopover({ immediate = false } = {}) {
window.clearTimeout(modeActionPopoverHideTimer);
const hide = () => {
const popover = $("#modeChipActionPopover");
if (popover) popover.hidden = true;
modeActionPopoverAnchor = null;
modeActionPopoverHideTimer = null;
};
if (immediate) hide();
else modeActionPopoverHideTimer = window.setTimeout(hide, 90);
}
function installModeActionPreview() {
const group = $(".mode-chips");
if (!group || group.dataset.modeActionPreviewBound === "true") return;
group.dataset.modeActionPreviewBound = "true";
const resolveButton = (event) => {
const target = event.target instanceof Element ? event.target : null;
return target?.closest("button[data-mode-profile-id]");
};
group.addEventListener("pointerover", (event) => {
const button = resolveButton(event);
if (button && !button.contains(event.relatedTarget)) showModeActionPopover(button);
});
group.addEventListener("pointerout", (event) => {
const button = resolveButton(event);
if (button && !button.contains(event.relatedTarget)) hideModeActionPopover();
});
group.addEventListener("focusin", (event) => {
const button = resolveButton(event);
if (button) showModeActionPopover(button);
});
group.addEventListener("focusout", (event) => {
const button = resolveButton(event);
if (button && !button.contains(event.relatedTarget)) hideModeActionPopover();
});
group.addEventListener("scroll", () => hideModeActionPopover({ immediate: true }), { passive: true });
group.addEventListener("pointerdown", () => hideModeActionPopover({ immediate: true }));
group.addEventListener("keydown", (event) => {
if (event.key === "Escape") hideModeActionPopover({ immediate: true });
});
const popover = ensureModeActionPopover();
popover.addEventListener("pointerenter", () => {
window.clearTimeout(modeActionPopoverHideTimer);
modeActionPopoverHideTimer = null;
});
popover.addEventListener("pointerleave", () => hideModeActionPopover());
window.addEventListener("resize", () => hideModeActionPopover({ immediate: true }), { passive: true });
}
function populateModeChips() {
const group = $(".mode-chips");
if (!group) return;
@@ -5857,6 +6155,12 @@ function populateModeChips() {
button.dataset.modeCommand = `进入${mode.name}`;
button.dataset.modeOrigin = mode?.origin || "preset";
button.setAttribute("aria-pressed", "false");
const actionCount = modeExecutionActionViews(mode).length
|| Number(mode.operation_count || 0);
button.setAttribute(
"aria-label",
`${mode.name || mode.id},${actionCount} 项执行动作,点击执行`,
);
let summary = $(".mode-chip-summary", button);
if (!summary) {
summary = document.createElement("small");
@@ -5905,7 +6209,9 @@ function populateModeChips() {
: `${phase.name} 目标到位`;
}).join(" → ")
: "";
button.title = [mode.description || mode.name, phaseSummary].filter(Boolean).join("\n");
button.title = [mode.description || mode.name, phaseSummary, "悬浮或聚焦查看全部执行动作"]
.filter(Boolean)
.join("\n");
});
const preferredStaticOrder = [
@@ -5915,12 +6221,20 @@ function populateModeChips() {
"queen_passenger_driving",
"leisure_lounger",
];
const dynamicModeOrder = ["rocking_horse", "dynamic_cradle"];
const allButtons = buttons();
const userButtons = allButtons.filter((button) => button.dataset.modeOrigin === "user");
const dynamicButtons = dynamicModeOrder
.map((modeId) => allButtons.find((button) => button.dataset.modeProfileId === modeId))
.filter(Boolean);
const profilesById = new Map(modes.map((mode) => [String(mode.id), mode]));
const userButtons = allButtons.filter((button) => {
const mode = profilesById.get(String(button.dataset.modeProfileId || ""));
return mode ? isUserModeProfile(mode) : button.dataset.modeOrigin === "user";
});
const dynamicOrder = new Map([["rocking_horse", 0], ["dynamic_cradle", 1]]);
const dynamicButtons = allButtons.filter((button) => {
const mode = profilesById.get(String(button.dataset.modeProfileId || ""));
return mode && !isUserModeProfile(mode) && isDynamicModeProfile(mode);
}).sort((left, right) => (
(dynamicOrder.get(left.dataset.modeProfileId) ?? Number.MAX_SAFE_INTEGER)
- (dynamicOrder.get(right.dataset.modeProfileId) ?? Number.MAX_SAFE_INTEGER)
));
const staticButtons = allButtons.filter(
(button) => !userButtons.includes(button) && !dynamicButtons.includes(button),
);
@@ -5946,15 +6260,17 @@ function populateModeChips() {
group.append(divider);
};
if (userButtons.length) {
appendGroupLabel("我的模式", "user");
appendGroupLabel("自定义模式", "custom");
userButtons.forEach((button) => group.append(button));
}
if (userButtons.length && staticButtons.length) appendDivider("user");
if (staticButtons.length) appendGroupLabel("预设模式", "preset");
if (userButtons.length && (staticButtons.length || dynamicButtons.length)) {
appendDivider("custom");
}
if (staticButtons.length) appendGroupLabel("静态模式", "static");
preferredStaticButtons.forEach((button) => group.append(button));
otherStaticButtons.forEach((button) => group.append(button));
if (dynamicButtons.length && staticButtons.length) {
appendDivider("dynamic");
if (dynamicButtons.length) {
if (userButtons.length || staticButtons.length) appendDivider("dynamic");
appendGroupLabel("动态模式", "dynamic");
}
dynamicButtons.forEach((button) => group.append(button));
@@ -8423,6 +8739,7 @@ document.addEventListener("DOMContentLoaded", async () => {
selectVehicle(event.target.value);
});
installModeChipScroller();
installModeActionPreview();
$(".mode-chips").addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest("button[data-mode-command]");
+134
View File
@@ -723,6 +723,140 @@
outline: none;
}
.assistant-control-suggestions {
width: 100%;
max-width: 314px;
box-sizing: border-box;
display: grid;
gap: 7px;
padding: 10px;
background: color-mix(in oklch, var(--card) 92%, var(--secondary));
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: 14px;
box-shadow: 0 8px 24px color-mix(in oklch, var(--foreground) 6%, transparent);
}
.assistant-message-control-suggestions {
width: auto;
max-width: none;
align-self: stretch;
}
.assistant-message-control-suggestions .assistant-message-body {
min-width: 0;
flex: 1 1 0;
}
.assistant-control-suggestions-title {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--assistant-muted, #64748b);
font-size: 10px;
font-weight: 760;
}
.assistant-control-suggestions-title svg {
width: 13px;
height: 13px;
color: var(--primary, #275df5);
}
.assistant-control-suggestion-list {
display: grid;
gap: 6px;
}
.assistant-control-suggestion {
width: 100%;
min-height: 46px;
box-sizing: border-box;
display: grid;
grid-template-columns: 30px minmax(0, 1fr) 16px;
align-items: center;
gap: 9px;
padding: 7px 9px;
color: var(--assistant-text, #233044);
text-align: left;
background: var(--card, #fff);
border: 1px solid var(--border, #dce3ed);
border-radius: 10px;
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease,
transform 150ms ease, opacity 150ms ease;
}
.assistant-control-suggestion:hover:not(:disabled) {
background: color-mix(in oklch, var(--primary) 5%, var(--card));
border-color: color-mix(in oklch, var(--primary) 38%, var(--border));
box-shadow: 0 5px 14px color-mix(in oklch, var(--primary) 10%, transparent);
transform: translateY(-1px);
}
.assistant-control-suggestion:focus-visible {
border-color: var(--ring, #275df5);
outline: 3px solid color-mix(in oklch, var(--ring, #275df5) 18%, transparent);
outline-offset: 1px;
}
.assistant-control-suggestion:disabled {
cursor: wait;
opacity: 0.55;
}
.assistant-control-suggestion.is-sending {
color: var(--status-info-foreground, #1741ad);
background: var(--status-info-soft, #edf2ff);
border-color: color-mix(in oklch, var(--status-info, #275df5) 36%, var(--border));
opacity: 1;
}
.assistant-control-suggestion-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
color: var(--primary, #275df5);
background: color-mix(in oklch, var(--primary) 9%, transparent);
border-radius: 9px;
}
.assistant-control-suggestion-icon svg,
.assistant-control-suggestion-arrow {
width: 15px;
height: 15px;
}
.assistant-control-suggestion-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.assistant-control-suggestion-copy strong,
.assistant-control-suggestion-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assistant-control-suggestion-copy strong {
color: inherit;
font-size: 11px;
font-weight: 780;
}
.assistant-control-suggestion-copy small {
color: var(--assistant-muted, #64748b);
font-size: 8px;
font-weight: 650;
}
.assistant-control-suggestion-arrow {
color: var(--assistant-muted, #64748b);
}
.assistant-thinking {
display: flex;
align-items: center;
+174 -10
View File
@@ -5,6 +5,7 @@
const MAX_STORED_MESSAGES = 24;
const COMPOSER_MIN_HEIGHT = 64;
const COMPOSER_MAX_HEIGHT = 116;
const MAX_CONTROL_SUGGESTIONS = 4;
const WELCOME_TEXT = "你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。";
let pendingController = null;
let pendingMessage = null;
@@ -375,6 +376,132 @@
followLatest();
}
function normalizeControlSuggestions(rawSuggestions) {
if (!Array.isArray(rawSuggestions)) return [];
const seenCommands = new Set();
const suggestions = [];
rawSuggestions.forEach((rawSuggestion, index) => {
const source = typeof rawSuggestion === "string"
? { command: rawSuggestion, label: rawSuggestion }
: rawSuggestion;
if (!source || typeof source !== "object") return;
const command = String(source.command || "").trim().replace(/\s+/g, " ");
if (!command || command.length > 160) return;
const commandKey = command.toLocaleLowerCase("zh-CN").replace(/\s+/g, "");
if (seenCommands.has(commandKey)) return;
seenCommands.add(commandKey);
suggestions.push({
id: String(source.id || `suggestion-${index + 1}`).trim(),
label: String(source.label || command).trim().slice(0, 64) || command,
command,
domain: String(source.domain || "").trim().toLowerCase(),
});
});
return suggestions.slice(0, MAX_CONTROL_SUGGESTIONS);
}
function controlSuggestionIcon(domain) {
if (["climate", "hvac"].includes(domain)) return "wind";
if (domain.includes("seat")) return "armchair";
if (["roof", "shade", "sunshade", "window"].includes(domain)) return "panel-top";
if (["light", "lighting", "ambient_light", "interior_light"].includes(domain)) {
return "lightbulb";
}
return "circle-play";
}
function setSuggestionGroupPending(group, activeButton, pending) {
group.classList.toggle("is-sending", pending);
group.setAttribute("aria-busy", String(pending));
group.querySelectorAll("button").forEach((button) => {
button.disabled = pending;
const label = button.querySelector("strong");
if (label && button === activeButton) {
label.textContent = pending ? "正在发送…" : button.dataset.suggestionLabel;
}
button.classList.toggle("is-sending", pending && button === activeButton);
button.setAttribute("aria-busy", String(pending && button === activeButton));
});
}
async function submitSuggestedCommand(command, group, button) {
if (button.disabled) return;
if (pendingController) {
window.CognitiveAgentConsole?.notify?.("请等待当前回答完成后再执行其他指令");
return;
}
setSuggestionGroupPending(group, button, true);
try {
await submitAssistant(command);
} finally {
setSuggestionGroupPending(group, button, false);
}
}
function appendControlSuggestions(message, rawSuggestions) {
if (!message?.body) return;
const suggestions = normalizeControlSuggestions(rawSuggestions);
if (!suggestions.length) return;
message.article?.classList.add("assistant-message-control-suggestions");
const signature = JSON.stringify(suggestions.map(({ id, command }) => [id, command]));
let group = $(".assistant-control-suggestions", message.body);
if (group?.dataset.suggestionSignature === signature) return;
if (!group) {
group = document.createElement("section");
group.className = "assistant-control-suggestions";
group.setAttribute("role", "group");
group.setAttribute("aria-label", "可以尝试的其他控制指令");
message.body.append(group);
}
group.dataset.suggestionSignature = signature;
group.replaceChildren();
const heading = document.createElement("p");
heading.className = "assistant-control-suggestions-title";
const headingIcon = document.createElement("i");
headingIcon.setAttribute("data-lucide", "sparkles");
const headingText = document.createElement("span");
headingText.textContent = "你还可以试试";
heading.append(headingIcon, headingText);
group.append(heading);
const list = document.createElement("div");
list.className = "assistant-control-suggestion-list";
suggestions.forEach((suggestion) => {
const button = document.createElement("button");
button.type = "button";
button.className = "assistant-control-suggestion";
button.dataset.suggestionId = suggestion.id;
button.dataset.suggestionCommand = suggestion.command;
button.dataset.suggestionLabel = suggestion.label;
button.setAttribute("aria-label", `执行控制指令:${suggestion.label}`);
button.setAttribute("aria-busy", "false");
button.title = suggestion.command;
const icon = document.createElement("span");
icon.className = "assistant-control-suggestion-icon";
icon.innerHTML = `<i data-lucide="${controlSuggestionIcon(suggestion.domain)}"></i>`;
const copy = document.createElement("span");
copy.className = "assistant-control-suggestion-copy";
const hint = document.createElement("small");
hint.textContent = "点击直接执行";
const label = document.createElement("strong");
label.textContent = suggestion.label;
copy.append(label, hint);
const arrow = document.createElement("i");
arrow.setAttribute("data-lucide", "arrow-up-right");
arrow.className = "assistant-control-suggestion-arrow";
button.append(icon, copy, arrow);
button.addEventListener("click", () => {
void submitSuggestedCommand(suggestion.command, group, button);
});
list.append(button);
});
group.append(list);
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
followLatest(true);
}
function appendModeCreatedState(message, profile, { updated = false } = {}) {
if (!message?.body || !profile?.id) return;
const selector = `[data-assistant-mode-created="${CSS.escape(String(profile.id))}"]`;
@@ -829,19 +956,52 @@
);
} else if (
event.payload.mode_profile
&& event.payload.orchestration_route === "mode_profile_create"
&& ["mode_profile_create", "mode_profile_update"].includes(
event.payload.orchestration_route,
)
) {
clearActiveModeDraftContext();
appendModeCreatedState(message, event.payload.mode_profile);
if (
event.payload.orchestration_route === "mode_profile_create"
|| normalizeModeName(activeModeDraftContext?.name)
=== normalizeModeName(event.payload.mode_profile.name)
) {
clearActiveModeDraftContext();
}
void window.CognitiveAgentConsole?.refreshModeProfiles?.(
event.payload.mode_profile.id,
);
appendModeCreatedState(message, event.payload.mode_profile, {
updated: event.payload.orchestration_route === "mode_profile_update",
});
} else if (
event.payload.deleted_mode_profile
&& event.payload.orchestration_route === "mode_profile_delete"
) {
if (
normalizeModeName(activeModeDraftContext?.name)
=== normalizeModeName(event.payload.deleted_mode_profile.name)
) {
clearActiveModeDraftContext();
}
void window.CognitiveAgentConsole?.refreshModeProfiles?.();
}
appendControlSuggestions(
message,
event.payload.suggestions || event.payload.recommended_commands,
);
}
return;
}
if (event.type === "control.suggestions") {
appendControlSuggestions(message, event.suggestions || event.recommended_commands);
return;
}
if (event.type === "answer.completed") {
const authoritativeAnswer = Object.prototype.hasOwnProperty.call(event, "answer")
? String(event.answer || "")
: message.content;
setMessageContent(message, authoritativeAnswer, { final: true });
appendControlSuggestions(message, event.suggestions || event.recommended_commands);
return;
}
if (event.type === "error") {
@@ -913,8 +1073,9 @@
}
}
async function submitAssistant() {
async function submitAssistant(messageOverride = null) {
if (pendingController) {
if (typeof messageOverride === "string") return;
pendingController.abort("user_stop");
return;
}
@@ -926,18 +1087,21 @@
window.CognitiveAgentConsole?.notify?.("请等待语音整理完成后再发送");
return;
}
const messageText = input?.value.trim() || "";
const suggestedMessage = typeof messageOverride === "string" ? messageOverride.trim() : "";
const messageText = suggestedMessage || input?.value.trim() || "";
if (!messageText) {
window.CognitiveAgentConsole?.notify?.("请输入问题或工作需求");
return;
}
createMessage("user", messageText);
input.value = "";
delete input.dataset.speechStage;
delete input.dataset.speechUtteranceId;
delete input.dataset.speechValue;
input.dispatchEvent(new Event("input", { bubbles: true }));
if (!suggestedMessage) {
input.value = "";
delete input.dataset.speechStage;
delete input.dataset.speechUtteranceId;
delete input.dataset.speechValue;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
const assistantMessage = createMessage("assistant", "", { pending: true });
pendingMessage = assistantMessage;
latestControlResult = null;
+189
View File
@@ -7101,6 +7101,195 @@ body.device-power-drawer-open {
linear-gradient(165deg, var(--card), var(--secondary));
}
/* Body-level portal: the horizontal mode rail clips its own overflow. */
.mode-chip-action-popover {
width: min(430px, calc(100vw - 24px));
max-height: min(68vh, 520px);
position: fixed;
z-index: 1900;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 9px;
padding: 11px;
overflow: hidden;
color: var(--popover-foreground);
background: color-mix(in oklch, var(--popover) 96%, var(--primary));
border: 1px solid color-mix(in oklch, var(--primary) 24%, var(--border));
border-radius: 14px;
box-shadow:
0 16px 42px color-mix(in oklch, var(--foreground) 18%, transparent),
0 0 0 1px color-mix(in oklch, var(--card) 65%, transparent) inset;
backdrop-filter: blur(16px);
transform-origin: top center;
animation: mode-action-popover-in 140ms ease-out;
}
.mode-chip-action-popover[hidden] {
display: none !important;
}
.mode-chip-action-popover.is-above {
transform-origin: bottom center;
}
.mode-chip-action-popover > header,
.mode-chip-action-popover > header > span {
min-width: 0;
display: flex;
}
.mode-chip-action-popover > header {
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
}
.mode-chip-action-popover > header > span {
align-items: flex-start;
flex-direction: column;
gap: 1px;
}
.mode-chip-action-popover > header small {
color: var(--muted-foreground);
font-size: 8px;
font-weight: 760;
letter-spacing: 0.08em;
}
.mode-chip-action-popover > header strong {
max-width: 330px;
overflow: hidden;
color: var(--heading-foreground);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mode-chip-action-popover > header b {
min-width: 38px;
min-height: 22px;
display: inline-grid;
place-items: center;
padding: 3px 7px;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 10%, var(--card));
border-radius: 999px;
font-size: 9px;
}
.mode-chip-action-popover ol {
min-height: 0;
display: grid;
gap: 5px;
margin: 0;
padding: 0 3px 0 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
list-style: none;
}
.mode-chip-action-popover li {
min-width: 0;
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
align-items: start;
gap: 8px;
padding: 7px 8px;
color: var(--body-foreground);
background: color-mix(in oklch, var(--secondary) 66%, var(--card));
border: 1px solid var(--border);
border-radius: 10px;
}
.mode-chip-action-step {
width: 22px;
height: 22px;
display: inline-grid;
place-items: center;
color: var(--primary);
background: color-mix(in oklch, var(--primary) 9%, var(--card));
border-radius: 7px;
font-size: 8px;
font-weight: 850;
}
.mode-chip-action-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.mode-chip-action-copy > strong {
overflow: hidden;
color: var(--heading-foreground);
font-size: 10px;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.mode-chip-action-copy > small {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 3px 10px;
color: var(--muted-foreground);
font-size: 8px;
line-height: 1.45;
}
.mode-chip-action-copy > small > span {
min-width: 0;
overflow-wrap: anywhere;
}
.mode-chip-action-copy > small b {
margin-right: 3px;
color: var(--primary);
font-size: 7px;
font-weight: 850;
}
.mode-chip-action-empty {
display: block !important;
padding: 13px !important;
color: var(--muted-foreground) !important;
font-size: 9px;
text-align: center;
}
@keyframes mode-action-popover-in {
from {
opacity: 0;
transform: translateY(-3px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 560px) {
.mode-chip-action-popover {
max-height: min(58vh, 430px);
padding: 9px;
}
.mode-chip-action-copy > small {
display: grid;
gap: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.mode-chip-action-popover {
animation: none;
}
}
.console-v2.v2-passive-display.v2-interactive-sync .v2-synced-device,
.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-gateway,
.console-v2.v2-passive-display.v2-interactive-sync #v2ExtensionPanel .v2-extension-synced-device {
+8 -8
View File
@@ -10,23 +10,23 @@
<link rel="icon" href="/icons/app-icon-192.png" />
<title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-23" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-65" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-66" />
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-43" />
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-7" />
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-24" />
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-26" />
<link rel="stylesheet" href="/energy-monitor.css?v=energy-monitor-4" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/three/0.147.0/three.min.js" defer></script>
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-127" defer></script>
<script src="/speech-input.js?v=sensevoice-12" defer></script>
<script src="/app.js?v=dual-seat-128" defer></script>
<script src="/speech-input.js?v=sensevoice-13" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=delivery-state-62" defer></script>
<script src="/self-extension.js?v=gscu-tcp-43" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-24" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-26" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
</head>
<body class="console-v2">
@@ -552,13 +552,13 @@
<header class="mode-gallery-header">
<div>
<h2 id="modeGalleryTitle">管理功能模式</h2>
<p>按运行方式整理,预设与自定义模式统一归类</p>
<p>按来源与运行方式整理,快速定位并管理模式</p>
</div>
<button class="mode-gallery-close" id="modeGalleryClose" type="button" aria-label="关闭功能模式管理"><i data-lucide="x"></i></button>
</header>
<div class="mode-gallery-summary"><span><i data-lucide="layers-3"></i>静态模式 · 动态模式</span><strong id="modeGallerySummaryCount">0</strong></div>
<div class="mode-gallery-summary"><span><i data-lucide="layers-3"></i>自定义模式 · 静态模式 · 动态模式</span><strong id="modeGallerySummaryCount">0</strong></div>
<div class="mode-gallery-groups" id="modeGalleryGroups" aria-live="polite"></div>
<footer class="mode-gallery-footer"><i data-lucide="info"></i><span>静态模式执行后保持目标状态;动态模式持续变化,可随时停止。执行状态与主界面同步。</span></footer>
<footer class="mode-gallery-footer"><i data-lucide="info"></i><span>自定义模式支持编辑与删除;静态模式执行后保持目标状态;动态模式持续变化,可随时停止。</span></footer>
</section>
</div>
+6 -6
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v272";
const CACHE_VERSION = "cognitive-agent-pad-v275";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,18 +6,18 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2",
"/app.js?v=dual-seat-127",
"/app.js?v=dual-seat-128",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-65",
"/console-v2.css?v=delivery-state-66",
"/console-v2.js?v=delivery-state-62",
"/self-extension.css?v=gscu-tcp-43",
"/self-extension.js?v=gscu-tcp-43",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-12",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
"/assistant-chat.css?v=codex-assistant-24",
"/assistant-chat.js?v=codex-assistant-24",
"/assistant-chat.css?v=codex-assistant-26",
"/assistant-chat.js?v=codex-assistant-26",
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
"/assets/pets/self-extension-cat.png?v=3",
+1 -1
View File
@@ -10,7 +10,7 @@
const MIN_PREVIEW_AUDIO_MS = 320;
const STATUS_RESET_MS = 5000;
const MICROPHONE_CONNECT_TIMEOUT_MS = 12000;
const DEFAULT_WAKE_WORD = "你好,双十一";
const DEFAULT_WAKE_WORD = "你好,小迪";
const PREFERRED_MICROPHONE = /TAKSTAR\s*V2/i;
const ACTIVE_PHASES = new Set(["arming", "armed", "capturing", "finalizing"]);
const DEBUG = new URLSearchParams(window.location.search).get("speechDebug") === "1"