296 lines
10 KiB
TypeScript
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`;
|
|
}
|