880 lines
34 KiB
JavaScript
880 lines
34 KiB
JavaScript
(function initializeUnifiedAssistant() {
|
|
const STORAGE_KEY = "cognitive-agent.assistant-messages";
|
|
const CONVERSATION_STORAGE_KEY = "cognitive-agent.conversation-id";
|
|
const MAX_STORED_MESSAGES = 24;
|
|
const COMPOSER_MIN_HEIGHT = 64;
|
|
const COMPOSER_MAX_HEIGHT = 116;
|
|
const WELCOME_TEXT = "你好,我是 AI助手。你可以向我提问,也可以直接交代需要完成的工作。";
|
|
let pendingController = null;
|
|
let pendingMessage = null;
|
|
let latestControlResult = 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 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 appendModeCreatedState(message, profile) {
|
|
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 = "已添加到「我的模式」";
|
|
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 || "",
|
|
);
|
|
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 || "已配置"));
|
|
actions.forEach((action, index) => {
|
|
if (consumed.has(index)) return;
|
|
const capability = String(action.capability || "");
|
|
const domain = String(action.domain || "");
|
|
const zone = targetZone(action);
|
|
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 ? `${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") {
|
|
label = ["passenger", "front_right"].includes(zone) ? "副驾座椅" : "主驾座椅";
|
|
value = params.position_pct != null
|
|
? `${params.position_pct}%`
|
|
: motionLabel(params.state || params.mode_id);
|
|
icon = "armchair";
|
|
order = instructionOrder("座椅", "副驾", "主驾");
|
|
} else if (capability === "climate.set_temperature") {
|
|
label = "空调温度";
|
|
value = `${params.temperature_c ?? "--"}℃`;
|
|
icon = "thermometer";
|
|
order = instructionOrder("空调", "温度");
|
|
} else if (domain === "climate") {
|
|
label = "空调";
|
|
value = params.level != null ? `${params.level}档` : motionLabel(params.state);
|
|
icon = "fan";
|
|
order = instructionOrder("空调", "风量");
|
|
} else if (capability === "interior_light.set_reading_power") {
|
|
label = "阅读灯";
|
|
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);
|
|
icon = "lightbulb";
|
|
order = instructionOrder(label, "灯光");
|
|
}
|
|
addRow(label, value, icon, order, [index]);
|
|
});
|
|
return rows.sort((left, right) => left.order - right.order);
|
|
}
|
|
|
|
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";
|
|
status.textContent = "正在保存到「我的模式」…";
|
|
try {
|
|
const response = await window.CognitiveAgentNetwork.request("/api/mode-profiles", {
|
|
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 = "模式已保存";
|
|
card.querySelector(".assistant-mode-draft-actions")?.setAttribute("hidden", "");
|
|
await window.CognitiveAgentConsole?.refreshModeProfiles?.(payload.mode_profile?.id);
|
|
appendModeCreatedState(message, payload.mode_profile);
|
|
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, 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);
|
|
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 appendModeDraftState(message, draft, actions) {
|
|
if (!message?.body || !draft?.name || !draft?.source_request_id) return;
|
|
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);
|
|
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 = "已为你生成功能模式";
|
|
const title = document.createElement("strong");
|
|
title.textContent = `「${draft.name}」`;
|
|
headingCopy.append(eyebrow, title);
|
|
const badge = document.createElement("span");
|
|
badge.className = "assistant-mode-draft-badge";
|
|
badge.textContent = `${Number(draft.operation_count) || 0} 项动作`;
|
|
heading.append(headingCopy, badge);
|
|
|
|
const list = document.createElement("div");
|
|
list.className = "assistant-mode-draft-list";
|
|
modeDraftRows(draft, actions).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);
|
|
list.append(row);
|
|
});
|
|
|
|
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 = '<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, 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;
|
|
return {
|
|
message,
|
|
conversation_id: conversationId(),
|
|
client_type: "web_console_codex_assistant",
|
|
execution_mode: executionMode,
|
|
control_transport: controlTransport,
|
|
...(vehicleId ? { vehicle_id: vehicleId } : {}),
|
|
...(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 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 defaultText = event.type === "work.completed"
|
|
? (completed ? "AI助手已完成工作" : "AI助手未能完成工作")
|
|
: (completed ? "座舱控制已完成" : "座舱控制未完成");
|
|
setActionStatus(
|
|
message,
|
|
event.display_text || defaultText,
|
|
completed ? "success" : "failed",
|
|
);
|
|
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
|
|
&& event.payload.orchestration_route === "mode_profile_create"
|
|
) {
|
|
appendModeCreatedState(message, event.payload.mode_profile);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (event.type === "answer.completed") {
|
|
const authoritativeAnswer = Object.prototype.hasOwnProperty.call(event, "answer")
|
|
? String(event.answer || "")
|
|
: message.content;
|
|
setMessageContent(message, authoritativeAnswer, { final: true });
|
|
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() {
|
|
if (pendingController) {
|
|
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 messageText = 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 }));
|
|
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();
|
|
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;
|
|
sessionStorage.removeItem(STORAGE_KEY);
|
|
sessionStorage.setItem(CONVERSATION_STORAGE_KEY, createConversationId());
|
|
}, true);
|
|
|
|
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 };
|
|
}
|
|
}());
|