diff --git a/app/controller/agents.js b/app/controller/agents.js index e945de1..983178b 100644 --- a/app/controller/agents.js +++ b/app/controller/agents.js @@ -12,12 +12,6 @@ class AgentsController extends Controller { this.ctx.body = this.app.utils.response(true, data); } - async getRelatedAgents() { - const { name, limit = 3 } = this.ctx.query; - const data = await this.ctx.service.agents.getRelatedAgents(name, limit); - this.ctx.body = this.app.utils.response(true, data); - } - async getAgentAsset() { const { stream, mimeType, cacheControl } = await this.ctx.service.agents.getAgentAssetStream(this.ctx.query); @@ -35,6 +29,12 @@ class AgentsController extends Controller { this.ctx.body = stream; } + async getRelatedAgents() { + const { name, limit = 3 } = this.ctx.query; + const data = await this.ctx.service.agents.getRelatedAgents(name, limit); + this.ctx.body = this.app.utils.response(true, data); + } + async importAgentFile() { const files = this.ctx.request.files ? Array.isArray(this.ctx.request.files) @@ -54,11 +54,14 @@ class AgentsController extends Controller { ); this.ctx.body = this.app.utils.response(true, data); } finally { - if (file?.filepath && fs.existsSync(file.filepath)) { - try { - fs.unlinkSync(file.filepath); - } catch (error) { - this.ctx.logger.warn(`[agents] 清理上传文件失败: ${error.message}`); + // 清理本次请求上传的所有临时文件,防止多文件或异常时泄漏 + for (const item of files) { + if (item?.filepath && fs.existsSync(item.filepath)) { + try { + fs.unlinkSync(item.filepath); + } catch (error) { + this.ctx.logger.warn(`[agents] 清理上传文件失败: ${error.message}`); + } } } } diff --git a/app/model/agent.js b/app/model/agent.js index 8912de8..d9ec191 100644 --- a/app/model/agent.js +++ b/app/model/agent.js @@ -56,26 +56,6 @@ module.exports = (app) => { type: TEXT('long'), comment: 'JSON 字符串数组', }, - demo_images: { - type: TEXT('long'), - comment: 'JSON 字符串数组', - }, - entrypoint_host: { - type: STRING(64), - comment: '入口宿主', - }, - entrypoint_type: { - type: STRING(64), - comment: '入口类型', - }, - entrypoint_name: { - type: STRING(255), - comment: '入口名称', - }, - entrypoint_ref: { - type: STRING(1000), - comment: '入口路径', - }, logo_path: { type: STRING(1000), comment: 'Logo 相对路径', diff --git a/app/model/agent_skill.js b/app/model/agent_skill.js index 09b68e6..2a2b5fc 100644 --- a/app/model/agent_skill.js +++ b/app/model/agent_skill.js @@ -17,23 +17,7 @@ module.exports = (app) => { skill_slug: { type: STRING(255), allowNull: false, - comment: 'Skill slug', - }, - skill_id: { - type: INTEGER, - allowNull: true, - comment: 'skills_items.id', - }, - relation_type: { - type: STRING(20), - allowNull: false, - comment: 'entrypoint、dependency 或 private', - }, - sort_order: { - type: INTEGER, - allowNull: false, - defaultValue: 0, - comment: '展示顺序', + comment: 'Skill 标识(包内 SKILL.md 解析出的 name 或目录名)', }, created_at: { type: DATE, @@ -52,11 +36,7 @@ module.exports = (app) => { timestamps: true, createdAt: 'created_at', updatedAt: 'updated_at', - indexes: [ - { fields: ['agent_id'] }, - { fields: ['skill_slug'] }, - { fields: ['relation_type'] }, - ], + indexes: [{ fields: ['agent_id'] }, { fields: ['skill_slug'] }], } ); diff --git a/app/service/agents.js b/app/service/agents.js index 895ad45..8294927 100644 --- a/app/service/agents.js +++ b/app/service/agents.js @@ -3,14 +3,10 @@ const AdmZip = require('adm-zip'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const yaml = require('js-yaml'); const mime = require('mime-types'); -const { - normalizeRelativePath, - extractSkillMdDescription, - extractSkillMdName, -} = require('../utils/skill-utils'); +const { normalizeRelativePath, extractSkillMdName } = require('../utils/skill-utils'); +const { resolveSkillIdentifier, sanitizeInstallKeySegment } = require('../utils/skill-install-key'); const { isValidSkillCategory, SKILL_CATEGORY_OPTIONS, @@ -55,6 +51,7 @@ class AgentsService extends Service { await Agent.sync(); await AgentFile.sync(); await AgentSkill.sync(); + await this.ensureAgentSkillsTableCompatible(); this.storageReady = true; })(); @@ -65,6 +62,25 @@ class AgentsService extends Service { } } + // 兼容历史 agent_skills 表结构,若存在 relation_type 且非空则修改为允许 NULL + async ensureAgentSkillsTableCompatible() { + try { + const queryInterface = this.app.model?.getQueryInterface?.(); + if (!queryInterface?.describeTable || !queryInterface?.changeColumn) return; + const table = await queryInterface.describeTable('agent_skills'); + if (table?.relation_type && !table.relation_type.allowNull) { + await queryInterface.changeColumn('agent_skills', 'relation_type', { + type: this.app.Sequelize.STRING(20), + allowNull: true, + defaultValue: null, + comment: '历史兼容字段', + }); + } + } catch (error) { + this.ctx?.logger?.warn?.(`[agents] 兼容检查 agent_skills 表结构失败: ${error.message}`); + } + } + normalizeAgentPath(filePath, message = '非法文件路径') { const normalized = normalizeRelativePath(String(filePath || '').replace(/^\.\//, '')); if (!normalized) { @@ -73,19 +89,6 @@ class AgentsService extends Service { return normalized; } - // agent.yaml 里的 ref 指向目录(skills/bugfix-workflow)或 SKILL.md 本身, - // 统一归一化为包内 SKILL.md 相对路径,供 agent_files 精确匹配。 - resolveSkillMdPath(refOrPath) { - const normalized = String(refOrPath || '').trim(); - if (!normalized) return ''; - return normalized.toLowerCase().endsWith('.md') ? normalized : `${normalized}/SKILL.md`; - } - - lookupSkillMd(skillMdMap, refOrPath) { - if (!skillMdMap) return ''; - return skillMdMap.get(this.resolveSkillMdPath(refOrPath)) || ''; - } - parseJsonArray(value) { if (!value) return []; if (Array.isArray(value)) return value; @@ -169,144 +172,28 @@ class AgentsService extends Service { return a.prerelease.localeCompare(b.prerelease); } - detectImageMime(buffer) { - if (!buffer || buffer.length < 12) return ''; - - if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) { - return 'image/png'; - } - - if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[buffer.length - 2] === 0xff) { - return 'image/jpeg'; - } - - if ( - buffer.subarray(0, 4).toString('ascii') === 'RIFF' && - buffer.subarray(8, 12).toString('ascii') === 'WEBP' - ) { - return 'image/webp'; - } - - return ''; - } - - buildAssetTargetPath(agentName, contentHash, filePath) { - const normalized = this.normalizeAgentPath(filePath); - const parts = normalized.split('/'); - const assetsIndex = parts.indexOf('assets'); - if (assetsIndex === -1) { - this.ctx.throw(400, '资源路径必须位于 assets 目录'); - } - const assetSubPath = parts.slice(assetsIndex + 1).join('/'); - if (!assetSubPath) { - this.ctx.throw(400, '资源路径必须位于 assets 目录'); - } - return this.normalizeAgentPath(`${agentName}/${contentHash}/assets/${assetSubPath}`); - } - buildAssetUrl(agentName, assetPath) { return `/api/agents/asset?name=${encodeURIComponent(agentName)}&path=${encodeURIComponent( assetPath )}`; } - buildSkillRelations(agentName, manifest) { - const relations = []; - const used = new Set(); - const entrypointName = String(manifest?.spec?.entrypoint?.name || '').trim(); - - if (entrypointName) { - relations.push({ - agentName, - skillSlug: entrypointName, - relationType: 'entrypoint', - sortOrder: 0, - }); - used.add(`entrypoint:${entrypointName}`); + parseCodexPluginJson(rawContent) { + try { + return JSON.parse(rawContent); + } catch (error) { + this.ctx.throw(400, `.codex-plugin/plugin.json 解析失败: ${error.message}`); } - - const dependencySkills = Array.isArray(manifest?.spec?.dependencies?.skills) - ? manifest.spec.dependencies.skills - : []; - - dependencySkills.forEach((item, index) => { - const skillSlug = String(item || '').trim(); - if (!skillSlug) return; - const key = `dependency:${skillSlug}`; - if (used.has(key)) return; - used.add(key); - relations.push({ - agentName, - skillSlug, - relationType: 'dependency', - sortOrder: index, - }); - }); - - const privateSkills = Array.isArray(manifest?.spec?.privateSkills) - ? manifest.spec.privateSkills - : []; - - privateSkills.forEach((item, index) => { - const skillSlug = String(item || '').trim(); - if (!skillSlug) return; - const key = `private:${skillSlug}`; - if (used.has(key)) return; - used.add(key); - relations.push({ - agentName, - skillSlug, - relationType: 'private', - sortOrder: index, - }); - }); - - return relations; } - buildRelatedAgents(target, candidates = [], limit = 3) { - const targetDependencies = new Set( - (target?.dependencies || []).map((item) => String(item || '').trim()).filter(Boolean) - ); - - return candidates - .filter((item) => item && item.name !== target.name) - .map((item) => { - const dependencies = Array.isArray(item.dependencies) ? item.dependencies : []; - const overlap = dependencies.filter((skill) => - targetDependencies.has(skill) - ).length; - return { - ...item, - overlapCount: overlap, - }; - }) - .filter((item) => item.overlapCount > 0) - .sort((left, right) => { - if (right.overlapCount !== left.overlapCount) { - return right.overlapCount - left.overlapCount; - } - return ( - new Date(right.updatedAt || 0).getTime() - - new Date(left.updatedAt || 0).getTime() - ); - }) - .slice(0, Number(limit) || 3); - } - - parseAgentYaml(rawContent) { + parseClaudePluginJson(rawContent) { try { - return yaml.load(rawContent); + return JSON.parse(rawContent); } catch (error) { - this.ctx.throw(400, `agent.yaml 解析失败: ${error.message}`); + this.ctx.throw(400, `.claude-plugin/plugin.json 解析失败: ${error.message}`); } } - getDemoImagePath(item, index) { - // demo.images 只接受 src,避免和其他文件路径字段语义混淆 - return this.normalizeAgentPath(item?.src || '', `spec.demo.images[${index}] 路径非法`); - } - normalizeCapabilities(capabilities) { if (!Array.isArray(capabilities)) return []; @@ -331,73 +218,60 @@ class AgentsService extends Service { .filter((item) => item && item.name); } - validateManifest(manifest, fileMap) { - if (!manifest || typeof manifest !== 'object') { - this.ctx.throw(400, 'agent.yaml 内容无效'); - } - if (manifest.apiVersion !== 'doraemon.dtstack.com/v1') { - this.ctx.throw(400, 'apiVersion 仅支持 doraemon.dtstack.com/v1'); - } - if (manifest.kind !== 'Agent') { - this.ctx.throw(400, 'kind 必须为 Agent'); + mapCodexCategory(codexCategory) { + const map = { + Coding: '工程效率', + }; + const mapped = map[String(codexCategory || '').trim()]; + return mapped && isValidSkillCategory(mapped) ? mapped : '通用'; + } + + normalizeManifestPath(value, message) { + const normalized = String(value || '').trim(); + if (!normalized.startsWith('./')) { + this.ctx.throw(400, `${message} 必须是 ./ 开头的相对路径`); } + // manifest 目录路径可能带末尾斜杠,统一后再拼接子路径,避免出现 skills//xxx + return this.normalizeAgentPath(normalized.slice(2), message).replace(/\/+$/, ''); + } - const metadata = manifest.metadata || {}; - const spec = manifest.spec || {}; - const author = metadata.author || {}; - const entrypoint = spec.entrypoint || {}; + validateCodexManifest(manifest) { + if (!manifest || typeof manifest !== 'object') { + this.ctx.throw(400, '.codex-plugin/plugin.json 内容无效'); + } - const name = this.validateAgentName(metadata.name); - const version = this.validateAgentVersion(metadata.version); - const category = String(metadata.category || '').trim(); + const iface = manifest.interface || {}; - if (!isValidSkillCategory(category)) { - this.ctx.throw(400, `category 无效,可选: ${SKILL_CATEGORY_OPTIONS.join(', ')}`); - } + const name = this.validateAgentName(manifest.name); + const version = this.validateAgentVersion(manifest.version); - const displayName = String(metadata.displayName || '').trim(); + const displayName = String(iface.displayName || manifest.name || '').trim(); if (!displayName) { - this.ctx.throw(400, 'metadata.displayName 不能为空'); + this.ctx.throw(400, 'interface.displayName 不能为空'); } - const description = String(metadata.description || '').trim(); + const description = String(manifest.description || '').trim(); if (!description) { - this.ctx.throw(400, 'metadata.description 不能为空'); + this.ctx.throw(400, 'description 不能为空'); } - const authorName = String(author.name || '').trim(); + const authorName = String((manifest.author || {}).name || iface.developerName || '').trim(); if (!authorName) { - this.ctx.throw(400, 'metadata.author.name 不能为空'); - } - - const profile = String(spec.profile || '').trim(); - if (!profile) { - this.ctx.throw(400, 'spec.profile 不能为空'); + this.ctx.throw(400, 'author.name 不能为空'); } - const logoPath = this.normalizeAgentPath(metadata.logo, 'metadata.logo 路径非法'); - if (!fileMap.has(logoPath)) { - this.ctx.throw(400, `Logo 文件不存在: ${logoPath}`); + const category = this.mapCodexCategory(iface.category); + const longDescription = String(iface.longDescription || description || '').trim(); + const defaultPrompt = Array.isArray(iface.defaultPrompt) ? iface.defaultPrompt : []; + if (defaultPrompt.length > 3) { + this.ctx.throw(400, 'interface.defaultPrompt 最多支持 3 条'); } - - const entrypointRef = this.normalizeAgentPath( - entrypoint.ref, - 'spec.entrypoint.ref 路径非法' - ); - if (!fileMap.has(`${entrypointRef}/SKILL.md`) && !fileMap.has(entrypointRef)) { - this.ctx.throw(400, `入口 Skill 不存在: ${entrypointRef}`); + if (defaultPrompt.some((item) => typeof item !== 'string' || item.length > 128)) { + this.ctx.throw(400, 'interface.defaultPrompt 每条必须是 128 字符以内的字符串'); } - const prompts = Array.isArray(spec.prompts) ? spec.prompts : []; - const capabilities = this.normalizeCapabilities(spec.capabilities); - const demoImages = Array.isArray(spec?.demo?.images) ? spec.demo.images : []; - - demoImages.forEach((item, index) => { - const targetPath = this.getDemoImagePath(item, index); - if (!fileMap.has(targetPath)) { - this.ctx.throw(400, `Demo 图片不存在: ${targetPath}`); - } - }); + const skills = this.normalizeManifestPath(manifest.skills, 'skills'); + const logoRef = iface.logo ? this.normalizeManifestPath(iface.logo, 'interface.logo') : ''; return { name, @@ -406,24 +280,33 @@ class AgentsService extends Service { description, authorName, category, - tags: Array.isArray(metadata.tags) ? metadata.tags.map((item) => String(item)) : [], - profile, - prompts: prompts.map((item) => ({ - title: String(item?.title || '').trim(), - prompt: String(item?.prompt || '').trim(), - })), - capabilities, - logoPath, - demoImages, - entrypoint: { - host: String(entrypoint.host || '').trim(), - type: String(entrypoint.type || '').trim(), - name: String(entrypoint.name || '').trim(), - ref: entrypointRef, - }, - dependencySkills: Array.isArray(spec?.dependencies?.skills) - ? spec.dependencies.skills.map((item) => String(item || '').trim()).filter(Boolean) + keywords: Array.isArray(manifest.keywords) + ? manifest.keywords.map((item) => String(item)) : [], + longDescription, + defaultPrompt, + capabilities: this.normalizeCapabilities(iface.capabilities), + skills, + logoRef, + }; + } + + validateClaudeManifest(manifest) { + if (!manifest || typeof manifest !== 'object') { + this.ctx.throw(400, '.claude-plugin/plugin.json 内容无效'); + } + + const name = this.validateAgentName(manifest.name); + const version = manifest.version ? this.validateAgentVersion(manifest.version) : ''; + const agents = Array.isArray(manifest.agents) ? manifest.agents : []; + if (agents.length === 0) { + this.ctx.throw(400, '.claude-plugin/plugin.json 必须声明 agents'); + } + + return { + name, + version, + agents: agents.map((item) => this.normalizeManifestPath(item, 'agents')), }; } @@ -519,10 +402,15 @@ class AgentsService extends Service { } const [rootDir] = [...topLevelDirs]; - const agentYamlPath = `${rootDir}/agent.yaml`; - const agentYamlEntry = fileMap.get(agentYamlPath); - if (!agentYamlEntry) { - this.ctx.throw(400, 'ZIP 中缺少根目录 agent.yaml'); + const pluginJsonPath = `${rootDir}/.codex-plugin/plugin.json`; + const pluginJsonEntry = fileMap.get(pluginJsonPath); + if (!pluginJsonEntry) { + this.ctx.throw(400, 'ZIP 中缺少根目录 .codex-plugin/plugin.json'); + } + const claudePluginJsonPath = `${rootDir}/.claude-plugin/plugin.json`; + const claudePluginJsonEntry = fileMap.get(claudePluginJsonPath); + if (!claudePluginJsonEntry) { + this.ctx.throw(400, 'ZIP 中缺少根目录 .claude-plugin/plugin.json'); } const relativeFileMap = new Map(); @@ -535,8 +423,31 @@ class AgentsService extends Service { }); }); - const manifest = this.parseAgentYaml(agentYamlEntry.buffer.toString('utf8')); - const validated = this.validateManifest(manifest, relativeFileMap); + const manifest = this.parseCodexPluginJson(pluginJsonEntry.buffer.toString('utf8')); + const claudeManifest = this.parseClaudePluginJson( + claudePluginJsonEntry.buffer.toString('utf8') + ); + const validated = this.validateCodexManifest(manifest); + const validatedClaude = this.validateClaudeManifest(claudeManifest); + if (validated.name !== rootDir || validatedClaude.name !== validated.name) { + this.ctx.throw(400, '两个 plugin manifest 的 name 必须与 Agent 目录名一致'); + } + if (validatedClaude.version && validatedClaude.version !== validated.version) { + this.ctx.throw(400, '两个 plugin manifest 的 version 必须一致'); + } + if ( + ![...relativeFileMap.keys()].some( + (filePath) => + filePath === validated.skills || filePath.startsWith(`${validated.skills}/`) + ) + ) { + this.ctx.throw(400, `Codex skills 路径不存在: ./${validated.skills}`); + } + validatedClaude.agents.forEach((agentPath) => { + if (!relativeFileMap.has(agentPath)) { + this.ctx.throw(400, `Claude agent 文件不存在: ./${agentPath}`); + } + }); const contentHash = this.buildContentHash( fileRecords.map((item) => ({ filePath: item.filePath, @@ -544,51 +455,61 @@ class AgentsService extends Service { })) ); - const logoRecord = relativeFileMap.get(validated.logoPath); - const logoMimeType = this.detectImageMime(logoRecord.buffer); - if (!logoMimeType) { - this.ctx.throw(400, 'Logo 文件类型仅支持 PNG、JPEG、WebP'); - } - if (logoRecord.size > config.maxImageSize) { - this.ctx.throw(400, `Logo 文件超过大小限制: ${validated.logoPath}`); - } - - const demoImages = validated.demoImages.map((item, index) => { - const rawPath = this.getDemoImagePath(item, index); - const record = relativeFileMap.get(rawPath); - const mimeType = this.detectImageMime(record.buffer); - if (!mimeType) { - this.ctx.throw(400, `Demo 图片类型仅支持 PNG、JPEG、WebP: ${rawPath}`); + // logo 从包内 assets/logo.png 读取(支持 png/jpeg/webp),随 resource 落盘并记录元数据 + const LOGO_ALLOWED = ['logo.png', 'logo.jpg', 'logo.jpeg', 'logo.webp']; + let logo = null; + const assetFiles = []; + const logoPaths = validated.logoRef + ? [validated.logoRef] + : [ + ...LOGO_ALLOWED.map((name) => `assets/${name}`), + ...LOGO_ALLOWED.map((name) => `.codex-plugin/assets/${name}`), + ]; + const hasExplicitLogo = Boolean(validated.logoRef); + for (const relativeLogoPath of logoPaths) { + const logoName = path.basename(relativeLogoPath); + // 兼容仓库内 assets 与 Codex 官方示例使用的 .codex-plugin/assets 两种布局 + const isSupportedLogoPath = + relativeLogoPath.startsWith('assets/') || + relativeLogoPath.startsWith('.codex-plugin/assets/'); + if (!LOGO_ALLOWED.includes(logoName) || !isSupportedLogoPath) { + this.ctx.throw( + 400, + 'interface.logo 仅支持 assets/logo.{png,jpg,jpeg,webp} 或 .codex-plugin/assets/logo.{png,jpg,jpeg,webp}' + ); } - if (record.size > config.maxImageSize) { - this.ctx.throw(400, `Demo 图片超过大小限制: ${rawPath}`); + const logoEntry = fileMap.get(`${rootDir}/${relativeLogoPath}`); + if (!logoEntry) { + if (hasExplicitLogo) { + this.ctx.throw(400, `Logo 文件不存在: ./${relativeLogoPath}`); + } + continue; } - - const storedPath = this.buildAssetTargetPath(validated.name, contentHash, rawPath); - return { - path: storedPath, - originalPath: rawPath, + if (logoEntry.size > config.maxImageSize) { + this.ctx.throw(400, `Logo 文件超过大小限制: ./${relativeLogoPath}`); + } + const mimeType = mime.lookup(logoName) || 'application/octet-stream'; + logo = { + path: `${validated.name}/${contentHash}/${relativeLogoPath}`, mimeType, - size: record.size, - hash: crypto.createHash('sha256').update(record.buffer).digest('hex'), - alt: String(item.alt || '').trim(), - sortOrder: index, - buffer: record.buffer, + size: logoEntry.size, + hash: this.buildContentHash([ + { filePath: relativeLogoPath, buffer: logoEntry.buffer }, + ]), }; - }); - - const logoPath = this.buildAssetTargetPath(validated.name, contentHash, validated.logoPath); - const logo = { - path: logoPath, - originalPath: validated.logoPath, - mimeType: logoMimeType, - size: logoRecord.size, - hash: crypto.createHash('sha256').update(logoRecord.buffer).digest('hex'), - buffer: logoRecord.buffer, - }; + assetFiles.push({ + path: logo.path, + buffer: logoEntry.buffer, + }); + break; + } const files = [...relativeFileMap.values()] - .filter((item) => !item.relativePath.startsWith('assets/')) + .filter( + (item) => + !item.relativePath.startsWith('assets/') && + !item.relativePath.startsWith('.codex-plugin/assets/') + ) .map((item) => { const isBinary = this.isLikelyBinary(item.buffer); return { @@ -604,34 +525,44 @@ class AgentsService extends Service { }; }); + // 解析 Agent 包内 skills 目录下的 SKILL.md,得到关联 Skill 的标识列表 + const agentSkills = []; + if (validated.skills) { + const skillsPrefix = `${validated.skills}/`; + [...relativeFileMap.values()].forEach((item) => { + if (!item.relativePath.startsWith(skillsPrefix)) return; + if (path.basename(item.relativePath).toLowerCase() !== 'skill.md') return; + + const content = item.buffer.toString('utf8'); + let name = extractSkillMdName(content).trim(); + if (!name) { + const dir = path.posix.dirname(item.relativePath); + name = dir.split('/').pop() || ''; + } + if (!name) return; + if (!agentSkills.includes(name)) agentSkills.push(name); + }); + } + return { agent: { name: validated.name, displayName: validated.displayName, version: validated.version, description: validated.description, - profile: validated.profile, + longDescription: validated.longDescription, authorName: validated.authorName, category: validated.category, - tags: validated.tags, - prompts: validated.prompts, + keywords: validated.keywords, + defaultPrompt: validated.defaultPrompt, capabilities: validated.capabilities, - entrypointHost: validated.entrypoint.host, - entrypointType: validated.entrypoint.type, - entrypointName: validated.entrypoint.name, - entrypointRef: validated.entrypoint.ref, - logoPath: logo.path, - logoMimeType: logo.mimeType, - logoSize: logo.size, - logoHash: logo.hash, + skills: agentSkills, + logo, contentHash, fileCount: fileRecords.length, }, - logo, - demoImages, files, - skillRelations: this.buildSkillRelations(validated.name, manifest), - assetFiles: [logo, ...demoImages], + assetFiles, }; } @@ -664,19 +595,6 @@ class AgentsService extends Service { fs.rmSync(targetPath, { recursive: true, force: true }); } - async findSkillIdBySlug(skillSlug, transaction) { - const { SkillsItem } = this.app.model; - if (!SkillsItem) return null; - const row = await SkillsItem.findOne({ - where: { - slug: skillSlug, - is_delete: 0, - }, - transaction, - }); - return row ? row.id : null; - } - async importAgentFile(params = {}, file) { if (!file?.filename || !file?.filepath) { this.ctx.throw(400, '上传文件无效'); @@ -693,7 +611,7 @@ class AgentsService extends Service { await this.ensureStorageReady(); const parsed = await this.parseAgentZip(file.filepath); - const { Agent, AgentFile, AgentSkill } = this.app.model; + const { Agent, AgentFile } = this.app.model; const existing = await Agent.findOne({ where: { name: parsed.agent.name, @@ -743,30 +661,21 @@ class AgentsService extends Service { display_name: parsed.agent.displayName, version: parsed.agent.version, description: parsed.agent.description, - profile: parsed.agent.profile, + profile: parsed.agent.longDescription, author_name: parsed.agent.authorName, category: parsed.agent.category, - tags: JSON.stringify(parsed.agent.tags || []), - prompts: JSON.stringify(parsed.agent.prompts || []), - capabilities: JSON.stringify(parsed.agent.capabilities || []), - demo_images: JSON.stringify( - parsed.demoImages.map((item) => ({ - path: item.path, - mimeType: item.mimeType, - size: item.size, - hash: item.hash, - alt: item.alt, - sortOrder: item.sortOrder, + tags: JSON.stringify(parsed.agent.keywords || []), + prompts: JSON.stringify( + (parsed.agent.defaultPrompt || []).map((prompt, index) => ({ + title: `开场问题 ${index + 1}`, + prompt, })) ), - entrypoint_host: parsed.agent.entrypointHost, - entrypoint_type: parsed.agent.entrypointType, - entrypoint_name: parsed.agent.entrypointName, - entrypoint_ref: parsed.agent.entrypointRef, - logo_path: parsed.agent.logoPath, - logo_mime_type: parsed.agent.logoMimeType, - logo_size: parsed.agent.logoSize, - logo_hash: parsed.agent.logoHash, + capabilities: JSON.stringify(parsed.agent.capabilities || []), + logo_path: parsed.agent.logo ? parsed.agent.logo.path : null, + logo_mime_type: parsed.agent.logo ? parsed.agent.logo.mimeType : null, + logo_size: parsed.agent.logo ? parsed.agent.logo.size : null, + logo_hash: parsed.agent.logo ? parsed.agent.logo.hash : null, content_hash: parsed.agent.contentHash, source_file_name: file.filename, file_count: parsed.agent.fileCount, @@ -786,10 +695,6 @@ class AgentsService extends Service { where: { agent_id: agentId }, transaction, }); - await AgentSkill.destroy({ - where: { agent_id: agentId }, - transaction, - }); } const fileRows = parsed.files.map((item) => ({ @@ -808,20 +713,21 @@ class AgentsService extends Service { await AgentFile.bulkCreate(fileRows, { transaction }); } - const relationRows = []; - for (const item of parsed.skillRelations) { - const skillId = await this.findSkillIdBySlug(item.skillSlug, transaction); - relationRows.push({ - agent_id: agentId, - skill_slug: item.skillSlug, - skill_id: skillId, - relation_type: item.relationType, - sort_order: item.sortOrder, - }); - } - - if (relationRows.length > 0) { - await AgentSkill.bulkCreate(relationRows, { transaction }); + // 持久化 Agent 关联的 Skill(先清后写,保证与本次包内容一致) + const { AgentSkill } = this.app.model; + const skillSlugs = Array.isArray(parsed.agent.skills) ? parsed.agent.skills : []; + await AgentSkill.destroy({ + where: { agent_id: agentId }, + transaction, + }); + if (skillSlugs.length > 0) { + await AgentSkill.bulkCreate( + skillSlugs.map((skillSlug) => ({ + agent_id: agentId, + skill_slug: skillSlug, + })), + { transaction } + ); } return { @@ -854,7 +760,6 @@ class AgentsService extends Service { } toAgentListItem(row) { - const dependencies = this.parseJsonArray(row.dependencies || '[]'); return { name: row.name, displayName: row.display_name, @@ -864,14 +769,13 @@ class AgentsService extends Service { tags: this.parseJsonArray(row.tags), version: row.version || '', updatedAt: row.updated_at ? row.updated_at.toISOString() : '', - dependencyCount: dependencies.length, - logoUrl: this.buildAssetUrl(row.name, row.logo_path), + logoUrl: row.logo_path ? this.buildAssetUrl(row.name, row.logo_path) : '', }; } async queryAgentList(params = {}) { await this.ensureStorageReady(); - const { Agent, AgentSkill } = this.app.model; + const { Agent } = this.app.model; const keyword = String(params.keyword || '').trim(); const category = String(params.category || '').trim(); const pageNum = Math.max(Number(params.pageNum) || 1, 1); @@ -905,35 +809,7 @@ class AgentsService extends Service { limit: pageSize, }); - const agentIds = rows.map((row) => row.id); - const relationRows = - agentIds.length > 0 - ? await AgentSkill.findAll({ - where: { - agent_id: { - [Op.in]: agentIds, - }, - relation_type: { - [Op.in]: ['dependency', 'private'], - }, - }, - }) - : []; - - const dependencyMap = relationRows.reduce((acc, item) => { - if (!acc[item.agent_id]) { - acc[item.agent_id] = []; - } - acc[item.agent_id].push(item.skill_slug); - return acc; - }, {}); - - const list = rows.map((row) => - this.toAgentListItem({ - ...row.toJSON(), - dependencies: JSON.stringify(dependencyMap[row.id] || []), - }) - ); + const list = rows.map((row) => this.toAgentListItem(row.toJSON())); return { list, @@ -944,10 +820,26 @@ class AgentsService extends Service { }; } + // 用 Agent 关联的 skill 标识匹配 skill 市场,返回命中的 skill 或 null + // 优先精确匹配 slug/installKey,再尝试 sanitize 后的小写连字符形式(覆盖 SKILL.md name 与市场 name 不一致的情形) + matchMarketSkill(identifier, skillCache) { + if (!skillCache) return null; + const value = String(identifier || '').trim(); + if (!value) return null; + + const matched = resolveSkillIdentifier(value, skillCache); + if (matched) return matched; + + const sanitized = sanitizeInstallKeySegment(value); + if (sanitized && sanitized !== value) { + return resolveSkillIdentifier(sanitized, skillCache) || null; + } + return null; + } + async getAgentDetail(name) { await this.ensureStorageReady(); - const { Agent, AgentSkill, SkillsItem, AgentFile } = this.app.model; - const { Op } = this.app.Sequelize; + const { Agent } = this.app.model; const row = await Agent.findOne({ where: { name, @@ -958,150 +850,75 @@ class AgentsService extends Service { this.ctx.throw(404, 'Agent 不存在'); } - const relations = await AgentSkill.findAll({ - where: { - agent_id: row.id, - }, - order: [ - ['relation_type', 'ASC'], - ['sort_order', 'ASC'], - ['id', 'ASC'], - ], - }); + const detail = row.toJSON(); - const skillSlugs = relations.map((item) => item.skill_slug); - const skillRows = - skillSlugs.length > 0 && SkillsItem - ? await SkillsItem.findAll({ - where: { - slug: skillSlugs, - is_delete: 0, - }, - }) - : []; - const skillMap = new Map(skillRows.map((item) => [item.slug, item])); - - const entrypoint = relations.find((item) => item.relation_type === 'entrypoint') || null; - const entrypointSkill = entrypoint ? skillMap.get(entrypoint.skill_slug) : null; - - const dependencies = relations - .filter((item) => item.relation_type === 'dependency') - .map((item) => { - const skill = skillMap.get(item.skill_slug); + // 读取 Agent 关联的 Skill,并判断是否已收录到 skill 市场(可点击跳转) + let skills = []; + const { AgentSkill } = this.app.model; + const skillRows = await AgentSkill.findAll({ + where: { agent_id: row.id }, + order: [['id', 'ASC']], + }); + if (skillRows.length > 0) { + let skillCache = null; + try { + skillCache = await this.ctx.service.skills.ensureSkillCache(); + } catch (error) { + this.app.logger.warn( + `[agents] 加载 skill 市场缓存失败,Agent(${name}) skills 降级为未收录: ${error.message}` + ); + } + skills = skillRows.map((item) => { + const identifier = item.skill_slug; + const matched = this.matchMarketSkill(identifier, skillCache); + if (matched) { + return { + slug: matched.slug, + installKey: matched.installKey || matched.slug, + name: matched.name || identifier, + description: matched.description || '', + isPackage: matched.isPackage ? 1 : 0, + parentSlug: matched.parentSlug || null, + installed: true, + }; + } return { - slug: item.skill_slug, - name: skill ? skill.name : item.skill_slug, - description: skill ? skill.description : '', - collected: Boolean(skill), - path: skill ? `/page/skills/${item.skill_slug}` : '', + slug: identifier, + installKey: identifier, + name: identifier, + description: '', + isPackage: 0, + parentSlug: null, + installed: false, }; }); - const privateSkills = relations - .filter((item) => item.relation_type === 'private') - .map((item) => ({ - slug: item.skill_slug, - name: item.skill_slug, - description: '', - collected: false, - builtin: true, - path: '', - })); - - // 未收录的入口 Skill 和内置 Skills 不在 SkillsItem 表,其真实 name/description - // 从 agent 包内自带的 SKILL.md 解析(agent_files 已在导入时保存文件内容)。 - const skillMdPaths = [ - ...(entrypoint && !entrypointSkill - ? [this.resolveSkillMdPath(row.entrypoint_ref)] - : []), - ...privateSkills.map((item) => `skills/${item.slug}/SKILL.md`), - ...dependencies - .filter((item) => !item.collected) - .map((item) => `skills/${item.slug}/SKILL.md`), - ].filter(Boolean); - const skillMdRows = - skillMdPaths.length > 0 && AgentFile - ? await AgentFile.findAll({ - where: { - agent_id: row.id, - file_path: { [Op.in]: skillMdPaths }, - is_delete: 0, - }, - }) - : []; - const skillMdMap = new Map(skillMdRows.map((item) => [item.file_path, item.content || ''])); - - // 入口 Skill:已收录用 SkillsItem 描述;未收录回填包内 SKILL.md 的 name/description - const entrypointItem = entrypoint - ? (() => { - const skill = entrypointSkill; - const skillMd = skill ? '' : this.lookupSkillMd(skillMdMap, row.entrypoint_ref); - return { - slug: entrypoint.skill_slug, - name: skill - ? skill.name - : extractSkillMdName(skillMd) || entrypoint.skill_slug, - description: skill - ? skill.description || '' - : extractSkillMdDescription(skillMd), - collected: Boolean(skill), - path: `/page/skills/${entrypoint.skill_slug}`, - }; - })() - : null; - - // 内置 Skills:总是从包内 SKILL.md 回填 - privateSkills.forEach((item) => { - const skillMd = skillMdMap.get(`skills/${item.slug}/SKILL.md`) || ''; - const name = extractSkillMdName(skillMd); - if (name) item.name = name; - const description = extractSkillMdDescription(skillMd); - if (description) item.description = description; - }); - - // 未收录的依赖 Skills:包内有 SKILL.md 时同样回填 - dependencies.forEach((item) => { - if (item.collected) return; - const skillMd = skillMdMap.get(`skills/${item.slug}/SKILL.md`) || ''; - const name = extractSkillMdName(skillMd); - if (name) item.name = name; - const description = extractSkillMdDescription(skillMd); - if (description) item.description = description; - }); - - const detail = row.toJSON(); - const demoImages = this.parseJsonArray(detail.demo_images).map((item) => ({ - ...item, - url: this.buildAssetUrl(detail.name, item.path), - })); - const capabilities = this.normalizeCapabilities(this.parseJsonArray(detail.capabilities)); + } return { name: detail.name, displayName: detail.display_name, description: detail.description || '', - profile: detail.profile || '', + longDescription: detail.profile || detail.description || '', authorName: detail.author_name || '', category: detail.category || '通用', tags: this.parseJsonArray(detail.tags), - prompts: this.parseJsonArray(detail.prompts), - capabilities, + defaultPrompt: this.parseJsonArray(detail.prompts), + capabilities: this.normalizeCapabilities(this.parseJsonArray(detail.capabilities)), version: detail.version || '', - logoUrl: this.buildAssetUrl(detail.name, detail.logo_path), - logoPath: detail.logo_path, - demoImages, - entrypoint: entrypointItem, - dependencies, - privateSkills, + logoUrl: detail.logo_path ? this.buildAssetUrl(detail.name, detail.logo_path) : '', updatedAt: detail.updated_at ? detail.updated_at.toISOString() : '', + skills, }; } + // 根据 Agent 关联的 Skill 重叠度推荐相关 Agent(共同 skill 越多越相关) async getRelatedAgents(name, limit = 3) { await this.ensureStorageReady(); + const nameValue = String(name || '').trim(); const { Agent, AgentSkill } = this.app.model; const target = await Agent.findOne({ where: { - name, + name: nameValue, is_delete: 0, }, }); @@ -1109,43 +926,69 @@ class AgentsService extends Service { this.ctx.throw(404, 'Agent 不存在'); } - const [allAgents, allRelations] = await Promise.all([ - Agent.findAll({ - where: { is_delete: 0 }, - order: [['updated_at', 'DESC']], - }), - AgentSkill.findAll({ - where: { relation_type: 'dependency' }, - }), - ]); - - const dependencyMap = allRelations.reduce((acc, item) => { - if (!acc[item.agent_id]) { - acc[item.agent_id] = []; + const safeLimit = Math.max(Number(limit) || 3, 1); + // 先查询当前 Agent 关联的技能,若无技能则无需进一步查询其他 Agent + const targetSkillRows = await AgentSkill.findAll({ + where: { agent_id: target.id }, + attributes: ['skill_slug'], + }); + if (targetSkillRows.length === 0) { + return []; + } + + const targetSkills = new Set(targetSkillRows.map((item) => item.skill_slug)); + const { Op } = this.app.Sequelize || {}; + const neOp = Op?.ne || '$ne'; + + // 仅根据共同技能和非当前 Agent 过滤,利用已有索引避免全表扫描 + const relatedSkillRows = await AgentSkill.findAll({ + where: { + skill_slug: Array.from(targetSkills), + agent_id: { [neOp]: target.id }, + }, + attributes: ['agent_id', 'skill_slug'], + }); + if (relatedSkillRows.length === 0) { + return []; + } + + // 统计各候选 Agent 的技能重叠数,使用 Set 防御重复关联 + const overlapSkillMap = new Map(); + relatedSkillRows.forEach((item) => { + if (!overlapSkillMap.has(item.agent_id)) { + overlapSkillMap.set(item.agent_id, new Set()); } - acc[item.agent_id].push(item.skill_slug); - return acc; - }, {}); - - const targetDependencies = dependencyMap[target.id] || []; - const candidates = allAgents.map((item) => ({ - name: item.name, - displayName: item.display_name, - description: item.description || '', - logoUrl: this.buildAssetUrl(item.name, item.logo_path), - dependencies: dependencyMap[item.id] || [], - updatedAt: item.updated_at ? item.updated_at.toISOString() : '', - })); - - return this.buildRelatedAgents( - { - name: target.name, - dependencies: targetDependencies, - entrypointName: target.entrypoint_name, + overlapSkillMap.get(item.agent_id).add(item.skill_slug); + }); + + const candidateIds = Array.from(overlapSkillMap.keys()); + const agentRows = await Agent.findAll({ + where: { + id: candidateIds, + is_delete: 0, }, - candidates, - limit - ); + }); + + return agentRows + .map((item) => { + const itemData = item?.toJSON ? item.toJSON() : item; + const overlapCount = overlapSkillMap.get(item.id)?.size || 0; + return { + ...this.toAgentListItem(itemData), + overlapCount, + }; + }) + .filter((item) => item.overlapCount > 0) + .sort((left, right) => { + if (right.overlapCount !== left.overlapCount) { + return right.overlapCount - left.overlapCount; + } + return ( + new Date(right.updatedAt || 0).getTime() - + new Date(left.updatedAt || 0).getTime() + ); + }) + .slice(0, safeLimit); } async getAgentAssetStream(params = {}) { @@ -1163,14 +1006,10 @@ class AgentsService extends Service { this.ctx.throw(404, 'Agent 不存在'); } - const demoImages = this.parseJsonArray(row.demo_images); const allowedPaths = new Map(); if (row.logo_path) { allowedPaths.set(row.logo_path, row.logo_mime_type || 'application/octet-stream'); } - demoImages.forEach((item) => { - allowedPaths.set(item.path, item.mimeType || 'application/octet-stream'); - }); const mimeType = allowedPaths.get(requestedPath); if (!mimeType) { diff --git a/app/web/pages/agents/codex-button-utils.js b/app/web/pages/agents/codex-button-utils.js index a4e181a..8c91fe5 100644 --- a/app/web/pages/agents/codex-button-utils.js +++ b/app/web/pages/agents/codex-button-utils.js @@ -15,7 +15,7 @@ function buildCodexNewThreadUrl({ prompt, originUrl }) { function buildAgentDetailCodexPrompt(detail = {}, _originUrl, selectedPrompt) { const firstPrompt = - selectedPrompt || (Array.isArray(detail.prompts) ? detail.prompts[0] : null); + selectedPrompt || (Array.isArray(detail.defaultPrompt) ? detail.defaultPrompt[0] : null); return firstPrompt ? String(firstPrompt.prompt || '') : ''; } diff --git a/app/web/pages/agents/detail/AgentDetailContent.tsx b/app/web/pages/agents/detail/AgentDetailContent.tsx index 3ec236d..708ecf5 100644 --- a/app/web/pages/agents/detail/AgentDetailContent.tsx +++ b/app/web/pages/agents/detail/AgentDetailContent.tsx @@ -3,66 +3,21 @@ import { CodeOutlined, CopyOutlined, DownloadOutlined, - MessageOutlined, - OrderedListOutlined, QuestionCircleOutlined, - ReadOutlined, - UserOutlined, } from '@ant-design/icons'; -import { Button, Card, Empty, message, Spin, Tabs, Tag, Typography } from 'antd'; +import { Button, Card, Empty, message, Spin, Tag, Typography } from 'antd'; import { API } from '@/api'; import { copyToClipboard } from '@/utils/copyUtils'; import { safeOpenUrl } from '@/utils/safeOpenUrl'; import { buildAgentDetailCodexPrompt, buildCodexNewThreadUrl } from '../codex-button-utils'; -import type { AgentCapability, AgentDetail, AgentItem, AgentSkillRelation } from '../types'; +import type { AgentDetail, AgentItem, AgentSkill } from '../types'; import './style.scss'; const { Paragraph, Text, Title } = Typography; -const { TabPane } = Tabs; const { normalizeAgentCapabilities } = require('./capability-utils'); const { buildAgentIntroBlocks } = require('./intro-utils'); -interface AgentDetailContentProps { - name: string; - history: { push: (path: string) => void }; -} - -const SkillRelationCard: React.FC<{ - item: AgentSkillRelation; - history: { push: (path: string) => void }; -}> = ({ item, history }) => { - const clickable = Boolean(item.collected && item.path); - - return ( - { - if (!clickable) return; - if (typeof window !== 'undefined') { - window.open(item.path as string, '_blank', 'noopener,noreferrer'); - return; - } - history.push(item.path as string); - }} - > -
- {item.name} - {item.builtin ? ( - 内置 - ) : !item.collected ? ( - 暂未收录 - ) : null} -
- - {item.description || '暂无描述'} - -
- ); -}; - const RelatedAgentCard: React.FC<{ item: AgentItem; history: { push: (path: string) => void }; @@ -74,14 +29,16 @@ const RelatedAgentCard: React.FC<{ onClick={() => history.push(`/page/agents/${item.name}`)} >
- {item.displayName} { - event.currentTarget.style.visibility = 'hidden'; - }} - /> + {item.logoUrl ? ( + {item.displayName} { + event.currentTarget.style.visibility = 'hidden'; + }} + /> + ) : null}
{item.displayName} {item.description || '暂无描述'} @@ -90,18 +47,21 @@ const RelatedAgentCard: React.FC<{ ); +interface AgentDetailContentProps { + name: string; + history: { push: (path: string) => void }; +} + const AgentDetailContent: React.FC = ({ name, history }) => { const [loading, setLoading] = useState(true); const [detail, setDetail] = useState(null); const [related, setRelated] = useState([]); - const [selectedDemoIndex, setSelectedDemoIndex] = useState(0); useEffect(() => { let cancelled = false; const load = async () => { setLoading(true); - setSelectedDemoIndex(0); try { const [detailRes, relatedRes] = await Promise.all([ API.getAgentDetail({ name }), @@ -133,12 +93,10 @@ const AgentDetailContent: React.FC = ({ name, history } const introBlocks = useMemo( () => buildAgentIntroBlocks({ - profile: detail?.profile || '', - description: detail?.description || '', - summary: detail?.description || '', - prompts: detail?.prompts || [], + longDescription: detail?.longDescription || '', + defaultPrompt: detail?.defaultPrompt || [], }), - [detail?.description, detail?.profile, detail?.prompts] + [detail?.longDescription, detail?.defaultPrompt] ); const normalizedCapabilities = useMemo( () => normalizeAgentCapabilities(detail?.capabilities || []), @@ -186,14 +144,16 @@ const AgentDetailContent: React.FC = ({ name, history }
- {detail.displayName} { - event.currentTarget.style.visibility = 'hidden'; - }} - /> + {detail.logoUrl ? ( + {detail.displayName} { + event.currentTarget.style.display = 'none'; + }} + /> + ) : null}
{detail.displayName}
@@ -203,122 +163,41 @@ const AgentDetailContent: React.FC = ({ name, history } {detail.category}
-
- {detail.tags.map((tag) => ( - {tag} - ))} -
+ {normalizedCapabilities.length > 0 ? ( +
+ + 功能: + + + {normalizedCapabilities + .map((item) => item.name) + .join(', ')} + +
+ ) : null} + {detail.tags && detail.tags.length > 0 ? ( +
+ + 关键词: + + {detail.tags.map((tag) => ( + {tag} + ))} +
+ ) : null}
- - - - 概览 - - } - key="overview" - > -
- - 你可以使用该 Agent 做什么 -
- {detail.description || '暂无描述'} -
-
- - - 能力范围 - {normalizedCapabilities.length > 0 ? ( -
- {normalizedCapabilities.map( - (item: AgentCapability, index: number) => ( -
- - {String(index + 1).padStart(2, '0')} - - {item.name} - {item.description ? ( - - {item.description} - - ) : null} -
- ) - )} -
- ) : ( - - )} -
- - - Agent 演示 - {detail.demoImages.length > 0 ? ( -
-
- {detail.demoImages.map((item, index) => ( - - ))} -
-
- { -
-
- ) : ( - - )} -
-
-
+
+
+ + 功能概览 +
+ {detail.description || '暂无描述'} +
+
- - - Agent 简介 - - } - key="profile" - >
@@ -340,26 +219,7 @@ const AgentDetailContent: React.FC = ({ name, history }
- 开场消息 -
- -
-
- -
- - {introBlocks.openingMessage || '暂无开场消息'} - -
-
-
- -
-
- 开场问题 + 快速使用 {introBlocks.openingQuestions.length} 个 @@ -378,7 +238,6 @@ const AgentDetailContent: React.FC = ({ name, history }
- {item.title} {item.prompt} @@ -404,69 +263,69 @@ const AgentDetailContent: React.FC = ({ name, history } ) : ( )}
- - - - Agent 能力 - - } - key="skills" - > -
- - 核心工作流 - {detail.entrypoint ? ( - - ) : ( - - )} - - - {detail.privateSkills.length > 0 ? ( - - 内置 Skills -
- {detail.privateSkills.map((item) => ( - - ))} -
-
- ) : null} - - {detail.dependencies.length > 0 ? ( - - 依赖 Skills -
- {detail.dependencies.map((item) => ( - - ))} -
-
- ) : null} -
-
- + +
+ Skills + + {detail.skills ? detail.skills.length : 0} 个 + +
+ {detail.skills && detail.skills.length > 0 ? ( +
+ {detail.skills.map((skill: AgentSkill, index: number) => ( + { + if (!skill.installed) return; + if (typeof window !== 'undefined') { + const base = + skill.parentSlug && + skill.parentSlug !== skill.slug + ? `/page/skills/${skill.parentSlug}/${skill.slug}` + : `/page/skills/${skill.slug}`; + window.open( + base, + '_blank', + 'noopener,noreferrer' + ); + } + }} + > +
+ {skill.name} + {!skill.installed ? 暂未收录 : null} +
+ + {skill.description || '暂无描述'} + +
+ ))} +
+ ) : ( + + )} +
+
+