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 || '暂无可复制命令'} +
+
+
+ ); + + const renderTerminalCommand = (command: string, copyMessage: string) => ( +
+
+
+ + + +
+ BASH +
+
+ $ + {command || '暂无可复制命令'} +
+
+ ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!detail) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+ + +
+
+
+
+ +
+ {detail.name} + + {heroSummary} + +
+
+ +
+ +
+
+ +
+ {heroMetaItems.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+
+ +
+
+
+ + {uiSelectedFilePath || 'SKILL.md'} +
+
+ {fileLoading ? ( + + + + ) : null} + + + +
+
+ +
+
{renderFileViewer()}
+
+
+
+ + +
+
+ ); +}; + +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 || '暂无可复制命令'} +
+
+ ); + + 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 || '暂无描述'} + +
+ +
+ 快捷操作 +
+ + + +
+
+
+
+ +
+ 快速概览 +
+ {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 })} + /> + + + + + +
+ + + + + {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 || [])} + > + + + + + + + + + + { + if (!Array.isArray(value)) return Promise.resolve(); + if (value.length > 5) { + return Promise.reject(new Error('标签最多 5 个')); + } + return Promise.resolve(); + }, + }, + ]} + > + + + + + + { + 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 || [])} + > + + + +
+
+
+ ); +}; + +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”已存在,请更换名称' + ); +});