Files
pikasTech-HWLAB/web/hwlab-cloud-web/app-skills.ts
T
2026-05-31 13:58:51 +08:00

296 lines
10 KiB
TypeScript

function initSkillsPanel() {
el.skillRefresh.addEventListener("click", () => loadSkillsSurface({ force: true }));
el.skillUploadForm.addEventListener("submit", async (event) => {
event.preventDefault();
await uploadSelectedSkillFiles();
});
el.skillList.addEventListener("click", (event) => {
const item = event.target.closest("[data-skill-id]");
if (!item) return;
loadSkillDetail(item.dataset.skillId);
});
el.skillList.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const item = event.target.closest("[data-skill-id]");
if (!item) return;
event.preventDefault();
loadSkillDetail(item.dataset.skillId);
});
}
async function loadSkillsSurface(options = {}) {
if (state.skills.loading && options.force !== true) return;
state.skills.loading = true;
state.skills.error = null;
renderSkillsSurface();
const response = await fetchJson("/v1/skills", {
timeoutMs: LIVE_SURFACE_TIMEOUT_MS,
timeoutName: "skills 列表"
});
state.skills.loading = false;
if (!response.ok) {
state.skills.error = response.error || "skills 列表不可用";
state.skills.items = [];
state.skills.roots = null;
renderSkillsSurface();
return;
}
state.skills.items = Array.isArray(response.data?.skills) ? response.data.skills : [];
state.skills.roots = response.data?.roots ?? null;
if (!state.skills.selectedId && state.skills.items.length > 0) {
state.skills.selectedId = state.skills.items[0].id;
} else if (state.skills.selectedId && !state.skills.items.some((skill) => skill.id === state.skills.selectedId)) {
state.skills.selectedId = state.skills.items[0]?.id ?? null;
}
renderSkillsSurface();
if (state.skills.selectedId) await loadSkillDetail(state.skills.selectedId, { preserveList: true });
}
async function uploadSelectedSkillFiles() {
const files = [...(el.skillUploadInput.files ?? [])];
if (files.length === 0 || state.skills.uploadPending) return;
state.skills.uploadPending = true;
state.skills.error = null;
renderSkillsSurface();
try {
const payloadFiles = await Promise.all(files.map(readSkillUploadFile));
const response = await fetchJson("/v1/skills/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ files: payloadFiles }),
timeoutMs: 60000,
timeoutName: "skill 上传"
});
if (!response.ok) {
state.skills.error = response.error || "skill 上传失败";
return;
}
el.skillUploadInput.value = "";
state.skills.selectedId = response.data?.skill?.id ?? state.skills.selectedId;
await loadSkillsSurface({ force: true });
} catch (error) {
state.skills.error = `skill 上传失败:${error.message}`;
} finally {
state.skills.uploadPending = false;
renderSkillsSurface();
}
}
function readSkillUploadFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
const bytes = new Uint8Array(reader.result);
resolve({
relativePath: skillUploadRelativePath(file),
sizeBytes: file.size,
contentBase64: bytesToBase64(bytes)
});
});
reader.addEventListener("error", () => reject(reader.error ?? new Error(`无法读取 ${file.name}`)));
reader.readAsArrayBuffer(file);
});
}
function skillUploadRelativePath(file) {
return file.webkitRelativePath || file.relativePath || file.name;
}
function bytesToBase64(bytes) {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
async function loadSkillDetail(skillId, options = {}) {
const id = nonEmptyString(skillId);
if (!id) return;
state.skills.selectedId = id;
state.skills.detailLoading = true;
if (options.preserveList !== true) renderSkillsSurface();
const tree = await fetchJson(`/v1/skills/${encodeURIComponent(id)}/tree`, {
timeoutMs: LIVE_SURFACE_TIMEOUT_MS,
timeoutName: "skill 文件树"
});
state.skills.detailLoading = false;
if (!tree.ok) {
state.skills.detail = null;
state.skills.preview = null;
state.skills.error = tree.error || "skill 文件树不可用";
renderSkillsSurface();
return;
}
state.skills.detail = tree.data;
renderSkillsSurface();
await loadSkillPreview(id, "SKILL.md");
}
async function loadSkillPreview(skillId, relativePath = "SKILL.md") {
const id = nonEmptyString(skillId);
const filePath = nonEmptyString(relativePath) ?? "SKILL.md";
if (!id) return;
state.skills.previewLoading = true;
renderSkillPreview();
const response = await fetchJson(`/v1/skills/${encodeURIComponent(id)}/files?path=${encodeURIComponent(filePath)}`, {
timeoutMs: LIVE_SURFACE_TIMEOUT_MS,
timeoutName: "SKILL.md 预览"
});
state.skills.previewLoading = false;
if (!response.ok) {
state.skills.preview = {
path: filePath,
status: "failed",
content: response.error || "文件不可预览"
};
} else {
state.skills.preview = response.data?.file ?? null;
}
renderSkillPreview();
}
function renderSkillsSurface() {
const count = state.skills.items.length;
const statusText = state.skills.uploadPending
? "上传中"
: state.skills.loading
? "加载中"
: state.skills.error
? "阻塞"
: `${count} 个 skill`;
el.skillStatus.textContent = statusText;
el.skillStatus.className = `state-tag tone-${toneClass(state.skills.error ? "blocked" : state.skills.loading || state.skills.uploadPending ? "pending" : "source")}`;
el.skillUploadButton.disabled = state.skills.uploadPending;
el.skillRefresh.disabled = state.skills.loading;
renderSkillList();
renderSkillPreview();
}
function renderSkillList() {
if (state.skills.loading && state.skills.items.length === 0) {
replaceChildren(el.skillList, skillNotice("正在读取 /v1/skills。"));
return;
}
if (state.skills.error && state.skills.items.length === 0) {
replaceChildren(el.skillList, skillNotice(state.skills.error, "blocked"));
return;
}
if (state.skills.items.length === 0) {
replaceChildren(el.skillList, skillNotice("当前没有可显示的 skill。"));
return;
}
replaceChildren(el.skillList, ...state.skills.items.map(skillListItem));
}
function skillListItem(skill) {
const isActive = skill.id === state.skills.selectedId;
const article = document.createElement("article");
article.className = `skill-row tone-border-${toneClass(skill.source)}${isActive ? " active" : ""}`;
article.dataset.skillId = skill.id;
article.tabIndex = 0;
article.setAttribute("role", "button");
article.setAttribute("aria-expanded", isActive ? "true" : "false");
article.setAttribute("aria-label", `${skill.name} ${skill.source}`);
const head = document.createElement("div");
head.className = "skill-row-head";
head.append(textSpan(skill.name || skill.id, "skill-name"), badge(skillSourceLabel(skill.source), skill.source));
const meta = document.createElement("div");
meta.className = "skill-meta";
meta.append(
textSpan(skill.id, "mono wrap"),
textSpan(`${Number(skill.fileCount) || 0} files`),
textSpan(formatBytes(skill.sizeBytes))
);
article.append(head, textSpan(skill.description || "No description provided.", "skill-description"), meta);
if (isActive) article.append(activeSkillTree(skill));
return article;
}
function activeSkillTree(skill) {
const treeWrap = document.createElement("div");
treeWrap.className = "skill-row-tree";
treeWrap.addEventListener("click", (event) => {
event.stopPropagation();
const button = event.target.closest("[data-skill-file]");
if (!button) return;
loadSkillPreview(button.dataset.skillId, button.dataset.skillFile);
});
treeWrap.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
const button = event.target.closest("[data-skill-file]");
if (!button) return;
event.stopPropagation();
});
if (state.skills.detailLoading) {
treeWrap.append(skillNotice("正在读取文件树。"));
return treeWrap;
}
const tree = Array.isArray(state.skills.detail?.tree) ? state.skills.detail.tree : [];
if (tree.length === 0) {
treeWrap.append(skillNotice("文件树为空。"));
return treeWrap;
}
treeWrap.append(...tree.map((entry) => skillTreeEntry(skill.id, entry)));
return treeWrap;
}
function skillTreeEntry(skillId, entry) {
if (entry.type === "directory") {
const div = document.createElement("div");
div.className = "skill-tree-entry directory";
div.textContent = entry.path;
return div;
}
const button = document.createElement("button");
button.className = "skill-tree-entry file";
button.type = "button";
button.dataset.skillId = skillId;
button.dataset.skillFile = entry.path;
button.textContent = `${entry.path} · ${formatBytes(entry.sizeBytes)}`;
return button;
}
function renderSkillPreview() {
if (state.skills.previewLoading) {
el.skillPreview.dataset.previewState = "loading";
el.skillPreview.textContent = "正在读取 SKILL.md。";
return;
}
const preview = state.skills.preview;
if (!preview) {
el.skillPreview.dataset.previewState = "empty";
el.skillPreview.textContent = "等待 SKILL.md。";
return;
}
el.skillPreview.dataset.previewState = preview.status === "failed" ? "blocked" : "ready";
if (preview.path === "SKILL.md" && preview.status !== "failed") {
el.skillPreview.innerHTML = renderMessageMarkdown(preview.content || "");
hardenRenderedMarkdown(el.skillPreview);
} else {
el.skillPreview.textContent = preview.content || "";
}
}
function skillNotice(text, tone = "pending") {
const div = document.createElement("div");
div.className = `skill-notice tone-border-${toneClass(tone)}`;
div.textContent = text;
return div;
}
function skillSourceLabel(source) {
if (source === "preinstalled") return "预装";
if (source === "uploaded") return "上传";
return source || "来源";
}
function formatBytes(value) {
const bytes = Number(value);
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
}