305 lines
9.8 KiB
JavaScript
305 lines
9.8 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
ensurePublishSkillInstalled,
|
|
PUBLISH_SKILL_NAME,
|
|
renderPublishSkill,
|
|
resolvePublicBaseUrl,
|
|
} from './user-publish.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
|
|
export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst';
|
|
export const IMAGE_GENERATION_SKILL_NAME = 'image-generation';
|
|
|
|
export const DEFAULT_USER_SKILLS = {
|
|
web: true,
|
|
search: true,
|
|
'search-enhanced': false,
|
|
[EXCEL_ANALYST_SKILL_NAME]: false,
|
|
'schedule-assistant': true,
|
|
'service-integration-smoke': true,
|
|
'form-builder': true,
|
|
'table-viewer': true,
|
|
'product-campaign-page': true,
|
|
'docx-generate': true,
|
|
'long-image-download': true,
|
|
[IMAGE_GENERATION_SKILL_NAME]: true,
|
|
[PAGE_DATA_COLLECT_SKILL_NAME]: true,
|
|
[PUBLISH_SKILL_NAME]: false,
|
|
};
|
|
|
|
/** Optional presets for admin role configuration (creator / developer). */
|
|
export const USER_ROLE_SKILL_PRESETS = {
|
|
user: DEFAULT_USER_SKILLS,
|
|
creator: {
|
|
...DEFAULT_USER_SKILLS,
|
|
[PUBLISH_SKILL_NAME]: true,
|
|
kanban: true,
|
|
timeline: true,
|
|
},
|
|
developer: {
|
|
...DEFAULT_USER_SKILLS,
|
|
git: true,
|
|
'diff-viewer': true,
|
|
'code-playground': true,
|
|
'test-runner': true,
|
|
},
|
|
};
|
|
|
|
export function parseSkillFrontmatter(content) {
|
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
if (!match) return { name: null, description: '' };
|
|
const block = match[1];
|
|
const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? null;
|
|
const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
|
return { name, description };
|
|
}
|
|
|
|
function parseManifestScalar(value) {
|
|
const text = String(value ?? '').trim();
|
|
if (!text) return '';
|
|
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
return text.slice(1, -1);
|
|
}
|
|
if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text);
|
|
if (/^(?:true|false)$/i.test(text)) return text.toLowerCase() === 'true';
|
|
if (/^(?:null|~)$/i.test(text)) return null;
|
|
return text;
|
|
}
|
|
|
|
/** Parse the small YAML subset used by platform skill manifests without adding a runtime dependency. */
|
|
export function parseSkillManifest(content) {
|
|
const lines = String(content ?? '').split(/\r?\n/);
|
|
const root = {};
|
|
const stack = [{ indent: -1, value: root }];
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const raw = lines[index];
|
|
const trimmed = raw.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const indent = raw.match(/^\s*/)?.[0]?.length ?? 0;
|
|
while (stack.length > 1 && stack.at(-1).indent >= indent) stack.pop();
|
|
const parent = stack.at(-1).value;
|
|
|
|
if (trimmed.startsWith('- ')) {
|
|
if (!Array.isArray(parent)) throw new Error(`Invalid manifest list at line ${index + 1}`);
|
|
parent.push(parseManifestScalar(trimmed.slice(2)));
|
|
continue;
|
|
}
|
|
|
|
const separator = trimmed.indexOf(':');
|
|
if (separator <= 0 || Array.isArray(parent)) {
|
|
throw new Error(`Invalid manifest entry at line ${index + 1}`);
|
|
}
|
|
const key = trimmed.slice(0, separator).trim();
|
|
const value = trimmed.slice(separator + 1).trim();
|
|
if (value) {
|
|
parent[key] = parseManifestScalar(value);
|
|
continue;
|
|
}
|
|
|
|
let nextTrimmed = '';
|
|
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
nextTrimmed = lines[cursor].trim();
|
|
if (nextTrimmed && !nextTrimmed.startsWith('#')) break;
|
|
}
|
|
const child = nextTrimmed.startsWith('- ') ? [] : {};
|
|
parent[key] = child;
|
|
stack.push({ indent, value: child });
|
|
}
|
|
|
|
return root;
|
|
}
|
|
|
|
export function listPlatformSkillCatalog(h5Root = __dirname) {
|
|
const skillsRoot = path.join(h5Root, 'skills');
|
|
if (!fs.existsSync(skillsRoot)) return [];
|
|
|
|
const catalog = [];
|
|
for (const entry of fs.readdirSync(skillsRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const skillPath = path.join(skillsRoot, entry.name, 'SKILL.md');
|
|
if (!fs.existsSync(skillPath)) continue;
|
|
const raw = fs.readFileSync(skillPath, 'utf8');
|
|
const meta = parseSkillFrontmatter(raw);
|
|
const manifestPath = path.join(skillsRoot, entry.name, 'skill.yaml');
|
|
let manifest = null;
|
|
if (fs.existsSync(manifestPath)) {
|
|
try {
|
|
manifest = parseSkillManifest(fs.readFileSync(manifestPath, 'utf8'));
|
|
} catch {
|
|
manifest = null;
|
|
}
|
|
}
|
|
const name = manifest?.name || meta.name || entry.name;
|
|
catalog.push({
|
|
name,
|
|
dirName: entry.name,
|
|
label: manifest?.label || name,
|
|
description: manifest?.description || meta.description || '平台通用技能',
|
|
version: manifest?.version ?? null,
|
|
executors: Array.isArray(manifest?.executors) ? manifest.executors : ['goose'],
|
|
manifest,
|
|
category: 'platform',
|
|
requiresPublish: name === PUBLISH_SKILL_NAME || name === PAGE_DATA_COLLECT_SKILL_NAME,
|
|
});
|
|
}
|
|
return catalog.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
export function catalogSkillNames(catalog) {
|
|
return catalog.map((item) => item.name);
|
|
}
|
|
|
|
export function isValidSkillName(catalog, name) {
|
|
return catalog.some((item) => item.name === name);
|
|
}
|
|
|
|
export function normalizeSkillPatch(catalog, patch) {
|
|
const normalized = {};
|
|
for (const [key, value] of Object.entries(patch ?? {})) {
|
|
if (!isValidSkillName(catalog, key)) continue;
|
|
normalized[key] = Boolean(value);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function resolveSkillMap(roleGrants, userOverrides, catalog) {
|
|
const resolved = {};
|
|
for (const item of catalog) {
|
|
const key = item.name;
|
|
if (key in userOverrides) {
|
|
resolved[key] = userOverrides[key];
|
|
} else if (key in roleGrants) {
|
|
resolved[key] = roleGrants[key];
|
|
} else {
|
|
resolved[key] = DEFAULT_USER_SKILLS[key] ?? false;
|
|
}
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
export function grantedSkillNames(skillMap) {
|
|
return Object.entries(skillMap)
|
|
.filter(([, enabled]) => enabled)
|
|
.map(([name]) => name);
|
|
}
|
|
|
|
export function applySkillGrantsToCapabilities(capabilities, skillMap) {
|
|
const effective = { ...capabilities };
|
|
const enabled = grantedSkillNames(skillMap);
|
|
if (enabled.length > 0) {
|
|
effective.skills = true;
|
|
}
|
|
if (enabled.includes(PUBLISH_SKILL_NAME) || enabled.includes(PAGE_DATA_COLLECT_SKILL_NAME)) {
|
|
effective.static_publish = true;
|
|
}
|
|
// Excel is intentionally a single MemindAdm skill switch. The internal
|
|
// capability is derived here instead of appearing as a second admin toggle.
|
|
effective.excel_analysis = enabled.includes(EXCEL_ANALYST_SKILL_NAME);
|
|
return effective;
|
|
}
|
|
|
|
function copySkillTree(srcDir, destDir) {
|
|
fs.mkdirSync(destDir, { recursive: true });
|
|
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
|
|
const from = path.join(srcDir, entry.name);
|
|
const to = path.join(destDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copySkillTree(from, to);
|
|
} else {
|
|
fs.copyFileSync(from, to);
|
|
}
|
|
}
|
|
}
|
|
|
|
const SKILL_SYNC_LOCK_RETRY_MS = 25;
|
|
const SKILL_SYNC_LOCK_TIMEOUT_MS = 10_000;
|
|
const SKILL_SYNC_LOCK_STALE_MS = 60_000;
|
|
const skillSyncWaitBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
|
|
function withWorkspaceSkillSyncLock(publishDir, callback) {
|
|
const agentsRoot = path.join(publishDir, '.agents');
|
|
const lockDir = path.join(agentsRoot, '.skills-sync.lock');
|
|
fs.mkdirSync(agentsRoot, { recursive: true });
|
|
const startedAt = Date.now();
|
|
|
|
while (true) {
|
|
try {
|
|
fs.mkdirSync(lockDir);
|
|
break;
|
|
} catch (error) {
|
|
if (error?.code !== 'EEXIST') throw error;
|
|
try {
|
|
const ageMs = Date.now() - fs.statSync(lockDir).mtimeMs;
|
|
if (ageMs > SKILL_SYNC_LOCK_STALE_MS) {
|
|
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
continue;
|
|
}
|
|
} catch (statError) {
|
|
if (statError?.code === 'ENOENT') continue;
|
|
throw statError;
|
|
}
|
|
if (Date.now() - startedAt >= SKILL_SYNC_LOCK_TIMEOUT_MS) {
|
|
throw new Error(`Timed out waiting for workspace skill sync lock: ${publishDir}`);
|
|
}
|
|
Atomics.wait(skillSyncWaitBuffer, 0, 0, SKILL_SYNC_LOCK_RETRY_MS);
|
|
}
|
|
}
|
|
|
|
try {
|
|
return callback();
|
|
} finally {
|
|
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export function syncSkillsToWorkspace({
|
|
h5Root,
|
|
publishDir,
|
|
skillMap,
|
|
catalog,
|
|
user,
|
|
publicBaseUrl,
|
|
}) {
|
|
return withWorkspaceSkillSyncLock(publishDir, () => {
|
|
const enabled = new Set(grantedSkillNames(skillMap));
|
|
const platformNames = new Set(catalog.map((item) => item.name));
|
|
const agentsSkills = path.join(publishDir, '.agents', 'skills');
|
|
|
|
if (fs.existsSync(agentsSkills)) {
|
|
for (const entry of fs.readdirSync(agentsSkills, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const skillName = entry.name;
|
|
const catalogItem = catalog.find((item) => item.name === skillName || item.dirName === skillName);
|
|
const resolvedName = catalogItem?.name ?? skillName;
|
|
if (platformNames.has(resolvedName) && !enabled.has(resolvedName)) {
|
|
fs.rmSync(path.join(agentsSkills, entry.name), { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const item of catalog) {
|
|
if (!enabled.has(item.name)) continue;
|
|
const srcDir = path.join(h5Root, 'skills', item.dirName);
|
|
const destDir = path.join(agentsSkills, item.name);
|
|
if (item.name === PUBLISH_SKILL_NAME && user) {
|
|
ensurePublishSkillInstalled(publishDir, {
|
|
slug: String(user.id).trim().toLowerCase(),
|
|
username: user.username ? String(user.username).trim().toLowerCase() : undefined,
|
|
publicBaseUrl,
|
|
publishDir,
|
|
});
|
|
continue;
|
|
}
|
|
if (fs.existsSync(srcDir)) {
|
|
if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true });
|
|
copySkillTree(srcDir, destDir);
|
|
}
|
|
}
|
|
});
|
|
}
|