upload-skills.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898
  1. #!/usr/bin/env node
  2. /**
  3. * OpenClaw Skills + Workshop CDN 上传脚本 v4.1
  4. *
  5. * 功能:
  6. * 1. 将所有 skills 上传到七牛云 CDN,生成下载 URL
  7. * 2. 将每个 skill 的元数据写入 Parse 数据库 Skill 表
  8. * 3. 将 workshop 文件(playbook + session + memory templates)上传到 CDN
  9. * 4. 生成 README.md(包含所有 skill 简介 + workshop 文件 + 下载链接)并上传到 CDN
  10. *
  11. * 使用方法:
  12. * node scripts/deploy/upload-skills.js # 上传全部(skills + workshop)
  13. * node scripts/deploy/upload-skills.js --skills-only # 只上传 skills
  14. * node scripts/deploy/upload-skills.js --workshop-only # 只上传 workshop
  15. *
  16. * 前置条件:
  17. * 1. npm install qiniu
  18. * 2. 配置下方 QINIU_ACCESS_KEY 和 QINIU_SECRET_KEY
  19. * 3. 先运行 deploy-to-openclaw.ps1 确保 ~/.openclaw 是最新的
  20. *
  21. * 参考:hongben-server/modules/fmode-tikhub-server/upload.sh
  22. */
  23. const qiniu = require('qiniu');
  24. const fs = require('fs');
  25. const path = require('path');
  26. // ============================================
  27. // 七牛云配置
  28. // ============================================
  29. const QINIU_ACCESS_KEY = process.env.QINIU_AK || 'EXsA-z_n4LGmWrwC088bygcGJtAditnWQe2nH-ZE';
  30. const QINIU_SECRET_KEY = process.env.QINIU_SK || 'HWTL92OL-Tup0-8ex8A9jnG3OaJzTxlF4OwiiDsX';
  31. const BUCKET = 'nova-repos';
  32. const CDN_DOMAIN = 'https://repos.fmode.cn';
  33. const CDN_PREFIX = 'x/openclaw-skills';
  34. // ============================================
  35. // Parse 数据库配置
  36. // ============================================
  37. const PARSE_BASE = 'https://server.fmode.cn/parse';
  38. const APP_ID = 'ncloudmaster';
  39. const MASTER_KEY = process.env.PARSE_MASTER_KEY || 'SnkK12*&sunq2#@20!';
  40. // 源目录(部署目录)
  41. const OPENCLAW_DIR = path.join(process.env.USERPROFILE || process.env.HOME, '.openclaw');
  42. const DEPLOYED_SKILLS_DIR = path.join(OPENCLAW_DIR, 'skills');
  43. const WORKSHOP_DIR = path.join(OPENCLAW_DIR, 'workspace');
  44. const MEMORY_TEMPLATES_DIR = path.join(WORKSHOP_DIR, 'memory');
  45. // CLI 参数
  46. const args = process.argv.slice(2);
  47. const SKILLS_ONLY = args.includes('--skills-only');
  48. const WORKSHOP_ONLY = args.includes('--workshop-only');
  49. // 要上传的技能目录列表(如果为空,则上传所有 skills)
  50. const SKILL_FILTER = []; // 例如 ['tiktok-video-search', 'jimeng-img-v4'] 指定只上传某些
  51. // ============================================
  52. // 技能包分组定义
  53. // ============================================
  54. const SKILL_PACKAGES = {
  55. 'voc-core': {
  56. label: 'VOC 核心套件',
  57. description: 'Amazon 品类洞察 + 评论分析 + 竞品分析 + 数据合成 + 社媒VOC + 工作坊',
  58. categories: ['voc', 'review-analysis', 'competitor-analysis', 'synthesis', 'social-voc', 'workshop'],
  59. status: 'stable', // stable | testing | hidden
  60. emoji: '🔍'
  61. },
  62. 'voc-social-data': {
  63. label: 'VOC 社媒数据套件',
  64. description: '小红书 / 抖音 / TikTok / Instagram 数据采集',
  65. categories: ['social-media', 'douyin', 'xiaohongshu'],
  66. status: 'stable',
  67. emoji: '📱',
  68. aliases: ['social-media']
  69. },
  70. 'ai-creation': {
  71. label: 'AI 创作套件',
  72. description: '即梦AI图片视频生成 + 一键成片(测试中)',
  73. categories: ['jimeng', 'video-creation'],
  74. status: 'testing',
  75. emoji: '🎨'
  76. },
  77. '_other': {
  78. label: '其他/测试',
  79. description: '测试技能,不对外提供',
  80. categories: ['other'],
  81. status: 'hidden',
  82. emoji: '🧪'
  83. }
  84. };
  85. const PACKAGE_ALIAS_TO_CANONICAL = Object.fromEntries(
  86. Object.entries(SKILL_PACKAGES).flatMap(([pkgId, pkg]) =>
  87. (pkg.aliases || []).map(alias => [alias, pkgId])
  88. )
  89. );
  90. function getPackageDefinition(pkgId) {
  91. return SKILL_PACKAGES[pkgId] || SKILL_PACKAGES[PACKAGE_ALIAS_TO_CANONICAL[pkgId]];
  92. }
  93. function isLegacyPackageId(pkgId) {
  94. return Boolean(PACKAGE_ALIAS_TO_CANONICAL[pkgId]);
  95. }
  96. function getPackageLogLabel(pkgId) {
  97. const pkg = getPackageDefinition(pkgId);
  98. if (!pkg) return pkgId;
  99. const suffix = isLegacyPackageId(pkgId) ? `(兼容旧名 ${pkgId})` : '';
  100. return `${pkg.emoji} ${pkg.label}${suffix}`;
  101. }
  102. // ============================================
  103. // Workshop 文件定义 (v4.1)
  104. // ============================================
  105. const WORKSHOP_FILES = {
  106. playbooks: [
  107. 'workshop-voc-playbook.md',
  108. 'workshop-global-rules.md',
  109. 'workshop-session-0-intake.md',
  110. 'workshop-session-1-category.md',
  111. 'workshop-session-2-brand.md',
  112. 'workshop-session-3-voc.md',
  113. 'workshop-session-4-deep.md',
  114. 'workshop-session-5-report.md'
  115. ],
  116. supporting: [
  117. 'product-analysis-playbook.md',
  118. 'synthesis-prompts.md',
  119. 'voc-report-schema-spec.md'
  120. ],
  121. memoryTemplates: [
  122. 'workshop-progress.json',
  123. 'brand-context.json',
  124. 'analysis-request.json',
  125. 'calibration-notes.json',
  126. 'stage-1-output.json',
  127. 'stage-2-output.json',
  128. 'stage-3-output.json',
  129. 'stage-4-output.json',
  130. 'stage-5-output.json'
  131. ]
  132. };
  133. function getPackageForCategory(category) {
  134. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  135. if (pkg.categories.includes(category || 'other')) return pkgId;
  136. }
  137. return '_other';
  138. }
  139. // ============================================
  140. // 七牛上传
  141. // ============================================
  142. function getUploadToken(bucket, key) {
  143. const mac = new qiniu.auth.digest.Mac(QINIU_ACCESS_KEY, QINIU_SECRET_KEY);
  144. const putPolicy = new qiniu.rs.PutPolicy({
  145. scope: `${bucket}:${key}`,
  146. expires: 3600
  147. });
  148. return putPolicy.uploadToken(mac);
  149. }
  150. function uploadFile(localFile, cdnKey) {
  151. return new Promise((resolve, reject) => {
  152. const token = getUploadToken(BUCKET, cdnKey);
  153. const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
  154. zone: qiniu.zone.Zone_z2
  155. }));
  156. const putExtra = new qiniu.form_up.PutExtra();
  157. formUploader.putFile(token, cdnKey, localFile, putExtra, (err, body, info) => {
  158. if (err) {
  159. reject(err);
  160. return;
  161. }
  162. if (info.statusCode === 200) {
  163. resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  164. } else {
  165. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  166. }
  167. });
  168. });
  169. }
  170. function uploadBuffer(buffer, cdnKey) {
  171. return new Promise((resolve, reject) => {
  172. const token = getUploadToken(BUCKET, cdnKey);
  173. const formUploader = new qiniu.form_up.FormUploader(new qiniu.conf.Config({
  174. zone: qiniu.zone.Zone_z2
  175. }));
  176. const putExtra = new qiniu.form_up.PutExtra();
  177. formUploader.put(token, cdnKey, buffer, putExtra, (err, body, info) => {
  178. if (err) {
  179. reject(err);
  180. return;
  181. }
  182. if (info.statusCode === 200) {
  183. resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  184. } else {
  185. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  186. }
  187. });
  188. });
  189. }
  190. // ============================================
  191. // Parse REST API helpers
  192. // ============================================
  193. const PARSE_HEADERS = {
  194. 'X-Parse-Application-Id': APP_ID,
  195. 'X-Parse-Master-Key': MASTER_KEY,
  196. 'Content-Type': 'application/json'
  197. };
  198. async function ensureSkillClass() {
  199. const resp = await fetch(`${PARSE_BASE}/schemas/Skill`, {
  200. headers: PARSE_HEADERS
  201. });
  202. if (resp.ok) return;
  203. // Class 不存在,创建
  204. const createResp = await fetch(`${PARSE_BASE}/schemas/Skill`, {
  205. method: 'POST',
  206. headers: PARSE_HEADERS,
  207. body: JSON.stringify({
  208. className: 'Skill',
  209. fields: {
  210. name: { type: 'String' },
  211. displayName: { type: 'String' },
  212. description: { type: 'String' },
  213. category: { type: 'String' },
  214. version: { type: 'String' },
  215. author: { type: 'String' },
  216. cdnUrls: { type: 'Object' },
  217. cdnPrefix: { type: 'String' }
  218. }
  219. })
  220. });
  221. const result = await createResp.json();
  222. if (createResp.ok) {
  223. console.log(' ✅ Skill 表已创建');
  224. } else {
  225. console.log(' ⚠️ 创建 Skill 表:', result.error || JSON.stringify(result));
  226. }
  227. }
  228. async function parseQuery(className, where) {
  229. const qs = new URLSearchParams({
  230. where: JSON.stringify(where),
  231. limit: '1'
  232. });
  233. const resp = await fetch(`${PARSE_BASE}/classes/${className}?${qs}`, {
  234. headers: PARSE_HEADERS
  235. });
  236. const data = await resp.json();
  237. return data.results || [];
  238. }
  239. async function parseCreate(className, body) {
  240. const resp = await fetch(`${PARSE_BASE}/classes/${className}`, {
  241. method: 'POST',
  242. headers: PARSE_HEADERS,
  243. body: JSON.stringify(body)
  244. });
  245. return resp.json();
  246. }
  247. async function parseUpdate(className, objectId, body) {
  248. const resp = await fetch(`${PARSE_BASE}/classes/${className}/${objectId}`, {
  249. method: 'PUT',
  250. headers: PARSE_HEADERS,
  251. body: JSON.stringify(body)
  252. });
  253. return resp.json();
  254. }
  255. async function upsertSkill(skillData) {
  256. const existing = await parseQuery('Skill', { name: skillData.name });
  257. if (existing.length > 0) {
  258. const result = await parseUpdate('Skill', existing[0].objectId, skillData);
  259. return { action: 'updated', objectId: existing[0].objectId, ...result };
  260. } else {
  261. const result = await parseCreate('Skill', skillData);
  262. return { action: 'created', ...result };
  263. }
  264. }
  265. // ============================================
  266. // Skill metadata extraction
  267. // ============================================
  268. function parseSkillMdFrontmatter(filePath) {
  269. try {
  270. const content = fs.readFileSync(filePath, 'utf8');
  271. const match = content.match(/^---\n([\s\S]*?)\n---/);
  272. if (!match) return {};
  273. const fm = {};
  274. match[1].split('\n').forEach(line => {
  275. const idx = line.indexOf(':');
  276. if (idx > 0) {
  277. const key = line.substring(0, idx).trim();
  278. const val = line.substring(idx + 1).trim();
  279. fm[key] = val;
  280. }
  281. });
  282. return fm;
  283. } catch (e) {
  284. return {};
  285. }
  286. }
  287. function parseApiConfig(filePath) {
  288. try {
  289. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  290. } catch (e) {
  291. return {};
  292. }
  293. }
  294. function extractSkillMeta(skillDir, skillName) {
  295. const skillMdPath = path.join(skillDir, 'SKILL.md');
  296. const apiConfigPath = path.join(skillDir, 'api-config.json');
  297. const fm = parseSkillMdFrontmatter(skillMdPath);
  298. const cfg = fs.existsSync(apiConfigPath) ? parseApiConfig(apiConfigPath) : {};
  299. return {
  300. name: skillName,
  301. displayName: cfg.displayName || fm.name || skillName,
  302. description: cfg.description || fm.description || '',
  303. category: cfg.category || '',
  304. version: cfg.version || fm.version || '1.0.0',
  305. author: fm.author || 'nkkj-BrainHack'
  306. };
  307. }
  308. // ============================================
  309. // Collect workshop files
  310. // ============================================
  311. function collectWorkshopFiles() {
  312. const files = [];
  313. // Playbooks + supporting docs
  314. const allMdFiles = [...WORKSHOP_FILES.playbooks, ...WORKSHOP_FILES.supporting];
  315. for (const fileName of allMdFiles) {
  316. const filePath = path.join(WORKSHOP_DIR, fileName);
  317. if (fs.existsSync(filePath)) {
  318. files.push({
  319. localPath: filePath,
  320. cdnKey: `${CDN_PREFIX}/workshop/${fileName}`,
  321. fileName,
  322. type: WORKSHOP_FILES.playbooks.includes(fileName) ? 'playbook' : 'supporting'
  323. });
  324. } else {
  325. console.log(` ⚠️ 跳过 ${fileName}(不存在)`);
  326. }
  327. }
  328. // Memory templates
  329. for (const fileName of WORKSHOP_FILES.memoryTemplates) {
  330. const filePath = path.join(MEMORY_TEMPLATES_DIR, fileName);
  331. if (fs.existsSync(filePath)) {
  332. files.push({
  333. localPath: filePath,
  334. cdnKey: `${CDN_PREFIX}/workshop/memory-templates/${fileName}`,
  335. fileName: `memory-templates/${fileName}`,
  336. type: 'memory-template'
  337. });
  338. } else {
  339. console.log(` ⚠️ 跳过 memory-templates/${fileName}(不存在)`);
  340. }
  341. }
  342. return files;
  343. }
  344. function generateWorkshopManifest(workshopResults) {
  345. const playbooks = {};
  346. const supporting = {};
  347. const memoryTemplates = {};
  348. for (const item of workshopResults) {
  349. if (item.status !== 'ok') continue;
  350. if (item.type === 'playbook') {
  351. playbooks[item.fileName] = item.url;
  352. } else if (item.type === 'supporting') {
  353. supporting[item.fileName] = item.url;
  354. } else if (item.type === 'memory-template') {
  355. const baseName = item.fileName.replace('memory-templates/', '');
  356. memoryTemplates[baseName] = item.url;
  357. }
  358. }
  359. return {
  360. package: 'workshop',
  361. label: 'VOC虾工作坊',
  362. description: 'VOC 工作坊完整流程文件(v4.1 Side Quest 架构)',
  363. version: '4.1.0',
  364. generatedAt: new Date().toISOString(),
  365. architecture: {
  366. sessions: 6,
  367. sideQuests: 17,
  368. stateFlow: 'pending → in_progress → awaitingCalibration → exploring → completed'
  369. },
  370. installTarget: {
  371. playbooks: '~/.openclaw/workspace/',
  372. memoryTemplates: '~/.openclaw/workspace/memory/'
  373. },
  374. files: { playbooks, supporting, memoryTemplates }
  375. };
  376. }
  377. // ============================================
  378. // Collect skills
  379. // ============================================
  380. function collectSkills() {
  381. const skills = [];
  382. const dir = DEPLOYED_SKILLS_DIR;
  383. if (!fs.existsSync(dir)) {
  384. console.error(`Skills 目录不存在: ${dir}`);
  385. process.exit(1);
  386. }
  387. const entries = fs.readdirSync(dir).filter(d => {
  388. const fullPath = path.join(dir, d);
  389. if (!fs.statSync(fullPath).isDirectory()) return false;
  390. if (SKILL_FILTER.length > 0 && !SKILL_FILTER.includes(d)) return false;
  391. return fs.existsSync(path.join(fullPath, 'SKILL.md'));
  392. });
  393. for (const skillName of entries) {
  394. const skillDir = path.join(dir, skillName);
  395. const files = [];
  396. for (const fileName of ['SKILL.md', 'api-config.json']) {
  397. const filePath = path.join(skillDir, fileName);
  398. if (fs.existsSync(filePath)) {
  399. files.push({
  400. localPath: filePath,
  401. cdnKey: `${CDN_PREFIX}/${skillName}/${fileName}`,
  402. fileName
  403. });
  404. }
  405. }
  406. if (files.length > 0) {
  407. const meta = extractSkillMeta(skillDir, skillName);
  408. skills.push({ name: skillName, files, meta });
  409. }
  410. }
  411. return skills;
  412. }
  413. // ============================================
  414. // Generate README.md
  415. // ============================================
  416. function generateReadme(skillEntries, workshopManifestUrl) {
  417. const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
  418. // 按技能包分组
  419. const pkgMap = {};
  420. for (const entry of skillEntries) {
  421. const pkgId = getPackageForCategory(entry.meta.category);
  422. if (!pkgMap[pkgId]) pkgMap[pkgId] = [];
  423. pkgMap[pkgId].push(entry);
  424. }
  425. // 统计可见技能数
  426. let visibleCount = 0;
  427. for (const [pkgId, entries] of Object.entries(pkgMap)) {
  428. if (SKILL_PACKAGES[pkgId]?.status !== 'hidden') visibleCount += entries.length;
  429. }
  430. const lines = [
  431. '# OpenClaw Skills 技能市场 + Workshop',
  432. '',
  433. `> 自动生成于 ${now},共 ${visibleCount} 个技能(分 ${Object.keys(pkgMap).filter(k => SKILL_PACKAGES[k]?.status !== 'hidden').length} 个套件)+ VOC工作坊 v4.1`,
  434. '',
  435. ];
  436. // 套件总览表
  437. lines.push('## 📦 套件总览');
  438. lines.push('');
  439. lines.push('| 套件 | 说明 | 技能数 | 状态 | 安装清单 |');
  440. lines.push('|------|------|--------|------|---------|');
  441. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  442. if (pkg.status === 'hidden') continue;
  443. const count = (pkgMap[pkgId] || []).length;
  444. const statusBadge = pkg.status === 'stable' ? '✅ 正式' : '🧪 测试中';
  445. const installLinks = [
  446. `[${pkgId}.json](${CDN_DOMAIN}/${CDN_PREFIX}/packages/${pkgId}.json)`,
  447. ...(pkg.aliases || []).map(alias => `[${alias}.json](${CDN_DOMAIN}/${CDN_PREFIX}/packages/${alias}.json)`)
  448. ];
  449. lines.push(`| ${pkg.emoji} **${pkg.label}** | ${pkg.description} | ${count} | ${statusBadge} | ${installLinks.join(' / ')} |`);
  450. }
  451. lines.push('');
  452. lines.push('---');
  453. lines.push('');
  454. // 每个套件详细内容
  455. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  456. if (pkg.status === 'hidden') continue;
  457. const entries = pkgMap[pkgId] || [];
  458. if (entries.length === 0) continue;
  459. const statusNote = pkg.status === 'testing' ? ' ⚠️ 测试中,功能可能变更' : '';
  460. lines.push(`## ${pkg.emoji} ${pkg.label}${statusNote}`);
  461. lines.push('');
  462. lines.push(`> ${pkg.description}`);
  463. lines.push('>');
  464. const installUrl = `${CDN_DOMAIN}/${CDN_PREFIX}/packages/${pkgId}.json`;
  465. lines.push(`> 📥 一键安装清单:[\`${pkgId}.json\`](${installUrl})`);
  466. if (pkg.aliases && pkg.aliases.length > 0) {
  467. const aliasLinks = pkg.aliases.map(alias => `[\`${alias}.json\`](${CDN_DOMAIN}/${CDN_PREFIX}/packages/${alias}.json)`);
  468. lines.push(`> ↩️ 兼容旧清单:${aliasLinks.join(' / ')}`);
  469. }
  470. lines.push('');
  471. // 按 category 子分组
  472. const catMap = {};
  473. for (const entry of entries) {
  474. const cat = entry.meta.category || '其他';
  475. if (!catMap[cat]) catMap[cat] = [];
  476. catMap[cat].push(entry);
  477. }
  478. for (const [cat, catEntries] of Object.entries(catMap).sort()) {
  479. lines.push(`### ${cat}`);
  480. lines.push('');
  481. lines.push('| 技能名 | 说明 | 版本 | 下载 |');
  482. lines.push('|--------|------|------|------|');
  483. for (const entry of catEntries) {
  484. const m = entry.meta;
  485. const desc = m.description.length > 60 ? m.description.substring(0, 60) + '...' : m.description;
  486. const skillMdUrl = entry.cdnUrls['SKILL.md'] || '';
  487. const apiCfgUrl = entry.cdnUrls['api-config.json'] || '';
  488. const links = [];
  489. if (skillMdUrl) links.push(`[SKILL.md](${skillMdUrl})`);
  490. if (apiCfgUrl) links.push(`[api-config.json](${apiCfgUrl})`);
  491. lines.push(`| **${m.displayName}** (\`${m.name}\`) | ${desc} | ${m.version} | ${links.join(' / ')} |`);
  492. }
  493. lines.push('');
  494. }
  495. }
  496. lines.push('---');
  497. lines.push('');
  498. lines.push('## 快速使用');
  499. lines.push('');
  500. lines.push('每个 Skill 包含两个文件:');
  501. lines.push('- **SKILL.md** — 技能说明文档(功能、入参、调用链、注意事项)');
  502. lines.push('- **api-config.json** — API 配置(endpoint、参数 schema、错误处理)');
  503. lines.push('');
  504. lines.push('下载后放入 `~/.openclaw/skills/<skill-name>/` 即可被 OpenClaw Agent 识别和调用。');
  505. lines.push('');
  506. lines.push('### 按套件安装');
  507. lines.push('');
  508. lines.push('下载对应套件的 `.json` 安装清单,包含该套件所有技能的 CDN 下载地址。');
  509. lines.push('');
  510. // Workshop section
  511. if (workshopManifestUrl) {
  512. lines.push('---');
  513. lines.push('');
  514. lines.push('## 🦐 VOC虾工作坊 v4.1');
  515. lines.push('');
  516. lines.push('> VOC 工作坊完整流程文件,含 Side Quest 支线探索系统');
  517. lines.push('>');
  518. lines.push(`> 📥 安装清单:[\`workshop.json\`](${workshopManifestUrl})`);
  519. lines.push('');
  520. lines.push('**架构**:6个Session + 17个Side Quest,主线感知 + 支线深耕');
  521. lines.push('');
  522. lines.push('| 文件 | 说明 |');
  523. lines.push('|------|------|');
  524. lines.push('| `workshop-voc-playbook.md` | 入口路由 |');
  525. lines.push('| `workshop-global-rules.md` | 全局规则 + Side Quest Protocol |');
  526. lines.push('| `workshop-session-0~5-*.md` | Session 0-5 流程文件 |');
  527. lines.push('| `memory-templates/*.json` | 记忆模板(9个JSON) |');
  528. lines.push('');
  529. lines.push('安装方式:将 playbook 文件放入 `~/.openclaw/workspace/`,memory templates 放入 `~/.openclaw/workspace/memory/`');
  530. lines.push('');
  531. }
  532. return lines.join('\n');
  533. }
  534. // 生成每个技能包的 install.json 清单
  535. function generatePackageManifests(skillEntries) {
  536. const pkgMap = {};
  537. for (const entry of skillEntries) {
  538. const pkgId = getPackageForCategory(entry.meta.category);
  539. if (!pkgMap[pkgId]) pkgMap[pkgId] = [];
  540. pkgMap[pkgId].push(entry);
  541. }
  542. const manifests = {};
  543. for (const [pkgId, pkg] of Object.entries(SKILL_PACKAGES)) {
  544. if (pkg.status === 'hidden') continue;
  545. const entries = pkgMap[pkgId] || [];
  546. if (entries.length === 0) continue;
  547. const manifest = {
  548. package: pkgId,
  549. label: pkg.label,
  550. description: pkg.description,
  551. status: pkg.status,
  552. aliases: pkg.aliases || [],
  553. skillCount: entries.length,
  554. generatedAt: new Date().toISOString(),
  555. skills: entries.map(e => ({
  556. name: e.meta.name,
  557. displayName: e.meta.displayName,
  558. category: e.meta.category,
  559. version: e.meta.version,
  560. files: e.cdnUrls
  561. }))
  562. };
  563. manifests[pkgId] = manifest;
  564. for (const alias of pkg.aliases || []) {
  565. manifests[alias] = {
  566. ...manifest,
  567. package: alias,
  568. canonicalPackage: pkgId,
  569. legacyPackage: true
  570. };
  571. }
  572. }
  573. return manifests;
  574. }
  575. // ============================================
  576. // Main
  577. // ============================================
  578. async function main() {
  579. console.log('==================================================');
  580. console.log(' OpenClaw Skills + Workshop CDN 上传 v4.1');
  581. console.log('==================================================');
  582. console.log('');
  583. if (SKILLS_ONLY) console.log(' 📌 模式: 仅上传 Skills');
  584. if (WORKSHOP_ONLY) console.log(' 📌 模式: 仅上传 Workshop');
  585. console.log('');
  586. // 检查凭证
  587. if (QINIU_ACCESS_KEY === 'YOUR_ACCESS_KEY_HERE' || QINIU_SECRET_KEY === 'YOUR_SECRET_KEY_HERE') {
  588. console.error('❌ 请先配置七牛云凭证!');
  589. console.error(' 方式1: 编辑脚本中的 QINIU_ACCESS_KEY / QINIU_SECRET_KEY');
  590. console.error(' 方式2: 设置环境变量 QINIU_AK / QINIU_SK');
  591. process.exit(1);
  592. }
  593. console.log(`☁️ Bucket: ${BUCKET}`);
  594. console.log(`🔗 CDN前缀: ${CDN_PREFIX}`);
  595. console.log('');
  596. // 收集技能
  597. const skills = WORKSHOP_ONLY ? [] : collectSkills();
  598. if (!WORKSHOP_ONLY) {
  599. console.log(`📦 找到 ${skills.length} 个技能待上传`);
  600. console.log(`📂 Skills源: ${DEPLOYED_SKILLS_DIR}`);
  601. }
  602. // 收集 workshop 文件
  603. const workshopFiles = SKILLS_ONLY ? [] : collectWorkshopFiles();
  604. if (!SKILLS_ONLY) {
  605. console.log(`� 找到 ${workshopFiles.length} 个 workshop 文件待上传`);
  606. console.log(`📂 Workshop源: ${WORKSHOP_DIR}`);
  607. }
  608. let totalFiles = 0;
  609. skills.forEach(s => { totalFiles += s.files.length; });
  610. totalFiles += workshopFiles.length;
  611. console.log(`📄 总计 ${totalFiles} 个文件`);
  612. console.log('');
  613. // ── Step 1: 上传 Skill 文件到七牛 ──
  614. let successCount = 0;
  615. let failCount = 0;
  616. const results = [];
  617. if (!WORKSHOP_ONLY && skills.length > 0) {
  618. console.log('── Step 1: 上传 Skills 到七牛云 ──');
  619. for (const skill of skills) {
  620. process.stdout.write(` 📦 ${skill.name} ... `);
  621. const skillResults = [];
  622. const cdnUrls = {};
  623. for (const file of skill.files) {
  624. try {
  625. const result = await uploadFile(file.localPath, file.cdnKey);
  626. skillResults.push({ ...result, fileName: file.fileName, status: 'ok' });
  627. cdnUrls[file.fileName] = result.url;
  628. successCount++;
  629. } catch (err) {
  630. skillResults.push({ fileName: file.fileName, status: 'fail', error: err.message });
  631. failCount++;
  632. }
  633. }
  634. const allOk = skillResults.every(r => r.status === 'ok');
  635. console.log(allOk ? '✅' : '⚠️');
  636. results.push({ name: skill.name, files: skillResults, meta: skill.meta, cdnUrls });
  637. }
  638. console.log('');
  639. console.log(` Skills 上传完成!成功: ${successCount} 失败: ${failCount}`);
  640. console.log('');
  641. } else if (!WORKSHOP_ONLY) {
  642. console.log('── Step 1: 跳过(无 Skills)──');
  643. console.log('');
  644. }
  645. // ── Step 1b: 上传 Workshop 文件到七牛 ──
  646. let wsSuccessCount = 0;
  647. let wsFailCount = 0;
  648. const workshopResults = [];
  649. if (!SKILLS_ONLY && workshopFiles.length > 0) {
  650. console.log('── Step 1b: 上传 Workshop 文件到七牛云 ──');
  651. for (const file of workshopFiles) {
  652. process.stdout.write(` 📝 ${file.fileName} ... `);
  653. try {
  654. const result = await uploadFile(file.localPath, file.cdnKey);
  655. workshopResults.push({ ...result, fileName: file.fileName, type: file.type, status: 'ok' });
  656. wsSuccessCount++;
  657. console.log('✅');
  658. } catch (err) {
  659. workshopResults.push({ fileName: file.fileName, type: file.type, status: 'fail', error: err.message });
  660. wsFailCount++;
  661. console.log(`❌ ${err.message}`);
  662. }
  663. }
  664. console.log('');
  665. console.log(` Workshop 上传完成!成功: ${wsSuccessCount} 失败: ${wsFailCount}`);
  666. console.log('');
  667. }
  668. // ── Step 2: 写入 Parse Skill 表 ──
  669. let dbSuccess = 0;
  670. let dbFail = 0;
  671. if (!WORKSHOP_ONLY && results.length > 0) {
  672. console.log('── Step 2: 同步到 Parse 数据库 (Skill 表) ──');
  673. await ensureSkillClass();
  674. for (const entry of results) {
  675. if (Object.keys(entry.cdnUrls).length === 0) continue;
  676. process.stdout.write(` 💾 ${entry.name} ... `);
  677. try {
  678. // Parse 不允许嵌套 key 含 '.',将文件名中的 '.' 替换为 '_'
  679. const safeCdnUrls = {};
  680. for (const [k, v] of Object.entries(entry.cdnUrls)) {
  681. safeCdnUrls[k.replace(/\./g, '_')] = v;
  682. }
  683. const skillData = {
  684. name: entry.meta.name,
  685. displayName: entry.meta.displayName,
  686. description: entry.meta.description,
  687. category: entry.meta.category,
  688. version: entry.meta.version,
  689. author: entry.meta.author,
  690. cdnUrls: safeCdnUrls,
  691. cdnPrefix: `${CDN_DOMAIN}/${CDN_PREFIX}/${entry.name}`
  692. };
  693. const result = await upsertSkill(skillData);
  694. console.log(`${result.action === 'created' ? '🆕 创建' : '🔄 更新'} (${result.objectId})`);
  695. dbSuccess++;
  696. } catch (err) {
  697. console.log(`❌ ${err.message}`);
  698. dbFail++;
  699. }
  700. }
  701. console.log('');
  702. console.log(` 数据库同步完成!成功: ${dbSuccess} 失败: ${dbFail}`);
  703. console.log('');
  704. }
  705. // ── Step 3: 生成清单 + README 并上传 ──
  706. console.log('── Step 3: 生成清单 + README 并上传 ──');
  707. const packageUrls = {};
  708. // 上传每个技能包的 install.json
  709. if (!WORKSHOP_ONLY && results.length > 0) {
  710. const manifests = generatePackageManifests(results);
  711. for (const [pkgId, manifest] of Object.entries(manifests)) {
  712. try {
  713. const key = `${CDN_PREFIX}/packages/${pkgId}.json`;
  714. const buf = Buffer.from(JSON.stringify(manifest, null, 2), 'utf8');
  715. const res = await uploadBuffer(buf, key);
  716. packageUrls[pkgId] = res.url;
  717. const pkg = getPackageDefinition(pkgId);
  718. const statusTag = manifest.status === 'testing' ? ' 🧪' : ' ✅';
  719. const legacyNote = isLegacyPackageId(pkgId) ? `(兼容旧名 ${pkgId})` : '';
  720. console.log(` ${statusTag} ${pkg.label}${legacyNote} (${manifest.skillCount}个技能): ${res.url}`);
  721. } catch (err) {
  722. console.log(` ❌ ${pkgId}.json 上传失败: ${err.message}`);
  723. }
  724. }
  725. }
  726. // 上传 workshop manifest
  727. let workshopManifestUrl = '';
  728. if (!SKILLS_ONLY && workshopResults.length > 0) {
  729. try {
  730. const wsManifest = generateWorkshopManifest(workshopResults);
  731. const key = `${CDN_PREFIX}/packages/workshop.json`;
  732. const buf = Buffer.from(JSON.stringify(wsManifest, null, 2), 'utf8');
  733. const res = await uploadBuffer(buf, key);
  734. workshopManifestUrl = res.url;
  735. packageUrls['workshop'] = res.url;
  736. const okCount = workshopResults.filter(r => r.status === 'ok').length;
  737. console.log(` 🦐 VOC虾工作坊 (${okCount}个文件): ${res.url}`);
  738. } catch (err) {
  739. console.log(` ❌ workshop.json 上传失败: ${err.message}`);
  740. }
  741. }
  742. // 上传 README.md
  743. let readmeUrl = '';
  744. try {
  745. const readme = generateReadme(results, workshopManifestUrl);
  746. const readmeKey = `${CDN_PREFIX}/README.md`;
  747. const readmeResult = await uploadBuffer(Buffer.from(readme, 'utf8'), readmeKey);
  748. readmeUrl = readmeResult.url;
  749. console.log(` ✅ README.md 已上传: ${readmeUrl}`);
  750. } catch (err) {
  751. console.log(` ❌ README.md 上传失败: ${err.message}`);
  752. }
  753. console.log('');
  754. // ── 输出 URL 汇总 ──
  755. console.log('📋 下载地址:');
  756. console.log('');
  757. for (const [pkgId, url] of Object.entries(packageUrls)) {
  758. if (pkgId === 'workshop') {
  759. console.log(` 🦐 VOC虾工作坊`);
  760. } else {
  761. const pkg = getPackageDefinition(pkgId);
  762. if (!pkg) continue;
  763. const statusTag = pkg.status === 'testing' ? '🧪' : '✅';
  764. const legacyNote = isLegacyPackageId(pkgId) ? `(兼容旧名 ${pkgId})` : '';
  765. console.log(` ${statusTag} ${pkg.label}${legacyNote}`);
  766. }
  767. console.log(` 👉 ${url}`);
  768. }
  769. console.log('');
  770. // 保存 URL 列表到文件(按技能包分组)
  771. const urlMap = { _packages: packageUrls };
  772. if (readmeUrl) urlMap['_README'] = readmeUrl;
  773. for (const entry of results) {
  774. if (Object.keys(entry.cdnUrls).length > 0) {
  775. urlMap[entry.name] = entry.cdnUrls;
  776. }
  777. }
  778. if (workshopResults.length > 0) {
  779. urlMap['_workshop'] = {};
  780. for (const item of workshopResults) {
  781. if (item.status === 'ok') urlMap['_workshop'][item.fileName] = item.url;
  782. }
  783. }
  784. const outputPath = path.resolve(__dirname, '..', 'skills-cdn-urls.json');
  785. fs.writeFileSync(outputPath, JSON.stringify(urlMap, null, 2), 'utf8');
  786. console.log(`💾 URL 列表已保存到: ${outputPath}`);
  787. console.log('');
  788. console.log('==================================================');
  789. console.log('📌 索引:');
  790. console.log(` README: ${CDN_DOMAIN}/${CDN_PREFIX}/README.md`);
  791. for (const [pkgId, url] of Object.entries(packageUrls)) {
  792. if (pkgId === 'workshop') {
  793. console.log(` 🦐 VOC虾工作坊: ${url}`);
  794. } else {
  795. const pkg = getPackageDefinition(pkgId);
  796. if (pkg) console.log(` ${getPackageLogLabel(pkgId)}: ${url}`);
  797. }
  798. }
  799. console.log('==================================================');
  800. }
  801. main().catch(err => {
  802. console.error('❌ 上传出错:', err);
  803. process.exit(1);
  804. });