Files
Cognitive_Agent/apps/web-console/firmware-workbench.js

465 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
if (window.self !== window.top || new URLSearchParams(window.location.search).has("embedded")) {
document.body.classList.add("embedded");
}
const STATUS_LABELS = {
waiting: "等待",
running: "运行中",
pending_confirmation: "待确认",
success: "成功",
failed: "失败",
canceled: "取消",
blocked: "阻塞",
published: "已发布",
ready: "待发布",
baseline: "基线固件",
};
const STAGE_ICONS = {
instruction: "file-input",
requirements: "list-checks",
baseline: "git-compare-arrows",
codegen: "code-2",
build: "hammer",
test: "shield-check",
package: "package-check",
publish: "cloud-upload",
};
const ACTIVE_STATES = new Set(["waiting", "running", "pending_confirmation"]);
const CAPABILITY_ORDER = ["时间控制", "位置控制", "座椅通风", "座椅加热", "动态木马"];
const FALLBACK_BASELINES = {
time_only: ["时间控制"],
};
const FALLBACK_PIPELINE = [
["instruction", "指令输入"], ["requirements", "需求解析"],
["baseline", "基线检查"], ["codegen", "代码生成"],
["build", "编译构建"], ["test", "测试验证"],
["package", "固件打包"], ["publish", "发布服务器"],
].map(([id, name]) => ({ id, name, status: "waiting", summary: "等待执行" }));
const ui = {
instruction: document.querySelector("#instructionInput"),
baseline: document.querySelector("#baselineSelect"),
baselineCapabilities: document.querySelector("#baselineCapabilities"),
targetCapabilities: document.querySelector("#targetCapabilities"),
start: document.querySelector("#startTaskButton"),
cancel: document.querySelector("#cancelTaskButton"),
retry: document.querySelector("#retryTaskButton"),
server: document.querySelector("#serverState"),
serverName: document.querySelector("#otaServerName"),
release: document.querySelector("#releaseResult"),
pipeline: document.querySelector("#pipeline"),
logs: document.querySelector("#logStream"),
scrollLogs: document.querySelector("#scrollLogButton"),
status: document.querySelector("#taskStatusBadge"),
metrics: document.querySelector("#taskMetrics"),
inspector: document.querySelector("#inspectorContent"),
artifacts: document.querySelector("#artifactTableBody"),
modal: document.querySelector("#detailModal"),
modalTitle: document.querySelector("#modalTitle"),
modalBody: document.querySelector("#modalBody"),
modalClose: document.querySelector("#closeModalButton"),
modalCloseFooter: document.querySelector("#modalCloseButton"),
toasts: document.querySelector("#toastRegion"),
};
const model = {
snapshot: { tasks: [], artifacts: [], releases: [], pipeline: FALLBACK_PIPELINE },
selectedTaskId: null,
inspectorTab: "changes",
pending: false,
pollTimer: null,
lastLogCount: 0,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
const contentType = response.headers.get("content-type") || "";
const payload = contentType.includes("application/json") ? await response.json() : null;
if (!response.ok) throw new Error(payload?.message || `请求失败(${response.status})`);
return payload;
}
function selectedTask() {
return model.snapshot.tasks.find((task) => task.id === model.selectedTaskId)
|| model.snapshot.tasks[0]
|| null;
}
function baselineCapabilities() {
const baseline = model.snapshot.baselines?.find((item) => item.id === ui.baseline.value);
return baseline?.capabilities || FALLBACK_BASELINES[ui.baseline.value] || FALLBACK_BASELINES.time_only;
}
function parseInstructionCapabilities(instruction) {
const lowered = String(instruction || "").toLowerCase();
const aliases = {
"时间控制": ["时间", "定时", "timed"],
"位置控制": ["位置", "position"],
"座椅通风": ["通风", "vent"],
"座椅加热": ["加热", "heater", "heat"],
"动态木马": ["木马", "rocking_horse", "rocking horse", "rocking-horse"],
};
const requested = CAPABILITY_ORDER.filter((capability) => aliases[capability].some((alias) => lowered.includes(alias)));
if (requested.includes("动态木马") && !requested.includes("位置控制")) requested.push("位置控制");
return CAPABILITY_ORDER.filter((capability) => requested.includes(capability));
}
function updateCapabilityPreview(task = null) {
const baseline = baselineCapabilities();
const taskMatchesDraft = task
&& task.instruction === ui.instruction.value.trim()
&& (task.baseline_id || "time_only") === ui.baseline.value;
const target = taskMatchesDraft
? task.target_capabilities
: CAPABILITY_ORDER.filter((item) => baseline.includes(item) || parseInstructionCapabilities(ui.instruction.value).includes(item));
ui.baselineCapabilities.textContent = baseline.join("+");
ui.targetCapabilities.textContent = (target?.length ? target : baseline).join("+");
ui.targetCapabilities.title = ui.targetCapabilities.textContent;
}
function formatDate(value) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
}).format(date).replaceAll("/", "-");
}
function formatClock(value) {
if (!value) return "--:--:--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--:--:--";
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
}).format(date);
}
function formatDuration(milliseconds) {
if (milliseconds === null || milliseconds === undefined) return "—";
if (milliseconds < 1000) return `${milliseconds} 毫秒`;
const seconds = milliseconds / 1000;
return seconds < 60 ? `${seconds.toFixed(seconds < 10 ? 1 : 0)} 秒` : `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`;
}
function formatSize(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} 字节`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} 千字节`;
return `${(value / 1024 / 1024).toFixed(2)} 兆字节`;
}
function toast(message, type = "info") {
const element = document.createElement("div");
element.className = `toast ${type}`;
element.textContent = message;
ui.toasts.append(element);
window.setTimeout(() => element.remove(), 4200);
}
function refreshIcons() {
if (window.lucide) window.lucide.createIcons({ attrs: { "stroke-width": 1.8 } });
}
function renderPipeline(task) {
const stages = task?.stages || model.snapshot.pipeline?.map((stage) => ({ ...stage, status: "waiting" })) || FALLBACK_PIPELINE;
ui.pipeline.innerHTML = stages.map((stage) => `
<li class="pipeline-stage" data-status="${escapeHtml(stage.status || "waiting")}" title="${escapeHtml(stage.summary || "")}">
<span class="stage-node"><i data-lucide="${STAGE_ICONS[stage.id] || "circle"}"></i></span>
<strong class="stage-name">${escapeHtml(stage.name)}</strong>
<span class="stage-state">${escapeHtml(STATUS_LABELS[stage.status] || stage.status || "等待")}${stage.duration_ms !== null && stage.duration_ms !== undefined ? ` · ${escapeHtml(formatDuration(stage.duration_ms))}` : ""}</span>
</li>`).join("");
}
function renderRelease(task) {
let icon = "cloud-upload";
let label = "尚未开始";
let state = "idle";
if (task) {
label = task.message || STATUS_LABELS[task.status] || task.status;
state = task.status;
if (task.status === "success") { icon = "circle-check-big"; state = "success"; }
else if (task.status === "running") icon = "loader-circle";
else if (task.status === "failed") icon = "circle-x";
else if (task.status === "canceled") icon = "ban";
}
ui.release.dataset.state = state;
ui.release.innerHTML = `<span class="result-icon"><i data-lucide="${icon}"></i></span><div><small>当前交付状态</small><strong>${escapeHtml(label)}</strong></div>`;
}
function renderLogs(task) {
const logs = task?.logs || [];
if (!logs.length) {
ui.logs.innerHTML = `<div class="empty-state"><i data-lucide="terminal-square"></i><strong>暂无执行记录</strong><span>创建开发任务后,这里将实时显示各阶段输出。</span></div>`;
model.lastLogCount = 0;
return;
}
const nearBottom = ui.logs.scrollHeight - ui.logs.scrollTop - ui.logs.clientHeight < 80;
ui.logs.innerHTML = logs.map((entry) => `
<article class="log-entry" data-level="${escapeHtml(entry.level)}">
<time class="log-time">${escapeHtml(formatClock(entry.time))}</time>
<span class="log-dot"></span>
<div class="log-copy"><strong>${escapeHtml(entry.title)}</strong><p>${escapeHtml(entry.message)}</p></div>
</article>`).join("");
if (nearBottom || logs.length > model.lastLogCount) ui.logs.scrollTop = ui.logs.scrollHeight;
model.lastLogCount = logs.length;
}
function renderInspector(task) {
const status = task?.status || "waiting";
ui.status.dataset.status = status;
ui.status.textContent = STATUS_LABELS[status] || status;
const fileCount = task?.file_changes?.length || 0;
ui.metrics.innerHTML = `
<div><dt>开始时间</dt><dd>${escapeHtml(formatDate(task?.started_at))}</dd></div>
<div><dt>结束时间</dt><dd>${escapeHtml(formatDate(task?.finished_at))}</dd></div>
<div><dt>实际耗时</dt><dd>${escapeHtml(formatDuration(task?.duration_ms))}</dd></div>
<div><dt>文件变更</dt><dd>${fileCount ? `${fileCount} 个文件` : "—"}</dd></div>`;
document.querySelectorAll("[data-inspector-tab]").forEach((button) => {
button.classList.toggle("active", button.dataset.inspectorTab === model.inspectorTab);
});
if (!task) {
ui.inspector.innerHTML = `<div class="empty-inline">暂无任务数据</div>`;
return;
}
if (model.inspectorTab === "build") {
ui.inspector.innerHTML = task.build_output?.length
? `<pre class="build-output">${escapeHtml(task.build_output.join("\n"))}</pre>`
: `<div class="empty-inline">尚无编译输出</div>`;
return;
}
if (model.inspectorTab === "tests") {
ui.inspector.innerHTML = task.test_results?.length
? task.test_results.map((test) => `<div class="test-item"><span class="test-mark"><i data-lucide="check"></i></span><div class="test-copy"><strong>${escapeHtml(test.name)}</strong><span>${escapeHtml(test.summary)}</span></div></div>`).join("")
: `<div class="empty-inline">测试尚未执行</div>`;
return;
}
ui.inspector.innerHTML = task.file_changes?.length
? task.file_changes.map((change) => `<div class="change-item"><span class="change-type">${change.type === "added" ? "新增" : change.type === "retained" ? "复用" : "修改"}</span><div class="change-copy"><strong>${escapeHtml(change.path)}</strong><span>${escapeHtml(change.summary)}</span></div></div>`).join("")
: `<div class="empty-inline">尚无文件变更</div>`;
}
function renderArtifacts() {
const artifacts = model.snapshot.artifacts || [];
if (!artifacts.length) {
ui.artifacts.innerHTML = `<tr><td colspan="6"><div class="empty-table">尚未生成固件产物</div></td></tr>`;
return;
}
ui.artifacts.innerHTML = artifacts.map((artifact) => `
<tr>
<td><div class="software-name"><strong>${escapeHtml(artifact.software_name)}</strong><span>${escapeHtml(artifact.version)}</span></div></td>
<td><div class="cap-tags">${(artifact.capabilities || []).map((capability) => `<span>${escapeHtml(capability)}</span>`).join("")}</div></td>
<td>${escapeHtml(formatDate(artifact.build_time))}</td>
<td><div class="file-cell"><strong>${escapeHtml(artifact.firmware_filename)}</strong><span>${escapeHtml(formatSize(artifact.size_bytes))} · <span class="hash-value" title="${escapeHtml(artifact.sha256)}">${escapeHtml(artifact.sha256)}</span></span></div></td>
<td><span class="status-badge" data-status="${escapeHtml(artifact.status)}">${escapeHtml(STATUS_LABELS[artifact.status] || artifact.status)}</span></td>
<td><div class="table-actions">
<button type="button" title="查看详情" data-artifact-action="detail" data-artifact-id="${artifact.id}">详情</button>
<button type="button" title="查看能力差异" data-artifact-action="capabilities" data-artifact-id="${artifact.id}">能力</button>
<button type="button" title="查看代码差异" data-artifact-action="code" data-artifact-id="${artifact.id}">代码</button>
<button type="button" title="查看验证记录" data-artifact-action="validation" data-artifact-id="${artifact.id}">验证</button>
<a href="${escapeHtml(artifact.download_url)}" title="下载固件" aria-label="下载固件" download><i data-lucide="download"></i></a>
<button class="publish-action" type="button" title="发布到服务器" data-artifact-action="publish" data-artifact-id="${artifact.id}"><i data-lucide="cloud-upload"></i></button>
${artifact.is_baseline ? `<span class="baseline-protection" title="时间控制基线固件受保护"><i data-lucide="shield-check"></i>受保护</span>` : `<button class="delete-action" type="button" title="删除固件产物" data-artifact-action="delete" data-artifact-id="${artifact.id}"><i data-lucide="trash-2"></i></button>`}
${artifact.status === "published" ? `<button class="notify-action" type="button" title="通知车端" aria-label="通知车端" data-artifact-action="notify" data-artifact-id="${artifact.id}"><i data-lucide="bell"></i></button>` : ""}
</div></td>
</tr>`).join("");
}
function render() {
const task = selectedTask();
if (task) model.selectedTaskId = task.id;
renderPipeline(task);
renderRelease(task);
renderLogs(task);
renderInspector(task);
renderArtifacts();
updateCapabilityPreview(task);
const active = task && ACTIVE_STATES.has(task.status);
ui.start.disabled = model.pending || Boolean(active);
ui.cancel.disabled = model.pending || !active;
ui.retry.disabled = model.pending || !task || !["failed", "canceled", "blocked"].includes(task.status);
ui.start.innerHTML = model.pending ? `<i data-lucide="loader-circle"></i>正在提交` : `<i data-lucide="play"></i>开始生成`;
refreshIcons();
}
async function refreshState({ quiet = false } = {}) {
try {
model.snapshot = await api("/api/firmware-workbench/state");
if (!model.selectedTaskId && model.snapshot.tasks?.length) model.selectedTaskId = model.snapshot.tasks[0].id;
ui.server.classList.add("online");
ui.server.innerHTML = `<span></span>发布服务已连接`;
ui.serverName.textContent = model.snapshot.ota_server?.name || "车机升级服务器";
render();
} catch (error) {
ui.server.classList.remove("online");
ui.server.innerHTML = `<span></span>发布服务连接失败`;
if (!quiet) toast(error.message, "error");
} finally {
schedulePoll();
}
}
function schedulePoll() {
window.clearTimeout(model.pollTimer);
const active = model.snapshot.tasks?.some((task) => ACTIVE_STATES.has(task.status));
model.pollTimer = window.setTimeout(() => refreshState({ quiet: true }), active ? 700 : 5000);
}
async function startTask() {
const instruction = ui.instruction.value.trim();
if (!instruction) { toast("请输入开发指令。", "error"); ui.instruction.focus(); return; }
model.pending = true;
render();
try {
const result = await api("/api/firmware-workbench/tasks", {
method: "POST", body: JSON.stringify({ instruction, baseline_id: ui.baseline.value }),
});
model.selectedTaskId = result.task.id;
toast("开发任务已创建,流水线开始执行。", "success");
await refreshState({ quiet: true });
} catch (error) {
toast(error.message, "error");
} finally {
model.pending = false;
render();
}
}
async function taskAction(action) {
const task = selectedTask();
if (!task) return;
model.pending = true;
render();
try {
await api(`/api/firmware-workbench/tasks/${encodeURIComponent(task.id)}/${action}`, { method: "POST", body: "{}" });
toast(action === "cancel" ? "取消请求已提交。" : "任务已重新进入流水线。", "success");
await refreshState({ quiet: true });
} catch (error) {
toast(error.message, "error");
} finally {
model.pending = false;
render();
}
}
function appendPrompt(text) {
const current = ui.instruction.value.trim();
if (current.includes(text)) return;
ui.instruction.value = current ? `${current.replace(/[。;;]?$/, "")};${text}。` : `${text}。`;
ui.instruction.focus();
updateCapabilityPreview();
}
function showModal(title, content) {
ui.modalTitle.textContent = title;
ui.modalBody.innerHTML = content;
ui.modal.hidden = false;
document.body.style.overflow = "hidden";
refreshIcons();
ui.modalClose.focus();
}
function closeModal() {
ui.modal.hidden = true;
document.body.style.overflow = "";
}
async function artifactAction(button) {
const artifact = model.snapshot.artifacts.find((item) => item.id === button.dataset.artifactId);
if (!artifact) return;
const action = button.dataset.artifactAction;
if (action === "delete") {
if (!window.confirm(`确认删除“${artifact.firmware_filename}”及其服务器发布记录吗?此操作不可恢复。`)) return;
button.disabled = true;
try {
const result = await api(`/api/firmware-workbench/artifacts/${artifact.id}`, { method: "DELETE" });
toast(result.message, "success");
await refreshState({ quiet: true });
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; }
return;
}
if (action === "publish") {
button.disabled = true;
try {
const release = await api(`/api/firmware-workbench/artifacts/${artifact.id}/publish`, { method: "POST", body: "{}" });
toast(`${release.message}(${release.id})`, "success");
await refreshState({ quiet: true });
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; }
return;
}
if (action === "notify") {
button.disabled = true;
try {
const result = await api(`/api/firmware-workbench/artifacts/${artifact.id}/notify`, { method: "POST", body: "{}" });
toast(`已向车端发送 ${result.published_count || result.burst_count || 3} 帧通知。`, "success");
await refreshState({ quiet: true });
} catch (error) { toast(error.message, "error"); }
finally { button.disabled = false; }
return;
}
if (action === "detail") {
showModal("固件产物详情", `<div class="detail-grid">
<div class="detail-field"><span>软件名称</span><strong>${escapeHtml(artifact.software_name)}</strong></div>
<div class="detail-field"><span>版本</span><strong>${escapeHtml(artifact.version)}</strong></div>
<div class="detail-field"><span>固件包</span><strong>${escapeHtml(artifact.firmware_filename)}</strong></div>
<div class="detail-field"><span>构建时间</span><strong>${escapeHtml(formatDate(artifact.build_time))}</strong></div>
<div class="detail-field"><span>固件包大小</span><strong>${escapeHtml(formatSize(artifact.size_bytes))}</strong></div>
<div class="detail-field"><span>发布状态</span><strong>${escapeHtml(STATUS_LABELS[artifact.status] || artifact.status)}</strong></div>
<div class="detail-field"><span>SHA-256(固件包)</span><strong>${escapeHtml(artifact.sha256)}</strong></div>
<div class="detail-field"><span>SHA-256(HEX 镜像)</span><strong>${escapeHtml(artifact.image_sha256)}</strong></div>
</div>`);
} else if (action === "capabilities") {
const diff = artifact.capability_diff || {};
showModal("能力差异", `<div class="diff-columns">
<div class="diff-panel"><h3>固件基线</h3>${(artifact.baseline_capabilities || []).map((item) => `<p>• ${escapeHtml(item)}</p>`).join("")}</div>
<div class="diff-panel added"><h3>目标固件变化</h3>${(diff.added || []).map((item) => `<p>+ ${escapeHtml(item)}</p>`).join("")}${(diff.retained || []).map((item) => `<p>✓ 保留${escapeHtml(item)}</p>`).join("") || "<p>无新增能力</p>"}</div>
</div>`);
} else if (action === "code") {
showModal("代码差异", `<pre class="code-block">${escapeHtml(artifact.code_diff || "暂无代码差异记录")}</pre>`);
} else if (action === "validation") {
showModal("验证记录", (artifact.validation_records || []).map((test) => `<div class="test-item"><span class="test-mark"><i data-lucide="check"></i></span><div class="test-copy"><strong>${escapeHtml(test.name)}</strong><span>${escapeHtml(test.summary)} · ${escapeHtml(formatDate(test.finished_at))}</span></div></div>`).join("") || `<div class="empty-inline">暂无验证记录</div>`);
}
}
ui.start.addEventListener("click", startTask);
ui.instruction.addEventListener("input", () => updateCapabilityPreview());
ui.baseline.addEventListener("change", () => updateCapabilityPreview());
ui.cancel.addEventListener("click", () => taskAction("cancel"));
ui.retry.addEventListener("click", () => taskAction("retry"));
ui.scrollLogs.addEventListener("click", () => { ui.logs.scrollTop = ui.logs.scrollHeight; });
document.querySelectorAll("[data-prompt]").forEach((button) => button.addEventListener("click", () => appendPrompt(button.dataset.prompt)));
document.querySelectorAll("[data-inspector-tab]").forEach((button) => button.addEventListener("click", () => { model.inspectorTab = button.dataset.inspectorTab; renderInspector(selectedTask()); refreshIcons(); }));
ui.artifacts.addEventListener("click", (event) => {
const button = event.target.closest("[data-artifact-action]");
if (button) artifactAction(button);
});
ui.modalClose.addEventListener("click", closeModal);
ui.modalCloseFooter.addEventListener("click", closeModal);
ui.modal.addEventListener("click", (event) => { if (event.target === ui.modal) closeModal(); });
document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !ui.modal.hidden) closeModal(); });
renderPipeline(null);
renderRelease(null);
updateCapabilityPreview();
refreshIcons();
refreshState();
})();