Merge pull request #611 from pikasTech/feat/v02-uploaded-skills
feat: add v0.2 uploaded skills management
This commit is contained in:
+3
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user