diff --git a/deploy/deploy.json b/deploy/deploy.json index 5283c6b1..4d58a1b6 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -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", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index b6f2de50..b94560f2 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -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": {} diff --git a/internal/cloud/codex-stdio-session-helpers.ts b/internal/cloud/codex-stdio-session-helpers.ts index d02d0fdf..e82af7c2 100644 --- a/internal/cloud/codex-stdio-session-helpers.ts +++ b/internal/cloud/codex-stdio-session-helpers.ts @@ -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); diff --git a/internal/cloud/server-skills-http.ts b/internal/cloud/server-skills-http.ts new file mode 100644 index 00000000..fa845a86 --- /dev/null +++ b/internal/cloud/server-skills-http.ts @@ -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 + } + }; +} diff --git a/internal/cloud/server-skills.test.ts b/internal/cloud/server-skills.test.ts new file mode 100644 index 00000000..ebe1e330 --- /dev/null +++ b/internal/cloud/server-skills.test.ts @@ -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() + }; +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 9bc56966..2f8f695f 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -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; diff --git a/internal/cloud/skills-store.ts b/internal/cloud/skills-store.ts new file mode 100644 index 00000000..71b6a5b7 --- /dev/null +++ b/internal/cloud/skills-store.ts @@ -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; +} diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 766eba23..8f057705 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -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", diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs new file mode 100644 index 00000000..c7f395ff --- /dev/null +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -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" + }); +}); diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index 98b01665..9e66b3b2 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -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) { diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index db994b6f..9ce59263 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -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)); } diff --git a/web/hwlab-cloud-web/app-helpers.ts b/web/hwlab-cloud-web/app-helpers.ts index 177b12be..1e8f1bef 100644 --- a/web/hwlab-cloud-web/app-helpers.ts +++ b/web/hwlab-cloud-web/app-helpers.ts @@ -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`; diff --git a/web/hwlab-cloud-web/app-skills.ts b/web/hwlab-cloud-web/app-skills.ts new file mode 100644 index 00000000..bd01ca82 --- /dev/null +++ b/web/hwlab-cloud-web/app-skills.ts @@ -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`; +} diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index 0c1245a5..e35289a3 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -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() { diff --git a/web/hwlab-cloud-web/index.html b/web/hwlab-cloud-web/index.html index 0b4865f5..2e40c5e0 100644 --- a/web/hwlab-cloud-web/index.html +++ b/web/hwlab-cloud-web/index.html @@ -34,6 +34,7 @@