1443 lines
61 KiB
JavaScript
1443 lines
61 KiB
JavaScript
(function initializeUnifiedAssistant() {
|
||
const STORAGE_KEY = "cognitive-agent.assistant-messages";
|
||
const CONVERSATION_STORAGE_KEY = "cognitive-agent.conversation-id";
|
||
const ACTIVE_MODE_DRAFT_STORAGE_KEY = "cognitive-agent.active-mode-draft";
|
||
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;
|
||
let latestControlResult = null;
|
||
let activeModeDraftContext = null;
|
||
let activeSchedulePoll = null;
|
||
let initialized = false;
|
||
|
||
const $ = (selector, root = document) => root.querySelector(selector);
|
||
|
||
function setDrawerOpen(open, { restoreFocus = false } = {}) {
|
||
const drawer = $("#assistantDrawer");
|
||
const trigger = $("#assistantDrawerToggle");
|
||
if (!drawer || !trigger) return;
|
||
document.body.classList.toggle("assistant-drawer-open", open);
|
||
trigger.classList.toggle("active", open);
|
||
trigger.setAttribute("aria-pressed", String(open));
|
||
trigger.setAttribute("aria-expanded", String(open));
|
||
const triggerLabel = open ? "收起AI助手" : "展开AI助手";
|
||
trigger.setAttribute("aria-label", triggerLabel);
|
||
trigger.title = triggerLabel;
|
||
drawer.setAttribute("aria-hidden", String(!open));
|
||
drawer.toggleAttribute("inert", !open);
|
||
const speech = window.CognitiveAgentSpeech;
|
||
if (typeof speech?.setAssistantOpen === "function") {
|
||
void speech.setAssistantOpen(open);
|
||
}
|
||
if (open) {
|
||
requestAnimationFrame(() => $("#utterance")?.focus({ preventScroll: true }));
|
||
} else {
|
||
// A collapsed assistant must never leave an invisible microphone or
|
||
// preview request running in the background.
|
||
if (typeof speech?.setAssistantOpen !== "function") {
|
||
void speech?.stop?.("AI助手已收起,语音输入已停止");
|
||
}
|
||
if (restoreFocus) trigger.focus({ preventScroll: true });
|
||
}
|
||
}
|
||
|
||
function installAssistantDrawer() {
|
||
const drawer = $(".v2-operation-shell");
|
||
const heading = $(".v2-operation-heading", drawer);
|
||
if (!drawer || !heading || $("#assistantDrawerToggle")) return;
|
||
|
||
drawer.id = "assistantDrawer";
|
||
drawer.setAttribute("role", "dialog");
|
||
drawer.setAttribute("aria-label", "AI助手");
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "assistant-drawer-actions";
|
||
const reset = $("#newConversation");
|
||
if (reset) {
|
||
reset.classList.add("assistant-drawer-new");
|
||
actions.append(reset);
|
||
}
|
||
heading.append(actions);
|
||
|
||
const trigger = document.createElement("button");
|
||
trigger.id = "assistantDrawerToggle";
|
||
trigger.className = "assistant-drawer-toggle v2-panel-control-button";
|
||
trigger.type = "button";
|
||
trigger.setAttribute("aria-controls", drawer.id);
|
||
trigger.setAttribute("aria-pressed", "false");
|
||
trigger.setAttribute("aria-expanded", "false");
|
||
trigger.setAttribute("aria-label", "展开AI助手");
|
||
trigger.title = "展开AI助手";
|
||
trigger.innerHTML = '<i data-lucide="bot-message-square"></i><span class="visually-hidden">AI助手</span><b aria-hidden="true"></b>';
|
||
($("#v2PanelControlDock") || document.body).append(trigger);
|
||
|
||
trigger.addEventListener("click", () => {
|
||
setDrawerOpen(!document.body.classList.contains("assistant-drawer-open"));
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && document.body.classList.contains("assistant-drawer-open")) {
|
||
event.preventDefault();
|
||
setDrawerOpen(false, { restoreFocus: true });
|
||
}
|
||
});
|
||
window.addEventListener("cognitive-agent:module-change", (event) => {
|
||
if (event.detail?.module === "self-extension") setDrawerOpen(false);
|
||
});
|
||
setDrawerOpen(true);
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
function createMessageId() {
|
||
if (globalThis.crypto?.randomUUID) return `ui-${globalThis.crypto.randomUUID()}`;
|
||
return `ui-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
function createConversationId() {
|
||
if (globalThis.crypto?.randomUUID) return `conv-${globalThis.crypto.randomUUID()}`;
|
||
return `conv-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
|
||
function conversationId() {
|
||
const existing = sessionStorage.getItem(CONVERSATION_STORAGE_KEY)
|
||
|| window.CognitiveAgentConsole?.getConversationId?.();
|
||
if (existing) return existing;
|
||
const created = createConversationId();
|
||
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, created);
|
||
return created;
|
||
}
|
||
|
||
function normalizeModeName(value) {
|
||
return String(value || "").trim().replace(/\s+/g, "").toLowerCase();
|
||
}
|
||
|
||
function sanitizeModeDraftContext(value) {
|
||
if (!value || typeof value !== "object") return null;
|
||
const name = String(value.name || "").trim();
|
||
const sourceRequestId = String(value.source_request_id || "").trim();
|
||
const vehicleId = String(value.vehicle_id || "").trim();
|
||
const draftConversationId = String(value.conversation_id || "").trim();
|
||
if (
|
||
!name
|
||
|| name.length > 64
|
||
|| !/^req-[a-z0-9-]{6,100}$/.test(sourceRequestId)
|
||
) return null;
|
||
if (!vehicleId || vehicleId.length > 80 || draftConversationId !== conversationId()) return null;
|
||
return {
|
||
name,
|
||
source_request_id: sourceRequestId,
|
||
vehicle_id: vehicleId,
|
||
conversation_id: draftConversationId,
|
||
};
|
||
}
|
||
|
||
function loadActiveModeDraftContext() {
|
||
try {
|
||
return sanitizeModeDraftContext(
|
||
JSON.parse(sessionStorage.getItem(ACTIVE_MODE_DRAFT_STORAGE_KEY) || "null"),
|
||
);
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function rememberModeDraftContext(draft) {
|
||
const candidate = sanitizeModeDraftContext({
|
||
name: draft?.name,
|
||
source_request_id: draft?.source_request_id,
|
||
vehicle_id: draft?.vehicle_id || $("#vehicleSelect")?.value || null,
|
||
conversation_id: conversationId(),
|
||
});
|
||
if (!candidate) return;
|
||
activeModeDraftContext = candidate;
|
||
sessionStorage.setItem(ACTIVE_MODE_DRAFT_STORAGE_KEY, JSON.stringify(candidate));
|
||
}
|
||
|
||
function clearActiveModeDraftContext(sourceRequestId = null) {
|
||
if (
|
||
sourceRequestId
|
||
&& activeModeDraftContext?.source_request_id !== String(sourceRequestId)
|
||
) return;
|
||
activeModeDraftContext = null;
|
||
sessionStorage.removeItem(ACTIVE_MODE_DRAFT_STORAGE_KEY);
|
||
}
|
||
|
||
function isModeDraftUpdateRequest(message, context) {
|
||
const compact = String(message || "").trim().replace(/\s+/g, "");
|
||
if (!compact || !context?.name) return false;
|
||
const politePrefix = "(?:(?:请|麻烦)(?:帮我|帮忙|给我)?|帮我|帮忙|给我)?";
|
||
const prefixUpdateVerb = "(?:修改一下|修改|调整|更改|更新|改一下|变更)";
|
||
const changePredicate = "(?:修改|改成|改为|更改|更新|变更|变为|变成|换成|调整|调节|调高|调低|调到|调成|调为|调至|设置|设为|设成|设到|设至|打开|开启|启动|关闭|关掉|关上|全关|停止|停用|切换|进入|退出|移动|前移|后退|前进|升高|降低|抬高|放低|降下|升起|调直|放倒|复位|半开|开到|关到|拉开|拉上|上锁|解锁)";
|
||
const modeName = "([^,。!?!?、;;::]{1,64}?模式)";
|
||
const modePossessiveBoundary = "(?:中的|里的|内的|之中的|的|中|里|内)";
|
||
const modeMemberBoundary = `(?:${modePossessiveBoundary}|[,,::])`;
|
||
const question = /为什么|什么|怎么|如何|介绍|解释|作用|区别|原理|哪些|多少|几(?:度|档|级|个|项)?|是否|能否|可不可以|合适吗|合理吗|会怎样|列出|告诉我|[??]|(?:吗|么|呢)[。.!!]?$/;
|
||
const candidates = [
|
||
{
|
||
pattern: new RegExp(`^${politePrefix}${prefixUpdateVerb}${modeName}${modeMemberBoundary}[,,::]?(.*)$`),
|
||
predicateRequired: false,
|
||
},
|
||
{
|
||
pattern: new RegExp(`^${politePrefix}(?:把|将)${modeName}${modeMemberBoundary}[,,::]?(.+)$`),
|
||
predicateRequired: true,
|
||
},
|
||
{
|
||
pattern: new RegExp(`^${politePrefix}${modeName}${modeMemberBoundary}[,,::]?(.+)$`),
|
||
predicateRequired: true,
|
||
},
|
||
];
|
||
return candidates.some(({ pattern, predicateRequired }) => {
|
||
const matched = compact.match(pattern);
|
||
const change = String(matched?.[2] || "");
|
||
return Boolean(
|
||
matched
|
||
&& normalizeModeName(matched[1]) === normalizeModeName(context.name)
|
||
&& !question.test(change)
|
||
&& (!predicateRequired || new RegExp(changePredicate).test(change))
|
||
);
|
||
});
|
||
}
|
||
|
||
function requestModeDraftContext(message) {
|
||
const context = activeModeDraftContext || loadActiveModeDraftContext();
|
||
if (!context || !isModeDraftUpdateRequest(message, context)) return null;
|
||
activeModeDraftContext = context;
|
||
return {
|
||
name: context.name,
|
||
source_request_id: context.source_request_id,
|
||
vehicle_id: context.vehicle_id,
|
||
};
|
||
}
|
||
|
||
function loadStoredMessages() {
|
||
try {
|
||
const stored = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "[]");
|
||
return Array.isArray(stored)
|
||
? stored.filter((item) => item && ["user", "assistant"].includes(item.role))
|
||
: [];
|
||
} catch (_error) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function persistMessages() {
|
||
const messages = [...document.querySelectorAll("#assistantMessages [data-message-role]")]
|
||
.map((node) => ({
|
||
role: node.dataset.messageRole,
|
||
content: node.dataset.messageContent || "",
|
||
}))
|
||
.filter((item) => item.content && item.content !== WELCOME_TEXT)
|
||
.slice(-MAX_STORED_MESSAGES);
|
||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||
}
|
||
|
||
function appendInlineText(container, text) {
|
||
const parts = String(text).split(/(`[^`\n]+`)/g);
|
||
parts.forEach((part) => {
|
||
if (part.startsWith("`") && part.endsWith("`") && part.length > 2) {
|
||
const code = document.createElement("code");
|
||
code.textContent = part.slice(1, -1);
|
||
container.append(code);
|
||
} else {
|
||
container.append(document.createTextNode(part));
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderRichText(container, value) {
|
||
container.replaceChildren();
|
||
const sections = String(value).split(/```(?:[\w+-]+)?\n?([\s\S]*?)```/g);
|
||
sections.forEach((section, index) => {
|
||
if (index % 2 === 1) {
|
||
const pre = document.createElement("pre");
|
||
const code = document.createElement("code");
|
||
code.textContent = section.replace(/\n$/, "");
|
||
pre.append(code);
|
||
container.append(pre);
|
||
return;
|
||
}
|
||
const lines = section.split("\n");
|
||
let list = null;
|
||
lines.forEach((line) => {
|
||
const bullet = line.match(/^\s*[-*]\s+(.+)$/);
|
||
if (bullet) {
|
||
if (!list) {
|
||
list = document.createElement("ul");
|
||
container.append(list);
|
||
}
|
||
const item = document.createElement("li");
|
||
appendInlineText(item, bullet[1]);
|
||
list.append(item);
|
||
return;
|
||
}
|
||
list = null;
|
||
if (!line.trim()) return;
|
||
const heading = line.match(/^#{1,3}\s+(.+)$/);
|
||
const block = document.createElement(heading ? "strong" : "p");
|
||
if (heading) block.className = "assistant-inline-heading";
|
||
appendInlineText(block, heading ? heading[1] : line);
|
||
container.append(block);
|
||
});
|
||
});
|
||
}
|
||
|
||
function shouldFollowLatest(transcript) {
|
||
return transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 90;
|
||
}
|
||
|
||
function followLatest(force = false) {
|
||
const transcript = $("#assistantTranscript");
|
||
if (!transcript || (!force && !shouldFollowLatest(transcript))) return;
|
||
requestAnimationFrame(() => {
|
||
transcript.scrollTop = transcript.scrollHeight;
|
||
});
|
||
}
|
||
|
||
function resizeComposerInput(input = $("#utterance")) {
|
||
if (!input) return;
|
||
input.style.height = "auto";
|
||
const contentHeight = input.scrollHeight;
|
||
const nextHeight = Math.max(
|
||
COMPOSER_MIN_HEIGHT,
|
||
Math.min(contentHeight, COMPOSER_MAX_HEIGHT),
|
||
);
|
||
input.style.height = `${nextHeight}px`;
|
||
const scrollable = contentHeight > COMPOSER_MAX_HEIGHT + 1;
|
||
input.classList.toggle("is-scrollable", scrollable);
|
||
if (!scrollable) input.scrollTop = 0;
|
||
}
|
||
|
||
function createMessage(role, content = "", { pending = false } = {}) {
|
||
const article = document.createElement("article");
|
||
article.className = `assistant-message assistant-message-${role}`;
|
||
article.dataset.messageId = createMessageId();
|
||
article.dataset.messageRole = role;
|
||
article.dataset.messageContent = content;
|
||
|
||
if (role === "assistant") {
|
||
const avatar = document.createElement("span");
|
||
avatar.className = "assistant-avatar";
|
||
avatar.setAttribute("aria-hidden", "true");
|
||
avatar.innerHTML = '<i data-lucide="waypoints"></i>';
|
||
article.append(avatar);
|
||
}
|
||
|
||
const body = document.createElement("div");
|
||
body.className = "assistant-message-body";
|
||
const contentNode = document.createElement("div");
|
||
contentNode.className = "assistant-message-content";
|
||
if (pending && !content) {
|
||
contentNode.classList.add("assistant-thinking");
|
||
contentNode.innerHTML = '<span>正在理解你的需求</span><i></i><i></i><i></i>';
|
||
} else if (role === "assistant") {
|
||
renderRichText(contentNode, content);
|
||
} else {
|
||
contentNode.textContent = content;
|
||
}
|
||
body.append(contentNode);
|
||
|
||
const action = document.createElement("div");
|
||
action.className = "assistant-action-status";
|
||
action.hidden = true;
|
||
action.setAttribute("role", "status");
|
||
body.append(action);
|
||
article.append(body);
|
||
$("#assistantMessages").append(article);
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
followLatest(true);
|
||
return { article, body, contentNode, action, content };
|
||
}
|
||
|
||
function setMessageContent(message, content, { final = false } = {}) {
|
||
message.content = content;
|
||
message.article.dataset.messageContent = content;
|
||
message.contentNode.classList.remove("assistant-thinking");
|
||
if (final) renderRichText(message.contentNode, content);
|
||
else message.contentNode.textContent = content;
|
||
followLatest();
|
||
}
|
||
|
||
function setActionStatus(message, text, state = "working") {
|
||
message.action.hidden = !text;
|
||
message.action.className = `assistant-action-status ${state}`;
|
||
message.action.replaceChildren();
|
||
if (!text) return;
|
||
const icon = document.createElement("i");
|
||
icon.setAttribute(
|
||
"data-lucide",
|
||
state === "success" ? "circle-check" : state === "failed" ? "circle-x" : "loader-circle",
|
||
);
|
||
const label = document.createElement("span");
|
||
label.textContent = text;
|
||
message.action.append(icon, label);
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
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))}"]`;
|
||
if ($(selector, message.body)) return;
|
||
const card = document.createElement("div");
|
||
card.className = "assistant-mode-created";
|
||
card.dataset.assistantModeCreated = String(profile.id);
|
||
|
||
const icon = document.createElement("span");
|
||
icon.className = "assistant-mode-created-icon";
|
||
icon.innerHTML = '<i data-lucide="circle-check"></i>';
|
||
|
||
const copy = document.createElement("span");
|
||
copy.className = "assistant-mode-created-copy";
|
||
const label = document.createElement("small");
|
||
label.textContent = updated ? "已更新「我的模式」" : "已添加到「我的模式」";
|
||
const name = document.createElement("strong");
|
||
name.textContent = String(profile.name || "新功能模式");
|
||
copy.append(label, name);
|
||
|
||
const reveal = document.createElement("button");
|
||
reveal.type = "button";
|
||
reveal.textContent = "查看";
|
||
reveal.addEventListener("click", () => {
|
||
window.CognitiveAgentConsole?.revealModeProfile?.(String(profile.id), { focus: true });
|
||
});
|
||
card.append(icon, copy, reveal);
|
||
message.body.append(card);
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
followLatest(true);
|
||
}
|
||
|
||
function modeDraftRows(draft, rawActions) {
|
||
const instruction = String(draft?.instruction || "");
|
||
const actions = Array.isArray(rawActions)
|
||
? rawActions.filter((item) => item && typeof item === "object")
|
||
: [];
|
||
const consumed = new Set();
|
||
const rows = [];
|
||
const targetZone = (action) => String(
|
||
action?.target?.instance || action?.target?.zone || action?.target_scope || action?.target || "",
|
||
);
|
||
const parameters = (action) => (
|
||
action?.parameters && typeof action.parameters === "object" ? action.parameters : {}
|
||
);
|
||
const instructionOrder = (...terms) => {
|
||
const indexes = terms.map((term) => instruction.indexOf(term)).filter((index) => index >= 0);
|
||
return indexes.length ? Math.min(...indexes) : 10_000 + rows.length;
|
||
};
|
||
const addRow = (label, value, icon, order, indexes = []) => {
|
||
indexes.forEach((index) => consumed.add(index));
|
||
rows.push({ label, value, icon, order });
|
||
};
|
||
|
||
const zeroGravityIndexes = actions
|
||
.map((action, index) => ({ action, index }))
|
||
.filter(({ action }) => String(action.capability || "").startsWith("seat.set_")
|
||
&& ["passenger", "front_right"].includes(targetZone(action)))
|
||
.map(({ index }) => index);
|
||
if (/零重力/.test(instruction) && zeroGravityIndexes.length >= 2) {
|
||
addRow(
|
||
"副驾座椅",
|
||
"零重力模式",
|
||
"armchair",
|
||
instructionOrder("副驾", "零重力"),
|
||
zeroGravityIndexes,
|
||
);
|
||
}
|
||
|
||
const lightIndexes = actions
|
||
.map((action, index) => ({ action, index }))
|
||
.filter(({ action }) => ["interior_light", "ambient_light"].includes(String(action.domain || "")))
|
||
.map(({ index }) => index);
|
||
if (
|
||
lightIndexes.length >= 2
|
||
&& /(?:灯光.*(?:全部|所有|全车).*(?:关闭|关掉|熄灭)|(?:全部|所有|全车).*灯光.*(?:关闭|关掉|熄灭))/.test(instruction)
|
||
) {
|
||
addRow(
|
||
"座舱灯光",
|
||
"全部关闭",
|
||
"lightbulb-off",
|
||
instructionOrder("灯光"),
|
||
lightIndexes,
|
||
);
|
||
}
|
||
|
||
const motionLabel = (value) => ({
|
||
open: "打开",
|
||
close: "关闭",
|
||
stop: "停止",
|
||
half: "半开",
|
||
on: "开启",
|
||
off: "关闭",
|
||
}[String(value)] || String(value || "已配置"));
|
||
const shadePositionLabel = (value) => {
|
||
if (value == null || String(value).trim() === "") return "目标开度待确认";
|
||
const position = Number(value);
|
||
if (!Number.isFinite(position) || position < 0 || position > 100) {
|
||
return "目标开度待确认";
|
||
}
|
||
const target = `目标开度 ${position}%`;
|
||
if (position === 0) return `完全关闭(${target})`;
|
||
if (position === 50) return `半开(${target})`;
|
||
if (position === 100) return `完全打开(${target})`;
|
||
return target;
|
||
};
|
||
actions.forEach((action, index) => {
|
||
if (consumed.has(index)) return;
|
||
const capability = String(action.capability || "");
|
||
const domain = String(action.domain || "");
|
||
const zone = targetZone(action);
|
||
const zoneLabel = {
|
||
driver: "主驾", passenger: "副驾", front_left: "主驾", front_right: "副驾",
|
||
front_row: "前排", rear_row: "后排", rear_left: "左后", rear_right: "右后",
|
||
}[zone] || "";
|
||
const params = parameters(action);
|
||
let label = capability || domain || "座舱设备";
|
||
let value = "已配置";
|
||
let icon = "sliders-horizontal";
|
||
let order = 10_000 + index;
|
||
|
||
if (capability === "roof.set_shade_motion" || capability === "roof.set_shade_position") {
|
||
label = "遮阳帘";
|
||
value = params.position_pct != null
|
||
? shadePositionLabel(params.position_pct)
|
||
: motionLabel(params.motion);
|
||
icon = "blinds";
|
||
order = instructionOrder("遮阳帘", "天幕帘");
|
||
} else if (domain === "window") {
|
||
const windowLabels = {
|
||
all: "全部车窗",
|
||
front: "前排车窗",
|
||
rear: "后排车窗",
|
||
front_left: "主驾车窗",
|
||
front_right: "副驾车窗",
|
||
rear_left: "左后车窗",
|
||
rear_right: "右后车窗",
|
||
};
|
||
label = windowLabels[zone] || "车窗";
|
||
value = params.position_pct != null ? `${params.position_pct}%` : motionLabel(params.motion);
|
||
icon = "panels-top-left";
|
||
order = instructionOrder("车窗");
|
||
} else if (domain === "seat") {
|
||
const seatLabel = ["passenger", "front_right"].includes(zone) ? "副驾" : "主驾";
|
||
const seatCapabilityLabels = {
|
||
"seat.set_longitudinal_position": "座椅前后位置",
|
||
"seat.set_cushion_tilt": "坐垫角度",
|
||
"seat.set_backrest_recline": "靠背角度",
|
||
"seat.set_legrest_height": "腿托高度",
|
||
"seat.set_ventilation": "座椅通风",
|
||
"seat.set_heating": "座椅加热",
|
||
"seat.set_driver_ventilation_native": "座椅通风",
|
||
"seat.set_driver_heating_native": "座椅加热",
|
||
"seat.set_driver_massage_native": "座椅按摩",
|
||
};
|
||
label = `${seatLabel}${seatCapabilityLabels[capability] || "座椅"}`;
|
||
value = params.position_pct != null
|
||
? `${params.position_pct}%${params.angle_deg != null ? `(${params.angle_deg}°)` : ""}`
|
||
: motionLabel(params.state || params.mode_id);
|
||
if (["seat.set_driver_ventilation_native", "seat.set_driver_heating_native"].includes(capability)) {
|
||
// CH1 driver controls encode high as 1 and low as 2; passenger
|
||
// comfort levels use a different scale and must not reuse this map.
|
||
const level = { 1: "高档", 2: "低档" }[params.level ?? 2] || "档位待确认";
|
||
value = params.state === "off" ? "关闭"
|
||
: params.state === "on" ? `开启 · ${level}` : "开关状态待确认";
|
||
} else if (["seat.set_ventilation", "seat.set_heating"].includes(capability)) {
|
||
value = params.state === "off" ? "关闭"
|
||
: params.state === "on"
|
||
? `开启${params.level != null ? ` · ${params.level}档` : ""}`
|
||
: "开关状态待确认";
|
||
} else if (capability === "seat.set_driver_massage_native") {
|
||
const massageModes = {
|
||
wave: "波浪", pulse: "脉冲", stretch: "舒展", slack: "舒缓", relax: "放松",
|
||
};
|
||
const massageStrengths = { 1: "轻柔", 2: "中等", 3: "较强" };
|
||
value = params.action === "off" ? "关闭"
|
||
: params.action === "mode"
|
||
? (massageModes[params.mode] ? `${massageModes[params.mode]}模式` : "按摩模式待确认")
|
||
: params.action === "strength"
|
||
? (massageStrengths[params.strength] ? `${massageStrengths[params.strength]}力度` : "按摩力度待确认")
|
||
: "按摩动作待确认";
|
||
}
|
||
icon = "armchair";
|
||
order = instructionOrder("座椅", "副驾", "主驾");
|
||
} else if (capability === "climate.set_temperature") {
|
||
label = `${zoneLabel}空调温度`;
|
||
value = `${params.temperature_c ?? "--"}℃`;
|
||
icon = "thermometer";
|
||
order = instructionOrder("空调", "温度");
|
||
} else if (domain === "climate") {
|
||
const climateModeLabels = {
|
||
"climate.set_cycle_mode": { internal: "内循环", external: "外循环" },
|
||
"climate.set_control_mode": { manual: "手动控制", auto: "自动控制" },
|
||
"climate.set_outlet_mode": {
|
||
face: "吹面", face_feet: "吹面 / 吹脚", feet: "吹脚",
|
||
feet_defrost: "吹脚 / 除霜", defrost: "除霜",
|
||
face_feet_defrost: "吹面 / 吹脚 / 除霜", face_defrost: "吹面 / 除霜",
|
||
},
|
||
};
|
||
label = capability === "climate.set_fan_level" ? "空调风量"
|
||
: capability === "climate.set_cycle_mode" ? "空调循环"
|
||
: capability === "climate.set_control_mode" ? "空调控制模式"
|
||
: capability === "climate.set_outlet_mode" ? "空调出风模式" : "空调";
|
||
label = `${zoneLabel}${label}`;
|
||
value = params.level != null ? `${params.level}档`
|
||
: params.mode != null ? (climateModeLabels[capability]?.[String(params.mode)] || String(params.mode))
|
||
: motionLabel(params.state);
|
||
icon = "fan";
|
||
order = instructionOrder("空调", "风量");
|
||
} else if (capability === "interior_light.set_reading_power") {
|
||
label = `${zoneLabel}阅读灯`;
|
||
value = motionLabel(params.state);
|
||
icon = "book-open";
|
||
order = instructionOrder("阅读灯", "灯光");
|
||
} else if (["interior_light", "ambient_light"].includes(domain)) {
|
||
label = domain === "ambient_light" ? "氛围灯" : "室内灯";
|
||
value = motionLabel(params.state);
|
||
if (capability === "ambient_light.set_brightness_level") {
|
||
label = "氛围灯亮度";
|
||
value = params.brightness_level != null ? `${params.brightness_level}档` : "亮度档位待确认";
|
||
} else if (capability === "ambient_light.set_color_index") {
|
||
label = "氛围灯颜色";
|
||
value = params.color_index != null ? `色号 ${params.color_index}` : "颜色待确认";
|
||
} else if (capability === "ambient_light.set_illumination") {
|
||
value = [
|
||
params.color_hex != null ? `颜色 ${params.color_hex}` : "颜色待确认",
|
||
params.brightness_pct != null ? `亮度 ${params.brightness_pct}%` : "亮度待确认",
|
||
].join(" · ");
|
||
}
|
||
icon = "lightbulb";
|
||
order = instructionOrder(label, "灯光");
|
||
}
|
||
addRow(label, value, icon, order, [index]);
|
||
});
|
||
return rows.sort((left, right) => left.order - right.order);
|
||
}
|
||
|
||
function modeDraftScheduleView(draft) {
|
||
const schedule = draft?.schedule;
|
||
if (
|
||
schedule?.schema !== "cognitive-agent-mode-schedule/v1"
|
||
|| !Array.isArray(schedule.phases) || !schedule.phases.length
|
||
|| !Number.isInteger(schedule.repeat_count)
|
||
|| schedule.repeat_count < 1 || schedule.repeat_count > 20
|
||
|| schedule.phases.some((phase) => !Number.isFinite(phase?.duration_ms)
|
||
|| phase.duration_ms <= 0 || !Array.isArray(phase.operations))
|
||
) return null;
|
||
const durationLabel = (durationMs) => {
|
||
const minutes = Math.floor(durationMs / 60000);
|
||
const seconds = Number(((durationMs % 60000) / 1000).toFixed(3));
|
||
return minutes ? `${minutes}分${seconds ? `${seconds}秒` : ""}` : `${seconds}秒`;
|
||
};
|
||
let offsetMs = 0;
|
||
const phases = schedule.phases.map((phase, index) => {
|
||
// Each phase is rendered separately, without instruction-based grouping:
|
||
// the same seat or temperature can have a different target later on.
|
||
const rows = modeDraftRows({ instruction: "" }, phase.operations.map((operation) => ({
|
||
...operation,
|
||
target: operation.target || (operation.domain === "seat"
|
||
? { instance: operation.target_scope }
|
||
: { zone: operation.target_scope }),
|
||
})));
|
||
const view = {
|
||
id: String(phase.id || `phase-${index + 1}`),
|
||
name: String(phase.name || `阶段 ${index + 1}`),
|
||
timing: `+${durationLabel(offsetMs)} 开始 · 持续 ${durationLabel(phase.duration_ms)}`,
|
||
rows,
|
||
};
|
||
offsetMs += phase.duration_ms;
|
||
return view;
|
||
});
|
||
return {
|
||
phases,
|
||
operationCount: schedule.phases.reduce((count, phase) => count + phase.operations.length, 0),
|
||
summary: `${phases.length} 个阶段 · 重复 ${schedule.repeat_count} 次 · 总时长 ${durationLabel(offsetMs * schedule.repeat_count)}`,
|
||
stopHint: "执行后可输入“停止动态模式”提前结束,已执行状态不自动回滚。各阶段按顺序执行,时间为每轮的相对时间。",
|
||
};
|
||
}
|
||
|
||
function appendModeDraftRows(container, rows) {
|
||
rows.forEach((item) => {
|
||
const row = document.createElement("div");
|
||
row.className = "assistant-mode-draft-row";
|
||
const icon = document.createElement("i");
|
||
icon.dataset.lucide = item.icon;
|
||
const label = document.createElement("span");
|
||
label.textContent = item.label;
|
||
const value = document.createElement("strong");
|
||
value.textContent = item.value;
|
||
row.append(icon, label, value);
|
||
container.append(row);
|
||
});
|
||
}
|
||
|
||
async function saveModeProfileDraft(message, card, draft, buttons, status) {
|
||
buttons.forEach((button) => { button.disabled = true; });
|
||
card.classList.add("is-busy");
|
||
status.hidden = false;
|
||
status.className = "assistant-mode-draft-status working";
|
||
const existingModeId = String(draft.existing_mode_id || "").trim();
|
||
status.textContent = existingModeId
|
||
? "正在将验证结果保存为模式更新…"
|
||
: "正在保存到「我的模式」…";
|
||
try {
|
||
const endpoint = existingModeId
|
||
? `/api/mode-profiles/${encodeURIComponent(existingModeId)}`
|
||
: "/api/mode-profiles";
|
||
const response = await window.CognitiveAgentNetwork.request(endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: draft.name,
|
||
instruction: draft.instruction,
|
||
source_request_id: draft.source_request_id,
|
||
vehicle_id: draft.vehicle_id || $("#vehicleSelect")?.value || null,
|
||
}),
|
||
timeoutMs: 45 * 1000,
|
||
retries: 0,
|
||
});
|
||
const payload = await response.json();
|
||
if (response.status === 401) {
|
||
window.location.replace("/login");
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error(payload.message || payload.error || `HTTP ${response.status}`);
|
||
card.classList.remove("is-busy");
|
||
card.classList.add("is-saved");
|
||
status.className = "assistant-mode-draft-status success";
|
||
status.textContent = existingModeId ? "模式已更新" : "模式已保存";
|
||
card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", "");
|
||
clearActiveModeDraftContext(draft.source_request_id);
|
||
await window.CognitiveAgentConsole?.refreshModeProfiles?.(payload.mode_profile?.id);
|
||
appendModeCreatedState(message, payload.mode_profile, { updated: Boolean(existingModeId) });
|
||
persistMessages();
|
||
} catch (error) {
|
||
card.classList.remove("is-busy");
|
||
status.className = "assistant-mode-draft-status failed";
|
||
status.textContent = error.message || "保存失败,请重试。";
|
||
buttons.forEach((button) => { button.disabled = false; });
|
||
}
|
||
}
|
||
|
||
async function tryModeProfileDraft(card, draft, actions, buttons, status) {
|
||
buttons.forEach((button) => { button.disabled = true; });
|
||
card.classList.add("is-busy");
|
||
status.hidden = false;
|
||
status.className = "assistant-mode-draft-status working";
|
||
status.textContent = "正在进行模拟试运行…";
|
||
try {
|
||
const result = await window.CognitiveAgentConsole?.tryModeDraft?.({ ...draft, actions });
|
||
if (!result || result.status !== "succeeded") throw new Error("试运行未通过,请检查动作设置。");
|
||
status.className = "assistant-mode-draft-status success";
|
||
status.textContent = "试运行通过 · 尚未保存";
|
||
} catch (error) {
|
||
status.className = "assistant-mode-draft-status failed";
|
||
status.textContent = error.message || "试运行失败,请重试。";
|
||
} finally {
|
||
card.classList.remove("is-busy");
|
||
buttons.forEach((button) => { button.disabled = false; });
|
||
}
|
||
}
|
||
|
||
function supersedeModeDraftCards(draft) {
|
||
const nextName = normalizeModeName(draft?.name);
|
||
const nextRequestId = String(draft?.source_request_id || "");
|
||
if (!nextName || !nextRequestId) return;
|
||
document.querySelectorAll("[data-assistant-mode-draft]").forEach((card) => {
|
||
if (
|
||
card.dataset.assistantModeName !== nextName
|
||
|| card.dataset.assistantModeDraft === nextRequestId
|
||
) return;
|
||
card.classList.remove("is-busy");
|
||
card.classList.add("is-superseded");
|
||
card.querySelectorAll("button").forEach((button) => {
|
||
button.disabled = true;
|
||
});
|
||
card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", "");
|
||
const priorStatus = card.querySelector(".assistant-mode-draft-status");
|
||
if (priorStatus) {
|
||
priorStatus.hidden = false;
|
||
priorStatus.className = "assistant-mode-draft-status superseded";
|
||
priorStatus.textContent = "已由下方最新草稿替换";
|
||
}
|
||
});
|
||
}
|
||
|
||
function appendModeDraftState(message, draft, actions) {
|
||
if (!message?.body || !draft?.name || !draft?.source_request_id) return;
|
||
rememberModeDraftContext(draft);
|
||
supersedeModeDraftCards(draft);
|
||
const selector = `[data-assistant-mode-draft="${CSS.escape(String(draft.source_request_id))}"]`;
|
||
if ($(selector, message.body)) return;
|
||
const card = document.createElement("section");
|
||
card.className = "assistant-mode-draft";
|
||
card.dataset.assistantModeDraft = String(draft.source_request_id);
|
||
card.dataset.assistantModeName = normalizeModeName(draft.name);
|
||
message.article?.classList.add("assistant-message-mode-draft");
|
||
|
||
const heading = document.createElement("header");
|
||
const headingCopy = document.createElement("span");
|
||
const eyebrow = document.createElement("small");
|
||
eyebrow.textContent = draft.existing_mode_id
|
||
? "已载入现有功能模式"
|
||
: "已为你生成功能模式";
|
||
const title = document.createElement("strong");
|
||
title.textContent = `「${draft.name}」`;
|
||
headingCopy.append(eyebrow, title);
|
||
const badge = document.createElement("span");
|
||
badge.className = "assistant-mode-draft-badge";
|
||
const scheduleView = modeDraftScheduleView(draft);
|
||
badge.textContent = `${scheduleView?.operationCount ?? (Number(draft.operation_count) || 0)} 项动作`;
|
||
heading.append(headingCopy, badge);
|
||
|
||
const list = document.createElement("div");
|
||
list.className = "assistant-mode-draft-list";
|
||
if (scheduleView) {
|
||
card.classList.add("assistant-mode-draft-scheduled");
|
||
const summary = document.createElement("p");
|
||
summary.className = "assistant-mode-schedule-summary";
|
||
summary.textContent = scheduleView.summary;
|
||
list.append(summary);
|
||
scheduleView.phases.forEach((phase, index) => {
|
||
const section = document.createElement("section");
|
||
section.className = "assistant-mode-schedule-phase";
|
||
section.dataset.modeSchedulePhase = phase.id;
|
||
const phaseHeading = document.createElement("header");
|
||
const name = document.createElement("strong");
|
||
name.textContent = `${index + 1}. ${phase.name}`;
|
||
const timing = document.createElement("small");
|
||
timing.textContent = phase.timing;
|
||
phaseHeading.append(name, timing);
|
||
section.append(phaseHeading);
|
||
appendModeDraftRows(section, phase.rows);
|
||
list.append(section);
|
||
});
|
||
const hint = document.createElement("p");
|
||
hint.className = "assistant-mode-schedule-hint";
|
||
hint.textContent = scheduleView.stopHint;
|
||
list.append(hint);
|
||
} else {
|
||
appendModeDraftRows(list, modeDraftRows(draft, actions));
|
||
}
|
||
|
||
const controls = document.createElement("div");
|
||
controls.className = "assistant-mode-draft-actions";
|
||
const save = document.createElement("button");
|
||
save.type = "button";
|
||
save.className = "assistant-mode-draft-save";
|
||
save.innerHTML = draft.existing_mode_id
|
||
? '<i data-lucide="refresh-cw"></i><span>确认更新此模式</span>'
|
||
: '<i data-lucide="bookmark-plus"></i><span>保存为功能模式</span>';
|
||
const trial = document.createElement("button");
|
||
trial.type = "button";
|
||
trial.className = "assistant-mode-draft-trial";
|
||
trial.textContent = "先试运行";
|
||
controls.append(save, trial);
|
||
const status = document.createElement("p");
|
||
status.className = "assistant-mode-draft-status";
|
||
status.hidden = true;
|
||
status.setAttribute("role", "status");
|
||
const buttons = [save, trial];
|
||
save.addEventListener("click", () => {
|
||
void saveModeProfileDraft(message, card, draft, buttons, status);
|
||
});
|
||
trial.addEventListener("click", () => {
|
||
void tryModeProfileDraft(card, draft, actions, buttons, status);
|
||
});
|
||
|
||
card.append(heading, list, controls, status);
|
||
message.body.append(card);
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
followLatest(true);
|
||
}
|
||
|
||
function startModeCreation() {
|
||
setDrawerOpen(true);
|
||
const input = $("#utterance");
|
||
if (!input) return;
|
||
const helper = createMessage(
|
||
"assistant",
|
||
"告诉我模式名称和希望联动的座舱状态。比如:创建宝宝睡眠模式,副驾半躺、空调 24℃、关闭阅读灯。我会先生成动作预览,确认后再加入「我的模式」。",
|
||
);
|
||
helper.article.classList.add("assistant-mode-guide");
|
||
input.value = "请帮我创建一个";
|
||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||
input.focus({ preventScroll: true });
|
||
input.setSelectionRange(input.value.length, input.value.length);
|
||
}
|
||
|
||
function setComposerPending(pending) {
|
||
const button = $("#runButton");
|
||
const input = $("#utterance");
|
||
if (!button || !input) return;
|
||
document.body.classList.toggle("assistant-drawer-busy", pending);
|
||
button.classList.toggle("assistant-stop", pending);
|
||
button.setAttribute("aria-label", pending ? "停止生成" : "发送消息");
|
||
button.title = pending ? "停止生成" : "发送消息";
|
||
// General questions must stay available even when no physical CAN adapter
|
||
// is connected; hardware eligibility is evaluated only for control routes.
|
||
button.disabled = false;
|
||
button.innerHTML = pending
|
||
? '<i data-lucide="square"></i><span>停止</span>'
|
||
: '<i data-lucide="send-horizontal"></i><span>发送</span>';
|
||
input.setAttribute("aria-busy", String(pending));
|
||
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
|
||
}
|
||
|
||
function requestBody(message) {
|
||
const executionMode = window.CognitiveAgentConsole?.getExecutionMode?.() || "dry_run";
|
||
const controlTransport = localStorage.getItem("cognitive-agent.control-transport") || "wired_can";
|
||
const hardwareClientId = localStorage.getItem("cognitive-agent.hardware-client-id");
|
||
const vehicleId = $("#vehicleSelect")?.value || null;
|
||
const modeProfileDraftContext = requestModeDraftContext(message);
|
||
return {
|
||
message,
|
||
conversation_id: conversationId(),
|
||
client_type: "web_console_codex_assistant",
|
||
execution_mode: executionMode,
|
||
control_transport: controlTransport,
|
||
...(vehicleId ? { vehicle_id: vehicleId } : {}),
|
||
...(modeProfileDraftContext
|
||
? { mode_profile_draft_context: modeProfileDraftContext }
|
||
: {}),
|
||
...(executionMode === "hardware" && controlTransport !== "wireless_mqtt" && hardwareClientId
|
||
? { hardware_client_id: hardwareClientId }
|
||
: {}),
|
||
...(executionMode === "simulated_hardware"
|
||
? { simulation_fault: $("#simulationFault")?.value || "normal" }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
async function readErrorResponse(response) {
|
||
try {
|
||
const payload = await response.json();
|
||
return payload.message || payload.error || `HTTP ${response.status}`;
|
||
} catch (_error) {
|
||
return `HTTP ${response.status}`;
|
||
}
|
||
}
|
||
|
||
function stopScheduleStatusPoll() {
|
||
if (!activeSchedulePoll) return;
|
||
window.clearTimeout(activeSchedulePoll.timer);
|
||
activeSchedulePoll.controller?.abort();
|
||
activeSchedulePoll = null;
|
||
}
|
||
|
||
function startScheduleStatusPoll(payload, message) {
|
||
stopScheduleStatusPoll();
|
||
const vehicleId = String(payload?.vehicle_id || payload?.vehicle?.id
|
||
|| payload?.mode_profile?.vehicle_id || $("#vehicleSelect")?.value || "");
|
||
if (!vehicleId) return;
|
||
const poll = {
|
||
vehicleId, conversationId: conversationId(), message,
|
||
sessionId: String(payload?.schedule_execution?.session_id || ""),
|
||
runId: String(payload?.schedule_execution?.run_id || ""),
|
||
modeProfileId: String(payload?.mode_profile?.id || ""),
|
||
timer: null, controller: null,
|
||
};
|
||
activeSchedulePoll = poll;
|
||
const isCurrent = () => activeSchedulePoll === poll
|
||
&& conversationId() === poll.conversationId
|
||
&& (!$("#vehicleSelect")?.value || $("#vehicleSelect").value === poll.vehicleId);
|
||
const update = async () => {
|
||
if (!isCurrent()) {
|
||
if (activeSchedulePoll === poll) stopScheduleStatusPoll();
|
||
return;
|
||
}
|
||
poll.controller = new AbortController();
|
||
try {
|
||
const response = await window.CognitiveAgentNetwork.request(
|
||
`/api/modes/schedule/status?vehicle_id=${encodeURIComponent(poll.vehicleId)}`,
|
||
{ method: "GET", signal: poll.controller.signal, timeoutMs: 8000, retries: 0 },
|
||
);
|
||
if (!isCurrent()) return;
|
||
if (response.status === 401) {
|
||
setActionStatus(message, "登录状态已失效,无法继续查询时间轴状态;请重新登录查看。", "failed");
|
||
stopScheduleStatusPoll();
|
||
return;
|
||
}
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const status = await response.json();
|
||
if (!isCurrent()) return;
|
||
if (status.vehicle_id && String(status.vehicle_id) !== poll.vehicleId) {
|
||
throw new Error("时间轴状态车型不匹配");
|
||
}
|
||
if ((poll.runId && status.run_id && String(status.run_id) !== poll.runId)
|
||
|| (poll.sessionId && status.session_id && String(status.session_id) !== poll.sessionId)) {
|
||
setActionStatus(message, "当前时间轴已被新的运行任务替换,请查看最新模式消息。", "working");
|
||
stopScheduleStatusPoll();
|
||
return;
|
||
}
|
||
const terminal = ["completed", "failed", "cancelled", "stopped", "idle"].includes(status.status);
|
||
if (status.status === "running") {
|
||
const phase = Number.isInteger(status.phase_index) && status.phase_index >= 0
|
||
? ` · 阶段 ${status.phase_index + 1}` : "";
|
||
const repeat = Number.isInteger(status.repeat_index) && status.repeat_index >= 0
|
||
? ` · 第 ${status.repeat_index + 1} 轮` : "";
|
||
setActionStatus(message, `动态时间轴执行中${repeat}${phase}。可输入“停止动态模式”;已执行状态不自动回滚。`, "working");
|
||
} else if (terminal) {
|
||
const stopped = ["cancelled", "stopped"].includes(status.status);
|
||
const failed = status.status === "failed";
|
||
const copy = failed
|
||
? `动态时间轴执行失败:${status.error || "某个阶段未通过执行校验"}。后续阶段不再执行,已执行状态不自动回滚。`
|
||
: stopped
|
||
? "动态时间轴已停止,后续阶段不再执行;已执行状态不自动回滚。"
|
||
: status.status === "completed"
|
||
? "动态时间轴已结束,全部计划阶段已处理;请以车辆反馈确认设备实际状态。"
|
||
: "当前没有正在运行的动态时间轴。";
|
||
setActionStatus(message, copy, failed ? "failed" : "success");
|
||
window.CognitiveAgentConsole?.updateScheduleExecutionStatus?.(poll.modeProfileId, status.status);
|
||
stopScheduleStatusPoll();
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
if (!isCurrent() || poll.controller?.signal.aborted) return;
|
||
setActionStatus(message, "暂时无法获取时间轴状态,正在重新查询;需要停止时可输入“停止动态模式”。", "working");
|
||
} finally {
|
||
if (isCurrent()) poll.timer = window.setTimeout(update, 2000);
|
||
}
|
||
};
|
||
poll.timer = window.setTimeout(update, 2000);
|
||
}
|
||
|
||
function applyAssistantEvent(event, message) {
|
||
if (event.type === "answer.delta") {
|
||
setMessageContent(message, `${message.content}${event.delta || ""}`);
|
||
return;
|
||
}
|
||
if (["action.started", "action.progress", "work.started", "work.progress"].includes(event.type)) {
|
||
const defaultText = event.type.startsWith("work.")
|
||
? "AI助手正在处理工作……"
|
||
: "正在执行座舱控制……";
|
||
setActionStatus(message, event.display_text || defaultText, "working");
|
||
if (event.type === "action.progress" && event.payload) {
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:vehicle-control-activity", {
|
||
detail: event.payload,
|
||
}));
|
||
}
|
||
return;
|
||
}
|
||
if (event.type === "action.completed" || event.type === "work.completed") {
|
||
latestControlResult = event.payload || latestControlResult;
|
||
const completed = event.success !== false;
|
||
const scheduleRunning = completed
|
||
&& event.payload?.orchestration_route === "mode_schedule_execute"
|
||
&& (event.payload?.status === "running" || event.payload?.schedule_execution?.status === "running");
|
||
const defaultText = event.type === "work.completed"
|
||
? (completed ? "AI助手已完成工作" : "AI助手未能完成工作")
|
||
: (completed ? "座舱控制已完成" : "座舱控制未完成");
|
||
setActionStatus(
|
||
message,
|
||
scheduleRunning
|
||
? "已启动动态时间轴;后续动作将分阶段执行。可输入“停止动态模式”提前结束,已执行的设备状态不会自动回滚。"
|
||
: event.display_text || defaultText,
|
||
scheduleRunning ? "working" : completed ? "success" : "failed",
|
||
);
|
||
if (scheduleRunning) startScheduleStatusPoll(event.payload, message);
|
||
if (event.payload) {
|
||
window.CognitiveAgentConsole?.applySyncedResult?.(event.payload);
|
||
if (
|
||
event.payload.mode_profile_draft
|
||
&& event.payload.orchestration_route === "mode_profile_create_preview"
|
||
) {
|
||
appendModeDraftState(
|
||
message,
|
||
event.payload.mode_profile_draft,
|
||
event.payload.actions,
|
||
);
|
||
} else if (
|
||
event.payload.mode_profile
|
||
&& ["mode_profile_create", "mode_profile_update"].includes(
|
||
event.payload.orchestration_route,
|
||
)
|
||
) {
|
||
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") {
|
||
const error = new Error(event.message || "请求处理失败");
|
||
error.code = event.error;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function streamAssistant(messageText, assistantMessage, signal) {
|
||
const response = await window.CognitiveAgentNetwork.request("/api/assistant/stream", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/x-ndjson",
|
||
},
|
||
body: JSON.stringify(requestBody(messageText)),
|
||
timeoutMs: 15 * 60 * 1000,
|
||
retries: 0,
|
||
signal,
|
||
});
|
||
if (!response.ok) throw new Error(await readErrorResponse(response));
|
||
if (!response.body?.getReader) throw new Error("当前浏览器不支持流式问答");
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffered = "";
|
||
let answerCompleted = false;
|
||
const abortError = () => {
|
||
const error = new Error("助手响应已取消");
|
||
error.name = "AbortError";
|
||
return error;
|
||
};
|
||
const cancelReader = () => {
|
||
// CognitiveAgentNetwork resolves once response headers arrive. Cancelling
|
||
// the body reader keeps the Stop button effective for the remaining
|
||
// long-lived NDJSON stream as well.
|
||
reader.cancel(signal.reason).catch(() => {});
|
||
};
|
||
signal.addEventListener("abort", cancelReader, { once: true });
|
||
try {
|
||
if (signal.aborted) {
|
||
await reader.cancel(signal.reason).catch(() => {});
|
||
throw abortError();
|
||
}
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (signal.aborted) throw abortError();
|
||
buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||
const lines = buffered.split("\n");
|
||
buffered = done ? "" : lines.pop() || "";
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
const event = JSON.parse(line);
|
||
applyAssistantEvent(event, assistantMessage);
|
||
if (event.type === "answer.completed") answerCompleted = true;
|
||
}
|
||
if (done) break;
|
||
}
|
||
if (buffered.trim()) {
|
||
const event = JSON.parse(buffered);
|
||
applyAssistantEvent(event, assistantMessage);
|
||
if (event.type === "answer.completed") answerCompleted = true;
|
||
}
|
||
if (!answerCompleted) throw new Error("助手响应意外中断,请重试。");
|
||
} finally {
|
||
signal.removeEventListener("abort", cancelReader);
|
||
reader.releaseLock();
|
||
}
|
||
}
|
||
|
||
async function submitAssistant(messageOverride = null) {
|
||
if (pendingController) {
|
||
if (typeof messageOverride === "string") return;
|
||
pendingController.abort("user_stop");
|
||
return;
|
||
}
|
||
const input = $("#utterance");
|
||
const speechPhase = window.CognitiveAgentSpeech?.phase;
|
||
const speechInputMode = window.CognitiveAgentSpeech?.inputMode;
|
||
if (["capturing", "finalizing"].includes(speechPhase)
|
||
|| (speechPhase === "arming" && speechInputMode === "manual")) {
|
||
window.CognitiveAgentConsole?.notify?.("请等待语音整理完成后再发送");
|
||
return;
|
||
}
|
||
const suggestedMessage = typeof messageOverride === "string" ? messageOverride.trim() : "";
|
||
const messageText = suggestedMessage || input?.value.trim() || "";
|
||
if (!messageText) {
|
||
window.CognitiveAgentConsole?.notify?.("请输入问题或工作需求");
|
||
return;
|
||
}
|
||
|
||
createMessage("user", messageText);
|
||
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;
|
||
pendingController = new AbortController();
|
||
setComposerPending(true);
|
||
|
||
let completedError = null;
|
||
try {
|
||
await streamAssistant(messageText, assistantMessage, pendingController.signal);
|
||
setMessageContent(assistantMessage, assistantMessage.content, { final: true });
|
||
persistMessages();
|
||
} catch (error) {
|
||
completedError = error;
|
||
if (pendingController.signal.aborted) {
|
||
const stopped = assistantMessage.content
|
||
? `${assistantMessage.content}\n\n已停止继续生成。已下发的车辆操作仍会由安全执行链路完成。`
|
||
: "已停止生成。已下发的车辆操作仍会由安全执行链路完成。";
|
||
setMessageContent(assistantMessage, stopped, { final: true });
|
||
setActionStatus(assistantMessage, "已停止显示", "failed");
|
||
} else {
|
||
setMessageContent(assistantMessage, error.message || "请求处理失败,请稍后再试。", {
|
||
final: true,
|
||
});
|
||
setActionStatus(assistantMessage, "本次请求未完成", "failed");
|
||
}
|
||
persistMessages();
|
||
} finally {
|
||
pendingController = null;
|
||
pendingMessage = null;
|
||
setComposerPending(false);
|
||
input.focus({ preventScroll: true });
|
||
window.dispatchEvent(new CustomEvent("cognitive-agent:request-complete", {
|
||
detail: {
|
||
utterance: messageText,
|
||
result: latestControlResult,
|
||
error: completedError,
|
||
},
|
||
}));
|
||
}
|
||
}
|
||
|
||
function renderInitialMessages() {
|
||
const container = $("#assistantMessages");
|
||
if (!container) return;
|
||
const stored = loadStoredMessages();
|
||
container.replaceChildren();
|
||
createMessage("assistant", WELCOME_TEXT);
|
||
stored.forEach((message) => createMessage(message.role, String(message.content || "")));
|
||
}
|
||
|
||
function initialize() {
|
||
if (initialized || !$("#requestForm") || !$("#assistantMessages")) return;
|
||
initialized = true;
|
||
document.body.classList.add("assistant-chat-enabled");
|
||
const title = $(".v2-operation-heading h2");
|
||
if (title) title.textContent = "AI助手";
|
||
const input = $("#utterance");
|
||
input.value = "";
|
||
input.placeholder = "向 AI助手提问或交代工作";
|
||
input.setAttribute("aria-label", "向 AI助手提问或交代工作");
|
||
installAssistantDrawer();
|
||
input.addEventListener("input", () => resizeComposerInput(input));
|
||
resizeComposerInput(input);
|
||
// Some browsers restore the last submitted textarea value after
|
||
// DOMContentLoaded. Clear that restoration once the initial page settles so
|
||
// a reload cannot accidentally resend an already handled vehicle command.
|
||
window.setTimeout(() => {
|
||
if (pendingController) return;
|
||
input.value = "";
|
||
resizeComposerInput(input);
|
||
}, 300);
|
||
renderInitialMessages();
|
||
activeModeDraftContext = loadActiveModeDraftContext();
|
||
setComposerPending(false);
|
||
}
|
||
|
||
document.addEventListener("submit", (event) => {
|
||
if (!(event.target instanceof HTMLFormElement) || event.target.id !== "requestForm") return;
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
submitAssistant();
|
||
}, true);
|
||
|
||
document.addEventListener("click", (event) => {
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
if (!target?.closest("#newConversation")) return;
|
||
stopScheduleStatusPoll();
|
||
sessionStorage.removeItem(STORAGE_KEY);
|
||
clearActiveModeDraftContext();
|
||
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId());
|
||
}, true);
|
||
|
||
document.addEventListener("change", (event) => {
|
||
if (event.target?.id === "vehicleSelect") stopScheduleStatusPoll();
|
||
});
|
||
window.addEventListener("pagehide", stopScheduleStatusPoll);
|
||
|
||
document.addEventListener("DOMContentLoaded", initialize);
|
||
window.CognitiveAgentAssistant = {
|
||
submit: submitAssistant,
|
||
open: () => setDrawerOpen(true),
|
||
close: () => setDrawerOpen(false),
|
||
startModeCreation,
|
||
announceModeCreated(profile) {
|
||
if (!profile?.id) return;
|
||
setDrawerOpen(true);
|
||
const message = createMessage("assistant", `已完成「${profile.name || "新功能模式"}」的配置。`);
|
||
appendModeCreatedState(message, profile);
|
||
persistMessages();
|
||
},
|
||
stop() {
|
||
pendingController?.abort("user_stop");
|
||
},
|
||
isPending: () => Boolean(pendingMessage),
|
||
};
|
||
if (window.__N7WEB_ASSISTANT_TEST__ === true) {
|
||
window.CognitiveAgentAssistant.__test = { setDrawerOpen };
|
||
}
|
||
}());
|