feat: add uploaded skills management

This commit is contained in:
Codex
2026-05-31 11:44:50 +08:00
parent 47ab535cf2
commit 70ebd1689c
18 changed files with 1421 additions and 8 deletions
+3 -1
View File
@@ -195,7 +195,9 @@
"NO_PROXY": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com",
"no_proxy": "hyueapi.com,.hyueapi.com,hyui.com,.hyui.com,127.0.0.1,localhost,::1,api.minimaxi.com,.minimaxi.com",
"OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key",
"HWLAB_CODE_AGENT_SKILLS_DIRS": "/app/skills",
"HWLAB_CODE_AGENT_SKILLS_DIRS": "/app/skills:/data/user-skills",
"HWLAB_PREINSTALLED_SKILLS_DIR": "/app/skills",
"HWLAB_USER_SKILLS_DIR": "/data/user-skills",
"HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE": "deepseek",
"HWLAB_CODE_AGENT_DEEPSEEK_MODEL": "deepseek-chat",
"HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL": "http://hwlab-deepseek-proxy.hwlab-dev.svc.cluster.local:4000/v1/responses",
+40
View File
@@ -24,6 +24,28 @@
}
}
},
{
"apiVersion": "v1",
"kind": "PersistentVolumeClaim",
"metadata": {
"labels": {
"app.kubernetes.io/name": "hwlab-user-skills",
"hwlab.pikastech.local/service-id": "hwlab-user-skills"
},
"name": "hwlab-user-skills",
"namespace": "hwlab-dev"
},
"spec": {
"accessModes": [
"ReadWriteOnce"
],
"resources": {
"requests": {
"storage": "2Gi"
}
}
}
},
{
"apiVersion": "apps/v1",
"kind": "Deployment",
@@ -200,8 +222,16 @@
},
{
"name": "HWLAB_CODE_AGENT_SKILLS_DIRS",
"value": "/app/skills:/data/user-skills"
},
{
"name": "HWLAB_PREINSTALLED_SKILLS_DIR",
"value": "/app/skills"
},
{
"name": "HWLAB_USER_SKILLS_DIR",
"value": "/data/user-skills"
},
{
"name": "OPENAI_API_KEY",
"valueFrom": {
@@ -262,6 +292,10 @@
"name": "hwlab-code-agent-workspace",
"mountPath": "/workspace"
},
{
"name": "hwlab-user-skills",
"mountPath": "/data/user-skills"
},
{
"name": "hwlab-code-agent-codex-home",
"mountPath": "/codex-home"
@@ -349,6 +383,12 @@
"claimName": "hwlab-code-agent-workspace"
}
},
{
"name": "hwlab-user-skills",
"persistentVolumeClaim": {
"claimName": "hwlab-user-skills"
}
},
{
"name": "hwlab-code-agent-codex-home",
"emptyDir": {}
@@ -804,7 +804,10 @@ export function resolveSkillDirs(env = process.env, options = {}) {
if (options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1") {
return [...new Set(normalized)];
}
return [path.resolve("/app/skills")];
const defaults = [env.HWLAB_PREINSTALLED_SKILLS_DIR || "/app/skills", env.HWLAB_USER_SKILLS_DIR]
.filter((dir) => typeof dir === "string" && dir.trim())
.map((dir) => path.resolve(dir.trim()));
return [...new Set(normalized.length > 0 ? normalized : defaults)];
}
export async function skillManifestPaths(skillsDir) {
@@ -880,7 +883,11 @@ export function dedupeSkills(items) {
const seen = new Set();
const deduped = [];
for (const item of items) {
const key = item.name.toLowerCase();
const key = [
String(item.name ?? "").toLowerCase(),
item.sourceRoot ? path.resolve(item.sourceRoot) : "",
item.manifest?.relativePath ?? (item.manifestPath ? path.basename(item.manifestPath) : "")
].join("\0");
if (seen.has(key)) continue;
seen.add(key);
deduped.push(item);
+75
View File
@@ -0,0 +1,75 @@
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
import { getHeader, parsePositiveInteger, readBody, sendJson } from "./server-http-utils.ts";
import {
getSkillTree,
listSkills,
readSkillTextFile,
uploadUserSkill
} from "./skills-store.ts";
const DEFAULT_SKILL_UPLOAD_BODY_LIMIT_BYTES = 16 * 1024 * 1024;
export async function handleSkillsHttp(request, response, url, options = {}) {
try {
if (request.method === "GET" && url.pathname === "/v1/skills") {
sendJson(response, 200, await listSkills({ env: options.env ?? process.env }));
return;
}
if (request.method === "POST" && url.pathname === "/v1/skills/uploads") {
const payload = await readJsonBody(request, skillUploadBodyLimit(options.env));
sendJson(response, 201, await uploadUserSkill(payload, { env: options.env ?? process.env }));
return;
}
const treeMatch = url.pathname.match(/^\/v1\/skills\/([^/]+)\/tree$/u);
if (request.method === "GET" && treeMatch) {
sendJson(response, 200, await getSkillTree(decodeURIComponent(treeMatch[1]), { env: options.env ?? process.env }));
return;
}
const fileMatch = url.pathname.match(/^\/v1\/skills\/([^/]+)\/files$/u);
if (request.method === "GET" && fileMatch) {
sendJson(
response,
200,
await readSkillTextFile(decodeURIComponent(fileMatch[1]), url.searchParams.get("path") || "SKILL.md", { env: options.env ?? process.env })
);
return;
}
sendJson(response, 404, skillsErrorPayload(404, "skill_route_not_found", "Skill route is not implemented.", request));
} catch (error) {
const statusCode = Number.isInteger(error?.statusCode) ? error.statusCode : 500;
sendJson(response, statusCode, skillsErrorPayload(statusCode, error?.code || "skill_request_failed", error?.message || "Skill request failed.", request));
}
}
async function readJsonBody(request, limitBytes) {
const body = await readBody(request, limitBytes);
try {
return body ? JSON.parse(body) : {};
} catch (error) {
const wrapped = new Error(`Invalid JSON body: ${error.message}`);
Object.assign(wrapped, { statusCode: 400, code: "parse_error" });
throw wrapped;
}
}
function skillUploadBodyLimit(env = process.env) {
return parsePositiveInteger(env?.HWLAB_SKILL_UPLOAD_BODY_LIMIT_BYTES, DEFAULT_SKILL_UPLOAD_BODY_LIMIT_BYTES);
}
function skillsErrorPayload(statusCode, code, message, request) {
return {
serviceId: CLOUD_API_SERVICE_ID,
route: request?.url || "/v1/skills",
status: "failed",
error: {
code,
message,
httpStatus: statusCode,
traceId: getHeader(request, "x-trace-id") || null
}
};
}
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { lstat, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { test } from "bun:test";
import { createCloudApiServer } from "./server.ts";
import { discoverSkillsForStdio } from "./codex-stdio-session-helpers.ts";
test("cloud api skills upload/list/tree/preview keeps preinstalled and uploaded skill roots separate", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-skills-api-"));
const preinstalledDir = path.join(root, "app-skills");
const userSkillsDir = path.join(root, "user-skills");
const workspace = path.join(root, "workspace", "hwlab");
await mkdir(path.join(preinstalledDir, "same-name"), { recursive: true });
await writeFile(path.join(preinstalledDir, "same-name", "SKILL.md"), skillMarkdown("same-name", "Preinstalled skill."));
const server = createCloudApiServer({
env: {
PATH: process.env.PATH,
HWLAB_PREINSTALLED_SKILLS_DIR: preinstalledDir,
HWLAB_USER_SKILLS_DIR: userSkillsDir,
HWLAB_CODE_AGENT_WORKSPACE: workspace,
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
HWLAB_CODE_AGENT_CODEX_HOME: path.join(root, "codex-home")
}
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const created = await fetchJson(`${baseUrl}/v1/skills/uploads`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
files: [
{
relativePath: "uploaded-same-name/SKILL.md",
contentBase64: Buffer.from(skillMarkdown("same-name", "Uploaded skill."), "utf8").toString("base64")
},
{
relativePath: "uploaded-same-name/references/note.txt",
contentBase64: Buffer.from("uploaded note", "utf8").toString("base64")
}
]
})
});
assert.equal(created.status, 201);
assert.equal(created.body.accepted, true);
assert.equal(created.body.skill.source, "uploaded");
assert.equal(created.body.skill.name, "same-name");
assert.equal(created.body.skill.rootDir.startsWith(userSkillsDir), true);
assert.equal(created.body.skill.rootDir.startsWith(preinstalledDir), false);
assert.equal(created.body.skill.manifestPath, path.join(created.body.skill.rootDir, "SKILL.md"));
const list = await fetchJson(`${baseUrl}/v1/skills`);
assert.equal(list.status, 200);
assert.equal(list.body.roots.preinstalled, preinstalledDir);
assert.equal(list.body.roots.uploaded, userSkillsDir);
const sameName = list.body.skills.filter((skill) => skill.name === "same-name");
assert.equal(sameName.length, 2);
assert.deepEqual(sameName.map((skill) => skill.source).sort(), ["preinstalled", "uploaded"]);
const tree = await fetchJson(`${baseUrl}/v1/skills/${encodeURIComponent(created.body.skill.id)}/tree`);
assert.equal(tree.status, 200);
assert.ok(tree.body.tree.some((entry) => entry.path === "SKILL.md" && entry.type === "file"));
assert.ok(tree.body.tree.some((entry) => entry.path === "references/note.txt" && entry.type === "file"));
const preview = await fetchJson(`${baseUrl}/v1/skills/${encodeURIComponent(created.body.skill.id)}/files?path=SKILL.md`);
assert.equal(preview.status, 200);
assert.match(preview.body.file.content, /Uploaded skill/u);
assert.equal(await readFile(path.join(preinstalledDir, "same-name", "SKILL.md"), "utf8"), skillMarkdown("same-name", "Preinstalled skill."));
const aggregation = path.join(workspace, ".agents", "skills");
assert.equal((await lstat(path.join(aggregation, "preinstalled-same-name"))).isSymbolicLink(), true);
assert.equal((await lstat(path.join(aggregation, `uploaded-${created.body.skill.id}`))).isSymbolicLink(), true);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
await rm(root, { recursive: true, force: true });
}
});
test("codex stdio skills discovery keeps same-name manifests from different roots", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-skills-discovery-"));
const preinstalledDir = path.join(root, "app-skills");
const userSkillsDir = path.join(root, "user-skills");
await mkdir(path.join(preinstalledDir, "same-name"), { recursive: true });
await mkdir(path.join(userSkillsDir, "skill-uploaded"), { recursive: true });
await writeFile(path.join(preinstalledDir, "same-name", "SKILL.md"), skillMarkdown("same-name", "Preinstalled skill."));
await writeFile(path.join(userSkillsDir, "skill-uploaded", "SKILL.md"), skillMarkdown("same-name", "Uploaded skill."));
try {
const discovered = await discoverSkillsForStdio({
env: {
HWLAB_CODE_AGENT_SKILLS_DIRS: `${preinstalledDir}${path.delimiter}${userSkillsDir}`
}
});
assert.equal(discovered.status, "ready");
const sameName = discovered.items.filter((skill) => skill.name === "same-name");
assert.equal(sameName.length, 2);
assert.deepEqual(sameName.map((skill) => skill.sourceRoot).sort(), [preinstalledDir, userSkillsDir].sort());
} finally {
await rm(root, { recursive: true, force: true });
}
});
function skillMarkdown(name, description) {
return [
"---",
`name: ${name}`,
`description: ${description}`,
"version: v-test",
"---",
"",
`# ${name}`,
"",
description
].join("\n");
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, options);
return {
status: response.status,
body: await response.json()
};
}
+12 -1
View File
@@ -54,6 +54,11 @@ import {
handleCodeAgentTraceHttp
} from "./server-code-agent-http.ts";
import { handleM3IoControlHttp } from "./server-m3-http.ts";
import { handleSkillsHttp } from "./server-skills-http.ts";
import {
configureSkillRuntime,
ensureCodexSkillsAggregationSync
} from "./skills-store.ts";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
@@ -110,7 +115,7 @@ export function ensureCodeAgentRuntimeBase(env = process.env) {
env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR ||= "repo-owned";
env.CODEX_HOME ||= codexHome;
env.HWLAB_CODE_AGENT_CODEX_COMMAND ||= codexCommand;
env.HWLAB_CODE_AGENT_SKILLS_DIRS ||= "/app/skills";
const skillRuntime = configureSkillRuntime(env);
for (const target of [path.dirname(workspace), codexHome]) {
try {
@@ -128,6 +133,7 @@ export function ensureCodeAgentRuntimeBase(env = process.env) {
}
}
lstatSync(workspace);
ensureCodexSkillsAggregationSync(skillRuntime);
} catch {
// Runtime health reports the concrete missing/writable blocker.
}
@@ -298,6 +304,11 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (url.pathname === "/v1/skills" || url.pathname.startsWith("/v1/skills/")) {
await handleSkillsHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/users/me") {
await options.accessController.handleUserRoute(request, response, url);
return;
+533
View File
@@ -0,0 +1,533 @@
import { randomBytes } from "node:crypto";
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
readlinkSync,
rmSync,
symlinkSync
} from "node:fs";
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import {
boundSkillText,
firstManifestField,
firstMarkdownSummary,
parseFrontmatter
} from "./codex-stdio-session-helpers.ts";
export const DEFAULT_PREINSTALLED_SKILLS_DIR = "/app/skills";
export const DEFAULT_USER_SKILLS_DIR = "/data/user-skills";
export const USER_SKILL_METADATA_FILE = ".hwlab-skill.json";
export const USER_SKILLS_INDEX_FILE = ".hwlab-user-skills.json";
const DEFAULT_CODE_AGENT_WORKSPACE = "/workspace/hwlab";
export function configureSkillRuntime(env = process.env) {
const config = skillRuntimeConfig(env);
env.HWLAB_PREINSTALLED_SKILLS_DIR ||= config.preinstalledDir;
env.HWLAB_USER_SKILLS_DIR ||= config.userSkillsDir;
const configured = splitSkillDirs(env.HWLAB_CODE_AGENT_SKILLS_DIRS || env.UNIDESK_SKILLS_PATH || "");
const strict = env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1";
const nextDirs = strict && configured.length > 0
? configured
: [...configured, config.preinstalledDir, config.userSkillsDir];
env.HWLAB_CODE_AGENT_SKILLS_DIRS = uniquePaths(nextDirs).join(path.delimiter);
return config;
}
export function skillRuntimeConfig(env = process.env) {
const workspace = path.resolve(String(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || env.HWLAB_CODE_AGENT_WORKSPACE || DEFAULT_CODE_AGENT_WORKSPACE));
const preinstalledDir = path.resolve(String(env.HWLAB_PREINSTALLED_SKILLS_DIR || DEFAULT_PREINSTALLED_SKILLS_DIR));
const userSkillsDir = path.resolve(String(env.HWLAB_USER_SKILLS_DIR || DEFAULT_USER_SKILLS_DIR));
return {
workspace,
preinstalledDir,
userSkillsDir,
codexSkillsDir: path.join(workspace, ".agents", "skills")
};
}
export function splitSkillDirs(value) {
return String(value ?? "")
.split(/[,;]/u)
.flatMap((part) => part.split(path.delimiter))
.map((dir) => dir.trim())
.filter(Boolean);
}
export async function listSkills(options = {}) {
const env = options.env ?? process.env;
const config = skillRuntimeConfig(env);
await mkdir(config.userSkillsDir, { recursive: true });
const [preinstalled, uploaded] = await Promise.all([
listPreinstalledSkills(config.preinstalledDir),
listUploadedSkills(config.userSkillsDir)
]);
ensureCodexSkillsAggregationSync(config);
const skills = [...preinstalled, ...uploaded].sort(compareSkillItems);
return {
serviceId: "hwlab-cloud-api",
route: "/v1/skills",
status: "ok",
roots: {
preinstalled: config.preinstalledDir,
uploaded: config.userSkillsDir,
codexAggregation: config.codexSkillsDir
},
count: skills.length,
skills
};
}
export async function uploadUserSkill(payload, options = {}) {
const env = options.env ?? process.env;
const config = skillRuntimeConfig(env);
const prepared = prepareUploadedFiles(payload?.files);
if (prepared.length === 0) {
throw httpError(400, "invalid_skill_upload", "Skill upload body must include files.");
}
await mkdir(config.userSkillsDir, { recursive: true });
const id = await allocateUserSkillId(config.userSkillsDir);
const rootDir = path.join(config.userSkillsDir, id);
await mkdir(rootDir, { recursive: false });
let sizeBytes = 0;
try {
for (const file of prepared) {
const target = safeJoin(rootDir, file.relativePath);
if (!target) {
throw httpError(400, "invalid_skill_path", `Skill upload file path is outside the skill root: ${file.relativePath}`);
}
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content, { flag: "wx" });
sizeBytes += file.content.length;
}
const now = new Date().toISOString();
const manifest = await readSkillManifestSummary(path.join(rootDir, "SKILL.md"));
const metadata = {
id,
source: "uploaded",
name: nonEmptyString(payload?.name) ?? manifest?.name ?? id,
description: manifest?.description ?? "Uploaded skill",
version: manifest?.version ?? null,
commitId: manifest?.commitId ?? null,
rootDir,
manifestPath: existsSync(path.join(rootDir, "SKILL.md")) ? path.join(rootDir, "SKILL.md") : null,
createdAt: now,
updatedAt: now,
fileCount: prepared.length,
sizeBytes
};
await writeJson(path.join(rootDir, USER_SKILL_METADATA_FILE), metadata);
await writeUserSkillsIndex(config.userSkillsDir);
ensureCodexSkillsAggregationSync(config);
return {
serviceId: "hwlab-cloud-api",
route: "/v1/skills/uploads",
status: "ok",
accepted: true,
skill: metadata
};
} catch (error) {
await rmIfEmptyOrCreated(rootDir);
throw error;
}
}
export async function getSkillTree(id, options = {}) {
const skill = await resolveSkillById(id, options);
if (!skill) throw httpError(404, "skill_not_found", `Skill not found: ${id}`);
const tree = await walkSkillTree(skill.rootDir);
return {
serviceId: "hwlab-cloud-api",
route: `/v1/skills/${encodeURIComponent(id)}/tree`,
status: "ok",
skill,
tree,
count: tree.length
};
}
export async function readSkillTextFile(id, relativePath = "SKILL.md", options = {}) {
const skill = await resolveSkillById(id, options);
if (!skill) throw httpError(404, "skill_not_found", `Skill not found: ${id}`);
const safeRelativePath = nonEmptyString(relativePath) ?? "SKILL.md";
const filePath = safeJoin(skill.rootDir, safeRelativePath);
if (!filePath) throw httpError(400, "invalid_skill_path", "Requested skill file path is outside the skill root.");
let content;
try {
content = await readFile(filePath);
} catch {
throw httpError(404, "skill_file_not_found", `Skill file not found: ${safeRelativePath}`);
}
if (looksBinary(content)) {
throw httpError(415, "skill_file_binary", "Binary skill files are not returned inline.");
}
return {
serviceId: "hwlab-cloud-api",
route: `/v1/skills/${encodeURIComponent(id)}/files`,
status: "ok",
skill,
file: {
path: normalizeRelativePath(safeRelativePath),
sizeBytes: content.length,
contentType: "text/plain; charset=utf-8",
content: content.toString("utf8")
}
};
}
export async function resolveSkillById(id, options = {}) {
const skillId = nonEmptyString(id);
if (!skillId) return null;
const payload = await listSkills(options);
return payload.skills.find((skill) => skill.id === skillId) ?? null;
}
export async function listPreinstalledSkills(rootDir) {
return listSkillRoots(rootDir, "preinstalled");
}
export async function listUploadedSkills(rootDir) {
await mkdir(rootDir, { recursive: true });
return listSkillRoots(rootDir, "uploaded");
}
export function ensureCodexSkillsAggregationSync(input = process.env) {
const config = input?.codexSkillsDir ? input : skillRuntimeConfig(input);
try {
mkdirSync(config.codexSkillsDir, { recursive: true });
} catch {
return;
}
for (const record of skillRootRecordsSync(config.preinstalledDir, "preinstalled")) {
const linkName = `preinstalled-${safeLinkSegment(record.name)}`;
upsertSkillSymlink(path.join(config.codexSkillsDir, linkName), record.rootDir);
}
for (const record of skillRootRecordsSync(config.userSkillsDir, "uploaded")) {
const linkName = `uploaded-${safeLinkSegment(record.id)}`;
upsertSkillSymlink(path.join(config.codexSkillsDir, linkName), record.rootDir);
}
}
async function listSkillRoots(rootDir, source) {
const records = [];
const root = path.resolve(rootDir);
if (!existsSync(root)) return records;
if (existsSync(path.join(root, "SKILL.md"))) {
records.push(await skillItemFromRoot({ rootDir: root, source, idSeed: path.basename(root) || source }));
}
let entries = [];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return records;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const childRoot = path.join(root, entry.name);
if (source === "preinstalled" && !existsSync(path.join(childRoot, "SKILL.md"))) continue;
if (source === "uploaded" && !isUploadedSkillRoot(childRoot, entry.name)) continue;
records.push(await skillItemFromRoot({ rootDir: childRoot, source, idSeed: entry.name }));
}
return records.filter(Boolean);
}
async function skillItemFromRoot({ rootDir, source, idSeed }) {
const manifestPath = path.join(rootDir, "SKILL.md");
const manifest = await readSkillManifestSummary(manifestPath);
const metadata = source === "uploaded" ? await readJson(path.join(rootDir, USER_SKILL_METADATA_FILE)) : null;
const id = source === "uploaded"
? nonEmptyString(metadata?.id) ?? path.basename(rootDir)
: `preinstalled-${safeLinkSegment(idSeed)}`;
const stats = await skillRootStats(rootDir);
return {
id,
source,
name: nonEmptyString(metadata?.name) ?? manifest?.name ?? path.basename(rootDir),
description: nonEmptyString(metadata?.description) ?? manifest?.description ?? "No description provided.",
version: nonEmptyString(metadata?.version) ?? manifest?.version ?? null,
commitId: nonEmptyString(metadata?.commitId) ?? manifest?.commitId ?? null,
rootDir,
manifestPath: existsSync(manifestPath) ? manifestPath : null,
createdAt: nonEmptyString(metadata?.createdAt) ?? null,
updatedAt: nonEmptyString(metadata?.updatedAt) ?? null,
fileCount: Number.isInteger(metadata?.fileCount) ? metadata.fileCount : stats.fileCount,
sizeBytes: Number.isFinite(Number(metadata?.sizeBytes)) ? Number(metadata.sizeBytes) : stats.sizeBytes
};
}
async function readSkillManifestSummary(manifestPath) {
try {
const text = await readFile(manifestPath, "utf8");
const frontmatter = parseFrontmatter(text);
const body = text.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/u, "");
const name = nonEmptyString(frontmatter.name) ?? path.basename(path.dirname(manifestPath));
return {
name,
description: boundSkillText(frontmatter.description ?? firstMarkdownSummary(body) ?? "No description provided."),
version: firstManifestField(frontmatter, ["version", "skillVersion"]),
commitId: firstManifestField(frontmatter, ["commitId", "commit", "gitCommit", "revision"])
};
} catch {
return null;
}
}
function prepareUploadedFiles(files) {
const rawFiles = Array.isArray(files) ? files : [];
const prepared = [];
for (const file of rawFiles) {
const relativePath = normalizeRelativePath(file?.relativePath ?? file?.path ?? file?.name);
if (!relativePath) continue;
const content = decodeUploadContent(file);
if (!content) continue;
prepared.push({ relativePath, content });
}
return stripSingleTopLevelSkillFolder(prepared);
}
function stripSingleTopLevelSkillFolder(files) {
if (files.some((file) => file.relativePath === "SKILL.md")) return files;
const firstParts = files.map((file) => file.relativePath.split("/")).filter((parts) => parts.length > 1);
if (firstParts.length !== files.length) return files;
const [top] = firstParts[0] ?? [];
if (!top || firstParts.some((parts) => parts[0] !== top)) return files;
if (!files.some((file) => file.relativePath === `${top}/SKILL.md`)) return files;
return files.map((file) => ({ ...file, relativePath: file.relativePath.slice(top.length + 1) }));
}
function decodeUploadContent(file) {
if (typeof file?.contentBase64 === "string") return Buffer.from(file.contentBase64, "base64");
if (typeof file?.contentText === "string") return Buffer.from(file.contentText, "utf8");
if (typeof file?.content === "string") return Buffer.from(file.content, "utf8");
return null;
}
async function allocateUserSkillId(rootDir) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const id = `skill-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
if (!existsSync(path.join(rootDir, id))) return id;
}
throw httpError(500, "skill_id_allocation_failed", "Could not allocate uploaded skill id.");
}
async function writeUserSkillsIndex(rootDir) {
const skills = await listSkillRoots(rootDir, "uploaded");
await writeJson(path.join(rootDir, USER_SKILLS_INDEX_FILE), {
version: 1,
updatedAt: new Date().toISOString(),
skills: skills.map((skill) => ({
id: skill.id,
name: skill.name,
source: skill.source,
rootDir: skill.rootDir,
manifestPath: skill.manifestPath,
createdAt: skill.createdAt,
updatedAt: skill.updatedAt,
fileCount: skill.fileCount,
sizeBytes: skill.sizeBytes
}))
});
}
async function walkSkillTree(rootDir, options = {}) {
const limit = Number.isInteger(options.limit) ? options.limit : 500;
const result = [];
async function walk(current, relativeRoot, depth) {
if (result.length >= limit || depth > 8) return;
let entries = [];
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, "en"))) {
if (entry.name === USER_SKILL_METADATA_FILE || entry.name === USER_SKILLS_INDEX_FILE) continue;
const relativePath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
result.push({ path: relativePath, type: "directory" });
await walk(fullPath, relativePath, depth + 1);
} else if (entry.isFile()) {
const buffer = await readFile(fullPath).catch(() => null);
result.push({ path: relativePath, type: "file", sizeBytes: buffer?.length ?? null });
}
if (result.length >= limit) return;
}
}
await walk(rootDir, "", 0);
return result;
}
async function skillRootStats(rootDir) {
const tree = await walkSkillTree(rootDir, { limit: 500 });
return {
fileCount: tree.filter((entry) => entry.type === "file").length,
sizeBytes: tree.reduce((total, entry) => total + (Number(entry.sizeBytes) || 0), 0)
};
}
function skillRootRecordsSync(rootDir, source) {
const root = path.resolve(rootDir);
if (!existsSync(root)) return [];
const records = [];
if (existsSync(path.join(root, "SKILL.md"))) {
records.push(skillRootRecordSync(root, source, path.basename(root) || source));
}
let entries = [];
try {
entries = readdirSync(root, { withFileTypes: true });
} catch {
return records.filter(Boolean);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const childRoot = path.join(root, entry.name);
if (source === "preinstalled" && !existsSync(path.join(childRoot, "SKILL.md"))) continue;
if (source === "uploaded" && !isUploadedSkillRoot(childRoot, entry.name)) continue;
records.push(skillRootRecordSync(childRoot, source, entry.name));
}
return records.filter(Boolean);
}
function skillRootRecordSync(rootDir, source, idSeed) {
const metadata = source === "uploaded" ? readJsonSync(path.join(rootDir, USER_SKILL_METADATA_FILE)) : null;
const manifest = readSkillManifestSummarySync(path.join(rootDir, "SKILL.md"));
return {
id: source === "uploaded" ? nonEmptyString(metadata?.id) ?? path.basename(rootDir) : `preinstalled-${safeLinkSegment(idSeed)}`,
name: nonEmptyString(metadata?.name) ?? manifest?.name ?? path.basename(rootDir),
rootDir
};
}
function readSkillManifestSummarySync(manifestPath) {
try {
const text = readFileSync(manifestPath, "utf8");
const frontmatter = parseFrontmatter(text);
return {
name: nonEmptyString(frontmatter.name) ?? path.basename(path.dirname(manifestPath))
};
} catch {
return null;
}
}
function isUploadedSkillRoot(rootDir, name) {
if (name === "lost+found") return false;
return name.startsWith("skill-") ||
existsSync(path.join(rootDir, USER_SKILL_METADATA_FILE)) ||
existsSync(path.join(rootDir, "SKILL.md"));
}
function upsertSkillSymlink(linkPath, target) {
try {
const current = lstatSync(linkPath);
if (!current.isSymbolicLink()) return;
const linked = path.resolve(path.dirname(linkPath), readlinkSync(linkPath));
if (linked === path.resolve(target)) return;
rmSync(linkPath, { force: true });
} catch {
// Missing links are created below.
}
try {
symlinkSync(target, linkPath, "dir");
} catch {
// Runtime health and API list expose source roots; symlink aggregation is best-effort.
}
}
function normalizeRelativePath(value) {
const text = String(value ?? "").replace(/\\/gu, "/").replace(/^\/+/, "").trim();
const parts = text.split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) return null;
return parts.join("/");
}
function safeJoin(rootDir, relativePath) {
const normalized = normalizeRelativePath(relativePath);
if (!normalized) return null;
const root = path.resolve(rootDir);
const target = path.resolve(root, normalized);
return target === root || target.startsWith(`${root}${path.sep}`) ? target : null;
}
function looksBinary(buffer) {
if (!Buffer.isBuffer(buffer)) return false;
return buffer.subarray(0, Math.min(buffer.length, 8000)).includes(0);
}
async function readJson(filePath) {
try {
return JSON.parse(await readFile(filePath, "utf8"));
} catch {
return null;
}
}
function readJsonSync(filePath) {
try {
return JSON.parse(readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
async function writeJson(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function rmIfEmptyOrCreated(rootDir) {
await rm(rootDir, { recursive: true, force: true }).catch(() => {});
}
function nonEmptyString(value) {
const text = typeof value === "string" ? value.trim() : "";
return text || null;
}
function uniquePaths(values) {
const seen = new Set();
const result = [];
for (const value of values) {
const text = String(value ?? "").trim();
if (!text) continue;
const resolved = path.resolve(text);
if (seen.has(resolved)) continue;
seen.add(resolved);
result.push(resolved);
}
return result;
}
function compareSkillItems(a, b) {
return String(a.name).localeCompare(String(b.name), "en") ||
String(a.source).localeCompare(String(b.source), "en") ||
String(a.id).localeCompare(String(b.id), "en");
}
function safeLinkSegment(value) {
return String(value ?? "skill")
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/giu, "-")
.replace(/^-+|-+$/gu, "")
.slice(0, 80) || "skill";
}
function httpError(statusCode, code, message) {
const error = new Error(message);
Object.assign(error, { statusCode, code });
return error;
}
+2 -1
View File
@@ -6,7 +6,8 @@ const POST_PROXY_ROUTES = new Set([
"/json-rpc",
"/v1/agent/chat",
"/v1/agent/chat/cancel",
"/v1/m3/io"
"/v1/m3/io",
"/v1/skills/uploads"
]);
const PUBLIC_PROXY_ROUTES = new Set([
"GET /auth/session",
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
test("cloud web proxies skills routes through authenticated cloud-api", () => {
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/skills"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "GET /v1/skills"
});
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/skills/skill_1/tree"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "GET /v1/skills/skill_1/tree"
});
assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/skills/uploads"), {
proxy: true,
authRequired: true,
publicRoute: false,
routeKey: "POST /v1/skills/uploads"
});
});
@@ -52,7 +52,7 @@ export function createCloudWebServer({
}
const routePath = url.pathname.replace(/\/+$/u, "") || "/";
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help"
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help" || routePath === "/skills"
? "index.html"
: url.pathname.slice(1);
for (const root of roots) {
+3
View File
@@ -856,6 +856,9 @@ function transformWorkloads({ workloads, deploy, catalog, source, registryPrefix
upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_BASE_URL", codexApiProfileBaseUrl);
upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_UPSTREAM_BASE_URL", codexApiForwarderUpstreamBaseUrl);
upsertEnv(container.env, "HWLAB_CODE_AGENT_CODEX_API_FORWARDER_PORT", codexApiForwarderPort);
upsertEnv(container.env, "HWLAB_PREINSTALLED_SKILLS_DIR", "/app/skills");
upsertEnv(container.env, "HWLAB_USER_SKILLS_DIR", "/data/user-skills");
upsertEnv(container.env, "HWLAB_CODE_AGENT_SKILLS_DIRS", "/app/skills:/data/user-skills");
upsertEnv(container.env, "NO_PROXY", codeAgentNoProxy(namespace));
upsertEnv(container.env, "no_proxy", codeAgentNoProxy(namespace));
}
+4
View File
@@ -610,6 +610,10 @@ function helpPathnames() {
return new Set(["/help"]);
}
function skillsPathnames() {
return new Set(["/skills"]);
}
function valueOrUnavailable(liveValue, sourceValue) {
if (liveValue !== undefined && liveValue !== null) return liveValue;
if (sourceValue !== undefined && sourceValue !== null) return `${sourceValue} 来源 SOURCE`;
+290
View File
@@ -0,0 +1,290 @@
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);
});
el.skillTree.addEventListener("click", (event) => {
const button = event.target.closest("[data-skill-file]");
if (!button) return;
loadSkillPreview(button.dataset.skillId, button.dataset.skillFile);
});
}
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();
renderSkillDetail();
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 article = document.createElement("article");
article.className = `skill-row tone-border-${toneClass(skill.source)}${skill.id === state.skills.selectedId ? " active" : ""}`;
article.dataset.skillId = skill.id;
article.tabIndex = 0;
article.setAttribute("role", "button");
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);
return article;
}
function renderSkillDetail() {
const skill = state.skills.items.find((item) => item.id === state.skills.selectedId) ?? state.skills.detail?.skill ?? null;
if (!skill) {
el.skillDetailTitle.textContent = "未选择 skill";
replaceChildren(el.skillTree, skillNotice("等待选择 skill。"));
return;
}
el.skillDetailTitle.textContent = skill.name || skill.id;
if (state.skills.detailLoading) {
replaceChildren(el.skillTree, skillNotice("正在读取文件树。"));
return;
}
const tree = Array.isArray(state.skills.detail?.tree) ? state.skills.detail.tree : [];
if (tree.length === 0) {
replaceChildren(el.skillTree, skillNotice("文件树为空。"));
return;
}
replaceChildren(el.skillTree, ...tree.map((entry) => skillTreeEntry(skill.id, entry)));
}
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`;
}
+26
View File
@@ -134,6 +134,15 @@ const el = {
gateSearch: byId("gate-search"),
gateRefresh: byId("gate-refresh"),
gateReviewBody: byId("gate-review-body"),
skillStatus: byId("skill-status"),
skillRefresh: byId("skill-refresh"),
skillUploadForm: byId("skill-upload-form"),
skillUploadInput: byId("skill-upload-input"),
skillUploadButton: byId("skill-upload-button"),
skillList: byId("skill-list"),
skillDetailTitle: byId("skill-detail-title"),
skillTree: byId("skill-tree"),
skillPreview: byId("skill-preview"),
commandForm: byId("command-form"),
commandInput: byId("command-input"),
codeAgentProviderProfile: byId("code-agent-provider-profile"),
@@ -197,6 +206,18 @@ const state = {
error: null,
loadedAt: null
},
skills: {
loading: false,
uploadPending: false,
detailLoading: false,
previewLoading: false,
items: [],
roots: null,
selectedId: null,
detail: null,
preview: null,
error: null
},
devicePod: {
selectedDevicePodId: "device-pod-71-freq",
list: null,
@@ -230,6 +251,7 @@ initLeftSidebarToggle();
initLeftSidebarResize();
initRightSidebarResize();
initDevicePodPanel();
initSkillsPanel();
initCodeAgentTimeoutControl();
initConversationScrollMemory();
initCommandBar();
@@ -752,6 +774,7 @@ function routeFromLocation() {
const hashRoute = window.location.hash.replace(/^#\/?/, "");
if (viewIds.has(hashRoute)) return hashRoute;
if (helpPathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "help";
if (skillsPathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "skills";
if (internalGatePathnames().has(window.location.pathname.replace(/\/+$/, "") || "/")) return "gate";
return "workspace";
}
@@ -769,6 +792,9 @@ function showView(route) {
if (activeRoute === "gate" && state.gateDiagnostics.rows.length === 0 && !state.gateDiagnostics.loading) {
loadGateDiagnostics();
}
if (activeRoute === "skills" && state.skills.items.length === 0 && !state.skills.loading) {
loadSkillsSurface();
}
}
function initLeftSidebarResize() {
+40
View File
@@ -34,6 +34,7 @@
<main class="workbench-shell" data-app-shell data-help-route-policy="non-default-internal-help" data-internal-help-path="/help.md" hidden aria-hidden="true">
<aside class="activity-rail" id="activity-rail" aria-label="工作台活动栏">
<button class="rail-button active" type="button" data-route="workspace" title="工作台" aria-label="工作台">工作台</button>
<button class="rail-button" type="button" data-route="skills" title="Skills" aria-label="Skills">Skills</button>
<button class="rail-button" type="button" data-route="gate" title="内部复核" aria-label="内部复核">内部复核</button>
<button class="rail-button" type="button" data-route="help" title="使用说明" aria-label="使用说明">使用说明</button>
<span class="rail-spacer" aria-hidden="true"></span>
@@ -120,6 +121,45 @@
</div>
</section>
<section class="view skills-view" id="skills" data-view="skills" aria-labelledby="skills-title" hidden>
<div class="workspace-panel skills-panel">
<div class="panel-title-row">
<div>
<p class="eyebrow">Skills</p>
<h2 id="skills-title">Skill 管理</h2>
</div>
<div class="skills-actions">
<span class="state-tag tone-source" id="skill-status">未加载</span>
<button class="command-button secondary" id="skill-refresh" type="button">刷新</button>
</div>
</div>
<div class="skills-layout">
<section class="skill-upload-panel" aria-label="上传 skill">
<form id="skill-upload-form" class="skill-upload-form">
<label class="skill-upload-drop">
<span>选择目录</span>
<input id="skill-upload-input" type="file" multiple webkitdirectory directory />
</label>
<button class="command-button" id="skill-upload-button" type="submit">上传</button>
</form>
<div id="skill-list" class="skill-list" aria-label="Skill 列表" aria-live="polite"></div>
</section>
<section class="skill-detail-panel" aria-label="Skill 文件">
<div class="panel-title-row skill-detail-head">
<div>
<p class="eyebrow">Skill Files</p>
<h3 id="skill-detail-title">未选择 skill</h3>
</div>
</div>
<div id="skill-tree" class="skill-tree" aria-label="Skill 文件树"></div>
</section>
<section class="skill-preview-panel" aria-label="SKILL.md 预览">
<div id="skill-preview" class="skill-preview" aria-live="polite">等待 SKILL.md。</div>
</section>
</div>
</div>
</section>
<section class="view gate-view" id="gate" data-view="gate" aria-labelledby="gate-title" hidden>
<header class="gate-header">
<div>
+14
View File
@@ -12,6 +12,7 @@ const requiredFiles = Object.freeze([
"index.html",
"styles.css",
"app.ts",
"app-skills.ts",
"app-device-pod.ts",
"app-conversation.ts",
"app-trace.ts",
@@ -56,6 +57,15 @@ const artifactCatalog = readRepo("deploy/artifact-catalog.dev.json");
const rightSidebar = sectionById(html, "aside", "device-pod-sidebar");
assert.ok(rightSidebar, "Device Pod right sidebar must exist");
assert.match(html, /data-route="skills"/u, "Cloud Web must expose the skills activity route");
assert.match(html, /id="skills"[^>]*data-view="skills"/u, "Cloud Web must expose a skills view");
assert.match(html, /id="skill-upload-input"[^>]*webkitdirectory/u, "Skills view must support directory upload");
assertIncludes(app, "/v1/skills", "skills frontend must call /v1/skills");
assertIncludes(app, "/v1/skills/uploads", "skills frontend must call /v1/skills/uploads");
assert.match(app, /function\s+initSkillsPanel\s*\(/u, "missing initSkillsPanel");
assert.match(app, /function\s+loadSkillsSurface\s*\(/u, "missing loadSkillsSurface");
assert.match(app, /function\s+uploadSelectedSkillFiles\s*\(/u, "missing uploadSelectedSkillFiles");
assert.match(app, /function\s+renderSkillPreview\s*\(/u, "missing renderSkillPreview");
for (const term of [
"Device Pod",
@@ -165,6 +175,10 @@ for (const oldAsset of ["code-agent-m3-evidence.mjs", "wiring-status.mjs", "work
}
assertIncludes(cloudApiServer, "/v1/device-pods", "cloud-api must expose Device Pod REST routes");
assertIncludes(cloudApiServer, "/v1/skills", "cloud-api must expose Skills REST routes");
assertIncludes(deployJson, "HWLAB_USER_SKILLS_DIR", "deploy manifest must define uploaded skills PVC env");
assertIncludes(deployJson, "/data/user-skills", "deploy manifest must keep uploaded skills outside /app/skills");
assertIncludes(readRepo("deploy/k8s/base/workloads.yaml"), "hwlab-user-skills", "base workload must include uploaded skills PVC");
assertIncludes(cloudApiPayloads, "buildDevicePodCloudApiPayload", "cloud-api payload module must keep the Device Pod compatibility payload helper");
assertIncludes(cloudApiPayloads, "device_pod_authority_unavailable", "Device Pod compatibility helper must block instead of fake fallback");
assertIncludes(devicePodData, "buildDevicePodRestPayload", "Legacy Device Pod fixtures must keep the REST payload helper for old smokes");
+2 -1
View File
@@ -9,6 +9,7 @@ export const cloudWebDistBuildCommand = "cd web/hwlab-cloud-web && bun run build
export const cloudWebDistFreshnessCommand = cloudWebDistBuildCommand;
export const cloudWebAppSourceFiles = Object.freeze([
"app.ts",
"app-skills.ts",
"app-device-pod.ts",
"app-conversation.ts",
"app-trace.ts",
@@ -24,7 +25,7 @@ export const cloudWebDistRuntimeFiles = Object.freeze([
"third_party/marked/marked.esm.js",
"third_party/marked/LICENSE"
]);
export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html", "help/index.html"]);
export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html", "help/index.html", "skills/index.html"]);
const staticRuntimeFiles = cloudWebDistRuntimeFiles.filter((file) => file !== "app.js");
+215 -1
View File
@@ -242,7 +242,7 @@ body > [data-app-shell][hidden] {
.activity-rail {
display: grid;
grid-template-rows: repeat(3, minmax(42px, auto)) minmax(0, 1fr) auto;
grid-template-rows: repeat(4, minmax(42px, auto)) minmax(0, 1fr) auto;
gap: 6px;
padding: 8px;
background: var(--rail);
@@ -452,6 +452,202 @@ h3 {
min-width: 0;
}
.skills-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.skills-panel {
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
padding: 12px;
}
.skills-layout {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns: minmax(250px, 0.76fr) minmax(210px, 0.54fr) minmax(320px, 1fr);
gap: 10px;
}
.skill-upload-panel,
.skill-detail-panel,
.skill-preview-panel {
min-width: 0;
min-height: 0;
display: grid;
gap: 10px;
align-content: start;
overflow: hidden;
}
.skill-upload-panel,
.skill-detail-panel {
grid-template-rows: auto minmax(0, 1fr);
}
.skill-upload-form {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.skill-upload-drop {
min-width: 0;
min-height: 42px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px dashed var(--line-strong);
background: var(--surface-2);
color: var(--muted);
font-weight: 760;
}
.skill-upload-drop input {
min-width: 0;
color: var(--text);
}
.skill-list,
.skill-tree,
.skill-preview {
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.skill-list,
.skill-tree {
display: grid;
align-content: start;
gap: 7px;
}
.skill-row,
.skill-notice,
.skill-tree-entry {
min-width: 0;
padding: 9px;
border: 1px solid var(--line);
background: var(--surface-2);
}
.skill-row {
display: grid;
gap: 7px;
cursor: pointer;
}
.skill-row:hover,
.skill-row:focus-visible,
.skill-row.active {
border-color: var(--accent);
outline: 0;
}
.skill-row.active {
box-shadow: inset 3px 0 0 var(--accent);
}
.skill-row-head,
.skill-meta {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
}
.skill-row-head {
justify-content: space-between;
}
.skill-name,
.skill-description,
.skill-meta {
min-width: 0;
overflow-wrap: anywhere;
}
.skill-name {
color: var(--text);
font-weight: 850;
}
.skill-description,
.skill-meta {
color: var(--muted);
font-size: 11px;
}
.skill-meta {
flex-wrap: wrap;
font-family: var(--mono);
}
.skill-detail-head {
min-height: 42px;
}
.skill-tree-entry.directory {
color: var(--accent-2);
font-family: var(--mono);
font-size: 11px;
}
.skill-tree-entry.file {
width: 100%;
color: var(--text);
font-family: var(--mono);
font-size: 11px;
text-align: left;
cursor: pointer;
}
.skill-tree-entry.file:hover,
.skill-tree-entry.file:focus-visible {
border-color: var(--accent);
outline: 0;
}
.skill-preview {
padding: 12px;
border: 1px solid var(--line);
background: #101310;
color: var(--text);
font-family: var(--mono);
font-size: 11px;
line-height: 1.55;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.skill-preview h1,
.skill-preview h2,
.skill-preview h3 {
margin: 0 0 8px;
font-family: var(--body);
font-size: 13px;
}
.skill-preview p,
.skill-preview ul,
.skill-preview ol,
.skill-preview pre {
margin: 0 0 8px;
}
.panel-title-row p,
.panel-copy {
color: var(--muted);
@@ -1945,11 +2141,21 @@ tbody tr:last-child td {
.workbench-view,
.gate-view,
.skills-view,
.help-view,
.right-sidebar {
padding: 8px;
}
.skills-layout {
grid-template-columns: 1fr;
grid-template-rows: auto minmax(160px, 0.7fr) minmax(220px, 1fr);
}
.skill-upload-form {
grid-template-columns: 1fr;
}
.conversation-column {
grid-template-rows: minmax(0, 1fr);
gap: 8px;
@@ -2096,6 +2302,14 @@ tbody tr:last-child td {
grid-template-rows: auto minmax(0, 1fr);
}
.skills-actions,
.skill-row-head,
.skill-upload-drop {
align-items: stretch;
flex-direction: column;
grid-template-columns: 1fr;
}
.command-button {
min-width: 45px;
padding: 0 8px;