diff --git a/.gitignore b/.gitignore
index ebb9c12..4f79674 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ cache
public
resources
app/view
+
+# Research docs (working notes, not for commit)
+docs/research
diff --git a/app/controller/skillLike.js b/app/controller/skillLike.js
new file mode 100644
index 0000000..1d8e41e
--- /dev/null
+++ b/app/controller/skillLike.js
@@ -0,0 +1,29 @@
+const Controller = require('egg').Controller;
+
+class SkillLikeController extends Controller {
+ async like() {
+ const { app, ctx } = this;
+ const { slug } = ctx.request.body || {};
+ const ip = ctx.service.skillLike.resolveClientIp();
+ const data = await ctx.service.skillLike.like(slug, ip);
+ ctx.body = app.utils.response(true, data);
+ }
+
+ async unlike() {
+ const { app, ctx } = this;
+ const { slug } = ctx.request.body || {};
+ const ip = ctx.service.skillLike.resolveClientIp();
+ const data = await ctx.service.skillLike.unlike(slug, ip);
+ ctx.body = app.utils.response(true, data);
+ }
+
+ async getLikeStatus() {
+ const { app, ctx } = this;
+ const { slug } = ctx.query || {};
+ const ip = ctx.service.skillLike.resolveClientIp();
+ const data = await ctx.service.skillLike.getLikeStatus(slug, ip);
+ ctx.body = app.utils.response(true, data);
+ }
+}
+
+module.exports = SkillLikeController;
diff --git a/app/controller/skills.js b/app/controller/skills.js
new file mode 100644
index 0000000..b040ef3
--- /dev/null
+++ b/app/controller/skills.js
@@ -0,0 +1,108 @@
+const Controller = require('egg').Controller;
+const fs = require('fs');
+
+class SkillsController extends Controller {
+ async getSkillList() {
+ const { app, ctx } = this;
+ const params = ctx.query;
+ const data = await ctx.service.skills.querySkillList(params);
+ ctx.body = app.utils.response(true, data);
+ }
+
+ async getSkillDetail() {
+ const { app, ctx } = this;
+ const { slug } = ctx.query;
+ const data = await ctx.service.skills.getSkillDetail(slug);
+ ctx.body = app.utils.response(true, data);
+ }
+
+ async getRelatedSkills() {
+ const { app, ctx } = this;
+ const { slug, limit = 6 } = ctx.query;
+ const data = await ctx.service.skills.getRelatedSkills(slug, limit);
+ ctx.body = app.utils.response(true, data);
+ }
+
+ async getSkillFileContent() {
+ const { app, ctx } = this;
+ const { slug, path: filePath } = ctx.query;
+ const data = await ctx.service.skills.getSkillFileContent(slug, filePath);
+ ctx.body = app.utils.response(true, data);
+ }
+
+ async downloadSkillArchive() {
+ const { ctx } = this;
+ const { slug } = ctx.query;
+ const { fileName, content } = await ctx.service.skills.getSkillArchive(slug);
+ ctx.set('Content-Type', 'application/zip');
+ ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
+ ctx.body = content;
+ }
+
+ async getSkillInstallMeta() {
+ const { app, ctx } = this;
+ const identifier = ctx.query.installKey || ctx.query.slug;
+ const data = await ctx.service.skills.getInstallMeta(identifier);
+ ctx.body = app.utils.response(true, data);
+ }
+
+ async importSkillFile() {
+ const { app, ctx } = this;
+ const params = ctx.request.body || {};
+ const files = ctx.request.files
+ ? Array.isArray(ctx.request.files)
+ ? ctx.request.files
+ : [ctx.request.files]
+ : [];
+ const file = files[0];
+
+ if (!file) {
+ ctx.throw(400, '缺少上传文件');
+ }
+
+ try {
+ const data = await ctx.service.skills.importSkillFile(params, file);
+ ctx.body = app.utils.response(true, data);
+ } finally {
+ if (file.filepath && fs.existsSync(file.filepath)) {
+ try {
+ fs.unlinkSync(file.filepath);
+ } catch (error) {
+ ctx.logger.warn(`[skills] 清理上传文件失败: ${error.message}`);
+ }
+ }
+ }
+ }
+
+ async updateSkill() {
+ const { app, ctx } = this;
+ const params = ctx.request.body || {};
+ const files = ctx.request.files
+ ? Array.isArray(ctx.request.files)
+ ? ctx.request.files
+ : [ctx.request.files]
+ : [];
+ const file = files[0] || null;
+
+ try {
+ const data = await ctx.service.skills.updateSkill(params, file);
+ ctx.body = app.utils.response(true, data, '更新成功');
+ } finally {
+ if (file?.filepath && fs.existsSync(file.filepath)) {
+ try {
+ fs.unlinkSync(file.filepath);
+ } catch (error) {
+ ctx.logger.warn(`[skills] 清理更新上传文件失败: ${error.message}`);
+ }
+ }
+ }
+ }
+
+ async deleteSkill() {
+ const { app, ctx } = this;
+ const data = await ctx.service.skills.deleteSkill(ctx.request.body || {});
+ ctx.body = app.utils.response(true, data, '删除成功');
+ }
+}
+
+module.exports = SkillsController;
diff --git a/app/model/skill_like.js b/app/model/skill_like.js
new file mode 100644
index 0000000..00a605d
--- /dev/null
+++ b/app/model/skill_like.js
@@ -0,0 +1,41 @@
+module.exports = (app) => {
+ const { INTEGER, STRING, DATE } = app.Sequelize;
+
+ const SkillLike = app.model.define(
+ 'skill_like',
+ {
+ id: {
+ type: INTEGER,
+ primaryKey: true,
+ autoIncrement: true,
+ },
+ skill_id: {
+ type: INTEGER,
+ allowNull: false,
+ comment: '技能ID',
+ },
+ ip: {
+ type: STRING(64),
+ allowNull: false,
+ comment: '点赞用户IP',
+ },
+ created_at: {
+ type: DATE,
+ allowNull: false,
+ defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
+ },
+ },
+ {
+ freezeTableName: true,
+ tableName: 'skill_likes',
+ timestamps: false,
+ indexes: [
+ { fields: ['skill_id', 'ip'], unique: true },
+ { fields: ['skill_id'] },
+ { fields: ['ip'] },
+ ],
+ }
+ );
+
+ return SkillLike;
+};
diff --git a/app/model/skills_file.js b/app/model/skills_file.js
new file mode 100644
index 0000000..631adb4
--- /dev/null
+++ b/app/model/skills_file.js
@@ -0,0 +1,77 @@
+module.exports = (app) => {
+ const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize;
+
+ const SkillsFile = app.model.define(
+ 'skills_file',
+ {
+ id: {
+ type: INTEGER,
+ primaryKey: true,
+ autoIncrement: true,
+ },
+ skill_id: {
+ type: INTEGER,
+ allowNull: false,
+ comment: 'skills_items.id',
+ },
+ file_path: {
+ type: STRING(512),
+ allowNull: false,
+ comment: '文件相对路径',
+ },
+ language: {
+ type: STRING(64),
+ allowNull: false,
+ defaultValue: 'text',
+ },
+ size: {
+ type: INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ is_binary: {
+ type: TINYINT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ encoding: {
+ type: STRING(16),
+ allowNull: false,
+ defaultValue: 'utf8',
+ },
+ content: {
+ type: TEXT('long'),
+ comment: '文本内容或base64内容',
+ },
+ updated_at_remote: {
+ type: DATE,
+ comment: '源仓库文件更新时间',
+ },
+ is_delete: {
+ type: TINYINT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ created_at: {
+ type: DATE,
+ allowNull: false,
+ defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
+ },
+ updated_at: {
+ type: DATE,
+ allowNull: false,
+ defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
+ },
+ },
+ {
+ freezeTableName: true,
+ tableName: 'skills_files',
+ timestamps: true,
+ createdAt: 'created_at',
+ updatedAt: 'updated_at',
+ indexes: [{ fields: ['skill_id'] }],
+ }
+ );
+
+ return SkillsFile;
+};
diff --git a/app/model/skills_item.js b/app/model/skills_item.js
new file mode 100644
index 0000000..fcbd1b7
--- /dev/null
+++ b/app/model/skills_item.js
@@ -0,0 +1,112 @@
+module.exports = (app) => {
+ const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize;
+
+ const SkillsItem = app.model.define(
+ 'skills_item',
+ {
+ id: {
+ type: INTEGER,
+ primaryKey: true,
+ autoIncrement: true,
+ },
+ source_id: {
+ type: INTEGER,
+ allowNull: false,
+ comment: '来源记录ID',
+ },
+ slug: {
+ type: STRING(255),
+ allowNull: false,
+ unique: true,
+ comment: '详情页唯一标识',
+ },
+ name: {
+ type: STRING(255),
+ allowNull: false,
+ },
+ description: {
+ type: TEXT,
+ },
+ category: {
+ type: STRING(64),
+ allowNull: false,
+ defaultValue: '通用',
+ },
+ version: {
+ type: STRING(128),
+ allowNull: false,
+ defaultValue: '',
+ comment: '技能版本号',
+ },
+ tags: {
+ type: TEXT('long'),
+ comment: 'JSON字符串数组',
+ },
+ allowed_tools: {
+ type: TEXT('long'),
+ comment: 'JSON字符串数组',
+ },
+ stars: {
+ type: INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ updated_at_remote: {
+ type: DATE,
+ comment: '源仓库文件更新时间',
+ },
+ source_repo: {
+ type: STRING(1000),
+ comment: '仓库地址',
+ },
+ source_path: {
+ type: STRING(1000),
+ comment: '仓库内 skill 相对路径',
+ },
+ skill_md: {
+ type: TEXT('long'),
+ comment: 'SKILL.md 原文',
+ },
+ install_command: {
+ type: TEXT,
+ comment: '推荐安装命令',
+ },
+ file_count: {
+ type: INTEGER,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ is_delete: {
+ type: TINYINT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ created_at: {
+ type: DATE,
+ allowNull: false,
+ defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
+ },
+ updated_at: {
+ type: DATE,
+ allowNull: false,
+ defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
+ },
+ },
+ {
+ freezeTableName: true,
+ tableName: 'skills_items',
+ timestamps: true,
+ createdAt: 'created_at',
+ updatedAt: 'updated_at',
+ indexes: [
+ { unique: true, fields: ['slug'] },
+ { fields: ['source_id'] },
+ { fields: ['category'] },
+ { fields: ['stars'] },
+ { fields: ['updated_at_remote'] },
+ ],
+ }
+ );
+
+ return SkillsItem;
+};
diff --git a/app/model/skills_source.js b/app/model/skills_source.js
new file mode 100644
index 0000000..6adc277
--- /dev/null
+++ b/app/model/skills_source.js
@@ -0,0 +1,95 @@
+module.exports = (app) => {
+ const { INTEGER, STRING, TEXT, DATE, TINYINT } = app.Sequelize;
+
+ const SkillsSource = app.model.define(
+ 'skills_source',
+ {
+ id: {
+ type: INTEGER,
+ primaryKey: true,
+ autoIncrement: true,
+ },
+ source_url: {
+ type: STRING(512),
+ allowNull: false,
+ unique: true,
+ comment: '用户输入的来源地址',
+ },
+ source_type: {
+ type: STRING(32),
+ allowNull: false,
+ defaultValue: 'git',
+ comment: '来源类型 github/gitlab/git/local',
+ },
+ clone_url: {
+ type: STRING(1000),
+ allowNull: false,
+ comment: '用于 clone 的仓库地址',
+ },
+ source_repo: {
+ type: STRING(1000),
+ allowNull: false,
+ comment: '用于安装命令展示的仓库地址',
+ },
+ ref: {
+ type: STRING(255),
+ comment: '分支或标签',
+ },
+ subpath: {
+ type: STRING(1000),
+ comment: '仓库内相对子目录',
+ },
+ repo_host: {
+ type: STRING(255),
+ comment: '仓库域名',
+ },
+ repo_path: {
+ type: STRING(500),
+ comment: '仓库路径 owner/repo 或 group/subgroup/repo',
+ },
+ sync_status: {
+ type: STRING(32),
+ allowNull: false,
+ defaultValue: 'idle',
+ comment: '同步状态 idle/syncing/failed',
+ },
+ sync_error: {
+ type: TEXT,
+ comment: '最近一次同步错误',
+ },
+ last_synced_at: {
+ type: DATE,
+ comment: '最近同步时间',
+ },
+ is_delete: {
+ type: TINYINT,
+ allowNull: false,
+ defaultValue: 0,
+ },
+ created_at: {
+ type: DATE,
+ allowNull: false,
+ defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
+ },
+ updated_at: {
+ type: DATE,
+ allowNull: false,
+ defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
+ },
+ },
+ {
+ freezeTableName: true,
+ tableName: 'skills_sources',
+ timestamps: true,
+ createdAt: 'created_at',
+ updatedAt: 'updated_at',
+ indexes: [
+ { fields: ['repo_host'] },
+ { fields: ['repo_path'] },
+ { fields: ['sync_status'] },
+ ],
+ }
+ );
+
+ return SkillsSource;
+};
diff --git a/app/router.js b/app/router.js
index 5622be6..2ce1e9b 100644
--- a/app/router.js
+++ b/app/router.js
@@ -146,6 +146,22 @@ module.exports = (app) => {
app.post('/api/mcp-servers/health/:serverId', app.controller.mcp.checkMCPServerHealth);
app.post('/api/mcp-servers/health/all', app.controller.mcp.checkAllMCPServersHealth);
+ /**
+ * Skills 市场
+ */
+ app.get('/api/skills/list', app.controller.skills.getSkillList);
+ app.get('/api/skills/detail', app.controller.skills.getSkillDetail);
+ app.get('/api/skills/related', app.controller.skills.getRelatedSkills);
+ app.get('/api/skills/file-content', app.controller.skills.getSkillFileContent);
+ app.get('/api/skills/install-meta', app.controller.skills.getSkillInstallMeta);
+ app.get('/api/skills/download', app.controller.skills.downloadSkillArchive);
+ app.post('/api/skills/import-file', app.controller.skills.importSkillFile);
+ app.post('/api/skills/update', app.controller.skills.updateSkill);
+ app.post('/api/skills/delete', app.controller.skills.deleteSkill);
+ app.post('/api/skills/like', app.controller.skillLike.like);
+ app.post('/api/skills/unlike', app.controller.skillLike.unlike);
+ app.get('/api/skills/like-status', app.controller.skillLike.getLikeStatus);
+
// io.of('/').route('getShellCommand', io.controller.home.getShellCommand)
// 暂时close Terminal相关功能
// io.of('/').route('loginServer', io.controller.home.loginServer)
diff --git a/app/service/skillLike.js b/app/service/skillLike.js
new file mode 100644
index 0000000..e0e2da4
--- /dev/null
+++ b/app/service/skillLike.js
@@ -0,0 +1,113 @@
+const Service = require('egg').Service;
+
+class SkillLikeService extends Service {
+ async ensureStorageReady() {
+ const { SkillLike, SkillsItem } = this.app.model;
+ if (!SkillLike || !SkillsItem) {
+ this.ctx.throw(500, 'SkillLike 数据模型未加载');
+ }
+ await SkillLike.sync();
+ }
+
+ resolveClientIp() {
+ const headers = this.ctx.request.headers;
+ const ip =
+ headers['x-forwarded-for']?.split(',')[0]?.trim() ||
+ headers['x-real-ip'] ||
+ this.ctx.ip ||
+ '';
+ return String(ip).trim();
+ }
+
+ async getSkillBySlug(slug) {
+ const { SkillsItem } = this.app.model;
+ const skill = await SkillsItem.findOne({
+ where: { slug, is_delete: 0 },
+ });
+ return skill;
+ }
+
+ async like(skillSlug, ip) {
+ await this.ensureStorageReady();
+ const { SkillLike } = this.app.model;
+
+ const skill = await this.getSkillBySlug(skillSlug);
+ if (!skill) {
+ this.ctx.throw(404, '技能不存在');
+ }
+
+ const existing = await SkillLike.findOne({
+ where: { skill_id: skill.id, ip },
+ });
+
+ if (existing) {
+ return { liked: true, message: '已经点赞过了' };
+ }
+
+ await SkillLike.create({
+ skill_id: skill.id,
+ ip,
+ });
+
+ const likeCount = await SkillLike.count({
+ where: { skill_id: skill.id },
+ });
+
+ await skill.update({ stars: likeCount });
+
+ return { liked: true, likeCount };
+ }
+
+ async unlike(skillSlug, ip) {
+ await this.ensureStorageReady();
+ const { SkillLike } = this.app.model;
+
+ const skill = await this.getSkillBySlug(skillSlug);
+ if (!skill) {
+ this.ctx.throw(404, '技能不存在');
+ }
+
+ const existing = await SkillLike.findOne({
+ where: { skill_id: skill.id, ip },
+ });
+
+ if (!existing) {
+ return { liked: false, message: '还未点赞' };
+ }
+
+ await existing.destroy();
+
+ const likeCount = await SkillLike.count({
+ where: { skill_id: skill.id },
+ });
+
+ await skill.update({ stars: likeCount });
+
+ return { liked: false, likeCount };
+ }
+
+ async getLikeStatus(skillSlug, ip) {
+ await this.ensureStorageReady();
+ const { SkillLike } = this.app.model;
+
+ const skill = await this.getSkillBySlug(skillSlug);
+ if (!skill) {
+ this.ctx.throw(404, '技能不存在');
+ }
+
+ const existing = await SkillLike.findOne({
+ where: { skill_id: skill.id, ip },
+ });
+
+ const likeCount = await SkillLike.count({
+ where: { skill_id: skill.id },
+ });
+
+ return {
+ liked: !!existing,
+ likeCount,
+ };
+ }
+}
+
+module.exports = SkillLikeService;
diff --git a/app/service/skills.js b/app/service/skills.js
new file mode 100644
index 0000000..ef76fee
--- /dev/null
+++ b/app/service/skills.js
@@ -0,0 +1,2420 @@
+const Service = require('egg').Service;
+const AdmZip = require('adm-zip');
+const crypto = require('crypto');
+const { spawn } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const fetch = require('node-fetch');
+
+const CACHE_TTL_MS = 60 * 1000;
+const MAX_FILE_LIST_COUNT = 2000;
+const MAX_FILE_CONTENT_SIZE = 2 * 1024 * 1024;
+const MAX_STORED_FILE_CONTENT_SIZE = 8 * 1024 * 1024;
+const GIT_COMMAND_TIMEOUT_MS = 120 * 1000;
+const GITHUB_API_TIMEOUT_MS = 10 * 1000;
+const MAX_PLATFORM_TAGS = 5;
+const MAX_TAG_LENGTH = 20;
+const DISCOVER_DEPTH_LIMIT = 2;
+const SKILLS_ROOT_DISCOVER_DEPTH_LIMIT = 8;
+const DISCOVER_MAX_DIR_COUNT = 3000;
+const SKILL_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+
+function sanitizeInstallKeySegment(value) {
+ return String(value || '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .replace(/-{2,}/g, '-');
+}
+
+function createInstallKeyCandidates(skill = {}) {
+ const candidates = [];
+ const pushCandidate = (value) => {
+ const normalized = sanitizeInstallKeySegment(value);
+ if (normalized && !candidates.includes(normalized)) {
+ candidates.push(normalized);
+ }
+ };
+
+ pushCandidate(skill.name);
+
+ const sourcePath = String(skill.sourcePath || '')
+ .trim()
+ .replace(/\\/g, '/');
+ if (sourcePath) {
+ const segments = sourcePath.split('/').filter(Boolean);
+ if (segments.length > 0) {
+ pushCandidate(segments[segments.length - 1]);
+ }
+ }
+
+ pushCandidate(skill.slug);
+ pushCandidate('skill');
+ return candidates;
+}
+
+function createInstallKeyMap(skills = []) {
+ const bySlug = new Map();
+ const byInstallKey = new Map();
+ const counts = new Map();
+ const list = skills.map((skill) => {
+ const candidates = createInstallKeyCandidates(skill);
+ let installKey = candidates.find((candidate) => !byInstallKey.has(candidate)) || '';
+
+ if (!installKey) {
+ const baseKey = candidates[0] || 'skill';
+ const nextCount = (counts.get(baseKey) || 1) + 1;
+ counts.set(baseKey, nextCount);
+ installKey = `${baseKey}-${nextCount}`;
+ while (byInstallKey.has(installKey)) {
+ const currentCount = (counts.get(baseKey) || nextCount) + 1;
+ counts.set(baseKey, currentCount);
+ installKey = `${baseKey}-${currentCount}`;
+ }
+ } else {
+ const baseKey = candidates[0] || installKey;
+ counts.set(baseKey, Math.max(counts.get(baseKey) || 1, 1));
+ }
+
+ const normalizedSkill = {
+ ...skill,
+ installKey,
+ };
+ bySlug.set(normalizedSkill.slug, normalizedSkill);
+ byInstallKey.set(installKey, normalizedSkill);
+ return normalizedSkill;
+ });
+
+ return {
+ list,
+ bySlug,
+ byInstallKey,
+ };
+}
+
+function resolveSkillIdentifier(identifier, indexes = {}) {
+ const value = String(identifier || '').trim();
+ if (!value) return null;
+ if (indexes.bySlug instanceof Map && indexes.bySlug.has(value)) {
+ return indexes.bySlug.get(value);
+ }
+ if (indexes.byInstallKey instanceof Map && indexes.byInstallKey.has(value)) {
+ return indexes.byInstallKey.get(value);
+ }
+ return null;
+}
+
+function createUniqueSkillNames(skillNames = []) {
+ const values = [];
+ const seen = new Set();
+
+ skillNames.forEach((item) => {
+ const name = String(item || '').trim();
+ if (!name) return;
+ if (seen.has(name)) return;
+ seen.add(name);
+ values.push(name);
+ });
+
+ return values;
+}
+
+const SKILL_CATEGORY_OPTIONS = [
+ '通用',
+ '前端',
+ '后端',
+ '数据与AI',
+ '运维与系统',
+ '工程效率',
+ '安全',
+ '其他',
+];
+
+const EXTENSION_LANGUAGE_MAP = {
+ '.md': 'markdown',
+ '.markdown': 'markdown',
+ '.js': 'javascript',
+ '.mjs': 'javascript',
+ '.cjs': 'javascript',
+ '.jsx': 'javascript',
+ '.ts': 'typescript',
+ '.tsx': 'typescript',
+ '.json': 'json',
+ '.yml': 'yaml',
+ '.yaml': 'yaml',
+ '.html': 'html',
+ '.css': 'css',
+ '.scss': 'scss',
+ '.less': 'less',
+ '.sh': 'bash',
+ '.bash': 'bash',
+ '.zsh': 'bash',
+ '.py': 'python',
+ '.go': 'go',
+ '.java': 'java',
+ '.kt': 'kotlin',
+ '.rb': 'ruby',
+ '.php': 'php',
+ '.rs': 'rust',
+ '.swift': 'swift',
+ '.xml': 'xml',
+ '.sql': 'sql',
+ '.toml': 'toml',
+ '.ini': 'ini',
+ '.conf': 'ini',
+ '.txt': 'text',
+ '.log': 'text',
+};
+
+class SkillsService extends Service {
+ constructor(ctx) {
+ super(ctx);
+ this.skillCache = null;
+ this.storageReady = false;
+ this.storageReadyPromise = null;
+ }
+
+ getSkillCategoryOptions() {
+ return [...SKILL_CATEGORY_OPTIONS];
+ }
+
+ getSkillsConfig() {
+ return this.app.config.skills || {};
+ }
+
+ resolveGitHubToken() {
+ const token = this.getSkillsConfig().githubToken;
+ return String(token || '').trim();
+ }
+
+ invalidateCache() {
+ this.skillCache = null;
+ }
+
+ isCacheValid() {
+ return (
+ this.skillCache &&
+ this.skillCache.loadedAt &&
+ Date.now() - this.skillCache.loadedAt < CACHE_TTL_MS
+ );
+ }
+
+ async ensureStorageReady() {
+ if (this.storageReady) return;
+ if (this.storageReadyPromise) {
+ await this.storageReadyPromise;
+ return;
+ }
+
+ this.storageReadyPromise = (async () => {
+ const { SkillsSource, SkillsItem, SkillsFile } = this.app.model;
+ if (!SkillsSource || !SkillsItem || !SkillsFile) {
+ this.ctx.throw(500, 'Skills 数据模型未加载');
+ }
+
+ await SkillsSource.sync();
+ await SkillsItem.sync();
+ await SkillsFile.sync();
+ await this.ensureSkillsItemVersionColumn();
+ this.storageReady = true;
+ })();
+
+ try {
+ await this.storageReadyPromise;
+ } finally {
+ this.storageReadyPromise = null;
+ }
+ }
+
+ async ensureSkillsItemVersionColumn() {
+ const queryInterface = this.app.model.getQueryInterface();
+ const table = await queryInterface.describeTable('skills_items');
+ if (table.version) return;
+
+ await queryInterface.addColumn('skills_items', 'version', {
+ type: this.app.Sequelize.STRING(128),
+ allowNull: false,
+ defaultValue: '',
+ comment: '技能版本号',
+ });
+ }
+
+ parseJsonArray(value) {
+ if (!value) return [];
+ if (Array.isArray(value)) return value;
+ if (typeof value !== 'string') return [];
+
+ try {
+ const parsed = JSON.parse(value);
+ return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
+ } catch (error) {
+ return [];
+ }
+ }
+
+ toPublicSkill(skill) {
+ return {
+ slug: skill.slug,
+ installKey: skill.installKey || skill.slug,
+ name: skill.name,
+ description: skill.description,
+ category: skill.category,
+ version: skill.version || '',
+ tags: skill.tags,
+ allowedTools: skill.allowedTools,
+ stars: skill.stars,
+ updatedAt: skill.updatedAt,
+ sourceRepo: skill.sourceRepo,
+ sourcePath: skill.sourcePath,
+ installCommand: skill.installCommand,
+ };
+ }
+
+ toSkillDto(row) {
+ return {
+ id: row.id,
+ sourceId: row.source_id,
+ slug: row.slug,
+ installKey: row.slug,
+ name: row.name || '',
+ description: row.description || '',
+ category: row.category || '通用',
+ version: row.version || '',
+ tags: this.parseJsonArray(row.tags),
+ allowedTools: this.parseJsonArray(row.allowed_tools),
+ stars: Number(row.stars) || 0,
+ updatedAt: (
+ row.updated_at ||
+ row.updated_at_remote ||
+ row.created_at ||
+ new Date()
+ ).toISOString(),
+ sourceRepo: row.source_repo || '',
+ sourcePath: row.source_path || '',
+ skillMd: row.skill_md || '',
+ installCommand: row.install_command || '',
+ fileCount: Number(row.file_count) || 0,
+ };
+ }
+
+ async ensureSkillCache() {
+ if (this.isCacheValid()) return this.skillCache;
+
+ await this.ensureStorageReady();
+ const { SkillsItem } = this.app.model;
+ const rows = await SkillsItem.findAll({
+ where: { is_delete: 0 },
+ order: [
+ ['stars', 'DESC'],
+ ['updated_at_remote', 'DESC'],
+ ['updated_at', 'DESC'],
+ ['id', 'DESC'],
+ ],
+ });
+
+ const rawSkills = rows.map((row) => this.toSkillDto(row));
+ const installKeyMap = createInstallKeyMap(rawSkills);
+ const categories = this.getSkillCategoryOptions();
+ this.skillCache = {
+ loadedAt: Date.now(),
+ skills: installKeyMap.list,
+ categories,
+ bySlug: installKeyMap.bySlug,
+ byInstallKey: installKeyMap.byInstallKey,
+ };
+
+ return this.skillCache;
+ }
+
+ getSkillList(params = {}) {
+ const {
+ keyword = '',
+ sortBy = 'stars',
+ category = '',
+ pageNum = 1,
+ pageSize = 20,
+ } = params;
+
+ const safePageNum = Math.max(parseInt(pageNum, 10) || 1, 1);
+ const safePageSize = Math.max(parseInt(pageSize, 10) || 20, 1);
+ const { skills, categories } = this.skillCache;
+
+ let list = [...skills];
+ if (keyword) {
+ const value = String(keyword).toLowerCase();
+ list = list.filter(
+ (item) =>
+ item.name.toLowerCase().includes(value) ||
+ item.description.toLowerCase().includes(value) ||
+ item.sourceRepo.toLowerCase().includes(value) ||
+ item.tags.some((tag) => tag.toLowerCase().includes(value))
+ );
+ }
+
+ if (category) {
+ list = list.filter((item) => item.category === category);
+ }
+
+ list.sort((a, b) => {
+ if (sortBy === 'recent') {
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
+ }
+ if (b.stars !== a.stars) return b.stars - a.stars;
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
+ });
+
+ const total = list.length;
+ const offset = (safePageNum - 1) * safePageSize;
+ const pageList = list
+ .slice(offset, offset + safePageSize)
+ .map((item) => this.toPublicSkill(item));
+
+ return {
+ list: pageList,
+ total,
+ pageNum: safePageNum,
+ pageSize: safePageSize,
+ categories,
+ };
+ }
+
+ async querySkillList(params = {}) {
+ await this.ensureSkillCache();
+ return this.getSkillList(params);
+ }
+
+ getSkillByIdentifier(identifier) {
+ const value = String(identifier || '').trim();
+ if (!value) {
+ this.ctx.throw(400, '缺少技能标识');
+ }
+
+ if (!SKILL_SLUG_PATTERN.test(value)) {
+ this.ctx.throw(400, '非法技能标识');
+ }
+
+ const skill = resolveSkillIdentifier(value, this.skillCache);
+ if (!skill) {
+ this.ctx.throw(404, '技能不存在');
+ }
+
+ return skill;
+ }
+
+ async getSkillDetail(slug) {
+ await this.ensureSkillCache();
+ const skill = this.getSkillByIdentifier(slug);
+ const { SkillsFile } = this.app.model;
+
+ const rows = await SkillsFile.findAll({
+ where: {
+ skill_id: skill.id,
+ is_delete: 0,
+ },
+ attributes: ['file_path'],
+ order: [['file_path', 'ASC']],
+ limit: MAX_FILE_LIST_COUNT,
+ });
+
+ return {
+ ...this.toPublicSkill(skill),
+ skillMd: skill.skillMd,
+ fileList: rows.map((row) => row.file_path),
+ };
+ }
+
+ async getSkillFileContent(slug, filePath) {
+ await this.ensureSkillCache();
+ const skill = this.getSkillByIdentifier(slug);
+ const normalizedPath = this.normalizeRelativePath(filePath);
+ const { SkillsFile } = this.app.model;
+
+ const row = await SkillsFile.findOne({
+ where: {
+ skill_id: skill.id,
+ file_path: normalizedPath,
+ is_delete: 0,
+ },
+ });
+
+ if (!row) {
+ this.ctx.throw(404, '文件不存在');
+ }
+
+ if (Number(row.size) > MAX_FILE_CONTENT_SIZE || !row.content) {
+ this.ctx.throw(413, '文件过大,无法在线预览');
+ }
+
+ return {
+ slug: skill.slug,
+ path: row.file_path,
+ language: row.language || 'text',
+ size: Number(row.size) || 0,
+ readonly: true,
+ isBinary: Boolean(row.is_binary),
+ encoding: row.encoding || 'utf8',
+ content: row.content || '',
+ };
+ }
+
+ async getSkillArchive(slug) {
+ await this.ensureSkillCache();
+ const skill = this.getSkillByIdentifier(slug);
+ const { SkillsFile } = this.app.model;
+ const rows = await SkillsFile.findAll({
+ where: {
+ skill_id: skill.id,
+ is_delete: 0,
+ },
+ order: [['file_path', 'ASC']],
+ limit: MAX_FILE_LIST_COUNT,
+ });
+
+ const zip = new AdmZip();
+ const rootFolder = this.sanitizeFileName(skill.name || skill.slug || 'skill');
+ rows.forEach((row) => {
+ const safeRelativePath = this.normalizeRelativePath(row.file_path);
+ const zipPath = path.posix.join(rootFolder, safeRelativePath);
+ const buffer = this.decodeStoredFileContent(row.content, Boolean(row.is_binary));
+ zip.addFile(zipPath, buffer);
+ });
+
+ return {
+ fileName: `${rootFolder}.zip`,
+ content: zip.toBuffer(),
+ };
+ }
+
+ validateSkillIdentifier(slug) {
+ const value = String(slug || '').trim();
+ if (!value) {
+ this.ctx.throw(400, '缺少技能标识');
+ }
+ if (!SKILL_SLUG_PATTERN.test(value)) {
+ this.ctx.throw(400, '非法技能标识');
+ }
+ return value;
+ }
+
+ buildSkillDownloadUrl(slug) {
+ const encodedSlug = encodeURIComponent(slug);
+ const { protocol, host } = this.ctx;
+ if (protocol && host) {
+ return `${protocol}://${host}/api/skills/download?slug=${encodedSlug}`;
+ }
+ return `/api/skills/download?slug=${encodedSlug}`;
+ }
+
+ async getSkillPackageInstallability(skillId) {
+ const { SkillsFile } = this.app.model;
+ const rows = await SkillsFile.findAll({
+ where: {
+ skill_id: skillId,
+ is_delete: 0,
+ },
+ attributes: ['file_path'],
+ order: [['file_path', 'ASC']],
+ limit: MAX_FILE_LIST_COUNT,
+ });
+
+ if (rows.length === 0) {
+ return {
+ installable: false,
+ reason: 'skill package has no files',
+ };
+ }
+
+ const hasSkillMd = rows.some(
+ (row) => path.basename(row.file_path).toLowerCase() === 'skill.md'
+ );
+ if (!hasSkillMd) {
+ return {
+ installable: false,
+ reason: 'skill package missing SKILL.md',
+ };
+ }
+
+ return {
+ installable: true,
+ reason: '',
+ };
+ }
+
+ async getInstallMeta(slug) {
+ await this.ensureSkillCache();
+ const safeIdentifier = this.validateSkillIdentifier(slug);
+ const skill = this.getSkillByIdentifier(safeIdentifier);
+ const { installable, reason } = await this.getSkillPackageInstallability(skill.id);
+ const downloadUrl = this.buildSkillDownloadUrl(skill.slug);
+
+ let sha256 = '';
+ if (installable) {
+ const { content } = await this.getSkillArchive(skill.slug);
+ sha256 = crypto.createHash('sha256').update(content).digest('hex');
+ }
+
+ return {
+ slug: skill.slug,
+ installKey: skill.installKey || skill.slug,
+ name: skill.name,
+ downloadUrl,
+ packageType: 'zip',
+ packageVersion: 'v1',
+ packageRootMode: 'find-skill-md',
+ installDirName: skill.installKey || skill.slug,
+ version: skill.version || '',
+ sha256,
+ sourceRepo: skill.sourceRepo || '',
+ installable,
+ reason,
+ };
+ }
+
+ decodeStoredFileContent(content, isBinary) {
+ if (!content) return Buffer.from('');
+ if (isBinary) {
+ try {
+ return Buffer.from(content, 'base64');
+ } catch (error) {
+ return Buffer.from('');
+ }
+ }
+ return Buffer.from(content, 'utf8');
+ }
+
+ normalizeRelativePath(filePath) {
+ const value = String(filePath || '').trim();
+ if (!value) {
+ this.ctx.throw(400, '缺少文件路径');
+ }
+
+ const normalized = path.normalize(value).replace(/\\/g, '/').replace(/^\/+/, '');
+
+ if (!normalized || normalized === '.' || normalized.startsWith('..')) {
+ this.ctx.throw(400, '非法文件路径');
+ }
+
+ return normalized;
+ }
+
+ isLikelyBinary(buffer) {
+ if (!buffer || buffer.length === 0) return false;
+ const sampleLength = Math.min(buffer.length, 1024);
+ for (let i = 0; i < sampleLength; i += 1) {
+ if (buffer[i] === 0) return true;
+ }
+ return false;
+ }
+
+ sanitizeFileName(fileName) {
+ return String(fileName || 'skill')
+ .trim()
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .toLowerCase();
+ }
+
+ sanitizeSlugSegment(value) {
+ return String(value || '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .replace(/-{2,}/g, '-');
+ }
+
+ hashString(value) {
+ let hash = 0;
+ const text = String(value || '');
+ for (let i = 0; i < text.length; i += 1) {
+ hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
+ }
+ return hash.toString(16);
+ }
+
+ buildSkillSlug(sourceMeta, relativeSkillPath, skillName, usedSlugs = new Set()) {
+ const sourceKey = `${sourceMeta.repoHost || 'local'}-${
+ sourceMeta.repoPath || sourceMeta.sourceUrl || ''
+ }-${sourceMeta.ref || 'default'}`;
+ const relativeKey = String(relativeSkillPath || skillName || 'skill').replace(/\\/g, '/');
+ const base = this.sanitizeSlugSegment(`${sourceKey}-${relativeKey}`) || 'skill';
+ let slug = base;
+
+ if (slug.length > 220) {
+ slug = `${slug.slice(0, 200)}-${this.hashString(base).slice(0, 12)}`;
+ }
+
+ let index = 2;
+ while (usedSlugs.has(slug)) {
+ const suffix = `-${index}`;
+ const head =
+ slug.length + suffix.length > 255 ? slug.slice(0, 255 - suffix.length) : slug;
+ slug = `${head}${suffix}`;
+ index += 1;
+ }
+
+ usedSlugs.add(slug);
+ return slug;
+ }
+
+ async getRelatedSkills(slug, limit = 6) {
+ await this.ensureSkillCache();
+ const target = this.getSkillByIdentifier(slug);
+ if (!target) {
+ this.ctx.throw(404, '技能不存在');
+ }
+
+ const targetTags = new Set((target.tags || []).map((item) => item.toLowerCase()));
+ const related = this.skillCache.skills
+ .filter((item) => item.slug !== target.slug)
+ .map((item) => {
+ const itemTags = (item.tags || []).map((tag) => tag.toLowerCase());
+ const overlap = itemTags.filter((tag) => targetTags.has(tag)).length;
+ const categoryScore = item.category === target.category ? 3 : 0;
+ const score = overlap * 10 + categoryScore + Math.min(item.stars, 10);
+ return { ...item, _score: score };
+ })
+ .filter((item) => item._score > 0)
+ .sort((a, b) => b._score - a._score || b.stars - a.stars)
+ .slice(0, Math.max(parseInt(limit, 10) || 6, 1))
+ .map((item) => {
+ const rest = { ...item };
+ delete rest._score;
+ return this.toPublicSkill(rest);
+ });
+
+ return related;
+ }
+
+ normalizeCategory(rawCategory) {
+ const category = String(rawCategory || '').trim();
+ if (!category) return '通用';
+ if (SKILL_CATEGORY_OPTIONS.includes(category)) {
+ return category;
+ }
+ return '其他';
+ }
+
+ parseArrayLike(value) {
+ if (!value) return [];
+ if (Array.isArray(value)) return value;
+ if (typeof value !== 'string') return [];
+ const trimmed = value.trim();
+
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
+ try {
+ const normalized = trimmed.replace(/'/g, '"');
+ const parsed = JSON.parse(normalized);
+ return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
+ } catch (error) {
+ return trimmed
+ .slice(1, -1)
+ .split(',')
+ .map((item) => item.trim().replace(/^['"]|['"]$/g, ''))
+ .filter(Boolean);
+ }
+ }
+
+ return trimmed
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+ }
+
+ normalizePlatformTags(rawTags) {
+ const values = this.parseArrayLike(rawTags)
+ .map((item) => String(item || '').trim())
+ .map((item) => item.replace(/\s+/g, ' '))
+ .filter(Boolean)
+ .map((item) => item.slice(0, MAX_TAG_LENGTH));
+
+ return Array.from(new Set(values)).slice(0, MAX_PLATFORM_TAGS);
+ }
+
+ getInstallCommand({ sourceRepo, sourceUrl, name }) {
+ const source = String(sourceRepo || sourceUrl || '').trim();
+ if (!source) return '';
+ if (/^upload:\/\//i.test(source)) return '';
+ return `npx skills add ${source} --skill "${name}"`;
+ }
+
+ buildUploadSourceMeta(fileName = '', identityKey = '') {
+ const parsedName = path.parse(String(fileName || '').trim());
+ const baseName = parsedName.name || parsedName.base || 'uploaded-skill';
+ const normalizedName = this.sanitizeSlugSegment(baseName) || 'uploaded-skill';
+ const identityText = String(identityKey || '').trim();
+ const normalizedIdentity = this.sanitizeSlugSegment(identityText);
+ const identityHash = identityText ? this.hashString(identityText).slice(0, 8) : '';
+ const sourceKey =
+ [normalizedName, normalizedIdentity, identityHash].filter(Boolean).join('-') ||
+ normalizedName;
+ return {
+ sourceUrl: `upload://${sourceKey}.skill`,
+ sourceType: 'upload',
+ cloneUrl: '',
+ sourceRepo: '',
+ ref: '',
+ subpath: '',
+ repoHost: 'upload',
+ repoPath: sourceKey,
+ originalAction: 'upload',
+ };
+ }
+
+ getUploadIdentityKey(skillRecords = [], preferredName = '') {
+ const name = String(preferredName || '').trim();
+ if (name) return name;
+ return skillRecords
+ .map((item) => String(item?.name || '').trim())
+ .filter(Boolean)
+ .sort((a, b) => a.localeCompare(b))
+ .join('|');
+ }
+
+ extractDescription(content) {
+ const stripped = content
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#') && !line.startsWith('---'));
+ return stripped[0] || '';
+ }
+
+ parseFrontmatter(content) {
+ const result = {};
+ const text = String(content || '');
+ const normalized = text.replace(/\r\n/g, '\n');
+ if (!normalized.startsWith('---\n')) return result;
+
+ const endMarkerIndex = normalized.indexOf('\n---\n', 4);
+ const endMarkerLength = 5;
+ if (endMarkerIndex === -1) return result;
+
+ const frontmatterText = normalized.slice(4, endMarkerIndex);
+ const lines = frontmatterText.split('\n');
+ let activeKey = '';
+
+ lines.forEach((line) => {
+ const keyMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
+ if (keyMatch) {
+ const key = keyMatch[1];
+ const rawValue = keyMatch[2].trim();
+ activeKey = key;
+
+ if (!rawValue) {
+ result[key] = [];
+ return;
+ }
+
+ if (
+ (rawValue.startsWith('"') && rawValue.endsWith('"')) ||
+ (rawValue.startsWith("'") && rawValue.endsWith("'"))
+ ) {
+ result[key] = rawValue.slice(1, -1);
+ return;
+ }
+
+ if (rawValue.startsWith('[') && rawValue.endsWith(']')) {
+ result[key] = this.parseArrayLike(rawValue);
+ return;
+ }
+
+ result[key] = rawValue;
+ return;
+ }
+
+ if (!activeKey) return;
+ const listMatch = line.match(/^\s*-\s+(.*)$/);
+ if (!listMatch) {
+ activeKey = '';
+ return;
+ }
+
+ const listValue = listMatch[1].trim().replace(/^['"]|['"]$/g, '');
+ if (!Array.isArray(result[activeKey])) {
+ result[activeKey] = [];
+ }
+ result[activeKey].push(listValue);
+ });
+
+ if (normalized.length >= endMarkerIndex + endMarkerLength) {
+ result.__body = normalized.slice(endMarkerIndex + endMarkerLength);
+ }
+
+ return result;
+ }
+
+ isLocalPathSource(source) {
+ const value = String(source || '').trim();
+ if (!value) return false;
+ if (/^(https?:\/\/|git@|ssh:\/\/)/i.test(value)) return false;
+ return (
+ value.startsWith('/') ||
+ value.startsWith('./') ||
+ value.startsWith('../') ||
+ value.startsWith('~/')
+ );
+ }
+
+ resolveLocalPath(source) {
+ const value = String(source || '').trim();
+ if (value.startsWith('~/')) {
+ const home = process.env.HOME || '';
+ return path.join(home, value.slice(2));
+ }
+ return path.resolve(value);
+ }
+
+ inferSourceType(hostname = '', pathSegments = []) {
+ const host = String(hostname || '').toLowerCase();
+ if (host.includes('github')) return 'github';
+ if (host.includes('gitlab')) return 'gitlab';
+ if (Array.isArray(pathSegments) && pathSegments.includes('-')) return 'gitlab';
+ return 'git';
+ }
+
+ parseSshGitSource(source) {
+ const match = String(source || '')
+ .trim()
+ .match(/^git@([^:]+):(.+?)(?:\.git)?$/i);
+ if (!match) return null;
+ const host = match[1];
+ const repoPath = match[2].replace(/^\/+|\/+$/g, '');
+ const sourceType = this.inferSourceType(host, repoPath.split('/'));
+
+ return {
+ sourceUrl: source,
+ sourceType,
+ cloneUrl: source,
+ sourceRepo: `https://${host}/${repoPath}`,
+ ref: '',
+ subpath: '',
+ repoHost: host,
+ repoPath,
+ originalAction: '',
+ };
+ }
+
+ async parseImportSource(source) {
+ const value = String(source || '').trim();
+ if (!value) {
+ this.ctx.throw(400, '缺少导入来源地址');
+ }
+
+ if (this.isLocalPathSource(value)) {
+ const absolutePath = this.resolveLocalPath(value);
+ if (!fs.existsSync(absolutePath)) {
+ this.ctx.throw(400, `本地路径不存在: ${absolutePath}`);
+ }
+
+ const stat = fs.statSync(absolutePath);
+ if (stat.isFile()) {
+ return {
+ sourceUrl: value,
+ sourceType: 'local',
+ cloneUrl: path.dirname(absolutePath),
+ sourceRepo: value,
+ ref: '',
+ subpath: path.basename(absolutePath),
+ repoHost: 'local',
+ repoPath: path.basename(path.dirname(absolutePath)),
+ originalAction: 'file',
+ };
+ }
+
+ return {
+ sourceUrl: value,
+ sourceType: 'local',
+ cloneUrl: absolutePath,
+ sourceRepo: value,
+ ref: '',
+ subpath: '',
+ repoHost: 'local',
+ repoPath: path.basename(absolutePath),
+ originalAction: '',
+ };
+ }
+
+ const sshSource = this.parseSshGitSource(value);
+ if (sshSource) {
+ return sshSource;
+ }
+
+ let url;
+ try {
+ url = new URL(value);
+ } catch (error) {
+ this.ctx.throw(400, `来源地址格式无效: ${value}`);
+ }
+
+ const segments = url.pathname.split('/').filter(Boolean);
+ if (segments.length < 2) {
+ this.ctx.throw(400, `无法识别仓库地址: ${value}`);
+ }
+
+ let repoSegments = [];
+ let action = '';
+ let actionTail = [];
+
+ const dashIndex = segments.indexOf('-');
+ if (dashIndex > 0 && ['tree', 'blob'].includes(segments[dashIndex + 1])) {
+ repoSegments = segments.slice(0, dashIndex);
+ action = segments[dashIndex + 1];
+ actionTail = segments.slice(dashIndex + 2);
+ } else if (['tree', 'blob'].includes(segments[2])) {
+ repoSegments = segments.slice(0, 2);
+ action = segments[2];
+ actionTail = segments.slice(3);
+ } else if (
+ String(url.hostname || '')
+ .toLowerCase()
+ .includes('github')
+ ) {
+ repoSegments = segments.slice(0, 2);
+ } else {
+ repoSegments = segments;
+ }
+
+ if (repoSegments.length < 2) {
+ this.ctx.throw(400, `无法识别仓库路径: ${value}`);
+ }
+
+ const normalizedRepoSegments = [...repoSegments];
+ normalizedRepoSegments[normalizedRepoSegments.length - 1] = normalizedRepoSegments[
+ normalizedRepoSegments.length - 1
+ ].replace(/\.git$/i, '');
+
+ const repoPath = normalizedRepoSegments.join('/');
+ const origin = `${url.protocol}//${url.host}`;
+ const cloneUrl = `${origin}/${repoPath}.git`;
+ let ref = '';
+ let subpath = '';
+
+ if (action && actionTail.length > 0) {
+ const resolved = await this.resolveRefAndSubpath(cloneUrl, actionTail, action);
+ ref = resolved.ref;
+ subpath = resolved.subpath;
+ }
+
+ return {
+ sourceUrl: value,
+ sourceType: this.inferSourceType(url.hostname, segments),
+ cloneUrl,
+ sourceRepo: `${origin}/${repoPath}`,
+ ref,
+ subpath,
+ repoHost: url.host,
+ repoPath,
+ originalAction: action,
+ };
+ }
+
+ async resolveRefAndSubpath(cloneUrl, tailSegments = [], action = '') {
+ if (!Array.isArray(tailSegments) || tailSegments.length === 0) {
+ return { ref: '', subpath: '' };
+ }
+
+ const branches = await this.listRemoteHeadRefs(cloneUrl);
+ const branchSet = new Set(branches);
+
+ for (let i = tailSegments.length; i >= 1; i -= 1) {
+ const candidateRef = tailSegments.slice(0, i).join('/');
+ if (branchSet.has(candidateRef)) {
+ return {
+ ref: candidateRef,
+ subpath: tailSegments.slice(i).join('/'),
+ };
+ }
+ }
+
+ if (action === 'tree' && tailSegments.length >= 2) {
+ return {
+ ref: tailSegments.join('/'),
+ subpath: '',
+ };
+ }
+
+ return {
+ ref: tailSegments[0],
+ subpath: tailSegments.slice(1).join('/'),
+ };
+ }
+
+ async listRemoteHeadRefs(cloneUrl) {
+ if (!cloneUrl) return [];
+ const env = this.buildCommandEnv({ remoteUrl: cloneUrl });
+ const authArgs = this.getGitAuthPrefixArgs(cloneUrl);
+ try {
+ const { stdout } = await this.runCommand(
+ 'git',
+ [...authArgs, 'ls-remote', '--heads', cloneUrl],
+ GIT_COMMAND_TIMEOUT_MS,
+ process.cwd(),
+ env
+ );
+ return String(stdout || '')
+ .split('\n')
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const match = line.match(/\s+refs\/heads\/(.+)$/);
+ return match ? match[1] : '';
+ })
+ .filter(Boolean);
+ } catch (error) {
+ this.ctx.logger.warn(`[skills] 获取远端分支失败,使用兜底解析: ${error.message}`);
+ return [];
+ }
+ }
+
+ async cloneSourceRepo(parsedSource, targetDir) {
+ if (parsedSource.sourceType === 'local') {
+ return;
+ }
+
+ const env = this.buildCommandEnv({ remoteUrl: parsedSource.cloneUrl });
+ const authArgs = this.getGitAuthPrefixArgs(parsedSource.cloneUrl);
+ const hasSubpath = Boolean(String(parsedSource.subpath || '').trim());
+
+ // 子目录导入优先使用 sparse clone,避免大仓库全量 clone 导致长时间卡顿。
+ if (hasSubpath) {
+ const sparseCloneArgs = [
+ ...authArgs,
+ 'clone',
+ '--depth',
+ '1',
+ '--filter=blob:none',
+ '--sparse',
+ ];
+ if (parsedSource.ref) {
+ sparseCloneArgs.push('--branch', parsedSource.ref);
+ }
+ sparseCloneArgs.push(parsedSource.cloneUrl, targetDir);
+
+ try {
+ await this.runCommand(
+ 'git',
+ sparseCloneArgs,
+ GIT_COMMAND_TIMEOUT_MS,
+ process.cwd(),
+ env
+ );
+ await this.runCommand(
+ 'git',
+ ['-C', targetDir, 'sparse-checkout', 'set', parsedSource.subpath],
+ GIT_COMMAND_TIMEOUT_MS,
+ process.cwd(),
+ env
+ );
+ return;
+ } catch (error) {
+ this.ctx.logger.warn(
+ `[skills] sparse clone 失败,回退普通 clone: ${error.message}`
+ );
+ if (fs.existsSync(targetDir)) {
+ try {
+ fs.rmSync(targetDir, { recursive: true, force: true });
+ } catch (cleanupError) {
+ this.ctx.logger.warn(
+ `[skills] sparse clone 回退清理目录失败: ${targetDir}, ${cleanupError.message}`
+ );
+ }
+ }
+ fs.mkdirSync(targetDir, { recursive: true });
+ }
+ }
+
+ const cloneArgs = [...authArgs, 'clone', '--depth', '1'];
+ if (parsedSource.ref) {
+ cloneArgs.push('--branch', parsedSource.ref);
+ }
+ cloneArgs.push(parsedSource.cloneUrl, targetDir);
+
+ try {
+ await this.runCommand('git', cloneArgs, GIT_COMMAND_TIMEOUT_MS, process.cwd(), env);
+ } catch (error) {
+ if (!parsedSource.ref) {
+ throw error;
+ }
+
+ // 分支解析异常时回退默认分支,尽量提升兼容性。
+ const fallbackArgs = [
+ ...authArgs,
+ 'clone',
+ '--depth',
+ '1',
+ parsedSource.cloneUrl,
+ targetDir,
+ ];
+ await this.runCommand('git', fallbackArgs, GIT_COMMAND_TIMEOUT_MS, process.cwd(), env);
+ }
+ }
+
+ resolveSelectedPath(repoDir, subpath = '') {
+ const rootPath = path.resolve(repoDir);
+ if (!subpath) {
+ return rootPath;
+ }
+
+ const normalizedSubpath = this.normalizeRelativePath(subpath);
+ const targetPath = path.resolve(rootPath, normalizedSubpath);
+ if (!this.isPathInsideRoot(rootPath, targetPath) && targetPath !== rootPath) {
+ this.ctx.throw(400, '来源子路径非法');
+ }
+
+ if (!fs.existsSync(targetPath)) {
+ this.ctx.throw(400, `来源子路径不存在: ${normalizedSubpath}`);
+ }
+
+ return targetPath;
+ }
+
+ isPathInsideRoot(rootPath, targetPath) {
+ if (targetPath === rootPath) return true;
+ return targetPath.startsWith(`${rootPath}${path.sep}`);
+ }
+
+ containsSkillMd(dirPath) {
+ const target = path.join(dirPath, 'SKILL.md');
+ return fs.existsSync(target) && fs.statSync(target).isFile();
+ }
+
+ shouldSkipDirName(dirName) {
+ return (
+ dirName === '.git' ||
+ dirName === 'node_modules' ||
+ dirName === '.idea' ||
+ dirName === '.vscode'
+ );
+ }
+
+ findSkillsRootDirs(baseDir, maxDepth = SKILLS_ROOT_DISCOVER_DEPTH_LIMIT) {
+ const roots = [];
+ const queue = [{ dir: baseDir, depth: 0 }];
+ const visited = new Set();
+ let visitCount = 0;
+
+ while (queue.length > 0) {
+ const { dir, depth } = queue.shift();
+ const normalizedDir = path.resolve(dir);
+ if (visited.has(normalizedDir)) continue;
+ visited.add(normalizedDir);
+ visitCount += 1;
+ if (visitCount > DISCOVER_MAX_DIR_COUNT) break;
+
+ if (path.basename(normalizedDir).toLowerCase() === 'skills') {
+ roots.push(normalizedDir);
+ }
+
+ if (depth >= maxDepth) continue;
+
+ let entries = [];
+ try {
+ entries = fs.readdirSync(normalizedDir, { withFileTypes: true });
+ } catch (error) {
+ continue;
+ }
+
+ entries.forEach((entry) => {
+ if (!entry.isDirectory()) return;
+ if (this.shouldSkipDirName(entry.name)) return;
+ queue.push({ dir: path.join(normalizedDir, entry.name), depth: depth + 1 });
+ });
+ }
+
+ return Array.from(new Set(roots));
+ }
+
+ findSkillDirsWithin(baseDir, maxDepth = DISCOVER_DEPTH_LIMIT) {
+ const result = [];
+ const queue = [{ dir: baseDir, depth: 0 }];
+ const visited = new Set();
+ let visitCount = 0;
+
+ while (queue.length > 0) {
+ const { dir, depth } = queue.shift();
+ const normalizedDir = path.resolve(dir);
+ if (visited.has(normalizedDir)) continue;
+ visited.add(normalizedDir);
+ visitCount += 1;
+ if (visitCount > DISCOVER_MAX_DIR_COUNT) break;
+
+ if (this.containsSkillMd(normalizedDir)) {
+ result.push(normalizedDir);
+ }
+
+ if (depth >= maxDepth) continue;
+
+ let entries = [];
+ try {
+ entries = fs.readdirSync(normalizedDir, { withFileTypes: true });
+ } catch (error) {
+ continue;
+ }
+
+ entries.forEach((entry) => {
+ if (!entry.isDirectory()) return;
+ if (this.shouldSkipDirName(entry.name)) return;
+ queue.push({ dir: path.join(normalizedDir, entry.name), depth: depth + 1 });
+ });
+ }
+
+ return Array.from(new Set(result));
+ }
+
+ discoverSkillDirs(selectedPath) {
+ const stat = fs.statSync(selectedPath);
+ if (stat.isFile()) {
+ if (path.basename(selectedPath) !== 'SKILL.md') {
+ this.ctx.throw(400, '文件来源仅支持 SKILL.md');
+ }
+ return [path.dirname(selectedPath)];
+ }
+
+ const selectedDir = path.resolve(selectedPath);
+
+ if (this.containsSkillMd(selectedDir)) {
+ return [selectedDir];
+ }
+
+ const roots = this.findSkillsRootDirs(selectedDir, SKILLS_ROOT_DISCOVER_DEPTH_LIMIT);
+ let skillDirs = [];
+
+ if (roots.length > 0) {
+ roots.forEach((rootDir) => {
+ const discovered = this.findSkillDirsWithin(rootDir, DISCOVER_DEPTH_LIMIT);
+ skillDirs = skillDirs.concat(discovered);
+ });
+ }
+
+ if (skillDirs.length === 0) {
+ skillDirs = this.findSkillDirsWithin(selectedDir, DISCOVER_DEPTH_LIMIT);
+ }
+
+ return Array.from(new Set(skillDirs.map((item) => path.resolve(item))));
+ }
+
+ buildFileLanguage(filePath) {
+ const extension = path.extname(filePath || '').toLowerCase();
+ return EXTENSION_LANGUAGE_MAP[extension] || 'text';
+ }
+
+ collectSkillFiles(skillDir) {
+ const files = [];
+ const queue = [skillDir];
+ const visited = new Set();
+
+ while (queue.length > 0) {
+ const currentDir = queue.shift();
+ const normalizedDir = path.resolve(currentDir);
+ if (visited.has(normalizedDir)) continue;
+ visited.add(normalizedDir);
+
+ let entries = [];
+ try {
+ entries = fs.readdirSync(normalizedDir, { withFileTypes: true });
+ } catch (error) {
+ continue;
+ }
+
+ entries.forEach((entry) => {
+ if (this.shouldSkipDirName(entry.name)) return;
+ const fullPath = path.join(normalizedDir, entry.name);
+ if (entry.isDirectory()) {
+ queue.push(fullPath);
+ return;
+ }
+
+ if (!entry.isFile()) return;
+ const relativePath = path.relative(skillDir, fullPath).split(path.sep).join('/');
+ if (!relativePath || relativePath.startsWith('..')) return;
+
+ try {
+ const stat = fs.statSync(fullPath);
+ const size = Number(stat.size) || 0;
+ if (size > MAX_STORED_FILE_CONTENT_SIZE) {
+ this.ctx.throw(
+ 400,
+ `文件 ${relativePath} 超过 ${Math.floor(
+ MAX_STORED_FILE_CONTENT_SIZE / 1024 / 1024
+ )}MB 限制,拒绝导入`
+ );
+ }
+ const buffer = fs.readFileSync(fullPath);
+ const isBinary = this.isLikelyBinary(buffer);
+ const encoding = isBinary ? 'base64' : 'utf8';
+ const content = isBinary ? buffer.toString('base64') : buffer.toString('utf8');
+
+ files.push({
+ filePath: relativePath,
+ language: this.buildFileLanguage(relativePath),
+ size,
+ isBinary,
+ encoding,
+ content,
+ updatedAt: stat.mtime,
+ });
+ } catch (error) {
+ if (error.status) {
+ throw error;
+ }
+ // 忽略不可读取文件,避免单文件损坏阻塞整次导入。
+ }
+ });
+
+ if (files.length >= MAX_FILE_LIST_COUNT) {
+ break;
+ }
+ }
+
+ return files.sort((a, b) => a.filePath.localeCompare(b.filePath));
+ }
+
+ prepareSkillRecord(skillDir, repoDir, sourceMeta, category, tags) {
+ const skillFilePath = path.join(skillDir, 'SKILL.md');
+ const content = fs.readFileSync(skillFilePath, 'utf8');
+ const stat = fs.statSync(skillFilePath);
+ const frontmatter = this.parseFrontmatter(content);
+ const body = frontmatter.__body || content;
+
+ const name =
+ String(frontmatter.name || path.basename(skillDir)).trim() || path.basename(skillDir);
+ const description =
+ String(frontmatter.description || this.extractDescription(body)).trim() ||
+ this.extractDescription(content);
+ const version = String(frontmatter.version || '').trim();
+ const allowedTools = this.parseArrayLike(
+ frontmatter['allowed-tools'] || frontmatter.allowedTools || frontmatter.allowed_tools
+ );
+
+ const sourcePath = path.relative(repoDir, skillDir).split(path.sep).join('/');
+ const installCommand = this.getInstallCommand({
+ sourceRepo: sourceMeta.sourceRepo,
+ sourceUrl: sourceMeta.sourceUrl,
+ name,
+ });
+
+ const files = this.collectSkillFiles(skillDir);
+
+ return {
+ name,
+ description,
+ category,
+ version,
+ tags,
+ allowedTools,
+ updatedAt: stat.mtime,
+ sourceRepo: sourceMeta.sourceRepo,
+ sourcePath,
+ skillMd: content,
+ installCommand,
+ files,
+ };
+ }
+
+ filterSkillByName(skills, skillName) {
+ const expected = String(skillName || '')
+ .trim()
+ .toLowerCase();
+ if (!expected) return skills;
+
+ return skills.filter((item) => {
+ const name = String(item.name || '')
+ .trim()
+ .toLowerCase();
+ const folderName = String(path.basename(item.sourcePath || ''))
+ .trim()
+ .toLowerCase();
+ return name === expected || folderName === expected;
+ });
+ }
+
+ async upsertSourceRecord(parsedSource, syncStatus, syncError = '') {
+ const { SkillsSource } = this.app.model;
+ const payload = {
+ source_url: parsedSource.sourceUrl,
+ source_type: parsedSource.sourceType,
+ clone_url: parsedSource.cloneUrl,
+ source_repo: parsedSource.sourceRepo || parsedSource.sourceUrl,
+ ref: parsedSource.ref || '',
+ subpath: parsedSource.subpath || '',
+ repo_host: parsedSource.repoHost || '',
+ repo_path: parsedSource.repoPath || '',
+ sync_status: syncStatus,
+ sync_error: syncError || '',
+ };
+
+ const existing = await SkillsSource.findOne({
+ where: { source_url: parsedSource.sourceUrl },
+ });
+
+ if (existing) {
+ await existing.update(payload);
+ return existing;
+ }
+
+ return await SkillsSource.create(payload);
+ }
+
+ async assertSkillNamesUnique(skillNames = [], options = {}) {
+ const names = createUniqueSkillNames(skillNames);
+ if (names.length === 0) return;
+
+ if (
+ names.length !==
+ skillNames.map((item) => String(item || '').trim()).filter(Boolean).length
+ ) {
+ this.ctx.throw(400, '导入失败:技能名称不能重复');
+ }
+
+ const { SkillsItem } = this.app.model;
+ const { Op } = this.app.Sequelize;
+ const where = {
+ is_delete: 0,
+ name: {
+ [Op.in]: names,
+ },
+ };
+
+ if (options.excludeSkillId) {
+ where.id = {
+ [Op.ne]: options.excludeSkillId,
+ };
+ }
+
+ if (options.excludeSourceId) {
+ where.source_id = {
+ [Op.ne]: options.excludeSourceId,
+ };
+ }
+
+ const existing = await SkillsItem.findOne({
+ where,
+ attributes: ['id', 'name'],
+ transaction: options.transaction,
+ });
+
+ if (existing) {
+ this.ctx.throw(400, `技能名称“${existing.name}”已存在,请更换名称`);
+ }
+ }
+
+ async importSkill(params = {}) {
+ const source = String(params.source || '').trim();
+ const skillName = String(params.skillName || '').trim();
+ const category = this.normalizeCategory(params.category);
+ const tags = this.normalizePlatformTags(params.tags);
+
+ if (!source) {
+ this.ctx.throw(400, '缺少导入来源地址');
+ }
+
+ await this.ensureStorageReady();
+
+ let parsedSource = null;
+ let sourceRecord = null;
+ let tempDir = '';
+
+ try {
+ parsedSource = await this.parseImportSource(source);
+ sourceRecord = await this.upsertSourceRecord(parsedSource, 'syncing');
+
+ if (parsedSource.sourceType !== 'local') {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-import-'));
+ await this.cloneSourceRepo(parsedSource, tempDir);
+ }
+
+ const repoDir = parsedSource.sourceType === 'local' ? parsedSource.cloneUrl : tempDir;
+ const selectedPath = this.resolveSelectedPath(repoDir, parsedSource.subpath);
+ const discoveredSkillDirs = this.discoverSkillDirs(selectedPath);
+
+ if (discoveredSkillDirs.length === 0) {
+ this.ctx.throw(400, '未在来源中发现技能(SKILL.md)');
+ }
+
+ let skillRecords = discoveredSkillDirs.map((skillDir) =>
+ this.prepareSkillRecord(skillDir, repoDir, parsedSource, category, tags)
+ );
+
+ skillRecords = this.filterSkillByName(skillRecords, skillName);
+ if (skillRecords.length === 0) {
+ this.ctx.throw(400, '未匹配到指定技能,请检查 skillName 是否正确');
+ }
+
+ await this.assertSkillNamesUnique(
+ skillRecords.map((item) => item.name),
+ { excludeSourceId: sourceRecord.id }
+ );
+
+ const importedSkills = await this.persistSkillsForSource(
+ sourceRecord.id,
+ parsedSource,
+ skillRecords
+ );
+
+ await sourceRecord.update({
+ sync_status: 'idle',
+ sync_error: '',
+ last_synced_at: new Date(),
+ });
+
+ this.invalidateCache();
+ await this.ensureSkillCache();
+
+ return {
+ source,
+ skillName,
+ category,
+ tags,
+ importedCount: importedSkills.length,
+ refreshedCount: importedSkills.length,
+ importedSkills: importedSkills.map((item) => ({
+ slug: item.slug,
+ name: item.name,
+ sourceRepo: item.sourceRepo,
+ sourcePath: item.sourcePath,
+ })),
+ };
+ } catch (error) {
+ if (sourceRecord) {
+ try {
+ await sourceRecord.update({
+ sync_status: 'failed',
+ sync_error: String(error.message || error),
+ });
+ } catch (updateError) {
+ this.ctx.logger.warn(`[skills] 写入同步失败状态异常: ${updateError.message}`);
+ }
+ }
+
+ if (error.status && error.status >= 400 && error.status < 600) {
+ throw error;
+ }
+ this.ctx.throw(500, `导入失败: ${error.message}`);
+ } finally {
+ if (tempDir && fs.existsSync(tempDir)) {
+ try {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ } catch (error) {
+ this.ctx.logger.warn(`[skills] 清理临时目录失败: ${tempDir}, ${error.message}`);
+ }
+ }
+ }
+ }
+
+ async importSkillFile(params = {}, file) {
+ const skillName = String(params.skillName || '').trim();
+ const category = this.normalizeCategory(params.category);
+ const tags = this.normalizePlatformTags(params.tags);
+ const fileName = String((file && file.filename) || '').trim();
+ const filePath = String((file && file.filepath) || '').trim();
+
+ if (!fileName || !filePath) {
+ this.ctx.throw(400, '上传文件无效');
+ }
+ if (!/\.zip$/i.test(fileName)) {
+ this.ctx.throw(400, '仅支持上传 .zip 文件');
+ }
+ if (!fs.existsSync(filePath)) {
+ this.ctx.throw(400, '上传文件不存在或已失效');
+ }
+
+ await this.ensureStorageReady();
+
+ let sourceRecord = null;
+ let tempDir = '';
+
+ try {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-upload-'));
+
+ try {
+ const zip = new AdmZip(filePath);
+ zip.extractAllTo(tempDir, true);
+ } catch (error) {
+ this.ctx.throw(400, `解析 .zip 文件失败: ${error.message}`);
+ }
+
+ const discoveredSkillDirs = this.discoverSkillDirs(tempDir);
+ if (discoveredSkillDirs.length === 0) {
+ this.ctx.throw(400, '.zip 包内未发现有效技能(缺少 SKILL.md)');
+ }
+
+ if (skillName && discoveredSkillDirs.length !== 1) {
+ this.ctx.throw(400, '填写技能名称时,.zip 包必须且只能包含一个技能目录');
+ }
+
+ const parsedSource = this.buildUploadSourceMeta(fileName, skillName);
+
+ const skillRecords = discoveredSkillDirs.map((skillDir) => {
+ const record = this.prepareSkillRecord(
+ skillDir,
+ tempDir,
+ parsedSource,
+ category,
+ tags
+ );
+ if (skillName) {
+ record.name = skillName;
+ }
+ return record;
+ });
+
+ await this.assertSkillNamesUnique(skillRecords.map((item) => item.name));
+
+ const uploadSourceMeta = this.buildUploadSourceMeta(
+ fileName,
+ this.getUploadIdentityKey(skillRecords, skillName)
+ );
+ sourceRecord = await this.upsertSourceRecord(uploadSourceMeta, 'syncing');
+
+ const importedSkills = await this.persistSkillsForSource(
+ sourceRecord.id,
+ uploadSourceMeta,
+ skillRecords
+ );
+
+ await sourceRecord.update({
+ sync_status: 'idle',
+ sync_error: '',
+ last_synced_at: new Date(),
+ });
+
+ this.invalidateCache();
+ await this.ensureSkillCache();
+
+ return {
+ source: parsedSource.sourceUrl,
+ skillName,
+ category,
+ tags,
+ importedCount: importedSkills.length,
+ refreshedCount: importedSkills.length,
+ importedSkills: importedSkills.map((item) => ({
+ slug: item.slug,
+ name: item.name,
+ sourceRepo: item.sourceRepo,
+ sourcePath: item.sourcePath,
+ })),
+ };
+ } catch (error) {
+ if (sourceRecord) {
+ try {
+ await sourceRecord.update({
+ sync_status: 'failed',
+ sync_error: String(error.message || error),
+ });
+ } catch (updateError) {
+ this.ctx.logger.warn(`[skills] 写入上传失败状态异常: ${updateError.message}`);
+ }
+ }
+
+ if (error.status && error.status >= 400 && error.status < 600) {
+ throw error;
+ }
+ this.ctx.throw(500, `导入失败: ${error.message}`);
+ } finally {
+ if (tempDir && fs.existsSync(tempDir)) {
+ try {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ } catch (error) {
+ this.ctx.logger.warn(
+ `[skills] 清理上传解压目录失败: ${tempDir}, ${error.message}`
+ );
+ }
+ }
+ }
+ }
+
+ extractSkillRecordsFromZip(filePath, parsedSource, category, tags, skillName = '') {
+ let tempDir = '';
+
+ try {
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-upload-'));
+
+ try {
+ const zip = new AdmZip(filePath);
+ zip.extractAllTo(tempDir, true);
+ } catch (error) {
+ this.ctx.throw(400, `解析 .zip 文件失败: ${error.message}`);
+ }
+
+ const discoveredSkillDirs = this.discoverSkillDirs(tempDir);
+ if (discoveredSkillDirs.length === 0) {
+ this.ctx.throw(400, '.zip 包内未发现有效技能(缺少 SKILL.md)');
+ }
+
+ return discoveredSkillDirs.map((skillDir) => {
+ const record = this.prepareSkillRecord(
+ skillDir,
+ tempDir,
+ parsedSource,
+ category,
+ tags
+ );
+ if (skillName) {
+ record.name = skillName;
+ }
+ return record;
+ });
+ } finally {
+ if (tempDir && fs.existsSync(tempDir)) {
+ try {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ } catch (error) {
+ this.ctx.logger.warn(
+ `[skills] 清理上传解压目录失败: ${tempDir}, ${error.message}`
+ );
+ }
+ }
+ }
+ }
+
+ validateVersion(value) {
+ return String(value || '')
+ .trim()
+ .slice(0, 128);
+ }
+
+ async updateSkill(params = {}, file) {
+ const slug = this.validateSkillIdentifier(params.slug);
+ const name = String(params.name || '').trim();
+ const category = this.normalizeCategory(params.category);
+ const version = this.validateVersion(params.version);
+ const tags = this.normalizePlatformTags(params.tags);
+
+ await this.ensureSkillCache();
+ const currentSkill = this.getSkillByIdentifier(slug);
+ await this.ensureStorageReady();
+
+ if (!name) {
+ this.ctx.throw(400, '技能名称不能为空');
+ }
+
+ const hasZipUpload = Boolean(file?.filename && file?.filepath);
+ if (hasZipUpload) {
+ if (!/\.zip$/i.test(String(file.filename || ''))) {
+ this.ctx.throw(400, '仅支持上传 .zip 文件');
+ }
+ if (!fs.existsSync(String(file.filepath || ''))) {
+ this.ctx.throw(400, '上传文件不存在或已失效');
+ }
+ }
+
+ const { SkillsItem, SkillsFile } = this.app.model;
+
+ const result = await this.app.model.transaction(async (transaction) => {
+ const itemRow = await SkillsItem.findOne({
+ where: {
+ id: currentSkill.id,
+ is_delete: 0,
+ },
+ transaction,
+ });
+
+ if (!itemRow) {
+ this.ctx.throw(404, '技能不存在');
+ }
+
+ await this.assertSkillNamesUnique([name], {
+ excludeSkillId: itemRow.id,
+ transaction,
+ });
+
+ const payload = {
+ name,
+ category,
+ version,
+ tags: JSON.stringify(tags || []),
+ };
+
+ if (!hasZipUpload) {
+ await itemRow.update(payload, { transaction });
+ return {
+ slug,
+ updated: true,
+ replacedArchive: false,
+ };
+ }
+
+ const parsedSource = this.buildUploadSourceMeta(file.filename);
+ const skillRecords = this.extractSkillRecordsFromZip(
+ file.filepath,
+ parsedSource,
+ category,
+ tags.length > 0 ? tags : currentSkill.tags || [],
+ name
+ );
+
+ if (skillRecords.length !== 1) {
+ this.ctx.throw(400, '编辑时上传的 .zip 包必须且只能包含一个技能目录');
+ }
+
+ const nextRecord = skillRecords[0];
+ nextRecord.name = name;
+ nextRecord.category = category;
+ nextRecord.version = version;
+ nextRecord.sourceRepo = currentSkill.sourceRepo || nextRecord.sourceRepo;
+ nextRecord.sourcePath = currentSkill.sourcePath || nextRecord.sourcePath;
+ nextRecord.tags = tags.length > 0 ? tags : currentSkill.tags || [];
+ nextRecord.installCommand =
+ currentSkill.installCommand ||
+ this.getInstallCommand({
+ sourceRepo: nextRecord.sourceRepo,
+ sourceUrl: parsedSource.sourceUrl,
+ name,
+ });
+
+ await itemRow.update(
+ {
+ ...payload,
+ description: nextRecord.description,
+ allowed_tools: JSON.stringify(nextRecord.allowedTools || []),
+ updated_at_remote: nextRecord.updatedAt,
+ source_repo: nextRecord.sourceRepo,
+ source_path: nextRecord.sourcePath,
+ skill_md: nextRecord.skillMd,
+ install_command: nextRecord.installCommand,
+ file_count: nextRecord.files.length,
+ },
+ { transaction }
+ );
+
+ await SkillsFile.destroy({
+ where: {
+ skill_id: itemRow.id,
+ },
+ transaction,
+ });
+
+ if (nextRecord.files.length > 0) {
+ await SkillsFile.bulkCreate(
+ nextRecord.files.map((fileItem) => ({
+ skill_id: itemRow.id,
+ file_path: fileItem.filePath,
+ language: fileItem.language,
+ size: fileItem.size,
+ is_binary: fileItem.isBinary ? 1 : 0,
+ encoding: fileItem.encoding,
+ content: fileItem.content,
+ updated_at_remote: fileItem.updatedAt,
+ is_delete: 0,
+ })),
+ { transaction }
+ );
+ }
+
+ return {
+ slug,
+ updated: true,
+ replacedArchive: true,
+ };
+ });
+
+ this.invalidateCache();
+ await this.ensureSkillCache();
+ return result;
+ }
+
+ async deleteSkill(params = {}) {
+ const slug = this.validateSkillIdentifier(params.slug);
+ await this.ensureSkillCache();
+ const skill = this.getSkillByIdentifier(slug);
+ await this.ensureStorageReady();
+
+ const { SkillsItem, SkillsFile } = this.app.model;
+
+ await this.app.model.transaction(async (transaction) => {
+ await SkillsItem.update(
+ {
+ is_delete: 1,
+ },
+ {
+ where: {
+ id: skill.id,
+ },
+ transaction,
+ }
+ );
+
+ await SkillsFile.update(
+ {
+ is_delete: 1,
+ },
+ {
+ where: {
+ skill_id: skill.id,
+ },
+ transaction,
+ }
+ );
+ });
+
+ this.invalidateCache();
+ await this.ensureSkillCache();
+
+ return {
+ slug,
+ deleted: true,
+ };
+ }
+
+ async persistSkillsForSource(sourceId, sourceMeta, skillRecords = []) {
+ const { SkillsItem, SkillsFile } = this.app.model;
+ const { Op } = this.app.Sequelize;
+ const repoStars = await this.fetchStarsBySourceRepo(sourceMeta.sourceRepo);
+
+ return await this.app.model.transaction(async (transaction) => {
+ const oldRows = await SkillsItem.findAll({
+ where: {
+ source_id: sourceId,
+ is_delete: 0,
+ },
+ attributes: ['id', 'slug', 'stars'],
+ order: [
+ ['stars', 'DESC'],
+ ['id', 'DESC'],
+ ],
+ transaction,
+ });
+
+ const fallbackStars = oldRows[0] ? Number(oldRows[0].stars) || 0 : 0;
+ const resolvedStars =
+ typeof repoStars === 'number' && Number.isFinite(repoStars) && repoStars >= 0
+ ? repoStars
+ : fallbackStars;
+ const oldRowMap = new Map(oldRows.map((item) => [item.slug, item]));
+
+ const usedSlugs = new Set();
+ const createdSkills = [];
+
+ for (const record of skillRecords) {
+ const slug = this.buildSkillSlug(
+ sourceMeta,
+ record.sourcePath,
+ record.name,
+ usedSlugs
+ );
+ const payload = {
+ source_id: sourceId,
+ slug,
+ name: record.name,
+ description: record.description,
+ category: record.category,
+ version: record.version || '',
+ tags: JSON.stringify(record.tags || []),
+ allowed_tools: JSON.stringify(record.allowedTools || []),
+ stars: resolvedStars,
+ updated_at_remote: record.updatedAt,
+ source_repo: record.sourceRepo,
+ source_path: record.sourcePath,
+ skill_md: record.skillMd,
+ install_command: record.installCommand,
+ file_count: record.files.length,
+ is_delete: 0,
+ };
+ const existingRow = oldRowMap.get(slug);
+
+ let itemRow = existingRow;
+ if (existingRow) {
+ await existingRow.update(payload, { transaction });
+ oldRowMap.delete(slug);
+ } else {
+ itemRow = await SkillsItem.create(payload, { transaction });
+ }
+
+ await SkillsFile.destroy({
+ where: {
+ skill_id: itemRow.id,
+ },
+ transaction,
+ });
+
+ if (record.files.length > 0) {
+ const fileRows = record.files.map((fileItem) => ({
+ skill_id: itemRow.id,
+ file_path: fileItem.filePath,
+ language: fileItem.language,
+ size: fileItem.size,
+ is_binary: fileItem.isBinary ? 1 : 0,
+ encoding: fileItem.encoding,
+ content: fileItem.content,
+ updated_at_remote: fileItem.updatedAt,
+ is_delete: 0,
+ }));
+
+ await SkillsFile.bulkCreate(fileRows, { transaction });
+ }
+
+ createdSkills.push({
+ slug,
+ name: record.name,
+ sourceRepo: record.sourceRepo,
+ sourcePath: record.sourcePath,
+ });
+ }
+
+ const removedIds = Array.from(oldRowMap.values()).map((item) => item.id);
+ if (removedIds.length > 0) {
+ await SkillsFile.destroy({
+ where: {
+ skill_id: {
+ [Op.in]: removedIds,
+ },
+ },
+ transaction,
+ });
+ await SkillsItem.update(
+ {
+ is_delete: 1,
+ },
+ {
+ where: {
+ id: {
+ [Op.in]: removedIds,
+ },
+ },
+ transaction,
+ }
+ );
+ }
+
+ return createdSkills;
+ });
+ }
+
+ async fetchStarsBySourceRepo(sourceRepo = '') {
+ const repoFullName = this.extractGitHubRepoFullName(sourceRepo);
+ if (!repoFullName) return null;
+ return await this.fetchGitHubRepoStars(repoFullName);
+ }
+
+ extractGitHubRepoFullName(sourceRepo = '') {
+ const raw = String(sourceRepo || '').trim();
+ if (!raw) return '';
+ const normalized = raw.replace(/^git\+/, '').replace(/\.git$/, '');
+ const sshMatch = normalized.match(/^git@github\.com:([^/]+)\/([^/]+)$/i);
+ if (sshMatch) {
+ return `${sshMatch[1]}/${sshMatch[2]}`;
+ }
+ const httpsMatch = normalized.match(/^https?:\/\/github\.com\/([^/]+)\/([^/#?]+)/i);
+ if (httpsMatch) {
+ return `${httpsMatch[1]}/${httpsMatch[2]}`;
+ }
+ return '';
+ }
+
+ parseCompactNumber(input) {
+ const raw = String(input || '')
+ .trim()
+ .replace(/,/g, '')
+ .toLowerCase();
+ if (!raw) return null;
+
+ const match = raw.match(/^(\d+(?:\.\d+)?)\s*([kmb])?$/i);
+ if (!match) return null;
+
+ const value = Number(match[1]);
+ if (!Number.isFinite(value)) return null;
+
+ const suffix = (match[2] || '').toLowerCase();
+ if (!suffix) return Math.round(value);
+ if (suffix === 'k') return Math.round(value * 1000);
+ if (suffix === 'm') return Math.round(value * 1000 * 1000);
+ if (suffix === 'b') return Math.round(value * 1000 * 1000 * 1000);
+ return null;
+ }
+
+ extractStarsFromGitHubHtml(html = '') {
+ const content = String(html || '');
+ if (!content) return null;
+
+ const titleMatch = content.match(/id="repo-stars-counter-star"[^>]*title="([^"]+)"/i);
+ if (titleMatch) {
+ const stars = this.parseCompactNumber(titleMatch[1]);
+ if (typeof stars === 'number' && Number.isFinite(stars) && stars >= 0) return stars;
+ }
+
+ const ariaMatch = content.match(/id="repo-stars-counter-star"[^>]*aria-label="([^"]+)"/i);
+ if (ariaMatch) {
+ const numberLike = ariaMatch[1].match(/[\d,.]+\s*[kmb]?/i);
+ if (numberLike) {
+ const stars = this.parseCompactNumber(numberLike[0]);
+ if (typeof stars === 'number' && Number.isFinite(stars) && stars >= 0) return stars;
+ }
+ }
+
+ const textMatch = content.match(/id="repo-stars-counter-star"[^>]*>([^<]+)= 0) return stars;
+ }
+
+ return null;
+ }
+
+ async fetchGitHubRepoStarsFromHtml(repoFullName) {
+ const url = `https://github.com/${repoFullName}`;
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'User-Agent': 'doraemon-skills-market',
+ Accept: 'text/html',
+ },
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ this.ctx.logger.warn(
+ `[skills] HTML兜底获取 stars 失败: ${repoFullName}, status=${response.status}`
+ );
+ return null;
+ }
+
+ const html = await response.text();
+ return this.extractStarsFromGitHubHtml(html);
+ } catch (error) {
+ this.ctx.logger.warn(
+ `[skills] HTML兜底获取 stars 异常: ${repoFullName}, ${error.message}`
+ );
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
+ async fetchGitHubRepoStars(repoFullName) {
+ if (!repoFullName) return null;
+
+ const url = `https://api.github.com/repos/${repoFullName}`;
+ const headers = {
+ Accept: 'application/vnd.github+json',
+ 'User-Agent': 'doraemon-skills-market',
+ };
+
+ const token = this.resolveGitHubToken();
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT_MS);
+
+ try {
+ const response = await fetch(url, {
+ method: 'GET',
+ headers,
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ this.ctx.logger.warn(
+ `[skills] 获取 GitHub stars 失败: ${repoFullName}, status=${response.status}`
+ );
+ if (response.status === 403 || response.status === 429) {
+ return await this.fetchGitHubRepoStarsFromHtml(repoFullName);
+ }
+ return null;
+ }
+
+ const data = await response.json();
+ const stars = Number(data.stargazers_count);
+ if (!Number.isFinite(stars) || stars < 0) return null;
+ return stars;
+ } catch (error) {
+ this.ctx.logger.warn(
+ `[skills] 获取 GitHub stars 异常: ${repoFullName}, ${error.message}`
+ );
+ return null;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+
+ extractHostFromRemote(remoteUrl = '') {
+ const value = String(remoteUrl || '').trim();
+ if (!value) return '';
+ try {
+ const target = new URL(value);
+ return String(target.hostname || '').toLowerCase();
+ } catch (error) {
+ return '';
+ }
+ }
+
+ isPrivateNetworkHost(host = '') {
+ const value = String(host || '').toLowerCase();
+ if (!value) return false;
+ if (value === 'localhost' || value.endsWith('.local')) return true;
+
+ const ipv4Match = value.match(/^(\d{1,3})(\.\d{1,3}){3}$/);
+ if (!ipv4Match) return false;
+
+ const segments = value.split('.').map((item) => parseInt(item, 10));
+ if (segments.some((item) => Number.isNaN(item) || item < 0 || item > 255)) {
+ return false;
+ }
+
+ if (segments[0] === 10) return true;
+ if (segments[0] === 127) return true;
+ if (segments[0] === 192 && segments[1] === 168) return true;
+ if (segments[0] === 172 && segments[1] >= 16 && segments[1] <= 31) return true;
+ return false;
+ }
+
+ shouldBypassProxyForHost(host = '') {
+ const value = String(host || '').toLowerCase();
+ if (!value) return false;
+ if (value.endsWith('.dtstack.cn')) return true;
+ return this.isPrivateNetworkHost(value);
+ }
+
+ appendNoProxyHost(rawNoProxy = '', host = '') {
+ const value = String(host || '').toLowerCase();
+ if (!value) return String(rawNoProxy || '').trim();
+
+ const entries = String(rawNoProxy || '')
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean);
+ const set = new Set(entries);
+
+ set.add(value);
+ set.add('localhost');
+ set.add('127.0.0.1');
+ if (value.includes('.')) {
+ const parts = value.split('.');
+ if (parts.length >= 2) {
+ set.add(`.${parts.slice(-2).join('.')}`);
+ }
+ }
+
+ return Array.from(set).join(',');
+ }
+
+ buildCommandEnv({ remoteUrl = '' } = {}) {
+ const env = { ...process.env };
+ const host = this.extractHostFromRemote(remoteUrl);
+ if (!host || !this.shouldBypassProxyForHost(host)) {
+ return env;
+ }
+
+ [
+ 'http_proxy',
+ 'https_proxy',
+ 'HTTP_PROXY',
+ 'HTTPS_PROXY',
+ 'all_proxy',
+ 'ALL_PROXY',
+ ].forEach((key) => {
+ delete env[key];
+ });
+
+ const noProxyValue = this.appendNoProxyHost(env.NO_PROXY || env.no_proxy || '', host);
+ env.NO_PROXY = noProxyValue;
+ env.no_proxy = noProxyValue;
+ return env;
+ }
+
+ resolveGitlabToken() {
+ const token = this.getSkillsConfig().gitlabToken;
+ return String(token || '').trim();
+ }
+
+ resolveGitlabHostWhitelist() {
+ const list = this.getSkillsConfig().gitlabHostWhitelist;
+ if (!Array.isArray(list)) return [];
+ return list
+ .map((item) =>
+ String(item || '')
+ .trim()
+ .toLowerCase()
+ )
+ .filter(Boolean);
+ }
+
+ getGitAuthPrefixArgs(remoteUrl = '') {
+ const host = this.extractHostFromRemote(remoteUrl);
+ const whitelist = this.resolveGitlabHostWhitelist();
+ if (!host || !whitelist.includes(host)) {
+ return [];
+ }
+
+ const token = this.resolveGitlabToken();
+ if (!token) {
+ return [];
+ }
+
+ const basicToken = Buffer.from(`oauth2:${token}`).toString('base64');
+ return ['-c', `http.https://${host}/.extraHeader=Authorization: Basic ${basicToken}`];
+ }
+
+ runCommand(
+ command,
+ args = [],
+ timeout = GIT_COMMAND_TIMEOUT_MS,
+ cwd = process.cwd(),
+ env = process.env
+ ) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, {
+ cwd,
+ env,
+ });
+
+ let stdout = '';
+ let stderr = '';
+ let timedOut = false;
+
+ const timer = setTimeout(() => {
+ timedOut = true;
+ child.kill('SIGTERM');
+ }, timeout);
+
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk.toString();
+ });
+
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk.toString();
+ });
+
+ child.on('error', (error) => {
+ clearTimeout(timer);
+ reject(error);
+ });
+
+ child.on('close', (code) => {
+ clearTimeout(timer);
+
+ if (timedOut) {
+ reject(new Error(`命令执行超时(${timeout}ms): ${command}`));
+ return;
+ }
+
+ if (code !== 0) {
+ const detail = this.trimCommandOutput(stderr || stdout);
+ reject(new Error(detail || `命令退出码: ${code}`));
+ return;
+ }
+
+ resolve({ stdout, stderr });
+ });
+ });
+ }
+
+ trimCommandOutput(content = '') {
+ const value = String(content || '').trim();
+ if (!value) return '';
+ const maxLength = 3000;
+ if (value.length <= maxLength) return value;
+ return value.slice(value.length - maxLength);
+ }
+}
+
+module.exports = SkillsService;
+module.exports.createInstallKeyMap = createInstallKeyMap;
+module.exports.resolveSkillIdentifier = resolveSkillIdentifier;
+module.exports.createUniqueSkillNames = createUniqueSkillNames;
diff --git a/app/web/api/index.ts b/app/web/api/index.ts
index e03e923..f3353af 100644
--- a/app/web/api/index.ts
+++ b/app/web/api/index.ts
@@ -9,8 +9,13 @@ function mapUrlObjToFuncObj(urlObj: any) {
const item = urlObj[key];
URL[key] = item.url;
API[key] = async function (params: any) {
+ const rawMethod = String(item.method || 'get');
+ const methodName =
+ typeof (http as any)[rawMethod] === 'function'
+ ? rawMethod
+ : rawMethod.toLowerCase();
// eslint-disable-next-line no-return-await
- return await http[item.method.toLowerCase()](item.url, params);
+ return await (http as any)[methodName](item.url, params);
};
});
return { API, URL };
diff --git a/app/web/api/url.ts b/app/web/api/url.ts
index 1f6cb9b..1c7986d 100644
--- a/app/web/api/url.ts
+++ b/app/web/api/url.ts
@@ -337,4 +337,68 @@ export default {
method: 'post',
url: '/api/mcp-servers/sync-info',
},
+
+ /**
+ * Skills 市场
+ */
+ // 获取 Skills 列表
+ getSkillList: {
+ method: 'get',
+ url: '/api/skills/list',
+ },
+ // 获取 Skill 详情
+ getSkillDetail: {
+ method: 'get',
+ url: '/api/skills/detail',
+ },
+ // 获取相关 Skills
+ getRelatedSkills: {
+ method: 'get',
+ url: '/api/skills/related',
+ },
+ // 获取 Skill 文件内容
+ getSkillFileContent: {
+ method: 'get',
+ url: '/api/skills/file-content',
+ },
+ // 获取 Skill 安装元信息
+ getSkillInstallMeta: {
+ method: 'get',
+ url: '/api/skills/install-meta',
+ },
+ // 下载 Skill 目录压缩包
+ downloadSkillArchive: {
+ method: 'get',
+ url: '/api/skills/download',
+ },
+ // 上传 .zip 文件导入
+ importSkillFile: {
+ method: 'postForm',
+ url: '/api/skills/import-file',
+ },
+ // 编辑 Skill
+ updateSkill: {
+ method: 'postForm',
+ url: '/api/skills/update',
+ },
+ // 删除 Skill
+ deleteSkill: {
+ method: 'post',
+ url: '/api/skills/delete',
+ },
+ // 点赞
+ likeSkill: {
+ method: 'post',
+ url: '/api/skills/like',
+ },
+ // 取消点赞
+ unlikeSkill: {
+ method: 'post',
+ url: '/api/skills/unlike',
+ },
+ // 获取点赞状态
+ getSkillLikeStatus: {
+ method: 'get',
+ url: '/api/skills/like-status',
+ },
};
diff --git a/app/web/asset/font/material-symbols/inter-latin.woff2 b/app/web/asset/font/material-symbols/inter-latin.woff2
new file mode 100644
index 0000000..91dc3e8
Binary files /dev/null and b/app/web/asset/font/material-symbols/inter-latin.woff2 differ
diff --git a/app/web/asset/font/material-symbols/manrope-latin.woff2 b/app/web/asset/font/material-symbols/manrope-latin.woff2
new file mode 100644
index 0000000..9d7abb1
Binary files /dev/null and b/app/web/asset/font/material-symbols/manrope-latin.woff2 differ
diff --git a/app/web/asset/font/material-symbols/material-symbols-outlined-subset.woff2 b/app/web/asset/font/material-symbols/material-symbols-outlined-subset.woff2
new file mode 100644
index 0000000..1abf51e
Binary files /dev/null and b/app/web/asset/font/material-symbols/material-symbols-outlined-subset.woff2 differ
diff --git a/app/web/asset/font/material-symbols/material-symbols-outlined-variable.woff2 b/app/web/asset/font/material-symbols/material-symbols-outlined-variable.woff2
new file mode 100644
index 0000000..d2437dd
--- /dev/null
+++ b/app/web/asset/font/material-symbols/material-symbols-outlined-variable.woff2
@@ -0,0 +1 @@
+Couldn't find the requested file /files/material-symbols-outlined-variable.woff2 in @fontsource/material-symbols-outlined.
\ No newline at end of file
diff --git a/app/web/asset/font/material-symbols/material-symbols-outlined.woff2 b/app/web/asset/font/material-symbols/material-symbols-outlined.woff2
new file mode 100644
index 0000000..1da1d47
--- /dev/null
+++ b/app/web/asset/font/material-symbols/material-symbols-outlined.woff2
@@ -0,0 +1,11 @@
+
+
+
+
+
Error 404 (Not Found)!!1
+
+
+ 404. That’s an error.
+
The requested URL /s/materialsymbolsoutlined/v36/syl0-zNym6YjUgnMfxKof8KX6U8S8bf9IH0Q.woff2 was not found on this server. That’s all we know.
diff --git a/app/web/asset/images/skills-detail-figma/agent.svg b/app/web/asset/images/skills-detail-figma/agent.svg
new file mode 100644
index 0000000..6e87e61
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/agent.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/article.svg b/app/web/asset/images/skills-detail-figma/article.svg
new file mode 100644
index 0000000..4ba9b73
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/article.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/check-circle.svg b/app/web/asset/images/skills-detail-figma/check-circle.svg
new file mode 100644
index 0000000..f55efdf
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/check-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/chevron-down.svg b/app/web/asset/images/skills-detail-figma/chevron-down.svg
new file mode 100644
index 0000000..a6d357e
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/chevron-down.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/chevron-right.svg b/app/web/asset/images/skills-detail-figma/chevron-right.svg
new file mode 100644
index 0000000..79a120a
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/chevron-right.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/contributor-1.png b/app/web/asset/images/skills-detail-figma/contributor-1.png
new file mode 100644
index 0000000..f2ced18
Binary files /dev/null and b/app/web/asset/images/skills-detail-figma/contributor-1.png differ
diff --git a/app/web/asset/images/skills-detail-figma/contributor-2.png b/app/web/asset/images/skills-detail-figma/contributor-2.png
new file mode 100644
index 0000000..695931f
Binary files /dev/null and b/app/web/asset/images/skills-detail-figma/contributor-2.png differ
diff --git a/app/web/asset/images/skills-detail-figma/copy-dark.svg b/app/web/asset/images/skills-detail-figma/copy-dark.svg
new file mode 100644
index 0000000..5047be1
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/copy-dark.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/copy-light.svg b/app/web/asset/images/skills-detail-figma/copy-light.svg
new file mode 100644
index 0000000..99b4a20
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/copy-light.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/download.svg b/app/web/asset/images/skills-detail-figma/download.svg
new file mode 100644
index 0000000..a294100
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/download.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/empty-related.svg b/app/web/asset/images/skills-detail-figma/empty-related.svg
new file mode 100644
index 0000000..782d86d
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/empty-related.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/external-link-xs.svg b/app/web/asset/images/skills-detail-figma/external-link-xs.svg
new file mode 100644
index 0000000..90f17ef
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/external-link-xs.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/file-doc.svg b/app/web/asset/images/skills-detail-figma/file-doc.svg
new file mode 100644
index 0000000..1cbed43
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/file-doc.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/folder-open-arrow.svg b/app/web/asset/images/skills-detail-figma/folder-open-arrow.svg
new file mode 100644
index 0000000..a393de4
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/folder-open-arrow.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/folder-open-blue.svg b/app/web/asset/images/skills-detail-figma/folder-open-blue.svg
new file mode 100644
index 0000000..01b59ac
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/folder-open-blue.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/fork.svg b/app/web/asset/images/skills-detail-figma/fork.svg
new file mode 100644
index 0000000..3d7fb8b
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/fork.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/hero-skill.svg b/app/web/asset/images/skills-detail-figma/hero-skill.svg
new file mode 100644
index 0000000..a27e658
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/hero-skill.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/human.svg b/app/web/asset/images/skills-detail-figma/human.svg
new file mode 100644
index 0000000..475e7c7
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/human.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/related-skill-docs.svg b/app/web/asset/images/skills-detail-figma/related-skill-docs.svg
new file mode 100644
index 0000000..8abff9b
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/related-skill-docs.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/related-skill-security.svg b/app/web/asset/images/skills-detail-figma/related-skill-security.svg
new file mode 100644
index 0000000..06bc84c
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/related-skill-security.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/related-skill-sql.svg b/app/web/asset/images/skills-detail-figma/related-skill-sql.svg
new file mode 100644
index 0000000..9a4c7f8
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/related-skill-sql.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/web/asset/images/skills-detail-figma/star-outline.svg b/app/web/asset/images/skills-detail-figma/star-outline.svg
new file mode 100644
index 0000000..d8fa2bf
--- /dev/null
+++ b/app/web/asset/images/skills-detail-figma/star-outline.svg
@@ -0,0 +1,5 @@
+
diff --git a/app/web/layouts/basicLayout/index.tsx b/app/web/layouts/basicLayout/index.tsx
index 068cb99..1e79f7f 100644
--- a/app/web/layouts/basicLayout/index.tsx
+++ b/app/web/layouts/basicLayout/index.tsx
@@ -11,6 +11,7 @@ const { Content } = Layout;
const BasicLayout = (props: any) => {
const { className, route, location } = props;
const { pathname } = location;
+ const isSkillDetailPage = /^\/page\/skills\/[^/]+$/.test(pathname);
// 如果弹出过哆啦A梦 Chrome 插件的弹框,则后续不再弹出
React.useEffect(() => {
@@ -39,7 +40,11 @@ const BasicLayout = (props: any) => {
}, [pathname]);
return (
-
+
{renderRoutes(route.routes)}
diff --git a/app/web/layouts/basicLayout/style.scss b/app/web/layouts/basicLayout/style.scss
index 9e466b6..b4ac03e 100644
--- a/app/web/layouts/basicLayout/style.scss
+++ b/app/web/layouts/basicLayout/style.scss
@@ -1,6 +1,7 @@
.layout-basic {
height: 100vh;
padding-top: 64px;
+ box-sizing: border-box;
.logo_img {
width: 30px;
height: 30px;
@@ -28,4 +29,19 @@
width: 100%;
overflow: auto;
}
+ &.is-skill-detail-layout {
+ overflow: hidden;
+ .main-content {
+ flex: 1;
+ height: 0;
+ min-height: 0;
+ background: #F2F7FA;
+ overflow: hidden;
+ }
+ .context_container {
+ height: 100%;
+ padding: 0;
+ overflow: hidden;
+ }
+ }
}
diff --git a/app/web/layouts/header/header.tsx b/app/web/layouts/header/header.tsx
index 7e200f6..41849e1 100644
--- a/app/web/layouts/header/header.tsx
+++ b/app/web/layouts/header/header.tsx
@@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import {
AppstoreOutlined,
+ BookOutlined,
CloudOutlined,
CloudServerOutlined,
DesktopOutlined,
@@ -49,6 +50,12 @@ const navMenuList: any = [
'mcp-server-inspector',
],
},
+ {
+ name: 'Skills',
+ path: '/page/skills',
+ icon: ,
+ routers: ['skills'],
+ },
{
name: '主机管理',
path: '/page/host-management',
diff --git a/app/web/pages/skills/detail/SkillDetailContent.tsx b/app/web/pages/skills/detail/SkillDetailContent.tsx
new file mode 100644
index 0000000..691387c
--- /dev/null
+++ b/app/web/pages/skills/detail/SkillDetailContent.tsx
@@ -0,0 +1,969 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import SyntaxHighlighter from 'react-syntax-highlighter';
+import { atomOneLight } from 'react-syntax-highlighter/dist/cjs/styles/hljs';
+import {
+ ArrowLeftOutlined,
+ LikeOutlined,
+ QuestionCircleOutlined,
+ StarOutlined,
+} from '@ant-design/icons';
+import { Button, Empty, Spin, Tree, Typography } from 'antd';
+import type { DataNode } from 'antd/lib/tree';
+
+import { API } from '@/api';
+import agentIcon from '@/asset/images/skills-detail-figma/agent.svg';
+import chevronDownIcon from '@/asset/images/skills-detail-figma/chevron-down.svg';
+import chevronRightIcon from '@/asset/images/skills-detail-figma/chevron-right.svg';
+import contributorOne from '@/asset/images/skills-detail-figma/contributor-1.png';
+import contributorTwo from '@/asset/images/skills-detail-figma/contributor-2.png';
+import copyDarkIcon from '@/asset/images/skills-detail-figma/copy-dark.svg';
+import downloadIcon from '@/asset/images/skills-detail-figma/download.svg';
+import emptyRelatedIcon from '@/asset/images/skills-detail-figma/empty-related.svg';
+import externalLinkXsIcon from '@/asset/images/skills-detail-figma/external-link-xs.svg';
+import fileDocIcon from '@/asset/images/skills-detail-figma/file-doc.svg';
+import folderOpenBlueIcon from '@/asset/images/skills-detail-figma/folder-open-blue.svg';
+import heroSkillIcon from '@/asset/images/skills-detail-figma/hero-skill.svg';
+import humanIcon from '@/asset/images/skills-detail-figma/human.svg';
+import relatedSkillDocsIcon from '@/asset/images/skills-detail-figma/related-skill-docs.svg';
+import relatedSkillSecurityIcon from '@/asset/images/skills-detail-figma/related-skill-security.svg';
+import relatedSkillSqlIcon from '@/asset/images/skills-detail-figma/related-skill-sql.svg';
+import MarkdownRenderer from '@/components/markdownRenderer';
+import { copyToClipboard } from '@/utils/copyUtils';
+import { SkillDetail, SkillFileContent, SkillInstallMeta, SkillItem } from '../types';
+import './style.scss';
+
+const { Title, Paragraph } = Typography;
+
+interface SkillTreeNode extends DataNode {
+ children?: SkillTreeNode[];
+}
+
+interface FrontmatterItem {
+ key: string;
+ value: string;
+}
+
+interface SkillDetailContentProps {
+ slug: string;
+ history: any;
+}
+
+interface FigmaIconProps {
+ src: string;
+ className?: string;
+ alt?: string;
+}
+
+type InstallPanelKey = 'agent' | 'human' | null;
+
+const relatedSkillIconUrls = [relatedSkillSqlIcon, relatedSkillSecurityIcon, relatedSkillDocsIcon];
+
+const relatedSkillShellClasses = ['is-blue', 'is-green', 'is-orange'];
+const browseMarketArrowIcon = externalLinkXsIcon;
+
+const FigmaIcon: React.FC = ({ src, className = '', alt = '' }) => (
+
+);
+
+const formatFileSize = (size = 0) => {
+ if (size < 1024) return `${size} B`;
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
+};
+
+const sortTreeNodes = (nodes: SkillTreeNode[]) => {
+ nodes.sort((a, b) => {
+ const aIsLeaf = Boolean(a.isLeaf);
+ const bIsLeaf = Boolean(b.isLeaf);
+ if (aIsLeaf !== bIsLeaf) return aIsLeaf ? 1 : -1;
+ return String(a.title).localeCompare(String(b.title));
+ });
+
+ nodes.forEach((node) => {
+ if (node.children && node.children.length > 0) {
+ sortTreeNodes(node.children);
+ }
+ });
+};
+
+const buildFileTreeData = (fileList: string[]): SkillTreeNode[] => {
+ const treeData: SkillTreeNode[] = [];
+
+ fileList.forEach((filePath) => {
+ const segments = filePath.split('/').filter(Boolean);
+ let currentNodes = treeData;
+ let currentPath = '';
+
+ segments.forEach((segment, index) => {
+ currentPath = currentPath ? `${currentPath}/${segment}` : segment;
+ const isLeaf = index === segments.length - 1;
+ let node = currentNodes.find((item) => item.key === currentPath);
+
+ if (!node) {
+ node = {
+ key: currentPath,
+ title: segment,
+ isLeaf,
+ children: isLeaf ? undefined : [],
+ };
+ currentNodes.push(node);
+ }
+
+ if (!isLeaf) {
+ node.children = node.children || [];
+ currentNodes = node.children;
+ }
+ });
+ });
+
+ sortTreeNodes(treeData);
+ return treeData;
+};
+
+const normalizeSourceUrl = (sourceRepo: string) => {
+ if (!sourceRepo) return '';
+ const normalized = sourceRepo.replace(/^git\+/, '').trim();
+ const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
+ if (sshMatch) {
+ return `https://${sshMatch[1]}/${sshMatch[2]}`;
+ }
+ if (/^https?:\/\//.test(normalized)) {
+ return normalized.replace(/\.git$/, '');
+ }
+ return '';
+};
+
+const normalizeFrontmatterValue = (value: string) => {
+ const trimmed = String(value || '').trim();
+ if (!trimmed) return '-';
+ if (
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
+ ) {
+ return trimmed.slice(1, -1);
+ }
+ return trimmed;
+};
+
+const parseMarkdownFrontmatter = (
+ markdown = ''
+): { frontmatter: FrontmatterItem[]; body: string } => {
+ const content = String(markdown || '');
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
+ if (!match) {
+ return {
+ frontmatter: [],
+ body: content,
+ };
+ }
+
+ const block = match[1] || '';
+ const lines = block.split(/\r?\n/);
+ const frontmatter: FrontmatterItem[] = [];
+ let currentKey = '';
+ let currentValueLines: string[] = [];
+
+ const pushCurrent = () => {
+ if (!currentKey) return;
+ frontmatter.push({
+ key: currentKey,
+ value: normalizeFrontmatterValue(currentValueLines.join('\n')),
+ });
+ };
+
+ lines.forEach((line) => {
+ const keyMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
+ const isTopLevelKey = Boolean(keyMatch) && !/^\s/.test(line);
+
+ if (isTopLevelKey && keyMatch) {
+ pushCurrent();
+ currentKey = keyMatch[1];
+ currentValueLines = [keyMatch[2] || ''];
+ return;
+ }
+
+ if (currentKey) {
+ currentValueLines.push(line);
+ }
+ });
+
+ pushCurrent();
+
+ return {
+ frontmatter,
+ body: content.slice(match[0].length),
+ };
+};
+
+const formatDownloadCommand = (downloadUrl = '', fileName = 'skill.zip') => {
+ if (!downloadUrl) return '';
+ return `curl -L "${downloadUrl}" -o ${fileName}`;
+};
+
+const formatCompactDate = (value?: string) => {
+ if (!value) return '-';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '-';
+ return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
+};
+
+const SkillDetailContent: React.FC = ({ slug, history }) => {
+ const [loading, setLoading] = useState(true);
+ const [fileLoading, setFileLoading] = useState(false);
+ const [detail, setDetail] = useState(null);
+ const [installMeta, setInstallMeta] = useState(null);
+ const [related, setRelated] = useState([]);
+ const [uiSelectedFilePath, setUiSelectedFilePath] = useState('');
+ const [selectedFilePath, setSelectedFilePath] = useState('');
+ const [fileContent, setFileContent] = useState(null);
+ const [activeInstallPanel, setActiveInstallPanel] = useState('agent');
+ const [likeStatus, setLikeStatus] = useState({ liked: false, likeCount: 0 });
+ const [likeLoading, setLikeLoading] = useState(false);
+
+ const fileTreeData = useMemo(
+ () => buildFileTreeData(detail?.fileList || []),
+ [detail?.fileList]
+ );
+ const sourceUrl = useMemo(
+ () => normalizeSourceUrl(detail?.sourceRepo || ''),
+ [detail?.sourceRepo]
+ );
+ const downloadPath = useMemo(
+ () => `/api/skills/download?slug=${encodeURIComponent(slug)}`,
+ [slug]
+ );
+ const installKey = installMeta?.installKey || detail?.installKey || slug;
+ const currentOrigin = useMemo(() => {
+ if (typeof window === 'undefined') return '';
+ return window.location.origin;
+ }, []);
+ const skillInstallCommand = useMemo(
+ () => `doraemon-skills install ${installKey}`,
+ [installKey]
+ );
+ const browseMarketPath = '/page/skills';
+ const cliInstallPlaceholderCommand =
+ '# 待补齐 Doraemon CLI 安装脚本 URL,例如 curl -fsSL | bash';
+ const archiveFileName = useMemo(() => {
+ const rawName = detail?.name || slug || 'skill';
+ const normalized = rawName
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ return `${normalized || 'skill'}.zip`;
+ }, [detail?.name, slug]);
+ const downloadCommand = useMemo(() => {
+ if (installMeta?.downloadUrl) {
+ return formatDownloadCommand(installMeta.downloadUrl, archiveFileName);
+ }
+ if (!currentOrigin) {
+ return `curl -L "${downloadPath}" -o ${archiveFileName}`;
+ }
+ return formatDownloadCommand(`${currentOrigin}${downloadPath}`, archiveFileName);
+ }, [archiveFileName, currentOrigin, downloadPath, installMeta?.downloadUrl]);
+ const heroMetaItems = useMemo(
+ () => [
+ {
+ label: '分类',
+ value: detail?.category || '通用',
+ },
+ {
+ label: '最近更新',
+ value: formatCompactDate(detail?.updatedAt),
+ },
+ ],
+ [detail?.category, detail?.updatedAt]
+ );
+ const isInstallable = Boolean(installMeta?.installable);
+ const manualDownloadUrl = installMeta?.downloadUrl || downloadPath;
+ const agentTerminalCommand = isInstallable ? skillInstallCommand : downloadCommand;
+ const heroSummary = useMemo(() => {
+ const rawText = (detail?.description || '').replace(/\s+/g, ' ').trim();
+ if (!rawText) return '暂无描述';
+ const sentence = rawText.split(/(?<=[.!?。!?])/)[0]?.trim() || rawText;
+ return sentence;
+ }, [detail?.description]);
+ const handleSelectFile = (nextPath: string) => {
+ if (!nextPath || nextPath === uiSelectedFilePath) return;
+ setUiSelectedFilePath(nextPath);
+ setSelectedFilePath(nextPath);
+ setFileContent(null);
+ setFileLoading(true);
+ };
+
+ const fetchLikeStatus = async () => {
+ try {
+ const res = await API.getSkillLikeStatus({ slug });
+ if (res.success) {
+ setLikeStatus({
+ liked: res.data.liked,
+ likeCount: res.data.likeCount,
+ });
+ }
+ } catch (error) {
+ console.error('获取点赞状态失败:', error);
+ }
+ };
+
+ const handleLike = async () => {
+ if (likeLoading) return;
+ setLikeLoading(true);
+ try {
+ if (likeStatus.liked) {
+ const res = await API.unlikeSkill({ slug });
+ if (res.success) {
+ setLikeStatus({ liked: false, likeCount: res.data.likeCount });
+ }
+ } else {
+ const res = await API.likeSkill({ slug });
+ if (res.success) {
+ setLikeStatus({ liked: true, likeCount: res.data.likeCount });
+ }
+ }
+ } catch (error) {
+ console.error('点赞操作失败:', error);
+ } finally {
+ setLikeLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ setUiSelectedFilePath('');
+ setSelectedFilePath('');
+ setFileContent(null);
+ setFileLoading(false);
+ setInstallMeta(null);
+ }, [slug]);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const loadDetail = async () => {
+ setLoading(true);
+ try {
+ const [detailRes, relatedRes] = await Promise.all([
+ API.getSkillDetail({ slug }),
+ API.getRelatedSkills({ slug, limit: 6 }),
+ ]);
+
+ if (cancelled) return;
+
+ let nextInstallMeta: SkillInstallMeta | null = null;
+
+ if (detailRes.success) {
+ const detailData = detailRes.data as SkillDetail;
+ setDetail(detailData);
+ const defaultFile = detailData.fileList.includes('SKILL.md')
+ ? 'SKILL.md'
+ : detailData.fileList[0] || '';
+ setUiSelectedFilePath(defaultFile);
+ setSelectedFilePath(defaultFile);
+ setFileContent(null);
+ setFileLoading(Boolean(defaultFile));
+
+ const installMetaRes = await API.getSkillInstallMeta({
+ installKey: detailData.installKey || slug,
+ });
+ if (!cancelled && installMetaRes.success) {
+ nextInstallMeta = installMetaRes.data as SkillInstallMeta;
+ }
+ } else {
+ setDetail(null);
+ setUiSelectedFilePath('');
+ setSelectedFilePath('');
+ }
+
+ setRelated(relatedRes.success ? relatedRes.data || [] : []);
+ setInstallMeta(nextInstallMeta);
+ } catch (error) {
+ console.error('获取 Skill 详情失败:', error);
+ if (!cancelled) {
+ setDetail(null);
+ setRelated([]);
+ setInstallMeta(null);
+ setUiSelectedFilePath('');
+ setSelectedFilePath('');
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ };
+
+ loadDetail();
+ fetchLikeStatus();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [slug]);
+
+ useEffect(() => {
+ if (!selectedFilePath) {
+ setFileContent(null);
+ return;
+ }
+
+ let cancelled = false;
+
+ const loadFileContent = async () => {
+ setFileLoading(true);
+ try {
+ const response = await API.getSkillFileContent({
+ slug,
+ path: selectedFilePath,
+ });
+ if (!cancelled) {
+ setFileContent(response.success ? (response.data as SkillFileContent) : null);
+ }
+ } catch (error) {
+ console.error('获取文件内容失败:', error);
+ if (!cancelled) {
+ setFileContent(null);
+ }
+ } finally {
+ if (!cancelled) {
+ setFileLoading(false);
+ }
+ }
+ };
+
+ loadFileContent();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedFilePath, slug]);
+
+ const renderFileViewer = () => {
+ if (fileLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!fileContent) {
+ return ;
+ }
+
+ if (fileContent.isBinary) {
+ return ;
+ }
+
+ if (fileContent.language === 'markdown') {
+ const parsedMarkdown = parseMarkdownFrontmatter(fileContent.content || '');
+ return (
+
+ {parsedMarkdown.frontmatter.length > 0 ? (
+
+
+
+ {parsedMarkdown.frontmatter.map((item) => {
+ const isCodeStyle =
+ item.value.includes('\n') ||
+ item.value.startsWith('{') ||
+ item.value.startsWith('[');
+ return (
+
+ | {item.key} |
+
+ {isCodeStyle ? (
+
+ {item.value}
+
+ ) : (
+ {item.value}
+ )}
+ |
+
+ );
+ })}
+
+
+
+ ) : null}
+ {parsedMarkdown.body.trim() ? (
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+ {fileContent.content || ''}
+
+ );
+ };
+
+ const renderInlineCommand = (command: string, copyMessage: string, compact = false) => (
+
+
+
+ {command || '暂无可复制命令'}
+
+
}
+ onClick={() => copyToClipboard(command, copyMessage)}
+ disabled={!command}
+ />
+
+
+ );
+
+ const renderTerminalCommand = (command: string, copyMessage: string) => (
+
+
+
+ $
+ {command || '暂无可复制命令'}
+ }
+ onClick={() => copyToClipboard(command, copyMessage)}
+ disabled={!command}
+ />
+
+
+ );
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!detail) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
{detail.name}
+
+ {heroSummary}
+
+
+
+
+
+
+
+
+
+
+ {heroMetaItems.map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+
+
+
+
+
+
+ {uiSelectedFilePath || 'SKILL.md'}
+
+
+ {fileLoading ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default SkillDetailContent;
diff --git a/app/web/pages/skills/detail/SkillSummaryModalContent.tsx b/app/web/pages/skills/detail/SkillSummaryModalContent.tsx
new file mode 100644
index 0000000..afecf96
--- /dev/null
+++ b/app/web/pages/skills/detail/SkillSummaryModalContent.tsx
@@ -0,0 +1,371 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { CopyOutlined, LinkOutlined, ShareAltOutlined, StarOutlined } from '@ant-design/icons';
+import { Button, Empty, Spin, Tabs, Tag, Typography } from 'antd';
+
+import { API } from '@/api';
+import { copyToClipboard } from '@/utils/copyUtils';
+import { SkillDetail, SkillInstallMeta } from '../types';
+import './summaryModal.scss';
+
+const { Paragraph, Text, Title } = Typography;
+const { TabPane } = Tabs;
+
+interface SkillSummaryModalContentProps {
+ slug: string;
+ history: any;
+}
+
+const normalizeSourceUrl = (sourceRepo: string) => {
+ if (!sourceRepo) return '';
+ const normalized = sourceRepo.replace(/^git\+/, '').trim();
+ const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
+ if (sshMatch) {
+ return `https://${sshMatch[1]}/${sshMatch[2]}`;
+ }
+ if (/^https?:\/\//.test(normalized)) {
+ return normalized.replace(/\.git$/, '');
+ }
+ return '';
+};
+
+const formatDownloadCommand = (downloadUrl = '', fileName = 'skill.zip') => {
+ if (!downloadUrl) return '';
+ return `curl -L "${downloadUrl}" -o ${fileName}`;
+};
+
+const SkillSummaryModalContent: React.FC = ({ slug, history }) => {
+ const [loading, setLoading] = useState(true);
+ const [detail, setDetail] = useState(null);
+ const [installMeta, setInstallMeta] = useState(null);
+
+ const sourceUrl = useMemo(
+ () => normalizeSourceUrl(detail?.sourceRepo || ''),
+ [detail?.sourceRepo]
+ );
+ const deepLinkPath = useMemo(() => `/page/skills/${encodeURIComponent(slug)}`, [slug]);
+ const downloadPath = useMemo(
+ () => `/api/skills/download?slug=${encodeURIComponent(slug)}`,
+ [slug]
+ );
+ const installKey = installMeta?.installKey || detail?.installKey || slug;
+ const currentOrigin = useMemo(() => {
+ if (typeof window === 'undefined') return '';
+ return window.location.origin;
+ }, []);
+ const deepLinkUrl = useMemo(() => {
+ if (!currentOrigin) return deepLinkPath;
+ return `${currentOrigin}${deepLinkPath}`;
+ }, [currentOrigin, deepLinkPath]);
+ const skillInstallCommand = useMemo(
+ () => `doraemon-skills install ${installKey}`,
+ [installKey]
+ );
+ const cliInstallPlaceholderCommand =
+ '# 待提供:Doraemon CLI 安装脚本 URL(例如 curl -fsSL <...> | bash)';
+ const archiveFileName = useMemo(() => {
+ const rawName = detail?.name || slug || 'skill';
+ const normalized = rawName
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ return `${normalized || 'skill'}.zip`;
+ }, [detail?.name, slug]);
+ const downloadCommand = useMemo(() => {
+ if (installMeta?.downloadUrl) {
+ return formatDownloadCommand(installMeta.downloadUrl, archiveFileName);
+ }
+ if (!currentOrigin) {
+ return `curl -L "${downloadPath}" -o ${archiveFileName}`;
+ }
+ return formatDownloadCommand(`${currentOrigin}${downloadPath}`, archiveFileName);
+ }, [archiveFileName, currentOrigin, downloadPath, installMeta?.downloadUrl]);
+ const agentInstruction = useMemo(
+ () =>
+ [
+ '请先检查 Doraemon CLI 是否已安装(例如执行 doraemon-skills --help)。',
+ '若未安装,请先执行 Human 区的 Doraemon CLI 安装步骤。',
+ `安装当前技能:${skillInstallCommand}`,
+ '若已安装,则直接执行上面的技能安装命令。',
+ ].join('\n'),
+ [skillInstallCommand]
+ );
+ const isInstallable = Boolean(installMeta?.installable);
+ const installUnavailableReason = installMeta?.reason || 'install-meta 暂不可用';
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const loadDetail = async () => {
+ setLoading(true);
+ try {
+ const detailRes = await API.getSkillDetail({ slug });
+ if (cancelled) return;
+
+ if (!detailRes.success) {
+ setDetail(null);
+ setInstallMeta(null);
+ return;
+ }
+
+ const detailData = detailRes.data as SkillDetail;
+ setDetail(detailData);
+
+ const installMetaRes = await API.getSkillInstallMeta({
+ installKey: detailData.installKey || slug,
+ });
+ if (!cancelled) {
+ setInstallMeta(
+ installMetaRes.success ? (installMetaRes.data as SkillInstallMeta) : null
+ );
+ }
+ } catch (error) {
+ console.error('获取 Skill 弹窗详情失败:', error);
+ if (!cancelled) {
+ setDetail(null);
+ setInstallMeta(null);
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ };
+
+ loadDetail();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [slug]);
+
+ const renderInstallCommandCard = ({
+ title,
+ description,
+ command,
+ copyMessage,
+ disabled = false,
+ }: {
+ title: string;
+ description?: string;
+ command: string;
+ copyMessage: string;
+ disabled?: boolean;
+ }) => (
+
+
+ {title}
+ {description ? {description} : null}
+
+
+ {command || '暂无可复制命令'}
+ }
+ onClick={() => copyToClipboard(command, copyMessage)}
+ disabled={disabled || !command}
+ />
+
+
+ );
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!detail) {
+ return (
+
+
+
+ );
+ }
+
+ const detailTags = (detail.tags || []).filter((tag) => tag && tag !== detail.category);
+ const detailSource = detail.sourceRepo || detail.sourcePath || '-';
+ const detailUpdatedAt = detail.updatedAt
+ ? new Date(detail.updatedAt).toLocaleString('zh-CN')
+ : '-';
+ const agentFallbackInstruction = [
+ '当前 skill 不支持 doraemon-skills 直接安装。',
+ `请先下载 zip:${downloadCommand}`,
+ `原因:${installUnavailableReason}`,
+ '然后手动解压并确认技能目录结构(需包含 SKILL.md)。',
+ ].join('\n');
+ const detailMetaItems = [
+ {
+ label: 'Stars',
+ value: String(detail.stars || 0),
+ className: 'is-accent',
+ },
+ {
+ label: '最近更新',
+ value: detailUpdatedAt,
+ },
+ {
+ label: '来源',
+ value: detailSource,
+ className: 'is-wide',
+ },
+ ];
+
+ return (
+
+
+
+
+
+ {detail.category || '未分类'}
+ 安装标识 · {installKey}
+
+
+
+
+
{detail.name}
+
+ {detail.description || '暂无描述'}
+
+
+
+
+
快捷操作
+
+
+ }
+ onClick={() =>
+ copyToClipboard(deepLinkUrl, '详情页深链已复制到剪贴板')
+ }
+ >
+ 复制深链
+
+ }
+ onClick={() => window.open(sourceUrl, '_blank')}
+ disabled={!sourceUrl}
+ >
+ 查看源码
+
+
+
+
+
+
+
+
快速概览
+
+ {detailMetaItems.map((item) => (
+
+
{item.label}
+
+ {item.label === 'Stars' ? (
+ <>
+ {item.value}
+ >
+ ) : (
+ item.value
+ )}
+
+
+ ))}
+
+
+
+ {detailTags.length > 0 ? (
+
+
标签
+
+ {detailTags.map((tag) => (
+ {tag}
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
安装决策
+
+ {isInstallable
+ ? '弹窗只保留安装前所需的核心说明,全部命令继续基于 installKey 生成。'
+ : '当前来源暂不支持 Doraemon CLI 直装,保留下载与手动接入的降级路径。'}
+
+
+
+ {isInstallable ? 'CLI 可安装' : '需手动接入'}
+
+
+
+
+
+
+ {isInstallable
+ ? renderInstallCommandCard({
+ title: '发给 Agent 的安装提示',
+ description: '包含 CLI 检查与技能安装两步说明',
+ command: agentInstruction,
+ copyMessage: 'Agent 指令已复制到剪贴板',
+ })
+ : renderInstallCommandCard({
+ title: '发给 Agent 的降级说明',
+ description: installUnavailableReason,
+ command: agentFallbackInstruction,
+ copyMessage: '降级指令已复制到剪贴板',
+ })}
+
+
+
+
+ {isInstallable ? (
+ <>
+ {renderInstallCommandCard({
+ title: '先安装 Doraemon CLI',
+ description:
+ '当前项目仍使用占位说明,等待统一安装脚本地址补齐',
+ command: cliInstallPlaceholderCommand,
+ copyMessage: 'CLI 安装命令已复制到剪贴板',
+ })}
+ {renderInstallCommandCard({
+ title: '安装当前技能',
+ description: '复制后可直接在终端执行',
+ command: skillInstallCommand,
+ copyMessage: '技能安装命令已复制到剪贴板',
+ })}
+ >
+ ) : (
+ renderInstallCommandCard({
+ title: '手动下载命令',
+ description: `原因:${installUnavailableReason}`,
+ command: downloadCommand,
+ copyMessage: '下载命令已复制到剪贴板',
+ disabled: !downloadCommand,
+ })
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default SkillSummaryModalContent;
diff --git a/app/web/pages/skills/detail/index.tsx b/app/web/pages/skills/detail/index.tsx
new file mode 100644
index 0000000..03cbc65
--- /dev/null
+++ b/app/web/pages/skills/detail/index.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+
+import SkillDetailContent from './SkillDetailContent';
+
+const SkillDetailPage: React.FC = ({ history, match }) => {
+ const { slug } = match.params;
+ return ;
+};
+
+export default SkillDetailPage;
diff --git a/app/web/pages/skills/detail/stitchAssets.scss b/app/web/pages/skills/detail/stitchAssets.scss
new file mode 100644
index 0000000..9078ad6
--- /dev/null
+++ b/app/web/pages/skills/detail/stitchAssets.scss
@@ -0,0 +1,63 @@
+@font-face {
+ font-family: SkillDetailMaterialSymbols;
+ font-style: normal;
+ font-weight: 400;
+ font-display: block;
+ src:
+ url("../../../asset/font/material-symbols/material-symbols-outlined-subset.woff2")
+ format("woff2");
+}
+
+@font-face {
+ font-family: SkillDetailInter;
+ font-style: normal;
+ font-weight: 400 600;
+ font-display: swap;
+ src: url("../../../asset/font/material-symbols/inter-latin.woff2") format("woff2");
+}
+
+@font-face {
+ font-family: SkillDetailManrope;
+ font-style: normal;
+ font-weight: 400 800;
+ font-display: swap;
+ src: url("../../../asset/font/material-symbols/manrope-latin.woff2") format("woff2");
+}
+
+.page-skill-detail {
+ --skill-detail-font-body:
+ "PingFang SC",
+ "Hiragino Sans GB",
+ "Microsoft YaHei",
+ -apple-system,
+ blinkmacsystemfont,
+ "Segoe UI",
+ sans-serif;
+ --skill-detail-font-display:
+ "PingFang SC",
+ "Hiragino Sans GB",
+ "Microsoft YaHei",
+ -apple-system,
+ blinkmacsystemfont,
+ "Segoe UI",
+ sans-serif;
+ --skill-detail-font-icon: "SkillDetailMaterialSymbols";
+}
+
+.skill-detail-material-symbol {
+ font-family: var(--skill-detail-font-icon);
+ font-style: normal;
+ font-weight: 400;
+ font-size: 20px;
+ line-height: 1;
+ letter-spacing: normal;
+ text-transform: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ white-space: nowrap;
+ direction: ltr;
+ -webkit-font-feature-settings: "liga";
+ -webkit-font-smoothing: antialiased;
+ font-feature-settings: "liga";
+}
diff --git a/app/web/pages/skills/detail/style.scss b/app/web/pages/skills/detail/style.scss
new file mode 100644
index 0000000..05b8ecb
--- /dev/null
+++ b/app/web/pages/skills/detail/style.scss
@@ -0,0 +1,1123 @@
+@import "./stitchAssets.scss";
+
+.page-skill-detail {
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ background: #F7F9FB;
+ font-family: var(--skill-detail-font-body);
+ color: #2A3439;
+ .skill-detail-figma-icon {
+ display: inline-block;
+ object-fit: contain;
+ flex: 0 0 auto;
+ &.is-hero {
+ width: 25px;
+ height: 25px;
+ }
+ &.is-tree-node {
+ width: 12px;
+ height: 12px;
+ }
+ &.is-option-icon {
+ width: 22px;
+ height: 19px;
+ }
+ &.is-agent-icon {
+ width: 22px;
+ height: 19px;
+ }
+ &.is-human-icon {
+ width: 16px;
+ height: 16px;
+ }
+ &.is-chevron {
+ width: 7px;
+ height: 5px;
+ }
+ &.is-chevron-right {
+ width: 5px;
+ height: 7px;
+ }
+ &.is-copy-dark {
+ width: 10px;
+ height: 12px;
+ }
+ &.is-download {
+ width: 10px;
+ height: 10px;
+ }
+ &.is-article {
+ width: 11px;
+ height: 11px;
+ }
+ &.is-empty-related {
+ width: 22px;
+ height: 28px;
+ }
+ &.is-related-skill-icon {
+ width: 20px;
+ height: 20px;
+ }
+ &.is-browse-market-arrow {
+ width: 9px;
+ height: 9px;
+ }
+ }
+ &.loading-wrap,
+ &.page-skill-detail-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ .skill-detail-shell {
+ height: 100%;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 256px minmax(0, 1fr) 320px;
+ overflow: hidden;
+ }
+ .detail-left-sidebar,
+ .detail-right-sidebar {
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .detail-main-column {
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ padding: 12px 20px 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow: auto;
+ align-items: stretch;
+ }
+ .detail-left-sidebar {
+ background: #F1F5F9;
+ border-right: 1px solid rgba(226, 232, 240, 0.7);
+ }
+ .sidebar-head {
+ padding: 16px 16px 17px;
+ border-bottom: 1px solid rgba(226, 232, 240, 0.5);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ .back-btn {
+ padding: 4px 8px;
+ height: auto;
+ background: transparent;
+ border: none;
+ &:hover {
+ background: transparent;
+ }
+ }
+ .sidebar-title-group {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+ span,
+ strong {
+ display: inline-flex;
+ align-items: center;
+ min-height: 16px;
+ font-size: 10px;
+ line-height: 16px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ }
+ span {
+ color: #566166;
+ }
+ strong {
+ color: #2563EB;
+ text-transform: uppercase;
+ }
+ }
+ .sidebar-tree-wrap {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 8px;
+ .ant-empty {
+ margin-top: 48px;
+ }
+ .ant-tree {
+ background: transparent;
+ font-size: 12px;
+ }
+ .ant-tree-treenode {
+ width: 100%;
+ }
+ .ant-tree-switcher {
+ color: #64748B;
+ flex: 0 0 auto;
+ }
+ .ant-tree-node-content-wrapper {
+ min-height: 28px;
+ padding: 0;
+ width: 100%;
+ flex: 1 1 auto;
+ min-width: 0;
+ &:hover {
+ background: transparent;
+ }
+ }
+ .ant-tree-title {
+ display: block;
+ width: 100%;
+ }
+ .ant-tree-node-content-wrapper.ant-tree-node-selected,
+ .ant-tree-treenode-selected > .ant-tree-node-content-wrapper {
+ background: transparent;
+ }
+ }
+ .explorer-tree-item {
+ width: 100%;
+ min-height: 28px;
+ padding: 6px 8px;
+ border-left: 2px solid transparent;
+ border-radius: 2px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: #64748B;
+ line-height: 16px;
+ cursor: pointer;
+ user-select: none;
+ &.is-selected {
+ background: rgba(37, 99, 235, 0.08);
+ border-left-color: #2563EB;
+ color: #2563EB;
+ font-weight: 600;
+ }
+ &:focus-visible {
+ outline: 1px solid rgba(37, 99, 235, 0.28);
+ outline-offset: 1px;
+ }
+ }
+ .sidebar-foot {
+ padding: 16px;
+ border-top: 1px solid rgba(226, 232, 240, 0.7);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .install-primary-btn {
+ height: 34px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ background: #1658D5;
+ border-color: #1658D5;
+ &.is-disabled,
+ &:disabled,
+ &[disabled] {
+ background: #E5E7EB !important;
+ border-color: #D1D5DB !important;
+ color: #9CA3AF !important;
+ }
+ }
+ .sidebar-help-btn {
+ padding: 8px 6px;
+ border: 0;
+ background: transparent;
+ color: #64748B;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ }
+ .detail-hero-main {
+ width: 100%;
+ padding: 16px 24px;
+ min-height: 182px;
+ border-radius: 8px;
+ border: 1px solid rgba(169, 180, 185, 0.1);
+ background: #FFF;
+ box-shadow: 0 0 0 1px rgba(169, 180, 185, 0.04);
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 10px;
+ }
+ .hero-head-row {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ }
+ .hero-title-block {
+ min-width: 0;
+ display: flex;
+ align-items: flex-start;
+ gap: 20px;
+ flex: 1;
+ }
+ .skill-title-icon {
+ width: 64px;
+ height: 64px;
+ border-radius: 8px;
+ background: #DBE1FF;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ }
+ .hero-copy {
+ min-width: 0;
+ h2.ant-typography {
+ margin-bottom: 4px;
+ color: #2A3439;
+ font-size: 24px;
+ font-family: var(--skill-detail-font-display);
+ font-weight: 700;
+ letter-spacing: -0.025em;
+ line-height: 32px;
+ }
+ }
+ .hero-description {
+ max-width: 760px;
+ margin-bottom: 0;
+ color: #566166;
+ font-size: 14px;
+ line-height: 24px;
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ }
+ .hero-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 0 0 auto;
+ }
+ .hero-stat-chip {
+ min-width: 54px;
+ height: 32px;
+ padding: 0 14px;
+ border-radius: 4px;
+ background: #E8EFF3;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: #2A3439;
+ font-size: 14px;
+ }
+ .like-btn.ant-btn {
+ min-width: 64px;
+ height: 32px;
+ padding: 0 12px;
+ border: 1px solid #DBE5F0;
+ border-radius: 8px;
+ background: linear-gradient(180deg, #FFF 0%, #F8FBFF 100%);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ color: #334155;
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1;
+ transition:
+ border-color 0.2s ease,
+ background 0.2s ease,
+ color 0.2s ease,
+ box-shadow 0.2s ease,
+ transform 0.2s ease;
+ .anticon {
+ width: 14px;
+ font-size: 14px;
+ color: #64748B;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.2s ease;
+ }
+ span:last-child {
+ min-width: 8px;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+ }
+ .ant-btn-loading-icon {
+ margin-right: 0;
+ }
+ &:hover,
+ &:focus {
+ border-color: #BFD3F8;
+ background: linear-gradient(180deg, #FFF 0%, #EDF4FF 100%);
+ color: #174EA6;
+ box-shadow: 0 4px 10px rgba(37, 99, 235, 0.12);
+ .anticon {
+ color: #174EA6;
+ }
+ }
+ &:active {
+ transform: translateY(1px);
+ box-shadow: 0 2px 6px rgba(37, 99, 235, 0.1);
+ }
+ &[disabled],
+ &[disabled]:hover,
+ &[disabled]:focus {
+ border-color: #DBE5F0;
+ background: linear-gradient(180deg, #FFF 0%, #F8FBFF 100%);
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
+ color: #334155;
+ opacity: 1;
+ .anticon {
+ color: #64748B;
+ }
+ }
+ &.is-liked {
+ border-color: rgba(22, 88, 213, 0.24);
+ background: linear-gradient(180deg, #F5F9FF 0%, #E9F1FF 100%);
+ color: #1658D5;
+ box-shadow: 0 4px 12px rgba(22, 88, 213, 0.12);
+ .anticon {
+ color: #1658D5;
+ }
+ }
+ }
+ .hero-meta-row {
+ padding-top: 14px;
+ border-top: 1px solid #F1F5F9;
+ display: flex;
+ align-items: center;
+ gap: 32px;
+ flex-wrap: nowrap;
+ }
+ .hero-meta-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ span {
+ color: #566166;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ }
+ strong {
+ color: #2A3439;
+ font-size: 14px;
+ line-height: 20px;
+ }
+ }
+ .document-panel {
+ min-height: 0;
+ flex: 1;
+ width: 100%;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ background: #E8EFF3;
+ display: flex;
+ flex-direction: column;
+ }
+ .document-toolbar {
+ height: 44px;
+ padding: 0 16px;
+ background: #E1E9EE;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex: 0 0 auto;
+ }
+ .document-toolbar-left {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: #566166;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ }
+ .document-toolbar-right {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ span {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: #CBD5E1;
+ }
+ }
+ .document-loading-indicator {
+ width: auto !important;
+ height: auto !important;
+ border-radius: 0 !important;
+ background: transparent !important;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 8px;
+ .ant-spin {
+ color: #2563EB;
+ }
+ }
+ .document-scroll-area {
+ min-height: 0;
+ flex: 1;
+ overflow: auto;
+ background: #FFF;
+ padding: 32px;
+ }
+ .document-content-shell {
+ position: relative;
+ min-height: 100%;
+ }
+ .file-viewer-loading {
+ min-height: 360px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ .markdown-file-viewer {
+ .markdown-renderer {
+ color: #566166;
+ font-size: 16px;
+ line-height: 1.7;
+ }
+ h1,
+ h2,
+ h3 {
+ color: #2A3439;
+ font-family: var(--skill-detail-font-display);
+ }
+ .frontmatter-table-wrap {
+ margin-bottom: 24px;
+ border: 1px solid #E2E8F0;
+ border-radius: 8px;
+ overflow: hidden;
+ }
+ .frontmatter-table {
+ width: 100%;
+ border-collapse: collapse;
+ th,
+ td {
+ padding: 14px 16px;
+ border-bottom: 1px solid #EEF2F7;
+ vertical-align: top;
+ }
+ tr:last-child th,
+ tr:last-child td {
+ border-bottom: 0;
+ }
+ th {
+ width: 180px;
+ background: #F8FAFC;
+ color: #2D4A6A;
+ font-size: 14px;
+ font-weight: 600;
+ text-align: left;
+ }
+ td {
+ background: #FFF;
+ color: #344054;
+ font-size: 14px;
+ line-height: 1.7;
+ word-break: break-word;
+ }
+ }
+ .frontmatter-code {
+ margin: 0;
+ padding: 10px 12px;
+ border-radius: 6px;
+ background: #F8FAFC;
+ white-space: pre-wrap;
+ }
+ }
+ .detail-right-sidebar {
+ padding: 12px 16px 16px;
+ border-left: 1px solid rgba(226, 232, 240, 0.7);
+ background: #F8FAFC;
+ overflow-y: auto;
+ overflow-x: hidden;
+ gap: 12px;
+ align-items: stretch;
+ > * {
+ width: 100%;
+ min-width: 0;
+ }
+ }
+ .sidebar-section-title {
+ margin-bottom: 8px;
+ color: #566166;
+ font-size: 10px;
+ font-weight: 600;
+ letter-spacing: 0.14em;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+ .install-soon-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 6px;
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ color: #C2410C;
+ background: #FED7AA;
+ border-radius: 4px;
+ }
+ .install-option-card {
+ border: 1px solid #DBE5F0;
+ border-radius: 8px;
+ background: #FFF;
+ overflow: hidden;
+ &.is-active {
+ border-color: #2563EB;
+ }
+ &.is-collapsed {
+ margin-top: 8px;
+ }
+ }
+ .install-option-trigger {
+ width: 100%;
+ min-height: 55px;
+ padding: 12px 16px;
+ border: 0;
+ background: #FFF;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ cursor: pointer;
+ }
+ .install-option-trigger:hover {
+ background: #FBFDFF;
+ }
+ .install-option-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ text-align: left;
+ min-width: 0;
+ flex: 1;
+ }
+ .install-option-icon-shell {
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ &.is-agent {
+ background: rgba(0, 83, 219, 0.1);
+ }
+ &.is-human {
+ background: #F0FDFA;
+ }
+ }
+ .install-option-title {
+ color: #2A3439;
+ font-size: 14px;
+ font-weight: 600;
+ font-family: var(--skill-detail-font-display);
+ line-height: 1;
+ }
+ .install-option-description {
+ color: #64748B;
+ font-size: 11px;
+ line-height: 15px;
+ }
+ .install-option-meta > div {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+ }
+ .install-option-body {
+ display: grid;
+ grid-template-rows: 0fr;
+ padding: 0 12px;
+ opacity: 0;
+ transition:
+ grid-template-rows 180ms ease,
+ padding 180ms ease,
+ opacity 140ms ease;
+ &.is-open {
+ grid-template-rows: 1fr;
+ padding: 4px 12px 12px;
+ opacity: 1;
+ }
+ &.is-closed {
+ pointer-events: none;
+ }
+ }
+ .install-option-body-inner {
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+ .human-command-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+ .human-command-title {
+ color: #566166;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 16px;
+ }
+ .download-panel,
+ .related-panel,
+ .meta-panel {
+ padding-top: 4px;
+ }
+ .download-btn {
+ height: 28px;
+ margin-bottom: 12px;
+ padding: 0 12px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 600;
+ background: #0F172A;
+ border-color: #0F172A;
+ color: #FFF;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ &:hover,
+ &:focus {
+ background: #1B2438;
+ border-color: #1B2438;
+ color: #FFF;
+ }
+ &:active {
+ background: #0B1220;
+ border-color: #0B1220;
+ color: #FFF;
+ }
+ }
+ .install-panel,
+ .download-panel,
+ .related-panel,
+ .meta-panel {
+ flex: 0 0 auto;
+ }
+ .related-panel {
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 auto;
+ width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ }
+ .command-surface {
+ border-radius: 6px;
+ overflow: hidden;
+ &.is-dark {
+ background: #111827;
+ color: #F8FAFC;
+ }
+ &.is-light {
+ border: 1px solid #E2E8F0;
+ background: #EEF3F7;
+ color: #475569;
+ }
+ &.is-terminal {
+ border-radius: 4px;
+ }
+ code {
+ display: block;
+ padding: 0 14px 12px;
+ font-size: 12px;
+ line-height: 1.6;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+ p {
+ margin: 0;
+ padding: 0 14px 12px;
+ color: inherit;
+ font-size: 12px;
+ line-height: 1.6;
+ opacity: 0.8;
+ }
+ }
+ .command-surface-inline {
+ min-height: 39px;
+ display: flex;
+ align-items: stretch;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
+ .command-inline-code-wrap {
+ flex: 1;
+ min-width: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ &::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none;
+ }
+ }
+ code {
+ display: inline-block;
+ min-width: max-content;
+ padding: 3px 2px;
+ white-space: nowrap;
+ line-height: 22px;
+ }
+ }
+ .command-surface.is-compact {
+ .command-surface-inline {
+ align-items: center;
+ }
+ .command-inline-code-wrap {
+ padding: 2px 0;
+ }
+ }
+ .terminal-head {
+ height: 25px;
+ padding: 0 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .terminal-dots {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ span {
+ width: 8px;
+ height: 8px;
+ border-radius: 999px;
+ background: #CBD5E1;
+ }
+ }
+ .terminal-label {
+ color: rgba(255, 255, 255, 0.46);
+ font-size: 9px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ }
+ .terminal-body {
+ min-height: 57px;
+ padding: 11px 12px 12px;
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: start;
+ column-gap: 14px;
+ .terminal-prompt {
+ color: #34D399;
+ font-size: 12px;
+ line-height: 16px;
+ font-family: SFMono-Regular, Consolas, monospace;
+ margin-top: 2px;
+ }
+ code {
+ padding: 0;
+ color: #E2E8F0;
+ line-height: 16px;
+ word-break: break-word;
+ margin-top: 1px;
+ }
+ }
+ .command-copy-btn {
+ color: inherit;
+ width: 18px;
+ height: 20px;
+ min-width: 18px;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ align-self: center;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: none;
+ .ant-btn-icon {
+ margin: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ }
+ &:hover,
+ &:focus {
+ color: inherit;
+ box-shadow: none;
+ }
+ &.is-terminal-copy {
+ border-color: transparent;
+ background: transparent;
+ &:hover,
+ &:focus {
+ background: rgba(255, 255, 255, 0.08);
+ border-color: transparent;
+ }
+ }
+ &.is-inline-copy {
+ border-color: rgba(203, 213, 225, 0.88);
+ background: #F8FAFC;
+ &:hover,
+ &:focus {
+ background: #F1F5F9;
+ border-color: rgba(148, 163, 184, 0.72);
+ }
+ }
+ }
+ .related-empty-state {
+ min-height: 112px;
+ border: 1px dashed #CBD5E1;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ color: #94A3B8;
+ font-size: 12px;
+ text-align: center;
+ }
+ .related-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding-right: 4px;
+ width: 100%;
+ min-width: 0;
+ }
+ .related-item {
+ width: 100%;
+ min-width: 0;
+ min-height: 72px;
+ padding: 10px 0;
+ border: 0;
+ background: transparent;
+ text-align: left;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ cursor: pointer;
+ white-space: normal;
+ & + .related-item {
+ margin-top: 8px;
+ padding-top: 18px;
+ }
+ &:hover,
+ &:focus {
+ background: transparent;
+ }
+ .related-item-copy {
+ min-width: 0;
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 6px;
+ }
+ strong {
+ display: block;
+ width: 100%;
+ color: #24313A;
+ font-size: 13px;
+ font-weight: 600;
+ line-height: 18px;
+ letter-spacing: 0.01em;
+ word-break: break-word;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ .related-item-description {
+ width: 100%;
+ color: #6B7B8C;
+ font-size: 11px;
+ line-height: 17px;
+ word-break: break-word;
+ white-space: normal;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ }
+ }
+ .related-item-icon-shell {
+ width: 44px;
+ height: 44px;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ align-self: center;
+ &.is-blue {
+ border-color: rgba(219, 234, 254, 0.5);
+ background-image:
+ linear-gradient(
+ 135deg,
+ rgba(59, 130, 246, 0.1) 0%,
+ rgba(99, 102, 241, 0.2) 100%
+ );
+ }
+ &.is-green {
+ border-color: rgba(209, 250, 229, 0.5);
+ background-image:
+ linear-gradient(
+ 135deg,
+ rgba(16, 185, 129, 0.1) 0%,
+ rgba(20, 184, 166, 0.2) 100%
+ );
+ }
+ &.is-orange {
+ border-color: rgba(254, 243, 199, 0.5);
+ background-image:
+ linear-gradient(
+ 135deg,
+ rgba(245, 158, 11, 0.1) 0%,
+ rgba(249, 115, 22, 0.2) 100%
+ );
+ }
+ }
+ .browse-market-link {
+ margin-top: 16px;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: #0053DB;
+ font-size: 10px;
+ line-height: 15px;
+ cursor: pointer;
+ &:hover,
+ &:focus {
+ background: transparent;
+ color: #0053DB;
+ }
+ }
+ .meta-panel {
+ margin-top: auto;
+ flex: 0 0 auto;
+ padding-top: 20px;
+ border-top: 1px solid #E2E8F0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+ .meta-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: #64748B;
+ font-size: 10px;
+ strong {
+ color: #2A3439;
+ font-weight: 600;
+ }
+ }
+ .contributors-stack {
+ display: inline-flex;
+ align-items: center;
+ img,
+ span {
+ width: 16px;
+ height: 16px;
+ border-radius: 999px;
+ border: 1px solid #FFF;
+ margin-left: -4px;
+ }
+ img:first-child,
+ span:first-child {
+ margin-left: 0;
+ }
+ span {
+ background: #E2E8F0;
+ color: #566166;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 8px;
+ }
+ }
+
+ @media (max-width: 1200px) {
+ .skill-detail-shell {
+ grid-template-columns: 220px minmax(0, 1fr) 300px;
+ }
+ .detail-main-column {
+ padding: 24px;
+ }
+ }
+
+ @media (max-width: 1200px) {
+ .detail-main-column {
+ padding: 24px;
+ }
+ .detail-hero-main,
+ .document-panel {
+ width: 100%;
+ }
+ }
+
+ @media (max-width: 960px) {
+ height: auto;
+ overflow: auto;
+ .skill-detail-shell {
+ height: auto;
+ grid-template-columns: 1fr;
+ }
+ .detail-left-sidebar,
+ .detail-right-sidebar {
+ border: 0;
+ }
+ .detail-main-column {
+ overflow: visible;
+ align-items: stretch;
+ }
+ .document-panel {
+ min-height: 640px;
+ }
+ }
+
+ @media (max-width: 768px) {
+ .detail-main-column {
+ padding: 16px;
+ }
+ .hero-head-row {
+ flex-direction: column;
+ }
+ .hero-title-block {
+ width: 100%;
+ }
+ .document-scroll-area {
+ padding: 20px 16px;
+ }
+ .hero-meta-row {
+ gap: 20px;
+ flex-wrap: wrap;
+ }
+ }
+}
diff --git a/app/web/pages/skills/detail/summaryModal.scss b/app/web/pages/skills/detail/summaryModal.scss
new file mode 100644
index 0000000..c968311
--- /dev/null
+++ b/app/web/pages/skills/detail/summaryModal.scss
@@ -0,0 +1,339 @@
+.skill-summary-modal {
+ padding: 24px;
+ background: transparent;
+ &.skill-summary-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 420px;
+ }
+ .skill-summary-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1.45fr) minmax(320px, 1fr);
+ gap: 22px;
+ }
+ .summary-hero-card,
+ .summary-install-card {
+ border: 1px solid #E4ECF5;
+ border-radius: 22px;
+ background: #FFF;
+ box-shadow: 0 18px 36px rgba(31, 45, 61, 0.08);
+ }
+ .summary-hero-card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 20px;
+ background: linear-gradient(180deg, #FFF 0%, #F7FAFD 100%);
+ }
+ .summary-hero-main,
+ .summary-hero-section {
+ border: 1px solid #E7EEF6;
+ border-radius: 18px;
+ background: linear-gradient(145deg, #FFF 0%, #F5F9FF 100%);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
+ }
+ .summary-hero-main {
+ padding: 24px;
+ }
+ .summary-hero-section {
+ padding: 16px 18px 18px;
+ }
+ .summary-tags-section {
+ background: linear-gradient(180deg, #FFF 0%, #F9FBFE 100%);
+ }
+ .summary-section-label {
+ display: inline-flex;
+ align-items: center;
+ margin-bottom: 12px;
+ color: #6E849C;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+ .summary-kicker-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 18px;
+ }
+ .summary-install-key {
+ display: inline-flex;
+ align-items: center;
+ min-height: 28px;
+ padding: 0 12px;
+ border-radius: 999px;
+ border: 1px solid #D6E3F1;
+ background: #F5F9FF;
+ color: #42607B;
+ font-size: 13px;
+ font-weight: 500;
+ }
+ .summary-title-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 20px;
+ }
+ .summary-title-group {
+ flex: 1;
+ min-width: 0;
+ .ant-typography {
+ margin-bottom: 0;
+ }
+ h2.ant-typography {
+ margin-bottom: 12px;
+ color: #1F2D3D;
+ font-size: 30px;
+ line-height: 1.2;
+ }
+ }
+ .summary-description {
+ max-width: 700px;
+ margin-bottom: 0;
+ color: #5C7086;
+ font-size: 15px;
+ line-height: 1.75;
+ }
+ .summary-action-panel {
+ flex: 0 0 214px;
+ padding: 14px;
+ border: 1px solid #DEE8F3;
+ border-radius: 16px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #F3F8FE 100%);
+ }
+ .summary-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ .ant-btn {
+ width: 100%;
+ height: 36px;
+ border-radius: 10px;
+ }
+ }
+ .summary-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ }
+ .summary-meta-card {
+ padding: 16px 18px;
+ border-radius: 14px;
+ border: 1px solid #E7EEF6;
+ background: linear-gradient(180deg, #FFF 0%, #F7FAFD 100%);
+ min-width: 0;
+ &.is-accent {
+ background: linear-gradient(135deg, #EAF3FF 0%, #F7FAFF 100%);
+ border-color: #D7E6FA;
+ }
+ &.is-wide {
+ grid-column: span 2;
+ }
+ }
+ .summary-meta-label {
+ display: block;
+ margin-bottom: 8px;
+ color: #8A9AAE;
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ }
+ .summary-meta-value {
+ color: #23384D;
+ font-size: 15px;
+ font-weight: 600;
+ line-height: 1.6;
+ word-break: break-word;
+ .anticon {
+ margin-right: 6px;
+ color: #D9A441;
+ }
+ }
+ .summary-tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ .ant-tag {
+ margin-right: 0;
+ padding: 4px 10px;
+ border-radius: 999px;
+ border-color: #DBE6F2;
+ color: #48627B;
+ background: #F7FAFD;
+ }
+ }
+ .summary-install-card {
+ padding: 24px;
+ .ant-tabs-nav {
+ margin-bottom: 16px;
+ }
+ .ant-tabs-tab {
+ padding: 10px 0;
+ font-weight: 600;
+ }
+ .ant-tabs-ink-bar {
+ height: 3px;
+ border-radius: 999px;
+ background: #2F7BFF;
+ }
+ }
+ .summary-install-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+ }
+ .summary-install-title {
+ display: block;
+ margin-bottom: 8px;
+ color: #1F2D3D;
+ font-size: 20px;
+ font-weight: 700;
+ }
+ .summary-install-caption {
+ margin-bottom: 0;
+ color: #60748A;
+ line-height: 1.7;
+ }
+ .summary-status-tag {
+ display: inline-flex;
+ align-items: center;
+ height: 32px;
+ padding: 0 14px;
+ border-radius: 999px;
+ font-size: 13px;
+ font-weight: 600;
+ white-space: nowrap;
+ &.is-ready {
+ color: #1F5FA6;
+ background: #E8F2FF;
+ border: 1px solid #CFE0F7;
+ }
+ &.is-fallback {
+ color: #9A6414;
+ background: #FFF5E8;
+ border: 1px solid #F4DFC0;
+ }
+ }
+ .summary-install-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+ .summary-command-card {
+ padding: 16px;
+ border: 1px solid #E6EDF5;
+ border-radius: 16px;
+ background: linear-gradient(180deg, #F8FBFE 0%, #F3F8FD 100%);
+ }
+ .summary-command-header {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 10px;
+ .ant-typography-secondary {
+ color: #62778E;
+ }
+ }
+ .summary-command-title {
+ color: #29415A;
+ font-size: 14px;
+ font-weight: 600;
+ }
+ .summary-command-block {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ min-height: 58px;
+ padding: 8px 8px 8px 14px;
+ border-radius: 13px;
+ border: 1px solid #E2EAF3;
+ background: rgba(255, 255, 255, 0.98);
+ code {
+ flex: 1;
+ color: #1F2D3D;
+ white-space: pre-wrap;
+ word-break: break-all;
+ font-size: 13px;
+ line-height: 1.65;
+ }
+ }
+ .summary-command-copy-btn {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ min-width: 34px;
+ height: 34px;
+ padding: 0;
+ border: 1px solid #D7E1EC;
+ border-radius: 9px;
+ background: linear-gradient(180deg, #FFF 0%, #F6FAFE 100%);
+ color: #2E4A66;
+ box-shadow: 0 4px 10px rgba(31, 45, 61, 0.06);
+ &:hover,
+ &:focus {
+ color: #2F7BFF;
+ border-color: #2F7BFF;
+ background: #F4F9FF;
+ }
+ &[disabled] {
+ border-color: #E5E7EB;
+ background: #F8FAFC;
+ }
+ }
+
+ @media (max-width: 1100px) {
+ .skill-summary-shell {
+ grid-template-columns: 1fr;
+ }
+ .summary-title-row {
+ flex-direction: column;
+ }
+ .summary-action-panel {
+ flex-basis: auto;
+ width: 100%;
+ }
+ .summary-actions {
+ flex-flow: row wrap;
+ .ant-btn {
+ width: auto;
+ min-width: 156px;
+ }
+ }
+ }
+
+ @media (max-width: 768px) {
+ padding: 16px;
+ .summary-hero-card,
+ .summary-install-card {
+ padding: 18px;
+ }
+ .summary-meta-grid {
+ grid-template-columns: 1fr;
+ }
+ .summary-meta-card.is-wide {
+ grid-column: span 1;
+ }
+ .summary-actions {
+ flex-direction: column;
+ align-items: stretch;
+ .ant-btn {
+ width: 100%;
+ }
+ }
+ .summary-install-header {
+ flex-direction: column;
+ }
+ .summary-command-block {
+ grid-template-columns: 1fr;
+ }
+ .summary-command-copy-btn {
+ justify-self: end;
+ }
+ }
+}
diff --git a/app/web/pages/skills/index.tsx b/app/web/pages/skills/index.tsx
new file mode 100644
index 0000000..4c2cf71
--- /dev/null
+++ b/app/web/pages/skills/index.tsx
@@ -0,0 +1,574 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import {
+ DeleteOutlined,
+ FilterOutlined,
+ ImportOutlined,
+ SearchOutlined,
+ StarOutlined,
+ UploadOutlined,
+} from '@ant-design/icons';
+import {
+ Button,
+ Card,
+ Col,
+ Divider,
+ Empty,
+ Form,
+ Input,
+ message,
+ Modal,
+ Pagination,
+ Row,
+ Select,
+ Space,
+ Spin,
+ Tag,
+ Typography,
+ Upload,
+} from 'antd';
+
+import { API } from '@/api';
+import { SkillItem, SkillListResponse } from './types';
+import './style.scss';
+
+const { Search } = Input;
+const { Option } = Select;
+const { Paragraph, Text } = Typography;
+const FIXED_CATEGORY_OPTIONS = [
+ '通用',
+ '前端',
+ '后端',
+ '数据与AI',
+ '运维与系统',
+ '工程效率',
+ '安全',
+ '其他',
+];
+const INITIAL_QUERY = {
+ keyword: '',
+ sortBy: 'stars',
+ category: '',
+ pageNum: 1,
+ pageSize: 12,
+};
+
+const EditIcon = () => (
+
+);
+
+const SkillsMarket: React.FC = ({ history }) => {
+ const [loading, setLoading] = useState(false);
+ const [skills, setSkills] = useState([]);
+ const [categories, setCategories] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [importVisible, setImportVisible] = useState(false);
+ const [importing, setImporting] = useState(false);
+ const [uploadFiles, setUploadFiles] = useState([]);
+ const [editVisible, setEditVisible] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const [editUploadFiles, setEditUploadFiles] = useState([]);
+ const [editingSkill, setEditingSkill] = useState(null);
+ const [importForm] = Form.useForm();
+ const [editForm] = Form.useForm();
+ const [query, setQuery] = useState(INITIAL_QUERY);
+
+ const fetchSkills = useCallback(async (nextQuery) => {
+ setLoading(true);
+ try {
+ const response = await API.getSkillList(nextQuery);
+ if (response.success) {
+ const data: SkillListResponse = response.data;
+ setSkills(data.list || []);
+ setCategories(data.categories || []);
+ setTotal(data.total || 0);
+ } else {
+ message.error(response.msg || '获取 Skills 列表失败');
+ }
+ } catch (error) {
+ message.error('获取 Skills 列表失败');
+ console.error('获取 Skills 列表失败:', error);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchSkills(query);
+ }, [fetchSkills, query]);
+
+ const updateQueryAndFetch = (patch: Partial) => {
+ const next = { ...query, ...patch };
+ setQuery(next);
+ };
+
+ const openImportModal = () => {
+ setImportVisible(true);
+ setUploadFiles([]);
+ importForm.setFieldsValue({
+ category: '通用',
+ tags: [],
+ });
+ };
+
+ const closeImportModal = () => {
+ if (importing) return;
+ setImportVisible(false);
+ setUploadFiles([]);
+ importForm.resetFields();
+ };
+
+ const openEditModal = (skill: SkillItem) => {
+ setEditingSkill(skill);
+ setEditVisible(true);
+ setEditUploadFiles([]);
+ editForm.setFieldsValue({
+ name: skill.name,
+ category: skill.category || '通用',
+ tags: skill.tags || [],
+ version: skill.version || '',
+ });
+ };
+
+ const closeEditModal = () => {
+ if (editing) return;
+ setEditVisible(false);
+ setEditingSkill(null);
+ setEditUploadFiles([]);
+ editForm.resetFields();
+ };
+
+ const handleImportSkill = async () => {
+ try {
+ await importForm.validateFields();
+ setImporting(true);
+ const targetFile = uploadFiles[0]?.originFileObj;
+ if (!targetFile) {
+ message.error('请先选择 .zip 文件');
+ return;
+ }
+ const values = importForm.getFieldsValue();
+ const response = await API.importSkillFile({
+ file: targetFile,
+ skillName: values.skillName || '',
+ category: values.category,
+ tags: JSON.stringify(values.tags || []),
+ });
+
+ if (!response.success) {
+ message.error(response.msg || '导入失败');
+ return;
+ }
+
+ const importedCount = Number(response.data?.importedCount || 0);
+ if (importedCount > 0) {
+ message.success(`导入成功,新增 ${importedCount} 个技能`);
+ } else {
+ message.success('导入完成,技能可能已存在');
+ }
+ setImportVisible(false);
+ setUploadFiles([]);
+ importForm.resetFields();
+ fetchSkills({ ...query });
+ } catch (error) {
+ if (error?.errorFields) return;
+ message.error('导入失败,请检查文件或网络权限');
+ console.error('导入 Skill 失败:', error);
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const handleUpdateSkill = async () => {
+ if (!editingSkill) return;
+
+ try {
+ const values = await editForm.validateFields();
+ setEditing(true);
+ const targetFile = editUploadFiles[0]?.originFileObj;
+ const response = await API.updateSkill({
+ slug: editingSkill.slug,
+ name: values.name,
+ category: values.category,
+ tags: JSON.stringify(values.tags || []),
+ version: values.version || '',
+ file: targetFile,
+ });
+
+ if (!response.success) {
+ message.error(response.msg || '更新失败');
+ return;
+ }
+
+ message.success(targetFile ? '技能已更新并替换 zip 内容' : '技能信息已更新');
+ setEditVisible(false);
+ setEditingSkill(null);
+ setEditUploadFiles([]);
+ editForm.resetFields();
+ fetchSkills({ ...query });
+ } catch (error) {
+ if (error?.errorFields) return;
+ message.error('更新失败,请稍后重试');
+ console.error('更新 Skill 失败:', error);
+ } finally {
+ setEditing(false);
+ }
+ };
+
+ const handleDeleteSkill = (skill: SkillItem) => {
+ Modal.confirm({
+ title: `确认删除「${skill.name}」?`,
+ content: '删除后该技能将不再出现在列表和详情页中。',
+ okText: '删除',
+ okButtonProps: { danger: true },
+ cancelText: '取消',
+ onOk: async () => {
+ const response = await API.deleteSkill({ slug: skill.slug });
+ if (!response.success) {
+ message.error(response.msg || '删除失败');
+ return;
+ }
+ message.success('删除成功');
+ fetchSkills({ ...query });
+ },
+ });
+ };
+
+ return (
+
+
+
+
Skills 市场
+
发现、筛选并导入本地可用的 Skills 能力
+
+
+
+
+ }
+ onChange={(e) => setQuery({ ...query, keyword: e.target.value })}
+ onSearch={(value) => updateQueryAndFetch({ keyword: value, pageNum: 1 })}
+ />
+
+ }
+ className="import-btn"
+ onClick={openImportModal}
+ >
+ 导入技能
+
+
+ }
+ onChange={(value) =>
+ updateQueryAndFetch({ category: value || '', pageNum: 1 })
+ }
+ >
+ {categories.map((item) => (
+
+ ))}
+
+
+
+
+
+
+
+ {skills.length === 0 ? (
+
+ ) : (
+
+
+ {skills.map((skill) => (
+
+ history.push(`/page/skills/${skill.slug}`)}
+ >
+
+
{skill.name}
+
+
+
+ {skill.stars || 0}
+
+
+
+
+ {skill.description || '暂无描述'}
+
+
+ 来源:
+
+ {skill.sourceRepo || skill.sourcePath}
+
+
+
+ 更新:
+
+ {new Date(skill.updatedAt).toLocaleDateString(
+ 'zh-CN'
+ )}
+
+
+
+ {skill.category || '未分类'}
+ {skill.tags.slice(0, 3).map((tag) => (
+ {tag}
+ ))}
+
+
+
+ ))}
+
+
+
+
+ updateQueryAndFetch({ pageNum: page, pageSize: pageSize || 12 })
+ }
+ onShowSizeChange={(_, size) =>
+ updateQueryAndFetch({ pageNum: 1, pageSize: size })
+ }
+ />
+
+
+ )}
+
+
+
+
+ false}
+ onChange={(info) => setUploadFiles(info.fileList || [])}
+ >
+ }>选择 .zip 文件
+
+
+
+
+
+
+
+
+ {
+ if (!Array.isArray(value)) return Promise.resolve();
+ if (value.length > 5) {
+ return Promise.reject(new Error('标签最多 5 个'));
+ }
+ return Promise.resolve();
+ },
+ },
+ ]}
+ >
+
+
+
+ 提示:.zip 包内部应包含 `技能目录/SKILL.md`
+
+
+
+ }
+ disabled={!editingSkill || editing}
+ onClick={() => {
+ if (!editingSkill) return;
+ closeEditModal();
+ handleDeleteSkill(editingSkill);
+ }}
+ >
+ 删除
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+ {
+ if (!Array.isArray(value)) return Promise.resolve();
+ if (value.length > 5) {
+ return Promise.reject(new Error('标签最多 5 个'));
+ }
+ return Promise.resolve();
+ },
+ },
+ ]}
+ >
+
+
+
+
+
+
+ false}
+ onChange={(info) => setEditUploadFiles(info.fileList || [])}
+ >
+ }>选择新的 .zip 文件
+
+
+
+
+
+ );
+};
+
+export default SkillsMarket;
diff --git a/app/web/pages/skills/style.scss b/app/web/pages/skills/style.scss
new file mode 100644
index 0000000..2ae49fe
--- /dev/null
+++ b/app/web/pages/skills/style.scss
@@ -0,0 +1,216 @@
+.page-skills {
+ height: 100%;
+ min-height: 0;
+ padding: 20px 24px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ .skills-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-shrink: 0;
+ }
+ .title-group {
+ .page-title {
+ margin: 0;
+ color: #1F2D3D;
+ font-size: 28px;
+ font-weight: 700;
+ }
+ .page-subtitle {
+ margin: 8px 0 0;
+ color: #667085;
+ }
+ }
+ .search-filter-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ margin-top: 18px;
+ flex-shrink: 0;
+ .keyword-search {
+ width: 620px;
+ max-width: 100%;
+ .ant-input-group > .ant-input-affix-wrapper:not(:last-child) {
+ padding-left: 14px;
+ }
+ .ant-input-group > .ant-input-affix-wrapper:not(:last-child) .ant-input {
+ padding-left: 0;
+ }
+ }
+ .import-btn {
+ min-width: 100px;
+ }
+ }
+ .skill-card {
+ height: 100%;
+ border-radius: 10px;
+ border: 1px solid #EDF2F7;
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+ &:hover,
+ &:focus {
+ border-color: #C8DAEE;
+ box-shadow: 0 14px 32px rgba(31, 45, 61, 0.08);
+ transform: translateY(-2px);
+ }
+ &:focus {
+ outline: none;
+ }
+ .ant-card-body {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+ .card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+ }
+ .card-header-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ }
+ .skill-name {
+ color: #1F2D3D;
+ font-size: 16px;
+ font-weight: 600;
+ }
+ .card-edit-trigger {
+ height: 20px;
+ width: 20px;
+ min-width: 20px;
+ padding: 0;
+ color: #667085;
+ font-size: 12px;
+ line-height: 1;
+ border: none;
+ background: transparent;
+ box-shadow: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+ .card-edit-trigger:hover,
+ .card-edit-trigger:focus {
+ color: #2F7CF6;
+ background: transparent;
+ }
+ .meta-stars {
+ color: #E6A23C;
+ font-weight: 600;
+ }
+ .skill-desc {
+ margin-bottom: 12px;
+ color: #475467;
+ line-height: 1.6;
+ min-height: calc(1.6em * 3);
+ max-height: calc(1.6em * 3);
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ word-break: break-word;
+ }
+ .meta-row {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+ gap: 8px;
+ .meta-value {
+ flex: 1;
+ color: #344054;
+ }
+ }
+ .tag-row {
+ min-height: 28px;
+ margin-bottom: 10px;
+ }
+ }
+ > .ant-divider {
+ flex-shrink: 0;
+ }
+ > .ant-spin-nested-loading {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ }
+ > .ant-spin-nested-loading > .ant-spin-container {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ }
+ .skills-list-section {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 16px;
+ }
+ .skills-grid {
+ flex-shrink: 0;
+ }
+ .pagination-wrap {
+ display: flex;
+ justify-content: flex-end;
+ flex-shrink: 0;
+ margin-top: auto;
+ padding-bottom: 4px;
+ }
+}
+
+.skill-detail-modal {
+ .ant-modal-content {
+ overflow: hidden;
+ border-radius: 24px;
+ box-shadow: 0 24px 60px rgba(31, 45, 61, 0.16);
+ }
+ .ant-modal-close {
+ top: 16px;
+ right: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 34px;
+ height: 34px;
+ line-height: 1;
+ border: 1px solid #DAE5F0;
+ border-radius: 50%;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #F2F7FD 100%);
+ box-shadow: 0 8px 18px rgba(31, 45, 61, 0.08);
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+ &:hover {
+ border-color: #BFD3EA;
+ box-shadow: 0 10px 20px rgba(31, 45, 61, 0.12);
+ transform: translateY(-1px);
+ }
+ }
+ .ant-modal-close-x {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ font-size: 14px;
+ color: #48627B;
+ }
+ .ant-modal-body {
+ background: linear-gradient(180deg, #F4F8FC 0%, #F8FAFC 100%);
+ }
+ .skill-detail-modal-inner {
+ padding: 0;
+ }
+}
+
+.skill-edit-modal-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+}
diff --git a/app/web/pages/skills/types.ts b/app/web/pages/skills/types.ts
new file mode 100644
index 0000000..8556cf4
--- /dev/null
+++ b/app/web/pages/skills/types.ts
@@ -0,0 +1,55 @@
+export interface SkillItem {
+ slug: string;
+ installKey: string;
+ name: string;
+ description: string;
+ category: string;
+ version: string;
+ tags: string[];
+ allowedTools: string[];
+ stars: number;
+ updatedAt: string;
+ sourceRepo: string;
+ sourcePath: string;
+ installCommand: string;
+}
+
+export interface SkillListResponse {
+ list: SkillItem[];
+ total: number;
+ pageNum: number;
+ pageSize: number;
+ categories: string[];
+}
+
+export interface SkillDetail extends SkillItem {
+ fileList: string[];
+ skillMd: string;
+}
+
+export interface SkillFileContent {
+ slug: string;
+ path: string;
+ language: string;
+ size: number;
+ readonly: boolean;
+ isBinary: boolean;
+ encoding: 'utf8' | 'base64';
+ content: string;
+}
+
+export interface SkillInstallMeta {
+ slug: string;
+ installKey: string;
+ name: string;
+ downloadUrl: string;
+ packageType: string;
+ packageVersion: string;
+ packageRootMode: string;
+ installDirName: string;
+ version: string;
+ sha256: string;
+ sourceRepo: string;
+ installable: boolean;
+ reason: string;
+}
diff --git a/app/web/router/index.ts b/app/web/router/index.ts
index 443b577..95d3d3e 100644
--- a/app/web/router/index.ts
+++ b/app/web/router/index.ts
@@ -1,10 +1,10 @@
+import Loadable from 'react-loadable';
+
import BasicLayout from '@/layouts/basicLayout';
// 文章订阅管理
import ArticleSubscriptionList from '@/pages/articleSubscription';
// 配置中心
import ConfigCenter from '@/pages/configCenter';
-// 配置详情
-import ConfigDetail from '@/pages/configDetail';
// 环境管理
import EnvManagement from '@/pages/envManagement';
import NotFound from '@/pages/exception/404';
@@ -22,14 +22,24 @@ import McpServerMarket from '@/pages/mcpServer/mcpMarket';
import McpServerRegistryCenter from '@/pages/mcpServer/registryCenter';
// 代理服务
import ProxyServer from '@/pages/proxyServer';
+import SkillsMarket from '@/pages/skills';
+import SkillDetail from '@/pages/skills/detail';
// hosts列表
import SwitchHostsList from '@/pages/switchHosts';
-// hosts编辑
-import SwitchHostsEdit from '@/pages/switchHosts/editHosts';
import TagsManagement from '@/pages/tagsManagement';
// 工具箱
import Toolbox from '@/pages/toolbox';
+const ConfigDetail = Loadable({
+ loader: () => import('@/pages/configDetail'),
+ loading: () => null,
+});
+
+const SwitchHostsEdit = Loadable({
+ loader: () => import('@/pages/switchHosts/editHosts'),
+ loading: () => null,
+});
+
const urlPrefix = '/page';
const routes: any = [
{
@@ -112,6 +122,14 @@ const routes: any = [
path: `${urlPrefix}/mcp-server-management`,
component: McpServerManagement,
},
+ {
+ path: `${urlPrefix}/skills/:slug`,
+ component: SkillDetail,
+ },
+ {
+ path: `${urlPrefix}/skills`,
+ component: SkillsMarket,
+ },
{
path: '*',
component: NotFound,
diff --git a/app/web/scss/reset.scss b/app/web/scss/reset.scss
index 2725a4f..c48c346 100644
--- a/app/web/scss/reset.scss
+++ b/app/web/scss/reset.scss
@@ -2,6 +2,9 @@ html,
body,
#app {
height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
}
iframe {
diff --git a/app/web/utils/http.ts b/app/web/utils/http.ts
index ec2c23b..6b957fd 100644
--- a/app/web/utils/http.ts
+++ b/app/web/utils/http.ts
@@ -76,7 +76,8 @@ class Http {
.then(authAfterRes)
.catch((err: any) => {
console.error('错误信息:', JSON.stringify(err));
- this.handleExcept(err); // 开发环境可讲此方法注视
+ this.handleExcept(err);
+ throw err; // 传播错误,让调用方能 catch 到
});
}
handleExcept(e: any) {
diff --git a/bin/doraemon-skills b/bin/doraemon-skills
new file mode 100755
index 0000000..8758d89
--- /dev/null
+++ b/bin/doraemon-skills
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+
+require('../scripts/doraemon-skills');
diff --git a/config/config.default.js b/config/config.default.js
index 64c2f9f..2fc20d7 100644
--- a/config/config.default.js
+++ b/config/config.default.js
@@ -38,6 +38,11 @@ module.exports = (app) => {
owner: 'dtux-kangaroo',
configRepositoryName: 'ko-config',
};
+ exports.skills = {
+ gitlabToken: process.env.GITLAB_TOKEN || '',
+ githubToken: process.env.GITHUB_TOKEN || '',
+ gitlabHostWhitelist: ['gitlab.prod.dtstack.cn'],
+ };
exports.middleware = ['access'];
@@ -58,7 +63,7 @@ module.exports = (app) => {
// 文件上传
fileSize: '200mb',
mode: 'file', // 使用文件模式,直接保存到临时文件
- fileExtensions: ['.zip', '.tar', '.gz', '.tgz'], // 允许的文件扩展名
+ fileExtensions: ['.zip', '.tar', '.gz', '.tgz', '.skill'], // 允许的文件扩展名
tmpdir: path.join(app.baseDir, 'cache/uploads'), // 临时文件目录
fields: 100, // 允许的最多字段数量
cleanSchedule: {
@@ -71,6 +76,7 @@ module.exports = (app) => {
'.tar',
'.gz',
'.tgz',
+ '.skill',
],
};
diff --git a/package.json b/package.json
index 9b10a06..364ec64 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,9 @@
"log": "conventional-changelog --config ./node_modules/vue-cli-plugin-commitlint/lib/log -i CHANGELOG.md -s -r 0",
"cz": "npm run log && git add . && git cz"
},
+ "bin": {
+ "doraemon-skills": "bin/doraemon-skills"
+ },
"dependencies": {
"@ant-design/icons": "4.5.0",
"@modelcontextprotocol/sdk": "^1.25.2",
diff --git a/scripts/doraemon-skills-lib.js b/scripts/doraemon-skills-lib.js
new file mode 100644
index 0000000..1716aac
--- /dev/null
+++ b/scripts/doraemon-skills-lib.js
@@ -0,0 +1,317 @@
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const AdmZip = require('adm-zip');
+const fetch = require('node-fetch');
+
+function fail(message) {
+ throw new Error(message);
+}
+
+function normalizeServerUrl(server) {
+ if (!server) {
+ fail(
+ 'Server is required. Provide --server , set DORAEMON_SKILLS_SERVER, or configure ~/.doraemon/skills.json.'
+ );
+ }
+ try {
+ const parsed = new URL(server);
+ return parsed.toString();
+ } catch (error) {
+ fail(`Invalid server URL: ${server}`);
+ }
+}
+
+function parseArgs(argv) {
+ const [command, ...rest] = argv;
+ const options = {};
+ const positionals = [];
+
+ for (let i = 0; i < rest.length; i++) {
+ const token = rest[i];
+ if (token === '--server' || token === '--dir') {
+ const value = rest[i + 1];
+ if (!value || value.startsWith('--')) {
+ fail(`Missing value for ${token}`);
+ }
+ options[token.slice(2)] = value;
+ i += 1;
+ continue;
+ }
+ if (token.startsWith('--')) {
+ fail(`Unknown option: ${token}`);
+ }
+ positionals.push(token);
+ }
+
+ return {
+ command,
+ positionals,
+ options,
+ };
+}
+
+function resolveInstallRoot(dirOption) {
+ const root = dirOption || './skills';
+ return path.resolve(process.cwd(), root);
+}
+
+function readBootstrapConfigServer({ homedir = os.homedir } = {}) {
+ const configPath = path.join(homedir(), '.doraemon', 'skills.json');
+ if (!fs.existsSync(configPath)) {
+ return '';
+ }
+
+ try {
+ const payload = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ return String(payload && payload.server ? payload.server : '').trim();
+ } catch (error) {
+ fail(`Invalid bootstrap config: ${configPath}`);
+ }
+}
+
+function resolveServer(options = {}, { env = process.env, homedir = os.homedir } = {}) {
+ const value =
+ options.server || env.DORAEMON_SKILLS_SERVER || readBootstrapConfigServer({ homedir });
+ return normalizeServerUrl(value);
+}
+
+function parseResponsePayload(payload) {
+ if (!payload || typeof payload !== 'object') {
+ fail('Invalid server response payload.');
+ }
+ if (payload.success !== true) {
+ const message = payload.message || payload.msg || 'Request failed.';
+ fail(message);
+ }
+ return payload.data;
+}
+
+async function readJsonResponse(response) {
+ const raw = await response.text();
+ let payload = null;
+ try {
+ payload = raw ? JSON.parse(raw) : null;
+ } catch (error) {
+ if (!response.ok) {
+ fail(`Request failed with status ${response.status}.`);
+ }
+ fail('Invalid JSON response.');
+ }
+ return payload;
+}
+
+function buildInstallMetaUrl(server, installKey) {
+ const metaUrl = new URL('/api/skills/install-meta', server);
+ metaUrl.searchParams.set('installKey', installKey);
+ return metaUrl;
+}
+
+async function requestInstallMeta(server, installKey) {
+ const metaUrl = buildInstallMetaUrl(server, installKey);
+
+ let response;
+ try {
+ response = await fetch(metaUrl.toString());
+ } catch (error) {
+ fail(`Failed to request install-meta: ${error.message}`);
+ }
+
+ const payload = await readJsonResponse(response);
+ if (!response.ok) {
+ const message =
+ payload && (payload.message || payload.msg)
+ ? payload.message || payload.msg
+ : `Request failed with status ${response.status}.`;
+ fail(message);
+ }
+
+ return parseResponsePayload(payload);
+}
+
+function findSkillRootBySkillMd(baseDir) {
+ const queue = [baseDir];
+ const skillMdDirs = [];
+
+ while (queue.length) {
+ const current = queue.shift();
+ const entries = fs.readdirSync(current, { withFileTypes: true });
+ let hasSkillMd = false;
+
+ for (const entry of entries) {
+ if (entry.isFile() && entry.name.toLowerCase() === 'skill.md') {
+ hasSkillMd = true;
+ break;
+ }
+ }
+ if (hasSkillMd) {
+ skillMdDirs.push(current);
+ continue;
+ }
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+ queue.push(path.join(current, entry.name));
+ }
+ }
+
+ if (skillMdDirs.length === 0) {
+ fail('Invalid package: SKILL.md not found.');
+ }
+
+ skillMdDirs.sort((a, b) => {
+ const depthDiff = a.split(path.sep).length - b.split(path.sep).length;
+ if (depthDiff !== 0) {
+ return depthDiff;
+ }
+ return a.localeCompare(b);
+ });
+
+ return skillMdDirs[0];
+}
+
+function ensureZipBuffer(buffer) {
+ if (!Buffer.isBuffer(buffer) || buffer.length < 2) {
+ fail('Download failed: package is not a zip archive.');
+ }
+ if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) {
+ fail('Download failed: package is not a zip archive.');
+ }
+}
+
+async function downloadArchive(downloadUrl) {
+ let response;
+ try {
+ response = await fetch(downloadUrl);
+ } catch (error) {
+ fail(`Failed to download package: ${error.message}`);
+ }
+
+ if (!response.ok) {
+ fail(`Failed to download package: HTTP ${response.status}`);
+ }
+
+ const buffer = await response.buffer();
+ ensureZipBuffer(buffer);
+ return buffer;
+}
+
+function extractArchive(buffer, tempDir) {
+ try {
+ const zip = new AdmZip(buffer);
+ zip.extractAllTo(tempDir, true);
+ } catch (error) {
+ fail(`Failed to extract zip: ${error.message}`);
+ }
+}
+
+function installFromSkillRoot(skillRoot, targetDir) {
+ if (fs.existsSync(targetDir)) {
+ fail(`Target directory already exists: ${targetDir}`);
+ }
+ fs.mkdirSync(path.dirname(targetDir), { recursive: true });
+ fs.cpSync(skillRoot, targetDir, { recursive: true });
+}
+
+async function runInstall(positionals, options) {
+ const installKey = positionals[0];
+ if (!installKey) {
+ fail('Usage: doraemon-skills install [--server ] [--dir ]');
+ }
+
+ const server = resolveServer(options);
+ const installRoot = resolveInstallRoot(options.dir);
+ const meta = await requestInstallMeta(server, installKey);
+
+ if (meta.installable === false) {
+ fail(`Skill is not installable: ${meta.reason || 'unknown reason'}`);
+ }
+ if (meta.packageType !== 'zip') {
+ fail(`Unsupported packageType: ${meta.packageType}`);
+ }
+ if (meta.packageRootMode !== 'find-skill-md') {
+ fail(`Unsupported packageRootMode: ${meta.packageRootMode}`);
+ }
+ if (!meta.downloadUrl) {
+ fail('install-meta missing downloadUrl.');
+ }
+ if (!meta.installDirName) {
+ fail('install-meta missing installDirName.');
+ }
+
+ const downloadUrl = new URL(meta.downloadUrl, server).toString();
+ const targetDir = path.resolve(installRoot, meta.installDirName);
+
+ console.log(`Downloading: ${downloadUrl}`);
+ const buffer = await downloadArchive(downloadUrl);
+
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doraemon-skills-'));
+ try {
+ extractArchive(buffer, tempDir);
+ const skillRoot = findSkillRootBySkillMd(tempDir);
+ installFromSkillRoot(skillRoot, targetDir);
+ } finally {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+
+ console.log(`Installed: ${meta.installKey || installKey} -> ${targetDir}`);
+}
+
+function runList(options) {
+ const installRoot = resolveInstallRoot(options.dir);
+ if (!fs.existsSync(installRoot)) {
+ return;
+ }
+
+ const names = fs
+ .readdirSync(installRoot, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort((a, b) => a.localeCompare(b));
+
+ for (const name of names) {
+ console.log(name);
+ }
+}
+
+async function main(argv = process.argv.slice(2)) {
+ const { command, positionals, options } = parseArgs(argv);
+
+ if (!command) {
+ fail('Usage: doraemon-skills ...');
+ }
+
+ if (command === 'install') {
+ await runInstall(positionals, options);
+ return;
+ }
+ if (command === 'list') {
+ runList(options);
+ return;
+ }
+
+ fail(`Unknown command: ${command}`);
+}
+
+module.exports = {
+ buildInstallMetaUrl,
+ downloadArchive,
+ ensureZipBuffer,
+ extractArchive,
+ fail,
+ findSkillRootBySkillMd,
+ installFromSkillRoot,
+ main,
+ normalizeServerUrl,
+ parseArgs,
+ parseResponsePayload,
+ readBootstrapConfigServer,
+ readJsonResponse,
+ requestInstallMeta,
+ resolveInstallRoot,
+ resolveServer,
+ runInstall,
+ runList,
+};
diff --git a/scripts/doraemon-skills.js b/scripts/doraemon-skills.js
new file mode 100755
index 0000000..66ebc08
--- /dev/null
+++ b/scripts/doraemon-skills.js
@@ -0,0 +1,8 @@
+#!/usr/bin/env node
+
+const cli = require('./doraemon-skills-lib');
+
+cli.main().catch((error) => {
+ console.error(`Error: ${error.message}`);
+ process.exitCode = 1;
+});
diff --git a/sql/doraemon.sql b/sql/doraemon.sql
index 4dcfb47..38260b4 100644
--- a/sql/doraemon.sql
+++ b/sql/doraemon.sql
@@ -292,4 +292,99 @@ CREATE TABLE IF NOT EXISTS `mcp_servers` (
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_server_id` (`server_id`)
- ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COLLATE = utf8_bin
+ ) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COLLATE = utf8_bin;
+
+-- ----------------------------
+-- Table structure for skills_sources
+-- ----------------------------
+DROP TABLE IF EXISTS `skills_sources`;
+CREATE TABLE `skills_sources` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `source_url` varchar(512) NOT NULL COMMENT '用户输入的来源地址',
+ `source_type` varchar(32) NOT NULL DEFAULT 'git' COMMENT '来源类型 github/gitlab/git/local',
+ `clone_url` varchar(1000) NOT NULL COMMENT '用于 clone 的仓库地址',
+ `source_repo` varchar(1000) NOT NULL COMMENT '用于安装命令展示的仓库地址',
+ `ref` varchar(255) DEFAULT NULL COMMENT '分支或标签',
+ `subpath` varchar(1000) DEFAULT NULL COMMENT '仓库内相对子目录',
+ `repo_host` varchar(255) DEFAULT NULL COMMENT '仓库域名',
+ `repo_path` varchar(500) DEFAULT NULL COMMENT '仓库路径 owner/repo 或 group/subgroup/repo',
+ `sync_status` varchar(32) NOT NULL DEFAULT 'idle' COMMENT '同步状态 idle/syncing/failed',
+ `sync_error` text COMMENT '最近一次同步错误',
+ `last_synced_at` datetime DEFAULT NULL COMMENT '最近同步时间',
+ `is_delete` tinyint NOT NULL DEFAULT '0',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_skills_source_url` (`source_url`),
+ KEY `idx_skills_repo_host` (`repo_host`),
+ KEY `idx_skills_repo_path` (`repo_path`),
+ KEY `idx_skills_sync_status` (`sync_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='技能来源表';
+
+-- ----------------------------
+-- Table structure for skills_items
+-- ----------------------------
+DROP TABLE IF EXISTS `skills_items`;
+CREATE TABLE `skills_items` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `source_id` int NOT NULL COMMENT '来源记录ID',
+ `slug` varchar(255) NOT NULL COMMENT '详情页唯一标识',
+ `name` varchar(255) NOT NULL,
+ `description` text,
+ `category` varchar(64) NOT NULL DEFAULT '通用',
+ `version` varchar(128) NOT NULL DEFAULT '' COMMENT '技能版本号',
+ `tags` longtext COMMENT 'JSON字符串数组',
+ `allowed_tools` longtext COMMENT 'JSON字符串数组',
+ `stars` int NOT NULL DEFAULT '0',
+ `updated_at_remote` datetime DEFAULT NULL COMMENT '源仓库文件更新时间',
+ `source_repo` varchar(1000) DEFAULT NULL COMMENT '仓库地址',
+ `source_path` varchar(1000) DEFAULT NULL COMMENT '仓库内 skill 相对路径',
+ `skill_md` longtext COMMENT 'SKILL.md 原文',
+ `install_command` text COMMENT '推荐安装命令',
+ `file_count` int NOT NULL DEFAULT '0',
+ `is_delete` tinyint NOT NULL DEFAULT '0',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_skills_slug` (`slug`),
+ KEY `idx_skills_source_id` (`source_id`),
+ KEY `idx_skills_category` (`category`),
+ KEY `idx_skills_stars` (`stars`),
+ KEY `idx_skills_updated_at_remote` (`updated_at_remote`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='技能条目表';
+
+-- ----------------------------
+-- Table structure for skills_files
+-- ----------------------------
+DROP TABLE IF EXISTS `skills_files`;
+CREATE TABLE `skills_files` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `skill_id` int NOT NULL COMMENT 'skills_items.id',
+ `file_path` varchar(512) NOT NULL COMMENT '文件相对路径',
+ `language` varchar(64) NOT NULL DEFAULT 'text',
+ `size` int NOT NULL DEFAULT '0',
+ `is_binary` tinyint NOT NULL DEFAULT '0',
+ `encoding` varchar(16) NOT NULL DEFAULT 'utf8',
+ `content` longtext COMMENT '文本内容或base64内容',
+ `updated_at_remote` datetime DEFAULT NULL COMMENT '源仓库文件更新时间',
+ `is_delete` tinyint NOT NULL DEFAULT '0',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_skills_file_skill_id` (`skill_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='技能文件表';
+
+-- ----------------------------
+-- Table structure for skill_likes
+-- ----------------------------
+DROP TABLE IF EXISTS `skill_likes`;
+CREATE TABLE `skill_likes` (
+ `id` int NOT NULL AUTO_INCREMENT,
+ `skill_id` int NOT NULL COMMENT '技能ID',
+ `ip` varchar(64) NOT NULL COMMENT '点赞用户IP',
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_skill_like_skill_ip` (`skill_id`, `ip`),
+ KEY `idx_skill_like_skill_id` (`skill_id`),
+ KEY `idx_skill_like_ip` (`ip`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='技能点赞表';
diff --git a/test/doraemon-skills-config.test.js b/test/doraemon-skills-config.test.js
new file mode 100644
index 0000000..38fb461
--- /dev/null
+++ b/test/doraemon-skills-config.test.js
@@ -0,0 +1,44 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const cliLib = require('../scripts/doraemon-skills-lib');
+
+test('resolveServer falls back to bootstrap-written config when option and env are absent', () => {
+ assert.equal(typeof cliLib.resolveServer, 'function');
+ assert.equal(typeof cliLib.readBootstrapConfigServer, 'function');
+
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'doraemon-cli-home-'));
+ const configDir = path.join(tempHome, '.doraemon');
+ fs.mkdirSync(configDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(configDir, 'skills.json'),
+ JSON.stringify({ server: 'https://doraemon.example.com' }),
+ 'utf8'
+ );
+
+ const server = cliLib.resolveServer(
+ {},
+ {
+ env: {},
+ homedir: () => tempHome,
+ }
+ );
+
+ assert.equal(server, 'https://doraemon.example.com/');
+});
+
+test('buildInstallMetaUrl queries install-meta with installKey instead of exposing slug semantics', () => {
+ assert.equal(typeof cliLib.buildInstallMetaUrl, 'function');
+
+ const metaUrl = cliLib.buildInstallMetaUrl('https://doraemon.example.com', 'skill-creator');
+
+ assert.equal(
+ metaUrl.toString(),
+ 'https://doraemon.example.com/api/skills/install-meta?installKey=skill-creator'
+ );
+ assert.equal(metaUrl.searchParams.get('installKey'), 'skill-creator');
+ assert.equal(metaUrl.searchParams.get('slug'), null);
+});
diff --git a/test/skills-install-key.test.js b/test/skills-install-key.test.js
new file mode 100644
index 0000000..24e24d1
--- /dev/null
+++ b/test/skills-install-key.test.js
@@ -0,0 +1,178 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+
+const skillsModule = require('../app/service/skills');
+const SkillsService = skillsModule;
+
+test('createInstallKeyMap derives user-facing install keys and keeps them unique', () => {
+ assert.equal(typeof skillsModule.createInstallKeyMap, 'function');
+
+ const result = skillsModule.createInstallKeyMap([
+ {
+ slug: 'upload-skill-creator-default-skill-creator',
+ name: 'skill-creator',
+ sourcePath: 'skills/skill-creator',
+ },
+ {
+ slug: 'upload-skill-creator-default-skill-creator-2',
+ name: 'skill creator',
+ sourcePath: 'skills/skill-creator-alt',
+ },
+ ]);
+
+ assert.equal(
+ result.bySlug.get('upload-skill-creator-default-skill-creator').installKey,
+ 'skill-creator'
+ );
+ assert.equal(
+ result.bySlug.get('upload-skill-creator-default-skill-creator-2').installKey,
+ 'skill-creator-alt'
+ );
+ assert.equal(
+ result.byInstallKey.get('skill-creator').slug,
+ 'upload-skill-creator-default-skill-creator'
+ );
+ assert.equal(
+ result.byInstallKey.get('skill-creator-alt').slug,
+ 'upload-skill-creator-default-skill-creator-2'
+ );
+});
+
+test('resolveSkillIdentifier accepts installKey without exposing internal slug', () => {
+ assert.equal(typeof skillsModule.resolveSkillIdentifier, 'function');
+
+ const skill = skillsModule.resolveSkillIdentifier('skill-creator', {
+ bySlug: new Map([
+ [
+ 'upload-skill-creator-default-skill-creator',
+ {
+ slug: 'upload-skill-creator-default-skill-creator',
+ installKey: 'skill-creator',
+ },
+ ],
+ ]),
+ byInstallKey: new Map([
+ [
+ 'skill-creator',
+ {
+ slug: 'upload-skill-creator-default-skill-creator',
+ installKey: 'skill-creator',
+ },
+ ],
+ ]),
+ });
+
+ assert.equal(skill.slug, 'upload-skill-creator-default-skill-creator');
+ assert.equal(skill.installKey, 'skill-creator');
+});
+
+test('getInstallMeta returns installKey and installDirName aligned to user-facing identifier', async () => {
+ const service = Object.create(SkillsService.prototype);
+ service.ctx = {
+ throw(status, message) {
+ const error = new Error(message);
+ error.status = status;
+ throw error;
+ },
+ };
+ service.skillCache = {
+ bySlug: new Map(),
+ byInstallKey: new Map(),
+ };
+ service.ensureSkillCache = async () => {};
+ service.getSkillPackageInstallability = async () => ({ installable: true, reason: '' });
+ service.getSkillArchive = async () => ({ content: Buffer.from('zip-content') });
+ service.buildSkillDownloadUrl = (slug) =>
+ `https://doraemon.test/api/skills/download?slug=${slug}`;
+
+ const skill = {
+ id: 1,
+ slug: 'upload-skill-creator-default-skill-creator',
+ installKey: 'skill-creator',
+ name: 'skill-creator',
+ sourceRepo: '',
+ };
+ service.skillCache.bySlug.set(skill.slug, skill);
+ service.skillCache.byInstallKey.set(skill.installKey, skill);
+
+ const meta = await service.getInstallMeta('skill-creator');
+
+ assert.equal(meta.slug, 'upload-skill-creator-default-skill-creator');
+ assert.equal(meta.installKey, 'skill-creator');
+ assert.equal(meta.installDirName, 'skill-creator');
+ assert.equal(
+ meta.downloadUrl,
+ 'https://doraemon.test/api/skills/download?slug=upload-skill-creator-default-skill-creator'
+ );
+});
+
+test('buildUploadSourceMeta keeps same zip with different custom names isolated', () => {
+ const service = Object.create(SkillsService.prototype);
+ service.hashString = SkillsService.prototype.hashString;
+ service.sanitizeSlugSegment = SkillsService.prototype.sanitizeSlugSegment;
+
+ const first = service.buildUploadSourceMeta('skill-creator.zip', 'skill-creator-a');
+ const second = service.buildUploadSourceMeta('skill-creator.zip', 'skill-creator-b');
+
+ assert.notEqual(first.sourceUrl, second.sourceUrl);
+ assert.notEqual(first.repoPath, second.repoPath);
+});
+
+test('assertSkillNamesUnique rejects duplicated names in one import batch', async () => {
+ const service = Object.create(SkillsService.prototype);
+ service.ctx = {
+ throw(status, message) {
+ const error = new Error(message);
+ error.status = status;
+ throw error;
+ },
+ };
+ service.app = {
+ model: {
+ SkillsItem: {
+ findOne: async () => null,
+ },
+ },
+ Sequelize: {
+ Op: {
+ in: 'in',
+ ne: 'ne',
+ },
+ },
+ };
+
+ await assert.rejects(
+ () => service.assertSkillNamesUnique(['skill-a', 'skill-a']),
+ (error) => error.status === 400 && error.message === '导入失败:技能名称不能重复'
+ );
+});
+
+test('assertSkillNamesUnique rejects existing skill name', async () => {
+ const service = Object.create(SkillsService.prototype);
+ service.ctx = {
+ throw(status, message) {
+ const error = new Error(message);
+ error.status = status;
+ throw error;
+ },
+ };
+ service.app = {
+ model: {
+ SkillsItem: {
+ findOne: async () => ({ id: 9, name: 'skill-creator' }),
+ },
+ },
+ Sequelize: {
+ Op: {
+ in: 'in',
+ ne: 'ne',
+ },
+ },
+ };
+
+ await assert.rejects(
+ () => service.assertSkillNamesUnique(['skill-creator']),
+ (error) =>
+ error.status === 400 && error.message === '技能名称“skill-creator”已存在,请更换名称'
+ );
+});